Compare commits

..

77 Commits

Author SHA1 Message Date
Arkadiusz Fal
a9ccd6b0f2 Bump build number 2022-01-07 22:19:13 +01:00
Arkadiusz Fal
76273a4724 Add option to rotate to landscape on entering fullscreen with button 2022-01-07 22:19:11 +01:00
Arkadiusz Fal
8370714b61 Fix hiding history in Now Playing view in tvOS 2022-01-07 20:11:56 +01:00
Arkadiusz Fal
d096fdb344 Slightly more compact thumbnails badges 2022-01-07 20:06:18 +01:00
Arkadiusz Fal
5b12dbcb1e Pause before dismissing player on tvOS 2022-01-07 19:48:03 +01:00
Arkadiusz Fal
d1ed896166 Add SponsorBlock categories details 2022-01-07 19:46:47 +01:00
Arkadiusz Fal
3630cd404d Fix refresh buttons opacity 2022-01-07 12:12:56 +01:00
Arkadiusz Fal
c698595517 Bump build number 2022-01-07 00:11:48 +01:00
Arkadiusz Fal
f2063be4a3 Update packages 2022-01-07 00:08:22 +01:00
Arkadiusz Fal
9304bf6158 Add refresh buttons keyboard shortcuts 2022-01-07 00:00:40 +01:00
Arkadiusz Fal
3495ecf693 Show recent channels/playlists in search in tab navigation 2022-01-06 18:21:14 +01:00
Arkadiusz Fal
f29dc792c2 Fix player controls progress bar warning 2022-01-06 17:47:07 +01:00
Arkadiusz Fal
792db567ed Fix manage object context in tvOS info view controllers 2022-01-06 17:06:03 +01:00
Arkadiusz Fal
e159bb772c Improve macOS Big Sur blur effect 2022-01-06 17:00:58 +01:00
Arkadiusz Fal
8a74938b98 Improve windows handling on macOS 2022-01-06 16:35:45 +01:00
Arkadiusz Fal
3baa7a6893 Redesigned settings (fixes #47) 2022-01-06 16:02:53 +01:00
Arkadiusz Fal
520d69f37a Backport blur effect for iOS 14/macOS Big Sur 2022-01-06 15:58:16 +01:00
Arkadiusz Fal
4e88f2baf8 Add setting for disabling thumbnails rounding 2022-01-06 15:57:28 +01:00
Arkadiusz Fal
b5d187c52f Add help link for adding Invidious account 2022-01-06 15:56:03 +01:00
Arkadiusz Fal
c1e219e46e Fix player controls progress bar 2022-01-06 15:55:34 +01:00
Arkadiusz Fal
7317aec1ed Minor layout improvements 2022-01-06 11:13:53 +01:00
Arkadiusz Fal
3e8ac15c66 Improve playlists toolbar layout on iOS 2022-01-05 17:26:25 +01:00
Arkadiusz Fal
363424fa74 Add pull to refresh for Subscriptions, Popular and Trending (fixes #31) 2022-01-05 17:25:57 +01:00
Arkadiusz Fal
1db4a3197d Add infinite scroll for comments 2022-01-05 17:12:32 +01:00
Arkadiusz Fal
ac755d0ee6 Fix tvOS player dismiss animation 2022-01-05 17:08:48 +01:00
Arkadiusz Fal
16a3a4728d Fix watched at string 2022-01-05 17:08:29 +01:00
Arkadiusz Fal
ea6363ba65 Add infinite scroll for search (fixes #5) 2022-01-05 11:44:53 +01:00
Arkadiusz Fal
3326088081 Improve search suggestion button area 2022-01-04 23:34:09 +01:00
Arkadiusz Fal
5498e2c4ab Bump build number 2022-01-02 22:38:56 +01:00
Arkadiusz Fal
00778b585f Add iOS options for handling landscape fullscreen (fixes #38) 2022-01-02 22:38:56 +01:00
Arkadiusz Fal
d6e75295e1 Add iOS option to lock portrait mode in browsing 2022-01-02 20:50:59 +01:00
Arkadiusz Fal
aec7480353 Add Play/Shuffle All buttons to playlists context menu 2022-01-02 20:50:59 +01:00
Arkadiusz Fal
e29982454b Add options for history: badge color and reset watched status on playing 2022-01-02 20:50:59 +01:00
Arkadiusz Fal
117057dd0e Add option to show/hide history of videos in player queue view 2022-01-02 20:50:59 +01:00
Arkadiusz Fal
9ede4b9b1f Add option to show/hide username in account picker button 2022-01-02 20:50:58 +01:00
Arkadiusz Fal
f0d1b74e34 Add Toggle Sidebar button for macOS 2022-01-02 20:46:02 +01:00
Arkadiusz Fal
2a75d0a1d4 Improve search suggestions layout, add separate button for search/append 2022-01-02 20:46:02 +01:00
Arkadiusz Fal
04df9551ba Add Play/Shuffle All for playlists (fixes #39)
Add Remove All from queue button on tvOS
2022-01-02 20:46:02 +01:00
Arkadiusz Fal
ba21583a95 Add Check for Updates button in macOS settings 2022-01-02 20:46:00 +01:00
Arkadiusz Fal
149607efbc Fix reporting player item duration to Now Playing 2021-12-29 20:20:09 +01:00
Arkadiusz Fal
89957e3b56 Better UI handling for loading video details (fixes #46) 2021-12-29 19:55:41 +01:00
Arkadiusz Fal
0af2db2fd7 Fix keywords background color 2021-12-29 19:40:25 +01:00
Arkadiusz Fal
ab174c73fd Extract progress view, show video details loading 2021-12-29 19:39:38 +01:00
Arkadiusz Fal
e4f3914ff8 Bump build number 2021-12-26 23:35:47 +01:00
Arkadiusz Fal
ac1c6685a1 Improve history, resume videos, mark watched videos (fixes #42) 2021-12-26 23:35:44 +01:00
Arkadiusz Fal
adcebb77a5 Fix video details buttons alignment 2021-12-26 21:27:46 +01:00
Arkadiusz Fal
32862ab446 Fix marking live videos from Piped 2021-12-26 20:14:45 +01:00
Arkadiusz Fal
e06febd2e3 Fix playback time formatting 2021-12-26 20:07:59 +01:00
Arkadiusz Fal
f257632354 Open PiP on iPad on going home screen (iOS 14.2+) 2021-12-26 20:07:25 +01:00
Arkadiusz Fal
19d57ff55c Retry loading thumbnails 2021-12-24 20:21:11 +01:00
Arkadiusz Fal
91fa4ea2ff Extract open URL action 2021-12-24 20:20:05 +01:00
Arkadiusz Fal
18d6000976 Fix skipping intro (should not happen when changing stream) 2021-12-20 00:39:45 +01:00
Arkadiusz Fal
ea90f650d8 Remove unused code, minor style changes 2021-12-20 00:36:12 +01:00
Arkadiusz Fal
0a5cb5b542 Fix video context menu channel subscription button (fixes #41) 2021-12-19 23:27:20 +01:00
Arkadiusz Fal
f132ba9683 Bump build number 2021-12-19 18:22:13 +01:00
Arkadiusz Fal
efce339234 Add context menu to close current video from player bar 2021-12-19 18:21:10 +01:00
Arkadiusz Fal
f89c5ff055 Improve player queue rows buttons labels 2021-12-19 18:18:33 +01:00
Arkadiusz Fal
9b2209c9b5 Update default list of favorites 2021-12-19 18:18:01 +01:00
Arkadiusz Fal
61a4951831 Layout and PiP improvements, new settings
- player is now a separate window on macOS
- add setting to disable pause when player is closed (fixes #40)
- add PiP settings:
  * Close PiP when starting playing other video
  * Close PiP when player is opened
  * Close PiP and open player when application
    enters foreground (iOS/tvOS) (fixes #37)
- new player placeholder when in PiP, context menu with exit option
2021-12-19 18:17:04 +01:00
Arkadiusz Fal
cef0b2594a Better loading and handling streams 2021-12-19 17:56:47 +01:00
Arkadiusz Fal
1fbb0cfa80 Remove favorites drag opacity effect on iOS (fixes #43)
No workaround for how to handle drag and drop effect on opening
context menu
2021-12-19 17:32:28 +01:00
Arkadiusz Fal
984e9e7b16 Fix visibility of likes/dislikes 2021-12-19 17:15:27 +01:00
Arkadiusz Fal
4793fc9a38 Fix visibility of Subscriptions tab navigation item on tvOS 2021-12-19 17:08:48 +01:00
Arkadiusz Fal
b6e1f8148c Bump version number 2021-12-17 21:24:21 +01:00
Arkadiusz Fal
23e2e216db Start playing after video intro instead of seeking from beginning 2021-12-17 21:02:15 +01:00
Arkadiusz Fal
d7058b46d3 Fix updating player item duration for live streams 2021-12-17 21:01:18 +01:00
Arkadiusz Fal
c4ca5eb4c7 Show channel thumbnail in player 2021-12-17 21:01:05 +01:00
Arkadiusz Fal
02e66e4520 Fix tab navigation environment objects 2021-12-17 20:58:24 +01:00
Arkadiusz Fal
de09f9dd52 SponsorBlock segments loading improvement 2021-12-17 20:55:52 +01:00
Arkadiusz Fal
4fab7c2c16 Fix channel view in tab navigation 2021-12-17 20:53:53 +01:00
Arkadiusz Fal
f609ed1ed4 Fix unsubscribing from channel 2021-12-17 20:53:24 +01:00
Arkadiusz Fal
201e91a3cc Show errors when handling playlists 2021-12-17 20:53:05 +01:00
Arkadiusz Fal
923f0c0356 More uniform comments UI 2021-12-17 20:46:49 +01:00
Arkadiusz Fal
008cd1553d Comments UI fixes 2021-12-17 18:22:46 +01:00
Arkadiusz Fal
8d49934fe8 Encapsulate open channel action 2021-12-17 17:34:55 +01:00
Arkadiusz Fal
a4c43d9a3a Fix subscriptions/playlists reload on account change 2021-12-14 23:50:19 +01:00
Arkadiusz Fal
310ed3b12b Update README 2021-12-10 23:34:11 +01:00
119 changed files with 4695 additions and 1519 deletions

View File

@@ -7,6 +7,7 @@ disabled_rules:
- multiline_arguments
excluded:
- Vendor
- Tests Apple TV
- Tests iOS
- Tests macOS

View 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

View 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

View File

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

View File

@@ -2,7 +2,7 @@ import Foundation
extension Double {
func formattedAsPlaybackTime() -> String? {
guard !isZero else {
guard !isZero, isFinite else {
return nil
}

View File

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

View File

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

View File

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

View File

@@ -25,11 +25,15 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
func setAccount(_ account: Account) {
self.account = account
signedIn = false
validInstance = false
signedIn = !account.anonymous
validInstance = account.anonymous
configure()
if !account.anonymous {
validate()
}
}
func validate() {
@@ -80,24 +84,26 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
}
configureTransformer(pathPattern("popular"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
content.json.arrayValue.map(InvidiousAPI.extractVideo)
content.json.arrayValue.map(self.extractVideo)
}
configureTransformer(pathPattern("trending"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
content.json.arrayValue.map(InvidiousAPI.extractVideo)
content.json.arrayValue.map(self.extractVideo)
}
configureTransformer(pathPattern("search"), requestMethods: [.get]) { (content: Entity<JSON>) -> [ContentItem] in
content.json.arrayValue.map {
let type = $0.dictionaryValue["type"]?.stringValue
configureTransformer(pathPattern("search"), requestMethods: [.get]) { (content: Entity<JSON>) -> SearchPage in
let results = content.json.arrayValue.compactMap { json -> ContentItem in
let type = json.dictionaryValue["type"]?.stringValue
if type == "channel" {
return ContentItem(channel: InvidiousAPI.extractChannel(from: $0))
return ContentItem(channel: self.extractChannel(from: json))
} else if type == "playlist" {
return ContentItem(playlist: InvidiousAPI.extractChannelPlaylist(from: $0))
return ContentItem(playlist: self.extractChannelPlaylist(from: json))
}
return ContentItem(video: InvidiousAPI.extractVideo(from: $0))
return ContentItem(video: self.extractVideo(from: json))
}
return SearchPage(results: results, last: results.isEmpty)
}
configureTransformer(pathPattern("search/suggestions"), requestMethods: [.get]) { (content: Entity<JSON>) -> [String] in
@@ -109,11 +115,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
}
configureTransformer(pathPattern("auth/playlists"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Playlist] in
content.json.arrayValue.map(Playlist.init)
content.json.arrayValue.map(self.extractPlaylist)
}
configureTransformer(pathPattern("auth/playlists/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Playlist in
Playlist(content.json)
self.extractPlaylist(from: content.json)
}
configureTransformer(pathPattern("auth/playlists"), requestMethods: [.post, .patch]) { (content: Entity<Data>) -> Playlist in
@@ -123,30 +129,30 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
configureTransformer(pathPattern("auth/feed"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
if let feedVideos = content.json.dictionaryValue["videos"] {
return feedVideos.arrayValue.map(InvidiousAPI.extractVideo)
return feedVideos.arrayValue.map(self.extractVideo)
}
return []
}
configureTransformer(pathPattern("auth/subscriptions"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Channel] in
content.json.arrayValue.map(InvidiousAPI.extractChannel)
content.json.arrayValue.map(self.extractChannel)
}
configureTransformer(pathPattern("channels/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Channel in
InvidiousAPI.extractChannel(from: content.json)
self.extractChannel(from: content.json)
}
configureTransformer(pathPattern("channels/*/latest"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
content.json.arrayValue.map(InvidiousAPI.extractVideo)
content.json.arrayValue.map(self.extractVideo)
}
configureTransformer(pathPattern("playlists/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> ChannelPlaylist in
InvidiousAPI.extractChannelPlaylist(from: content.json)
self.extractChannelPlaylist(from: content.json)
}
configureTransformer(pathPattern("videos/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Video in
InvidiousAPI.extractVideo(from: content.json)
self.extractVideo(from: content.json)
}
}
@@ -234,7 +240,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
resource(baseURL: account.url, path: basePathAppending("playlists/\(id)"))
}
func search(_ query: SearchQuery) -> Resource {
func search(_ query: SearchQuery, page: String?) -> Resource {
var resource = resource(baseURL: account.url, path: basePathAppending("search"))
.withParam("q", searchQuery(query.query))
.withParam("sort_by", query.sortBy.parameter)
@@ -248,6 +254,10 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
resource = resource.withParam("duration", duration.rawValue)
}
if let page = page {
resource = resource.withParam("page", page)
}
return resource
}
@@ -291,7 +301,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
return AVURLAsset(url: url)
}
static func extractVideo(from json: JSON) -> Video {
func extractVideo(from json: JSON) -> Video {
let indexID: String?
var id: Video.ID
var publishedAt: Date?
@@ -334,8 +344,15 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
)
}
static func extractChannel(from json: JSON) -> Channel {
let thumbnailURL = "https:\(json["authorThumbnails"].arrayValue.first?.dictionaryValue["url"]?.stringValue ?? "")"
func extractChannel(from json: JSON) -> Channel {
var thumbnailURL = json["authorThumbnails"].arrayValue.last?.dictionaryValue["url"]?.stringValue ?? ""
// append https protocol to unproxied thumbnail URL if it's missing
if thumbnailURL.count > 2,
String(thumbnailURL[..<thumbnailURL.index(thumbnailURL.startIndex, offsetBy: 2)]) == "//"
{
thumbnailURL = "https:\(thumbnailURL)"
}
return Channel(
id: json["authorId"].stringValue,
@@ -343,33 +360,33 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
thumbnailURL: URL(string: thumbnailURL),
subscriptionsCount: json["subCount"].int,
subscriptionsText: json["subCountText"].string,
videos: json.dictionaryValue["latestVideos"]?.arrayValue.map(InvidiousAPI.extractVideo) ?? []
videos: json.dictionaryValue["latestVideos"]?.arrayValue.map(extractVideo) ?? []
)
}
static func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist {
func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist {
let details = json.dictionaryValue
return ChannelPlaylist(
id: details["playlistId"]!.stringValue,
title: details["title"]!.stringValue,
thumbnailURL: details["playlistThumbnail"]?.url,
channel: extractChannel(from: json),
videos: details["videos"]?.arrayValue.compactMap(InvidiousAPI.extractVideo) ?? []
videos: details["videos"]?.arrayValue.compactMap(extractVideo) ?? []
)
}
private static func extractThumbnails(from details: JSON) -> [Thumbnail] {
private func extractThumbnails(from details: JSON) -> [Thumbnail] {
details["videoThumbnails"].arrayValue.map { json in
Thumbnail(url: json["url"].url!, quality: .init(rawValue: json["quality"].string!)!)
}
}
private static func extractStreams(from json: JSON) -> [Stream] {
private func extractStreams(from json: JSON) -> [Stream] {
extractFormatStreams(from: json["formatStreams"].arrayValue) +
extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue)
}
private static func extractFormatStreams(from streams: [JSON]) -> [Stream] {
private func extractFormatStreams(from streams: [JSON]) -> [Stream] {
streams.map {
SingleAssetStream(
avAsset: AVURLAsset(url: $0["url"].url!),
@@ -380,7 +397,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 +416,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) }
)
}
}

View File

@@ -8,10 +8,6 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
@Published var account: Account!
var anonymousAccount: Account {
.init(instanceID: account.instance.id, name: "Anonymous", url: account.instance.apiURL)
}
init(account: Account? = nil) {
super.init()
@@ -40,23 +36,28 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
}
configureTransformer(pathPattern("channel/*")) { (content: Entity<JSON>) -> Channel? in
PipedAPI.extractChannel(from: content.json)
self.extractChannel(from: content.json)
}
configureTransformer(pathPattern("playlists/*")) { (content: Entity<JSON>) -> ChannelPlaylist? in
PipedAPI.extractChannelPlaylist(from: content.json)
self.extractChannelPlaylist(from: content.json)
}
configureTransformer(pathPattern("streams/*")) { (content: Entity<JSON>) -> Video? in
PipedAPI.extractVideo(from: content.json)
self.extractVideo(from: content.json)
}
configureTransformer(pathPattern("trending")) { (content: Entity<JSON>) -> [Video] in
PipedAPI.extractVideos(from: content.json)
self.extractVideos(from: content.json)
}
configureTransformer(pathPattern("search")) { (content: Entity<JSON>) -> [ContentItem] in
PipedAPI.extractContentItems(from: content.json.dictionaryValue["items"]!)
configureTransformer(pathPattern("search")) { (content: Entity<JSON>) -> SearchPage in
let nextPage = content.json.dictionaryValue["nextpage"]?.stringValue
return SearchPage(
results: self.extractContentItems(from: content.json.dictionaryValue["items"]!),
nextPage: nextPage,
last: nextPage == "null"
)
}
configureTransformer(pathPattern("suggestions")) { (content: Entity<JSON>) -> [String] in
@@ -64,16 +65,16 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
}
configureTransformer(pathPattern("subscriptions")) { (content: Entity<JSON>) -> [Channel] in
content.json.arrayValue.map { PipedAPI.extractChannel(from: $0)! }
content.json.arrayValue.map { self.extractChannel(from: $0)! }
}
configureTransformer(pathPattern("feed")) { (content: Entity<JSON>) -> [Video] in
content.json.arrayValue.map { PipedAPI.extractVideo(from: $0)! }
content.json.arrayValue.map { self.extractVideo(from: $0)! }
}
configureTransformer(pathPattern("comments/*")) { (content: Entity<JSON>) -> CommentsPage in
let details = content.json.dictionaryValue
let comments = details["comments"]?.arrayValue.map { PipedAPI.extractComment(from: $0)! } ?? []
let comments = details["comments"]?.arrayValue.map { self.extractComment(from: $0)! } ?? []
let nextPage = details["nextpage"]?.stringValue
let disabled = details["disabled"]?.boolValue ?? false
@@ -86,7 +87,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
}
func needsAuthorization(_ url: URL) -> Bool {
PipedAPI.authorizedEndpoints.contains { url.absoluteString.contains($0) }
Self.authorizedEndpoints.contains { url.absoluteString.contains($0) }
}
func updateToken() {
@@ -127,10 +128,18 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
.withParam("region", country.rawValue)
}
func search(_ query: SearchQuery) -> Resource {
resource(baseURL: account.instance.apiURL, path: "search")
func search(_ query: SearchQuery, page: String?) -> Resource {
let path = page.isNil ? "search" : "nextpage/search"
let resource = resource(baseURL: account.instance.apiURL, path: path)
.withParam("q", query.query)
.withParam("filter", "")
.withParam("filter", "all")
if page.isNil {
return resource
}
return resource.withParam("nextpage", page)
}
func searchSuggestions(query: String) -> Resource {
@@ -190,7 +199,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
"**\(path)"
}
private static func extractContentItem(from content: JSON) -> ContentItem? {
private func extractContentItem(from content: JSON) -> ContentItem? {
let details = content.dictionaryValue
let url: String! = details["url"]?.string
@@ -210,17 +219,17 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
switch contentType {
case .video:
if let video = PipedAPI.extractVideo(from: content) {
if let video = extractVideo(from: content) {
return ContentItem(video: video)
}
case .playlist:
if let playlist = PipedAPI.extractChannelPlaylist(from: content) {
if let playlist = extractChannelPlaylist(from: content) {
return ContentItem(playlist: playlist)
}
case .channel:
if let channel = PipedAPI.extractChannel(from: content) {
if let channel = extractChannel(from: content) {
return ContentItem(channel: channel)
}
}
@@ -228,11 +237,11 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
return nil
}
private static func extractContentItems(from content: JSON) -> [ContentItem] {
content.arrayValue.compactMap { PipedAPI.extractContentItem(from: $0) }
private func extractContentItems(from content: JSON) -> [ContentItem] {
content.arrayValue.compactMap { extractContentItem(from: $0) }
}
private static func extractChannel(from content: JSON) -> Channel? {
private func extractChannel(from content: JSON) -> Channel? {
let attributes = content.dictionaryValue
guard let id = attributes["id"]?.stringValue ??
(attributes["url"] ?? attributes["uploaderUrl"])?.stringValue.components(separatedBy: "/").last
@@ -244,25 +253,28 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
var videos = [Video]()
if let relatedStreams = attributes["relatedStreams"] {
videos = PipedAPI.extractVideos(from: relatedStreams)
videos = extractVideos(from: relatedStreams)
}
let name = attributes["name"]?.stringValue ?? attributes["uploaderName"]?.stringValue ?? attributes["uploader"]?.stringValue ?? ""
let thumbnailURL = attributes["avatarUrl"]?.url ?? attributes["uploaderAvatar"]?.url ?? attributes["avatar"]?.url ?? attributes["thumbnail"]?.url
return Channel(
id: id,
name: attributes["name"]!.stringValue,
thumbnailURL: attributes["thumbnail"]?.url,
name: name,
thumbnailURL: thumbnailURL,
subscriptionsCount: subscriptionsCount,
videos: videos
)
}
static func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist? {
func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist? {
let details = json.dictionaryValue
let id = details["url"]?.stringValue.components(separatedBy: "?list=").last ?? UUID().uuidString
let thumbnailURL = details["thumbnail"]?.url ?? details["thumbnailUrl"]?.url
var videos = [Video]()
if let relatedStreams = details["relatedStreams"] {
videos = PipedAPI.extractVideos(from: relatedStreams)
videos = extractVideos(from: relatedStreams)
}
return ChannelPlaylist(
id: id,
@@ -274,7 +286,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
)
}
private static func extractVideo(from content: JSON) -> Video? {
private func extractVideo(from content: JSON) -> Video? {
let details = content.dictionaryValue
let url = details["url"]?.string
@@ -287,7 +299,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
let channelId = details["uploaderUrl"]!.stringValue.components(separatedBy: "/").last!
let thumbnails: [Thumbnail] = Thumbnail.Quality.allCases.compactMap {
if let url = PipedAPI.buildThumbnailURL(from: content, quality: $0) {
if let url = buildThumbnailURL(from: content, quality: $0) {
return Thumbnail(url: url, quality: $0)
}
@@ -295,19 +307,23 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
}
let author = details["uploaderName"]?.stringValue ?? details["uploader"]!.stringValue
let authorThumbnailURL = details["avatarUrl"]?.url ?? details["uploaderAvatar"]?.url ?? details["avatar"]?.url
let published = (details["uploadedDate"] ?? details["uploadDate"])?.stringValue ??
(details["uploaded"]!.double! / 1000).formattedAsRelativeTime()!
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,
views: details["views"]!.intValue,
description: PipedAPI.extractDescription(from: content),
channel: Channel(id: channelId, name: author),
description: extractDescription(from: content),
channel: Channel(id: channelId, name: author, thumbnailURL: authorThumbnailURL),
thumbnails: thumbnails,
live: live,
likes: details["likes"]?.int,
dislikes: details["dislikes"]?.int,
streams: extractStreams(from: content),
@@ -315,16 +331,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 +353,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
)!
}
private static func extractDescription(from content: JSON) -> String? {
private func extractDescription(from content: JSON) -> String? {
guard var description = content.dictionaryValue["description"]?.string else {
return nil
}
@@ -359,22 +375,22 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
return description
}
private static func extractVideos(from content: JSON) -> [Video] {
private func extractVideos(from content: JSON) -> [Video] {
content.arrayValue.compactMap(extractVideo(from:))
}
private static func extractStreams(from content: JSON) -> [Stream] {
private func extractStreams(from content: JSON) -> [Stream] {
var streams = [Stream]()
if let hlsURL = content.dictionaryValue["hls"]?.url {
streams.append(Stream(hlsURL: hlsURL))
}
guard let audioStream = PipedAPI.compatibleAudioStreams(from: content).first else {
guard let audioStream = compatibleAudioStreams(from: content).first else {
return streams
}
let videoStreams = PipedAPI.compatibleVideoStream(from: content)
let videoStreams = compatibleVideoStream(from: content)
videoStreams.forEach { videoStream in
let audioAsset = AVURLAsset(url: audioStream.dictionaryValue["url"]!.url!)
@@ -397,14 +413,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 +430,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

View File

@@ -9,7 +9,7 @@ protocol VideosAPI {
func channel(_ id: String) -> Resource
func channelVideos(_ id: String) -> Resource
func trending(country: Country, category: TrendingCategory?) -> Resource
func search(_ query: SearchQuery) -> Resource
func search(_ query: SearchQuery, page: String?) -> Resource
func searchSuggestions(query: String) -> Resource
func video(_ id: Video.ID) -> Resource
@@ -55,11 +55,12 @@ extension VideosAPI {
}
func shareURL(_ item: ContentItem, frontendHost: String? = nil, time: CMTime? = nil) -> URL? {
guard let frontendHost = frontendHost ?? account.instance.frontendHost else {
guard let frontendHost = frontendHost ?? account?.instance?.frontendHost,
var urlComponents = account?.instance?.urlComponents
else {
return nil
}
var urlComponents = account.instance.urlComponents
urlComponents.host = frontendHost
var queryItems = [URLQueryItem]()

View File

@@ -42,4 +42,8 @@ enum VideosApp: String, CaseIterable {
var supportsComments: Bool {
self == .piped
}
var searchUsesIndexedPages: Bool {
self == .invidious
}
}

View File

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

View File

@@ -8,26 +8,25 @@ final class CommentsModel: ObservableObject {
@Published var nextPage: String?
@Published var firstPage = true
@Published var loaded = true
@Published var loaded = false
@Published var disabled = false
@Published var replies = [Comment]()
@Published var repliesPageID: String?
@Published var repliesLoaded = false
var accounts: AccountsModel!
var player: PlayerModel!
var instance: Instance? {
static var instance: Instance? {
InstancesModel.find(Defaults[.commentsInstanceID])
}
var api: VideosAPI? {
instance.isNil ? nil : PipedAPI(account: instance!.anonymousAccount)
Self.instance.isNil ? nil : PipedAPI(account: Self.instance!.anonymousAccount)
}
static var enabled: Bool {
!Defaults[.commentsInstanceID].isNil && !Defaults[.commentsInstanceID]!.isEmpty
!instance.isNil
}
#if !os(tvOS)
@@ -45,21 +44,23 @@ final class CommentsModel: ObservableObject {
return
}
reset()
guard !instance.isNil,
guard !Self.instance.isNil,
!(player?.currentVideo.isNil ?? true)
else {
return
}
if !firstPage && !nextPageAvailable {
return
}
firstPage = page.isNil || page!.isEmpty
api?.comments(player.currentVideo!.videoID, page: page)?
.load()
.onSuccess { [weak self] response in
if let page: CommentsPage = response.typedContent() {
self?.all = page.comments
self?.all += page.comments
self?.nextPage = page.nextPage
self?.disabled = page.disabled
}
@@ -69,6 +70,13 @@ final class CommentsModel: ObservableObject {
}
}
func loadNextPageIfNeeded(current comment: Comment) {
let thresholdIndex = all.index(all.endIndex, offsetBy: -5)
if all.firstIndex(where: { $0 == comment }) == thresholdIndex {
loadNextPage()
}
}
func loadNextPage() {
load(page: nextPage)
}
@@ -91,9 +99,10 @@ final class CommentsModel: ObservableObject {
.onSuccess { [weak self] response in
if let page: CommentsPage = response.typedContent() {
self?.replies = page.comments
self?.repliesLoaded = true
}
}
.onCompletion { [weak self] _ in
.onFailure { [weak self] _ in
self?.repliesLoaded = true
}
}

80
Model/HistoryModel.swift Normal file
View 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()
}
}

View File

@@ -41,10 +41,80 @@ final class NavigationModel: ObservableObject {
@Published var presentingSettings = false
@Published var presentingWelcomeScreen = false
static func openChannel(
_ channel: Channel,
player: PlayerModel,
recents: RecentsModel,
navigation: NavigationModel,
navigationStyle: NavigationStyle,
delay: Bool = false
) {
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

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

View File

@@ -1,53 +1,67 @@
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 { updateWindowTitle() } }
@Published var historyVideos = [Video]()
@Published var savedTime: CMTime?
@Published var preservedTime: CMTime?
@Published var playerNavigationLinkActive = false
@Published var playerNavigationLinkActive = false { didSet { handleNavigationViewPlayerPresentationChange() } }
@Published var sponsorBlock = SponsorBlockAPI()
@Published var segmentRestorationTime: CMTime?
@Published var lastSkipped: Segment? { didSet { rebuildTVMenu() } }
@Published var restoredSegments = [Segment]()
@Published var channelWithDetails: Channel?
#if os(iOS)
@Published var motionManager: CMMotionManager!
@Published var lockedOrientation: UIInterfaceOrientation?
@Published var lastOrientation: UIInterfaceOrientation?
#endif
var accounts: AccountsModel
var comments: CommentsModel
var 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 +72,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 +83,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 +151,7 @@ final class PlayerModel: ObservableObject {
}
var live: Bool {
currentItem?.video?.live ?? false
currentVideo?.live ?? false
}
var playerItemDuration: CMTime? {
@@ -103,7 +159,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 +182,45 @@ 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]
) { [weak self] in
if Defaults[.showChannelSubscribers] {
self?.loadCurrentItemChannelDetails()
}
}
}
}
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 +229,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 +292,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 +309,7 @@ final class PlayerModel: ObservableObject {
self.stream = stream
self.composition = AVMutableComposition()
self.asset = nil
}
let startPlaying = {
@@ -191,27 +317,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 +378,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 +421,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 +463,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 +491,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 +532,33 @@ 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)
}
#else
hide()
#endif
presentingPlayer = false
} else {
advanceToNextItem()
}
@@ -382,13 +572,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 +629,7 @@ final class PlayerModel: ObservableObject {
}
self.timeObserverThrottle.execute {
self.updateCurrentItemIntervals()
self.updateWatch()
}
}
}
@@ -469,22 +659,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 +678,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
}
@@ -516,6 +708,36 @@ final class PlayerModel: ObservableObject {
currentArtwork = MPMediaItemArtwork(boundsSize: image!.size) { _ in image! }
}
func loadCurrentItemChannelDetails() {
guard let video = currentVideo,
!video.channel.detailsLoaded
else {
return
}
if restoreLoadedChannel() {
return
}
accounts.api.channel(video.channel.id).load().onSuccess { [weak self] response in
if let channel: Channel = response.typedContent() {
self?.channelWithDetails = channel
withAnimation {
self?.currentItem?.video.channel = channel
}
}
}
}
@discardableResult func restoreLoadedChannel() -> Bool {
if !currentVideo.isNil, channelWithDetails?.id == currentVideo!.channel.id {
currentItem.video.channel = channelWithDetails!
return true
}
return false
}
func rateLabel(_ rate: Float) -> String {
let formatter = NumberFormatter()
formatter.minimumFractionDigits = 0
@@ -525,8 +747,103 @@ 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 updateWindowTitle() {
#if os(macOS)
Windows.player.window?.title = windowTitle
#endif
}
#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
}

View File

@@ -1,4 +1,4 @@
import AVFoundation
import AVKit
import Defaults
import Foundation
import Siesta
@@ -8,16 +8,30 @@ extension PlayerModel {
currentItem?.video
}
func playAll(_ videos: [Video]) {
let first = videos.first
func play(_ videos: [Video], shuffling: Bool = false, inNavigationView: Bool = false) {
let videosToPlay = shuffling ? videos.shuffled() : videos
videos.forEach { video in
enqueueVideo(video) { _, item in
guard let first = videosToPlay.first else {
return
}
enqueueVideo(first, prepending: true) { _, item in
self.advanceToItem(item)
}
videosToPlay.dropFirst().reversed().forEach { video in
enqueueVideo(video, prepending: true) { _, item in
if item.video == first {
self.advanceToItem(item)
}
}
}
if inNavigationView {
playerNavigationLinkActive = true
} else {
show()
}
}
func playNext(_ video: Video) {
@@ -29,8 +43,11 @@ extension PlayerModel {
}
func playNow(_ video: Video, at time: TimeInterval? = nil) {
player.replaceCurrentItem(with: nil)
addCurrentItemToHistory()
if playingInPictureInPicture, closePiPOnNavigation {
closePiP()
}
prepareCurrentItemForHistory()
enqueueVideo(video, prepending: true) { _, item in
self.advanceToItem(item, at: time)
@@ -38,7 +55,12 @@ extension PlayerModel {
}
func playItem(_ item: PlayerQueueItem, video: Video? = nil, at time: TimeInterval? = nil) {
if !playingInPictureInPicture {
player.replaceCurrentItem(with: nil)
}
comments.reset()
stream = nil
currentItem = item
if !time.isNil {
@@ -51,23 +73,19 @@ extension PlayerModel {
currentItem.video = video!
}
savedTime = currentItem.playbackTime
preservedTime = currentItem.playbackTime
restoreLoadedChannel()
loadAvailableStreams(currentVideo!) { streams in
guard let stream = self.preferredStream(streams) else {
DispatchQueue.main.async { [weak self] in
guard let video = self?.currentVideo else {
return
}
self.streamSelection = stream
self.playStream(
stream,
of: self.currentVideo!,
preservingTime: !self.currentItem.playbackTime.isNil
)
self?.loadAvailableStreams(video)
}
}
private func preferredStream(_ streams: [Stream]) -> Stream? {
func preferredStream(_ streams: [Stream]) -> Stream? {
let quality = Defaults[.quality]
var streams = streams
@@ -85,7 +103,7 @@ extension PlayerModel {
}
func advanceToNextItem() {
addCurrentItemToHistory()
prepareCurrentItemForHistory()
if let nextItem = queue.first {
advanceToItem(nextItem)
@@ -93,11 +111,13 @@ extension PlayerModel {
}
func advanceToItem(_ newItem: PlayerQueueItem, at time: TimeInterval? = nil) {
player.replaceCurrentItem(with: nil)
addCurrentItemToHistory()
prepareCurrentItemForHistory()
remove(newItem)
currentItem = newItem
player.pause()
accounts.api.loadDetails(newItem) { newItem in
self.playItem(newItem, video: newItem.video, at: time)
}
@@ -126,7 +146,7 @@ extension PlayerModel {
}
func isAutoplaying(_ item: AVPlayerItem) -> Bool {
player.currentItem == item && presentingPlayer
player.currentItem == item
}
@discardableResult func enqueueVideo(
@@ -138,6 +158,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
@@ -151,20 +177,15 @@ extension PlayerModel {
return item
}
func addCurrentItemToHistory() {
if let item = currentItem, Defaults[.saveHistory] {
addItemToHistory(item)
func prepareCurrentItemForHistory(finished: Bool = false) {
if !currentItem.isNil, Defaults[.saveHistory] {
if let video = currentVideo, !historyVideos.contains(where: { $0 == video }) {
historyVideos.append(video)
}
updateWatch(finished: finished)
}
}
func addItemToHistory(_ item: PlayerQueueItem) {
if let index = history.firstIndex(where: { $0.video?.videoID == item.video?.videoID }) {
history.remove(at: index)
}
history.insert(currentItem, at: 0)
}
func playHistory(_ item: PlayerQueueItem) {
var time = item.playbackTime
@@ -175,67 +196,9 @@ 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() {
guard !accounts.current.isNil else {
return
}
queue = Defaults[.queue]
queue.forEach { item in
accounts.api.loadDetails(item) { newItem in
if let index = self.queue.firstIndex(where: { $0.id == item.id }) {
self.queue[index] = newItem
}
}
}
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
}
}
}
}
}

View File

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

View File

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

View File

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

View File

@@ -22,11 +22,12 @@ struct Playlist: Identifiable, Equatable, Hashable {
var videos = [Video]()
init(id: String, title: String, visibility: Visibility, updated: TimeInterval) {
init(id: String, title: String, visibility: Visibility, updated: TimeInterval, videos: [Video] = []) {
self.id = id
self.title = title
self.visibility = visibility
self.updated = updated
self.videos = videos
}
init(_ json: JSON) {
@@ -34,7 +35,6 @@ struct Playlist: Identifiable, Equatable, Hashable {
title = json["title"].stringValue
visibility = json["isListed"].boolValue ? .public : .private
updated = json["updated"].doubleValue
videos = json["videos"].arrayValue.map { InvidiousAPI.extractVideo(from: $0) }
}
static func == (lhs: Playlist, rhs: Playlist) -> Bool {

View File

@@ -28,7 +28,7 @@ final class PlaylistsModel: ObservableObject {
}
func load(force: Bool = false, onSuccess: @escaping () -> Void = {}) {
guard !resource.isNil else {
guard accounts.app.supportsUserPlaylists, accounts.signedIn else {
playlists = []
return
}
@@ -52,14 +52,22 @@ final class PlaylistsModel: ObservableObject {
}
}
func addVideo(playlistID: Playlist.ID, videoID: Video.ID, onSuccess: @escaping () -> Void = {}) {
func addVideo(
playlistID: Playlist.ID,
videoID: Video.ID,
onSuccess: @escaping () -> Void = {},
onFailure: @escaping (RequestError) -> Void = { _ in }
) {
let resource = accounts.api.playlistVideos(playlistID)
let body = ["videoId": videoID]
resource?.request(.post, json: body).onSuccess { _ in
self.load(force: true)
onSuccess()
}
resource?
.request(.post, json: body)
.onSuccess { _ in
self.load(force: true)
onSuccess()
}
.onFailure(onFailure)
}
func removeVideo(videoIndexID: String, playlistID: Playlist.ID, onSuccess: @escaping () -> Void = {}) {

View File

@@ -55,6 +55,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 {

View File

@@ -4,6 +4,7 @@ import SwiftUI
final class SearchModel: ObservableObject {
@Published var store = Store<[ContentItem]>()
@Published var page: SearchPage?
var accounts = AccountsModel()
@Published var query = SearchQuery()
@@ -13,7 +14,6 @@ final class SearchModel: ObservableObject {
@Published var fieldIsFocused = false
private var previousResource: Resource?
private var resource: Resource!
var isLoading: Bool {
@@ -23,60 +23,54 @@ final class SearchModel: ObservableObject {
func changeQuery(_ changeHandler: @escaping (SearchQuery) -> Void = { _ in }) {
changeHandler(query)
let newResource = accounts.api.search(query)
guard newResource != previousResource else {
let newResource = accounts.api.search(query, page: nil)
guard newResource != resource else {
return
}
previousResource?.removeObservers(ownedBy: store)
previousResource = newResource
page = nil
resource = newResource
resource.addObserver(store)
if !query.isEmpty {
loadResourceIfNeededAndReplaceStore()
loadResource()
}
}
func resetQuery(_ query: SearchQuery = SearchQuery()) {
self.query = query
let newResource = accounts.api.search(query)
guard newResource != previousResource else {
let newResource = accounts.api.search(query, page: nil)
guard newResource != resource else {
return
}
page = nil
store.replace([])
previousResource?.removeObservers(ownedBy: store)
previousResource = newResource
resource = newResource
resource.addObserver(store)
if !query.isEmpty {
loadResourceIfNeededAndReplaceStore()
loadResource()
}
}
func loadResourceIfNeededAndReplaceStore() {
func loadResource() {
let currentResource = resource!
if let request = resource.loadIfNeeded() {
request.onSuccess { response in
if let results: [ContentItem] = response.typedContent() {
self.replace(results, for: currentResource)
}
resource.load().onSuccess { response in
if let page: SearchPage = response.typedContent() {
self.page = page
self.replace(page.results, for: currentResource)
}
} else {
replace(store.collection, for: currentResource)
}
}
func replace(_ videos: [ContentItem], for resource: Resource) {
func replace(_ items: [ContentItem], for resource: Resource) {
if self.resource == resource {
store = Store<[ContentItem]>(videos)
store = Store<[ContentItem]>(items)
}
}
@@ -84,6 +78,7 @@ final class SearchModel: ObservableObject {
func loadSuggestions(_ query: String) {
guard !query.isEmpty else {
querySuggestions.replace([])
return
}
@@ -108,4 +103,38 @@ final class SearchModel: ObservableObject {
}
}
}
func loadNextPage() {
guard var pageToLoad = page, !pageToLoad.last else {
return
}
if pageToLoad.nextPage.isNil, accounts.app.searchUsesIndexedPages {
pageToLoad.nextPage = "2"
}
resource?.removeObservers(ownedBy: store)
resource = accounts.api.search(query, page: page?.nextPage)
resource.addObserver(store)
resource
.load()
.onSuccess { response in
if let page: SearchPage = response.typedContent() {
var nextPage: Int?
if self.accounts.app.searchUsesIndexedPages {
nextPage = Int(pageToLoad.nextPage ?? "0")
}
self.page = page
if self.accounts.app.searchUsesIndexedPages {
self.page?.nextPage = String((nextPage ?? 1) + 1)
}
self.replace(self.store.collection + page.results, for: self.resource)
}
}
}
}

View File

@@ -0,0 +1,7 @@
import Foundation
struct SearchPage {
var results = [ContentItem]()
var nextPage: String?
var last = false
}

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import Alamofire
import AVKit
import Foundation
import SwiftUI
import SwiftyJSON
struct Video: Identifiable, Equatable, Hashable {
@@ -85,7 +86,7 @@ struct Video: Identifiable, Equatable, Hashable {
}
var likesCount: String? {
guard likes != -1 else {
guard (likes ?? 0) > 0 else {
return nil
}
@@ -93,7 +94,7 @@ struct Video: Identifiable, Equatable, Hashable {
}
var dislikesCount: String? {
guard dislikes != -1 else {
guard (dislikes ?? 0) > 0 else {
return nil
}
@@ -105,10 +106,24 @@ struct Video: Identifiable, Equatable, Hashable {
}
static func == (lhs: Video, rhs: Video) -> Bool {
lhs.id == rhs.id
let videoIDIsEqual = lhs.videoID == rhs.videoID
if !lhs.indexID.isNil, !rhs.indexID.isNil {
return videoIDIsEqual && lhs.indexID == rhs.indexID
}
return videoIDIsEqual
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
var watchFetchRequest: FetchRequest<Watch> {
FetchRequest<Watch>(
entity: Watch.entity(),
sortDescriptors: [],
predicate: NSPredicate(format: "videoID = %@", videoID)
)
}
}

48
Model/Watch.swift Normal file
View 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())
}
}

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

View File

@@ -1,6 +1,7 @@
![Yattee Banner](https://r.yattee.stream/icons/yattee-banner.png)
Video player for [Invidious](https://github.com/iv-org/invidious) and [Piped](https://github.com/TeamPiped/Piped) built for iOS, tvOS and macOS.
<div align="center">
<img src="https://r.yattee.stream/icons/yattee-150.png" width="150" height="150" alt="Yattee logo">
<h1>Yattee</h1>
<p>Videos browser and player for <a href="https://github.com/iv-org/invidious">Invidious</a> and <a href="https://github.com/TeamPiped/Piped">Piped</a> (alternative, privacy-friendly YouTube frontends)<br />built for iOS, tvOS and macOS.</p>
[![AGPL v3](https://shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0.en.html)
[![GitHub issues](https://img.shields.io/github/issues/yattee/yattee)](https://github.com/yattee/yattee/issues)
@@ -8,16 +9,15 @@ Video player for [Invidious](https://github.com/iv-org/invidious) and [Piped](ht
[![Matrix](https://img.shields.io/matrix/yattee:matrix.org)](https://matrix.to/#/#yattee:matrix.org)
![Screenshot](https://r.yattee.stream/screenshots/all-platforms.png)
</div>
## Features
* Native user interface built with [SwiftUI](https://developer.apple.com/xcode/swiftui/)
* Native user interface built with [SwiftUI](https://developer.apple.com/xcode/swiftui/) with customization settings
* Multiple instances and accounts, fast switching
* [SponsorBlock](https://sponsor.ajay.app/), configurable categories to skip
* Player queue and history
* Fullscreen playback, Picture in Picture and AirPlay support
* Stream quality selection
* Favorites: customizable section of channels, playlists, trending, searches and other views
* `yattee://` URL Scheme for integrations
### Availability
| Feature | Invidious | Piped |
@@ -38,11 +38,11 @@ Video player for [Invidious](https://github.com/iv-org/invidious) and [Piped](ht
You can browse and use accounts from one app and play videos with another (for example: use Invidious account for subscriptions and use Piped as playback source). Comments can be displayed from Piped even when Invidious is used for browsing/playing.
## Documentation
* [Installation Instructions](https://github.com/yattee/yattee/wiki/Installation-instructions)
* [Integrations](https://github.com/yattee/yattee/wiki/Integrations)
* [Installation Instructions](https://github.com/yattee/yattee/wiki/Installation-Instructions)
* [FAQ](https://github.com/yattee/yattee/wiki)
* [Screenshots Gallery](https://github.com/yattee/yattee/wiki/Screenshots-Gallery)
* [Tips](https://github.com/yattee/yattee/wiki/Tips)
* [FAQ](https://github.com/yattee/yattee/wiki)
* [Integrations](https://github.com/yattee/yattee/wiki/Integrations)
* [Donations](https://github.com/yattee/yattee/wiki/Donations)
## Contributing

View File

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

View File

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

View File

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

View File

@@ -1,38 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.329",
"green" : "0.224",
"red" : "0.043"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.329",
"green" : "0.224",
"red" : "0.043"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -2,12 +2,12 @@
"colors" : [
{
"color" : {
"color-space" : "srgb",
"color-space" : "display-p3",
"components" : {
"alpha" : "1.000",
"blue" : "0.537",
"green" : "0.522",
"red" : "1.000"
"blue" : "0.361",
"green" : "0.200",
"red" : "0.129"
}
},
"idiom" : "universal"
@@ -20,12 +20,12 @@
}
],
"color" : {
"color-space" : "srgb",
"color-space" : "display-p3",
"components" : {
"alpha" : "1.000",
"blue" : "0.537",
"green" : "0.522",
"red" : "1.000"
"blue" : "0.263",
"green" : "0.290",
"red" : "0.859"
}
},
"idiom" : "universal"

View File

@@ -1,5 +1,8 @@
import Defaults
import Foundation
#if os(iOS)
import UIKit
#endif
extension Defaults.Keys {
static let kavinPipedInstanceID = "kavin-piped"
@@ -22,29 +25,53 @@ 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 showChannelSubscribers = Key<Bool>("showChannelSubscribers", default: true)
static let commentsInstanceID = Key<Instance.ID?>("commentsInstance", default: kavinPipedInstanceID)
#if !os(tvOS)
static let commentsPlacement = Key<CommentsPlacement>("commentsPlacement", default: .separate)
#endif
static let pauseOnHidingPlayer = Key<Bool>("pauseOnHidingPlayer", default: true)
static let closePiPOnNavigation = Key<Bool>("closePiPOnNavigation", default: false)
static let closePiPOnOpeningPlayer = Key<Bool>("closePiPOnOpeningPlayer", default: false)
#if !os(macOS)
static let closePiPAndOpenPlayerOnEnteringForeground = Key<Bool>("closePiPAndOpenPlayerOnEnteringForeground", default: false)
#endif
static let recentlyOpened = Key<[RecentItem]>("recentlyOpened", default: [])
static let queue = Key<[PlayerQueueItem]>("queue", default: [])
static let history = Key<[PlayerQueueItem]>("history", default: [])
static let lastPlayed = Key<PlayerQueueItem?>("lastPlayed")
static let saveHistory = Key<Bool>("saveHistory", default: true)
static let showWatchingProgress = Key<Bool>("showWatchingProgress", default: true)
static let watchedThreshold = Key<Int>("watchedThreshold", default: 90)
static let watchedVideoStyle = Key<WatchedVideoStyle>("watchedVideoStyle", default: .badge)
static let watchedVideoBadgeColor = Key<WatchedVideoBadgeColor>("WatchedVideoBadgeColor", default: .red)
static let watchedVideoPlayNowBehavior = Key<WatchedVideoPlayNowBehavior>("watchedVideoPlayNowBehavior", default: .continue)
static let resetWatchedStatusOnPlaying = Key<Bool>("resetWatchedStatusOnPlaying", default: false)
static let saveRecents = Key<Bool>("saveRecents", default: true)
static let trendingCategory = Key<TrendingCategory>("trendingCategory", default: .default)
@@ -55,6 +82,13 @@ extension Defaults.Keys {
#if os(macOS)
static let enableBetaChannel = Key<Bool>("enableBetaChannel", default: false)
#endif
#if os(iOS)
static let honorSystemOrientationLock = Key<Bool>("honorSystemOrientationLock", default: true)
static let enterFullscreenInLandscape = Key<Bool>("enterFullscreenInLandscape", default: UIDevice.current.userInterfaceIdiom == .phone)
static let lockLandscapeOnRotation = Key<Bool>("lockLandscapeOnRotation", default: false)
static let lockLandscapeWhenEnteringFullscreen = Key<Bool>("lockLandscapeWhenEnteringFullscreen", default: false)
#endif
}
enum ResolutionSetting: String, CaseIterable, Defaults.Serializable {
@@ -72,7 +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
}
@@ -94,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
}
@@ -137,6 +167,18 @@ enum VisibleSection: String, CaseIterable, Comparable, Defaults.Serializable {
}
}
enum WatchedVideoStyle: String, Defaults.Serializable {
case nothing, badge, decreasedOpacity, both
}
enum WatchedVideoBadgeColor: String, Defaults.Serializable {
case colorSchemeBased, red, blue
}
enum WatchedVideoPlayNowBehavior: String, Defaults.Serializable {
case `continue`, restart
}
#if !os(tvOS)
enum CommentsPlacement: String, CaseIterable, Defaults.Serializable {
case info, separate

View File

@@ -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: LoadMoreContentHandlerClosure = { print("infinite load") }
}
typealias LoadMoreContentHandlerClosure = () -> 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: LoadMoreContentHandlerClosure {
get { self[LoadMoreContentHandler.self] }
set { self[LoadMoreContentHandler.self] = newValue }
}
}

View File

@@ -49,20 +49,22 @@ struct FavoriteItemView: View {
}
.contentShape(Rectangle())
.opacity(dragging?.id == item.id ? 0.5 : 1)
.onAppear {
resource?.addObserver(store)
resource?.load()
}
#if os(macOS)
.opacity(dragging?.id == item.id ? 0.5 : 1)
#endif
.onAppear {
resource?.addObserver(store)
resource?.load()
}
#if !os(tvOS)
.onDrag {
dragging = item
return NSItemProvider(object: item.id as NSString)
}
.onDrop(
of: [UTType.text],
delegate: DropFavorite(item: item, favorites: $favorites, current: $dragging)
)
.onDrag {
dragging = item
return NSItemProvider(object: item.id as NSString)
}
.onDrop(
of: [UTType.text],
delegate: DropFavorite(item: item, favorites: $favorites, current: $dragging)
)
#endif
}
}
@@ -111,12 +113,15 @@ struct FavoriteItemView: View {
return accounts.api.playlist(id)
case let .searchQuery(text, date, duration, order):
return accounts.api.search(.init(
query: text,
sortBy: SearchQuery.SortOrder(rawValue: order) ?? .uploadDate,
date: SearchQuery.Date(rawValue: date),
duration: SearchQuery.Duration(rawValue: duration)
))
return accounts.api.search(
.init(
query: text,
sortBy: SearchQuery.SortOrder(rawValue: order) ?? .uploadDate,
date: SearchQuery.Date(rawValue: date),
duration: SearchQuery.Duration(rawValue: duration)
),
page: nil
)
}
return nil

View File

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

View File

@@ -62,10 +62,18 @@ struct MenuCommands: Commands {
.disabled(model.player?.queue.isEmpty ?? true)
.keyboardShortcut("s")
Button((model.player?.presentingPlayer ?? true) ? "Hide Player" : "Show Player") {
Button(togglePlayerLabel) {
model.player?.togglePlayer()
}
.keyboardShortcut("o")
}
}
private var togglePlayerLabel: String {
#if os(macOS)
"Show Player"
#else
(model.player?.presentingPlayer ?? true) ? "Hide Player" : "Show Player"
#endif
}
}

View File

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

View File

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

View File

@@ -11,16 +11,17 @@ struct AppSidebarPlaylists: View {
NavigationLink(tag: TabSelection.playlist(playlist.id), selection: $navigation.tabSelection) {
LazyView(PlaylistVideosView(playlist))
} label: {
Label(playlist.title, systemImage: AppSidebarNavigation.symbolSystemImage(playlist.title))
Label(playlist.title, systemImage: RecentsModel.symbolSystemImage(playlist.title))
.backport
.badge(Text("\(playlist.videos.count)"))
}
.id(playlist.id)
.contextMenu {
Button("Add to queue...") {
playlists.find(id: playlist.id)?.videos.forEach { video in
player.enqueueVideo(video)
}
Button("Play All") {
player.play(playlists.find(id: playlist.id)?.videos ?? [])
}
Button("Shuffle All") {
player.play(playlists.find(id: playlist.id)?.videos ?? [], shuffling: true)
}
Button("Edit") {
navigation.presentEditPlaylistForm(playlists.find(id: playlist.id))

View File

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

View File

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

View File

@@ -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) {
@@ -42,32 +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)
}
}
@@ -76,7 +83,8 @@ struct AppTabNavigation: View {
.background(
EmptyView().fullScreenCover(isPresented: $player.presentingPlayer) {
videoPlayer
.environment(\.navigationStyle, .sidebar)
.environment(\.managedObjectContext, persistenceController.container.viewContext)
.environment(\.navigationStyle, .tab)
}
)
}
@@ -160,7 +168,7 @@ struct AppTabNavigation: View {
private var playerNavigationLink: some View {
NavigationLink(isActive: $player.playerNavigationLinkActive, destination: {
VideoPlayerView()
videoPlayer
.environment(\.inNavigationView, true)
}) {
EmptyView()

View File

@@ -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
@@ -119,10 +139,6 @@ struct ContentView: View {
player.accounts = accounts
player.comments = comments
if !accounts.current.isNil {
player.loadHistoryDetails()
}
if !Defaults[.saveRecents] {
recents.clear()
}
@@ -136,6 +152,9 @@ struct ContentView: View {
#endif
navigation.tabSelection = section ?? .search
subscriptions.load()
playlists.load()
}
func openWelcomeScreenIfAccountEmpty() {
@@ -145,28 +164,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 {

View File

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

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

View File

@@ -1,65 +1,81 @@
import SDWebImageSwiftUI
import SwiftUI
struct CommentView: View {
let comment: Comment
@Binding var repliesID: Comment.ID?
@State private var subscribed = false
#if os(iOS)
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
#endif
@Environment(\.colorScheme) private var colorScheme
@Environment(\.navigationStyle) private var navigationStyle
@EnvironmentObject<CommentsModel> private var comments
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<RecentsModel> private var recents
@EnvironmentObject<SubscriptionsModel> private var subscriptions
var body: some View {
VStack(alignment: .leading) {
HStack(alignment: .center, spacing: 10) {
authorAvatar
HStack(spacing: 10) {
ZStack(alignment: .bottomTrailing) {
authorAvatar
#if os(iOS)
Group {
if horizontalSizeClass == .regular {
HStack(spacing: 20) {
authorAndTime
Spacer()
Group {
statusIcons
likes
}
}
} else {
HStack(alignment: .center, spacing: 20) {
authorAndTime
Spacer()
VStack(alignment: .trailing, spacing: 8) {
likes
statusIcons
}
}
if subscribed {
Image(systemName: "star.circle.fill")
#if os(tvOS)
.background(Color.background(scheme: colorScheme))
#else
.background(Color.background)
#endif
.clipShape(Circle())
.foregroundColor(.secondary)
}
}
.font(.system(size: 15))
.onAppear {
subscribed = subscriptions.isSubscribing(comment.channel.id)
}
#else
HStack(spacing: 20) {
authorAndTime
authorAndTime
}
.contextMenu {
Button(action: openChannelAction) {
Label("\(comment.channel.name) Channel", systemImage: "rectangle.stack.fill.badge.person.crop")
}
}
Spacer()
Spacer()
Group {
#if os(iOS)
if horizontalSizeClass == .regular {
Group {
statusIcons
likes
}
} else {
VStack(alignment: .trailing, spacing: 8) {
likes
statusIcons
}
}
#else
statusIcons
likes
}
#endif
#endif
}
}
#if os(tvOS)
.font(.system(size: 25).bold())
#else
.font(.system(size: 15))
#endif
Group {
commentText
@@ -91,26 +107,28 @@ struct CommentView: View {
.placeholder {
Rectangle().fill(Color("PlaceholderColor"))
}
.retryOnAppear(false)
.retryOnAppear(true)
.indicator(.activity)
.mask(RoundedRectangle(cornerRadius: 60))
.frame(width: 45, height: 45, alignment: .leading)
.contextMenu {
Button(action: openChannelAction) {
Label("\(comment.channel.name) Channel", systemImage: "rectangle.stack.fill.badge.person.crop")
}
}
#if os(tvOS)
.frame(width: 80, height: 80, alignment: .leading)
.focusable()
#else
.frame(width: 45, height: 45, alignment: .leading)
#endif
}
private var authorAndTime: some View {
VStack(alignment: .leading) {
Text(comment.author)
.fontWeight(.bold)
#if os(tvOS)
.font(.system(size: 30).bold())
#else
.font(.system(size: 14).bold())
#endif
Text(comment.time)
.font(.caption2)
.foregroundColor(.secondary)
}
.lineLimit(1)
@@ -125,6 +143,9 @@ struct CommentView: View {
Image(systemName: "heart.fill")
}
}
#if !os(tvOS)
.font(.system(size: 12))
#endif
.foregroundColor(.secondary)
}
@@ -135,6 +156,9 @@ struct CommentView: View {
Image(systemName: "hand.thumbsup")
Text("\(comment.likeCount.formattedAsAbbreviation())")
}
#if !os(tvOS)
.font(.system(size: 12))
#endif
}
}
.foregroundColor(.secondary)
@@ -163,6 +187,7 @@ struct CommentView: View {
#if os(tvOS)
.padding(.leading, 5)
#else
.font(.system(size: 13))
.foregroundColor(.secondary)
#endif
}
@@ -181,7 +206,7 @@ struct CommentView: View {
#if os(macOS)
0.4
#else
0.8
0.6
#endif
}
@@ -226,17 +251,13 @@ struct CommentView: View {
}
private func openChannelAction() {
player.presentingPlayer = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
let recent = RecentItem(from: comment.channel)
recents.add(recent)
navigation.presentingChannel = true
if navigationStyle == .sidebar {
navigation.sidebarSectionChanged.toggle()
navigation.tabSelection = .recentlyOpened(recent.tag)
}
}
NavigationModel.openChannel(
comment.channel,
player: player,
recents: recents,
navigation: navigation,
navigationStyle: navigationStyle
)
}
}
@@ -247,5 +268,7 @@ struct CommentView_Previews: PreviewProvider {
static var previews: some View {
CommentView(comment: fixture, repliesID: .constant(fixture.id))
.environmentObject(SubscriptionsModel())
.padding(5)
}
}

View File

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

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

View File

@@ -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,7 +99,7 @@ struct PlaybackBar: View {
return "LIVE"
}
guard player.time != nil, player.time!.isValid, !player.currentVideo.isNil else {
guard !player.isLoadingVideo else {
return "loading..."
}

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import AVKit
import Logging
import Defaults
import SwiftUI
final class PlayerViewController: UIViewController {
@@ -7,11 +7,14 @@ final class PlayerViewController: UIViewController {
var commentsModel: CommentsModel!
var navigationModel: NavigationModel!
var playerModel: PlayerModel!
var playerViewController = AVPlayerViewController()
var subscriptionsModel: SubscriptionsModel!
var playerView = AVPlayerViewController()
let persistenceController = PersistenceController.shared
#if !os(tvOS)
var aspectRatio: Double? {
let ratio = Double(playerViewController.videoBounds.width) / Double(playerViewController.videoBounds.height)
let ratio = Double(playerView.videoBounds.width) / Double(playerView.videoBounds.height)
guard ratio.isFinite else {
return VideoPlayerView.defaultAspectRatio // swiftlint:disable:this implicit_return
@@ -27,34 +30,56 @@ final class PlayerViewController: UIViewController {
loadPlayer()
#if os(tvOS)
if !playerViewController.isBeingPresented, !playerViewController.isBeingDismissed {
present(playerViewController, animated: false)
if !playerView.isBeingPresented, !playerView.isBeingDismissed {
present(playerView, animated: false)
}
#endif
}
#if os(tvOS)
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
if !playerModel.presentingPlayer, !Defaults[.pauseOnHidingPlayer], !playerModel.isPlaying {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.playerModel.play()
}
}
}
#endif
func loadPlayer() {
guard !playerLoaded else {
return
}
playerModel.controller = self
playerViewController.player = playerModel.player
playerViewController.allowsPictureInPicturePlayback = true
playerViewController.delegate = self
playerView.player = playerModel.player
playerView.allowsPictureInPicturePlayback = true
#if os(iOS)
if #available(iOS 14.2, *) {
playerView.canStartPictureInPictureAutomaticallyFromInline = true
}
#endif
playerView.delegate = self
#if os(tvOS)
playerModel.avPlayerViewController = playerViewController
var infoViewControllers = [UIHostingController<AnyView>]()
if CommentsModel.enabled {
infoViewControllers.append(infoViewController([.comments], title: "Comments"))
}
var queueSections = [NowPlayingView.ViewSection.playingNext]
if Defaults[.showHistoryInPlayer] {
queueSections.append(.playedPreviously)
}
infoViewControllers.append(contentsOf: [
infoViewController([.related], title: "Related"),
infoViewController([.playingNext, .playedPreviously], title: "Playing Next")
infoViewController(queueSections, title: "Queue")
])
playerViewController.customInfoViewControllers = infoViewControllers
playerView.customInfoViewControllers = infoViewControllers
#else
embedViewController()
#endif
@@ -71,6 +96,8 @@ final class PlayerViewController: UIViewController {
.frame(maxHeight: 600)
.environmentObject(commentsModel)
.environmentObject(playerModel)
.environmentObject(subscriptionsModel)
.environment(\.managedObjectContext, persistenceController.container.viewContext)
)
)
@@ -80,12 +107,12 @@ final class PlayerViewController: UIViewController {
}
#else
func embedViewController() {
playerViewController.view.frame = view.bounds
playerView.view.frame = view.bounds
addChild(playerViewController)
view.addSubview(playerViewController.view)
addChild(playerView)
view.addSubview(playerView.view)
playerViewController.didMove(toParent: self)
playerView.didMove(toParent: self)
}
#endif
}
@@ -99,26 +126,50 @@ extension PlayerViewController: AVPlayerViewControllerDelegate {
true
}
func playerViewControllerWillBeginDismissalTransition(_: AVPlayerViewController) {}
func playerViewControllerDidEndDismissalTransition(_: AVPlayerViewController) {
func playerViewControllerWillBeginDismissalTransition(_: AVPlayerViewController) {
if Defaults[.pauseOnHidingPlayer] {
playerModel.pause()
}
dismiss(animated: false)
}
func playerViewControllerDidEndDismissalTransition(_: AVPlayerViewController) {}
func playerViewController(
_: AVPlayerViewController,
willBeginFullScreenPresentationWithAnimationCoordinator _: UIViewControllerTransitionCoordinator
) {}
willBeginFullScreenPresentationWithAnimationCoordinator context: UIViewControllerTransitionCoordinator
) {
playerModel.playingFullscreen = true
#if os(iOS)
if !context.isCancelled, Defaults[.lockLandscapeWhenEnteringFullscreen] {
Orientation.lockOrientation(.landscape, andRotateTo: UIDevice.current.orientation.isLandscape ? nil : .landscapeRight)
}
#endif
}
func playerViewController(
_: AVPlayerViewController,
willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator
) {
let wasPlaying = playerModel.isPlaying
coordinator.animate(alongsideTransition: nil) { context in
#if os(iOS)
if wasPlaying {
self.playerModel.play()
}
#endif
if !context.isCancelled {
#if os(iOS)
if self.traitCollection.verticalSizeClass == .compact {
self.dismiss(animated: true)
self.playerModel.lockedOrientation = nil
if Defaults[.enterFullscreenInLandscape] {
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
}
self.playerModel.playingFullscreen = false
if wasPlaying {
self.playerModel.play()
}
#endif
}
@@ -126,19 +177,19 @@ extension PlayerViewController: AVPlayerViewControllerDelegate {
}
func playerViewController(
_ playerViewController: AVPlayerViewController,
_: AVPlayerViewController,
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void
) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
if self.navigationModel.presentingChannel {
self.playerModel.playerNavigationLinkActive = true
} else {
self.playerModel.presentPlayer()
self.playerModel.show()
}
#if os(tvOS)
if self.playerModel.playingInPictureInPicture {
self.present(playerViewController, animated: false) {
self.present(self.playerView, animated: false) {
completionHandler(true)
}
}

View File

@@ -1,5 +1,6 @@
import Defaults
import Foundation
import SDWebImageSwiftUI
import SwiftUI
struct VideoDetails: View {
@@ -20,11 +21,15 @@ struct VideoDetails: View {
@Environment(\.presentationMode) private var presentationMode
@Environment(\.inNavigationView) private var inNavigationView
@Environment(\.navigationStyle) private var navigationStyle
@EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<RecentsModel> private var recents
@EnvironmentObject<SubscriptionsModel> private var subscriptions
@Default(.showChannelSubscribers) private var showChannelSubscribers
@Default(.showKeywords) private var showKeywords
init(
@@ -36,7 +41,7 @@ struct VideoDetails: View {
}
var video: Video? {
player.currentItem?.video
player.currentVideo
}
var body: some View {
@@ -65,7 +70,9 @@ struct VideoDetails: View {
}
.padding(.horizontal)
if CommentsModel.enabled, CommentsModel.placement == .separate {
if !sidebarQueue ||
(CommentsModel.enabled && CommentsModel.placement == .separate)
{
pagePicker
.padding(.horizontal)
}
@@ -90,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)
}
}
@@ -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,21 +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")
HStack(spacing: 10) {
Group {
ZStack(alignment: .bottomTrailing) {
authorAvatar
if subscribed {
Image(systemName: "star.circle.fill")
.background(Color.background)
.clipShape(Circle())
.foregroundColor(.secondary)
}
}
VStack(alignment: .leading) {
Text(video!.channel.name)
.font(.system(size: 14))
.bold()
if showChannelSubscribers {
Group {
if let subscribers = video!.channel.subscriptionsString {
Text("\(subscribers) subscribers")
}
}
.foregroundColor(.secondary)
.font(.caption2)
}
}
}
}
.contentShape(RoundedRectangle(cornerRadius: 12))
.contextMenu {
if let video = video {
Button(action: {
NavigationModel.openChannel(
video.channel,
player: player,
recents: recents,
navigation: navigation,
navigationStyle: navigationStyle
)
}) {
Label("\(video.channel.name) Channel", systemImage: "rectangle.stack.fill.badge.person.crop")
}
}
}
.foregroundColor(.secondary)
if accounts.app.supportsSubscriptions {
Spacer()
@@ -209,7 +251,7 @@ struct VideoDetails: View {
.alert(isPresented: $presentingUnsubscribeAlert) {
Alert(
title: Text(
"Are you you want to unsubscribe from \(video!.channel.name)?"
"Are you sure you want to unsubscribe from \(video!.channel.name)?"
),
primaryButton: .destructive(Text("Unsubscribe")) {
subscriptions.unsubscribe(video!.channel.id)
@@ -246,8 +288,7 @@ struct VideoDetails: View {
if !video.isNil {
Text("Info").tag(Page.info)
if CommentsModel.enabled, CommentsModel.placement == .separate {
Text("Comments")
.tag(Page.comments)
Text("Comments").tag(Page.comments)
}
if !sidebarQueue {
Text("Related").tag(Page.related)
@@ -315,28 +356,32 @@ struct VideoDetails: View {
if let likes = video.likesCount {
Divider()
.frame(minHeight: 35)
videoDetail(label: "Likes", value: likes, symbol: "hand.thumbsup")
}
if let dislikes = video.dislikesCount {
Divider()
.frame(minHeight: 35)
videoDetail(label: "Dislikes", value: dislikes, symbol: "hand.thumbsdown")
}
Spacer()
if accounts.app.supportsUserPlaylists {
Button {
presentingAddToPlaylist = true
} label: {
Label("Add to Playlist", systemImage: "text.badge.plus")
.labelStyle(.iconOnly)
.help("Add to Playlist...")
}
.buttonStyle(.plain)
Button {
presentingAddToPlaylist = true
} label: {
Label("Add to Playlist", systemImage: "text.badge.plus")
.labelStyle(.iconOnly)
.help("Add to Playlist...")
}
.buttonStyle(.plain)
.opacity(accounts.app.supportsUserPlaylists ? 1 : 0)
#if os(macOS)
.frame(minWidth: 35, alignment: .trailing)
#endif
}
.frame(maxHeight: 35)
.foregroundColor(.secondary)
@@ -364,11 +409,27 @@ struct VideoDetails: View {
ContentItem(video: player.currentVideo!)
}
private var authorAvatar: some View {
Group {
if let video = video, let url = video.channel.thumbnailURL {
WebImage(url: url)
.resizable()
.placeholder {
Rectangle().fill(Color("PlaceholderColor"))
}
.retryOnAppear(true)
.indicator(.activity)
.clipShape(Circle())
.frame(width: 45, height: 45, alignment: .leading)
}
}
}
var detailsPage: some View {
Group {
Group {
if let video = player.currentItem?.video {
Group {
if let video = player.currentVideo {
VStack(spacing: 6) {
HStack {
publishedDateSection
Spacer()
@@ -377,9 +438,9 @@ struct VideoDetails: View {
Divider()
countsSection
}
Divider()
Divider()
}
VStack(alignment: .leading, spacing: 10) {
if let description = video.description {
@@ -414,7 +475,7 @@ struct VideoDetails: View {
.foregroundColor(.white)
.padding(.vertical, 4)
.padding(.horizontal, 8)
.background(Color("VideoDetailLikesSymbolColor"))
.background(Color("KeywordBackgroundColor"))
.mask(RoundedRectangle(cornerRadius: 3))
}
}
@@ -435,7 +496,7 @@ struct VideoDetails: View {
}
.padding(.horizontal)
Group {
LazyVStack {
if !video.isNil, CommentsModel.placement == .info {
CommentsView()
}

View File

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

View File

@@ -7,8 +7,12 @@ struct AddToPlaylistView: View {
@State private var selectedPlaylistID: Playlist.ID = ""
@State private var error = ""
@State private var presentingErrorAlert = false
@Environment(\.colorScheme) private var colorScheme
@Environment(\.presentationMode) private var presentationMode
@EnvironmentObject<PlaylistsModel> private var model
var body: some View {
@@ -120,6 +124,12 @@ struct AddToPlaylistView: View {
Button("Add to Playlist", action: addToPlaylist)
.disabled(selectedPlaylist.isNil)
.padding(.top, 30)
.alert(isPresented: $presentingErrorAlert) {
Alert(
title: Text("Error when accessing playlist"),
message: Text(error)
)
}
#if !os(tvOS)
.keyboardShortcut(.defaultAction)
#endif
@@ -155,9 +165,17 @@ struct AddToPlaylistView: View {
Defaults[.lastUsedPlaylistID] = id
model.addVideo(playlistID: id, videoID: video.videoID) {
presentationMode.wrappedValue.dismiss()
}
model.addVideo(
playlistID: id,
videoID: video.videoID,
onSuccess: {
presentationMode.wrappedValue.dismiss()
},
onFailure: { requestError in
error = "(\(requestError.httpStatusCode ?? -1)) \(requestError.userMessage)"
presentingErrorAlert = true
}
)
}
private var selectedPlaylist: Playlist? {

View File

@@ -8,7 +8,10 @@ struct PlaylistFormView: View {
@State private var visibility = Playlist.Visibility.public
@State private var valid = false
@State private var showingDeleteConfirmation = false
@State private var presentingDeleteConfirmation = false
@State private var formError = ""
@State private var presentingErrorAlert = false
@Environment(\.colorScheme) private var colorScheme
@Environment(\.presentationMode) private var presentationMode
@@ -57,6 +60,12 @@ struct PlaylistFormView: View {
Button("Save", action: submitForm)
.disabled(!valid)
.alert(isPresented: $presentingErrorAlert) {
Alert(
title: Text("Error when accessing playlist"),
message: Text(formError)
)
}
.keyboardShortcut(.defaultAction)
}
.frame(minHeight: 35)
@@ -165,15 +174,21 @@ struct PlaylistFormView: View {
let body = ["title": name, "privacy": visibility.rawValue]
resource?.request(editing ? .patch : .post, json: body).onSuccess { response in
if let modifiedPlaylist: Playlist = response.typedContent() {
playlist = modifiedPlaylist
resource?
.request(editing ? .patch : .post, json: body)
.onSuccess { response in
if let modifiedPlaylist: Playlist = response.typedContent() {
playlist = modifiedPlaylist
}
playlists.load(force: true)
presentationMode.wrappedValue.dismiss()
}
.onFailure { error in
formError = "(\(error.httpStatusCode ?? -1)) \(error.userMessage)"
presentingErrorAlert = true
}
playlists.load(force: true)
presentationMode.wrappedValue.dismiss()
}
}
var resource: Resource? {
@@ -207,9 +222,9 @@ struct PlaylistFormView: View {
var deletePlaylistButton: some View {
Button("Delete") {
showingDeleteConfirmation = true
presentingDeleteConfirmation = true
}
.alert(isPresented: $showingDeleteConfirmation) {
.alert(isPresented: $presentingDeleteConfirmation) {
Alert(
title: Text("Are you sure you want to delete playlist?"),
message: Text("Playlist \"\(playlist.title)\" will be deleted.\nIt cannot be undone."),
@@ -221,11 +236,17 @@ struct PlaylistFormView: View {
}
func deletePlaylistAndDismiss() {
accounts.api.playlist(playlist.id)?.request(.delete).onSuccess { _ in
playlist = nil
playlists.load(force: true)
presentationMode.wrappedValue.dismiss()
}
accounts.api.playlist(playlist.id)?
.request(.delete)
.onSuccess { _ in
playlist = nil
playlists.load(force: true)
presentationMode.wrappedValue.dismiss()
}
.onFailure { error in
formError = "(\(error.httpStatusCode ?? -1)) \(error.localizedDescription)"
presentingErrorAlert = true
}
}
}

View File

@@ -74,25 +74,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 +81,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
}
@@ -127,6 +116,9 @@ struct PlaylistsView: View {
.onChange(of: accounts.current) { _ in
model.load(force: true)
}
#if os(iOS)
.navigationBarTitleDisplayMode(RefreshControl.navigationBarTitleDisplayMode)
#endif
}
#if os(tvOS)
@@ -142,23 +134,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 +162,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 +211,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 +221,9 @@ struct PlaylistsView: View {
}
}
}
} label: {
Text(currentPlaylist?.title ?? "Select playlist")
.frame(maxWidth: 140, alignment: .leading)
}
#endif
}
@@ -265,6 +251,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
}

View File

@@ -7,12 +7,7 @@ struct SearchSuggestions: View {
var body: some View {
List {
Button {
state.changeQuery { query in
query.query = state.queryText
state.fieldIsFocused = false
}
recents.addQuery(state.queryText)
runQueryAction()
} label: {
HStack {
Image(systemName: "magnifyingglass")
@@ -20,28 +15,48 @@ struct SearchSuggestions: View {
.lineLimit(1)
}
}
.padding(.vertical, 5)
#if os(macOS)
.onHover(perform: onHover(_:))
.onHover(perform: onHover(_:))
#endif
ForEach(visibleSuggestions, id: \.self) { suggestion in
Button {
state.queryText = suggestion
} label: {
HStack {
Image(systemName: "arrow.up.left.circle")
.foregroundColor(.secondary)
HStack(spacing: 0) {
Text(state.suggestionsText)
.lineLimit(1)
.layoutPriority(2)
.foregroundColor(.secondary)
HStack {
Button {
state.queryText = suggestion
runQueryAction()
} label: {
HStack {
Image(systemName: "magnifyingglass")
HStack(spacing: 0) {
Text(state.suggestionsText)
.lineLimit(1)
.layoutPriority(2)
.foregroundColor(.secondary)
Text(querySuffix(suggestion))
.lineLimit(1)
.layoutPriority(1)
Text(querySuffix(suggestion))
.lineLimit(1)
.layoutPriority(1)
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
}
}
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.buttonStyle(.plain)
Spacer()
Button {
state.queryText = suggestion
} label: {
Image(systemName: "arrow.up.left.circle")
.foregroundColor(.secondary)
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.buttonStyle(.plain)
}
#if os(macOS)
.onHover(perform: onHover(_:))
@@ -53,6 +68,15 @@ struct SearchSuggestions: View {
#endif
}
private func runQueryAction() {
state.changeQuery { query in
query.query = state.queryText
state.fieldIsFocused = false
}
recents.addQuery(state.queryText)
}
private var visibleSuggestions: [String] {
state.querySuggestions.collection.filter {
$0.compare(state.queryText, options: .caseInsensitive) != .orderedSame

View File

@@ -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
@@ -199,10 +201,12 @@ struct SearchView: View {
}
HorizontalCells(items: items)
.environment(\.loadMoreContentHandler) { state.loadNextPage() }
}
.edgesIgnoringSafeArea(.horizontal)
#else
VerticalCells(items: items)
.environment(\.loadMoreContentHandler) { state.loadNextPage() }
#endif
if noResults {
@@ -253,14 +257,51 @@ struct SearchView: View {
.foregroundColor(.secondary)
}
ForEach(recentItems) { item in
Button(item.title) {
state.queryText = item.title
state.changeQuery { query in query.query = item.title }
updateFavoriteItem()
Button {
switch item.type {
case .query:
state.queryText = item.title
state.changeQuery { query in query.query = item.title }
updateFavoriteItem()
recents.add(item)
case .channel:
guard let channel = item.channel else {
return
}
NavigationModel.openChannel(
channel,
player: player,
recents: recents,
navigation: navigation,
navigationStyle: navigationStyle,
delay: false
)
case .playlist:
guard let playlist = item.playlist else {
return
}
NavigationModel.openChannelPlaylist(
playlist,
player: player,
recents: recents,
navigation: navigation,
navigationStyle: navigationStyle,
delay: false
)
}
} label: {
let systemImage = item.type == .query ? "magnifyingglass" :
item.type == .channel ? RecentsModel.symbolSystemImage(item.title) :
"list.and.film"
Label(item.title, systemImage: systemImage)
.lineLimit(1)
}
.contextMenu {
deleteButton(item)
deleteAllButton
removeButton(item)
removeAllButton
}
}
}
@@ -272,21 +313,21 @@ struct SearchView: View {
#endif
}
private func deleteButton(_ item: RecentItem) -> some View {
private func removeButton(_ item: RecentItem) -> some View {
Button {
recents.close(item)
recentsChanged.toggle()
} label: {
Label("Delete", systemImage: "trash")
Label("Remove", systemImage: "trash")
}
}
private var deleteAllButton: some View {
private var removeAllButton: some View {
Button {
recents.clearQueries()
recentsChanged.toggle()
} label: {
Label("Delete All", systemImage: "trash.fill")
Label("Remove All", systemImage: "trash.fill")
}
}
@@ -295,7 +336,7 @@ struct SearchView: View {
}
private var recentItems: [RecentItem] {
Defaults[.recentlyOpened].filter { $0.type == .query }.reversed()
Defaults[.recentlyOpened].reversed()
}
private var searchSortOrderPicker: some View {

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -0,0 +1,251 @@
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(.showChannelSubscribers) private var channelSubscribers
@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
channelSubscribersToggle
}
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 channelSubscribersToggle: some View {
Toggle("Show subscribers count", isOn: $channelSubscribers)
}
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()
}
}

View File

@@ -1,152 +0,0 @@
import Defaults
import SwiftUI
struct ServicesSettings: View {
@Default(.sponsorBlockInstance) private var sponsorBlockInstance
@Default(.sponsorBlockCategories) private var sponsorBlockCategories
@Default(.commentsInstanceID) private var commentsInstanceID
#if !os(tvOS)
@Default(.commentsPlacement) private var commentsPlacement
#endif
var body: some View {
Section(header: SettingsHeader(text: "Comments")) {
commentsInstancePicker
#if !os(tvOS)
commentsPlacementPicker
.disabled(!CommentsModel.enabled)
#endif
}
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("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
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()
}
}
}

View File

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

View File

@@ -5,8 +5,10 @@ import SwiftUI
struct SettingsView: View {
#if os(macOS)
private enum Tabs: Hashable {
case instances, browsing, playback, services, updates
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,20 +45,28 @@ 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()
@@ -65,20 +75,22 @@ struct SettingsView: View {
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")) {
@@ -88,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()
@@ -117,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")
}
}
}

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

View File

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

View File

@@ -4,6 +4,8 @@ import SwiftUI
struct HorizontalCells: View {
var items = [ContentItem]()
@Environment(\.loadMoreContentHandler) private var loadMoreContentHandler
@Default(.channelOnThumbnail) private var channelOnThumbnail
var body: some View {
@@ -12,6 +14,7 @@ struct HorizontalCells: View {
ForEach(items) { item in
ContentItemView(item: item)
.environment(\.horizontalCells, true)
.onAppear { loadMoreContentItemsIfNeeded(current: item) }
#if os(tvOS)
.frame(width: 580)
.padding(.trailing, 20)
@@ -33,6 +36,13 @@ struct HorizontalCells: View {
.edgesIgnoringSafeArea(.horizontal)
}
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

View File

@@ -6,6 +6,8 @@ struct VerticalCells: View {
@Environment(\.verticalSizeClass) private var verticalSizeClass
#endif
@Environment(\.loadMoreContentHandler) private var loadMoreContentHandler
var items = [ContentItem]()
var body: some View {
@@ -13,17 +15,25 @@ struct VerticalCells: View {
LazyVGrid(columns: columns, alignment: .center) {
ForEach(items.sorted { $0 < $1 }) { 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
}
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

View File

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

View File

@@ -3,7 +3,7 @@ import SDWebImageSwiftUI
import SwiftUI
struct VideoCell: View {
var video: Video
private var video: Video
@Environment(\.inNavigationView) private var inNavigationView
@@ -18,27 +18,29 @@ struct VideoCell: View {
@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 +50,58 @@ 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() {
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 +115,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)
@@ -151,7 +215,7 @@ struct VideoCell: View {
}
#endif
var verticalRow: some View {
private var verticalRow: some View {
VStack(alignment: .leading, spacing: 0) {
thumbnail
@@ -201,7 +265,7 @@ struct VideoCell: View {
}
}
if let time = video.length.formattedAsPlaybackTime(), !timeOnThumbnail {
if let time = time, !timeOnThumbnail {
Spacer()
HStack(spacing: 2) {
@@ -225,13 +289,30 @@ struct VideoCell: View {
}
}
var additionalDetailsAvailable: Bool {
video.publishedDate != nil || video.views != 0 || (!timeOnThumbnail && !video.length.formattedAsPlaybackTime().isNil)
private var additionalDetailsAvailable: Bool {
video.publishedDate != nil || video.views != 0 ||
(!timeOnThumbnail && !video.length.formattedAsPlaybackTime().isNil)
}
var thumbnail: some View {
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) {
@@ -247,24 +328,52 @@ struct VideoCell: View {
DetailBadge(text: video.author, style: .prominent)
}
}
#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 +381,7 @@ struct VideoCell: View {
.placeholder {
Rectangle().fill(Color("PlaceholderColor"))
}
.retryOnAppear(false)
.retryOnAppear(true)
.onFailure { _ in
thumbnails.insertUnloadable(url)
}
@@ -289,11 +398,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 +440,10 @@ struct VideoCell: View {
content
} else {
content
.aspectRatio(1.777, contentMode: .fill)
.aspectRatio(
VideoPlayerView.defaultAspectRatio,
contentMode: .fill
)
}
}
}

View File

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

View File

@@ -10,12 +10,10 @@ struct ChannelPlaylistView: View {
@StateObject private var store = Store<ChannelPlaylist>()
@Environment(\.colorScheme) private var colorScheme
#if os(iOS)
@Environment(\.inNavigationView) private var inNavigationView
#endif
@Environment(\.inNavigationView) private var inNavigationView
@EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<PlayerModel> private var player
var items: [ContentItem] {
ContentItem.array(of: store.item?.videos ?? [])
@@ -53,9 +51,15 @@ struct ChannelPlaylistView: View {
FavoriteButton(item: FavoriteItem(section: .channelPlaylist(playlist.id, playlist.title)))
.labelStyle(.iconOnly)
playButton
.labelStyle(.iconOnly)
shuffleButton
.labelStyle(.iconOnly)
}
#endif
VerticalCells(items: items)
.environment(\.inChannelPlaylistView, true)
}
#if os(iOS)
.sheet(isPresented: $presentingShareSheet) {
@@ -68,7 +72,9 @@ struct ChannelPlaylistView: View {
resource?.addObserver(store)
resource?.loadIfNeeded()
}
#if !os(tvOS)
#if os(tvOS)
.background(Color.background(scheme: colorScheme))
#else
.toolbar {
ToolbarItem(placement: .navigation) {
ShareButton(
@@ -78,17 +84,50 @@ struct ChannelPlaylistView: View {
)
}
ToolbarItem {
FavoriteButton(item: FavoriteItem(section: .channelPlaylist(playlist.id, playlist.title)))
ToolbarItem(placement: playlistButtonsPlacement) {
HStack {
FavoriteButton(item: FavoriteItem(section: .channelPlaylist(playlist.id, playlist.title)))
playButton
shuffleButton
}
}
}
.navigationTitle(playlist.title)
#else
.background(Color.background(scheme: colorScheme))
#if os(iOS)
.navigationBarHidden(player.playerNavigationLinkActive)
#endif
#endif
}
private var playlistButtonsPlacement: ToolbarItemPlacement {
#if os(iOS)
.navigationBarTrailing
#else
.automatic
#endif
}
private var playButton: some View {
Button {
player.play(videos, inNavigationView: inNavigationView)
} label: {
Label("Play All", systemImage: "play")
}
}
private var shuffleButton: some View {
Button {
player.play(videos, shuffling: true, inNavigationView: inNavigationView)
} label: {
Label("Shuffle", systemImage: "shuffle")
}
}
private var videos: [Video] {
items.compactMap(\.video)
}
private var contentItem: ContentItem {
ContentItem(playlist: playlist)
}

View File

@@ -10,11 +10,11 @@ struct ChannelVideosView: View {
@StateObject private var store = Store<Channel>()
@Environment(\.colorScheme) private var colorScheme
@Environment(\.presentationMode) private var presentationMode
@Environment(\.inNavigationView) private var inNavigationView
#if os(iOS)
@Environment(\.inNavigationView) private var inNavigationView
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@EnvironmentObject<PlayerModel> private var player
#endif
@EnvironmentObject<AccountsModel> private var accounts
@@ -66,46 +66,40 @@ struct ChannelVideosView: View {
.frame(maxWidth: .infinity)
#endif
#if os(iOS)
VerticalCells(items: videos)
#else
if #available(macOS 12.0, *) {
VerticalCells(items: videos)
.prefersDefaultFocus(in: focusNamespace)
} else {
VerticalCells(items: videos)
}
VerticalCells(items: videos)
.environment(\.inChannelView, true)
#if os(tvOS)
.prefersDefaultFocus(in: focusNamespace)
#endif
}
.environment(\.inChannelView, true)
#if !os(tvOS)
.toolbar {
ToolbarItem(placement: .navigation) {
ShareButton(
contentItem: contentItem,
presentingShareSheet: $presentingShareSheet,
shareURL: $shareURL
)
}
.toolbar {
ToolbarItem(placement: .navigation) {
ShareButton(
contentItem: contentItem,
presentingShareSheet: $presentingShareSheet,
shareURL: $shareURL
)
}
ToolbarItem {
HStack {
HStack(spacing: 3) {
Text("\(store.item?.subscriptionsString ?? "loading")")
.fontWeight(.bold)
Text(" subscribers")
}
.allowsTightening(true)
.foregroundColor(.secondary)
.opacity(store.item?.subscriptionsString != nil ? 1 : 0)
subscriptionToggleButton
FavoriteButton(item: FavoriteItem(section: .channel(channel.id, channel.name)))
ToolbarItem {
HStack {
HStack(spacing: 3) {
Text("\(store.item?.subscriptionsString ?? "loading")")
.fontWeight(.bold)
Text(" subscribers")
}
.allowsTightening(true)
.foregroundColor(.secondary)
.opacity(store.item?.subscriptionsString != nil ? 1 : 0)
subscriptionToggleButton
FavoriteButton(item: FavoriteItem(section: .channel(channel.id, channel.name)))
}
}
}
#endif
#if os(iOS)
.sheet(isPresented: $presentingShareSheet) {
@@ -120,6 +114,9 @@ struct ChannelVideosView: View {
resource.load()
}
}
#if os(iOS)
.navigationBarHidden(player.playerNavigationLinkActive)
#endif
.navigationTitle(navigationTitle)
return Group {

View File

@@ -1,3 +1,4 @@
import Defaults
import SwiftUI
struct DetailBadge: View {
@@ -33,10 +34,10 @@ struct DetailBadge: View {
.background(.thinMaterial)
} else {
content
#if os(tvOS)
.background(Color.background(scheme: colorScheme))
#else
.background(Color.background.opacity(0.95))
#if os(macOS)
.background(VisualEffectBlur(material: .hudWindow))
#elseif os(iOS)
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
#endif
}
}
@@ -82,12 +83,17 @@ struct DetailBadge: View {
var text: String
var style: Style = .default
@Default(.roundedThumbnails) private var roundedThumbnails
var body: some View {
Text(text)
.truncationMode(.middle)
.padding(10)
.padding(4)
#if os(tvOS)
.padding(.horizontal, 5)
#endif
.modifier(StyleModifier(style: style))
.mask(RoundedRectangle(cornerRadius: 12))
.mask(RoundedRectangle(cornerRadius: roundedThumbnails ? 6 : 0))
}
}

View File

@@ -3,6 +3,7 @@ import SwiftUI
struct LazyView<Content: View>: View {
let build: () -> Content
init(_ build: @autoclosure @escaping () -> Content) {
self.build = build
}

View File

@@ -0,0 +1,21 @@
import SwiftUI
struct PlaceholderProgressView: View {
var body: some View {
VStack {
Spacer()
HStack {
Spacer()
ProgressView()
Spacer()
}
Spacer()
}
}
}
struct PlaceholderProgressView_Previews: PreviewProvider {
static var previews: some View {
PlaceholderProgressView()
}
}

View File

@@ -1,5 +1,6 @@
import Foundation
import SwiftUI
struct PlayerControlsView<Content: View>: View {
let content: Content
@@ -27,20 +28,31 @@ struct PlayerControlsView<Content: View>: View {
private var controls: some View {
let controls = HStack {
Button(action: {
model.presentingPlayer.toggle()
model.togglePlayer()
}) {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text(model.currentItem?.video?.title ?? "Not playing")
Text(model.currentVideo?.title ?? "Not playing")
.font(.system(size: 14).bold())
.foregroundColor(model.currentItem.isNil ? .secondary : .accentColor)
.lineLimit(1)
Text(model.currentItem?.video?.author ?? "Yattee v\(appVersion) (build \(appBuild))")
.fontWeight(model.currentItem.isNil ? .light : .bold)
.font(.system(size: 10))
.foregroundColor(.secondary)
.lineLimit(1)
if let video = model.currentVideo {
Text(video.author)
.fontWeight(.bold)
.font(.system(size: 10))
.foregroundColor(.secondary)
.lineLimit(1)
}
}
.contextMenu {
Button {
model.closeCurrentItem()
} label: {
Label("Close Video", systemImage: "xmark.circle")
.labelStyle(.automatic)
}
.disabled(model.currentItem.isNil)
}
Spacer()
@@ -96,7 +108,7 @@ struct PlayerControlsView<Content: View>: View {
.borderBottom(height: navigationStyle == .sidebar ? 0 : 0.4, color: Color("ControlsBorderColor"))
#if !os(tvOS)
.onSwipeGesture(up: {
model.presentingPlayer = true
model.show()
})
#endif
@@ -106,27 +118,21 @@ struct PlayerControlsView<Content: View>: View {
.background(Material.ultraThinMaterial)
} else {
controls
#if !os(tvOS)
.background(Color.tertiaryBackground)
#if os(macOS)
.background(VisualEffectBlur(material: .hudWindow))
#elseif os(iOS)
.background(VisualEffectBlur(blurStyle: .systemUltraThinMaterial))
#endif
}
}
}
private var appVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
}
private var appBuild: String {
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown"
}
private var progressViewValue: Double {
[model.time?.seconds, model.videoDuration].compactMap { $0 }.min() ?? 0
}
private var progressViewTotal: Double {
model.playerItemDuration?.seconds ?? model.currentVideo?.length ?? progressViewValue
model.videoDuration ?? 100
}
}

View File

@@ -4,25 +4,54 @@ import SwiftUI
struct PlaylistVideosView: View {
let playlist: Playlist
var videos: [ContentItem] {
@Environment(\.inNavigationView) private var inNavigationView
@EnvironmentObject<PlayerModel> private var player
var contentItems: [ContentItem] {
ContentItem.array(of: playlist.videos)
}
var videos: [Video] {
contentItems.compactMap(\.video)
}
init(_ playlist: Playlist) {
self.playlist = playlist
}
var body: some View {
PlayerControlsView {
VerticalCells(items: videos)
VerticalCells(items: contentItems)
#if !os(tvOS)
.navigationTitle("\(playlist.title) Playlist")
#endif
}
.toolbar {
ToolbarItem {
FavoriteButton(item: FavoriteItem(section: .playlist(playlist.id)))
ToolbarItem(placement: playlistButtonsPlacement) {
HStack {
FavoriteButton(item: FavoriteItem(section: .channelPlaylist(playlist.id, playlist.title)))
Button {
player.play(videos, inNavigationView: inNavigationView)
} label: {
Label("Play All", systemImage: "play")
}
Button {
player.play(videos, shuffling: true, inNavigationView: inNavigationView)
} label: {
Label("Shuffle", systemImage: "shuffle")
}
}
}
}
}
private var playlistButtonsPlacement: ToolbarItemPlacement {
#if os(iOS)
.navigationBarTrailing
#else
.automatic
#endif
}
}

View File

@@ -30,5 +30,22 @@ struct PopularView: View {
FavoriteButton(item: FavoriteItem(section: .popular))
}
}
#if !os(tvOS)
.background(
Button("Refresh") {
resource?.load()
}
.keyboardShortcut("r")
.opacity(0)
)
#endif
#if os(iOS)
.refreshControl { refreshControl in
resource?.load().onCompletion { _ in
refreshControl.endRefreshing()
}
}
.navigationBarTitleDisplayMode(RefreshControl.navigationBarTitleDisplayMode)
#endif
}
}

View File

@@ -24,6 +24,13 @@ struct SubscriptionsView: View {
.onChange(of: accounts.current) { _ in
loadResources(force: true)
}
#if os(iOS)
.refreshControl { refreshControl in
loadResources(force: true) {
refreshControl.endRefreshing()
}
}
#endif
}
}
.toolbar {
@@ -31,26 +38,44 @@ struct SubscriptionsView: View {
FavoriteButton(item: FavoriteItem(section: .subscriptions))
}
}
#if !os(tvOS)
.background(
Button("Refresh") {
loadResources(force: true)
}
.keyboardShortcut("r")
.opacity(0)
)
#endif
#if os(iOS)
.navigationBarTitleDisplayMode(RefreshControl.navigationBarTitleDisplayMode)
#endif
}
fileprivate func loadResources(force: Bool = false) {
private func loadResources(force: Bool = false, onCompletion: @escaping () -> Void = {}) {
feed?.addObserver(store)
if accounts.app == .invidious {
// Invidious for some reason won't refresh feed until homepage is loaded
if let request = force ? accounts.api.home?.load() : accounts.api.home?.loadIfNeeded() {
request.onSuccess { _ in
loadFeed(force: force)
loadFeed(force: force, onCompletion: onCompletion)
}
} else {
loadFeed(force: force)
loadFeed(force: force, onCompletion: onCompletion)
}
} else {
loadFeed(force: force)
loadFeed(force: force, onCompletion: onCompletion)
}
}
fileprivate func loadFeed(force: Bool = false) {
_ = force ? feed?.load() : feed?.loadIfNeeded()
private func loadFeed(force: Bool = false, onCompletion: @escaping () -> Void = {}) {
if let request = force ? feed?.load() : feed?.loadIfNeeded() {
request.onCompletion { _ in
onCompletion()
}
} else {
onCompletion()
}
}
}

View File

@@ -1,3 +1,4 @@
import CoreData
import Defaults
import SwiftUI
@@ -8,6 +9,7 @@ struct VideoContextMenuView: View {
@Environment(\.inNavigationView) private var inNavigationView
@Environment(\.inChannelView) private var inChannelView
@Environment(\.inChannelPlaylistView) private var inChannelPlaylistView
@Environment(\.navigationStyle) private var navigationStyle
@Environment(\.currentPlaylistID) private var playlistID
@@ -18,7 +20,35 @@ struct VideoContextMenuView: View {
@EnvironmentObject<RecentsModel> private var recents
@EnvironmentObject<SubscriptionsModel> private var subscriptions
@FetchRequest private var watchRequest: FetchedResults<Watch>
@Default(.saveHistory) private var saveHistory
private var viewContext: NSManagedObjectContext = PersistenceController.shared.container.viewContext
init(video: Video, playerNavigationLinkActive: Binding<Bool>) {
self.video = video
_playerNavigationLinkActive = playerNavigationLinkActive
_watchRequest = video.watchFetchRequest
}
var body: some View {
if saveHistory {
Section {
if let watchedAtString = watchedAtString {
Text(watchedAtString)
}
if !watch.isNil, !watch!.finished, !watchingNow {
continueButton
}
if !watch.isNil, !watchingNow {
removeFromHistoryButton
}
}
}
Section {
playNowButton
}
@@ -28,11 +58,11 @@ struct VideoContextMenuView: View {
addToQueueButton
}
if !inChannelView {
if !inChannelView, !inChannelPlaylistView {
Section {
openChannelButton
if accounts.app.supportsSubscriptions {
if accounts.app.supportsSubscriptions, accounts.api.signedIn {
subscriptionButton
}
}
@@ -53,19 +83,49 @@ struct VideoContextMenuView: View {
#endif
}
private var playNowButton: some View {
Button {
player.playNow(video)
private var watch: Watch? {
watchRequest.first
}
guard !player.playingInPictureInPicture else {
private var watchingNow: Bool {
player.currentVideo == video
}
private var watchedAtString: String? {
if watchingNow {
return "Watching now"
}
if let watch = watch, let watchedAtString = watch.watchedAtString {
return "Watched \(watchedAtString)"
}
return nil
}
private var continueButton: some View {
Button {
player.play(video, at: watch!.stoppedAt, inNavigationView: inNavigationView)
} label: {
Label("Continue from \(watch!.stoppedAt.formattedAsPlaybackTime() ?? "where I left off")", systemImage: "playpause")
}
}
var removeFromHistoryButton: some View {
Button {
guard let watch = watch else {
return
}
if inNavigationView {
playerNavigationLinkActive = true
} else {
player.presentPlayer()
}
player.removeWatch(watch)
} label: {
Label("Remove from history", systemImage: "delete.left.fill")
}
}
private var playNowButton: some View {
Button {
player.play(video, inNavigationView: inNavigationView)
} label: {
Label("Play Now", systemImage: "play")
}
@@ -79,14 +139,6 @@ struct VideoContextMenuView: View {
}
}
private var isShowingChannelButton: Bool {
if case .channel = navigation.tabSelection {
return false
}
return !inChannelView
}
private var addToQueueButton: some View {
Button {
player.enqueueVideo(video)
@@ -97,14 +149,13 @@ struct VideoContextMenuView: View {
private var openChannelButton: some View {
Button {
let recent = RecentItem(from: video.channel)
recents.add(recent)
navigation.presentingChannel = true
if navigationStyle == .sidebar {
navigation.sidebarSectionChanged.toggle()
navigation.tabSelection = .recentlyOpened(recent.tag)
}
NavigationModel.openChannel(
video.channel,
player: player,
recents: recents,
navigation: navigation,
navigationStyle: navigationStyle
)
} label: {
Label("\(video.author) Channel", systemImage: "rectangle.stack.fill.badge.person.crop")
}

View File

@@ -3,20 +3,71 @@ import SwiftUI
@main
struct YatteeApp: App {
static var version: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
}
static var build: String {
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown"
}
#if os(macOS)
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@StateObject private var updater = UpdaterModel()
#elseif os(iOS)
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
#endif
@StateObject private var accounts = AccountsModel()
@StateObject private var comments = CommentsModel()
@StateObject private var instances = InstancesModel()
@StateObject private var menu = MenuModel()
@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 thumbnails = ThumbnailsModel()
let persistenceController = PersistenceController.shared
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.managedObjectContext, persistenceController.container.viewContext)
.environmentObject(accounts)
.environmentObject(comments)
.environmentObject(instances)
.environmentObject(navigation)
.environmentObject(player)
.environmentObject(playlists)
.environmentObject(recents)
.environmentObject(subscriptions)
.environmentObject(thumbnails)
.environmentObject(menu)
.environmentObject(search)
#if os(macOS)
.background(
HostingWindowFinder { window in
Windows.mainWindow = window
}
)
#else
.onReceive(
NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)
) { _ in
player.handleEnterForeground()
}
#endif
#if os(iOS)
.handlesExternalEvents(preferring: Set(["*"]), allowing: Set(["*"]))
#endif
}
#if !os(tvOS)
#if os(iOS)
.handlesExternalEvents(matching: Set(["*"]))
#endif
#if !os(tvOS)
.commands {
SidebarCommands()
@@ -34,10 +85,36 @@ struct YatteeApp: App {
#endif
#if os(macOS)
WindowGroup(player.windowTitle) {
VideoPlayerView()
.background(
HostingWindowFinder { window in
Windows.playerWindow = window
}
)
.onAppear { player.presentingPlayer = true }
.onDisappear { player.presentingPlayer = false }
.environment(\.managedObjectContext, persistenceController.container.viewContext)
.environment(\.navigationStyle, .sidebar)
.environmentObject(accounts)
.environmentObject(comments)
.environmentObject(instances)
.environmentObject(navigation)
.environmentObject(player)
.environmentObject(playlists)
.environmentObject(recents)
.environmentObject(subscriptions)
.environmentObject(thumbnails)
.handlesExternalEvents(preferring: Set(["player", "*"]), allowing: Set(["player", "*"]))
}
.handlesExternalEvents(matching: Set(["player", "*"]))
Settings {
SettingsView()
.environmentObject(AccountsModel())
.environmentObject(InstancesModel())
.environment(\.managedObjectContext, persistenceController.container.viewContext)
.environmentObject(accounts)
.environmentObject(instances)
.environmentObject(player)
.environmentObject(updater)
}
#endif

View File

@@ -0,0 +1,15 @@
//
// UIResponder+Extensions.swift
// SwiftUI_Pull_to_Refresh
//
// Created by Geri Borbás on 21/09/2021.
//
import Foundation
import UIKit
extension UIResponder {
var parentViewController: UIViewController? {
next as? UIViewController ?? next?.parentViewController
}
}

View File

@@ -0,0 +1,70 @@
//
// UIView+Extensions.swift
// SwiftUI_Pull_to_Refresh
//
// Created by Geri Borbás on 19/09/2021.
//
import Foundation
import UIKit
extension UIView {
/// Returs frame in screen coordinates.
var globalFrame: CGRect {
if let window = window {
return convert(bounds, to: window.screen.coordinateSpace)
} else {
return .zero
}
}
/// Returns with all the instances of the given view type in the view hierarchy.
func viewsInHierarchy<ViewType: UIView>() -> [ViewType]? {
var views: [ViewType] = []
viewsInHierarchy(views: &views)
return views.isEmpty ? nil : views
}
private func viewsInHierarchy<ViewType: UIView>(views: inout [ViewType]) {
subviews.forEach { eachSubView in
if let matchingView = eachSubView as? ViewType {
views.append(matchingView)
}
eachSubView.viewsInHierarchy(views: &views)
}
}
/// Search ancestral view hierarcy for the given view type.
func searchViewAnchestorsFor<ViewType: UIView>(
_ onViewFound: (ViewType) -> Void
) {
if let matchingView = superview as? ViewType {
onViewFound(matchingView)
} else {
superview?.searchViewAnchestorsFor(onViewFound)
}
}
/// Search ancestral view hierarcy for the given view type.
func searchViewAnchestorsFor<ViewType: UIView>() -> ViewType? {
if let matchingView = superview as? ViewType {
return matchingView
} else {
return superview?.searchViewAnchestorsFor()
}
}
func printViewHierarchyInformation(_ level: Int = 0) {
printViewInformation(level)
subviews.forEach { $0.printViewHierarchyInformation(level + 1) }
}
func printViewInformation(_ level: Int) {
let leadingWhitespace = String(repeating: " ", count: level)
let className = "\(Self.self)"
let superclassName = "\(superclass!)"
let frame = "\(self.frame)"
let id = (accessibilityIdentifier == nil) ? "" : " `\(accessibilityIdentifier!)`"
print("\(leadingWhitespace)\(className): \(superclassName)\(id) \(frame)")
}
}

Some files were not shown because too many files have changed in this diff Show More