mirror of
https://github.com/yattee/yattee.git
synced 2025-12-13 03:28:14 +00:00
Compare commits
158 Commits
v1.2-beta.
...
v1.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd32bae24b | ||
|
|
17e56dc69a | ||
|
|
ad715fa367 | ||
|
|
560d7c4b9a | ||
|
|
7a88f28170 | ||
|
|
419e8991c9 | ||
|
|
720bdde728 | ||
|
|
965a757031 | ||
|
|
811196ae55 | ||
|
|
5b0f6397d6 | ||
|
|
d5362519d6 | ||
|
|
e9ee0f5ee9 | ||
|
|
0a9fb75c0c | ||
|
|
eb33b65f3d | ||
|
|
1c71520d6f | ||
|
|
7a7e265ba1 | ||
|
|
c086112a49 | ||
|
|
0802fe0029 | ||
|
|
40813c2859 | ||
|
|
b1238869a6 | ||
|
|
453a5fa71b | ||
|
|
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 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -81,6 +81,9 @@ fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots/**/*.png
|
||||
fastlane/test_output
|
||||
fastlane/builds
|
||||
fastlane/.env
|
||||
fastlane/*.p8
|
||||
|
||||
# Code Injection
|
||||
#
|
||||
@@ -91,3 +94,6 @@ iOSInjectionProject/
|
||||
|
||||
# SwiftLint Remote Config Cache
|
||||
.swiftlint/RemoteConfigCache
|
||||
|
||||
# User-specific xcconfig files
|
||||
Xcode-config/DEVELOPMENT_TEAM.xcconfig
|
||||
|
||||
@@ -7,6 +7,7 @@ disabled_rules:
|
||||
- multiline_arguments
|
||||
|
||||
excluded:
|
||||
- Vendor
|
||||
- Tests Apple TV
|
||||
- Tests iOS
|
||||
- Tests macOS
|
||||
|
||||
97
Backports/VisualEffectBlur-iOS.swift
Normal file
97
Backports/VisualEffectBlur-iOS.swift
Normal file
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
Copyright © 2020 Apple Inc.
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
public struct VisualEffectBlur<Content: View>: View {
|
||||
/// Defaults to .systemMaterial
|
||||
var blurStyle: UIBlurEffect.Style
|
||||
|
||||
/// Defaults to nil
|
||||
var vibrancyStyle: UIVibrancyEffectStyle?
|
||||
|
||||
var content: Content
|
||||
|
||||
public init(blurStyle: UIBlurEffect.Style = .systemMaterial, vibrancyStyle: UIVibrancyEffectStyle? = nil, @ViewBuilder content: () -> Content) {
|
||||
self.blurStyle = blurStyle
|
||||
self.vibrancyStyle = vibrancyStyle
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Representable(blurStyle: blurStyle, vibrancyStyle: vibrancyStyle, content: ZStack { content })
|
||||
.accessibility(hidden: Content.self == EmptyView.self)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Representable
|
||||
|
||||
extension VisualEffectBlur {
|
||||
struct Representable<Content: View>: UIViewRepresentable {
|
||||
var blurStyle: UIBlurEffect.Style
|
||||
var vibrancyStyle: UIVibrancyEffectStyle?
|
||||
var content: Content
|
||||
|
||||
func makeUIView(context: Context) -> UIVisualEffectView {
|
||||
context.coordinator.blurView
|
||||
}
|
||||
|
||||
func updateUIView(_: UIVisualEffectView, context: Context) {
|
||||
context.coordinator.update(content: content, blurStyle: blurStyle, vibrancyStyle: vibrancyStyle)
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(content: content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Coordinator
|
||||
|
||||
extension VisualEffectBlur.Representable {
|
||||
class Coordinator {
|
||||
let blurView = UIVisualEffectView()
|
||||
let vibrancyView = UIVisualEffectView()
|
||||
let hostingController: UIHostingController<Content>
|
||||
|
||||
init(content: Content) {
|
||||
hostingController = UIHostingController(rootView: content)
|
||||
hostingController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
hostingController.view.backgroundColor = nil
|
||||
blurView.contentView.addSubview(vibrancyView)
|
||||
|
||||
blurView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
vibrancyView.contentView.addSubview(hostingController.view)
|
||||
vibrancyView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
}
|
||||
|
||||
func update(content: Content, blurStyle: UIBlurEffect.Style, vibrancyStyle: UIVibrancyEffectStyle?) {
|
||||
hostingController.rootView = content
|
||||
|
||||
let blurEffect = UIBlurEffect(style: blurStyle)
|
||||
blurView.effect = blurEffect
|
||||
|
||||
if let vibrancyStyle = vibrancyStyle {
|
||||
vibrancyView.effect = UIVibrancyEffect(blurEffect: blurEffect, style: vibrancyStyle)
|
||||
} else {
|
||||
vibrancyView.effect = nil
|
||||
}
|
||||
|
||||
hostingController.view.setNeedsDisplay()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension VisualEffectBlur where Content == EmptyView {
|
||||
init(blurStyle: UIBlurEffect.Style = .systemMaterial) {
|
||||
self.init(blurStyle: blurStyle, vibrancyStyle: nil) {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
83
Backports/VisualEffectBlur-macOS.swift
Normal file
83
Backports/VisualEffectBlur-macOS.swift
Normal file
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
Copyright © 2020 Apple Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import SwiftUI
|
||||
|
||||
#if os(macOS)
|
||||
|
||||
public struct VisualEffectBlur: View {
|
||||
private var material: NSVisualEffectView.Material
|
||||
private var blendingMode: NSVisualEffectView.BlendingMode
|
||||
private var state: NSVisualEffectView.State
|
||||
|
||||
public init(
|
||||
material: NSVisualEffectView.Material = .headerView,
|
||||
blendingMode: NSVisualEffectView.BlendingMode = .withinWindow,
|
||||
state: NSVisualEffectView.State = .followsWindowActiveState
|
||||
) {
|
||||
self.material = material
|
||||
self.blendingMode = blendingMode
|
||||
self.state = state
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Representable(
|
||||
material: material,
|
||||
blendingMode: blendingMode,
|
||||
state: state
|
||||
).accessibility(hidden: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Representable
|
||||
|
||||
extension VisualEffectBlur {
|
||||
struct Representable: NSViewRepresentable {
|
||||
var material: NSVisualEffectView.Material
|
||||
var blendingMode: NSVisualEffectView.BlendingMode
|
||||
var state: NSVisualEffectView.State
|
||||
|
||||
func makeNSView(context: Context) -> NSVisualEffectView {
|
||||
context.coordinator.visualEffectView
|
||||
}
|
||||
|
||||
func updateNSView(_: NSVisualEffectView, context: Context) {
|
||||
context.coordinator.update(material: material)
|
||||
context.coordinator.update(blendingMode: blendingMode)
|
||||
context.coordinator.update(state: state)
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
}
|
||||
|
||||
class Coordinator {
|
||||
let visualEffectView = NSVisualEffectView()
|
||||
|
||||
init() {
|
||||
visualEffectView.blendingMode = .withinWindow
|
||||
}
|
||||
|
||||
func update(material: NSVisualEffectView.Material) {
|
||||
visualEffectView.material = material
|
||||
}
|
||||
|
||||
func update(blendingMode: NSVisualEffectView.BlendingMode) {
|
||||
visualEffectView.blendingMode = blendingMode
|
||||
}
|
||||
|
||||
func update(state: NSVisualEffectView.State) {
|
||||
visualEffectView.state = state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -3,12 +3,10 @@ import SwiftUI
|
||||
extension Color {
|
||||
#if os(macOS)
|
||||
static let background = Color(NSColor.windowBackgroundColor)
|
||||
static let secondaryBackground = Color(NSColor.underPageBackgroundColor)
|
||||
static let tertiaryBackground = Color(NSColor.controlBackgroundColor)
|
||||
static let secondaryBackground = Color(NSColor.controlBackgroundColor)
|
||||
#elseif os(iOS)
|
||||
static let background = Color(UIColor.systemBackground)
|
||||
static let secondaryBackground = Color(UIColor.secondarySystemBackground)
|
||||
static let tertiaryBackground = Color(UIColor.tertiarySystemBackground)
|
||||
#else
|
||||
static func background(scheme: ColorScheme) -> Color {
|
||||
scheme == .dark ? .black : .init(white: 0.8)
|
||||
|
||||
@@ -2,7 +2,7 @@ import Foundation
|
||||
|
||||
extension Double {
|
||||
func formattedAsPlaybackTime() -> String? {
|
||||
guard !isZero else {
|
||||
guard !isZero, isFinite else {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,6 @@ import AppKit
|
||||
extension NSTextField {
|
||||
override open var focusRingType: NSFocusRingType {
|
||||
get { .none }
|
||||
set {} // swiftlint:disable:this unused_setter_value
|
||||
set {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,13 +17,13 @@ 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(),
|
||||
|
||||
@@ -32,7 +32,6 @@ struct FixtureEnvironmentObjectsModifier: ViewModifier {
|
||||
|
||||
player.currentItem = PlayerQueueItem(Video.fixture)
|
||||
player.queue = Video.allFixtures.map { PlayerQueueItem($0) }
|
||||
player.history = player.queue
|
||||
|
||||
return player
|
||||
}
|
||||
|
||||
218
Gemfile.lock
Normal file
218
Gemfile.lock
Normal file
@@ -0,0 +1,218 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.5)
|
||||
rexml
|
||||
addressable (2.8.0)
|
||||
public_suffix (>= 2.0.2, < 5.0)
|
||||
artifactory (3.0.15)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.2.0)
|
||||
aws-partitions (1.618.0)
|
||||
aws-sdk-core (3.132.0)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
aws-partitions (~> 1, >= 1.525.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.58.0)
|
||||
aws-sdk-core (~> 3, >= 3.127.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sdk-s3 (1.114.0)
|
||||
aws-sdk-core (~> 3, >= 3.127.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.4)
|
||||
aws-sigv4 (1.5.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
declarative (0.0.20)
|
||||
digest-crc (0.6.4)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
domain_name (0.5.20190701)
|
||||
unf (>= 0.0.5, < 1.0.0)
|
||||
dotenv (2.8.1)
|
||||
emoji_regex (3.2.3)
|
||||
excon (0.92.4)
|
||||
faraday (1.10.1)
|
||||
faraday-em_http (~> 1.0)
|
||||
faraday-em_synchrony (~> 1.0)
|
||||
faraday-excon (~> 1.1)
|
||||
faraday-httpclient (~> 1.0)
|
||||
faraday-multipart (~> 1.0)
|
||||
faraday-net_http (~> 1.0)
|
||||
faraday-net_http_persistent (~> 1.0)
|
||||
faraday-patron (~> 1.0)
|
||||
faraday-rack (~> 1.0)
|
||||
faraday-retry (~> 1.0)
|
||||
ruby2_keywords (>= 0.0.4)
|
||||
faraday-cookie_jar (0.0.7)
|
||||
faraday (>= 0.8.0)
|
||||
http-cookie (~> 1.0.0)
|
||||
faraday-em_http (1.0.0)
|
||||
faraday-em_synchrony (1.0.0)
|
||||
faraday-excon (1.1.0)
|
||||
faraday-httpclient (1.0.1)
|
||||
faraday-multipart (1.0.4)
|
||||
multipart-post (~> 2)
|
||||
faraday-net_http (1.0.1)
|
||||
faraday-net_http_persistent (1.2.0)
|
||||
faraday-patron (1.0.0)
|
||||
faraday-rack (1.0.0)
|
||||
faraday-retry (1.0.3)
|
||||
faraday_middleware (1.2.0)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.2.6)
|
||||
fastlane (2.209.0)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.0)
|
||||
babosa (>= 1.0.3, < 2.0.0)
|
||||
bundler (>= 1.12.0, < 3.0.0)
|
||||
colored
|
||||
commander (~> 4.6)
|
||||
dotenv (>= 2.1.1, < 3.0.0)
|
||||
emoji_regex (>= 0.1, < 4.0)
|
||||
excon (>= 0.71.0, < 1.0.0)
|
||||
faraday (~> 1.0)
|
||||
faraday-cookie_jar (~> 0.0.6)
|
||||
faraday_middleware (~> 1.0)
|
||||
fastimage (>= 2.1.0, < 3.0.0)
|
||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||
google-apis-androidpublisher_v3 (~> 0.3)
|
||||
google-apis-playcustomapp_v1 (~> 0.1)
|
||||
google-cloud-storage (~> 1.31)
|
||||
highline (~> 2.0)
|
||||
json (< 3.0.0)
|
||||
jwt (>= 2.1.0, < 3)
|
||||
mini_magick (>= 4.9.4, < 5.0.0)
|
||||
multipart-post (~> 2.0.0)
|
||||
naturally (~> 2.2)
|
||||
optparse (~> 0.1.1)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
rubyzip (>= 2.0.0, < 3.0.0)
|
||||
security (= 0.1.3)
|
||||
simctl (~> 1.6.3)
|
||||
terminal-notifier (>= 2.0.0, < 3.0.0)
|
||||
terminal-table (>= 1.4.5, < 2.0.0)
|
||||
tty-screen (>= 0.6.3, < 1.0.0)
|
||||
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||
word_wrap (~> 1.0.0)
|
||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||
xcpretty (~> 0.3.0)
|
||||
xcpretty-travis-formatter (>= 0.0.3)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.25.0)
|
||||
google-apis-core (>= 0.7, < 2.a)
|
||||
google-apis-core (0.7.0)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
httpclient (>= 2.8.1, < 3.a)
|
||||
mini_mime (~> 1.0)
|
||||
representable (~> 3.0)
|
||||
retriable (>= 2.0, < 4.a)
|
||||
rexml
|
||||
webrick
|
||||
google-apis-iamcredentials_v1 (0.13.0)
|
||||
google-apis-core (>= 0.7, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.10.0)
|
||||
google-apis-core (>= 0.7, < 2.a)
|
||||
google-apis-storage_v1 (0.17.0)
|
||||
google-apis-core (>= 0.7, < 2.a)
|
||||
google-cloud-core (1.6.0)
|
||||
google-cloud-env (~> 1.0)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (1.6.0)
|
||||
faraday (>= 0.17.3, < 3.0)
|
||||
google-cloud-errors (1.2.0)
|
||||
google-cloud-storage (1.38.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-iamcredentials_v1 (~> 0.1)
|
||||
google-apis-storage_v1 (~> 0.17.0)
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
mini_mime (~> 1.0)
|
||||
googleauth (1.2.0)
|
||||
faraday (>= 0.17.3, < 3.a)
|
||||
jwt (>= 1.4, < 3.0)
|
||||
memoist (~> 0.16)
|
||||
multi_json (~> 1.11)
|
||||
os (>= 0.9, < 2.0)
|
||||
signet (>= 0.16, < 2.a)
|
||||
highline (2.0.3)
|
||||
http-cookie (1.0.5)
|
||||
domain_name (~> 0.5)
|
||||
httpclient (2.8.3)
|
||||
jmespath (1.6.1)
|
||||
json (2.6.2)
|
||||
jwt (2.4.1)
|
||||
memoist (0.16.2)
|
||||
mini_magick (4.11.0)
|
||||
mini_mime (1.1.2)
|
||||
multi_json (1.15.0)
|
||||
multipart-post (2.0.0)
|
||||
nanaimo (0.3.0)
|
||||
naturally (2.2.1)
|
||||
optparse (0.1.1)
|
||||
os (1.1.4)
|
||||
plist (3.6.0)
|
||||
public_suffix (4.0.7)
|
||||
rake (13.0.6)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
retriable (3.1.2)
|
||||
rexml (3.2.5)
|
||||
rouge (2.0.7)
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.3.2)
|
||||
security (0.1.3)
|
||||
signet (0.17.0)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 3.0)
|
||||
multi_json (~> 1.10)
|
||||
simctl (1.6.8)
|
||||
CFPropertyList
|
||||
naturally
|
||||
terminal-notifier (2.0.0)
|
||||
terminal-table (1.8.0)
|
||||
unicode-display_width (~> 1.1, >= 1.1.1)
|
||||
trailblazer-option (0.1.2)
|
||||
tty-cursor (0.7.1)
|
||||
tty-screen (0.8.1)
|
||||
tty-spinner (0.9.3)
|
||||
tty-cursor (~> 0.7)
|
||||
uber (0.1.0)
|
||||
unf (0.1.4)
|
||||
unf_ext
|
||||
unf_ext (0.0.8.2)
|
||||
unicode-display_width (1.8.0)
|
||||
webrick (1.7.0)
|
||||
word_wrap (1.0.0)
|
||||
xcodeproj (1.22.0)
|
||||
CFPropertyList (>= 2.3.3, < 4.0)
|
||||
atomos (~> 0.1.3)
|
||||
claide (>= 1.0.2, < 2.0)
|
||||
colored2 (~> 3.1)
|
||||
nanaimo (~> 0.3.0)
|
||||
rexml (~> 3.2.4)
|
||||
xcpretty (0.3.0)
|
||||
rouge (~> 2.0.7)
|
||||
xcpretty-travis-formatter (1.0.1)
|
||||
xcpretty (~> 0.2, >= 0.0.7)
|
||||
|
||||
PLATFORMS
|
||||
arm64-darwin-21
|
||||
|
||||
DEPENDENCIES
|
||||
fastlane
|
||||
|
||||
BUNDLED WITH
|
||||
2.3.6
|
||||
@@ -22,6 +22,10 @@ final class AccountsModel: ObservableObject {
|
||||
return AccountsModel.find(id)
|
||||
}
|
||||
|
||||
var any: Account? {
|
||||
lastUsed ?? all.randomElement()
|
||||
}
|
||||
|
||||
var app: VideosApp {
|
||||
current?.instance?.app ?? .invidious
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@ final class InstancesModel: ObservableObject {
|
||||
Defaults[.instances]
|
||||
}
|
||||
|
||||
static var forPlayer: Instance? {
|
||||
guard let id = Defaults[.playerInstanceID] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return InstancesModel.find(id)
|
||||
}
|
||||
|
||||
var lastUsed: Instance? {
|
||||
guard let id = Defaults[.lastInstanceID] else {
|
||||
return nil
|
||||
|
||||
@@ -25,11 +25,15 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
func setAccount(_ account: Account) {
|
||||
self.account = account
|
||||
signedIn = false
|
||||
|
||||
validInstance = false
|
||||
signedIn = !account.anonymous
|
||||
validInstance = account.anonymous
|
||||
|
||||
configure()
|
||||
|
||||
if !account.anonymous {
|
||||
validate()
|
||||
}
|
||||
}
|
||||
|
||||
func validate() {
|
||||
@@ -80,24 +84,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
|
||||
@@ -109,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
|
||||
@@ -123,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,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)
|
||||
@@ -248,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
|
||||
}
|
||||
|
||||
@@ -291,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?
|
||||
@@ -334,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,
|
||||
@@ -343,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!),
|
||||
@@ -380,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 []
|
||||
@@ -399,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,14 +4,10 @@ import Siesta
|
||||
import SwiftyJSON
|
||||
|
||||
final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
static var authorizedEndpoints = ["subscriptions", "subscribe", "unsubscribe"]
|
||||
static var authorizedEndpoints = ["subscriptions", "subscribe", "unsubscribe", "user/playlists"]
|
||||
|
||||
@Published var account: Account!
|
||||
|
||||
var anonymousAccount: Account {
|
||||
.init(instanceID: account.instance.id, name: "Anonymous", url: account.instance.apiURL)
|
||||
}
|
||||
|
||||
init(account: Account? = nil) {
|
||||
super.init()
|
||||
|
||||
@@ -40,23 +36,33 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("channel/*")) { (content: Entity<JSON>) -> Channel? in
|
||||
PipedAPI.extractChannel(from: content.json)
|
||||
self.extractChannel(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("playlists/*")) { (content: Entity<JSON>) -> ChannelPlaylist? in
|
||||
PipedAPI.extractChannelPlaylist(from: content.json)
|
||||
self.extractChannelPlaylist(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("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
|
||||
@@ -64,29 +70,33 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("subscriptions")) { (content: Entity<JSON>) -> [Channel] in
|
||||
content.json.arrayValue.map { PipedAPI.extractChannel(from: $0)! }
|
||||
content.json.arrayValue.map { self.extractChannel(from: $0)! }
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("feed")) { (content: Entity<JSON>) -> [Video] in
|
||||
content.json.arrayValue.map { PipedAPI.extractVideo(from: $0)! }
|
||||
content.json.arrayValue.map { self.extractVideo(from: $0)! }
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("comments/*")) { (content: Entity<JSON>) -> CommentsPage in
|
||||
let details = content.json.dictionaryValue
|
||||
let comments = details["comments"]?.arrayValue.map { PipedAPI.extractComment(from: $0)! } ?? []
|
||||
let comments = details["comments"]?.arrayValue.map { self.extractComment(from: $0)! } ?? []
|
||||
let nextPage = details["nextpage"]?.stringValue
|
||||
let disabled = details["disabled"]?.boolValue ?? false
|
||||
|
||||
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 {
|
||||
PipedAPI.authorizedEndpoints.contains { url.absoluteString.contains($0) }
|
||||
Self.authorizedEndpoints.contains { url.absoluteString.contains($0) }
|
||||
}
|
||||
|
||||
func updateToken() {
|
||||
@@ -127,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 {
|
||||
@@ -157,7 +175,9 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
var home: Resource? { nil }
|
||||
var popular: Resource? { nil }
|
||||
var playlists: Resource? { nil }
|
||||
var playlists: Resource? {
|
||||
resource(baseURL: account.instance.apiURL, path: "user/playlists")
|
||||
}
|
||||
|
||||
func subscribe(_ channelID: String, onCompletion: @escaping () -> Void = {}) {
|
||||
resource(baseURL: account.instance.apiURL, path: "subscribe")
|
||||
@@ -171,10 +191,79 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
.onCompletion { _ in onCompletion() }
|
||||
}
|
||||
|
||||
func playlist(_: String) -> Resource? { nil }
|
||||
func playlist(_ id: String) -> Resource? {
|
||||
channelPlaylist(id)
|
||||
}
|
||||
|
||||
func playlistVideo(_: String, _: String) -> Resource? { nil }
|
||||
func playlistVideos(_: String) -> Resource? { nil }
|
||||
|
||||
func addVideoToPlaylist(
|
||||
_ videoID: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void = { _ in },
|
||||
onSuccess: @escaping () -> Void = {}
|
||||
) {
|
||||
let resource = resource(baseURL: account.instance.apiURL, path: "user/playlists/add")
|
||||
let body = ["videoId": videoID, "playlistId": playlistID]
|
||||
|
||||
resource
|
||||
.request(.post, json: body)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func removeVideoFromPlaylist(
|
||||
_ index: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
) {
|
||||
let resource = resource(baseURL: account.instance.apiURL, path: "user/playlists/remove")
|
||||
let body: [String: Any] = ["index": Int(index)!, "playlistId": playlistID]
|
||||
|
||||
resource
|
||||
.request(.post, json: body)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func playlistForm(
|
||||
_ name: String,
|
||||
_: String,
|
||||
playlist: Playlist?,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping (Playlist?) -> Void
|
||||
) {
|
||||
let body = ["name": name]
|
||||
let resource = playlist.isNil ? resource(baseURL: account.instance.apiURL, path: "user/playlists/create") : nil
|
||||
|
||||
resource?
|
||||
.request(.post, json: body)
|
||||
.onSuccess { response in
|
||||
if let modifiedPlaylist: Playlist = response.typedContent() {
|
||||
onSuccess(modifiedPlaylist)
|
||||
} else {
|
||||
onSuccess(nil)
|
||||
}
|
||||
}
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func deletePlaylist(
|
||||
_ playlist: Playlist,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
) {
|
||||
let resource = resource(baseURL: account.instance.apiURL, path: "user/playlists/delete")
|
||||
let body = ["playlistId": playlist.id]
|
||||
|
||||
resource
|
||||
.request(.post, json: body)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func comments(_ id: Video.ID, page: String?) -> Resource? {
|
||||
let path = page.isNil ? "comments/\(id)" : "nextpage/comments/\(id)"
|
||||
let resource = resource(baseURL: account.url, path: path)
|
||||
@@ -190,7 +279,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
"**\(path)"
|
||||
}
|
||||
|
||||
private static func extractContentItem(from content: JSON) -> ContentItem? {
|
||||
private func extractContentItem(from content: JSON) -> ContentItem? {
|
||||
let details = content.dictionaryValue
|
||||
let url: String! = details["url"]?.string
|
||||
|
||||
@@ -210,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
|
||||
@@ -244,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,
|
||||
@@ -274,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
|
||||
|
||||
@@ -287,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)
|
||||
}
|
||||
|
||||
@@ -295,19 +389,28 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
let author = details["uploaderName"]?.stringValue ?? details["uploader"]!.stringValue
|
||||
let published = (details["uploadedDate"] ?? details["uploadDate"])?.stringValue ??
|
||||
(details["uploaded"]!.double! / 1000).formattedAsRelativeTime()!
|
||||
let authorThumbnailURL = details["avatarUrl"]?.url ?? details["uploaderAvatar"]?.url ?? details["avatar"]?.url
|
||||
let subscriptionsCount = details["uploaderSubscriberCount"]?.int
|
||||
|
||||
let uploaded = details["uploaded"]?.doubleValue
|
||||
var published = (uploaded.isNil || uploaded == -1) ? nil : (uploaded! / 1000).formattedAsRelativeTime()
|
||||
if published.isNil {
|
||||
published = (details["uploadedDate"] ?? details["uploadDate"])?.stringValue ?? ""
|
||||
}
|
||||
|
||||
let live = details["livestream"]?.boolValue ?? (details["duration"]?.intValue == -1)
|
||||
|
||||
return Video(
|
||||
videoID: PipedAPI.extractID(from: content),
|
||||
videoID: extractID(from: content),
|
||||
title: details["title"]!.stringValue,
|
||||
author: author,
|
||||
length: details["duration"]!.doubleValue,
|
||||
published: published,
|
||||
published: published!,
|
||||
views: details["views"]!.intValue,
|
||||
description: PipedAPI.extractDescription(from: content),
|
||||
channel: Channel(id: channelId, name: author),
|
||||
description: extractDescription(from: content),
|
||||
channel: Channel(id: channelId, name: author, thumbnailURL: authorThumbnailURL, subscriptionsCount: subscriptionsCount),
|
||||
thumbnails: thumbnails,
|
||||
live: live,
|
||||
likes: details["likes"]?.int,
|
||||
dislikes: details["dislikes"]?.int,
|
||||
streams: extractStreams(from: content),
|
||||
@@ -315,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
|
||||
@@ -337,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
|
||||
}
|
||||
@@ -359,26 +470,32 @@ 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!)
|
||||
let videoAsset = AVURLAsset(url: videoStream.dictionaryValue["url"]!.url!)
|
||||
guard let audioAssetUrl = audioStream.dictionaryValue["url"]?.url,
|
||||
let videoAssetUrl = videoStream.dictionaryValue["url"]?.url
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
let audioAsset = AVURLAsset(url: audioAssetUrl)
|
||||
let videoAsset = AVURLAsset(url: videoAssetUrl)
|
||||
|
||||
let videoOnly = videoStream.dictionaryValue["videoOnly"]?.boolValue ?? true
|
||||
let resolution = Stream.Resolution.from(resolution: videoStream.dictionaryValue["quality"]!.stringValue)
|
||||
@@ -397,14 +514,14 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
return streams
|
||||
}
|
||||
|
||||
private static func extractRelated(from content: JSON) -> [Video] {
|
||||
private func extractRelated(from content: JSON) -> [Video] {
|
||||
content
|
||||
.dictionaryValue["relatedStreams"]?
|
||||
.arrayValue
|
||||
.compactMap(extractVideo(from:)) ?? []
|
||||
}
|
||||
|
||||
private static func compatibleAudioStreams(from content: JSON) -> [JSON] {
|
||||
private func compatibleAudioStreams(from content: JSON) -> [JSON] {
|
||||
content
|
||||
.dictionaryValue["audioStreams"]?
|
||||
.arrayValue
|
||||
@@ -414,14 +531,14 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
} ?? []
|
||||
}
|
||||
|
||||
private static func compatibleVideoStream(from content: JSON) -> [JSON] {
|
||||
private func compatibleVideoStream(from content: JSON) -> [JSON] {
|
||||
content
|
||||
.dictionaryValue["videoStreams"]?
|
||||
.arrayValue
|
||||
.filter { $0.dictionaryValue["format"] == "MPEG_4" } ?? []
|
||||
}
|
||||
|
||||
private static func extractComment(from content: JSON) -> Comment? {
|
||||
private func extractComment(from content: JSON) -> Comment? {
|
||||
let details = content.dictionaryValue
|
||||
let author = details["author"]?.stringValue ?? ""
|
||||
let commentorUrl = details["commentorUrl"]?.stringValue
|
||||
|
||||
@@ -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
|
||||
@@ -27,6 +27,34 @@ protocol VideosAPI {
|
||||
func playlistVideo(_ playlistID: String, _ videoID: String) -> Resource?
|
||||
func playlistVideos(_ id: String) -> Resource?
|
||||
|
||||
func addVideoToPlaylist(
|
||||
_ videoID: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
)
|
||||
|
||||
func removeVideoFromPlaylist(
|
||||
_ index: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
)
|
||||
|
||||
func playlistForm(
|
||||
_ name: String,
|
||||
_ visibility: String,
|
||||
playlist: Playlist?,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping (Playlist?) -> Void
|
||||
)
|
||||
|
||||
func deletePlaylist(
|
||||
_ playlist: Playlist,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
)
|
||||
|
||||
func channelPlaylist(_ id: String) -> Resource?
|
||||
|
||||
func loadDetails(_ item: PlayerQueueItem, completionHandler: @escaping (PlayerQueueItem) -> Void)
|
||||
@@ -55,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]()
|
||||
@@ -73,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 {
|
||||
|
||||
@@ -32,6 +32,18 @@ enum VideosApp: String, CaseIterable {
|
||||
}
|
||||
|
||||
var supportsUserPlaylists: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
var userPlaylistsEndpointIncludesVideos: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var userPlaylistsHaveVisibility: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var userPlaylistsAreEditable: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
@@ -42,4 +54,8 @@ enum VideosApp: String, CaseIterable {
|
||||
var supportsComments: Bool {
|
||||
self == .piped
|
||||
}
|
||||
|
||||
var searchUsesIndexedPages: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@ struct Channel: Identifiable, Hashable {
|
||||
self.videos = videos
|
||||
}
|
||||
|
||||
var detailsLoaded: Bool {
|
||||
!subscriptionsString.isNil
|
||||
}
|
||||
|
||||
var subscriptionsString: String? {
|
||||
if subscriptionsCount != nil, subscriptionsCount! > 0 {
|
||||
return subscriptionsCount!.formattedAsAbbreviation()
|
||||
|
||||
@@ -8,27 +8,33 @@ final class CommentsModel: ObservableObject {
|
||||
@Published var nextPage: String?
|
||||
@Published var firstPage = true
|
||||
|
||||
@Published var loaded = true
|
||||
@Published var loaded = false
|
||||
@Published var disabled = false
|
||||
|
||||
@Published var replies = [Comment]()
|
||||
@Published var repliesPageID: String?
|
||||
@Published var repliesLoaded = false
|
||||
|
||||
var accounts: AccountsModel!
|
||||
var player: PlayerModel!
|
||||
|
||||
var instance: Instance? {
|
||||
static var instance: Instance? {
|
||||
InstancesModel.find(Defaults[.commentsInstanceID])
|
||||
}
|
||||
|
||||
var api: VideosAPI? {
|
||||
instance.isNil ? nil : PipedAPI(account: instance!.anonymousAccount)
|
||||
Self.instance.isNil ? nil : PipedAPI(account: Self.instance!.anonymousAccount)
|
||||
}
|
||||
|
||||
static var enabled: Bool {
|
||||
!Defaults[.commentsInstanceID].isNil && !Defaults[.commentsInstanceID]!.isEmpty
|
||||
!instance.isNil
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
static var placement: CommentsPlacement {
|
||||
Defaults[.commentsPlacement]
|
||||
}
|
||||
#endif
|
||||
|
||||
var nextPageAvailable: Bool {
|
||||
!(nextPage?.isEmpty ?? true)
|
||||
}
|
||||
@@ -38,21 +44,23 @@ final class CommentsModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
reset()
|
||||
|
||||
guard !instance.isNil,
|
||||
guard !Self.instance.isNil,
|
||||
!(player?.currentVideo.isNil ?? true)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
if !firstPage && !nextPageAvailable {
|
||||
return
|
||||
}
|
||||
|
||||
firstPage = page.isNil || page!.isEmpty
|
||||
|
||||
api?.comments(player.currentVideo!.videoID, page: page)?
|
||||
.load()
|
||||
.onSuccess { [weak self] response in
|
||||
if let page: CommentsPage = response.typedContent() {
|
||||
self?.all = page.comments
|
||||
self?.all += page.comments
|
||||
self?.nextPage = page.nextPage
|
||||
self?.disabled = page.disabled
|
||||
}
|
||||
@@ -62,6 +70,13 @@ final class CommentsModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func loadNextPageIfNeeded(current comment: Comment) {
|
||||
let thresholdIndex = all.index(all.endIndex, offsetBy: -5)
|
||||
if all.firstIndex(where: { $0 == comment }) == thresholdIndex {
|
||||
loadNextPage()
|
||||
}
|
||||
}
|
||||
|
||||
func loadNextPage() {
|
||||
load(page: nextPage)
|
||||
}
|
||||
@@ -71,7 +86,12 @@ final class CommentsModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
if page == repliesPageID {
|
||||
return
|
||||
}
|
||||
|
||||
replies = []
|
||||
repliesPageID = page
|
||||
repliesLoaded = false
|
||||
|
||||
api?.comments(player.currentVideo!.videoID, page: page)?
|
||||
@@ -79,9 +99,10 @@ final class CommentsModel: ObservableObject {
|
||||
.onSuccess { [weak self] response in
|
||||
if let page: CommentsPage = response.typedContent() {
|
||||
self?.replies = page.comments
|
||||
self?.repliesLoaded = true
|
||||
}
|
||||
}
|
||||
.onCompletion { [weak self] _ in
|
||||
.onFailure { [weak self] _ in
|
||||
self?.repliesLoaded = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
80
Model/HistoryModel.swift
Normal file
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
|
||||
@@ -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
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,53 +1,65 @@
|
||||
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]()
|
||||
|
||||
#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?
|
||||
private var infrequentTimeObserver: Any?
|
||||
@@ -58,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 {
|
||||
@@ -68,22 +81,63 @@ final class PlayerModel: ObservableObject {
|
||||
#endif
|
||||
}}
|
||||
|
||||
@Default(.pauseOnHidingPlayer) private var pauseOnHidingPlayer
|
||||
@Default(.closePiPOnNavigation) var closePiPOnNavigation
|
||||
@Default(.closePiPOnOpeningPlayer) var closePiPOnOpeningPlayer
|
||||
|
||||
#if !os(macOS)
|
||||
@Default(.closePiPAndOpenPlayerOnEnteringForeground) var closePiPAndOpenPlayerOnEnteringForeground
|
||||
#endif
|
||||
|
||||
init(accounts: AccountsModel? = nil, comments: CommentsModel? = nil) {
|
||||
self.accounts = accounts ?? AccountsModel()
|
||||
self.comments = comments ?? CommentsModel()
|
||||
|
||||
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 {
|
||||
@@ -95,7 +149,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
var live: Bool {
|
||||
currentItem?.video?.live ?? false
|
||||
currentVideo?.live ?? false
|
||||
}
|
||||
|
||||
var playerItemDuration: CMTime? {
|
||||
@@ -103,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() {
|
||||
@@ -126,26 +180,41 @@ 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])
|
||||
comments.load()
|
||||
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)")
|
||||
@@ -154,11 +223,58 @@ final class PlayerModel: ObservableObject {
|
||||
loadComposition(stream, of: video, preservingTime: preservingTime)
|
||||
}
|
||||
|
||||
updateCurrentArtwork()
|
||||
if !upgrading {
|
||||
updateCurrentArtwork()
|
||||
}
|
||||
}
|
||||
|
||||
private func pauseOnPlayerDismiss() {
|
||||
if !playingInPictureInPicture, !presentingPlayer {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
if !presentingPlayer, pauseOnHidingPlayer, !playingInPictureInPicture {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
self?.pause()
|
||||
}
|
||||
}
|
||||
|
||||
if !presentingPlayer, !pauseOnHidingPlayer, isPlaying {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
|
||||
self?.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleNavigationViewPlayerPresentationChange() {
|
||||
if pauseOnHidingPlayer, !playingInPictureInPicture, !playerNavigationLinkActive {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
self.pause()
|
||||
}
|
||||
@@ -170,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
|
||||
@@ -184,6 +303,7 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
self.stream = stream
|
||||
self.composition = AVMutableComposition()
|
||||
self.asset = nil
|
||||
}
|
||||
|
||||
let startPlaying = {
|
||||
@@ -191,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()
|
||||
@@ -226,6 +372,32 @@ 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,
|
||||
@@ -243,7 +415,7 @@ final class PlayerModel: ObservableObject {
|
||||
of video: Video,
|
||||
preservingTime: Bool = false
|
||||
) {
|
||||
asset.loadValuesAsynchronously(forKeys: ["playable"]) { [weak self] in
|
||||
asset.loadValuesAsynchronously(forKeys: Self.assetKeysToLoad) { [weak self] in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
@@ -285,9 +457,9 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -313,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 {
|
||||
@@ -350,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()
|
||||
}
|
||||
@@ -382,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
|
||||
}
|
||||
|
||||
@@ -439,7 +621,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
self.timeObserverThrottle.execute {
|
||||
self.updateCurrentItemIntervals()
|
||||
self.updateWatch()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -469,22 +651,15 @@ 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)
|
||||
var nowPlayingInfo: [String: AnyObject] = [
|
||||
MPMediaItemPropertyTitle: currentItem.video.title as AnyObject,
|
||||
MPMediaItemPropertyArtist: currentItem.video.author as AnyObject,
|
||||
MPMediaItemPropertyPlaybackDuration: duration as AnyObject,
|
||||
MPNowPlayingInfoPropertyIsLiveStream: currentItem.video.live as AnyObject,
|
||||
MPNowPlayingInfoPropertyElapsedPlaybackTime: player.currentTime().seconds as AnyObject,
|
||||
MPNowPlayingInfoPropertyPlaybackQueueCount: queue.count as AnyObject,
|
||||
@@ -495,6 +670,15 @@ final class PlayerModel: ObservableObject {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -525,8 +709,105 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
func closeCurrentItem() {
|
||||
addCurrentItemToHistory()
|
||||
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,7 +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 {
|
||||
@@ -50,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
|
||||
|
||||
@@ -76,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 })
|
||||
@@ -84,7 +104,7 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
func advanceToNextItem() {
|
||||
addCurrentItemToHistory()
|
||||
prepareCurrentItemForHistory()
|
||||
|
||||
if let nextItem = queue.first {
|
||||
advanceToItem(nextItem)
|
||||
@@ -92,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)
|
||||
}
|
||||
@@ -124,7 +147,7 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
func isAutoplaying(_ item: AVPlayerItem) -> Bool {
|
||||
player.currentItem == item && presentingPlayer
|
||||
player.currentItem == item
|
||||
}
|
||||
|
||||
@discardableResult func enqueueVideo(
|
||||
@@ -136,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
|
||||
@@ -149,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
|
||||
|
||||
@@ -173,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 }) {
|
||||
@@ -208,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,7 +29,7 @@ final class PlaylistsModel: ObservableObject {
|
||||
}
|
||||
|
||||
func load(force: Bool = false, onSuccess: @escaping () -> Void = {}) {
|
||||
guard !resource.isNil else {
|
||||
guard accounts.app.supportsUserPlaylists, accounts.signedIn else {
|
||||
playlists = []
|
||||
return
|
||||
}
|
||||
@@ -52,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ final class RecentsModel: ObservableObject {
|
||||
if !saveRecents {
|
||||
clear()
|
||||
|
||||
if item.type != .channel {
|
||||
if item.type == .query {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,9 @@ final class RecentsModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func addQuery(_ query: String) {
|
||||
func addQuery(_ query: String, navigation: NavigationModel? = nil) {
|
||||
if !query.isEmpty {
|
||||
navigation?.tabSelection = .search
|
||||
add(.init(from: query))
|
||||
}
|
||||
}
|
||||
@@ -55,6 +56,15 @@ final class RecentsModel: ObservableObject {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
static func symbolSystemImage(_ name: String) -> String {
|
||||
let firstLetter = name.first?.lowercased()
|
||||
let regex = #"^[a-z0-9]$"#
|
||||
|
||||
let symbolName = firstLetter?.range(of: regex, options: .regularExpression) != nil ? firstLetter! : "questionmark"
|
||||
|
||||
return "\(symbolName).circle"
|
||||
}
|
||||
}
|
||||
|
||||
struct RecentItem: Defaults.Serializable, Identifiable {
|
||||
|
||||
@@ -4,15 +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 {
|
||||
@@ -22,60 +23,54 @@ final class SearchModel: ObservableObject {
|
||||
func changeQuery(_ changeHandler: @escaping (SearchQuery) -> Void = { _ in }) {
|
||||
changeHandler(query)
|
||||
|
||||
let newResource = accounts.api.search(query)
|
||||
guard newResource != previousResource else {
|
||||
let newResource = accounts.api.search(query, page: nil)
|
||||
guard newResource != resource else {
|
||||
return
|
||||
}
|
||||
|
||||
previousResource?.removeObservers(ownedBy: store)
|
||||
previousResource = newResource
|
||||
page = nil
|
||||
|
||||
resource = newResource
|
||||
resource.addObserver(store)
|
||||
|
||||
if !query.isEmpty {
|
||||
loadResourceIfNeededAndReplaceStore()
|
||||
loadResource()
|
||||
}
|
||||
}
|
||||
|
||||
func resetQuery(_ query: SearchQuery = SearchQuery()) {
|
||||
self.query = query
|
||||
|
||||
let newResource = accounts.api.search(query)
|
||||
guard newResource != previousResource else {
|
||||
let newResource = accounts.api.search(query, page: nil)
|
||||
guard newResource != resource else {
|
||||
return
|
||||
}
|
||||
|
||||
page = nil
|
||||
store.replace([])
|
||||
|
||||
previousResource?.removeObservers(ownedBy: store)
|
||||
previousResource = newResource
|
||||
|
||||
resource = newResource
|
||||
resource.addObserver(store)
|
||||
|
||||
if !query.isEmpty {
|
||||
loadResourceIfNeededAndReplaceStore()
|
||||
loadResource()
|
||||
}
|
||||
}
|
||||
|
||||
func loadResourceIfNeededAndReplaceStore() {
|
||||
func loadResource() {
|
||||
let currentResource = resource!
|
||||
|
||||
if let request = resource.loadIfNeeded() {
|
||||
request.onSuccess { response in
|
||||
if let results: [ContentItem] = response.typedContent() {
|
||||
self.replace(results, for: currentResource)
|
||||
}
|
||||
resource.load().onSuccess { response in
|
||||
if let page: SearchPage = response.typedContent() {
|
||||
self.page = page
|
||||
self.replace(page.results, for: currentResource)
|
||||
}
|
||||
} else {
|
||||
replace(store.collection, for: currentResource)
|
||||
}
|
||||
}
|
||||
|
||||
func replace(_ videos: [ContentItem], for resource: Resource) {
|
||||
func replace(_ items: [ContentItem], for resource: Resource) {
|
||||
if self.resource == resource {
|
||||
store = Store<[ContentItem]>(videos)
|
||||
store = Store<[ContentItem]>(items)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,12 +78,13 @@ final class SearchModel: ObservableObject {
|
||||
|
||||
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)
|
||||
@@ -99,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
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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,9 +35,11 @@ final class SubscriptionsModel: ObservableObject {
|
||||
}
|
||||
|
||||
func load(force: Bool = false, onSuccess: @escaping () -> Void = {}) {
|
||||
guard accounts.app.supportsSubscriptions else {
|
||||
guard accounts.app.supportsSubscriptions, accounts.signedIn else {
|
||||
channels = []
|
||||
return
|
||||
}
|
||||
|
||||
let request = force ? resource?.load() : resource?.loadIfNeeded()
|
||||
|
||||
request?
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Alamofire
|
||||
import AVKit
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import SwiftyJSON
|
||||
|
||||
struct Video: Identifiable, Equatable, Hashable {
|
||||
@@ -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
48
Model/Watch.swift
Normal file
@@ -0,0 +1,48 @@
|
||||
import CoreData
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
@objc(Watch)
|
||||
final class Watch: NSManagedObject, Identifiable {
|
||||
@Default(.watchedThreshold) private var watchedThreshold
|
||||
}
|
||||
|
||||
extension Watch {
|
||||
@nonobjc class func fetchRequest() -> NSFetchRequest<Watch> {
|
||||
NSFetchRequest<Watch>(entityName: "Watch")
|
||||
}
|
||||
|
||||
@NSManaged var videoID: String
|
||||
@NSManaged var videoDuration: Double
|
||||
|
||||
@NSManaged var watchedAt: Date?
|
||||
@NSManaged var stoppedAt: Double
|
||||
|
||||
var progress: Double {
|
||||
guard videoDuration.isFinite, !videoDuration.isZero else {
|
||||
return 0
|
||||
}
|
||||
|
||||
let progress = (stoppedAt / videoDuration) * 100
|
||||
|
||||
if progress >= Double(watchedThreshold) {
|
||||
return 100
|
||||
}
|
||||
|
||||
return min(max(progress, 0), 100)
|
||||
}
|
||||
|
||||
var finished: Bool {
|
||||
progress >= Double(watchedThreshold)
|
||||
}
|
||||
|
||||
var watchedAtString: String? {
|
||||
guard let watchedAt = watchedAt else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .full
|
||||
return formatter.localizedString(for: watchedAt, relativeTo: Date())
|
||||
}
|
||||
}
|
||||
17
Model/Yattee.xcdatamodeld/Yattee.xcdatamodel/contents
Normal file
17
Model/Yattee.xcdatamodeld/Yattee.xcdatamodel/contents
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="19574" systemVersion="21D5025f" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="1">
|
||||
<entity name="Watch" representedClassName="Watch" syncable="YES">
|
||||
<attribute name="stoppedAt" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||
<attribute name="videoDuration" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||
<attribute name="videoID" attributeType="String"/>
|
||||
<attribute name="watchedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="videoID"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<elements>
|
||||
<element name="Watch" positionX="-63" positionY="-18" width="128" height="89"/>
|
||||
</elements>
|
||||
</model>
|
||||
145
README.md
145
README.md
@@ -1,148 +1,73 @@
|
||||

|
||||
|
||||
Video player for [Invidious](https://github.com/iv-org/invidious) and [Piped](https://github.com/TeamPiped/Piped) instances built for iOS, tvOS and macOS.
|
||||
|
||||
<div align="center">
|
||||
<img src="https://r.yattee.stream/icons/yattee-150.png" width="150" height="150" alt="Yattee logo">
|
||||
<h1>Yattee</h1>
|
||||
<p>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)
|
||||
[](https://matrix.to/#/#Yattee:matrix.org)
|
||||
|
||||
Now on **Discord**:
|
||||
|
||||
[](https://yattee.stream/discord)
|
||||
|
||||

|
||||
</div>
|
||||
|
||||
## Features
|
||||
* Native user interface built with [SwiftUI](https://developer.apple.com/xcode/swiftui/)
|
||||
* Native user interface built with [SwiftUI](https://developer.apple.com/xcode/swiftui/) with customization settings
|
||||
* Multiple instances and accounts, fast switching
|
||||
* [SponsorBlock](https://sponsor.ajay.app/), configurable categories to skip
|
||||
* Player queue and history
|
||||
* Fullscreen playback, Picture in Picture and AirPlay support
|
||||
* Stream quality selection
|
||||
* Favorites: customizable section of channels, playlists, trending, searches and other views
|
||||
* `yattee://` URL Scheme for integrations
|
||||
|
||||
### Features in development
|
||||
* New player component with custom controls, gestures and support for 4K playback
|
||||
|
||||
You can leave your feedback in [discussions](https://github.com/yattee/yattee/discussions) or join [Discord](https://yattee.stream/discord) or [Matrix Channel](https://matrix.to/#/#Yattee:matrix.org) for a chat. Thanks!
|
||||
|
||||
### Availability
|
||||
| Feature | Invidious | Piped |
|
||||
|| Invidious | Piped |
|
||||
| - | - | - |
|
||||
| User Accounts | ✅ | ✅ |
|
||||
| Subscriptions | ✅ | ✅ |
|
||||
| Popular | ✅ | 🔴 |
|
||||
| User Playlists | ✅ | 🔴 |
|
||||
| User Playlists | ✅ | ✅ |
|
||||
| Trending | ✅ | ✅ |
|
||||
| Channels | ✅ | ✅ |
|
||||
| Channel Playlists | ✅ | ✅ |
|
||||
| Search | ✅ | ✅ |
|
||||
| Search Suggestions | ✅ | ✅ |
|
||||
| Search Filters | ✅ | 🔴 |
|
||||
| Popular | ✅ | 🔴 |
|
||||
| Subtitles | 🔴 | ✅ |
|
||||
| Comments | 🔴 | ✅ |
|
||||
|
||||
## Installation
|
||||
### Requirements
|
||||
System requirements:
|
||||
* iOS 14 (or newer)
|
||||
* tvOS 15 (or newer)
|
||||
* macOS Big Sur (or newer)
|
||||
You can browse and use accounts from one app and play videos with another (for example: use Invidious account for subscriptions and use Piped as playback source). Comments can be displayed from Piped even when Invidious is used for browsing/playing.
|
||||
|
||||
### How to install?
|
||||
|
||||
#### macOS
|
||||
Download and run latest version from the [Releases](https://github.com/yattee/yattee/releases) page.
|
||||
|
||||
#### iOS/tvOS: [AltStore](https://altstore.io/) (free)
|
||||
You can sideload IPA files downloaded from the [Releases](https://github.com/yattee/yattee/releases) page to your iOS or tvOS device - check [AltStore FAQ](https://altstore.io/faq/) for more information.
|
||||
|
||||
If you have to access to the beta AltStore version (v1.5, for Patreons only), you can add the following repository in `Browse > Sources` screen:
|
||||
|
||||
`https://alt.yattee.stream`
|
||||
|
||||
#### iOS/tvOS: Signing IPA files online (paid)
|
||||
[UDID Registrations](https://www.udidregistrations.com/) provides services to sign IPA files for your devices. Refer to: ***Break free from the App Store*** section of the website for more information.
|
||||
|
||||
#### iOS/tvOS: Manual installation
|
||||
Download sources and compile them on a Mac using Xcode, install to your devices. Please note that if you are not registered in Apple Developer Program you will need to reinstall every 7 days.
|
||||
|
||||
## Integrations
|
||||
### macOS
|
||||
With [Finicky](https://github.com/johnste/finicky) you can configure your system to open all the video links in the app. Example configuration:
|
||||
```js
|
||||
{
|
||||
match: [
|
||||
finicky.matchDomains(/(.*\.)?youtube.com/),
|
||||
finicky.matchDomains(/(.*\.)?youtu.be/)
|
||||
],
|
||||
browser: "/Applications/Yattee.app"
|
||||
}
|
||||
```
|
||||
|
||||
## Screenshots
|
||||
### iOS
|
||||
| Player | Search | Playlists |
|
||||
| - | - | - |
|
||||
| [](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
|
||||
|
||||
|
||||
## Donations
|
||||
|
||||
You can support development of this app with
|
||||
[Patreon](https://www.patreon.com/arekf) or cryptocurrencies:
|
||||
|
||||
**Monero (XMR)**
|
||||
```
|
||||
48zfKjLmnXs21PinU2ucMiUPwhiKt5d7WJKiy3ACVS28BKqSn52c1TX8L337oESHJ5TZCyGkozjfWZG11h6C46mN9n4NPrD
|
||||
```
|
||||
**Bitcoin (BTC)**
|
||||
```
|
||||
bc1qe24zz5a5hm0trc7glwckz93py274eycxzju3mv
|
||||
```
|
||||
**Ethereum (ETH)**
|
||||
```
|
||||
0xa2f81A58Ec5E550132F03615c8d91954A4E37423
|
||||
```
|
||||
|
||||
Donations will be used to cover development program access and domain renewal costs.
|
||||
## Documentation
|
||||
* [Installation](https://github.com/yattee/yattee/wiki/Installation-Instructions)
|
||||
* [Building](https://github.com/yattee/yattee/wiki/Building-instructions)
|
||||
* [FAQ](https://github.com/yattee/yattee/wiki)
|
||||
* [Screenshots Gallery](https://github.com/yattee/yattee/wiki/Screenshots-Gallery)
|
||||
* [Tips](https://github.com/yattee/yattee/wiki/Tips)
|
||||
* [Integrations](https://github.com/yattee/yattee/wiki/Integrations)
|
||||
* [Donations](https://github.com/yattee/yattee/wiki/Donations)
|
||||
|
||||
## Contributing
|
||||
If you're interestred in contributing, you can browse the [issues](https://github.com/yattee/yattee/issues) list or create a new one to discuss your feature idea. Every contribution is very welcome.
|
||||
|
||||
## License and Liability
|
||||
Use [building instructions](https://github.com/yattee/yattee/wiki/Building-instructions) or
|
||||
join [Discord](https://yattee.stream/discord) or [Matrix Channel](https://matrix.to/#/#yattee:matrix.org) for a chat if you need an advice or want to discuss the project.
|
||||
|
||||
## License
|
||||
Yattee and its components is shared on [AGPL v3](https://www.gnu.org/licenses/agpl-3.0.en.html) license.
|
||||
|
||||
Contributors take no responsibility for the use of the tool (Point 16. of the license). We strongly recommend you abide by the valid official regulations in your country. Furthermore, we refuse liability for any inappropriate use of the tool, such as downloading materials without proper consent.
|
||||
|
||||
## Disclaimer
|
||||
The Yattee project and its contents are not affiliated with, funded, authorized, endorsed by, or in any way accociated with YouTube, Google LLC or any of its affiliates and subsidaries. The official YouTube website can be found at www.youtube.com.
|
||||
|
||||
Any trademark, service mark, trade name, or other intellectual property rights used in the Yattee project are owned by the respective owners.
|
||||
|
||||
This tool is an open source software built for learning and research purposes.
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
"color-space" : "display-p3",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.638",
|
||||
"green" : "0.638",
|
||||
"red" : "0.638"
|
||||
"blue" : "0.361",
|
||||
"green" : "0.200",
|
||||
"red" : "0.129"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
@@ -23,9 +23,9 @@
|
||||
"color-space" : "display-p3",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.256",
|
||||
"green" : "0.256",
|
||||
"red" : "0.253"
|
||||
"blue" : "0.361",
|
||||
"green" : "0.200",
|
||||
"red" : "0.129"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
@@ -2,10 +2,12 @@
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "extended-gray",
|
||||
"color-space" : "display-p3",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"white" : "0.724"
|
||||
"blue" : "0.263",
|
||||
"green" : "0.290",
|
||||
"red" : "0.859"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
@@ -21,9 +23,9 @@
|
||||
"color-space" : "display-p3",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.328",
|
||||
"green" : "0.328",
|
||||
"red" : "0.325"
|
||||
"blue" : "0.263",
|
||||
"green" : "0.290",
|
||||
"red" : "0.859"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
@@ -5,9 +5,9 @@
|
||||
"color-space" : "display-p3",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.781",
|
||||
"green" : "0.781",
|
||||
"red" : "0.781"
|
||||
"blue" : "0.757",
|
||||
"green" : "0.761",
|
||||
"red" : "0.757"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
@@ -23,9 +23,9 @@
|
||||
"color-space" : "display-p3",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.311",
|
||||
"green" : "0.311",
|
||||
"red" : "0.311"
|
||||
"blue" : "0.259",
|
||||
"green" : "0.259",
|
||||
"red" : "0.259"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.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
38
Shared/Defaults+Workaround.swift
Normal file
@@ -0,0 +1,38 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
extension Defaults.Serializable where Self: Codable {
|
||||
static var bridge: Defaults.TopLevelCodableBridge<Self> { Defaults.TopLevelCodableBridge() }
|
||||
}
|
||||
|
||||
extension Defaults.Serializable where Self: Codable & NSSecureCoding {
|
||||
static var bridge: Defaults.CodableNSSecureCodingBridge<Self> { Defaults.CodableNSSecureCodingBridge() }
|
||||
}
|
||||
|
||||
extension Defaults.Serializable where Self: Codable & NSSecureCoding & Defaults.PreferNSSecureCoding {
|
||||
static var bridge: Defaults.NSSecureCodingBridge<Self> { Defaults.NSSecureCodingBridge() }
|
||||
}
|
||||
|
||||
extension Defaults.Serializable where Self: Codable & RawRepresentable {
|
||||
static var bridge: Defaults.RawRepresentableCodableBridge<Self> { Defaults.RawRepresentableCodableBridge() }
|
||||
}
|
||||
|
||||
extension Defaults.Serializable where Self: Codable & RawRepresentable & Defaults.PreferRawRepresentable {
|
||||
static var bridge: Defaults.RawRepresentableBridge<Self> { Defaults.RawRepresentableBridge() }
|
||||
}
|
||||
|
||||
extension Defaults.Serializable where Self: RawRepresentable {
|
||||
static var bridge: Defaults.RawRepresentableBridge<Self> { Defaults.RawRepresentableBridge() }
|
||||
}
|
||||
|
||||
extension Defaults.Serializable where Self: NSSecureCoding {
|
||||
static var bridge: Defaults.NSSecureCodingBridge<Self> { Defaults.NSSecureCodingBridge() }
|
||||
}
|
||||
|
||||
extension Defaults.CollectionSerializable where Element: Defaults.Serializable {
|
||||
static var bridge: Defaults.CollectionBridge<Self> { Defaults.CollectionBridge() }
|
||||
}
|
||||
|
||||
extension Defaults.SetAlgebraSerializable where Element: Defaults.Serializable & Hashable {
|
||||
static var bridge: Defaults.SetAlgebraBridge<Self> { Defaults.SetAlgebraBridge() }
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
extension Defaults.Keys {
|
||||
static let kavinPipedInstanceID = "kavin-piped"
|
||||
@@ -22,32 +25,70 @@ 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 honorSystemOrientationLock = Key<Bool>("honorSystemOrientationLock", default: true)
|
||||
static let enterFullscreenInLandscape = Key<Bool>("enterFullscreenInLandscape", default: UIDevice.current.userInterfaceIdiom == .phone)
|
||||
static let lockLandscapeOnRotation = Key<Bool>("lockLandscapeOnRotation", default: false)
|
||||
static let lockLandscapeWhenEnteringFullscreen = Key<Bool>("lockLandscapeWhenEnteringFullscreen", default: false)
|
||||
#endif
|
||||
}
|
||||
|
||||
enum ResolutionSetting: String, CaseIterable, Defaults.Serializable {
|
||||
@@ -65,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
|
||||
}
|
||||
@@ -87,10 +128,6 @@ enum PlayerSidebarSetting: String, CaseIterable, Defaults.Serializable {
|
||||
enum VisibleSection: String, CaseIterable, Comparable, Defaults.Serializable {
|
||||
case favorites, subscriptions, popular, trending, playlists
|
||||
|
||||
static func from(_ string: String) -> VisibleSection {
|
||||
allCases.first { $0.rawValue == string }!
|
||||
}
|
||||
|
||||
var title: String {
|
||||
rawValue.localizedCapitalized
|
||||
}
|
||||
@@ -129,3 +166,21 @@ enum VisibleSection: String, CaseIterable, Comparable, Defaults.Serializable {
|
||||
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?.load()
|
||||
}
|
||||
#if os(macOS)
|
||||
.opacity(dragging?.id == item.id ? 0.5 : 1)
|
||||
#endif
|
||||
.onAppear {
|
||||
resource?.addObserver(store)
|
||||
resource?.load()
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.onDrag {
|
||||
dragging = item
|
||||
return NSItemProvider(object: item.id as NSString)
|
||||
}
|
||||
.onDrop(
|
||||
of: [UTType.text],
|
||||
delegate: DropFavorite(item: item, favorites: $favorites, current: $dragging)
|
||||
)
|
||||
.onDrag {
|
||||
dragging = item
|
||||
return NSItemProvider(object: item.id as NSString)
|
||||
}
|
||||
.onDrop(
|
||||
of: [UTType.text],
|
||||
delegate: DropFavorite(item: item, favorites: $favorites, current: $dragging)
|
||||
)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.onChange(of: accounts.current) { _ in
|
||||
resource?.addObserver(store)
|
||||
resource?.load()
|
||||
}
|
||||
}
|
||||
|
||||
private var isVisible: Bool {
|
||||
@@ -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,11 +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
|
||||
}
|
||||
@@ -51,9 +57,12 @@ struct FavoritesView: View {
|
||||
.navigationTitle("Favorites")
|
||||
#endif
|
||||
#if os(macOS)
|
||||
.background(Color.tertiaryBackground)
|
||||
.background(Color.secondaryBackground)
|
||||
.frame(minWidth: 360)
|
||||
#endif
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(RefreshControl.navigationBarTitleDisplayMode)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,29 +12,29 @@ struct MenuCommands: Commands {
|
||||
private var navigationMenu: some Commands {
|
||||
CommandGroup(before: .windowSize) {
|
||||
Button("Favorites") {
|
||||
model.navigation?.tabSelection = .favorites
|
||||
setTabSelection(.favorites)
|
||||
}
|
||||
.keyboardShortcut("1")
|
||||
|
||||
Button("Subscriptions") {
|
||||
model.navigation?.tabSelection = .subscriptions
|
||||
setTabSelection(.subscriptions)
|
||||
}
|
||||
.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")
|
||||
|
||||
@@ -42,6 +42,15 @@ struct MenuCommands: Commands {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -62,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,21 +6,29 @@ struct AccountsMenuView: View {
|
||||
|
||||
@Default(.accounts) private var accounts
|
||||
@Default(.instances) private var instances
|
||||
@Default(.accountPickerDisplaysUsername) private var accountPickerDisplaysUsername
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
ForEach(allAccounts, id: \.id) { account in
|
||||
Button(accountButtonTitle(account: account)) {
|
||||
Button {
|
||||
model.setCurrent(account)
|
||||
} label: {
|
||||
HStack {
|
||||
Text(accountButtonTitle(account: account))
|
||||
|
||||
Spacer()
|
||||
|
||||
if model.current == account {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
if #available(iOS 15.0, macOS 12.0, *) {
|
||||
label
|
||||
.labelStyle(.titleAndIcon)
|
||||
} else {
|
||||
HStack {
|
||||
Image(systemName: "person.crop.circle")
|
||||
HStack {
|
||||
Image(systemName: "person.crop.circle")
|
||||
if accountPickerDisplaysUsername {
|
||||
label
|
||||
.labelStyle(.titleOnly)
|
||||
}
|
||||
|
||||
@@ -6,20 +6,19 @@ import SwiftUI
|
||||
|
||||
struct AppSidebarNavigation: View {
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@EnvironmentObject<CommentsModel> private var comments
|
||||
@EnvironmentObject<InstancesModel> private var instances
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<PlaylistsModel> private var playlists
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
@EnvironmentObject<SearchModel> private var search
|
||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||
@EnvironmentObject<ThumbnailsModel> private var thumbnailsModel
|
||||
|
||||
@Default(.visibleSections) private var visibleSections
|
||||
|
||||
#if os(iOS)
|
||||
@State private var didApplyPrimaryViewWorkAround = false
|
||||
|
||||
@EnvironmentObject<CommentsModel> private var comments
|
||||
@EnvironmentObject<InstancesModel> private var instances
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<PlaylistsModel> private var playlists
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
@EnvironmentObject<SearchModel> private var search
|
||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||
@EnvironmentObject<ThumbnailsModel> private var thumbnailsModel
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
@@ -62,36 +61,24 @@ struct AppSidebarNavigation: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.background(
|
||||
EmptyView().fullScreenCover(isPresented: $player.presentingPlayer) {
|
||||
videoPlayer
|
||||
.environment(\.navigationStyle, .sidebar)
|
||||
}
|
||||
)
|
||||
#elseif os(macOS)
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $player.presentingPlayer) {
|
||||
videoPlayer
|
||||
.frame(minWidth: 1000, minHeight: 750)
|
||||
.environment(\.navigationStyle, .sidebar)
|
||||
}
|
||||
)
|
||||
#endif
|
||||
.environment(\.navigationStyle, .sidebar)
|
||||
}
|
||||
|
||||
private var videoPlayer: some View {
|
||||
VideoPlayerView()
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(comments)
|
||||
.environmentObject(instances)
|
||||
.environmentObject(navigation)
|
||||
.environmentObject(player)
|
||||
.environmentObject(playlists)
|
||||
.environmentObject(recents)
|
||||
.environmentObject(subscriptions)
|
||||
.environmentObject(thumbnailsModel)
|
||||
#if os(iOS)
|
||||
.background(
|
||||
EmptyView().fullScreenCover(isPresented: $player.presentingPlayer) {
|
||||
VideoPlayerView()
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(comments)
|
||||
.environmentObject(instances)
|
||||
.environmentObject(navigation)
|
||||
.environmentObject(player)
|
||||
.environmentObject(playlists)
|
||||
.environmentObject(recents)
|
||||
.environmentObject(subscriptions)
|
||||
.environmentObject(thumbnailsModel)
|
||||
.environment(\.navigationStyle, .sidebar)
|
||||
}
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
var toolbarContent: some ToolbarContent {
|
||||
@@ -113,6 +100,16 @@ struct AppSidebarNavigation: View {
|
||||
"Current User: \(accounts.current?.description ?? "Not set")"
|
||||
)
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
ToolbarItem(placement: .navigation) {
|
||||
Button {
|
||||
NSApp.keyWindow?.firstResponder?.tryToPerform(#selector(NSSplitViewController.toggleSidebar(_:)), with: nil)
|
||||
} label: {
|
||||
Label("Toggle Sidebar", systemImage: "sidebar.left")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,13 +120,4 @@ struct AppSidebarNavigation: View {
|
||||
return .automatic
|
||||
#endif
|
||||
}
|
||||
|
||||
static func symbolSystemImage(_ name: String) -> String {
|
||||
let firstLetter = name.first?.lowercased()
|
||||
let regex = #"^[a-z0-9]$"#
|
||||
|
||||
let symbolName = firstLetter?.range(of: regex, options: .regularExpression) != nil ? firstLetter! : "questionmark"
|
||||
|
||||
return "\(symbolName).circle"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,16 +11,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))
|
||||
.backport
|
||||
.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))
|
||||
@@ -33,6 +32,18 @@ struct AppSidebarPlaylists: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func playlistLabel(_ playlist: Playlist) -> some View {
|
||||
let label = Label(playlist.title, systemImage: RecentsModel.symbolSystemImage(playlist.title))
|
||||
|
||||
if player.accounts.app.userPlaylistsEndpointIncludesVideos {
|
||||
label
|
||||
.backport
|
||||
.badge(Text("\(playlist.videos.count)"))
|
||||
} else {
|
||||
label
|
||||
}
|
||||
}
|
||||
|
||||
var newPlaylistButton: some View {
|
||||
Button(action: { navigation.presentNewPlaylistForm() }) {
|
||||
Label("New Playlist", systemImage: "plus.circle")
|
||||
|
||||
@@ -88,6 +88,6 @@ struct RecentNavigationLink<DestinationContent: View>: View {
|
||||
}
|
||||
|
||||
var labelSystemImage: String {
|
||||
systemImage != nil ? systemImage! : AppSidebarNavigation.symbolSystemImage(recent.title)
|
||||
systemImage != nil ? systemImage! : RecentsModel.symbolSystemImage(recent.title)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ struct AppSidebarSubscriptions: View {
|
||||
NavigationLink(tag: TabSelection.channel(channel.id), selection: $navigation.tabSelection) {
|
||||
LazyView(ChannelVideosView(channel: channel))
|
||||
} label: {
|
||||
Label(channel.name, systemImage: AppSidebarNavigation.symbolSystemImage(channel.name))
|
||||
Label(channel.name, systemImage: RecentsModel.symbolSystemImage(channel.name))
|
||||
}
|
||||
.contextMenu {
|
||||
Button("Unsubscribe") {
|
||||
|
||||
@@ -15,6 +15,8 @@ struct AppTabNavigation: View {
|
||||
|
||||
@Default(.visibleSections) private var visibleSections
|
||||
|
||||
let persistenceController = PersistenceController.shared
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: navigation.tabSelectionBinding) {
|
||||
if visibleSections.contains(.favorites) {
|
||||
@@ -33,7 +35,7 @@ struct AppTabNavigation: View {
|
||||
trendingNavigationView
|
||||
}
|
||||
|
||||
if visibleSections.contains(.playlists), accounts.app.supportsUserPlaylists {
|
||||
if playlistsVisible {
|
||||
playlistsNavigationView
|
||||
}
|
||||
|
||||
@@ -42,31 +44,37 @@ struct AppTabNavigation: View {
|
||||
.id(accounts.current?.id ?? "")
|
||||
.environment(\.navigationStyle, .tab)
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingChannel, onDismiss: {
|
||||
if let channel = recents.presentedChannel {
|
||||
recents.close(RecentItem(from: channel))
|
||||
}
|
||||
}) {
|
||||
EmptyView().sheet(isPresented: $navigation.presentingChannel) {
|
||||
if let channel = recents.presentedChannel {
|
||||
NavigationView {
|
||||
ChannelVideosView(channel: channel)
|
||||
.environment(\.managedObjectContext, persistenceController.container.viewContext)
|
||||
.environment(\.inChannelView, true)
|
||||
.environment(\.inNavigationView, true)
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(navigation)
|
||||
.environmentObject(player)
|
||||
.environmentObject(subscriptions)
|
||||
.environmentObject(thumbnailsModel)
|
||||
|
||||
.background(playerNavigationLink)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingPlaylist, onDismiss: {
|
||||
if let playlist = recents.presentedPlaylist {
|
||||
recents.close(RecentItem(from: playlist))
|
||||
}
|
||||
}) {
|
||||
EmptyView().sheet(isPresented: $navigation.presentingPlaylist) {
|
||||
if let playlist = recents.presentedPlaylist {
|
||||
NavigationView {
|
||||
ChannelPlaylistView(playlist: playlist)
|
||||
.environment(\.managedObjectContext, persistenceController.container.viewContext)
|
||||
.environment(\.inNavigationView, true)
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(navigation)
|
||||
.environmentObject(player)
|
||||
.environmentObject(subscriptions)
|
||||
.environmentObject(thumbnailsModel)
|
||||
|
||||
.background(playerNavigationLink)
|
||||
}
|
||||
}
|
||||
@@ -75,7 +83,8 @@ struct AppTabNavigation: View {
|
||||
.background(
|
||||
EmptyView().fullScreenCover(isPresented: $player.presentingPlayer) {
|
||||
videoPlayer
|
||||
.environment(\.navigationStyle, .sidebar)
|
||||
.environment(\.managedObjectContext, persistenceController.container.viewContext)
|
||||
.environment(\.navigationStyle, .tab)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -109,6 +118,11 @@ struct AppTabNavigation: View {
|
||||
accounts.app.supportsSubscriptions && !(accounts.current?.anonymous ?? true)
|
||||
}
|
||||
|
||||
private var playlistsVisible: Bool {
|
||||
visibleSections.contains(.playlists) &&
|
||||
accounts.app.supportsUserPlaylists && !(accounts.current?.anonymous ?? true)
|
||||
}
|
||||
|
||||
private var popularNavigationView: some View {
|
||||
NavigationView {
|
||||
LazyView(PopularView())
|
||||
@@ -159,7 +173,7 @@ struct AppTabNavigation: View {
|
||||
|
||||
private var playerNavigationLink: some View {
|
||||
NavigationLink(isActive: $player.playerNavigationLinkActive, destination: {
|
||||
VideoPlayerView()
|
||||
videoPlayer
|
||||
.environment(\.inNavigationView, true)
|
||||
}) {
|
||||
EmptyView()
|
||||
|
||||
@@ -7,16 +7,16 @@ import Siesta
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@StateObject private var accounts = AccountsModel()
|
||||
@StateObject private var comments = CommentsModel()
|
||||
@StateObject private var instances = InstancesModel()
|
||||
@StateObject private var navigation = NavigationModel()
|
||||
@StateObject private var player = PlayerModel()
|
||||
@StateObject private var playlists = PlaylistsModel()
|
||||
@StateObject private var recents = RecentsModel()
|
||||
@StateObject private var search = SearchModel()
|
||||
@StateObject private var subscriptions = SubscriptionsModel()
|
||||
@StateObject private var thumbnailsModel = ThumbnailsModel()
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@EnvironmentObject<CommentsModel> private var comments
|
||||
@EnvironmentObject<InstancesModel> private var instances
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<PlaylistsModel> private var playlists
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
@EnvironmentObject<SearchModel> private var search
|
||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||
@EnvironmentObject<ThumbnailsModel> private var thumbnailsModel
|
||||
|
||||
@EnvironmentObject<MenuModel> private var menu
|
||||
|
||||
@@ -39,6 +39,10 @@ struct ContentView: View {
|
||||
#endif
|
||||
}
|
||||
.onAppear(perform: configure)
|
||||
.onChange(of: accounts.signedIn) { _ in
|
||||
subscriptions.load(force: true)
|
||||
playlists.load(force: true)
|
||||
}
|
||||
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(comments)
|
||||
@@ -61,8 +65,7 @@ struct ContentView: View {
|
||||
}
|
||||
)
|
||||
#if !os(tvOS)
|
||||
.handlesExternalEvents(preferring: Set(["*"]), allowing: Set(["*"]))
|
||||
.onOpenURL(perform: handleOpenedURL)
|
||||
.onOpenURL { OpenURLHandler(accounts: accounts, player: player).handle($0) }
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingAddToPlaylist) {
|
||||
AddToPlaylistView(video: navigation.videoToAddToPlaylist)
|
||||
@@ -81,9 +84,21 @@ struct ContentView: View {
|
||||
SettingsView()
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(instances)
|
||||
.environmentObject(player)
|
||||
}
|
||||
)
|
||||
#endif
|
||||
.alert(isPresented: $navigation.presentingUnsubscribeAlert) {
|
||||
Alert(
|
||||
title: Text(
|
||||
"Are you sure you want to unsubscribe from \(navigation.channelToUnsubscribe.name)?"
|
||||
),
|
||||
primaryButton: .destructive(Text("Unsubscribe")) {
|
||||
subscriptions.unsubscribe(navigation.channelToUnsubscribe.id)
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func configure() {
|
||||
@@ -94,6 +109,12 @@ struct ContentView: View {
|
||||
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
|
||||
@@ -109,7 +130,6 @@ struct ContentView: View {
|
||||
search.accounts = accounts
|
||||
subscriptions.accounts = accounts
|
||||
|
||||
comments.accounts = accounts
|
||||
comments.player = player
|
||||
|
||||
menu.accounts = accounts
|
||||
@@ -120,7 +140,7 @@ struct ContentView: View {
|
||||
player.comments = comments
|
||||
|
||||
if !accounts.current.isNil {
|
||||
player.loadHistoryDetails()
|
||||
player.restoreQueue()
|
||||
}
|
||||
|
||||
if !Defaults[.saveRecents] {
|
||||
@@ -136,6 +156,9 @@ struct ContentView: View {
|
||||
#endif
|
||||
|
||||
navigation.tabSelection = section ?? .search
|
||||
|
||||
subscriptions.load()
|
||||
playlists.load()
|
||||
}
|
||||
|
||||
func openWelcomeScreenIfAccountEmpty() {
|
||||
@@ -145,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 {
|
||||
|
||||
@@ -4,8 +4,6 @@ import SwiftUI
|
||||
struct Sidebar: View {
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlaylistsModel> private var playlists
|
||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||
|
||||
@Default(.visibleSections) private var visibleSections
|
||||
|
||||
@@ -29,14 +27,6 @@ struct Sidebar: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
subscriptions.load()
|
||||
playlists.load()
|
||||
}
|
||||
.onChange(of: accounts.signedIn) { _ in
|
||||
subscriptions.load(force: true)
|
||||
playlists.load(force: true)
|
||||
}
|
||||
.onChange(of: navigation.sidebarSectionChanged) { _ in
|
||||
scrollScrollViewToItem(scrollView: scrollView, for: navigation.tabSelection)
|
||||
}
|
||||
@@ -55,6 +45,7 @@ struct Sidebar: View {
|
||||
Label("Favorites", systemImage: "heart")
|
||||
.accessibility(label: Text("Favorites"))
|
||||
}
|
||||
.id("favorites")
|
||||
}
|
||||
if visibleSections.contains(.subscriptions),
|
||||
accounts.app.supportsSubscriptions && accounts.signedIn
|
||||
@@ -63,6 +54,7 @@ struct Sidebar: View {
|
||||
Label("Subscriptions", systemImage: "star.circle")
|
||||
.accessibility(label: Text("Subscriptions"))
|
||||
}
|
||||
.id("subscriptions")
|
||||
}
|
||||
|
||||
if visibleSections.contains(.popular), accounts.app.supportsPopular {
|
||||
@@ -70,6 +62,7 @@ struct Sidebar: View {
|
||||
Label("Popular", systemImage: "arrow.up.right.circle")
|
||||
.accessibility(label: Text("Popular"))
|
||||
}
|
||||
.id("popular")
|
||||
}
|
||||
|
||||
if visibleSections.contains(.trending) {
|
||||
@@ -77,12 +70,14 @@ struct Sidebar: View {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -90,8 +85,12 @@ struct Sidebar: View {
|
||||
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
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,65 +1,81 @@
|
||||
import SDWebImageSwiftUI
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct CommentView: View {
|
||||
let comment: Comment
|
||||
@Binding var repliesID: Comment.ID?
|
||||
|
||||
@State private var subscribed = false
|
||||
|
||||
#if os(iOS)
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
#endif
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.navigationStyle) private var navigationStyle
|
||||
|
||||
@EnvironmentObject<CommentsModel> private var comments
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
authorAvatar
|
||||
HStack(spacing: 10) {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
authorAvatar
|
||||
|
||||
#if os(iOS)
|
||||
Group {
|
||||
if horizontalSizeClass == .regular {
|
||||
HStack(spacing: 20) {
|
||||
authorAndTime
|
||||
|
||||
Spacer()
|
||||
|
||||
Group {
|
||||
statusIcons
|
||||
likes
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HStack(alignment: .center, spacing: 20) {
|
||||
authorAndTime
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing, spacing: 8) {
|
||||
likes
|
||||
statusIcons
|
||||
}
|
||||
}
|
||||
if subscribed {
|
||||
Image(systemName: "star.circle.fill")
|
||||
#if os(tvOS)
|
||||
.background(Color.background(scheme: colorScheme))
|
||||
#else
|
||||
.background(Color.background)
|
||||
#endif
|
||||
.clipShape(Circle())
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.font(.system(size: 15))
|
||||
.onAppear {
|
||||
subscribed = subscriptions.isSubscribing(comment.channel.id)
|
||||
}
|
||||
|
||||
#else
|
||||
HStack(spacing: 20) {
|
||||
authorAndTime
|
||||
authorAndTime
|
||||
}
|
||||
.contextMenu {
|
||||
Button(action: openChannelAction) {
|
||||
Label("\(comment.channel.name) Channel", systemImage: "rectangle.stack.fill.badge.person.crop")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
|
||||
Group {
|
||||
#if os(iOS)
|
||||
if horizontalSizeClass == .regular {
|
||||
Group {
|
||||
statusIcons
|
||||
likes
|
||||
}
|
||||
} else {
|
||||
VStack(alignment: .trailing, spacing: 8) {
|
||||
likes
|
||||
statusIcons
|
||||
}
|
||||
}
|
||||
#else
|
||||
statusIcons
|
||||
likes
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
.font(.system(size: 25).bold())
|
||||
#else
|
||||
.font(.system(size: 15))
|
||||
#endif
|
||||
|
||||
Group {
|
||||
commentText
|
||||
@@ -91,26 +107,28 @@ struct CommentView: View {
|
||||
.placeholder {
|
||||
Rectangle().fill(Color("PlaceholderColor"))
|
||||
}
|
||||
.retryOnAppear(false)
|
||||
.retryOnAppear(true)
|
||||
.indicator(.activity)
|
||||
.mask(RoundedRectangle(cornerRadius: 60))
|
||||
.frame(width: 45, height: 45, alignment: .leading)
|
||||
.contextMenu {
|
||||
Button(action: openChannelAction) {
|
||||
Label("\(comment.channel.name) Channel", systemImage: "rectangle.stack.fill.badge.person.crop")
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
.frame(width: 80, height: 80, alignment: .leading)
|
||||
.focusable()
|
||||
#else
|
||||
.frame(width: 45, height: 45, alignment: .leading)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var authorAndTime: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text(comment.author)
|
||||
.fontWeight(.bold)
|
||||
#if os(tvOS)
|
||||
.font(.system(size: 30).bold())
|
||||
#else
|
||||
.font(.system(size: 14).bold())
|
||||
#endif
|
||||
|
||||
Text(comment.time)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.lineLimit(1)
|
||||
@@ -125,6 +143,9 @@ struct CommentView: View {
|
||||
Image(systemName: "heart.fill")
|
||||
}
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.font(.system(size: 12))
|
||||
#endif
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
@@ -135,6 +156,9 @@ struct CommentView: View {
|
||||
Image(systemName: "hand.thumbsup")
|
||||
Text("\(comment.likeCount.formattedAsAbbreviation())")
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.font(.system(size: 12))
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
@@ -144,10 +168,6 @@ struct CommentView: View {
|
||||
Button {
|
||||
repliesID = repliesID == comment.id ? nil : comment.id
|
||||
|
||||
if repliesID.isNil {
|
||||
comments.replies = []
|
||||
}
|
||||
|
||||
guard !repliesID.isNil, !comment.repliesPage.isNil else {
|
||||
return
|
||||
}
|
||||
@@ -167,6 +187,7 @@ struct CommentView: View {
|
||||
#if os(tvOS)
|
||||
.padding(.leading, 5)
|
||||
#else
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(.secondary)
|
||||
#endif
|
||||
}
|
||||
@@ -185,7 +206,7 @@ struct CommentView: View {
|
||||
#if os(macOS)
|
||||
0.4
|
||||
#else
|
||||
0.8
|
||||
0.6
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -230,17 +251,13 @@ struct CommentView: View {
|
||||
}
|
||||
|
||||
private func openChannelAction() {
|
||||
player.presentingPlayer = false
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
let recent = RecentItem(from: comment.channel)
|
||||
recents.add(recent)
|
||||
navigation.presentingChannel = true
|
||||
|
||||
if navigationStyle == .sidebar {
|
||||
navigation.sidebarSectionChanged.toggle()
|
||||
navigation.tabSelection = .recentlyOpened(recent.tag)
|
||||
}
|
||||
}
|
||||
NavigationModel.openChannel(
|
||||
comment.channel,
|
||||
player: player,
|
||||
recents: recents,
|
||||
navigation: navigation,
|
||||
navigationStyle: navigationStyle
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,5 +268,7 @@ struct CommentView_Previews: PreviewProvider {
|
||||
|
||||
static var previews: some View {
|
||||
CommentView(comment: fixture, repliesID: .constant(fixture.id))
|
||||
.environmentObject(SubscriptionsModel())
|
||||
.padding(5)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,79 +1,49 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CommentsView: View {
|
||||
var embedInScrollView = false
|
||||
@State private var repliesID: Comment.ID?
|
||||
|
||||
@EnvironmentObject<CommentsModel> private var comments
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if comments.disabled {
|
||||
Text("Comments are disabled for this video")
|
||||
.foregroundColor(.secondary)
|
||||
NoCommentsView(text: "Comments are disabled", systemImage: "xmark.circle.fill")
|
||||
} else if comments.loaded && comments.all.isEmpty {
|
||||
Text("No comments")
|
||||
.foregroundColor(.secondary)
|
||||
NoCommentsView(text: "No comments", systemImage: "0.circle.fill")
|
||||
} else if !comments.loaded {
|
||||
progressView
|
||||
} else {
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
VStack(alignment: .leading) {
|
||||
let last = comments.all.last
|
||||
ForEach(comments.all) { comment in
|
||||
CommentView(comment: comment, repliesID: $repliesID)
|
||||
|
||||
if comment != last {
|
||||
Divider()
|
||||
.padding(.vertical, 5)
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
if comments.nextPageAvailable {
|
||||
Button {
|
||||
repliesID = nil
|
||||
comments.loadNextPage()
|
||||
} label: {
|
||||
Label("Show more", systemImage: "arrow.turn.down.right")
|
||||
}
|
||||
}
|
||||
|
||||
if !comments.firstPage {
|
||||
Button {
|
||||
repliesID = nil
|
||||
comments.load(page: nil)
|
||||
} label: {
|
||||
Label("Show first", systemImage: "arrow.turn.down.left")
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.vertical, 8)
|
||||
.foregroundColor(.secondary)
|
||||
PlaceholderProgressView()
|
||||
.onAppear {
|
||||
comments.load()
|
||||
}
|
||||
} else {
|
||||
let last = comments.all.last
|
||||
let commentsStack = LazyVStack {
|
||||
ForEach(comments.all) { comment in
|
||||
CommentView(comment: comment, repliesID: $repliesID)
|
||||
.onAppear {
|
||||
comments.loadNextPageIfNeeded(current: comment)
|
||||
}
|
||||
.padding(.bottom, comment == last ? 5 : 0)
|
||||
|
||||
if comment != last {
|
||||
Divider()
|
||||
.padding(.vertical, 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if embedInScrollView {
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
commentsStack
|
||||
}
|
||||
} else {
|
||||
commentsStack
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.onAppear {
|
||||
if !comments.loaded {
|
||||
comments.load()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var progressView: some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
Spacer()
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
28
Shared/Player/NoCommentsView.swift
Normal file
28
Shared/Player/NoCommentsView.swift
Normal file
@@ -0,0 +1,28 @@
|
||||
import SwiftUI
|
||||
|
||||
struct NoCommentsView: View {
|
||||
var text: String
|
||||
var systemImage: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .center, spacing: 10) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 36))
|
||||
|
||||
Text(text)
|
||||
#if !os(tvOS)
|
||||
.font(.system(size: 12))
|
||||
#endif
|
||||
}
|
||||
.frame(minWidth: 0, maxWidth: .infinity)
|
||||
#if !os(tvOS)
|
||||
.foregroundColor(.secondary)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct NoCommentsView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
NoCommentsView(text: "No comments", systemImage: "xmark.circle.fill")
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,9 @@ struct PlaybackBar: View {
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
closeButton
|
||||
#if !os(macOS)
|
||||
closeButton
|
||||
#endif
|
||||
|
||||
if player.currentItem != nil {
|
||||
HStack {
|
||||
@@ -20,6 +22,9 @@ struct PlaybackBar: View {
|
||||
rateMenu
|
||||
}
|
||||
.font(.caption2)
|
||||
#if os(macOS)
|
||||
.padding(.leading, 4)
|
||||
#endif
|
||||
|
||||
Spacer()
|
||||
|
||||
@@ -68,14 +73,14 @@ struct PlaybackBar: View {
|
||||
message: Text(player.playerError?.localizedDescription ?? "")
|
||||
)
|
||||
}
|
||||
.frame(minWidth: 0, maxWidth: .infinity)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 20)
|
||||
.padding(4)
|
||||
.background(colorScheme == .dark ? Color.black : Color.white)
|
||||
}
|
||||
|
||||
private var closeButton: some View {
|
||||
Button {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
player.hide()
|
||||
} label: {
|
||||
Label(
|
||||
"Close",
|
||||
@@ -94,12 +99,18 @@ 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"
|
||||
|
||||
@@ -5,6 +5,7 @@ struct Player: UIViewControllerRepresentable {
|
||||
@EnvironmentObject<CommentsModel> private var comments
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||
|
||||
var controller: PlayerViewController?
|
||||
|
||||
@@ -22,6 +23,7 @@ struct Player: UIViewControllerRepresentable {
|
||||
controller.commentsModel = comments
|
||||
controller.navigationModel = navigation
|
||||
controller.playerModel = player
|
||||
controller.subscriptionsModel = subscriptions
|
||||
player.controller = controller
|
||||
|
||||
return controller
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@@ -8,10 +9,12 @@ struct PlayerQueueRow: View {
|
||||
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
|
||||
@Default(.closePiPOnNavigation) var closePiPOnNavigation
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
Button {
|
||||
player.addCurrentItemToHistory()
|
||||
player.prepareCurrentItemForHistory()
|
||||
|
||||
if history {
|
||||
player.playHistory(item)
|
||||
@@ -24,6 +27,10 @@ struct PlayerQueueRow: View {
|
||||
fullScreen = false
|
||||
}
|
||||
}
|
||||
|
||||
if closePiPOnNavigation, player.playingInPictureInPicture {
|
||||
player.closePiP()
|
||||
}
|
||||
} label: {
|
||||
VideoBanner(video: item.video, playbackTime: item.playbackTime, videoDuration: item.videoDuration)
|
||||
}
|
||||
|
||||
@@ -6,9 +6,14 @@ struct PlayerQueueView: View {
|
||||
@Binding var sidebarQueue: Bool
|
||||
@Binding var fullScreen: Bool
|
||||
|
||||
@FetchRequest(sortDescriptors: [.init(key: "watchedAt", ascending: false)])
|
||||
var watches: FetchedResults<Watch>
|
||||
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
|
||||
@Default(.saveHistory) private var saveHistory
|
||||
@Default(.showHistoryInPlayer) private var showHistoryInPlayer
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
@@ -17,7 +22,7 @@ struct PlayerQueueView: View {
|
||||
if sidebarQueue {
|
||||
related
|
||||
}
|
||||
if saveHistory {
|
||||
if saveHistory, showHistoryInPlayer {
|
||||
playedPreviously
|
||||
}
|
||||
}
|
||||
@@ -46,23 +51,33 @@ struct PlayerQueueView: View {
|
||||
ForEach(player.queue) { item in
|
||||
PlayerQueueRow(item: item, fullScreen: $fullScreen)
|
||||
.contextMenu {
|
||||
removeButton(item, history: false)
|
||||
removeAllButton(history: false)
|
||||
removeButton(item)
|
||||
removeAllButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var visibleWatches: [Watch] {
|
||||
watches.filter { $0.videoID != player.currentVideo?.videoID }
|
||||
}
|
||||
|
||||
var playedPreviously: some View {
|
||||
Group {
|
||||
if !player.history.isEmpty {
|
||||
if !visibleWatches.isEmpty {
|
||||
Section(header: Text("Played Previously")) {
|
||||
ForEach(player.history) { item in
|
||||
PlayerQueueRow(item: item, history: true, fullScreen: $fullScreen)
|
||||
.contextMenu {
|
||||
removeButton(item, history: true)
|
||||
removeAllButton(history: true)
|
||||
}
|
||||
ForEach(visibleWatches, id: \.videoID) { watch in
|
||||
PlayerQueueRow(
|
||||
item: PlayerQueueItem.from(watch, video: player.historyVideo(watch.videoID)),
|
||||
history: true,
|
||||
fullScreen: $fullScreen
|
||||
)
|
||||
.onAppear {
|
||||
player.loadHistoryVideoDetails(watch.videoID)
|
||||
}
|
||||
.contextMenu {
|
||||
removeHistoryButton(watch)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,11 +91,16 @@ struct PlayerQueueView: View {
|
||||
ForEach(player.currentVideo!.related) { video in
|
||||
PlayerQueueRow(item: PlayerQueueItem(video), fullScreen: $fullScreen)
|
||||
.contextMenu {
|
||||
Button("Play Next") {
|
||||
Button {
|
||||
player.playNext(video)
|
||||
} label: {
|
||||
Label("Play Next", systemImage: "text.insert")
|
||||
}
|
||||
Button("Play Last") {
|
||||
|
||||
Button {
|
||||
player.enqueueVideo(video)
|
||||
} label: {
|
||||
Label("Play Last", systemImage: "text.append")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,28 +109,28 @@ struct PlayerQueueView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func removeButton(_ item: PlayerQueueItem, history: Bool) -> some View {
|
||||
private func removeButton(_ item: PlayerQueueItem) -> some View {
|
||||
Button {
|
||||
removeButtonAction(item, history: history)
|
||||
player.remove(item)
|
||||
} label: {
|
||||
Label("Remove", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
|
||||
private func removeButtonAction(_ item: PlayerQueueItem, history: Bool) {
|
||||
_ = history ? player.removeHistory(item) : player.remove(item)
|
||||
}
|
||||
|
||||
private func removeAllButton(history: Bool) -> some View {
|
||||
private func removeAllButton() -> some View {
|
||||
Button {
|
||||
removeAllButtonAction(history: history)
|
||||
player.removeQueueItems()
|
||||
} label: {
|
||||
Label("Remove All", systemImage: "trash.fill")
|
||||
}
|
||||
}
|
||||
|
||||
private func removeAllButtonAction(history: Bool) {
|
||||
_ = history ? player.removeHistoryItems() : player.removeQueueItems()
|
||||
private func removeHistoryButton(_ watch: Watch) -> some View {
|
||||
Button {
|
||||
player.removeWatch(watch)
|
||||
} label: {
|
||||
Label("Remove", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import AVKit
|
||||
import Logging
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
final class PlayerViewController: UIViewController {
|
||||
@@ -7,11 +7,14 @@ final class PlayerViewController: UIViewController {
|
||||
var commentsModel: CommentsModel!
|
||||
var navigationModel: NavigationModel!
|
||||
var playerModel: PlayerModel!
|
||||
var playerViewController = AVPlayerViewController()
|
||||
var subscriptionsModel: SubscriptionsModel!
|
||||
var playerView = AVPlayerViewController()
|
||||
|
||||
let persistenceController = PersistenceController.shared
|
||||
|
||||
#if !os(tvOS)
|
||||
var aspectRatio: Double? {
|
||||
let ratio = Double(playerViewController.videoBounds.width) / Double(playerViewController.videoBounds.height)
|
||||
let ratio = Double(playerView.videoBounds.width) / Double(playerView.videoBounds.height)
|
||||
|
||||
guard ratio.isFinite else {
|
||||
return VideoPlayerView.defaultAspectRatio // swiftlint:disable:this implicit_return
|
||||
@@ -27,29 +30,56 @@ final class PlayerViewController: UIViewController {
|
||||
loadPlayer()
|
||||
|
||||
#if os(tvOS)
|
||||
if !playerViewController.isBeingPresented, !playerViewController.isBeingDismissed {
|
||||
present(playerViewController, animated: false)
|
||||
if !playerView.isBeingPresented, !playerView.isBeingDismissed {
|
||||
present(playerView, animated: false)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
|
||||
if !playerModel.presentingPlayer, !Defaults[.pauseOnHidingPlayer], !playerModel.isPlaying {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
self?.playerModel.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
func loadPlayer() {
|
||||
guard !playerLoaded else {
|
||||
return
|
||||
}
|
||||
|
||||
playerModel.controller = self
|
||||
playerViewController.player = playerModel.player
|
||||
playerViewController.allowsPictureInPicturePlayback = true
|
||||
playerViewController.delegate = self
|
||||
playerView.player = playerModel.player
|
||||
playerView.allowsPictureInPicturePlayback = true
|
||||
#if os(iOS)
|
||||
if #available(iOS 14.2, *) {
|
||||
playerView.canStartPictureInPictureAutomaticallyFromInline = true
|
||||
}
|
||||
#endif
|
||||
playerView.delegate = self
|
||||
|
||||
#if os(tvOS)
|
||||
playerModel.avPlayerViewController = playerViewController
|
||||
playerViewController.customInfoViewControllers = [
|
||||
infoViewController([.comments], title: "Comments"),
|
||||
var infoViewControllers = [UIHostingController<AnyView>]()
|
||||
if CommentsModel.enabled {
|
||||
infoViewControllers.append(infoViewController([.comments], title: "Comments"))
|
||||
}
|
||||
|
||||
var queueSections = [NowPlayingView.ViewSection.playingNext]
|
||||
if Defaults[.showHistoryInPlayer] {
|
||||
queueSections.append(.playedPreviously)
|
||||
}
|
||||
|
||||
infoViewControllers.append(contentsOf: [
|
||||
infoViewController([.related], title: "Related"),
|
||||
infoViewController([.playingNext, .playedPreviously], title: "Playing Next")
|
||||
]
|
||||
infoViewController(queueSections, title: "Queue")
|
||||
])
|
||||
|
||||
playerView.customInfoViewControllers = infoViewControllers
|
||||
#else
|
||||
embedViewController()
|
||||
#endif
|
||||
@@ -66,6 +96,8 @@ final class PlayerViewController: UIViewController {
|
||||
.frame(maxHeight: 600)
|
||||
.environmentObject(commentsModel)
|
||||
.environmentObject(playerModel)
|
||||
.environmentObject(subscriptionsModel)
|
||||
.environment(\.managedObjectContext, persistenceController.container.viewContext)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -75,12 +107,12 @@ final class PlayerViewController: UIViewController {
|
||||
}
|
||||
#else
|
||||
func embedViewController() {
|
||||
playerViewController.view.frame = view.bounds
|
||||
playerView.view.frame = view.bounds
|
||||
|
||||
addChild(playerViewController)
|
||||
view.addSubview(playerViewController.view)
|
||||
addChild(playerView)
|
||||
view.addSubview(playerView.view)
|
||||
|
||||
playerViewController.didMove(toParent: self)
|
||||
playerView.didMove(toParent: self)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -94,26 +126,50 @@ extension PlayerViewController: AVPlayerViewControllerDelegate {
|
||||
true
|
||||
}
|
||||
|
||||
func playerViewControllerWillBeginDismissalTransition(_: AVPlayerViewController) {}
|
||||
|
||||
func playerViewControllerDidEndDismissalTransition(_: AVPlayerViewController) {
|
||||
func playerViewControllerWillBeginDismissalTransition(_: AVPlayerViewController) {
|
||||
if Defaults[.pauseOnHidingPlayer] {
|
||||
playerModel.pause()
|
||||
}
|
||||
dismiss(animated: false)
|
||||
}
|
||||
|
||||
func playerViewControllerDidEndDismissalTransition(_: AVPlayerViewController) {}
|
||||
|
||||
func playerViewController(
|
||||
_: AVPlayerViewController,
|
||||
willBeginFullScreenPresentationWithAnimationCoordinator _: UIViewControllerTransitionCoordinator
|
||||
) {}
|
||||
willBeginFullScreenPresentationWithAnimationCoordinator context: UIViewControllerTransitionCoordinator
|
||||
) {
|
||||
playerModel.playingFullscreen = true
|
||||
|
||||
#if os(iOS)
|
||||
if !context.isCancelled, Defaults[.lockLandscapeWhenEnteringFullscreen] {
|
||||
Orientation.lockOrientation(.landscape, andRotateTo: UIDevice.current.orientation.isLandscape ? nil : .landscapeRight)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func playerViewController(
|
||||
_: AVPlayerViewController,
|
||||
willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator
|
||||
) {
|
||||
let wasPlaying = playerModel.isPlaying
|
||||
coordinator.animate(alongsideTransition: nil) { context in
|
||||
#if os(iOS)
|
||||
if wasPlaying {
|
||||
self.playerModel.play()
|
||||
}
|
||||
#endif
|
||||
if !context.isCancelled {
|
||||
#if os(iOS)
|
||||
if self.traitCollection.verticalSizeClass == .compact {
|
||||
self.dismiss(animated: true)
|
||||
self.playerModel.lockedOrientation = nil
|
||||
if Defaults[.enterFullscreenInLandscape] {
|
||||
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
|
||||
}
|
||||
|
||||
self.playerModel.playingFullscreen = false
|
||||
|
||||
if wasPlaying {
|
||||
self.playerModel.play()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -121,19 +177,19 @@ extension PlayerViewController: AVPlayerViewControllerDelegate {
|
||||
}
|
||||
|
||||
func playerViewController(
|
||||
_ playerViewController: AVPlayerViewController,
|
||||
_: AVPlayerViewController,
|
||||
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void
|
||||
) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
if self.navigationModel.presentingChannel {
|
||||
self.playerModel.playerNavigationLinkActive = true
|
||||
} else {
|
||||
self.playerModel.presentPlayer()
|
||||
self.playerModel.show()
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
if self.playerModel.playingInPictureInPicture {
|
||||
self.present(playerViewController, animated: false) {
|
||||
self.present(self.playerView, animated: false) {
|
||||
completionHandler(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
import SDWebImageSwiftUI
|
||||
import SwiftUI
|
||||
|
||||
struct VideoDetails: View {
|
||||
enum Page {
|
||||
case info, queue, related, comments
|
||||
case info, comments, related, queue
|
||||
}
|
||||
|
||||
@Binding var sidebarQueue: Bool
|
||||
@Binding var fullScreen: Bool
|
||||
|
||||
@State private var subscribed = false
|
||||
@State private var subscriptionToggleButtonDisabled = false
|
||||
@State private var presentingUnsubscribeAlert = false
|
||||
@State private var presentingAddToPlaylist = false
|
||||
@State private var presentingShareSheet = false
|
||||
@@ -20,9 +22,12 @@ struct VideoDetails: View {
|
||||
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
@Environment(\.inNavigationView) private var inNavigationView
|
||||
@Environment(\.navigationStyle) private var navigationStyle
|
||||
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||
|
||||
@Default(.showKeywords) private var showKeywords
|
||||
@@ -36,7 +41,7 @@ struct VideoDetails: View {
|
||||
}
|
||||
|
||||
var video: Video? {
|
||||
player.currentItem?.video
|
||||
player.currentVideo
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -65,7 +70,9 @@ struct VideoDetails: View {
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
if CommentsModel.enabled {
|
||||
if !sidebarQueue ||
|
||||
(CommentsModel.enabled && CommentsModel.placement == .separate)
|
||||
{
|
||||
pagePicker
|
||||
.padding(.horizontal)
|
||||
}
|
||||
@@ -90,8 +97,12 @@ struct VideoDetails: View {
|
||||
|
||||
switch currentPage {
|
||||
case .info:
|
||||
ScrollView(.vertical) {
|
||||
detailsPage
|
||||
if player.isLoadingVideo {
|
||||
PlaceholderProgressView()
|
||||
} else {
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
detailsPage
|
||||
}
|
||||
}
|
||||
case .queue:
|
||||
PlayerQueueView(sidebarQueue: $sidebarQueue, fullScreen: $fullScreen)
|
||||
@@ -101,7 +112,7 @@ struct VideoDetails: View {
|
||||
RelatedView()
|
||||
.edgesIgnoringSafeArea(.horizontal)
|
||||
case .comments:
|
||||
CommentsView()
|
||||
CommentsView(embedInScrollView: true)
|
||||
.edgesIgnoringSafeArea(.horizontal)
|
||||
}
|
||||
}
|
||||
@@ -118,7 +129,7 @@ struct VideoDetails: View {
|
||||
}
|
||||
.onChange(of: sidebarQueue) { queue in
|
||||
if queue {
|
||||
if currentPage == .queue {
|
||||
if currentPage == .related || currentPage == .queue {
|
||||
currentPage = .info
|
||||
}
|
||||
} else if video.isNil {
|
||||
@@ -126,7 +137,7 @@ struct VideoDetails: View {
|
||||
}
|
||||
}
|
||||
.edgesIgnoringSafeArea(.horizontal)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
|
||||
var title: some View {
|
||||
@@ -178,23 +189,52 @@ struct VideoDetails: View {
|
||||
Group {
|
||||
if video != nil {
|
||||
HStack(alignment: .center) {
|
||||
HStack(spacing: 4) {
|
||||
if subscribed {
|
||||
Image(systemName: "star.circle.fill")
|
||||
}
|
||||
VStack(alignment: .leading) {
|
||||
Text(video!.channel.name)
|
||||
.font(.system(size: 13))
|
||||
.bold()
|
||||
if let subscribers = video!.channel.subscriptionsString {
|
||||
Text("\(subscribers) subscribers")
|
||||
.font(.caption2)
|
||||
HStack(spacing: 10) {
|
||||
Group {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
authorAvatar
|
||||
|
||||
if subscribed {
|
||||
Image(systemName: "star.circle.fill")
|
||||
.background(Color.background)
|
||||
.clipShape(Circle())
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text(video!.channel.name)
|
||||
.font(.system(size: 14))
|
||||
.bold()
|
||||
|
||||
Group {
|
||||
if let subscribers = video!.channel.subscriptionsString {
|
||||
Text("\(subscribers) subscribers")
|
||||
}
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
}
|
||||
.contentShape(RoundedRectangle(cornerRadius: 12))
|
||||
.contextMenu {
|
||||
if let video = video {
|
||||
Button(action: {
|
||||
NavigationModel.openChannel(
|
||||
video.channel,
|
||||
player: player,
|
||||
recents: recents,
|
||||
navigation: navigation,
|
||||
navigationStyle: navigationStyle
|
||||
)
|
||||
}) {
|
||||
Label("\(video.channel.name) Channel", systemImage: "rectangle.stack.fill.badge.person.crop")
|
||||
}
|
||||
}
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if accounts.app.supportsSubscriptions {
|
||||
if accounts.app.supportsSubscriptions, accounts.signedIn {
|
||||
Spacer()
|
||||
|
||||
Section {
|
||||
@@ -209,13 +249,16 @@ struct VideoDetails: View {
|
||||
.alert(isPresented: $presentingUnsubscribeAlert) {
|
||||
Alert(
|
||||
title: Text(
|
||||
"Are you you want to unsubscribe from \(video!.channel.name)?"
|
||||
"Are you sure you want to unsubscribe from \(video!.channel.name)?"
|
||||
),
|
||||
primaryButton: .destructive(Text("Unsubscribe")) {
|
||||
subscriptions.unsubscribe(video!.channel.id)
|
||||
subscriptionToggleButtonDisabled = true
|
||||
|
||||
withAnimation {
|
||||
subscribed.toggle()
|
||||
subscriptions.unsubscribe(video!.channel.id) {
|
||||
withAnimation {
|
||||
subscriptionToggleButtonDisabled = false
|
||||
subscribed.toggle()
|
||||
}
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
@@ -223,16 +266,20 @@ struct VideoDetails: View {
|
||||
}
|
||||
} else {
|
||||
Button("Subscribe") {
|
||||
subscriptions.subscribe(video!.channel.id)
|
||||
subscriptionToggleButtonDisabled = true
|
||||
|
||||
withAnimation {
|
||||
subscribed.toggle()
|
||||
subscriptions.subscribe(video!.channel.id) {
|
||||
withAnimation {
|
||||
subscriptionToggleButtonDisabled = false
|
||||
subscribed.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
.backport
|
||||
.tint(.blue)
|
||||
.tint(subscriptionToggleButtonDisabled ? .gray : .blue)
|
||||
}
|
||||
}
|
||||
.disabled(subscriptionToggleButtonDisabled)
|
||||
.font(.system(size: 13))
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
@@ -245,13 +292,12 @@ struct VideoDetails: View {
|
||||
Picker("Page", selection: $currentPage) {
|
||||
if !video.isNil {
|
||||
Text("Info").tag(Page.info)
|
||||
if CommentsModel.enabled, CommentsModel.placement == .separate {
|
||||
Text("Comments").tag(Page.comments)
|
||||
}
|
||||
if !sidebarQueue {
|
||||
Text("Related").tag(Page.related)
|
||||
}
|
||||
if CommentsModel.enabled {
|
||||
Text("Comments")
|
||||
.tag(Page.comments)
|
||||
}
|
||||
}
|
||||
if !sidebarQueue {
|
||||
Text("Queue").tag(Page.queue)
|
||||
@@ -315,28 +361,32 @@ struct VideoDetails: View {
|
||||
|
||||
if let likes = video.likesCount {
|
||||
Divider()
|
||||
.frame(minHeight: 35)
|
||||
|
||||
videoDetail(label: "Likes", value: likes, symbol: "hand.thumbsup")
|
||||
}
|
||||
|
||||
if let dislikes = video.dislikesCount {
|
||||
Divider()
|
||||
.frame(minHeight: 35)
|
||||
|
||||
videoDetail(label: "Dislikes", value: dislikes, symbol: "hand.thumbsdown")
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if accounts.app.supportsUserPlaylists {
|
||||
Button {
|
||||
presentingAddToPlaylist = true
|
||||
} label: {
|
||||
Label("Add to Playlist", systemImage: "text.badge.plus")
|
||||
.labelStyle(.iconOnly)
|
||||
.help("Add to Playlist...")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Button {
|
||||
presentingAddToPlaylist = true
|
||||
} label: {
|
||||
Label("Add to Playlist", systemImage: "text.badge.plus")
|
||||
.labelStyle(.iconOnly)
|
||||
.help("Add to Playlist...")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.opacity(accounts.app.supportsUserPlaylists ? 1 : 0)
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 35, alignment: .trailing)
|
||||
#endif
|
||||
}
|
||||
.frame(maxHeight: 35)
|
||||
.foregroundColor(.secondary)
|
||||
@@ -364,67 +414,99 @@ struct VideoDetails: View {
|
||||
ContentItem(video: player.currentVideo!)
|
||||
}
|
||||
|
||||
private var authorAvatar: some View {
|
||||
Group {
|
||||
if let video = video, let url = video.channel.thumbnailURL {
|
||||
WebImage(url: url)
|
||||
.resizable()
|
||||
.placeholder {
|
||||
Rectangle().fill(Color("PlaceholderColor"))
|
||||
}
|
||||
.retryOnAppear(true)
|
||||
.indicator(.activity)
|
||||
.clipShape(Circle())
|
||||
.frame(width: 45, height: 45, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var detailsPage: some View {
|
||||
Group {
|
||||
if let video = player.currentItem?.video {
|
||||
Group {
|
||||
HStack {
|
||||
publishedDateSection
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
countsSection
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if let description = video.description {
|
||||
Group {
|
||||
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
|
||||
Text(description)
|
||||
.textSelection(.enabled)
|
||||
} else {
|
||||
Text(description)
|
||||
}
|
||||
Group {
|
||||
if let video = player.currentVideo {
|
||||
VStack(spacing: 6) {
|
||||
HStack {
|
||||
publishedDateSection
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.font(.system(size: 14))
|
||||
.lineSpacing(3)
|
||||
.padding(.bottom, 4)
|
||||
} else {
|
||||
Text("No description")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Divider()
|
||||
|
||||
countsSection
|
||||
|
||||
Divider()
|
||||
}
|
||||
|
||||
if showKeywords {
|
||||
ScrollView(.horizontal, showsIndicators: showScrollIndicators) {
|
||||
HStack {
|
||||
ForEach(video.keywords, id: \.self) { keyword in
|
||||
HStack(alignment: .center, spacing: 0) {
|
||||
Text("#")
|
||||
.font(.system(size: 11).bold())
|
||||
|
||||
Text(keyword)
|
||||
.frame(maxWidth: 500)
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(.white)
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, 8)
|
||||
.background(Color("VideoDetailLikesSymbolColor"))
|
||||
.mask(RoundedRectangle(cornerRadius: 3))
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if let description = video.description {
|
||||
Group {
|
||||
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
|
||||
Text(description)
|
||||
.textSelection(.enabled)
|
||||
} else {
|
||||
Text(description)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.font(.system(size: 14))
|
||||
.lineSpacing(3)
|
||||
} else {
|
||||
Text("No description")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if showKeywords {
|
||||
ScrollView(.horizontal, showsIndicators: showScrollIndicators) {
|
||||
HStack {
|
||||
ForEach(video.keywords, id: \.self) { keyword in
|
||||
HStack(alignment: .center, spacing: 0) {
|
||||
Text("#")
|
||||
.font(.system(size: 11).bold())
|
||||
|
||||
Text(keyword)
|
||||
.frame(maxWidth: 500)
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(.white)
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, 8)
|
||||
.background(Color("KeywordBackgroundColor"))
|
||||
.mask(RoundedRectangle(cornerRadius: 3))
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !video.isNil, CommentsModel.placement == .info {
|
||||
Divider()
|
||||
#if os(macOS)
|
||||
.padding(.bottom, 20)
|
||||
#else
|
||||
.padding(.vertical, 10)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
LazyVStack {
|
||||
if !video.isNil, CommentsModel.placement == .info {
|
||||
CommentsView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
func videoDetail(label: String, value: String, symbol: String) -> some View {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import AVKit
|
||||
#if os(iOS)
|
||||
import CoreMotion
|
||||
#endif
|
||||
import Defaults
|
||||
import Siesta
|
||||
import SwiftUI
|
||||
@@ -14,7 +17,7 @@ struct VideoPlayerView: View {
|
||||
}
|
||||
|
||||
@State private var playerSize: CGSize = .zero
|
||||
@State private var fullScreen = false
|
||||
@State private var fullScreenDetails = false
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@@ -22,8 +25,17 @@ struct VideoPlayerView: View {
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
|
||||
@Default(.enterFullscreenInLandscape) private var enterFullscreenInLandscape
|
||||
@Default(.honorSystemOrientationLock) private var honorSystemOrientationLock
|
||||
@Default(.lockLandscapeOnRotation) private var lockLandscapeOnRotation
|
||||
|
||||
@State private var motionManager: CMMotionManager!
|
||||
@State private var orientation = UIInterfaceOrientation.portrait
|
||||
@State private var lastOrientation: UIInterfaceOrientation?
|
||||
#endif
|
||||
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
|
||||
var body: some View {
|
||||
@@ -31,20 +43,44 @@ struct VideoPlayerView: View {
|
||||
HSplitView {
|
||||
content
|
||||
}
|
||||
.frame(idealWidth: 1000, maxWidth: 1100, minHeight: 700)
|
||||
.onOpenURL { OpenURLHandler(accounts: accounts, player: player).handle($0) }
|
||||
.frame(minWidth: 950, minHeight: 700)
|
||||
#else
|
||||
GeometryReader { geometry in
|
||||
HStack(spacing: 0) {
|
||||
content
|
||||
}
|
||||
.onAppear {
|
||||
self.playerSize = geometry.size
|
||||
.onAppear {
|
||||
playerSize = geometry.size
|
||||
|
||||
#if os(iOS)
|
||||
configureOrientationUpdatesBasedOnAccelerometer()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.onChange(of: geometry.size) { size in
|
||||
self.playerSize = size
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
#if os(iOS)
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
|
||||
handleOrientationDidChangeNotification()
|
||||
}
|
||||
.onDisappear {
|
||||
guard !player.playingFullscreen else {
|
||||
return // swiftlint:disable:this implicit_return
|
||||
}
|
||||
|
||||
if Defaults[.lockPortraitWhenBrowsing] {
|
||||
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
|
||||
} else {
|
||||
Orientation.lockOrientation(.allButUpsideDown)
|
||||
}
|
||||
|
||||
motionManager?.stopAccelerometerUpdates()
|
||||
motionManager = nil
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -66,25 +102,26 @@ struct VideoPlayerView: View {
|
||||
|
||||
if player.currentItem.isNil {
|
||||
playerPlaceholder(geometry: geometry)
|
||||
} else if player.playingInPictureInPicture {
|
||||
pictureInPicturePlaceholder(geometry: geometry)
|
||||
} else {
|
||||
#if os(macOS)
|
||||
Player()
|
||||
.modifier(VideoPlayerSizeModifier(geometry: geometry, aspectRatio: player.controller?.aspectRatio))
|
||||
|
||||
#else
|
||||
player.playerView
|
||||
.modifier(VideoPlayerSizeModifier(geometry: geometry, aspectRatio: player.controller?.aspectRatio))
|
||||
#endif
|
||||
player.playerView
|
||||
.modifier(
|
||||
VideoPlayerSizeModifier(
|
||||
geometry: geometry,
|
||||
aspectRatio: player.controller?.aspectRatio
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.onSwipeGesture(
|
||||
up: {
|
||||
withAnimation {
|
||||
fullScreen = true
|
||||
fullScreenDetails = true
|
||||
}
|
||||
},
|
||||
down: { presentationMode.wrappedValue.dismiss() }
|
||||
down: { player.hide() }
|
||||
)
|
||||
#endif
|
||||
|
||||
@@ -93,15 +130,19 @@ struct VideoPlayerView: View {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
if verticalSizeClass == .regular {
|
||||
VideoDetails(sidebarQueue: sidebarQueueBinding, fullScreen: $fullScreen)
|
||||
VideoDetails(sidebarQueue: sidebarQueueBinding, fullScreen: $fullScreenDetails)
|
||||
}
|
||||
|
||||
#else
|
||||
VideoDetails(sidebarQueue: sidebarQueueBinding, fullScreen: $fullScreen)
|
||||
VideoDetails(sidebarQueue: sidebarQueueBinding, fullScreen: $fullScreenDetails)
|
||||
#endif
|
||||
}
|
||||
.background(colorScheme == .dark ? Color.black : Color.white)
|
||||
.modifier(VideoDetailsPaddingModifier(geometry: geometry, aspectRatio: player.controller?.aspectRatio, fullScreen: fullScreen))
|
||||
.modifier(VideoDetailsPaddingModifier(
|
||||
geometry: geometry,
|
||||
aspectRatio: player.controller?.aspectRatio,
|
||||
fullScreen: fullScreenDetails
|
||||
))
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -111,12 +152,12 @@ struct VideoPlayerView: View {
|
||||
#endif
|
||||
#if os(iOS)
|
||||
if sidebarQueue {
|
||||
PlayerQueueView(sidebarQueue: .constant(true), fullScreen: $fullScreen)
|
||||
PlayerQueueView(sidebarQueue: .constant(true), fullScreen: $fullScreenDetails)
|
||||
.frame(maxWidth: 350)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
if Defaults[.playerSidebar] != .never {
|
||||
PlayerQueueView(sidebarQueue: sidebarQueueBinding, fullScreen: $fullScreen)
|
||||
PlayerQueueView(sidebarQueue: sidebarQueueBinding, fullScreen: $fullScreenDetails)
|
||||
.frame(minWidth: 300)
|
||||
}
|
||||
#endif
|
||||
@@ -143,6 +184,35 @@ struct VideoPlayerView: View {
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: geometry.size.width / VideoPlayerView.defaultAspectRatio)
|
||||
}
|
||||
|
||||
func pictureInPicturePlaceholder(geometry: GeometryProxy) -> some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
VStack {
|
||||
Spacer()
|
||||
VStack(spacing: 10) {
|
||||
#if !os(tvOS)
|
||||
Image(systemName: "pip")
|
||||
.font(.system(size: 120))
|
||||
#endif
|
||||
|
||||
Text("Playing in Picture in Picture")
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.foregroundColor(.gray)
|
||||
Spacer()
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
player.closePiP()
|
||||
} label: {
|
||||
Label("Exit Picture in Picture", systemImage: "pip.exit")
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: geometry.size.width / VideoPlayerView.defaultAspectRatio)
|
||||
}
|
||||
|
||||
var sidebarQueue: Bool {
|
||||
switch Defaults[.playerSidebar] {
|
||||
case .never:
|
||||
@@ -160,6 +230,119 @@ struct VideoPlayerView: View {
|
||||
set: { _ in }
|
||||
)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private func configureOrientationUpdatesBasedOnAccelerometer() {
|
||||
if UIDevice.current.orientation.isLandscape,
|
||||
enterFullscreenInLandscape,
|
||||
!player.playingFullscreen,
|
||||
!player.playingInPictureInPicture
|
||||
{
|
||||
DispatchQueue.main.async {
|
||||
player.enterFullScreen()
|
||||
}
|
||||
}
|
||||
|
||||
guard !honorSystemOrientationLock, motionManager.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
motionManager = CMMotionManager()
|
||||
motionManager.accelerometerUpdateInterval = 0.2
|
||||
motionManager.startAccelerometerUpdates(to: OperationQueue()) { data, _ in
|
||||
guard player.presentingPlayer, !player.playingInPictureInPicture, !data.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let acceleration = data?.acceleration else {
|
||||
return
|
||||
}
|
||||
|
||||
var orientation = UIInterfaceOrientation.unknown
|
||||
|
||||
if acceleration.x >= 0.65 {
|
||||
orientation = .landscapeLeft
|
||||
} else if acceleration.x <= -0.65 {
|
||||
orientation = .landscapeRight
|
||||
} else if acceleration.y <= -0.65 {
|
||||
orientation = .portrait
|
||||
} else if acceleration.y >= 0.65 {
|
||||
orientation = .portraitUpsideDown
|
||||
}
|
||||
|
||||
guard lastOrientation != orientation else {
|
||||
return
|
||||
}
|
||||
|
||||
lastOrientation = orientation
|
||||
|
||||
if orientation.isLandscape {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
|
||||
guard enterFullscreenInLandscape else {
|
||||
return
|
||||
}
|
||||
|
||||
player.enterFullScreen()
|
||||
|
||||
let orientationLockMask = orientation == .landscapeLeft ?
|
||||
UIInterfaceOrientationMask.landscapeLeft : .landscapeRight
|
||||
|
||||
Orientation.lockOrientation(orientationLockMask, andRotateTo: orientation)
|
||||
|
||||
guard lockLandscapeOnRotation else {
|
||||
return
|
||||
}
|
||||
|
||||
player.lockedOrientation = orientation
|
||||
}
|
||||
} else {
|
||||
guard abs(acceleration.z) <= 0.74,
|
||||
player.lockedOrientation.isNil,
|
||||
enterFullscreenInLandscape
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
|
||||
player.exitFullScreen()
|
||||
}
|
||||
|
||||
Orientation.lockOrientation(.portrait)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleOrientationDidChangeNotification() {
|
||||
let newOrientation = UIApplication.shared.windows.first?.windowScene?.interfaceOrientation
|
||||
if newOrientation?.isLandscape ?? false,
|
||||
player.presentingPlayer,
|
||||
lockLandscapeOnRotation,
|
||||
!player.lockedOrientation.isNil
|
||||
{
|
||||
Orientation.lockOrientation(.landscape, andRotateTo: newOrientation)
|
||||
return
|
||||
}
|
||||
|
||||
guard player.presentingPlayer, enterFullscreenInLandscape, honorSystemOrientationLock else {
|
||||
return
|
||||
}
|
||||
|
||||
if UIDevice.current.orientation.isLandscape {
|
||||
DispatchQueue.main.async {
|
||||
player.lockedOrientation = newOrientation
|
||||
player.enterFullScreen()
|
||||
}
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
player.exitFullScreen()
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
|
||||
player.exitFullScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
struct VideoPlayerView_Previews: PreviewProvider {
|
||||
|
||||
@@ -7,22 +7,27 @@ struct AddToPlaylistView: View {
|
||||
|
||||
@State private var selectedPlaylistID: Playlist.ID = ""
|
||||
|
||||
@State private var error = ""
|
||||
@State private var presentingErrorAlert = false
|
||||
@State private var submitButtonDisabled = false
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
|
||||
@EnvironmentObject<PlaylistsModel> private var model
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
VStack {
|
||||
header
|
||||
Spacer()
|
||||
if model.isEmpty {
|
||||
emptyPlaylistsMessage
|
||||
} else {
|
||||
header
|
||||
Spacer()
|
||||
form
|
||||
Spacer()
|
||||
footer
|
||||
}
|
||||
Spacer()
|
||||
footer
|
||||
}
|
||||
.frame(maxWidth: 1000, maxHeight: height)
|
||||
}
|
||||
@@ -118,8 +123,14 @@ struct AddToPlaylistView: View {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Add to Playlist", action: addToPlaylist)
|
||||
.disabled(selectedPlaylist.isNil)
|
||||
.disabled(submitButtonDisabled || selectedPlaylist.isNil)
|
||||
.padding(.top, 30)
|
||||
.alert(isPresented: $presentingErrorAlert) {
|
||||
Alert(
|
||||
title: Text("Error when accessing playlist"),
|
||||
message: Text(error)
|
||||
)
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
#endif
|
||||
@@ -155,9 +166,20 @@ struct AddToPlaylistView: View {
|
||||
|
||||
Defaults[.lastUsedPlaylistID] = id
|
||||
|
||||
model.addVideo(playlistID: id, videoID: video.videoID) {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
submitButtonDisabled = true
|
||||
|
||||
model.addVideo(
|
||||
playlistID: id,
|
||||
videoID: video.videoID,
|
||||
onSuccess: {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
},
|
||||
onFailure: { requestError in
|
||||
error = "(\(requestError.httpStatusCode ?? -1)) \(requestError.userMessage)"
|
||||
presentingErrorAlert = true
|
||||
submitButtonDisabled = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var selectedPlaylist: Playlist? {
|
||||
|
||||
@@ -8,7 +8,10 @@ struct PlaylistFormView: View {
|
||||
@State private var visibility = Playlist.Visibility.public
|
||||
|
||||
@State private var valid = false
|
||||
@State private var showingDeleteConfirmation = false
|
||||
@State private var presentingDeleteConfirmation = false
|
||||
|
||||
@State private var formError = ""
|
||||
@State private var presentingErrorAlert = false
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
@@ -40,9 +43,12 @@ struct PlaylistFormView: View {
|
||||
TextField("Name", text: $name, onCommit: validate)
|
||||
.frame(maxWidth: 450)
|
||||
.padding(.leading, 10)
|
||||
.disabled(editing && !accounts.app.userPlaylistsAreEditable)
|
||||
|
||||
visibilityFormItem
|
||||
.pickerStyle(.segmented)
|
||||
if accounts.app.userPlaylistsHaveVisibility {
|
||||
visibilityFormItem
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.padding(.horizontal)
|
||||
@@ -56,7 +62,13 @@ struct PlaylistFormView: View {
|
||||
Spacer()
|
||||
|
||||
Button("Save", action: submitForm)
|
||||
.disabled(!valid)
|
||||
.disabled(!valid || (editing && !accounts.app.userPlaylistsAreEditable))
|
||||
.alert(isPresented: $presentingErrorAlert) {
|
||||
Alert(
|
||||
title: Text("Error when accessing playlist"),
|
||||
message: Text(formError)
|
||||
)
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
.frame(minHeight: 35)
|
||||
@@ -66,7 +78,7 @@ struct PlaylistFormView: View {
|
||||
#if os(iOS)
|
||||
.padding(.vertical)
|
||||
#else
|
||||
.frame(width: 400, height: 150)
|
||||
.frame(width: 400, height: accounts.app.userPlaylistsHaveVisibility ? 150 : 120)
|
||||
#endif
|
||||
|
||||
#else
|
||||
@@ -110,20 +122,24 @@ struct PlaylistFormView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
TextField("Playlist Name", text: $name, onCommit: validate)
|
||||
.disabled(editing && !accounts.app.userPlaylistsAreEditable)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Visibility")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
if accounts.app.userPlaylistsHaveVisibility {
|
||||
HStack {
|
||||
Text("Visibility")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
visibilityFormItem
|
||||
visibilityFormItem
|
||||
}
|
||||
.padding(.top, 10)
|
||||
}
|
||||
.padding(.top, 10)
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
|
||||
Button("Save", action: submitForm).disabled(!valid)
|
||||
Button("Save", action: submitForm)
|
||||
.disabled(!valid || (editing && !accounts.app.userPlaylistsAreEditable))
|
||||
}
|
||||
.padding(.top, 40)
|
||||
|
||||
@@ -163,23 +179,17 @@ struct PlaylistFormView: View {
|
||||
return
|
||||
}
|
||||
|
||||
let body = ["title": name, "privacy": visibility.rawValue]
|
||||
|
||||
resource?.request(editing ? .patch : .post, json: body).onSuccess { response in
|
||||
if let modifiedPlaylist: Playlist = response.typedContent() {
|
||||
playlist = modifiedPlaylist
|
||||
}
|
||||
|
||||
accounts.api.playlistForm(name, visibility.rawValue, playlist: playlist, onFailure: { error in
|
||||
formError = "(\(error.httpStatusCode ?? -1)) \(error.userMessage)"
|
||||
presentingErrorAlert = true
|
||||
}) { modifiedPlaylist in
|
||||
self.playlist = modifiedPlaylist
|
||||
playlists.load(force: true)
|
||||
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
var resource: Resource? {
|
||||
editing ? accounts.api.playlist(playlist.id) : accounts.api.playlists
|
||||
}
|
||||
|
||||
var visibilityFormItem: some View {
|
||||
#if os(macOS)
|
||||
Picker("Visibility", selection: $visibility) {
|
||||
@@ -207,9 +217,9 @@ struct PlaylistFormView: View {
|
||||
|
||||
var deletePlaylistButton: some View {
|
||||
Button("Delete") {
|
||||
showingDeleteConfirmation = true
|
||||
presentingDeleteConfirmation = true
|
||||
}
|
||||
.alert(isPresented: $showingDeleteConfirmation) {
|
||||
.alert(isPresented: $presentingDeleteConfirmation) {
|
||||
Alert(
|
||||
title: Text("Are you sure you want to delete playlist?"),
|
||||
message: Text("Playlist \"\(playlist.title)\" will be deleted.\nIt cannot be undone."),
|
||||
@@ -221,7 +231,10 @@ struct PlaylistFormView: View {
|
||||
}
|
||||
|
||||
func deletePlaylistAndDismiss() {
|
||||
accounts.api.playlist(playlist.id)?.request(.delete).onSuccess { _ in
|
||||
accounts.api.deletePlaylist(playlist, onFailure: { error in
|
||||
formError = "(\(error.httpStatusCode ?? -1)) \(error.localizedDescription)"
|
||||
presentingErrorAlert = true
|
||||
}) {
|
||||
playlist = nil
|
||||
playlists.load(force: true)
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
|
||||
@@ -11,6 +11,8 @@ struct PlaylistsView: View {
|
||||
@State private var showingEditPlaylist = false
|
||||
@State private var editedPlaylist: Playlist?
|
||||
|
||||
@StateObject private var store = Store<ChannelPlaylist>()
|
||||
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<PlaylistsModel> private var model
|
||||
@@ -18,7 +20,36 @@ struct PlaylistsView: View {
|
||||
@Namespace private var focusNamespace
|
||||
|
||||
var items: [ContentItem] {
|
||||
ContentItem.array(of: currentPlaylist?.videos ?? [])
|
||||
var videos = currentPlaylist?.videos ?? []
|
||||
|
||||
if videos.isEmpty {
|
||||
videos = store.item?.videos ?? []
|
||||
if !player.accounts.app.userPlaylistsEndpointIncludesVideos {
|
||||
var i = 0
|
||||
|
||||
for index in videos.indices {
|
||||
var video = videos[index]
|
||||
video.indexID = "\(i)"
|
||||
i += 1
|
||||
videos[index] = video
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ContentItem.array(of: videos)
|
||||
}
|
||||
|
||||
private var resource: Resource? {
|
||||
guard !player.accounts.app.userPlaylistsEndpointIncludesVideos,
|
||||
let playlist = currentPlaylist
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let resource = player.accounts.api.playlist(playlist.id)
|
||||
resource?.addObserver(store)
|
||||
|
||||
return resource
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -74,25 +105,6 @@ struct PlaylistsView: View {
|
||||
)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItemGroup {
|
||||
#if !os(iOS)
|
||||
if !model.isEmpty {
|
||||
if #available(macOS 12.0, *) {
|
||||
selectPlaylistButton
|
||||
.prefersDefaultFocus(in: focusNamespace)
|
||||
} else {
|
||||
selectPlaylistButton
|
||||
}
|
||||
}
|
||||
|
||||
if currentPlaylist != nil {
|
||||
editPlaylistButton
|
||||
}
|
||||
#endif
|
||||
|
||||
FavoriteButton(item: FavoriteItem(section: .playlist(selectedPlaylistID)))
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
ToolbarItemGroup(placement: .bottomBar) {
|
||||
Group {
|
||||
@@ -100,21 +112,29 @@ struct PlaylistsView: View {
|
||||
Text("No Playlists")
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Text("Current Playlist")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
selectPlaylistButton
|
||||
.transaction { t in t.animation = .none }
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
newPlaylistButton
|
||||
|
||||
if currentPlaylist != nil {
|
||||
editPlaylistButton
|
||||
HStack(spacing: 10) {
|
||||
playButton
|
||||
shuffleButton
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
HStack(spacing: 2) {
|
||||
newPlaylistButton
|
||||
|
||||
if currentPlaylist != nil {
|
||||
editPlaylistButton
|
||||
}
|
||||
}
|
||||
}
|
||||
.transaction { t in t.animation = .none }
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -123,10 +143,20 @@ struct PlaylistsView: View {
|
||||
#endif
|
||||
.onAppear {
|
||||
model.load()
|
||||
resource?.load()
|
||||
}
|
||||
.onChange(of: accounts.current) { _ in
|
||||
model.load(force: true)
|
||||
}
|
||||
.onChange(of: selectedPlaylistID) { _ in
|
||||
resource?.load()
|
||||
}
|
||||
.onChange(of: model.reloadPlaylists) { _ in
|
||||
resource?.load()
|
||||
}
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(RefreshControl.navigationBarTitleDisplayMode)
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
@@ -142,23 +172,14 @@ struct PlaylistsView: View {
|
||||
selectPlaylistButton
|
||||
}
|
||||
|
||||
Button {
|
||||
player.playAll(items.compactMap(\.video))
|
||||
player.presentPlayer()
|
||||
} label: {
|
||||
HStack(spacing: 15) {
|
||||
Image(systemName: "play.fill")
|
||||
Text("Play All")
|
||||
}
|
||||
}
|
||||
|
||||
if currentPlaylist != nil {
|
||||
editPlaylistButton
|
||||
}
|
||||
|
||||
if let playlist = currentPlaylist {
|
||||
editPlaylistButton
|
||||
|
||||
FavoriteButton(item: FavoriteItem(section: .playlist(playlist.id)))
|
||||
.labelStyle(.iconOnly)
|
||||
|
||||
playButton
|
||||
shuffleButton
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@@ -179,7 +200,7 @@ struct PlaylistsView: View {
|
||||
}
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
|
||||
#if os(macOS)
|
||||
.background(Color.tertiaryBackground)
|
||||
.background(Color.secondaryBackground)
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -228,7 +249,7 @@ struct PlaylistsView: View {
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
#else
|
||||
Menu(currentPlaylist?.title ?? "Select playlist") {
|
||||
Menu {
|
||||
ForEach(model.all) { playlist in
|
||||
Button(action: { selectedPlaylistID = playlist.id }) {
|
||||
if playlist == currentPlaylist {
|
||||
@@ -238,6 +259,9 @@ struct PlaylistsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text(currentPlaylist?.title ?? "Select playlist")
|
||||
.frame(maxWidth: 140, alignment: .leading)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -265,6 +289,22 @@ struct PlaylistsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var playButton: some View {
|
||||
Button {
|
||||
player.play(items.compactMap(\.video))
|
||||
} label: {
|
||||
Image(systemName: "play")
|
||||
}
|
||||
}
|
||||
|
||||
private var shuffleButton: some View {
|
||||
Button {
|
||||
player.play(items.compactMap(\.video), shuffling: true)
|
||||
} label: {
|
||||
Image(systemName: "shuffle")
|
||||
}
|
||||
}
|
||||
|
||||
private var currentPlaylist: Playlist? {
|
||||
model.find(id: selectedPlaylistID) ?? model.all.first
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
struct SearchTextField: View {
|
||||
@Environment(\.navigationStyle) private var navigationStyle
|
||||
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
@EnvironmentObject<SearchModel> private var state
|
||||
|
||||
@@ -28,8 +29,11 @@ struct SearchTextField: View {
|
||||
.opacity(0.8)
|
||||
#endif
|
||||
TextField("Search...", text: $state.queryText) {
|
||||
state.changeQuery { query in query.query = state.queryText }
|
||||
recents.addQuery(state.queryText)
|
||||
state.changeQuery { query in
|
||||
query.query = state.queryText
|
||||
navigation.hideKeyboard()
|
||||
}
|
||||
recents.addQuery(state.queryText, navigation: navigation)
|
||||
}
|
||||
.onChange(of: state.queryText) { _ in
|
||||
if state.query.query.compare(state.queryText, options: .caseInsensitive) == .orderedSame {
|
||||
@@ -37,6 +41,7 @@ struct SearchTextField: View {
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.frame(maxWidth: 190)
|
||||
.textFieldStyle(.plain)
|
||||
#else
|
||||
.textFieldStyle(.roundedBorder)
|
||||
@@ -52,6 +57,11 @@ struct SearchTextField: View {
|
||||
.padding(.trailing)
|
||||
#endif
|
||||
clearButton
|
||||
} else {
|
||||
#if os(macOS)
|
||||
clearButton
|
||||
.opacity(0)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,65 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SearchSuggestions: View {
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
@EnvironmentObject<SearchModel> private var state
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Button {
|
||||
state.changeQuery { query in
|
||||
query.query = state.queryText
|
||||
state.fieldIsFocused = false
|
||||
}
|
||||
|
||||
recents.addQuery(state.queryText)
|
||||
runQueryAction()
|
||||
} label: {
|
||||
HStack(spacing: 5) {
|
||||
Label(state.queryText, systemImage: "magnifyingglass")
|
||||
HStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
Text(state.queryText)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 5)
|
||||
|
||||
#if os(macOS)
|
||||
.onHover(perform: onHover(_:))
|
||||
.onHover(perform: onHover(_:))
|
||||
#endif
|
||||
|
||||
ForEach(visibleSuggestions, id: \.self) { suggestion in
|
||||
Button {
|
||||
state.queryText = suggestion
|
||||
} label: {
|
||||
HStack(spacing: 0) {
|
||||
Label(state.queryText, systemImage: "arrow.up.left.circle")
|
||||
.lineLimit(1)
|
||||
.layoutPriority(2)
|
||||
.foregroundColor(.secondary)
|
||||
HStack {
|
||||
Button {
|
||||
state.queryText = suggestion
|
||||
runQueryAction()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
HStack(spacing: 0) {
|
||||
if suggestion.hasPrefix(state.suggestionsText.lowercased()) {
|
||||
Text(state.suggestionsText.lowercased())
|
||||
.lineLimit(1)
|
||||
.layoutPriority(2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Text(querySuffix(suggestion))
|
||||
.lineLimit(1)
|
||||
.layoutPriority(1)
|
||||
Text(querySuffix(suggestion))
|
||||
.lineLimit(1)
|
||||
.layoutPriority(1)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
state.queryText = suggestion
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.left.circle")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
#if os(macOS)
|
||||
.onHover(perform: onHover(_:))
|
||||
@@ -48,6 +71,16 @@ struct SearchSuggestions: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
private func runQueryAction() {
|
||||
state.changeQuery { query in
|
||||
query.query = state.queryText
|
||||
state.fieldIsFocused = false
|
||||
navigation.hideKeyboard()
|
||||
}
|
||||
|
||||
recents.addQuery(state.queryText, navigation: navigation)
|
||||
}
|
||||
|
||||
private var visibleSuggestions: [String] {
|
||||
state.querySuggestions.collection.filter {
|
||||
$0.compare(state.queryText, options: .caseInsensitive) != .orderedSame
|
||||
@@ -55,7 +88,7 @@ struct SearchSuggestions: View {
|
||||
}
|
||||
|
||||
private func querySuffix(_ suggestion: String) -> String {
|
||||
suggestion.replacingFirstOccurrence(of: state.queryText.lowercased(), with: "")
|
||||
suggestion.replacingFirstOccurrence(of: state.suggestionsText.lowercased(), with: "")
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
|
||||
@@ -21,6 +21,8 @@ struct SearchView: View {
|
||||
@Environment(\.navigationStyle) private var navigationStyle
|
||||
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
@EnvironmentObject<SearchModel> private var state
|
||||
private var favorites = FavoritesModel.shared
|
||||
@@ -175,11 +177,40 @@ struct SearchView: View {
|
||||
.navigationTitle("Search")
|
||||
#endif
|
||||
#if os(iOS)
|
||||
.navigationBarHidden(!Defaults[.visibleSections].isEmpty || navigationStyle == .sidebar)
|
||||
.navigationBarHidden(navigationBarHidden)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var navigationBarHidden: Bool {
|
||||
if navigationStyle == .sidebar {
|
||||
return true
|
||||
}
|
||||
|
||||
let preferred = Defaults[.visibleSections]
|
||||
var visibleSections = [VisibleSection]()
|
||||
|
||||
if accounts.app.supportsPopular && preferred.contains(.popular) {
|
||||
visibleSections.append(.popular)
|
||||
}
|
||||
|
||||
if accounts.app.supportsSubscriptions && accounts.signedIn && preferred.contains(.subscriptions) {
|
||||
visibleSections.append(.subscriptions)
|
||||
}
|
||||
|
||||
if accounts.app.supportsUserPlaylists && accounts.signedIn && preferred.contains(.playlists) {
|
||||
visibleSections.append(.playlists)
|
||||
}
|
||||
|
||||
[VisibleSection.favorites, .trending].forEach { section in
|
||||
if preferred.contains(section) {
|
||||
visibleSections.append(section)
|
||||
}
|
||||
}
|
||||
|
||||
return !visibleSections.isEmpty
|
||||
}
|
||||
|
||||
private var results: some View {
|
||||
VStack {
|
||||
if showRecentQueries {
|
||||
@@ -199,10 +230,12 @@ struct SearchView: View {
|
||||
}
|
||||
|
||||
HorizontalCells(items: items)
|
||||
.environment(\.loadMoreContentHandler) { state.loadNextPage() }
|
||||
}
|
||||
.edgesIgnoringSafeArea(.horizontal)
|
||||
#else
|
||||
VerticalCells(items: items)
|
||||
VerticalCells(items: items, allowEmpty: state.query.isEmpty)
|
||||
.environment(\.loadMoreContentHandler) { state.loadNextPage() }
|
||||
#endif
|
||||
|
||||
if noResults {
|
||||
@@ -253,15 +286,7 @@ struct SearchView: View {
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
ForEach(recentItems) { item in
|
||||
Button(item.title) {
|
||||
state.queryText = item.title
|
||||
state.changeQuery { query in query.query = item.title }
|
||||
updateFavoriteItem()
|
||||
}
|
||||
.contextMenu {
|
||||
deleteButton(item)
|
||||
deleteAllButton
|
||||
}
|
||||
recentItemButton(item)
|
||||
}
|
||||
}
|
||||
.redrawOn(change: recentsChanged)
|
||||
@@ -272,21 +297,70 @@ struct SearchView: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
private func deleteButton(_ item: RecentItem) -> some View {
|
||||
private func recentItemButton(_ item: RecentItem) -> some View {
|
||||
Button {
|
||||
switch item.type {
|
||||
case .query:
|
||||
state.queryText = item.title
|
||||
state.changeQuery { query in query.query = item.title }
|
||||
|
||||
updateFavoriteItem()
|
||||
recents.add(item)
|
||||
case .channel:
|
||||
guard let channel = item.channel else {
|
||||
return
|
||||
}
|
||||
|
||||
NavigationModel.openChannel(
|
||||
channel,
|
||||
player: player,
|
||||
recents: recents,
|
||||
navigation: navigation,
|
||||
navigationStyle: navigationStyle,
|
||||
delay: false
|
||||
)
|
||||
case .playlist:
|
||||
guard let playlist = item.playlist else {
|
||||
return
|
||||
}
|
||||
|
||||
NavigationModel.openChannelPlaylist(
|
||||
playlist,
|
||||
player: player,
|
||||
recents: recents,
|
||||
navigation: navigation,
|
||||
navigationStyle: navigationStyle,
|
||||
delay: false
|
||||
)
|
||||
}
|
||||
} label: {
|
||||
let systemImage = item.type == .query ? "magnifyingglass" :
|
||||
item.type == .channel ? RecentsModel.symbolSystemImage(item.title) :
|
||||
"list.and.film"
|
||||
Label(item.title, systemImage: systemImage)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.contextMenu {
|
||||
removeButton(item)
|
||||
removeAllButton
|
||||
}
|
||||
}
|
||||
|
||||
private func removeButton(_ item: RecentItem) -> some View {
|
||||
Button {
|
||||
recents.close(item)
|
||||
recentsChanged.toggle()
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
Label("Remove", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
|
||||
private var deleteAllButton: some View {
|
||||
private var removeAllButton: some View {
|
||||
Button {
|
||||
recents.clearQueries()
|
||||
recents.clear()
|
||||
recentsChanged.toggle()
|
||||
} label: {
|
||||
Label("Delete All", systemImage: "trash.fill")
|
||||
Label("Remove All", systemImage: "trash.fill")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,7 +369,7 @@ struct SearchView: View {
|
||||
}
|
||||
|
||||
private var recentItems: [RecentItem] {
|
||||
Defaults[.recentlyOpened].filter { $0.type == .query }.reversed()
|
||||
Defaults[.recentlyOpened].reversed()
|
||||
}
|
||||
|
||||
private var searchSortOrderPicker: some View {
|
||||
|
||||
@@ -16,6 +16,7 @@ struct AccountForm: View {
|
||||
@State private var validationDebounce = Debounce()
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.openURL) private var openURL
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
|
||||
var body: some View {
|
||||
@@ -62,6 +63,10 @@ struct AccountForm: View {
|
||||
#if os(macOS)
|
||||
.padding(.horizontal)
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
helpButton
|
||||
#endif
|
||||
}
|
||||
#else
|
||||
formFields
|
||||
@@ -71,6 +76,22 @@ struct AccountForm: View {
|
||||
.onChange(of: password) { _ in validate() }
|
||||
}
|
||||
|
||||
var helpButton: some View {
|
||||
Group {
|
||||
if instance.app == .invidious {
|
||||
Button {
|
||||
openURL(URL(string: "https://github.com/yattee/yattee/wiki/Adding-Invidious-instance-and-account")!)
|
||||
} label: {
|
||||
Label("How to add Invidious account?", systemImage: "questionmark.circle")
|
||||
#if os(macOS)
|
||||
.help("How to add Invidious account?")
|
||||
.labelStyle(.iconOnly)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var formFields: some View {
|
||||
Group {
|
||||
if !instance.app.accountsUsePassword {
|
||||
@@ -105,6 +126,10 @@ struct AccountForm: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
#if os(macOS)
|
||||
helpButton
|
||||
#endif
|
||||
|
||||
Button("Save", action: submitForm)
|
||||
.disabled(!isValid)
|
||||
#if !os(tvOS)
|
||||
|
||||
@@ -15,10 +15,10 @@ struct AccountsNavigationLink: View {
|
||||
}
|
||||
|
||||
private func removeInstanceButton(_ instance: Instance) -> some View {
|
||||
if #available(iOS 15.0, *) {
|
||||
return Button("Remove", role: .destructive) { removeAction(instance) }
|
||||
} else {
|
||||
return Button("Remove") { removeAction(instance) }
|
||||
Button {
|
||||
removeAction(instance)
|
||||
} label: {
|
||||
Label("Remove", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,58 +2,114 @@ import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct BrowsingSettings: View {
|
||||
#if !os(tvOS)
|
||||
@Default(.accountPickerDisplaysUsername) private var accountPickerDisplaysUsername
|
||||
@Default(.roundedThumbnails) private var roundedThumbnails
|
||||
#endif
|
||||
#if os(iOS)
|
||||
@Default(.lockPortraitWhenBrowsing) private var lockPortraitWhenBrowsing
|
||||
#endif
|
||||
@Default(.channelOnThumbnail) private var channelOnThumbnail
|
||||
@Default(.timeOnThumbnail) private var timeOnThumbnail
|
||||
@Default(.saveRecents) private var saveRecents
|
||||
@Default(.saveHistory) private var saveHistory
|
||||
@Default(.visibleSections) private var visibleSections
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
Section(header: SettingsHeader(text: "Browsing")) {
|
||||
Toggle("Show channel name on thumbnail", isOn: $channelOnThumbnail)
|
||||
Toggle("Show video length on thumbnail", isOn: $timeOnThumbnail)
|
||||
Toggle("Save recent queries and channels", isOn: $saveRecents)
|
||||
Toggle("Save history of played videos", isOn: $saveHistory)
|
||||
}
|
||||
Section(header: SettingsHeader(text: "Sections")) {
|
||||
#if os(macOS)
|
||||
let list = ForEach(VisibleSection.allCases, id: \.self) { section in
|
||||
VisibleSectionSelectionRow(
|
||||
title: section.title,
|
||||
selected: visibleSections.contains(section)
|
||||
) { value in
|
||||
toggleSection(section, value: value)
|
||||
}
|
||||
}
|
||||
|
||||
Group {
|
||||
if #available(macOS 12.0, *) {
|
||||
list
|
||||
.listStyle(.inset(alternatesRowBackgrounds: true))
|
||||
} else {
|
||||
list
|
||||
.listStyle(.inset)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
#else
|
||||
ForEach(VisibleSection.allCases, id: \.self) { section in
|
||||
VisibleSectionSelectionRow(
|
||||
title: section.title,
|
||||
selected: visibleSections.contains(section)
|
||||
) { value in
|
||||
toggleSection(section, value: value)
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
sections
|
||||
#else
|
||||
List {
|
||||
sections
|
||||
}
|
||||
#if os(iOS)
|
||||
.listStyle(.insetGrouped)
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(tvOS)
|
||||
.frame(maxWidth: 1000)
|
||||
#else
|
||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
|
||||
#endif
|
||||
.navigationTitle("Browsing")
|
||||
}
|
||||
|
||||
func toggleSection(_ section: VisibleSection, value: Bool) {
|
||||
private var sections: some View {
|
||||
Group {
|
||||
#if !os(tvOS)
|
||||
interfaceSettings
|
||||
#endif
|
||||
thumbnailsSettings
|
||||
visibleSectionsSettings
|
||||
}
|
||||
}
|
||||
|
||||
private var interfaceSettings: some View {
|
||||
Section(header: SettingsHeader(text: "Interface")) {
|
||||
#if os(iOS)
|
||||
Toggle("Lock portrait mode", isOn: $lockPortraitWhenBrowsing)
|
||||
.onChange(of: lockPortraitWhenBrowsing) { lock in
|
||||
if lock {
|
||||
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
|
||||
} else {
|
||||
Orientation.lockOrientation(.allButUpsideDown)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if !os(tvOS)
|
||||
Toggle("Show account username", isOn: $accountPickerDisplaysUsername)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private var thumbnailsSettings: some View {
|
||||
Section(header: SettingsHeader(text: "Thumbnails")) {
|
||||
#if !os(tvOS)
|
||||
Toggle("Round corners", isOn: $roundedThumbnails)
|
||||
#endif
|
||||
Toggle("Show channel name", isOn: $channelOnThumbnail)
|
||||
Toggle("Show video length", isOn: $timeOnThumbnail)
|
||||
}
|
||||
}
|
||||
|
||||
private var visibleSectionsSettings: some View {
|
||||
Section(header: SettingsHeader(text: "Sections")) {
|
||||
#if os(macOS)
|
||||
let list = ForEach(VisibleSection.allCases, id: \.self) { section in
|
||||
VisibleSectionSelectionRow(
|
||||
title: section.title,
|
||||
selected: visibleSections.contains(section)
|
||||
) { value in
|
||||
toggleSection(section, value: value)
|
||||
}
|
||||
}
|
||||
|
||||
Group {
|
||||
if #available(macOS 12.0, *) {
|
||||
list
|
||||
.listStyle(.inset(alternatesRowBackgrounds: true))
|
||||
} else {
|
||||
list
|
||||
.listStyle(.inset)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
#else
|
||||
ForEach(VisibleSection.allCases, id: \.self) { section in
|
||||
VisibleSectionSelectionRow(
|
||||
title: section.title,
|
||||
selected: visibleSections.contains(section)
|
||||
) { value in
|
||||
toggleSection(section, value: value)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleSection(_ section: VisibleSection, value: Bool) {
|
||||
if value {
|
||||
visibleSections.insert(section)
|
||||
} else {
|
||||
|
||||
124
Shared/Settings/Help.swift
Normal file
124
Shared/Settings/Help.swift
Normal file
@@ -0,0 +1,124 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct Help: View {
|
||||
static let wikiURL = URL(string: "https://github.com/yattee/yattee/wiki")!
|
||||
static let matrixURL = URL(string: "https://tinyurl.com/yattee-matrix")!
|
||||
static let issuesURL = URL(string: "https://github.com/yattee/yattee/issues")!
|
||||
static let milestonesURL = URL(string: "https://github.com/yattee/yattee/milestones")!
|
||||
static let donationsURL = URL(string: "https://github.com/yattee/yattee/wiki/Donations")!
|
||||
static let contributingURL = URL(string: "https://github.com/yattee/yattee/wiki/Contributing")!
|
||||
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Section {
|
||||
header("I am lost")
|
||||
|
||||
Text("You can find information about using Yattee in the Wiki pages.")
|
||||
.padding(.bottom, 8)
|
||||
|
||||
helpItemLink("Wiki", url: Self.wikiURL, systemImage: "questionmark.circle")
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Section {
|
||||
header("I want to ask a question")
|
||||
|
||||
Text("Discussions take place in Matrix chat channel. It's a good spot for general questions.")
|
||||
.padding(.bottom, 8)
|
||||
|
||||
helpItemLink("Matrix Channel", url: Self.matrixURL, systemImage: "message")
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Section {
|
||||
header("I found a bug /")
|
||||
header("I have a feature request")
|
||||
|
||||
Text("Bugs and great feature ideas can be sent to the GitHub issues tracker. ")
|
||||
Text("If you are reporting a bug, include all relevant details (especially: app\u{00a0}version, used device and system version, steps to reproduce).")
|
||||
Text("If you are interested what's coming in future updates, you can track project Milestones.")
|
||||
.padding(.bottom, 8)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
helpItemLink("Issues Tracker", url: Self.issuesURL, systemImage: "ladybug")
|
||||
helpItemLink("Milestones", url: Self.milestonesURL, systemImage: "list.star")
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Section {
|
||||
header("I like this app!")
|
||||
|
||||
Text("That's nice to hear. It is fun to deliver apps other people want to use. " +
|
||||
"You can consider donating to the project or help by contributing to new features development.")
|
||||
.padding(.bottom, 8)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
helpItemLink("Donations", url: Self.donationsURL, systemImage: "dollarsign.circle")
|
||||
helpItemLink("Contributing", url: Self.contributingURL, systemImage: "hammer")
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.padding(.horizontal)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
.frame(maxWidth: 1000)
|
||||
#else
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
#endif
|
||||
.navigationTitle("Help")
|
||||
}
|
||||
|
||||
func header(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.fontWeight(.bold)
|
||||
.font(.title3)
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
|
||||
func helpItemLink(_ label: String, url: URL, systemImage: String) -> some View {
|
||||
Group {
|
||||
#if os(tvOS)
|
||||
VStack {
|
||||
Button {} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: systemImage)
|
||||
Text(label)
|
||||
}
|
||||
.font(.system(size: 25).bold())
|
||||
}
|
||||
|
||||
Text(url.absoluteString)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
#else
|
||||
Button {
|
||||
openURL(url)
|
||||
} label: {
|
||||
Label(label, systemImage: systemImage)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Help_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Help()
|
||||
}
|
||||
}
|
||||
196
Shared/Settings/HistorySettings.swift
Normal file
196
Shared/Settings/HistorySettings.swift
Normal file
@@ -0,0 +1,196 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct HistorySettings: View {
|
||||
static let watchedThresholds = [50, 60, 70, 80, 90, 95, 100]
|
||||
|
||||
@State private var presentingClearHistoryConfirmation = false
|
||||
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
|
||||
@Default(.saveRecents) private var saveRecents
|
||||
@Default(.saveHistory) private var saveHistory
|
||||
@Default(.showWatchingProgress) private var showWatchingProgress
|
||||
@Default(.watchedThreshold) private var watchedThreshold
|
||||
@Default(.watchedVideoStyle) private var watchedVideoStyle
|
||||
@Default(.watchedVideoBadgeColor) private var watchedVideoBadgeColor
|
||||
@Default(.watchedVideoPlayNowBehavior) private var watchedVideoPlayNowBehavior
|
||||
@Default(.resetWatchedStatusOnPlaying) private var resetWatchedStatusOnPlaying
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(macOS)
|
||||
sections
|
||||
#else
|
||||
List {
|
||||
sections
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(tvOS)
|
||||
.frame(maxWidth: 1000)
|
||||
#elseif os(iOS)
|
||||
.listStyle(.insetGrouped)
|
||||
#endif
|
||||
.navigationTitle("History")
|
||||
}
|
||||
|
||||
private var sections: some View {
|
||||
Group {
|
||||
#if os(tvOS)
|
||||
Section(header: SettingsHeader(text: "History")) {
|
||||
Toggle("Save history of searches, channels and playlists", isOn: $saveRecents)
|
||||
Toggle("Save history of played videos", isOn: $saveHistory)
|
||||
Toggle("Show progress of watching on thumbnails", isOn: $showWatchingProgress)
|
||||
.disabled(!saveHistory)
|
||||
|
||||
watchedVideoPlayNowBehaviorPicker
|
||||
|
||||
watchedThresholdPicker
|
||||
resetWatchedStatusOnPlayingToggle
|
||||
watchedVideoStylePicker
|
||||
watchedVideoBadgeColorPicker
|
||||
}
|
||||
#else
|
||||
Section(header: SettingsHeader(text: "History")) {
|
||||
Toggle("Save history of searches, channels and playlists", isOn: $saveRecents)
|
||||
Toggle("Save history of played videos", isOn: $saveHistory)
|
||||
Toggle("Show progress of watching on thumbnails", isOn: $showWatchingProgress)
|
||||
.disabled(!saveHistory)
|
||||
}
|
||||
|
||||
Section(header: SettingsHeader(text: "Watched")) {
|
||||
watchedVideoPlayNowBehaviorPicker
|
||||
#if os(macOS)
|
||||
.padding(.top, 1)
|
||||
#endif
|
||||
watchedThresholdPicker
|
||||
resetWatchedStatusOnPlayingToggle
|
||||
}
|
||||
|
||||
Section(header: SettingsHeader(text: "Interface")) {
|
||||
watchedVideoStylePicker
|
||||
#if os(macOS)
|
||||
.padding(.top, 1)
|
||||
#endif
|
||||
watchedVideoBadgeColorPicker
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
Spacer()
|
||||
#endif
|
||||
#endif
|
||||
|
||||
clearHistoryButton
|
||||
}
|
||||
}
|
||||
|
||||
private var watchedThresholdPicker: some View {
|
||||
Section(header: SettingsHeader(text: "Mark video as watched after playing", secondary: true)) {
|
||||
Picker("Mark video as watched after playing", selection: $watchedThreshold) {
|
||||
ForEach(Self.watchedThresholds, id: \.self) { threshold in
|
||||
Text("\(threshold)%").tag(threshold)
|
||||
}
|
||||
}
|
||||
.disabled(!saveHistory)
|
||||
.labelsHidden()
|
||||
|
||||
#if os(iOS)
|
||||
.pickerStyle(.automatic)
|
||||
#elseif os(tvOS)
|
||||
.pickerStyle(.inline)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private var watchedVideoStylePicker: some View {
|
||||
Section(header: SettingsHeader(text: "Mark watched videos with", secondary: true)) {
|
||||
Picker("Mark watched videos with", selection: $watchedVideoStyle) {
|
||||
Text("Nothing").tag(WatchedVideoStyle.nothing)
|
||||
Text("Badge").tag(WatchedVideoStyle.badge)
|
||||
Text("Decreased opacity").tag(WatchedVideoStyle.decreasedOpacity)
|
||||
Text("Badge & Decreased opacity").tag(WatchedVideoStyle.both)
|
||||
}
|
||||
.disabled(!saveHistory)
|
||||
.labelsHidden()
|
||||
|
||||
#if os(iOS)
|
||||
.pickerStyle(.automatic)
|
||||
#elseif os(tvOS)
|
||||
.pickerStyle(.inline)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private var watchedVideoBadgeColorPicker: some View {
|
||||
Section(header: SettingsHeader(text: "Badge color", secondary: true)) {
|
||||
Picker("Badge color", selection: $watchedVideoBadgeColor) {
|
||||
Text("Based on system color scheme").tag(WatchedVideoBadgeColor.colorSchemeBased)
|
||||
Text("Blue").tag(WatchedVideoBadgeColor.blue)
|
||||
Text("Red").tag(WatchedVideoBadgeColor.red)
|
||||
}
|
||||
.disabled(!saveHistory)
|
||||
.disabled(watchedVideoStyle == .decreasedOpacity)
|
||||
.disabled(watchedVideoStyle == .nothing)
|
||||
.labelsHidden()
|
||||
|
||||
#if os(iOS)
|
||||
.pickerStyle(.automatic)
|
||||
#elseif os(tvOS)
|
||||
.pickerStyle(.inline)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private var watchedVideoPlayNowBehaviorPicker: some View {
|
||||
Section(header: SettingsHeader(text: "When partially watched video is played", secondary: true)) {
|
||||
Picker("When partially watched video is played", selection: $watchedVideoPlayNowBehavior) {
|
||||
Text("Continue").tag(WatchedVideoPlayNowBehavior.continue)
|
||||
Text("Restart").tag(WatchedVideoPlayNowBehavior.restart)
|
||||
}
|
||||
.disabled(!saveHistory)
|
||||
.labelsHidden()
|
||||
|
||||
#if os(iOS)
|
||||
.pickerStyle(.automatic)
|
||||
#elseif os(tvOS)
|
||||
.pickerStyle(.inline)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private var resetWatchedStatusOnPlayingToggle: some View {
|
||||
Toggle("Reset watched status when playing again", isOn: $resetWatchedStatusOnPlaying)
|
||||
.disabled(!saveHistory)
|
||||
}
|
||||
|
||||
private var clearHistoryButton: some View {
|
||||
Button("Clear History") {
|
||||
presentingClearHistoryConfirmation = true
|
||||
}
|
||||
.alert(isPresented: $presentingClearHistoryConfirmation) {
|
||||
Alert(
|
||||
title: Text(
|
||||
"Are you sure you want to clear history of watched videos?"
|
||||
),
|
||||
message: Text(
|
||||
"This cannot be undone. You might need to switch between views or restart the app to see changes."
|
||||
),
|
||||
primaryButton: .destructive(Text("Clear All")) {
|
||||
player.removeAllWatches()
|
||||
presentingClearHistoryConfirmation = false
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
.foregroundColor(.red)
|
||||
.disabled(!saveHistory)
|
||||
}
|
||||
}
|
||||
|
||||
struct HistorySettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
HistorySettings()
|
||||
.injectFixtureEnvironmentObjects()
|
||||
}
|
||||
}
|
||||
@@ -8,34 +8,13 @@ struct InstanceSettings: View {
|
||||
|
||||
@State private var frontendURL = ""
|
||||
|
||||
@EnvironmentObject<AccountsModel> private var model
|
||||
@EnvironmentObject<InstancesModel> private var instances
|
||||
|
||||
var instance: Instance! {
|
||||
InstancesModel.find(instanceID)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if instance.app.hasFrontendURL {
|
||||
Section(header: Text("Frontend URL")) {
|
||||
TextField(
|
||||
"Frontend URL",
|
||||
text: $frontendURL
|
||||
)
|
||||
.onAppear {
|
||||
frontendURL = instance.frontendURL ?? ""
|
||||
}
|
||||
.onChange(of: frontendURL) { newValue in
|
||||
InstancesModel.setFrontendURL(instance, newValue)
|
||||
}
|
||||
.labelsHidden()
|
||||
.autocapitalization(.none)
|
||||
.keyboardType(.URL)
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Accounts"), footer: sectionFooter) {
|
||||
Section(header: Text("Accounts")) {
|
||||
if instance.app.supportsAccounts {
|
||||
ForEach(InstancesModel.accounts(instanceID), id: \.self) { account in
|
||||
#if os(tvOS)
|
||||
@@ -57,15 +36,21 @@ struct InstanceSettings: View {
|
||||
Spacer()
|
||||
}
|
||||
.contextMenu {
|
||||
Button("Remove") { removeAccount(account) }
|
||||
Button {
|
||||
removeAccount(account)
|
||||
} label: {
|
||||
Label("Remove", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.redrawOn(change: accountsChanged)
|
||||
|
||||
Button("Add account...") {
|
||||
Button {
|
||||
presentingAccountForm = true
|
||||
} label: {
|
||||
Label("Add Account...", systemImage: "plus")
|
||||
}
|
||||
.sheet(isPresented: $presentingAccountForm, onDismiss: { accountsChanged.toggle() }) {
|
||||
AccountForm(instance: instance)
|
||||
@@ -78,6 +63,23 @@ struct InstanceSettings: View {
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
if instance.app.hasFrontendURL {
|
||||
Section(header: Text("Frontend URL")) {
|
||||
TextField(
|
||||
"Frontend URL",
|
||||
text: $frontendURL
|
||||
)
|
||||
.onAppear {
|
||||
frontendURL = instance.frontendURL ?? ""
|
||||
}
|
||||
.onChange(of: frontendURL) { newValue in
|
||||
InstancesModel.setFrontendURL(instance, newValue)
|
||||
}
|
||||
.labelsHidden()
|
||||
.autocapitalization(.none)
|
||||
.keyboardType(.URL)
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
.frame(maxWidth: 1000)
|
||||
@@ -88,15 +90,6 @@ struct InstanceSettings: View {
|
||||
.navigationTitle(instance.description)
|
||||
}
|
||||
|
||||
private var sectionFooter: some View {
|
||||
if !instance.app.supportsAccounts {
|
||||
return Text("")
|
||||
}
|
||||
|
||||
return Text("Tap and hold to remove account")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
private func removeAccount(_ account: Account) {
|
||||
AccountsModel.remove(account)
|
||||
accountsChanged.toggle()
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct PlaybackSettings: View {
|
||||
@Default(.instances) private var instances
|
||||
@Default(.playerInstanceID) private var playerInstanceID
|
||||
@Default(.quality) private var quality
|
||||
@Default(.playerSidebar) private var playerSidebar
|
||||
@Default(.showKeywords) private var showKeywords
|
||||
@Default(.saveHistory) private var saveHistory
|
||||
|
||||
#if os(iOS)
|
||||
private var idiom: UIUserInterfaceIdiom {
|
||||
UIDevice.current.userInterfaceIdiom
|
||||
}
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
Section(header: SettingsHeader(text: "Player")) {
|
||||
sourcePicker
|
||||
qualityPicker
|
||||
|
||||
if idiom == .pad {
|
||||
sidebarPicker
|
||||
}
|
||||
|
||||
keywordsToggle
|
||||
}
|
||||
#else
|
||||
Section(header: SettingsHeader(text: "Source")) {
|
||||
sourcePicker
|
||||
}
|
||||
|
||||
Section(header: SettingsHeader(text: "Quality")) {
|
||||
qualityPicker
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
Section(header: SettingsHeader(text: "Sidebar")) {
|
||||
sidebarPicker
|
||||
}
|
||||
#endif
|
||||
|
||||
keywordsToggle
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
Spacer()
|
||||
#endif
|
||||
}
|
||||
|
||||
private var sourcePicker: some View {
|
||||
Picker("Source", selection: $playerInstanceID) {
|
||||
Text("Best available stream").tag(String?.none)
|
||||
|
||||
ForEach(instances) { instance in
|
||||
Text(instance.description).tag(Optional(instance.id))
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
#if os(iOS)
|
||||
.pickerStyle(.automatic)
|
||||
#elseif os(tvOS)
|
||||
.pickerStyle(.inline)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var qualityPicker: some View {
|
||||
Picker("Quality", selection: $quality) {
|
||||
ForEach(ResolutionSetting.allCases, id: \.self) { resolution in
|
||||
Text(resolution.description).tag(resolution)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
|
||||
#if os(iOS)
|
||||
.pickerStyle(.automatic)
|
||||
#elseif os(tvOS)
|
||||
.pickerStyle(.inline)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var sidebarPicker: some View {
|
||||
Picker("Sidebar", selection: $playerSidebar) {
|
||||
#if os(macOS)
|
||||
Text("Show").tag(PlayerSidebarSetting.always)
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
Text("Show sidebar when space permits").tag(PlayerSidebarSetting.whenFits)
|
||||
#endif
|
||||
|
||||
Text("Hide").tag(PlayerSidebarSetting.never)
|
||||
}
|
||||
.labelsHidden()
|
||||
|
||||
#if os(iOS)
|
||||
.pickerStyle(.automatic)
|
||||
#elseif os(tvOS)
|
||||
.pickerStyle(.inline)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var keywordsToggle: some View {
|
||||
Toggle("Show video keywords", isOn: $showKeywords)
|
||||
}
|
||||
}
|
||||
|
||||
struct PlaybackSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
PlaybackSettings()
|
||||
.injectFixtureEnvironmentObjects()
|
||||
}
|
||||
}
|
||||
245
Shared/Settings/PlayerSettings.swift
Normal file
245
Shared/Settings/PlayerSettings.swift
Normal file
@@ -0,0 +1,245 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct PlayerSettings: View {
|
||||
@Default(.instances) private var instances
|
||||
@Default(.playerInstanceID) private var playerInstanceID
|
||||
@Default(.quality) private var quality
|
||||
@Default(.commentsInstanceID) private var commentsInstanceID
|
||||
|
||||
#if !os(tvOS)
|
||||
@Default(.commentsPlacement) private var commentsPlacement
|
||||
#endif
|
||||
|
||||
@Default(.playerSidebar) private var playerSidebar
|
||||
@Default(.showHistoryInPlayer) private var showHistory
|
||||
@Default(.showKeywords) private var showKeywords
|
||||
@Default(.pauseOnHidingPlayer) private var pauseOnHidingPlayer
|
||||
#if os(iOS)
|
||||
@Default(.honorSystemOrientationLock) private var honorSystemOrientationLock
|
||||
@Default(.lockLandscapeOnRotation) private var lockLandscapeOnRotation
|
||||
@Default(.enterFullscreenInLandscape) private var enterFullscreenInLandscape
|
||||
@Default(.lockLandscapeWhenEnteringFullscreen) private var lockLandscapeWhenEnteringFullscreen
|
||||
#endif
|
||||
@Default(.closePiPOnNavigation) private var closePiPOnNavigation
|
||||
@Default(.closePiPOnOpeningPlayer) private var closePiPOnOpeningPlayer
|
||||
#if !os(macOS)
|
||||
@Default(.closePiPAndOpenPlayerOnEnteringForeground) private var closePiPAndOpenPlayerOnEnteringForeground
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
private var idiom: UIUserInterfaceIdiom {
|
||||
UIDevice.current.userInterfaceIdiom
|
||||
}
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(macOS)
|
||||
sections
|
||||
|
||||
Spacer()
|
||||
#else
|
||||
List {
|
||||
sections
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(tvOS)
|
||||
.frame(maxWidth: 1000)
|
||||
#elseif os(iOS)
|
||||
.listStyle(.insetGrouped)
|
||||
#endif
|
||||
.navigationTitle("Player")
|
||||
}
|
||||
|
||||
private var sections: some View {
|
||||
Group {
|
||||
Section(header: SettingsHeader(text: "Playback")) {
|
||||
sourcePicker
|
||||
qualityPicker
|
||||
pauseOnHidingPlayerToggle
|
||||
}
|
||||
|
||||
Section(header: SettingsHeader(text: "Comments")) {
|
||||
commentsInstancePicker
|
||||
#if !os(tvOS)
|
||||
commentsPlacementPicker
|
||||
.disabled(!CommentsModel.enabled)
|
||||
#endif
|
||||
}
|
||||
|
||||
Section(header: SettingsHeader(text: "Interface")) {
|
||||
#if os(iOS)
|
||||
if idiom == .pad {
|
||||
sidebarPicker
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
sidebarPicker
|
||||
#endif
|
||||
|
||||
keywordsToggle
|
||||
showHistoryToggle
|
||||
}
|
||||
|
||||
Section(header: SettingsHeader(text: "Picture in Picture")) {
|
||||
closePiPOnNavigationToggle
|
||||
closePiPOnOpeningPlayerToggle
|
||||
#if !os(macOS)
|
||||
closePiPAndOpenPlayerOnEnteringForegroundToggle
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
Section(header: SettingsHeader(text: "Orientation"), footer: orientationFooter) {
|
||||
if idiom == .pad {
|
||||
enterFullscreenInLandscapeToggle
|
||||
}
|
||||
honorSystemOrientationLockToggle
|
||||
lockLandscapeOnRotationToggle
|
||||
lockLandscapeWhenEnteringFullscreenToggle
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private var sourcePicker: some View {
|
||||
Picker("Source", selection: $playerInstanceID) {
|
||||
Text("Best available stream").tag(String?.none)
|
||||
|
||||
ForEach(instances) { instance in
|
||||
Text(instance.description).tag(Optional(instance.id))
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
#if os(iOS)
|
||||
.pickerStyle(.automatic)
|
||||
#elseif os(tvOS)
|
||||
.pickerStyle(.inline)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var qualityPicker: some View {
|
||||
Picker("Quality", selection: $quality) {
|
||||
ForEach(ResolutionSetting.allCases, id: \.self) { resolution in
|
||||
Text(resolution.description).tag(resolution)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
|
||||
#if os(iOS)
|
||||
.pickerStyle(.automatic)
|
||||
#elseif os(tvOS)
|
||||
.pickerStyle(.inline)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var commentsInstancePicker: some View {
|
||||
Picker("Source", selection: $commentsInstanceID) {
|
||||
Text("Disabled").tag(Optional(""))
|
||||
|
||||
ForEach(InstancesModel.all.filter { $0.app.supportsComments }) { instance in
|
||||
Text(instance.description).tag(Optional(instance.id))
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
#if os(iOS)
|
||||
.pickerStyle(.automatic)
|
||||
#elseif os(tvOS)
|
||||
.pickerStyle(.inline)
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
private var commentsPlacementPicker: some View {
|
||||
Picker("Placement", selection: $commentsPlacement) {
|
||||
Text("Below video description").tag(CommentsPlacement.info)
|
||||
Text("Separate tab").tag(CommentsPlacement.separate)
|
||||
}
|
||||
.labelsHidden()
|
||||
#if os(iOS)
|
||||
.pickerStyle(.automatic)
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
private var sidebarPicker: some View {
|
||||
Picker("Sidebar", selection: $playerSidebar) {
|
||||
#if os(macOS)
|
||||
Text("Show sidebar").tag(PlayerSidebarSetting.always)
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
Text("Show sidebar when space permits").tag(PlayerSidebarSetting.whenFits)
|
||||
#endif
|
||||
|
||||
Text("Hide sidebar").tag(PlayerSidebarSetting.never)
|
||||
}
|
||||
.labelsHidden()
|
||||
|
||||
#if os(iOS)
|
||||
.pickerStyle(.automatic)
|
||||
#elseif os(tvOS)
|
||||
.pickerStyle(.inline)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var keywordsToggle: some View {
|
||||
Toggle("Show keywords", isOn: $showKeywords)
|
||||
}
|
||||
|
||||
private var showHistoryToggle: some View {
|
||||
Toggle("Show history", isOn: $showHistory)
|
||||
}
|
||||
|
||||
private var pauseOnHidingPlayerToggle: some View {
|
||||
Toggle("Pause when player is closed", isOn: $pauseOnHidingPlayer)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private var honorSystemOrientationLockToggle: some View {
|
||||
Toggle("Honor orientation lock", isOn: $honorSystemOrientationLock)
|
||||
.disabled(!enterFullscreenInLandscape)
|
||||
}
|
||||
|
||||
private var enterFullscreenInLandscapeToggle: some View {
|
||||
Toggle("Enter fullscreen in landscape", isOn: $enterFullscreenInLandscape)
|
||||
}
|
||||
|
||||
private var lockLandscapeOnRotationToggle: some View {
|
||||
Toggle("Lock landscape on rotation", isOn: $lockLandscapeOnRotation)
|
||||
.disabled(!enterFullscreenInLandscape)
|
||||
}
|
||||
|
||||
private var lockLandscapeWhenEnteringFullscreenToggle: some View {
|
||||
Toggle("Rotate and lock landscape on entering fullscreen", isOn: $lockLandscapeWhenEnteringFullscreen)
|
||||
}
|
||||
|
||||
private var orientationFooter: some View {
|
||||
Text("Orientation settings are experimental and do not yet work properly with all devices and iOS versions")
|
||||
}
|
||||
#endif
|
||||
|
||||
private var closePiPOnNavigationToggle: some View {
|
||||
Toggle("Close PiP when starting playing other video", isOn: $closePiPOnNavigation)
|
||||
}
|
||||
|
||||
private var closePiPOnOpeningPlayerToggle: some View {
|
||||
Toggle("Close PiP when player is opened", isOn: $closePiPOnOpeningPlayer)
|
||||
}
|
||||
|
||||
#if !os(macOS)
|
||||
private var closePiPAndOpenPlayerOnEnteringForegroundToggle: some View {
|
||||
Toggle("Close PiP and open player when application enters foreground", isOn: $closePiPAndOpenPlayerOnEnteringForeground)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
struct PlaybackSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
PlayerSettings()
|
||||
.injectFixtureEnvironmentObjects()
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct ServicesSettings: View {
|
||||
@Default(.sponsorBlockInstance) private var sponsorBlockInstance
|
||||
@Default(.sponsorBlockCategories) private var sponsorBlockCategories
|
||||
@Default(.commentsInstanceID) private var commentsInstanceID
|
||||
|
||||
var body: some View {
|
||||
Section(header: SettingsHeader(text: "Comments")) {
|
||||
commentsInstancePicker
|
||||
}
|
||||
|
||||
Section(header: SettingsHeader(text: "SponsorBlock API")) {
|
||||
TextField(
|
||||
"SponsorBlock API Instance",
|
||||
text: $sponsorBlockInstance
|
||||
)
|
||||
.labelsHidden()
|
||||
#if !os(macOS)
|
||||
.autocapitalization(.none)
|
||||
.keyboardType(.URL)
|
||||
#endif
|
||||
}
|
||||
|
||||
Section(header: SettingsHeader(text: "Categories to Skip")) {
|
||||
#if os(macOS)
|
||||
let list = ForEach(SponsorBlockAPI.categories, id: \.self) { category in
|
||||
SponsorBlockCategorySelectionRow(
|
||||
title: SponsorBlockAPI.categoryDescription(category) ?? "Unknown",
|
||||
selected: sponsorBlockCategories.contains(category)
|
||||
) { value in
|
||||
toggleCategory(category, value: value)
|
||||
}
|
||||
}
|
||||
|
||||
Group {
|
||||
if #available(macOS 12.0, *) {
|
||||
list
|
||||
.listStyle(.inset(alternatesRowBackgrounds: true))
|
||||
} else {
|
||||
list
|
||||
.listStyle(.inset)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
#else
|
||||
ForEach(SponsorBlockAPI.categories, id: \.self) { category in
|
||||
SponsorBlockCategorySelectionRow(
|
||||
title: SponsorBlockAPI.categoryDescription(category) ?? "Unknown",
|
||||
selected: sponsorBlockCategories.contains(category)
|
||||
) { value in
|
||||
toggleCategory(category, value: value)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private var commentsInstancePicker: some View {
|
||||
Picker("Comments", selection: $commentsInstanceID) {
|
||||
Text("Disabled").tag(Optional(""))
|
||||
|
||||
ForEach(InstancesModel.all.filter { $0.app.supportsComments }) { instance in
|
||||
Text(instance.description).tag(Optional(instance.id))
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
#if os(iOS)
|
||||
.pickerStyle(.automatic)
|
||||
#elseif os(tvOS)
|
||||
.pickerStyle(.inline)
|
||||
#endif
|
||||
}
|
||||
|
||||
func toggleCategory(_ category: String, value: Bool) {
|
||||
if let index = sponsorBlockCategories.firstIndex(where: { $0 == category }), !value {
|
||||
sponsorBlockCategories.remove(at: index)
|
||||
} else if value {
|
||||
sponsorBlockCategories.insert(category)
|
||||
}
|
||||
}
|
||||
|
||||
struct SponsorBlockCategorySelectionRow: View {
|
||||
let title: String
|
||||
let selected: Bool
|
||||
var action: (Bool) -> Void
|
||||
|
||||
@State private var toggleChecked = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: { action(!selected) }) {
|
||||
HStack {
|
||||
#if os(macOS)
|
||||
Toggle(isOn: $toggleChecked) {
|
||||
Text(self.title)
|
||||
Spacer()
|
||||
}
|
||||
.onAppear {
|
||||
toggleChecked = selected
|
||||
}
|
||||
.onChange(of: toggleChecked) { new in
|
||||
action(new)
|
||||
}
|
||||
#else
|
||||
Text(self.title)
|
||||
Spacer()
|
||||
if selected {
|
||||
Image(systemName: "checkmark")
|
||||
#if os(iOS)
|
||||
.foregroundColor(.accentColor)
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ServicesSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
VStack {
|
||||
ServicesSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,28 @@ import SwiftUI
|
||||
|
||||
struct SettingsHeader: View {
|
||||
var text: String
|
||||
var secondary = false
|
||||
|
||||
var body: some View {
|
||||
Text(text)
|
||||
#if os(macOS) || os(tvOS)
|
||||
.font(.title3)
|
||||
.foregroundColor(.secondary)
|
||||
.focusable(false)
|
||||
Group {
|
||||
#if os(iOS)
|
||||
if secondary {
|
||||
EmptyView()
|
||||
} else {
|
||||
Text(text)
|
||||
}
|
||||
#else
|
||||
Text(text)
|
||||
#endif
|
||||
}
|
||||
#if os(tvOS)
|
||||
.font(secondary ? .footnote : .title3)
|
||||
.foregroundColor(.secondary)
|
||||
.focusable(false)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
.font(secondary ? .system(size: 13) : .system(size: 15))
|
||||
.foregroundColor(secondary ? Color.primary : .secondary)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ import SwiftUI
|
||||
struct SettingsView: View {
|
||||
#if os(macOS)
|
||||
private enum Tabs: Hashable {
|
||||
case instances, browsing, playback, services
|
||||
case instances, browsing, player, history, sponsorBlock, updates, help
|
||||
}
|
||||
|
||||
@State private var selection = Tabs.instances
|
||||
#endif
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@@ -24,7 +26,7 @@ struct SettingsView: View {
|
||||
|
||||
var body: some View {
|
||||
#if os(macOS)
|
||||
TabView {
|
||||
TabView(selection: $selection) {
|
||||
Form {
|
||||
InstancesSettings()
|
||||
.environmentObject(accounts)
|
||||
@@ -43,34 +45,52 @@ struct SettingsView: View {
|
||||
.tag(Tabs.browsing)
|
||||
|
||||
Form {
|
||||
PlaybackSettings()
|
||||
PlayerSettings()
|
||||
}
|
||||
.tabItem {
|
||||
Label("Playback", systemImage: "play.rectangle")
|
||||
Label("Player", systemImage: "play.rectangle")
|
||||
}
|
||||
.tag(Tabs.playback)
|
||||
.tag(Tabs.player)
|
||||
|
||||
Form {
|
||||
ServicesSettings()
|
||||
HistorySettings()
|
||||
}
|
||||
.tabItem {
|
||||
Label("Services", systemImage: "puzzlepiece")
|
||||
Label("History", systemImage: "clock.arrow.circlepath")
|
||||
}
|
||||
.tag(Tabs.services)
|
||||
.tag(Tabs.history)
|
||||
|
||||
Form {
|
||||
SponsorBlockSettings()
|
||||
}
|
||||
.tabItem {
|
||||
Label("SponsorBlock", systemImage: "dollarsign.circle")
|
||||
}
|
||||
.tag(Tabs.sponsorBlock)
|
||||
|
||||
Form {
|
||||
UpdatesSettings()
|
||||
}
|
||||
.tabItem {
|
||||
Label("Updates", systemImage: "gearshape.2")
|
||||
}
|
||||
.tag(Tabs.updates)
|
||||
|
||||
Form {
|
||||
Help()
|
||||
}
|
||||
.tabItem {
|
||||
Label("Help", systemImage: "questionmark.circle")
|
||||
}
|
||||
.tag(Tabs.help)
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 400, height: 380)
|
||||
.frame(width: 480, height: windowHeight)
|
||||
#else
|
||||
NavigationView {
|
||||
List {
|
||||
#if os(tvOS)
|
||||
AccountSelectionView()
|
||||
|
||||
Section(header: SettingsHeader(text: "Favorites")) {
|
||||
NavigationLink("Edit favorites...") {
|
||||
EditFavorites()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
Section(header: Text("Instances")) {
|
||||
@@ -80,13 +100,55 @@ struct SettingsView: View {
|
||||
addInstanceButton
|
||||
}
|
||||
|
||||
BrowsingSettings()
|
||||
PlaybackSettings()
|
||||
ServicesSettings()
|
||||
#if os(tvOS)
|
||||
Divider()
|
||||
#endif
|
||||
|
||||
Section {
|
||||
#if os(tvOS)
|
||||
NavigationLink {
|
||||
EditFavorites()
|
||||
} label: {
|
||||
Label("Favorites", systemImage: "heart.fill")
|
||||
}
|
||||
#endif
|
||||
|
||||
NavigationLink {
|
||||
BrowsingSettings()
|
||||
} label: {
|
||||
Label("Browsing", systemImage: "list.and.film")
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
PlayerSettings()
|
||||
} label: {
|
||||
Label("Player", systemImage: "play.rectangle")
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
HistorySettings()
|
||||
} label: {
|
||||
Label("History", systemImage: "clock.arrow.circlepath")
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
SponsorBlockSettings()
|
||||
} label: {
|
||||
Label("SponsorBlock", systemImage: "dollarsign.circle")
|
||||
}
|
||||
}
|
||||
|
||||
Section(footer: versionString) {
|
||||
NavigationLink {
|
||||
Help()
|
||||
} label: {
|
||||
Label("Help", systemImage: "questionmark.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
#if !os(tvOS)
|
||||
Button("Done") {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
@@ -109,9 +171,39 @@ struct SettingsView: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private var windowHeight: Double {
|
||||
switch selection {
|
||||
case .instances:
|
||||
return 390
|
||||
case .browsing:
|
||||
return 350
|
||||
case .player:
|
||||
return 450
|
||||
case .history:
|
||||
return 480
|
||||
case .sponsorBlock:
|
||||
return 660
|
||||
case .updates:
|
||||
return 200
|
||||
case .help:
|
||||
return 570
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private var versionString: some View {
|
||||
Text("Yattee \(YatteeApp.version) (build \(YatteeApp.build))")
|
||||
#if os(tvOS)
|
||||
.foregroundColor(.secondary)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var addInstanceButton: some View {
|
||||
Button("Add Instance...") {
|
||||
Button {
|
||||
presentingInstanceForm = true
|
||||
} label: {
|
||||
Label("Add Instance...", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
150
Shared/Settings/SponsorBlockSettings.swift
Normal file
150
Shared/Settings/SponsorBlockSettings.swift
Normal file
@@ -0,0 +1,150 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct SponsorBlockSettings: View {
|
||||
@Default(.sponsorBlockInstance) private var sponsorBlockInstance
|
||||
@Default(.sponsorBlockCategories) private var sponsorBlockCategories
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(macOS)
|
||||
sections
|
||||
|
||||
Spacer()
|
||||
#else
|
||||
List {
|
||||
sections
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(tvOS)
|
||||
.frame(maxWidth: 1000)
|
||||
#elseif os(iOS)
|
||||
.listStyle(.insetGrouped)
|
||||
#endif
|
||||
.navigationTitle("SponsorBlock")
|
||||
}
|
||||
|
||||
private var sections: some View {
|
||||
Group {
|
||||
Section(header: SettingsHeader(text: "SponsorBlock API")) {
|
||||
TextField(
|
||||
"SponsorBlock API Instance",
|
||||
text: $sponsorBlockInstance
|
||||
)
|
||||
.labelsHidden()
|
||||
#if !os(macOS)
|
||||
.autocapitalization(.none)
|
||||
.keyboardType(.URL)
|
||||
#endif
|
||||
}
|
||||
|
||||
Section(header: SettingsHeader(text: "Categories to Skip"), footer: categoriesDetails) {
|
||||
#if os(macOS)
|
||||
let list = ForEach(SponsorBlockAPI.categories, id: \.self) { category in
|
||||
SponsorBlockCategorySelectionRow(
|
||||
title: SponsorBlockAPI.categoryDescription(category) ?? "Unknown",
|
||||
selected: sponsorBlockCategories.contains(category)
|
||||
) { value in
|
||||
toggleCategory(category, value: value)
|
||||
}
|
||||
}
|
||||
|
||||
Group {
|
||||
if #available(macOS 12.0, *) {
|
||||
list
|
||||
.listStyle(.inset(alternatesRowBackgrounds: true))
|
||||
} else {
|
||||
list
|
||||
.listStyle(.inset)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
#else
|
||||
ForEach(SponsorBlockAPI.categories, id: \.self) { category in
|
||||
SponsorBlockCategorySelectionRow(
|
||||
title: SponsorBlockAPI.categoryDescription(category) ?? "Unknown",
|
||||
selected: sponsorBlockCategories.contains(category)
|
||||
) { value in
|
||||
toggleCategory(category, value: value)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var categoriesDetails: some View {
|
||||
VStack(alignment: .leading) {
|
||||
ForEach(SponsorBlockAPI.categories, id: \.self) { category in
|
||||
Text(SponsorBlockAPI.categoryDescription(category) ?? "Category")
|
||||
.fontWeight(.bold)
|
||||
#if os(tvOS)
|
||||
.focusable()
|
||||
#endif
|
||||
|
||||
Text(SponsorBlockAPI.categoryDetails(category) ?? "Details")
|
||||
.padding(.bottom, 3)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.top, 3)
|
||||
}
|
||||
|
||||
func toggleCategory(_ category: String, value: Bool) {
|
||||
if let index = sponsorBlockCategories.firstIndex(where: { $0 == category }), !value {
|
||||
sponsorBlockCategories.remove(at: index)
|
||||
} else if value {
|
||||
sponsorBlockCategories.insert(category)
|
||||
}
|
||||
}
|
||||
|
||||
struct SponsorBlockCategorySelectionRow: View {
|
||||
let title: String
|
||||
let selected: Bool
|
||||
var action: (Bool) -> Void
|
||||
|
||||
@State private var toggleChecked = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: { action(!selected) }) {
|
||||
HStack {
|
||||
#if os(macOS)
|
||||
Toggle(isOn: $toggleChecked) {
|
||||
Text(self.title)
|
||||
Spacer()
|
||||
}
|
||||
.onAppear {
|
||||
toggleChecked = selected
|
||||
}
|
||||
.onChange(of: toggleChecked) { new in
|
||||
action(new)
|
||||
}
|
||||
#else
|
||||
Text(self.title)
|
||||
Spacer()
|
||||
if selected {
|
||||
Image(systemName: "checkmark")
|
||||
#if os(iOS)
|
||||
.foregroundColor(.accentColor)
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SponsorBlockSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
VStack {
|
||||
SponsorBlockSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ struct TrendingCountry: View {
|
||||
@State private var query: String = ""
|
||||
@State private var selection: Country?
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
|
||||
var body: some View {
|
||||
@@ -30,8 +31,8 @@ struct TrendingCountry: View {
|
||||
countriesList
|
||||
}
|
||||
#if os(tvOS)
|
||||
.searchable(text: $query, placement: .automatic, prompt: Text(TrendingCountry.prompt))
|
||||
.background(Color.black)
|
||||
.searchable(text: $query, placement: .automatic, prompt: Text(Self.prompt))
|
||||
.background(Color.background(scheme: colorScheme))
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,69 @@ struct TrendingView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
#if os(macOS)
|
||||
ToolbarItemGroup {
|
||||
if let favoriteItem = favoriteItem {
|
||||
FavoriteButton(item: favoriteItem)
|
||||
.id(favoriteItem.id)
|
||||
}
|
||||
|
||||
if accounts.app.supportsTrendingCategories {
|
||||
categoryButton
|
||||
}
|
||||
countryButton
|
||||
}
|
||||
#elseif os(iOS)
|
||||
ToolbarItemGroup(placement: .bottomBar) {
|
||||
Group {
|
||||
if accounts.app.supportsTrendingCategories {
|
||||
HStack {
|
||||
Text("Category")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
categoryButton
|
||||
// only way to disable Menu animation is to
|
||||
// force redraw of the view when it changes
|
||||
.id(UUID())
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if let favoriteItem = favoriteItem {
|
||||
FavoriteButton(item: favoriteItem)
|
||||
.id(favoriteItem.id)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Country")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
countryButton
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.onChange(of: resource) { _ in
|
||||
resource.load()
|
||||
updateFavoriteItem()
|
||||
}
|
||||
.onAppear {
|
||||
if videos.isEmpty {
|
||||
resource.addObserver(store)
|
||||
resource.loadIfNeeded()
|
||||
} else {
|
||||
store.replace(videos)
|
||||
}
|
||||
|
||||
updateFavoriteItem()
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
.fullScreenCover(isPresented: $presentingCountrySelection) {
|
||||
TrendingCountry(selectedCountry: $country)
|
||||
@@ -59,69 +122,23 @@ struct TrendingView: View {
|
||||
.frame(minWidth: 400, minHeight: 400)
|
||||
#endif
|
||||
}
|
||||
.background(
|
||||
Button("Refresh") {
|
||||
resource.load()
|
||||
}
|
||||
.keyboardShortcut("r")
|
||||
.opacity(0)
|
||||
)
|
||||
.navigationTitle("Trending")
|
||||
#endif
|
||||
.toolbar {
|
||||
#if os(macOS)
|
||||
ToolbarItemGroup {
|
||||
if let favoriteItem = favoriteItem {
|
||||
FavoriteButton(item: favoriteItem)
|
||||
.id(favoriteItem.id)
|
||||
}
|
||||
|
||||
if accounts.app.supportsTrendingCategories {
|
||||
categoryButton
|
||||
}
|
||||
countryButton
|
||||
}
|
||||
#elseif os(iOS)
|
||||
ToolbarItemGroup(placement: .bottomBar) {
|
||||
Group {
|
||||
HStack {
|
||||
if accounts.app.supportsTrendingCategories {
|
||||
Text("Category")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
categoryButton
|
||||
// only way to disable Menu animation is to
|
||||
// force redraw of the view when it changes
|
||||
.id(UUID())
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if let favoriteItem = favoriteItem {
|
||||
FavoriteButton(item: favoriteItem)
|
||||
.id(favoriteItem.id)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Country")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
countryButton
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.onChange(of: resource) { _ in
|
||||
resource.load()
|
||||
updateFavoriteItem()
|
||||
}
|
||||
.onAppear {
|
||||
if videos.isEmpty {
|
||||
resource.addObserver(store)
|
||||
resource.loadIfNeeded()
|
||||
} else {
|
||||
store.replace(videos)
|
||||
}
|
||||
|
||||
updateFavoriteItem()
|
||||
}
|
||||
#if os(iOS)
|
||||
.refreshControl { refreshControl in
|
||||
resource.load().onCompletion { _ in
|
||||
refreshControl.endRefreshing()
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(RefreshControl.navigationBarTitleDisplayMode)
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
|
||||
@@ -4,14 +4,17 @@ import SwiftUI
|
||||
struct HorizontalCells: View {
|
||||
var items = [ContentItem]()
|
||||
|
||||
@Environment(\.loadMoreContentHandler) private var loadMoreContentHandler
|
||||
|
||||
@Default(.channelOnThumbnail) private var channelOnThumbnail
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
LazyHStack(spacing: 20) {
|
||||
ForEach(items) { item in
|
||||
ForEach(contentItems) { item in
|
||||
ContentItemView(item: item)
|
||||
.environment(\.horizontalCells, true)
|
||||
.onAppear { loadMoreContentItemsIfNeeded(current: item) }
|
||||
#if os(tvOS)
|
||||
.frame(width: 580)
|
||||
.padding(.trailing, 20)
|
||||
@@ -33,6 +36,21 @@ struct HorizontalCells: View {
|
||||
.edgesIgnoringSafeArea(.horizontal)
|
||||
}
|
||||
|
||||
var contentItems: [ContentItem] {
|
||||
items.isEmpty ? placeholders : items
|
||||
}
|
||||
|
||||
var placeholders: [ContentItem] {
|
||||
(0 ..< 9).map { _ in .init() }
|
||||
}
|
||||
|
||||
func loadMoreContentItemsIfNeeded(current item: ContentItem) {
|
||||
let thresholdIndex = items.index(items.endIndex, offsetBy: -5)
|
||||
if items.firstIndex(where: { $0.id == item.id }) == thresholdIndex {
|
||||
loadMoreContentHandler()
|
||||
}
|
||||
}
|
||||
|
||||
var cellHeight: Double {
|
||||
#if os(tvOS)
|
||||
560
|
||||
|
||||
@@ -6,27 +6,46 @@ struct VerticalCells: View {
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
#endif
|
||||
|
||||
@Environment(\.loadMoreContentHandler) private var loadMoreContentHandler
|
||||
|
||||
var items = [ContentItem]()
|
||||
var allowEmpty = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.vertical, showsIndicators: scrollViewShowsIndicators) {
|
||||
LazyVGrid(columns: columns, alignment: .center) {
|
||||
ForEach(items.sorted { $0 < $1 }) { item in
|
||||
ForEach(contentItems) { item in
|
||||
ContentItemView(item: item)
|
||||
.onAppear { loadMoreContentItemsIfNeeded(current: item) }
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.edgesIgnoringSafeArea(.horizontal)
|
||||
#if os(macOS)
|
||||
.background(Color.tertiaryBackground)
|
||||
.background(Color.secondaryBackground)
|
||||
.frame(minWidth: 360)
|
||||
#endif
|
||||
}
|
||||
|
||||
var contentItems: [ContentItem] {
|
||||
items.isEmpty ? (allowEmpty ? items : placeholders) : items.sorted { $0 < $1 }
|
||||
}
|
||||
|
||||
var placeholders: [ContentItem] {
|
||||
(0 ..< 9).map { _ in .init() }
|
||||
}
|
||||
|
||||
func loadMoreContentItemsIfNeeded(current item: ContentItem) {
|
||||
let thresholdIndex = items.index(items.endIndex, offsetBy: -5)
|
||||
if items.firstIndex(where: { $0.id == item.id }) == thresholdIndex {
|
||||
loadMoreContentHandler()
|
||||
}
|
||||
}
|
||||
|
||||
var columns: [GridItem] {
|
||||
#if os(tvOS)
|
||||
items.count < 3 ? Array(repeating: GridItem(.fixed(500)), count: [items.count, 1].max()!) : adaptiveItem
|
||||
contentItems.count < 3 ? Array(repeating: GridItem(.fixed(500)), count: [contentItems.count, 1].max()!) : adaptiveItem
|
||||
#else
|
||||
adaptiveItem
|
||||
#endif
|
||||
|
||||
@@ -96,7 +96,7 @@ struct VideoBanner: View {
|
||||
|
||||
private var progressView: some View {
|
||||
Group {
|
||||
if !playbackTime.isNil {
|
||||
if !playbackTime.isNil, !(video?.live ?? false) {
|
||||
ProgressView(value: progressViewValue, total: progressViewTotal)
|
||||
.progressViewStyle(.linear)
|
||||
.frame(maxWidth: thumbnailWidth)
|
||||
|
||||
@@ -3,9 +3,10 @@ import SDWebImageSwiftUI
|
||||
import SwiftUI
|
||||
|
||||
struct VideoCell: View {
|
||||
var video: Video
|
||||
private var video: Video
|
||||
|
||||
@Environment(\.inNavigationView) private var inNavigationView
|
||||
@Environment(\.navigationStyle) private var navigationStyle
|
||||
|
||||
#if os(iOS)
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
@@ -13,32 +14,36 @@ struct VideoCell: View {
|
||||
#endif
|
||||
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
@EnvironmentObject<ThumbnailsModel> private var thumbnails
|
||||
|
||||
@Default(.channelOnThumbnail) private var channelOnThumbnail
|
||||
@Default(.timeOnThumbnail) private var timeOnThumbnail
|
||||
@Default(.roundedThumbnails) private var roundedThumbnails
|
||||
@Default(.saveHistory) private var saveHistory
|
||||
@Default(.showWatchingProgress) private var showWatchingProgress
|
||||
@Default(.watchedVideoStyle) private var watchedVideoStyle
|
||||
@Default(.watchedVideoBadgeColor) private var watchedVideoBadgeColor
|
||||
@Default(.watchedVideoPlayNowBehavior) private var watchedVideoPlayNowBehavior
|
||||
|
||||
@FetchRequest private var watchRequest: FetchedResults<Watch>
|
||||
|
||||
init(video: Video) {
|
||||
self.video = video
|
||||
_watchRequest = video.watchFetchRequest
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
Button(action: {
|
||||
player.playNow(video)
|
||||
|
||||
guard !player.playingInPictureInPicture else {
|
||||
return
|
||||
}
|
||||
|
||||
if inNavigationView {
|
||||
player.playerNavigationLinkActive = true
|
||||
} else {
|
||||
player.presentPlayer()
|
||||
}
|
||||
}) {
|
||||
Button(action: playAction) {
|
||||
content
|
||||
}
|
||||
}
|
||||
.opacity(contentOpacity)
|
||||
.buttonStyle(.plain)
|
||||
.contentShape(RoundedRectangle(cornerRadius: 12))
|
||||
.contentShape(RoundedRectangle(cornerRadius: thumbnailRoundingCornerRadius))
|
||||
.contextMenu {
|
||||
VideoContextMenuView(
|
||||
video: video,
|
||||
@@ -48,7 +53,62 @@ struct VideoCell: View {
|
||||
}
|
||||
}
|
||||
|
||||
var content: some View {
|
||||
private var thumbnailRoundingCornerRadius: Double {
|
||||
#if os(tvOS)
|
||||
return Double(12)
|
||||
#else
|
||||
return Double(roundedThumbnails ? 12 : 0)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func playAction() {
|
||||
guard video.videoID != Video.fixtureID else {
|
||||
return
|
||||
}
|
||||
|
||||
if watchingNow {
|
||||
if !player.playingInPictureInPicture {
|
||||
player.show()
|
||||
}
|
||||
|
||||
if !playNowContinues {
|
||||
player.player.seek(to: .zero)
|
||||
}
|
||||
|
||||
player.play()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var playAt: TimeInterval?
|
||||
|
||||
if playNowContinues,
|
||||
!watch.isNil,
|
||||
!watch!.finished
|
||||
{
|
||||
playAt = watch!.stoppedAt
|
||||
}
|
||||
|
||||
player.play(video, at: playAt, inNavigationView: inNavigationView)
|
||||
}
|
||||
|
||||
private var playNowContinues: Bool {
|
||||
watchedVideoPlayNowBehavior == .continue
|
||||
}
|
||||
|
||||
private var watch: Watch? {
|
||||
watchRequest.first
|
||||
}
|
||||
|
||||
private var finished: Bool {
|
||||
watch?.finished ?? false
|
||||
}
|
||||
|
||||
private var watchingNow: Bool {
|
||||
player.currentVideo == video
|
||||
}
|
||||
|
||||
private var content: some View {
|
||||
VStack {
|
||||
#if os(iOS)
|
||||
if verticalSizeClass == .compact, !horizontalCells {
|
||||
@@ -62,12 +122,23 @@ struct VideoCell: View {
|
||||
#endif
|
||||
}
|
||||
#if os(macOS)
|
||||
.background(Color.tertiaryBackground)
|
||||
.background(Color.secondaryBackground)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var contentOpacity: Double {
|
||||
guard saveHistory,
|
||||
!watch.isNil,
|
||||
watchedVideoStyle == .decreasedOpacity || watchedVideoStyle == .both
|
||||
else {
|
||||
return 1
|
||||
}
|
||||
|
||||
return watch!.finished ? 0.5 : 1
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
var horizontalRow: some View {
|
||||
private var horizontalRow: some View {
|
||||
HStack(alignment: .top, spacing: 2) {
|
||||
Section {
|
||||
#if os(tvOS)
|
||||
@@ -83,9 +154,7 @@ struct VideoCell: View {
|
||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
if !channelOnThumbnail {
|
||||
Text(video.channel.name)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.secondary)
|
||||
channelButton(badge: false)
|
||||
}
|
||||
|
||||
if additionalDetailsAvailable {
|
||||
@@ -151,7 +220,7 @@ struct VideoCell: View {
|
||||
}
|
||||
#endif
|
||||
|
||||
var verticalRow: some View {
|
||||
private var verticalRow: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
thumbnail
|
||||
|
||||
@@ -167,9 +236,7 @@ struct VideoCell: View {
|
||||
.frame(minHeight: 40, alignment: .top)
|
||||
#endif
|
||||
if !channelOnThumbnail {
|
||||
Text(video.channel.name)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.secondary)
|
||||
channelButton(badge: false)
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
@@ -201,7 +268,7 @@ struct VideoCell: View {
|
||||
}
|
||||
}
|
||||
|
||||
if let time = video.length.formattedAsPlaybackTime(), !timeOnThumbnail {
|
||||
if let time = time, !timeOnThumbnail {
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 2) {
|
||||
@@ -225,13 +292,53 @@ struct VideoCell: View {
|
||||
}
|
||||
}
|
||||
|
||||
var additionalDetailsAvailable: Bool {
|
||||
video.publishedDate != nil || video.views != 0 || (!timeOnThumbnail && !video.length.formattedAsPlaybackTime().isNil)
|
||||
private func channelButton(badge: Bool = true) -> some View {
|
||||
Button {
|
||||
NavigationModel.openChannel(
|
||||
video.channel,
|
||||
player: player,
|
||||
recents: recents,
|
||||
navigation: navigation,
|
||||
navigationStyle: navigationStyle
|
||||
)
|
||||
} label: {
|
||||
if badge {
|
||||
DetailBadge(text: video.author, style: .prominent)
|
||||
.foregroundColor(.primary)
|
||||
} else {
|
||||
Text(video.channel.name)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("\(video.channel.name) Channel")
|
||||
}
|
||||
|
||||
var thumbnail: some View {
|
||||
private var additionalDetailsAvailable: Bool {
|
||||
video.publishedDate != nil || video.views != 0 ||
|
||||
(!timeOnThumbnail && !video.length.formattedAsPlaybackTime().isNil)
|
||||
}
|
||||
|
||||
private var thumbnail: some View {
|
||||
ZStack(alignment: .leading) {
|
||||
thumbnailImage
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
thumbnailImage
|
||||
if saveHistory, showWatchingProgress, watch?.progress ?? 0 > 0 {
|
||||
ProgressView(value: watch!.progress, total: 100)
|
||||
.progressViewStyle(LinearProgressViewStyle(tint: Color("AppRedColor")))
|
||||
#if os(tvOS)
|
||||
.padding(.horizontal, 16)
|
||||
#else
|
||||
.padding(.horizontal, 10)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
.offset(x: 0, y: 4)
|
||||
#else
|
||||
.offset(x: 0, y: -3)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
VStack {
|
||||
HStack(alignment: .top) {
|
||||
@@ -244,27 +351,55 @@ struct VideoCell: View {
|
||||
Spacer()
|
||||
|
||||
if channelOnThumbnail {
|
||||
DetailBadge(text: video.author, style: .prominent)
|
||||
channelButton()
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
.padding(16)
|
||||
#else
|
||||
.padding(10)
|
||||
#endif
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(alignment: .top) {
|
||||
HStack(alignment: .center) {
|
||||
if saveHistory,
|
||||
watchedVideoStyle == .badge || watchedVideoStyle == .both,
|
||||
watch?.finished ?? false
|
||||
{
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(Color(
|
||||
watchedVideoBadgeColor == .colorSchemeBased ? "WatchProgressBarColor" :
|
||||
watchedVideoBadgeColor == .red ? "AppRedColor" : "AppBlueColor"
|
||||
))
|
||||
.background(Color.white)
|
||||
.clipShape(Circle())
|
||||
#if os(tvOS)
|
||||
.font(.system(size: 40))
|
||||
#else
|
||||
.font(.system(size: 30))
|
||||
#endif
|
||||
}
|
||||
Spacer()
|
||||
|
||||
if timeOnThumbnail, let time = video.length.formattedAsPlaybackTime() {
|
||||
if timeOnThumbnail,
|
||||
!video.live,
|
||||
let time = time
|
||||
{
|
||||
DetailBadge(text: time, style: .prominent)
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
.padding(16)
|
||||
#else
|
||||
.padding(10)
|
||||
#endif
|
||||
}
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
var thumbnailImage: some View {
|
||||
private var thumbnailImage: some View {
|
||||
Group {
|
||||
if let url = thumbnails.best(video) {
|
||||
WebImage(url: url)
|
||||
@@ -272,7 +407,7 @@ struct VideoCell: View {
|
||||
.placeholder {
|
||||
Rectangle().fill(Color("PlaceholderColor"))
|
||||
}
|
||||
.retryOnAppear(false)
|
||||
.retryOnAppear(true)
|
||||
.onFailure { _ in
|
||||
thumbnails.insertUnloadable(url)
|
||||
}
|
||||
@@ -289,11 +424,33 @@ struct VideoCell: View {
|
||||
.font(.system(size: 30))
|
||||
}
|
||||
}
|
||||
.mask(RoundedRectangle(cornerRadius: 12))
|
||||
.mask(RoundedRectangle(cornerRadius: thumbnailRoundingCornerRadius))
|
||||
.modifier(AspectRatioModifier())
|
||||
}
|
||||
|
||||
func videoDetail(_ text: String, lineLimit: Int = 1) -> some View {
|
||||
private var time: String? {
|
||||
guard var videoTime = video.length.formattedAsPlaybackTime() else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !saveHistory || !showWatchingProgress || watch?.finished ?? false {
|
||||
return videoTime
|
||||
}
|
||||
|
||||
if let stoppedAt = watch?.stoppedAt,
|
||||
stoppedAt.isFinite,
|
||||
let stoppedAtFormatted = stoppedAt.formattedAsPlaybackTime()
|
||||
{
|
||||
if (watch?.videoDuration ?? 0) > 0 {
|
||||
videoTime = watch!.videoDuration.formattedAsPlaybackTime() ?? "?"
|
||||
}
|
||||
return "\(stoppedAtFormatted) / \(videoTime)"
|
||||
}
|
||||
|
||||
return videoTime
|
||||
}
|
||||
|
||||
private func videoDetail(_ text: String, lineLimit: Int = 1) -> some View {
|
||||
Text(text)
|
||||
.fontWeight(.bold)
|
||||
.lineLimit(lineLimit)
|
||||
@@ -309,7 +466,10 @@ struct VideoCell: View {
|
||||
content
|
||||
} else {
|
||||
content
|
||||
.aspectRatio(1.777, contentMode: .fill)
|
||||
.aspectRatio(
|
||||
VideoPlayerView.defaultAspectRatio,
|
||||
contentMode: .fill
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,18 +8,18 @@ struct ChannelCell: View {
|
||||
@Environment(\.navigationStyle) private var navigationStyle
|
||||
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
let recent = RecentItem(from: channel)
|
||||
recents.add(recent)
|
||||
navigation.presentingChannel = true
|
||||
|
||||
if navigationStyle == .sidebar {
|
||||
navigation.sidebarSectionChanged.toggle()
|
||||
navigation.tabSelection = .recentlyOpened(recent.tag)
|
||||
}
|
||||
NavigationModel.openChannel(
|
||||
channel,
|
||||
player: player,
|
||||
recents: recents,
|
||||
navigation: navigation,
|
||||
navigationStyle: navigationStyle
|
||||
)
|
||||
} label: {
|
||||
content
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user