Compare commits
174 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86be252bd5 | ||
|
|
20f96fb9d6 | ||
|
|
e539fb0067 | ||
|
|
03d5eefab0 | ||
|
|
0bc4a677d4 | ||
|
|
b374f82da4 | ||
|
|
b70697e1be | ||
|
|
db5765a84b | ||
|
|
8d36f57271 | ||
|
|
836057578f | ||
|
|
e39f4373bb | ||
|
|
1490437537 | ||
|
|
4f1b52826d | ||
|
|
15e62468bb | ||
|
|
1380036c44 | ||
|
|
c893e5dc38 | ||
|
|
8b4838dca5 | ||
|
|
1c520831d1 | ||
|
|
8770bfb56d | ||
|
|
ae4796a4c5 | ||
|
|
70b55ec2b2 | ||
|
|
c14a4a153d | ||
|
|
c8fa972a61 | ||
|
|
cc7bb83e74 | ||
|
|
6a65123876 | ||
|
|
aa42551c7c | ||
|
|
9d8a2607ab | ||
|
|
b4a0835a43 | ||
|
|
066e048022 | ||
|
|
d825cd8b20 | ||
|
|
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 | ||
|
|
fe33cc5e3a | ||
|
|
7e7b4e89b5 | ||
|
|
d88292662f | ||
|
|
21b04e21c4 | ||
|
|
a44a61b017 | ||
|
|
1b090fcd51 | ||
|
|
12eb4401b5 | ||
|
|
170f2ee94e | ||
|
|
fe56739211 | ||
|
|
759a942426 | ||
|
|
8d9bbf647a | ||
|
|
eeb7b1f151 | ||
|
|
62bff9283c | ||
|
|
3624c9619a | ||
|
|
f7fc2369e3 | ||
|
|
82ea8733ec | ||
|
|
1f495562fc | ||
|
|
37b99c59e1 | ||
|
|
7f9b53bd1f | ||
|
|
941e6a909d | ||
|
|
5143c4f8ce | ||
|
|
19a3f08336 | ||
|
|
eb537676e6 | ||
|
|
e97daa1944 | ||
|
|
bd59b8e2c3 | ||
|
|
19b146c6ad | ||
|
|
dd995105b5 | ||
|
|
c4b5c7ce41 | ||
|
|
cc2bf90218 | ||
|
|
45c917160e | ||
|
|
9f5e9ea237 | ||
|
|
1c61ad37a9 | ||
|
|
06f7391ad9 | ||
|
|
e61d1dfe2e | ||
|
|
ff83abd103 | ||
|
|
500c55689b | ||
|
|
f60f7a0455 | ||
|
|
52ab162a6c | ||
|
|
c6f077dcd3 | ||
|
|
b5ffa5b267 | ||
|
|
5ef89ac9f4 | ||
|
|
696751e07c | ||
|
|
e88219ce88 | ||
|
|
adf4157ff3 | ||
|
|
c78dd4a35e | ||
|
|
8d2694df33 | ||
|
|
0e3effd512 | ||
|
|
a70d4f3b38 | ||
|
|
6328bfbfab | ||
|
|
184992ea32 | ||
|
|
dd8d6b6c4a |
@@ -7,6 +7,7 @@ disabled_rules:
|
||||
- multiline_arguments
|
||||
|
||||
excluded:
|
||||
- Vendor
|
||||
- Tests Apple TV
|
||||
- Tests iOS
|
||||
- Tests macOS
|
||||
|
||||
13
Backports/Backport.swift
Normal file
@@ -0,0 +1,13 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct Backport<Content> {
|
||||
public let content: Content
|
||||
|
||||
public init(_ content: Content) {
|
||||
self.content = content
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
var backport: Backport<Self> { Backport(self) }
|
||||
}
|
||||
16
Backports/Badge+Backport.swift
Normal file
@@ -0,0 +1,16 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func badge(_ count: Text) -> some View {
|
||||
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
|
||||
content.badge(count)
|
||||
} else {
|
||||
HStack {
|
||||
content
|
||||
Spacer()
|
||||
Text("\(count)")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Backports/Tint+Backport.swift
Normal file
@@ -0,0 +1,11 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func tint(_ color: Color?) -> some View {
|
||||
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
|
||||
content.tint(color)
|
||||
} else {
|
||||
content.foregroundColor(color)
|
||||
}
|
||||
}
|
||||
}
|
||||
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
@@ -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
|
||||
15
Extensions/Color+Background.swift
Normal file
@@ -0,0 +1,15 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Color {
|
||||
#if os(macOS)
|
||||
static let background = Color(NSColor.windowBackgroundColor)
|
||||
static let secondaryBackground = Color(NSColor.controlBackgroundColor)
|
||||
#elseif os(iOS)
|
||||
static let background = Color(UIColor.systemBackground)
|
||||
static let secondaryBackground = Color(UIColor.secondarySystemBackground)
|
||||
#else
|
||||
static func background(scheme: ColorScheme) -> Color {
|
||||
scheme == .dark ? .black : .init(white: 0.8)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import Foundation
|
||||
|
||||
extension Double {
|
||||
func formattedAsPlaybackTime() -> String? {
|
||||
guard !isZero else {
|
||||
guard !isZero, isFinite else {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -14,4 +14,13 @@ extension Double {
|
||||
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
func formattedAsRelativeTime() -> String? {
|
||||
let date = Date(timeIntervalSince1970: self)
|
||||
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .full
|
||||
|
||||
return formatter.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
}
|
||||
|
||||
8
Extensions/NSTextField+FocusRingType.swift
Normal file
@@ -0,0 +1,8 @@
|
||||
import AppKit
|
||||
|
||||
extension NSTextField {
|
||||
override open var focusRingType: NSFocusRingType {
|
||||
get { .none }
|
||||
set {}
|
||||
}
|
||||
}
|
||||
10
Extensions/String+Format.swift
Normal file
@@ -0,0 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
func replacingFirstOccurrence(of target: String, with replacement: String) -> String {
|
||||
guard let range = range(of: target) else {
|
||||
return self
|
||||
}
|
||||
return replacingCharacters(in: range, with: replacement)
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,21 @@ extension View {
|
||||
verticalEdgeBorder(.bottom, height: height, color: color)
|
||||
}
|
||||
|
||||
func borderLeading(width: Double, color: Color = Color(white: 0.7, opacity: 1)) -> some View {
|
||||
horizontalEdgeBorder(.leading, width: width, color: color)
|
||||
}
|
||||
|
||||
func borderTrailing(width: Double, color: Color = Color(white: 0.7, opacity: 1)) -> some View {
|
||||
horizontalEdgeBorder(.trailing, width: width, color: color)
|
||||
}
|
||||
|
||||
private func verticalEdgeBorder(_ edge: Alignment, height: Double, color: Color) -> some View {
|
||||
overlay(Rectangle().frame(width: nil, height: height, alignment: .top).foregroundColor(color), alignment: edge)
|
||||
overlay(Rectangle().frame(width: nil, height: height, alignment: .top)
|
||||
.foregroundColor(color), alignment: edge)
|
||||
}
|
||||
|
||||
private func horizontalEdgeBorder(_ edge: Alignment, width: Double, color: Color) -> some View {
|
||||
overlay(Rectangle().frame(width: width, height: nil, alignment: .leading)
|
||||
.foregroundColor(color), alignment: edge)
|
||||
}
|
||||
}
|
||||
|
||||
18
Fixtures/Comment+Fixtures.swift
Normal file
@@ -0,0 +1,18 @@
|
||||
import Foundation
|
||||
|
||||
extension Comment {
|
||||
static var fixture: Comment {
|
||||
Comment(
|
||||
id: UUID().uuidString,
|
||||
author: "The Author",
|
||||
authorAvatarURL: "https://pipedproxy-ams-2.kavin.rocks/Si7ZhtmpX84wj6MoJYLs8kwALw2Hm53wzbrPamoU-z3qvCKs2X3zPNYKMSJEvPDLUHzbvTfLcg=s176-c-k-c0x00ffffff-no-rw?host=yt3.ggpht.com",
|
||||
time: "2 months ago",
|
||||
pinned: true,
|
||||
hearted: true,
|
||||
likeCount: 30032,
|
||||
text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut luctus feugiat mi, suscipit pharetra lectus dapibus vel. Vivamus orci erat, sagittis sit amet dui vel, feugiat cursus ante. Pellentesque eget orci tortor. Suspendisse pulvinar orci tortor, eu scelerisque neque consequat nec. Aliquam sit amet turpis et nunc placerat finibus eget sit amet justo. Nullam tincidunt ornare neque. Donec ornare, arcu at elementum pulvinar, urna elit pharetra diam, vel ultrices lacus diam at lorem. Sed vel maximus dolor. Morbi massa est, interdum quis justo sit amet, dapibus bibendum tellus. Integer at purus nec neque tincidunt convallis sit amet eu odio. Duis et ante vitae sem tincidunt facilisis sit amet ac mauris. Quisque varius non nisi vel placerat. Nulla orci metus, imperdiet ac accumsan sed, pellentesque eget nisl. Praesent a suscipit lacus, ut finibus orci. Nulla ut eros commodo, fermentum purus at, porta leo. In finibus luctus nulla, eget posuere eros mollis vel. ",
|
||||
repliesPage: "some url",
|
||||
channel: .init(id: "", name: "")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ extension Thumbnail {
|
||||
}
|
||||
|
||||
private static var fixturesHost: String {
|
||||
"https://invidious.home.arekf.net"
|
||||
"https://invidious.snopyta.org"
|
||||
}
|
||||
|
||||
private static func fixtureUrl(videoId: String, quality: Thumbnail.Quality) -> URL {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
extension Video {
|
||||
static var fixtureID: Video.ID = "video-fixture"
|
||||
static var fixtureChannelID: Channel.ID = "channel-fixture"
|
||||
|
||||
static var fixture: Video {
|
||||
let id = "D2sxamzaHkM"
|
||||
let thumbnailURL = "https://yt3.ggpht.com/ytc/AKedOLR-pT_JEsz_hcaA4Gjx8DHcqJ8mS42aTRqcVy6P7w=s88-c-k-c0x00ffffff-no-rj-mo"
|
||||
|
||||
return Video(
|
||||
videoID: UUID().uuidString,
|
||||
title: "Relaxing Piano Music that will make you feel amazingly good",
|
||||
videoID: fixtureID,
|
||||
title: "Relaxing Piano Music to feel good",
|
||||
author: "Fancy Videotuber",
|
||||
length: 582,
|
||||
published: "7 years ago",
|
||||
@@ -15,16 +17,16 @@ extension Video {
|
||||
description: "Some relaxing live piano music",
|
||||
genre: "Music",
|
||||
channel: Channel(
|
||||
id: "AbCdEFgHI",
|
||||
id: fixtureChannelID,
|
||||
name: "The Channel",
|
||||
thumbnailURL: URL(string: thumbnailURL)!,
|
||||
subscriptionsCount: 2300,
|
||||
videos: []
|
||||
),
|
||||
thumbnails: Thumbnail.fixturesForAllQualities(videoId: id),
|
||||
thumbnails: [],
|
||||
live: false,
|
||||
upcoming: false,
|
||||
publishedAt: Date.now,
|
||||
publishedAt: Date(),
|
||||
likes: 37333,
|
||||
dislikes: 30,
|
||||
keywords: ["very", "cool", "video", "msfs 2020", "757", "747", "A380", "737-900", "MOD", "Zibo", "MD80", "MD11", "Rotate", "Laminar", "787", "A350", "MSFS", "MS2020", "Microsoft Flight Simulator", "Microsoft", "Flight", "Simulator", "SIM", "World", "Ortho", "Flying", "Boeing MAX"]
|
||||
|
||||
@@ -5,6 +5,7 @@ struct FixtureEnvironmentObjectsModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.environmentObject(AccountsModel())
|
||||
.environmentObject(CommentsModel())
|
||||
.environmentObject(InstancesModel())
|
||||
.environmentObject(invidious)
|
||||
.environmentObject(NavigationModel())
|
||||
@@ -31,7 +32,6 @@ struct FixtureEnvironmentObjectsModifier: ViewModifier {
|
||||
|
||||
player.currentItem = PlayerQueueItem(Video.fixture)
|
||||
player.queue = Video.allFixtures.map { PlayerQueueItem($0) }
|
||||
player.history = player.queue
|
||||
|
||||
return player
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ struct Account: Defaults.Serializable, Hashable, Identifiable {
|
||||
let instanceID: String
|
||||
var name: String?
|
||||
let url: String
|
||||
let sid: String
|
||||
let username: String
|
||||
let password: String?
|
||||
var token: String?
|
||||
let anonymous: Bool
|
||||
|
||||
init(
|
||||
@@ -16,7 +18,9 @@ struct Account: Defaults.Serializable, Hashable, Identifiable {
|
||||
instanceID: String? = nil,
|
||||
name: String? = nil,
|
||||
url: String? = nil,
|
||||
sid: String? = nil,
|
||||
username: String? = nil,
|
||||
password: String? = nil,
|
||||
token: String? = nil,
|
||||
anonymous: Bool = false
|
||||
) {
|
||||
self.anonymous = anonymous
|
||||
@@ -25,27 +29,29 @@ struct Account: Defaults.Serializable, Hashable, Identifiable {
|
||||
self.instanceID = instanceID ?? UUID().uuidString
|
||||
self.name = name
|
||||
self.url = url ?? ""
|
||||
self.sid = sid ?? ""
|
||||
self.username = username ?? ""
|
||||
self.token = token
|
||||
self.password = password ?? ""
|
||||
}
|
||||
|
||||
var instance: Instance {
|
||||
Defaults[.instances].first { $0.id == instanceID }!
|
||||
var instance: Instance! {
|
||||
Defaults[.instances].first { $0.id == instanceID }
|
||||
}
|
||||
|
||||
var anonymizedSID: String {
|
||||
guard sid.count > 3 else {
|
||||
return ""
|
||||
var shortUsername: String {
|
||||
guard username.count > 10 else {
|
||||
return username
|
||||
}
|
||||
|
||||
let index = sid.index(sid.startIndex, offsetBy: 4)
|
||||
return String(sid[..<index])
|
||||
let index = username.index(username.startIndex, offsetBy: 11)
|
||||
return String(username[..<index])
|
||||
}
|
||||
|
||||
var description: String {
|
||||
(name != nil && name!.isEmpty) ? "Unnamed (\(anonymizedSID))" : name!
|
||||
(name != nil && name!.isEmpty) ? shortUsername : name!
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(sid)
|
||||
hasher.combine(username)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import SwiftUI
|
||||
final class AccountValidator: Service {
|
||||
let app: Binding<VideosApp>
|
||||
let url: String
|
||||
let account: Account?
|
||||
let account: Account!
|
||||
|
||||
var formObjectID: Binding<String>
|
||||
var isValid: Binding<Bool>
|
||||
@@ -46,7 +46,11 @@ final class AccountValidator: Service {
|
||||
return
|
||||
}
|
||||
|
||||
$0.headers["Cookie"] = self.cookieHeader
|
||||
$0.headers["Cookie"] = self.invidiousCookieHeader
|
||||
}
|
||||
|
||||
configure("/login", requestMethods: [.post]) {
|
||||
$0.headers["Content-Type"] = "application/json"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,20 +88,27 @@ final class AccountValidator: Service {
|
||||
}
|
||||
}
|
||||
|
||||
func validateInvidiousAccount() {
|
||||
func validateAccount() {
|
||||
reset()
|
||||
|
||||
feed
|
||||
.load()
|
||||
.onSuccess { _ in
|
||||
guard self.account!.sid == self.formObjectID.wrappedValue else {
|
||||
accountRequest
|
||||
.onSuccess { response in
|
||||
guard self.account!.username == self.formObjectID.wrappedValue else {
|
||||
return
|
||||
}
|
||||
|
||||
self.isValid.wrappedValue = true
|
||||
switch self.app.wrappedValue {
|
||||
case .invidious:
|
||||
self.isValid.wrappedValue = true
|
||||
case .piped:
|
||||
let error = response.json.dictionaryValue["error"]?.string
|
||||
let token = response.json.dictionaryValue["token"]?.string
|
||||
self.isValid.wrappedValue = error?.isEmpty ?? !(token?.isEmpty ?? true)
|
||||
self.error!.wrappedValue = error
|
||||
}
|
||||
}
|
||||
.onFailure { _ in
|
||||
guard self.account!.sid == self.formObjectID.wrappedValue else {
|
||||
guard self.account!.username == self.formObjectID.wrappedValue else {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -109,6 +120,15 @@ final class AccountValidator: Service {
|
||||
}
|
||||
}
|
||||
|
||||
var accountRequest: Request {
|
||||
switch app.wrappedValue {
|
||||
case .invidious:
|
||||
return feed.load()
|
||||
case .piped:
|
||||
return login.request(.post, json: ["username": account.username, "password": account.password])
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
isValid.wrappedValue = false
|
||||
isValidated.wrappedValue = false
|
||||
@@ -116,8 +136,12 @@ final class AccountValidator: Service {
|
||||
error?.wrappedValue = nil
|
||||
}
|
||||
|
||||
var cookieHeader: String {
|
||||
"SID=\(account!.sid)"
|
||||
var invidiousCookieHeader: String {
|
||||
"SID=\(account.username)"
|
||||
}
|
||||
|
||||
var login: Resource {
|
||||
resource("/login")
|
||||
}
|
||||
|
||||
var feed: Resource {
|
||||
|
||||
@@ -15,7 +15,8 @@ struct AccountsBridge: Defaults.Bridge {
|
||||
"instanceID": value.instanceID,
|
||||
"name": value.name ?? "",
|
||||
"apiURL": value.url,
|
||||
"sid": value.sid
|
||||
"username": value.username,
|
||||
"password": value.password ?? ""
|
||||
]
|
||||
}
|
||||
|
||||
@@ -25,13 +26,14 @@ struct AccountsBridge: Defaults.Bridge {
|
||||
let id = object["id"],
|
||||
let instanceID = object["instanceID"],
|
||||
let url = object["apiURL"],
|
||||
let sid = object["sid"]
|
||||
let username = object["username"]
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let name = object["name"] ?? ""
|
||||
let password = object["password"]
|
||||
|
||||
return Account(id: id, instanceID: instanceID, name: name, url: url, sid: sid)
|
||||
return Account(id: id, instanceID: instanceID, name: name, url: url, username: username, password: password)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,12 @@ final class AccountsModel: ObservableObject {
|
||||
return AccountsModel.find(id)
|
||||
}
|
||||
|
||||
var any: Account? {
|
||||
lastUsed ?? all.randomElement()
|
||||
}
|
||||
|
||||
var app: VideosApp {
|
||||
current?.instance.app ?? .invidious
|
||||
current?.instance?.app ?? .invidious
|
||||
}
|
||||
|
||||
var api: VideosAPI {
|
||||
@@ -35,7 +39,7 @@ final class AccountsModel: ObservableObject {
|
||||
}
|
||||
|
||||
var signedIn: Bool {
|
||||
!isEmpty && !current.anonymous
|
||||
!isEmpty && !current.anonymous && api.signedIn
|
||||
}
|
||||
|
||||
init() {
|
||||
@@ -74,8 +78,14 @@ final class AccountsModel: ObservableObject {
|
||||
Defaults[.accounts].first { $0.id == id }
|
||||
}
|
||||
|
||||
static func add(instance: Instance, name: String, sid: String) -> Account {
|
||||
let account = Account(instanceID: instance.id, name: name, url: instance.apiURL, sid: sid)
|
||||
static func add(instance: Instance, name: String, username: String, password: String? = nil) -> Account {
|
||||
let account = Account(
|
||||
instanceID: instance.id,
|
||||
name: name,
|
||||
url: instance.apiURL,
|
||||
username: username,
|
||||
password: password
|
||||
)
|
||||
Defaults[.accounts].append(account)
|
||||
|
||||
return account
|
||||
|
||||
@@ -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,12 +25,15 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
func setAccount(_ account: Account) {
|
||||
self.account = account
|
||||
|
||||
validInstance = false
|
||||
signedIn = false
|
||||
|
||||
validInstance = account.anonymous
|
||||
|
||||
configure()
|
||||
validate()
|
||||
|
||||
if !account.anonymous {
|
||||
validate()
|
||||
}
|
||||
}
|
||||
|
||||
func validate() {
|
||||
@@ -70,7 +73,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
func configure() {
|
||||
configure {
|
||||
if !self.account.sid.isEmpty {
|
||||
if !self.account.username.isEmpty {
|
||||
$0.headers["Cookie"] = self.cookieHeader
|
||||
}
|
||||
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
|
||||
@@ -81,24 +84,29 @@ 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))
|
||||
} else if type == "video" {
|
||||
return ContentItem(video: self.extractVideo(from: json))
|
||||
}
|
||||
return ContentItem(video: InvidiousAPI.extractVideo(from: $0))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return SearchPage(results: results, last: results.isEmpty)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("search/suggestions"), requestMethods: [.get]) { (content: Entity<JSON>) -> [String] in
|
||||
@@ -110,11 +118,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
|
||||
@@ -124,30 +132,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +168,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
private var cookieHeader: String {
|
||||
"SID=\(account.sid)"
|
||||
"SID=\(account.username)"
|
||||
}
|
||||
|
||||
var popular: Resource? {
|
||||
@@ -185,8 +193,18 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
|
||||
}
|
||||
|
||||
func channelSubscription(_ id: String) -> Resource? {
|
||||
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions")).child(id)
|
||||
func subscribe(_ channelID: String, onCompletion: @escaping () -> Void = {}) {
|
||||
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
|
||||
.child(channelID)
|
||||
.request(.post)
|
||||
.onCompletion { _ in onCompletion() }
|
||||
}
|
||||
|
||||
func unsubscribe(_ channelID: String, onCompletion: @escaping () -> Void) {
|
||||
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
|
||||
.child(channelID)
|
||||
.request(.delete)
|
||||
.onCompletion { _ in onCompletion() }
|
||||
}
|
||||
|
||||
func channel(_ id: String) -> Resource {
|
||||
@@ -202,7 +220,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
var playlists: Resource? {
|
||||
resource(baseURL: account.url, path: basePathAppending("auth/playlists"))
|
||||
if account.isNil || account.anonymous {
|
||||
return nil
|
||||
}
|
||||
|
||||
return resource(baseURL: account.url, path: basePathAppending("auth/playlists"))
|
||||
}
|
||||
|
||||
func playlist(_ id: String) -> Resource? {
|
||||
@@ -217,11 +239,71 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
playlist(playlistID)?.child("videos").child(videoID)
|
||||
}
|
||||
|
||||
func addVideoToPlaylist(
|
||||
_ videoID: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void = { _ in },
|
||||
onSuccess: @escaping () -> Void = {}
|
||||
) {
|
||||
let resource = playlistVideos(playlistID)
|
||||
let body = ["videoId": videoID]
|
||||
|
||||
resource?
|
||||
.request(.post, json: body)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func removeVideoFromPlaylist(
|
||||
_ index: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
) {
|
||||
let resource = playlistVideo(playlistID, index)
|
||||
|
||||
resource?
|
||||
.request(.delete)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func playlistForm(
|
||||
_ name: String,
|
||||
_ visibility: String,
|
||||
playlist: Playlist?,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping (Playlist?) -> Void
|
||||
) {
|
||||
let body = ["title": name, "privacy": visibility]
|
||||
let resource = !playlist.isNil ? self.playlist(playlist!.id) : playlists
|
||||
|
||||
resource?
|
||||
.request(!playlist.isNil ? .patch : .post, json: body)
|
||||
.onSuccess { response in
|
||||
if let modifiedPlaylist: Playlist = response.typedContent() {
|
||||
onSuccess(modifiedPlaylist)
|
||||
}
|
||||
}
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func deletePlaylist(
|
||||
_ playlist: Playlist,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
) {
|
||||
self.playlist(playlist.id)?
|
||||
.request(.delete)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func channelPlaylist(_ id: String) -> Resource? {
|
||||
resource(baseURL: account.url, path: basePathAppending("playlists/\(id)"))
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -235,6 +317,10 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
resource = resource.withParam("duration", duration.rawValue)
|
||||
}
|
||||
|
||||
if let page = page {
|
||||
resource = resource.withParam("page", page)
|
||||
}
|
||||
|
||||
return resource
|
||||
}
|
||||
|
||||
@@ -243,6 +329,8 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
.withParam("q", query.lowercased())
|
||||
}
|
||||
|
||||
func comments(_: Video.ID, page _: String?) -> Resource? { nil }
|
||||
|
||||
private func searchQuery(_ query: String) -> String {
|
||||
var searchQuery = query
|
||||
|
||||
@@ -276,7 +364,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?
|
||||
@@ -319,8 +407,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,
|
||||
@@ -328,33 +423,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!),
|
||||
@@ -365,7 +460,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
}
|
||||
|
||||
private static func extractAdaptiveFormats(from streams: [JSON]) -> [Stream] {
|
||||
private func extractAdaptiveFormats(from streams: [JSON]) -> [Stream] {
|
||||
let audioAssetURL = streams.first { $0["type"].stringValue.starts(with: "audio/mp4") }
|
||||
guard audioAssetURL != nil else {
|
||||
return []
|
||||
@@ -384,10 +479,20 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
}
|
||||
|
||||
private static func extractRelated(from content: JSON) -> [Video] {
|
||||
private func extractRelated(from content: JSON) -> [Video] {
|
||||
content
|
||||
.dictionaryValue["recommendedVideos"]?
|
||||
.arrayValue
|
||||
.compactMap(extractVideo(from:)) ?? []
|
||||
}
|
||||
|
||||
private func extractPlaylist(from content: JSON) -> Playlist {
|
||||
.init(
|
||||
id: content["playlistId"].stringValue,
|
||||
title: content["title"].stringValue,
|
||||
visibility: content["isListed"].boolValue ? .public : .private,
|
||||
updated: content["updated"].doubleValue,
|
||||
videos: content["videos"].arrayValue.map { extractVideo(from: $0) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,9 @@ import Siesta
|
||||
import SwiftyJSON
|
||||
|
||||
final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
@Published var account: Account!
|
||||
static var authorizedEndpoints = ["subscriptions", "subscribe", "unsubscribe", "user/playlists"]
|
||||
|
||||
var anonymousAccount: Account {
|
||||
.init(instanceID: account.instance.id, name: "Anonymous", url: account.instance.apiURL)
|
||||
}
|
||||
@Published var account: Account!
|
||||
|
||||
init(account: Account? = nil) {
|
||||
super.init()
|
||||
@@ -27,33 +25,99 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
func configure() {
|
||||
invalidateConfiguration()
|
||||
|
||||
configure {
|
||||
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
|
||||
}
|
||||
|
||||
configure(whenURLMatches: { url in self.needsAuthorization(url) }) {
|
||||
$0.headers["Authorization"] = self.account.token
|
||||
}
|
||||
|
||||
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("user/playlists/create")) { (_: Entity<JSON>) in }
|
||||
configureTransformer(pathPattern("user/playlists/delete")) { (_: Entity<JSON>) in }
|
||||
configureTransformer(pathPattern("user/playlists/add")) { (_: Entity<JSON>) in }
|
||||
configureTransformer(pathPattern("user/playlists/remove")) { (_: Entity<JSON>) in }
|
||||
|
||||
configureTransformer(pathPattern("streams/*")) { (content: Entity<JSON>) -> Video? in
|
||||
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
|
||||
content.json.arrayValue.map(String.init)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("subscriptions")) { (content: Entity<JSON>) -> [Channel] in
|
||||
content.json.arrayValue.map { self.extractChannel(from: $0)! }
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("feed")) { (content: Entity<JSON>) -> [Video] in
|
||||
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 { self.extractComment(from: $0)! } ?? []
|
||||
let nextPage = details["nextpage"]?.stringValue
|
||||
let disabled = details["disabled"]?.boolValue ?? false
|
||||
|
||||
return CommentsPage(comments: comments, nextPage: nextPage, disabled: disabled)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("user/playlists")) { (content: Entity<JSON>) -> [Playlist] in
|
||||
content.json.arrayValue.map { self.extractUserPlaylist(from: $0)! }
|
||||
}
|
||||
|
||||
if account.token.isNil {
|
||||
updateToken()
|
||||
}
|
||||
}
|
||||
|
||||
func needsAuthorization(_ url: URL) -> Bool {
|
||||
Self.authorizedEndpoints.contains { url.absoluteString.contains($0) }
|
||||
}
|
||||
|
||||
func updateToken() {
|
||||
guard !account.anonymous else {
|
||||
return
|
||||
}
|
||||
|
||||
account.token = nil
|
||||
|
||||
login.request(
|
||||
.post,
|
||||
json: ["username": account.username, "password": account.password]
|
||||
)
|
||||
.onSuccess { response in
|
||||
self.account.token = response.json.dictionaryValue["token"]?.string ?? ""
|
||||
self.configure()
|
||||
}
|
||||
}
|
||||
|
||||
var login: Resource {
|
||||
resource(baseURL: account.url, path: "login")
|
||||
}
|
||||
|
||||
func channel(_ id: String) -> Resource {
|
||||
@@ -73,10 +137,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 {
|
||||
@@ -88,25 +160,126 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
resource(baseURL: account.instance.apiURL, path: "streams/\(id)")
|
||||
}
|
||||
|
||||
var signedIn: Bool { false }
|
||||
var signedIn: Bool {
|
||||
!account.anonymous && !(account.token?.isEmpty ?? true)
|
||||
}
|
||||
|
||||
var subscriptions: Resource? {
|
||||
resource(baseURL: account.instance.apiURL, path: "subscriptions")
|
||||
}
|
||||
|
||||
var feed: Resource? {
|
||||
resource(baseURL: account.instance.apiURL, path: "feed")
|
||||
.withParam("authToken", account.token)
|
||||
}
|
||||
|
||||
var subscriptions: Resource? { nil }
|
||||
var feed: Resource? { nil }
|
||||
var home: Resource? { nil }
|
||||
var popular: Resource? { nil }
|
||||
var playlists: Resource? { nil }
|
||||
var playlists: Resource? {
|
||||
resource(baseURL: account.instance.apiURL, path: "user/playlists")
|
||||
}
|
||||
|
||||
func channelSubscription(_: String) -> Resource? { nil }
|
||||
func subscribe(_ channelID: String, onCompletion: @escaping () -> Void = {}) {
|
||||
resource(baseURL: account.instance.apiURL, path: "subscribe")
|
||||
.request(.post, json: ["channelId": channelID])
|
||||
.onCompletion { _ in onCompletion() }
|
||||
}
|
||||
|
||||
func unsubscribe(_ channelID: String, onCompletion: @escaping () -> Void = {}) {
|
||||
resource(baseURL: account.instance.apiURL, path: "unsubscribe")
|
||||
.request(.post, json: ["channelId": channelID])
|
||||
.onCompletion { _ in onCompletion() }
|
||||
}
|
||||
|
||||
func playlist(_ id: String) -> Resource? {
|
||||
channelPlaylist(id)
|
||||
}
|
||||
|
||||
func playlist(_: String) -> Resource? { nil }
|
||||
func playlistVideo(_: String, _: String) -> Resource? { nil }
|
||||
func playlistVideos(_: String) -> Resource? { nil }
|
||||
|
||||
func addVideoToPlaylist(
|
||||
_ videoID: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void = { _ in },
|
||||
onSuccess: @escaping () -> Void = {}
|
||||
) {
|
||||
let resource = resource(baseURL: account.instance.apiURL, path: "user/playlists/add")
|
||||
let body = ["videoId": videoID, "playlistId": playlistID]
|
||||
|
||||
resource
|
||||
.request(.post, json: body)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func removeVideoFromPlaylist(
|
||||
_ index: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
) {
|
||||
let resource = resource(baseURL: account.instance.apiURL, path: "user/playlists/remove")
|
||||
let body: [String: Any] = ["index": Int(index)!, "playlistId": playlistID]
|
||||
|
||||
resource
|
||||
.request(.post, json: body)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func playlistForm(
|
||||
_ name: String,
|
||||
_: String,
|
||||
playlist: Playlist?,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping (Playlist?) -> Void
|
||||
) {
|
||||
let body = ["name": name]
|
||||
let resource = playlist.isNil ? resource(baseURL: account.instance.apiURL, path: "user/playlists/create") : nil
|
||||
|
||||
resource?
|
||||
.request(.post, json: body)
|
||||
.onSuccess { response in
|
||||
if let modifiedPlaylist: Playlist = response.typedContent() {
|
||||
onSuccess(modifiedPlaylist)
|
||||
} else {
|
||||
onSuccess(nil)
|
||||
}
|
||||
}
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func deletePlaylist(
|
||||
_ playlist: Playlist,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
) {
|
||||
let resource = resource(baseURL: account.instance.apiURL, path: "user/playlists/delete")
|
||||
let body = ["playlistId": playlist.id]
|
||||
|
||||
resource
|
||||
.request(.post, json: body)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func comments(_ id: Video.ID, page: String?) -> Resource? {
|
||||
let path = page.isNil ? "comments/\(id)" : "nextpage/comments/\(id)"
|
||||
let resource = resource(baseURL: account.url, path: path)
|
||||
|
||||
if page.isNil {
|
||||
return resource
|
||||
}
|
||||
|
||||
return resource.withParam("nextpage", page)
|
||||
}
|
||||
|
||||
private func pathPattern(_ path: String) -> String {
|
||||
"**\(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
|
||||
|
||||
@@ -126,29 +299,31 @@ 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)
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
@@ -160,29 +335,32 @@ 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,
|
||||
title: details["name"]!.stringValue,
|
||||
title: details["name"]?.stringValue ?? "",
|
||||
thumbnailURL: thumbnailURL,
|
||||
channel: extractChannel(from: json)!,
|
||||
videos: videos,
|
||||
@@ -190,7 +368,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
|
||||
|
||||
@@ -203,7 +381,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)
|
||||
}
|
||||
|
||||
@@ -211,17 +389,28 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
let author = details["uploaderName"]?.stringValue ?? details["uploader"]!.stringValue
|
||||
let authorThumbnailURL = details["avatarUrl"]?.url ?? details["uploaderAvatar"]?.url ?? details["avatar"]?.url
|
||||
let subscriptionsCount = details["uploaderSubscriberCount"]?.int
|
||||
|
||||
let uploaded = details["uploaded"]?.doubleValue
|
||||
var published = uploaded.isNil ? nil : (uploaded! / 1000).formattedAsRelativeTime()
|
||||
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: details["uploadedDate"]?.stringValue ?? details["uploadDate"]!.stringValue,
|
||||
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, subscriptionsCount: subscriptionsCount),
|
||||
thumbnails: thumbnails,
|
||||
live: live,
|
||||
likes: details["likes"]?.int,
|
||||
dislikes: details["dislikes"]?.int,
|
||||
streams: extractStreams(from: content),
|
||||
@@ -229,16 +418,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
|
||||
@@ -251,7 +440,15 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
)!
|
||||
}
|
||||
|
||||
private static func extractDescription(from content: JSON) -> String? {
|
||||
private func extractUserPlaylist(from json: JSON) -> Playlist? {
|
||||
let id = json["id"].stringValue
|
||||
let title = json["name"].stringValue
|
||||
let visibility = Playlist.Visibility.private
|
||||
|
||||
return Playlist(id: id, title: title, visibility: visibility)
|
||||
}
|
||||
|
||||
private func extractDescription(from content: JSON) -> String? {
|
||||
guard var description = content.dictionaryValue["description"]?.string else {
|
||||
return nil
|
||||
}
|
||||
@@ -273,22 +470,22 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
return description
|
||||
}
|
||||
|
||||
private static func extractVideos(from content: JSON) -> [Video] {
|
||||
private func extractVideos(from content: JSON) -> [Video] {
|
||||
content.arrayValue.compactMap(extractVideo(from:))
|
||||
}
|
||||
|
||||
private static func extractStreams(from content: JSON) -> [Stream] {
|
||||
private func extractStreams(from content: JSON) -> [Stream] {
|
||||
var streams = [Stream]()
|
||||
|
||||
if let hlsURL = content.dictionaryValue["hls"]?.url {
|
||||
streams.append(Stream(hlsURL: hlsURL))
|
||||
}
|
||||
|
||||
guard let audioStream = PipedAPI.compatibleAudioStreams(from: content).first else {
|
||||
guard let audioStream = compatibleAudioStreams(from: content).first else {
|
||||
return streams
|
||||
}
|
||||
|
||||
let videoStreams = PipedAPI.compatibleVideoStream(from: content)
|
||||
let videoStreams = compatibleVideoStream(from: content)
|
||||
|
||||
videoStreams.forEach { videoStream in
|
||||
let audioAsset = AVURLAsset(url: audioStream.dictionaryValue["url"]!.url!)
|
||||
@@ -311,14 +508,14 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
return streams
|
||||
}
|
||||
|
||||
private static func extractRelated(from content: JSON) -> [Video] {
|
||||
private func extractRelated(from content: JSON) -> [Video] {
|
||||
content
|
||||
.dictionaryValue["relatedStreams"]?
|
||||
.arrayValue
|
||||
.compactMap(extractVideo(from:)) ?? []
|
||||
}
|
||||
|
||||
private static func compatibleAudioStreams(from content: JSON) -> [JSON] {
|
||||
private func compatibleAudioStreams(from content: JSON) -> [JSON] {
|
||||
content
|
||||
.dictionaryValue["audioStreams"]?
|
||||
.arrayValue
|
||||
@@ -328,10 +525,29 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
} ?? []
|
||||
}
|
||||
|
||||
private static func compatibleVideoStream(from content: JSON) -> [JSON] {
|
||||
private func compatibleVideoStream(from content: JSON) -> [JSON] {
|
||||
content
|
||||
.dictionaryValue["videoStreams"]?
|
||||
.arrayValue
|
||||
.filter { $0.dictionaryValue["format"] == "MPEG_4" } ?? []
|
||||
}
|
||||
|
||||
private func extractComment(from content: JSON) -> Comment? {
|
||||
let details = content.dictionaryValue
|
||||
let author = details["author"]?.stringValue ?? ""
|
||||
let commentorUrl = details["commentorUrl"]?.stringValue
|
||||
let channelId = commentorUrl?.components(separatedBy: "/")[2] ?? ""
|
||||
return Comment(
|
||||
id: details["commentId"]?.stringValue ?? UUID().uuidString,
|
||||
author: author,
|
||||
authorAvatarURL: details["thumbnail"]?.stringValue ?? "",
|
||||
time: details["commentedTime"]?.stringValue ?? "",
|
||||
pinned: details["pinned"]?.boolValue ?? false,
|
||||
hearted: details["hearted"]?.boolValue ?? false,
|
||||
likeCount: details["likeCount"]?.intValue ?? 0,
|
||||
text: details["commentText"]?.stringValue ?? "",
|
||||
repliesPage: details["repliesPage"]?.stringValue,
|
||||
channel: Channel(id: channelId, name: author)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -20,16 +20,47 @@ protocol VideosAPI {
|
||||
var popular: Resource? { get }
|
||||
var playlists: Resource? { get }
|
||||
|
||||
func channelSubscription(_ id: String) -> Resource?
|
||||
func subscribe(_ channelID: String, onCompletion: @escaping () -> Void)
|
||||
func unsubscribe(_ channelID: String, onCompletion: @escaping () -> Void)
|
||||
|
||||
func playlist(_ id: String) -> Resource?
|
||||
func playlistVideo(_ playlistID: String, _ videoID: String) -> Resource?
|
||||
func playlistVideos(_ id: String) -> Resource?
|
||||
|
||||
func addVideoToPlaylist(
|
||||
_ videoID: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
)
|
||||
|
||||
func removeVideoFromPlaylist(
|
||||
_ index: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
)
|
||||
|
||||
func playlistForm(
|
||||
_ name: String,
|
||||
_ visibility: String,
|
||||
playlist: Playlist?,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping (Playlist?) -> Void
|
||||
)
|
||||
|
||||
func deletePlaylist(
|
||||
_ playlist: Playlist,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
)
|
||||
|
||||
func channelPlaylist(_ id: String) -> Resource?
|
||||
|
||||
func loadDetails(_ item: PlayerQueueItem, completionHandler: @escaping (PlayerQueueItem) -> Void)
|
||||
func shareURL(_ item: ContentItem, frontendHost: String?, time: CMTime?) -> URL?
|
||||
|
||||
func comments(_ id: Video.ID, page: String?) -> Resource?
|
||||
}
|
||||
|
||||
extension VideosAPI {
|
||||
@@ -52,11 +83,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]()
|
||||
@@ -70,6 +102,8 @@ extension VideosAPI {
|
||||
case .playlist:
|
||||
urlComponents.path = "/playlist"
|
||||
queryItems.append(.init(name: "list", value: item.playlist.id))
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
if !time.isNil, time!.seconds.isFinite {
|
||||
@@ -80,6 +114,6 @@ extension VideosAPI {
|
||||
urlComponents.queryItems = queryItems
|
||||
}
|
||||
|
||||
return urlComponents.url!
|
||||
return urlComponents.url
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,11 @@ enum VideosApp: String, CaseIterable {
|
||||
}
|
||||
|
||||
var supportsAccounts: Bool {
|
||||
self == .invidious
|
||||
true
|
||||
}
|
||||
|
||||
var accountsUsePassword: Bool {
|
||||
self == .piped
|
||||
}
|
||||
|
||||
var supportsPopular: Bool {
|
||||
@@ -28,10 +32,30 @@ enum VideosApp: String, CaseIterable {
|
||||
}
|
||||
|
||||
var supportsUserPlaylists: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
var userPlaylistsEndpointIncludesVideos: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var userPlaylistsHaveVisibility: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var userPlaylistsAreEditable: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var hasFrontendURL: Bool {
|
||||
self == .piped
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
16
Model/Comment.swift
Normal file
@@ -0,0 +1,16 @@
|
||||
struct Comment: Identifiable, Equatable {
|
||||
let id: String
|
||||
let author: String
|
||||
let authorAvatarURL: String
|
||||
let time: String
|
||||
let pinned: Bool
|
||||
let hearted: Bool
|
||||
var likeCount: Int
|
||||
let text: String
|
||||
let repliesPage: String?
|
||||
let channel: Channel
|
||||
|
||||
var hasReplies: Bool {
|
||||
!(repliesPage?.isEmpty ?? true)
|
||||
}
|
||||
}
|
||||
119
Model/CommentsModel.swift
Normal file
@@ -0,0 +1,119 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
import SwiftyJSON
|
||||
|
||||
final class CommentsModel: ObservableObject {
|
||||
@Published var all = [Comment]()
|
||||
|
||||
@Published var nextPage: String?
|
||||
@Published var firstPage = true
|
||||
|
||||
@Published var loaded = false
|
||||
@Published var disabled = false
|
||||
|
||||
@Published var replies = [Comment]()
|
||||
@Published var repliesPageID: String?
|
||||
@Published var repliesLoaded = false
|
||||
|
||||
var player: PlayerModel!
|
||||
|
||||
static var instance: Instance? {
|
||||
InstancesModel.find(Defaults[.commentsInstanceID])
|
||||
}
|
||||
|
||||
var api: VideosAPI? {
|
||||
Self.instance.isNil ? nil : PipedAPI(account: Self.instance!.anonymousAccount)
|
||||
}
|
||||
|
||||
static var enabled: Bool {
|
||||
!instance.isNil
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
static var placement: CommentsPlacement {
|
||||
Defaults[.commentsPlacement]
|
||||
}
|
||||
#endif
|
||||
|
||||
var nextPageAvailable: Bool {
|
||||
!(nextPage?.isEmpty ?? true)
|
||||
}
|
||||
|
||||
func load(page: String? = nil) {
|
||||
guard Self.enabled else {
|
||||
return
|
||||
}
|
||||
|
||||
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?.nextPage = page.nextPage
|
||||
self?.disabled = page.disabled
|
||||
}
|
||||
}
|
||||
.onCompletion { [weak self] _ in
|
||||
self?.loaded = true
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func loadReplies(page: String) {
|
||||
guard !player.currentVideo.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
if page == repliesPageID {
|
||||
return
|
||||
}
|
||||
|
||||
replies = []
|
||||
repliesPageID = page
|
||||
repliesLoaded = false
|
||||
|
||||
api?.comments(player.currentVideo!.videoID, page: page)?
|
||||
.load()
|
||||
.onSuccess { [weak self] response in
|
||||
if let page: CommentsPage = response.typedContent() {
|
||||
self?.replies = page.comments
|
||||
self?.repliesLoaded = true
|
||||
}
|
||||
}
|
||||
.onFailure { [weak self] _ in
|
||||
self?.repliesLoaded = true
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
all = []
|
||||
disabled = false
|
||||
firstPage = true
|
||||
nextPage = nil
|
||||
loaded = false
|
||||
replies = []
|
||||
repliesLoaded = false
|
||||
}
|
||||
}
|
||||
7
Model/CommentsPage.swift
Normal file
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
struct CommentsPage {
|
||||
var comments = [Comment]()
|
||||
var nextPage: String?
|
||||
var disabled = false
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import Foundation
|
||||
|
||||
struct ContentItem: Identifiable {
|
||||
enum ContentType: String {
|
||||
case video, playlist, channel
|
||||
case video, playlist, channel, placeholder
|
||||
|
||||
private var sortOrder: Int {
|
||||
switch self {
|
||||
@@ -35,6 +35,6 @@ struct ContentItem: Identifiable {
|
||||
}
|
||||
|
||||
var contentType: ContentType {
|
||||
video.isNil ? (channel.isNil ? .playlist : .channel) : .video
|
||||
video.isNil ? (channel.isNil ? (playlist.isNil ? .placeholder : .playlist) : .channel) : .video
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ struct FavoritesModel {
|
||||
static let shared = FavoritesModel()
|
||||
|
||||
@Default(.favorites) var all
|
||||
@Default(.visibleSections) var visibleSections
|
||||
|
||||
var isEnabled: Bool {
|
||||
visibleSections.contains(.favorites)
|
||||
}
|
||||
|
||||
func contains(_ item: FavoriteItem) -> Bool {
|
||||
all.contains { $0 == item }
|
||||
|
||||
80
Model/HistoryModel.swift
Normal file
@@ -0,0 +1,80 @@
|
||||
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 else {
|
||||
return
|
||||
}
|
||||
|
||||
let time = player.currentTime()
|
||||
let seconds = time.seconds
|
||||
currentItem.playbackTime = time
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,31 @@ final class NavigationModel: ObservableObject {
|
||||
case nowPlaying
|
||||
case search
|
||||
|
||||
var stringValue: String {
|
||||
switch self {
|
||||
case .favorites:
|
||||
return "favorites"
|
||||
case .subscriptions:
|
||||
return "subscriptions"
|
||||
case .popular:
|
||||
return "popular"
|
||||
case .trending:
|
||||
return "trending"
|
||||
case .playlists:
|
||||
return "playlists"
|
||||
case let .channel(string):
|
||||
return "channel\(string)"
|
||||
case let .playlist(string):
|
||||
return "playlist\(string)"
|
||||
case .recentlyOpened:
|
||||
return "recentlyOpened"
|
||||
case .search:
|
||||
return "search"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
var playlistID: Playlist.ID? {
|
||||
if case let .playlist(id) = self {
|
||||
return id
|
||||
@@ -23,7 +48,7 @@ final class NavigationModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
@Published var tabSelection: TabSelection! = .favorites
|
||||
@Published var tabSelection: TabSelection!
|
||||
|
||||
@Published var presentingAddToPlaylist = false
|
||||
@Published var videoToAddToPlaylist: Video!
|
||||
@@ -41,10 +66,84 @@ 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
|
||||
) {
|
||||
guard channel.id != Video.fixtureChannelID else {
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
@@ -71,6 +170,12 @@ final class NavigationModel: ObservableObject {
|
||||
channelToUnsubscribe = channel
|
||||
presentingUnsubscribeAlert = channelToUnsubscribe != nil
|
||||
}
|
||||
|
||||
func hideKeyboard() {
|
||||
#if os(iOS)
|
||||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
typealias TabSelection = NavigationModel.TabSelection
|
||||
|
||||
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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,50 +1,64 @@
|
||||
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]
|
||||
static let assetKeysToLoad = ["tracks", "playable", "duration"]
|
||||
let logger = Logger(label: "stream.yattee.app")
|
||||
|
||||
private(set) var player = AVPlayer()
|
||||
private(set) var playerView = Player()
|
||||
var controller: PlayerViewController? { didSet { playerView.controller = controller } }
|
||||
#if os(tvOS)
|
||||
var avPlayerViewController: AVPlayerViewController?
|
||||
#endif
|
||||
var playerView = Player()
|
||||
var controller: PlayerViewController?
|
||||
var playerItem: AVPlayerItem?
|
||||
|
||||
@Published var presentingPlayer = false { didSet { pauseOnPlayerDismiss() } }
|
||||
@Published var presentingPlayer = false { didSet { handlePresentationChange() } }
|
||||
|
||||
@Published var stream: Stream?
|
||||
@Published var currentRate: Float = 1.0 { didSet { player.rate = currentRate } }
|
||||
|
||||
@Published var availableStreams = [Stream]() { didSet { rebuildTVMenu() } }
|
||||
@Published var availableStreams = [Stream]() { didSet { handleAvailableStreamsChange() } }
|
||||
@Published var streamSelection: Stream? { didSet { rebuildTVMenu() } }
|
||||
|
||||
@Published var queue = [PlayerQueueItem]() { didSet { Defaults[.queue] = queue } }
|
||||
@Published var currentItem: PlayerQueueItem! { didSet { Defaults[.lastPlayed] = currentItem } }
|
||||
@Published var history = [PlayerQueueItem]() { didSet { Defaults[.history] = history } }
|
||||
@Published var 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]()
|
||||
|
||||
var accounts: AccountsModel
|
||||
#if os(iOS)
|
||||
@Published var motionManager: CMMotionManager!
|
||||
@Published var lockedOrientation: UIInterfaceOrientation?
|
||||
@Published var lastOrientation: UIInterfaceOrientation?
|
||||
#endif
|
||||
|
||||
var accounts: AccountsModel
|
||||
var comments: CommentsModel
|
||||
|
||||
var asset: AVURLAsset?
|
||||
var composition = AVMutableComposition()
|
||||
var loadedCompositionAssets = [AVMediaType]()
|
||||
|
||||
var context: NSManagedObjectContext = PersistenceController.shared.container.viewContext
|
||||
|
||||
private var currentArtwork: MPMediaItemArtwork?
|
||||
private var frequentTimeObserver: Any?
|
||||
@@ -56,6 +70,7 @@ final class PlayerModel: ObservableObject {
|
||||
private var timeObserverThrottle = Throttle(interval: 2)
|
||||
|
||||
var playingInPictureInPicture = false
|
||||
var playingFullscreen = false
|
||||
|
||||
@Published var presentingErrorDetails = false
|
||||
var playerError: Error? { didSet {
|
||||
@@ -66,21 +81,63 @@ final class PlayerModel: ObservableObject {
|
||||
#endif
|
||||
}}
|
||||
|
||||
init(accounts: AccountsModel? = nil, instances _: InstancesModel? = nil) {
|
||||
self.accounts = accounts ?? AccountsModel()
|
||||
@Default(.pauseOnHidingPlayer) private var pauseOnHidingPlayer
|
||||
@Default(.closePiPOnNavigation) var closePiPOnNavigation
|
||||
@Default(.closePiPOnOpeningPlayer) var closePiPOnOpeningPlayer
|
||||
|
||||
#if !os(macOS)
|
||||
@Default(.closePiPAndOpenPlayerOnEnteringForeground) var closePiPAndOpenPlayerOnEnteringForeground
|
||||
#endif
|
||||
|
||||
init(accounts: AccountsModel? = nil, comments: CommentsModel? = nil) {
|
||||
self.accounts = accounts ?? AccountsModel()
|
||||
self.comments = comments ?? CommentsModel()
|
||||
|
||||
addItemDidPlayToEndTimeObserver()
|
||||
addFrequentTimeObserver()
|
||||
addInfrequentTimeObserver()
|
||||
addPlayerTimeControlStatusObserver()
|
||||
}
|
||||
|
||||
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 player.currentItem == nil || time == nil || !time!.isValid
|
||||
}
|
||||
|
||||
var isPlaying: Bool {
|
||||
@@ -92,7 +149,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
var live: Bool {
|
||||
currentItem?.video?.live ?? false
|
||||
currentVideo?.live ?? false
|
||||
}
|
||||
|
||||
var playerItemDuration: CMTime? {
|
||||
@@ -100,7 +157,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
var videoDuration: TimeInterval? {
|
||||
currentItem?.duration ?? currentVideo?.length
|
||||
currentItem?.duration ?? currentVideo?.length ?? player.currentItem?.asset.duration.seconds
|
||||
}
|
||||
|
||||
func togglePlay() {
|
||||
@@ -123,40 +180,101 @@ final class PlayerModel: ObservableObject {
|
||||
player.pause()
|
||||
}
|
||||
|
||||
func upgradeToStream(_ stream: Stream) {
|
||||
if !self.stream.isNil, self.stream != stream {
|
||||
playStream(stream, of: currentVideo!, preservingTime: true)
|
||||
func play(_ video: Video, at time: TimeInterval? = nil, inNavigationView: Bool = false) {
|
||||
playNow(video, at: time)
|
||||
|
||||
guard !playingInPictureInPicture else {
|
||||
return
|
||||
}
|
||||
|
||||
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])
|
||||
if !upgrading {
|
||||
resetSegments()
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.sponsorBlock.loadSegments(
|
||||
videoID: video.videoID,
|
||||
categories: Defaults[.sponsorBlockCategories]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if let url = stream.singleAssetURL {
|
||||
logger.info("playing stream with one asset\(stream.kind == .hls ? " (HLS)" : ""): \(url)")
|
||||
|
||||
insertPlayerItem(stream, for: video, preservingTime: preservingTime)
|
||||
loadSingleAsset(url, stream: stream, of: video, preservingTime: preservingTime)
|
||||
} else {
|
||||
logger.info("playing stream with many assets:")
|
||||
logger.info("composition audio asset: \(stream.audioAsset.url)")
|
||||
logger.info("composition video asset: \(stream.videoAsset.url)")
|
||||
|
||||
Task {
|
||||
await self.loadComposition(stream, of: video, preservingTime: preservingTime)
|
||||
loadComposition(stream, of: video, preservingTime: preservingTime)
|
||||
}
|
||||
|
||||
if !upgrading {
|
||||
updateCurrentArtwork()
|
||||
}
|
||||
}
|
||||
|
||||
func upgradeToStream(_ stream: Stream) {
|
||||
if !self.stream.isNil, 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() {
|
||||
if presentingPlayer, closePiPOnOpeningPlayer, playingInPictureInPicture {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
self?.closePiP()
|
||||
}
|
||||
}
|
||||
|
||||
updateCurrentArtwork()
|
||||
if !presentingPlayer, pauseOnHidingPlayer, !playingInPictureInPicture {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
self?.pause()
|
||||
}
|
||||
}
|
||||
|
||||
if !presentingPlayer, !pauseOnHidingPlayer, isPlaying {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
|
||||
self?.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func pauseOnPlayerDismiss() {
|
||||
if !playingInPictureInPicture, !presentingPlayer {
|
||||
private func handleNavigationViewPlayerPresentationChange() {
|
||||
if pauseOnHidingPlayer, !playingInPictureInPicture, !playerNavigationLinkActive {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
self.pause()
|
||||
}
|
||||
@@ -168,11 +286,14 @@ final class PlayerModel: ObservableObject {
|
||||
for video: Video,
|
||||
preservingTime: Bool = false
|
||||
) {
|
||||
let playerItem = playerItem(stream)
|
||||
removeItemDidPlayToEndTimeObserver()
|
||||
|
||||
playerItem = playerItem(stream)
|
||||
guard playerItem != nil else {
|
||||
return
|
||||
}
|
||||
|
||||
addItemDidPlayToEndTimeObserver()
|
||||
attachMetadata(to: playerItem!, video: video, for: stream)
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
@@ -182,6 +303,7 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
self.stream = stream
|
||||
self.composition = AVMutableComposition()
|
||||
self.asset = nil
|
||||
}
|
||||
|
||||
let startPlaying = {
|
||||
@@ -189,27 +311,53 @@ final class PlayerModel: ObservableObject {
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
#endif
|
||||
|
||||
if self.isAutoplaying(playerItem!) {
|
||||
if self.isAutoplaying(self.playerItem!) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
|
||||
self?.play()
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
if !preservingTime,
|
||||
let segment = self.sponsorBlock.segments.first,
|
||||
segment.start < 3,
|
||||
self.lastSkipped.isNil
|
||||
{
|
||||
self.player.seek(
|
||||
to: segment.endTime,
|
||||
toleranceBefore: .secondsInDefaultTimescale(1),
|
||||
toleranceAfter: .zero
|
||||
) { finished in
|
||||
guard finished else {
|
||||
return
|
||||
}
|
||||
|
||||
self.lastSkipped = segment
|
||||
self.play()
|
||||
}
|
||||
} else {
|
||||
self.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let replaceItemAndSeek = {
|
||||
self.player.replaceCurrentItem(with: playerItem)
|
||||
self.seekToSavedTime { finished in
|
||||
guard video == self.currentVideo else {
|
||||
return
|
||||
}
|
||||
self.player.replaceCurrentItem(with: self.playerItem)
|
||||
self.seekToPreservedTime { finished in
|
||||
guard finished else {
|
||||
return
|
||||
}
|
||||
self.savedTime = nil
|
||||
self.preservedTime = nil
|
||||
|
||||
startPlaying()
|
||||
}
|
||||
}
|
||||
|
||||
if preservingTime {
|
||||
if savedTime.isNil {
|
||||
if preservedTime.isNil {
|
||||
saveTime {
|
||||
replaceItemAndSeek()
|
||||
startPlaying()
|
||||
@@ -224,55 +372,94 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func loadSingleAsset(
|
||||
_ url: URL,
|
||||
stream: Stream,
|
||||
of video: Video,
|
||||
preservingTime: Bool = false
|
||||
) {
|
||||
asset?.cancelLoading()
|
||||
asset = AVURLAsset(url: url)
|
||||
asset?.loadValuesAsynchronously(forKeys: Self.assetKeysToLoad) { [weak self] in
|
||||
var error: NSError?
|
||||
|
||||
switch self?.asset?.statusOfValue(forKey: "duration", error: &error) {
|
||||
case .loaded:
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.insertPlayerItem(stream, for: video, preservingTime: preservingTime)
|
||||
}
|
||||
case .failed:
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.playerError = error
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadComposition(
|
||||
_ stream: Stream,
|
||||
of video: Video,
|
||||
preservingTime: Bool = false
|
||||
) async {
|
||||
await loadCompositionAsset(stream.audioAsset, type: .audio, of: video)
|
||||
await loadCompositionAsset(stream.videoAsset, type: .video, of: video)
|
||||
|
||||
guard streamSelection == stream else {
|
||||
logger.critical("IGNORING LOADED")
|
||||
return
|
||||
}
|
||||
|
||||
insertPlayerItem(stream, for: video, preservingTime: preservingTime)
|
||||
) {
|
||||
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
|
||||
) async {
|
||||
async let assetTracks = asset.loadTracks(withMediaType: type)
|
||||
of video: Video,
|
||||
preservingTime: Bool = false
|
||||
) {
|
||||
asset.loadValuesAsynchronously(forKeys: Self.assetKeysToLoad) { [weak self] in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
self.logger.info("loading \(type.rawValue) track")
|
||||
|
||||
logger.info("loading \(type.rawValue) track")
|
||||
guard let compositionTrack = composition.addMutableTrack(
|
||||
withMediaType: type,
|
||||
preferredTrackID: kCMPersistentTrackID_Invalid
|
||||
) else {
|
||||
logger.critical("composition \(type.rawValue) addMutableTrack FAILED")
|
||||
return
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
guard let assetTrack = try? await assetTracks.first else {
|
||||
logger.critical("asset \(type.rawValue) track FAILED")
|
||||
return
|
||||
}
|
||||
|
||||
try! compositionTrack.insertTimeRange(
|
||||
CMTimeRange(start: .zero, duration: CMTime.secondsInDefaultTimescale(video.length)),
|
||||
of: assetTrack,
|
||||
at: .zero
|
||||
)
|
||||
|
||||
logger.critical("\(type.rawValue) LOADED")
|
||||
}
|
||||
|
||||
private func playerItem(_ stream: Stream) -> AVPlayerItem? {
|
||||
if let url = stream.singleAssetURL {
|
||||
return AVPlayerItem(asset: AVURLAsset(url: url))
|
||||
private func playerItem(_: Stream) -> AVPlayerItem? {
|
||||
if let asset = asset {
|
||||
return AVPlayerItem(asset: asset)
|
||||
} else {
|
||||
return AVPlayerItem(asset: composition)
|
||||
}
|
||||
@@ -298,6 +485,10 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
item.preferredForwardBufferDuration = 5
|
||||
|
||||
observePlayerItemStatus(item)
|
||||
}
|
||||
|
||||
private func observePlayerItemStatus(_ item: AVPlayerItem) {
|
||||
statusObservation?.invalidate()
|
||||
statusObservation = item.observe(\.status, options: [.old, .new]) { [weak self] playerItem, _ in
|
||||
guard let self = self else {
|
||||
@@ -335,25 +526,31 @@ final class PlayerModel: ObservableObject {
|
||||
self,
|
||||
selector: #selector(itemDidPlayToEndTime),
|
||||
name: NSNotification.Name.AVPlayerItemDidPlayToEndTime,
|
||||
object: nil
|
||||
object: playerItem
|
||||
)
|
||||
}
|
||||
|
||||
private func removeItemDidPlayToEndTimeObserver() {
|
||||
NotificationCenter.default.removeObserver(
|
||||
self,
|
||||
name: NSNotification.Name.AVPlayerItemDidPlayToEndTime,
|
||||
object: playerItem
|
||||
)
|
||||
}
|
||||
|
||||
@objc func itemDidPlayToEndTime() {
|
||||
currentItem.playbackTime = playerItemDuration
|
||||
prepareCurrentItemForHistory(finished: true)
|
||||
|
||||
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)
|
||||
controller?.playerView.dismiss(animated: false) { [weak self] in
|
||||
self?.controller?.dismiss(animated: true)
|
||||
}
|
||||
#endif
|
||||
presentingPlayer = false
|
||||
} else {
|
||||
advanceToNextItem()
|
||||
}
|
||||
@@ -367,13 +564,13 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.savedTime = currentTime
|
||||
self?.preservedTime = currentTime
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
|
||||
private func seekToSavedTime(completionHandler: @escaping (Bool) -> Void = { _ in }) {
|
||||
guard let time = savedTime else {
|
||||
private func seekToPreservedTime(completionHandler: @escaping (Bool) -> Void = { _ in }) {
|
||||
guard let time = preservedTime else {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -424,7 +621,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
self.timeObserverThrottle.execute {
|
||||
self.updateCurrentItemIntervals()
|
||||
self.updateWatch()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -454,29 +651,34 @@ final class PlayerModel: ObservableObject {
|
||||
#endif
|
||||
|
||||
self.timeObserverThrottle.execute {
|
||||
self.updateCurrentItemIntervals()
|
||||
self.updateWatch()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 nowPlayingInfo: [String: AnyObject] = [
|
||||
var nowPlayingInfo: [String: AnyObject] = [
|
||||
MPMediaItemPropertyTitle: currentItem.video.title as AnyObject,
|
||||
MPMediaItemPropertyArtist: currentItem.video.author as AnyObject,
|
||||
MPMediaItemPropertyArtwork: currentArtwork as AnyObject,
|
||||
MPMediaItemPropertyPlaybackDuration: duration as AnyObject,
|
||||
MPNowPlayingInfoPropertyIsLiveStream: currentItem.video.live as AnyObject,
|
||||
MPNowPlayingInfoPropertyElapsedPlaybackTime: player.currentTime().seconds as AnyObject,
|
||||
MPNowPlayingInfoPropertyPlaybackQueueCount: queue.count as AnyObject,
|
||||
MPMediaItemPropertyMediaType: MPMediaType.anyVideo.rawValue as AnyObject
|
||||
]
|
||||
|
||||
if !currentArtwork.isNil {
|
||||
nowPlayingInfo[MPMediaItemPropertyArtwork] = currentArtwork as AnyObject
|
||||
}
|
||||
|
||||
if !currentItem.video.live {
|
||||
let itemDuration = currentItem.videoDuration ?? currentItem.duration
|
||||
let duration = itemDuration.isFinite ? Double(itemDuration) : nil
|
||||
|
||||
if !duration.isNil {
|
||||
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration as AnyObject
|
||||
}
|
||||
}
|
||||
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
||||
}
|
||||
|
||||
@@ -505,4 +707,107 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
return "\(formatter.string(from: NSNumber(value: rate))!)×"
|
||||
}
|
||||
|
||||
func closeCurrentItem() {
|
||||
prepareCurrentItemForHistory()
|
||||
currentItem = nil
|
||||
player.replaceCurrentItem(with: nil)
|
||||
}
|
||||
|
||||
func closePiP() {
|
||||
guard playingInPictureInPicture else {
|
||||
return
|
||||
}
|
||||
|
||||
let wasPlaying = isPlaying
|
||||
pause()
|
||||
|
||||
#if os(tvOS)
|
||||
show()
|
||||
#endif
|
||||
|
||||
doClosePiP(wasPlaying: wasPlaying)
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
private func doClosePiP(wasPlaying: Bool) {
|
||||
let item = player.currentItem
|
||||
let time = player.currentTime()
|
||||
|
||||
self.player.replaceCurrentItem(with: nil)
|
||||
|
||||
guard !item.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
self.player.seek(to: time)
|
||||
self.player.replaceCurrentItem(with: item)
|
||||
|
||||
guard wasPlaying else {
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
|
||||
self?.play()
|
||||
}
|
||||
}
|
||||
#else
|
||||
private func doClosePiP(wasPlaying: Bool) {
|
||||
controller?.playerView.player = nil
|
||||
controller?.playerView.player = player
|
||||
|
||||
guard wasPlaying else {
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
self?.play()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
func handleCurrentItemChange() {
|
||||
#if os(macOS)
|
||||
Windows.player.window?.title = windowTitle
|
||||
#endif
|
||||
|
||||
Defaults[.lastPlayed] = currentItem
|
||||
}
|
||||
|
||||
#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 !playingFullscreen else {
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("entering fullscreen")
|
||||
|
||||
controller?.playerView
|
||||
.perform(NSSelectorFromString("enterFullScreenAnimated:completionHandler:"), with: false, with: nil)
|
||||
}
|
||||
|
||||
func exitFullScreen() {
|
||||
guard playingFullscreen else {
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("exiting fullscreen")
|
||||
|
||||
controller?.playerView
|
||||
.perform(NSSelectorFromString("exitFullScreenAnimated:completionHandler:"), with: false, with: nil)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -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,7 +43,11 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
func playNow(_ video: Video, at time: TimeInterval? = nil) {
|
||||
addCurrentItemToHistory()
|
||||
if playingInPictureInPicture, closePiPOnNavigation {
|
||||
closePiP()
|
||||
}
|
||||
|
||||
prepareCurrentItemForHistory()
|
||||
|
||||
enqueueVideo(video, prepending: true) { _, item in
|
||||
self.advanceToItem(item, at: time)
|
||||
@@ -37,6 +55,12 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
func playItem(_ item: PlayerQueueItem, video: Video? = nil, at time: TimeInterval? = nil) {
|
||||
if !playingInPictureInPicture {
|
||||
player.replaceCurrentItem(with: nil)
|
||||
}
|
||||
|
||||
comments.reset()
|
||||
stream = nil
|
||||
currentItem = item
|
||||
|
||||
if !time.isNil {
|
||||
@@ -49,23 +73,18 @@ extension PlayerModel {
|
||||
currentItem.video = video!
|
||||
}
|
||||
|
||||
savedTime = currentItem.playbackTime
|
||||
preservedTime = currentItem.playbackTime
|
||||
|
||||
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,7 +94,9 @@ extension PlayerModel {
|
||||
|
||||
switch quality {
|
||||
case .best:
|
||||
return streams.first { $0.kind == .hls } ?? streams.first
|
||||
return streams.first { $0.kind == .hls } ??
|
||||
streams.filter { $0.kind == .stream }.max { $0.resolution < $1.resolution } ??
|
||||
streams.first
|
||||
default:
|
||||
let sorted = streams.filter { $0.kind != .hls }.sorted { $0.resolution > $1.resolution }
|
||||
return sorted.first(where: { $0.resolution.height <= quality.value.height })
|
||||
@@ -83,7 +104,7 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
func advanceToNextItem() {
|
||||
addCurrentItemToHistory()
|
||||
prepareCurrentItemForHistory()
|
||||
|
||||
if let nextItem = queue.first {
|
||||
advanceToItem(nextItem)
|
||||
@@ -91,10 +112,13 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
func advanceToItem(_ newItem: PlayerQueueItem, at time: TimeInterval? = nil) {
|
||||
addCurrentItemToHistory()
|
||||
prepareCurrentItemForHistory()
|
||||
|
||||
remove(newItem)
|
||||
|
||||
currentItem = newItem
|
||||
player.pause()
|
||||
|
||||
accounts.api.loadDetails(newItem) { newItem in
|
||||
self.playItem(newItem, video: newItem.video, at: time)
|
||||
}
|
||||
@@ -123,7 +147,7 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
func isAutoplaying(_ item: AVPlayerItem) -> Bool {
|
||||
player.currentItem == item && presentingPlayer
|
||||
player.currentItem == item
|
||||
}
|
||||
|
||||
@discardableResult func enqueueVideo(
|
||||
@@ -135,6 +159,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
|
||||
player.pause()
|
||||
}
|
||||
|
||||
queue.insert(item, at: prepending ? 0 : queue.endIndex)
|
||||
|
||||
accounts.api.loadDetails(item) { newItem in
|
||||
@@ -148,20 +178,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
|
||||
|
||||
@@ -172,34 +197,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 }) {
|
||||
@@ -207,32 +218,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,15 +18,16 @@ struct Playlist: Identifiable, Equatable, Hashable {
|
||||
var title: String
|
||||
var visibility: Visibility
|
||||
|
||||
var updated: TimeInterval
|
||||
var updated: TimeInterval?
|
||||
|
||||
var videos = [Video]()
|
||||
|
||||
init(id: String, title: String, visibility: Visibility, updated: TimeInterval) {
|
||||
init(id: String, title: String, visibility: Visibility, updated: TimeInterval? = nil, 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 {
|
||||
|
||||
@@ -4,6 +4,7 @@ import SwiftUI
|
||||
|
||||
final class PlaylistsModel: ObservableObject {
|
||||
@Published var playlists = [Playlist]()
|
||||
@Published var reloadPlaylists = false
|
||||
|
||||
var accounts = AccountsModel()
|
||||
|
||||
@@ -28,6 +29,11 @@ final class PlaylistsModel: ObservableObject {
|
||||
}
|
||||
|
||||
func load(force: Bool = false, onSuccess: @escaping () -> Void = {}) {
|
||||
guard accounts.app.supportsUserPlaylists, accounts.signedIn else {
|
||||
playlists = []
|
||||
return
|
||||
}
|
||||
|
||||
let request = force ? resource?.load() : resource?.loadIfNeeded()
|
||||
|
||||
guard !request.isNil else {
|
||||
@@ -47,22 +53,26 @@ final class PlaylistsModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func addVideo(playlistID: Playlist.ID, videoID: Video.ID, onSuccess: @escaping () -> Void = {}) {
|
||||
let resource = accounts.api.playlistVideos(playlistID)
|
||||
let body = ["videoId": videoID]
|
||||
|
||||
resource?.request(.post, json: body).onSuccess { _ in
|
||||
self.load(force: true)
|
||||
onSuccess()
|
||||
func addVideo(
|
||||
playlistID: Playlist.ID,
|
||||
videoID: Video.ID,
|
||||
onSuccess: @escaping () -> Void = {},
|
||||
onFailure: @escaping (RequestError) -> Void = { _ in }
|
||||
) {
|
||||
accounts.api.addVideoToPlaylist(videoID, playlistID, onFailure: onFailure) {
|
||||
self.load(force: true) {
|
||||
self.reloadPlaylists.toggle()
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeVideo(videoIndexID: String, playlistID: Playlist.ID, onSuccess: @escaping () -> Void = {}) {
|
||||
let resource = accounts.api.playlistVideo(playlistID, videoIndexID)
|
||||
|
||||
resource?.request(.delete).onSuccess { _ in
|
||||
self.load(force: true)
|
||||
onSuccess()
|
||||
func removeVideo(index: String, playlistID: Playlist.ID, onSuccess: @escaping () -> Void = {}) {
|
||||
accounts.api.removeVideoFromPlaylist(index, playlistID, onFailure: { _ in }) {
|
||||
self.load(force: true) {
|
||||
self.reloadPlaylists.toggle()
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import Alamofire
|
||||
import Foundation
|
||||
import SwiftyJSON
|
||||
|
||||
final class PlaylistsProvider: DataProvider {
|
||||
@Published var playlists = [Playlist]()
|
||||
|
||||
let profile = Profile()
|
||||
|
||||
func load(successHandler: @escaping ([Playlist]) -> Void = { _ in }) {
|
||||
let headers = HTTPHeaders([HTTPHeader(name: "Cookie", value: "SID=\(profile.sid)")])
|
||||
DataProvider.request("auth/playlists", headers: headers).responseJSON { response in
|
||||
switch response.result {
|
||||
case let .success(value):
|
||||
self.playlists = JSON(value).arrayValue.map { Playlist($0) }
|
||||
successHandler(self.playlists)
|
||||
case let .failure(error):
|
||||
print(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import Foundation
|
||||
|
||||
final class RecentsModel: ObservableObject {
|
||||
@Default(.recentlyOpened) var items
|
||||
|
||||
@Default(.saveRecents) var saveRecents
|
||||
func clear() {
|
||||
items = []
|
||||
}
|
||||
@@ -13,6 +13,14 @@ final class RecentsModel: ObservableObject {
|
||||
}
|
||||
|
||||
func add(_ item: RecentItem) {
|
||||
if !saveRecents {
|
||||
clear()
|
||||
|
||||
if item.type == .query {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if let index = items.firstIndex(where: { $0.id == item.id }) {
|
||||
items.remove(at: index)
|
||||
}
|
||||
@@ -26,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))
|
||||
}
|
||||
}
|
||||
@@ -47,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 {
|
||||
|
||||
@@ -4,13 +4,16 @@ import SwiftUI
|
||||
|
||||
final class SearchModel: ObservableObject {
|
||||
@Published var store = Store<[ContentItem]>()
|
||||
@Published var page: SearchPage?
|
||||
|
||||
var accounts = AccountsModel()
|
||||
@Published var query = SearchQuery()
|
||||
@Published var queryText = ""
|
||||
@Published var querySuggestions = Store<[String]>()
|
||||
@Published var suggestionsText = ""
|
||||
|
||||
@Published var fieldIsFocused = false
|
||||
|
||||
private var previousResource: Resource?
|
||||
private var resource: Resource!
|
||||
|
||||
var isLoading: Bool {
|
||||
@@ -20,69 +23,68 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
private var suggestionsDebounceTimer: Timer?
|
||||
|
||||
func loadSuggestions(_ query: String) {
|
||||
guard !query.isEmpty else {
|
||||
querySuggestions.replace([])
|
||||
return
|
||||
}
|
||||
|
||||
suggestionsDebounceTimer?.invalidate()
|
||||
|
||||
suggestionsDebounceTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: false) { _ in
|
||||
suggestionsDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: false) { _ in
|
||||
let resource = self.accounts.api.searchSuggestions(query: query)
|
||||
|
||||
resource.addObserver(self.querySuggestions)
|
||||
@@ -93,10 +95,46 @@ final class SearchModel: ObservableObject {
|
||||
if let suggestions: [String] = response.typedContent() {
|
||||
self.querySuggestions = Store<[String]>(suggestions)
|
||||
}
|
||||
self.suggestionsText = query
|
||||
}
|
||||
} else {
|
||||
self.querySuggestions = Store<[String]>(self.querySuggestions.collection)
|
||||
self.suggestionsText = query
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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: pageToLoad.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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,11 +19,15 @@ final class SubscriptionsModel: ObservableObject {
|
||||
}
|
||||
|
||||
func subscribe(_ channelID: String, onSuccess: @escaping () -> Void = {}) {
|
||||
performRequest(channelID, method: .post, onSuccess: onSuccess)
|
||||
accounts.api.subscribe(channelID) {
|
||||
self.scheduleLoad(onSuccess: onSuccess)
|
||||
}
|
||||
}
|
||||
|
||||
func unsubscribe(_ channelID: String, onSuccess: @escaping () -> Void = {}) {
|
||||
performRequest(channelID, method: .delete, onSuccess: onSuccess)
|
||||
accounts.api.unsubscribe(channelID) {
|
||||
self.scheduleLoad(onSuccess: onSuccess)
|
||||
}
|
||||
}
|
||||
|
||||
func isSubscribing(_ channelID: String) -> Bool {
|
||||
@@ -31,6 +35,11 @@ final class SubscriptionsModel: ObservableObject {
|
||||
}
|
||||
|
||||
func load(force: Bool = false, onSuccess: @escaping () -> Void = {}) {
|
||||
guard accounts.app.supportsSubscriptions, accounts.signedIn else {
|
||||
channels = []
|
||||
return
|
||||
}
|
||||
|
||||
let request = force ? resource?.load() : resource?.loadIfNeeded()
|
||||
|
||||
request?
|
||||
@@ -45,8 +54,8 @@ final class SubscriptionsModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func performRequest(_ channelID: String, method: RequestMethod, onSuccess: @escaping () -> Void = {}) {
|
||||
accounts.api.channelSubscription(channelID)?.request(method).onCompletion { _ in
|
||||
private func scheduleLoad(onSuccess: @escaping () -> Void) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
self.load(force: true, onSuccess: onSuccess)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Alamofire
|
||||
import AVKit
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import SwiftyJSON
|
||||
|
||||
struct Video: Identifiable, Equatable, Hashable {
|
||||
@@ -16,7 +17,7 @@ struct Video: Identifiable, Equatable, Hashable {
|
||||
var genre: String?
|
||||
|
||||
// index used when in the Playlist
|
||||
let indexID: String?
|
||||
var indexID: String?
|
||||
|
||||
var live: Bool
|
||||
var upcoming: Bool
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
117
README.md
@@ -1,29 +1,36 @@
|
||||

|
||||
|
||||
Video player with support for [Invidious](https://github.com/iv-org/invidious) and [Piped](https://github.com/TeamPiped/Piped) instances built for iOS 15, tvOS 15 and macOS Monterey.
|
||||
<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)
|
||||
[](https://github.com/yattee/yattee/pulls)
|
||||
[](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/) with selection of categories to skip
|
||||
* [SponsorBlock](https://sponsor.ajay.app/), configurable categories to skip
|
||||
* Player queue and history
|
||||
* Fullscreen playback and Picture in Picture
|
||||
* Fullscreen playback, Picture in Picture and AirPlay support
|
||||
* Stream quality selection
|
||||
* Favorites: customizable section of channels, playlists, trending, searches and other views
|
||||
* AirPlay support
|
||||
* Safari Extension for macOS and iOS for redirecting to the app
|
||||
* URL Scheme for easy integrations
|
||||
|
||||
### Features in alpha testing
|
||||
* New player component with custom controls, gestures and support for 4K playback
|
||||
|
||||
You can leave your feedback in [discussion on v1.4 release](https://github.com/yattee/yattee/discussions/93) or join [Matrix Channel](https://matrix.to/#/#yattee:matrix.org) for a chat. Thanks!
|
||||
|
||||
### Availability
|
||||
| Feature | Invidious | Piped |
|
||||
|| Invidious | Piped |
|
||||
| - | - | - |
|
||||
| User Accounts | ✅ | 🔴 |
|
||||
| Subscriptions | ✅ | 🔴 |
|
||||
| User Accounts | ✅ | ✅ |
|
||||
| Subscriptions | ✅ | ✅ |
|
||||
| Popular | ✅ | 🔴 |
|
||||
| User Playlists | ✅ | 🔴 |
|
||||
| User Playlists | ✅ | ✅ |
|
||||
| Trending | ✅ | ✅ |
|
||||
| Channels | ✅ | ✅ |
|
||||
| Channel Playlists | ✅ | ✅ |
|
||||
@@ -31,82 +38,22 @@ Video player with support for [Invidious](https://github.com/iv-org/invidious) a
|
||||
| Search Suggestions | ✅ | ✅ |
|
||||
| Search Filters | ✅ | 🔴 |
|
||||
| Subtitles | 🔴 | ✅ |
|
||||
| Comments | 🔴 | ✅ |
|
||||
|
||||
## Installation
|
||||
### Requirements
|
||||
Application is built using latest APIs, that's why for now **only recent** software versions: iOS/tvOS 15 or macOS Monterey are supported.
|
||||
You can browse and use accounts from one app and play videos with another (for example: use Invidious account for subscriptions and use Piped as playback source). Comments can be displayed from Piped even when Invidious is used for browsing/playing.
|
||||
|
||||
### How to install?
|
||||
#### [AltStore](https://altstore.io/)
|
||||
You can sideload IPA files that you can download from Releases page.
|
||||
Alternatively, if you have to access to the beta AltStore version (v1.5), you can add the following repository in `Browse > Sources` screen: `https://alt.yattee.stream`
|
||||
|
||||
#### Manual installation
|
||||
Download sources and compile them on a Mac using Xcode, install to your devices. Please note that if you are not registered in Apple Developer Program then the applications will require reinstalling every 7 days.
|
||||
|
||||
## Integrations
|
||||
### Safari
|
||||
macOS and iOS apps include Safari extension which will redirect opened YouTube tabs to the app.
|
||||
### Firefox
|
||||
You can use [Privacy Redirect](https://github.com/SimonBrazell/privacy-redirect) extension to make the videos open in the app. In extension settings put the following URL as Invidious instance: `https://r.yatte.stream`
|
||||
|
||||
### macOS
|
||||
With [Finicky](https://github.com/johnste/finicky) you can configure your systems so the video links across the entire system will get opened in the app. Example configuration:
|
||||
```js
|
||||
{
|
||||
match: [
|
||||
finicky.matchDomains(/(.*\.)?youtube.com/),
|
||||
finicky.matchDomains(/(.*\.)?youtu.be/)
|
||||
],
|
||||
browser: "/Applications/Yattee.app"
|
||||
}
|
||||
```
|
||||
|
||||
## Screenshots
|
||||
### iOS
|
||||
| Player | Search | Playlists |
|
||||
| - | - | - |
|
||||
| [](https://r.yattee.stream/screenshots/iOS/player.png) | [](https://r.yattee.stream/screenshots/iOS/search-suggestions.png) | [](https://r.yattee.stream/screenshots/iOS/playlists.png) |
|
||||
### iPadOS
|
||||
| Settings | Player | Subscriptions |
|
||||
| - | - | - |
|
||||
| [](https://r.yattee.stream/screenshots/iPadOS/settings.png) | [](https://r.yattee.stream/screenshots/iPadOS/player.png) | [](https://r.yattee.stream/screenshots/iPadOS/subscriptions.png) |
|
||||
### tvOS
|
||||
| Player | Popular | Search | Now Playing | Settings |
|
||||
| - | - | - | - | - |
|
||||
| [](https://r.yattee.stream/screenshots/tvOS/player.png) | [](https://r.yattee.stream/screenshots/tvOS/popular.png) | [](https://r.yattee.stream/screenshots/tvOS/search.png) | [](https://r.yattee.stream/screenshots/tvOS/now-playing.png) | [](https://r.yattee.stream/screenshots/tvOS/settings.png) |
|
||||
### macOS
|
||||
| Player | Channel | Search | Settings |
|
||||
| - | - | - | - |
|
||||
| [](https://r.yattee.stream/screenshots/macOS/player.png) | [](https://r.yattee.stream/screenshots/macOS/channel.png) | [](https://r.yattee.stream/screenshots/macOS/search.png) | [](https://r.yattee.stream/screenshots/macOS/settings.png) |
|
||||
|
||||
## Tips
|
||||
### Settings
|
||||
* [tvOS] To open settings press Play/Pause button while hovering over navigation menu or video
|
||||
### Navigation
|
||||
* Use videos context menus to add to queue, open or subscribe channel and add to playlist
|
||||
* [tvOS] Pressing buttons in the app trigger switch to next available option (for example: next account in Settings). If you want to access list of all options, press and hold to open the context menu.
|
||||
* [iOS] Swipe the player/title bar: up to open fullscreen details view, bottom to close fullscreen details or hide player
|
||||
### Favorites
|
||||
* Add more sections using ❤️ button in views channels, playlists, searches, subscriptions and popular
|
||||
* [iOS/macOS] Reorganize with dragging and dropping
|
||||
* [iOS/macOS] Remove section with right click/press and hold on section name
|
||||
* [tvOS] Reorganize and remove from `Settings > Edit Favorites...`
|
||||
### Keyboard shortcuts
|
||||
* `Command+1` - Favorites
|
||||
* `Command+2` - Subscriptions
|
||||
* `Command+3` - Popular
|
||||
* `Command+4` - Trending
|
||||
* `Command+F` - Search
|
||||
* `Command+P` - Play/Pause
|
||||
* `Command+S` - Play Next
|
||||
* `Command+O` - Toggle Player
|
||||
## Documentation
|
||||
* [Installation Instructions](https://github.com/yattee/yattee/wiki/Installation-Instructions)
|
||||
* [FAQ](https://github.com/yattee/yattee/wiki)
|
||||
* [Screenshots Gallery](https://github.com/yattee/yattee/wiki/Screenshots-Gallery)
|
||||
* [Tips](https://github.com/yattee/yattee/wiki/Tips)
|
||||
* [Integrations](https://github.com/yattee/yattee/wiki/Integrations)
|
||||
* [Donations](https://github.com/yattee/yattee/wiki/Donations)
|
||||
|
||||
## Contributing
|
||||
Every contribution to make this tool better is very welcome. Start with [creating issue](https://github.com/yattee/app/issues/new) to have discussion which can be later transformed into a Pull Request.
|
||||
|
||||
Review existing Issues and Pull Requests before creating new ones.
|
||||
If you're interestred in contributing, you can browse the [issues](https://github.com/yattee/yattee/issues) list or create a new one to discuss your feature idea. Every contribution is very welcome.
|
||||
|
||||
Join [Matrix Channel](https://matrix.to/#/#yattee:matrix.org) for a chat if you need an advice or want to discuss the project.
|
||||
## License and Liability
|
||||
|
||||
Yattee and its components is shared on [AGPL v3](https://www.gnu.org/licenses/agpl-3.0.en.html) license.
|
||||
|
||||
@@ -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"
|
||||
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 617 B After Width: | Height: | Size: 543 B |
|
Before Width: | Height: | Size: 773 B After Width: | Height: | Size: 557 B |
|
Before Width: | Height: | Size: 773 B After Width: | Height: | Size: 557 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 801 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 801 B |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1012 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 690 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 690 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 690 B |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1000 B |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1000 B |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 854 B |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1010 B |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.5 KiB |
@@ -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.537",
|
||||
"green" : "0.522",
|
||||
"red" : "1.000"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.537",
|
||||
"green" : "0.522",
|
||||
"red" : "1.000"
|
||||
}
|
||||
},
|
||||
"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.329",
|
||||
"green" : "0.224",
|
||||
"red" : "0.043"
|
||||
"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.329",
|
||||
"green" : "0.224",
|
||||
"red" : "0.043"
|
||||
"blue" : "0.263",
|
||||
"green" : "0.290",
|
||||
"red" : "0.859"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
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,11 +1,15 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
extension Defaults.Keys {
|
||||
static let kavinPipedInstanceID = "kavin-piped"
|
||||
static let instances = Key<[Instance]>("instances", default: [
|
||||
.init(
|
||||
app: .piped,
|
||||
id: "default-piped-instance",
|
||||
id: kavinPipedInstanceID,
|
||||
name: "Kavin",
|
||||
apiURL: "https://pipedapi.kavin.rocks",
|
||||
frontendURL: "https://piped.kavin.rocks"
|
||||
@@ -21,31 +25,69 @@ extension Defaults.Keys {
|
||||
|
||||
static let favorites = Key<[FavoriteItem]>("favorites", default: [
|
||||
.init(section: .trending("US", "default")),
|
||||
.init(section: .trending("GB", "default")),
|
||||
.init(section: .trending("ES", "default")),
|
||||
.init(section: .channel("UC-lHJZR3Gqxm24_Vd_AJ5Yw", "PewDiePie")),
|
||||
.init(section: .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 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 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)
|
||||
static let trendingCountry = Key<Country>("trendingCountry", default: .us)
|
||||
|
||||
static let visibleSections = Key<Set<VisibleSection>>("visibleSections", default: [.favorites, .subscriptions, .trending, .playlists])
|
||||
|
||||
#if os(macOS)
|
||||
static let enableBetaChannel = Key<Bool>("enableBetaChannel", default: false)
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
static let tabNavigationSection = Key<TabNavigationSectionSetting>("tabNavigationSection", default: .trending)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -64,7 +106,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
|
||||
}
|
||||
@@ -83,8 +125,62 @@ enum PlayerSidebarSetting: String, CaseIterable, Defaults.Serializable {
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
enum TabNavigationSectionSetting: String, Defaults.Serializable {
|
||||
case trending, popular
|
||||
enum VisibleSection: String, CaseIterable, Comparable, Defaults.Serializable {
|
||||
case favorites, subscriptions, popular, trending, playlists
|
||||
|
||||
var title: String {
|
||||
rawValue.localizedCapitalized
|
||||
}
|
||||
|
||||
var tabSelection: TabSelection {
|
||||
switch self {
|
||||
case .favorites:
|
||||
return TabSelection.favorites
|
||||
case .subscriptions:
|
||||
return TabSelection.subscriptions
|
||||
case .popular:
|
||||
return TabSelection.popular
|
||||
case .trending:
|
||||
return TabSelection.trending
|
||||
case .playlists:
|
||||
return TabSelection.playlists
|
||||
}
|
||||
}
|
||||
|
||||
private var sortOrder: Int {
|
||||
switch self {
|
||||
case .favorites:
|
||||
return 0
|
||||
case .subscriptions:
|
||||
return 1
|
||||
case .popular:
|
||||
return 2
|
||||
case .trending:
|
||||
return 3
|
||||
case .playlists:
|
||||
return 4
|
||||
}
|
||||
}
|
||||
|
||||
static func < (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.sortOrder < rhs.sortOrder
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -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,12 @@ private struct CurrentPlaylistID: EnvironmentKey {
|
||||
static let defaultValue: String? = nil
|
||||
}
|
||||
|
||||
private struct LoadMoreContentHandler: EnvironmentKey {
|
||||
static let defaultValue: LoadMoreContentHandlerType = {}
|
||||
}
|
||||
|
||||
typealias LoadMoreContentHandlerType = () -> Void
|
||||
|
||||
extension EnvironmentValues {
|
||||
var inNavigationView: Bool {
|
||||
get { self[InNavigationViewKey.self] }
|
||||
@@ -36,6 +46,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 +65,9 @@ extension EnvironmentValues {
|
||||
get { self[CurrentPlaylistID.self] }
|
||||
set { self[CurrentPlaylistID.self] = newValue }
|
||||
}
|
||||
|
||||
var loadMoreContentHandler: LoadMoreContentHandlerType {
|
||||
get { self[LoadMoreContentHandler.self] }
|
||||
set { self[LoadMoreContentHandler.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,18 @@ struct DropFavorite: DropDelegate {
|
||||
return
|
||||
}
|
||||
|
||||
let from = favorites.firstIndex(of: current!)!
|
||||
let to = favorites.firstIndex(of: item)!
|
||||
guard let current = current else {
|
||||
return
|
||||
}
|
||||
|
||||
guard favorites[to].id != current!.id else {
|
||||
let from = favorites.firstIndex(of: current)
|
||||
let to = favorites.firstIndex(of: item)
|
||||
|
||||
guard let from = from, let to = to else {
|
||||
return
|
||||
}
|
||||
|
||||
guard favorites[to].id != current.id else {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -49,23 +49,29 @@ struct FavoriteItemView: View {
|
||||
}
|
||||
|
||||
.contentShape(Rectangle())
|
||||
.opacity(dragging?.id == item.id ? 0.5 : 1)
|
||||
.onAppear {
|
||||
resource?.addObserver(store)
|
||||
resource?.loadIfNeeded()
|
||||
}
|
||||
#if os(macOS)
|
||||
.opacity(dragging?.id == item.id ? 0.5 : 1)
|
||||
#endif
|
||||
.onAppear {
|
||||
resource?.addObserver(store)
|
||||
resource?.load()
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.onDrag {
|
||||
dragging = item
|
||||
return NSItemProvider(object: item.id as NSString)
|
||||
}
|
||||
.onDrop(
|
||||
of: [UTType.text],
|
||||
delegate: DropFavorite(item: item, favorites: $favorites, current: $dragging)
|
||||
)
|
||||
.onDrag {
|
||||
dragging = item
|
||||
return NSItemProvider(object: item.id as NSString)
|
||||
}
|
||||
.onDrop(
|
||||
of: [UTType.text],
|
||||
delegate: DropFavorite(item: item, favorites: $favorites, current: $dragging)
|
||||
)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.onChange(of: accounts.current) { _ in
|
||||
resource?.addObserver(store)
|
||||
resource?.load()
|
||||
}
|
||||
}
|
||||
|
||||
private var isVisible: Bool {
|
||||
@@ -107,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
|
||||
}
|
||||
|
||||
@@ -27,8 +27,17 @@ 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
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -42,18 +51,18 @@ struct FavoritesView: View {
|
||||
.redrawOn(change: favoritesChanged)
|
||||
|
||||
#if os(tvOS)
|
||||
.sheet(isPresented: $presentingEditFavorites) {
|
||||
EditFavorites()
|
||||
}
|
||||
.edgesIgnoringSafeArea(.horizontal)
|
||||
#else
|
||||
.onDrop(of: [UTType.text], delegate: DropFavoriteOutside(current: $dragging))
|
||||
.navigationTitle("Favorites")
|
||||
#endif
|
||||
#if os(macOS)
|
||||
.background()
|
||||
.background(Color.secondaryBackground)
|
||||
.frame(minWidth: 360)
|
||||
#endif
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(RefreshControl.navigationBarTitleDisplayMode)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,36 +10,53 @@ struct MenuCommands: Commands {
|
||||
}
|
||||
|
||||
private var navigationMenu: some Commands {
|
||||
CommandMenu("Navigation") {
|
||||
CommandGroup(before: .windowSize) {
|
||||
Button("Favorites") {
|
||||
model.navigation?.tabSelection = .favorites
|
||||
setTabSelection(.favorites)
|
||||
}
|
||||
.keyboardShortcut("1")
|
||||
|
||||
Button("Subscriptions") {
|
||||
model.navigation?.tabSelection = .subscriptions
|
||||
setTabSelection(.subscriptions)
|
||||
}
|
||||
.disabled(!(model.accounts?.app.supportsSubscriptions ?? true))
|
||||
.disabled(subscriptionsDisabled)
|
||||
.keyboardShortcut("2")
|
||||
|
||||
Button("Popular") {
|
||||
model.navigation?.tabSelection = .popular
|
||||
setTabSelection(.popular)
|
||||
}
|
||||
.disabled(!(model.accounts?.app.supportsPopular ?? true))
|
||||
.disabled(!(model.accounts?.app.supportsPopular ?? false))
|
||||
.keyboardShortcut("3")
|
||||
|
||||
Button("Trending") {
|
||||
model.navigation?.tabSelection = .trending
|
||||
setTabSelection(.trending)
|
||||
}
|
||||
.keyboardShortcut("4")
|
||||
|
||||
Button("Search") {
|
||||
model.navigation?.tabSelection = .search
|
||||
setTabSelection(.search)
|
||||
}
|
||||
.keyboardShortcut("f")
|
||||
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
|
||||
private func setTabSelection(_ tabSelection: NavigationModel.TabSelection) {
|
||||
guard let navigation = model.navigation else {
|
||||
return
|
||||
}
|
||||
|
||||
navigation.sidebarSectionChanged.toggle()
|
||||
navigation.tabSelection = tabSelection
|
||||
}
|
||||
|
||||
private var subscriptionsDisabled: Bool {
|
||||
!(
|
||||
(model.accounts?.app.supportsSubscriptions ?? false) && model.accounts?.signedIn ?? false
|
||||
)
|
||||
}
|
||||
|
||||
private var playbackMenu: some Commands {
|
||||
CommandMenu("Playback") {
|
||||
Button((model.player?.isPlaying ?? true) ? "Pause" : "Play") {
|
||||
@@ -54,10 +71,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct UnsubscribeAlertModifier: ViewModifier {
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.alert(unsubscribeAlertTitle, isPresented: $navigation.presentingUnsubscribeAlert) {
|
||||
if let channel = navigation.channelToUnsubscribe {
|
||||
Button("Unsubscribe", role: .destructive) {
|
||||
subscriptions.unsubscribe(channel.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var unsubscribeAlertTitle: String {
|
||||
if let channel = navigation.channelToUnsubscribe {
|
||||
return "Unsubscribe from \(channel.name)"
|
||||
}
|
||||
|
||||
return "Unknown channel"
|
||||
}
|
||||
}
|
||||
@@ -6,22 +6,42 @@ 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: {
|
||||
Label(model.current?.name ?? "Select Account", systemImage: "person.crop.circle")
|
||||
.labelStyle(.titleAndIcon)
|
||||
HStack {
|
||||
Image(systemName: "person.crop.circle")
|
||||
if accountPickerDisplaysUsername {
|
||||
label
|
||||
.labelStyle(.titleOnly)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(instances.isEmpty)
|
||||
.transaction { t in t.animation = .none }
|
||||
}
|
||||
|
||||
private var label: some View {
|
||||
Label(model.current?.description ?? "Select Account", systemImage: "person.crop.circle")
|
||||
}
|
||||
|
||||
private var allAccounts: [Account] {
|
||||
accounts + instances.map(\.anonymousAccount)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
#if os(iOS)
|
||||
import Introspect
|
||||
@@ -5,9 +6,19 @@ import SwiftUI
|
||||
|
||||
struct AppSidebarNavigation: View {
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
|
||||
#if os(iOS)
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@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 {
|
||||
@@ -38,13 +49,36 @@ struct AppSidebarNavigation: View {
|
||||
.frame(minWidth: sidebarMinWidth)
|
||||
|
||||
VStack {
|
||||
Image(systemName: "play.tv")
|
||||
.renderingMode(.original)
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(.accentColor)
|
||||
PlayerControlsView {
|
||||
HStack(alignment: .center) {
|
||||
Spacer()
|
||||
Image(systemName: "play.tv")
|
||||
.renderingMode(.original)
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(.accentColor)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.environment(\.navigationStyle, .sidebar)
|
||||
#if os(iOS)
|
||||
.background(
|
||||
EmptyView().fullScreenCover(isPresented: $player.presentingPlayer) {
|
||||
VideoPlayerView()
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(comments)
|
||||
.environmentObject(instances)
|
||||
.environmentObject(navigation)
|
||||
.environmentObject(player)
|
||||
.environmentObject(playlists)
|
||||
.environmentObject(recents)
|
||||
.environmentObject(subscriptions)
|
||||
.environmentObject(thumbnailsModel)
|
||||
.environment(\.navigationStyle, .sidebar)
|
||||
}
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
var toolbarContent: some ToolbarContent {
|
||||
@@ -66,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,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,15 +11,15 @@ struct AppSidebarPlaylists: View {
|
||||
NavigationLink(tag: TabSelection.playlist(playlist.id), selection: $navigation.tabSelection) {
|
||||
LazyView(PlaylistVideosView(playlist))
|
||||
} label: {
|
||||
Label(playlist.title, systemImage: AppSidebarNavigation.symbolSystemImage(playlist.title))
|
||||
.badge(Text("\(playlist.videos.count)"))
|
||||
playlistLabel(playlist)
|
||||
}
|
||||
.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))
|
||||
@@ -30,8 +30,17 @@ struct AppSidebarPlaylists: View {
|
||||
newPlaylistButton
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.onAppear {
|
||||
playlists.load()
|
||||
}
|
||||
|
||||
@ViewBuilder func playlistLabel(_ playlist: Playlist) -> some View {
|
||||
let label = Label(playlist.title, systemImage: RecentsModel.symbolSystemImage(playlist.title))
|
||||
|
||||
if player.accounts.app.userPlaylistsEndpointIncludesVideos {
|
||||
label
|
||||
.backport
|
||||
.badge(Text("\(playlist.videos.count)"))
|
||||
} else {
|
||||
label
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,19 +11,15 @@ 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") {
|
||||
navigation.presentUnsubscribeAlert(channel)
|
||||
}
|
||||
}
|
||||
.modifier(UnsubscribeAlertModifier())
|
||||
.id("channel\(channel.id)")
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
subscriptions.load()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,124 +3,124 @@ import SwiftUI
|
||||
|
||||
struct AppTabNavigation: 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(.tabNavigationSection) private var tabNavigationSection
|
||||
@Default(.visibleSections) private var visibleSections
|
||||
|
||||
let persistenceController = PersistenceController.shared
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: navigation.tabSelectionBinding) {
|
||||
NavigationView {
|
||||
LazyView(FavoritesView())
|
||||
.toolbar { toolbarContent }
|
||||
}
|
||||
.tabItem {
|
||||
Label("Favorites", systemImage: "heart")
|
||||
.accessibility(label: Text("Favorites"))
|
||||
}
|
||||
.tag(TabSelection.favorites)
|
||||
|
||||
if subscriptionsVisible {
|
||||
NavigationView {
|
||||
LazyView(SubscriptionsView())
|
||||
.toolbar { toolbarContent }
|
||||
}
|
||||
.tabItem {
|
||||
Label("Subscriptions", systemImage: "star.circle.fill")
|
||||
.accessibility(label: Text("Subscriptions"))
|
||||
}
|
||||
.tag(TabSelection.subscriptions)
|
||||
if visibleSections.contains(.favorites) {
|
||||
favoritesNavigationView
|
||||
}
|
||||
|
||||
if subscriptionsVisible {
|
||||
if accounts.app.supportsPopular {
|
||||
if tabNavigationSection == .popular {
|
||||
popularNavigationView
|
||||
} else {
|
||||
trendingNavigationView
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if accounts.app.supportsPopular {
|
||||
popularNavigationView
|
||||
}
|
||||
subscriptionsNavigationView
|
||||
}
|
||||
|
||||
if visibleSections.contains(.popular), accounts.app.supportsPopular, visibleSections.count < 5 {
|
||||
popularNavigationView
|
||||
}
|
||||
|
||||
if visibleSections.contains(.trending) {
|
||||
trendingNavigationView
|
||||
}
|
||||
|
||||
if accounts.app.supportsUserPlaylists {
|
||||
NavigationView {
|
||||
LazyView(PlaylistsView())
|
||||
.toolbar { toolbarContent }
|
||||
}
|
||||
.tabItem {
|
||||
Label("Playlists", systemImage: "list.and.film")
|
||||
.accessibility(label: Text("Playlists"))
|
||||
}
|
||||
.tag(TabSelection.playlists)
|
||||
if playlistsVisible {
|
||||
playlistsNavigationView
|
||||
}
|
||||
|
||||
NavigationView {
|
||||
LazyView(
|
||||
SearchView()
|
||||
.toolbar { toolbarContent }
|
||||
.searchable(text: $search.queryText, placement: .navigationBarDrawer(displayMode: .always)) {
|
||||
ForEach(search.querySuggestions.collection, id: \.self) { suggestion in
|
||||
Text(suggestion)
|
||||
.searchCompletion(suggestion)
|
||||
}
|
||||
}
|
||||
.onChange(of: search.queryText) { query in
|
||||
search.loadSuggestions(query)
|
||||
}
|
||||
.onSubmit(of: .search) {
|
||||
search.changeQuery { query in
|
||||
query.query = search.queryText
|
||||
}
|
||||
|
||||
recents.addQuery(search.queryText)
|
||||
}
|
||||
)
|
||||
}
|
||||
.tabItem {
|
||||
Label("Search", systemImage: "magnifyingglass")
|
||||
.accessibility(label: Text("Search"))
|
||||
}
|
||||
.tag(TabSelection.search)
|
||||
searchNavigationView
|
||||
}
|
||||
.id(accounts.current?.id ?? "")
|
||||
.environment(\.navigationStyle, .tab)
|
||||
.sheet(isPresented: $navigation.presentingChannel, onDismiss: {
|
||||
if let channel = recents.presentedChannel {
|
||||
recents.close(RecentItem(from: channel))
|
||||
}
|
||||
}) {
|
||||
if let channel = recents.presentedChannel {
|
||||
NavigationView {
|
||||
ChannelVideosView(channel: channel)
|
||||
.environment(\.inChannelView, true)
|
||||
.environment(\.inNavigationView, true)
|
||||
.background(playerNavigationLink)
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingChannel) {
|
||||
if let channel = recents.presentedChannel {
|
||||
NavigationView {
|
||||
ChannelVideosView(channel: channel)
|
||||
.environment(\.managedObjectContext, persistenceController.container.viewContext)
|
||||
.environment(\.inChannelView, true)
|
||||
.environment(\.inNavigationView, true)
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(navigation)
|
||||
.environmentObject(player)
|
||||
.environmentObject(subscriptions)
|
||||
.environmentObject(thumbnailsModel)
|
||||
|
||||
.background(playerNavigationLink)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $navigation.presentingPlaylist, onDismiss: {
|
||||
if let playlist = recents.presentedPlaylist {
|
||||
recents.close(RecentItem(from: playlist))
|
||||
}
|
||||
}) {
|
||||
if let playlist = recents.presentedPlaylist {
|
||||
NavigationView {
|
||||
ChannelPlaylistView(playlist: playlist)
|
||||
.environment(\.inNavigationView, true)
|
||||
.background(playerNavigationLink)
|
||||
)
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingPlaylist) {
|
||||
if let playlist = recents.presentedPlaylist {
|
||||
NavigationView {
|
||||
ChannelPlaylistView(playlist: playlist)
|
||||
.environment(\.managedObjectContext, persistenceController.container.viewContext)
|
||||
.environment(\.inNavigationView, true)
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(navigation)
|
||||
.environmentObject(player)
|
||||
.environmentObject(subscriptions)
|
||||
.environmentObject(thumbnailsModel)
|
||||
|
||||
.background(playerNavigationLink)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
.background(
|
||||
EmptyView().fullScreenCover(isPresented: $player.presentingPlayer) {
|
||||
videoPlayer
|
||||
.environment(\.managedObjectContext, persistenceController.container.viewContext)
|
||||
.environment(\.navigationStyle, .tab)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var favoritesNavigationView: some View {
|
||||
NavigationView {
|
||||
LazyView(FavoritesView())
|
||||
.toolbar { toolbarContent }
|
||||
}
|
||||
.tabItem {
|
||||
Label("Favorites", systemImage: "heart")
|
||||
.accessibility(label: Text("Favorites"))
|
||||
}
|
||||
.tag(TabSelection.favorites)
|
||||
}
|
||||
|
||||
private var subscriptionsNavigationView: some View {
|
||||
NavigationView {
|
||||
LazyView(SubscriptionsView())
|
||||
.toolbar { toolbarContent }
|
||||
}
|
||||
.tabItem {
|
||||
Label("Subscriptions", systemImage: "star.circle.fill")
|
||||
.accessibility(label: Text("Subscriptions"))
|
||||
}
|
||||
.tag(TabSelection.subscriptions)
|
||||
}
|
||||
|
||||
private var subscriptionsVisible: Bool {
|
||||
accounts.app.supportsSubscriptions && !(accounts.current?.anonymous ?? true)
|
||||
visibleSections.contains(.subscriptions) &&
|
||||
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 {
|
||||
@@ -129,7 +129,7 @@ struct AppTabNavigation: View {
|
||||
.toolbar { toolbarContent }
|
||||
}
|
||||
.tabItem {
|
||||
Label("Popular", systemImage: "chart.bar")
|
||||
Label("Popular", systemImage: "arrow.up.right.circle")
|
||||
.accessibility(label: Text("Popular"))
|
||||
}
|
||||
.tag(TabSelection.popular)
|
||||
@@ -141,21 +141,58 @@ struct AppTabNavigation: View {
|
||||
.toolbar { toolbarContent }
|
||||
}
|
||||
.tabItem {
|
||||
Label("Trending", systemImage: "chart.line.uptrend.xyaxis")
|
||||
Label("Trending", systemImage: "chart.bar")
|
||||
.accessibility(label: Text("Trending"))
|
||||
}
|
||||
.tag(TabSelection.trending)
|
||||
}
|
||||
|
||||
private var playlistsNavigationView: some View {
|
||||
NavigationView {
|
||||
LazyView(PlaylistsView())
|
||||
.toolbar { toolbarContent }
|
||||
}
|
||||
.tabItem {
|
||||
Label("Playlists", systemImage: "list.and.film")
|
||||
.accessibility(label: Text("Playlists"))
|
||||
}
|
||||
.tag(TabSelection.playlists)
|
||||
}
|
||||
|
||||
private var searchNavigationView: some View {
|
||||
NavigationView {
|
||||
LazyView(SearchView())
|
||||
.toolbar { toolbarContent }
|
||||
}
|
||||
.tabItem {
|
||||
Label("Search", systemImage: "magnifyingglass")
|
||||
.accessibility(label: Text("Search"))
|
||||
}
|
||||
.tag(TabSelection.search)
|
||||
}
|
||||
|
||||
private var playerNavigationLink: some View {
|
||||
NavigationLink(isActive: $player.playerNavigationLinkActive, destination: {
|
||||
VideoPlayerView()
|
||||
videoPlayer
|
||||
.environment(\.inNavigationView, true)
|
||||
}) {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
private var videoPlayer: some View {
|
||||
VideoPlayerView()
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(comments)
|
||||
.environmentObject(instances)
|
||||
.environmentObject(navigation)
|
||||
.environmentObject(player)
|
||||
.environmentObject(playlists)
|
||||
.environmentObject(recents)
|
||||
.environmentObject(subscriptions)
|
||||
.environmentObject(thumbnailsModel)
|
||||
}
|
||||
|
||||
var toolbarContent: some ToolbarContent {
|
||||
#if os(iOS)
|
||||
Group {
|
||||
|
||||
@@ -7,15 +7,16 @@ import Siesta
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@StateObject private var accounts = AccountsModel()
|
||||
@StateObject private var instances = InstancesModel()
|
||||
@StateObject private var navigation = NavigationModel()
|
||||
@StateObject private var player = PlayerModel()
|
||||
@StateObject private var playlists = PlaylistsModel()
|
||||
@StateObject private var recents = RecentsModel()
|
||||
@StateObject private var search = SearchModel()
|
||||
@StateObject private var subscriptions = SubscriptionsModel()
|
||||
@StateObject private var thumbnailsModel = ThumbnailsModel()
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@EnvironmentObject<CommentsModel> private var comments
|
||||
@EnvironmentObject<InstancesModel> private var instances
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<PlaylistsModel> private var playlists
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
@EnvironmentObject<SearchModel> private var search
|
||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||
@EnvironmentObject<ThumbnailsModel> private var thumbnailsModel
|
||||
|
||||
@EnvironmentObject<MenuModel> private var menu
|
||||
|
||||
@@ -24,7 +25,7 @@ struct ContentView: View {
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
if horizontalSizeClass == .compact {
|
||||
AppTabNavigation()
|
||||
@@ -38,8 +39,13 @@ struct ContentView: View {
|
||||
#endif
|
||||
}
|
||||
.onAppear(perform: configure)
|
||||
.onChange(of: accounts.signedIn) { _ in
|
||||
subscriptions.load(force: true)
|
||||
playlists.load(force: true)
|
||||
}
|
||||
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(comments)
|
||||
.environmentObject(instances)
|
||||
.environmentObject(navigation)
|
||||
.environmentObject(player)
|
||||
@@ -49,63 +55,66 @@ struct ContentView: View {
|
||||
.environmentObject(subscriptions)
|
||||
.environmentObject(thumbnailsModel)
|
||||
|
||||
.sheet(isPresented: $navigation.presentingWelcomeScreen) {
|
||||
WelcomeScreen()
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(navigation)
|
||||
}
|
||||
#if os(iOS)
|
||||
.fullScreenCover(isPresented: $player.presentingPlayer) {
|
||||
VideoPlayerView()
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(instances)
|
||||
.environmentObject(navigation)
|
||||
.environmentObject(player)
|
||||
.environmentObject(playlists)
|
||||
.environmentObject(subscriptions)
|
||||
.environmentObject(thumbnailsModel)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
.sheet(isPresented: $player.presentingPlayer) {
|
||||
VideoPlayerView()
|
||||
.frame(minWidth: 900, minHeight: 800)
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(instances)
|
||||
.environmentObject(navigation)
|
||||
.environmentObject(player)
|
||||
.environmentObject(playlists)
|
||||
.environmentObject(subscriptions)
|
||||
.environmentObject(thumbnailsModel)
|
||||
}
|
||||
#endif
|
||||
// iOS 14 has problem with multiple sheets in one view
|
||||
// but it's ok when it's in background
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingWelcomeScreen) {
|
||||
WelcomeScreen()
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(navigation)
|
||||
}
|
||||
)
|
||||
#if !os(tvOS)
|
||||
.handlesExternalEvents(preferring: Set(["*"]), allowing: Set(["*"]))
|
||||
.onOpenURL(perform: handleOpenedURL)
|
||||
.sheet(isPresented: $navigation.presentingAddToPlaylist) {
|
||||
AddToPlaylistView(video: navigation.videoToAddToPlaylist)
|
||||
.environmentObject(playlists)
|
||||
}
|
||||
.sheet(isPresented: $navigation.presentingPlaylistForm) {
|
||||
PlaylistFormView(playlist: $navigation.editedPlaylist)
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(playlists)
|
||||
}
|
||||
.sheet(isPresented: $navigation.presentingSettings, onDismiss: openWelcomeScreenIfAccountEmpty) {
|
||||
SettingsView()
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(instances)
|
||||
}
|
||||
.onOpenURL { OpenURLHandler(accounts: accounts, player: player).handle($0) }
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingAddToPlaylist) {
|
||||
AddToPlaylistView(video: navigation.videoToAddToPlaylist)
|
||||
.environmentObject(playlists)
|
||||
}
|
||||
)
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingPlaylistForm) {
|
||||
PlaylistFormView(playlist: $navigation.editedPlaylist)
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(playlists)
|
||||
}
|
||||
)
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingSettings, onDismiss: openWelcomeScreenIfAccountEmpty) {
|
||||
SettingsView()
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(instances)
|
||||
.environmentObject(player)
|
||||
}
|
||||
)
|
||||
#endif
|
||||
.alert(isPresented: $navigation.presentingUnsubscribeAlert) {
|
||||
Alert(
|
||||
title: Text(
|
||||
"Are you sure you want to unsubscribe from \(navigation.channelToUnsubscribe.name)?"
|
||||
),
|
||||
primaryButton: .destructive(Text("Unsubscribe")) {
|
||||
subscriptions.unsubscribe(navigation.channelToUnsubscribe.id)
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func configure() {
|
||||
SiestaLog.Category.enabled = .common
|
||||
SDImageCodersManager.shared.addCoder(SDImageWebPCoder.shared)
|
||||
SDWebImageManager.defaultImageCache = PINCache(name: "net.yattee.app")
|
||||
SDWebImageManager.defaultImageCache = PINCache(name: "stream.yattee.app")
|
||||
#if !os(macOS)
|
||||
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
if Defaults[.lockPortraitWhenBrowsing] {
|
||||
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
|
||||
}
|
||||
#endif
|
||||
|
||||
if let account = accounts.lastUsed ??
|
||||
instances.lastUsed?.anonymousAccount ??
|
||||
InstancesModel.all.first?.anonymousAccount
|
||||
@@ -117,18 +126,39 @@ struct ContentView: View {
|
||||
navigation.presentingWelcomeScreen = true
|
||||
}
|
||||
|
||||
player.accounts = accounts
|
||||
playlists.accounts = accounts
|
||||
search.accounts = accounts
|
||||
subscriptions.accounts = accounts
|
||||
|
||||
comments.player = player
|
||||
|
||||
menu.accounts = accounts
|
||||
menu.navigation = navigation
|
||||
menu.player = player
|
||||
|
||||
player.accounts = accounts
|
||||
player.comments = comments
|
||||
|
||||
if !accounts.current.isNil {
|
||||
player.loadHistoryDetails()
|
||||
player.restoreQueue()
|
||||
}
|
||||
|
||||
if !Defaults[.saveRecents] {
|
||||
recents.clear()
|
||||
}
|
||||
|
||||
var section = Defaults[.visibleSections].min()?.tabSelection
|
||||
|
||||
#if os(macOS)
|
||||
if section == .playlists {
|
||||
section = .search
|
||||
}
|
||||
#endif
|
||||
|
||||
navigation.tabSelection = section ?? .search
|
||||
|
||||
subscriptions.load()
|
||||
playlists.load()
|
||||
}
|
||||
|
||||
func openWelcomeScreenIfAccountEmpty() {
|
||||
@@ -138,28 +168,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 {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct Sidebar: View {
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
|
||||
@Default(.visibleSections) private var visibleSections
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { scrollView in
|
||||
List {
|
||||
@@ -13,9 +16,14 @@ struct Sidebar: View {
|
||||
AppSidebarRecents()
|
||||
.id("recentlyOpened")
|
||||
|
||||
if accounts.signedIn {
|
||||
AppSidebarSubscriptions()
|
||||
AppSidebarPlaylists()
|
||||
if accounts.api.signedIn {
|
||||
if visibleSections.contains(.subscriptions), accounts.app.supportsSubscriptions {
|
||||
AppSidebarSubscriptions()
|
||||
}
|
||||
|
||||
if visibleSections.contains(.playlists), accounts.app.supportsUserPlaylists {
|
||||
AppSidebarPlaylists()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,43 +39,58 @@ struct Sidebar: View {
|
||||
}
|
||||
|
||||
var mainNavigationLinks: some View {
|
||||
Section("Videos") {
|
||||
NavigationLink(destination: LazyView(FavoritesView()), tag: TabSelection.favorites, selection: $navigation.tabSelection) {
|
||||
Label("Favorites", systemImage: "heart")
|
||||
.accessibility(label: Text("Favorites"))
|
||||
Section(header: Text("Videos")) {
|
||||
if visibleSections.contains(.favorites) {
|
||||
NavigationLink(destination: LazyView(FavoritesView()), tag: TabSelection.favorites, selection: $navigation.tabSelection) {
|
||||
Label("Favorites", systemImage: "heart")
|
||||
.accessibility(label: Text("Favorites"))
|
||||
}
|
||||
.id("favorites")
|
||||
}
|
||||
if accounts.app.supportsSubscriptions && accounts.signedIn {
|
||||
if visibleSections.contains(.subscriptions),
|
||||
accounts.app.supportsSubscriptions && accounts.signedIn
|
||||
{
|
||||
NavigationLink(destination: LazyView(SubscriptionsView()), tag: TabSelection.subscriptions, selection: $navigation.tabSelection) {
|
||||
Label("Subscriptions", systemImage: "star.circle")
|
||||
.accessibility(label: Text("Subscriptions"))
|
||||
}
|
||||
.id("subscriptions")
|
||||
}
|
||||
|
||||
if accounts.app.supportsPopular {
|
||||
if visibleSections.contains(.popular), accounts.app.supportsPopular {
|
||||
NavigationLink(destination: LazyView(PopularView()), tag: TabSelection.popular, selection: $navigation.tabSelection) {
|
||||
Label("Popular", systemImage: "chart.bar")
|
||||
Label("Popular", systemImage: "arrow.up.right.circle")
|
||||
.accessibility(label: Text("Popular"))
|
||||
}
|
||||
.id("popular")
|
||||
}
|
||||
|
||||
NavigationLink(destination: LazyView(TrendingView()), tag: TabSelection.trending, selection: $navigation.tabSelection) {
|
||||
Label("Trending", systemImage: "chart.line.uptrend.xyaxis")
|
||||
.accessibility(label: Text("Trending"))
|
||||
if visibleSections.contains(.trending) {
|
||||
NavigationLink(destination: LazyView(TrendingView()), tag: TabSelection.trending, selection: $navigation.tabSelection) {
|
||||
Label("Trending", systemImage: "chart.bar")
|
||||
.accessibility(label: Text("Trending"))
|
||||
}
|
||||
.id("trending")
|
||||
}
|
||||
|
||||
NavigationLink(destination: LazyView(SearchView()), tag: TabSelection.search, selection: $navigation.tabSelection) {
|
||||
Label("Search", systemImage: "magnifyingglass")
|
||||
.accessibility(label: Text("Search"))
|
||||
}
|
||||
.id("search")
|
||||
.keyboardShortcut("f")
|
||||
}
|
||||
}
|
||||
|
||||
func scrollScrollViewToItem(scrollView: ScrollViewProxy, for selection: TabSelection) {
|
||||
private func scrollScrollViewToItem(scrollView: ScrollViewProxy, for selection: TabSelection) {
|
||||
if case .recentlyOpened = selection {
|
||||
scrollView.scrollTo("recentlyOpened")
|
||||
return
|
||||
} else if case let .playlist(id) = selection {
|
||||
scrollView.scrollTo(id)
|
||||
return
|
||||
}
|
||||
|
||||
scrollView.scrollTo(selection.stringValue)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
274
Shared/Player/CommentView.swift
Normal file
@@ -0,0 +1,274 @@
|
||||
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) {
|
||||
HStack(spacing: 10) {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
authorAvatar
|
||||
|
||||
if subscribed {
|
||||
Image(systemName: "star.circle.fill")
|
||||
#if os(tvOS)
|
||||
.background(Color.background(scheme: colorScheme))
|
||||
#else
|
||||
.background(Color.background)
|
||||
#endif
|
||||
.clipShape(Circle())
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
subscribed = subscriptions.isSubscribing(comment.channel.id)
|
||||
}
|
||||
|
||||
authorAndTime
|
||||
}
|
||||
.contextMenu {
|
||||
Button(action: openChannelAction) {
|
||||
Label("\(comment.channel.name) Channel", systemImage: "rectangle.stack.fill.badge.person.crop")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Group {
|
||||
#if os(iOS)
|
||||
if horizontalSizeClass == .regular {
|
||||
Group {
|
||||
statusIcons
|
||||
likes
|
||||
}
|
||||
} else {
|
||||
VStack(alignment: .trailing, spacing: 8) {
|
||||
likes
|
||||
statusIcons
|
||||
}
|
||||
}
|
||||
#else
|
||||
statusIcons
|
||||
likes
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
.font(.system(size: 25).bold())
|
||||
#else
|
||||
.font(.system(size: 15))
|
||||
#endif
|
||||
|
||||
Group {
|
||||
commentText
|
||||
|
||||
if comment.hasReplies {
|
||||
HStack(spacing: repliesButtonStackSpacing) {
|
||||
repliesButton
|
||||
|
||||
ProgressView()
|
||||
.scaleEffect(progressViewScale, anchor: .center)
|
||||
.opacity(repliesID == comment.id && !comments.repliesLoaded ? 1 : 0)
|
||||
.frame(maxHeight: 0)
|
||||
}
|
||||
|
||||
if comment.id == repliesID {
|
||||
repliesList
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
.padding(.horizontal, 20)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var authorAvatar: some View {
|
||||
WebImage(url: URL(string: comment.authorAvatarURL)!)
|
||||
.resizable()
|
||||
.placeholder {
|
||||
Rectangle().fill(Color("PlaceholderColor"))
|
||||
}
|
||||
.retryOnAppear(true)
|
||||
.indicator(.activity)
|
||||
.mask(RoundedRectangle(cornerRadius: 60))
|
||||
#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)
|
||||
#if os(tvOS)
|
||||
.font(.system(size: 30).bold())
|
||||
#else
|
||||
.font(.system(size: 14).bold())
|
||||
#endif
|
||||
|
||||
Text(comment.time)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
private var statusIcons: some View {
|
||||
HStack(spacing: 15) {
|
||||
if comment.pinned {
|
||||
Image(systemName: "pin.fill")
|
||||
}
|
||||
if comment.hearted {
|
||||
Image(systemName: "heart.fill")
|
||||
}
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.font(.system(size: 12))
|
||||
#endif
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
private var likes: some View {
|
||||
Group {
|
||||
if comment.likeCount > 0 {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: "hand.thumbsup")
|
||||
Text("\(comment.likeCount.formattedAsAbbreviation())")
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.font(.system(size: 12))
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
private var repliesButton: some View {
|
||||
Button {
|
||||
repliesID = repliesID == comment.id ? nil : comment.id
|
||||
|
||||
guard !repliesID.isNil, !comment.repliesPage.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
comments.loadReplies(page: comment.repliesPage!)
|
||||
} label: {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: repliesID == comment.id ? "arrow.turn.left.up" : "arrow.turn.right.down")
|
||||
Text("Replies")
|
||||
}
|
||||
#if os(tvOS)
|
||||
.padding(10)
|
||||
#endif
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.vertical, 2)
|
||||
#if os(tvOS)
|
||||
.padding(.leading, 5)
|
||||
#else
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(.secondary)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var repliesButtonStackSpacing: Double {
|
||||
#if os(tvOS)
|
||||
24
|
||||
#elseif os(iOS)
|
||||
4
|
||||
#else
|
||||
2
|
||||
#endif
|
||||
}
|
||||
|
||||
private var progressViewScale: Double {
|
||||
#if os(macOS)
|
||||
0.4
|
||||
#else
|
||||
0.6
|
||||
#endif
|
||||
}
|
||||
|
||||
private var repliesList: some View {
|
||||
Group {
|
||||
let last = comments.replies.last
|
||||
ForEach(comments.replies) { comment in
|
||||
CommentView(comment: comment, repliesID: $repliesID)
|
||||
#if os(tvOS)
|
||||
.focusable()
|
||||
#endif
|
||||
|
||||
if comment != last {
|
||||
Divider()
|
||||
.padding(.vertical, 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.leading, 22)
|
||||
}
|
||||
|
||||
private var commentText: some View {
|
||||
Group {
|
||||
let text = Text(comment.text)
|
||||
#if os(macOS)
|
||||
.font(.system(size: 14))
|
||||
#elseif os(iOS)
|
||||
.font(.system(size: 15))
|
||||
#endif
|
||||
.lineSpacing(3)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if #available(iOS 15.0, macOS 12.0, *) {
|
||||
text
|
||||
#if !os(tvOS)
|
||||
.textSelection(.enabled)
|
||||
#endif
|
||||
} else {
|
||||
text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func openChannelAction() {
|
||||
NavigationModel.openChannel(
|
||||
comment.channel,
|
||||
player: player,
|
||||
recents: recents,
|
||||
navigation: navigation,
|
||||
navigationStyle: navigationStyle
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct CommentView_Previews: PreviewProvider {
|
||||
static var fixture: Comment {
|
||||
Comment.fixture
|
||||
}
|
||||
|
||||
static var previews: some View {
|
||||
CommentView(comment: fixture, repliesID: .constant(fixture.id))
|
||||
.environmentObject(SubscriptionsModel())
|
||||
.padding(5)
|
||||
}
|
||||
}
|
||||
61
Shared/Player/CommentsView.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CommentsView: View {
|
||||
var embedInScrollView = false
|
||||
@State private var repliesID: Comment.ID?
|
||||
|
||||
@EnvironmentObject<CommentsModel> private var comments
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if comments.disabled {
|
||||
NoCommentsView(text: "Comments are disabled", systemImage: "xmark.circle.fill")
|
||||
} else if comments.loaded && comments.all.isEmpty {
|
||||
NoCommentsView(text: "No comments", systemImage: "0.circle.fill")
|
||||
} else if !comments.loaded {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
struct CommentsView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
|
||||
CommentsView()
|
||||
.previewInterfaceOrientation(.landscapeRight)
|
||||
.injectFixtureEnvironmentObjects()
|
||||
}
|
||||
|
||||
CommentsView()
|
||||
.injectFixtureEnvironmentObjects()
|
||||
}
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -3,25 +3,28 @@ import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct PlaybackBar: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@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 !os(macOS)
|
||||
closeButton
|
||||
#endif
|
||||
|
||||
if player.currentItem != nil {
|
||||
HStack {
|
||||
Text(playbackStatus)
|
||||
|
||||
Text("•")
|
||||
|
||||
rateMenu
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundColor(.gray)
|
||||
#if os(macOS)
|
||||
.padding(.leading, 4)
|
||||
#endif
|
||||
|
||||
Spacer()
|
||||
|
||||
@@ -58,24 +61,26 @@ struct PlaybackBar: View {
|
||||
#endif
|
||||
}
|
||||
.transaction { t in t.animation = .none }
|
||||
.foregroundColor(.gray)
|
||||
.font(.caption2)
|
||||
} else {
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.alert(player.playerError?.localizedDescription ?? "", isPresented: $player.presentingErrorDetails) {
|
||||
Button("OK") {}
|
||||
.foregroundColor(colorScheme == .dark ? .gray : .black)
|
||||
.alert(isPresented: $player.presentingErrorDetails) {
|
||||
Alert(
|
||||
title: Text("Error"),
|
||||
message: Text(player.playerError?.localizedDescription ?? "")
|
||||
)
|
||||
}
|
||||
.environment(\.colorScheme, .dark)
|
||||
.frame(minWidth: 0, maxWidth: .infinity)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 20)
|
||||
.padding(4)
|
||||
.background(.black)
|
||||
.background(colorScheme == .dark ? Color.black : Color.white)
|
||||
}
|
||||
|
||||
private var closeButton: some View {
|
||||
Button {
|
||||
dismiss()
|
||||
player.hide()
|
||||
} label: {
|
||||
Label(
|
||||
"Close",
|
||||
@@ -94,21 +99,35 @@ struct PlaybackBar: View {
|
||||
return "LIVE"
|
||||
}
|
||||
|
||||
guard player.time != nil, player.time!.isValid, !player.currentVideo.isNil else {
|
||||
guard !player.isLoadingVideo else {
|
||||
return "loading..."
|
||||
}
|
||||
|
||||
let videoLengthAtRate = player.currentVideo!.length / Double(player.currentRate)
|
||||
let remainingSeconds = videoLengthAtRate - player.time!.seconds
|
||||
guard let video = player.currentVideo,
|
||||
let time = player.time
|
||||
else {
|
||||
return ""
|
||||
}
|
||||
|
||||
let videoLengthAtRate = video.length / Double(player.currentRate)
|
||||
let remainingSeconds = videoLengthAtRate - time.seconds
|
||||
|
||||
if remainingSeconds < 60 {
|
||||
return "less than a minute"
|
||||
}
|
||||
|
||||
let timeFinishAt = Date.now.addingTimeInterval(remainingSeconds)
|
||||
let timeFinishAtString = timeFinishAt.formatted(date: .omitted, time: .shortened)
|
||||
let timeFinishAt = Date().addingTimeInterval(remainingSeconds)
|
||||
|
||||
return "ends at \(timeFinishAtString)"
|
||||
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 {
|
||||
|
||||
@@ -2,8 +2,10 @@ import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct Player: UIViewControllerRepresentable {
|
||||
@EnvironmentObject<CommentsModel> private var comments
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||
|
||||
var controller: PlayerViewController?
|
||||
|
||||
@@ -18,8 +20,10 @@ struct Player: UIViewControllerRepresentable {
|
||||
|
||||
let controller = PlayerViewController()
|
||||
|
||||
controller.commentsModel = comments
|
||||
controller.navigationModel = navigation
|
||||
controller.playerModel = player
|
||||
controller.subscriptionsModel = subscriptions
|
||||
player.controller = controller
|
||||
|
||||
return controller
|
||||
|
||||