Compare commits

..

174 Commits
v1.0 ... v1.3.4

Author SHA1 Message Date
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
Arkadiusz Fal
f7fc2369e3 Bump build number 2021-12-05 18:31:35 +01:00
Arkadiusz Fal
82ea8733ec Fix crash when video thumbnail cannot be loaded (fixes #28) 2021-12-05 18:31:35 +01:00
Arkadiusz Fal
1f495562fc Comments improvements
* Show text when there is no comments or comments are disabled
* Show progress indicator for loading comments/replies
* Improve layout of icons and text spacing
2021-12-05 18:31:33 +01:00
Arkadiusz Fal
37b99c59e1 Fix disabling comments 2021-12-05 18:12:13 +01:00
Arkadiusz Fal
7f9b53bd1f Fix login with Invidious accounts 2021-12-05 18:10:10 +01:00
Arkadiusz Fal
941e6a909d Set full screen views background color based on color scheme on tvOS (fixes #30) 2021-12-05 18:09:25 +01:00
Arkadiusz Fal
5143c4f8ce Bump build number 2021-12-04 20:57:11 +01:00
Arkadiusz Fal
19a3f08336 Comments (fixes #4) 2021-12-04 20:57:09 +01:00
Arkadiusz Fal
eb537676e6 Update README 2021-12-02 21:35:55 +01:00
Arkadiusz Fal
e97daa1944 Minor UI fixes 2021-12-02 21:35:42 +01:00
Arkadiusz Fal
bd59b8e2c3 Improve favorite button 2021-12-02 21:35:25 +01:00
Arkadiusz Fal
19b146c6ad Close current video (fixes #15) 2021-12-02 21:19:10 +01:00
Arkadiusz Fal
dd995105b5 Minor UI fixes for macOS Big Sur 2021-12-02 20:33:32 +01:00
Arkadiusz Fal
c4b5c7ce41 Fix scrolling of favorites on macOS Big Sur 2021-12-02 20:33:32 +01:00
Arkadiusz Fal
cc2bf90218 Bump build number 2021-12-02 00:18:51 +01:00
Arkadiusz Fal
45c917160e Display build number next to version 2021-12-02 00:17:19 +01:00
Arkadiusz Fal
9f5e9ea237 Add context menu to related items for queuing 2021-12-02 00:15:36 +01:00
Arkadiusz Fal
1c61ad37a9 Fix search on tvOS 2021-12-02 00:13:41 +01:00
Arkadiusz Fal
06f7391ad9 Add setting for saving recents (fixes #14) 2021-12-02 00:12:15 +01:00
Arkadiusz Fal
e61d1dfe2e Add settings for selecting visible sections (fixes #16) 2021-12-02 00:10:21 +01:00
Arkadiusz Fal
ff83abd103 Fix crash on opening player in iOS 14 (fixes #20) 2021-12-02 00:08:48 +01:00
Arkadiusz Fal
500c55689b Bump build number 2021-12-01 00:01:08 +01:00
Arkadiusz Fal
f60f7a0455 Improve playback bar font colors 2021-11-30 23:59:24 +01:00
Arkadiusz Fal
52ab162a6c Fix crash caused by tab navigation 2021-11-30 23:58:46 +01:00
Arkadiusz Fal
c6f077dcd3 Remove unused code 2021-11-30 23:58:27 +01:00
Arkadiusz Fal
b5ffa5b267 Fix sheets and covers on iOS 14 2021-11-30 23:58:11 +01:00
Arkadiusz Fal
5ef89ac9f4 iOS 14/macOS Big Sur Support 2021-11-30 19:01:08 +01:00
Arkadiusz Fal
696751e07c Remove alpha channel from iOS icons 2021-11-30 19:01:08 +01:00
Arkadiusz Fal
e88219ce88 Bump version 2021-11-16 22:12:40 +01:00
Arkadiusz Fal
adf4157ff3 Update README 2021-11-16 21:12:47 +01:00
Arkadiusz Fal
c78dd4a35e Enable text selection for video description 2021-11-15 19:28:21 +01:00
Arkadiusz Fal
8d2694df33 Merge pull request #2 from yattee/feature/piped-accounts
Add support for logging in with Piped accounts, viewing feed and managing subscriptions.
2021-11-15 19:21:01 +01:00
Arkadiusz Fal
0e3effd512 Add support for Piped accounts and subscriptions 2021-11-15 18:58:45 +01:00
Arkadiusz Fal
a70d4f3b38 Fix share URLs 2021-11-13 16:45:47 +01:00
Arkadiusz Fal
6328bfbfab Remove development team 2021-11-12 21:54:55 +01:00
Arkadiusz Fal
184992ea32 Bump packages 2021-11-12 21:54:03 +01:00
Arkadiusz Fal
dd8d6b6c4a Fix removing instance 2021-11-12 21:46:15 +01:00
183 changed files with 7811 additions and 2486 deletions

View File

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

13
Backports/Backport.swift Normal file
View File

@@ -0,0 +1,13 @@
import SwiftUI
public struct Backport<Content> {
public let content: Content
public init(_ content: Content) {
self.content = content
}
}
extension View {
var backport: Backport<Self> { Backport(self) }
}

View File

@@ -0,0 +1,16 @@
import SwiftUI
extension Backport where Content: View {
@ViewBuilder func badge(_ count: Text) -> some View {
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
content.badge(count)
} else {
HStack {
content
Spacer()
Text("\(count)")
.foregroundColor(.secondary)
}
}
}
}

View File

@@ -0,0 +1,11 @@
import SwiftUI
extension Backport where Content: View {
@ViewBuilder func tint(_ color: Color?) -> some View {
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
content.tint(color)
} else {
content.foregroundColor(color)
}
}
}

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

@@ -0,0 +1,15 @@
import SwiftUI
extension Color {
#if os(macOS)
static let background = Color(NSColor.windowBackgroundColor)
static let secondaryBackground = Color(NSColor.controlBackgroundColor)
#elseif os(iOS)
static let background = Color(UIColor.systemBackground)
static let secondaryBackground = Color(UIColor.secondarySystemBackground)
#else
static func background(scheme: ColorScheme) -> Color {
scheme == .dark ? .black : .init(white: 0.8)
}
#endif
}

View File

@@ -2,7 +2,7 @@ import Foundation
extension Double {
func formattedAsPlaybackTime() -> String? {
guard !isZero else {
guard !isZero, isFinite else {
return nil
}
@@ -14,4 +14,13 @@ extension Double {
return formatter.string(from: self)
}
func formattedAsRelativeTime() -> String? {
let date = Date(timeIntervalSince1970: self)
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .full
return formatter.localizedString(for: date, relativeTo: Date())
}
}

View File

@@ -0,0 +1,8 @@
import AppKit
extension NSTextField {
override open var focusRingType: NSFocusRingType {
get { .none }
set {}
}
}

View File

@@ -0,0 +1,10 @@
import Foundation
extension String {
func replacingFirstOccurrence(of target: String, with replacement: String) -> String {
guard let range = range(of: target) else {
return self
}
return replacingCharacters(in: range, with: replacement)
}
}

View File

@@ -10,7 +10,21 @@ extension View {
verticalEdgeBorder(.bottom, height: height, color: color)
}
func borderLeading(width: Double, color: Color = Color(white: 0.7, opacity: 1)) -> some View {
horizontalEdgeBorder(.leading, width: width, color: color)
}
func borderTrailing(width: Double, color: Color = Color(white: 0.7, opacity: 1)) -> some View {
horizontalEdgeBorder(.trailing, width: width, color: color)
}
private func verticalEdgeBorder(_ edge: Alignment, height: Double, color: Color) -> some View {
overlay(Rectangle().frame(width: nil, height: height, alignment: .top).foregroundColor(color), alignment: edge)
overlay(Rectangle().frame(width: nil, height: height, alignment: .top)
.foregroundColor(color), alignment: edge)
}
private func horizontalEdgeBorder(_ edge: Alignment, width: Double, color: Color) -> some View {
overlay(Rectangle().frame(width: width, height: nil, alignment: .leading)
.foregroundColor(color), alignment: edge)
}
}

View File

@@ -0,0 +1,18 @@
import Foundation
extension Comment {
static var fixture: Comment {
Comment(
id: UUID().uuidString,
author: "The Author",
authorAvatarURL: "https://pipedproxy-ams-2.kavin.rocks/Si7ZhtmpX84wj6MoJYLs8kwALw2Hm53wzbrPamoU-z3qvCKs2X3zPNYKMSJEvPDLUHzbvTfLcg=s176-c-k-c0x00ffffff-no-rw?host=yt3.ggpht.com",
time: "2 months ago",
pinned: true,
hearted: true,
likeCount: 30032,
text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut luctus feugiat mi, suscipit pharetra lectus dapibus vel. Vivamus orci erat, sagittis sit amet dui vel, feugiat cursus ante. Pellentesque eget orci tortor. Suspendisse pulvinar orci tortor, eu scelerisque neque consequat nec. Aliquam sit amet turpis et nunc placerat finibus eget sit amet justo. Nullam tincidunt ornare neque. Donec ornare, arcu at elementum pulvinar, urna elit pharetra diam, vel ultrices lacus diam at lorem. Sed vel maximus dolor. Morbi massa est, interdum quis justo sit amet, dapibus bibendum tellus. Integer at purus nec neque tincidunt convallis sit amet eu odio. Duis et ante vitae sem tincidunt facilisis sit amet ac mauris. Quisque varius non nisi vel placerat. Nulla orci metus, imperdiet ac accumsan sed, pellentesque eget nisl. Praesent a suscipit lacus, ut finibus orci. Nulla ut eros commodo, fermentum purus at, porta leo. In finibus luctus nulla, eget posuere eros mollis vel. ",
repliesPage: "some url",
channel: .init(id: "", name: "")
)
}
}

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,16 +17,16 @@ extension Video {
description: "Some relaxing live piano music",
genre: "Music",
channel: Channel(
id: "AbCdEFgHI",
id: fixtureChannelID,
name: "The Channel",
thumbnailURL: URL(string: thumbnailURL)!,
subscriptionsCount: 2300,
videos: []
),
thumbnails: Thumbnail.fixturesForAllQualities(videoId: id),
thumbnails: [],
live: false,
upcoming: false,
publishedAt: Date.now,
publishedAt: Date(),
likes: 37333,
dislikes: 30,
keywords: ["very", "cool", "video", "msfs 2020", "757", "747", "A380", "737-900", "MOD", "Zibo", "MD80", "MD11", "Rotate", "Laminar", "787", "A350", "MSFS", "MS2020", "Microsoft Flight Simulator", "Microsoft", "Flight", "Simulator", "SIM", "World", "Ortho", "Flying", "Boeing MAX"]

View File

@@ -5,6 +5,7 @@ struct FixtureEnvironmentObjectsModifier: ViewModifier {
func body(content: Content) -> some View {
content
.environmentObject(AccountsModel())
.environmentObject(CommentsModel())
.environmentObject(InstancesModel())
.environmentObject(invidious)
.environmentObject(NavigationModel())
@@ -31,7 +32,6 @@ struct FixtureEnvironmentObjectsModifier: ViewModifier {
player.currentItem = PlayerQueueItem(Video.fixture)
player.queue = Video.allFixtures.map { PlayerQueueItem($0) }
player.history = player.queue
return player
}

View File

@@ -8,7 +8,9 @@ struct Account: Defaults.Serializable, Hashable, Identifiable {
let instanceID: String
var name: String?
let url: String
let sid: String
let username: String
let password: String?
var token: String?
let anonymous: Bool
init(
@@ -16,7 +18,9 @@ struct Account: Defaults.Serializable, Hashable, Identifiable {
instanceID: String? = nil,
name: String? = nil,
url: String? = nil,
sid: String? = nil,
username: String? = nil,
password: String? = nil,
token: String? = nil,
anonymous: Bool = false
) {
self.anonymous = anonymous
@@ -25,27 +29,29 @@ struct Account: Defaults.Serializable, Hashable, Identifiable {
self.instanceID = instanceID ?? UUID().uuidString
self.name = name
self.url = url ?? ""
self.sid = sid ?? ""
self.username = username ?? ""
self.token = token
self.password = password ?? ""
}
var instance: Instance {
Defaults[.instances].first { $0.id == instanceID }!
var instance: Instance! {
Defaults[.instances].first { $0.id == instanceID }
}
var anonymizedSID: String {
guard sid.count > 3 else {
return ""
var shortUsername: String {
guard username.count > 10 else {
return username
}
let index = sid.index(sid.startIndex, offsetBy: 4)
return String(sid[..<index])
let index = username.index(username.startIndex, offsetBy: 11)
return String(username[..<index])
}
var description: String {
(name != nil && name!.isEmpty) ? "Unnamed (\(anonymizedSID))" : name!
(name != nil && name!.isEmpty) ? shortUsername : name!
}
func hash(into hasher: inout Hasher) {
hasher.combine(sid)
hasher.combine(username)
}
}

View File

@@ -5,7 +5,7 @@ import SwiftUI
final class AccountValidator: Service {
let app: Binding<VideosApp>
let url: String
let account: Account?
let account: Account!
var formObjectID: Binding<String>
var isValid: Binding<Bool>
@@ -46,7 +46,11 @@ final class AccountValidator: Service {
return
}
$0.headers["Cookie"] = self.cookieHeader
$0.headers["Cookie"] = self.invidiousCookieHeader
}
configure("/login", requestMethods: [.post]) {
$0.headers["Content-Type"] = "application/json"
}
}
@@ -84,20 +88,27 @@ final class AccountValidator: Service {
}
}
func validateInvidiousAccount() {
func validateAccount() {
reset()
feed
.load()
.onSuccess { _ in
guard self.account!.sid == self.formObjectID.wrappedValue else {
accountRequest
.onSuccess { response in
guard self.account!.username == self.formObjectID.wrappedValue else {
return
}
self.isValid.wrappedValue = true
switch self.app.wrappedValue {
case .invidious:
self.isValid.wrappedValue = true
case .piped:
let error = response.json.dictionaryValue["error"]?.string
let token = response.json.dictionaryValue["token"]?.string
self.isValid.wrappedValue = error?.isEmpty ?? !(token?.isEmpty ?? true)
self.error!.wrappedValue = error
}
}
.onFailure { _ in
guard self.account!.sid == self.formObjectID.wrappedValue else {
guard self.account!.username == self.formObjectID.wrappedValue else {
return
}
@@ -109,6 +120,15 @@ final class AccountValidator: Service {
}
}
var accountRequest: Request {
switch app.wrappedValue {
case .invidious:
return feed.load()
case .piped:
return login.request(.post, json: ["username": account.username, "password": account.password])
}
}
func reset() {
isValid.wrappedValue = false
isValidated.wrappedValue = false
@@ -116,8 +136,12 @@ final class AccountValidator: Service {
error?.wrappedValue = nil
}
var cookieHeader: String {
"SID=\(account!.sid)"
var invidiousCookieHeader: String {
"SID=\(account.username)"
}
var login: Resource {
resource("/login")
}
var feed: Resource {

View File

@@ -15,7 +15,8 @@ struct AccountsBridge: Defaults.Bridge {
"instanceID": value.instanceID,
"name": value.name ?? "",
"apiURL": value.url,
"sid": value.sid
"username": value.username,
"password": value.password ?? ""
]
}
@@ -25,13 +26,14 @@ struct AccountsBridge: Defaults.Bridge {
let id = object["id"],
let instanceID = object["instanceID"],
let url = object["apiURL"],
let sid = object["sid"]
let username = object["username"]
else {
return nil
}
let name = object["name"] ?? ""
let password = object["password"]
return Account(id: id, instanceID: instanceID, name: name, url: url, sid: sid)
return Account(id: id, instanceID: instanceID, name: name, url: url, username: username, password: password)
}
}

View File

@@ -22,8 +22,12 @@ final class AccountsModel: ObservableObject {
return AccountsModel.find(id)
}
var any: Account? {
lastUsed ?? all.randomElement()
}
var app: VideosApp {
current?.instance.app ?? .invidious
current?.instance?.app ?? .invidious
}
var api: VideosAPI {
@@ -35,7 +39,7 @@ final class AccountsModel: ObservableObject {
}
var signedIn: Bool {
!isEmpty && !current.anonymous
!isEmpty && !current.anonymous && api.signedIn
}
init() {
@@ -74,8 +78,14 @@ final class AccountsModel: ObservableObject {
Defaults[.accounts].first { $0.id == id }
}
static func add(instance: Instance, name: String, sid: String) -> Account {
let account = Account(instanceID: instance.id, name: name, url: instance.apiURL, sid: sid)
static func add(instance: Instance, name: String, username: String, password: String? = nil) -> Account {
let account = Account(
instanceID: instance.id,
name: name,
url: instance.apiURL,
username: username,
password: password
)
Defaults[.accounts].append(account)
return account

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,12 +25,15 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
func setAccount(_ account: Account) {
self.account = account
validInstance = false
signedIn = false
validInstance = account.anonymous
configure()
validate()
if !account.anonymous {
validate()
}
}
func validate() {
@@ -70,7 +73,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
func configure() {
configure {
if !self.account.sid.isEmpty {
if !self.account.username.isEmpty {
$0.headers["Cookie"] = self.cookieHeader
}
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
@@ -81,24 +84,29 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
}
configureTransformer(pathPattern("popular"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
content.json.arrayValue.map(InvidiousAPI.extractVideo)
content.json.arrayValue.map(self.extractVideo)
}
configureTransformer(pathPattern("trending"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
content.json.arrayValue.map(InvidiousAPI.extractVideo)
content.json.arrayValue.map(self.extractVideo)
}
configureTransformer(pathPattern("search"), requestMethods: [.get]) { (content: Entity<JSON>) -> [ContentItem] in
content.json.arrayValue.map {
let type = $0.dictionaryValue["type"]?.stringValue
configureTransformer(pathPattern("search"), requestMethods: [.get]) { (content: Entity<JSON>) -> SearchPage in
let results = content.json.arrayValue.compactMap { json -> ContentItem? in
let type = json.dictionaryValue["type"]?.stringValue
if type == "channel" {
return ContentItem(channel: InvidiousAPI.extractChannel(from: $0))
return ContentItem(channel: self.extractChannel(from: json))
} else if type == "playlist" {
return ContentItem(playlist: InvidiousAPI.extractChannelPlaylist(from: $0))
return ContentItem(playlist: self.extractChannelPlaylist(from: json))
} else if type == "video" {
return ContentItem(video: self.extractVideo(from: json))
}
return ContentItem(video: InvidiousAPI.extractVideo(from: $0))
return nil
}
return SearchPage(results: results, last: results.isEmpty)
}
configureTransformer(pathPattern("search/suggestions"), requestMethods: [.get]) { (content: Entity<JSON>) -> [String] in
@@ -110,11 +118,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
}
configureTransformer(pathPattern("auth/playlists"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Playlist] in
content.json.arrayValue.map(Playlist.init)
content.json.arrayValue.map(self.extractPlaylist)
}
configureTransformer(pathPattern("auth/playlists/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Playlist in
Playlist(content.json)
self.extractPlaylist(from: content.json)
}
configureTransformer(pathPattern("auth/playlists"), requestMethods: [.post, .patch]) { (content: Entity<Data>) -> Playlist in
@@ -124,30 +132,30 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
configureTransformer(pathPattern("auth/feed"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
if let feedVideos = content.json.dictionaryValue["videos"] {
return feedVideos.arrayValue.map(InvidiousAPI.extractVideo)
return feedVideos.arrayValue.map(self.extractVideo)
}
return []
}
configureTransformer(pathPattern("auth/subscriptions"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Channel] in
content.json.arrayValue.map(InvidiousAPI.extractChannel)
content.json.arrayValue.map(self.extractChannel)
}
configureTransformer(pathPattern("channels/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Channel in
InvidiousAPI.extractChannel(from: content.json)
self.extractChannel(from: content.json)
}
configureTransformer(pathPattern("channels/*/latest"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
content.json.arrayValue.map(InvidiousAPI.extractVideo)
content.json.arrayValue.map(self.extractVideo)
}
configureTransformer(pathPattern("playlists/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> ChannelPlaylist in
InvidiousAPI.extractChannelPlaylist(from: content.json)
self.extractChannelPlaylist(from: content.json)
}
configureTransformer(pathPattern("videos/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Video in
InvidiousAPI.extractVideo(from: content.json)
self.extractVideo(from: content.json)
}
}
@@ -160,7 +168,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
}
private var cookieHeader: String {
"SID=\(account.sid)"
"SID=\(account.username)"
}
var popular: Resource? {
@@ -185,8 +193,18 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
}
func channelSubscription(_ id: String) -> Resource? {
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions")).child(id)
func subscribe(_ channelID: String, onCompletion: @escaping () -> Void = {}) {
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
.child(channelID)
.request(.post)
.onCompletion { _ in onCompletion() }
}
func unsubscribe(_ channelID: String, onCompletion: @escaping () -> Void) {
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
.child(channelID)
.request(.delete)
.onCompletion { _ in onCompletion() }
}
func channel(_ id: String) -> Resource {
@@ -202,7 +220,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
}
var playlists: Resource? {
resource(baseURL: account.url, path: basePathAppending("auth/playlists"))
if account.isNil || account.anonymous {
return nil
}
return resource(baseURL: account.url, path: basePathAppending("auth/playlists"))
}
func playlist(_ id: String) -> Resource? {
@@ -217,11 +239,71 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
playlist(playlistID)?.child("videos").child(videoID)
}
func addVideoToPlaylist(
_ videoID: String,
_ playlistID: String,
onFailure: @escaping (RequestError) -> Void = { _ in },
onSuccess: @escaping () -> Void = {}
) {
let resource = playlistVideos(playlistID)
let body = ["videoId": videoID]
resource?
.request(.post, json: body)
.onSuccess { _ in onSuccess() }
.onFailure(onFailure)
}
func removeVideoFromPlaylist(
_ index: String,
_ playlistID: String,
onFailure: @escaping (RequestError) -> Void,
onSuccess: @escaping () -> Void
) {
let resource = playlistVideo(playlistID, index)
resource?
.request(.delete)
.onSuccess { _ in onSuccess() }
.onFailure(onFailure)
}
func playlistForm(
_ name: String,
_ visibility: String,
playlist: Playlist?,
onFailure: @escaping (RequestError) -> Void,
onSuccess: @escaping (Playlist?) -> Void
) {
let body = ["title": name, "privacy": visibility]
let resource = !playlist.isNil ? self.playlist(playlist!.id) : playlists
resource?
.request(!playlist.isNil ? .patch : .post, json: body)
.onSuccess { response in
if let modifiedPlaylist: Playlist = response.typedContent() {
onSuccess(modifiedPlaylist)
}
}
.onFailure(onFailure)
}
func deletePlaylist(
_ playlist: Playlist,
onFailure: @escaping (RequestError) -> Void,
onSuccess: @escaping () -> Void
) {
self.playlist(playlist.id)?
.request(.delete)
.onSuccess { _ in onSuccess() }
.onFailure(onFailure)
}
func channelPlaylist(_ id: String) -> Resource? {
resource(baseURL: account.url, path: basePathAppending("playlists/\(id)"))
}
func search(_ query: SearchQuery) -> Resource {
func search(_ query: SearchQuery, page: String?) -> Resource {
var resource = resource(baseURL: account.url, path: basePathAppending("search"))
.withParam("q", searchQuery(query.query))
.withParam("sort_by", query.sortBy.parameter)
@@ -235,6 +317,10 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
resource = resource.withParam("duration", duration.rawValue)
}
if let page = page {
resource = resource.withParam("page", page)
}
return resource
}
@@ -243,6 +329,8 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
.withParam("q", query.lowercased())
}
func comments(_: Video.ID, page _: String?) -> Resource? { nil }
private func searchQuery(_ query: String) -> String {
var searchQuery = query
@@ -276,7 +364,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
return AVURLAsset(url: url)
}
static func extractVideo(from json: JSON) -> Video {
func extractVideo(from json: JSON) -> Video {
let indexID: String?
var id: Video.ID
var publishedAt: Date?
@@ -319,8 +407,15 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
)
}
static func extractChannel(from json: JSON) -> Channel {
let thumbnailURL = "https:\(json["authorThumbnails"].arrayValue.first?.dictionaryValue["url"]?.stringValue ?? "")"
func extractChannel(from json: JSON) -> Channel {
var thumbnailURL = json["authorThumbnails"].arrayValue.last?.dictionaryValue["url"]?.stringValue ?? ""
// append https protocol to unproxied thumbnail URL if it's missing
if thumbnailURL.count > 2,
String(thumbnailURL[..<thumbnailURL.index(thumbnailURL.startIndex, offsetBy: 2)]) == "//"
{
thumbnailURL = "https:\(thumbnailURL)"
}
return Channel(
id: json["authorId"].stringValue,
@@ -328,33 +423,33 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
thumbnailURL: URL(string: thumbnailURL),
subscriptionsCount: json["subCount"].int,
subscriptionsText: json["subCountText"].string,
videos: json.dictionaryValue["latestVideos"]?.arrayValue.map(InvidiousAPI.extractVideo) ?? []
videos: json.dictionaryValue["latestVideos"]?.arrayValue.map(extractVideo) ?? []
)
}
static func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist {
func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist {
let details = json.dictionaryValue
return ChannelPlaylist(
id: details["playlistId"]!.stringValue,
title: details["title"]!.stringValue,
thumbnailURL: details["playlistThumbnail"]?.url,
channel: extractChannel(from: json),
videos: details["videos"]?.arrayValue.compactMap(InvidiousAPI.extractVideo) ?? []
videos: details["videos"]?.arrayValue.compactMap(extractVideo) ?? []
)
}
private static func extractThumbnails(from details: JSON) -> [Thumbnail] {
private func extractThumbnails(from details: JSON) -> [Thumbnail] {
details["videoThumbnails"].arrayValue.map { json in
Thumbnail(url: json["url"].url!, quality: .init(rawValue: json["quality"].string!)!)
}
}
private static func extractStreams(from json: JSON) -> [Stream] {
private func extractStreams(from json: JSON) -> [Stream] {
extractFormatStreams(from: json["formatStreams"].arrayValue) +
extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue)
}
private static func extractFormatStreams(from streams: [JSON]) -> [Stream] {
private func extractFormatStreams(from streams: [JSON]) -> [Stream] {
streams.map {
SingleAssetStream(
avAsset: AVURLAsset(url: $0["url"].url!),
@@ -365,7 +460,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
}
}
private static func extractAdaptiveFormats(from streams: [JSON]) -> [Stream] {
private func extractAdaptiveFormats(from streams: [JSON]) -> [Stream] {
let audioAssetURL = streams.first { $0["type"].stringValue.starts(with: "audio/mp4") }
guard audioAssetURL != nil else {
return []
@@ -384,10 +479,20 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
}
}
private static func extractRelated(from content: JSON) -> [Video] {
private func extractRelated(from content: JSON) -> [Video] {
content
.dictionaryValue["recommendedVideos"]?
.arrayValue
.compactMap(extractVideo(from:)) ?? []
}
private func extractPlaylist(from content: JSON) -> Playlist {
.init(
id: content["playlistId"].stringValue,
title: content["title"].stringValue,
visibility: content["isListed"].boolValue ? .public : .private,
updated: content["updated"].doubleValue,
videos: content["videos"].arrayValue.map { extractVideo(from: $0) }
)
}
}

View File

@@ -4,11 +4,9 @@ import Siesta
import SwiftyJSON
final class PipedAPI: Service, ObservableObject, VideosAPI {
@Published var account: Account!
static var authorizedEndpoints = ["subscriptions", "subscribe", "unsubscribe", "user/playlists"]
var anonymousAccount: Account {
.init(instanceID: account.instance.id, name: "Anonymous", url: account.instance.apiURL)
}
@Published var account: Account!
init(account: Account? = nil) {
super.init()
@@ -27,33 +25,99 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
}
func configure() {
invalidateConfiguration()
configure {
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
}
configure(whenURLMatches: { url in self.needsAuthorization(url) }) {
$0.headers["Authorization"] = self.account.token
}
configureTransformer(pathPattern("channel/*")) { (content: Entity<JSON>) -> Channel? in
PipedAPI.extractChannel(from: content.json)
self.extractChannel(from: content.json)
}
configureTransformer(pathPattern("playlists/*")) { (content: Entity<JSON>) -> ChannelPlaylist? in
PipedAPI.extractChannelPlaylist(from: content.json)
self.extractChannelPlaylist(from: content.json)
}
configureTransformer(pathPattern("user/playlists/create")) { (_: Entity<JSON>) in }
configureTransformer(pathPattern("user/playlists/delete")) { (_: Entity<JSON>) in }
configureTransformer(pathPattern("user/playlists/add")) { (_: Entity<JSON>) in }
configureTransformer(pathPattern("user/playlists/remove")) { (_: Entity<JSON>) in }
configureTransformer(pathPattern("streams/*")) { (content: Entity<JSON>) -> Video? in
PipedAPI.extractVideo(from: content.json)
self.extractVideo(from: content.json)
}
configureTransformer(pathPattern("trending")) { (content: Entity<JSON>) -> [Video] in
PipedAPI.extractVideos(from: content.json)
self.extractVideos(from: content.json)
}
configureTransformer(pathPattern("search")) { (content: Entity<JSON>) -> [ContentItem] in
PipedAPI.extractContentItems(from: content.json.dictionaryValue["items"]!)
configureTransformer(pathPattern("search")) { (content: Entity<JSON>) -> SearchPage in
let nextPage = content.json.dictionaryValue["nextpage"]?.stringValue
return SearchPage(
results: self.extractContentItems(from: content.json.dictionaryValue["items"]!),
nextPage: nextPage,
last: nextPage == "null"
)
}
configureTransformer(pathPattern("suggestions")) { (content: Entity<JSON>) -> [String] in
content.json.arrayValue.map(String.init)
}
configureTransformer(pathPattern("subscriptions")) { (content: Entity<JSON>) -> [Channel] in
content.json.arrayValue.map { self.extractChannel(from: $0)! }
}
configureTransformer(pathPattern("feed")) { (content: Entity<JSON>) -> [Video] in
content.json.arrayValue.map { self.extractVideo(from: $0)! }
}
configureTransformer(pathPattern("comments/*")) { (content: Entity<JSON>) -> CommentsPage in
let details = content.json.dictionaryValue
let comments = details["comments"]?.arrayValue.map { self.extractComment(from: $0)! } ?? []
let nextPage = details["nextpage"]?.stringValue
let disabled = details["disabled"]?.boolValue ?? false
return CommentsPage(comments: comments, nextPage: nextPage, disabled: disabled)
}
configureTransformer(pathPattern("user/playlists")) { (content: Entity<JSON>) -> [Playlist] in
content.json.arrayValue.map { self.extractUserPlaylist(from: $0)! }
}
if account.token.isNil {
updateToken()
}
}
func needsAuthorization(_ url: URL) -> Bool {
Self.authorizedEndpoints.contains { url.absoluteString.contains($0) }
}
func updateToken() {
guard !account.anonymous else {
return
}
account.token = nil
login.request(
.post,
json: ["username": account.username, "password": account.password]
)
.onSuccess { response in
self.account.token = response.json.dictionaryValue["token"]?.string ?? ""
self.configure()
}
}
var login: Resource {
resource(baseURL: account.url, path: "login")
}
func channel(_ id: String) -> Resource {
@@ -73,10 +137,18 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
.withParam("region", country.rawValue)
}
func search(_ query: SearchQuery) -> Resource {
resource(baseURL: account.instance.apiURL, path: "search")
func search(_ query: SearchQuery, page: String?) -> Resource {
let path = page.isNil ? "search" : "nextpage/search"
let resource = resource(baseURL: account.instance.apiURL, path: path)
.withParam("q", query.query)
.withParam("filter", "")
.withParam("filter", "all")
if page.isNil {
return resource
}
return resource.withParam("nextpage", page)
}
func searchSuggestions(query: String) -> Resource {
@@ -88,25 +160,126 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
resource(baseURL: account.instance.apiURL, path: "streams/\(id)")
}
var signedIn: Bool { false }
var signedIn: Bool {
!account.anonymous && !(account.token?.isEmpty ?? true)
}
var subscriptions: Resource? {
resource(baseURL: account.instance.apiURL, path: "subscriptions")
}
var feed: Resource? {
resource(baseURL: account.instance.apiURL, path: "feed")
.withParam("authToken", account.token)
}
var subscriptions: Resource? { nil }
var feed: Resource? { nil }
var home: Resource? { nil }
var popular: Resource? { nil }
var playlists: Resource? { nil }
var playlists: Resource? {
resource(baseURL: account.instance.apiURL, path: "user/playlists")
}
func channelSubscription(_: String) -> Resource? { nil }
func subscribe(_ channelID: String, onCompletion: @escaping () -> Void = {}) {
resource(baseURL: account.instance.apiURL, path: "subscribe")
.request(.post, json: ["channelId": channelID])
.onCompletion { _ in onCompletion() }
}
func unsubscribe(_ channelID: String, onCompletion: @escaping () -> Void = {}) {
resource(baseURL: account.instance.apiURL, path: "unsubscribe")
.request(.post, json: ["channelId": channelID])
.onCompletion { _ in onCompletion() }
}
func playlist(_ id: String) -> Resource? {
channelPlaylist(id)
}
func playlist(_: String) -> Resource? { nil }
func playlistVideo(_: String, _: String) -> Resource? { nil }
func playlistVideos(_: String) -> Resource? { nil }
func addVideoToPlaylist(
_ videoID: String,
_ playlistID: String,
onFailure: @escaping (RequestError) -> Void = { _ in },
onSuccess: @escaping () -> Void = {}
) {
let resource = resource(baseURL: account.instance.apiURL, path: "user/playlists/add")
let body = ["videoId": videoID, "playlistId": playlistID]
resource
.request(.post, json: body)
.onSuccess { _ in onSuccess() }
.onFailure(onFailure)
}
func removeVideoFromPlaylist(
_ index: String,
_ playlistID: String,
onFailure: @escaping (RequestError) -> Void,
onSuccess: @escaping () -> Void
) {
let resource = resource(baseURL: account.instance.apiURL, path: "user/playlists/remove")
let body: [String: Any] = ["index": Int(index)!, "playlistId": playlistID]
resource
.request(.post, json: body)
.onSuccess { _ in onSuccess() }
.onFailure(onFailure)
}
func playlistForm(
_ name: String,
_: String,
playlist: Playlist?,
onFailure: @escaping (RequestError) -> Void,
onSuccess: @escaping (Playlist?) -> Void
) {
let body = ["name": name]
let resource = playlist.isNil ? resource(baseURL: account.instance.apiURL, path: "user/playlists/create") : nil
resource?
.request(.post, json: body)
.onSuccess { response in
if let modifiedPlaylist: Playlist = response.typedContent() {
onSuccess(modifiedPlaylist)
} else {
onSuccess(nil)
}
}
.onFailure(onFailure)
}
func deletePlaylist(
_ playlist: Playlist,
onFailure: @escaping (RequestError) -> Void,
onSuccess: @escaping () -> Void
) {
let resource = resource(baseURL: account.instance.apiURL, path: "user/playlists/delete")
let body = ["playlistId": playlist.id]
resource
.request(.post, json: body)
.onSuccess { _ in onSuccess() }
.onFailure(onFailure)
}
func comments(_ id: Video.ID, page: String?) -> Resource? {
let path = page.isNil ? "comments/\(id)" : "nextpage/comments/\(id)"
let resource = resource(baseURL: account.url, path: path)
if page.isNil {
return resource
}
return resource.withParam("nextpage", page)
}
private func pathPattern(_ path: String) -> String {
"**\(path)"
}
private static func extractContentItem(from content: JSON) -> ContentItem? {
private func extractContentItem(from content: JSON) -> ContentItem? {
let details = content.dictionaryValue
let url: String! = details["url"]?.string
@@ -126,29 +299,31 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
switch contentType {
case .video:
if let video = PipedAPI.extractVideo(from: content) {
if let video = extractVideo(from: content) {
return ContentItem(video: video)
}
case .playlist:
if let playlist = PipedAPI.extractChannelPlaylist(from: content) {
if let playlist = extractChannelPlaylist(from: content) {
return ContentItem(playlist: playlist)
}
case .channel:
if let channel = PipedAPI.extractChannel(from: content) {
if let channel = extractChannel(from: content) {
return ContentItem(channel: channel)
}
default:
return nil
}
return nil
}
private static func extractContentItems(from content: JSON) -> [ContentItem] {
content.arrayValue.compactMap { PipedAPI.extractContentItem(from: $0) }
private func extractContentItems(from content: JSON) -> [ContentItem] {
content.arrayValue.compactMap { extractContentItem(from: $0) }
}
private static func extractChannel(from content: JSON) -> Channel? {
private func extractChannel(from content: JSON) -> Channel? {
let attributes = content.dictionaryValue
guard let id = attributes["id"]?.stringValue ??
(attributes["url"] ?? attributes["uploaderUrl"])?.stringValue.components(separatedBy: "/").last
@@ -160,29 +335,32 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
var videos = [Video]()
if let relatedStreams = attributes["relatedStreams"] {
videos = PipedAPI.extractVideos(from: relatedStreams)
videos = extractVideos(from: relatedStreams)
}
let name = attributes["name"]?.stringValue ?? attributes["uploaderName"]?.stringValue ?? attributes["uploader"]?.stringValue ?? ""
let thumbnailURL = attributes["avatarUrl"]?.url ?? attributes["uploaderAvatar"]?.url ?? attributes["avatar"]?.url ?? attributes["thumbnail"]?.url
return Channel(
id: id,
name: attributes["name"]!.stringValue,
thumbnailURL: attributes["thumbnail"]?.url,
name: name,
thumbnailURL: thumbnailURL,
subscriptionsCount: subscriptionsCount,
videos: videos
)
}
static func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist? {
func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist? {
let details = json.dictionaryValue
let id = details["url"]?.stringValue.components(separatedBy: "?list=").last ?? UUID().uuidString
let thumbnailURL = details["thumbnail"]?.url ?? details["thumbnailUrl"]?.url
var videos = [Video]()
if let relatedStreams = details["relatedStreams"] {
videos = PipedAPI.extractVideos(from: relatedStreams)
videos = extractVideos(from: relatedStreams)
}
return ChannelPlaylist(
id: id,
title: details["name"]!.stringValue,
title: details["name"]?.stringValue ?? "",
thumbnailURL: thumbnailURL,
channel: extractChannel(from: json)!,
videos: videos,
@@ -190,7 +368,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
)
}
private static func extractVideo(from content: JSON) -> Video? {
private func extractVideo(from content: JSON) -> Video? {
let details = content.dictionaryValue
let url = details["url"]?.string
@@ -203,7 +381,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
let channelId = details["uploaderUrl"]!.stringValue.components(separatedBy: "/").last!
let thumbnails: [Thumbnail] = Thumbnail.Quality.allCases.compactMap {
if let url = PipedAPI.buildThumbnailURL(from: content, quality: $0) {
if let url = buildThumbnailURL(from: content, quality: $0) {
return Thumbnail(url: url, quality: $0)
}
@@ -211,17 +389,28 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
}
let author = details["uploaderName"]?.stringValue ?? details["uploader"]!.stringValue
let authorThumbnailURL = details["avatarUrl"]?.url ?? details["uploaderAvatar"]?.url ?? details["avatar"]?.url
let subscriptionsCount = details["uploaderSubscriberCount"]?.int
let uploaded = details["uploaded"]?.doubleValue
var published = uploaded.isNil ? nil : (uploaded! / 1000).formattedAsRelativeTime()
if published.isNil {
published = (details["uploadedDate"] ?? details["uploadDate"])?.stringValue ?? ""
}
let live = details["livestream"]?.boolValue ?? (details["duration"]?.intValue == -1)
return Video(
videoID: PipedAPI.extractID(from: content),
videoID: extractID(from: content),
title: details["title"]!.stringValue,
author: author,
length: details["duration"]!.doubleValue,
published: details["uploadedDate"]?.stringValue ?? details["uploadDate"]!.stringValue,
published: published!,
views: details["views"]!.intValue,
description: PipedAPI.extractDescription(from: content),
channel: Channel(id: channelId, name: author),
description: extractDescription(from: content),
channel: Channel(id: channelId, name: author, thumbnailURL: authorThumbnailURL, subscriptionsCount: subscriptionsCount),
thumbnails: thumbnails,
live: live,
likes: details["likes"]?.int,
dislikes: details["dislikes"]?.int,
streams: extractStreams(from: content),
@@ -229,16 +418,16 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
)
}
private static func extractID(from content: JSON) -> Video.ID {
private func extractID(from content: JSON) -> Video.ID {
content.dictionaryValue["url"]?.stringValue.components(separatedBy: "?v=").last ??
extractThumbnailURL(from: content)!.relativeString.components(separatedBy: "/")[4]
}
private static func extractThumbnailURL(from content: JSON) -> URL? {
private func extractThumbnailURL(from content: JSON) -> URL? {
content.dictionaryValue["thumbnail"]?.url! ?? content.dictionaryValue["thumbnailUrl"]!.url!
}
private static func buildThumbnailURL(from content: JSON, quality: Thumbnail.Quality) -> URL? {
private func buildThumbnailURL(from content: JSON, quality: Thumbnail.Quality) -> URL? {
let thumbnailURL = extractThumbnailURL(from: content)
guard !thumbnailURL.isNil else {
return nil
@@ -251,7 +440,15 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
)!
}
private static func extractDescription(from content: JSON) -> String? {
private func extractUserPlaylist(from json: JSON) -> Playlist? {
let id = json["id"].stringValue
let title = json["name"].stringValue
let visibility = Playlist.Visibility.private
return Playlist(id: id, title: title, visibility: visibility)
}
private func extractDescription(from content: JSON) -> String? {
guard var description = content.dictionaryValue["description"]?.string else {
return nil
}
@@ -273,22 +470,22 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
return description
}
private static func extractVideos(from content: JSON) -> [Video] {
private func extractVideos(from content: JSON) -> [Video] {
content.arrayValue.compactMap(extractVideo(from:))
}
private static func extractStreams(from content: JSON) -> [Stream] {
private func extractStreams(from content: JSON) -> [Stream] {
var streams = [Stream]()
if let hlsURL = content.dictionaryValue["hls"]?.url {
streams.append(Stream(hlsURL: hlsURL))
}
guard let audioStream = PipedAPI.compatibleAudioStreams(from: content).first else {
guard let audioStream = compatibleAudioStreams(from: content).first else {
return streams
}
let videoStreams = PipedAPI.compatibleVideoStream(from: content)
let videoStreams = compatibleVideoStream(from: content)
videoStreams.forEach { videoStream in
let audioAsset = AVURLAsset(url: audioStream.dictionaryValue["url"]!.url!)
@@ -311,14 +508,14 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
return streams
}
private static func extractRelated(from content: JSON) -> [Video] {
private func extractRelated(from content: JSON) -> [Video] {
content
.dictionaryValue["relatedStreams"]?
.arrayValue
.compactMap(extractVideo(from:)) ?? []
}
private static func compatibleAudioStreams(from content: JSON) -> [JSON] {
private func compatibleAudioStreams(from content: JSON) -> [JSON] {
content
.dictionaryValue["audioStreams"]?
.arrayValue
@@ -328,10 +525,29 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
} ?? []
}
private static func compatibleVideoStream(from content: JSON) -> [JSON] {
private func compatibleVideoStream(from content: JSON) -> [JSON] {
content
.dictionaryValue["videoStreams"]?
.arrayValue
.filter { $0.dictionaryValue["format"] == "MPEG_4" } ?? []
}
private func extractComment(from content: JSON) -> Comment? {
let details = content.dictionaryValue
let author = details["author"]?.stringValue ?? ""
let commentorUrl = details["commentorUrl"]?.stringValue
let channelId = commentorUrl?.components(separatedBy: "/")[2] ?? ""
return Comment(
id: details["commentId"]?.stringValue ?? UUID().uuidString,
author: author,
authorAvatarURL: details["thumbnail"]?.stringValue ?? "",
time: details["commentedTime"]?.stringValue ?? "",
pinned: details["pinned"]?.boolValue ?? false,
hearted: details["hearted"]?.boolValue ?? false,
likeCount: details["likeCount"]?.intValue ?? 0,
text: details["commentText"]?.stringValue ?? "",
repliesPage: details["repliesPage"]?.stringValue,
channel: Channel(id: channelId, name: author)
)
}
}

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
@@ -20,16 +20,47 @@ protocol VideosAPI {
var popular: Resource? { get }
var playlists: Resource? { get }
func channelSubscription(_ id: String) -> Resource?
func subscribe(_ channelID: String, onCompletion: @escaping () -> Void)
func unsubscribe(_ channelID: String, onCompletion: @escaping () -> Void)
func playlist(_ id: String) -> Resource?
func playlistVideo(_ playlistID: String, _ videoID: String) -> Resource?
func playlistVideos(_ id: String) -> Resource?
func addVideoToPlaylist(
_ videoID: String,
_ playlistID: String,
onFailure: @escaping (RequestError) -> Void,
onSuccess: @escaping () -> Void
)
func removeVideoFromPlaylist(
_ index: String,
_ playlistID: String,
onFailure: @escaping (RequestError) -> Void,
onSuccess: @escaping () -> Void
)
func playlistForm(
_ name: String,
_ visibility: String,
playlist: Playlist?,
onFailure: @escaping (RequestError) -> Void,
onSuccess: @escaping (Playlist?) -> Void
)
func deletePlaylist(
_ playlist: Playlist,
onFailure: @escaping (RequestError) -> Void,
onSuccess: @escaping () -> Void
)
func channelPlaylist(_ id: String) -> Resource?
func loadDetails(_ item: PlayerQueueItem, completionHandler: @escaping (PlayerQueueItem) -> Void)
func shareURL(_ item: ContentItem, frontendHost: String?, time: CMTime?) -> URL?
func comments(_ id: Video.ID, page: String?) -> Resource?
}
extension VideosAPI {
@@ -52,11 +83,12 @@ extension VideosAPI {
}
func shareURL(_ item: ContentItem, frontendHost: String? = nil, time: CMTime? = nil) -> URL? {
guard let frontendHost = frontendHost ?? account.instance.frontendHost else {
guard let frontendHost = frontendHost ?? account?.instance?.frontendHost,
var urlComponents = account?.instance?.urlComponents
else {
return nil
}
var urlComponents = account.instance.urlComponents
urlComponents.host = frontendHost
var queryItems = [URLQueryItem]()
@@ -70,6 +102,8 @@ extension VideosAPI {
case .playlist:
urlComponents.path = "/playlist"
queryItems.append(.init(name: "list", value: item.playlist.id))
default:
return nil
}
if !time.isNil, time!.seconds.isFinite {
@@ -80,6 +114,6 @@ extension VideosAPI {
urlComponents.queryItems = queryItems
}
return urlComponents.url!
return urlComponents.url
}
}

View File

@@ -8,7 +8,11 @@ enum VideosApp: String, CaseIterable {
}
var supportsAccounts: Bool {
self == .invidious
true
}
var accountsUsePassword: Bool {
self == .piped
}
var supportsPopular: Bool {
@@ -28,10 +32,30 @@ enum VideosApp: String, CaseIterable {
}
var supportsUserPlaylists: Bool {
true
}
var userPlaylistsEndpointIncludesVideos: Bool {
self == .invidious
}
var userPlaylistsHaveVisibility: Bool {
self == .invidious
}
var userPlaylistsAreEditable: Bool {
self == .invidious
}
var hasFrontendURL: Bool {
self == .piped
}
var supportsComments: Bool {
self == .piped
}
var searchUsesIndexedPages: Bool {
self == .invidious
}
}

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

16
Model/Comment.swift Normal file
View File

@@ -0,0 +1,16 @@
struct Comment: Identifiable, Equatable {
let id: String
let author: String
let authorAvatarURL: String
let time: String
let pinned: Bool
let hearted: Bool
var likeCount: Int
let text: String
let repliesPage: String?
let channel: Channel
var hasReplies: Bool {
!(repliesPage?.isEmpty ?? true)
}
}

119
Model/CommentsModel.swift Normal file
View File

@@ -0,0 +1,119 @@
import Defaults
import Foundation
import SwiftyJSON
final class CommentsModel: ObservableObject {
@Published var all = [Comment]()
@Published var nextPage: String?
@Published var firstPage = true
@Published var loaded = false
@Published var disabled = false
@Published var replies = [Comment]()
@Published var repliesPageID: String?
@Published var repliesLoaded = false
var player: PlayerModel!
static var instance: Instance? {
InstancesModel.find(Defaults[.commentsInstanceID])
}
var api: VideosAPI? {
Self.instance.isNil ? nil : PipedAPI(account: Self.instance!.anonymousAccount)
}
static var enabled: Bool {
!instance.isNil
}
#if !os(tvOS)
static var placement: CommentsPlacement {
Defaults[.commentsPlacement]
}
#endif
var nextPageAvailable: Bool {
!(nextPage?.isEmpty ?? true)
}
func load(page: String? = nil) {
guard Self.enabled else {
return
}
guard !Self.instance.isNil,
!(player?.currentVideo.isNil ?? true)
else {
return
}
if !firstPage && !nextPageAvailable {
return
}
firstPage = page.isNil || page!.isEmpty
api?.comments(player.currentVideo!.videoID, page: page)?
.load()
.onSuccess { [weak self] response in
if let page: CommentsPage = response.typedContent() {
self?.all += page.comments
self?.nextPage = page.nextPage
self?.disabled = page.disabled
}
}
.onCompletion { [weak self] _ in
self?.loaded = true
}
}
func loadNextPageIfNeeded(current comment: Comment) {
let thresholdIndex = all.index(all.endIndex, offsetBy: -5)
if all.firstIndex(where: { $0 == comment }) == thresholdIndex {
loadNextPage()
}
}
func loadNextPage() {
load(page: nextPage)
}
func loadReplies(page: String) {
guard !player.currentVideo.isNil else {
return
}
if page == repliesPageID {
return
}
replies = []
repliesPageID = page
repliesLoaded = false
api?.comments(player.currentVideo!.videoID, page: page)?
.load()
.onSuccess { [weak self] response in
if let page: CommentsPage = response.typedContent() {
self?.replies = page.comments
self?.repliesLoaded = true
}
}
.onFailure { [weak self] _ in
self?.repliesLoaded = true
}
}
func reset() {
all = []
disabled = false
firstPage = true
nextPage = nil
loaded = false
replies = []
repliesLoaded = false
}
}

7
Model/CommentsPage.swift Normal file
View File

@@ -0,0 +1,7 @@
import Foundation
struct CommentsPage {
var comments = [Comment]()
var nextPage: String?
var disabled = false
}

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

View File

@@ -5,6 +5,11 @@ struct FavoritesModel {
static let shared = FavoritesModel()
@Default(.favorites) var all
@Default(.visibleSections) var visibleSections
var isEnabled: Bool {
visibleSections.contains(.favorites)
}
func contains(_ item: FavoriteItem) -> Bool {
all.contains { $0 == item }

80
Model/HistoryModel.swift Normal file
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
@@ -23,7 +48,7 @@ final class NavigationModel: ObservableObject {
}
}
@Published var tabSelection: TabSelection! = .favorites
@Published var tabSelection: TabSelection!
@Published var presentingAddToPlaylist = false
@Published var videoToAddToPlaylist: Video!
@@ -41,10 +66,84 @@ final class NavigationModel: ObservableObject {
@Published var presentingSettings = false
@Published var presentingWelcomeScreen = false
static func openChannel(
_ channel: Channel,
player: PlayerModel,
recents: RecentsModel,
navigation: NavigationModel,
navigationStyle: NavigationStyle,
delay: Bool = true
) {
guard channel.id != Video.fixtureChannelID else {
return
}
let recent = RecentItem(from: channel)
#if os(macOS)
Windows.main.open()
#else
player.hide()
#endif
let openRecent = {
recents.add(recent)
navigation.presentingChannel = true
}
if navigationStyle == .tab {
if delay {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
openRecent()
}
} else {
openRecent()
}
} else if navigationStyle == .sidebar {
openRecent()
navigation.sidebarSectionChanged.toggle()
navigation.tabSelection = .recentlyOpened(recent.tag)
}
}
static func openChannelPlaylist(
_ playlist: ChannelPlaylist,
player: PlayerModel,
recents: RecentsModel,
navigation: NavigationModel,
navigationStyle: NavigationStyle,
delay: Bool = false
) {
let recent = RecentItem(from: playlist)
#if os(macOS)
Windows.main.open()
#else
player.hide()
#endif
let openRecent = {
recents.add(recent)
navigation.presentingPlaylist = true
}
if navigationStyle == .tab {
if delay {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
openRecent()
}
} else {
openRecent()
}
} else if navigationStyle == .sidebar {
openRecent()
navigation.sidebarSectionChanged.toggle()
navigation.tabSelection = .recentlyOpened(recent.tag)
}
}
var tabSelectionBinding: Binding<TabSelection> {
Binding<TabSelection>(
get: {
self.tabSelection ?? .favorites
self.tabSelection ?? .search
},
set: { newValue in
self.tabSelection = newValue
@@ -71,6 +170,12 @@ final class NavigationModel: ObservableObject {
channelToUnsubscribe = channel
presentingUnsubscribeAlert = channelToUnsubscribe != nil
}
func hideKeyboard() {
#if os(iOS)
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
#endif
}
}
typealias TabSelection = NavigationModel.TabSelection

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,50 +1,64 @@
import AVKit
import CoreData
#if os(iOS)
import CoreMotion
#endif
import Defaults
import Foundation
import Logging
import MediaPlayer
#if !os(macOS)
import UIKit
#endif
import Siesta
import SwiftUI
import SwiftyJSON
#if !os(macOS)
import UIKit
#endif
final class PlayerModel: ObservableObject {
static let availableRates: [Float] = [0.5, 0.67, 0.8, 1, 1.25, 1.5, 2]
static let assetKeysToLoad = ["tracks", "playable", "duration"]
let logger = Logger(label: "stream.yattee.app")
private(set) var player = AVPlayer()
private(set) var playerView = Player()
var controller: PlayerViewController? { didSet { playerView.controller = controller } }
#if os(tvOS)
var avPlayerViewController: AVPlayerViewController?
#endif
var playerView = Player()
var controller: PlayerViewController?
var playerItem: AVPlayerItem?
@Published var presentingPlayer = false { didSet { pauseOnPlayerDismiss() } }
@Published var presentingPlayer = false { didSet { handlePresentationChange() } }
@Published var stream: Stream?
@Published var currentRate: Float = 1.0 { didSet { player.rate = currentRate } }
@Published var availableStreams = [Stream]() { didSet { rebuildTVMenu() } }
@Published var availableStreams = [Stream]() { didSet { handleAvailableStreamsChange() } }
@Published var streamSelection: Stream? { didSet { rebuildTVMenu() } }
@Published var queue = [PlayerQueueItem]() { didSet { Defaults[.queue] = queue } }
@Published var currentItem: PlayerQueueItem! { didSet { Defaults[.lastPlayed] = currentItem } }
@Published var history = [PlayerQueueItem]() { didSet { Defaults[.history] = history } }
@Published var currentItem: PlayerQueueItem! { didSet { handleCurrentItemChange() } }
@Published var historyVideos = [Video]()
@Published var savedTime: CMTime?
@Published var preservedTime: CMTime?
@Published var playerNavigationLinkActive = false
@Published var playerNavigationLinkActive = false { didSet { handleNavigationViewPlayerPresentationChange() } }
@Published var sponsorBlock = SponsorBlockAPI()
@Published var segmentRestorationTime: CMTime?
@Published var lastSkipped: Segment? { didSet { rebuildTVMenu() } }
@Published var restoredSegments = [Segment]()
var accounts: AccountsModel
#if os(iOS)
@Published var motionManager: CMMotionManager!
@Published var lockedOrientation: UIInterfaceOrientation?
@Published var lastOrientation: UIInterfaceOrientation?
#endif
var accounts: AccountsModel
var comments: CommentsModel
var asset: AVURLAsset?
var composition = AVMutableComposition()
var loadedCompositionAssets = [AVMediaType]()
var context: NSManagedObjectContext = PersistenceController.shared.container.viewContext
private var currentArtwork: MPMediaItemArtwork?
private var frequentTimeObserver: Any?
@@ -56,6 +70,7 @@ final class PlayerModel: ObservableObject {
private var timeObserverThrottle = Throttle(interval: 2)
var playingInPictureInPicture = false
var playingFullscreen = false
@Published var presentingErrorDetails = false
var playerError: Error? { didSet {
@@ -66,21 +81,63 @@ final class PlayerModel: ObservableObject {
#endif
}}
init(accounts: AccountsModel? = nil, instances _: InstancesModel? = nil) {
self.accounts = accounts ?? AccountsModel()
@Default(.pauseOnHidingPlayer) private var pauseOnHidingPlayer
@Default(.closePiPOnNavigation) var closePiPOnNavigation
@Default(.closePiPOnOpeningPlayer) var closePiPOnOpeningPlayer
#if !os(macOS)
@Default(.closePiPAndOpenPlayerOnEnteringForeground) var closePiPAndOpenPlayerOnEnteringForeground
#endif
init(accounts: AccountsModel? = nil, comments: CommentsModel? = nil) {
self.accounts = accounts ?? AccountsModel()
self.comments = comments ?? CommentsModel()
addItemDidPlayToEndTimeObserver()
addFrequentTimeObserver()
addInfrequentTimeObserver()
addPlayerTimeControlStatusObserver()
}
func presentPlayer() {
func show() {
guard !presentingPlayer else {
#if os(macOS)
Windows.player.focus()
#endif
return
}
#if os(macOS)
Windows.player.open()
Windows.player.focus()
#endif
presentingPlayer = true
}
func hide() {
presentingPlayer = false
playerNavigationLinkActive = false
}
func togglePlayer() {
presentingPlayer.toggle()
#if os(macOS)
if !presentingPlayer {
Windows.player.open()
}
Windows.player.focus()
#else
if presentingPlayer {
hide()
} else {
show()
}
#endif
}
var isLoadingVideo: Bool {
guard !currentVideo.isNil else {
return false
}
return player.currentItem == nil || time == nil || !time!.isValid
}
var isPlaying: Bool {
@@ -92,7 +149,7 @@ final class PlayerModel: ObservableObject {
}
var live: Bool {
currentItem?.video?.live ?? false
currentVideo?.live ?? false
}
var playerItemDuration: CMTime? {
@@ -100,7 +157,7 @@ final class PlayerModel: ObservableObject {
}
var videoDuration: TimeInterval? {
currentItem?.duration ?? currentVideo?.length
currentItem?.duration ?? currentVideo?.length ?? player.currentItem?.asset.duration.seconds
}
func togglePlay() {
@@ -123,40 +180,101 @@ final class PlayerModel: ObservableObject {
player.pause()
}
func upgradeToStream(_ stream: Stream) {
if !self.stream.isNil, self.stream != stream {
playStream(stream, of: currentVideo!, preservingTime: true)
func play(_ video: Video, at time: TimeInterval? = nil, inNavigationView: Bool = false) {
playNow(video, at: time)
guard !playingInPictureInPicture else {
return
}
if inNavigationView {
playerNavigationLinkActive = true
} else {
show()
}
}
func playStream(
_ stream: Stream,
of video: Video,
preservingTime: Bool = false
preservingTime: Bool = false,
upgrading: Bool = false
) {
playerError = nil
resetSegments()
sponsorBlock.loadSegments(videoID: video.videoID, categories: Defaults[.sponsorBlockCategories])
if !upgrading {
resetSegments()
DispatchQueue.main.async { [weak self] in
self?.sponsorBlock.loadSegments(
videoID: video.videoID,
categories: Defaults[.sponsorBlockCategories]
)
}
}
if let url = stream.singleAssetURL {
logger.info("playing stream with one asset\(stream.kind == .hls ? " (HLS)" : ""): \(url)")
insertPlayerItem(stream, for: video, preservingTime: preservingTime)
loadSingleAsset(url, stream: stream, of: video, preservingTime: preservingTime)
} else {
logger.info("playing stream with many assets:")
logger.info("composition audio asset: \(stream.audioAsset.url)")
logger.info("composition video asset: \(stream.videoAsset.url)")
Task {
await self.loadComposition(stream, of: video, preservingTime: preservingTime)
loadComposition(stream, of: video, preservingTime: preservingTime)
}
if !upgrading {
updateCurrentArtwork()
}
}
func upgradeToStream(_ stream: Stream) {
if !self.stream.isNil, self.stream != stream {
playStream(stream, of: currentVideo!, preservingTime: true, upgrading: true)
}
}
private func handleAvailableStreamsChange() {
rebuildTVMenu()
guard stream.isNil else {
return
}
guard let stream = preferredStream(availableStreams) else {
return
}
streamSelection = stream
playStream(
stream,
of: currentVideo!,
preservingTime: !currentItem.playbackTime.isNil
)
}
private func handlePresentationChange() {
if presentingPlayer, closePiPOnOpeningPlayer, playingInPictureInPicture {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.closePiP()
}
}
updateCurrentArtwork()
if !presentingPlayer, pauseOnHidingPlayer, !playingInPictureInPicture {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.pause()
}
}
if !presentingPlayer, !pauseOnHidingPlayer, isPlaying {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
self?.play()
}
}
}
private func pauseOnPlayerDismiss() {
if !playingInPictureInPicture, !presentingPlayer {
private func handleNavigationViewPlayerPresentationChange() {
if pauseOnHidingPlayer, !playingInPictureInPicture, !playerNavigationLinkActive {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.pause()
}
@@ -168,11 +286,14 @@ final class PlayerModel: ObservableObject {
for video: Video,
preservingTime: Bool = false
) {
let playerItem = playerItem(stream)
removeItemDidPlayToEndTimeObserver()
playerItem = playerItem(stream)
guard playerItem != nil else {
return
}
addItemDidPlayToEndTimeObserver()
attachMetadata(to: playerItem!, video: video, for: stream)
DispatchQueue.main.async { [weak self] in
@@ -182,6 +303,7 @@ final class PlayerModel: ObservableObject {
self.stream = stream
self.composition = AVMutableComposition()
self.asset = nil
}
let startPlaying = {
@@ -189,27 +311,53 @@ final class PlayerModel: ObservableObject {
try? AVAudioSession.sharedInstance().setActive(true)
#endif
if self.isAutoplaying(playerItem!) {
if self.isAutoplaying(self.playerItem!) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
self?.play()
guard let self = self else {
return
}
if !preservingTime,
let segment = self.sponsorBlock.segments.first,
segment.start < 3,
self.lastSkipped.isNil
{
self.player.seek(
to: segment.endTime,
toleranceBefore: .secondsInDefaultTimescale(1),
toleranceAfter: .zero
) { finished in
guard finished else {
return
}
self.lastSkipped = segment
self.play()
}
} else {
self.play()
}
}
}
}
let replaceItemAndSeek = {
self.player.replaceCurrentItem(with: playerItem)
self.seekToSavedTime { finished in
guard video == self.currentVideo else {
return
}
self.player.replaceCurrentItem(with: self.playerItem)
self.seekToPreservedTime { finished in
guard finished else {
return
}
self.savedTime = nil
self.preservedTime = nil
startPlaying()
}
}
if preservingTime {
if savedTime.isNil {
if preservedTime.isNil {
saveTime {
replaceItemAndSeek()
startPlaying()
@@ -224,55 +372,94 @@ final class PlayerModel: ObservableObject {
}
}
private func loadSingleAsset(
_ url: URL,
stream: Stream,
of video: Video,
preservingTime: Bool = false
) {
asset?.cancelLoading()
asset = AVURLAsset(url: url)
asset?.loadValuesAsynchronously(forKeys: Self.assetKeysToLoad) { [weak self] in
var error: NSError?
switch self?.asset?.statusOfValue(forKey: "duration", error: &error) {
case .loaded:
DispatchQueue.main.async { [weak self] in
self?.insertPlayerItem(stream, for: video, preservingTime: preservingTime)
}
case .failed:
DispatchQueue.main.async { [weak self] in
self?.playerError = error
}
default:
return
}
}
}
private func loadComposition(
_ stream: Stream,
of video: Video,
preservingTime: Bool = false
) async {
await loadCompositionAsset(stream.audioAsset, type: .audio, of: video)
await loadCompositionAsset(stream.videoAsset, type: .video, of: video)
guard streamSelection == stream else {
logger.critical("IGNORING LOADED")
return
}
insertPlayerItem(stream, for: video, preservingTime: preservingTime)
) {
loadedCompositionAssets = []
loadCompositionAsset(stream.audioAsset, stream: stream, type: .audio, of: video, preservingTime: preservingTime)
loadCompositionAsset(stream.videoAsset, stream: stream, type: .video, of: video, preservingTime: preservingTime)
}
private func loadCompositionAsset(
_ asset: AVURLAsset,
stream: Stream,
type: AVMediaType,
of video: Video
) async {
async let assetTracks = asset.loadTracks(withMediaType: type)
of video: Video,
preservingTime: Bool = false
) {
asset.loadValuesAsynchronously(forKeys: Self.assetKeysToLoad) { [weak self] in
guard let self = self else {
return
}
self.logger.info("loading \(type.rawValue) track")
logger.info("loading \(type.rawValue) track")
guard let compositionTrack = composition.addMutableTrack(
withMediaType: type,
preferredTrackID: kCMPersistentTrackID_Invalid
) else {
logger.critical("composition \(type.rawValue) addMutableTrack FAILED")
return
let assetTracks = asset.tracks(withMediaType: type)
guard let compositionTrack = self.composition.addMutableTrack(
withMediaType: type,
preferredTrackID: kCMPersistentTrackID_Invalid
) else {
self.logger.critical("composition \(type.rawValue) addMutableTrack FAILED")
return
}
guard let assetTrack = assetTracks.first else {
self.logger.critical("asset \(type.rawValue) track FAILED")
return
}
try! compositionTrack.insertTimeRange(
CMTimeRange(start: .zero, duration: CMTime.secondsInDefaultTimescale(video.length)),
of: assetTrack,
at: .zero
)
self.logger.critical("\(type.rawValue) LOADED")
guard self.streamSelection == stream else {
self.logger.critical("IGNORING LOADED")
return
}
self.loadedCompositionAssets.append(type)
if self.loadedCompositionAssets.count == 2 {
self.insertPlayerItem(stream, for: video, preservingTime: preservingTime)
}
}
guard let assetTrack = try? await assetTracks.first else {
logger.critical("asset \(type.rawValue) track FAILED")
return
}
try! compositionTrack.insertTimeRange(
CMTimeRange(start: .zero, duration: CMTime.secondsInDefaultTimescale(video.length)),
of: assetTrack,
at: .zero
)
logger.critical("\(type.rawValue) LOADED")
}
private func playerItem(_ stream: Stream) -> AVPlayerItem? {
if let url = stream.singleAssetURL {
return AVPlayerItem(asset: AVURLAsset(url: url))
private func playerItem(_: Stream) -> AVPlayerItem? {
if let asset = asset {
return AVPlayerItem(asset: asset)
} else {
return AVPlayerItem(asset: composition)
}
@@ -298,6 +485,10 @@ final class PlayerModel: ObservableObject {
item.preferredForwardBufferDuration = 5
observePlayerItemStatus(item)
}
private func observePlayerItemStatus(_ item: AVPlayerItem) {
statusObservation?.invalidate()
statusObservation = item.observe(\.status, options: [.old, .new]) { [weak self] playerItem, _ in
guard let self = self else {
@@ -335,25 +526,31 @@ final class PlayerModel: ObservableObject {
self,
selector: #selector(itemDidPlayToEndTime),
name: NSNotification.Name.AVPlayerItemDidPlayToEndTime,
object: nil
object: playerItem
)
}
private func removeItemDidPlayToEndTimeObserver() {
NotificationCenter.default.removeObserver(
self,
name: NSNotification.Name.AVPlayerItemDidPlayToEndTime,
object: playerItem
)
}
@objc func itemDidPlayToEndTime() {
currentItem.playbackTime = playerItemDuration
prepareCurrentItemForHistory(finished: true)
if queue.isEmpty {
#if !os(macOS)
try? AVAudioSession.sharedInstance().setActive(false)
#endif
addCurrentItemToHistory()
resetQueue()
#if os(tvOS)
avPlayerViewController!.dismiss(animated: true) { [weak self] in
self?.controller!.dismiss(animated: true)
controller?.playerView.dismiss(animated: false) { [weak self] in
self?.controller?.dismiss(animated: true)
}
#endif
presentingPlayer = false
} else {
advanceToNextItem()
}
@@ -367,13 +564,13 @@ final class PlayerModel: ObservableObject {
}
DispatchQueue.main.async { [weak self] in
self?.savedTime = currentTime
self?.preservedTime = currentTime
completionHandler()
}
}
private func seekToSavedTime(completionHandler: @escaping (Bool) -> Void = { _ in }) {
guard let time = savedTime else {
private func seekToPreservedTime(completionHandler: @escaping (Bool) -> Void = { _ in }) {
guard let time = preservedTime else {
return
}
@@ -424,7 +621,7 @@ final class PlayerModel: ObservableObject {
}
self.timeObserverThrottle.execute {
self.updateCurrentItemIntervals()
self.updateWatch()
}
}
}
@@ -454,29 +651,34 @@ final class PlayerModel: ObservableObject {
#endif
self.timeObserverThrottle.execute {
self.updateCurrentItemIntervals()
self.updateWatch()
}
}
}
private func updateCurrentItemIntervals() {
currentItem?.playbackTime = player.currentTime()
currentItem?.videoDuration = player.currentItem?.asset.duration.seconds
}
fileprivate func updateNowPlayingInfo() {
let duration: Int? = currentItem.video.live ? nil : Int(currentItem.videoDuration ?? 0)
let nowPlayingInfo: [String: AnyObject] = [
var nowPlayingInfo: [String: AnyObject] = [
MPMediaItemPropertyTitle: currentItem.video.title as AnyObject,
MPMediaItemPropertyArtist: currentItem.video.author as AnyObject,
MPMediaItemPropertyArtwork: currentArtwork as AnyObject,
MPMediaItemPropertyPlaybackDuration: duration as AnyObject,
MPNowPlayingInfoPropertyIsLiveStream: currentItem.video.live as AnyObject,
MPNowPlayingInfoPropertyElapsedPlaybackTime: player.currentTime().seconds as AnyObject,
MPNowPlayingInfoPropertyPlaybackQueueCount: queue.count as AnyObject,
MPMediaItemPropertyMediaType: MPMediaType.anyVideo.rawValue as AnyObject
]
if !currentArtwork.isNil {
nowPlayingInfo[MPMediaItemPropertyArtwork] = currentArtwork as AnyObject
}
if !currentItem.video.live {
let itemDuration = currentItem.videoDuration ?? currentItem.duration
let duration = itemDuration.isFinite ? Double(itemDuration) : nil
if !duration.isNil {
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration as AnyObject
}
}
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}
@@ -505,4 +707,107 @@ final class PlayerModel: ObservableObject {
return "\(formatter.string(from: NSNumber(value: rate))!)×"
}
func closeCurrentItem() {
prepareCurrentItemForHistory()
currentItem = nil
player.replaceCurrentItem(with: nil)
}
func closePiP() {
guard playingInPictureInPicture else {
return
}
let wasPlaying = isPlaying
pause()
#if os(tvOS)
show()
#endif
doClosePiP(wasPlaying: wasPlaying)
}
#if os(tvOS)
private func doClosePiP(wasPlaying: Bool) {
let item = player.currentItem
let time = player.currentTime()
self.player.replaceCurrentItem(with: nil)
guard !item.isNil else {
return
}
self.player.seek(to: time)
self.player.replaceCurrentItem(with: item)
guard wasPlaying else {
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
self?.play()
}
}
#else
private func doClosePiP(wasPlaying: Bool) {
controller?.playerView.player = nil
controller?.playerView.player = player
guard wasPlaying else {
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.play()
}
}
#endif
func handleCurrentItemChange() {
#if os(macOS)
Windows.player.window?.title = windowTitle
#endif
Defaults[.lastPlayed] = currentItem
}
#if os(macOS)
var windowTitle: String {
currentVideo.isNil ? "Not playing" : "\(currentVideo!.title) - \(currentVideo!.author)"
}
#else
func handleEnterForeground() {
guard closePiPAndOpenPlayerOnEnteringForeground, playingInPictureInPicture else {
return
}
show()
closePiP()
}
func enterFullScreen() {
guard !playingFullscreen else {
return
}
logger.info("entering fullscreen")
controller?.playerView
.perform(NSSelectorFromString("enterFullScreenAnimated:completionHandler:"), with: false, with: nil)
}
func exitFullScreen() {
guard playingFullscreen else {
return
}
logger.info("exiting fullscreen")
controller?.playerView
.perform(NSSelectorFromString("exitFullScreenAnimated:completionHandler:"), with: false, with: nil)
}
#endif
}

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

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

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

@@ -3,7 +3,7 @@ import Foundation
final class RecentsModel: ObservableObject {
@Default(.recentlyOpened) var items
@Default(.saveRecents) var saveRecents
func clear() {
items = []
}
@@ -13,6 +13,14 @@ final class RecentsModel: ObservableObject {
}
func add(_ item: RecentItem) {
if !saveRecents {
clear()
if item.type == .query {
return
}
}
if let index = items.firstIndex(where: { $0.id == item.id }) {
items.remove(at: index)
}
@@ -26,8 +34,9 @@ final class RecentsModel: ObservableObject {
}
}
func addQuery(_ query: String) {
func addQuery(_ query: String, navigation: NavigationModel? = nil) {
if !query.isEmpty {
navigation?.tabSelection = .search
add(.init(from: query))
}
}
@@ -47,6 +56,15 @@ final class RecentsModel: ObservableObject {
return nil
}
static func symbolSystemImage(_ name: String) -> String {
let firstLetter = name.first?.lowercased()
let regex = #"^[a-z0-9]$"#
let symbolName = firstLetter?.range(of: regex, options: .regularExpression) != nil ? firstLetter! : "questionmark"
return "\(symbolName).circle"
}
}
struct RecentItem: Defaults.Serializable, Identifiable {

View File

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

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

@@ -19,11 +19,15 @@ final class SubscriptionsModel: ObservableObject {
}
func subscribe(_ channelID: String, onSuccess: @escaping () -> Void = {}) {
performRequest(channelID, method: .post, onSuccess: onSuccess)
accounts.api.subscribe(channelID) {
self.scheduleLoad(onSuccess: onSuccess)
}
}
func unsubscribe(_ channelID: String, onSuccess: @escaping () -> Void = {}) {
performRequest(channelID, method: .delete, onSuccess: onSuccess)
accounts.api.unsubscribe(channelID) {
self.scheduleLoad(onSuccess: onSuccess)
}
}
func isSubscribing(_ channelID: String) -> Bool {
@@ -31,6 +35,11 @@ final class SubscriptionsModel: ObservableObject {
}
func load(force: Bool = false, onSuccess: @escaping () -> Void = {}) {
guard accounts.app.supportsSubscriptions, accounts.signedIn else {
channels = []
return
}
let request = force ? resource?.load() : resource?.loadIfNeeded()
request?
@@ -45,8 +54,8 @@ final class SubscriptionsModel: ObservableObject {
}
}
fileprivate func performRequest(_ channelID: String, method: RequestMethod, onSuccess: @escaping () -> Void = {}) {
accounts.api.channelSubscription(channelID)?.request(method).onCompletion { _ in
private func scheduleLoad(onSuccess: @escaping () -> Void) {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.load(force: true, onSuccess: onSuccess)
}
}

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>

117
README.md
View File

@@ -1,29 +1,36 @@
![Yattee Banner](https://r.yattee.stream/icons/yattee-banner.png)
Video player with support for [Invidious](https://github.com/iv-org/invidious) and [Piped](https://github.com/TeamPiped/Piped) instances built for iOS 15, tvOS 15 and macOS Monterey.
<div align="center">
<img src="https://r.yattee.stream/icons/yattee-150.png" width="150" height="150" alt="Yattee logo">
<h1>Yattee</h1>
<p>Alternative YouTube frontend for iOS, tvOS and macOS<br />built with <a href="https://github.com/iv-org/invidious">Invidious</a> and <a href="https://github.com/TeamPiped/Piped">Piped</a></p>
[![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)
![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/) with selection of categories to skip
* [SponsorBlock](https://sponsor.ajay.app/), configurable categories to skip
* Player queue and history
* Fullscreen playback and Picture in Picture
* Fullscreen playback, Picture in Picture and AirPlay support
* Stream quality selection
* Favorites: customizable section of channels, playlists, trending, searches and other views
* AirPlay support
* Safari Extension for macOS and iOS for redirecting to the app
* URL Scheme for easy integrations
### Features in alpha testing
* New player component with custom controls, gestures and support for 4K playback
You can leave your feedback in [discussion on v1.4 release](https://github.com/yattee/yattee/discussions/93) or join [Matrix Channel](https://matrix.to/#/#yattee:matrix.org) for a chat. Thanks!
### Availability
| Feature | Invidious | Piped |
|| Invidious | Piped |
| - | - | - |
| User Accounts | ✅ | 🔴 |
| Subscriptions | ✅ | 🔴 |
| User Accounts | ✅ | |
| Subscriptions | ✅ | |
| Popular | ✅ | 🔴 |
| User Playlists | ✅ | 🔴 |
| User Playlists | ✅ | |
| Trending | ✅ | ✅ |
| Channels | ✅ | ✅ |
| Channel Playlists | ✅ | ✅ |
@@ -31,82 +38,22 @@ Video player with support for [Invidious](https://github.com/iv-org/invidious) a
| Search Suggestions | ✅ | ✅ |
| Search Filters | ✅ | 🔴 |
| Subtitles | 🔴 | ✅ |
| Comments | 🔴 | ✅ |
## Installation
### Requirements
Application is built using latest APIs, that's why for now **only recent** software versions: iOS/tvOS 15 or macOS Monterey are supported.
You can browse and use accounts from one app and play videos with another (for example: use Invidious account for subscriptions and use Piped as playback source). Comments can be displayed from Piped even when Invidious is used for browsing/playing.
### How to install?
#### [AltStore](https://altstore.io/)
You can sideload IPA files that you can download from Releases page.
Alternatively, if you have to access to the beta AltStore version (v1.5), you can add the following repository in `Browse > Sources` screen: `https://alt.yattee.stream`
#### Manual installation
Download sources and compile them on a Mac using Xcode, install to your devices. Please note that if you are not registered in Apple Developer Program then the applications will require reinstalling every 7 days.
## Integrations
### Safari
macOS and iOS apps include Safari extension which will redirect opened YouTube tabs to the app.
### Firefox
You can use [Privacy Redirect](https://github.com/SimonBrazell/privacy-redirect) extension to make the videos open in the app. In extension settings put the following URL as Invidious instance: `https://r.yatte.stream`
### macOS
With [Finicky](https://github.com/johnste/finicky) you can configure your systems so the video links across the entire system will get opened in the app. Example configuration:
```js
{
match: [
finicky.matchDomains(/(.*\.)?youtube.com/),
finicky.matchDomains(/(.*\.)?youtu.be/)
],
browser: "/Applications/Yattee.app"
}
```
## Screenshots
### iOS
| Player | Search | Playlists |
| - | - | - |
| [![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
## Documentation
* [Installation Instructions](https://github.com/yattee/yattee/wiki/Installation-Instructions)
* [FAQ](https://github.com/yattee/yattee/wiki)
* [Screenshots Gallery](https://github.com/yattee/yattee/wiki/Screenshots-Gallery)
* [Tips](https://github.com/yattee/yattee/wiki/Tips)
* [Integrations](https://github.com/yattee/yattee/wiki/Integrations)
* [Donations](https://github.com/yattee/yattee/wiki/Donations)
## Contributing
Every contribution to make this tool better is very welcome. Start with [creating issue](https://github.com/yattee/app/issues/new) to have discussion which can be later transformed into a Pull Request.
Review existing Issues and Pull Requests before creating new ones.
If you're interestred in contributing, you can browse the [issues](https://github.com/yattee/yattee/issues) list or create a new one to discuss your feature idea. Every contribution is very welcome.
Join [Matrix Channel](https://matrix.to/#/#yattee:matrix.org) for a chat if you need an advice or want to discuss the project.
## License and Liability
Yattee and its components is shared on [AGPL v3](https://www.gnu.org/licenses/agpl-3.0.en.html) license.

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"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 617 B

After

Width:  |  Height:  |  Size: 543 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 773 B

After

Width:  |  Height:  |  Size: 557 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 773 B

After

Width:  |  Height:  |  Size: 557 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 801 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 801 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1012 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 690 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 690 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 690 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1000 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1000 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 854 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1010 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

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,11 +1,15 @@
import Defaults
import Foundation
#if os(iOS)
import UIKit
#endif
extension Defaults.Keys {
static let kavinPipedInstanceID = "kavin-piped"
static let instances = Key<[Instance]>("instances", default: [
.init(
app: .piped,
id: "default-piped-instance",
id: kavinPipedInstanceID,
name: "Kavin",
apiURL: "https://pipedapi.kavin.rocks",
frontendURL: "https://piped.kavin.rocks"
@@ -21,31 +25,69 @@ extension Defaults.Keys {
static let favorites = Key<[FavoriteItem]>("favorites", default: [
.init(section: .trending("US", "default")),
.init(section: .trending("GB", "default")),
.init(section: .trending("ES", "default")),
.init(section: .channel("UC-lHJZR3Gqxm24_Vd_AJ5Yw", "PewDiePie")),
.init(section: .searchQuery("Apple Pie Recipes", "", "", ""))
.init(section: .channel("UCXuqSBlHAE6Xw-yeJA0Tunw", "Linus Tech Tips")),
.init(section: .channel("UCBJycsmduvYEL83R_U4JriQ", "Marques Brownlee")),
.init(section: .channel("UCE_M8A5yxnLfW0KghEeajjw", "Apple"))
])
#if !os(tvOS)
static let accountPickerDisplaysUsername = Key<Bool>("accountPickerDisplaysUsername", default: false)
#endif
#if os(iOS)
static let lockPortraitWhenBrowsing = Key<Bool>("lockPortraitWhenBrowsing", default: UIDevice.current.userInterfaceIdiom == .phone)
#endif
static let channelOnThumbnail = Key<Bool>("channelOnThumbnail", default: true)
static let timeOnThumbnail = Key<Bool>("timeOnThumbnail", default: true)
static let roundedThumbnails = Key<Bool>("roundedThumbnails", default: true)
static let quality = Key<ResolutionSetting>("quality", default: .best)
static let playerSidebar = Key<PlayerSidebarSetting>("playerSidebar", default: PlayerSidebarSetting.defaultValue)
static let playerInstanceID = Key<Instance.ID?>("playerInstance")
static let showKeywords = Key<Bool>("showKeywords", default: false)
static let showHistoryInPlayer = Key<Bool>("showHistoryInPlayer", default: false)
static let commentsInstanceID = Key<Instance.ID?>("commentsInstance", default: kavinPipedInstanceID)
#if !os(tvOS)
static let commentsPlacement = Key<CommentsPlacement>("commentsPlacement", default: .separate)
#endif
static let pauseOnHidingPlayer = Key<Bool>("pauseOnHidingPlayer", default: true)
static let closePiPOnNavigation = Key<Bool>("closePiPOnNavigation", default: false)
static let closePiPOnOpeningPlayer = Key<Bool>("closePiPOnOpeningPlayer", default: false)
#if !os(macOS)
static let closePiPAndOpenPlayerOnEnteringForeground = Key<Bool>("closePiPAndOpenPlayerOnEnteringForeground", default: false)
#endif
static let recentlyOpened = Key<[RecentItem]>("recentlyOpened", default: [])
static let queue = Key<[PlayerQueueItem]>("queue", default: [])
static let history = Key<[PlayerQueueItem]>("history", default: [])
static let lastPlayed = Key<PlayerQueueItem?>("lastPlayed")
static let saveHistory = Key<Bool>("saveHistory", default: true)
static let showWatchingProgress = Key<Bool>("showWatchingProgress", default: true)
static let watchedThreshold = Key<Int>("watchedThreshold", default: 90)
static let watchedVideoStyle = Key<WatchedVideoStyle>("watchedVideoStyle", default: .badge)
static let watchedVideoBadgeColor = Key<WatchedVideoBadgeColor>("WatchedVideoBadgeColor", default: .red)
static let watchedVideoPlayNowBehavior = Key<WatchedVideoPlayNowBehavior>("watchedVideoPlayNowBehavior", default: .continue)
static let resetWatchedStatusOnPlaying = Key<Bool>("resetWatchedStatusOnPlaying", default: false)
static let saveRecents = Key<Bool>("saveRecents", default: true)
static let trendingCategory = Key<TrendingCategory>("trendingCategory", default: .default)
static let trendingCountry = Key<Country>("trendingCountry", default: .us)
static let visibleSections = Key<Set<VisibleSection>>("visibleSections", default: [.favorites, .subscriptions, .trending, .playlists])
#if os(macOS)
static let enableBetaChannel = Key<Bool>("enableBetaChannel", default: false)
#endif
#if os(iOS)
static let tabNavigationSection = Key<TabNavigationSectionSetting>("tabNavigationSection", default: .trending)
static let honorSystemOrientationLock = Key<Bool>("honorSystemOrientationLock", default: true)
static let enterFullscreenInLandscape = Key<Bool>("enterFullscreenInLandscape", default: UIDevice.current.userInterfaceIdiom == .phone)
static let lockLandscapeOnRotation = Key<Bool>("lockLandscapeOnRotation", default: false)
static let lockLandscapeWhenEnteringFullscreen = Key<Bool>("lockLandscapeWhenEnteringFullscreen", default: false)
#endif
}
@@ -64,7 +106,7 @@ enum ResolutionSetting: String, CaseIterable, Defaults.Serializable {
var description: String {
switch self {
case .best:
return "Best available"
return "Best available quality"
default:
return value.name
}
@@ -83,8 +125,62 @@ enum PlayerSidebarSetting: String, CaseIterable, Defaults.Serializable {
}
}
#if os(iOS)
enum TabNavigationSectionSetting: String, Defaults.Serializable {
case trending, popular
enum VisibleSection: String, CaseIterable, Comparable, Defaults.Serializable {
case favorites, subscriptions, popular, trending, playlists
var title: String {
rawValue.localizedCapitalized
}
var tabSelection: TabSelection {
switch self {
case .favorites:
return TabSelection.favorites
case .subscriptions:
return TabSelection.subscriptions
case .popular:
return TabSelection.popular
case .trending:
return TabSelection.trending
case .playlists:
return TabSelection.playlists
}
}
private var sortOrder: Int {
switch self {
case .favorites:
return 0
case .subscriptions:
return 1
case .popular:
return 2
case .trending:
return 3
case .playlists:
return 4
}
}
static func < (lhs: Self, rhs: Self) -> Bool {
lhs.sortOrder < rhs.sortOrder
}
}
enum WatchedVideoStyle: String, Defaults.Serializable {
case nothing, badge, decreasedOpacity, both
}
enum WatchedVideoBadgeColor: String, Defaults.Serializable {
case colorSchemeBased, red, blue
}
enum WatchedVideoPlayNowBehavior: String, Defaults.Serializable {
case `continue`, restart
}
#if !os(tvOS)
enum CommentsPlacement: String, CaseIterable, Defaults.Serializable {
case info, separate
}
#endif

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

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,8 +27,17 @@ struct FavoritesView: View {
FavoriteItemView(item: item, dragging: $dragging)
}
#else
#if os(iOS)
let first = favorites.first
#endif
ForEach(favorites) { item in
FavoriteItemView(item: item, dragging: $dragging)
#if os(macOS)
.workaroundForVerticalScrollingBug()
#endif
#if os(iOS)
.padding(.top, item == first && RefreshControl.navigationBarTitleDisplayMode == .inline ? 10 : 0)
#endif
}
#endif
}
@@ -42,18 +51,18 @@ struct FavoritesView: View {
.redrawOn(change: favoritesChanged)
#if os(tvOS)
.sheet(isPresented: $presentingEditFavorites) {
EditFavorites()
}
.edgesIgnoringSafeArea(.horizontal)
#else
.onDrop(of: [UTType.text], delegate: DropFavoriteOutside(current: $dragging))
.navigationTitle("Favorites")
#endif
#if os(macOS)
.background()
.background(Color.secondaryBackground)
.frame(minWidth: 360)
#endif
#if os(iOS)
.navigationBarTitleDisplayMode(RefreshControl.navigationBarTitleDisplayMode)
#endif
}
}
}

View File

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

View File

@@ -1,26 +0,0 @@
import Foundation
import SwiftUI
struct UnsubscribeAlertModifier: ViewModifier {
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<SubscriptionsModel> private var subscriptions
func body(content: Content) -> some View {
content
.alert(unsubscribeAlertTitle, isPresented: $navigation.presentingUnsubscribeAlert) {
if let channel = navigation.channelToUnsubscribe {
Button("Unsubscribe", role: .destructive) {
subscriptions.unsubscribe(channel.id)
}
}
}
}
var unsubscribeAlertTitle: String {
if let channel = navigation.channelToUnsubscribe {
return "Unsubscribe from \(channel.name)"
}
return "Unknown channel"
}
}

View File

@@ -6,22 +6,42 @@ struct AccountsMenuView: View {
@Default(.accounts) private var accounts
@Default(.instances) private var instances
@Default(.accountPickerDisplaysUsername) private var accountPickerDisplaysUsername
var body: some View {
Menu {
ForEach(allAccounts, id: \.id) { account in
Button(accountButtonTitle(account: account)) {
Button {
model.setCurrent(account)
} label: {
HStack {
Text(accountButtonTitle(account: account))
Spacer()
if model.current == account {
Image(systemName: "checkmark")
}
}
}
}
} label: {
Label(model.current?.name ?? "Select Account", systemImage: "person.crop.circle")
.labelStyle(.titleAndIcon)
HStack {
Image(systemName: "person.crop.circle")
if accountPickerDisplaysUsername {
label
.labelStyle(.titleOnly)
}
}
}
.disabled(instances.isEmpty)
.transaction { t in t.animation = .none }
}
private var label: some View {
Label(model.current?.description ?? "Select Account", systemImage: "person.crop.circle")
}
private var allAccounts: [Account] {
accounts + instances.map(\.anonymousAccount)
}

View File

@@ -1,3 +1,4 @@
import Defaults
import SwiftUI
#if os(iOS)
import Introspect
@@ -5,9 +6,19 @@ import SwiftUI
struct AppSidebarNavigation: View {
@EnvironmentObject<AccountsModel> private var accounts
#if os(iOS)
@EnvironmentObject<NavigationModel> private var navigation
@State private var didApplyPrimaryViewWorkAround = false
@EnvironmentObject<CommentsModel> private var comments
@EnvironmentObject<InstancesModel> private var instances
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<PlaylistsModel> private var playlists
@EnvironmentObject<RecentsModel> private var recents
@EnvironmentObject<SearchModel> private var search
@EnvironmentObject<SubscriptionsModel> private var subscriptions
@EnvironmentObject<ThumbnailsModel> private var thumbnailsModel
#endif
var body: some View {
@@ -38,13 +49,36 @@ struct AppSidebarNavigation: View {
.frame(minWidth: sidebarMinWidth)
VStack {
Image(systemName: "play.tv")
.renderingMode(.original)
.font(.system(size: 60))
.foregroundColor(.accentColor)
PlayerControlsView {
HStack(alignment: .center) {
Spacer()
Image(systemName: "play.tv")
.renderingMode(.original)
.font(.system(size: 60))
.foregroundColor(.accentColor)
Spacer()
}
}
}
}
.environment(\.navigationStyle, .sidebar)
#if os(iOS)
.background(
EmptyView().fullScreenCover(isPresented: $player.presentingPlayer) {
VideoPlayerView()
.environmentObject(accounts)
.environmentObject(comments)
.environmentObject(instances)
.environmentObject(navigation)
.environmentObject(player)
.environmentObject(playlists)
.environmentObject(recents)
.environmentObject(subscriptions)
.environmentObject(thumbnailsModel)
.environment(\.navigationStyle, .sidebar)
}
)
#endif
}
var toolbarContent: some ToolbarContent {
@@ -66,6 +100,16 @@ struct AppSidebarNavigation: View {
"Current User: \(accounts.current?.description ?? "Not set")"
)
}
#if os(macOS)
ToolbarItem(placement: .navigation) {
Button {
NSApp.keyWindow?.firstResponder?.tryToPerform(#selector(NSSplitViewController.toggleSidebar(_:)), with: nil)
} label: {
Label("Toggle Sidebar", systemImage: "sidebar.left")
}
}
#endif
}
}
@@ -76,13 +120,4 @@ struct AppSidebarNavigation: View {
return .automatic
#endif
}
static func symbolSystemImage(_ name: String) -> String {
let firstLetter = name.first?.lowercased()
let regex = #"^[a-z0-9]$"#
let symbolName = firstLetter?.range(of: regex, options: .regularExpression) != nil ? firstLetter! : "questionmark"
return "\(symbolName).circle"
}
}

View File

@@ -11,15 +11,15 @@ struct AppSidebarPlaylists: View {
NavigationLink(tag: TabSelection.playlist(playlist.id), selection: $navigation.tabSelection) {
LazyView(PlaylistVideosView(playlist))
} label: {
Label(playlist.title, systemImage: AppSidebarNavigation.symbolSystemImage(playlist.title))
.badge(Text("\(playlist.videos.count)"))
playlistLabel(playlist)
}
.id(playlist.id)
.contextMenu {
Button("Add to queue...") {
playlists.find(id: playlist.id)?.videos.forEach { video in
player.enqueueVideo(video)
}
Button("Play All") {
player.play(playlists.find(id: playlist.id)?.videos ?? [])
}
Button("Shuffle All") {
player.play(playlists.find(id: playlist.id)?.videos ?? [], shuffling: true)
}
Button("Edit") {
navigation.presentEditPlaylistForm(playlists.find(id: playlist.id))
@@ -30,8 +30,17 @@ struct AppSidebarPlaylists: View {
newPlaylistButton
.padding(.top, 8)
}
.onAppear {
playlists.load()
}
@ViewBuilder func playlistLabel(_ playlist: Playlist) -> some View {
let label = Label(playlist.title, systemImage: RecentsModel.symbolSystemImage(playlist.title))
if player.accounts.app.userPlaylistsEndpointIncludesVideos {
label
.backport
.badge(Text("\(playlist.videos.count)"))
} else {
label
}
}

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,19 +11,15 @@ struct AppSidebarSubscriptions: View {
NavigationLink(tag: TabSelection.channel(channel.id), selection: $navigation.tabSelection) {
LazyView(ChannelVideosView(channel: channel))
} label: {
Label(channel.name, systemImage: AppSidebarNavigation.symbolSystemImage(channel.name))
Label(channel.name, systemImage: RecentsModel.symbolSystemImage(channel.name))
}
.contextMenu {
Button("Unsubscribe") {
navigation.presentUnsubscribeAlert(channel)
}
}
.modifier(UnsubscribeAlertModifier())
.id("channel\(channel.id)")
}
}
.onAppear {
subscriptions.load()
}
}
}

View File

@@ -3,124 +3,124 @@ import SwiftUI
struct AppTabNavigation: View {
@EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<CommentsModel> private var comments
@EnvironmentObject<InstancesModel> private var instances
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<PlaylistsModel> private var playlists
@EnvironmentObject<RecentsModel> private var recents
@EnvironmentObject<SearchModel> private var search
@EnvironmentObject<SubscriptionsModel> private var subscriptions
@EnvironmentObject<ThumbnailsModel> private var thumbnailsModel
@Default(.tabNavigationSection) private var tabNavigationSection
@Default(.visibleSections) private var visibleSections
let persistenceController = PersistenceController.shared
var body: some View {
TabView(selection: navigation.tabSelectionBinding) {
NavigationView {
LazyView(FavoritesView())
.toolbar { toolbarContent }
}
.tabItem {
Label("Favorites", systemImage: "heart")
.accessibility(label: Text("Favorites"))
}
.tag(TabSelection.favorites)
if subscriptionsVisible {
NavigationView {
LazyView(SubscriptionsView())
.toolbar { toolbarContent }
}
.tabItem {
Label("Subscriptions", systemImage: "star.circle.fill")
.accessibility(label: Text("Subscriptions"))
}
.tag(TabSelection.subscriptions)
if visibleSections.contains(.favorites) {
favoritesNavigationView
}
if subscriptionsVisible {
if accounts.app.supportsPopular {
if tabNavigationSection == .popular {
popularNavigationView
} else {
trendingNavigationView
}
}
} else {
if accounts.app.supportsPopular {
popularNavigationView
}
subscriptionsNavigationView
}
if visibleSections.contains(.popular), accounts.app.supportsPopular, visibleSections.count < 5 {
popularNavigationView
}
if visibleSections.contains(.trending) {
trendingNavigationView
}
if accounts.app.supportsUserPlaylists {
NavigationView {
LazyView(PlaylistsView())
.toolbar { toolbarContent }
}
.tabItem {
Label("Playlists", systemImage: "list.and.film")
.accessibility(label: Text("Playlists"))
}
.tag(TabSelection.playlists)
if playlistsVisible {
playlistsNavigationView
}
NavigationView {
LazyView(
SearchView()
.toolbar { toolbarContent }
.searchable(text: $search.queryText, placement: .navigationBarDrawer(displayMode: .always)) {
ForEach(search.querySuggestions.collection, id: \.self) { suggestion in
Text(suggestion)
.searchCompletion(suggestion)
}
}
.onChange(of: search.queryText) { query in
search.loadSuggestions(query)
}
.onSubmit(of: .search) {
search.changeQuery { query in
query.query = search.queryText
}
recents.addQuery(search.queryText)
}
)
}
.tabItem {
Label("Search", systemImage: "magnifyingglass")
.accessibility(label: Text("Search"))
}
.tag(TabSelection.search)
searchNavigationView
}
.id(accounts.current?.id ?? "")
.environment(\.navigationStyle, .tab)
.sheet(isPresented: $navigation.presentingChannel, onDismiss: {
if let channel = recents.presentedChannel {
recents.close(RecentItem(from: channel))
}
}) {
if let channel = recents.presentedChannel {
NavigationView {
ChannelVideosView(channel: channel)
.environment(\.inChannelView, true)
.environment(\.inNavigationView, true)
.background(playerNavigationLink)
.background(
EmptyView().sheet(isPresented: $navigation.presentingChannel) {
if let channel = recents.presentedChannel {
NavigationView {
ChannelVideosView(channel: channel)
.environment(\.managedObjectContext, persistenceController.container.viewContext)
.environment(\.inChannelView, true)
.environment(\.inNavigationView, true)
.environmentObject(accounts)
.environmentObject(navigation)
.environmentObject(player)
.environmentObject(subscriptions)
.environmentObject(thumbnailsModel)
.background(playerNavigationLink)
}
}
}
}
.sheet(isPresented: $navigation.presentingPlaylist, onDismiss: {
if let playlist = recents.presentedPlaylist {
recents.close(RecentItem(from: playlist))
}
}) {
if let playlist = recents.presentedPlaylist {
NavigationView {
ChannelPlaylistView(playlist: playlist)
.environment(\.inNavigationView, true)
.background(playerNavigationLink)
)
.background(
EmptyView().sheet(isPresented: $navigation.presentingPlaylist) {
if let playlist = recents.presentedPlaylist {
NavigationView {
ChannelPlaylistView(playlist: playlist)
.environment(\.managedObjectContext, persistenceController.container.viewContext)
.environment(\.inNavigationView, true)
.environmentObject(accounts)
.environmentObject(navigation)
.environmentObject(player)
.environmentObject(subscriptions)
.environmentObject(thumbnailsModel)
.background(playerNavigationLink)
}
}
}
)
.background(
EmptyView().fullScreenCover(isPresented: $player.presentingPlayer) {
videoPlayer
.environment(\.managedObjectContext, persistenceController.container.viewContext)
.environment(\.navigationStyle, .tab)
}
)
}
private var favoritesNavigationView: some View {
NavigationView {
LazyView(FavoritesView())
.toolbar { toolbarContent }
}
.tabItem {
Label("Favorites", systemImage: "heart")
.accessibility(label: Text("Favorites"))
}
.tag(TabSelection.favorites)
}
private var subscriptionsNavigationView: some View {
NavigationView {
LazyView(SubscriptionsView())
.toolbar { toolbarContent }
}
.tabItem {
Label("Subscriptions", systemImage: "star.circle.fill")
.accessibility(label: Text("Subscriptions"))
}
.tag(TabSelection.subscriptions)
}
private var subscriptionsVisible: Bool {
accounts.app.supportsSubscriptions && !(accounts.current?.anonymous ?? true)
visibleSections.contains(.subscriptions) &&
accounts.app.supportsSubscriptions && !(accounts.current?.anonymous ?? true)
}
private var playlistsVisible: Bool {
visibleSections.contains(.playlists) &&
accounts.app.supportsUserPlaylists && !(accounts.current?.anonymous ?? true)
}
private var popularNavigationView: some View {
@@ -129,7 +129,7 @@ struct AppTabNavigation: View {
.toolbar { toolbarContent }
}
.tabItem {
Label("Popular", systemImage: "chart.bar")
Label("Popular", systemImage: "arrow.up.right.circle")
.accessibility(label: Text("Popular"))
}
.tag(TabSelection.popular)
@@ -141,21 +141,58 @@ struct AppTabNavigation: View {
.toolbar { toolbarContent }
}
.tabItem {
Label("Trending", systemImage: "chart.line.uptrend.xyaxis")
Label("Trending", systemImage: "chart.bar")
.accessibility(label: Text("Trending"))
}
.tag(TabSelection.trending)
}
private var playlistsNavigationView: some View {
NavigationView {
LazyView(PlaylistsView())
.toolbar { toolbarContent }
}
.tabItem {
Label("Playlists", systemImage: "list.and.film")
.accessibility(label: Text("Playlists"))
}
.tag(TabSelection.playlists)
}
private var searchNavigationView: some View {
NavigationView {
LazyView(SearchView())
.toolbar { toolbarContent }
}
.tabItem {
Label("Search", systemImage: "magnifyingglass")
.accessibility(label: Text("Search"))
}
.tag(TabSelection.search)
}
private var playerNavigationLink: some View {
NavigationLink(isActive: $player.playerNavigationLinkActive, destination: {
VideoPlayerView()
videoPlayer
.environment(\.inNavigationView, true)
}) {
EmptyView()
}
}
private var videoPlayer: some View {
VideoPlayerView()
.environmentObject(accounts)
.environmentObject(comments)
.environmentObject(instances)
.environmentObject(navigation)
.environmentObject(player)
.environmentObject(playlists)
.environmentObject(recents)
.environmentObject(subscriptions)
.environmentObject(thumbnailsModel)
}
var toolbarContent: some ToolbarContent {
#if os(iOS)
Group {

View File

@@ -7,15 +7,16 @@ import Siesta
import SwiftUI
struct ContentView: View {
@StateObject private var accounts = AccountsModel()
@StateObject private var instances = InstancesModel()
@StateObject private var navigation = NavigationModel()
@StateObject private var player = PlayerModel()
@StateObject private var playlists = PlaylistsModel()
@StateObject private var recents = RecentsModel()
@StateObject private var search = SearchModel()
@StateObject private var subscriptions = SubscriptionsModel()
@StateObject private var thumbnailsModel = ThumbnailsModel()
@EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<CommentsModel> private var comments
@EnvironmentObject<InstancesModel> private var instances
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<PlaylistsModel> private var playlists
@EnvironmentObject<RecentsModel> private var recents
@EnvironmentObject<SearchModel> private var search
@EnvironmentObject<SubscriptionsModel> private var subscriptions
@EnvironmentObject<ThumbnailsModel> private var thumbnailsModel
@EnvironmentObject<MenuModel> private var menu
@@ -24,7 +25,7 @@ struct ContentView: View {
#endif
var body: some View {
Section {
Group {
#if os(iOS)
if horizontalSizeClass == .compact {
AppTabNavigation()
@@ -38,8 +39,13 @@ struct ContentView: View {
#endif
}
.onAppear(perform: configure)
.onChange(of: accounts.signedIn) { _ in
subscriptions.load(force: true)
playlists.load(force: true)
}
.environmentObject(accounts)
.environmentObject(comments)
.environmentObject(instances)
.environmentObject(navigation)
.environmentObject(player)
@@ -49,63 +55,66 @@ struct ContentView: View {
.environmentObject(subscriptions)
.environmentObject(thumbnailsModel)
.sheet(isPresented: $navigation.presentingWelcomeScreen) {
WelcomeScreen()
.environmentObject(accounts)
.environmentObject(navigation)
}
#if os(iOS)
.fullScreenCover(isPresented: $player.presentingPlayer) {
VideoPlayerView()
.environmentObject(accounts)
.environmentObject(instances)
.environmentObject(navigation)
.environmentObject(player)
.environmentObject(playlists)
.environmentObject(subscriptions)
.environmentObject(thumbnailsModel)
}
#elseif os(macOS)
.sheet(isPresented: $player.presentingPlayer) {
VideoPlayerView()
.frame(minWidth: 900, minHeight: 800)
.environmentObject(accounts)
.environmentObject(instances)
.environmentObject(navigation)
.environmentObject(player)
.environmentObject(playlists)
.environmentObject(subscriptions)
.environmentObject(thumbnailsModel)
}
#endif
// iOS 14 has problem with multiple sheets in one view
// but it's ok when it's in background
.background(
EmptyView().sheet(isPresented: $navigation.presentingWelcomeScreen) {
WelcomeScreen()
.environmentObject(accounts)
.environmentObject(navigation)
}
)
#if !os(tvOS)
.handlesExternalEvents(preferring: Set(["*"]), allowing: Set(["*"]))
.onOpenURL(perform: handleOpenedURL)
.sheet(isPresented: $navigation.presentingAddToPlaylist) {
AddToPlaylistView(video: navigation.videoToAddToPlaylist)
.environmentObject(playlists)
}
.sheet(isPresented: $navigation.presentingPlaylistForm) {
PlaylistFormView(playlist: $navigation.editedPlaylist)
.environmentObject(accounts)
.environmentObject(playlists)
}
.sheet(isPresented: $navigation.presentingSettings, onDismiss: openWelcomeScreenIfAccountEmpty) {
SettingsView()
.environmentObject(accounts)
.environmentObject(instances)
}
.onOpenURL { OpenURLHandler(accounts: accounts, player: player).handle($0) }
.background(
EmptyView().sheet(isPresented: $navigation.presentingAddToPlaylist) {
AddToPlaylistView(video: navigation.videoToAddToPlaylist)
.environmentObject(playlists)
}
)
.background(
EmptyView().sheet(isPresented: $navigation.presentingPlaylistForm) {
PlaylistFormView(playlist: $navigation.editedPlaylist)
.environmentObject(accounts)
.environmentObject(playlists)
}
)
.background(
EmptyView().sheet(isPresented: $navigation.presentingSettings, onDismiss: openWelcomeScreenIfAccountEmpty) {
SettingsView()
.environmentObject(accounts)
.environmentObject(instances)
.environmentObject(player)
}
)
#endif
.alert(isPresented: $navigation.presentingUnsubscribeAlert) {
Alert(
title: Text(
"Are you sure you want to unsubscribe from \(navigation.channelToUnsubscribe.name)?"
),
primaryButton: .destructive(Text("Unsubscribe")) {
subscriptions.unsubscribe(navigation.channelToUnsubscribe.id)
},
secondaryButton: .cancel()
)
}
}
func configure() {
SiestaLog.Category.enabled = .common
SDImageCodersManager.shared.addCoder(SDImageWebPCoder.shared)
SDWebImageManager.defaultImageCache = PINCache(name: "net.yattee.app")
SDWebImageManager.defaultImageCache = PINCache(name: "stream.yattee.app")
#if !os(macOS)
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
#endif
#if os(iOS)
if Defaults[.lockPortraitWhenBrowsing] {
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
}
#endif
if let account = accounts.lastUsed ??
instances.lastUsed?.anonymousAccount ??
InstancesModel.all.first?.anonymousAccount
@@ -117,18 +126,39 @@ struct ContentView: View {
navigation.presentingWelcomeScreen = true
}
player.accounts = accounts
playlists.accounts = accounts
search.accounts = accounts
subscriptions.accounts = accounts
comments.player = player
menu.accounts = accounts
menu.navigation = navigation
menu.player = player
player.accounts = accounts
player.comments = comments
if !accounts.current.isNil {
player.loadHistoryDetails()
player.restoreQueue()
}
if !Defaults[.saveRecents] {
recents.clear()
}
var section = Defaults[.visibleSections].min()?.tabSelection
#if os(macOS)
if section == .playlists {
section = .search
}
#endif
navigation.tabSelection = section ?? .search
subscriptions.load()
playlists.load()
}
func openWelcomeScreenIfAccountEmpty() {
@@ -138,28 +168,6 @@ struct ContentView: View {
navigation.presentingWelcomeScreen = true
}
#if !os(tvOS)
func handleOpenedURL(_ url: URL) {
guard !accounts.current.isNil else {
return
}
let parser = VideoURLParser(url: url)
guard let id = parser.id else {
return
}
accounts.api.video(id).load().onSuccess { response in
if let video: Video = response.typedContent() {
player.addCurrentItemToHistory()
self.player.playNow(video, at: parser.time)
self.player.presentPlayer()
}
}
}
#endif
}
struct ContentView_Previews: PreviewProvider {

View File

@@ -1,9 +1,12 @@
import Defaults
import SwiftUI
struct Sidebar: View {
@EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<NavigationModel> private var navigation
@Default(.visibleSections) private var visibleSections
var body: some View {
ScrollViewReader { scrollView in
List {
@@ -13,9 +16,14 @@ struct Sidebar: View {
AppSidebarRecents()
.id("recentlyOpened")
if accounts.signedIn {
AppSidebarSubscriptions()
AppSidebarPlaylists()
if accounts.api.signedIn {
if visibleSections.contains(.subscriptions), accounts.app.supportsSubscriptions {
AppSidebarSubscriptions()
}
if visibleSections.contains(.playlists), accounts.app.supportsUserPlaylists {
AppSidebarPlaylists()
}
}
}
}
@@ -31,43 +39,58 @@ struct Sidebar: View {
}
var mainNavigationLinks: some View {
Section("Videos") {
NavigationLink(destination: LazyView(FavoritesView()), tag: TabSelection.favorites, selection: $navigation.tabSelection) {
Label("Favorites", systemImage: "heart")
.accessibility(label: Text("Favorites"))
Section(header: Text("Videos")) {
if visibleSections.contains(.favorites) {
NavigationLink(destination: LazyView(FavoritesView()), tag: TabSelection.favorites, selection: $navigation.tabSelection) {
Label("Favorites", systemImage: "heart")
.accessibility(label: Text("Favorites"))
}
.id("favorites")
}
if accounts.app.supportsSubscriptions && accounts.signedIn {
if visibleSections.contains(.subscriptions),
accounts.app.supportsSubscriptions && accounts.signedIn
{
NavigationLink(destination: LazyView(SubscriptionsView()), tag: TabSelection.subscriptions, selection: $navigation.tabSelection) {
Label("Subscriptions", systemImage: "star.circle")
.accessibility(label: Text("Subscriptions"))
}
.id("subscriptions")
}
if accounts.app.supportsPopular {
if visibleSections.contains(.popular), accounts.app.supportsPopular {
NavigationLink(destination: LazyView(PopularView()), tag: TabSelection.popular, selection: $navigation.tabSelection) {
Label("Popular", systemImage: "chart.bar")
Label("Popular", systemImage: "arrow.up.right.circle")
.accessibility(label: Text("Popular"))
}
.id("popular")
}
NavigationLink(destination: LazyView(TrendingView()), tag: TabSelection.trending, selection: $navigation.tabSelection) {
Label("Trending", systemImage: "chart.line.uptrend.xyaxis")
.accessibility(label: Text("Trending"))
if visibleSections.contains(.trending) {
NavigationLink(destination: LazyView(TrendingView()), tag: TabSelection.trending, selection: $navigation.tabSelection) {
Label("Trending", systemImage: "chart.bar")
.accessibility(label: Text("Trending"))
}
.id("trending")
}
NavigationLink(destination: LazyView(SearchView()), tag: TabSelection.search, selection: $navigation.tabSelection) {
Label("Search", systemImage: "magnifyingglass")
.accessibility(label: Text("Search"))
}
.id("search")
.keyboardShortcut("f")
}
}
func scrollScrollViewToItem(scrollView: ScrollViewProxy, for selection: TabSelection) {
private func scrollScrollViewToItem(scrollView: ScrollViewProxy, for selection: TabSelection) {
if case .recentlyOpened = selection {
scrollView.scrollTo("recentlyOpened")
return
} else if case let .playlist(id) = selection {
scrollView.scrollTo(id)
return
}
scrollView.scrollTo(selection.stringValue)
}
}

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

@@ -0,0 +1,274 @@
import SDWebImageSwiftUI
import SwiftUI
struct CommentView: View {
let comment: Comment
@Binding var repliesID: Comment.ID?
@State private var subscribed = false
#if os(iOS)
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
#endif
@Environment(\.colorScheme) private var colorScheme
@Environment(\.navigationStyle) private var navigationStyle
@EnvironmentObject<CommentsModel> private var comments
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<RecentsModel> private var recents
@EnvironmentObject<SubscriptionsModel> private var subscriptions
var body: some View {
VStack(alignment: .leading) {
HStack(alignment: .center, spacing: 10) {
HStack(spacing: 10) {
ZStack(alignment: .bottomTrailing) {
authorAvatar
if subscribed {
Image(systemName: "star.circle.fill")
#if os(tvOS)
.background(Color.background(scheme: colorScheme))
#else
.background(Color.background)
#endif
.clipShape(Circle())
.foregroundColor(.secondary)
}
}
.onAppear {
subscribed = subscriptions.isSubscribing(comment.channel.id)
}
authorAndTime
}
.contextMenu {
Button(action: openChannelAction) {
Label("\(comment.channel.name) Channel", systemImage: "rectangle.stack.fill.badge.person.crop")
}
}
Spacer()
Group {
#if os(iOS)
if horizontalSizeClass == .regular {
Group {
statusIcons
likes
}
} else {
VStack(alignment: .trailing, spacing: 8) {
likes
statusIcons
}
}
#else
statusIcons
likes
#endif
}
}
#if os(tvOS)
.font(.system(size: 25).bold())
#else
.font(.system(size: 15))
#endif
Group {
commentText
if comment.hasReplies {
HStack(spacing: repliesButtonStackSpacing) {
repliesButton
ProgressView()
.scaleEffect(progressViewScale, anchor: .center)
.opacity(repliesID == comment.id && !comments.repliesLoaded ? 1 : 0)
.frame(maxHeight: 0)
}
if comment.id == repliesID {
repliesList
}
}
}
}
#if os(tvOS)
.padding(.horizontal, 20)
#endif
}
private var authorAvatar: some View {
WebImage(url: URL(string: comment.authorAvatarURL)!)
.resizable()
.placeholder {
Rectangle().fill(Color("PlaceholderColor"))
}
.retryOnAppear(true)
.indicator(.activity)
.mask(RoundedRectangle(cornerRadius: 60))
#if os(tvOS)
.frame(width: 80, height: 80, alignment: .leading)
.focusable()
#else
.frame(width: 45, height: 45, alignment: .leading)
#endif
}
private var authorAndTime: some View {
VStack(alignment: .leading) {
Text(comment.author)
#if os(tvOS)
.font(.system(size: 30).bold())
#else
.font(.system(size: 14).bold())
#endif
Text(comment.time)
.font(.caption2)
.foregroundColor(.secondary)
}
.lineLimit(1)
}
private var statusIcons: some View {
HStack(spacing: 15) {
if comment.pinned {
Image(systemName: "pin.fill")
}
if comment.hearted {
Image(systemName: "heart.fill")
}
}
#if !os(tvOS)
.font(.system(size: 12))
#endif
.foregroundColor(.secondary)
}
private var likes: some View {
Group {
if comment.likeCount > 0 {
HStack(spacing: 5) {
Image(systemName: "hand.thumbsup")
Text("\(comment.likeCount.formattedAsAbbreviation())")
}
#if !os(tvOS)
.font(.system(size: 12))
#endif
}
}
.foregroundColor(.secondary)
}
private var repliesButton: some View {
Button {
repliesID = repliesID == comment.id ? nil : comment.id
guard !repliesID.isNil, !comment.repliesPage.isNil else {
return
}
comments.loadReplies(page: comment.repliesPage!)
} label: {
HStack(spacing: 5) {
Image(systemName: repliesID == comment.id ? "arrow.turn.left.up" : "arrow.turn.right.down")
Text("Replies")
}
#if os(tvOS)
.padding(10)
#endif
}
.buttonStyle(.plain)
.padding(.vertical, 2)
#if os(tvOS)
.padding(.leading, 5)
#else
.font(.system(size: 13))
.foregroundColor(.secondary)
#endif
}
private var repliesButtonStackSpacing: Double {
#if os(tvOS)
24
#elseif os(iOS)
4
#else
2
#endif
}
private var progressViewScale: Double {
#if os(macOS)
0.4
#else
0.6
#endif
}
private var repliesList: some View {
Group {
let last = comments.replies.last
ForEach(comments.replies) { comment in
CommentView(comment: comment, repliesID: $repliesID)
#if os(tvOS)
.focusable()
#endif
if comment != last {
Divider()
.padding(.vertical, 5)
}
}
}
.padding(.leading, 22)
}
private var commentText: some View {
Group {
let text = Text(comment.text)
#if os(macOS)
.font(.system(size: 14))
#elseif os(iOS)
.font(.system(size: 15))
#endif
.lineSpacing(3)
.fixedSize(horizontal: false, vertical: true)
if #available(iOS 15.0, macOS 12.0, *) {
text
#if !os(tvOS)
.textSelection(.enabled)
#endif
} else {
text
}
}
}
private func openChannelAction() {
NavigationModel.openChannel(
comment.channel,
player: player,
recents: recents,
navigation: navigation,
navigationStyle: navigationStyle
)
}
}
struct CommentView_Previews: PreviewProvider {
static var fixture: Comment {
Comment.fixture
}
static var previews: some View {
CommentView(comment: fixture, repliesID: .constant(fixture.id))
.environmentObject(SubscriptionsModel())
.padding(5)
}
}

View File

@@ -0,0 +1,61 @@
import SwiftUI
struct CommentsView: View {
var embedInScrollView = false
@State private var repliesID: Comment.ID?
@EnvironmentObject<CommentsModel> private var comments
var body: some View {
Group {
if comments.disabled {
NoCommentsView(text: "Comments are disabled", systemImage: "xmark.circle.fill")
} else if comments.loaded && comments.all.isEmpty {
NoCommentsView(text: "No comments", systemImage: "0.circle.fill")
} else if !comments.loaded {
PlaceholderProgressView()
.onAppear {
comments.load()
}
} else {
let last = comments.all.last
let commentsStack = LazyVStack {
ForEach(comments.all) { comment in
CommentView(comment: comment, repliesID: $repliesID)
.onAppear {
comments.loadNextPageIfNeeded(current: comment)
}
.padding(.bottom, comment == last ? 5 : 0)
if comment != last {
Divider()
.padding(.vertical, 5)
}
}
}
if embedInScrollView {
ScrollView(.vertical, showsIndicators: false) {
commentsStack
}
} else {
commentsStack
}
}
}
.padding(.horizontal)
}
}
struct CommentsView_Previews: PreviewProvider {
static var previews: some View {
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
CommentsView()
.previewInterfaceOrientation(.landscapeRight)
.injectFixtureEnvironmentObjects()
}
CommentsView()
.injectFixtureEnvironmentObjects()
}
}

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

@@ -3,25 +3,28 @@ import Foundation
import SwiftUI
struct PlaybackBar: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
@Environment(\.presentationMode) private var presentationMode
@Environment(\.inNavigationView) private var inNavigationView
@EnvironmentObject<PlayerModel> private var player
var body: some View {
HStack {
closeButton
#if !os(macOS)
closeButton
#endif
if player.currentItem != nil {
HStack {
Text(playbackStatus)
Text("")
rateMenu
}
.font(.caption2)
.foregroundColor(.gray)
#if os(macOS)
.padding(.leading, 4)
#endif
Spacer()
@@ -58,24 +61,26 @@ struct PlaybackBar: View {
#endif
}
.transaction { t in t.animation = .none }
.foregroundColor(.gray)
.font(.caption2)
} else {
Spacer()
}
}
.alert(player.playerError?.localizedDescription ?? "", isPresented: $player.presentingErrorDetails) {
Button("OK") {}
.foregroundColor(colorScheme == .dark ? .gray : .black)
.alert(isPresented: $player.presentingErrorDetails) {
Alert(
title: Text("Error"),
message: Text(player.playerError?.localizedDescription ?? "")
)
}
.environment(\.colorScheme, .dark)
.frame(minWidth: 0, maxWidth: .infinity)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 20)
.padding(4)
.background(.black)
.background(colorScheme == .dark ? Color.black : Color.white)
}
private var closeButton: some View {
Button {
dismiss()
player.hide()
} label: {
Label(
"Close",
@@ -94,21 +99,35 @@ struct PlaybackBar: View {
return "LIVE"
}
guard player.time != nil, player.time!.isValid, !player.currentVideo.isNil else {
guard !player.isLoadingVideo else {
return "loading..."
}
let videoLengthAtRate = player.currentVideo!.length / Double(player.currentRate)
let remainingSeconds = videoLengthAtRate - player.time!.seconds
guard let video = player.currentVideo,
let time = player.time
else {
return ""
}
let videoLengthAtRate = video.length / Double(player.currentRate)
let remainingSeconds = videoLengthAtRate - time.seconds
if remainingSeconds < 60 {
return "less than a minute"
}
let timeFinishAt = Date.now.addingTimeInterval(remainingSeconds)
let timeFinishAtString = timeFinishAt.formatted(date: .omitted, time: .shortened)
let timeFinishAt = Date().addingTimeInterval(remainingSeconds)
return "ends at \(timeFinishAtString)"
return "ends at \(formattedTimeFinishAt(timeFinishAt))"
}
private func formattedTimeFinishAt(_ date: Date) -> String {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .none
dateFormatter.timeStyle = .short
return dateFormatter.string(from: date)
}
private var rateMenu: some View {

View File

@@ -2,8 +2,10 @@ import Defaults
import SwiftUI
struct Player: UIViewControllerRepresentable {
@EnvironmentObject<CommentsModel> private var comments
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<SubscriptionsModel> private var subscriptions
var controller: PlayerViewController?
@@ -18,8 +20,10 @@ struct Player: UIViewControllerRepresentable {
let controller = PlayerViewController()
controller.commentsModel = comments
controller.navigationModel = navigation
controller.playerModel = player
controller.subscriptionsModel = subscriptions
player.controller = controller
return controller

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