Compare commits

..

158 Commits

Author SHA1 Message Date
Arkadiusz Fal
cd32bae24b Add Fastlane config 2022-08-15 16:27:38 +02:00
Arkadiusz Fal
17e56dc69a Bump build number 2022-08-15 16:27:03 +02:00
Arkadiusz Fal
ad715fa367 Fix #246 2022-08-15 14:51:19 +02:00
Arkadiusz Fal
560d7c4b9a Update packages 2022-08-05 09:23:58 +02:00
Arkadiusz Fal
7a88f28170 Update packages 2022-07-11 15:31:46 +02:00
Arkadiusz Fal
419e8991c9 Fix parsing Piped streams 2022-07-11 15:30:32 +02:00
Jan Weiß
720bdde728 Set project indentation default to spaces. 2022-07-11 14:39:47 +02:00
Jan Weiß
965a757031 Added unversioned TeamID infrastructure.
This is described in detail in the file "Shared.xcconfig".
2022-07-11 14:39:47 +02:00
Arkadiusz Fal
811196ae55 Update README 2022-07-04 19:57:12 +02:00
Arkadiusz Fal
5b0f6397d6 Minor style change 2022-06-15 01:09:16 +02:00
Arkadiusz Fal
d5362519d6 Update build number 2022-06-15 01:04:50 +02:00
Arkadiusz Fal
e9ee0f5ee9 Update packages 2022-06-15 01:04:32 +02:00
Arkadiusz Fal
0a9fb75c0c Update README 2022-06-15 00:42:47 +02:00
Arkadiusz Fal
eb33b65f3d Fix #161 2022-06-14 23:24:17 +02:00
Arkadiusz Fal
1c71520d6f Fix #166 2022-06-14 18:12:42 +02:00
Arkadiusz Fal
7a7e265ba1 Update build and version numbers 2022-05-27 22:59:10 +02:00
Arkadiusz Fal
c086112a49 Update README 2022-05-25 23:11:38 +02:00
Arkadiusz Fal
0802fe0029 Fix #133 2022-05-25 09:23:34 +02:00
Arkadiusz Fal
40813c2859 Update build number for Testflight 2022-05-25 09:14:27 +02:00
Arkadiusz Fal
b1238869a6 Update README 2022-05-22 23:27:59 +02:00
Arkadiusz Fal
453a5fa71b Update README 2022-05-22 23:23:05 +02:00
Arkadiusz Fal
86be252bd5 Bump build and version number 2022-05-22 18:45:19 +02:00
Bharathi
20f96fb9d6 Update README.md
Change in Piped "User Playlists" availability
2022-05-22 18:16:01 +02:00
Arkadiusz Fal
e539fb0067 Remaining playlists fixes 2022-05-22 18:08:14 +02:00
Arkadiusz Fal
03d5eefab0 Reload playlist on adding video
In case video was added to the saame playlist
2022-05-22 00:36:25 +02:00
Arkadiusz Fal
0bc4a677d4 Create/delete Piped playlists and add/remove videos to Piped playlists 2022-05-22 00:30:10 +02:00
Arkadiusz Fal
b374f82da4 Update package 2022-05-21 23:01:04 +02:00
Arkadiusz Fal
b70697e1be Improve subscriptions count
Piped API now includes it in the streams response, no need for separate
query
2022-04-16 20:05:20 +02:00
Arkadiusz Fal
db5765a84b Disable placeholder channel link 2022-04-16 20:03:25 +02:00
Arkadiusz Fal
8d36f57271 Preliminary support for Piped playlist (listing playlists and videos) 2022-04-10 17:07:10 +02:00
Arkadiusz Fal
836057578f Minor changes 2022-04-02 14:34:06 +02:00
Arkadiusz Fal
e39f4373bb Fix crashes (#69, #71) 2022-03-31 20:39:02 +02:00
Arkadiusz Fal
1490437537 Update README 2022-03-28 21:30:31 +02:00
Arkadiusz Fal
4f1b52826d Fix #109 2022-03-28 21:26:52 +02:00
Arkadiusz Fal
15e62468bb Update README 2022-03-27 23:13:53 +02:00
Arkadiusz Fal
1380036c44 Bump build and version number 2022-03-27 22:02:07 +02:00
Arkadiusz Fal
c893e5dc38 Fix menu commands 2022-03-27 22:02:07 +02:00
Arkadiusz Fal
8b4838dca5 Fix placeholders on tvOS 2022-03-27 20:31:56 +02:00
Arkadiusz Fal
1c520831d1 Improve placeholders 2022-03-27 20:27:59 +02:00
Arkadiusz Fal
8770bfb56d Fix #87 2022-03-27 13:26:38 +02:00
Arkadiusz Fal
ae4796a4c5 Add placeholders 2022-03-27 13:26:38 +02:00
Arkadiusz Fal
70b55ec2b2 Further subscribe buttons improvements 2022-03-26 19:01:38 +01:00
Arkadiusz Fal
c14a4a153d Fix #72 2022-03-26 15:22:29 +01:00
Arkadiusz Fal
c8fa972a61 Hide player on video end only on tvOS 2022-03-26 15:12:06 +01:00
Arkadiusz Fal
cc7bb83e74 Fix #84 2022-03-26 14:37:55 +01:00
Arkadiusz Fal
6a65123876 Hide subscribe button when not logged in 2022-03-26 14:07:00 +01:00
Arkadiusz Fal
aa42551c7c Fix #81 2022-03-26 13:50:01 +01:00
Arkadiusz Fal
9d8a2607ab Fix parsing subscriptions published date 2022-03-24 14:13:51 +01:00
Arkadiusz Fal
b4a0835a43 Fix #80 2022-03-24 14:04:31 +01:00
Arkadiusz Fal
066e048022 Add Defaults workaround 2022-03-24 14:03:38 +01:00
Arkadiusz Fal
d825cd8b20 Update README 2022-03-20 23:29:26 +01:00
Arkadiusz Fal
bb988764b4 Bump version number 2022-02-26 11:10:32 +01:00
Arkadiusz Fal
f7789c73d5 Fix opening playlists when recents is not saved (fix #57) 2022-02-26 11:10:29 +01:00
Ryan Stentz
1085bf0e9a fix issue #57 2022-02-26 11:07:50 +01:00
Arkadiusz Fal
5f263efeb2 Bump build and version number 2022-01-24 22:34:24 +01:00
Arkadiusz Fal
a98b4eac83 Fix selecting best quality stream (fix #54) 2022-01-24 22:23:10 +01:00
Arkadiusz Fal
975b8fe5c3 Fix displaying settings/account buttons when only search is visible (fix #56) 2022-01-24 22:22:47 +01:00
Arkadiusz Fal
33e86710a8 Bump build number 2022-01-20 23:14:42 +01:00
Arkadiusz Fal
96f4e819a7 Update README 2022-01-20 23:14:11 +01:00
Arkadiusz Fal
8ab97ddbaf Fix search closing when entering new query after opening recent 2022-01-20 23:14:11 +01:00
Arkadiusz Fal
df72bf99ba Fix displaying searches in favorites 2022-01-13 21:16:25 +01:00
Arkadiusz Fal
db4a817164 Bump build number 2022-01-09 16:52:12 +01:00
Arkadiusz Fal
ce8a8cbef3 Fix selecting video details tab on sidebar visibility change 2022-01-09 16:38:17 +01:00
Arkadiusz Fal
a04827cc56 Fix restoring queue 2022-01-09 16:38:05 +01:00
Arkadiusz Fal
534f356471 Fix search suggestion prefix 2022-01-09 15:47:48 +01:00
Arkadiusz Fal
5050ad5d02 Fix menu command for Popular 2022-01-09 15:47:24 +01:00
Arkadiusz Fal
ca38133b1d Fix opening channel from video details 2022-01-09 15:47:00 +01:00
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
Arkadiusz Fal
fe33cc5e3a Bump build number 2021-12-08 00:29:00 +01:00
Arkadiusz Fal
7e7b4e89b5 Add Sparkle update framework for macOS 2021-12-08 00:27:38 +01:00
Arkadiusz Fal
d88292662f Minor README updates 2021-12-08 00:08:13 +01:00
Arkadiusz Fal
21b04e21c4 Remove unused file 2021-12-08 00:07:23 +01:00
Arkadiusz Fal
a44a61b017 Remove redundant query for replies when collapsed and expanded 2021-12-08 00:06:59 +01:00
Arkadiusz Fal
1b090fcd51 Bump build number 2021-12-06 19:15:05 +01:00
Arkadiusz Fal
12eb4401b5 Update README 2021-12-06 19:13:57 +01:00
Arkadiusz Fal
170f2ee94e Fix reloading favorites view 2021-12-06 19:13:49 +01:00
Arkadiusz Fal
fe56739211 Fix crash on dismissing channel playlist on iOS 2021-12-06 19:13:37 +01:00
Arkadiusz Fal
759a942426 Fix search field on macOS 2021-12-06 19:13:20 +01:00
Arkadiusz Fal
8d9bbf647a Fix disabling comments on tvOS 2021-12-06 19:12:59 +01:00
Arkadiusz Fal
eeb7b1f151 Improve search suggestions 2021-12-06 19:12:33 +01:00
Arkadiusz Fal
62bff9283c Faster replacing player item 2021-12-06 19:12:02 +01:00
Arkadiusz Fal
3624c9619a Add setting for displaying comments in separate tab or below description 2021-12-06 19:11:19 +01:00
143 changed files with 6177 additions and 1892 deletions

6
.gitignore vendored
View File

@@ -81,6 +81,9 @@ fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/**/*.png
fastlane/test_output
fastlane/builds
fastlane/.env
fastlane/*.p8
# Code Injection
#
@@ -91,3 +94,6 @@ iOSInjectionProject/
# SwiftLint Remote Config Cache
.swiftlint/RemoteConfigCache
# User-specific xcconfig files
Xcode-config/DEVELOPMENT_TEAM.xcconfig

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

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

View File

@@ -10,7 +10,7 @@ extension Thumbnail {
}
private static var fixturesHost: String {
"https://invidious.home.arekf.net"
"https://invidious.snopyta.org"
}
private static func fixtureUrl(videoId: String, quality: Thumbnail.Quality) -> URL {

View File

@@ -1,13 +1,15 @@
import Foundation
extension Video {
static var fixtureID: Video.ID = "video-fixture"
static var fixtureChannelID: Channel.ID = "channel-fixture"
static var fixture: Video {
let id = "D2sxamzaHkM"
let thumbnailURL = "https://yt3.ggpht.com/ytc/AKedOLR-pT_JEsz_hcaA4Gjx8DHcqJ8mS42aTRqcVy6P7w=s88-c-k-c0x00ffffff-no-rj-mo"
return Video(
videoID: UUID().uuidString,
title: "Relaxing Piano Music that will make you feel amazingly good",
videoID: fixtureID,
title: "Relaxing Piano Music to feel good",
author: "Fancy Videotuber",
length: 582,
published: "7 years ago",
@@ -15,13 +17,13 @@ extension Video {
description: "Some relaxing live piano music",
genre: "Music",
channel: Channel(
id: "AbCdEFgHI",
id: fixtureChannelID,
name: "The Channel",
thumbnailURL: URL(string: thumbnailURL)!,
subscriptionsCount: 2300,
videos: []
),
thumbnails: Thumbnail.fixturesForAllQualities(videoId: id),
thumbnails: [],
live: false,
upcoming: false,
publishedAt: Date(),

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
}

3
Gemfile Normal file
View File

@@ -0,0 +1,3 @@
source "https://rubygems.org"
gem "fastlane"

218
Gemfile.lock Normal file
View File

@@ -0,0 +1,218 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.5)
rexml
addressable (2.8.0)
public_suffix (>= 2.0.2, < 5.0)
artifactory (3.0.15)
atomos (0.1.3)
aws-eventstream (1.2.0)
aws-partitions (1.618.0)
aws-sdk-core (3.132.0)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.525.0)
aws-sigv4 (~> 1.1)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.58.0)
aws-sdk-core (~> 3, >= 3.127.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.114.0)
aws-sdk-core (~> 3, >= 3.127.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.4)
aws-sigv4 (1.5.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
declarative (0.0.20)
digest-crc (0.6.4)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.92.4)
faraday (1.10.1)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.7)
faraday (>= 0.8.0)
http-cookie (~> 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (1.0.1)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday_middleware (1.2.0)
faraday (~> 1.0)
fastimage (2.2.6)
fastlane (2.209.0)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
babosa (>= 1.0.3, < 2.0.0)
bundler (>= 1.12.0, < 3.0.0)
colored
commander (~> 4.6)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
faraday (~> 1.0)
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (~> 2.0.0)
naturally (~> 2.2)
optparse (~> 0.1.1)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.3)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (>= 1.4.5, < 2.0.0)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.3.0)
xcpretty-travis-formatter (>= 0.0.3)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.25.0)
google-apis-core (>= 0.7, < 2.a)
google-apis-core (0.7.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
mini_mime (~> 1.0)
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
rexml
webrick
google-apis-iamcredentials_v1 (0.13.0)
google-apis-core (>= 0.7, < 2.a)
google-apis-playcustomapp_v1 (0.10.0)
google-apis-core (>= 0.7, < 2.a)
google-apis-storage_v1 (0.17.0)
google-apis-core (>= 0.7, < 2.a)
google-cloud-core (1.6.0)
google-cloud-env (~> 1.0)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.2.0)
google-cloud-storage (1.38.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.17.0)
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
googleauth (1.2.0)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
memoist (~> 0.16)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.5)
domain_name (~> 0.5)
httpclient (2.8.3)
jmespath (1.6.1)
json (2.6.2)
jwt (2.4.1)
memoist (0.16.2)
mini_magick (4.11.0)
mini_mime (1.1.2)
multi_json (1.15.0)
multipart-post (2.0.0)
nanaimo (0.3.0)
naturally (2.2.1)
optparse (0.1.1)
os (1.1.4)
plist (3.6.0)
public_suffix (4.0.7)
rake (13.0.6)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
rexml (3.2.5)
rouge (2.0.7)
ruby2_keywords (0.0.5)
rubyzip (2.3.2)
security (0.1.3)
signet (0.17.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
multi_json (~> 1.10)
simctl (1.6.8)
CFPropertyList
naturally
terminal-notifier (2.0.0)
terminal-table (1.8.0)
unicode-display_width (~> 1.1, >= 1.1.1)
trailblazer-option (0.1.2)
tty-cursor (0.7.1)
tty-screen (0.8.1)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unf (0.1.4)
unf_ext
unf_ext (0.0.8.2)
unicode-display_width (1.8.0)
webrick (1.7.0)
word_wrap (1.0.0)
xcodeproj (1.22.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.3.0)
rexml (~> 3.2.4)
xcpretty (0.3.0)
rouge (~> 2.0.7)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
PLATFORMS
arm64-darwin-21
DEPENDENCIES
fastlane
BUNDLED WITH
2.3.6

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,29 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
}
configureTransformer(pathPattern("popular"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
content.json.arrayValue.map(InvidiousAPI.extractVideo)
content.json.arrayValue.map(self.extractVideo)
}
configureTransformer(pathPattern("trending"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
content.json.arrayValue.map(InvidiousAPI.extractVideo)
content.json.arrayValue.map(self.extractVideo)
}
configureTransformer(pathPattern("search"), requestMethods: [.get]) { (content: Entity<JSON>) -> [ContentItem] in
content.json.arrayValue.map {
let type = $0.dictionaryValue["type"]?.stringValue
configureTransformer(pathPattern("search"), requestMethods: [.get]) { (content: Entity<JSON>) -> SearchPage in
let results = content.json.arrayValue.compactMap { json -> ContentItem? in
let type = json.dictionaryValue["type"]?.stringValue
if type == "channel" {
return ContentItem(channel: InvidiousAPI.extractChannel(from: $0))
return ContentItem(channel: self.extractChannel(from: json))
} else if type == "playlist" {
return ContentItem(playlist: InvidiousAPI.extractChannelPlaylist(from: $0))
return ContentItem(playlist: self.extractChannelPlaylist(from: json))
} else if type == "video" {
return ContentItem(video: self.extractVideo(from: json))
}
return ContentItem(video: InvidiousAPI.extractVideo(from: $0))
return nil
}
return SearchPage(results: results, last: results.isEmpty)
}
configureTransformer(pathPattern("search/suggestions"), requestMethods: [.get]) { (content: Entity<JSON>) -> [String] in
@@ -109,11 +118,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
}
configureTransformer(pathPattern("auth/playlists"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Playlist] in
content.json.arrayValue.map(Playlist.init)
content.json.arrayValue.map(self.extractPlaylist)
}
configureTransformer(pathPattern("auth/playlists/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Playlist in
Playlist(content.json)
self.extractPlaylist(from: content.json)
}
configureTransformer(pathPattern("auth/playlists"), requestMethods: [.post, .patch]) { (content: Entity<Data>) -> Playlist in
@@ -123,30 +132,30 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
configureTransformer(pathPattern("auth/feed"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
if let feedVideos = content.json.dictionaryValue["videos"] {
return feedVideos.arrayValue.map(InvidiousAPI.extractVideo)
return feedVideos.arrayValue.map(self.extractVideo)
}
return []
}
configureTransformer(pathPattern("auth/subscriptions"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Channel] in
content.json.arrayValue.map(InvidiousAPI.extractChannel)
content.json.arrayValue.map(self.extractChannel)
}
configureTransformer(pathPattern("channels/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Channel in
InvidiousAPI.extractChannel(from: content.json)
self.extractChannel(from: content.json)
}
configureTransformer(pathPattern("channels/*/latest"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
content.json.arrayValue.map(InvidiousAPI.extractVideo)
content.json.arrayValue.map(self.extractVideo)
}
configureTransformer(pathPattern("playlists/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> ChannelPlaylist in
InvidiousAPI.extractChannelPlaylist(from: content.json)
self.extractChannelPlaylist(from: content.json)
}
configureTransformer(pathPattern("videos/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Video in
InvidiousAPI.extractVideo(from: content.json)
self.extractVideo(from: content.json)
}
}
@@ -230,11 +239,71 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
playlist(playlistID)?.child("videos").child(videoID)
}
func addVideoToPlaylist(
_ videoID: String,
_ playlistID: String,
onFailure: @escaping (RequestError) -> Void = { _ in },
onSuccess: @escaping () -> Void = {}
) {
let resource = playlistVideos(playlistID)
let body = ["videoId": videoID]
resource?
.request(.post, json: body)
.onSuccess { _ in onSuccess() }
.onFailure(onFailure)
}
func removeVideoFromPlaylist(
_ index: String,
_ playlistID: String,
onFailure: @escaping (RequestError) -> Void,
onSuccess: @escaping () -> Void
) {
let resource = playlistVideo(playlistID, index)
resource?
.request(.delete)
.onSuccess { _ in onSuccess() }
.onFailure(onFailure)
}
func playlistForm(
_ name: String,
_ visibility: String,
playlist: Playlist?,
onFailure: @escaping (RequestError) -> Void,
onSuccess: @escaping (Playlist?) -> Void
) {
let body = ["title": name, "privacy": visibility]
let resource = !playlist.isNil ? self.playlist(playlist!.id) : playlists
resource?
.request(!playlist.isNil ? .patch : .post, json: body)
.onSuccess { response in
if let modifiedPlaylist: Playlist = response.typedContent() {
onSuccess(modifiedPlaylist)
}
}
.onFailure(onFailure)
}
func deletePlaylist(
_ playlist: Playlist,
onFailure: @escaping (RequestError) -> Void,
onSuccess: @escaping () -> Void
) {
self.playlist(playlist.id)?
.request(.delete)
.onSuccess { _ in onSuccess() }
.onFailure(onFailure)
}
func channelPlaylist(_ id: String) -> Resource? {
resource(baseURL: account.url, path: basePathAppending("playlists/\(id)"))
}
func search(_ query: SearchQuery) -> Resource {
func search(_ query: SearchQuery, page: String?) -> Resource {
var resource = resource(baseURL: account.url, path: basePathAppending("search"))
.withParam("q", searchQuery(query.query))
.withParam("sort_by", query.sortBy.parameter)
@@ -248,6 +317,10 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
resource = resource.withParam("duration", duration.rawValue)
}
if let page = page {
resource = resource.withParam("page", page)
}
return resource
}
@@ -291,7 +364,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
return AVURLAsset(url: url)
}
static func extractVideo(from json: JSON) -> Video {
func extractVideo(from json: JSON) -> Video {
let indexID: String?
var id: Video.ID
var publishedAt: Date?
@@ -334,8 +407,15 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
)
}
static func extractChannel(from json: JSON) -> Channel {
let thumbnailURL = "https:\(json["authorThumbnails"].arrayValue.first?.dictionaryValue["url"]?.stringValue ?? "")"
func extractChannel(from json: JSON) -> Channel {
var thumbnailURL = json["authorThumbnails"].arrayValue.last?.dictionaryValue["url"]?.stringValue ?? ""
// append https protocol to unproxied thumbnail URL if it's missing
if thumbnailURL.count > 2,
String(thumbnailURL[..<thumbnailURL.index(thumbnailURL.startIndex, offsetBy: 2)]) == "//"
{
thumbnailURL = "https:\(thumbnailURL)"
}
return Channel(
id: json["authorId"].stringValue,
@@ -343,33 +423,33 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
thumbnailURL: URL(string: thumbnailURL),
subscriptionsCount: json["subCount"].int,
subscriptionsText: json["subCountText"].string,
videos: json.dictionaryValue["latestVideos"]?.arrayValue.map(InvidiousAPI.extractVideo) ?? []
videos: json.dictionaryValue["latestVideos"]?.arrayValue.map(extractVideo) ?? []
)
}
static func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist {
func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist {
let details = json.dictionaryValue
return ChannelPlaylist(
id: details["playlistId"]!.stringValue,
title: details["title"]!.stringValue,
thumbnailURL: details["playlistThumbnail"]?.url,
channel: extractChannel(from: json),
videos: details["videos"]?.arrayValue.compactMap(InvidiousAPI.extractVideo) ?? []
videos: details["videos"]?.arrayValue.compactMap(extractVideo) ?? []
)
}
private static func extractThumbnails(from details: JSON) -> [Thumbnail] {
private func extractThumbnails(from details: JSON) -> [Thumbnail] {
details["videoThumbnails"].arrayValue.map { json in
Thumbnail(url: json["url"].url!, quality: .init(rawValue: json["quality"].string!)!)
}
}
private static func extractStreams(from json: JSON) -> [Stream] {
private func extractStreams(from json: JSON) -> [Stream] {
extractFormatStreams(from: json["formatStreams"].arrayValue) +
extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue)
}
private static func extractFormatStreams(from streams: [JSON]) -> [Stream] {
private func extractFormatStreams(from streams: [JSON]) -> [Stream] {
streams.map {
SingleAssetStream(
avAsset: AVURLAsset(url: $0["url"].url!),
@@ -380,7 +460,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
}
}
private static func extractAdaptiveFormats(from streams: [JSON]) -> [Stream] {
private func extractAdaptiveFormats(from streams: [JSON]) -> [Stream] {
let audioAssetURL = streams.first { $0["type"].stringValue.starts(with: "audio/mp4") }
guard audioAssetURL != nil else {
return []
@@ -399,10 +479,20 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
}
}
private static func extractRelated(from content: JSON) -> [Video] {
private func extractRelated(from content: JSON) -> [Video] {
content
.dictionaryValue["recommendedVideos"]?
.arrayValue
.compactMap(extractVideo(from:)) ?? []
}
private func extractPlaylist(from content: JSON) -> Playlist {
.init(
id: content["playlistId"].stringValue,
title: content["title"].stringValue,
visibility: content["isListed"].boolValue ? .public : .private,
updated: content["updated"].doubleValue,
videos: content["videos"].arrayValue.map { extractVideo(from: $0) }
)
}
}

View File

@@ -4,14 +4,10 @@ import Siesta
import SwiftyJSON
final class PipedAPI: Service, ObservableObject, VideosAPI {
static var authorizedEndpoints = ["subscriptions", "subscribe", "unsubscribe"]
static var authorizedEndpoints = ["subscriptions", "subscribe", "unsubscribe", "user/playlists"]
@Published var account: Account!
var anonymousAccount: Account {
.init(instanceID: account.instance.id, name: "Anonymous", url: account.instance.apiURL)
}
init(account: Account? = nil) {
super.init()
@@ -40,23 +36,33 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
}
configureTransformer(pathPattern("channel/*")) { (content: Entity<JSON>) -> Channel? in
PipedAPI.extractChannel(from: content.json)
self.extractChannel(from: content.json)
}
configureTransformer(pathPattern("playlists/*")) { (content: Entity<JSON>) -> ChannelPlaylist? in
PipedAPI.extractChannelPlaylist(from: content.json)
self.extractChannelPlaylist(from: content.json)
}
configureTransformer(pathPattern("user/playlists/create")) { (_: Entity<JSON>) in }
configureTransformer(pathPattern("user/playlists/delete")) { (_: Entity<JSON>) in }
configureTransformer(pathPattern("user/playlists/add")) { (_: Entity<JSON>) in }
configureTransformer(pathPattern("user/playlists/remove")) { (_: Entity<JSON>) in }
configureTransformer(pathPattern("streams/*")) { (content: Entity<JSON>) -> Video? in
PipedAPI.extractVideo(from: content.json)
self.extractVideo(from: content.json)
}
configureTransformer(pathPattern("trending")) { (content: Entity<JSON>) -> [Video] in
PipedAPI.extractVideos(from: content.json)
self.extractVideos(from: content.json)
}
configureTransformer(pathPattern("search")) { (content: Entity<JSON>) -> [ContentItem] in
PipedAPI.extractContentItems(from: content.json.dictionaryValue["items"]!)
configureTransformer(pathPattern("search")) { (content: Entity<JSON>) -> SearchPage in
let nextPage = content.json.dictionaryValue["nextpage"]?.stringValue
return SearchPage(
results: self.extractContentItems(from: content.json.dictionaryValue["items"]!),
nextPage: nextPage,
last: nextPage == "null"
)
}
configureTransformer(pathPattern("suggestions")) { (content: Entity<JSON>) -> [String] in
@@ -64,29 +70,33 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
}
configureTransformer(pathPattern("subscriptions")) { (content: Entity<JSON>) -> [Channel] in
content.json.arrayValue.map { PipedAPI.extractChannel(from: $0)! }
content.json.arrayValue.map { self.extractChannel(from: $0)! }
}
configureTransformer(pathPattern("feed")) { (content: Entity<JSON>) -> [Video] in
content.json.arrayValue.map { PipedAPI.extractVideo(from: $0)! }
content.json.arrayValue.map { self.extractVideo(from: $0)! }
}
configureTransformer(pathPattern("comments/*")) { (content: Entity<JSON>) -> CommentsPage in
let details = content.json.dictionaryValue
let comments = details["comments"]?.arrayValue.map { PipedAPI.extractComment(from: $0)! } ?? []
let comments = details["comments"]?.arrayValue.map { self.extractComment(from: $0)! } ?? []
let nextPage = details["nextpage"]?.stringValue
let disabled = details["disabled"]?.boolValue ?? false
return CommentsPage(comments: comments, nextPage: nextPage, disabled: disabled)
}
configureTransformer(pathPattern("user/playlists")) { (content: Entity<JSON>) -> [Playlist] in
content.json.arrayValue.map { self.extractUserPlaylist(from: $0)! }
}
if account.token.isNil {
updateToken()
}
}
func needsAuthorization(_ url: URL) -> Bool {
PipedAPI.authorizedEndpoints.contains { url.absoluteString.contains($0) }
Self.authorizedEndpoints.contains { url.absoluteString.contains($0) }
}
func updateToken() {
@@ -127,10 +137,18 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
.withParam("region", country.rawValue)
}
func search(_ query: SearchQuery) -> Resource {
resource(baseURL: account.instance.apiURL, path: "search")
func search(_ query: SearchQuery, page: String?) -> Resource {
let path = page.isNil ? "search" : "nextpage/search"
let resource = resource(baseURL: account.instance.apiURL, path: path)
.withParam("q", query.query)
.withParam("filter", "")
.withParam("filter", "all")
if page.isNil {
return resource
}
return resource.withParam("nextpage", page)
}
func searchSuggestions(query: String) -> Resource {
@@ -157,7 +175,9 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
var home: Resource? { nil }
var popular: Resource? { nil }
var playlists: Resource? { nil }
var playlists: Resource? {
resource(baseURL: account.instance.apiURL, path: "user/playlists")
}
func subscribe(_ channelID: String, onCompletion: @escaping () -> Void = {}) {
resource(baseURL: account.instance.apiURL, path: "subscribe")
@@ -171,10 +191,79 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
.onCompletion { _ in onCompletion() }
}
func playlist(_: String) -> Resource? { nil }
func playlist(_ id: String) -> Resource? {
channelPlaylist(id)
}
func playlistVideo(_: String, _: String) -> Resource? { nil }
func playlistVideos(_: String) -> Resource? { nil }
func addVideoToPlaylist(
_ videoID: String,
_ playlistID: String,
onFailure: @escaping (RequestError) -> Void = { _ in },
onSuccess: @escaping () -> Void = {}
) {
let resource = resource(baseURL: account.instance.apiURL, path: "user/playlists/add")
let body = ["videoId": videoID, "playlistId": playlistID]
resource
.request(.post, json: body)
.onSuccess { _ in onSuccess() }
.onFailure(onFailure)
}
func removeVideoFromPlaylist(
_ index: String,
_ playlistID: String,
onFailure: @escaping (RequestError) -> Void,
onSuccess: @escaping () -> Void
) {
let resource = resource(baseURL: account.instance.apiURL, path: "user/playlists/remove")
let body: [String: Any] = ["index": Int(index)!, "playlistId": playlistID]
resource
.request(.post, json: body)
.onSuccess { _ in onSuccess() }
.onFailure(onFailure)
}
func playlistForm(
_ name: String,
_: String,
playlist: Playlist?,
onFailure: @escaping (RequestError) -> Void,
onSuccess: @escaping (Playlist?) -> Void
) {
let body = ["name": name]
let resource = playlist.isNil ? resource(baseURL: account.instance.apiURL, path: "user/playlists/create") : nil
resource?
.request(.post, json: body)
.onSuccess { response in
if let modifiedPlaylist: Playlist = response.typedContent() {
onSuccess(modifiedPlaylist)
} else {
onSuccess(nil)
}
}
.onFailure(onFailure)
}
func deletePlaylist(
_ playlist: Playlist,
onFailure: @escaping (RequestError) -> Void,
onSuccess: @escaping () -> Void
) {
let resource = resource(baseURL: account.instance.apiURL, path: "user/playlists/delete")
let body = ["playlistId": playlist.id]
resource
.request(.post, json: body)
.onSuccess { _ in onSuccess() }
.onFailure(onFailure)
}
func comments(_ id: Video.ID, page: String?) -> Resource? {
let path = page.isNil ? "comments/\(id)" : "nextpage/comments/\(id)"
let resource = resource(baseURL: account.url, path: path)
@@ -190,7 +279,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
"**\(path)"
}
private static func extractContentItem(from content: JSON) -> ContentItem? {
private func extractContentItem(from content: JSON) -> ContentItem? {
let details = content.dictionaryValue
let url: String! = details["url"]?.string
@@ -210,29 +299,31 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
switch contentType {
case .video:
if let video = PipedAPI.extractVideo(from: content) {
if let video = extractVideo(from: content) {
return ContentItem(video: video)
}
case .playlist:
if let playlist = PipedAPI.extractChannelPlaylist(from: content) {
if let playlist = extractChannelPlaylist(from: content) {
return ContentItem(playlist: playlist)
}
case .channel:
if let channel = PipedAPI.extractChannel(from: content) {
if let channel = extractChannel(from: content) {
return ContentItem(channel: channel)
}
default:
return nil
}
return nil
}
private static func extractContentItems(from content: JSON) -> [ContentItem] {
content.arrayValue.compactMap { PipedAPI.extractContentItem(from: $0) }
private func extractContentItems(from content: JSON) -> [ContentItem] {
content.arrayValue.compactMap { extractContentItem(from: $0) }
}
private static func extractChannel(from content: JSON) -> Channel? {
private func extractChannel(from content: JSON) -> Channel? {
let attributes = content.dictionaryValue
guard let id = attributes["id"]?.stringValue ??
(attributes["url"] ?? attributes["uploaderUrl"])?.stringValue.components(separatedBy: "/").last
@@ -244,29 +335,32 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
var videos = [Video]()
if let relatedStreams = attributes["relatedStreams"] {
videos = PipedAPI.extractVideos(from: relatedStreams)
videos = extractVideos(from: relatedStreams)
}
let name = attributes["name"]?.stringValue ?? attributes["uploaderName"]?.stringValue ?? attributes["uploader"]?.stringValue ?? ""
let thumbnailURL = attributes["avatarUrl"]?.url ?? attributes["uploaderAvatar"]?.url ?? attributes["avatar"]?.url ?? attributes["thumbnail"]?.url
return Channel(
id: id,
name: attributes["name"]!.stringValue,
thumbnailURL: attributes["thumbnail"]?.url,
name: name,
thumbnailURL: thumbnailURL,
subscriptionsCount: subscriptionsCount,
videos: videos
)
}
static func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist? {
func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist? {
let details = json.dictionaryValue
let id = details["url"]?.stringValue.components(separatedBy: "?list=").last ?? UUID().uuidString
let thumbnailURL = details["thumbnail"]?.url ?? details["thumbnailUrl"]?.url
var videos = [Video]()
if let relatedStreams = details["relatedStreams"] {
videos = PipedAPI.extractVideos(from: relatedStreams)
videos = extractVideos(from: relatedStreams)
}
return ChannelPlaylist(
id: id,
title: details["name"]!.stringValue,
title: details["name"]?.stringValue ?? "",
thumbnailURL: thumbnailURL,
channel: extractChannel(from: json)!,
videos: videos,
@@ -274,7 +368,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
)
}
private static func extractVideo(from content: JSON) -> Video? {
private func extractVideo(from content: JSON) -> Video? {
let details = content.dictionaryValue
let url = details["url"]?.string
@@ -287,7 +381,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
let channelId = details["uploaderUrl"]!.stringValue.components(separatedBy: "/").last!
let thumbnails: [Thumbnail] = Thumbnail.Quality.allCases.compactMap {
if let url = PipedAPI.buildThumbnailURL(from: content, quality: $0) {
if let url = buildThumbnailURL(from: content, quality: $0) {
return Thumbnail(url: url, quality: $0)
}
@@ -295,19 +389,28 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
}
let author = details["uploaderName"]?.stringValue ?? details["uploader"]!.stringValue
let published = (details["uploadedDate"] ?? details["uploadDate"])?.stringValue ??
(details["uploaded"]!.double! / 1000).formattedAsRelativeTime()!
let authorThumbnailURL = details["avatarUrl"]?.url ?? details["uploaderAvatar"]?.url ?? details["avatar"]?.url
let subscriptionsCount = details["uploaderSubscriberCount"]?.int
let uploaded = details["uploaded"]?.doubleValue
var published = (uploaded.isNil || uploaded == -1) ? nil : (uploaded! / 1000).formattedAsRelativeTime()
if published.isNil {
published = (details["uploadedDate"] ?? details["uploadDate"])?.stringValue ?? ""
}
let live = details["livestream"]?.boolValue ?? (details["duration"]?.intValue == -1)
return Video(
videoID: PipedAPI.extractID(from: content),
videoID: extractID(from: content),
title: details["title"]!.stringValue,
author: author,
length: details["duration"]!.doubleValue,
published: published,
published: published!,
views: details["views"]!.intValue,
description: PipedAPI.extractDescription(from: content),
channel: Channel(id: channelId, name: author),
description: extractDescription(from: content),
channel: Channel(id: channelId, name: author, thumbnailURL: authorThumbnailURL, subscriptionsCount: subscriptionsCount),
thumbnails: thumbnails,
live: live,
likes: details["likes"]?.int,
dislikes: details["dislikes"]?.int,
streams: extractStreams(from: content),
@@ -315,16 +418,16 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
)
}
private static func extractID(from content: JSON) -> Video.ID {
private func extractID(from content: JSON) -> Video.ID {
content.dictionaryValue["url"]?.stringValue.components(separatedBy: "?v=").last ??
extractThumbnailURL(from: content)!.relativeString.components(separatedBy: "/")[4]
}
private static func extractThumbnailURL(from content: JSON) -> URL? {
private func extractThumbnailURL(from content: JSON) -> URL? {
content.dictionaryValue["thumbnail"]?.url! ?? content.dictionaryValue["thumbnailUrl"]!.url!
}
private static func buildThumbnailURL(from content: JSON, quality: Thumbnail.Quality) -> URL? {
private func buildThumbnailURL(from content: JSON, quality: Thumbnail.Quality) -> URL? {
let thumbnailURL = extractThumbnailURL(from: content)
guard !thumbnailURL.isNil else {
return nil
@@ -337,7 +440,15 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
)!
}
private static func extractDescription(from content: JSON) -> String? {
private func extractUserPlaylist(from json: JSON) -> Playlist? {
let id = json["id"].stringValue
let title = json["name"].stringValue
let visibility = Playlist.Visibility.private
return Playlist(id: id, title: title, visibility: visibility)
}
private func extractDescription(from content: JSON) -> String? {
guard var description = content.dictionaryValue["description"]?.string else {
return nil
}
@@ -359,26 +470,32 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
return description
}
private static func extractVideos(from content: JSON) -> [Video] {
private func extractVideos(from content: JSON) -> [Video] {
content.arrayValue.compactMap(extractVideo(from:))
}
private static func extractStreams(from content: JSON) -> [Stream] {
private func extractStreams(from content: JSON) -> [Stream] {
var streams = [Stream]()
if let hlsURL = content.dictionaryValue["hls"]?.url {
streams.append(Stream(hlsURL: hlsURL))
}
guard let audioStream = PipedAPI.compatibleAudioStreams(from: content).first else {
guard let audioStream = compatibleAudioStreams(from: content).first else {
return streams
}
let videoStreams = PipedAPI.compatibleVideoStream(from: content)
let videoStreams = compatibleVideoStream(from: content)
videoStreams.forEach { videoStream in
let audioAsset = AVURLAsset(url: audioStream.dictionaryValue["url"]!.url!)
let videoAsset = AVURLAsset(url: videoStream.dictionaryValue["url"]!.url!)
guard let audioAssetUrl = audioStream.dictionaryValue["url"]?.url,
let videoAssetUrl = videoStream.dictionaryValue["url"]?.url
else {
return
}
let audioAsset = AVURLAsset(url: audioAssetUrl)
let videoAsset = AVURLAsset(url: videoAssetUrl)
let videoOnly = videoStream.dictionaryValue["videoOnly"]?.boolValue ?? true
let resolution = Stream.Resolution.from(resolution: videoStream.dictionaryValue["quality"]!.stringValue)
@@ -397,14 +514,14 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
return streams
}
private static func extractRelated(from content: JSON) -> [Video] {
private func extractRelated(from content: JSON) -> [Video] {
content
.dictionaryValue["relatedStreams"]?
.arrayValue
.compactMap(extractVideo(from:)) ?? []
}
private static func compatibleAudioStreams(from content: JSON) -> [JSON] {
private func compatibleAudioStreams(from content: JSON) -> [JSON] {
content
.dictionaryValue["audioStreams"]?
.arrayValue
@@ -414,14 +531,14 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
} ?? []
}
private static func compatibleVideoStream(from content: JSON) -> [JSON] {
private func compatibleVideoStream(from content: JSON) -> [JSON] {
content
.dictionaryValue["videoStreams"]?
.arrayValue
.filter { $0.dictionaryValue["format"] == "MPEG_4" } ?? []
}
private static func extractComment(from content: JSON) -> Comment? {
private func extractComment(from content: JSON) -> Comment? {
let details = content.dictionaryValue
let author = details["author"]?.stringValue ?? ""
let commentorUrl = details["commentorUrl"]?.stringValue

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
@@ -27,6 +27,34 @@ protocol VideosAPI {
func playlistVideo(_ playlistID: String, _ videoID: String) -> Resource?
func playlistVideos(_ id: String) -> Resource?
func addVideoToPlaylist(
_ videoID: String,
_ playlistID: String,
onFailure: @escaping (RequestError) -> Void,
onSuccess: @escaping () -> Void
)
func removeVideoFromPlaylist(
_ index: String,
_ playlistID: String,
onFailure: @escaping (RequestError) -> Void,
onSuccess: @escaping () -> Void
)
func playlistForm(
_ name: String,
_ visibility: String,
playlist: Playlist?,
onFailure: @escaping (RequestError) -> Void,
onSuccess: @escaping (Playlist?) -> Void
)
func deletePlaylist(
_ playlist: Playlist,
onFailure: @escaping (RequestError) -> Void,
onSuccess: @escaping () -> Void
)
func channelPlaylist(_ id: String) -> Resource?
func loadDetails(_ item: PlayerQueueItem, completionHandler: @escaping (PlayerQueueItem) -> Void)
@@ -55,11 +83,12 @@ extension VideosAPI {
}
func shareURL(_ item: ContentItem, frontendHost: String? = nil, time: CMTime? = nil) -> URL? {
guard let frontendHost = frontendHost ?? account.instance.frontendHost else {
guard let frontendHost = frontendHost ?? account?.instance?.frontendHost,
var urlComponents = account?.instance?.urlComponents
else {
return nil
}
var urlComponents = account.instance.urlComponents
urlComponents.host = frontendHost
var queryItems = [URLQueryItem]()
@@ -73,6 +102,8 @@ extension VideosAPI {
case .playlist:
urlComponents.path = "/playlist"
queryItems.append(.init(name: "list", value: item.playlist.id))
default:
return nil
}
if !time.isNil, time!.seconds.isFinite {

View File

@@ -32,6 +32,18 @@ enum VideosApp: String, CaseIterable {
}
var supportsUserPlaylists: Bool {
true
}
var userPlaylistsEndpointIncludesVideos: Bool {
self == .invidious
}
var userPlaylistsHaveVisibility: Bool {
self == .invidious
}
var userPlaylistsAreEditable: Bool {
self == .invidious
}
@@ -42,4 +54,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,27 +8,33 @@ final class CommentsModel: ObservableObject {
@Published var nextPage: String?
@Published var firstPage = true
@Published var loaded = true
@Published var loaded = false
@Published var disabled = false
@Published var replies = [Comment]()
@Published var repliesPageID: String?
@Published var repliesLoaded = false
var accounts: AccountsModel!
var player: PlayerModel!
var instance: Instance? {
static var instance: Instance? {
InstancesModel.find(Defaults[.commentsInstanceID])
}
var api: VideosAPI? {
instance.isNil ? nil : PipedAPI(account: instance!.anonymousAccount)
Self.instance.isNil ? nil : PipedAPI(account: Self.instance!.anonymousAccount)
}
static var enabled: Bool {
!Defaults[.commentsInstanceID].isNil && !Defaults[.commentsInstanceID]!.isEmpty
!instance.isNil
}
#if !os(tvOS)
static var placement: CommentsPlacement {
Defaults[.commentsPlacement]
}
#endif
var nextPageAvailable: Bool {
!(nextPage?.isEmpty ?? true)
}
@@ -38,21 +44,23 @@ final class CommentsModel: ObservableObject {
return
}
reset()
guard !instance.isNil,
guard !Self.instance.isNil,
!(player?.currentVideo.isNil ?? true)
else {
return
}
if !firstPage && !nextPageAvailable {
return
}
firstPage = page.isNil || page!.isEmpty
api?.comments(player.currentVideo!.videoID, page: page)?
.load()
.onSuccess { [weak self] response in
if let page: CommentsPage = response.typedContent() {
self?.all = page.comments
self?.all += page.comments
self?.nextPage = page.nextPage
self?.disabled = page.disabled
}
@@ -62,6 +70,13 @@ final class CommentsModel: ObservableObject {
}
}
func loadNextPageIfNeeded(current comment: Comment) {
let thresholdIndex = all.index(all.endIndex, offsetBy: -5)
if all.firstIndex(where: { $0 == comment }) == thresholdIndex {
loadNextPage()
}
}
func loadNextPage() {
load(page: nextPage)
}
@@ -71,7 +86,12 @@ final class CommentsModel: ObservableObject {
return
}
if page == repliesPageID {
return
}
replies = []
repliesPageID = page
repliesLoaded = false
api?.comments(player.currentVideo!.videoID, page: page)?
@@ -79,9 +99,10 @@ final class CommentsModel: ObservableObject {
.onSuccess { [weak self] response in
if let page: CommentsPage = response.typedContent() {
self?.replies = page.comments
self?.repliesLoaded = true
}
}
.onCompletion { [weak self] _ in
.onFailure { [weak self] _ in
self?.repliesLoaded = true
}
}

View File

@@ -2,7 +2,7 @@ import Foundation
struct ContentItem: Identifiable {
enum ContentType: String {
case video, playlist, channel
case video, playlist, channel, placeholder
private var sortOrder: Int {
switch self {
@@ -35,6 +35,6 @@ struct ContentItem: Identifiable {
}
var contentType: ContentType {
video.isNil ? (channel.isNil ? .playlist : .channel) : .video
video.isNil ? (channel.isNil ? (playlist.isNil ? .placeholder : .playlist) : .channel) : .video
}
}

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

@@ -14,6 +14,31 @@ final class NavigationModel: ObservableObject {
case nowPlaying
case search
var stringValue: String {
switch self {
case .favorites:
return "favorites"
case .subscriptions:
return "subscriptions"
case .popular:
return "popular"
case .trending:
return "trending"
case .playlists:
return "playlists"
case let .channel(string):
return "channel\(string)"
case let .playlist(string):
return "playlist\(string)"
case .recentlyOpened:
return "recentlyOpened"
case .search:
return "search"
default:
return ""
}
}
var playlistID: Playlist.ID? {
if case let .playlist(id) = self {
return id
@@ -41,10 +66,84 @@ final class NavigationModel: ObservableObject {
@Published var presentingSettings = false
@Published var presentingWelcomeScreen = false
static func openChannel(
_ channel: Channel,
player: PlayerModel,
recents: RecentsModel,
navigation: NavigationModel,
navigationStyle: NavigationStyle,
delay: Bool = true
) {
guard channel.id != Video.fixtureChannelID else {
return
}
let recent = RecentItem(from: channel)
#if os(macOS)
Windows.main.open()
#else
player.hide()
#endif
let openRecent = {
recents.add(recent)
navigation.presentingChannel = true
}
if navigationStyle == .tab {
if delay {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
openRecent()
}
} else {
openRecent()
}
} else if navigationStyle == .sidebar {
openRecent()
navigation.sidebarSectionChanged.toggle()
navigation.tabSelection = .recentlyOpened(recent.tag)
}
}
static func openChannelPlaylist(
_ playlist: ChannelPlaylist,
player: PlayerModel,
recents: RecentsModel,
navigation: NavigationModel,
navigationStyle: NavigationStyle,
delay: Bool = false
) {
let recent = RecentItem(from: playlist)
#if os(macOS)
Windows.main.open()
#else
player.hide()
#endif
let openRecent = {
recents.add(recent)
navigation.presentingPlaylist = true
}
if navigationStyle == .tab {
if delay {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
openRecent()
}
} else {
openRecent()
}
} else if navigationStyle == .sidebar {
openRecent()
navigation.sidebarSectionChanged.toggle()
navigation.tabSelection = .recentlyOpened(recent.tag)
}
}
var tabSelectionBinding: Binding<TabSelection> {
Binding<TabSelection>(
get: {
self.tabSelection ?? .favorites
self.tabSelection ?? .search
},
set: { newValue in
self.tabSelection = newValue
@@ -71,6 +170,12 @@ final class NavigationModel: ObservableObject {
channelToUnsubscribe = channel
presentingUnsubscribeAlert = channelToUnsubscribe != nil
}
func hideKeyboard() {
#if os(iOS)
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
#endif
}
}
typealias TabSelection = NavigationModel.TabSelection

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,65 @@
import AVKit
import CoreData
#if os(iOS)
import CoreMotion
#endif
import Defaults
import Foundation
import Logging
import MediaPlayer
#if !os(macOS)
import UIKit
#endif
import Siesta
import SwiftUI
import SwiftyJSON
#if !os(macOS)
import UIKit
#endif
final class PlayerModel: ObservableObject {
static let availableRates: [Float] = [0.5, 0.67, 0.8, 1, 1.25, 1.5, 2]
static let assetKeysToLoad = ["tracks", "playable", "duration"]
let logger = Logger(label: "stream.yattee.app")
private(set) var player = AVPlayer()
private(set) var playerView = Player()
var controller: PlayerViewController? { didSet { playerView.controller = controller } }
#if os(tvOS)
var avPlayerViewController: AVPlayerViewController?
#endif
var playerView = Player()
var controller: PlayerViewController?
var playerItem: AVPlayerItem?
@Published var presentingPlayer = false { didSet { pauseOnPlayerDismiss() } }
@Published var presentingPlayer = false { didSet { handlePresentationChange() } }
@Published var stream: Stream?
@Published var currentRate: Float = 1.0 { didSet { player.rate = currentRate } }
@Published var availableStreams = [Stream]() { didSet { rebuildTVMenu() } }
@Published var availableStreams = [Stream]() { didSet { handleAvailableStreamsChange() } }
@Published var streamSelection: Stream? { didSet { rebuildTVMenu() } }
@Published var queue = [PlayerQueueItem]() { didSet { Defaults[.queue] = queue } }
@Published var currentItem: PlayerQueueItem! { didSet { Defaults[.lastPlayed] = currentItem } }
@Published var history = [PlayerQueueItem]() { didSet { Defaults[.history] = history } }
@Published var currentItem: PlayerQueueItem! { didSet { handleCurrentItemChange() } }
@Published var historyVideos = [Video]()
@Published var savedTime: CMTime?
@Published var preservedTime: CMTime?
@Published var playerNavigationLinkActive = false
@Published var playerNavigationLinkActive = false { didSet { handleNavigationViewPlayerPresentationChange() } }
@Published var sponsorBlock = SponsorBlockAPI()
@Published var segmentRestorationTime: CMTime?
@Published var lastSkipped: Segment? { didSet { rebuildTVMenu() } }
@Published var restoredSegments = [Segment]()
#if os(iOS)
@Published var motionManager: CMMotionManager!
@Published var lockedOrientation: UIInterfaceOrientation?
@Published var lastOrientation: UIInterfaceOrientation?
#endif
var accounts: AccountsModel
var comments: CommentsModel
var asset: AVURLAsset?
var composition = AVMutableComposition()
var loadedCompositionAssets = [AVMediaType]()
var context: NSManagedObjectContext = PersistenceController.shared.container.viewContext
private var currentArtwork: MPMediaItemArtwork?
private var frequentTimeObserver: Any?
private var infrequentTimeObserver: Any?
@@ -58,6 +70,7 @@ final class PlayerModel: ObservableObject {
private var timeObserverThrottle = Throttle(interval: 2)
var playingInPictureInPicture = false
var playingFullscreen = false
@Published var presentingErrorDetails = false
var playerError: Error? { didSet {
@@ -68,22 +81,63 @@ final class PlayerModel: ObservableObject {
#endif
}}
@Default(.pauseOnHidingPlayer) private var pauseOnHidingPlayer
@Default(.closePiPOnNavigation) var closePiPOnNavigation
@Default(.closePiPOnOpeningPlayer) var closePiPOnOpeningPlayer
#if !os(macOS)
@Default(.closePiPAndOpenPlayerOnEnteringForeground) var closePiPAndOpenPlayerOnEnteringForeground
#endif
init(accounts: AccountsModel? = nil, comments: CommentsModel? = nil) {
self.accounts = accounts ?? AccountsModel()
self.comments = comments ?? CommentsModel()
addItemDidPlayToEndTimeObserver()
addFrequentTimeObserver()
addInfrequentTimeObserver()
addPlayerTimeControlStatusObserver()
}
func presentPlayer() {
func show() {
guard !presentingPlayer else {
#if os(macOS)
Windows.player.focus()
#endif
return
}
#if os(macOS)
Windows.player.open()
Windows.player.focus()
#endif
presentingPlayer = true
}
func hide() {
presentingPlayer = false
playerNavigationLinkActive = false
}
func togglePlayer() {
presentingPlayer.toggle()
#if os(macOS)
if !presentingPlayer {
Windows.player.open()
}
Windows.player.focus()
#else
if presentingPlayer {
hide()
} else {
show()
}
#endif
}
var isLoadingVideo: Bool {
guard !currentVideo.isNil else {
return false
}
return player.currentItem == nil || time == nil || !time!.isValid
}
var isPlaying: Bool {
@@ -95,7 +149,7 @@ final class PlayerModel: ObservableObject {
}
var live: Bool {
currentItem?.video?.live ?? false
currentVideo?.live ?? false
}
var playerItemDuration: CMTime? {
@@ -103,7 +157,7 @@ final class PlayerModel: ObservableObject {
}
var videoDuration: TimeInterval? {
currentItem?.duration ?? currentVideo?.length
currentItem?.duration ?? currentVideo?.length ?? player.currentItem?.asset.duration.seconds
}
func togglePlay() {
@@ -126,26 +180,41 @@ final class PlayerModel: ObservableObject {
player.pause()
}
func upgradeToStream(_ stream: Stream) {
if !self.stream.isNil, self.stream != stream {
playStream(stream, of: currentVideo!, preservingTime: true)
func play(_ video: Video, at time: TimeInterval? = nil, inNavigationView: Bool = false) {
playNow(video, at: time)
guard !playingInPictureInPicture else {
return
}
if inNavigationView {
playerNavigationLinkActive = true
} else {
show()
}
}
func playStream(
_ stream: Stream,
of video: Video,
preservingTime: Bool = false
preservingTime: Bool = false,
upgrading: Bool = false
) {
playerError = nil
resetSegments()
sponsorBlock.loadSegments(videoID: video.videoID, categories: Defaults[.sponsorBlockCategories])
comments.load()
if !upgrading {
resetSegments()
DispatchQueue.main.async { [weak self] in
self?.sponsorBlock.loadSegments(
videoID: video.videoID,
categories: Defaults[.sponsorBlockCategories]
)
}
}
if let url = stream.singleAssetURL {
logger.info("playing stream with one asset\(stream.kind == .hls ? " (HLS)" : ""): \(url)")
insertPlayerItem(stream, for: video, preservingTime: preservingTime)
loadSingleAsset(url, stream: stream, of: video, preservingTime: preservingTime)
} else {
logger.info("playing stream with many assets:")
logger.info("composition audio asset: \(stream.audioAsset.url)")
@@ -154,11 +223,58 @@ final class PlayerModel: ObservableObject {
loadComposition(stream, of: video, preservingTime: preservingTime)
}
updateCurrentArtwork()
if !upgrading {
updateCurrentArtwork()
}
}
private func pauseOnPlayerDismiss() {
if !playingInPictureInPicture, !presentingPlayer {
func upgradeToStream(_ stream: Stream) {
if !self.stream.isNil, self.stream != stream {
playStream(stream, of: currentVideo!, preservingTime: true, upgrading: true)
}
}
private func handleAvailableStreamsChange() {
rebuildTVMenu()
guard stream.isNil else {
return
}
guard let stream = preferredStream(availableStreams) else {
return
}
streamSelection = stream
playStream(
stream,
of: currentVideo!,
preservingTime: !currentItem.playbackTime.isNil
)
}
private func handlePresentationChange() {
if presentingPlayer, closePiPOnOpeningPlayer, playingInPictureInPicture {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.closePiP()
}
}
if !presentingPlayer, pauseOnHidingPlayer, !playingInPictureInPicture {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.pause()
}
}
if !presentingPlayer, !pauseOnHidingPlayer, isPlaying {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
self?.play()
}
}
}
private func handleNavigationViewPlayerPresentationChange() {
if pauseOnHidingPlayer, !playingInPictureInPicture, !playerNavigationLinkActive {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.pause()
}
@@ -170,11 +286,14 @@ final class PlayerModel: ObservableObject {
for video: Video,
preservingTime: Bool = false
) {
let playerItem = playerItem(stream)
removeItemDidPlayToEndTimeObserver()
playerItem = playerItem(stream)
guard playerItem != nil else {
return
}
addItemDidPlayToEndTimeObserver()
attachMetadata(to: playerItem!, video: video, for: stream)
DispatchQueue.main.async { [weak self] in
@@ -184,6 +303,7 @@ final class PlayerModel: ObservableObject {
self.stream = stream
self.composition = AVMutableComposition()
self.asset = nil
}
let startPlaying = {
@@ -191,27 +311,53 @@ final class PlayerModel: ObservableObject {
try? AVAudioSession.sharedInstance().setActive(true)
#endif
if self.isAutoplaying(playerItem!) {
if self.isAutoplaying(self.playerItem!) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
self?.play()
guard let self = self else {
return
}
if !preservingTime,
let segment = self.sponsorBlock.segments.first,
segment.start < 3,
self.lastSkipped.isNil
{
self.player.seek(
to: segment.endTime,
toleranceBefore: .secondsInDefaultTimescale(1),
toleranceAfter: .zero
) { finished in
guard finished else {
return
}
self.lastSkipped = segment
self.play()
}
} else {
self.play()
}
}
}
}
let replaceItemAndSeek = {
self.player.replaceCurrentItem(with: playerItem)
self.seekToSavedTime { finished in
guard video == self.currentVideo else {
return
}
self.player.replaceCurrentItem(with: self.playerItem)
self.seekToPreservedTime { finished in
guard finished else {
return
}
self.savedTime = nil
self.preservedTime = nil
startPlaying()
}
}
if preservingTime {
if savedTime.isNil {
if preservedTime.isNil {
saveTime {
replaceItemAndSeek()
startPlaying()
@@ -226,6 +372,32 @@ final class PlayerModel: ObservableObject {
}
}
private func loadSingleAsset(
_ url: URL,
stream: Stream,
of video: Video,
preservingTime: Bool = false
) {
asset?.cancelLoading()
asset = AVURLAsset(url: url)
asset?.loadValuesAsynchronously(forKeys: Self.assetKeysToLoad) { [weak self] in
var error: NSError?
switch self?.asset?.statusOfValue(forKey: "duration", error: &error) {
case .loaded:
DispatchQueue.main.async { [weak self] in
self?.insertPlayerItem(stream, for: video, preservingTime: preservingTime)
}
case .failed:
DispatchQueue.main.async { [weak self] in
self?.playerError = error
}
default:
return
}
}
}
private func loadComposition(
_ stream: Stream,
of video: Video,
@@ -243,7 +415,7 @@ final class PlayerModel: ObservableObject {
of video: Video,
preservingTime: Bool = false
) {
asset.loadValuesAsynchronously(forKeys: ["playable"]) { [weak self] in
asset.loadValuesAsynchronously(forKeys: Self.assetKeysToLoad) { [weak self] in
guard let self = self else {
return
}
@@ -285,9 +457,9 @@ final class PlayerModel: ObservableObject {
}
}
private func playerItem(_ stream: Stream) -> AVPlayerItem? {
if let url = stream.singleAssetURL {
return AVPlayerItem(asset: AVURLAsset(url: url))
private func playerItem(_: Stream) -> AVPlayerItem? {
if let asset = asset {
return AVPlayerItem(asset: asset)
} else {
return AVPlayerItem(asset: composition)
}
@@ -313,6 +485,10 @@ final class PlayerModel: ObservableObject {
item.preferredForwardBufferDuration = 5
observePlayerItemStatus(item)
}
private func observePlayerItemStatus(_ item: AVPlayerItem) {
statusObservation?.invalidate()
statusObservation = item.observe(\.status, options: [.old, .new]) { [weak self] playerItem, _ in
guard let self = self else {
@@ -350,25 +526,31 @@ final class PlayerModel: ObservableObject {
self,
selector: #selector(itemDidPlayToEndTime),
name: NSNotification.Name.AVPlayerItemDidPlayToEndTime,
object: nil
object: playerItem
)
}
private func removeItemDidPlayToEndTimeObserver() {
NotificationCenter.default.removeObserver(
self,
name: NSNotification.Name.AVPlayerItemDidPlayToEndTime,
object: playerItem
)
}
@objc func itemDidPlayToEndTime() {
currentItem.playbackTime = playerItemDuration
prepareCurrentItemForHistory(finished: true)
if queue.isEmpty {
#if !os(macOS)
try? AVAudioSession.sharedInstance().setActive(false)
#endif
addCurrentItemToHistory()
resetQueue()
#if os(tvOS)
avPlayerViewController!.dismiss(animated: true) { [weak self] in
self?.controller!.dismiss(animated: true)
controller?.playerView.dismiss(animated: false) { [weak self] in
self?.controller?.dismiss(animated: true)
}
#endif
presentingPlayer = false
} else {
advanceToNextItem()
}
@@ -382,13 +564,13 @@ final class PlayerModel: ObservableObject {
}
DispatchQueue.main.async { [weak self] in
self?.savedTime = currentTime
self?.preservedTime = currentTime
completionHandler()
}
}
private func seekToSavedTime(completionHandler: @escaping (Bool) -> Void = { _ in }) {
guard let time = savedTime else {
private func seekToPreservedTime(completionHandler: @escaping (Bool) -> Void = { _ in }) {
guard let time = preservedTime else {
return
}
@@ -439,7 +621,7 @@ final class PlayerModel: ObservableObject {
}
self.timeObserverThrottle.execute {
self.updateCurrentItemIntervals()
self.updateWatch()
}
}
}
@@ -469,22 +651,15 @@ final class PlayerModel: ObservableObject {
#endif
self.timeObserverThrottle.execute {
self.updateCurrentItemIntervals()
self.updateWatch()
}
}
}
private func updateCurrentItemIntervals() {
currentItem?.playbackTime = player.currentTime()
currentItem?.videoDuration = player.currentItem?.asset.duration.seconds
}
fileprivate func updateNowPlayingInfo() {
let duration: Int? = currentItem.video.live ? nil : Int(currentItem.videoDuration ?? 0)
var nowPlayingInfo: [String: AnyObject] = [
MPMediaItemPropertyTitle: currentItem.video.title as AnyObject,
MPMediaItemPropertyArtist: currentItem.video.author as AnyObject,
MPMediaItemPropertyPlaybackDuration: duration as AnyObject,
MPNowPlayingInfoPropertyIsLiveStream: currentItem.video.live as AnyObject,
MPNowPlayingInfoPropertyElapsedPlaybackTime: player.currentTime().seconds as AnyObject,
MPNowPlayingInfoPropertyPlaybackQueueCount: queue.count as AnyObject,
@@ -495,6 +670,15 @@ final class PlayerModel: ObservableObject {
nowPlayingInfo[MPMediaItemPropertyArtwork] = currentArtwork as AnyObject
}
if !currentItem.video.live {
let itemDuration = currentItem.videoDuration ?? currentItem.duration
let duration = itemDuration.isFinite ? Double(itemDuration) : nil
if !duration.isNil {
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration as AnyObject
}
}
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}
@@ -525,8 +709,105 @@ final class PlayerModel: ObservableObject {
}
func closeCurrentItem() {
addCurrentItemToHistory()
prepareCurrentItemForHistory()
currentItem = nil
player.replaceCurrentItem(with: nil)
}
func closePiP() {
guard playingInPictureInPicture else {
return
}
let wasPlaying = isPlaying
pause()
#if os(tvOS)
show()
#endif
doClosePiP(wasPlaying: wasPlaying)
}
#if os(tvOS)
private func doClosePiP(wasPlaying: Bool) {
let item = player.currentItem
let time = player.currentTime()
self.player.replaceCurrentItem(with: nil)
guard !item.isNil else {
return
}
self.player.seek(to: time)
self.player.replaceCurrentItem(with: item)
guard wasPlaying else {
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
self?.play()
}
}
#else
private func doClosePiP(wasPlaying: Bool) {
controller?.playerView.player = nil
controller?.playerView.player = player
guard wasPlaying else {
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.play()
}
}
#endif
func handleCurrentItemChange() {
#if os(macOS)
Windows.player.window?.title = windowTitle
#endif
Defaults[.lastPlayed] = currentItem
}
#if os(macOS)
var windowTitle: String {
currentVideo.isNil ? "Not playing" : "\(currentVideo!.title) - \(currentVideo!.author)"
}
#else
func handleEnterForeground() {
guard closePiPAndOpenPlayerOnEnteringForeground, playingInPictureInPicture else {
return
}
show()
closePiP()
}
func enterFullScreen() {
guard !playingFullscreen else {
return
}
logger.info("entering fullscreen")
controller?.playerView
.perform(NSSelectorFromString("enterFullScreenAnimated:completionHandler:"), with: false, with: nil)
}
func exitFullScreen() {
guard playingFullscreen else {
return
}
logger.info("exiting fullscreen")
controller?.playerView
.perform(NSSelectorFromString("exitFullScreenAnimated:completionHandler:"), with: false, with: nil)
}
#endif
}

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,7 +43,11 @@ extension PlayerModel {
}
func playNow(_ video: Video, at time: TimeInterval? = nil) {
addCurrentItemToHistory()
if playingInPictureInPicture, closePiPOnNavigation {
closePiP()
}
prepareCurrentItemForHistory()
enqueueVideo(video, prepending: true) { _, item in
self.advanceToItem(item, at: time)
@@ -37,7 +55,12 @@ extension PlayerModel {
}
func playItem(_ item: PlayerQueueItem, video: Video? = nil, at time: TimeInterval? = nil) {
if !playingInPictureInPicture {
player.replaceCurrentItem(with: nil)
}
comments.reset()
stream = nil
currentItem = item
if !time.isNil {
@@ -50,23 +73,18 @@ extension PlayerModel {
currentItem.video = video!
}
savedTime = currentItem.playbackTime
preservedTime = currentItem.playbackTime
loadAvailableStreams(currentVideo!) { streams in
guard let stream = self.preferredStream(streams) else {
DispatchQueue.main.async { [weak self] in
guard let video = self?.currentVideo else {
return
}
self.streamSelection = stream
self.playStream(
stream,
of: self.currentVideo!,
preservingTime: !self.currentItem.playbackTime.isNil
)
self?.loadAvailableStreams(video)
}
}
private func preferredStream(_ streams: [Stream]) -> Stream? {
func preferredStream(_ streams: [Stream]) -> Stream? {
let quality = Defaults[.quality]
var streams = streams
@@ -76,7 +94,9 @@ extension PlayerModel {
switch quality {
case .best:
return streams.first { $0.kind == .hls } ?? streams.first
return streams.first { $0.kind == .hls } ??
streams.filter { $0.kind == .stream }.max { $0.resolution < $1.resolution } ??
streams.first
default:
let sorted = streams.filter { $0.kind != .hls }.sorted { $0.resolution > $1.resolution }
return sorted.first(where: { $0.resolution.height <= quality.value.height })
@@ -84,7 +104,7 @@ extension PlayerModel {
}
func advanceToNextItem() {
addCurrentItemToHistory()
prepareCurrentItemForHistory()
if let nextItem = queue.first {
advanceToItem(nextItem)
@@ -92,10 +112,13 @@ extension PlayerModel {
}
func advanceToItem(_ newItem: PlayerQueueItem, at time: TimeInterval? = nil) {
addCurrentItemToHistory()
prepareCurrentItemForHistory()
remove(newItem)
currentItem = newItem
player.pause()
accounts.api.loadDetails(newItem) { newItem in
self.playItem(newItem, video: newItem.video, at: time)
}
@@ -124,7 +147,7 @@ extension PlayerModel {
}
func isAutoplaying(_ item: AVPlayerItem) -> Bool {
player.currentItem == item && presentingPlayer
player.currentItem == item
}
@discardableResult func enqueueVideo(
@@ -136,6 +159,12 @@ extension PlayerModel {
) -> PlayerQueueItem? {
let item = PlayerQueueItem(video, playbackTime: atTime)
if play {
currentItem = item
// pause playing current video as it's going to be replaced with next one
player.pause()
}
queue.insert(item, at: prepending ? 0 : queue.endIndex)
accounts.api.loadDetails(item) { newItem in
@@ -149,20 +178,15 @@ extension PlayerModel {
return item
}
func addCurrentItemToHistory() {
if let item = currentItem, Defaults[.saveHistory] {
addItemToHistory(item)
func prepareCurrentItemForHistory(finished: Bool = false) {
if !currentItem.isNil, Defaults[.saveHistory] {
if let video = currentVideo, !historyVideos.contains(where: { $0 == video }) {
historyVideos.append(video)
}
updateWatch(finished: finished)
}
}
func addItemToHistory(_ item: PlayerQueueItem) {
if let index = history.firstIndex(where: { $0.video?.videoID == item.video?.videoID }) {
history.remove(at: index)
}
history.insert(currentItem, at: 0)
}
func playHistory(_ item: PlayerQueueItem) {
var time = item.playbackTime
@@ -173,34 +197,20 @@ extension PlayerModel {
let newItem = enqueueVideo(item.video, atTime: time, prepending: true)
advanceToItem(newItem!)
if let historyItemIndex = history.firstIndex(of: item) {
history.remove(at: historyItemIndex)
}
}
@discardableResult func removeHistory(_ item: PlayerQueueItem) -> PlayerQueueItem? {
if let index = history.firstIndex(where: { $0 == item }) {
return history.remove(at: index)
}
return nil
}
func removeQueueItems() {
queue.removeAll()
}
func removeHistoryItems() {
history.removeAll()
}
func loadHistoryDetails() {
func restoreQueue() {
guard !accounts.current.isNil else {
return
}
queue = Defaults[.queue]
queue = ([Defaults[.lastPlayed]] + Defaults[.queue]).compactMap { $0 }
Defaults[.lastPlayed] = nil
queue.forEach { item in
accounts.api.loadDetails(item) { newItem in
if let index = self.queue.firstIndex(where: { $0.id == item.id }) {
@@ -208,32 +218,5 @@ extension PlayerModel {
}
}
}
var savedHistory = Defaults[.history]
if let lastPlayed = Defaults[.lastPlayed] {
if let index = savedHistory.firstIndex(where: { $0.videoID == lastPlayed.videoID }) {
var updatedLastPlayed = savedHistory[index]
updatedLastPlayed.playbackTime = lastPlayed.playbackTime
updatedLastPlayed.videoDuration = lastPlayed.videoDuration
savedHistory.remove(at: index)
savedHistory.insert(updatedLastPlayed, at: 0)
} else {
savedHistory.insert(lastPlayed, at: 0)
}
Defaults[.lastPlayed] = nil
}
history = savedHistory
history.forEach { item in
accounts.api.loadDetails(item) { newItem in
if let index = self.history.firstIndex(where: { $0.id == item.id }) {
self.history[index] = newItem
}
}
}
}
}

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

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

View File

@@ -4,6 +4,7 @@ import SwiftUI
final class PlaylistsModel: ObservableObject {
@Published var playlists = [Playlist]()
@Published var reloadPlaylists = false
var accounts = AccountsModel()
@@ -28,7 +29,7 @@ final class PlaylistsModel: ObservableObject {
}
func load(force: Bool = false, onSuccess: @escaping () -> Void = {}) {
guard !resource.isNil else {
guard accounts.app.supportsUserPlaylists, accounts.signedIn else {
playlists = []
return
}
@@ -52,22 +53,26 @@ final class PlaylistsModel: ObservableObject {
}
}
func addVideo(playlistID: Playlist.ID, videoID: Video.ID, onSuccess: @escaping () -> Void = {}) {
let resource = accounts.api.playlistVideos(playlistID)
let body = ["videoId": videoID]
resource?.request(.post, json: body).onSuccess { _ in
self.load(force: true)
onSuccess()
func addVideo(
playlistID: Playlist.ID,
videoID: Video.ID,
onSuccess: @escaping () -> Void = {},
onFailure: @escaping (RequestError) -> Void = { _ in }
) {
accounts.api.addVideoToPlaylist(videoID, playlistID, onFailure: onFailure) {
self.load(force: true) {
self.reloadPlaylists.toggle()
onSuccess()
}
}
}
func removeVideo(videoIndexID: String, playlistID: Playlist.ID, onSuccess: @escaping () -> Void = {}) {
let resource = accounts.api.playlistVideo(playlistID, videoIndexID)
resource?.request(.delete).onSuccess { _ in
self.load(force: true)
onSuccess()
func removeVideo(index: String, playlistID: Playlist.ID, onSuccess: @escaping () -> Void = {}) {
accounts.api.removeVideoFromPlaylist(index, playlistID, onFailure: { _ in }) {
self.load(force: true) {
self.reloadPlaylists.toggle()
onSuccess()
}
}
}

View File

@@ -1,22 +0,0 @@
import Alamofire
import Foundation
import SwiftyJSON
final class PlaylistsProvider: DataProvider {
@Published var playlists = [Playlist]()
let profile = Profile()
func load(successHandler: @escaping ([Playlist]) -> Void = { _ in }) {
let headers = HTTPHeaders([HTTPHeader(name: "Cookie", value: "SID=\(profile.sid)")])
DataProvider.request("auth/playlists", headers: headers).responseJSON { response in
switch response.result {
case let .success(value):
self.playlists = JSON(value).arrayValue.map { Playlist($0) }
successHandler(self.playlists)
case let .failure(error):
print(error)
}
}
}
}

View File

@@ -16,7 +16,7 @@ final class RecentsModel: ObservableObject {
if !saveRecents {
clear()
if item.type != .channel {
if item.type == .query {
return
}
}
@@ -34,8 +34,9 @@ final class RecentsModel: ObservableObject {
}
}
func addQuery(_ query: String) {
func addQuery(_ query: String, navigation: NavigationModel? = nil) {
if !query.isEmpty {
navigation?.tabSelection = .search
add(.init(from: query))
}
}
@@ -55,6 +56,15 @@ final class RecentsModel: ObservableObject {
return nil
}
static func symbolSystemImage(_ name: String) -> String {
let firstLetter = name.first?.lowercased()
let regex = #"^[a-z0-9]$"#
let symbolName = firstLetter?.range(of: regex, options: .regularExpression) != nil ? firstLetter! : "questionmark"
return "\(symbolName).circle"
}
}
struct RecentItem: Defaults.Serializable, Identifiable {

View File

@@ -4,15 +4,16 @@ import SwiftUI
final class SearchModel: ObservableObject {
@Published var store = Store<[ContentItem]>()
@Published var page: SearchPage?
var accounts = AccountsModel()
@Published var query = SearchQuery()
@Published var queryText = ""
@Published var querySuggestions = Store<[String]>()
@Published var suggestionsText = ""
@Published var fieldIsFocused = false
private var previousResource: Resource?
private var resource: Resource!
var isLoading: Bool {
@@ -22,60 +23,54 @@ final class SearchModel: ObservableObject {
func changeQuery(_ changeHandler: @escaping (SearchQuery) -> Void = { _ in }) {
changeHandler(query)
let newResource = accounts.api.search(query)
guard newResource != previousResource else {
let newResource = accounts.api.search(query, page: nil)
guard newResource != resource else {
return
}
previousResource?.removeObservers(ownedBy: store)
previousResource = newResource
page = nil
resource = newResource
resource.addObserver(store)
if !query.isEmpty {
loadResourceIfNeededAndReplaceStore()
loadResource()
}
}
func resetQuery(_ query: SearchQuery = SearchQuery()) {
self.query = query
let newResource = accounts.api.search(query)
guard newResource != previousResource else {
let newResource = accounts.api.search(query, page: nil)
guard newResource != resource else {
return
}
page = nil
store.replace([])
previousResource?.removeObservers(ownedBy: store)
previousResource = newResource
resource = newResource
resource.addObserver(store)
if !query.isEmpty {
loadResourceIfNeededAndReplaceStore()
loadResource()
}
}
func loadResourceIfNeededAndReplaceStore() {
func loadResource() {
let currentResource = resource!
if let request = resource.loadIfNeeded() {
request.onSuccess { response in
if let results: [ContentItem] = response.typedContent() {
self.replace(results, for: currentResource)
}
resource.load().onSuccess { response in
if let page: SearchPage = response.typedContent() {
self.page = page
self.replace(page.results, for: currentResource)
}
} else {
replace(store.collection, for: currentResource)
}
}
func replace(_ videos: [ContentItem], for resource: Resource) {
func replace(_ items: [ContentItem], for resource: Resource) {
if self.resource == resource {
store = Store<[ContentItem]>(videos)
store = Store<[ContentItem]>(items)
}
}
@@ -83,12 +78,13 @@ final class SearchModel: ObservableObject {
func loadSuggestions(_ query: String) {
guard !query.isEmpty else {
querySuggestions.replace([])
return
}
suggestionsDebounceTimer?.invalidate()
suggestionsDebounceTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: false) { _ in
suggestionsDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: false) { _ in
let resource = self.accounts.api.searchSuggestions(query: query)
resource.addObserver(self.querySuggestions)
@@ -99,10 +95,46 @@ final class SearchModel: ObservableObject {
if let suggestions: [String] = response.typedContent() {
self.querySuggestions = Store<[String]>(suggestions)
}
self.suggestionsText = query
}
} else {
self.querySuggestions = Store<[String]>(self.querySuggestions.collection)
self.suggestionsText = query
}
}
}
func loadNextPage() {
guard var pageToLoad = page, !pageToLoad.last else {
return
}
if pageToLoad.nextPage.isNil, accounts.app.searchUsesIndexedPages {
pageToLoad.nextPage = "2"
}
resource?.removeObservers(ownedBy: store)
resource = accounts.api.search(query, page: pageToLoad.nextPage)
resource.addObserver(store)
resource
.load()
.onSuccess { response in
if let page: SearchPage = response.typedContent() {
var nextPage: Int?
if self.accounts.app.searchUsesIndexedPages {
nextPage = Int(pageToLoad.nextPage ?? "0")
}
self.page = page
if self.accounts.app.searchUsesIndexedPages {
self.page?.nextPage = String((nextPage ?? 1) + 1)
}
self.replace(self.store.collection + page.results, for: self.resource)
}
}
}
}

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 {
@@ -16,7 +17,7 @@ struct Video: Identifiable, Equatable, Hashable {
var genre: String?
// index used when in the Playlist
let indexID: String?
var indexID: String?
var live: Bool
var upcoming: Bool
@@ -85,7 +86,7 @@ struct Video: Identifiable, Equatable, Hashable {
}
var likesCount: String? {
guard likes != -1 else {
guard (likes ?? 0) > 0 else {
return nil
}
@@ -93,7 +94,7 @@ struct Video: Identifiable, Equatable, Hashable {
}
var dislikesCount: String? {
guard dislikes != -1 else {
guard (dislikes ?? 0) > 0 else {
return nil
}
@@ -105,10 +106,24 @@ struct Video: Identifiable, Equatable, Hashable {
}
static func == (lhs: Video, rhs: Video) -> Bool {
lhs.id == rhs.id
let videoIDIsEqual = lhs.videoID == rhs.videoID
if !lhs.indexID.isNil, !rhs.indexID.isNil {
return videoIDIsEqual && lhs.indexID == rhs.indexID
}
return videoIDIsEqual
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
var watchFetchRequest: FetchRequest<Watch> {
FetchRequest<Watch>(
entity: Watch.entity(),
sortDescriptors: [],
predicate: NSPredicate(format: "videoID = %@", videoID)
)
}
}

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

145
README.md
View File

@@ -1,148 +1,73 @@
![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) instances built for iOS, tvOS and macOS.
<div align="center">
<img src="https://r.yattee.stream/icons/yattee-150.png" width="150" height="150" alt="Yattee logo">
<h1>Yattee</h1>
<p>Alternative YouTube frontend for iOS, tvOS and macOS<br />built with <a href="https://github.com/iv-org/invidious">Invidious</a> and <a href="https://github.com/TeamPiped/Piped">Piped</a></p>
[![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)
[![GitHub pull requests](https://img.shields.io/github/issues-pr/yattee/yattee)](https://github.com/yattee/yattee/pulls)
[![Matrix](https://img.shields.io/matrix/yattee:matrix.org)](https://matrix.to/#/#yattee:matrix.org)
[![Matrix](https://img.shields.io/matrix/yattee:matrix.org)](https://matrix.to/#/#Yattee:matrix.org)
Now on **Discord**:
[![Discord](https://img.shields.io/discord/992481375817052251?style=for-the-badge)](https://yattee.stream/discord)
![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
### Features in development
* New player component with custom controls, gestures and support for 4K playback
You can leave your feedback in [discussions](https://github.com/yattee/yattee/discussions) or join [Discord](https://yattee.stream/discord) or [Matrix Channel](https://matrix.to/#/#Yattee:matrix.org) for a chat. Thanks!
### Availability
| Feature | Invidious | Piped |
|| Invidious | Piped |
| - | - | - |
| User Accounts | ✅ | ✅ |
| Subscriptions | ✅ | ✅ |
| Popular | ✅ | 🔴 |
| User Playlists | ✅ | 🔴 |
| User Playlists | ✅ | |
| Trending | ✅ | ✅ |
| Channels | ✅ | ✅ |
| Channel Playlists | ✅ | ✅ |
| Search | ✅ | ✅ |
| Search Suggestions | ✅ | ✅ |
| Search Filters | ✅ | 🔴 |
| Popular | ✅ | 🔴 |
| Subtitles | 🔴 | ✅ |
| Comments | 🔴 | ✅ |
## Installation
### Requirements
System requirements:
* iOS 14 (or newer)
* tvOS 15 (or newer)
* macOS Big Sur (or newer)
You can browse and use accounts from one app and play videos with another (for example: use Invidious account for subscriptions and use Piped as playback source). Comments can be displayed from Piped even when Invidious is used for browsing/playing.
### How to install?
#### macOS
Download and run latest version from the [Releases](https://github.com/yattee/yattee/releases) page.
#### iOS/tvOS: [AltStore](https://altstore.io/) (free)
You can sideload IPA files downloaded from the [Releases](https://github.com/yattee/yattee/releases) page to your iOS or tvOS device - check [AltStore FAQ](https://altstore.io/faq/) for more information.
If you have to access to the beta AltStore version (v1.5, for Patreons only), you can add the following repository in `Browse > Sources` screen:
`https://alt.yattee.stream`
#### iOS/tvOS: Signing IPA files online (paid)
[UDID Registrations](https://www.udidregistrations.com/) provides services to sign IPA files for your devices. Refer to: ***Break free from the App Store*** section of the website for more information.
#### iOS/tvOS: Manual installation
Download sources and compile them on a Mac using Xcode, install to your devices. Please note that if you are not registered in Apple Developer Program you will need to reinstall every 7 days.
## Integrations
### macOS
With [Finicky](https://github.com/johnste/finicky) you can configure your system to open all the video links in the app. Example configuration:
```js
{
match: [
finicky.matchDomains(/(.*\.)?youtube.com/),
finicky.matchDomains(/(.*\.)?youtu.be/)
],
browser: "/Applications/Yattee.app"
}
```
## Screenshots
### iOS
| Player | Search | Playlists |
| - | - | - |
| [![Yattee Player iOS](https://r.yattee.stream/screenshots/iOS/player-thumb.png)](https://r.yattee.stream/screenshots/iOS/player.png) | [![Yattee Search iOS](https://r.yattee.stream/screenshots/iOS/search-suggestions-thumb.png)](https://r.yattee.stream/screenshots/iOS/search-suggestions.png) | [![Yattee Subscriptions iOS](https://r.yattee.stream/screenshots/iOS/playlists-thumb.png)](https://r.yattee.stream/screenshots/iOS/playlists.png) |
### iPadOS
| Settings | Player | Subscriptions |
| - | - | - |
| [![Yattee Player iPadOS](https://r.yattee.stream/screenshots/iPadOS/settings-thumb.png)](https://r.yattee.stream/screenshots/iPadOS/settings.png) | [![Yattee Player iPadOS](https://r.yattee.stream/screenshots/iPadOS/player-thumb.png)](https://r.yattee.stream/screenshots/iPadOS/player.png) | [![Yattee Subscriptions iPad S](https://r.yattee.stream/screenshots/iPadOS/subscriptions-thumb.png)](https://r.yattee.stream/screenshots/iPadOS/subscriptions.png) |
### tvOS
| Player | Popular | Search | Now Playing | Settings |
| - | - | - | - | - |
| [![Yattee Player tvOS](https://r.yattee.stream/screenshots/tvOS/player-thumb.png)](https://r.yattee.stream/screenshots/tvOS/player.png) | [![Yattee Popular tvOS](https://r.yattee.stream/screenshots/tvOS/popular-thumb.png)](https://r.yattee.stream/screenshots/tvOS/popular.png) | [![Yattee Search tvOS](https://r.yattee.stream/screenshots/tvOS/search-thumb.png)](https://r.yattee.stream/screenshots/tvOS/search.png) | [![Yattee Now Playing tvOS](https://r.yattee.stream/screenshots/tvOS/now-playing-thumb.png)](https://r.yattee.stream/screenshots/tvOS/now-playing.png) | [![Yattee Settings tvOS](https://r.yattee.stream/screenshots/tvOS/settings-thumb.png)](https://r.yattee.stream/screenshots/tvOS/settings.png) |
### macOS
| Player | Channel | Search | Settings |
| - | - | - | - |
| [![Yattee Player macOS](https://r.yattee.stream/screenshots/macOS/player-thumb.png)](https://r.yattee.stream/screenshots/macOS/player.png) | [![Yattee Channel macOS](https://r.yattee.stream/screenshots/macOS/channel-thumb.png)](https://r.yattee.stream/screenshots/macOS/channel.png) | [![Yattee Search macOS](https://r.yattee.stream/screenshots/macOS/search-thumb.png)](https://r.yattee.stream/screenshots/macOS/search.png) | [![Yattee Settings macOS](https://r.yattee.stream/screenshots/macOS/settings-thumb.png)](https://r.yattee.stream/screenshots/macOS/settings.png) |
## Tips
### Settings
* [tvOS] To open settings, press Play/Pause button while hovering over navigation menu or video
### Navigation
* Use videos context menus to add to queue, open or subscribe channel and add to playlist
* [tvOS] Pressing buttons in the app trigger switch to next available option (for example: next account in Settings). If you want to access list of all options, press and hold to open the context menu.
* [iOS] Swipe the player/title bar: up to open fullscreen details view, bottom to close fullscreen details or hide player
### Favorites
* Add more sections using ❤️ button in views channels, playlists, searches, subscriptions and popular
* [iOS/macOS] Reorganize with dragging and dropping
* [iOS/macOS] Remove section with right click/press and hold on section name
* [tvOS] Reorganize and remove from `Settings > Edit Favorites...`
### Keyboard shortcuts
* `Command+1` - Favorites
* `Command+2` - Subscriptions
* `Command+3` - Popular
* `Command+4` - Trending
* `Command+F` - Search
* `Command+P` - Play/Pause
* `Command+S` - Play Next
* `Command+O` - Toggle Player
## Donations
You can support development of this app with
[Patreon](https://www.patreon.com/arekf) or cryptocurrencies:
**Monero (XMR)**
```
48zfKjLmnXs21PinU2ucMiUPwhiKt5d7WJKiy3ACVS28BKqSn52c1TX8L337oESHJ5TZCyGkozjfWZG11h6C46mN9n4NPrD
```
**Bitcoin (BTC)**
```
bc1qe24zz5a5hm0trc7glwckz93py274eycxzju3mv
```
**Ethereum (ETH)**
```
0xa2f81A58Ec5E550132F03615c8d91954A4E37423
```
Donations will be used to cover development program access and domain renewal costs.
## Documentation
* [Installation](https://github.com/yattee/yattee/wiki/Installation-Instructions)
* [Building](https://github.com/yattee/yattee/wiki/Building-instructions)
* [FAQ](https://github.com/yattee/yattee/wiki)
* [Screenshots Gallery](https://github.com/yattee/yattee/wiki/Screenshots-Gallery)
* [Tips](https://github.com/yattee/yattee/wiki/Tips)
* [Integrations](https://github.com/yattee/yattee/wiki/Integrations)
* [Donations](https://github.com/yattee/yattee/wiki/Donations)
## Contributing
If you're interestred in contributing, you can browse the [issues](https://github.com/yattee/yattee/issues) list or create a new one to discuss your feature idea. Every contribution is very welcome.
## License and Liability
Use [building instructions](https://github.com/yattee/yattee/wiki/Building-instructions) or
join [Discord](https://yattee.stream/discord) or [Matrix Channel](https://matrix.to/#/#yattee:matrix.org) for a chat if you need an advice or want to discuss the project.
## License
Yattee and its components is shared on [AGPL v3](https://www.gnu.org/licenses/agpl-3.0.en.html) license.
Contributors take no responsibility for the use of the tool (Point 16. of the license). We strongly recommend you abide by the valid official regulations in your country. Furthermore, we refuse liability for any inappropriate use of the tool, such as downloading materials without proper consent.
## Disclaimer
The Yattee project and its contents are not affiliated with, funded, authorized, endorsed by, or in any way accociated with YouTube, Google LLC or any of its affiliates and subsidaries. The official YouTube website can be found at www.youtube.com.
Any trademark, service mark, trade name, or other intellectual property rights used in the Yattee project are owned by the respective owners.
This tool is an open source software built for learning and research purposes.

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.537",
"green" : "0.522",
"red" : "1.000"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.537",
"green" : "0.522",
"red" : "1.000"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

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

View File

@@ -0,0 +1,38 @@
import Defaults
import Foundation
extension Defaults.Serializable where Self: Codable {
static var bridge: Defaults.TopLevelCodableBridge<Self> { Defaults.TopLevelCodableBridge() }
}
extension Defaults.Serializable where Self: Codable & NSSecureCoding {
static var bridge: Defaults.CodableNSSecureCodingBridge<Self> { Defaults.CodableNSSecureCodingBridge() }
}
extension Defaults.Serializable where Self: Codable & NSSecureCoding & Defaults.PreferNSSecureCoding {
static var bridge: Defaults.NSSecureCodingBridge<Self> { Defaults.NSSecureCodingBridge() }
}
extension Defaults.Serializable where Self: Codable & RawRepresentable {
static var bridge: Defaults.RawRepresentableCodableBridge<Self> { Defaults.RawRepresentableCodableBridge() }
}
extension Defaults.Serializable where Self: Codable & RawRepresentable & Defaults.PreferRawRepresentable {
static var bridge: Defaults.RawRepresentableBridge<Self> { Defaults.RawRepresentableBridge() }
}
extension Defaults.Serializable where Self: RawRepresentable {
static var bridge: Defaults.RawRepresentableBridge<Self> { Defaults.RawRepresentableBridge() }
}
extension Defaults.Serializable where Self: NSSecureCoding {
static var bridge: Defaults.NSSecureCodingBridge<Self> { Defaults.NSSecureCodingBridge() }
}
extension Defaults.CollectionSerializable where Element: Defaults.Serializable {
static var bridge: Defaults.CollectionBridge<Self> { Defaults.CollectionBridge() }
}
extension Defaults.SetAlgebraSerializable where Element: Defaults.Serializable & Hashable {
static var bridge: Defaults.SetAlgebraBridge<Self> { Defaults.SetAlgebraBridge() }
}

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,32 +25,70 @@ extension Defaults.Keys {
static let favorites = Key<[FavoriteItem]>("favorites", default: [
.init(section: .trending("US", "default")),
.init(section: .trending("GB", "default")),
.init(section: .trending("ES", "default")),
.init(section: .channel("UC-lHJZR3Gqxm24_Vd_AJ5Yw", "PewDiePie")),
.init(section: .searchQuery("Apple Pie Recipes", "", "", ""))
.init(section: .channel("UCXuqSBlHAE6Xw-yeJA0Tunw", "Linus Tech Tips")),
.init(section: .channel("UCBJycsmduvYEL83R_U4JriQ", "Marques Brownlee")),
.init(section: .channel("UCE_M8A5yxnLfW0KghEeajjw", "Apple"))
])
#if !os(tvOS)
static let accountPickerDisplaysUsername = Key<Bool>("accountPickerDisplaysUsername", default: false)
#endif
#if os(iOS)
static let lockPortraitWhenBrowsing = Key<Bool>("lockPortraitWhenBrowsing", default: UIDevice.current.userInterfaceIdiom == .phone)
#endif
static let channelOnThumbnail = Key<Bool>("channelOnThumbnail", default: true)
static let timeOnThumbnail = Key<Bool>("timeOnThumbnail", default: true)
static let roundedThumbnails = Key<Bool>("roundedThumbnails", default: true)
static let quality = Key<ResolutionSetting>("quality", default: .best)
static let playerSidebar = Key<PlayerSidebarSetting>("playerSidebar", default: PlayerSidebarSetting.defaultValue)
static let playerInstanceID = Key<Instance.ID?>("playerInstance")
static let showKeywords = Key<Bool>("showKeywords", default: false)
static let showHistoryInPlayer = Key<Bool>("showHistoryInPlayer", default: false)
static let commentsInstanceID = Key<Instance.ID?>("commentsInstance", default: kavinPipedInstanceID)
#if !os(tvOS)
static let commentsPlacement = Key<CommentsPlacement>("commentsPlacement", default: .separate)
#endif
static let pauseOnHidingPlayer = Key<Bool>("pauseOnHidingPlayer", default: true)
static let closePiPOnNavigation = Key<Bool>("closePiPOnNavigation", default: false)
static let closePiPOnOpeningPlayer = Key<Bool>("closePiPOnOpeningPlayer", default: false)
#if !os(macOS)
static let closePiPAndOpenPlayerOnEnteringForeground = Key<Bool>("closePiPAndOpenPlayerOnEnteringForeground", default: false)
#endif
static let recentlyOpened = Key<[RecentItem]>("recentlyOpened", default: [])
static let queue = Key<[PlayerQueueItem]>("queue", default: [])
static let history = Key<[PlayerQueueItem]>("history", default: [])
static let lastPlayed = Key<PlayerQueueItem?>("lastPlayed")
static let saveHistory = Key<Bool>("saveHistory", default: true)
static let showWatchingProgress = Key<Bool>("showWatchingProgress", default: true)
static let watchedThreshold = Key<Int>("watchedThreshold", default: 90)
static let watchedVideoStyle = Key<WatchedVideoStyle>("watchedVideoStyle", default: .badge)
static let watchedVideoBadgeColor = Key<WatchedVideoBadgeColor>("WatchedVideoBadgeColor", default: .red)
static let watchedVideoPlayNowBehavior = Key<WatchedVideoPlayNowBehavior>("watchedVideoPlayNowBehavior", default: .continue)
static let resetWatchedStatusOnPlaying = Key<Bool>("resetWatchedStatusOnPlaying", default: false)
static let saveRecents = Key<Bool>("saveRecents", default: true)
static let trendingCategory = Key<TrendingCategory>("trendingCategory", default: .default)
static let trendingCountry = Key<Country>("trendingCountry", default: .us)
static let visibleSections = Key<Set<VisibleSection>>("visibleSections", default: [.favorites, .subscriptions, .trending, .playlists])
#if os(macOS)
static let enableBetaChannel = Key<Bool>("enableBetaChannel", default: false)
#endif
#if os(iOS)
static let honorSystemOrientationLock = Key<Bool>("honorSystemOrientationLock", default: true)
static let enterFullscreenInLandscape = Key<Bool>("enterFullscreenInLandscape", default: UIDevice.current.userInterfaceIdiom == .phone)
static let lockLandscapeOnRotation = Key<Bool>("lockLandscapeOnRotation", default: false)
static let lockLandscapeWhenEnteringFullscreen = Key<Bool>("lockLandscapeWhenEnteringFullscreen", default: false)
#endif
}
enum ResolutionSetting: String, CaseIterable, Defaults.Serializable {
@@ -65,7 +106,7 @@ enum ResolutionSetting: String, CaseIterable, Defaults.Serializable {
var description: String {
switch self {
case .best:
return "Best available"
return "Best available quality"
default:
return value.name
}
@@ -87,10 +128,6 @@ enum PlayerSidebarSetting: String, CaseIterable, Defaults.Serializable {
enum VisibleSection: String, CaseIterable, Comparable, Defaults.Serializable {
case favorites, subscriptions, popular, trending, playlists
static func from(_ string: String) -> VisibleSection {
allCases.first { $0.rawValue == string }!
}
var title: String {
rawValue.localizedCapitalized
}
@@ -129,3 +166,21 @@ enum VisibleSection: String, CaseIterable, Comparable, Defaults.Serializable {
lhs.sortOrder < rhs.sortOrder
}
}
enum WatchedVideoStyle: String, Defaults.Serializable {
case nothing, badge, decreasedOpacity, both
}
enum WatchedVideoBadgeColor: String, Defaults.Serializable {
case colorSchemeBased, red, blue
}
enum WatchedVideoPlayNowBehavior: String, Defaults.Serializable {
case `continue`, restart
}
#if !os(tvOS)
enum CommentsPlacement: String, CaseIterable, Defaults.Serializable {
case info, separate
}
#endif

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: LoadMoreContentHandlerType = {}
}
typealias LoadMoreContentHandlerType = () -> Void
extension EnvironmentValues {
var inNavigationView: Bool {
get { self[InNavigationViewKey.self] }
@@ -36,6 +46,11 @@ extension EnvironmentValues {
set { self[InChannelViewKey.self] = newValue }
}
var inChannelPlaylistView: Bool {
get { self[InChannelPlaylistViewKey.self] }
set { self[InChannelPlaylistViewKey.self] = newValue }
}
var horizontalCells: Bool {
get { self[HorizontalCellsKey.self] }
set { self[HorizontalCellsKey.self] = newValue }
@@ -50,4 +65,9 @@ extension EnvironmentValues {
get { self[CurrentPlaylistID.self] }
set { self[CurrentPlaylistID.self] = newValue }
}
var loadMoreContentHandler: LoadMoreContentHandlerType {
get { self[LoadMoreContentHandler.self] }
set { self[LoadMoreContentHandler.self] = newValue }
}
}

View File

@@ -11,10 +11,18 @@ struct DropFavorite: DropDelegate {
return
}
let from = favorites.firstIndex(of: current!)!
let to = favorites.firstIndex(of: item)!
guard let current = current else {
return
}
guard favorites[to].id != current!.id else {
let from = favorites.firstIndex(of: current)
let to = favorites.firstIndex(of: item)
guard let from = from, let to = to else {
return
}
guard favorites[to].id != current.id else {
return
}

View File

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

View File

@@ -13,6 +13,8 @@ final class FavoriteResourceObserver: ObservableObject, ResourceObserver {
contentItems = playlist.videos.map { ContentItem(video: $0) }
} else if let playlist: Playlist = resource.typedContent() {
contentItems = playlist.videos.map { ContentItem(video: $0) }
} else if let page: SearchPage = resource.typedContent() {
contentItems = page.results
} else if let items: [ContentItem] = resource.typedContent() {
contentItems = items
}

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

@@ -12,29 +12,29 @@ struct MenuCommands: Commands {
private var navigationMenu: some Commands {
CommandGroup(before: .windowSize) {
Button("Favorites") {
model.navigation?.tabSelection = .favorites
setTabSelection(.favorites)
}
.keyboardShortcut("1")
Button("Subscriptions") {
model.navigation?.tabSelection = .subscriptions
setTabSelection(.subscriptions)
}
.disabled(subscriptionsDisabled)
.keyboardShortcut("2")
Button("Popular") {
model.navigation?.tabSelection = .popular
setTabSelection(.popular)
}
.disabled(!(model.accounts?.app.supportsPopular ?? true))
.disabled(!(model.accounts?.app.supportsPopular ?? false))
.keyboardShortcut("3")
Button("Trending") {
model.navigation?.tabSelection = .trending
setTabSelection(.trending)
}
.keyboardShortcut("4")
Button("Search") {
model.navigation?.tabSelection = .search
setTabSelection(.search)
}
.keyboardShortcut("f")
@@ -42,6 +42,15 @@ struct MenuCommands: Commands {
}
}
private func setTabSelection(_ tabSelection: NavigationModel.TabSelection) {
guard let navigation = model.navigation else {
return
}
navigation.sidebarSectionChanged.toggle()
navigation.tabSelection = tabSelection
}
private var subscriptionsDisabled: Bool {
!(
(model.accounts?.app.supportsSubscriptions ?? false) && model.accounts?.signedIn ?? false
@@ -62,10 +71,18 @@ struct MenuCommands: Commands {
.disabled(model.player?.queue.isEmpty ?? true)
.keyboardShortcut("s")
Button((model.player?.presentingPlayer ?? true) ? "Hide Player" : "Show Player") {
Button(togglePlayerLabel) {
model.player?.togglePlayer()
}
.keyboardShortcut("o")
}
}
private var togglePlayerLabel: String {
#if os(macOS)
"Show Player"
#else
(model.player?.presentingPlayer ?? true) ? "Hide Player" : "Show Player"
#endif
}
}

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,15 @@ struct AppSidebarPlaylists: View {
NavigationLink(tag: TabSelection.playlist(playlist.id), selection: $navigation.tabSelection) {
LazyView(PlaylistVideosView(playlist))
} label: {
Label(playlist.title, systemImage: AppSidebarNavigation.symbolSystemImage(playlist.title))
.backport
.badge(Text("\(playlist.videos.count)"))
playlistLabel(playlist)
}
.id(playlist.id)
.contextMenu {
Button("Add to queue...") {
playlists.find(id: playlist.id)?.videos.forEach { video in
player.enqueueVideo(video)
}
Button("Play All") {
player.play(playlists.find(id: playlist.id)?.videos ?? [])
}
Button("Shuffle All") {
player.play(playlists.find(id: playlist.id)?.videos ?? [], shuffling: true)
}
Button("Edit") {
navigation.presentEditPlaylistForm(playlists.find(id: playlist.id))
@@ -33,6 +32,18 @@ struct AppSidebarPlaylists: View {
}
}
@ViewBuilder func playlistLabel(_ playlist: Playlist) -> some View {
let label = Label(playlist.title, systemImage: RecentsModel.symbolSystemImage(playlist.title))
if player.accounts.app.userPlaylistsEndpointIncludesVideos {
label
.backport
.badge(Text("\(playlist.videos.count)"))
} else {
label
}
}
var newPlaylistButton: some View {
Button(action: { navigation.presentNewPlaylistForm() }) {
Label("New Playlist", systemImage: "plus.circle")

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) {
@@ -33,7 +35,7 @@ struct AppTabNavigation: View {
trendingNavigationView
}
if visibleSections.contains(.playlists), accounts.app.supportsUserPlaylists {
if playlistsVisible {
playlistsNavigationView
}
@@ -42,31 +44,37 @@ struct AppTabNavigation: View {
.id(accounts.current?.id ?? "")
.environment(\.navigationStyle, .tab)
.background(
EmptyView().sheet(isPresented: $navigation.presentingChannel, onDismiss: {
if let channel = recents.presentedChannel {
recents.close(RecentItem(from: channel))
}
}) {
EmptyView().sheet(isPresented: $navigation.presentingChannel) {
if let channel = recents.presentedChannel {
NavigationView {
ChannelVideosView(channel: channel)
.environment(\.managedObjectContext, persistenceController.container.viewContext)
.environment(\.inChannelView, true)
.environment(\.inNavigationView, true)
.environmentObject(accounts)
.environmentObject(navigation)
.environmentObject(player)
.environmentObject(subscriptions)
.environmentObject(thumbnailsModel)
.background(playerNavigationLink)
}
}
}
)
.background(
EmptyView().sheet(isPresented: $navigation.presentingPlaylist, onDismiss: {
if let playlist = recents.presentedPlaylist {
recents.close(RecentItem(from: playlist))
}
}) {
EmptyView().sheet(isPresented: $navigation.presentingPlaylist) {
if let playlist = recents.presentedPlaylist {
NavigationView {
ChannelPlaylistView(playlist: playlist)
.environment(\.managedObjectContext, persistenceController.container.viewContext)
.environment(\.inNavigationView, true)
.environmentObject(accounts)
.environmentObject(navigation)
.environmentObject(player)
.environmentObject(subscriptions)
.environmentObject(thumbnailsModel)
.background(playerNavigationLink)
}
}
@@ -75,7 +83,8 @@ struct AppTabNavigation: View {
.background(
EmptyView().fullScreenCover(isPresented: $player.presentingPlayer) {
videoPlayer
.environment(\.navigationStyle, .sidebar)
.environment(\.managedObjectContext, persistenceController.container.viewContext)
.environment(\.navigationStyle, .tab)
}
)
}
@@ -109,6 +118,11 @@ struct AppTabNavigation: View {
accounts.app.supportsSubscriptions && !(accounts.current?.anonymous ?? true)
}
private var playlistsVisible: Bool {
visibleSections.contains(.playlists) &&
accounts.app.supportsUserPlaylists && !(accounts.current?.anonymous ?? true)
}
private var popularNavigationView: some View {
NavigationView {
LazyView(PopularView())
@@ -159,7 +173,7 @@ struct AppTabNavigation: View {
private var playerNavigationLink: some View {
NavigationLink(isActive: $player.playerNavigationLinkActive, destination: {
VideoPlayerView()
videoPlayer
.environment(\.inNavigationView, true)
}) {
EmptyView()

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
@@ -120,7 +140,7 @@ struct ContentView: View {
player.comments = comments
if !accounts.current.isNil {
player.loadHistoryDetails()
player.restoreQueue()
}
if !Defaults[.saveRecents] {
@@ -136,6 +156,9 @@ struct ContentView: View {
#endif
navigation.tabSelection = section ?? .search
subscriptions.load()
playlists.load()
}
func openWelcomeScreenIfAccountEmpty() {
@@ -145,28 +168,6 @@ struct ContentView: View {
navigation.presentingWelcomeScreen = true
}
#if !os(tvOS)
func handleOpenedURL(_ url: URL) {
guard !accounts.current.isNil else {
return
}
let parser = VideoURLParser(url: url)
guard let id = parser.id else {
return
}
accounts.api.video(id).load().onSuccess { response in
if let video: Video = response.typedContent() {
player.addCurrentItemToHistory()
self.player.playNow(video, at: parser.time)
self.player.presentPlayer()
}
}
}
#endif
}
struct ContentView_Previews: PreviewProvider {

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)
}
@@ -55,6 +45,7 @@ struct Sidebar: View {
Label("Favorites", systemImage: "heart")
.accessibility(label: Text("Favorites"))
}
.id("favorites")
}
if visibleSections.contains(.subscriptions),
accounts.app.supportsSubscriptions && accounts.signedIn
@@ -63,6 +54,7 @@ struct Sidebar: View {
Label("Subscriptions", systemImage: "star.circle")
.accessibility(label: Text("Subscriptions"))
}
.id("subscriptions")
}
if visibleSections.contains(.popular), accounts.app.supportsPopular {
@@ -70,6 +62,7 @@ struct Sidebar: View {
Label("Popular", systemImage: "arrow.up.right.circle")
.accessibility(label: Text("Popular"))
}
.id("popular")
}
if visibleSections.contains(.trending) {
@@ -77,12 +70,14 @@ struct Sidebar: View {
Label("Trending", systemImage: "chart.bar")
.accessibility(label: Text("Trending"))
}
.id("trending")
}
NavigationLink(destination: LazyView(SearchView()), tag: TabSelection.search, selection: $navigation.tabSelection) {
Label("Search", systemImage: "magnifyingglass")
.accessibility(label: Text("Search"))
}
.id("search")
.keyboardShortcut("f")
}
}
@@ -90,8 +85,12 @@ struct Sidebar: View {
private func scrollScrollViewToItem(scrollView: ScrollViewProxy, for selection: TabSelection) {
if case .recentlyOpened = selection {
scrollView.scrollTo("recentlyOpened")
return
} else if case let .playlist(id) = selection {
scrollView.scrollTo(id)
return
}
scrollView.scrollTo(selection.stringValue)
}
}

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)
@@ -144,10 +168,6 @@ struct CommentView: View {
Button {
repliesID = repliesID == comment.id ? nil : comment.id
if repliesID.isNil {
comments.replies = []
}
guard !repliesID.isNil, !comment.repliesPage.isNil else {
return
}
@@ -167,6 +187,7 @@ struct CommentView: View {
#if os(tvOS)
.padding(.leading, 5)
#else
.font(.system(size: 13))
.foregroundColor(.secondary)
#endif
}
@@ -185,7 +206,7 @@ struct CommentView: View {
#if os(macOS)
0.4
#else
0.8
0.6
#endif
}
@@ -230,17 +251,13 @@ struct CommentView: View {
}
private func openChannelAction() {
player.presentingPlayer = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
let recent = RecentItem(from: comment.channel)
recents.add(recent)
navigation.presentingChannel = true
if navigationStyle == .sidebar {
navigation.sidebarSectionChanged.toggle()
navigation.tabSelection = .recentlyOpened(recent.tag)
}
}
NavigationModel.openChannel(
comment.channel,
player: player,
recents: recents,
navigation: navigation,
navigationStyle: navigationStyle
)
}
}
@@ -251,5 +268,7 @@ struct CommentView_Previews: PreviewProvider {
static var previews: some View {
CommentView(comment: fixture, repliesID: .constant(fixture.id))
.environmentObject(SubscriptionsModel())
.padding(5)
}
}

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,12 +99,18 @@ struct PlaybackBar: View {
return "LIVE"
}
guard player.time != nil, player.time!.isValid, !player.currentVideo.isNil else {
guard !player.isLoadingVideo else {
return "loading..."
}
let videoLengthAtRate = player.currentVideo!.length / Double(player.currentRate)
let remainingSeconds = videoLengthAtRate - player.time!.seconds
guard let video = player.currentVideo,
let time = player.time
else {
return ""
}
let videoLengthAtRate = video.length / Double(player.currentRate)
let remainingSeconds = videoLengthAtRate - time.seconds
if remainingSeconds < 60 {
return "less than a minute"

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

View File

@@ -1,16 +1,18 @@
import Defaults
import Foundation
import SDWebImageSwiftUI
import SwiftUI
struct VideoDetails: View {
enum Page {
case info, queue, related, comments
case info, comments, related, queue
}
@Binding var sidebarQueue: Bool
@Binding var fullScreen: Bool
@State private var subscribed = false
@State private var subscriptionToggleButtonDisabled = false
@State private var presentingUnsubscribeAlert = false
@State private var presentingAddToPlaylist = false
@State private var presentingShareSheet = false
@@ -20,9 +22,12 @@ struct VideoDetails: View {
@Environment(\.presentationMode) private var presentationMode
@Environment(\.inNavigationView) private var inNavigationView
@Environment(\.navigationStyle) private var navigationStyle
@EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<RecentsModel> private var recents
@EnvironmentObject<SubscriptionsModel> private var subscriptions
@Default(.showKeywords) private var showKeywords
@@ -36,7 +41,7 @@ struct VideoDetails: View {
}
var video: Video? {
player.currentItem?.video
player.currentVideo
}
var body: some View {
@@ -65,7 +70,9 @@ struct VideoDetails: View {
}
.padding(.horizontal)
if CommentsModel.enabled {
if !sidebarQueue ||
(CommentsModel.enabled && CommentsModel.placement == .separate)
{
pagePicker
.padding(.horizontal)
}
@@ -90,8 +97,12 @@ struct VideoDetails: View {
switch currentPage {
case .info:
ScrollView(.vertical) {
detailsPage
if player.isLoadingVideo {
PlaceholderProgressView()
} else {
ScrollView(.vertical, showsIndicators: false) {
detailsPage
}
}
case .queue:
PlayerQueueView(sidebarQueue: $sidebarQueue, fullScreen: $fullScreen)
@@ -101,7 +112,7 @@ struct VideoDetails: View {
RelatedView()
.edgesIgnoringSafeArea(.horizontal)
case .comments:
CommentsView()
CommentsView(embedInScrollView: true)
.edgesIgnoringSafeArea(.horizontal)
}
}
@@ -118,7 +129,7 @@ struct VideoDetails: View {
}
.onChange(of: sidebarQueue) { queue in
if queue {
if currentPage == .queue {
if currentPage == .related || currentPage == .queue {
currentPage = .info
}
} else if video.isNil {
@@ -126,7 +137,7 @@ struct VideoDetails: View {
}
}
.edgesIgnoringSafeArea(.horizontal)
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .topLeading)
}
var title: some View {
@@ -178,23 +189,52 @@ struct VideoDetails: View {
Group {
if video != nil {
HStack(alignment: .center) {
HStack(spacing: 4) {
if subscribed {
Image(systemName: "star.circle.fill")
}
VStack(alignment: .leading) {
Text(video!.channel.name)
.font(.system(size: 13))
.bold()
if let subscribers = video!.channel.subscriptionsString {
Text("\(subscribers) subscribers")
.font(.caption2)
HStack(spacing: 10) {
Group {
ZStack(alignment: .bottomTrailing) {
authorAvatar
if subscribed {
Image(systemName: "star.circle.fill")
.background(Color.background)
.clipShape(Circle())
.foregroundColor(.secondary)
}
}
VStack(alignment: .leading) {
Text(video!.channel.name)
.font(.system(size: 14))
.bold()
Group {
if let subscribers = video!.channel.subscriptionsString {
Text("\(subscribers) subscribers")
}
}
.foregroundColor(.secondary)
.font(.caption2)
}
}
}
.contentShape(RoundedRectangle(cornerRadius: 12))
.contextMenu {
if let video = video {
Button(action: {
NavigationModel.openChannel(
video.channel,
player: player,
recents: recents,
navigation: navigation,
navigationStyle: navigationStyle
)
}) {
Label("\(video.channel.name) Channel", systemImage: "rectangle.stack.fill.badge.person.crop")
}
}
}
.foregroundColor(.secondary)
if accounts.app.supportsSubscriptions {
if accounts.app.supportsSubscriptions, accounts.signedIn {
Spacer()
Section {
@@ -209,13 +249,16 @@ struct VideoDetails: View {
.alert(isPresented: $presentingUnsubscribeAlert) {
Alert(
title: Text(
"Are you you want to unsubscribe from \(video!.channel.name)?"
"Are you sure you want to unsubscribe from \(video!.channel.name)?"
),
primaryButton: .destructive(Text("Unsubscribe")) {
subscriptions.unsubscribe(video!.channel.id)
subscriptionToggleButtonDisabled = true
withAnimation {
subscribed.toggle()
subscriptions.unsubscribe(video!.channel.id) {
withAnimation {
subscriptionToggleButtonDisabled = false
subscribed.toggle()
}
}
},
secondaryButton: .cancel()
@@ -223,16 +266,20 @@ struct VideoDetails: View {
}
} else {
Button("Subscribe") {
subscriptions.subscribe(video!.channel.id)
subscriptionToggleButtonDisabled = true
withAnimation {
subscribed.toggle()
subscriptions.subscribe(video!.channel.id) {
withAnimation {
subscriptionToggleButtonDisabled = false
subscribed.toggle()
}
}
}
.backport
.tint(.blue)
.tint(subscriptionToggleButtonDisabled ? .gray : .blue)
}
}
.disabled(subscriptionToggleButtonDisabled)
.font(.system(size: 13))
.buttonStyle(.borderless)
}
@@ -245,13 +292,12 @@ struct VideoDetails: View {
Picker("Page", selection: $currentPage) {
if !video.isNil {
Text("Info").tag(Page.info)
if CommentsModel.enabled, CommentsModel.placement == .separate {
Text("Comments").tag(Page.comments)
}
if !sidebarQueue {
Text("Related").tag(Page.related)
}
if CommentsModel.enabled {
Text("Comments")
.tag(Page.comments)
}
}
if !sidebarQueue {
Text("Queue").tag(Page.queue)
@@ -315,28 +361,32 @@ struct VideoDetails: View {
if let likes = video.likesCount {
Divider()
.frame(minHeight: 35)
videoDetail(label: "Likes", value: likes, symbol: "hand.thumbsup")
}
if let dislikes = video.dislikesCount {
Divider()
.frame(minHeight: 35)
videoDetail(label: "Dislikes", value: dislikes, symbol: "hand.thumbsdown")
}
Spacer()
if accounts.app.supportsUserPlaylists {
Button {
presentingAddToPlaylist = true
} label: {
Label("Add to Playlist", systemImage: "text.badge.plus")
.labelStyle(.iconOnly)
.help("Add to Playlist...")
}
.buttonStyle(.plain)
Button {
presentingAddToPlaylist = true
} label: {
Label("Add to Playlist", systemImage: "text.badge.plus")
.labelStyle(.iconOnly)
.help("Add to Playlist...")
}
.buttonStyle(.plain)
.opacity(accounts.app.supportsUserPlaylists ? 1 : 0)
#if os(macOS)
.frame(minWidth: 35, alignment: .trailing)
#endif
}
.frame(maxHeight: 35)
.foregroundColor(.secondary)
@@ -364,67 +414,99 @@ struct VideoDetails: View {
ContentItem(video: player.currentVideo!)
}
private var authorAvatar: some View {
Group {
if let video = video, let url = video.channel.thumbnailURL {
WebImage(url: url)
.resizable()
.placeholder {
Rectangle().fill(Color("PlaceholderColor"))
}
.retryOnAppear(true)
.indicator(.activity)
.clipShape(Circle())
.frame(width: 45, height: 45, alignment: .leading)
}
}
}
var detailsPage: some View {
Group {
if let video = player.currentItem?.video {
Group {
HStack {
publishedDateSection
Spacer()
}
Divider()
countsSection
}
Divider()
VStack(alignment: .leading, spacing: 10) {
if let description = video.description {
Group {
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
Text(description)
.textSelection(.enabled)
} else {
Text(description)
}
Group {
if let video = player.currentVideo {
VStack(spacing: 6) {
HStack {
publishedDateSection
Spacer()
}
.frame(maxWidth: .infinity, alignment: .leading)
.font(.system(size: 14))
.lineSpacing(3)
.padding(.bottom, 4)
} else {
Text("No description")
.foregroundColor(.secondary)
Divider()
countsSection
Divider()
}
if showKeywords {
ScrollView(.horizontal, showsIndicators: showScrollIndicators) {
HStack {
ForEach(video.keywords, id: \.self) { keyword in
HStack(alignment: .center, spacing: 0) {
Text("#")
.font(.system(size: 11).bold())
Text(keyword)
.frame(maxWidth: 500)
}
.font(.caption)
.foregroundColor(.white)
.padding(.vertical, 4)
.padding(.horizontal, 8)
.background(Color("VideoDetailLikesSymbolColor"))
.mask(RoundedRectangle(cornerRadius: 3))
VStack(alignment: .leading, spacing: 10) {
if let description = video.description {
Group {
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
Text(description)
.textSelection(.enabled)
} else {
Text(description)
}
}
.padding(.bottom, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.font(.system(size: 14))
.lineSpacing(3)
} else {
Text("No description")
.foregroundColor(.secondary)
}
if showKeywords {
ScrollView(.horizontal, showsIndicators: showScrollIndicators) {
HStack {
ForEach(video.keywords, id: \.self) { keyword in
HStack(alignment: .center, spacing: 0) {
Text("#")
.font(.system(size: 11).bold())
Text(keyword)
.frame(maxWidth: 500)
}
.font(.caption)
.foregroundColor(.white)
.padding(.vertical, 4)
.padding(.horizontal, 8)
.background(Color("KeywordBackgroundColor"))
.mask(RoundedRectangle(cornerRadius: 3))
}
}
.padding(.bottom, 10)
}
}
}
}
if !video.isNil, CommentsModel.placement == .info {
Divider()
#if os(macOS)
.padding(.bottom, 20)
#else
.padding(.vertical, 10)
#endif
}
}
.padding(.horizontal)
LazyVStack {
if !video.isNil, CommentsModel.placement == .info {
CommentsView()
}
}
}
.padding(.horizontal)
}
func videoDetail(label: String, value: String, symbol: String) -> some View {

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,22 +7,27 @@ struct AddToPlaylistView: View {
@State private var selectedPlaylistID: Playlist.ID = ""
@State private var error = ""
@State private var presentingErrorAlert = false
@State private var submitButtonDisabled = false
@Environment(\.colorScheme) private var colorScheme
@Environment(\.presentationMode) private var presentationMode
@EnvironmentObject<PlaylistsModel> private var model
var body: some View {
Group {
VStack {
header
Spacer()
if model.isEmpty {
emptyPlaylistsMessage
} else {
header
Spacer()
form
Spacer()
footer
}
Spacer()
footer
}
.frame(maxWidth: 1000, maxHeight: height)
}
@@ -118,8 +123,14 @@ struct AddToPlaylistView: View {
HStack {
Spacer()
Button("Add to Playlist", action: addToPlaylist)
.disabled(selectedPlaylist.isNil)
.disabled(submitButtonDisabled || selectedPlaylist.isNil)
.padding(.top, 30)
.alert(isPresented: $presentingErrorAlert) {
Alert(
title: Text("Error when accessing playlist"),
message: Text(error)
)
}
#if !os(tvOS)
.keyboardShortcut(.defaultAction)
#endif
@@ -155,9 +166,20 @@ struct AddToPlaylistView: View {
Defaults[.lastUsedPlaylistID] = id
model.addVideo(playlistID: id, videoID: video.videoID) {
presentationMode.wrappedValue.dismiss()
}
submitButtonDisabled = true
model.addVideo(
playlistID: id,
videoID: video.videoID,
onSuccess: {
presentationMode.wrappedValue.dismiss()
},
onFailure: { requestError in
error = "(\(requestError.httpStatusCode ?? -1)) \(requestError.userMessage)"
presentingErrorAlert = true
submitButtonDisabled = false
}
)
}
private var selectedPlaylist: Playlist? {

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
@@ -40,9 +43,12 @@ struct PlaylistFormView: View {
TextField("Name", text: $name, onCommit: validate)
.frame(maxWidth: 450)
.padding(.leading, 10)
.disabled(editing && !accounts.app.userPlaylistsAreEditable)
visibilityFormItem
.pickerStyle(.segmented)
if accounts.app.userPlaylistsHaveVisibility {
visibilityFormItem
.pickerStyle(.segmented)
}
}
#if os(macOS)
.padding(.horizontal)
@@ -56,7 +62,13 @@ struct PlaylistFormView: View {
Spacer()
Button("Save", action: submitForm)
.disabled(!valid)
.disabled(!valid || (editing && !accounts.app.userPlaylistsAreEditable))
.alert(isPresented: $presentingErrorAlert) {
Alert(
title: Text("Error when accessing playlist"),
message: Text(formError)
)
}
.keyboardShortcut(.defaultAction)
}
.frame(minHeight: 35)
@@ -66,7 +78,7 @@ struct PlaylistFormView: View {
#if os(iOS)
.padding(.vertical)
#else
.frame(width: 400, height: 150)
.frame(width: 400, height: accounts.app.userPlaylistsHaveVisibility ? 150 : 120)
#endif
#else
@@ -110,20 +122,24 @@ struct PlaylistFormView: View {
.frame(maxWidth: .infinity, alignment: .leading)
TextField("Playlist Name", text: $name, onCommit: validate)
.disabled(editing && !accounts.app.userPlaylistsAreEditable)
}
HStack {
Text("Visibility")
.frame(maxWidth: .infinity, alignment: .leading)
if accounts.app.userPlaylistsHaveVisibility {
HStack {
Text("Visibility")
.frame(maxWidth: .infinity, alignment: .leading)
visibilityFormItem
visibilityFormItem
}
.padding(.top, 10)
}
.padding(.top, 10)
HStack {
Spacer()
Button("Save", action: submitForm).disabled(!valid)
Button("Save", action: submitForm)
.disabled(!valid || (editing && !accounts.app.userPlaylistsAreEditable))
}
.padding(.top, 40)
@@ -163,23 +179,17 @@ struct PlaylistFormView: View {
return
}
let body = ["title": name, "privacy": visibility.rawValue]
resource?.request(editing ? .patch : .post, json: body).onSuccess { response in
if let modifiedPlaylist: Playlist = response.typedContent() {
playlist = modifiedPlaylist
}
accounts.api.playlistForm(name, visibility.rawValue, playlist: playlist, onFailure: { error in
formError = "(\(error.httpStatusCode ?? -1)) \(error.userMessage)"
presentingErrorAlert = true
}) { modifiedPlaylist in
self.playlist = modifiedPlaylist
playlists.load(force: true)
presentationMode.wrappedValue.dismiss()
}
}
var resource: Resource? {
editing ? accounts.api.playlist(playlist.id) : accounts.api.playlists
}
var visibilityFormItem: some View {
#if os(macOS)
Picker("Visibility", selection: $visibility) {
@@ -207,9 +217,9 @@ struct PlaylistFormView: View {
var deletePlaylistButton: some View {
Button("Delete") {
showingDeleteConfirmation = true
presentingDeleteConfirmation = true
}
.alert(isPresented: $showingDeleteConfirmation) {
.alert(isPresented: $presentingDeleteConfirmation) {
Alert(
title: Text("Are you sure you want to delete playlist?"),
message: Text("Playlist \"\(playlist.title)\" will be deleted.\nIt cannot be undone."),
@@ -221,7 +231,10 @@ struct PlaylistFormView: View {
}
func deletePlaylistAndDismiss() {
accounts.api.playlist(playlist.id)?.request(.delete).onSuccess { _ in
accounts.api.deletePlaylist(playlist, onFailure: { error in
formError = "(\(error.httpStatusCode ?? -1)) \(error.localizedDescription)"
presentingErrorAlert = true
}) {
playlist = nil
playlists.load(force: true)
presentationMode.wrappedValue.dismiss()

View File

@@ -11,6 +11,8 @@ struct PlaylistsView: View {
@State private var showingEditPlaylist = false
@State private var editedPlaylist: Playlist?
@StateObject private var store = Store<ChannelPlaylist>()
@EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<PlaylistsModel> private var model
@@ -18,7 +20,36 @@ struct PlaylistsView: View {
@Namespace private var focusNamespace
var items: [ContentItem] {
ContentItem.array(of: currentPlaylist?.videos ?? [])
var videos = currentPlaylist?.videos ?? []
if videos.isEmpty {
videos = store.item?.videos ?? []
if !player.accounts.app.userPlaylistsEndpointIncludesVideos {
var i = 0
for index in videos.indices {
var video = videos[index]
video.indexID = "\(i)"
i += 1
videos[index] = video
}
}
}
return ContentItem.array(of: videos)
}
private var resource: Resource? {
guard !player.accounts.app.userPlaylistsEndpointIncludesVideos,
let playlist = currentPlaylist
else {
return nil
}
let resource = player.accounts.api.playlist(playlist.id)
resource?.addObserver(store)
return resource
}
var body: some View {
@@ -74,25 +105,6 @@ struct PlaylistsView: View {
)
#endif
.toolbar {
ToolbarItemGroup {
#if !os(iOS)
if !model.isEmpty {
if #available(macOS 12.0, *) {
selectPlaylistButton
.prefersDefaultFocus(in: focusNamespace)
} else {
selectPlaylistButton
}
}
if currentPlaylist != nil {
editPlaylistButton
}
#endif
FavoriteButton(item: FavoriteItem(section: .playlist(selectedPlaylistID)))
}
#if os(iOS)
ToolbarItemGroup(placement: .bottomBar) {
Group {
@@ -100,21 +112,29 @@ struct PlaylistsView: View {
Text("No Playlists")
.foregroundColor(.secondary)
} else {
Text("Current Playlist")
.foregroundColor(.secondary)
selectPlaylistButton
.transaction { t in t.animation = .none }
}
Spacer()
newPlaylistButton
if currentPlaylist != nil {
editPlaylistButton
HStack(spacing: 10) {
playButton
shuffleButton
}
Spacer()
}
HStack(spacing: 2) {
newPlaylistButton
if currentPlaylist != nil {
editPlaylistButton
}
}
}
.transaction { t in t.animation = .none }
}
#endif
}
@@ -123,10 +143,20 @@ struct PlaylistsView: View {
#endif
.onAppear {
model.load()
resource?.load()
}
.onChange(of: accounts.current) { _ in
model.load(force: true)
}
.onChange(of: selectedPlaylistID) { _ in
resource?.load()
}
.onChange(of: model.reloadPlaylists) { _ in
resource?.load()
}
#if os(iOS)
.navigationBarTitleDisplayMode(RefreshControl.navigationBarTitleDisplayMode)
#endif
}
#if os(tvOS)
@@ -142,23 +172,14 @@ struct PlaylistsView: View {
selectPlaylistButton
}
Button {
player.playAll(items.compactMap(\.video))
player.presentPlayer()
} label: {
HStack(spacing: 15) {
Image(systemName: "play.fill")
Text("Play All")
}
}
if currentPlaylist != nil {
editPlaylistButton
}
if let playlist = currentPlaylist {
editPlaylistButton
FavoriteButton(item: FavoriteItem(section: .playlist(playlist.id)))
.labelStyle(.iconOnly)
playButton
shuffleButton
}
Spacer()
@@ -179,7 +200,7 @@ struct PlaylistsView: View {
}
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
#if os(macOS)
.background(Color.tertiaryBackground)
.background(Color.secondaryBackground)
#endif
}
@@ -228,7 +249,7 @@ struct PlaylistsView: View {
Button("Cancel", role: .cancel) {}
}
#else
Menu(currentPlaylist?.title ?? "Select playlist") {
Menu {
ForEach(model.all) { playlist in
Button(action: { selectedPlaylistID = playlist.id }) {
if playlist == currentPlaylist {
@@ -238,6 +259,9 @@ struct PlaylistsView: View {
}
}
}
} label: {
Text(currentPlaylist?.title ?? "Select playlist")
.frame(maxWidth: 140, alignment: .leading)
}
#endif
}
@@ -265,6 +289,22 @@ struct PlaylistsView: View {
}
}
private var playButton: some View {
Button {
player.play(items.compactMap(\.video))
} label: {
Image(systemName: "play")
}
}
private var shuffleButton: some View {
Button {
player.play(items.compactMap(\.video), shuffling: true)
} label: {
Image(systemName: "shuffle")
}
}
private var currentPlaylist: Playlist? {
model.find(id: selectedPlaylistID) ?? model.all.first
}

View File

@@ -3,6 +3,7 @@ import SwiftUI
struct SearchTextField: View {
@Environment(\.navigationStyle) private var navigationStyle
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<RecentsModel> private var recents
@EnvironmentObject<SearchModel> private var state
@@ -28,8 +29,11 @@ struct SearchTextField: View {
.opacity(0.8)
#endif
TextField("Search...", text: $state.queryText) {
state.changeQuery { query in query.query = state.queryText }
recents.addQuery(state.queryText)
state.changeQuery { query in
query.query = state.queryText
navigation.hideKeyboard()
}
recents.addQuery(state.queryText, navigation: navigation)
}
.onChange(of: state.queryText) { _ in
if state.query.query.compare(state.queryText, options: .caseInsensitive) == .orderedSame {
@@ -37,6 +41,7 @@ struct SearchTextField: View {
}
}
#if os(macOS)
.frame(maxWidth: 190)
.textFieldStyle(.plain)
#else
.textFieldStyle(.roundedBorder)
@@ -52,6 +57,11 @@ struct SearchTextField: View {
.padding(.trailing)
#endif
clearButton
} else {
#if os(macOS)
clearButton
.opacity(0)
#endif
}
}
}

View File

@@ -1,42 +1,65 @@
import SwiftUI
struct SearchSuggestions: View {
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<RecentsModel> private var recents
@EnvironmentObject<SearchModel> private var state
var body: some View {
List {
Button {
state.changeQuery { query in
query.query = state.queryText
state.fieldIsFocused = false
}
recents.addQuery(state.queryText)
runQueryAction()
} label: {
HStack(spacing: 5) {
Label(state.queryText, systemImage: "magnifyingglass")
HStack {
Image(systemName: "magnifyingglass")
Text(state.queryText)
.lineLimit(1)
}
}
.padding(.vertical, 5)
#if os(macOS)
.onHover(perform: onHover(_:))
.onHover(perform: onHover(_:))
#endif
ForEach(visibleSuggestions, id: \.self) { suggestion in
Button {
state.queryText = suggestion
} label: {
HStack(spacing: 0) {
Label(state.queryText, systemImage: "arrow.up.left.circle")
.lineLimit(1)
.layoutPriority(2)
.foregroundColor(.secondary)
HStack {
Button {
state.queryText = suggestion
runQueryAction()
} label: {
HStack {
Image(systemName: "magnifyingglass")
HStack(spacing: 0) {
if suggestion.hasPrefix(state.suggestionsText.lowercased()) {
Text(state.suggestionsText.lowercased())
.lineLimit(1)
.layoutPriority(2)
.foregroundColor(.secondary)
}
Text(querySuffix(suggestion))
.lineLimit(1)
.layoutPriority(1)
Text(querySuffix(suggestion))
.lineLimit(1)
.layoutPriority(1)
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
}
}
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.buttonStyle(.plain)
Spacer()
Button {
state.queryText = suggestion
} label: {
Image(systemName: "arrow.up.left.circle")
.foregroundColor(.secondary)
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.buttonStyle(.plain)
}
#if os(macOS)
.onHover(perform: onHover(_:))
@@ -48,6 +71,16 @@ struct SearchSuggestions: View {
#endif
}
private func runQueryAction() {
state.changeQuery { query in
query.query = state.queryText
state.fieldIsFocused = false
navigation.hideKeyboard()
}
recents.addQuery(state.queryText, navigation: navigation)
}
private var visibleSuggestions: [String] {
state.querySuggestions.collection.filter {
$0.compare(state.queryText, options: .caseInsensitive) != .orderedSame
@@ -55,7 +88,7 @@ struct SearchSuggestions: View {
}
private func querySuffix(_ suggestion: String) -> String {
suggestion.replacingFirstOccurrence(of: state.queryText.lowercased(), with: "")
suggestion.replacingFirstOccurrence(of: state.suggestionsText.lowercased(), with: "")
}
#if os(macOS)

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
@@ -175,11 +177,40 @@ struct SearchView: View {
.navigationTitle("Search")
#endif
#if os(iOS)
.navigationBarHidden(!Defaults[.visibleSections].isEmpty || navigationStyle == .sidebar)
.navigationBarHidden(navigationBarHidden)
.navigationBarTitleDisplayMode(.inline)
#endif
}
private var navigationBarHidden: Bool {
if navigationStyle == .sidebar {
return true
}
let preferred = Defaults[.visibleSections]
var visibleSections = [VisibleSection]()
if accounts.app.supportsPopular && preferred.contains(.popular) {
visibleSections.append(.popular)
}
if accounts.app.supportsSubscriptions && accounts.signedIn && preferred.contains(.subscriptions) {
visibleSections.append(.subscriptions)
}
if accounts.app.supportsUserPlaylists && accounts.signedIn && preferred.contains(.playlists) {
visibleSections.append(.playlists)
}
[VisibleSection.favorites, .trending].forEach { section in
if preferred.contains(section) {
visibleSections.append(section)
}
}
return !visibleSections.isEmpty
}
private var results: some View {
VStack {
if showRecentQueries {
@@ -199,10 +230,12 @@ struct SearchView: View {
}
HorizontalCells(items: items)
.environment(\.loadMoreContentHandler) { state.loadNextPage() }
}
.edgesIgnoringSafeArea(.horizontal)
#else
VerticalCells(items: items)
VerticalCells(items: items, allowEmpty: state.query.isEmpty)
.environment(\.loadMoreContentHandler) { state.loadNextPage() }
#endif
if noResults {
@@ -253,15 +286,7 @@ struct SearchView: View {
.foregroundColor(.secondary)
}
ForEach(recentItems) { item in
Button(item.title) {
state.queryText = item.title
state.changeQuery { query in query.query = item.title }
updateFavoriteItem()
}
.contextMenu {
deleteButton(item)
deleteAllButton
}
recentItemButton(item)
}
}
.redrawOn(change: recentsChanged)
@@ -272,21 +297,70 @@ struct SearchView: View {
#endif
}
private func deleteButton(_ item: RecentItem) -> some View {
private func recentItemButton(_ item: RecentItem) -> some View {
Button {
switch item.type {
case .query:
state.queryText = item.title
state.changeQuery { query in query.query = item.title }
updateFavoriteItem()
recents.add(item)
case .channel:
guard let channel = item.channel else {
return
}
NavigationModel.openChannel(
channel,
player: player,
recents: recents,
navigation: navigation,
navigationStyle: navigationStyle,
delay: false
)
case .playlist:
guard let playlist = item.playlist else {
return
}
NavigationModel.openChannelPlaylist(
playlist,
player: player,
recents: recents,
navigation: navigation,
navigationStyle: navigationStyle,
delay: false
)
}
} label: {
let systemImage = item.type == .query ? "magnifyingglass" :
item.type == .channel ? RecentsModel.symbolSystemImage(item.title) :
"list.and.film"
Label(item.title, systemImage: systemImage)
.lineLimit(1)
}
.contextMenu {
removeButton(item)
removeAllButton
}
}
private func removeButton(_ item: RecentItem) -> some View {
Button {
recents.close(item)
recentsChanged.toggle()
} label: {
Label("Delete", systemImage: "trash")
Label("Remove", systemImage: "trash")
}
}
private var deleteAllButton: some View {
private var removeAllButton: some View {
Button {
recents.clearQueries()
recents.clear()
recentsChanged.toggle()
} label: {
Label("Delete All", systemImage: "trash.fill")
Label("Remove All", systemImage: "trash.fill")
}
}
@@ -295,7 +369,7 @@ struct SearchView: View {
}
private var recentItems: [RecentItem] {
Defaults[.recentlyOpened].filter { $0.type == .query }.reversed()
Defaults[.recentlyOpened].reversed()
}
private var searchSortOrderPicker: some View {

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,245 @@
import Defaults
import SwiftUI
struct PlayerSettings: View {
@Default(.instances) private var instances
@Default(.playerInstanceID) private var playerInstanceID
@Default(.quality) private var quality
@Default(.commentsInstanceID) private var commentsInstanceID
#if !os(tvOS)
@Default(.commentsPlacement) private var commentsPlacement
#endif
@Default(.playerSidebar) private var playerSidebar
@Default(.showHistoryInPlayer) private var showHistory
@Default(.showKeywords) private var showKeywords
@Default(.pauseOnHidingPlayer) private var pauseOnHidingPlayer
#if os(iOS)
@Default(.honorSystemOrientationLock) private var honorSystemOrientationLock
@Default(.lockLandscapeOnRotation) private var lockLandscapeOnRotation
@Default(.enterFullscreenInLandscape) private var enterFullscreenInLandscape
@Default(.lockLandscapeWhenEnteringFullscreen) private var lockLandscapeWhenEnteringFullscreen
#endif
@Default(.closePiPOnNavigation) private var closePiPOnNavigation
@Default(.closePiPOnOpeningPlayer) private var closePiPOnOpeningPlayer
#if !os(macOS)
@Default(.closePiPAndOpenPlayerOnEnteringForeground) private var closePiPAndOpenPlayerOnEnteringForeground
#endif
#if os(iOS)
private var idiom: UIUserInterfaceIdiom {
UIDevice.current.userInterfaceIdiom
}
#endif
var body: some View {
Group {
#if os(macOS)
sections
Spacer()
#else
List {
sections
}
#endif
}
#if os(tvOS)
.frame(maxWidth: 1000)
#elseif os(iOS)
.listStyle(.insetGrouped)
#endif
.navigationTitle("Player")
}
private var sections: some View {
Group {
Section(header: SettingsHeader(text: "Playback")) {
sourcePicker
qualityPicker
pauseOnHidingPlayerToggle
}
Section(header: SettingsHeader(text: "Comments")) {
commentsInstancePicker
#if !os(tvOS)
commentsPlacementPicker
.disabled(!CommentsModel.enabled)
#endif
}
Section(header: SettingsHeader(text: "Interface")) {
#if os(iOS)
if idiom == .pad {
sidebarPicker
}
#endif
#if os(macOS)
sidebarPicker
#endif
keywordsToggle
showHistoryToggle
}
Section(header: SettingsHeader(text: "Picture in Picture")) {
closePiPOnNavigationToggle
closePiPOnOpeningPlayerToggle
#if !os(macOS)
closePiPAndOpenPlayerOnEnteringForegroundToggle
#endif
}
#if os(iOS)
Section(header: SettingsHeader(text: "Orientation"), footer: orientationFooter) {
if idiom == .pad {
enterFullscreenInLandscapeToggle
}
honorSystemOrientationLockToggle
lockLandscapeOnRotationToggle
lockLandscapeWhenEnteringFullscreenToggle
}
#endif
}
}
private var sourcePicker: some View {
Picker("Source", selection: $playerInstanceID) {
Text("Best available stream").tag(String?.none)
ForEach(instances) { instance in
Text(instance.description).tag(Optional(instance.id))
}
}
.labelsHidden()
#if os(iOS)
.pickerStyle(.automatic)
#elseif os(tvOS)
.pickerStyle(.inline)
#endif
}
private var qualityPicker: some View {
Picker("Quality", selection: $quality) {
ForEach(ResolutionSetting.allCases, id: \.self) { resolution in
Text(resolution.description).tag(resolution)
}
}
.labelsHidden()
#if os(iOS)
.pickerStyle(.automatic)
#elseif os(tvOS)
.pickerStyle(.inline)
#endif
}
private var commentsInstancePicker: some View {
Picker("Source", selection: $commentsInstanceID) {
Text("Disabled").tag(Optional(""))
ForEach(InstancesModel.all.filter { $0.app.supportsComments }) { instance in
Text(instance.description).tag(Optional(instance.id))
}
}
.labelsHidden()
#if os(iOS)
.pickerStyle(.automatic)
#elseif os(tvOS)
.pickerStyle(.inline)
#endif
}
#if !os(tvOS)
private var commentsPlacementPicker: some View {
Picker("Placement", selection: $commentsPlacement) {
Text("Below video description").tag(CommentsPlacement.info)
Text("Separate tab").tag(CommentsPlacement.separate)
}
.labelsHidden()
#if os(iOS)
.pickerStyle(.automatic)
#endif
}
#endif
private var sidebarPicker: some View {
Picker("Sidebar", selection: $playerSidebar) {
#if os(macOS)
Text("Show sidebar").tag(PlayerSidebarSetting.always)
#endif
#if os(iOS)
Text("Show sidebar when space permits").tag(PlayerSidebarSetting.whenFits)
#endif
Text("Hide sidebar").tag(PlayerSidebarSetting.never)
}
.labelsHidden()
#if os(iOS)
.pickerStyle(.automatic)
#elseif os(tvOS)
.pickerStyle(.inline)
#endif
}
private var keywordsToggle: some View {
Toggle("Show keywords", isOn: $showKeywords)
}
private var showHistoryToggle: some View {
Toggle("Show history", isOn: $showHistory)
}
private var pauseOnHidingPlayerToggle: some View {
Toggle("Pause when player is closed", isOn: $pauseOnHidingPlayer)
}
#if os(iOS)
private var honorSystemOrientationLockToggle: some View {
Toggle("Honor orientation lock", isOn: $honorSystemOrientationLock)
.disabled(!enterFullscreenInLandscape)
}
private var enterFullscreenInLandscapeToggle: some View {
Toggle("Enter fullscreen in landscape", isOn: $enterFullscreenInLandscape)
}
private var lockLandscapeOnRotationToggle: some View {
Toggle("Lock landscape on rotation", isOn: $lockLandscapeOnRotation)
.disabled(!enterFullscreenInLandscape)
}
private var lockLandscapeWhenEnteringFullscreenToggle: some View {
Toggle("Rotate and lock landscape on entering fullscreen", isOn: $lockLandscapeWhenEnteringFullscreen)
}
private var orientationFooter: some View {
Text("Orientation settings are experimental and do not yet work properly with all devices and iOS versions")
}
#endif
private var closePiPOnNavigationToggle: some View {
Toggle("Close PiP when starting playing other video", isOn: $closePiPOnNavigation)
}
private var closePiPOnOpeningPlayerToggle: some View {
Toggle("Close PiP when player is opened", isOn: $closePiPOnOpeningPlayer)
}
#if !os(macOS)
private var closePiPAndOpenPlayerOnEnteringForegroundToggle: some View {
Toggle("Close PiP and open player when application enters foreground", isOn: $closePiPAndOpenPlayerOnEnteringForeground)
}
#endif
}
struct PlaybackSettings_Previews: PreviewProvider {
static var previews: some View {
PlayerSettings()
.injectFixtureEnvironmentObjects()
}
}

View File

@@ -1,131 +0,0 @@
import Defaults
import SwiftUI
struct ServicesSettings: View {
@Default(.sponsorBlockInstance) private var sponsorBlockInstance
@Default(.sponsorBlockCategories) private var sponsorBlockCategories
@Default(.commentsInstanceID) private var commentsInstanceID
var body: some View {
Section(header: SettingsHeader(text: "Comments")) {
commentsInstancePicker
}
Section(header: SettingsHeader(text: "SponsorBlock API")) {
TextField(
"SponsorBlock API Instance",
text: $sponsorBlockInstance
)
.labelsHidden()
#if !os(macOS)
.autocapitalization(.none)
.keyboardType(.URL)
#endif
}
Section(header: SettingsHeader(text: "Categories to Skip")) {
#if os(macOS)
let list = ForEach(SponsorBlockAPI.categories, id: \.self) { category in
SponsorBlockCategorySelectionRow(
title: SponsorBlockAPI.categoryDescription(category) ?? "Unknown",
selected: sponsorBlockCategories.contains(category)
) { value in
toggleCategory(category, value: value)
}
}
Group {
if #available(macOS 12.0, *) {
list
.listStyle(.inset(alternatesRowBackgrounds: true))
} else {
list
.listStyle(.inset)
}
}
Spacer()
#else
ForEach(SponsorBlockAPI.categories, id: \.self) { category in
SponsorBlockCategorySelectionRow(
title: SponsorBlockAPI.categoryDescription(category) ?? "Unknown",
selected: sponsorBlockCategories.contains(category)
) { value in
toggleCategory(category, value: value)
}
}
#endif
}
}
private var commentsInstancePicker: some View {
Picker("Comments", selection: $commentsInstanceID) {
Text("Disabled").tag(Optional(""))
ForEach(InstancesModel.all.filter { $0.app.supportsComments }) { instance in
Text(instance.description).tag(Optional(instance.id))
}
}
.labelsHidden()
#if os(iOS)
.pickerStyle(.automatic)
#elseif os(tvOS)
.pickerStyle(.inline)
#endif
}
func toggleCategory(_ category: String, value: Bool) {
if let index = sponsorBlockCategories.firstIndex(where: { $0 == category }), !value {
sponsorBlockCategories.remove(at: index)
} else if value {
sponsorBlockCategories.insert(category)
}
}
struct SponsorBlockCategorySelectionRow: View {
let title: String
let selected: Bool
var action: (Bool) -> Void
@State private var toggleChecked = false
var body: some View {
Button(action: { action(!selected) }) {
HStack {
#if os(macOS)
Toggle(isOn: $toggleChecked) {
Text(self.title)
Spacer()
}
.onAppear {
toggleChecked = selected
}
.onChange(of: toggleChecked) { new in
action(new)
}
#else
Text(self.title)
Spacer()
if selected {
Image(systemName: "checkmark")
#if os(iOS)
.foregroundColor(.accentColor)
#endif
}
#endif
}
.contentShape(Rectangle())
}
#if !os(tvOS)
.buttonStyle(.plain)
#endif
}
}
}
struct ServicesSettings_Previews: PreviewProvider {
static var previews: some View {
VStack {
ServicesSettings()
}
}
}

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
case instances, browsing, player, history, sponsorBlock, updates, help
}
@State private var selection = Tabs.instances
#endif
@Environment(\.colorScheme) private var colorScheme
@@ -24,7 +26,7 @@ struct SettingsView: View {
var body: some View {
#if os(macOS)
TabView {
TabView(selection: $selection) {
Form {
InstancesSettings()
.environmentObject(accounts)
@@ -43,34 +45,52 @@ struct SettingsView: View {
.tag(Tabs.browsing)
Form {
PlaybackSettings()
PlayerSettings()
}
.tabItem {
Label("Playback", systemImage: "play.rectangle")
Label("Player", systemImage: "play.rectangle")
}
.tag(Tabs.playback)
.tag(Tabs.player)
Form {
ServicesSettings()
HistorySettings()
}
.tabItem {
Label("Services", systemImage: "puzzlepiece")
Label("History", systemImage: "clock.arrow.circlepath")
}
.tag(Tabs.services)
.tag(Tabs.history)
Form {
SponsorBlockSettings()
}
.tabItem {
Label("SponsorBlock", systemImage: "dollarsign.circle")
}
.tag(Tabs.sponsorBlock)
Form {
UpdatesSettings()
}
.tabItem {
Label("Updates", systemImage: "gearshape.2")
}
.tag(Tabs.updates)
Form {
Help()
}
.tabItem {
Label("Help", systemImage: "questionmark.circle")
}
.tag(Tabs.help)
}
.padding(20)
.frame(width: 400, height: 380)
.frame(width: 480, height: windowHeight)
#else
NavigationView {
List {
#if os(tvOS)
AccountSelectionView()
Section(header: SettingsHeader(text: "Favorites")) {
NavigationLink("Edit favorites...") {
EditFavorites()
}
}
#endif
Section(header: Text("Instances")) {
@@ -80,13 +100,55 @@ struct SettingsView: View {
addInstanceButton
}
BrowsingSettings()
PlaybackSettings()
ServicesSettings()
#if os(tvOS)
Divider()
#endif
Section {
#if os(tvOS)
NavigationLink {
EditFavorites()
} label: {
Label("Favorites", systemImage: "heart.fill")
}
#endif
NavigationLink {
BrowsingSettings()
} label: {
Label("Browsing", systemImage: "list.and.film")
}
NavigationLink {
PlayerSettings()
} label: {
Label("Player", systemImage: "play.rectangle")
}
NavigationLink {
HistorySettings()
} label: {
Label("History", systemImage: "clock.arrow.circlepath")
}
NavigationLink {
SponsorBlockSettings()
} label: {
Label("SponsorBlock", systemImage: "dollarsign.circle")
}
}
Section(footer: versionString) {
NavigationLink {
Help()
} label: {
Label("Help", systemImage: "questionmark.circle")
}
}
}
.navigationTitle("Settings")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .navigationBarLeading) {
#if !os(tvOS)
Button("Done") {
presentationMode.wrappedValue.dismiss()
@@ -109,9 +171,39 @@ struct SettingsView: View {
#endif
}
#if os(macOS)
private var windowHeight: Double {
switch selection {
case .instances:
return 390
case .browsing:
return 350
case .player:
return 450
case .history:
return 480
case .sponsorBlock:
return 660
case .updates:
return 200
case .help:
return 570
}
}
#endif
private var versionString: some View {
Text("Yattee \(YatteeApp.version) (build \(YatteeApp.build))")
#if os(tvOS)
.foregroundColor(.secondary)
#endif
}
private var addInstanceButton: some View {
Button("Add Instance...") {
Button {
presentingInstanceForm = true
} label: {
Label("Add Instance...", systemImage: "plus")
}
}
}

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

@@ -9,6 +9,7 @@ struct TrendingCountry: View {
@State private var query: String = ""
@State private var selection: Country?
@Environment(\.colorScheme) private var colorScheme
@Environment(\.presentationMode) private var presentationMode
var body: some View {
@@ -30,8 +31,8 @@ struct TrendingCountry: View {
countriesList
}
#if os(tvOS)
.searchable(text: $query, placement: .automatic, prompt: Text(TrendingCountry.prompt))
.background(Color.black)
.searchable(text: $query, placement: .automatic, prompt: Text(Self.prompt))
.background(Color.background(scheme: colorScheme))
#endif
}

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,14 +4,17 @@ import SwiftUI
struct HorizontalCells: View {
var items = [ContentItem]()
@Environment(\.loadMoreContentHandler) private var loadMoreContentHandler
@Default(.channelOnThumbnail) private var channelOnThumbnail
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 20) {
ForEach(items) { item in
ForEach(contentItems) { item in
ContentItemView(item: item)
.environment(\.horizontalCells, true)
.onAppear { loadMoreContentItemsIfNeeded(current: item) }
#if os(tvOS)
.frame(width: 580)
.padding(.trailing, 20)
@@ -33,6 +36,21 @@ struct HorizontalCells: View {
.edgesIgnoringSafeArea(.horizontal)
}
var contentItems: [ContentItem] {
items.isEmpty ? placeholders : items
}
var placeholders: [ContentItem] {
(0 ..< 9).map { _ in .init() }
}
func loadMoreContentItemsIfNeeded(current item: ContentItem) {
let thresholdIndex = items.index(items.endIndex, offsetBy: -5)
if items.firstIndex(where: { $0.id == item.id }) == thresholdIndex {
loadMoreContentHandler()
}
}
var cellHeight: Double {
#if os(tvOS)
560

View File

@@ -6,27 +6,46 @@ struct VerticalCells: View {
@Environment(\.verticalSizeClass) private var verticalSizeClass
#endif
@Environment(\.loadMoreContentHandler) private var loadMoreContentHandler
var items = [ContentItem]()
var allowEmpty = false
var body: some View {
ScrollView(.vertical, showsIndicators: scrollViewShowsIndicators) {
LazyVGrid(columns: columns, alignment: .center) {
ForEach(items.sorted { $0 < $1 }) { item in
ForEach(contentItems) { item in
ContentItemView(item: item)
.onAppear { loadMoreContentItemsIfNeeded(current: item) }
}
}
.padding()
}
.edgesIgnoringSafeArea(.horizontal)
#if os(macOS)
.background(Color.tertiaryBackground)
.background(Color.secondaryBackground)
.frame(minWidth: 360)
#endif
}
var contentItems: [ContentItem] {
items.isEmpty ? (allowEmpty ? items : placeholders) : items.sorted { $0 < $1 }
}
var placeholders: [ContentItem] {
(0 ..< 9).map { _ in .init() }
}
func loadMoreContentItemsIfNeeded(current item: ContentItem) {
let thresholdIndex = items.index(items.endIndex, offsetBy: -5)
if items.firstIndex(where: { $0.id == item.id }) == thresholdIndex {
loadMoreContentHandler()
}
}
var columns: [GridItem] {
#if os(tvOS)
items.count < 3 ? Array(repeating: GridItem(.fixed(500)), count: [items.count, 1].max()!) : adaptiveItem
contentItems.count < 3 ? Array(repeating: GridItem(.fixed(500)), count: [contentItems.count, 1].max()!) : adaptiveItem
#else
adaptiveItem
#endif

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,9 +3,10 @@ import SDWebImageSwiftUI
import SwiftUI
struct VideoCell: View {
var video: Video
private var video: Video
@Environment(\.inNavigationView) private var inNavigationView
@Environment(\.navigationStyle) private var navigationStyle
#if os(iOS)
@Environment(\.verticalSizeClass) private var verticalSizeClass
@@ -13,32 +14,36 @@ struct VideoCell: View {
#endif
@EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<RecentsModel> private var recents
@EnvironmentObject<ThumbnailsModel> private var thumbnails
@Default(.channelOnThumbnail) private var channelOnThumbnail
@Default(.timeOnThumbnail) private var timeOnThumbnail
@Default(.roundedThumbnails) private var roundedThumbnails
@Default(.saveHistory) private var saveHistory
@Default(.showWatchingProgress) private var showWatchingProgress
@Default(.watchedVideoStyle) private var watchedVideoStyle
@Default(.watchedVideoBadgeColor) private var watchedVideoBadgeColor
@Default(.watchedVideoPlayNowBehavior) private var watchedVideoPlayNowBehavior
@FetchRequest private var watchRequest: FetchedResults<Watch>
init(video: Video) {
self.video = video
_watchRequest = video.watchFetchRequest
}
var body: some View {
Group {
Button(action: {
player.playNow(video)
guard !player.playingInPictureInPicture else {
return
}
if inNavigationView {
player.playerNavigationLinkActive = true
} else {
player.presentPlayer()
}
}) {
Button(action: playAction) {
content
}
}
.opacity(contentOpacity)
.buttonStyle(.plain)
.contentShape(RoundedRectangle(cornerRadius: 12))
.contentShape(RoundedRectangle(cornerRadius: thumbnailRoundingCornerRadius))
.contextMenu {
VideoContextMenuView(
video: video,
@@ -48,7 +53,62 @@ struct VideoCell: View {
}
}
var content: some View {
private var thumbnailRoundingCornerRadius: Double {
#if os(tvOS)
return Double(12)
#else
return Double(roundedThumbnails ? 12 : 0)
#endif
}
private func playAction() {
guard video.videoID != Video.fixtureID else {
return
}
if watchingNow {
if !player.playingInPictureInPicture {
player.show()
}
if !playNowContinues {
player.player.seek(to: .zero)
}
player.play()
return
}
var playAt: TimeInterval?
if playNowContinues,
!watch.isNil,
!watch!.finished
{
playAt = watch!.stoppedAt
}
player.play(video, at: playAt, inNavigationView: inNavigationView)
}
private var playNowContinues: Bool {
watchedVideoPlayNowBehavior == .continue
}
private var watch: Watch? {
watchRequest.first
}
private var finished: Bool {
watch?.finished ?? false
}
private var watchingNow: Bool {
player.currentVideo == video
}
private var content: some View {
VStack {
#if os(iOS)
if verticalSizeClass == .compact, !horizontalCells {
@@ -62,12 +122,23 @@ struct VideoCell: View {
#endif
}
#if os(macOS)
.background(Color.tertiaryBackground)
.background(Color.secondaryBackground)
#endif
}
private var contentOpacity: Double {
guard saveHistory,
!watch.isNil,
watchedVideoStyle == .decreasedOpacity || watchedVideoStyle == .both
else {
return 1
}
return watch!.finished ? 0.5 : 1
}
#if os(iOS)
var horizontalRow: some View {
private var horizontalRow: some View {
HStack(alignment: .top, spacing: 2) {
Section {
#if os(tvOS)
@@ -83,9 +154,7 @@ struct VideoCell: View {
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
if !channelOnThumbnail {
Text(video.channel.name)
.fontWeight(.semibold)
.foregroundColor(.secondary)
channelButton(badge: false)
}
if additionalDetailsAvailable {
@@ -151,7 +220,7 @@ struct VideoCell: View {
}
#endif
var verticalRow: some View {
private var verticalRow: some View {
VStack(alignment: .leading, spacing: 0) {
thumbnail
@@ -167,9 +236,7 @@ struct VideoCell: View {
.frame(minHeight: 40, alignment: .top)
#endif
if !channelOnThumbnail {
Text(video.channel.name)
.fontWeight(.semibold)
.foregroundColor(.secondary)
channelButton(badge: false)
.padding(.top, 4)
.padding(.bottom, 6)
}
@@ -201,7 +268,7 @@ struct VideoCell: View {
}
}
if let time = video.length.formattedAsPlaybackTime(), !timeOnThumbnail {
if let time = time, !timeOnThumbnail {
Spacer()
HStack(spacing: 2) {
@@ -225,13 +292,53 @@ struct VideoCell: View {
}
}
var additionalDetailsAvailable: Bool {
video.publishedDate != nil || video.views != 0 || (!timeOnThumbnail && !video.length.formattedAsPlaybackTime().isNil)
private func channelButton(badge: Bool = true) -> some View {
Button {
NavigationModel.openChannel(
video.channel,
player: player,
recents: recents,
navigation: navigation,
navigationStyle: navigationStyle
)
} label: {
if badge {
DetailBadge(text: video.author, style: .prominent)
.foregroundColor(.primary)
} else {
Text(video.channel.name)
.fontWeight(.semibold)
.foregroundColor(.secondary)
}
}
.buttonStyle(.plain)
.help("\(video.channel.name) Channel")
}
var thumbnail: some View {
private var additionalDetailsAvailable: Bool {
video.publishedDate != nil || video.views != 0 ||
(!timeOnThumbnail && !video.length.formattedAsPlaybackTime().isNil)
}
private var thumbnail: some View {
ZStack(alignment: .leading) {
thumbnailImage
ZStack(alignment: .bottomLeading) {
thumbnailImage
if saveHistory, showWatchingProgress, watch?.progress ?? 0 > 0 {
ProgressView(value: watch!.progress, total: 100)
.progressViewStyle(LinearProgressViewStyle(tint: Color("AppRedColor")))
#if os(tvOS)
.padding(.horizontal, 16)
#else
.padding(.horizontal, 10)
#endif
#if os(macOS)
.offset(x: 0, y: 4)
#else
.offset(x: 0, y: -3)
#endif
}
}
VStack {
HStack(alignment: .top) {
@@ -244,27 +351,55 @@ struct VideoCell: View {
Spacer()
if channelOnThumbnail {
DetailBadge(text: video.author, style: .prominent)
channelButton()
}
}
#if os(tvOS)
.padding(16)
#else
.padding(10)
#endif
Spacer()
HStack(alignment: .top) {
HStack(alignment: .center) {
if saveHistory,
watchedVideoStyle == .badge || watchedVideoStyle == .both,
watch?.finished ?? false
{
Image(systemName: "checkmark.circle.fill")
.foregroundColor(Color(
watchedVideoBadgeColor == .colorSchemeBased ? "WatchProgressBarColor" :
watchedVideoBadgeColor == .red ? "AppRedColor" : "AppBlueColor"
))
.background(Color.white)
.clipShape(Circle())
#if os(tvOS)
.font(.system(size: 40))
#else
.font(.system(size: 30))
#endif
}
Spacer()
if timeOnThumbnail, let time = video.length.formattedAsPlaybackTime() {
if timeOnThumbnail,
!video.live,
let time = time
{
DetailBadge(text: time, style: .prominent)
}
}
#if os(tvOS)
.padding(16)
#else
.padding(10)
#endif
}
.lineLimit(1)
}
}
var thumbnailImage: some View {
private var thumbnailImage: some View {
Group {
if let url = thumbnails.best(video) {
WebImage(url: url)
@@ -272,7 +407,7 @@ struct VideoCell: View {
.placeholder {
Rectangle().fill(Color("PlaceholderColor"))
}
.retryOnAppear(false)
.retryOnAppear(true)
.onFailure { _ in
thumbnails.insertUnloadable(url)
}
@@ -289,11 +424,33 @@ struct VideoCell: View {
.font(.system(size: 30))
}
}
.mask(RoundedRectangle(cornerRadius: 12))
.mask(RoundedRectangle(cornerRadius: thumbnailRoundingCornerRadius))
.modifier(AspectRatioModifier())
}
func videoDetail(_ text: String, lineLimit: Int = 1) -> some View {
private var time: String? {
guard var videoTime = video.length.formattedAsPlaybackTime() else {
return nil
}
if !saveHistory || !showWatchingProgress || watch?.finished ?? false {
return videoTime
}
if let stoppedAt = watch?.stoppedAt,
stoppedAt.isFinite,
let stoppedAtFormatted = stoppedAt.formattedAsPlaybackTime()
{
if (watch?.videoDuration ?? 0) > 0 {
videoTime = watch!.videoDuration.formattedAsPlaybackTime() ?? "?"
}
return "\(stoppedAtFormatted) / \(videoTime)"
}
return videoTime
}
private func videoDetail(_ text: String, lineLimit: Int = 1) -> some View {
Text(text)
.fontWeight(.bold)
.lineLimit(lineLimit)
@@ -309,7 +466,10 @@ struct VideoCell: View {
content
} else {
content
.aspectRatio(1.777, contentMode: .fill)
.aspectRatio(
VideoPlayerView.defaultAspectRatio,
contentMode: .fill
)
}
}
}

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)

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