mirror of
https://github.com/yattee/yattee.git
synced 2025-12-12 19:18:16 +00:00
Compare commits
107 Commits
v1.2
...
v1.4-alpha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
574c58b6e4 | ||
|
|
c1f3fecfe7 | ||
|
|
78834b1548 | ||
|
|
cf68c6c69f | ||
|
|
d7c8dce994 | ||
|
|
c431c20bc0 | ||
|
|
a9b057505c | ||
|
|
93943ecd83 | ||
|
|
a17cbf0085 | ||
|
|
8e74c3ec0a | ||
|
|
cc5f41807b | ||
|
|
a60a2a6744 | ||
|
|
0a36870480 | ||
|
|
48ab8b27c8 | ||
|
|
bb988764b4 | ||
|
|
f7789c73d5 | ||
|
|
1085bf0e9a | ||
|
|
5f263efeb2 | ||
|
|
a98b4eac83 | ||
|
|
975b8fe5c3 | ||
|
|
33e86710a8 | ||
|
|
96f4e819a7 | ||
|
|
8ab97ddbaf | ||
|
|
df72bf99ba | ||
|
|
db4a817164 | ||
|
|
ce8a8cbef3 | ||
|
|
a04827cc56 | ||
|
|
534f356471 | ||
|
|
5050ad5d02 | ||
|
|
ca38133b1d | ||
|
|
a9ccd6b0f2 | ||
|
|
76273a4724 | ||
|
|
8370714b61 | ||
|
|
d096fdb344 | ||
|
|
5b12dbcb1e | ||
|
|
d1ed896166 | ||
|
|
3630cd404d | ||
|
|
c698595517 | ||
|
|
f2063be4a3 | ||
|
|
9304bf6158 | ||
|
|
3495ecf693 | ||
|
|
f29dc792c2 | ||
|
|
792db567ed | ||
|
|
e159bb772c | ||
|
|
8a74938b98 | ||
|
|
3baa7a6893 | ||
|
|
520d69f37a | ||
|
|
4e88f2baf8 | ||
|
|
b5d187c52f | ||
|
|
c1e219e46e | ||
|
|
7317aec1ed | ||
|
|
3e8ac15c66 | ||
|
|
363424fa74 | ||
|
|
1db4a3197d | ||
|
|
ac755d0ee6 | ||
|
|
16a3a4728d | ||
|
|
ea6363ba65 | ||
|
|
3326088081 | ||
|
|
5498e2c4ab | ||
|
|
00778b585f | ||
|
|
d6e75295e1 | ||
|
|
aec7480353 | ||
|
|
e29982454b | ||
|
|
117057dd0e | ||
|
|
9ede4b9b1f | ||
|
|
f0d1b74e34 | ||
|
|
2a75d0a1d4 | ||
|
|
04df9551ba | ||
|
|
ba21583a95 | ||
|
|
149607efbc | ||
|
|
89957e3b56 | ||
|
|
0af2db2fd7 | ||
|
|
ab174c73fd | ||
|
|
e4f3914ff8 | ||
|
|
ac1c6685a1 | ||
|
|
adcebb77a5 | ||
|
|
32862ab446 | ||
|
|
e06febd2e3 | ||
|
|
f257632354 | ||
|
|
19d57ff55c | ||
|
|
91fa4ea2ff | ||
|
|
18d6000976 | ||
|
|
ea90f650d8 | ||
|
|
0a5cb5b542 | ||
|
|
f132ba9683 | ||
|
|
efce339234 | ||
|
|
f89c5ff055 | ||
|
|
9b2209c9b5 | ||
|
|
61a4951831 | ||
|
|
cef0b2594a | ||
|
|
1fbb0cfa80 | ||
|
|
984e9e7b16 | ||
|
|
4793fc9a38 | ||
|
|
b6e1f8148c | ||
|
|
23e2e216db | ||
|
|
d7058b46d3 | ||
|
|
c4ca5eb4c7 | ||
|
|
02e66e4520 | ||
|
|
de09f9dd52 | ||
|
|
4fab7c2c16 | ||
|
|
f609ed1ed4 | ||
|
|
201e91a3cc | ||
|
|
923f0c0356 | ||
|
|
008cd1553d | ||
|
|
8d49934fe8 | ||
|
|
a4c43d9a3a | ||
|
|
310ed3b12b |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -91,3 +91,6 @@ iOSInjectionProject/
|
||||
|
||||
# SwiftLint Remote Config Cache
|
||||
.swiftlint/RemoteConfigCache
|
||||
|
||||
# disable simulator libraries - to be removed when replaced with framework for mpv
|
||||
Vendor/mpv/iOS/lib_sim
|
||||
|
||||
@@ -7,6 +7,7 @@ disabled_rules:
|
||||
- multiline_arguments
|
||||
|
||||
excluded:
|
||||
- Vendor
|
||||
- Tests Apple TV
|
||||
- Tests iOS
|
||||
- Tests macOS
|
||||
|
||||
97
Backports/VisualEffectBlur-iOS.swift
Normal file
97
Backports/VisualEffectBlur-iOS.swift
Normal file
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
Copyright © 2020 Apple Inc.
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
public struct VisualEffectBlur<Content: View>: View {
|
||||
/// Defaults to .systemMaterial
|
||||
var blurStyle: UIBlurEffect.Style
|
||||
|
||||
/// Defaults to nil
|
||||
var vibrancyStyle: UIVibrancyEffectStyle?
|
||||
|
||||
var content: Content
|
||||
|
||||
public init(blurStyle: UIBlurEffect.Style = .systemMaterial, vibrancyStyle: UIVibrancyEffectStyle? = nil, @ViewBuilder content: () -> Content) {
|
||||
self.blurStyle = blurStyle
|
||||
self.vibrancyStyle = vibrancyStyle
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Representable(blurStyle: blurStyle, vibrancyStyle: vibrancyStyle, content: ZStack { content })
|
||||
.accessibility(hidden: Content.self == EmptyView.self)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Representable
|
||||
|
||||
extension VisualEffectBlur {
|
||||
struct Representable<Content: View>: UIViewRepresentable {
|
||||
var blurStyle: UIBlurEffect.Style
|
||||
var vibrancyStyle: UIVibrancyEffectStyle?
|
||||
var content: Content
|
||||
|
||||
func makeUIView(context: Context) -> UIVisualEffectView {
|
||||
context.coordinator.blurView
|
||||
}
|
||||
|
||||
func updateUIView(_: UIVisualEffectView, context: Context) {
|
||||
context.coordinator.update(content: content, blurStyle: blurStyle, vibrancyStyle: vibrancyStyle)
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(content: content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Coordinator
|
||||
|
||||
extension VisualEffectBlur.Representable {
|
||||
class Coordinator {
|
||||
let blurView = UIVisualEffectView()
|
||||
let vibrancyView = UIVisualEffectView()
|
||||
let hostingController: UIHostingController<Content>
|
||||
|
||||
init(content: Content) {
|
||||
hostingController = UIHostingController(rootView: content)
|
||||
hostingController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
hostingController.view.backgroundColor = nil
|
||||
blurView.contentView.addSubview(vibrancyView)
|
||||
|
||||
blurView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
vibrancyView.contentView.addSubview(hostingController.view)
|
||||
vibrancyView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
}
|
||||
|
||||
func update(content: Content, blurStyle: UIBlurEffect.Style, vibrancyStyle: UIVibrancyEffectStyle?) {
|
||||
hostingController.rootView = content
|
||||
|
||||
let blurEffect = UIBlurEffect(style: blurStyle)
|
||||
blurView.effect = blurEffect
|
||||
|
||||
if let vibrancyStyle = vibrancyStyle {
|
||||
vibrancyView.effect = UIVibrancyEffect(blurEffect: blurEffect, style: vibrancyStyle)
|
||||
} else {
|
||||
vibrancyView.effect = nil
|
||||
}
|
||||
|
||||
hostingController.view.setNeedsDisplay()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension VisualEffectBlur where Content == EmptyView {
|
||||
init(blurStyle: UIBlurEffect.Style = .systemMaterial) {
|
||||
self.init(blurStyle: blurStyle, vibrancyStyle: nil) {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
83
Backports/VisualEffectBlur-macOS.swift
Normal file
83
Backports/VisualEffectBlur-macOS.swift
Normal file
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
Copyright © 2020 Apple Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import SwiftUI
|
||||
|
||||
#if os(macOS)
|
||||
|
||||
public struct VisualEffectBlur: View {
|
||||
private var material: NSVisualEffectView.Material
|
||||
private var blendingMode: NSVisualEffectView.BlendingMode
|
||||
private var state: NSVisualEffectView.State
|
||||
|
||||
public init(
|
||||
material: NSVisualEffectView.Material = .headerView,
|
||||
blendingMode: NSVisualEffectView.BlendingMode = .withinWindow,
|
||||
state: NSVisualEffectView.State = .followsWindowActiveState
|
||||
) {
|
||||
self.material = material
|
||||
self.blendingMode = blendingMode
|
||||
self.state = state
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Representable(
|
||||
material: material,
|
||||
blendingMode: blendingMode,
|
||||
state: state
|
||||
).accessibility(hidden: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Representable
|
||||
|
||||
extension VisualEffectBlur {
|
||||
struct Representable: NSViewRepresentable {
|
||||
var material: NSVisualEffectView.Material
|
||||
var blendingMode: NSVisualEffectView.BlendingMode
|
||||
var state: NSVisualEffectView.State
|
||||
|
||||
func makeNSView(context: Context) -> NSVisualEffectView {
|
||||
context.coordinator.visualEffectView
|
||||
}
|
||||
|
||||
func updateNSView(_: NSVisualEffectView, context: Context) {
|
||||
context.coordinator.update(material: material)
|
||||
context.coordinator.update(blendingMode: blendingMode)
|
||||
context.coordinator.update(state: state)
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
}
|
||||
|
||||
class Coordinator {
|
||||
let visualEffectView = NSVisualEffectView()
|
||||
|
||||
init() {
|
||||
visualEffectView.blendingMode = .withinWindow
|
||||
}
|
||||
|
||||
func update(material: NSVisualEffectView.Material) {
|
||||
visualEffectView.material = material
|
||||
}
|
||||
|
||||
func update(blendingMode: NSVisualEffectView.BlendingMode) {
|
||||
visualEffectView.blendingMode = blendingMode
|
||||
}
|
||||
|
||||
func update(state: NSVisualEffectView.State) {
|
||||
visualEffectView.state = state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -3,12 +3,10 @@ import SwiftUI
|
||||
extension Color {
|
||||
#if os(macOS)
|
||||
static let background = Color(NSColor.windowBackgroundColor)
|
||||
static let secondaryBackground = Color(NSColor.underPageBackgroundColor)
|
||||
static let tertiaryBackground = Color(NSColor.controlBackgroundColor)
|
||||
static let secondaryBackground = Color(NSColor.controlBackgroundColor)
|
||||
#elseif os(iOS)
|
||||
static let background = Color(UIColor.systemBackground)
|
||||
static let secondaryBackground = Color(UIColor.secondarySystemBackground)
|
||||
static let tertiaryBackground = Color(UIColor.tertiarySystemBackground)
|
||||
#else
|
||||
static func background(scheme: ColorScheme) -> Color {
|
||||
scheme == .dark ? .black : .init(white: 0.8)
|
||||
|
||||
7
Extensions/Comparable+Clamped.swift
Normal file
7
Extensions/Comparable+Clamped.swift
Normal file
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
extension Comparable {
|
||||
func clamped(to limits: ClosedRange<Self>) -> Self {
|
||||
min(max(self, limits.lowerBound), limits.upperBound)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import Foundation
|
||||
|
||||
extension Double {
|
||||
func formattedAsPlaybackTime() -> String? {
|
||||
guard !isZero else {
|
||||
guard !isZero, isFinite else {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ struct FixtureEnvironmentObjectsModifier: ViewModifier {
|
||||
|
||||
player.currentItem = PlayerQueueItem(Video.fixture)
|
||||
player.queue = Video.allFixtures.map { PlayerQueueItem($0) }
|
||||
player.history = player.queue
|
||||
|
||||
return player
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ final class AccountsModel: ObservableObject {
|
||||
return AccountsModel.find(id)
|
||||
}
|
||||
|
||||
var any: Account? {
|
||||
lastUsed ?? all.randomElement()
|
||||
}
|
||||
|
||||
var app: VideosApp {
|
||||
current?.instance?.app ?? .invidious
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@ final class InstancesModel: ObservableObject {
|
||||
Defaults[.instances]
|
||||
}
|
||||
|
||||
static var forPlayer: Instance? {
|
||||
guard let id = Defaults[.playerInstanceID] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return InstancesModel.find(id)
|
||||
}
|
||||
|
||||
var lastUsed: Instance? {
|
||||
guard let id = Defaults[.lastInstanceID] else {
|
||||
return nil
|
||||
|
||||
@@ -25,11 +25,15 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
func setAccount(_ account: Account) {
|
||||
self.account = account
|
||||
signedIn = false
|
||||
|
||||
validInstance = false
|
||||
signedIn = !account.anonymous
|
||||
validInstance = account.anonymous
|
||||
|
||||
configure()
|
||||
|
||||
if !account.anonymous {
|
||||
validate()
|
||||
}
|
||||
}
|
||||
|
||||
func validate() {
|
||||
@@ -80,24 +84,26 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("popular"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
|
||||
content.json.arrayValue.map(InvidiousAPI.extractVideo)
|
||||
content.json.arrayValue.map(self.extractVideo)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("trending"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
|
||||
content.json.arrayValue.map(InvidiousAPI.extractVideo)
|
||||
content.json.arrayValue.map(self.extractVideo)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("search"), requestMethods: [.get]) { (content: Entity<JSON>) -> [ContentItem] in
|
||||
content.json.arrayValue.map {
|
||||
let type = $0.dictionaryValue["type"]?.stringValue
|
||||
configureTransformer(pathPattern("search"), requestMethods: [.get]) { (content: Entity<JSON>) -> SearchPage in
|
||||
let results = content.json.arrayValue.compactMap { json -> ContentItem in
|
||||
let type = json.dictionaryValue["type"]?.stringValue
|
||||
|
||||
if type == "channel" {
|
||||
return ContentItem(channel: InvidiousAPI.extractChannel(from: $0))
|
||||
return ContentItem(channel: self.extractChannel(from: json))
|
||||
} else if type == "playlist" {
|
||||
return ContentItem(playlist: InvidiousAPI.extractChannelPlaylist(from: $0))
|
||||
return ContentItem(playlist: self.extractChannelPlaylist(from: json))
|
||||
}
|
||||
return ContentItem(video: InvidiousAPI.extractVideo(from: $0))
|
||||
return ContentItem(video: self.extractVideo(from: json))
|
||||
}
|
||||
|
||||
return SearchPage(results: results, last: results.isEmpty)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("search/suggestions"), requestMethods: [.get]) { (content: Entity<JSON>) -> [String] in
|
||||
@@ -109,11 +115,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("auth/playlists"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Playlist] in
|
||||
content.json.arrayValue.map(Playlist.init)
|
||||
content.json.arrayValue.map(self.extractPlaylist)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("auth/playlists/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Playlist in
|
||||
Playlist(content.json)
|
||||
self.extractPlaylist(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("auth/playlists"), requestMethods: [.post, .patch]) { (content: Entity<Data>) -> Playlist in
|
||||
@@ -123,30 +129,30 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
configureTransformer(pathPattern("auth/feed"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
|
||||
if let feedVideos = content.json.dictionaryValue["videos"] {
|
||||
return feedVideos.arrayValue.map(InvidiousAPI.extractVideo)
|
||||
return feedVideos.arrayValue.map(self.extractVideo)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("auth/subscriptions"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Channel] in
|
||||
content.json.arrayValue.map(InvidiousAPI.extractChannel)
|
||||
content.json.arrayValue.map(self.extractChannel)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("channels/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Channel in
|
||||
InvidiousAPI.extractChannel(from: content.json)
|
||||
self.extractChannel(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("channels/*/latest"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
|
||||
content.json.arrayValue.map(InvidiousAPI.extractVideo)
|
||||
content.json.arrayValue.map(self.extractVideo)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("playlists/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> ChannelPlaylist in
|
||||
InvidiousAPI.extractChannelPlaylist(from: content.json)
|
||||
self.extractChannelPlaylist(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("videos/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Video in
|
||||
InvidiousAPI.extractVideo(from: content.json)
|
||||
self.extractVideo(from: content.json)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,7 +240,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
resource(baseURL: account.url, path: basePathAppending("playlists/\(id)"))
|
||||
}
|
||||
|
||||
func search(_ query: SearchQuery) -> Resource {
|
||||
func search(_ query: SearchQuery, page: String?) -> Resource {
|
||||
var resource = resource(baseURL: account.url, path: basePathAppending("search"))
|
||||
.withParam("q", searchQuery(query.query))
|
||||
.withParam("sort_by", query.sortBy.parameter)
|
||||
@@ -248,6 +254,10 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
resource = resource.withParam("duration", duration.rawValue)
|
||||
}
|
||||
|
||||
if let page = page {
|
||||
resource = resource.withParam("page", page)
|
||||
}
|
||||
|
||||
return resource
|
||||
}
|
||||
|
||||
@@ -291,7 +301,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
return AVURLAsset(url: url)
|
||||
}
|
||||
|
||||
static func extractVideo(from json: JSON) -> Video {
|
||||
func extractVideo(from json: JSON) -> Video {
|
||||
let indexID: String?
|
||||
var id: Video.ID
|
||||
var publishedAt: Date?
|
||||
@@ -334,8 +344,15 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
)
|
||||
}
|
||||
|
||||
static func extractChannel(from json: JSON) -> Channel {
|
||||
let thumbnailURL = "https:\(json["authorThumbnails"].arrayValue.first?.dictionaryValue["url"]?.stringValue ?? "")"
|
||||
func extractChannel(from json: JSON) -> Channel {
|
||||
var thumbnailURL = json["authorThumbnails"].arrayValue.last?.dictionaryValue["url"]?.stringValue ?? ""
|
||||
|
||||
// append https protocol to unproxied thumbnail URL if it's missing
|
||||
if thumbnailURL.count > 2,
|
||||
String(thumbnailURL[..<thumbnailURL.index(thumbnailURL.startIndex, offsetBy: 2)]) == "//"
|
||||
{
|
||||
thumbnailURL = "https:\(thumbnailURL)"
|
||||
}
|
||||
|
||||
return Channel(
|
||||
id: json["authorId"].stringValue,
|
||||
@@ -343,33 +360,33 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
thumbnailURL: URL(string: thumbnailURL),
|
||||
subscriptionsCount: json["subCount"].int,
|
||||
subscriptionsText: json["subCountText"].string,
|
||||
videos: json.dictionaryValue["latestVideos"]?.arrayValue.map(InvidiousAPI.extractVideo) ?? []
|
||||
videos: json.dictionaryValue["latestVideos"]?.arrayValue.map(extractVideo) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
static func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist {
|
||||
func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist {
|
||||
let details = json.dictionaryValue
|
||||
return ChannelPlaylist(
|
||||
id: details["playlistId"]!.stringValue,
|
||||
title: details["title"]!.stringValue,
|
||||
thumbnailURL: details["playlistThumbnail"]?.url,
|
||||
channel: extractChannel(from: json),
|
||||
videos: details["videos"]?.arrayValue.compactMap(InvidiousAPI.extractVideo) ?? []
|
||||
videos: details["videos"]?.arrayValue.compactMap(extractVideo) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
private static func extractThumbnails(from details: JSON) -> [Thumbnail] {
|
||||
private func extractThumbnails(from details: JSON) -> [Thumbnail] {
|
||||
details["videoThumbnails"].arrayValue.map { json in
|
||||
Thumbnail(url: json["url"].url!, quality: .init(rawValue: json["quality"].string!)!)
|
||||
}
|
||||
}
|
||||
|
||||
private static func extractStreams(from json: JSON) -> [Stream] {
|
||||
private func extractStreams(from json: JSON) -> [Stream] {
|
||||
extractFormatStreams(from: json["formatStreams"].arrayValue) +
|
||||
extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue)
|
||||
}
|
||||
|
||||
private static func extractFormatStreams(from streams: [JSON]) -> [Stream] {
|
||||
private func extractFormatStreams(from streams: [JSON]) -> [Stream] {
|
||||
streams.map {
|
||||
SingleAssetStream(
|
||||
avAsset: AVURLAsset(url: $0["url"].url!),
|
||||
@@ -380,13 +397,13 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
}
|
||||
|
||||
private static func extractAdaptiveFormats(from streams: [JSON]) -> [Stream] {
|
||||
private func extractAdaptiveFormats(from streams: [JSON]) -> [Stream] {
|
||||
let audioAssetURL = streams.first { $0["type"].stringValue.starts(with: "audio/mp4") }
|
||||
guard audioAssetURL != nil else {
|
||||
return []
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -394,15 +411,26 @@ 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func extractRelated(from content: JSON) -> [Video] {
|
||||
private func extractRelated(from content: JSON) -> [Video] {
|
||||
content
|
||||
.dictionaryValue["recommendedVideos"]?
|
||||
.arrayValue
|
||||
.compactMap(extractVideo(from:)) ?? []
|
||||
}
|
||||
|
||||
private func extractPlaylist(from content: JSON) -> Playlist {
|
||||
.init(
|
||||
id: content["playlistId"].stringValue,
|
||||
title: content["title"].stringValue,
|
||||
visibility: content["isListed"].boolValue ? .public : .private,
|
||||
updated: content["updated"].doubleValue,
|
||||
videos: content["videos"].arrayValue.map { extractVideo(from: $0) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,6 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
@Published var account: Account!
|
||||
|
||||
var anonymousAccount: Account {
|
||||
.init(instanceID: account.instance.id, name: "Anonymous", url: account.instance.apiURL)
|
||||
}
|
||||
|
||||
init(account: Account? = nil) {
|
||||
super.init()
|
||||
|
||||
@@ -40,23 +36,28 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("channel/*")) { (content: Entity<JSON>) -> Channel? in
|
||||
PipedAPI.extractChannel(from: content.json)
|
||||
self.extractChannel(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("playlists/*")) { (content: Entity<JSON>) -> ChannelPlaylist? in
|
||||
PipedAPI.extractChannelPlaylist(from: content.json)
|
||||
self.extractChannelPlaylist(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("streams/*")) { (content: Entity<JSON>) -> Video? in
|
||||
PipedAPI.extractVideo(from: content.json)
|
||||
self.extractVideo(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("trending")) { (content: Entity<JSON>) -> [Video] in
|
||||
PipedAPI.extractVideos(from: content.json)
|
||||
self.extractVideos(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("search")) { (content: Entity<JSON>) -> [ContentItem] in
|
||||
PipedAPI.extractContentItems(from: content.json.dictionaryValue["items"]!)
|
||||
configureTransformer(pathPattern("search")) { (content: Entity<JSON>) -> SearchPage in
|
||||
let nextPage = content.json.dictionaryValue["nextpage"]?.stringValue
|
||||
return SearchPage(
|
||||
results: self.extractContentItems(from: content.json.dictionaryValue["items"]!),
|
||||
nextPage: nextPage,
|
||||
last: nextPage == "null"
|
||||
)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("suggestions")) { (content: Entity<JSON>) -> [String] in
|
||||
@@ -64,16 +65,16 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("subscriptions")) { (content: Entity<JSON>) -> [Channel] in
|
||||
content.json.arrayValue.map { PipedAPI.extractChannel(from: $0)! }
|
||||
content.json.arrayValue.map { self.extractChannel(from: $0)! }
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("feed")) { (content: Entity<JSON>) -> [Video] in
|
||||
content.json.arrayValue.map { PipedAPI.extractVideo(from: $0)! }
|
||||
content.json.arrayValue.map { self.extractVideo(from: $0)! }
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("comments/*")) { (content: Entity<JSON>) -> CommentsPage in
|
||||
let details = content.json.dictionaryValue
|
||||
let comments = details["comments"]?.arrayValue.map { PipedAPI.extractComment(from: $0)! } ?? []
|
||||
let comments = details["comments"]?.arrayValue.map { self.extractComment(from: $0)! } ?? []
|
||||
let nextPage = details["nextpage"]?.stringValue
|
||||
let disabled = details["disabled"]?.boolValue ?? false
|
||||
|
||||
@@ -86,7 +87,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
func needsAuthorization(_ url: URL) -> Bool {
|
||||
PipedAPI.authorizedEndpoints.contains { url.absoluteString.contains($0) }
|
||||
Self.authorizedEndpoints.contains { url.absoluteString.contains($0) }
|
||||
}
|
||||
|
||||
func updateToken() {
|
||||
@@ -127,10 +128,18 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
.withParam("region", country.rawValue)
|
||||
}
|
||||
|
||||
func search(_ query: SearchQuery) -> Resource {
|
||||
resource(baseURL: account.instance.apiURL, path: "search")
|
||||
func search(_ query: SearchQuery, page: String?) -> Resource {
|
||||
let path = page.isNil ? "search" : "nextpage/search"
|
||||
|
||||
let resource = resource(baseURL: account.instance.apiURL, path: path)
|
||||
.withParam("q", query.query)
|
||||
.withParam("filter", "")
|
||||
.withParam("filter", "all")
|
||||
|
||||
if page.isNil {
|
||||
return resource
|
||||
}
|
||||
|
||||
return resource.withParam("nextpage", page)
|
||||
}
|
||||
|
||||
func searchSuggestions(query: String) -> Resource {
|
||||
@@ -190,7 +199,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
"**\(path)"
|
||||
}
|
||||
|
||||
private static func extractContentItem(from content: JSON) -> ContentItem? {
|
||||
private func extractContentItem(from content: JSON) -> ContentItem? {
|
||||
let details = content.dictionaryValue
|
||||
let url: String! = details["url"]?.string
|
||||
|
||||
@@ -210,17 +219,17 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
switch contentType {
|
||||
case .video:
|
||||
if let video = PipedAPI.extractVideo(from: content) {
|
||||
if let video = extractVideo(from: content) {
|
||||
return ContentItem(video: video)
|
||||
}
|
||||
|
||||
case .playlist:
|
||||
if let playlist = PipedAPI.extractChannelPlaylist(from: content) {
|
||||
if let playlist = extractChannelPlaylist(from: content) {
|
||||
return ContentItem(playlist: playlist)
|
||||
}
|
||||
|
||||
case .channel:
|
||||
if let channel = PipedAPI.extractChannel(from: content) {
|
||||
if let channel = extractChannel(from: content) {
|
||||
return ContentItem(channel: channel)
|
||||
}
|
||||
}
|
||||
@@ -228,11 +237,11 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func extractContentItems(from content: JSON) -> [ContentItem] {
|
||||
content.arrayValue.compactMap { PipedAPI.extractContentItem(from: $0) }
|
||||
private func extractContentItems(from content: JSON) -> [ContentItem] {
|
||||
content.arrayValue.compactMap { extractContentItem(from: $0) }
|
||||
}
|
||||
|
||||
private static func extractChannel(from content: JSON) -> Channel? {
|
||||
private func extractChannel(from content: JSON) -> Channel? {
|
||||
let attributes = content.dictionaryValue
|
||||
guard let id = attributes["id"]?.stringValue ??
|
||||
(attributes["url"] ?? attributes["uploaderUrl"])?.stringValue.components(separatedBy: "/").last
|
||||
@@ -244,25 +253,28 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
var videos = [Video]()
|
||||
if let relatedStreams = attributes["relatedStreams"] {
|
||||
videos = PipedAPI.extractVideos(from: relatedStreams)
|
||||
videos = extractVideos(from: relatedStreams)
|
||||
}
|
||||
|
||||
let name = attributes["name"]?.stringValue ?? attributes["uploaderName"]?.stringValue ?? attributes["uploader"]?.stringValue ?? ""
|
||||
let thumbnailURL = attributes["avatarUrl"]?.url ?? attributes["uploaderAvatar"]?.url ?? attributes["avatar"]?.url ?? attributes["thumbnail"]?.url
|
||||
|
||||
return Channel(
|
||||
id: id,
|
||||
name: attributes["name"]!.stringValue,
|
||||
thumbnailURL: attributes["thumbnail"]?.url,
|
||||
name: name,
|
||||
thumbnailURL: thumbnailURL,
|
||||
subscriptionsCount: subscriptionsCount,
|
||||
videos: videos
|
||||
)
|
||||
}
|
||||
|
||||
static func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist? {
|
||||
func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist? {
|
||||
let details = json.dictionaryValue
|
||||
let id = details["url"]?.stringValue.components(separatedBy: "?list=").last ?? UUID().uuidString
|
||||
let thumbnailURL = details["thumbnail"]?.url ?? details["thumbnailUrl"]?.url
|
||||
var videos = [Video]()
|
||||
if let relatedStreams = details["relatedStreams"] {
|
||||
videos = PipedAPI.extractVideos(from: relatedStreams)
|
||||
videos = extractVideos(from: relatedStreams)
|
||||
}
|
||||
return ChannelPlaylist(
|
||||
id: id,
|
||||
@@ -274,7 +286,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
)
|
||||
}
|
||||
|
||||
private static func extractVideo(from content: JSON) -> Video? {
|
||||
private func extractVideo(from content: JSON) -> Video? {
|
||||
let details = content.dictionaryValue
|
||||
let url = details["url"]?.string
|
||||
|
||||
@@ -287,7 +299,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
let channelId = details["uploaderUrl"]!.stringValue.components(separatedBy: "/").last!
|
||||
|
||||
let thumbnails: [Thumbnail] = Thumbnail.Quality.allCases.compactMap {
|
||||
if let url = PipedAPI.buildThumbnailURL(from: content, quality: $0) {
|
||||
if let url = buildThumbnailURL(from: content, quality: $0) {
|
||||
return Thumbnail(url: url, quality: $0)
|
||||
}
|
||||
|
||||
@@ -295,19 +307,27 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
let author = details["uploaderName"]?.stringValue ?? details["uploader"]!.stringValue
|
||||
let published = (details["uploadedDate"] ?? details["uploadDate"])?.stringValue ??
|
||||
(details["uploaded"]!.double! / 1000).formattedAsRelativeTime()!
|
||||
let authorThumbnailURL = details["avatarUrl"]?.url ?? details["uploaderAvatar"]?.url ?? details["avatar"]?.url
|
||||
|
||||
let uploaded = details["uploaded"]?.doubleValue
|
||||
var published = uploaded.isNil ? nil : (uploaded! / 1000).formattedAsRelativeTime()
|
||||
if published.isNil {
|
||||
published = (details["uploadedDate"] ?? details["uploadDate"])?.stringValue ?? ""
|
||||
}
|
||||
|
||||
let live = details["livestream"]?.boolValue ?? (details["duration"]?.intValue == -1)
|
||||
|
||||
return Video(
|
||||
videoID: PipedAPI.extractID(from: content),
|
||||
videoID: extractID(from: content),
|
||||
title: details["title"]!.stringValue,
|
||||
author: author,
|
||||
length: details["duration"]!.doubleValue,
|
||||
published: published,
|
||||
published: published!,
|
||||
views: details["views"]!.intValue,
|
||||
description: PipedAPI.extractDescription(from: content),
|
||||
channel: Channel(id: channelId, name: author),
|
||||
description: extractDescription(from: content),
|
||||
channel: Channel(id: channelId, name: author, thumbnailURL: authorThumbnailURL),
|
||||
thumbnails: thumbnails,
|
||||
live: live,
|
||||
likes: details["likes"]?.int,
|
||||
dislikes: details["dislikes"]?.int,
|
||||
streams: extractStreams(from: content),
|
||||
@@ -315,16 +335,16 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
)
|
||||
}
|
||||
|
||||
private static func extractID(from content: JSON) -> Video.ID {
|
||||
private func extractID(from content: JSON) -> Video.ID {
|
||||
content.dictionaryValue["url"]?.stringValue.components(separatedBy: "?v=").last ??
|
||||
extractThumbnailURL(from: content)!.relativeString.components(separatedBy: "/")[4]
|
||||
}
|
||||
|
||||
private static func extractThumbnailURL(from content: JSON) -> URL? {
|
||||
private func extractThumbnailURL(from content: JSON) -> URL? {
|
||||
content.dictionaryValue["thumbnail"]?.url! ?? content.dictionaryValue["thumbnailUrl"]!.url!
|
||||
}
|
||||
|
||||
private static func buildThumbnailURL(from content: JSON, quality: Thumbnail.Quality) -> URL? {
|
||||
private func buildThumbnailURL(from content: JSON, quality: Thumbnail.Quality) -> URL? {
|
||||
let thumbnailURL = extractThumbnailURL(from: content)
|
||||
guard !thumbnailURL.isNil else {
|
||||
return nil
|
||||
@@ -337,7 +357,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
)!
|
||||
}
|
||||
|
||||
private static func extractDescription(from content: JSON) -> String? {
|
||||
private func extractDescription(from content: JSON) -> String? {
|
||||
guard var description = content.dictionaryValue["description"]?.string else {
|
||||
return nil
|
||||
}
|
||||
@@ -359,22 +379,30 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
return description
|
||||
}
|
||||
|
||||
private static func extractVideos(from content: JSON) -> [Video] {
|
||||
private func extractVideos(from content: JSON) -> [Video] {
|
||||
content.arrayValue.compactMap(extractVideo(from:))
|
||||
}
|
||||
|
||||
private static func extractStreams(from content: JSON) -> [Stream] {
|
||||
private func extractStreams(from content: JSON) -> [Stream] {
|
||||
var streams = [Stream]()
|
||||
|
||||
if let hlsURL = content.dictionaryValue["hls"]?.url {
|
||||
streams.append(Stream(hlsURL: hlsURL))
|
||||
}
|
||||
|
||||
guard let audioStream = PipedAPI.compatibleAudioStreams(from: content).first else {
|
||||
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 = PipedAPI.compatibleVideoStream(from: content)
|
||||
let videoStreams = content.dictionaryValue["videoStreams"]?.arrayValue ?? []
|
||||
|
||||
videoStreams.forEach { videoStream in
|
||||
let audioAsset = AVURLAsset(url: audioStream.dictionaryValue["url"]!.url!)
|
||||
@@ -382,10 +410,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(
|
||||
@@ -397,31 +426,14 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
return streams
|
||||
}
|
||||
|
||||
private static func extractRelated(from content: JSON) -> [Video] {
|
||||
private func extractRelated(from content: JSON) -> [Video] {
|
||||
content
|
||||
.dictionaryValue["relatedStreams"]?
|
||||
.arrayValue
|
||||
.compactMap(extractVideo(from:)) ?? []
|
||||
}
|
||||
|
||||
private static func compatibleAudioStreams(from content: JSON) -> [JSON] {
|
||||
content
|
||||
.dictionaryValue["audioStreams"]?
|
||||
.arrayValue
|
||||
.filter { $0.dictionaryValue["format"]?.stringValue == "M4A" }
|
||||
.sorted {
|
||||
$0.dictionaryValue["bitrate"]?.intValue ?? 0 > $1.dictionaryValue["bitrate"]?.intValue ?? 0
|
||||
} ?? []
|
||||
}
|
||||
|
||||
private static func compatibleVideoStream(from content: JSON) -> [JSON] {
|
||||
content
|
||||
.dictionaryValue["videoStreams"]?
|
||||
.arrayValue
|
||||
.filter { $0.dictionaryValue["format"] == "MPEG_4" } ?? []
|
||||
}
|
||||
|
||||
private static func extractComment(from content: JSON) -> Comment? {
|
||||
private func extractComment(from content: JSON) -> Comment? {
|
||||
let details = content.dictionaryValue
|
||||
let author = details["author"]?.stringValue ?? ""
|
||||
let commentorUrl = details["commentorUrl"]?.stringValue
|
||||
|
||||
@@ -9,7 +9,7 @@ protocol VideosAPI {
|
||||
func channel(_ id: String) -> Resource
|
||||
func channelVideos(_ id: String) -> Resource
|
||||
func trending(country: Country, category: TrendingCategory?) -> Resource
|
||||
func search(_ query: SearchQuery) -> Resource
|
||||
func search(_ query: SearchQuery, page: String?) -> Resource
|
||||
func searchSuggestions(query: String) -> Resource
|
||||
|
||||
func video(_ id: Video.ID) -> Resource
|
||||
@@ -55,11 +55,12 @@ extension VideosAPI {
|
||||
}
|
||||
|
||||
func shareURL(_ item: ContentItem, frontendHost: String? = nil, time: CMTime? = nil) -> URL? {
|
||||
guard let frontendHost = frontendHost ?? account.instance.frontendHost else {
|
||||
guard let frontendHost = frontendHost ?? account?.instance?.frontendHost,
|
||||
var urlComponents = account?.instance?.urlComponents
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var urlComponents = account.instance.urlComponents
|
||||
urlComponents.host = frontendHost
|
||||
|
||||
var queryItems = [URLQueryItem]()
|
||||
|
||||
@@ -42,4 +42,8 @@ enum VideosApp: String, CaseIterable {
|
||||
var supportsComments: Bool {
|
||||
self == .piped
|
||||
}
|
||||
|
||||
var searchUsesIndexedPages: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@ struct Channel: Identifiable, Hashable {
|
||||
self.videos = videos
|
||||
}
|
||||
|
||||
var detailsLoaded: Bool {
|
||||
!subscriptionsString.isNil
|
||||
}
|
||||
|
||||
var subscriptionsString: String? {
|
||||
if subscriptionsCount != nil, subscriptionsCount! > 0 {
|
||||
return subscriptionsCount!.formattedAsAbbreviation()
|
||||
|
||||
@@ -8,26 +8,25 @@ final class CommentsModel: ObservableObject {
|
||||
@Published var nextPage: String?
|
||||
@Published var firstPage = true
|
||||
|
||||
@Published var loaded = true
|
||||
@Published var loaded = false
|
||||
@Published var disabled = false
|
||||
|
||||
@Published var replies = [Comment]()
|
||||
@Published var repliesPageID: String?
|
||||
@Published var repliesLoaded = false
|
||||
|
||||
var accounts: AccountsModel!
|
||||
var player: PlayerModel!
|
||||
|
||||
var instance: Instance? {
|
||||
static var instance: Instance? {
|
||||
InstancesModel.find(Defaults[.commentsInstanceID])
|
||||
}
|
||||
|
||||
var api: VideosAPI? {
|
||||
instance.isNil ? nil : PipedAPI(account: instance!.anonymousAccount)
|
||||
Self.instance.isNil ? nil : PipedAPI(account: Self.instance!.anonymousAccount)
|
||||
}
|
||||
|
||||
static var enabled: Bool {
|
||||
!Defaults[.commentsInstanceID].isNil && !Defaults[.commentsInstanceID]!.isEmpty
|
||||
!instance.isNil
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
@@ -45,21 +44,23 @@ final class CommentsModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
reset()
|
||||
|
||||
guard !instance.isNil,
|
||||
guard !Self.instance.isNil,
|
||||
!(player?.currentVideo.isNil ?? true)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
if !firstPage && !nextPageAvailable {
|
||||
return
|
||||
}
|
||||
|
||||
firstPage = page.isNil || page!.isEmpty
|
||||
|
||||
api?.comments(player.currentVideo!.videoID, page: page)?
|
||||
.load()
|
||||
.onSuccess { [weak self] response in
|
||||
if let page: CommentsPage = response.typedContent() {
|
||||
self?.all = page.comments
|
||||
self?.all += page.comments
|
||||
self?.nextPage = page.nextPage
|
||||
self?.disabled = page.disabled
|
||||
}
|
||||
@@ -69,6 +70,13 @@ final class CommentsModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func loadNextPageIfNeeded(current comment: Comment) {
|
||||
let thresholdIndex = all.index(all.endIndex, offsetBy: -5)
|
||||
if all.firstIndex(where: { $0 == comment }) == thresholdIndex {
|
||||
loadNextPage()
|
||||
}
|
||||
}
|
||||
|
||||
func loadNextPage() {
|
||||
load(page: nextPage)
|
||||
}
|
||||
@@ -91,9 +99,10 @@ final class CommentsModel: ObservableObject {
|
||||
.onSuccess { [weak self] response in
|
||||
if let page: CommentsPage = response.typedContent() {
|
||||
self?.replies = page.comments
|
||||
self?.repliesLoaded = true
|
||||
}
|
||||
}
|
||||
.onCompletion { [weak self] _ in
|
||||
.onFailure { [weak self] _ in
|
||||
self?.repliesLoaded = true
|
||||
}
|
||||
}
|
||||
|
||||
81
Model/HistoryModel.swift
Normal file
81
Model/HistoryModel.swift
Normal file
@@ -0,0 +1,81 @@
|
||||
import CoreData
|
||||
import CoreMedia
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
extension PlayerModel {
|
||||
func historyVideo(_ id: String) -> Video? {
|
||||
historyVideos.first { $0.videoID == id }
|
||||
}
|
||||
|
||||
func loadHistoryVideoDetails(_ id: Video.ID) {
|
||||
guard historyVideo(id).isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
accounts.api.video(id).load().onSuccess { [weak self] response in
|
||||
guard let video: Video = response.typedContent() else {
|
||||
return
|
||||
}
|
||||
|
||||
self?.historyVideos.append(video)
|
||||
}
|
||||
}
|
||||
|
||||
func updateWatch(finished: Bool = false) {
|
||||
guard let id = currentVideo?.videoID,
|
||||
Defaults[.saveHistory]
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
let time = backend.currentTime
|
||||
let seconds = time?.seconds ?? 0
|
||||
|
||||
let watch: Watch!
|
||||
let watchFetchRequest = Watch.fetchRequest()
|
||||
watchFetchRequest.predicate = NSPredicate(format: "videoID = %@", id as String)
|
||||
|
||||
let results = try? context.fetch(watchFetchRequest)
|
||||
|
||||
if results?.isEmpty ?? true {
|
||||
if seconds < 1 {
|
||||
return
|
||||
}
|
||||
watch = Watch(context: context)
|
||||
watch.videoID = id
|
||||
} else {
|
||||
watch = results?.first
|
||||
|
||||
if !Defaults[.resetWatchedStatusOnPlaying], watch.finished {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if let seconds = playerItemDuration?.seconds {
|
||||
watch.videoDuration = seconds
|
||||
}
|
||||
|
||||
if finished {
|
||||
watch.stoppedAt = watch.videoDuration
|
||||
} else if seconds.isFinite, seconds > 0 {
|
||||
watch.stoppedAt = seconds
|
||||
}
|
||||
|
||||
watch.watchedAt = Date()
|
||||
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
func removeWatch(_ watch: Watch) {
|
||||
context.delete(watch)
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
func removeAllWatches() {
|
||||
let watchesFetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "Watch")
|
||||
let deleteRequest = NSBatchDeleteRequest(fetchRequest: watchesFetchRequest)
|
||||
_ = try? context.execute(deleteRequest)
|
||||
_ = try? context.save()
|
||||
}
|
||||
}
|
||||
@@ -41,10 +41,80 @@ final class NavigationModel: ObservableObject {
|
||||
@Published var presentingSettings = false
|
||||
@Published var presentingWelcomeScreen = false
|
||||
|
||||
static func openChannel(
|
||||
_ channel: Channel,
|
||||
player: PlayerModel,
|
||||
recents: RecentsModel,
|
||||
navigation: NavigationModel,
|
||||
navigationStyle: NavigationStyle,
|
||||
delay: Bool = true
|
||||
) {
|
||||
let recent = RecentItem(from: channel)
|
||||
#if os(macOS)
|
||||
Windows.main.open()
|
||||
#else
|
||||
player.hide()
|
||||
#endif
|
||||
|
||||
let openRecent = {
|
||||
recents.add(recent)
|
||||
navigation.presentingChannel = true
|
||||
}
|
||||
|
||||
if navigationStyle == .tab {
|
||||
if delay {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
openRecent()
|
||||
}
|
||||
} else {
|
||||
openRecent()
|
||||
}
|
||||
} else if navigationStyle == .sidebar {
|
||||
openRecent()
|
||||
navigation.sidebarSectionChanged.toggle()
|
||||
navigation.tabSelection = .recentlyOpened(recent.tag)
|
||||
}
|
||||
}
|
||||
|
||||
static func openChannelPlaylist(
|
||||
_ playlist: ChannelPlaylist,
|
||||
player: PlayerModel,
|
||||
recents: RecentsModel,
|
||||
navigation: NavigationModel,
|
||||
navigationStyle: NavigationStyle,
|
||||
delay: Bool = false
|
||||
) {
|
||||
let recent = RecentItem(from: playlist)
|
||||
#if os(macOS)
|
||||
Windows.main.open()
|
||||
#else
|
||||
player.hide()
|
||||
#endif
|
||||
|
||||
let openRecent = {
|
||||
recents.add(recent)
|
||||
navigation.presentingPlaylist = true
|
||||
}
|
||||
|
||||
if navigationStyle == .tab {
|
||||
if delay {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
openRecent()
|
||||
}
|
||||
} else {
|
||||
openRecent()
|
||||
}
|
||||
} else if navigationStyle == .sidebar {
|
||||
openRecent()
|
||||
navigation.sidebarSectionChanged.toggle()
|
||||
navigation.tabSelection = .recentlyOpened(recent.tag)
|
||||
}
|
||||
}
|
||||
|
||||
var tabSelectionBinding: Binding<TabSelection> {
|
||||
Binding<TabSelection>(
|
||||
get: {
|
||||
self.tabSelection ?? .favorites
|
||||
self.tabSelection ?? .search
|
||||
},
|
||||
set: { newValue in
|
||||
self.tabSelection = newValue
|
||||
|
||||
47
Model/PersistenceController.swift
Normal file
47
Model/PersistenceController.swift
Normal file
@@ -0,0 +1,47 @@
|
||||
import CoreData
|
||||
|
||||
struct PersistenceController {
|
||||
static let shared = PersistenceController()
|
||||
|
||||
static var preview: PersistenceController = {
|
||||
let result = PersistenceController(inMemory: true)
|
||||
let viewContext = result.container.viewContext
|
||||
|
||||
do {
|
||||
try viewContext.save()
|
||||
} catch {
|
||||
// Replace this implementation with code to handle the error appropriately.
|
||||
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
|
||||
let nsError = error as NSError
|
||||
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
|
||||
}
|
||||
return result
|
||||
}()
|
||||
|
||||
let container: NSPersistentContainer
|
||||
|
||||
init(inMemory: Bool = false) {
|
||||
container = NSPersistentContainer(name: "Yattee")
|
||||
if inMemory {
|
||||
container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
|
||||
}
|
||||
container.viewContext.automaticallyMergesChangesFromParent = true
|
||||
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
||||
container.loadPersistentStores { _, error in
|
||||
if let error = error as NSError? {
|
||||
// Replace this implementation with code to handle the error appropriately.
|
||||
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
|
||||
|
||||
/*
|
||||
Typical reasons for an error here include:
|
||||
* The parent directory does not exist, cannot be created, or disallows writing.
|
||||
* The persistent store is not accessible, due to permissions or data protection when the device is locked.
|
||||
* The device is out of space.
|
||||
* The store could not be migrated to the current model version.
|
||||
Check the error message to determine what the actual problem was.
|
||||
*/
|
||||
fatalError("Unresolved error \(error), \(error.userInfo)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
567
Model/Player/Backends/AVPlayerBackend.swift
Normal file
567
Model/Player/Backends/AVPlayerBackend.swift
Normal file
@@ -0,0 +1,567 @@
|
||||
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?
|
||||
|
||||
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]) -> Stream? {
|
||||
streams.first { $0.kind == .hls } ??
|
||||
streams.filter { $0.kind == .adaptive }.max { $0.resolution < $1.resolution } ??
|
||||
streams.first
|
||||
}
|
||||
|
||||
func canPlay(_ stream: Stream) -> Bool {
|
||||
stream.kind == .hls || stream.kind == .stream || stream.videoFormat == "MPEG_4" ||
|
||||
(stream.videoFormat.starts(with: "video/mp4") && stream.encoding == "h264")
|
||||
}
|
||||
|
||||
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) {
|
||||
controller?.playerView.player = nil
|
||||
controller?.playerView.player = avPlayer
|
||||
|
||||
guard wasPlaying else {
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
self?.play()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
func updateControls() {}
|
||||
func startControlsUpdates() {}
|
||||
func stopControlsUpdates() {}
|
||||
func setNeedsDrawing(_: Bool) {}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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: playerItem
|
||||
)
|
||||
}
|
||||
|
||||
private func removeItemDidPlayToEndTimeObserver() {
|
||||
NotificationCenter.default.removeObserver(
|
||||
self,
|
||||
name: NSNotification.Name.AVPlayerItemDidPlayToEndTime,
|
||||
object: 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 {
|
||||
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 {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.model.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
|
||||
if player.timeControlStatus == .playing, 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
308
Model/Player/Backends/MPVBackend.swift
Normal file
308
Model/Player/Backends/MPVBackend.swift
Normal file
@@ -0,0 +1,308 @@
|
||||
import AVFAudio
|
||||
import CoreMedia
|
||||
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
|
||||
self?.controls.isLoadingVideo = self?.isLoadingVideo ?? true
|
||||
}
|
||||
}}
|
||||
|
||||
var isPlaying = true { didSet {
|
||||
if isPlaying {
|
||||
startClientUpdates()
|
||||
} else {
|
||||
stopControlsUpdates()
|
||||
}
|
||||
|
||||
updateControlsIsPlaying()
|
||||
}}
|
||||
var playerItemDuration: CMTime?
|
||||
|
||||
#if !os(macOS)
|
||||
var controller: MPVViewController!
|
||||
#endif
|
||||
var client: MPVClient! { didSet { client.backend = self } }
|
||||
|
||||
private var clientTimer: RepeatingTimer!
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func bestPlayable(_ streams: [Stream]) -> Stream? {
|
||||
streams.filter { $0.kind == .adaptive }.max { $0.resolution < $1.resolution } ??
|
||||
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) {
|
||||
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 < 3,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func play() {
|
||||
isPlaying = true
|
||||
startClientUpdates()
|
||||
|
||||
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(_: Float) {
|
||||
// TODO: Implement rate change
|
||||
}
|
||||
|
||||
func closeItem() {}
|
||||
|
||||
func enterFullScreen() {}
|
||||
|
||||
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 func getClientUpdates() {
|
||||
self.logger.info("getting client updates")
|
||||
|
||||
currentTime = client?.currentTime
|
||||
playerItemDuration = client?.duration
|
||||
|
||||
if controlsUpdates {
|
||||
updateControls()
|
||||
}
|
||||
|
||||
model.updateNowPlayingInfo()
|
||||
|
||||
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_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 !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)
|
||||
}
|
||||
}
|
||||
285
Model/Player/Backends/MPVClient.swift
Normal file
285
Model/Player/Backends/MPVClient.swift
Normal file
@@ -0,0 +1,285 @@
|
||||
import CoreMedia
|
||||
import Foundation
|
||||
import Logging
|
||||
#if !os(macOS)
|
||||
import Siesta
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
final class MPVClient: ObservableObject {
|
||||
private var logger = Logger(label: "mpv-client")
|
||||
|
||||
var mpv: OpaquePointer!
|
||||
var mpvGL: OpaquePointer!
|
||||
var queue: DispatchQueue!
|
||||
#if os(macOS)
|
||||
var layer: VideoLayer!
|
||||
var link: CVDisplayLink!
|
||||
#else
|
||||
var glView: MPVOGLView!
|
||||
#endif
|
||||
var backend: MPVBackend!
|
||||
|
||||
var seeking = false
|
||||
|
||||
func create(frame: CGRect? = nil) {
|
||||
#if !os(macOS)
|
||||
if let frame = frame {
|
||||
glView = MPVOGLView(frame: frame)
|
||||
}
|
||||
#endif
|
||||
|
||||
mpv = mpv_create()
|
||||
if mpv == nil {
|
||||
print("failed creating context\n")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
checkError(mpv_request_log_messages(mpv, "warn"))
|
||||
|
||||
#if os(macOS)
|
||||
checkError(mpv_set_option_string(mpv, "input-media-keys", "yes"))
|
||||
#else
|
||||
checkError(mpv_set_option_string(mpv, "hwdec", "yes"))
|
||||
checkError(mpv_set_option_string(mpv, "override-display-fps", "\(UIScreen.main.maximumFramesPerSecond)"))
|
||||
checkError(mpv_set_option_string(mpv, "video-sync", "display-resample"))
|
||||
#endif
|
||||
checkError(mpv_set_option_string(mpv, "vo", "libmpv"))
|
||||
|
||||
checkError(mpv_initialize(mpv))
|
||||
|
||||
let api = UnsafeMutableRawPointer(mutating: (MPV_RENDER_API_TYPE_OPENGL as NSString).utf8String)
|
||||
var initParams = mpv_opengl_init_params(
|
||||
get_proc_address: getProcAddress,
|
||||
get_proc_address_ctx: nil,
|
||||
extra_exts: nil
|
||||
)
|
||||
|
||||
queue = DispatchQueue(label: "mpv", qos: .background)
|
||||
|
||||
withUnsafeMutablePointer(to: &initParams) { initParams in
|
||||
var params = [
|
||||
mpv_render_param(type: MPV_RENDER_PARAM_API_TYPE, data: api),
|
||||
mpv_render_param(type: MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, data: initParams),
|
||||
mpv_render_param()
|
||||
]
|
||||
|
||||
if mpv_render_context_create(&mpvGL, mpv, ¶ms) < 0 {
|
||||
puts("failed to initialize mpv GL context")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
mpv_render_context_set_update_callback(
|
||||
mpvGL,
|
||||
glUpdate,
|
||||
UnsafeMutableRawPointer(Unmanaged.passUnretained(layer).toOpaque())
|
||||
)
|
||||
#else
|
||||
glView.mpvGL = UnsafeMutableRawPointer(mpvGL)
|
||||
|
||||
mpv_render_context_set_update_callback(
|
||||
mpvGL,
|
||||
glUpdate(_:),
|
||||
UnsafeMutableRawPointer(Unmanaged.passUnretained(glView).toOpaque())
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
queue!.async {
|
||||
mpv_set_wakeup_callback(self.mpv, wakeUp, UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()))
|
||||
}
|
||||
}
|
||||
|
||||
func readEvents() {
|
||||
queue?.async { [self] in
|
||||
while self.mpv != nil {
|
||||
let event = mpv_wait_event(self.mpv, 0)
|
||||
if event!.pointee.event_id == MPV_EVENT_NONE {
|
||||
break
|
||||
}
|
||||
backend.handle(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadFile(_ url: URL, time: CMTime? = nil, completionHandler: ((Int32) -> Void)? = nil) {
|
||||
var args = [url.absoluteString]
|
||||
if let time = time {
|
||||
args.append("replace")
|
||||
args.append("start=\(Int(time.seconds))")
|
||||
}
|
||||
|
||||
command("loadfile", args: args, returnValueCallback: completionHandler)
|
||||
}
|
||||
|
||||
func addAudio(_ url: URL, completionHandler: ((Int32) -> Void)? = nil) {
|
||||
command("audio-add", args: [url.absoluteString], returnValueCallback: completionHandler)
|
||||
}
|
||||
|
||||
func play() {
|
||||
setFlagAsync("pause", false)
|
||||
}
|
||||
|
||||
func pause() {
|
||||
setFlagAsync("pause", true)
|
||||
}
|
||||
|
||||
func togglePlay() {
|
||||
command("cycle", args: ["pause"])
|
||||
}
|
||||
|
||||
func stop() {
|
||||
command("stop")
|
||||
}
|
||||
|
||||
var currentTime: CMTime {
|
||||
CMTime.secondsInDefaultTimescale(getDouble("time-pos"))
|
||||
}
|
||||
|
||||
var duration: CMTime {
|
||||
CMTime.secondsInDefaultTimescale(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) {
|
||||
logger.info("setting player size to \(width),\(height)")
|
||||
#if !os(macOS)
|
||||
guard width <= UIScreen.main.bounds.width, height <= UIScreen.main.bounds.height else {
|
||||
logger.info("requested size is greater than screen size, ignoring")
|
||||
return
|
||||
}
|
||||
|
||||
glView?.frame = CGRect(x: 0, y: 0, width: width, height: height)
|
||||
#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)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
67
Model/Player/Backends/PlayerBackend.swift
Normal file
67
Model/Player/Backends/PlayerBackend.swift
Normal file
@@ -0,0 +1,67 @@
|
||||
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]) -> 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)
|
||||
}
|
||||
|
||||
extension PlayerBackend {
|
||||
func seek(to time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
|
||||
seek(to: time, completionHandler: completionHandler)
|
||||
}
|
||||
|
||||
func seek(to seconds: Double, completionHandler: ((Bool) -> Void)? = nil) {
|
||||
seek(to: .secondsInDefaultTimescale(seconds), completionHandler: completionHandler)
|
||||
}
|
||||
|
||||
func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
|
||||
seek(relative: time, completionHandler: completionHandler)
|
||||
}
|
||||
}
|
||||
16
Model/Player/Backends/PlayerBackendType.swift
Normal file
16
Model/Player/Backends/PlayerBackendType.swift
Normal file
@@ -0,0 +1,16 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
enum PlayerBackendType: String, CaseIterable, Defaults.Serializable {
|
||||
case mpv
|
||||
case appleAVPlayer
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .mpv:
|
||||
return "MPV"
|
||||
case .appleAVPlayer:
|
||||
return "AVPlayer"
|
||||
}
|
||||
}
|
||||
}
|
||||
117
Model/Player/PlayerControlsModel.swift
Normal file
117
Model/Player/PlayerControlsModel.swift
Normal file
@@ -0,0 +1,117 @@
|
||||
import CoreMedia
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
final class PlayerControlsModel: ObservableObject {
|
||||
@Published var isLoadingVideo = true
|
||||
@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
|
||||
|
||||
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() {
|
||||
player.backend.updateControls()
|
||||
|
||||
withAnimation(PlayerControls.animation) {
|
||||
presentingControls = true
|
||||
}
|
||||
}
|
||||
|
||||
func hide() {
|
||||
withAnimation(PlayerControls.animation) {
|
||||
presentingControls = false
|
||||
}
|
||||
}
|
||||
|
||||
func toggle() {
|
||||
if !presentingControls {
|
||||
player.backend.updateControls()
|
||||
}
|
||||
|
||||
withAnimation(PlayerControls.animation) {
|
||||
presentingControls.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
func toggleFullscreen(_ value: Bool) {
|
||||
withAnimation(Animation.easeOut) {
|
||||
resetTimer()
|
||||
withAnimation(PlayerControls.animation) {
|
||||
playingFullscreen = !value
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
if 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 reset() {
|
||||
currentTime = .zero
|
||||
duration = .zero
|
||||
}
|
||||
|
||||
func resetTimer() {
|
||||
removeTimer()
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false) { _ in
|
||||
withAnimation(PlayerControls.animation) { [weak self] in
|
||||
self?.presentingControls = false
|
||||
self?.player.backend.stopControlsUpdates()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeTimer() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
}
|
||||
@@ -1,63 +1,88 @@
|
||||
import AVKit
|
||||
import CoreData
|
||||
#if os(iOS)
|
||||
import CoreMotion
|
||||
#endif
|
||||
import Defaults
|
||||
import Foundation
|
||||
import Logging
|
||||
import MediaPlayer
|
||||
#if !os(macOS)
|
||||
import UIKit
|
||||
#endif
|
||||
import Siesta
|
||||
import SwiftUI
|
||||
import SwiftyJSON
|
||||
#if !os(macOS)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
final class PlayerModel: ObservableObject {
|
||||
static let availableRates: [Float] = [0.5, 0.67, 0.8, 1, 1.25, 1.5, 2]
|
||||
let logger = Logger(label: "stream.yattee.app")
|
||||
|
||||
private(set) var player = AVPlayer()
|
||||
private(set) var playerView = Player()
|
||||
var controller: PlayerViewController? { didSet { playerView.controller = controller } }
|
||||
#if os(tvOS)
|
||||
var avPlayerViewController: AVPlayerViewController?
|
||||
#endif
|
||||
var avPlayerView = AppleAVPlayerView()
|
||||
var playerItem: AVPlayerItem?
|
||||
|
||||
@Published var presentingPlayer = false { didSet { pauseOnPlayerDismiss() } }
|
||||
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
|
||||
@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 { rebuildTVMenu() } }
|
||||
@Published var availableStreams = [Stream]() { didSet { handleAvailableStreamsChange() } }
|
||||
@Published var streamSelection: Stream? { didSet { rebuildTVMenu() } }
|
||||
|
||||
@Published var queue = [PlayerQueueItem]() { didSet { Defaults[.queue] = queue } }
|
||||
@Published var currentItem: PlayerQueueItem! { didSet { Defaults[.lastPlayed] = currentItem } }
|
||||
@Published var history = [PlayerQueueItem]() { didSet { Defaults[.history] = history } }
|
||||
@Published var currentItem: PlayerQueueItem! { didSet { handleCurrentItemChange() } }
|
||||
@Published var historyVideos = [Video]()
|
||||
|
||||
@Published var savedTime: CMTime?
|
||||
@Published var preservedTime: CMTime?
|
||||
|
||||
@Published var playerNavigationLinkActive = false
|
||||
@Published var playerNavigationLinkActive = false { didSet { handleNavigationViewPlayerPresentationChange() } }
|
||||
|
||||
@Published var sponsorBlock = SponsorBlockAPI()
|
||||
@Published var segmentRestorationTime: CMTime?
|
||||
@Published var lastSkipped: Segment? { didSet { rebuildTVMenu() } }
|
||||
@Published var restoredSegments = [Segment]()
|
||||
|
||||
@Published var returnYouTubeDislike = ReturnYouTubeDislikeAPI()
|
||||
|
||||
@Published var channelWithDetails: Channel?
|
||||
|
||||
#if os(iOS)
|
||||
@Published var motionManager: CMMotionManager!
|
||||
@Published var lockedOrientation: UIInterfaceOrientation?
|
||||
@Published var lastOrientation: UIInterfaceOrientation?
|
||||
#endif
|
||||
|
||||
var accounts: AccountsModel
|
||||
var comments: CommentsModel
|
||||
var controls: PlayerControlsModel { didSet {
|
||||
backends.forEach { backend in
|
||||
var backend = backend
|
||||
backend.controls = controls
|
||||
}
|
||||
}}
|
||||
var context: NSManagedObjectContext = PersistenceController.shared.container.viewContext
|
||||
|
||||
var composition = AVMutableComposition()
|
||||
var loadedCompositionAssets = [AVMediaType]()
|
||||
|
||||
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
|
||||
@Published var playingInPictureInPicture = false
|
||||
|
||||
@Published var presentingErrorDetails = false
|
||||
var playerError: Error? { didSet {
|
||||
@@ -68,26 +93,85 @@ final class PlayerModel: ObservableObject {
|
||||
#endif
|
||||
}}
|
||||
|
||||
init(accounts: AccountsModel? = nil, comments: CommentsModel? = nil) {
|
||||
@Default(.pauseOnHidingPlayer) private var pauseOnHidingPlayer
|
||||
@Default(.closePiPOnNavigation) var closePiPOnNavigation
|
||||
@Default(.closePiPOnOpeningPlayer) var closePiPOnOpeningPlayer
|
||||
|
||||
#if !os(macOS)
|
||||
@Default(.closePiPAndOpenPlayerOnEnteringForeground) var closePiPAndOpenPlayerOnEnteringForeground
|
||||
#endif
|
||||
|
||||
private var currentArtwork: MPMediaItemArtwork?
|
||||
|
||||
init(accounts: AccountsModel? = nil, comments: CommentsModel? = nil, controls: PlayerControlsModel? = nil) {
|
||||
self.accounts = accounts ?? AccountsModel()
|
||||
self.comments = comments ?? CommentsModel()
|
||||
self.controls = controls ?? PlayerControlsModel()
|
||||
|
||||
addItemDidPlayToEndTimeObserver()
|
||||
addFrequentTimeObserver()
|
||||
addInfrequentTimeObserver()
|
||||
addPlayerTimeControlStatusObserver()
|
||||
self.avPlayerBackend = AVPlayerBackend(model: self, controls: controls)
|
||||
self.mpvBackend = MPVBackend(model: self)
|
||||
|
||||
self.activeBackend = Defaults[.activeBackend]
|
||||
}
|
||||
|
||||
func presentPlayer() {
|
||||
func show() {
|
||||
guard !presentingPlayer else {
|
||||
#if os(macOS)
|
||||
Windows.player.focus()
|
||||
#endif
|
||||
return
|
||||
}
|
||||
#if os(macOS)
|
||||
Windows.player.open()
|
||||
Windows.player.focus()
|
||||
#endif
|
||||
presentingPlayer = true
|
||||
}
|
||||
|
||||
func hide() {
|
||||
presentingPlayer = false
|
||||
playerNavigationLinkActive = false
|
||||
}
|
||||
|
||||
func togglePlayer() {
|
||||
presentingPlayer.toggle()
|
||||
#if os(macOS)
|
||||
if !presentingPlayer {
|
||||
Windows.player.open()
|
||||
}
|
||||
Windows.player.focus()
|
||||
#else
|
||||
if presentingPlayer {
|
||||
hide()
|
||||
} else {
|
||||
show()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
var isLoadingVideo: Bool {
|
||||
guard !currentVideo.isNil else {
|
||||
return false
|
||||
}
|
||||
|
||||
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? {
|
||||
@@ -95,399 +179,310 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
var live: Bool {
|
||||
currentItem?.video?.live ?? false
|
||||
}
|
||||
|
||||
var playerItemDuration: CMTime? {
|
||||
player.currentItem?.asset.duration
|
||||
}
|
||||
|
||||
var videoDuration: TimeInterval? {
|
||||
currentItem?.duration ?? currentVideo?.length
|
||||
currentVideo?.live ?? false
|
||||
}
|
||||
|
||||
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 {
|
||||
backend.pause()
|
||||
}
|
||||
|
||||
func play(_ video: Video, at time: TimeInterval? = nil, inNavigationView: Bool = false) {
|
||||
playNow(video, at: time)
|
||||
|
||||
guard !playingInPictureInPicture else {
|
||||
return
|
||||
}
|
||||
|
||||
player.pause()
|
||||
}
|
||||
|
||||
func upgradeToStream(_ stream: Stream) {
|
||||
if !self.stream.isNil, self.stream != stream {
|
||||
playStream(stream, of: currentVideo!, preservingTime: true)
|
||||
if inNavigationView {
|
||||
playerNavigationLinkActive = true
|
||||
} else {
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
func playStream(
|
||||
_ stream: Stream,
|
||||
of video: Video,
|
||||
preservingTime: Bool = false
|
||||
preservingTime: Bool = false,
|
||||
upgrading: Bool = false
|
||||
) {
|
||||
playerError = nil
|
||||
resetSegments()
|
||||
sponsorBlock.loadSegments(videoID: video.videoID, categories: Defaults[.sponsorBlockCategories])
|
||||
comments.load()
|
||||
if !upgrading {
|
||||
resetSegments()
|
||||
|
||||
if let url = stream.singleAssetURL {
|
||||
logger.info("playing stream with one asset\(stream.kind == .hls ? " (HLS)" : ""): \(url)")
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.sponsorBlock.loadSegments(
|
||||
videoID: video.videoID,
|
||||
categories: Defaults[.sponsorBlockCategories]
|
||||
) {
|
||||
if Defaults[.showChannelSubscribers] {
|
||||
self?.loadCurrentItemChannelDetails()
|
||||
}
|
||||
}
|
||||
|
||||
insertPlayerItem(stream, for: 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)")
|
||||
guard Defaults[.enableReturnYouTubeDislike] else {
|
||||
return
|
||||
}
|
||||
|
||||
loadComposition(stream, of: video, preservingTime: preservingTime)
|
||||
self?.returnYouTubeDislike.loadDislikes(videoID: video.videoID) { [weak self] dislikes in
|
||||
self?.currentItem?.video?.dislikes = dislikes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateCurrentArtwork()
|
||||
controls.reset()
|
||||
|
||||
backend.playStream(
|
||||
stream,
|
||||
of: video,
|
||||
preservingTime: preservingTime,
|
||||
upgrading: upgrading
|
||||
)
|
||||
|
||||
if !upgrading {
|
||||
updateCurrentArtwork()
|
||||
}
|
||||
}
|
||||
|
||||
private func pauseOnPlayerDismiss() {
|
||||
if !playingInPictureInPicture, !presentingPlayer {
|
||||
func saveTime(completionHandler: @escaping () -> Void = {}) {
|
||||
guard let currentTime = backend.currentTime, currentTime.seconds > 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.preservedTime = currentTime
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
|
||||
func upgradeToStream(_ stream: Stream, force: Bool = false) {
|
||||
if !self.stream.isNil, force || self.stream != stream {
|
||||
playStream(stream, of: currentVideo!, preservingTime: true, upgrading: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleAvailableStreamsChange() {
|
||||
rebuildTVMenu()
|
||||
|
||||
guard stream.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let stream = preferredStream(availableStreams) else {
|
||||
return
|
||||
}
|
||||
|
||||
streamSelection = stream
|
||||
playStream(
|
||||
stream,
|
||||
of: currentVideo!,
|
||||
preservingTime: !currentItem.playbackTime.isNil
|
||||
)
|
||||
}
|
||||
|
||||
private func handlePresentationChange() {
|
||||
backend.setNeedsDrawing(presentingPlayer)
|
||||
controls.hide()
|
||||
|
||||
if presentingPlayer, closePiPOnOpeningPlayer, playingInPictureInPicture {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
self?.closePiP()
|
||||
}
|
||||
}
|
||||
|
||||
if !presentingPlayer, pauseOnHidingPlayer, !playingInPictureInPicture {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
self?.pause()
|
||||
}
|
||||
}
|
||||
|
||||
if !presentingPlayer, !pauseOnHidingPlayer, backend.isPlaying {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
|
||||
self?.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleNavigationViewPlayerPresentationChange() {
|
||||
backend.setNeedsDrawing(playerNavigationLinkActive)
|
||||
controls.hide()
|
||||
|
||||
if pauseOnHidingPlayer, !playingInPictureInPicture, !playerNavigationLinkActive {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
self.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func insertPlayerItem(
|
||||
_ stream: Stream,
|
||||
for video: Video,
|
||||
preservingTime: Bool = false
|
||||
) {
|
||||
let playerItem = playerItem(stream)
|
||||
guard playerItem != nil else {
|
||||
func changeActiveBackend(from: PlayerBackendType, to: PlayerBackendType) {
|
||||
Defaults[.activeBackend] = to
|
||||
self.activeBackend = to
|
||||
|
||||
guard var stream = stream else {
|
||||
return
|
||||
}
|
||||
|
||||
attachMetadata(to: playerItem!, video: video, for: stream)
|
||||
inactiveBackends().forEach { $0.pause() }
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
let fromBackend: PlayerBackend = from == .appleAVPlayer ? avPlayerBackend : mpvBackend
|
||||
let toBackend: PlayerBackend = to == .appleAVPlayer ? avPlayerBackend : mpvBackend
|
||||
|
||||
self.stream = stream
|
||||
self.composition = AVMutableComposition()
|
||||
}
|
||||
|
||||
let startPlaying = {
|
||||
#if !os(macOS)
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
#endif
|
||||
|
||||
if self.isAutoplaying(playerItem!) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
|
||||
self?.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let replaceItemAndSeek = {
|
||||
self.player.replaceCurrentItem(with: playerItem)
|
||||
self.seekToSavedTime { 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.savedTime = nil
|
||||
|
||||
startPlaying()
|
||||
toBackend.play()
|
||||
}
|
||||
|
||||
self.stream = stream
|
||||
streamSelection = stream
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if preservingTime {
|
||||
if savedTime.isNil {
|
||||
saveTime {
|
||||
replaceItemAndSeek()
|
||||
startPlaying()
|
||||
if !backend.canPlay(stream) {
|
||||
guard let preferredStream = preferredStream(availableStreams) else {
|
||||
return
|
||||
}
|
||||
|
||||
stream = preferredStream
|
||||
streamSelection = preferredStream
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
self?.upgradeToStream(stream, force: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func inactiveBackends() -> [PlayerBackend] {
|
||||
[activeBackend == PlayerBackendType.mpv ? avPlayerBackend : mpvBackend]
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
} else {
|
||||
replaceItemAndSeek()
|
||||
startPlaying()
|
||||
}
|
||||
} else {
|
||||
player.replaceCurrentItem(with: playerItem)
|
||||
startPlaying()
|
||||
}
|
||||
}
|
||||
|
||||
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: ["playable"]) { [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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func playerItem(_ stream: Stream) -> AVPlayerItem? {
|
||||
if let url = stream.singleAssetURL {
|
||||
return AVPlayerItem(asset: AVURLAsset(url: url))
|
||||
} else {
|
||||
return AVPlayerItem(asset: composition)
|
||||
@discardableResult func restoreLoadedChannel() -> Bool {
|
||||
if !currentVideo.isNil, channelWithDetails?.id == currentVideo!.channel.id {
|
||||
currentItem.video.channel = channelWithDetails!
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
func rateLabel(_ rate: Float) -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.minimumFractionDigits = 0
|
||||
formatter.maximumFractionDigits = 2
|
||||
|
||||
item.externalMetadata = externalMetadata
|
||||
return "\(formatter.string(from: NSNumber(value: rate))!)×"
|
||||
}
|
||||
|
||||
func closeCurrentItem() {
|
||||
prepareCurrentItemForHistory()
|
||||
currentItem = nil
|
||||
|
||||
backend.closeItem()
|
||||
}
|
||||
|
||||
func closePiP() {
|
||||
guard playingInPictureInPicture else {
|
||||
return
|
||||
}
|
||||
|
||||
let wasPlaying = isPlaying
|
||||
pause()
|
||||
|
||||
#if os(tvOS)
|
||||
show()
|
||||
#endif
|
||||
|
||||
item.preferredForwardBufferDuration = 5
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
backend.closePiP(wasPlaying: wasPlaying)
|
||||
}
|
||||
|
||||
#if !os(macOS)
|
||||
private func makeMetadataItem(_ identifier: AVMetadataIdentifier, value: Any) -> AVMetadataItem {
|
||||
let item = AVMutableMetadataItem()
|
||||
func handleCurrentItemChange() {
|
||||
#if os(macOS)
|
||||
Windows.player.window?.title = windowTitle
|
||||
#endif
|
||||
|
||||
item.identifier = identifier
|
||||
item.value = value as? NSCopying & NSObjectProtocol
|
||||
item.extendedLanguageTag = "und"
|
||||
Defaults[.lastPlayed] = currentItem
|
||||
}
|
||||
|
||||
return item.copy() as! AVMetadataItem
|
||||
#if os(macOS)
|
||||
var windowTitle: String {
|
||||
currentVideo.isNil ? "Not playing" : "\(currentVideo!.title) - \(currentVideo!.author)"
|
||||
}
|
||||
#else
|
||||
func handleEnterForeground() {
|
||||
guard closePiPAndOpenPlayerOnEnteringForeground, playingInPictureInPicture else {
|
||||
return
|
||||
}
|
||||
|
||||
show()
|
||||
closePiP()
|
||||
}
|
||||
|
||||
func enterFullScreen() {
|
||||
guard !controls.playingFullscreen else {
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("entering fullscreen")
|
||||
|
||||
backend.enterFullScreen()
|
||||
}
|
||||
|
||||
func exitFullScreen() {
|
||||
guard controls.playingFullscreen else {
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("exiting fullscreen")
|
||||
|
||||
backend.exitFullScreen()
|
||||
}
|
||||
#endif
|
||||
|
||||
private func addItemDidPlayToEndTimeObserver() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(itemDidPlayToEndTime),
|
||||
name: NSNotification.Name.AVPlayerItemDidPlayToEndTime,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
@objc func itemDidPlayToEndTime() {
|
||||
currentItem.playbackTime = playerItemDuration
|
||||
|
||||
if queue.isEmpty {
|
||||
#if !os(macOS)
|
||||
try? AVAudioSession.sharedInstance().setActive(false)
|
||||
#endif
|
||||
addCurrentItemToHistory()
|
||||
resetQueue()
|
||||
#if os(tvOS)
|
||||
avPlayerViewController!.dismiss(animated: true) { [weak self] in
|
||||
self?.controller!.dismiss(animated: true)
|
||||
}
|
||||
#endif
|
||||
presentingPlayer = false
|
||||
} else {
|
||||
advanceToNextItem()
|
||||
}
|
||||
}
|
||||
|
||||
private func saveTime(completionHandler: @escaping () -> Void = {}) {
|
||||
let currentTime = player.currentTime()
|
||||
|
||||
guard currentTime.seconds > 0 else {
|
||||
func updateNowPlayingInfo() {
|
||||
guard let video = currentItem?.video else {
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.savedTime = currentTime
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
|
||||
private func seekToSavedTime(completionHandler: @escaping (Bool) -> Void = { _ in }) {
|
||||
guard let time = savedTime 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.updateCurrentItemIntervals()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.updateCurrentItemIntervals()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateCurrentItemIntervals() {
|
||||
currentItem?.playbackTime = player.currentTime()
|
||||
currentItem?.videoDuration = player.currentItem?.asset.duration.seconds
|
||||
}
|
||||
|
||||
fileprivate func updateNowPlayingInfo() {
|
||||
let duration: Int? = currentItem.video.live ? nil : Int(currentItem.videoDuration ?? 0)
|
||||
let currentTime = (backend.currentTime?.seconds.isFinite ?? false) ? backend.currentTime!.seconds : 0
|
||||
var nowPlayingInfo: [String: AnyObject] = [
|
||||
MPMediaItemPropertyTitle: currentItem.video.title as AnyObject,
|
||||
MPMediaItemPropertyArtist: currentItem.video.author as AnyObject,
|
||||
MPMediaItemPropertyPlaybackDuration: duration as AnyObject,
|
||||
MPNowPlayingInfoPropertyIsLiveStream: currentItem.video.live as AnyObject,
|
||||
MPNowPlayingInfoPropertyElapsedPlaybackTime: player.currentTime().seconds as 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
|
||||
]
|
||||
|
||||
@@ -495,11 +490,22 @@ final class PlayerModel: ObservableObject {
|
||||
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
|
||||
}
|
||||
|
||||
private func updateCurrentArtwork() {
|
||||
guard let thumbnailData = try? Data(contentsOf: currentItem.video.thumbnailURL(quality: .medium)!) else {
|
||||
func updateCurrentArtwork() {
|
||||
guard let video = currentVideo,
|
||||
let thumbnailData = try? Data(contentsOf: video.thumbnailURL(quality: .medium)!)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -515,18 +521,4 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
currentArtwork = MPMediaItemArtwork(boundsSize: image!.size) { _ in image! }
|
||||
}
|
||||
|
||||
func rateLabel(_ rate: Float) -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.minimumFractionDigits = 0
|
||||
formatter.maximumFractionDigits = 2
|
||||
|
||||
return "\(formatter.string(from: NSNumber(value: rate))!)×"
|
||||
}
|
||||
|
||||
func closeCurrentItem() {
|
||||
addCurrentItemToHistory()
|
||||
currentItem = nil
|
||||
player.replaceCurrentItem(with: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import AVFoundation
|
||||
import AVKit
|
||||
import Defaults
|
||||
import Foundation
|
||||
import Siesta
|
||||
@@ -8,16 +8,30 @@ extension PlayerModel {
|
||||
currentItem?.video
|
||||
}
|
||||
|
||||
func playAll(_ videos: [Video]) {
|
||||
let first = videos.first
|
||||
func play(_ videos: [Video], shuffling: Bool = false, inNavigationView: Bool = false) {
|
||||
let videosToPlay = shuffling ? videos.shuffled() : videos
|
||||
|
||||
videos.forEach { video in
|
||||
enqueueVideo(video) { _, item in
|
||||
guard let first = videosToPlay.first else {
|
||||
return
|
||||
}
|
||||
|
||||
enqueueVideo(first, prepending: true) { _, item in
|
||||
self.advanceToItem(item)
|
||||
}
|
||||
|
||||
videosToPlay.dropFirst().reversed().forEach { video in
|
||||
enqueueVideo(video, prepending: true) { _, item in
|
||||
if item.video == first {
|
||||
self.advanceToItem(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if inNavigationView {
|
||||
playerNavigationLinkActive = true
|
||||
} else {
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
func playNext(_ video: Video) {
|
||||
@@ -29,8 +43,11 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
func playNow(_ video: Video, at time: TimeInterval? = nil) {
|
||||
player.replaceCurrentItem(with: nil)
|
||||
addCurrentItemToHistory()
|
||||
if playingInPictureInPicture, closePiPOnNavigation {
|
||||
closePiP()
|
||||
}
|
||||
|
||||
prepareCurrentItemForHistory()
|
||||
|
||||
enqueueVideo(video, prepending: true) { _, item in
|
||||
self.advanceToItem(item, at: time)
|
||||
@@ -38,7 +55,12 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
func playItem(_ item: PlayerQueueItem, video: Video? = nil, at time: TimeInterval? = nil) {
|
||||
if !playingInPictureInPicture {
|
||||
backend.closeItem()
|
||||
}
|
||||
|
||||
comments.reset()
|
||||
stream = nil
|
||||
currentItem = item
|
||||
|
||||
if !time.isNil {
|
||||
@@ -51,23 +73,19 @@ extension PlayerModel {
|
||||
currentItem.video = video!
|
||||
}
|
||||
|
||||
savedTime = currentItem.playbackTime
|
||||
preservedTime = currentItem.playbackTime
|
||||
restoreLoadedChannel()
|
||||
|
||||
loadAvailableStreams(currentVideo!) { streams in
|
||||
guard let stream = self.preferredStream(streams) else {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let video = self?.currentVideo else {
|
||||
return
|
||||
}
|
||||
|
||||
self.streamSelection = stream
|
||||
self.playStream(
|
||||
stream,
|
||||
of: self.currentVideo!,
|
||||
preservingTime: !self.currentItem.playbackTime.isNil
|
||||
)
|
||||
self?.loadAvailableStreams(video)
|
||||
}
|
||||
}
|
||||
|
||||
private func preferredStream(_ streams: [Stream]) -> Stream? {
|
||||
func preferredStream(_ streams: [Stream]) -> Stream? {
|
||||
let quality = Defaults[.quality]
|
||||
var streams = streams
|
||||
|
||||
@@ -75,17 +93,19 @@ extension PlayerModel {
|
||||
streams = streams.filter { $0.instance.id == id }
|
||||
}
|
||||
|
||||
streams = streams.filter { backend.canPlay($0) }
|
||||
|
||||
switch quality {
|
||||
case .best:
|
||||
return streams.first { $0.kind == .hls } ?? streams.first
|
||||
return backend.bestPlayable(streams)
|
||||
default:
|
||||
let sorted = streams.filter { $0.kind != .hls }.sorted { $0.resolution > $1.resolution }
|
||||
let sorted = streams.filter { $0.kind != .hls }.sorted { $0.resolution > $1.resolution }.sorted { $0.kind < $1.kind }
|
||||
return sorted.first(where: { $0.resolution.height <= quality.value.height })
|
||||
}
|
||||
}
|
||||
|
||||
func advanceToNextItem() {
|
||||
addCurrentItemToHistory()
|
||||
prepareCurrentItemForHistory()
|
||||
|
||||
if let nextItem = queue.first {
|
||||
advanceToItem(nextItem)
|
||||
@@ -93,11 +113,13 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
func advanceToItem(_ newItem: PlayerQueueItem, at time: TimeInterval? = nil) {
|
||||
player.replaceCurrentItem(with: nil)
|
||||
addCurrentItemToHistory()
|
||||
prepareCurrentItemForHistory()
|
||||
|
||||
remove(newItem)
|
||||
|
||||
currentItem = newItem
|
||||
pause()
|
||||
|
||||
accounts.api.loadDetails(newItem) { newItem in
|
||||
self.playItem(newItem, video: newItem.video, at: time)
|
||||
}
|
||||
@@ -122,11 +144,7 @@ extension PlayerModel {
|
||||
self.removeQueueItems()
|
||||
}
|
||||
|
||||
player.replaceCurrentItem(with: nil)
|
||||
}
|
||||
|
||||
func isAutoplaying(_ item: AVPlayerItem) -> Bool {
|
||||
player.currentItem == item && presentingPlayer
|
||||
backend.closeItem()
|
||||
}
|
||||
|
||||
@discardableResult func enqueueVideo(
|
||||
@@ -138,6 +156,12 @@ extension PlayerModel {
|
||||
) -> PlayerQueueItem? {
|
||||
let item = PlayerQueueItem(video, playbackTime: atTime)
|
||||
|
||||
if play {
|
||||
currentItem = item
|
||||
// pause playing current video as it's going to be replaced with next one
|
||||
pause()
|
||||
}
|
||||
|
||||
queue.insert(item, at: prepending ? 0 : queue.endIndex)
|
||||
|
||||
accounts.api.loadDetails(item) { newItem in
|
||||
@@ -151,20 +175,15 @@ extension PlayerModel {
|
||||
return item
|
||||
}
|
||||
|
||||
func addCurrentItemToHistory() {
|
||||
if let item = currentItem, Defaults[.saveHistory] {
|
||||
addItemToHistory(item)
|
||||
func prepareCurrentItemForHistory(finished: Bool = false) {
|
||||
if !currentItem.isNil, Defaults[.saveHistory] {
|
||||
if let video = currentVideo, !historyVideos.contains(where: { $0 == video }) {
|
||||
historyVideos.append(video)
|
||||
}
|
||||
updateWatch(finished: finished)
|
||||
}
|
||||
}
|
||||
|
||||
func addItemToHistory(_ item: PlayerQueueItem) {
|
||||
if let index = history.firstIndex(where: { $0.video?.videoID == item.video?.videoID }) {
|
||||
history.remove(at: index)
|
||||
}
|
||||
|
||||
history.insert(currentItem, at: 0)
|
||||
}
|
||||
|
||||
func playHistory(_ item: PlayerQueueItem) {
|
||||
var time = item.playbackTime
|
||||
|
||||
@@ -175,34 +194,20 @@ extension PlayerModel {
|
||||
let newItem = enqueueVideo(item.video, atTime: time, prepending: true)
|
||||
|
||||
advanceToItem(newItem!)
|
||||
|
||||
if let historyItemIndex = history.firstIndex(of: item) {
|
||||
history.remove(at: historyItemIndex)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult func removeHistory(_ item: PlayerQueueItem) -> PlayerQueueItem? {
|
||||
if let index = history.firstIndex(where: { $0 == item }) {
|
||||
return history.remove(at: index)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeQueueItems() {
|
||||
queue.removeAll()
|
||||
}
|
||||
|
||||
func removeHistoryItems() {
|
||||
history.removeAll()
|
||||
}
|
||||
|
||||
func loadHistoryDetails() {
|
||||
func restoreQueue() {
|
||||
guard !accounts.current.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
queue = Defaults[.queue]
|
||||
queue = ([Defaults[.lastPlayed]] + Defaults[.queue]).compactMap { $0 }
|
||||
Defaults[.lastPlayed] = nil
|
||||
|
||||
queue.forEach { item in
|
||||
accounts.api.loadDetails(item) { newItem in
|
||||
if let index = self.queue.firstIndex(where: { $0.id == item.id }) {
|
||||
@@ -210,32 +215,5 @@ extension PlayerModel {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var savedHistory = Defaults[.history]
|
||||
|
||||
if let lastPlayed = Defaults[.lastPlayed] {
|
||||
if let index = savedHistory.firstIndex(where: { $0.videoID == lastPlayed.videoID }) {
|
||||
var updatedLastPlayed = savedHistory[index]
|
||||
|
||||
updatedLastPlayed.playbackTime = lastPlayed.playbackTime
|
||||
updatedLastPlayed.videoDuration = lastPlayed.videoDuration
|
||||
|
||||
savedHistory.remove(at: index)
|
||||
savedHistory.insert(updatedLastPlayed, at: 0)
|
||||
} else {
|
||||
savedHistory.insert(lastPlayed, at: 0)
|
||||
}
|
||||
|
||||
Defaults[.lastPlayed] = nil
|
||||
}
|
||||
|
||||
history = savedHistory
|
||||
history.forEach { item in
|
||||
accounts.api.loadDetails(item) { newItem in
|
||||
if let index = self.history.firstIndex(where: { $0.id == item.id }) {
|
||||
self.history[index] = newItem
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,15 @@ struct PlayerQueueItem: Hashable, Identifiable, Defaults.Serializable {
|
||||
var playbackTime: CMTime?
|
||||
var videoDuration: TimeInterval?
|
||||
|
||||
static func from(_ watch: Watch, video: Video? = nil) -> Self {
|
||||
.init(
|
||||
video,
|
||||
videoID: watch.videoID,
|
||||
playbackTime: CMTime.secondsInDefaultTimescale(watch.stoppedAt),
|
||||
videoDuration: watch.videoDuration
|
||||
)
|
||||
}
|
||||
|
||||
init(_ video: Video? = nil, videoID: Video.ID? = nil, playbackTime: CMTime? = nil, videoDuration: TimeInterval? = nil) {
|
||||
self.video = video
|
||||
self.videoID = videoID ?? video!.videoID
|
||||
@@ -19,7 +28,7 @@ struct PlayerQueueItem: Hashable, Identifiable, Defaults.Serializable {
|
||||
}
|
||||
|
||||
var duration: TimeInterval {
|
||||
videoDuration ?? video.length
|
||||
videoDuration ?? video?.length ?? .zero
|
||||
}
|
||||
|
||||
var shouldRestartPlaying: Bool {
|
||||
|
||||
@@ -38,9 +38,12 @@ 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)")
|
||||
}
|
||||
|
||||
@@ -63,13 +66,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() {
|
||||
|
||||
@@ -15,21 +15,20 @@ extension PlayerModel {
|
||||
availableStreams.sorted(by: streamsSorter)
|
||||
}
|
||||
|
||||
func loadAvailableStreams(
|
||||
_ video: Video,
|
||||
completionHandler: @escaping ([Stream]) -> Void = { _ in }
|
||||
) {
|
||||
func loadAvailableStreams(_ video: Video) {
|
||||
availableStreams = []
|
||||
var instancesWithLoadedStreams = [Instance]()
|
||||
let playerInstance = InstancesModel.forPlayer ?? InstancesModel.all.first
|
||||
|
||||
InstancesModel.all.forEach { instance in
|
||||
fetchStreams(instance.anonymous.video(video.videoID), instance: instance, video: video) { _ in
|
||||
self.completeIfAllInstancesLoaded(
|
||||
instance: instance,
|
||||
streams: self.availableStreams,
|
||||
instancesWithLoadedStreams: &instancesWithLoadedStreams,
|
||||
completionHandler: completionHandler
|
||||
)
|
||||
guard !playerInstance.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("loading streams from \(playerInstance!.description)")
|
||||
|
||||
fetchStreams(playerInstance!.anonymous.video(video.videoID), instance: playerInstance!, video: video) { _ in
|
||||
InstancesModel.all.filter { $0 != playerInstance }.forEach { instance in
|
||||
self.logger.info("loading streams from \(instance.description)")
|
||||
self.fetchStreams(instance.anonymous.video(video.videoID), instance: instance, video: video)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,26 +43,18 @@ extension PlayerModel {
|
||||
.load()
|
||||
.onSuccess { response in
|
||||
if let video: Video = response.typedContent() {
|
||||
guard video == self.currentVideo else {
|
||||
self.logger.info("ignoring loaded streams from \(instance.description) as current video has changed")
|
||||
return
|
||||
}
|
||||
self.availableStreams += self.streamsWithInstance(instance: instance, streams: video.streams)
|
||||
} else {
|
||||
self.logger.critical("no streams available from \(instance.description)")
|
||||
}
|
||||
}
|
||||
.onCompletion(onCompletion)
|
||||
}
|
||||
|
||||
private func completeIfAllInstancesLoaded(
|
||||
instance: Instance,
|
||||
streams: [Stream],
|
||||
instancesWithLoadedStreams: inout [Instance],
|
||||
completionHandler: @escaping ([Stream]) -> Void
|
||||
) {
|
||||
instancesWithLoadedStreams.append(instance)
|
||||
rebuildTVMenu()
|
||||
|
||||
if InstancesModel.all.count == instancesWithLoadedStreams.count {
|
||||
completionHandler(streams.sorted { $0.kind < $1.kind })
|
||||
}
|
||||
}
|
||||
|
||||
func streamsWithInstance(instance: Instance, streams: [Stream]) -> [Stream] {
|
||||
streams.map { stream in
|
||||
stream.instance = instance
|
||||
|
||||
@@ -66,7 +66,7 @@ extension PlayerModel {
|
||||
|
||||
func rebuildTVMenu() {
|
||||
#if os(tvOS)
|
||||
avPlayerViewController?.transportBarCustomMenuItems = [
|
||||
controller?.playerView.transportBarCustomMenuItems = [
|
||||
restoreLastSkippedSegmentAction,
|
||||
rateMenu,
|
||||
streamsMenu
|
||||
|
||||
@@ -22,11 +22,12 @@ struct Playlist: Identifiable, Equatable, Hashable {
|
||||
|
||||
var videos = [Video]()
|
||||
|
||||
init(id: String, title: String, visibility: Visibility, updated: TimeInterval) {
|
||||
init(id: String, title: String, visibility: Visibility, updated: TimeInterval, videos: [Video] = []) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.visibility = visibility
|
||||
self.updated = updated
|
||||
self.videos = videos
|
||||
}
|
||||
|
||||
init(_ json: JSON) {
|
||||
@@ -34,7 +35,6 @@ struct Playlist: Identifiable, Equatable, Hashable {
|
||||
title = json["title"].stringValue
|
||||
visibility = json["isListed"].boolValue ? .public : .private
|
||||
updated = json["updated"].doubleValue
|
||||
videos = json["videos"].arrayValue.map { InvidiousAPI.extractVideo(from: $0) }
|
||||
}
|
||||
|
||||
static func == (lhs: Playlist, rhs: Playlist) -> Bool {
|
||||
|
||||
@@ -28,7 +28,7 @@ final class PlaylistsModel: ObservableObject {
|
||||
}
|
||||
|
||||
func load(force: Bool = false, onSuccess: @escaping () -> Void = {}) {
|
||||
guard !resource.isNil else {
|
||||
guard accounts.app.supportsUserPlaylists, accounts.signedIn else {
|
||||
playlists = []
|
||||
return
|
||||
}
|
||||
@@ -52,14 +52,22 @@ final class PlaylistsModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func addVideo(playlistID: Playlist.ID, videoID: Video.ID, onSuccess: @escaping () -> Void = {}) {
|
||||
func addVideo(
|
||||
playlistID: Playlist.ID,
|
||||
videoID: Video.ID,
|
||||
onSuccess: @escaping () -> Void = {},
|
||||
onFailure: @escaping (RequestError) -> Void = { _ in }
|
||||
) {
|
||||
let resource = accounts.api.playlistVideos(playlistID)
|
||||
let body = ["videoId": videoID]
|
||||
|
||||
resource?.request(.post, json: body).onSuccess { _ in
|
||||
self.load(force: true)
|
||||
onSuccess()
|
||||
}
|
||||
resource?
|
||||
.request(.post, json: body)
|
||||
.onSuccess { _ in
|
||||
self.load(force: true)
|
||||
onSuccess()
|
||||
}
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func removeVideo(videoIndexID: String, playlistID: Playlist.ID, onSuccess: @escaping () -> Void = {}) {
|
||||
|
||||
@@ -16,7 +16,7 @@ final class RecentsModel: ObservableObject {
|
||||
if !saveRecents {
|
||||
clear()
|
||||
|
||||
if item.type != .channel {
|
||||
if item.type == .query {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,9 @@ final class RecentsModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func addQuery(_ query: String) {
|
||||
func addQuery(_ query: String, navigation: NavigationModel? = nil) {
|
||||
if !query.isEmpty {
|
||||
navigation?.tabSelection = .search
|
||||
add(.init(from: query))
|
||||
}
|
||||
}
|
||||
@@ -55,6 +56,15 @@ final class RecentsModel: ObservableObject {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
static func symbolSystemImage(_ name: String) -> String {
|
||||
let firstLetter = name.first?.lowercased()
|
||||
let regex = #"^[a-z0-9]$"#
|
||||
|
||||
let symbolName = firstLetter?.range(of: regex, options: .regularExpression) != nil ? firstLetter! : "questionmark"
|
||||
|
||||
return "\(symbolName).circle"
|
||||
}
|
||||
}
|
||||
|
||||
struct RecentItem: Defaults.Serializable, Identifiable {
|
||||
|
||||
48
Model/ReturnYouTubeDislike/ReturnYouTubeDislikeAPI.swift
Normal file
48
Model/ReturnYouTubeDislike/ReturnYouTubeDislikeAPI.swift
Normal file
@@ -0,0 +1,48 @@
|
||||
import Alamofire
|
||||
import Defaults
|
||||
import Foundation
|
||||
import Logging
|
||||
import SwiftyJSON
|
||||
|
||||
final class ReturnYouTubeDislikeAPI: ObservableObject {
|
||||
let logger = Logger(label: "stream.yattee.app.rytd")
|
||||
|
||||
@Published var videoID: String?
|
||||
@Published var dislikes = -1
|
||||
|
||||
func loadDislikes(videoID: String, completionHandler: @escaping (Int) -> Void = { _ in }) {
|
||||
guard self.videoID != videoID else {
|
||||
completionHandler(dislikes)
|
||||
return
|
||||
}
|
||||
|
||||
self.videoID = videoID
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.requestDislikes(completionHandler: completionHandler)
|
||||
}
|
||||
}
|
||||
|
||||
private func requestDislikes(completionHandler: @escaping (Int) -> Void = { _ in }) {
|
||||
AF.request(votesURL).responseDecodable(of: JSON.self) { [weak self] response in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
switch response.result {
|
||||
case let .success(value):
|
||||
let value = JSON(value).dictionaryValue["dislikes"]?.int
|
||||
self.dislikes = value ?? -1
|
||||
|
||||
case let .failure(error):
|
||||
self.logger.error("failed to load dislikes: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
completionHandler(self.dislikes)
|
||||
}
|
||||
}
|
||||
|
||||
private var votesURL: String {
|
||||
"https://returnyoutubedislikeapi.com/Votes?videoId=\(videoID ?? "")"
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import SwiftUI
|
||||
|
||||
final class SearchModel: ObservableObject {
|
||||
@Published var store = Store<[ContentItem]>()
|
||||
@Published var page: SearchPage?
|
||||
|
||||
var accounts = AccountsModel()
|
||||
@Published var query = SearchQuery()
|
||||
@@ -13,7 +14,6 @@ final class SearchModel: ObservableObject {
|
||||
|
||||
@Published var fieldIsFocused = false
|
||||
|
||||
private var previousResource: Resource?
|
||||
private var resource: Resource!
|
||||
|
||||
var isLoading: Bool {
|
||||
@@ -23,60 +23,54 @@ final class SearchModel: ObservableObject {
|
||||
func changeQuery(_ changeHandler: @escaping (SearchQuery) -> Void = { _ in }) {
|
||||
changeHandler(query)
|
||||
|
||||
let newResource = accounts.api.search(query)
|
||||
guard newResource != previousResource else {
|
||||
let newResource = accounts.api.search(query, page: nil)
|
||||
guard newResource != resource else {
|
||||
return
|
||||
}
|
||||
|
||||
previousResource?.removeObservers(ownedBy: store)
|
||||
previousResource = newResource
|
||||
page = nil
|
||||
|
||||
resource = newResource
|
||||
resource.addObserver(store)
|
||||
|
||||
if !query.isEmpty {
|
||||
loadResourceIfNeededAndReplaceStore()
|
||||
loadResource()
|
||||
}
|
||||
}
|
||||
|
||||
func resetQuery(_ query: SearchQuery = SearchQuery()) {
|
||||
self.query = query
|
||||
|
||||
let newResource = accounts.api.search(query)
|
||||
guard newResource != previousResource else {
|
||||
let newResource = accounts.api.search(query, page: nil)
|
||||
guard newResource != resource else {
|
||||
return
|
||||
}
|
||||
|
||||
page = nil
|
||||
store.replace([])
|
||||
|
||||
previousResource?.removeObservers(ownedBy: store)
|
||||
previousResource = newResource
|
||||
|
||||
resource = newResource
|
||||
resource.addObserver(store)
|
||||
|
||||
if !query.isEmpty {
|
||||
loadResourceIfNeededAndReplaceStore()
|
||||
loadResource()
|
||||
}
|
||||
}
|
||||
|
||||
func loadResourceIfNeededAndReplaceStore() {
|
||||
func loadResource() {
|
||||
let currentResource = resource!
|
||||
|
||||
if let request = resource.loadIfNeeded() {
|
||||
request.onSuccess { response in
|
||||
if let results: [ContentItem] = response.typedContent() {
|
||||
self.replace(results, for: currentResource)
|
||||
}
|
||||
resource.load().onSuccess { response in
|
||||
if let page: SearchPage = response.typedContent() {
|
||||
self.page = page
|
||||
self.replace(page.results, for: currentResource)
|
||||
}
|
||||
} else {
|
||||
replace(store.collection, for: currentResource)
|
||||
}
|
||||
}
|
||||
|
||||
func replace(_ videos: [ContentItem], for resource: Resource) {
|
||||
func replace(_ items: [ContentItem], for resource: Resource) {
|
||||
if self.resource == resource {
|
||||
store = Store<[ContentItem]>(videos)
|
||||
store = Store<[ContentItem]>(items)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +78,7 @@ final class SearchModel: ObservableObject {
|
||||
|
||||
func loadSuggestions(_ query: String) {
|
||||
guard !query.isEmpty else {
|
||||
querySuggestions.replace([])
|
||||
return
|
||||
}
|
||||
|
||||
@@ -108,4 +103,38 @@ final class SearchModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadNextPage() {
|
||||
guard var pageToLoad = page, !pageToLoad.last else {
|
||||
return
|
||||
}
|
||||
|
||||
if pageToLoad.nextPage.isNil, accounts.app.searchUsesIndexedPages {
|
||||
pageToLoad.nextPage = "2"
|
||||
}
|
||||
|
||||
resource?.removeObservers(ownedBy: store)
|
||||
|
||||
resource = accounts.api.search(query, page: page?.nextPage)
|
||||
resource.addObserver(store)
|
||||
|
||||
resource
|
||||
.load()
|
||||
.onSuccess { response in
|
||||
if let page: SearchPage = response.typedContent() {
|
||||
var nextPage: Int?
|
||||
if self.accounts.app.searchUsesIndexedPages {
|
||||
nextPage = Int(pageToLoad.nextPage ?? "0")
|
||||
}
|
||||
|
||||
self.page = page
|
||||
|
||||
if self.accounts.app.searchUsesIndexedPages {
|
||||
self.page?.nextPage = String((nextPage ?? 1) + 1)
|
||||
}
|
||||
|
||||
self.replace(self.store.collection + page.results, for: self.resource)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
7
Model/Search/SearchPage.swift
Normal file
7
Model/Search/SearchPage.swift
Normal file
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
struct SearchPage {
|
||||
var results = [ContentItem]()
|
||||
var nextPage: String?
|
||||
var last = false
|
||||
}
|
||||
@@ -61,11 +61,8 @@ final class SearchQuery: ObservableObject {
|
||||
@Published var date: SearchQuery.Date? = .month
|
||||
@Published var duration: SearchQuery.Duration?
|
||||
|
||||
@Published var page = 1
|
||||
|
||||
init(query: String = "", page: Int = 1, sortBy: SearchQuery.SortOrder = .relevance, date: SearchQuery.Date? = nil, duration: SearchQuery.Duration? = nil) {
|
||||
init(query: String = "", sortBy: SearchQuery.SortOrder = .relevance, date: SearchQuery.Date? = nil, duration: SearchQuery.Duration? = nil) {
|
||||
self.query = query
|
||||
self.page = page
|
||||
self.sortBy = sortBy
|
||||
self.date = date
|
||||
self.duration = duration
|
||||
|
||||
@@ -17,6 +17,10 @@ class Segment: ObservableObject, Hashable {
|
||||
segment.last!
|
||||
}
|
||||
|
||||
var duration: Double {
|
||||
end - start
|
||||
}
|
||||
|
||||
var endTime: CMTime {
|
||||
CMTime(seconds: end, preferredTimescale: 1000)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import SwiftyJSON
|
||||
final class SponsorBlockAPI: ObservableObject {
|
||||
static let categories = ["sponsor", "selfpromo", "intro", "outro", "interaction", "music_offtopic"]
|
||||
|
||||
let logger = Logger(label: "net.yattee.app.sb")
|
||||
let logger = Logger(label: "stream.yattee.app.sb")
|
||||
|
||||
@Published var videoID: String?
|
||||
@Published var segments = [Segment]()
|
||||
@@ -20,29 +20,70 @@ final class SponsorBlockAPI: ObservableObject {
|
||||
switch name {
|
||||
case "selfpromo":
|
||||
return "Self-promotion"
|
||||
|
||||
case "music_offtopic":
|
||||
return "Offtopic in Music Videos"
|
||||
|
||||
default:
|
||||
return name.capitalized
|
||||
}
|
||||
}
|
||||
|
||||
func loadSegments(videoID: String, categories: Set<String>) {
|
||||
static func categoryDetails(_ name: String) -> String? {
|
||||
guard SponsorBlockAPI.categories.contains(name) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "sponsor":
|
||||
return "Part of a video promoting a product or service not directly related to the creator. " +
|
||||
"The creator will receive payment or compensation in the form of money or free products."
|
||||
|
||||
case "selfpromo":
|
||||
return "Promoting a product or service that is directly related to the creator themselves. " +
|
||||
"This usually includes merchandise or promotion of monetized platforms."
|
||||
|
||||
case "intro":
|
||||
return "Segments typically found at the start of a video that include an animation, " +
|
||||
"still frame or clip which are also seen in other videos by the same creator."
|
||||
|
||||
case "outro":
|
||||
return "Typically near or at the end of the video when the credits pop up and/or endcards are shown."
|
||||
|
||||
case "interaction":
|
||||
return "Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)."
|
||||
|
||||
case "music_offtopic":
|
||||
return "For videos which feature music as the primary content."
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func loadSegments(videoID: String, categories: Set<String>, completionHandler: @escaping () -> Void = {}) {
|
||||
guard !skipSegmentsURL.isNil, self.videoID != videoID else {
|
||||
completionHandler()
|
||||
return
|
||||
}
|
||||
|
||||
self.videoID = videoID
|
||||
|
||||
requestSegments(categories: categories)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.requestSegments(categories: categories, completionHandler: completionHandler)
|
||||
}
|
||||
}
|
||||
|
||||
private func requestSegments(categories: Set<String>) {
|
||||
private func requestSegments(categories: Set<String>, completionHandler: @escaping () -> Void = {}) {
|
||||
guard let url = skipSegmentsURL, !categories.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
AF.request(url, parameters: parameters(categories: categories)).responseJSON { response in
|
||||
AF.request(url, parameters: parameters(categories: categories)).responseDecodable(of: JSON.self) { [weak self] response in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
switch response.result {
|
||||
case let .success(value):
|
||||
self.segments = JSON(value).arrayValue.map(SponsorBlockSegment.init).sorted { $0.end < $1.end }
|
||||
@@ -56,6 +97,8 @@ final class SponsorBlockAPI: ObservableObject {
|
||||
|
||||
self.logger.error("failed to load SponsorBlock segments: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 hd2160p, hd1440p60, hd1440p, hd1080p60, hd1080p, hd720p60, hd720p, sd480p, sd360p, sd240p, sd144p, unknown
|
||||
|
||||
var name: String {
|
||||
"\(height)p\(refreshRate != -1 ? ", \(refreshRate) fps" : "")"
|
||||
@@ -68,6 +68,7 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
var kind: Kind!
|
||||
|
||||
var encoding: String!
|
||||
var videoFormat: String!
|
||||
|
||||
init(
|
||||
instance: Instance? = nil,
|
||||
@@ -76,7 +77,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 +87,35 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
self.resolution = resolution
|
||||
self.kind = kind
|
||||
self.encoding = encoding
|
||||
self.videoFormat = 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 format: String {
|
||||
let lowercasedFormat = (videoFormat ?? "unknown").lowercased()
|
||||
if lowercasedFormat.contains("webm") {
|
||||
return "WEBM"
|
||||
} else if lowercasedFormat.contains("avc1") {
|
||||
return "avc1"
|
||||
} else if lowercasedFormat.contains("av01") {
|
||||
return "AV1"
|
||||
} else if lowercasedFormat.contains("mpeg_4") || lowercasedFormat.contains("mp4") {
|
||||
return "MP4"
|
||||
} else {
|
||||
return lowercasedFormat
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
"\(quality) - \(instance?.description ?? "")"
|
||||
let formatString = format == "unknown" ? "" : " (\(format))"
|
||||
return "\(quality)\(formatString) - \(instance?.description ?? "")"
|
||||
}
|
||||
|
||||
var assets: [AVURLAsset] {
|
||||
|
||||
@@ -35,9 +35,11 @@ final class SubscriptionsModel: ObservableObject {
|
||||
}
|
||||
|
||||
func load(force: Bool = false, onSuccess: @escaping () -> Void = {}) {
|
||||
guard accounts.app.supportsSubscriptions else {
|
||||
guard accounts.app.supportsSubscriptions, accounts.signedIn else {
|
||||
channels = []
|
||||
return
|
||||
}
|
||||
|
||||
let request = force ? resource?.load() : resource?.loadIfNeeded()
|
||||
|
||||
request?
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Alamofire
|
||||
import AVKit
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import SwiftyJSON
|
||||
|
||||
struct Video: Identifiable, Equatable, Hashable {
|
||||
@@ -85,7 +86,7 @@ struct Video: Identifiable, Equatable, Hashable {
|
||||
}
|
||||
|
||||
var likesCount: String? {
|
||||
guard likes != -1 else {
|
||||
guard (likes ?? 0) > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -93,7 +94,7 @@ struct Video: Identifiable, Equatable, Hashable {
|
||||
}
|
||||
|
||||
var dislikesCount: String? {
|
||||
guard dislikes != -1 else {
|
||||
guard (dislikes ?? 0) > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -105,10 +106,24 @@ struct Video: Identifiable, Equatable, Hashable {
|
||||
}
|
||||
|
||||
static func == (lhs: Video, rhs: Video) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
let videoIDIsEqual = lhs.videoID == rhs.videoID
|
||||
|
||||
if !lhs.indexID.isNil, !rhs.indexID.isNil {
|
||||
return videoIDIsEqual && lhs.indexID == rhs.indexID
|
||||
}
|
||||
|
||||
return videoIDIsEqual
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
|
||||
var watchFetchRequest: FetchRequest<Watch> {
|
||||
FetchRequest<Watch>(
|
||||
entity: Watch.entity(),
|
||||
sortDescriptors: [],
|
||||
predicate: NSPredicate(format: "videoID = %@", videoID)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
48
Model/Watch.swift
Normal file
48
Model/Watch.swift
Normal file
@@ -0,0 +1,48 @@
|
||||
import CoreData
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
@objc(Watch)
|
||||
final class Watch: NSManagedObject, Identifiable {
|
||||
@Default(.watchedThreshold) private var watchedThreshold
|
||||
}
|
||||
|
||||
extension Watch {
|
||||
@nonobjc class func fetchRequest() -> NSFetchRequest<Watch> {
|
||||
NSFetchRequest<Watch>(entityName: "Watch")
|
||||
}
|
||||
|
||||
@NSManaged var videoID: String
|
||||
@NSManaged var videoDuration: Double
|
||||
|
||||
@NSManaged var watchedAt: Date?
|
||||
@NSManaged var stoppedAt: Double
|
||||
|
||||
var progress: Double {
|
||||
guard videoDuration.isFinite, !videoDuration.isZero else {
|
||||
return 0
|
||||
}
|
||||
|
||||
let progress = (stoppedAt / videoDuration) * 100
|
||||
|
||||
if progress >= Double(watchedThreshold) {
|
||||
return 100
|
||||
}
|
||||
|
||||
return min(max(progress, 0), 100)
|
||||
}
|
||||
|
||||
var finished: Bool {
|
||||
progress >= Double(watchedThreshold)
|
||||
}
|
||||
|
||||
var watchedAtString: String? {
|
||||
guard let watchedAt = watchedAt else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .full
|
||||
return formatter.localizedString(for: watchedAt, relativeTo: Date())
|
||||
}
|
||||
}
|
||||
17
Model/Yattee.xcdatamodeld/Yattee.xcdatamodel/contents
Normal file
17
Model/Yattee.xcdatamodeld/Yattee.xcdatamodel/contents
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="19574" systemVersion="21D5025f" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="1">
|
||||
<entity name="Watch" representedClassName="Watch" syncable="YES">
|
||||
<attribute name="stoppedAt" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||
<attribute name="videoDuration" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||
<attribute name="videoID" attributeType="String"/>
|
||||
<attribute name="watchedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="videoID"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<elements>
|
||||
<element name="Watch" positionX="-63" positionY="-18" width="128" height="89"/>
|
||||
</elements>
|
||||
</model>
|
||||
18
README.md
18
README.md
@@ -1,6 +1,7 @@
|
||||

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

|
||||
</div>
|
||||
|
||||
## Features
|
||||
* Native user interface built with [SwiftUI](https://developer.apple.com/xcode/swiftui/)
|
||||
* Native user interface built with [SwiftUI](https://developer.apple.com/xcode/swiftui/) with customization settings
|
||||
* Multiple instances and accounts, fast switching
|
||||
* [SponsorBlock](https://sponsor.ajay.app/), configurable categories to skip
|
||||
* Player queue and history
|
||||
* Fullscreen playback, Picture in Picture and AirPlay support
|
||||
* Stream quality selection
|
||||
* Favorites: customizable section of channels, playlists, trending, searches and other views
|
||||
* `yattee://` URL Scheme for integrations
|
||||
|
||||
### Availability
|
||||
| Feature | Invidious | Piped |
|
||||
@@ -38,11 +38,11 @@ Video player for [Invidious](https://github.com/iv-org/invidious) and [Piped](ht
|
||||
You can browse and use accounts from one app and play videos with another (for example: use Invidious account for subscriptions and use Piped as playback source). Comments can be displayed from Piped even when Invidious is used for browsing/playing.
|
||||
|
||||
## Documentation
|
||||
* [Installation Instructions](https://github.com/yattee/yattee/wiki/Installation-instructions)
|
||||
* [Integrations](https://github.com/yattee/yattee/wiki/Integrations)
|
||||
* [Installation Instructions](https://github.com/yattee/yattee/wiki/Installation-Instructions)
|
||||
* [FAQ](https://github.com/yattee/yattee/wiki)
|
||||
* [Screenshots Gallery](https://github.com/yattee/yattee/wiki/Screenshots-Gallery)
|
||||
* [Tips](https://github.com/yattee/yattee/wiki/Tips)
|
||||
* [FAQ](https://github.com/yattee/yattee/wiki)
|
||||
* [Integrations](https://github.com/yattee/yattee/wiki/Integrations)
|
||||
* [Donations](https://github.com/yattee/yattee/wiki/Donations)
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
"color-space" : "display-p3",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.638",
|
||||
"green" : "0.638",
|
||||
"red" : "0.638"
|
||||
"blue" : "0.361",
|
||||
"green" : "0.200",
|
||||
"red" : "0.129"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
@@ -23,9 +23,9 @@
|
||||
"color-space" : "display-p3",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.256",
|
||||
"green" : "0.256",
|
||||
"red" : "0.253"
|
||||
"blue" : "0.361",
|
||||
"green" : "0.200",
|
||||
"red" : "0.129"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
@@ -2,10 +2,12 @@
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "extended-gray",
|
||||
"color-space" : "display-p3",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"white" : "0.724"
|
||||
"blue" : "0.263",
|
||||
"green" : "0.290",
|
||||
"red" : "0.859"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
@@ -21,9 +23,9 @@
|
||||
"color-space" : "display-p3",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.328",
|
||||
"green" : "0.328",
|
||||
"red" : "0.325"
|
||||
"blue" : "0.263",
|
||||
"green" : "0.290",
|
||||
"red" : "0.859"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
@@ -5,9 +5,9 @@
|
||||
"color-space" : "display-p3",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.781",
|
||||
"green" : "0.781",
|
||||
"red" : "0.781"
|
||||
"blue" : "0.757",
|
||||
"green" : "0.761",
|
||||
"red" : "0.757"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
@@ -23,9 +23,9 @@
|
||||
"color-space" : "display-p3",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.311",
|
||||
"green" : "0.311",
|
||||
"red" : "0.311"
|
||||
"blue" : "0.259",
|
||||
"green" : "0.259",
|
||||
"red" : "0.259"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.329",
|
||||
"green" : "0.224",
|
||||
"red" : "0.043"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.329",
|
||||
"green" : "0.224",
|
||||
"red" : "0.043"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,12 @@
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"color-space" : "display-p3",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.537",
|
||||
"green" : "0.522",
|
||||
"red" : "1.000"
|
||||
"blue" : "0.361",
|
||||
"green" : "0.200",
|
||||
"red" : "0.129"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
@@ -20,12 +20,12 @@
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"color-space" : "display-p3",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.537",
|
||||
"green" : "0.522",
|
||||
"red" : "1.000"
|
||||
"blue" : "0.263",
|
||||
"green" : "0.290",
|
||||
"red" : "0.859"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
38
Shared/Defaults+Workaround.swift
Normal file
38
Shared/Defaults+Workaround.swift
Normal file
@@ -0,0 +1,38 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
extension Defaults.Serializable where Self: Codable {
|
||||
static var bridge: Defaults.TopLevelCodableBridge<Self> { Defaults.TopLevelCodableBridge() }
|
||||
}
|
||||
|
||||
extension Defaults.Serializable where Self: Codable & NSSecureCoding {
|
||||
static var bridge: Defaults.CodableNSSecureCodingBridge<Self> { Defaults.CodableNSSecureCodingBridge() }
|
||||
}
|
||||
|
||||
extension Defaults.Serializable where Self: Codable & NSSecureCoding & Defaults.PreferNSSecureCoding {
|
||||
static var bridge: Defaults.NSSecureCodingBridge<Self> { Defaults.NSSecureCodingBridge() }
|
||||
}
|
||||
|
||||
extension Defaults.Serializable where Self: Codable & RawRepresentable {
|
||||
static var bridge: Defaults.RawRepresentableCodableBridge<Self> { Defaults.RawRepresentableCodableBridge() }
|
||||
}
|
||||
|
||||
extension Defaults.Serializable where Self: Codable & RawRepresentable & Defaults.PreferRawRepresentable {
|
||||
static var bridge: Defaults.RawRepresentableBridge<Self> { Defaults.RawRepresentableBridge() }
|
||||
}
|
||||
|
||||
extension Defaults.Serializable where Self: RawRepresentable {
|
||||
static var bridge: Defaults.RawRepresentableBridge<Self> { Defaults.RawRepresentableBridge() }
|
||||
}
|
||||
|
||||
extension Defaults.Serializable where Self: NSSecureCoding {
|
||||
static var bridge: Defaults.NSSecureCodingBridge<Self> { Defaults.NSSecureCodingBridge() }
|
||||
}
|
||||
|
||||
extension Defaults.CollectionSerializable where Element: Defaults.Serializable {
|
||||
static var bridge: Defaults.CollectionBridge<Self> { Defaults.CollectionBridge() }
|
||||
}
|
||||
|
||||
extension Defaults.SetAlgebraSerializable where Element: Defaults.Serializable & Hashable {
|
||||
static var bridge: Defaults.SetAlgebraBridge<Self> { Defaults.SetAlgebraBridge() }
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
extension Defaults.Keys {
|
||||
static let kavinPipedInstanceID = "kavin-piped"
|
||||
@@ -20,31 +23,59 @@ 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: .searchQuery("Apple Pie Recipes", "", "", ""))
|
||||
.init(section: .channel("UCXuqSBlHAE6Xw-yeJA0Tunw", "Linus Tech Tips")),
|
||||
.init(section: .channel("UCBJycsmduvYEL83R_U4JriQ", "Marques Brownlee")),
|
||||
.init(section: .channel("UCE_M8A5yxnLfW0KghEeajjw", "Apple"))
|
||||
])
|
||||
|
||||
#if !os(tvOS)
|
||||
static let accountPickerDisplaysUsername = Key<Bool>("accountPickerDisplaysUsername", default: false)
|
||||
#endif
|
||||
#if os(iOS)
|
||||
static let lockPortraitWhenBrowsing = Key<Bool>("lockPortraitWhenBrowsing", default: UIDevice.current.userInterfaceIdiom == .phone)
|
||||
#endif
|
||||
static let channelOnThumbnail = Key<Bool>("channelOnThumbnail", default: true)
|
||||
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)
|
||||
#endif
|
||||
static let pauseOnHidingPlayer = Key<Bool>("pauseOnHidingPlayer", default: true)
|
||||
|
||||
static let closePiPOnNavigation = Key<Bool>("closePiPOnNavigation", default: false)
|
||||
static let closePiPOnOpeningPlayer = Key<Bool>("closePiPOnOpeningPlayer", default: false)
|
||||
#if !os(macOS)
|
||||
static let closePiPAndOpenPlayerOnEnteringForeground = Key<Bool>("closePiPAndOpenPlayerOnEnteringForeground", default: false)
|
||||
#endif
|
||||
|
||||
static let recentlyOpened = Key<[RecentItem]>("recentlyOpened", default: [])
|
||||
|
||||
static let queue = Key<[PlayerQueueItem]>("queue", default: [])
|
||||
static let history = Key<[PlayerQueueItem]>("history", default: [])
|
||||
static let lastPlayed = Key<PlayerQueueItem?>("lastPlayed")
|
||||
|
||||
static let saveHistory = Key<Bool>("saveHistory", default: true)
|
||||
static let showWatchingProgress = Key<Bool>("showWatchingProgress", default: true)
|
||||
static let watchedThreshold = Key<Int>("watchedThreshold", default: 90)
|
||||
static let watchedVideoStyle = Key<WatchedVideoStyle>("watchedVideoStyle", default: .badge)
|
||||
static let watchedVideoBadgeColor = Key<WatchedVideoBadgeColor>("WatchedVideoBadgeColor", default: .red)
|
||||
static let watchedVideoPlayNowBehavior = Key<WatchedVideoPlayNowBehavior>("watchedVideoPlayNowBehavior", default: .continue)
|
||||
static let resetWatchedStatusOnPlaying = Key<Bool>("resetWatchedStatusOnPlaying", default: false)
|
||||
static let saveRecents = Key<Bool>("saveRecents", default: true)
|
||||
|
||||
static let trendingCategory = Key<TrendingCategory>("trendingCategory", default: .default)
|
||||
@@ -55,6 +86,13 @@ extension Defaults.Keys {
|
||||
#if os(macOS)
|
||||
static let enableBetaChannel = Key<Bool>("enableBetaChannel", default: false)
|
||||
#endif
|
||||
|
||||
#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)
|
||||
#endif
|
||||
}
|
||||
|
||||
enum ResolutionSetting: String, CaseIterable, Defaults.Serializable {
|
||||
@@ -72,7 +110,7 @@ enum ResolutionSetting: String, CaseIterable, Defaults.Serializable {
|
||||
var description: String {
|
||||
switch self {
|
||||
case .best:
|
||||
return "Best available"
|
||||
return "Best available quality"
|
||||
default:
|
||||
return value.name
|
||||
}
|
||||
@@ -94,10 +132,6 @@ enum PlayerSidebarSetting: String, CaseIterable, Defaults.Serializable {
|
||||
enum VisibleSection: String, CaseIterable, Comparable, Defaults.Serializable {
|
||||
case favorites, subscriptions, popular, trending, playlists
|
||||
|
||||
static func from(_ string: String) -> VisibleSection {
|
||||
allCases.first { $0.rawValue == string }!
|
||||
}
|
||||
|
||||
var title: String {
|
||||
rawValue.localizedCapitalized
|
||||
}
|
||||
@@ -137,6 +171,18 @@ enum VisibleSection: String, CaseIterable, Comparable, Defaults.Serializable {
|
||||
}
|
||||
}
|
||||
|
||||
enum WatchedVideoStyle: String, Defaults.Serializable {
|
||||
case nothing, badge, decreasedOpacity, both
|
||||
}
|
||||
|
||||
enum WatchedVideoBadgeColor: String, Defaults.Serializable {
|
||||
case colorSchemeBased, red, blue
|
||||
}
|
||||
|
||||
enum WatchedVideoPlayNowBehavior: String, Defaults.Serializable {
|
||||
case `continue`, restart
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
enum CommentsPlacement: String, CaseIterable, Defaults.Serializable {
|
||||
case info, separate
|
||||
|
||||
@@ -9,6 +9,10 @@ private struct InChannelViewKey: EnvironmentKey {
|
||||
static let defaultValue = false
|
||||
}
|
||||
|
||||
private struct InChannelPlaylistViewKey: EnvironmentKey {
|
||||
static let defaultValue = false
|
||||
}
|
||||
|
||||
private struct HorizontalCellsKey: EnvironmentKey {
|
||||
static let defaultValue = false
|
||||
}
|
||||
@@ -25,6 +29,16 @@ private struct CurrentPlaylistID: EnvironmentKey {
|
||||
static let defaultValue: String? = nil
|
||||
}
|
||||
|
||||
typealias LoadMoreContentHandlerType = () -> Void
|
||||
|
||||
private struct LoadMoreContentHandler: EnvironmentKey {
|
||||
static let defaultValue: LoadMoreContentHandlerType = {}
|
||||
}
|
||||
|
||||
private struct ScrollViewBottomPaddingKey: EnvironmentKey {
|
||||
static let defaultValue: Double = 30
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var inNavigationView: Bool {
|
||||
get { self[InNavigationViewKey.self] }
|
||||
@@ -36,6 +50,11 @@ extension EnvironmentValues {
|
||||
set { self[InChannelViewKey.self] = newValue }
|
||||
}
|
||||
|
||||
var inChannelPlaylistView: Bool {
|
||||
get { self[InChannelPlaylistViewKey.self] }
|
||||
set { self[InChannelPlaylistViewKey.self] = newValue }
|
||||
}
|
||||
|
||||
var horizontalCells: Bool {
|
||||
get { self[HorizontalCellsKey.self] }
|
||||
set { self[HorizontalCellsKey.self] = newValue }
|
||||
@@ -50,4 +69,14 @@ extension EnvironmentValues {
|
||||
get { self[CurrentPlaylistID.self] }
|
||||
set { self[CurrentPlaylistID.self] = newValue }
|
||||
}
|
||||
|
||||
var loadMoreContentHandler: LoadMoreContentHandlerType {
|
||||
get { self[LoadMoreContentHandler.self] }
|
||||
set { self[LoadMoreContentHandler.self] = newValue }
|
||||
}
|
||||
|
||||
var scrollViewBottomPadding: Double {
|
||||
get { self[ScrollViewBottomPaddingKey.self] }
|
||||
set { self[ScrollViewBottomPaddingKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,20 +49,22 @@ struct FavoriteItemView: View {
|
||||
}
|
||||
|
||||
.contentShape(Rectangle())
|
||||
.opacity(dragging?.id == item.id ? 0.5 : 1)
|
||||
.onAppear {
|
||||
resource?.addObserver(store)
|
||||
resource?.load()
|
||||
}
|
||||
#if os(macOS)
|
||||
.opacity(dragging?.id == item.id ? 0.5 : 1)
|
||||
#endif
|
||||
.onAppear {
|
||||
resource?.addObserver(store)
|
||||
resource?.load()
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.onDrag {
|
||||
dragging = item
|
||||
return NSItemProvider(object: item.id as NSString)
|
||||
}
|
||||
.onDrop(
|
||||
of: [UTType.text],
|
||||
delegate: DropFavorite(item: item, favorites: $favorites, current: $dragging)
|
||||
)
|
||||
.onDrag {
|
||||
dragging = item
|
||||
return NSItemProvider(object: item.id as NSString)
|
||||
}
|
||||
.onDrop(
|
||||
of: [UTType.text],
|
||||
delegate: DropFavorite(item: item, favorites: $favorites, current: $dragging)
|
||||
)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -111,12 +113,15 @@ struct FavoriteItemView: View {
|
||||
return accounts.api.playlist(id)
|
||||
|
||||
case let .searchQuery(text, date, duration, order):
|
||||
return accounts.api.search(.init(
|
||||
query: text,
|
||||
sortBy: SearchQuery.SortOrder(rawValue: order) ?? .uploadDate,
|
||||
date: SearchQuery.Date(rawValue: date),
|
||||
duration: SearchQuery.Duration(rawValue: duration)
|
||||
))
|
||||
return accounts.api.search(
|
||||
.init(
|
||||
query: text,
|
||||
sortBy: SearchQuery.SortOrder(rawValue: order) ?? .uploadDate,
|
||||
date: SearchQuery.Date(rawValue: date),
|
||||
duration: SearchQuery.Duration(rawValue: duration)
|
||||
),
|
||||
page: nil
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -13,6 +13,8 @@ final class FavoriteResourceObserver: ObservableObject, ResourceObserver {
|
||||
contentItems = playlist.videos.map { ContentItem(video: $0) }
|
||||
} else if let playlist: Playlist = resource.typedContent() {
|
||||
contentItems = playlist.videos.map { ContentItem(video: $0) }
|
||||
} else if let page: SearchPage = resource.typedContent() {
|
||||
contentItems = page.results
|
||||
} else if let items: [ContentItem] = resource.typedContent() {
|
||||
contentItems = items
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -27,12 +27,19 @@ struct FavoritesView: View {
|
||||
FavoriteItemView(item: item, dragging: $dragging)
|
||||
}
|
||||
#else
|
||||
#if os(iOS)
|
||||
let first = favorites.first
|
||||
#endif
|
||||
ForEach(favorites) { item in
|
||||
FavoriteItemView(item: item, dragging: $dragging)
|
||||
#if os(macOS)
|
||||
.workaroundForVerticalScrollingBug()
|
||||
#endif
|
||||
#if os(iOS)
|
||||
.padding(.top, item == first && RefreshControl.navigationBarTitleDisplayMode == .inline ? 10 : 0)
|
||||
#endif
|
||||
}
|
||||
Color.clear.padding(.bottom, 30)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -51,9 +58,12 @@ struct FavoritesView: View {
|
||||
.navigationTitle("Favorites")
|
||||
#endif
|
||||
#if os(macOS)
|
||||
.background(Color.tertiaryBackground)
|
||||
.background(Color.secondaryBackground)
|
||||
.frame(minWidth: 360)
|
||||
#endif
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(RefreshControl.navigationBarTitleDisplayMode)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ struct MenuCommands: Commands {
|
||||
Button("Popular") {
|
||||
model.navigation?.tabSelection = .popular
|
||||
}
|
||||
.disabled(!(model.accounts?.app.supportsPopular ?? true))
|
||||
.disabled(!(model.accounts?.app.supportsPopular ?? false))
|
||||
.keyboardShortcut("3")
|
||||
|
||||
Button("Trending") {
|
||||
@@ -62,10 +62,18 @@ struct MenuCommands: Commands {
|
||||
.disabled(model.player?.queue.isEmpty ?? true)
|
||||
.keyboardShortcut("s")
|
||||
|
||||
Button((model.player?.presentingPlayer ?? true) ? "Hide Player" : "Show Player") {
|
||||
Button(togglePlayerLabel) {
|
||||
model.player?.togglePlayer()
|
||||
}
|
||||
.keyboardShortcut("o")
|
||||
}
|
||||
}
|
||||
|
||||
private var togglePlayerLabel: String {
|
||||
#if os(macOS)
|
||||
"Show Player"
|
||||
#else
|
||||
(model.player?.presentingPlayer ?? true) ? "Hide Player" : "Show Player"
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,21 +6,29 @@ struct AccountsMenuView: View {
|
||||
|
||||
@Default(.accounts) private var accounts
|
||||
@Default(.instances) private var instances
|
||||
@Default(.accountPickerDisplaysUsername) private var accountPickerDisplaysUsername
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
ForEach(allAccounts, id: \.id) { account in
|
||||
Button(accountButtonTitle(account: account)) {
|
||||
Button {
|
||||
model.setCurrent(account)
|
||||
} label: {
|
||||
HStack {
|
||||
Text(accountButtonTitle(account: account))
|
||||
|
||||
Spacer()
|
||||
|
||||
if model.current == account {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
if #available(iOS 15.0, macOS 12.0, *) {
|
||||
label
|
||||
.labelStyle(.titleAndIcon)
|
||||
} else {
|
||||
HStack {
|
||||
Image(systemName: "person.crop.circle")
|
||||
HStack {
|
||||
Image(systemName: "person.crop.circle")
|
||||
if accountPickerDisplaysUsername {
|
||||
label
|
||||
.labelStyle(.titleOnly)
|
||||
}
|
||||
|
||||
@@ -6,20 +6,19 @@ import SwiftUI
|
||||
|
||||
struct AppSidebarNavigation: View {
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@EnvironmentObject<CommentsModel> private var comments
|
||||
@EnvironmentObject<InstancesModel> private var instances
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<PlaylistsModel> private var playlists
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
@EnvironmentObject<SearchModel> private var search
|
||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||
@EnvironmentObject<ThumbnailsModel> private var thumbnailsModel
|
||||
|
||||
@Default(.visibleSections) private var visibleSections
|
||||
|
||||
#if os(iOS)
|
||||
@State private var didApplyPrimaryViewWorkAround = false
|
||||
|
||||
@EnvironmentObject<CommentsModel> private var comments
|
||||
@EnvironmentObject<InstancesModel> private var instances
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<PlaylistsModel> private var playlists
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
@EnvironmentObject<SearchModel> private var search
|
||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||
@EnvironmentObject<ThumbnailsModel> private var thumbnailsModel
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
@@ -50,7 +49,7 @@ struct AppSidebarNavigation: View {
|
||||
.frame(minWidth: sidebarMinWidth)
|
||||
|
||||
VStack {
|
||||
PlayerControlsView {
|
||||
BrowserPlayerControls {
|
||||
HStack(alignment: .center) {
|
||||
Spacer()
|
||||
Image(systemName: "play.tv")
|
||||
@@ -62,36 +61,24 @@ struct AppSidebarNavigation: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.background(
|
||||
EmptyView().fullScreenCover(isPresented: $player.presentingPlayer) {
|
||||
videoPlayer
|
||||
.environment(\.navigationStyle, .sidebar)
|
||||
}
|
||||
)
|
||||
#elseif os(macOS)
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $player.presentingPlayer) {
|
||||
videoPlayer
|
||||
.frame(minWidth: 1000, minHeight: 750)
|
||||
.environment(\.navigationStyle, .sidebar)
|
||||
}
|
||||
)
|
||||
#endif
|
||||
.environment(\.navigationStyle, .sidebar)
|
||||
}
|
||||
|
||||
private var videoPlayer: some View {
|
||||
VideoPlayerView()
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(comments)
|
||||
.environmentObject(instances)
|
||||
.environmentObject(navigation)
|
||||
.environmentObject(player)
|
||||
.environmentObject(playlists)
|
||||
.environmentObject(recents)
|
||||
.environmentObject(subscriptions)
|
||||
.environmentObject(thumbnailsModel)
|
||||
#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 {
|
||||
@@ -113,6 +100,16 @@ struct AppSidebarNavigation: View {
|
||||
"Current User: \(accounts.current?.description ?? "Not set")"
|
||||
)
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
ToolbarItem(placement: .navigation) {
|
||||
Button {
|
||||
NSApp.keyWindow?.firstResponder?.tryToPerform(#selector(NSSplitViewController.toggleSidebar(_:)), with: nil)
|
||||
} label: {
|
||||
Label("Toggle Sidebar", systemImage: "sidebar.left")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,13 +120,4 @@ struct AppSidebarNavigation: View {
|
||||
return .automatic
|
||||
#endif
|
||||
}
|
||||
|
||||
static func symbolSystemImage(_ name: String) -> String {
|
||||
let firstLetter = name.first?.lowercased()
|
||||
let regex = #"^[a-z0-9]$"#
|
||||
|
||||
let symbolName = firstLetter?.range(of: regex, options: .regularExpression) != nil ? firstLetter! : "questionmark"
|
||||
|
||||
return "\(symbolName).circle"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,16 +11,17 @@ struct AppSidebarPlaylists: View {
|
||||
NavigationLink(tag: TabSelection.playlist(playlist.id), selection: $navigation.tabSelection) {
|
||||
LazyView(PlaylistVideosView(playlist))
|
||||
} label: {
|
||||
Label(playlist.title, systemImage: AppSidebarNavigation.symbolSystemImage(playlist.title))
|
||||
Label(playlist.title, systemImage: RecentsModel.symbolSystemImage(playlist.title))
|
||||
.backport
|
||||
.badge(Text("\(playlist.videos.count)"))
|
||||
}
|
||||
.id(playlist.id)
|
||||
.contextMenu {
|
||||
Button("Add to queue...") {
|
||||
playlists.find(id: playlist.id)?.videos.forEach { video in
|
||||
player.enqueueVideo(video)
|
||||
}
|
||||
Button("Play All") {
|
||||
player.play(playlists.find(id: playlist.id)?.videos ?? [])
|
||||
}
|
||||
Button("Shuffle All") {
|
||||
player.play(playlists.find(id: playlist.id)?.videos ?? [], shuffling: true)
|
||||
}
|
||||
Button("Edit") {
|
||||
navigation.presentEditPlaylistForm(playlists.find(id: playlist.id))
|
||||
|
||||
@@ -88,6 +88,6 @@ struct RecentNavigationLink<DestinationContent: View>: View {
|
||||
}
|
||||
|
||||
var labelSystemImage: String {
|
||||
systemImage != nil ? systemImage! : AppSidebarNavigation.symbolSystemImage(recent.title)
|
||||
systemImage != nil ? systemImage! : RecentsModel.symbolSystemImage(recent.title)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ struct AppSidebarSubscriptions: View {
|
||||
NavigationLink(tag: TabSelection.channel(channel.id), selection: $navigation.tabSelection) {
|
||||
LazyView(ChannelVideosView(channel: channel))
|
||||
} label: {
|
||||
Label(channel.name, systemImage: AppSidebarNavigation.symbolSystemImage(channel.name))
|
||||
Label(channel.name, systemImage: RecentsModel.symbolSystemImage(channel.name))
|
||||
}
|
||||
.contextMenu {
|
||||
Button("Unsubscribe") {
|
||||
|
||||
@@ -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
|
||||
@@ -15,6 +16,8 @@ struct AppTabNavigation: View {
|
||||
|
||||
@Default(.visibleSections) private var visibleSections
|
||||
|
||||
let persistenceController = PersistenceController.shared
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: navigation.tabSelectionBinding) {
|
||||
if visibleSections.contains(.favorites) {
|
||||
@@ -33,7 +36,7 @@ struct AppTabNavigation: View {
|
||||
trendingNavigationView
|
||||
}
|
||||
|
||||
if visibleSections.contains(.playlists), accounts.app.supportsUserPlaylists {
|
||||
if playlistsVisible {
|
||||
playlistsNavigationView
|
||||
}
|
||||
|
||||
@@ -42,32 +45,37 @@ struct AppTabNavigation: View {
|
||||
.id(accounts.current?.id ?? "")
|
||||
.environment(\.navigationStyle, .tab)
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingChannel, onDismiss: {
|
||||
if let channel = recents.presentedChannel {
|
||||
recents.close(RecentItem(from: channel))
|
||||
}
|
||||
}) {
|
||||
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, onDismiss: {
|
||||
if let playlist = recents.presentedPlaylist {
|
||||
recents.close(RecentItem(from: playlist))
|
||||
}
|
||||
}) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -76,7 +84,8 @@ struct AppTabNavigation: View {
|
||||
.background(
|
||||
EmptyView().fullScreenCover(isPresented: $player.presentingPlayer) {
|
||||
videoPlayer
|
||||
.environment(\.navigationStyle, .sidebar)
|
||||
.environment(\.managedObjectContext, persistenceController.container.viewContext)
|
||||
.environment(\.navigationStyle, .tab)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -87,7 +96,7 @@ struct AppTabNavigation: View {
|
||||
.toolbar { toolbarContent }
|
||||
}
|
||||
.tabItem {
|
||||
Label("Favorites", systemImage: "heart")
|
||||
Label("Favorites", systemImage: "heart.fill")
|
||||
.accessibility(label: Text("Favorites"))
|
||||
}
|
||||
.tag(TabSelection.favorites)
|
||||
@@ -110,13 +119,18 @@ struct AppTabNavigation: View {
|
||||
accounts.app.supportsSubscriptions && !(accounts.current?.anonymous ?? true)
|
||||
}
|
||||
|
||||
private var playlistsVisible: Bool {
|
||||
visibleSections.contains(.playlists) &&
|
||||
accounts.app.supportsUserPlaylists && !(accounts.current?.anonymous ?? true)
|
||||
}
|
||||
|
||||
private var popularNavigationView: some View {
|
||||
NavigationView {
|
||||
LazyView(PopularView())
|
||||
.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)
|
||||
@@ -128,7 +142,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)
|
||||
@@ -160,7 +174,7 @@ struct AppTabNavigation: View {
|
||||
|
||||
private var playerNavigationLink: some View {
|
||||
NavigationLink(isActive: $player.playerNavigationLinkActive, destination: {
|
||||
VideoPlayerView()
|
||||
videoPlayer
|
||||
.environment(\.inNavigationView, true)
|
||||
}) {
|
||||
EmptyView()
|
||||
@@ -174,6 +188,7 @@ struct AppTabNavigation: View {
|
||||
.environmentObject(instances)
|
||||
.environmentObject(navigation)
|
||||
.environmentObject(player)
|
||||
.environmentObject(playerControls)
|
||||
.environmentObject(playlists)
|
||||
.environmentObject(recents)
|
||||
.environmentObject(subscriptions)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import AVFAudio
|
||||
import Defaults
|
||||
import MediaPlayer
|
||||
import SDWebImage
|
||||
import SDWebImagePINPlugin
|
||||
import SDWebImageWebPCoder
|
||||
@@ -7,16 +8,17 @@ import Siesta
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@StateObject private var accounts = AccountsModel()
|
||||
@StateObject private var comments = CommentsModel()
|
||||
@StateObject private var instances = InstancesModel()
|
||||
@StateObject private var navigation = NavigationModel()
|
||||
@StateObject private var player = PlayerModel()
|
||||
@StateObject private var playlists = PlaylistsModel()
|
||||
@StateObject private var recents = RecentsModel()
|
||||
@StateObject private var search = SearchModel()
|
||||
@StateObject private var subscriptions = SubscriptionsModel()
|
||||
@StateObject private var thumbnailsModel = ThumbnailsModel()
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@EnvironmentObject<CommentsModel> private var comments
|
||||
@EnvironmentObject<InstancesModel> private var instances
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<PlayerControlsModel> private var playerControls
|
||||
@EnvironmentObject<PlaylistsModel> private var playlists
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
@EnvironmentObject<SearchModel> private var search
|
||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||
@EnvironmentObject<ThumbnailsModel> private var thumbnailsModel
|
||||
|
||||
@EnvironmentObject<MenuModel> private var menu
|
||||
|
||||
@@ -39,6 +41,10 @@ struct ContentView: View {
|
||||
#endif
|
||||
}
|
||||
.onAppear(perform: configure)
|
||||
.onChange(of: accounts.signedIn) { _ in
|
||||
subscriptions.load(force: true)
|
||||
playlists.load(force: true)
|
||||
}
|
||||
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(comments)
|
||||
@@ -61,8 +67,7 @@ struct ContentView: View {
|
||||
}
|
||||
)
|
||||
#if !os(tvOS)
|
||||
.handlesExternalEvents(preferring: Set(["*"]), allowing: Set(["*"]))
|
||||
.onOpenURL(perform: handleOpenedURL)
|
||||
.onOpenURL { OpenURLHandler(accounts: accounts, player: player).handle($0) }
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingAddToPlaylist) {
|
||||
AddToPlaylistView(video: navigation.videoToAddToPlaylist)
|
||||
@@ -81,9 +86,21 @@ struct ContentView: View {
|
||||
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() {
|
||||
@@ -91,7 +108,13 @@ 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)
|
||||
if Defaults[.lockPortraitWhenBrowsing] {
|
||||
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
|
||||
}
|
||||
#endif
|
||||
|
||||
if let account = accounts.lastUsed ??
|
||||
@@ -109,18 +132,19 @@ struct ContentView: View {
|
||||
search.accounts = accounts
|
||||
subscriptions.accounts = accounts
|
||||
|
||||
comments.accounts = accounts
|
||||
comments.player = player
|
||||
|
||||
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.loadHistoryDetails()
|
||||
player.restoreQueue()
|
||||
}
|
||||
|
||||
if !Defaults[.saveRecents] {
|
||||
@@ -136,6 +160,59 @@ struct ContentView: View {
|
||||
#endif
|
||||
|
||||
navigation.tabSelection = section ?? .search
|
||||
|
||||
subscriptions.load()
|
||||
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() {
|
||||
@@ -145,28 +222,6 @@ struct ContentView: View {
|
||||
|
||||
navigation.presentingWelcomeScreen = true
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
func handleOpenedURL(_ url: URL) {
|
||||
guard !accounts.current.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
let parser = VideoURLParser(url: url)
|
||||
|
||||
guard let id = parser.id else {
|
||||
return
|
||||
}
|
||||
|
||||
accounts.api.video(id).load().onSuccess { response in
|
||||
if let video: Video = response.typedContent() {
|
||||
player.addCurrentItemToHistory()
|
||||
self.player.playNow(video, at: parser.time)
|
||||
self.player.presentPlayer()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
struct ContentView_Previews: PreviewProvider {
|
||||
|
||||
@@ -4,8 +4,6 @@ import SwiftUI
|
||||
struct Sidebar: View {
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlaylistsModel> private var playlists
|
||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||
|
||||
@Default(.visibleSections) private var visibleSections
|
||||
|
||||
@@ -29,14 +27,6 @@ struct Sidebar: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
subscriptions.load()
|
||||
playlists.load()
|
||||
}
|
||||
.onChange(of: accounts.signedIn) { _ in
|
||||
subscriptions.load(force: true)
|
||||
playlists.load(force: true)
|
||||
}
|
||||
.onChange(of: navigation.sidebarSectionChanged) { _ in
|
||||
scrollScrollViewToItem(scrollView: scrollView, for: navigation.tabSelection)
|
||||
}
|
||||
|
||||
41
Shared/OpenURLHandler.swift
Normal file
41
Shared/OpenURLHandler.swift
Normal file
@@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
|
||||
struct OpenURLHandler {
|
||||
var accounts: AccountsModel
|
||||
var player: PlayerModel
|
||||
|
||||
func handle(_ url: URL) {
|
||||
if accounts.current.isNil {
|
||||
accounts.setCurrent(accounts.any)
|
||||
}
|
||||
|
||||
guard !accounts.current.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
guard url.host != Windows.player.location else {
|
||||
return
|
||||
}
|
||||
#endif
|
||||
|
||||
let parser = VideoURLParser(url: url)
|
||||
|
||||
guard let id = parser.id,
|
||||
id != player.currentVideo?.id
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
Windows.main.open()
|
||||
#endif
|
||||
|
||||
accounts.api.video(id).load().onSuccess { response in
|
||||
if let video: Video = response.typedContent() {
|
||||
self.player.playNow(video, at: parser.time)
|
||||
self.player.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
25
Shared/Player/AppleAVPlayerView.swift
Normal file
25
Shared/Player/AppleAVPlayerView.swift
Normal file
@@ -0,0 +1,25 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct AppleAVPlayerView: UIViewControllerRepresentable {
|
||||
@EnvironmentObject<CommentsModel> private var comments
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||
|
||||
func makeUIViewController(context _: Context) -> UIViewController {
|
||||
let controller = AppleAVPlayerViewController()
|
||||
|
||||
controller.commentsModel = comments
|
||||
controller.navigationModel = navigation
|
||||
controller.playerModel = player
|
||||
controller.subscriptionsModel = subscriptions
|
||||
player.avPlayerBackend.controller = controller
|
||||
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_: UIViewController, context _: Context) {
|
||||
player.rebuildTVMenu()
|
||||
}
|
||||
}
|
||||
207
Shared/Player/AppleAVPlayerViewController.swift
Normal file
207
Shared/Player/AppleAVPlayerViewController.swift
Normal file
@@ -0,0 +1,207 @@
|
||||
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[.lockLandscapeWhenEnteringFullscreen] {
|
||||
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) {
|
||||
if self.navigationModel.presentingChannel {
|
||||
self.playerModel.playerNavigationLinkActive = true
|
||||
} else {
|
||||
self.playerModel.show()
|
||||
}
|
||||
|
||||
#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
|
||||
playerModel.playerNavigationLinkActive = false
|
||||
}
|
||||
|
||||
func playerViewControllerWillStopPictureInPicture(_: AVPlayerViewController) {
|
||||
playerModel.playingInPictureInPicture = false
|
||||
}
|
||||
}
|
||||
@@ -1,65 +1,81 @@
|
||||
import SDWebImageSwiftUI
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct CommentView: View {
|
||||
let comment: Comment
|
||||
@Binding var repliesID: Comment.ID?
|
||||
|
||||
@State private var subscribed = false
|
||||
|
||||
#if os(iOS)
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
#endif
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.navigationStyle) private var navigationStyle
|
||||
|
||||
@EnvironmentObject<CommentsModel> private var comments
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
authorAvatar
|
||||
HStack(spacing: 10) {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
authorAvatar
|
||||
|
||||
#if os(iOS)
|
||||
Group {
|
||||
if horizontalSizeClass == .regular {
|
||||
HStack(spacing: 20) {
|
||||
authorAndTime
|
||||
|
||||
Spacer()
|
||||
|
||||
Group {
|
||||
statusIcons
|
||||
likes
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HStack(alignment: .center, spacing: 20) {
|
||||
authorAndTime
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing, spacing: 8) {
|
||||
likes
|
||||
statusIcons
|
||||
}
|
||||
}
|
||||
if subscribed {
|
||||
Image(systemName: "star.circle.fill")
|
||||
#if os(tvOS)
|
||||
.background(Color.background(scheme: colorScheme))
|
||||
#else
|
||||
.background(Color.background)
|
||||
#endif
|
||||
.clipShape(Circle())
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.font(.system(size: 15))
|
||||
.onAppear {
|
||||
subscribed = subscriptions.isSubscribing(comment.channel.id)
|
||||
}
|
||||
|
||||
#else
|
||||
HStack(spacing: 20) {
|
||||
authorAndTime
|
||||
authorAndTime
|
||||
}
|
||||
.contextMenu {
|
||||
Button(action: openChannelAction) {
|
||||
Label("\(comment.channel.name) Channel", systemImage: "rectangle.stack.fill.badge.person.crop")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
|
||||
Group {
|
||||
#if os(iOS)
|
||||
if horizontalSizeClass == .regular {
|
||||
Group {
|
||||
statusIcons
|
||||
likes
|
||||
}
|
||||
} else {
|
||||
VStack(alignment: .trailing, spacing: 8) {
|
||||
likes
|
||||
statusIcons
|
||||
}
|
||||
}
|
||||
#else
|
||||
statusIcons
|
||||
likes
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
.font(.system(size: 25).bold())
|
||||
#else
|
||||
.font(.system(size: 15))
|
||||
#endif
|
||||
|
||||
Group {
|
||||
commentText
|
||||
@@ -91,26 +107,28 @@ struct CommentView: View {
|
||||
.placeholder {
|
||||
Rectangle().fill(Color("PlaceholderColor"))
|
||||
}
|
||||
.retryOnAppear(false)
|
||||
.retryOnAppear(true)
|
||||
.indicator(.activity)
|
||||
.mask(RoundedRectangle(cornerRadius: 60))
|
||||
.frame(width: 45, height: 45, alignment: .leading)
|
||||
.contextMenu {
|
||||
Button(action: openChannelAction) {
|
||||
Label("\(comment.channel.name) Channel", systemImage: "rectangle.stack.fill.badge.person.crop")
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
.frame(width: 80, height: 80, alignment: .leading)
|
||||
.focusable()
|
||||
#else
|
||||
.frame(width: 45, height: 45, alignment: .leading)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var authorAndTime: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text(comment.author)
|
||||
.fontWeight(.bold)
|
||||
#if os(tvOS)
|
||||
.font(.system(size: 30).bold())
|
||||
#else
|
||||
.font(.system(size: 14).bold())
|
||||
#endif
|
||||
|
||||
Text(comment.time)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.lineLimit(1)
|
||||
@@ -125,6 +143,9 @@ struct CommentView: View {
|
||||
Image(systemName: "heart.fill")
|
||||
}
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.font(.system(size: 12))
|
||||
#endif
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
@@ -135,6 +156,9 @@ struct CommentView: View {
|
||||
Image(systemName: "hand.thumbsup")
|
||||
Text("\(comment.likeCount.formattedAsAbbreviation())")
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.font(.system(size: 12))
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
@@ -163,6 +187,7 @@ struct CommentView: View {
|
||||
#if os(tvOS)
|
||||
.padding(.leading, 5)
|
||||
#else
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(.secondary)
|
||||
#endif
|
||||
}
|
||||
@@ -181,7 +206,7 @@ struct CommentView: View {
|
||||
#if os(macOS)
|
||||
0.4
|
||||
#else
|
||||
0.8
|
||||
0.6
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -226,17 +251,13 @@ struct CommentView: View {
|
||||
}
|
||||
|
||||
private func openChannelAction() {
|
||||
player.presentingPlayer = false
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
let recent = RecentItem(from: comment.channel)
|
||||
recents.add(recent)
|
||||
navigation.presentingChannel = true
|
||||
|
||||
if navigationStyle == .sidebar {
|
||||
navigation.sidebarSectionChanged.toggle()
|
||||
navigation.tabSelection = .recentlyOpened(recent.tag)
|
||||
}
|
||||
}
|
||||
NavigationModel.openChannel(
|
||||
comment.channel,
|
||||
player: player,
|
||||
recents: recents,
|
||||
navigation: navigation,
|
||||
navigationStyle: navigationStyle
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,5 +268,7 @@ struct CommentView_Previews: PreviewProvider {
|
||||
|
||||
static var previews: some View {
|
||||
CommentView(comment: fixture, repliesID: .constant(fixture.id))
|
||||
.environmentObject(SubscriptionsModel())
|
||||
.padding(5)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,79 +1,49 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CommentsView: View {
|
||||
var embedInScrollView = false
|
||||
@State private var repliesID: Comment.ID?
|
||||
|
||||
@EnvironmentObject<CommentsModel> private var comments
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if comments.disabled {
|
||||
Text("Comments are disabled for this video")
|
||||
.foregroundColor(.secondary)
|
||||
NoCommentsView(text: "Comments are disabled", systemImage: "xmark.circle.fill")
|
||||
} else if comments.loaded && comments.all.isEmpty {
|
||||
Text("No comments")
|
||||
.foregroundColor(.secondary)
|
||||
NoCommentsView(text: "No comments", systemImage: "0.circle.fill")
|
||||
} else if !comments.loaded {
|
||||
progressView
|
||||
} else {
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
VStack(alignment: .leading) {
|
||||
let last = comments.all.last
|
||||
ForEach(comments.all) { comment in
|
||||
CommentView(comment: comment, repliesID: $repliesID)
|
||||
|
||||
if comment != last {
|
||||
Divider()
|
||||
.padding(.vertical, 5)
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
if comments.nextPageAvailable {
|
||||
Button {
|
||||
repliesID = nil
|
||||
comments.loadNextPage()
|
||||
} label: {
|
||||
Label("Show more", systemImage: "arrow.turn.down.right")
|
||||
}
|
||||
}
|
||||
|
||||
if !comments.firstPage {
|
||||
Button {
|
||||
repliesID = nil
|
||||
comments.load(page: nil)
|
||||
} label: {
|
||||
Label("Show first", systemImage: "arrow.turn.down.left")
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.vertical, 8)
|
||||
.foregroundColor(.secondary)
|
||||
PlaceholderProgressView()
|
||||
.onAppear {
|
||||
comments.load()
|
||||
}
|
||||
} else {
|
||||
let last = comments.all.last
|
||||
let commentsStack = LazyVStack {
|
||||
ForEach(comments.all) { comment in
|
||||
CommentView(comment: comment, repliesID: $repliesID)
|
||||
.onAppear {
|
||||
comments.loadNextPageIfNeeded(current: comment)
|
||||
}
|
||||
.padding(.bottom, comment == last ? 5 : 0)
|
||||
|
||||
if comment != last {
|
||||
Divider()
|
||||
.padding(.vertical, 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if embedInScrollView {
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
commentsStack
|
||||
}
|
||||
} else {
|
||||
commentsStack
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.onAppear {
|
||||
if !comments.loaded {
|
||||
comments.load()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var progressView: some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
Spacer()
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
249
Shared/Player/Controls/PlayerControls.swift
Normal file
249
Shared/Player/Controls/PlayerControls.swift
Normal file
@@ -0,0 +1,249 @@
|
||||
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
|
||||
#endif
|
||||
|
||||
init(player: PlayerModel) {
|
||||
self.player = player
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
ZStack(alignment: .bottom) {
|
||||
VStack(spacing: 0) {
|
||||
Group {
|
||||
statusBar
|
||||
.padding(3)
|
||||
#if os(macOS)
|
||||
.background(VisualEffectBlur(material: .hudWindow))
|
||||
#elseif os(iOS)
|
||||
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
|
||||
#endif
|
||||
.mask(RoundedRectangle(cornerRadius: 3))
|
||||
|
||||
buttonsBar
|
||||
.padding(.top, 4)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
mediumButtonsBar
|
||||
|
||||
Spacer()
|
||||
|
||||
timeline
|
||||
.offset(y: 10)
|
||||
.zIndex(1)
|
||||
|
||||
bottomBar
|
||||
|
||||
#if os(macOS)
|
||||
.background(VisualEffectBlur(material: .hudWindow))
|
||||
#elseif os(iOS)
|
||||
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
|
||||
#endif
|
||||
.mask(RoundedRectangle(cornerRadius: 3))
|
||||
}
|
||||
}
|
||||
.opacity(model.presentingControls ? 1 : 0)
|
||||
}
|
||||
.background(controlsBackground)
|
||||
.environment(\.colorScheme, .dark)
|
||||
}
|
||||
|
||||
var controlsBackground: some View {
|
||||
PlayerGestures()
|
||||
.background(Color.black.opacity(model.presentingControls ? 0.5 : 0))
|
||||
}
|
||||
|
||||
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) {
|
||||
#if os(iOS)
|
||||
hidePlayerButton
|
||||
#endif
|
||||
Text(playbackStatus)
|
||||
|
||||
Spacer()
|
||||
|
||||
ToggleBackendButton()
|
||||
Text("•")
|
||||
StreamControl()
|
||||
#if os(macOS)
|
||||
.frame(maxWidth: 160)
|
||||
#endif
|
||||
}
|
||||
.foregroundColor(.primary)
|
||||
.padding(.trailing, 4)
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
|
||||
private var hidePlayerButton: some View {
|
||||
Button {
|
||||
player.hide()
|
||||
} label: {
|
||||
Image(systemName: "chevron.down.circle.fill")
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
}
|
||||
|
||||
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 {
|
||||
fullscreenButton
|
||||
Spacer()
|
||||
// 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"
|
||||
) {
|
||||
model.toggleFullscreen(fullScreenLayout)
|
||||
}
|
||||
.keyboardShortcut(fullScreenLayout ? .cancelAction : .defaultAction)
|
||||
}
|
||||
|
||||
var mediumButtonsBar: some View {
|
||||
HStack {
|
||||
button("Seek Backward", systemImage: "gobackward.10", size: 50, cornerRadius: 10) {
|
||||
player.backend.seek(relative: .secondsInDefaultTimescale(-10))
|
||||
}
|
||||
.keyboardShortcut("k")
|
||||
|
||||
Spacer()
|
||||
|
||||
button(
|
||||
model.isPlaying ? "Pause" : "Play",
|
||||
systemImage: model.isPlaying ? "pause.fill" : "play.fill",
|
||||
size: 50,
|
||||
cornerRadius: 10
|
||||
) {
|
||||
player.backend.togglePlay()
|
||||
}
|
||||
.keyboardShortcut("p")
|
||||
.disabled(model.isLoadingVideo)
|
||||
|
||||
Spacer()
|
||||
|
||||
button("Seek Forward", systemImage: "goforward.10", size: 50, cornerRadius: 10) {
|
||||
player.backend.seek(relative: .secondsInDefaultTimescale(10))
|
||||
}
|
||||
.keyboardShortcut("l")
|
||||
}
|
||||
.font(.system(size: 30))
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
|
||||
var bottomBar: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
|
||||
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(macOS)
|
||||
model.playingFullscreen || verticalSizeClass == .compact
|
||||
#else
|
||||
model.playingFullscreen
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct PlayerControls_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
PlayerControls(player: PlayerModel())
|
||||
}
|
||||
}
|
||||
23
Shared/Player/Controls/ToggleBackendButton.swift
Normal file
23
Shared/Player/Controls/ToggleBackendButton.swift
Normal file
@@ -0,0 +1,23 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ToggleBackendButton: View {
|
||||
@EnvironmentObject<PlayerControlsModel> private var controls
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
player.saveTime {
|
||||
player.changeActiveBackend(from: player.activeBackend, to: player.activeBackend.next())
|
||||
controls.resetTimer()
|
||||
}
|
||||
} label: {
|
||||
Text(player.activeBackend.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ToggleBackendButton_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ToggleBackendButton()
|
||||
}
|
||||
}
|
||||
63
Shared/Player/MPV/MPVOGLView.swift
Normal file
63
Shared/Player/MPV/MPVOGLView.swift
Normal file
@@ -0,0 +1,63 @@
|
||||
import GLKit
|
||||
import OpenGLES
|
||||
|
||||
final class MPVOGLView: GLKView {
|
||||
private var defaultFBO: GLint?
|
||||
|
||||
var mpvGL: UnsafeMutableRawPointer?
|
||||
var needsDrawing = true
|
||||
|
||||
override init(frame: CGRect) {
|
||||
guard let context = EAGLContext(api: .openGLES2) else {
|
||||
print("Failed to initialize OpenGLES 2.0 context")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
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(_ rect: CGRect) {
|
||||
glGetIntegerv(UInt32(GL_FRAMEBUFFER_BINDING), &defaultFBO!)
|
||||
|
||||
if mpvGL != nil {
|
||||
var data = mpv_opengl_fbo(
|
||||
fbo: Int32(defaultFBO!),
|
||||
w: Int32(rect.size.width) * Int32(contentScaleFactor),
|
||||
h: Int32(rect.size.height) * Int32(contentScaleFactor),
|
||||
internal_format: 0
|
||||
)
|
||||
var flip: CInt = 1
|
||||
withUnsafeMutablePointer(to: &flip) { flip in
|
||||
withUnsafeMutablePointer(to: &data) { data in
|
||||
var params = [
|
||||
mpv_render_param(type: MPV_RENDER_PARAM_OPENGL_FBO, data: data),
|
||||
mpv_render_param(type: MPV_RENDER_PARAM_FLIP_Y, data: flip),
|
||||
mpv_render_param()
|
||||
]
|
||||
mpv_render_context_render(OpaquePointer(mpvGL), ¶ms)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
super.init(coder: aDecoder)
|
||||
}
|
||||
}
|
||||
26
Shared/Player/MPV/MPVViewController.swift
Normal file
26
Shared/Player/MPV/MPVViewController.swift
Normal file
@@ -0,0 +1,26 @@
|
||||
import UIKit
|
||||
|
||||
final class MPVViewController: UIViewController {
|
||||
var client: MPVClient!
|
||||
var glView: MPVOGLView!
|
||||
|
||||
init() {
|
||||
client = MPVClient()
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.loadView()
|
||||
|
||||
client.create(frame: view.frame)
|
||||
glView = client.glView
|
||||
|
||||
view.addSubview(glView)
|
||||
|
||||
super.viewDidLoad()
|
||||
}
|
||||
}
|
||||
28
Shared/Player/NoCommentsView.swift
Normal file
28
Shared/Player/NoCommentsView.swift
Normal file
@@ -0,0 +1,28 @@
|
||||
import SwiftUI
|
||||
|
||||
struct NoCommentsView: View {
|
||||
var text: String
|
||||
var systemImage: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .center, spacing: 10) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 36))
|
||||
|
||||
Text(text)
|
||||
#if !os(tvOS)
|
||||
.font(.system(size: 12))
|
||||
#endif
|
||||
}
|
||||
.frame(minWidth: 0, maxWidth: .infinity)
|
||||
#if !os(tvOS)
|
||||
.foregroundColor(.secondary)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct NoCommentsView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
NoCommentsView(text: "No comments", systemImage: "xmark.circle.fill")
|
||||
}
|
||||
}
|
||||
@@ -1,220 +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 {
|
||||
closeButton
|
||||
|
||||
if player.currentItem != nil {
|
||||
HStack {
|
||||
Text(playbackStatus)
|
||||
Text("•")
|
||||
rateMenu
|
||||
}
|
||||
.font(.caption2)
|
||||
|
||||
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)
|
||||
.padding(4)
|
||||
.background(colorScheme == .dark ? Color.black : Color.white)
|
||||
}
|
||||
|
||||
private var closeButton: some View {
|
||||
Button {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
} 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.time != nil, player.time!.isValid, !player.currentVideo.isNil else {
|
||||
return "loading..."
|
||||
}
|
||||
|
||||
let videoLengthAtRate = player.currentVideo!.length / Double(player.currentRate)
|
||||
let remainingSeconds = videoLengthAtRate - player.time!.seconds
|
||||
|
||||
if remainingSeconds < 60 {
|
||||
return "less than a minute"
|
||||
}
|
||||
|
||||
let timeFinishAt = Date().addingTimeInterval(remainingSeconds)
|
||||
|
||||
return "ends at \(formattedTimeFinishAt(timeFinishAt))"
|
||||
}
|
||||
|
||||
private func formattedTimeFinishAt(_ date: Date) -> String {
|
||||
let dateFormatter = DateFormatter()
|
||||
|
||||
dateFormatter.dateStyle = .none
|
||||
dateFormatter.timeStyle = .short
|
||||
|
||||
return dateFormatter.string(from: date)
|
||||
}
|
||||
|
||||
private var rateMenu: some View {
|
||||
#if os(macOS)
|
||||
ratePicker
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: 70)
|
||||
#else
|
||||
Menu {
|
||||
ratePicker
|
||||
} label: {
|
||||
Text(player.rateLabel(player.currentRate))
|
||||
}
|
||||
|
||||
#endif
|
||||
}
|
||||
|
||||
private var ratePicker: some View {
|
||||
Picker("", selection: $player.currentRate) {
|
||||
ForEach(PlayerModel.availableRates, id: \.self) { rate in
|
||||
Text(player.rateLabel(rate)).tag(rate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var restoreLastSkippedSegmentButton: some View {
|
||||
HStack(spacing: 4) {
|
||||
Button {
|
||||
player.restoreLastSkippedSegment()
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "arrow.uturn.left.circle")
|
||||
Text(player.lastSkipped!.title())
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Text("•")
|
||||
}
|
||||
}
|
||||
|
||||
private var streamControl: some View {
|
||||
#if os(macOS)
|
||||
Picker("", selection: $player.streamSelection) {
|
||||
ForEach(InstancesModel.all) { instance in
|
||||
let instanceStreams = availableStreamsForInstance(instance)
|
||||
if !instanceStreams.values.isEmpty {
|
||||
let kinds = Array(instanceStreams.keys).sorted { $0 < $1 }
|
||||
|
||||
Section(header: Text(instance.longDescription)) {
|
||||
ForEach(kinds, id: \.self) { key in
|
||||
ForEach(instanceStreams[key] ?? []) { stream in
|
||||
Text(stream.quality).tag(Stream?.some(stream))
|
||||
}
|
||||
|
||||
if kinds.count > 1 {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#else
|
||||
Menu {
|
||||
ForEach(InstancesModel.all) { instance in
|
||||
let instanceStreams = availableStreamsForInstance(instance)
|
||||
if !instanceStreams.values.isEmpty {
|
||||
let kinds = Array(instanceStreams.keys).sorted { $0 < $1 }
|
||||
Picker("", selection: $player.streamSelection) {
|
||||
ForEach(kinds, id: \.self) { key in
|
||||
ForEach(instanceStreams[key] ?? []) { stream in
|
||||
Text(stream.description).tag(Stream?.some(stream))
|
||||
}
|
||||
|
||||
if kinds.count > 1 {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text(player.streamSelection?.quality ?? "")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func availableStreamsForInstance(_ instance: Instance) -> [Stream.Kind: [Stream]] {
|
||||
let streams = player.availableStreamsSorted.filter { $0.instance == instance }
|
||||
|
||||
return Dictionary(grouping: streams, by: \.kind!)
|
||||
}
|
||||
}
|
||||
|
||||
struct PlaybackBar_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
PlaybackBar()
|
||||
.injectFixtureEnvironmentObjects()
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct Player: UIViewControllerRepresentable {
|
||||
@EnvironmentObject<CommentsModel> private var comments
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
|
||||
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
|
||||
player.controller = controller
|
||||
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_: PlayerViewController, context _: Context) {
|
||||
player.rebuildTVMenu()
|
||||
}
|
||||
}
|
||||
55
Shared/Player/PlayerGestures.swift
Normal file
55
Shared/Player/PlayerGestures.swift
Normal file
@@ -0,0 +1,55 @@
|
||||
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))
|
||||
}
|
||||
)
|
||||
|
||||
gestureRectangle
|
||||
.tapRecognizer(
|
||||
tapSensitivity: 0.2,
|
||||
singleTapAction: {
|
||||
model.toggle()
|
||||
},
|
||||
doubleTapAction: {
|
||||
player.backend.togglePlay()
|
||||
}
|
||||
)
|
||||
|
||||
gestureRectangle
|
||||
.tapRecognizer(
|
||||
tapSensitivity: 0.2,
|
||||
singleTapAction: {
|
||||
model.toggle()
|
||||
},
|
||||
doubleTapAction: {
|
||||
player.backend.seek(relative: .secondsInDefaultTimescale(10))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var gestureRectangle: some View {
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
struct PlayerGestures_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
PlayerGestures()
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@@ -8,10 +9,12 @@ struct PlayerQueueRow: View {
|
||||
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
|
||||
@Default(.closePiPOnNavigation) var closePiPOnNavigation
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
Button {
|
||||
player.addCurrentItemToHistory()
|
||||
player.prepareCurrentItemForHistory()
|
||||
|
||||
if history {
|
||||
player.playHistory(item)
|
||||
@@ -24,6 +27,10 @@ struct PlayerQueueRow: View {
|
||||
fullScreen = false
|
||||
}
|
||||
}
|
||||
|
||||
if closePiPOnNavigation, player.playingInPictureInPicture {
|
||||
player.closePiP()
|
||||
}
|
||||
} label: {
|
||||
VideoBanner(video: item.video, playbackTime: item.playbackTime, videoDuration: item.videoDuration)
|
||||
}
|
||||
|
||||
@@ -6,9 +6,14 @@ struct PlayerQueueView: View {
|
||||
@Binding var sidebarQueue: Bool
|
||||
@Binding var fullScreen: Bool
|
||||
|
||||
@FetchRequest(sortDescriptors: [.init(key: "watchedAt", ascending: false)])
|
||||
var watches: FetchedResults<Watch>
|
||||
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
|
||||
@Default(.saveHistory) private var saveHistory
|
||||
@Default(.showHistoryInPlayer) private var showHistoryInPlayer
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
@@ -17,7 +22,7 @@ struct PlayerQueueView: View {
|
||||
if sidebarQueue {
|
||||
related
|
||||
}
|
||||
if saveHistory {
|
||||
if saveHistory, showHistoryInPlayer {
|
||||
playedPreviously
|
||||
}
|
||||
}
|
||||
@@ -46,23 +51,33 @@ struct PlayerQueueView: View {
|
||||
ForEach(player.queue) { item in
|
||||
PlayerQueueRow(item: item, fullScreen: $fullScreen)
|
||||
.contextMenu {
|
||||
removeButton(item, history: false)
|
||||
removeAllButton(history: false)
|
||||
removeButton(item)
|
||||
removeAllButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var visibleWatches: [Watch] {
|
||||
watches.filter { $0.videoID != player.currentVideo?.videoID }
|
||||
}
|
||||
|
||||
var playedPreviously: some View {
|
||||
Group {
|
||||
if !player.history.isEmpty {
|
||||
if !visibleWatches.isEmpty {
|
||||
Section(header: Text("Played Previously")) {
|
||||
ForEach(player.history) { item in
|
||||
PlayerQueueRow(item: item, history: true, fullScreen: $fullScreen)
|
||||
.contextMenu {
|
||||
removeButton(item, history: true)
|
||||
removeAllButton(history: true)
|
||||
}
|
||||
ForEach(visibleWatches, id: \.videoID) { watch in
|
||||
PlayerQueueRow(
|
||||
item: PlayerQueueItem.from(watch, video: player.historyVideo(watch.videoID)),
|
||||
history: true,
|
||||
fullScreen: $fullScreen
|
||||
)
|
||||
.onAppear {
|
||||
player.loadHistoryVideoDetails(watch.videoID)
|
||||
}
|
||||
.contextMenu {
|
||||
removeHistoryButton(watch)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,11 +91,16 @@ struct PlayerQueueView: View {
|
||||
ForEach(player.currentVideo!.related) { video in
|
||||
PlayerQueueRow(item: PlayerQueueItem(video), fullScreen: $fullScreen)
|
||||
.contextMenu {
|
||||
Button("Play Next") {
|
||||
Button {
|
||||
player.playNext(video)
|
||||
} label: {
|
||||
Label("Play Next", systemImage: "text.insert")
|
||||
}
|
||||
Button("Play Last") {
|
||||
|
||||
Button {
|
||||
player.enqueueVideo(video)
|
||||
} label: {
|
||||
Label("Play Last", systemImage: "text.append")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,28 +109,28 @@ struct PlayerQueueView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func removeButton(_ item: PlayerQueueItem, history: Bool) -> some View {
|
||||
private func removeButton(_ item: PlayerQueueItem) -> some View {
|
||||
Button {
|
||||
removeButtonAction(item, history: history)
|
||||
player.remove(item)
|
||||
} label: {
|
||||
Label("Remove", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
|
||||
private func removeButtonAction(_ item: PlayerQueueItem, history: Bool) {
|
||||
_ = history ? player.removeHistory(item) : player.remove(item)
|
||||
}
|
||||
|
||||
private func removeAllButton(history: Bool) -> some View {
|
||||
private func removeAllButton() -> some View {
|
||||
Button {
|
||||
removeAllButtonAction(history: history)
|
||||
player.removeQueueItems()
|
||||
} label: {
|
||||
Label("Remove All", systemImage: "trash.fill")
|
||||
}
|
||||
}
|
||||
|
||||
private func removeAllButtonAction(history: Bool) {
|
||||
_ = history ? player.removeHistoryItems() : player.removeQueueItems()
|
||||
private func removeHistoryButton(_ watch: Watch) -> some View {
|
||||
Button {
|
||||
player.removeWatch(watch)
|
||||
} label: {
|
||||
Label("Remove", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import AVKit
|
||||
import Logging
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
final class PlayerViewController: UIViewController {
|
||||
@@ -7,11 +7,14 @@ final class PlayerViewController: UIViewController {
|
||||
var commentsModel: CommentsModel!
|
||||
var navigationModel: NavigationModel!
|
||||
var playerModel: PlayerModel!
|
||||
var playerViewController = AVPlayerViewController()
|
||||
var subscriptionsModel: SubscriptionsModel!
|
||||
var playerView = AVPlayerViewController()
|
||||
|
||||
let persistenceController = PersistenceController.shared
|
||||
|
||||
#if !os(tvOS)
|
||||
var aspectRatio: Double? {
|
||||
let ratio = Double(playerViewController.videoBounds.width) / Double(playerViewController.videoBounds.height)
|
||||
let ratio = Double(playerView.videoBounds.width) / Double(playerView.videoBounds.height)
|
||||
|
||||
guard ratio.isFinite else {
|
||||
return VideoPlayerView.defaultAspectRatio // swiftlint:disable:this implicit_return
|
||||
@@ -27,34 +30,56 @@ final class PlayerViewController: UIViewController {
|
||||
loadPlayer()
|
||||
|
||||
#if os(tvOS)
|
||||
if !playerViewController.isBeingPresented, !playerViewController.isBeingDismissed {
|
||||
present(playerViewController, animated: false)
|
||||
if !playerView.isBeingPresented, !playerView.isBeingDismissed {
|
||||
present(playerView, animated: false)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
|
||||
if !playerModel.presentingPlayer, !Defaults[.pauseOnHidingPlayer], !playerModel.isPlaying {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
self?.playerModel.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
func loadPlayer() {
|
||||
guard !playerLoaded else {
|
||||
return
|
||||
}
|
||||
|
||||
playerModel.controller = self
|
||||
playerViewController.player = playerModel.player
|
||||
playerViewController.allowsPictureInPicturePlayback = true
|
||||
playerViewController.delegate = self
|
||||
playerView.player = playerModel.player
|
||||
playerView.allowsPictureInPicturePlayback = true
|
||||
#if os(iOS)
|
||||
if #available(iOS 14.2, *) {
|
||||
playerView.canStartPictureInPictureAutomaticallyFromInline = true
|
||||
}
|
||||
#endif
|
||||
playerView.delegate = self
|
||||
|
||||
#if os(tvOS)
|
||||
playerModel.avPlayerViewController = playerViewController
|
||||
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([.playingNext, .playedPreviously], title: "Playing Next")
|
||||
infoViewController(queueSections, title: "Queue")
|
||||
])
|
||||
|
||||
playerViewController.customInfoViewControllers = infoViewControllers
|
||||
playerView.customInfoViewControllers = infoViewControllers
|
||||
#else
|
||||
embedViewController()
|
||||
#endif
|
||||
@@ -71,6 +96,8 @@ final class PlayerViewController: UIViewController {
|
||||
.frame(maxHeight: 600)
|
||||
.environmentObject(commentsModel)
|
||||
.environmentObject(playerModel)
|
||||
.environmentObject(subscriptionsModel)
|
||||
.environment(\.managedObjectContext, persistenceController.container.viewContext)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -80,12 +107,12 @@ final class PlayerViewController: UIViewController {
|
||||
}
|
||||
#else
|
||||
func embedViewController() {
|
||||
playerViewController.view.frame = view.bounds
|
||||
playerView.view.frame = view.bounds
|
||||
|
||||
addChild(playerViewController)
|
||||
view.addSubview(playerViewController.view)
|
||||
addChild(playerView)
|
||||
view.addSubview(playerView.view)
|
||||
|
||||
playerViewController.didMove(toParent: self)
|
||||
playerView.didMove(toParent: self)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -99,26 +126,50 @@ extension PlayerViewController: AVPlayerViewControllerDelegate {
|
||||
true
|
||||
}
|
||||
|
||||
func playerViewControllerWillBeginDismissalTransition(_: AVPlayerViewController) {}
|
||||
|
||||
func playerViewControllerDidEndDismissalTransition(_: AVPlayerViewController) {
|
||||
func playerViewControllerWillBeginDismissalTransition(_: AVPlayerViewController) {
|
||||
if Defaults[.pauseOnHidingPlayer] {
|
||||
playerModel.pause()
|
||||
}
|
||||
dismiss(animated: false)
|
||||
}
|
||||
|
||||
func playerViewControllerDidEndDismissalTransition(_: AVPlayerViewController) {}
|
||||
|
||||
func playerViewController(
|
||||
_: AVPlayerViewController,
|
||||
willBeginFullScreenPresentationWithAnimationCoordinator _: UIViewControllerTransitionCoordinator
|
||||
) {}
|
||||
willBeginFullScreenPresentationWithAnimationCoordinator context: UIViewControllerTransitionCoordinator
|
||||
) {
|
||||
playerModel.playingFullscreen = true
|
||||
|
||||
#if os(iOS)
|
||||
if !context.isCancelled, Defaults[.lockLandscapeWhenEnteringFullscreen] {
|
||||
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)
|
||||
if self.traitCollection.verticalSizeClass == .compact {
|
||||
self.dismiss(animated: true)
|
||||
self.playerModel.lockedOrientation = nil
|
||||
if Defaults[.enterFullscreenInLandscape] {
|
||||
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
|
||||
}
|
||||
|
||||
self.playerModel.playingFullscreen = false
|
||||
|
||||
if wasPlaying {
|
||||
self.playerModel.play()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -126,19 +177,19 @@ extension PlayerViewController: AVPlayerViewControllerDelegate {
|
||||
}
|
||||
|
||||
func playerViewController(
|
||||
_ playerViewController: AVPlayerViewController,
|
||||
_: AVPlayerViewController,
|
||||
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void
|
||||
) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
if self.navigationModel.presentingChannel {
|
||||
self.playerModel.playerNavigationLinkActive = true
|
||||
} else {
|
||||
self.playerModel.presentPlayer()
|
||||
self.playerModel.show()
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
if self.playerModel.playingInPictureInPicture {
|
||||
self.present(playerViewController, animated: false) {
|
||||
self.present(self.playerView, animated: false) {
|
||||
completionHandler(true)
|
||||
}
|
||||
}
|
||||
|
||||
79
Shared/Player/StreamControl.swift
Normal file
79
Shared/Player/StreamControl.swift
Normal file
@@ -0,0 +1,79 @@
|
||||
import SwiftUI
|
||||
|
||||
struct StreamControl: View {
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(macOS)
|
||||
Picker("", selection: $player.streamSelection) {
|
||||
ForEach(InstancesModel.all) { instance in
|
||||
let instanceStreams = availableStreamsForInstance(instance)
|
||||
if !instanceStreams.values.isEmpty {
|
||||
let kinds = Array(instanceStreams.keys).sorted { $0 < $1 }
|
||||
|
||||
Section(header: Text(instance.longDescription)) {
|
||||
ForEach(kinds, id: \.self) { key in
|
||||
ForEach(instanceStreams[key] ?? []) { stream in
|
||||
Text(stream.quality).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()
|
||||
}
|
||||
}
|
||||
48
Shared/Player/TapRecognizerViewModifier.swift
Normal file
48
Shared/Player/TapRecognizerViewModifier.swift
Normal file
@@ -0,0 +1,48 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TapRecognizerViewModifier: ViewModifier {
|
||||
@State private var singleTapIsTaped: Bool = .init()
|
||||
|
||||
var tapSensitivity: Double
|
||||
var singleTapAction: () -> Void
|
||||
var doubleTapAction: () -> Void
|
||||
|
||||
init(tapSensitivity: Double, singleTapAction: @escaping () -> Void, doubleTapAction: @escaping () -> Void) {
|
||||
self.tapSensitivity = tapSensitivity
|
||||
self.singleTapAction = singleTapAction
|
||||
self.doubleTapAction = doubleTapAction
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.gesture(simultaneouslyGesture)
|
||||
}
|
||||
|
||||
private var singleTapGesture: some Gesture {
|
||||
TapGesture(count: 1).onEnded {
|
||||
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) -> some View {
|
||||
modifier(TapRecognizerViewModifier(tapSensitivity: tapSensitivity, singleTapAction: singleTapAction, doubleTapAction: doubleTapAction))
|
||||
}
|
||||
}
|
||||
193
Shared/Player/TimelineView.swift
Normal file
193
Shared/Player/TimelineView.swift
Normal file
@@ -0,0 +1,193 @@
|
||||
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 = 10.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) {
|
||||
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 * 2)
|
||||
|
||||
.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()
|
||||
}
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
.gesture(DragGesture(minimumDistance: 0).onEnded { value in
|
||||
let target = (value.location.x / size.width) * units
|
||||
current = target
|
||||
player.backend.seek(to: target)
|
||||
})
|
||||
}
|
||||
|
||||
var projectedValue: Double {
|
||||
let change = (dragOffset / size.width) * units
|
||||
let projected = draggedFrom + change
|
||||
return projected.isFinite ? 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))
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
import SDWebImageSwiftUI
|
||||
import SwiftUI
|
||||
|
||||
struct VideoDetails: View {
|
||||
enum Page {
|
||||
case info, queue, related, comments
|
||||
case info, comments, related, queue
|
||||
}
|
||||
|
||||
@Binding var sidebarQueue: Bool
|
||||
@@ -20,11 +21,15 @@ struct VideoDetails: View {
|
||||
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
@Environment(\.inNavigationView) private var inNavigationView
|
||||
@Environment(\.navigationStyle) private var navigationStyle
|
||||
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||
|
||||
@Default(.showChannelSubscribers) private var showChannelSubscribers
|
||||
@Default(.showKeywords) private var showKeywords
|
||||
|
||||
init(
|
||||
@@ -36,7 +41,7 @@ struct VideoDetails: View {
|
||||
}
|
||||
|
||||
var video: Video? {
|
||||
player.currentItem?.video
|
||||
player.currentVideo
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -65,7 +70,9 @@ struct VideoDetails: View {
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
if CommentsModel.enabled, CommentsModel.placement == .separate {
|
||||
if !sidebarQueue ||
|
||||
(CommentsModel.enabled && CommentsModel.placement == .separate)
|
||||
{
|
||||
pagePicker
|
||||
.padding(.horizontal)
|
||||
}
|
||||
@@ -90,7 +97,7 @@ struct VideoDetails: View {
|
||||
|
||||
switch currentPage {
|
||||
case .info:
|
||||
ScrollView(.vertical) {
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
detailsPage
|
||||
}
|
||||
case .queue:
|
||||
@@ -101,7 +108,7 @@ struct VideoDetails: View {
|
||||
RelatedView()
|
||||
.edgesIgnoringSafeArea(.horizontal)
|
||||
case .comments:
|
||||
CommentsView()
|
||||
CommentsView(embedInScrollView: true)
|
||||
.edgesIgnoringSafeArea(.horizontal)
|
||||
}
|
||||
}
|
||||
@@ -118,7 +125,7 @@ struct VideoDetails: View {
|
||||
}
|
||||
.onChange(of: sidebarQueue) { queue in
|
||||
if queue {
|
||||
if currentPage == .queue {
|
||||
if currentPage == .related || currentPage == .queue {
|
||||
currentPage = .info
|
||||
}
|
||||
} else if video.isNil {
|
||||
@@ -126,7 +133,7 @@ struct VideoDetails: View {
|
||||
}
|
||||
}
|
||||
.edgesIgnoringSafeArea(.horizontal)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
|
||||
var title: some View {
|
||||
@@ -178,21 +185,52 @@ struct VideoDetails: View {
|
||||
Group {
|
||||
if video != nil {
|
||||
HStack(alignment: .center) {
|
||||
HStack(spacing: 4) {
|
||||
if subscribed {
|
||||
Image(systemName: "star.circle.fill")
|
||||
}
|
||||
VStack(alignment: .leading) {
|
||||
Text(video!.channel.name)
|
||||
.font(.system(size: 13))
|
||||
.bold()
|
||||
if let subscribers = video!.channel.subscriptionsString {
|
||||
Text("\(subscribers) subscribers")
|
||||
HStack(spacing: 10) {
|
||||
Group {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
authorAvatar
|
||||
|
||||
if subscribed {
|
||||
Image(systemName: "star.circle.fill")
|
||||
.background(Color.background)
|
||||
.clipShape(Circle())
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text(video!.channel.name)
|
||||
.font(.system(size: 14))
|
||||
.bold()
|
||||
|
||||
if showChannelSubscribers {
|
||||
Group {
|
||||
if let subscribers = video!.channel.subscriptionsString {
|
||||
Text("\(subscribers) subscribers")
|
||||
}
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.contentShape(RoundedRectangle(cornerRadius: 12))
|
||||
.contextMenu {
|
||||
if let video = video {
|
||||
Button(action: {
|
||||
NavigationModel.openChannel(
|
||||
video.channel,
|
||||
player: player,
|
||||
recents: recents,
|
||||
navigation: navigation,
|
||||
navigationStyle: navigationStyle
|
||||
)
|
||||
}) {
|
||||
Label("\(video.channel.name) Channel", systemImage: "rectangle.stack.fill.badge.person.crop")
|
||||
}
|
||||
}
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if accounts.app.supportsSubscriptions {
|
||||
Spacer()
|
||||
@@ -209,7 +247,7 @@ struct VideoDetails: View {
|
||||
.alert(isPresented: $presentingUnsubscribeAlert) {
|
||||
Alert(
|
||||
title: Text(
|
||||
"Are you you want to unsubscribe from \(video!.channel.name)?"
|
||||
"Are you sure you want to unsubscribe from \(video!.channel.name)?"
|
||||
),
|
||||
primaryButton: .destructive(Text("Unsubscribe")) {
|
||||
subscriptions.unsubscribe(video!.channel.id)
|
||||
@@ -246,8 +284,7 @@ struct VideoDetails: View {
|
||||
if !video.isNil {
|
||||
Text("Info").tag(Page.info)
|
||||
if CommentsModel.enabled, CommentsModel.placement == .separate {
|
||||
Text("Comments")
|
||||
.tag(Page.comments)
|
||||
Text("Comments").tag(Page.comments)
|
||||
}
|
||||
if !sidebarQueue {
|
||||
Text("Related").tag(Page.related)
|
||||
@@ -315,28 +352,32 @@ struct VideoDetails: View {
|
||||
|
||||
if let likes = video.likesCount {
|
||||
Divider()
|
||||
.frame(minHeight: 35)
|
||||
|
||||
videoDetail(label: "Likes", value: likes, symbol: "hand.thumbsup")
|
||||
}
|
||||
|
||||
if let dislikes = video.dislikesCount {
|
||||
Divider()
|
||||
.frame(minHeight: 35)
|
||||
|
||||
videoDetail(label: "Dislikes", value: dislikes, symbol: "hand.thumbsdown")
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if accounts.app.supportsUserPlaylists {
|
||||
Button {
|
||||
presentingAddToPlaylist = true
|
||||
} label: {
|
||||
Label("Add to Playlist", systemImage: "text.badge.plus")
|
||||
.labelStyle(.iconOnly)
|
||||
.help("Add to Playlist...")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Button {
|
||||
presentingAddToPlaylist = true
|
||||
} label: {
|
||||
Label("Add to Playlist", systemImage: "text.badge.plus")
|
||||
.labelStyle(.iconOnly)
|
||||
.help("Add to Playlist...")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.opacity(accounts.app.supportsUserPlaylists ? 1 : 0)
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 35, alignment: .trailing)
|
||||
#endif
|
||||
}
|
||||
.frame(maxHeight: 35)
|
||||
.foregroundColor(.secondary)
|
||||
@@ -364,11 +405,27 @@ struct VideoDetails: View {
|
||||
ContentItem(video: player.currentVideo!)
|
||||
}
|
||||
|
||||
private var authorAvatar: some View {
|
||||
Group {
|
||||
if let video = video, let url = video.channel.thumbnailURL {
|
||||
WebImage(url: url)
|
||||
.resizable()
|
||||
.placeholder {
|
||||
Rectangle().fill(Color("PlaceholderColor"))
|
||||
}
|
||||
.retryOnAppear(true)
|
||||
.indicator(.activity)
|
||||
.clipShape(Circle())
|
||||
.frame(width: 45, height: 45, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var detailsPage: some View {
|
||||
Group {
|
||||
Group {
|
||||
if let video = player.currentItem?.video {
|
||||
Group {
|
||||
if let video = player.currentVideo {
|
||||
VStack(spacing: 6) {
|
||||
HStack {
|
||||
publishedDateSection
|
||||
Spacer()
|
||||
@@ -377,9 +434,9 @@ struct VideoDetails: View {
|
||||
Divider()
|
||||
|
||||
countsSection
|
||||
}
|
||||
|
||||
Divider()
|
||||
Divider()
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if let description = video.description {
|
||||
@@ -414,7 +471,7 @@ struct VideoDetails: View {
|
||||
.foregroundColor(.white)
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, 8)
|
||||
.background(Color("VideoDetailLikesSymbolColor"))
|
||||
.background(Color("KeywordBackgroundColor"))
|
||||
.mask(RoundedRectangle(cornerRadius: 3))
|
||||
}
|
||||
}
|
||||
@@ -435,7 +492,7 @@ struct VideoDetails: View {
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
Group {
|
||||
LazyVStack {
|
||||
if !video.isNil, CommentsModel.placement == .info {
|
||||
CommentsView()
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ import SwiftUI
|
||||
struct VideoDetailsPaddingModifier: ViewModifier {
|
||||
static var defaultAdditionalDetailsPadding: Double {
|
||||
#if os(macOS)
|
||||
30
|
||||
5
|
||||
#else
|
||||
40
|
||||
10
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import AVKit
|
||||
#if os(iOS)
|
||||
import CoreMotion
|
||||
#endif
|
||||
import Defaults
|
||||
import Siesta
|
||||
import SwiftUI
|
||||
@@ -14,16 +17,28 @@ struct VideoPlayerView: View {
|
||||
}
|
||||
|
||||
@State private var playerSize: CGSize = .zero
|
||||
@State private var fullScreen = false
|
||||
@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
|
||||
|
||||
@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
|
||||
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@EnvironmentObject<PlayerControlsModel> private var playerControls
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
|
||||
var body: some View {
|
||||
@@ -31,19 +46,45 @@ struct VideoPlayerView: View {
|
||||
HSplitView {
|
||||
content
|
||||
}
|
||||
.frame(idealWidth: 1000, maxWidth: 1100, minHeight: 700)
|
||||
.onOpenURL { OpenURLHandler(accounts: accounts, player: player).handle($0) }
|
||||
.frame(minWidth: 950, minHeight: 700)
|
||||
#else
|
||||
GeometryReader { geometry in
|
||||
HStack(spacing: 0) {
|
||||
content
|
||||
}
|
||||
.onAppear {
|
||||
self.playerSize = geometry.size
|
||||
.onAppear {
|
||||
playerSize = geometry.size
|
||||
|
||||
#if os(iOS)
|
||||
configureOrientationUpdatesBasedOnAccelerometer()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.onChange(of: geometry.size) { size in
|
||||
self.playerSize = size
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.onChange(of: fullScreenDetails) { value in
|
||||
player.backend.setNeedsDrawing(!value)
|
||||
}
|
||||
#if os(iOS)
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
|
||||
handleOrientationDidChangeNotification()
|
||||
}
|
||||
.onDisappear {
|
||||
guard !playerControls.playingFullscreen else {
|
||||
return // swiftlint:disable:this implicit_return
|
||||
}
|
||||
|
||||
if Defaults[.lockPortraitWhenBrowsing] {
|
||||
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
|
||||
} else {
|
||||
Orientation.lockOrientation(.allButUpsideDown)
|
||||
}
|
||||
|
||||
motionManager?.stopAccelerometerUpdates()
|
||||
motionManager = nil
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -53,74 +94,129 @@ struct VideoPlayerView: View {
|
||||
Group {
|
||||
#if os(tvOS)
|
||||
player.playerView
|
||||
.ignoresSafeArea(.all, edges: .all)
|
||||
#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 {
|
||||
#if os(macOS)
|
||||
Player()
|
||||
.modifier(VideoPlayerSizeModifier(geometry: geometry, aspectRatio: player.controller?.aspectRatio))
|
||||
ZStack(alignment: .top) {
|
||||
switch player.activeBackend {
|
||||
case .mpv:
|
||||
player.mpvPlayerView
|
||||
.overlay(GeometryReader { proxy in
|
||||
Color.clear
|
||||
.onAppear {
|
||||
player.playerSize = proxy.size
|
||||
// TODO: move to backend method
|
||||
player.mpvBackend.client?.setSize(proxy.size.width, proxy.size.height)
|
||||
}
|
||||
.onChange(of: proxy.size) { _ in
|
||||
player.playerSize = proxy.size
|
||||
player.mpvBackend.client?.setSize(proxy.size.width, proxy.size.height)
|
||||
}
|
||||
})
|
||||
case .appleAVPlayer:
|
||||
player.avPlayerView
|
||||
}
|
||||
|
||||
#else
|
||||
player.playerView
|
||||
.modifier(VideoPlayerSizeModifier(geometry: geometry, aspectRatio: player.controller?.aspectRatio))
|
||||
#endif
|
||||
PlayerGestures()
|
||||
|
||||
PlayerControls(player: player)
|
||||
}
|
||||
.modifier(
|
||||
VideoPlayerSizeModifier(
|
||||
geometry: geometry,
|
||||
aspectRatio: player.avPlayerBackend.controller?.aspectRatio,
|
||||
fullScreen: playerControls.playingFullscreen
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: fullScreenLayout ? .infinity : nil, maxHeight: fullScreenLayout ? .infinity : nil)
|
||||
.onHover { hovering in
|
||||
hoveringPlayer = hovering
|
||||
hovering ? playerControls.show() : playerControls.hide()
|
||||
}
|
||||
#if os(iOS)
|
||||
.onSwipeGesture(
|
||||
up: {
|
||||
withAnimation {
|
||||
fullScreen = true
|
||||
fullScreenDetails = true
|
||||
}
|
||||
},
|
||||
down: { presentationMode.wrappedValue.dismiss() }
|
||||
down: { player.hide() }
|
||||
)
|
||||
|
||||
#elseif os(macOS)
|
||||
.onAppear(perform: {
|
||||
NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved]) {
|
||||
if hoveringPlayer {
|
||||
playerControls.resetTimer()
|
||||
}
|
||||
|
||||
return $0
|
||||
}
|
||||
})
|
||||
#endif
|
||||
|
||||
.background(Color.black)
|
||||
|
||||
Group {
|
||||
#if os(iOS)
|
||||
if verticalSizeClass == .regular {
|
||||
VideoDetails(sidebarQueue: sidebarQueueBinding, fullScreen: $fullScreen)
|
||||
}
|
||||
if !playerControls.playingFullscreen {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
if verticalSizeClass == .regular {
|
||||
VideoDetails(sidebarQueue: sidebarQueueBinding, fullScreen: $fullScreenDetails)
|
||||
}
|
||||
|
||||
#else
|
||||
VideoDetails(sidebarQueue: sidebarQueueBinding, fullScreen: $fullScreen)
|
||||
#endif
|
||||
#else
|
||||
VideoDetails(sidebarQueue: sidebarQueueBinding, fullScreen: $fullScreenDetails)
|
||||
#endif
|
||||
}
|
||||
.background(colorScheme == .dark ? Color.black : Color.white)
|
||||
.modifier(VideoDetailsPaddingModifier(
|
||||
geometry: geometry,
|
||||
aspectRatio: player.avPlayerBackend.controller?.aspectRatio,
|
||||
fullScreen: fullScreenDetails
|
||||
))
|
||||
}
|
||||
.background(colorScheme == .dark ? Color.black : Color.white)
|
||||
.modifier(VideoDetailsPaddingModifier(geometry: geometry, aspectRatio: player.controller?.aspectRatio, fullScreen: fullScreen))
|
||||
}
|
||||
#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: $fullScreen)
|
||||
.frame(maxWidth: 350)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
if Defaults[.playerSidebar] != .never {
|
||||
PlayerQueueView(sidebarQueue: sidebarQueueBinding, fullScreen: $fullScreen)
|
||||
.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(macOS)
|
||||
.statusBar(hidden: playerControls.playingFullscreen)
|
||||
.navigationBarHidden(true)
|
||||
#endif
|
||||
}
|
||||
|
||||
var fullScreenLayout: Bool {
|
||||
#if !os(macOS)
|
||||
playerControls.playingFullscreen || verticalSizeClass == .compact
|
||||
#else
|
||||
playerControls.playingFullscreen
|
||||
#endif
|
||||
}
|
||||
|
||||
func playerPlaceholder(geometry: GeometryProxy) -> some View {
|
||||
@@ -143,6 +239,35 @@ struct VideoPlayerView: View {
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: geometry.size.width / VideoPlayerView.defaultAspectRatio)
|
||||
}
|
||||
|
||||
func pictureInPicturePlaceholder(geometry: GeometryProxy) -> some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
VStack {
|
||||
Spacer()
|
||||
VStack(spacing: 10) {
|
||||
#if !os(tvOS)
|
||||
Image(systemName: "pip")
|
||||
.font(.system(size: 120))
|
||||
#endif
|
||||
|
||||
Text("Playing in Picture in Picture")
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.foregroundColor(.gray)
|
||||
Spacer()
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
player.closePiP()
|
||||
} label: {
|
||||
Label("Exit Picture in Picture", systemImage: "pip.exit")
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: geometry.size.width / VideoPlayerView.defaultAspectRatio)
|
||||
}
|
||||
|
||||
var sidebarQueue: Bool {
|
||||
switch Defaults[.playerSidebar] {
|
||||
case .never:
|
||||
@@ -160,6 +285,119 @@ struct VideoPlayerView: View {
|
||||
set: { _ in }
|
||||
)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private func configureOrientationUpdatesBasedOnAccelerometer() {
|
||||
if UIDevice.current.orientation.isLandscape,
|
||||
enterFullscreenInLandscape,
|
||||
!playerControls.playingFullscreen,
|
||||
!player.playingInPictureInPicture
|
||||
{
|
||||
DispatchQueue.main.async {
|
||||
player.enterFullScreen()
|
||||
}
|
||||
}
|
||||
|
||||
guard !honorSystemOrientationLock, motionManager.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
motionManager = CMMotionManager()
|
||||
motionManager.accelerometerUpdateInterval = 0.2
|
||||
motionManager.startAccelerometerUpdates(to: OperationQueue()) { data, _ in
|
||||
guard player.presentingPlayer, !player.playingInPictureInPicture, !data.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let acceleration = data?.acceleration else {
|
||||
return
|
||||
}
|
||||
|
||||
var orientation = UIInterfaceOrientation.unknown
|
||||
|
||||
if acceleration.x >= 0.65 {
|
||||
orientation = .landscapeLeft
|
||||
} else if acceleration.x <= -0.65 {
|
||||
orientation = .landscapeRight
|
||||
} else if acceleration.y <= -0.65 {
|
||||
orientation = .portrait
|
||||
} else if acceleration.y >= 0.65 {
|
||||
orientation = .portraitUpsideDown
|
||||
}
|
||||
|
||||
guard lastOrientation != orientation else {
|
||||
return
|
||||
}
|
||||
|
||||
lastOrientation = orientation
|
||||
|
||||
if orientation.isLandscape {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
|
||||
guard enterFullscreenInLandscape else {
|
||||
return
|
||||
}
|
||||
|
||||
player.enterFullScreen()
|
||||
|
||||
let orientationLockMask = orientation == .landscapeLeft ?
|
||||
UIInterfaceOrientationMask.landscapeLeft : .landscapeRight
|
||||
|
||||
Orientation.lockOrientation(orientationLockMask, andRotateTo: orientation)
|
||||
|
||||
guard lockLandscapeOnRotation else {
|
||||
return
|
||||
}
|
||||
|
||||
player.lockedOrientation = orientation
|
||||
}
|
||||
} else {
|
||||
guard abs(acceleration.z) <= 0.74,
|
||||
player.lockedOrientation.isNil,
|
||||
enterFullscreenInLandscape
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
|
||||
player.exitFullScreen()
|
||||
}
|
||||
|
||||
Orientation.lockOrientation(.portrait)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleOrientationDidChangeNotification() {
|
||||
let newOrientation = UIApplication.shared.windows.first?.windowScene?.interfaceOrientation
|
||||
if newOrientation?.isLandscape ?? false,
|
||||
player.presentingPlayer,
|
||||
lockLandscapeOnRotation,
|
||||
!player.lockedOrientation.isNil
|
||||
{
|
||||
Orientation.lockOrientation(.landscape, andRotateTo: newOrientation)
|
||||
return
|
||||
}
|
||||
|
||||
guard player.presentingPlayer, enterFullscreenInLandscape, honorSystemOrientationLock else {
|
||||
return
|
||||
}
|
||||
|
||||
if UIDevice.current.orientation.isLandscape {
|
||||
DispatchQueue.main.async {
|
||||
player.lockedOrientation = newOrientation
|
||||
player.enterFullScreen()
|
||||
}
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
player.exitFullScreen()
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
|
||||
player.exitFullScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
struct VideoPlayerView_Previews: PreviewProvider {
|
||||
|
||||
@@ -7,8 +7,12 @@ struct AddToPlaylistView: View {
|
||||
|
||||
@State private var selectedPlaylistID: Playlist.ID = ""
|
||||
|
||||
@State private var error = ""
|
||||
@State private var presentingErrorAlert = false
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
|
||||
@EnvironmentObject<PlaylistsModel> private var model
|
||||
|
||||
var body: some View {
|
||||
@@ -120,6 +124,12 @@ struct AddToPlaylistView: View {
|
||||
Button("Add to Playlist", action: addToPlaylist)
|
||||
.disabled(selectedPlaylist.isNil)
|
||||
.padding(.top, 30)
|
||||
.alert(isPresented: $presentingErrorAlert) {
|
||||
Alert(
|
||||
title: Text("Error when accessing playlist"),
|
||||
message: Text(error)
|
||||
)
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
#endif
|
||||
@@ -155,9 +165,17 @@ struct AddToPlaylistView: View {
|
||||
|
||||
Defaults[.lastUsedPlaylistID] = id
|
||||
|
||||
model.addVideo(playlistID: id, videoID: video.videoID) {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
model.addVideo(
|
||||
playlistID: id,
|
||||
videoID: video.videoID,
|
||||
onSuccess: {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
},
|
||||
onFailure: { requestError in
|
||||
error = "(\(requestError.httpStatusCode ?? -1)) \(requestError.userMessage)"
|
||||
presentingErrorAlert = true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var selectedPlaylist: Playlist? {
|
||||
|
||||
@@ -8,7 +8,10 @@ struct PlaylistFormView: View {
|
||||
@State private var visibility = Playlist.Visibility.public
|
||||
|
||||
@State private var valid = false
|
||||
@State private var showingDeleteConfirmation = false
|
||||
@State private var presentingDeleteConfirmation = false
|
||||
|
||||
@State private var formError = ""
|
||||
@State private var presentingErrorAlert = false
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
@@ -57,6 +60,12 @@ struct PlaylistFormView: View {
|
||||
|
||||
Button("Save", action: submitForm)
|
||||
.disabled(!valid)
|
||||
.alert(isPresented: $presentingErrorAlert) {
|
||||
Alert(
|
||||
title: Text("Error when accessing playlist"),
|
||||
message: Text(formError)
|
||||
)
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
.frame(minHeight: 35)
|
||||
@@ -165,15 +174,21 @@ struct PlaylistFormView: View {
|
||||
|
||||
let body = ["title": name, "privacy": visibility.rawValue]
|
||||
|
||||
resource?.request(editing ? .patch : .post, json: body).onSuccess { response in
|
||||
if let modifiedPlaylist: Playlist = response.typedContent() {
|
||||
playlist = modifiedPlaylist
|
||||
resource?
|
||||
.request(editing ? .patch : .post, json: body)
|
||||
.onSuccess { response in
|
||||
if let modifiedPlaylist: Playlist = response.typedContent() {
|
||||
playlist = modifiedPlaylist
|
||||
}
|
||||
|
||||
playlists.load(force: true)
|
||||
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
.onFailure { error in
|
||||
formError = "(\(error.httpStatusCode ?? -1)) \(error.userMessage)"
|
||||
presentingErrorAlert = true
|
||||
}
|
||||
|
||||
playlists.load(force: true)
|
||||
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
var resource: Resource? {
|
||||
@@ -207,9 +222,9 @@ struct PlaylistFormView: View {
|
||||
|
||||
var deletePlaylistButton: some View {
|
||||
Button("Delete") {
|
||||
showingDeleteConfirmation = true
|
||||
presentingDeleteConfirmation = true
|
||||
}
|
||||
.alert(isPresented: $showingDeleteConfirmation) {
|
||||
.alert(isPresented: $presentingDeleteConfirmation) {
|
||||
Alert(
|
||||
title: Text("Are you sure you want to delete playlist?"),
|
||||
message: Text("Playlist \"\(playlist.title)\" will be deleted.\nIt cannot be undone."),
|
||||
@@ -221,11 +236,17 @@ struct PlaylistFormView: View {
|
||||
}
|
||||
|
||||
func deletePlaylistAndDismiss() {
|
||||
accounts.api.playlist(playlist.id)?.request(.delete).onSuccess { _ in
|
||||
playlist = nil
|
||||
playlists.load(force: true)
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
accounts.api.playlist(playlist.id)?
|
||||
.request(.delete)
|
||||
.onSuccess { _ in
|
||||
playlist = nil
|
||||
playlists.load(force: true)
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
.onFailure { error in
|
||||
formError = "(\(error.httpStatusCode ?? -1)) \(error.localizedDescription)"
|
||||
presentingErrorAlert = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,43 @@ struct PlaylistsView: View {
|
||||
}
|
||||
|
||||
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 +77,7 @@ struct PlaylistsView: View {
|
||||
Spacer()
|
||||
#else
|
||||
VerticalCells(items: items)
|
||||
.environment(\.scrollViewBottomPadding, 70)
|
||||
#endif
|
||||
}
|
||||
.environment(\.currentPlaylistID, currentPlaylist?.id)
|
||||
@@ -48,6 +85,12 @@ struct PlaylistsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
model.load()
|
||||
}
|
||||
.onChange(of: accounts.current) { _ in
|
||||
model.load(force: true)
|
||||
}
|
||||
#if os(tvOS)
|
||||
.fullScreenCover(isPresented: $showingNewPlaylist, onDismiss: selectCreatedPlaylist) {
|
||||
PlaylistFormView(playlist: $createdPlaylist)
|
||||
@@ -57,76 +100,26 @@ 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 {
|
||||
ToolbarItemGroup {
|
||||
#if !os(iOS)
|
||||
if !model.isEmpty {
|
||||
if #available(macOS 12.0, *) {
|
||||
selectPlaylistButton
|
||||
.prefersDefaultFocus(in: focusNamespace)
|
||||
} else {
|
||||
selectPlaylistButton
|
||||
}
|
||||
}
|
||||
|
||||
if currentPlaylist != nil {
|
||||
editPlaylistButton
|
||||
}
|
||||
#endif
|
||||
|
||||
FavoriteButton(item: FavoriteItem(section: .playlist(selectedPlaylistID)))
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
ToolbarItemGroup(placement: .bottomBar) {
|
||||
Group {
|
||||
if model.isEmpty {
|
||||
Text("No Playlists")
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Text("Current Playlist")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
selectPlaylistButton
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
newPlaylistButton
|
||||
|
||||
if currentPlaylist != nil {
|
||||
editPlaylistButton
|
||||
}
|
||||
}
|
||||
.transaction { t in t.animation = .none }
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(tvOS)
|
||||
.focusScope(focusNamespace)
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(RefreshControl.navigationBarTitleDisplayMode)
|
||||
#endif
|
||||
.onAppear {
|
||||
model.load()
|
||||
}
|
||||
.onChange(of: accounts.current) { _ in
|
||||
model.load(force: true)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
@@ -142,23 +135,14 @@ struct PlaylistsView: View {
|
||||
selectPlaylistButton
|
||||
}
|
||||
|
||||
Button {
|
||||
player.playAll(items.compactMap(\.video))
|
||||
player.presentPlayer()
|
||||
} label: {
|
||||
HStack(spacing: 15) {
|
||||
Image(systemName: "play.fill")
|
||||
Text("Play All")
|
||||
}
|
||||
}
|
||||
|
||||
if currentPlaylist != nil {
|
||||
editPlaylistButton
|
||||
}
|
||||
|
||||
if let playlist = currentPlaylist {
|
||||
editPlaylistButton
|
||||
|
||||
FavoriteButton(item: FavoriteItem(section: .playlist(playlist.id)))
|
||||
.labelStyle(.iconOnly)
|
||||
|
||||
playButton
|
||||
shuffleButton
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@@ -179,7 +163,7 @@ struct PlaylistsView: View {
|
||||
}
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
|
||||
#if os(macOS)
|
||||
.background(Color.tertiaryBackground)
|
||||
.background(Color.secondaryBackground)
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -228,7 +212,7 @@ struct PlaylistsView: View {
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
#else
|
||||
Menu(currentPlaylist?.title ?? "Select playlist") {
|
||||
Menu {
|
||||
ForEach(model.all) { playlist in
|
||||
Button(action: { selectedPlaylistID = playlist.id }) {
|
||||
if playlist == currentPlaylist {
|
||||
@@ -238,6 +222,9 @@ struct PlaylistsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text(currentPlaylist?.title ?? "Select playlist")
|
||||
.frame(maxWidth: 140, alignment: .center)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -248,16 +235,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
|
||||
@@ -265,6 +253,26 @@ struct PlaylistsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var playButton: some View {
|
||||
Button {
|
||||
player.play(items.compactMap(\.video))
|
||||
} label: {
|
||||
Image(systemName: "play")
|
||||
.padding(8)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
private var shuffleButton: some View {
|
||||
Button {
|
||||
player.play(items.compactMap(\.video), shuffling: true)
|
||||
} label: {
|
||||
Image(systemName: "shuffle")
|
||||
.padding(8)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
private var currentPlaylist: Playlist? {
|
||||
model.find(id: selectedPlaylistID) ?? model.all.first
|
||||
}
|
||||
|
||||
54
Shared/RepeatingTimer.swift
Normal file
54
Shared/RepeatingTimer.swift
Normal file
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
|
||||
final class RepeatingTimer {
|
||||
let timeInterval: TimeInterval
|
||||
|
||||
init(timeInterval: TimeInterval) {
|
||||
self.timeInterval = timeInterval
|
||||
}
|
||||
|
||||
private lazy var timer: DispatchSourceTimer = {
|
||||
let t = DispatchSource.makeTimerSource()
|
||||
t.schedule(deadline: .now() + self.timeInterval, repeating: self.timeInterval)
|
||||
t.setEventHandler { [weak self] in
|
||||
self?.eventHandler?()
|
||||
}
|
||||
return t
|
||||
}()
|
||||
|
||||
var eventHandler: (() -> Void)?
|
||||
|
||||
private enum State {
|
||||
case suspended
|
||||
case resumed
|
||||
}
|
||||
|
||||
private var state: State = .suspended
|
||||
|
||||
deinit {
|
||||
timer.setEventHandler {}
|
||||
timer.cancel()
|
||||
/*
|
||||
If the timer is suspended, calling cancel without resuming
|
||||
triggers a crash. This is documented here https://forums.developer.apple.com/thread/15902
|
||||
*/
|
||||
resume()
|
||||
eventHandler = nil
|
||||
}
|
||||
|
||||
func resume() {
|
||||
if state == .resumed {
|
||||
return
|
||||
}
|
||||
state = .resumed
|
||||
timer.resume()
|
||||
}
|
||||
|
||||
func suspend() {
|
||||
if state == .suspended {
|
||||
return
|
||||
}
|
||||
state = .suspended
|
||||
timer.suspend()
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
struct SearchTextField: View {
|
||||
@Environment(\.navigationStyle) private var navigationStyle
|
||||
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
@EnvironmentObject<SearchModel> private var state
|
||||
|
||||
@@ -29,7 +30,7 @@ struct SearchTextField: View {
|
||||
#endif
|
||||
TextField("Search...", text: $state.queryText) {
|
||||
state.changeQuery { query in query.query = state.queryText }
|
||||
recents.addQuery(state.queryText)
|
||||
recents.addQuery(state.queryText, navigation: navigation)
|
||||
}
|
||||
.onChange(of: state.queryText) { _ in
|
||||
if state.query.query.compare(state.queryText, options: .caseInsensitive) == .orderedSame {
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SearchSuggestions: View {
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
@EnvironmentObject<SearchModel> private var state
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Button {
|
||||
state.changeQuery { query in
|
||||
query.query = state.queryText
|
||||
state.fieldIsFocused = false
|
||||
}
|
||||
|
||||
recents.addQuery(state.queryText)
|
||||
runQueryAction()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
@@ -20,28 +16,50 @@ struct SearchSuggestions: View {
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 5)
|
||||
|
||||
#if os(macOS)
|
||||
.onHover(perform: onHover(_:))
|
||||
.onHover(perform: onHover(_:))
|
||||
#endif
|
||||
|
||||
ForEach(visibleSuggestions, id: \.self) { suggestion in
|
||||
Button {
|
||||
state.queryText = suggestion
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "arrow.up.left.circle")
|
||||
.foregroundColor(.secondary)
|
||||
HStack(spacing: 0) {
|
||||
Text(state.suggestionsText)
|
||||
.lineLimit(1)
|
||||
.layoutPriority(2)
|
||||
.foregroundColor(.secondary)
|
||||
HStack {
|
||||
Button {
|
||||
state.queryText = suggestion
|
||||
runQueryAction()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
HStack(spacing: 0) {
|
||||
if suggestion.hasPrefix(state.suggestionsText.lowercased()) {
|
||||
Text(state.suggestionsText.lowercased())
|
||||
.lineLimit(1)
|
||||
.layoutPriority(2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Text(querySuffix(suggestion))
|
||||
.lineLimit(1)
|
||||
.layoutPriority(1)
|
||||
Text(querySuffix(suggestion))
|
||||
.lineLimit(1)
|
||||
.layoutPriority(1)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
state.queryText = suggestion
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.left.circle")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
#if os(macOS)
|
||||
.onHover(perform: onHover(_:))
|
||||
@@ -53,6 +71,15 @@ struct SearchSuggestions: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
private func runQueryAction() {
|
||||
state.changeQuery { query in
|
||||
query.query = state.queryText
|
||||
state.fieldIsFocused = false
|
||||
}
|
||||
|
||||
recents.addQuery(state.queryText, navigation: navigation)
|
||||
}
|
||||
|
||||
private var visibleSuggestions: [String] {
|
||||
state.querySuggestions.collection.filter {
|
||||
$0.compare(state.queryText, options: .caseInsensitive) != .orderedSame
|
||||
|
||||
@@ -21,6 +21,8 @@ struct SearchView: View {
|
||||
@Environment(\.navigationStyle) private var navigationStyle
|
||||
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
@EnvironmentObject<SearchModel> private var state
|
||||
private var favorites = FavoritesModel.shared
|
||||
@@ -39,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)
|
||||
@@ -68,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 }
|
||||
}
|
||||
@@ -97,9 +107,7 @@ struct SearchView: View {
|
||||
filtersMenu
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
SearchTextField()
|
||||
#endif
|
||||
SearchTextField()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -175,11 +183,40 @@ struct SearchView: View {
|
||||
.navigationTitle("Search")
|
||||
#endif
|
||||
#if os(iOS)
|
||||
.navigationBarHidden(!Defaults[.visibleSections].isEmpty || navigationStyle == .sidebar)
|
||||
.navigationBarHidden(navigationBarHidden)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var navigationBarHidden: Bool {
|
||||
if navigationStyle == .sidebar {
|
||||
return true
|
||||
}
|
||||
|
||||
let preferred = Defaults[.visibleSections]
|
||||
var visibleSections = [VisibleSection]()
|
||||
|
||||
if accounts.app.supportsPopular && preferred.contains(.popular) {
|
||||
visibleSections.append(.popular)
|
||||
}
|
||||
|
||||
if accounts.app.supportsSubscriptions && accounts.signedIn && preferred.contains(.subscriptions) {
|
||||
visibleSections.append(.subscriptions)
|
||||
}
|
||||
|
||||
if accounts.app.supportsUserPlaylists && preferred.contains(.playlists) {
|
||||
visibleSections.append(.playlists)
|
||||
}
|
||||
|
||||
[VisibleSection.favorites, .trending].forEach { section in
|
||||
if preferred.contains(section) {
|
||||
visibleSections.append(section)
|
||||
}
|
||||
}
|
||||
|
||||
return !visibleSections.isEmpty
|
||||
}
|
||||
|
||||
private var results: some View {
|
||||
VStack {
|
||||
if showRecentQueries {
|
||||
@@ -199,10 +236,12 @@ struct SearchView: View {
|
||||
}
|
||||
|
||||
HorizontalCells(items: items)
|
||||
.environment(\.loadMoreContentHandler) { state.loadNextPage() }
|
||||
}
|
||||
.edgesIgnoringSafeArea(.horizontal)
|
||||
#else
|
||||
VerticalCells(items: items)
|
||||
.environment(\.loadMoreContentHandler) { state.loadNextPage() }
|
||||
#endif
|
||||
|
||||
if noResults {
|
||||
@@ -253,14 +292,51 @@ struct SearchView: View {
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
ForEach(recentItems) { item in
|
||||
Button(item.title) {
|
||||
state.queryText = item.title
|
||||
state.changeQuery { query in query.query = item.title }
|
||||
updateFavoriteItem()
|
||||
Button {
|
||||
switch item.type {
|
||||
case .query:
|
||||
state.queryText = item.title
|
||||
state.changeQuery { query in query.query = item.title }
|
||||
|
||||
updateFavoriteItem()
|
||||
recents.add(item)
|
||||
case .channel:
|
||||
guard let channel = item.channel else {
|
||||
return
|
||||
}
|
||||
|
||||
NavigationModel.openChannel(
|
||||
channel,
|
||||
player: player,
|
||||
recents: recents,
|
||||
navigation: navigation,
|
||||
navigationStyle: navigationStyle,
|
||||
delay: false
|
||||
)
|
||||
case .playlist:
|
||||
guard let playlist = item.playlist else {
|
||||
return
|
||||
}
|
||||
|
||||
NavigationModel.openChannelPlaylist(
|
||||
playlist,
|
||||
player: player,
|
||||
recents: recents,
|
||||
navigation: navigation,
|
||||
navigationStyle: navigationStyle,
|
||||
delay: false
|
||||
)
|
||||
}
|
||||
} label: {
|
||||
let systemImage = item.type == .query ? "magnifyingglass" :
|
||||
item.type == .channel ? RecentsModel.symbolSystemImage(item.title) :
|
||||
"list.and.film"
|
||||
Label(item.title, systemImage: systemImage)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.contextMenu {
|
||||
deleteButton(item)
|
||||
deleteAllButton
|
||||
removeButton(item)
|
||||
removeAllButton
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -272,21 +348,21 @@ struct SearchView: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
private func deleteButton(_ item: RecentItem) -> some View {
|
||||
private func removeButton(_ item: RecentItem) -> some View {
|
||||
Button {
|
||||
recents.close(item)
|
||||
recentsChanged.toggle()
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
Label("Remove", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
|
||||
private var deleteAllButton: some View {
|
||||
private var removeAllButton: some View {
|
||||
Button {
|
||||
recents.clearQueries()
|
||||
recentsChanged.toggle()
|
||||
} label: {
|
||||
Label("Delete All", systemImage: "trash.fill")
|
||||
Label("Remove All", systemImage: "trash.fill")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,7 +371,7 @@ struct SearchView: View {
|
||||
}
|
||||
|
||||
private var recentItems: [RecentItem] {
|
||||
Defaults[.recentlyOpened].filter { $0.type == .query }.reversed()
|
||||
Defaults[.recentlyOpened].reversed()
|
||||
}
|
||||
|
||||
private var searchSortOrderPicker: some View {
|
||||
|
||||
@@ -16,6 +16,7 @@ struct AccountForm: View {
|
||||
@State private var validationDebounce = Debounce()
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.openURL) private var openURL
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
|
||||
var body: some View {
|
||||
@@ -62,6 +63,10 @@ struct AccountForm: View {
|
||||
#if os(macOS)
|
||||
.padding(.horizontal)
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
helpButton
|
||||
#endif
|
||||
}
|
||||
#else
|
||||
formFields
|
||||
@@ -71,6 +76,22 @@ struct AccountForm: View {
|
||||
.onChange(of: password) { _ in validate() }
|
||||
}
|
||||
|
||||
var helpButton: some View {
|
||||
Group {
|
||||
if instance.app == .invidious {
|
||||
Button {
|
||||
openURL(URL(string: "https://github.com/yattee/yattee/wiki/Adding-Invidious-instance-and-account")!)
|
||||
} label: {
|
||||
Label("How to add Invidious account?", systemImage: "questionmark.circle")
|
||||
#if os(macOS)
|
||||
.help("How to add Invidious account?")
|
||||
.labelStyle(.iconOnly)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var formFields: some View {
|
||||
Group {
|
||||
if !instance.app.accountsUsePassword {
|
||||
@@ -105,6 +126,10 @@ struct AccountForm: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
#if os(macOS)
|
||||
helpButton
|
||||
#endif
|
||||
|
||||
Button("Save", action: submitForm)
|
||||
.disabled(!isValid)
|
||||
#if !os(tvOS)
|
||||
|
||||
@@ -15,10 +15,10 @@ struct AccountsNavigationLink: View {
|
||||
}
|
||||
|
||||
private func removeInstanceButton(_ instance: Instance) -> some View {
|
||||
if #available(iOS 15.0, *) {
|
||||
return Button("Remove", role: .destructive) { removeAction(instance) }
|
||||
} else {
|
||||
return Button("Remove") { removeAction(instance) }
|
||||
Button {
|
||||
removeAction(instance)
|
||||
} label: {
|
||||
Label("Remove", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,58 +2,114 @@ import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct BrowsingSettings: View {
|
||||
#if !os(tvOS)
|
||||
@Default(.accountPickerDisplaysUsername) private var accountPickerDisplaysUsername
|
||||
@Default(.roundedThumbnails) private var roundedThumbnails
|
||||
#endif
|
||||
#if os(iOS)
|
||||
@Default(.lockPortraitWhenBrowsing) private var lockPortraitWhenBrowsing
|
||||
#endif
|
||||
@Default(.channelOnThumbnail) private var channelOnThumbnail
|
||||
@Default(.timeOnThumbnail) private var timeOnThumbnail
|
||||
@Default(.saveRecents) private var saveRecents
|
||||
@Default(.saveHistory) private var saveHistory
|
||||
@Default(.visibleSections) private var visibleSections
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
Section(header: SettingsHeader(text: "Browsing")) {
|
||||
Toggle("Show channel name on thumbnail", isOn: $channelOnThumbnail)
|
||||
Toggle("Show video length on thumbnail", isOn: $timeOnThumbnail)
|
||||
Toggle("Save recent queries and channels", isOn: $saveRecents)
|
||||
Toggle("Save history of played videos", isOn: $saveHistory)
|
||||
}
|
||||
Section(header: SettingsHeader(text: "Sections")) {
|
||||
#if os(macOS)
|
||||
let list = ForEach(VisibleSection.allCases, id: \.self) { section in
|
||||
VisibleSectionSelectionRow(
|
||||
title: section.title,
|
||||
selected: visibleSections.contains(section)
|
||||
) { value in
|
||||
toggleSection(section, value: value)
|
||||
}
|
||||
}
|
||||
|
||||
Group {
|
||||
if #available(macOS 12.0, *) {
|
||||
list
|
||||
.listStyle(.inset(alternatesRowBackgrounds: true))
|
||||
} else {
|
||||
list
|
||||
.listStyle(.inset)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
#else
|
||||
ForEach(VisibleSection.allCases, id: \.self) { section in
|
||||
VisibleSectionSelectionRow(
|
||||
title: section.title,
|
||||
selected: visibleSections.contains(section)
|
||||
) { value in
|
||||
toggleSection(section, value: value)
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
sections
|
||||
#else
|
||||
List {
|
||||
sections
|
||||
}
|
||||
#if os(iOS)
|
||||
.listStyle(.insetGrouped)
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(tvOS)
|
||||
.frame(maxWidth: 1000)
|
||||
#else
|
||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
|
||||
#endif
|
||||
.navigationTitle("Browsing")
|
||||
}
|
||||
|
||||
func toggleSection(_ section: VisibleSection, value: Bool) {
|
||||
private var sections: some View {
|
||||
Group {
|
||||
#if !os(tvOS)
|
||||
interfaceSettings
|
||||
#endif
|
||||
thumbnailsSettings
|
||||
visibleSectionsSettings
|
||||
}
|
||||
}
|
||||
|
||||
private var interfaceSettings: some View {
|
||||
Section(header: SettingsHeader(text: "Interface")) {
|
||||
#if os(iOS)
|
||||
Toggle("Lock portrait mode", isOn: $lockPortraitWhenBrowsing)
|
||||
.onChange(of: lockPortraitWhenBrowsing) { lock in
|
||||
if lock {
|
||||
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
|
||||
} else {
|
||||
Orientation.lockOrientation(.allButUpsideDown)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if !os(tvOS)
|
||||
Toggle("Show account username", isOn: $accountPickerDisplaysUsername)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private var thumbnailsSettings: some View {
|
||||
Section(header: SettingsHeader(text: "Thumbnails")) {
|
||||
#if !os(tvOS)
|
||||
Toggle("Round corners", isOn: $roundedThumbnails)
|
||||
#endif
|
||||
Toggle("Show channel name", isOn: $channelOnThumbnail)
|
||||
Toggle("Show video length", isOn: $timeOnThumbnail)
|
||||
}
|
||||
}
|
||||
|
||||
private var visibleSectionsSettings: some View {
|
||||
Section(header: SettingsHeader(text: "Sections")) {
|
||||
#if os(macOS)
|
||||
let list = ForEach(VisibleSection.allCases, id: \.self) { section in
|
||||
VisibleSectionSelectionRow(
|
||||
title: section.title,
|
||||
selected: visibleSections.contains(section)
|
||||
) { value in
|
||||
toggleSection(section, value: value)
|
||||
}
|
||||
}
|
||||
|
||||
Group {
|
||||
if #available(macOS 12.0, *) {
|
||||
list
|
||||
.listStyle(.inset(alternatesRowBackgrounds: true))
|
||||
} else {
|
||||
list
|
||||
.listStyle(.inset)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
#else
|
||||
ForEach(VisibleSection.allCases, id: \.self) { section in
|
||||
VisibleSectionSelectionRow(
|
||||
title: section.title,
|
||||
selected: visibleSections.contains(section)
|
||||
) { value in
|
||||
toggleSection(section, value: value)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleSection(_ section: VisibleSection, value: Bool) {
|
||||
if value {
|
||||
visibleSections.insert(section)
|
||||
} else {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user