mirror of
https://github.com/yattee/yattee.git
synced 2025-12-12 19:18:16 +00:00
Compare commits
224 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
792a2c1c6c | ||
|
|
12ef9e091c | ||
|
|
101ecb6892 | ||
|
|
be2e4acedd | ||
|
|
c6cff4dee4 | ||
|
|
eaeaa45422 | ||
|
|
6ddf1113bf | ||
|
|
15f3e11a78 | ||
|
|
713570dfd6 | ||
|
|
50eb0be1d7 | ||
|
|
3adbea1897 | ||
|
|
80a644eb7a | ||
|
|
b1637c5ef1 | ||
|
|
166482601d | ||
|
|
1d61dec8eb | ||
|
|
ca7195caba | ||
|
|
947f216fac | ||
|
|
a054d343a9 | ||
|
|
48263ae7db | ||
|
|
562df2d9ba | ||
|
|
e5f137a2d2 | ||
|
|
6856506834 | ||
|
|
a604382a3d | ||
|
|
0bbbf0907d | ||
|
|
921987be5d | ||
|
|
b47156ba5e | ||
|
|
5ab06d0e09 | ||
|
|
af632d7943 | ||
|
|
56993de1c2 | ||
|
|
ca61f0d8e5 | ||
|
|
6e89538623 | ||
|
|
d167ff575d | ||
|
|
c7d6253739 | ||
|
|
24241d3485 | ||
|
|
5cfcffc885 | ||
|
|
9d2e6f117d | ||
|
|
7c24a86a6a | ||
|
|
4d70c8a3c3 | ||
|
|
59f48c739a | ||
|
|
ae144ea82f | ||
|
|
2583be9401 | ||
|
|
0061bd8c20 | ||
|
|
12afb31c03 | ||
|
|
35867ba14a | ||
|
|
50e1491990 | ||
|
|
1e23809359 | ||
|
|
eed9330c0c | ||
|
|
1e2d6cf72f | ||
|
|
ac7dad2ab8 | ||
|
|
e1f03bc025 | ||
|
|
0dee8310ce | ||
|
|
b16eae3d88 | ||
|
|
02a29e5d07 | ||
|
|
22bbf731e9 | ||
|
|
c0053cf837 | ||
|
|
16fb7087e3 | ||
|
|
8f48da93d8 | ||
|
|
f3eac03b58 | ||
|
|
86b8c7384c | ||
|
|
1502d02184 | ||
|
|
a0dc280f22 | ||
|
|
91ec2f39b0 | ||
|
|
1d85f92087 | ||
|
|
ce2f9cee99 | ||
|
|
63fe52695c | ||
|
|
9524991c5f | ||
|
|
78f2c681a7 | ||
|
|
8f8abe7bb1 | ||
|
|
0a6beabae8 | ||
|
|
873bbf90bb | ||
|
|
450a4b42f7 | ||
|
|
db4b3115b1 | ||
|
|
fba465a22a | ||
|
|
d996069a20 | ||
|
|
d7a2564617 | ||
|
|
d5f8e35430 | ||
|
|
8f9de6d1be | ||
|
|
0fc6f7fdb7 | ||
|
|
d5f88a73f8 | ||
|
|
97af5a6e0c | ||
|
|
4c5ef920b4 | ||
|
|
a55683e6bf | ||
|
|
739ca007e8 | ||
|
|
7d7bd40a89 | ||
|
|
c6798be167 | ||
|
|
2b7ccc4b03 | ||
|
|
08ce572b9e | ||
|
|
1cc66fdc10 | ||
|
|
34a05433d5 | ||
|
|
5383cf0e90 | ||
|
|
a4fdd50388 | ||
|
|
f67b1d4feb | ||
|
|
b53b5eac56 | ||
|
|
6c5b8ef3ec | ||
|
|
226da4d2be | ||
|
|
49ffffae53 | ||
|
|
848a43ce7f | ||
|
|
6ca0e82feb | ||
|
|
f7f53c6417 | ||
|
|
55a0b2dee6 | ||
|
|
9f0700d2bf | ||
|
|
ab0c2e7b84 | ||
|
|
d603ef7431 | ||
|
|
281e0510cd | ||
|
|
837c9a3f75 | ||
|
|
bcec9d09ab | ||
|
|
82a09d1584 | ||
|
|
dae667fa8a | ||
|
|
0f802684f2 | ||
|
|
59b49c2e2f | ||
|
|
3a8d6aed76 | ||
|
|
2952e10359 | ||
|
|
59f84ec129 | ||
|
|
a76dae6656 | ||
|
|
a208ef9147 | ||
|
|
7a998d2d69 | ||
|
|
f3659904dc | ||
|
|
72246448f1 | ||
|
|
6617ad5fc6 | ||
|
|
65b3eb60d9 | ||
|
|
0174d2f8a0 | ||
|
|
8f340586a6 | ||
|
|
23e07baa7a | ||
|
|
e0e0352238 | ||
|
|
543a7c0da6 | ||
|
|
c78a0dc8c3 | ||
|
|
0051b3ab74 | ||
|
|
1f0a2d25e9 | ||
|
|
5d8e8483d1 | ||
|
|
7972498f2c | ||
|
|
703ac90a33 | ||
|
|
265c4cd95c | ||
|
|
b7929465a7 | ||
|
|
669f7d5aa6 | ||
|
|
3136d6328d | ||
|
|
32b19d8cd5 | ||
|
|
2efb3ec334 | ||
|
|
16c580b1f3 | ||
|
|
5cef7d40ff | ||
|
|
7b9aa8ce99 | ||
|
|
a83657b8c6 | ||
|
|
287bd25360 | ||
|
|
7c45f3286b | ||
|
|
a9e3e81567 | ||
|
|
f45001da78 | ||
|
|
3779b7ed1f | ||
|
|
55517fd44d | ||
|
|
77c40226eb | ||
|
|
5424d5168a | ||
|
|
2c7fce011f | ||
|
|
6911fb8e08 | ||
|
|
a73a030d92 | ||
|
|
930cefc29e | ||
|
|
02d9b34fb0 | ||
|
|
0a8d9dfceb | ||
|
|
84db321b70 | ||
|
|
10c1fbd503 | ||
|
|
c7b64c973d | ||
|
|
40097de1fd | ||
|
|
8eac32078b | ||
|
|
8271feb77a | ||
|
|
77fde219e0 | ||
|
|
383bb32215 | ||
|
|
6a4f031cca | ||
|
|
1e65f6d807 | ||
|
|
b4d5322ac6 | ||
|
|
a5bfabed0c | ||
|
|
f6569db418 | ||
|
|
9cc9c74f97 | ||
|
|
5dbc211c95 | ||
|
|
03b27280f4 | ||
|
|
913ac37991 | ||
|
|
7cfbe5ae5a | ||
|
|
bf80c4024c | ||
|
|
e70808c463 | ||
|
|
ce68c0f5b4 | ||
|
|
8e829ed3b1 | ||
|
|
5c0cf7452c | ||
|
|
2d02d9b472 | ||
|
|
72a98314c1 | ||
|
|
ea997ffdb9 | ||
|
|
83dfdd6c0e | ||
|
|
6596a440a5 | ||
|
|
d52ccf2ce6 | ||
|
|
b19918e219 | ||
|
|
2fe211edb4 | ||
|
|
f852782f5e | ||
|
|
c8feeca41f | ||
|
|
9a594b4a8d | ||
|
|
c48301c788 | ||
|
|
9936d9dd9e | ||
|
|
8f9fb7ba82 | ||
|
|
a7763c5802 | ||
|
|
160ea86298 | ||
|
|
a9e9fa3a6d | ||
|
|
afa0049333 | ||
|
|
28f346dee2 | ||
|
|
67690bc435 | ||
|
|
5db74a3997 | ||
|
|
8ef016d792 | ||
|
|
b59baa6fab | ||
|
|
3657d732d9 | ||
|
|
309e4a3281 | ||
|
|
f6e5486412 | ||
|
|
d58a68cd66 | ||
|
|
faa7d82b8f | ||
|
|
3dd9ff837e | ||
|
|
73a62ea76e | ||
|
|
7ce37fd5dd | ||
|
|
25ca69f17d | ||
|
|
578c5a8a61 | ||
|
|
c3e81d1b67 | ||
|
|
a65db8555b | ||
|
|
afc1a25c26 | ||
|
|
4851db4879 | ||
|
|
912e1d1a23 | ||
|
|
acc880fd47 | ||
|
|
30e587ea97 | ||
|
|
d723b2d6f1 | ||
|
|
f7c0f8dd34 | ||
|
|
5f53515bdd | ||
|
|
ad4920e0f2 | ||
|
|
e467b21c9d | ||
|
|
f94a00a7bb |
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -23,7 +23,9 @@ jobs:
|
||||
testflight:
|
||||
strategy:
|
||||
matrix:
|
||||
lane: ['mac beta', 'ios beta', 'tvos beta']
|
||||
# disabled mac beta lane
|
||||
# lane: ['mac beta', 'ios beta', 'tvos beta']
|
||||
lane: ['ios beta', 'tvos beta']
|
||||
name: Releasing ${{ matrix.lane }} version to TestFlight
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
|
||||
21
Backports/ToolbarBackground+Backport.swift
Normal file
21
Backports/ToolbarBackground+Backport.swift
Normal file
@@ -0,0 +1,21 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func toolbarBackground(_ color: Color) -> some View {
|
||||
if #available(iOS 16, *) {
|
||||
content
|
||||
.toolbarBackground(color, for: .navigationBar)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func toolbarBackgroundVisibility(_ visible: Bool) -> some View {
|
||||
if #available(iOS 16, *) {
|
||||
content
|
||||
.toolbarBackground(visible ? .visible : .hidden, for: .navigationBar)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
12
Backports/ToolbarColorScheme+Backport.swift
Normal file
12
Backports/ToolbarColorScheme+Backport.swift
Normal file
@@ -0,0 +1,12 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func toolbarColorScheme(_ colorScheme: ColorScheme) -> some View {
|
||||
if #available(iOS 16, *) {
|
||||
content
|
||||
.toolbarColorScheme(colorScheme, for: .navigationBar)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
61
CHANGELOG.md
61
CHANGELOG.md
@@ -1,11 +1,52 @@
|
||||
## Build 138
|
||||
* Added pagination/infinite scroll for channel contents (Invidious and Piped)
|
||||
* Added support for channel tabs for Invidious (previously available only for Piped)
|
||||
* New browsing setting: "Show unwatched feed badges"
|
||||
* Other minor changes and improvements
|
||||
|
||||
### Previous Builds
|
||||
* Added filter to hide Short videos, available via view menu/toolbar button
|
||||
* Added localizations: Arabic, Portugese, Portuguese (Brazil)
|
||||
## Build 152
|
||||
* Tapping second time on search tab button focuses the input field and selects entered query text (iOS)
|
||||
* Added Browsing setting "Keep channels with unwatched videos on top of subscriptions list"
|
||||
* Improved buttons and layout on tvOS
|
||||
* Fixed issue with trending categories (Invidious) not working when using non-English language
|
||||
* Fixed issue with search query suggestions not being displayed properly in some languages
|
||||
* Changed subscriptions page picker label from icon to text
|
||||
* Views will display information if there is no videos to show instead of always showing placeholders
|
||||
* Fixed AVPlayer issue with music mode playing video track
|
||||
* Added remove context menu option for all types of recent items in Search
|
||||
* Added advanced setting "Show video context menu options to force selected backend"
|
||||
* Fixed reported crashes
|
||||
* Other minor changes and improvements
|
||||
* Other minor fixes and improvements
|
||||
|
||||
## Previous Builds
|
||||
* Improved Home
|
||||
- Added menu with view options on iOS and toolbar buttons on macOS/tvOS
|
||||
- Added Home Settings
|
||||
- Moved settings from Browsing to Home Settings
|
||||
- Enhanced Favorites management: select listing type and videos limit for each element
|
||||
- Select listing type for History just like for Favorites
|
||||
* Added view option to hide watched videos
|
||||
* Added Browsing setting "Startup section"
|
||||
* Added feed/channels list segmented picker in Subscriptions and moved view options menu on iOS
|
||||
* Thumbnails in list view respect "Round corners" setting
|
||||
* Added watching progress indicator to list view
|
||||
* Moved "Show toggle watch status button" to History settings
|
||||
* Removed "Rotate to portrait when exiting fullscreen" setting - it is instead automatically decided depending on device type
|
||||
* Fixed channels view layout on tvOS
|
||||
* Fixed channels and playlists navigation on tvOS
|
||||
* Fixed issue where controls were not visible when music mode was enabled
|
||||
* Fixed issue with closing Picture in Picture on macOS
|
||||
* Fixed issue where playing video with AVPlayer would cause it to be immediately marked as watched
|
||||
* Fixed issue with playlists view showing duplicated buttons when "Show cache status" is enabled
|
||||
* Fixed issue where navigating to channel from list view in Playlists and Search would immediately go back
|
||||
* Fixed issue where first URL would fail to open
|
||||
|
||||
* Added support for AVPlayer native system controls on iOS and macOS
|
||||
- Use system features such as AirPlay, subtitles switching (Piped with HLS), text detection and copy and more
|
||||
- Added Controls setting: "Use system controls with AVPlayer"
|
||||
* Player rotates for landscape videos on entering full screen on iOS
|
||||
- Player > Orientation setting: "Rotate when entering fullscreen on landscape video"
|
||||
* Added Player > Playback setting: "Close video and player on end"
|
||||
* Added reporting for opening stream in OSD for AVPlayer
|
||||
* Fixed issue with opening channels and playlists links
|
||||
* Fixed issues where controls/player layout could break (e.g., when going to background and back)
|
||||
* Fixed issue where stream picker would show duplicate entries
|
||||
* Fixed issue where search suggestions would show unnecessary bottom padding
|
||||
* Fixed landscape channel sheet layout in player
|
||||
* Fixed reported crashes
|
||||
* Localization updates and fixes
|
||||
* Other minor fixes and improvements
|
||||
|
||||
11
Extensions/AVPlayerViewController+FullScreen.swift
Normal file
11
Extensions/AVPlayerViewController+FullScreen.swift
Normal file
@@ -0,0 +1,11 @@
|
||||
import AVKit
|
||||
|
||||
extension AVPlayerViewController {
|
||||
func enterFullScreen(animated: Bool) {
|
||||
perform(NSSelectorFromString("enterFullScreenAnimated:completionHandler:"), with: animated, with: nil)
|
||||
}
|
||||
|
||||
func exitFullScreen(animated: Bool) {
|
||||
perform(NSSelectorFromString("exitFullScreenAnimated:completionHandler:"), with: animated, with: nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import CoreData
|
||||
|
||||
extension NSManagedObjectContext {
|
||||
/// Executes the given `NSBatchDeleteRequest` and directly merges the changes to bring the given managed object context up to date.
|
||||
///
|
||||
/// - Parameter batchDeleteRequest: The `NSBatchDeleteRequest` to execute.
|
||||
/// - Throws: An error if anything went wrong executing the batch deletion.
|
||||
func executeAndMergeChanges(_ batchDeleteRequest: NSBatchDeleteRequest) throws {
|
||||
batchDeleteRequest.resultType = .resultTypeObjectIDs
|
||||
let result = try execute(batchDeleteRequest) as? NSBatchDeleteResult
|
||||
let changes: [AnyHashable: Any] = [NSDeletedObjectsKey: result?.result as? [NSManagedObjectID] ?? []]
|
||||
NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [self])
|
||||
}
|
||||
}
|
||||
14
Extensions/String+ReplacingHTMLEntities.swift
Normal file
14
Extensions/String+ReplacingHTMLEntities.swift
Normal file
@@ -0,0 +1,14 @@
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
var replacingHTMLEntities: String {
|
||||
do {
|
||||
return try NSAttributedString(data: Data(utf8), options: [
|
||||
.documentType: NSAttributedString.DocumentType.html,
|
||||
.characterEncoding: String.Encoding.utf8.rawValue
|
||||
], documentAttributes: nil).string
|
||||
} catch {
|
||||
return self
|
||||
}
|
||||
}
|
||||
}
|
||||
31
Gemfile.lock
31
Gemfile.lock
@@ -3,21 +3,21 @@ GEM
|
||||
specs:
|
||||
CFPropertyList (3.0.6)
|
||||
rexml
|
||||
addressable (2.8.1)
|
||||
addressable (2.8.4)
|
||||
public_suffix (>= 2.0.2, < 6.0)
|
||||
artifactory (3.0.15)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.2.0)
|
||||
aws-partitions (1.716.0)
|
||||
aws-sdk-core (3.170.0)
|
||||
aws-partitions (1.769.0)
|
||||
aws-sdk-core (3.173.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
aws-partitions (~> 1, >= 1.651.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.62.0)
|
||||
aws-sdk-kms (1.64.0)
|
||||
aws-sdk-core (~> 3, >= 3.165.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sdk-s3 (1.119.1)
|
||||
aws-sdk-s3 (1.122.0)
|
||||
aws-sdk-core (~> 3, >= 3.165.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.4)
|
||||
@@ -65,8 +65,8 @@ GEM
|
||||
faraday-retry (1.0.3)
|
||||
faraday_middleware (1.2.0)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.2.6)
|
||||
fastlane (2.212.1)
|
||||
fastimage (2.2.7)
|
||||
fastlane (2.213.0)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
@@ -90,7 +90,7 @@ GEM
|
||||
json (< 3.0.0)
|
||||
jwt (>= 2.1.0, < 3)
|
||||
mini_magick (>= 4.9.4, < 5.0.0)
|
||||
multipart-post (~> 2.0.0)
|
||||
multipart-post (>= 2.0.0, < 3.0.0)
|
||||
naturally (~> 2.2)
|
||||
optparse (~> 0.1.1)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
@@ -106,8 +106,8 @@ GEM
|
||||
xcpretty (~> 0.3.0)
|
||||
xcpretty-travis-formatter (>= 0.0.3)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.34.0)
|
||||
google-apis-core (>= 0.9.1, < 2.a)
|
||||
google-apis-androidpublisher_v3 (0.42.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-core (0.11.0)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
@@ -119,8 +119,8 @@ GEM
|
||||
webrick
|
||||
google-apis-iamcredentials_v1 (0.17.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.12.0)
|
||||
google-apis-core (>= 0.9.1, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.13.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-storage_v1 (0.19.0)
|
||||
google-apis-core (>= 0.9.0, < 2.a)
|
||||
google-cloud-core (1.6.0)
|
||||
@@ -128,7 +128,7 @@ GEM
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (1.6.0)
|
||||
faraday (>= 0.17.3, < 3.0)
|
||||
google-cloud-errors (1.3.0)
|
||||
google-cloud-errors (1.3.1)
|
||||
google-cloud-storage (1.44.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
@@ -137,7 +137,7 @@ GEM
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
mini_mime (~> 1.0)
|
||||
googleauth (1.3.0)
|
||||
googleauth (1.5.2)
|
||||
faraday (>= 0.17.3, < 3.a)
|
||||
jwt (>= 1.4, < 3.0)
|
||||
memoist (~> 0.16)
|
||||
@@ -155,7 +155,7 @@ GEM
|
||||
mini_magick (4.12.0)
|
||||
mini_mime (1.1.2)
|
||||
multi_json (1.15.0)
|
||||
multipart-post (2.0.0)
|
||||
multipart-post (2.3.0)
|
||||
nanaimo (0.3.0)
|
||||
naturally (2.2.1)
|
||||
optparse (0.1.1)
|
||||
@@ -211,6 +211,7 @@ GEM
|
||||
PLATFORMS
|
||||
arm64-darwin-21
|
||||
x86_64-darwin-19
|
||||
x86_64-darwin-20
|
||||
x86_64-linux
|
||||
|
||||
DEPENDENCIES
|
||||
|
||||
@@ -60,29 +60,13 @@ struct Account: Defaults.Serializable, Hashable, Identifiable {
|
||||
instanceID.isNil
|
||||
}
|
||||
|
||||
var shortUsername: String {
|
||||
let (username, _) = credentials
|
||||
|
||||
guard let username,
|
||||
username.count > 10
|
||||
else {
|
||||
return username ?? ""
|
||||
}
|
||||
|
||||
let index = username.index(username.startIndex, offsetBy: 11)
|
||||
return String(username[..<index])
|
||||
}
|
||||
|
||||
var description: String {
|
||||
guard !isPublic else {
|
||||
return name
|
||||
}
|
||||
|
||||
guard !name.isEmpty else {
|
||||
return shortUsername
|
||||
}
|
||||
|
||||
return name
|
||||
let (username, _) = credentials
|
||||
return username ?? name
|
||||
}
|
||||
|
||||
var urlHost: String {
|
||||
|
||||
@@ -13,7 +13,7 @@ struct AccountsBridge: Defaults.Bridge {
|
||||
return [
|
||||
"id": value.id,
|
||||
"instanceID": value.instanceID ?? "",
|
||||
"name": value.name ?? "",
|
||||
"name": value.name,
|
||||
"apiURL": value.urlString,
|
||||
"username": value.username,
|
||||
"password": value.password ?? ""
|
||||
|
||||
@@ -24,7 +24,7 @@ final class AccountsModel: ObservableObject {
|
||||
return nil
|
||||
}
|
||||
|
||||
return AccountsModel.find(id)
|
||||
return Self.find(id)
|
||||
}
|
||||
|
||||
var any: Account? {
|
||||
@@ -140,15 +140,4 @@ final class AccountsModel: ObservableObject {
|
||||
KeychainModel.shared.getAccountKey(account, "password")
|
||||
)
|
||||
}
|
||||
|
||||
static func removeDefaultsCredentials(_ account: Account) {
|
||||
if let accountIndex = Defaults[.accounts].firstIndex(where: { $0.id == account.id }) {
|
||||
var account = Defaults[.accounts][accountIndex]
|
||||
account.name = ""
|
||||
account.username = ""
|
||||
account.password = nil
|
||||
|
||||
Defaults[.accounts][accountIndex] = account
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ final class InstancesModel: ObservableObject {
|
||||
return nil
|
||||
}
|
||||
|
||||
return InstancesModel.shared.find(id)
|
||||
return Self.shared.find(id)
|
||||
}
|
||||
|
||||
var lastUsed: Instance? {
|
||||
@@ -21,7 +21,7 @@ final class InstancesModel: ObservableObject {
|
||||
return nil
|
||||
}
|
||||
|
||||
return InstancesModel.shared.find(id)
|
||||
return Self.shared.find(id)
|
||||
}
|
||||
|
||||
func find(_ id: Instance.ID?) -> Instance? {
|
||||
|
||||
@@ -79,7 +79,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
configureTransformer(pathPattern("search/suggestions"), requestMethods: [.get]) { (content: Entity<JSON>) -> [String] in
|
||||
if let suggestions = content.json.dictionaryValue["suggestions"] {
|
||||
return suggestions.arrayValue.map(String.init)
|
||||
return suggestions.arrayValue.map { $0.stringValue.replacingHTMLEntities }
|
||||
}
|
||||
|
||||
return []
|
||||
@@ -236,7 +236,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
func trending(country: Country, category: TrendingCategory?) -> Resource {
|
||||
resource(baseURL: account.url, path: "\(Self.basePath)/trending")
|
||||
.withParam("type", category?.name)
|
||||
.withParam("type", category?.type)
|
||||
.withParam("region", country.rawValue)
|
||||
}
|
||||
|
||||
@@ -579,8 +579,6 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
let nextPage = json.dictionaryValue["continuation"]?.string
|
||||
var contentItems = [ContentItem]()
|
||||
|
||||
var items = [ContentItem]()
|
||||
|
||||
if let key = Self.contentItemsKeys.first(where: { json.dictionaryValue.keys.contains($0) }),
|
||||
let items = json.dictionaryValue[key]
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@ import Logging
|
||||
import SwiftyJSON
|
||||
|
||||
struct BaseCacheModel {
|
||||
static var shared = BaseCacheModel()
|
||||
static var shared = Self()
|
||||
|
||||
static let jsonToDataTransformer: (JSON) -> Data = { try! $0.rawData() }
|
||||
static let jsonFromDataTransformer: (Data) -> JSON = { try! JSON(data: $0) }
|
||||
|
||||
@@ -4,7 +4,7 @@ import Logging
|
||||
import SwiftyJSON
|
||||
|
||||
struct BookmarksCacheModel {
|
||||
static var shared = BookmarksCacheModel()
|
||||
static var shared = Self()
|
||||
let logger = Logger(label: "stream.yattee.cache")
|
||||
|
||||
static let bookmarksGroup = "group.stream.yattee.app.bookmarks"
|
||||
|
||||
@@ -4,7 +4,7 @@ import Logging
|
||||
import SwiftyJSON
|
||||
|
||||
struct ChannelPlaylistsCacheModel: CacheModel {
|
||||
static let shared = ChannelPlaylistsCacheModel()
|
||||
static let shared = Self()
|
||||
let logger = Logger(label: "stream.yattee.cache.channel-playlists")
|
||||
|
||||
static let diskConfig = DiskConfig(name: "channel-playlists")
|
||||
|
||||
@@ -4,7 +4,7 @@ import Logging
|
||||
import SwiftyJSON
|
||||
|
||||
struct ChannelsCacheModel: CacheModel {
|
||||
static let shared = ChannelsCacheModel()
|
||||
static let shared = Self()
|
||||
let logger = Logger(label: "stream.yattee.cache.channels")
|
||||
|
||||
static let diskConfig = DiskConfig(name: "channels")
|
||||
|
||||
@@ -5,7 +5,7 @@ import Logging
|
||||
import SwiftyJSON
|
||||
|
||||
struct FeedCacheModel: CacheModel {
|
||||
static let shared = FeedCacheModel()
|
||||
static let shared = Self()
|
||||
let logger = Logger(label: "stream.yattee.cache.feed")
|
||||
|
||||
static let diskConfig = DiskConfig(name: "feed")
|
||||
|
||||
@@ -4,7 +4,7 @@ import Logging
|
||||
import SwiftyJSON
|
||||
|
||||
struct PlaylistsCacheModel: CacheModel {
|
||||
static let shared = PlaylistsCacheModel()
|
||||
static let shared = Self()
|
||||
static let limit = 30
|
||||
let logger = Logger(label: "stream.yattee.cache.playlists")
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ final class SubscribedChannelsModel: ObservableObject, CacheModel {
|
||||
@Published var error: RequestError?
|
||||
|
||||
var accounts: AccountsModel { .shared }
|
||||
var unwatchedFeedCount: UnwatchedFeedCountModel { .shared }
|
||||
|
||||
var resource: Resource? {
|
||||
accounts.api.subscriptions
|
||||
@@ -32,6 +33,19 @@ final class SubscribedChannelsModel: ObservableObject, CacheModel {
|
||||
channels.sorted { $0.name.lowercased() < $1.name.lowercased() }
|
||||
}
|
||||
|
||||
var allByUnwatchedCount: [Channel] {
|
||||
if let account = accounts.current {
|
||||
return all.sorted { c1, c2 in
|
||||
let c1HasUnwatched = (unwatchedFeedCount.unwatchedByChannel[account]?[c1.id] ?? -1) > 0
|
||||
let c2HasUnwatched = (unwatchedFeedCount.unwatchedByChannel[account]?[c2.id] ?? -1) > 0
|
||||
let nameIncreasing = c1.name.lowercased() < c2.name.lowercased()
|
||||
|
||||
return c1HasUnwatched ? (c2HasUnwatched ? nameIncreasing : true) : (c2HasUnwatched ? false : nameIncreasing)
|
||||
}
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func subscribe(_ channelID: String, onSuccess: @escaping () -> Void = {}) {
|
||||
accounts.api.subscribe(channelID) {
|
||||
self.scheduleLoad(onSuccess: onSuccess)
|
||||
|
||||
@@ -4,7 +4,7 @@ import Logging
|
||||
import SwiftyJSON
|
||||
|
||||
struct VideosCacheModel: CacheModel {
|
||||
static let shared = VideosCacheModel()
|
||||
static let shared = Self()
|
||||
let logger = Logger(label: "stream.yattee.cache.videos")
|
||||
|
||||
static let diskConfig = DiskConfig(name: "videos")
|
||||
|
||||
@@ -33,21 +33,6 @@ struct Channel: Identifiable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
var contentItemType: ContentItem.ContentType {
|
||||
switch self {
|
||||
case .videos:
|
||||
return .video
|
||||
case .playlists:
|
||||
return .playlist
|
||||
case .livestreams:
|
||||
return .video
|
||||
case .shorts:
|
||||
return .video
|
||||
case .channels:
|
||||
return .channel
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .videos:
|
||||
@@ -91,7 +76,9 @@ struct Channel: Identifiable, Hashable {
|
||||
var subscriptionsText: String?
|
||||
|
||||
var totalViews: Int?
|
||||
var verified: Bool? // swiftlint:disable discouraged_optional_boolean
|
||||
// swiftlint:disable discouraged_optional_boolean
|
||||
var verified: Bool?
|
||||
// swiftlint:enable discouraged_optional_boolean
|
||||
|
||||
var videos = [Video]()
|
||||
var tabs = [Tab]()
|
||||
|
||||
@@ -25,7 +25,7 @@ struct ChannelPlaylist: Identifiable {
|
||||
}
|
||||
|
||||
static func from(_ json: JSON) -> Self {
|
||||
ChannelPlaylist(
|
||||
Self(
|
||||
id: json["id"].stringValue,
|
||||
title: json["title"].stringValue,
|
||||
thumbnailURL: json["thumbnailURL"].url,
|
||||
|
||||
@@ -42,7 +42,9 @@ final class CommentsModel: ObservableObject {
|
||||
|
||||
firstPage = page.isNil || page!.isEmpty
|
||||
|
||||
player.playerAPI(video)?.comments(video.videoID, page: page)?
|
||||
player
|
||||
.playerAPI(video)?
|
||||
.comments(video.videoID, page: page)?
|
||||
.load()
|
||||
.onSuccess { [weak self] response in
|
||||
if let page: CommentsPage = response.typedContent() {
|
||||
|
||||
@@ -31,15 +31,15 @@ struct ContentItem: Identifiable {
|
||||
var id: String = UUID().uuidString
|
||||
|
||||
static func array(of videos: [Video]) -> [ContentItem] {
|
||||
videos.map { ContentItem(video: $0) }
|
||||
videos.map { Self(video: $0) }
|
||||
}
|
||||
|
||||
static func array(of playlists: [ChannelPlaylist]) -> [ContentItem] {
|
||||
playlists.map { ContentItem(playlist: $0) }
|
||||
playlists.map { Self(playlist: $0) }
|
||||
}
|
||||
|
||||
static func array(of channels: [Channel]) -> [ContentItem] {
|
||||
channels.map { ContentItem(channel: $0) }
|
||||
channels.map { Self(channel: $0) }
|
||||
}
|
||||
|
||||
static func < (lhs: ContentItem, rhs: ContentItem) -> Bool {
|
||||
|
||||
@@ -234,6 +234,8 @@ extension Country {
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:enable switch_case_on_newline
|
||||
|
||||
var flag: String {
|
||||
let unicodeScalars = rawValue
|
||||
.unicodeScalars
|
||||
|
||||
@@ -3,6 +3,7 @@ import Foundation
|
||||
|
||||
struct FavoriteItem: Codable, Equatable, Identifiable, Defaults.Serializable {
|
||||
enum Section: Codable, Equatable, Defaults.Serializable {
|
||||
case history
|
||||
case subscriptions
|
||||
case popular
|
||||
case trending(String, String?)
|
||||
@@ -13,6 +14,8 @@ struct FavoriteItem: Codable, Equatable, Identifiable, Defaults.Serializable {
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .history:
|
||||
return "History"
|
||||
case .subscriptions:
|
||||
return "Subscriptions"
|
||||
case .popular:
|
||||
@@ -50,4 +53,8 @@ struct FavoriteItem: Codable, Equatable, Identifiable, Defaults.Serializable {
|
||||
|
||||
var id = UUID().uuidString
|
||||
var section: Section
|
||||
|
||||
var widgetSettingsKey: String {
|
||||
"favorites-\(id)"
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,11 @@ import Defaults
|
||||
import Foundation
|
||||
|
||||
struct FavoritesModel {
|
||||
static let shared = FavoritesModel()
|
||||
static let shared = Self()
|
||||
|
||||
@Default(.showFavoritesInHome) var showFavoritesInHome
|
||||
@Default(.favorites) var all
|
||||
@Default(.widgetsSettings) var widgetsSettings
|
||||
|
||||
var isEnabled: Bool {
|
||||
showFavoritesInHome
|
||||
@@ -74,9 +75,47 @@ struct FavoritesModel {
|
||||
func addableItems() -> [FavoriteItem] {
|
||||
let allItems = [
|
||||
FavoriteItem(section: .subscriptions),
|
||||
FavoriteItem(section: .popular)
|
||||
FavoriteItem(section: .popular),
|
||||
FavoriteItem(section: .history)
|
||||
]
|
||||
|
||||
return allItems.filter { item in !all.contains { $0.section == item.section } }
|
||||
}
|
||||
|
||||
func listingStyle(_ item: FavoriteItem) -> WidgetListingStyle {
|
||||
widgetSettings(item).listingStyle
|
||||
}
|
||||
|
||||
func limit(_ item: FavoriteItem) -> Int {
|
||||
min(WidgetSettings.maxLimit(listingStyle(item)), widgetSettings(item).limit)
|
||||
}
|
||||
|
||||
func setListingStyle(_ style: WidgetListingStyle, _ item: FavoriteItem) {
|
||||
if let index = widgetsSettings.firstIndex(where: { $0.id == item.widgetSettingsKey }) {
|
||||
var settings = widgetsSettings[index]
|
||||
settings.listingStyle = style
|
||||
widgetsSettings[index] = settings
|
||||
} else {
|
||||
let settings = WidgetSettings(id: item.widgetSettingsKey, listingStyle: style)
|
||||
widgetsSettings.append(settings)
|
||||
}
|
||||
}
|
||||
|
||||
func setLimit(_ limit: Int, _ item: FavoriteItem) {
|
||||
if let index = widgetsSettings.firstIndex(where: { $0.id == item.widgetSettingsKey }) {
|
||||
var settings = widgetsSettings[index]
|
||||
let limit = min(max(1, limit), WidgetSettings.maxLimit(settings.listingStyle))
|
||||
settings.limit = limit
|
||||
widgetsSettings[index] = settings
|
||||
} else {
|
||||
var settings = WidgetSettings(id: item.widgetSettingsKey, limit: limit)
|
||||
let limit = min(max(1, limit), WidgetSettings.maxLimit(settings.listingStyle))
|
||||
settings.limit = limit
|
||||
widgetsSettings.append(settings)
|
||||
}
|
||||
}
|
||||
|
||||
func widgetSettings(_ item: FavoriteItem) -> WidgetSettings {
|
||||
widgetsSettings.first { $0.id == item.widgetSettingsKey } ?? WidgetSettings(id: item.widgetSettingsKey)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ final class FeedModel: ObservableObject, CacheModel {
|
||||
@Published var isLoading = false
|
||||
@Published var videos = [Video]()
|
||||
@Published private var page = 1
|
||||
@Published var watchedUUID = UUID()
|
||||
|
||||
private var feedCount = UnwatchedFeedCountModel.shared
|
||||
private var cacheModel = FeedCacheModel.shared
|
||||
@@ -115,7 +116,7 @@ final class FeedModel: ObservableObject, CacheModel {
|
||||
}
|
||||
|
||||
func calculateUnwatchedFeed() {
|
||||
guard let account = accounts.current, accounts.signedIn, Defaults[.showUnwatchedFeedBadges] else { return }
|
||||
guard let account = accounts.current, accounts.signedIn else { return }
|
||||
let feed = cacheModel.retrieveFeed(account: account)
|
||||
backgroundContext.perform { [weak self] in
|
||||
guard let self else { return }
|
||||
@@ -132,20 +133,15 @@ final class FeedModel: ObservableObject, CacheModel {
|
||||
|
||||
let byChannel = Dictionary(grouping: unwatched) { $0.channel.id }.mapValues(\.count)
|
||||
self.feedCount.unwatchedByChannel[account] = byChannel
|
||||
self.watchedUUID = UUID()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func markAllFeedAsWatched() {
|
||||
guard let account = accounts.current, accounts.signedIn else { return }
|
||||
|
||||
let mark = { [weak self] in
|
||||
self?.backgroundContext.perform { [weak self] in
|
||||
guard let self else { return }
|
||||
self.videos.forEach { Watch.markAsWatched(videoID: $0.videoID, account: account, duration: $0.length, context: self.backgroundContext) }
|
||||
|
||||
self.calculateUnwatchedFeed()
|
||||
}
|
||||
guard let self else { return }
|
||||
self.markVideos(self.videos, watched: true, watchedAt: Date(timeIntervalSince1970: 0))
|
||||
}
|
||||
|
||||
if videos.isEmpty {
|
||||
@@ -211,14 +207,14 @@ final class FeedModel: ObservableObject, CacheModel {
|
||||
}
|
||||
}
|
||||
|
||||
func markVideos(_ videos: [Video], watched: Bool) {
|
||||
func markVideos(_ videos: [Video], watched: Bool, watchedAt: Date? = nil) {
|
||||
guard accounts.signedIn, let account = accounts.current else { return }
|
||||
|
||||
backgroundContext.perform { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
if watched {
|
||||
videos.forEach { Watch.markAsWatched(videoID: $0.videoID, account: account, duration: $0.length, context: self.backgroundContext) }
|
||||
videos.forEach { Watch.markAsWatched(videoID: $0.videoID, account: account, duration: $0.length, watchedAt: watchedAt, context: self.backgroundContext) }
|
||||
} else {
|
||||
let watches = self.watchFetchRequestResult(videos, context: self.backgroundContext)
|
||||
watches.forEach { self.backgroundContext.delete($0) }
|
||||
@@ -227,6 +223,7 @@ final class FeedModel: ObservableObject, CacheModel {
|
||||
try? self.backgroundContext.save()
|
||||
|
||||
self.calculateUnwatchedFeed()
|
||||
WatchModel.shared.watchesChanged()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,6 +261,10 @@ final class FeedModel: ObservableObject, CacheModel {
|
||||
return (feedCount.unwatched[account] ?? 0) > 0
|
||||
}
|
||||
|
||||
var watchedId: String {
|
||||
watchedUUID.uuidString
|
||||
}
|
||||
|
||||
var feedTime: Date? {
|
||||
if let account = accounts.current {
|
||||
return cacheModel.getFeedTime(account: account)
|
||||
|
||||
@@ -10,18 +10,21 @@ extension PlayerModel {
|
||||
historyVideos.first { $0.videoID == id }
|
||||
}
|
||||
|
||||
func loadHistoryVideoDetails(_ watch: Watch) {
|
||||
func loadHistoryVideoDetails(_ watch: Watch, onCompletion: @escaping () -> Void = {}) {
|
||||
guard historyVideo(watch.videoID).isNil else {
|
||||
onCompletion()
|
||||
return
|
||||
}
|
||||
|
||||
if !Video.VideoID.isValid(watch.videoID), let url = URL(string: watch.videoID) {
|
||||
historyVideos.append(.local(url))
|
||||
onCompletion()
|
||||
return
|
||||
}
|
||||
|
||||
if let video = VideosCacheModel.shared.retrieveVideo(watch.video.cacheKey) {
|
||||
historyVideos.append(video)
|
||||
onCompletion()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -35,6 +38,7 @@ extension PlayerModel {
|
||||
if let video: Video = response.typedContent() {
|
||||
VideosCacheModel.shared.storeVideo(video)
|
||||
self.historyVideos.append(video)
|
||||
onCompletion()
|
||||
}
|
||||
}
|
||||
.onCompletion { _ in
|
||||
@@ -65,7 +69,7 @@ extension PlayerModel {
|
||||
|
||||
let watch: Watch!
|
||||
|
||||
let duration = self.playerTime.duration.seconds
|
||||
let duration = self.activeBackend == .mpv ? self.playerTime.duration.seconds : self.avPlayerBackend.playerItemDuration?.seconds ?? 0
|
||||
|
||||
if results?.isEmpty ?? true {
|
||||
watch = Watch(context: self.backgroundContext)
|
||||
@@ -107,13 +111,19 @@ extension PlayerModel {
|
||||
try? self.context.save()
|
||||
|
||||
FeedModel.shared.calculateUnwatchedFeed()
|
||||
WatchModel.shared.watchesChanged()
|
||||
}
|
||||
}
|
||||
|
||||
func removeAllWatches() {
|
||||
let watchesFetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "Watch")
|
||||
let deleteRequest = NSBatchDeleteRequest(fetchRequest: watchesFetchRequest)
|
||||
_ = try? context.execute(deleteRequest)
|
||||
_ = try? context.save()
|
||||
|
||||
do {
|
||||
try context.executeAndMergeChanges(deleteRequest)
|
||||
try context.save()
|
||||
} catch let error as NSError {
|
||||
logger.info(.init(stringLiteral: error.localizedDescription))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ import Foundation
|
||||
import KeychainAccess
|
||||
|
||||
struct KeychainModel {
|
||||
static var shared = KeychainModel()
|
||||
static var shared = Self()
|
||||
|
||||
var keychain = Keychain(service: "stream.yattee.app")
|
||||
|
||||
func updateAccountKey(_ account: Account, _ key: String, _ value: String) {
|
||||
keychain[accountKey(account, key)] = value
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
keychain[accountKey(account, key)] = value
|
||||
}
|
||||
}
|
||||
|
||||
func getAccountKey(_ account: Account, _ key: String) -> String? {
|
||||
@@ -19,8 +21,10 @@ struct KeychainModel {
|
||||
}
|
||||
|
||||
func removeAccountKeys(_ account: Account) {
|
||||
try? keychain.remove(accountKey(account, "token"))
|
||||
try? keychain.remove(accountKey(account, "username"))
|
||||
try? keychain.remove(accountKey(account, "password"))
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
try? keychain.remove(accountKey(account, "token"))
|
||||
try? keychain.remove(accountKey(account, "username"))
|
||||
try? keychain.remove(accountKey(account, "password"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ final class NavigationModel: ObservableObject {
|
||||
case .search:
|
||||
return "search"
|
||||
#if os(tvOS)
|
||||
case .settings: // swiftlint:disable:this switch_case_alignment
|
||||
case .settings:
|
||||
return "settings"
|
||||
#endif
|
||||
default:
|
||||
@@ -63,7 +63,9 @@ final class NavigationModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
@Published var tabSelection: TabSelection!
|
||||
@Published var tabSelection: TabSelection! { didSet {
|
||||
if oldValue == tabSelection { multipleTapHandler() }
|
||||
}}
|
||||
|
||||
@Published var presentingAddToPlaylist = false
|
||||
@Published var videoToAddToPlaylist: Video!
|
||||
@@ -83,6 +85,10 @@ final class NavigationModel: ObservableObject {
|
||||
@Published var presentingSettings = false
|
||||
@Published var presentingAccounts = false
|
||||
@Published var presentingWelcomeScreen = false
|
||||
@Published var presentingHomeSettings = false
|
||||
|
||||
@Published var presentingChannelSheet = false
|
||||
@Published var channelPresentedInSheet: Channel!
|
||||
|
||||
@Published var presentingShareSheet = false
|
||||
@Published var shareURL: URL?
|
||||
@@ -103,7 +109,6 @@ final class NavigationModel: ObservableObject {
|
||||
|
||||
hideKeyboard()
|
||||
let presentingPlayer = player.presentingPlayer
|
||||
player.hide()
|
||||
presentingChannel = false
|
||||
|
||||
#if os(macOS)
|
||||
@@ -113,20 +118,34 @@ final class NavigationModel: ObservableObject {
|
||||
let recent = RecentItem(from: channel)
|
||||
recents.add(RecentItem(from: channel))
|
||||
|
||||
if navigationStyle == .sidebar {
|
||||
sidebarSectionChanged.toggle()
|
||||
tabSelection = .recentlyOpened(recent.tag)
|
||||
} else {
|
||||
var delay = 0.0
|
||||
let navigateToChannel = {
|
||||
#if os(iOS)
|
||||
if presentingPlayer { delay = 1.0 }
|
||||
self.player.hide()
|
||||
#endif
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
||||
|
||||
if navigationStyle == .sidebar {
|
||||
self.sidebarSectionChanged.toggle()
|
||||
self.tabSelection = .recentlyOpened(recent.tag)
|
||||
} else {
|
||||
withAnimation(Constants.overlayAnimation) {
|
||||
self.presentingChannel = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
if presentingPlayer {
|
||||
presentChannelInSheet(channel)
|
||||
} else {
|
||||
navigateToChannel()
|
||||
}
|
||||
#elseif os(tvOS)
|
||||
Delay.by(0.01) {
|
||||
navigateToChannel()
|
||||
}
|
||||
#else
|
||||
navigateToChannel()
|
||||
#endif
|
||||
}
|
||||
|
||||
func openChannelPlaylist(_ playlist: ChannelPlaylist, navigationStyle: NavigationStyle) {
|
||||
@@ -273,6 +292,20 @@ final class NavigationModel: ObservableObject {
|
||||
shareURL = url
|
||||
presentingShareSheet = true
|
||||
}
|
||||
|
||||
func presentChannelInSheet(_ channel: Channel) {
|
||||
channelPresentedInSheet = channel
|
||||
presentingChannelSheet = true
|
||||
}
|
||||
|
||||
func multipleTapHandler() {
|
||||
switch tabSelection {
|
||||
case .search:
|
||||
self.search.focused = true
|
||||
default:
|
||||
print("not implemented")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
typealias TabSelection = NavigationModel.TabSelection
|
||||
|
||||
@@ -37,7 +37,7 @@ struct OpenVideosModel {
|
||||
}
|
||||
}
|
||||
|
||||
static let shared = OpenVideosModel()
|
||||
static let shared = Self()
|
||||
var player: PlayerModel! = .shared
|
||||
var logger = Logger(label: "stream.yattee.open-videos")
|
||||
|
||||
@@ -107,7 +107,7 @@ struct OpenVideosModel {
|
||||
prepending: playbackMode == .playNow || playbackMode == .playNext
|
||||
)
|
||||
|
||||
WatchNextViewModel.shared.hide()
|
||||
NavigationModel.shared.presentingChannelSheet = false
|
||||
|
||||
if playbackMode == .playNow || playbackMode == .shuffleAll {
|
||||
#if os(iOS)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import CoreData
|
||||
|
||||
struct PersistenceController {
|
||||
static let shared = PersistenceController()
|
||||
static let shared = Self()
|
||||
|
||||
static var preview: PersistenceController = {
|
||||
let result = PersistenceController(inMemory: true)
|
||||
let result = Self(inMemory: true)
|
||||
let viewContext = result.container.viewContext
|
||||
|
||||
do {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import AVFoundation
|
||||
import AVKit
|
||||
import Defaults
|
||||
import Foundation
|
||||
import Logging
|
||||
@@ -6,6 +6,7 @@ import MediaPlayer
|
||||
#if !os(macOS)
|
||||
import UIKit
|
||||
#endif
|
||||
import SwiftUI
|
||||
|
||||
final class AVPlayerBackend: PlayerBackend {
|
||||
static let assetKeysToLoad = ["tracks", "playable", "duration"]
|
||||
@@ -37,9 +38,7 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
!avPlayer.currentItem.isNil
|
||||
}
|
||||
|
||||
var isLoadingVideo: Bool {
|
||||
model.currentItem == nil || model.time == nil || !model.time!.isValid
|
||||
}
|
||||
var isLoadingVideo = false
|
||||
|
||||
var isPlaying: Bool {
|
||||
avPlayer.timeControlStatus == .playing
|
||||
@@ -84,6 +83,10 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
private(set) var playerLayer = AVPlayerLayer()
|
||||
#if os(tvOS)
|
||||
var controller: AppleAVPlayerViewController?
|
||||
#elseif os(iOS)
|
||||
var controller = AVPlayerViewController() { didSet {
|
||||
controller.player = avPlayer
|
||||
}}
|
||||
#endif
|
||||
var startPictureInPictureOnPlay = false
|
||||
var startPictureInPictureOnSwitch = false
|
||||
@@ -108,6 +111,9 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
addPlayerTimeControlStatusObserver()
|
||||
|
||||
playerLayer.player = avPlayer
|
||||
#if os(iOS)
|
||||
controller.player = avPlayer
|
||||
#endif
|
||||
}
|
||||
|
||||
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting) -> Stream? {
|
||||
@@ -130,6 +136,8 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
preservingTime: Bool,
|
||||
upgrading _: Bool
|
||||
) {
|
||||
isLoadingVideo = true
|
||||
|
||||
if let url = stream.singleAssetURL {
|
||||
model.logger.info("playing stream with one asset\(stream.kind == .hls ? " (HLS)" : ""): \(url)")
|
||||
|
||||
@@ -325,6 +333,10 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
return
|
||||
}
|
||||
|
||||
if self.model.musicMode {
|
||||
self.startMusicMode()
|
||||
}
|
||||
|
||||
if !preservingTime,
|
||||
!self.model.transitioningToPiP,
|
||||
let segment = self.model.sponsorBlock.segments.first,
|
||||
@@ -467,12 +479,10 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
return
|
||||
}
|
||||
|
||||
self.isLoadingVideo = false
|
||||
|
||||
switch playerItem.status {
|
||||
case .readyToPlay:
|
||||
if self.model.playingInPictureInPicture {
|
||||
self.startPictureInPictureOnSwitch = false
|
||||
self.startPictureInPictureOnPlay = false
|
||||
}
|
||||
if self.model.activeBackend == .appleAVPlayer,
|
||||
self.isAutoplaying(playerItem)
|
||||
{
|
||||
@@ -487,17 +497,21 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
self.model.play()
|
||||
}
|
||||
} else if self.startPictureInPictureOnPlay {
|
||||
self.startPictureInPictureOnPlay = false
|
||||
self.model.stream = self.stream
|
||||
self.model.streamSelection = self.stream
|
||||
|
||||
if self.model.activeBackend != .appleAVPlayer {
|
||||
self.startPictureInPictureOnSwitch = true
|
||||
let seconds = self.model.mpvBackend.currentTime?.seconds ?? 0
|
||||
self.seek(to: seconds, seekType: .backendSync) { _ in
|
||||
self.seek(to: seconds, seekType: .backendSync) { finished in
|
||||
guard finished else { return }
|
||||
DispatchQueue.main.async {
|
||||
self.model.pause()
|
||||
self.model.changeActiveBackend(from: .mpv, to: .appleAVPlayer, changingStream: false)
|
||||
|
||||
Delay.by(3) {
|
||||
self.startPictureInPictureOnPlay = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -688,7 +702,6 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
|
||||
func didChangeTo() {
|
||||
if startPictureInPictureOnSwitch {
|
||||
startPictureInPictureOnSwitch = false
|
||||
tryStartingPictureInPicture()
|
||||
} else if model.musicMode {
|
||||
startMusicMode()
|
||||
@@ -697,6 +710,10 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
}
|
||||
}
|
||||
|
||||
var isStartingPiP: Bool {
|
||||
startPictureInPictureOnPlay || startPictureInPictureOnSwitch
|
||||
}
|
||||
|
||||
func tryStartingPictureInPicture() {
|
||||
guard let controller = model.pipController else { return }
|
||||
|
||||
@@ -712,6 +729,32 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Delay.by(5) {
|
||||
self.startPictureInPictureOnSwitch = false
|
||||
}
|
||||
}
|
||||
|
||||
func setPlayerInLayer(_ playerIsPresented: Bool) {
|
||||
if playerIsPresented {
|
||||
bindPlayerToLayer()
|
||||
} else {
|
||||
removePlayerFromLayer()
|
||||
}
|
||||
}
|
||||
|
||||
func removePlayerFromLayer() {
|
||||
playerLayer.player = nil
|
||||
#if os(iOS)
|
||||
controller.player = nil
|
||||
#endif
|
||||
}
|
||||
|
||||
func bindPlayerToLayer() {
|
||||
playerLayer.player = avPlayer
|
||||
#if os(iOS)
|
||||
controller.player = avPlayer
|
||||
#endif
|
||||
}
|
||||
|
||||
func getTimeUpdates() {}
|
||||
|
||||
@@ -222,7 +222,9 @@ final class MPVBackend: PlayerBackend {
|
||||
func playStream(_ stream: Stream, of video: Video, preservingTime: Bool, upgrading: Bool) {
|
||||
#if !os(macOS)
|
||||
if model.presentingPlayer {
|
||||
UIApplication.shared.isIdleTimerDisabled = true
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.isIdleTimerDisabled = true
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -280,7 +282,7 @@ final class MPVBackend: PlayerBackend {
|
||||
self.stop()
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else {
|
||||
guard let self, let client = self.client else {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -296,7 +298,7 @@ final class MPVBackend: PlayerBackend {
|
||||
}
|
||||
}
|
||||
|
||||
self.client.loadFile(url, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
|
||||
client.loadFile(url, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
|
||||
self?.isLoadingVideo = true
|
||||
}
|
||||
} else {
|
||||
@@ -308,7 +310,7 @@ final class MPVBackend: PlayerBackend {
|
||||
let fileToLoad = self.model.musicMode ? stream.audioAsset.url : stream.videoAsset.url
|
||||
let audioTrack = self.model.musicMode ? nil : stream.audioAsset.url
|
||||
|
||||
self.client?.loadFile(fileToLoad, audio: audioTrack, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
|
||||
client.loadFile(fileToLoad, audio: audioTrack, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
|
||||
self?.isLoadingVideo = true
|
||||
self?.pause()
|
||||
}
|
||||
|
||||
@@ -318,14 +318,14 @@ final class MPVClient: ObservableObject {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
let model = self.backend.model
|
||||
let aspectRatio = self.aspectRatio > 0 && self.aspectRatio < VideoPlayerView.defaultAspectRatio ? self.aspectRatio : VideoPlayerView.defaultAspectRatio
|
||||
let height = [model.playerSize.height, model.playerSize.width / aspectRatio].min()!
|
||||
var insets = 0.0
|
||||
#if os(iOS)
|
||||
insets = OrientationTracker.shared.currentInterfaceOrientation.isPortrait ? SafeAreaModel.shared.safeArea.bottom : 0
|
||||
#endif
|
||||
let offsetY = max(0, model.playingFullScreen ? ((model.playerSize.height / 2.0) - ((height + insets) / 2)) : 0)
|
||||
UIView.animate(withDuration: 0.2, animations: {
|
||||
let aspectRatio = self.aspectRatio > 0 && self.aspectRatio < VideoPlayerView.defaultAspectRatio ? self.aspectRatio : VideoPlayerView.defaultAspectRatio
|
||||
let height = [model.playerSize.height, model.playerSize.width / aspectRatio].min()!
|
||||
var insets = 0.0
|
||||
#if os(iOS)
|
||||
insets = OrientationTracker.shared.currentInterfaceOrientation.isPortrait ? SafeArea.insets.bottom : 0
|
||||
#endif
|
||||
let offsetY = model.playingFullScreen ? ((model.playerSize.height / 2.0) - ((height + insets) / 2)) : 0
|
||||
self.glView?.frame = CGRect(x: 0, y: offsetY, width: roundedWidth, height: height)
|
||||
}) { completion in
|
||||
if completion {
|
||||
|
||||
@@ -105,12 +105,12 @@ extension PlayerBackend {
|
||||
return
|
||||
}
|
||||
|
||||
let action = {
|
||||
switch model.playbackMode {
|
||||
case .queue, .shuffle:
|
||||
model.prepareCurrentItemForHistory(finished: true)
|
||||
switch model.playbackMode {
|
||||
case .queue, .shuffle:
|
||||
model.prepareCurrentItemForHistory(finished: true)
|
||||
|
||||
if model.queue.isEmpty {
|
||||
if model.queue.isEmpty {
|
||||
if Defaults[.closeVideoOnEOF] {
|
||||
#if os(tvOS)
|
||||
if model.activeBackend == .appleAVPlayer {
|
||||
model.avPlayerBackend.controller?.dismiss(animated: false)
|
||||
@@ -118,34 +118,16 @@ extension PlayerBackend {
|
||||
#endif
|
||||
model.resetQueue()
|
||||
model.hide()
|
||||
} else {
|
||||
model.advanceToNextItem()
|
||||
}
|
||||
case .loopOne:
|
||||
loopAction()
|
||||
case .related:
|
||||
guard let item = model.autoplayItem else { return }
|
||||
model.resetAutoplay()
|
||||
model.advanceToItem(item)
|
||||
}
|
||||
}
|
||||
let actionAndHideWatchNext: (Bool) -> Void = { delay in
|
||||
WatchNextViewModel.shared.hide()
|
||||
if delay {
|
||||
Delay.by(0.3) {
|
||||
action()
|
||||
}
|
||||
} else {
|
||||
action()
|
||||
model.advanceToNextItem()
|
||||
}
|
||||
}
|
||||
if Defaults[.openWatchNextOnFinishedWatching], model.presentingPlayer {
|
||||
let timer = Delay.by(TimeInterval(Defaults[.openWatchNextOnFinishedWatchingDelay]) ?? 5.0) {
|
||||
actionAndHideWatchNext(true)
|
||||
}
|
||||
WatchNextViewModel.shared.finishedWatching(model.currentItem, timer: timer)
|
||||
} else {
|
||||
actionAndHideWatchNext(false)
|
||||
case .loopOne:
|
||||
loopAction()
|
||||
case .related:
|
||||
guard let item = model.autoplayItem else { return }
|
||||
model.resetAutoplay()
|
||||
model.advanceToItem(item)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import Foundation
|
||||
import SwiftUI
|
||||
|
||||
final class PiPDelegate: NSObject, AVPictureInPictureControllerDelegate {
|
||||
var player: PlayerModel!
|
||||
var player: PlayerModel { .shared }
|
||||
|
||||
func pictureInPictureController(
|
||||
_: AVPictureInPictureController,
|
||||
@@ -16,29 +16,31 @@ final class PiPDelegate: NSObject, AVPictureInPictureControllerDelegate {
|
||||
func pictureInPictureControllerWillStartPictureInPicture(_: AVPictureInPictureController) {}
|
||||
|
||||
func pictureInPictureControllerDidStartPictureInPicture(_: AVPictureInPictureController) {
|
||||
guard let player else { return }
|
||||
player.play()
|
||||
|
||||
player.playingInPictureInPicture = true
|
||||
player.avPlayerBackend.startPictureInPictureOnPlay = false
|
||||
player.avPlayerBackend.startPictureInPictureOnSwitch = false
|
||||
player.controls.objectWillChange.send()
|
||||
|
||||
if Defaults[.closePlayerOnOpeningPiP] { Delay.by(0.1) { player.hide() } }
|
||||
if Defaults[.closePlayerOnOpeningPiP] { Delay.by(0.1) { self.player.hide() } }
|
||||
}
|
||||
|
||||
func pictureInPictureControllerDidStopPictureInPicture(_: AVPictureInPictureController) {
|
||||
guard let player else { return }
|
||||
|
||||
player.playingInPictureInPicture = false
|
||||
player.controls.objectWillChange.send()
|
||||
}
|
||||
|
||||
func pictureInPictureControllerWillStopPictureInPicture(_: AVPictureInPictureController) {}
|
||||
func pictureInPictureControllerWillStopPictureInPicture(_: AVPictureInPictureController) {
|
||||
player.show()
|
||||
}
|
||||
|
||||
func pictureInPictureController(
|
||||
_: AVPictureInPictureController,
|
||||
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void
|
||||
) {
|
||||
let wasPlaying = player.isPlaying
|
||||
|
||||
var delay = 0.0
|
||||
#if os(iOS)
|
||||
if !player.presentingPlayer {
|
||||
@@ -50,7 +52,7 @@ final class PiPDelegate: NSObject, AVPictureInPictureControllerDelegate {
|
||||
#endif
|
||||
|
||||
if !player.currentItem.isNil, !player.musicMode {
|
||||
player?.show()
|
||||
player.show()
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
@@ -58,6 +60,11 @@ final class PiPDelegate: NSObject, AVPictureInPictureControllerDelegate {
|
||||
self?.player.playingInPictureInPicture = false
|
||||
}
|
||||
|
||||
if wasPlaying {
|
||||
Delay.by(1) {
|
||||
self?.player.play()
|
||||
}
|
||||
}
|
||||
completionHandler(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,13 +49,13 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
let logger = Logger(label: "stream.yattee.app")
|
||||
|
||||
var avPlayerView = AppleAVPlayerView()
|
||||
var playerItem: AVPlayerItem?
|
||||
|
||||
var mpvPlayerView = MPVPlayerView()
|
||||
|
||||
@Published var presentingPlayer = false { didSet { handlePresentationChange() } }
|
||||
@Published var activeBackend = PlayerBackendType.mpv
|
||||
@Published var forceBackendOnPlay: PlayerBackendType?
|
||||
|
||||
var avPlayerBackend = AVPlayerBackend()
|
||||
var mpvBackend = MPVBackend()
|
||||
@@ -128,7 +128,7 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
#if os(iOS)
|
||||
@Published var lockedOrientation: UIInterfaceOrientationMask?
|
||||
@Default(.rotateToPortraitOnExitFullScreen) private var rotateToPortraitOnExitFullScreen
|
||||
@Default(.rotateToLandscapeOnEnterFullScreen) private var rotateToLandscapeOnEnterFullScreen
|
||||
#endif
|
||||
|
||||
var accounts: AccountsModel { .shared }
|
||||
@@ -152,6 +152,9 @@ final class PlayerModel: ObservableObject {
|
||||
@Published var playingInPictureInPicture = false
|
||||
var pipController: AVPictureInPictureController?
|
||||
var pipDelegate = PiPDelegate()
|
||||
#if !os(macOS)
|
||||
var appleAVPlayerViewControllerDelegate = AppleAVPlayerViewControllerDelegate()
|
||||
#endif
|
||||
|
||||
var playerError: Error? { didSet {
|
||||
if let error = playerError {
|
||||
@@ -163,6 +166,7 @@ final class PlayerModel: ObservableObject {
|
||||
@Default(.saveLastPlayed) var saveLastPlayed
|
||||
@Default(.lastPlayed) var lastPlayed
|
||||
@Default(.qualityProfiles) var qualityProfiles
|
||||
@Default(.avPlayerUsesSystemControls) var avPlayerUsesSystemControls
|
||||
@Default(.forceAVPlayerForLiveStreams) var forceAVPlayerForLiveStreams
|
||||
@Default(.pauseOnHidingPlayer) private var pauseOnHidingPlayer
|
||||
@Default(.closePiPOnNavigation) var closePiPOnNavigation
|
||||
@@ -186,16 +190,17 @@ final class PlayerModel: ObservableObject {
|
||||
mpvBackend.client = mpvController.client
|
||||
#endif
|
||||
|
||||
Defaults[.activeBackend] = .mpv
|
||||
playbackMode = Defaults[.playbackMode]
|
||||
|
||||
guard pipController.isNil else { return }
|
||||
pipController = .init(playerLayer: avPlayerBackend.playerLayer)
|
||||
let pipDelegate = PiPDelegate()
|
||||
pipDelegate.player = self
|
||||
|
||||
self.pipDelegate = pipDelegate
|
||||
pipController = .init(playerLayer: avPlayerBackend.playerLayer)
|
||||
pipController?.delegate = pipDelegate
|
||||
#if os(iOS)
|
||||
if #available(iOS 14.2, *) {
|
||||
pipController?.canStartPictureInPictureAutomaticallyFromInline = true
|
||||
}
|
||||
#endif
|
||||
currentRate = playerRate
|
||||
}
|
||||
|
||||
@@ -231,16 +236,11 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.exitFullScreen(showControls: false)
|
||||
Delay.by(0.3) {
|
||||
self?.exitFullScreen(showControls: false)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
if Defaults[.lockPortraitWhenBrowsing] {
|
||||
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
|
||||
} else {
|
||||
Orientation.lockOrientation(.allButUpsideDown)
|
||||
}
|
||||
#endif
|
||||
#if os(macOS)
|
||||
Windows.player.hide()
|
||||
#endif
|
||||
@@ -334,7 +334,7 @@ final class PlayerModel: ObservableObject {
|
||||
pause()
|
||||
videoBeingOpened = video
|
||||
|
||||
WatchNextViewModel.shared.hide()
|
||||
navigation.presentingChannelSheet = false
|
||||
|
||||
var changeBackendHandler: (() -> Void)?
|
||||
|
||||
@@ -410,6 +410,10 @@ final class PlayerModel: ObservableObject {
|
||||
upgrading: upgrading
|
||||
)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.forceBackendOnPlay = nil
|
||||
}
|
||||
|
||||
if !upgrading {
|
||||
updateCurrentArtwork()
|
||||
}
|
||||
@@ -444,7 +448,7 @@ final class PlayerModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
if let backend = (live && forceAVPlayerForLiveStreams) ? PlayerBackendType.appleAVPlayer : qualityProfile?.backend,
|
||||
if let backend = forceBackendOnPlay ?? ((live && forceAVPlayerForLiveStreams) ? PlayerBackendType.appleAVPlayer : qualityProfile?.backend),
|
||||
backend != activeBackend,
|
||||
backend == .appleAVPlayer || !(avPlayerBackend.startPictureInPictureOnPlay || playingInPictureInPicture)
|
||||
{
|
||||
@@ -459,18 +463,29 @@ final class PlayerModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
streamSelection = stream
|
||||
playStream(
|
||||
stream,
|
||||
of: currentVideo,
|
||||
preservingTime: !currentItem.playbackTime.isNil
|
||||
)
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
DispatchQueue.main.async {
|
||||
self.streamSelection = stream
|
||||
}
|
||||
self.playStream(
|
||||
stream,
|
||||
of: currentVideo,
|
||||
preservingTime: !self.currentItem.playbackTime.isNil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePresentationChange() {
|
||||
backend.setNeedsDrawing(presentingPlayer)
|
||||
|
||||
#if os(iOS)
|
||||
if presentingPlayer, activeBackend == .appleAVPlayer, avPlayerUsesSystemControls, Constants.isIPhone {
|
||||
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
|
||||
}
|
||||
#endif
|
||||
|
||||
controls.hide()
|
||||
controls.hideOverlays()
|
||||
|
||||
#if !os(macOS)
|
||||
UIApplication.shared.isIdleTimerDisabled = presentingPlayer
|
||||
@@ -487,6 +502,18 @@ final class PlayerModel: ObservableObject {
|
||||
self?.pause()
|
||||
}
|
||||
}
|
||||
|
||||
if !presentingPlayer {
|
||||
#if os(iOS)
|
||||
if Defaults[.lockPortraitWhenBrowsing] {
|
||||
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
|
||||
} else {
|
||||
Orientation.lockOrientation(.allButUpsideDown)
|
||||
}
|
||||
|
||||
OrientationModel.shared.stopOrientationUpdates()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func changeActiveBackend(from: PlayerBackendType, to: PlayerBackendType, changingStream: Bool = true) {
|
||||
@@ -537,10 +564,15 @@ final class PlayerModel: ObservableObject {
|
||||
self.stream = stream
|
||||
streamSelection = stream
|
||||
|
||||
self.upgradeToStream(stream, force: true)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !backend.canPlay(stream) || (to == .mpv && !stream.hlsURL.isNil) {
|
||||
if !backend.canPlay(stream) ||
|
||||
(to == .mpv && stream.isHLS) ||
|
||||
(to == .appleAVPlayer && !stream.isHLS)
|
||||
{
|
||||
guard let preferredStream = streamByQualityProfile else {
|
||||
return
|
||||
}
|
||||
@@ -585,6 +617,8 @@ final class PlayerModel: ObservableObject {
|
||||
func closeCurrentItem(finished: Bool = false) {
|
||||
pause()
|
||||
videoBeingOpened = nil
|
||||
advancing = false
|
||||
forceBackendOnPlay = nil
|
||||
|
||||
closing = true
|
||||
controls.presentingControls = false
|
||||
@@ -626,8 +660,8 @@ final class PlayerModel: ObservableObject {
|
||||
if avPlayerBackend.video == video {
|
||||
if activeBackend != .appleAVPlayer {
|
||||
avPlayerBackend.startPictureInPictureOnSwitch = true
|
||||
changeActiveBackend(from: activeBackend, to: .appleAVPlayer)
|
||||
}
|
||||
changeActiveBackend(from: activeBackend, to: .appleAVPlayer)
|
||||
} else {
|
||||
avPlayerBackend.startPictureInPictureOnPlay = true
|
||||
playStream(stream, of: video, preservingTime: true, upgrading: true, withBackend: avPlayerBackend)
|
||||
@@ -664,6 +698,46 @@ final class PlayerModel: ObservableObject {
|
||||
backend.closePiP()
|
||||
}
|
||||
|
||||
var pipImage: String {
|
||||
transitioningToPiP ? "pip.fill" : pipController?.isPictureInPictureActive ?? false ? "pip.exit" : "pip.enter"
|
||||
}
|
||||
|
||||
var fullscreenImage: String {
|
||||
playingFullScreen ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right"
|
||||
}
|
||||
|
||||
func toggleFullScreenAction() {
|
||||
toggleFullscreen(playingFullScreen, showControls: false)
|
||||
}
|
||||
|
||||
func togglePiPAction() {
|
||||
(pipController?.isPictureInPictureActive ?? false) ? closePiP() : startPiP()
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
var lockOrientationImage: String {
|
||||
lockedOrientation.isNil ? "lock.rotation.open" : "lock.rotation"
|
||||
}
|
||||
|
||||
func lockOrientationAction() {
|
||||
if lockedOrientation.isNil {
|
||||
let orientationMask = OrientationTracker.shared.currentInterfaceOrientationMask
|
||||
lockedOrientation = orientationMask
|
||||
let orientation = OrientationTracker.shared.currentInterfaceOrientation
|
||||
Orientation.lockOrientation(orientationMask, andRotateTo: .landscapeLeft)
|
||||
// iOS 16 workaround
|
||||
Orientation.lockOrientation(orientationMask, andRotateTo: orientation)
|
||||
} else {
|
||||
lockedOrientation = nil
|
||||
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: OrientationTracker.shared.currentInterfaceOrientation)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
func replayAction() {
|
||||
backend.seek(to: 0.0, seekType: .userInteracted)
|
||||
}
|
||||
|
||||
func handleQueueChange() {
|
||||
Defaults[.queue] = queue
|
||||
|
||||
@@ -837,7 +911,7 @@ final class PlayerModel: ObservableObject {
|
||||
#else
|
||||
func handleEnterForeground() {
|
||||
setNeedsDrawing(presentingPlayer)
|
||||
avPlayerBackend.playerLayer.player = avPlayerBackend.avPlayer
|
||||
avPlayerBackend.bindPlayerToLayer()
|
||||
|
||||
guard closePiPAndOpenPlayerOnEnteringForeground, playingInPictureInPicture else {
|
||||
return
|
||||
@@ -851,7 +925,7 @@ final class PlayerModel: ObservableObject {
|
||||
if Defaults[.pauseOnEnteringBackground], !playingInPictureInPicture, !musicMode {
|
||||
pause()
|
||||
} else if !playingInPictureInPicture {
|
||||
avPlayerBackend.playerLayer.player = nil
|
||||
avPlayerBackend.removePlayerFromLayer()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -874,6 +948,13 @@ final class PlayerModel: ObservableObject {
|
||||
#if os(tvOS)
|
||||
guard activeBackend == .mpv else { return }
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
if activeBackend == .appleAVPlayer, avPlayerUsesSystemControls {
|
||||
return
|
||||
}
|
||||
#endif
|
||||
|
||||
guard let video = currentItem?.video else {
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = .none
|
||||
return
|
||||
@@ -937,24 +1018,34 @@ final class PlayerModel: ObservableObject {
|
||||
Windows.player.toggleFullScreen()
|
||||
#endif
|
||||
|
||||
playingFullScreen = !isFullScreen
|
||||
|
||||
#if os(iOS)
|
||||
if !playingFullScreen {
|
||||
playingFullScreen = true
|
||||
Orientation.lockOrientation(.allButUpsideDown)
|
||||
} else {
|
||||
let rotationOrientation = rotateToPortraitOnExitFullScreen ? UIInterfaceOrientation.portrait : nil
|
||||
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: rotationOrientation)
|
||||
// TODO: rework to move view before rotating
|
||||
if SafeArea.insets.left > 0 {
|
||||
Delay.by(0.15) {
|
||||
self.playingFullScreen = false
|
||||
}
|
||||
} else {
|
||||
self.playingFullScreen = false
|
||||
if playingFullScreen {
|
||||
if activeBackend == .appleAVPlayer, avPlayerUsesSystemControls {
|
||||
avPlayerBackend.controller.enterFullScreen(animated: true)
|
||||
return
|
||||
}
|
||||
guard rotateToLandscapeOnEnterFullScreen.isRotating else { return }
|
||||
if currentVideoIsLandscape {
|
||||
let delay = activeBackend == .appleAVPlayer && avPlayerUsesSystemControls ? 0.8 : 0
|
||||
// not sure why but first rotation call is ignore so doing rotate to same orientation first
|
||||
Delay.by(delay) {
|
||||
let orientation = OrientationTracker.shared.currentDeviceOrientation.isLandscape ? OrientationTracker.shared.currentInterfaceOrientation : self.rotateToLandscapeOnEnterFullScreen.interaceOrientation
|
||||
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: OrientationTracker.shared.currentInterfaceOrientation)
|
||||
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: orientation)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if activeBackend == .appleAVPlayer, avPlayerUsesSystemControls {
|
||||
avPlayerBackend.controller.exitFullScreen(animated: true)
|
||||
avPlayerBackend.controller.dismiss(animated: true)
|
||||
return
|
||||
}
|
||||
let rotationOrientation = Constants.isIPhone ? UIInterfaceOrientation.portrait : nil
|
||||
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: rotationOrientation)
|
||||
}
|
||||
#else
|
||||
playingFullScreen = !isFullScreen
|
||||
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -992,6 +1083,12 @@ final class PlayerModel: ObservableObject {
|
||||
#endif
|
||||
}
|
||||
|
||||
var currentVideoIsLandscape: Bool {
|
||||
guard currentVideo != nil else { return false }
|
||||
|
||||
return aspectRatio > 1
|
||||
}
|
||||
|
||||
var formattedSize: String {
|
||||
guard let videoWidth = backend?.videoWidth, let videoHeight = backend?.videoHeight else { return "unknown" }
|
||||
return "\(String(format: "%.2f", videoWidth))\u{d7}\(String(format: "%.2f", videoHeight))"
|
||||
|
||||
@@ -10,11 +10,12 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
var videoForDisplay: Video? {
|
||||
videoBeingOpened ?? (closing ? nil : currentVideo)
|
||||
videoBeingOpened ?? currentVideo
|
||||
}
|
||||
|
||||
func play(_ videos: [Video], shuffling: Bool = false) {
|
||||
WatchNextViewModel.shared.hide()
|
||||
navigation.presentingChannelSheet = false
|
||||
|
||||
playbackMode = shuffling ? .shuffle : .queue
|
||||
|
||||
videos.forEach { enqueueVideo($0, loadDetails: false) }
|
||||
@@ -33,6 +34,8 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
func playNow(_ video: Video, at time: CMTime? = nil) {
|
||||
navigation.presentingChannelSheet = false
|
||||
|
||||
if playingInPictureInPicture, closePiPOnNavigation {
|
||||
closePiP()
|
||||
}
|
||||
@@ -55,7 +58,7 @@ extension PlayerModel {
|
||||
|
||||
comments.reset()
|
||||
stream = nil
|
||||
WatchNextViewModel.shared.hide()
|
||||
navigation.presentingChannelSheet = false
|
||||
|
||||
withAnimation {
|
||||
aspectRatio = VideoPlayerView.defaultAspectRatio
|
||||
@@ -85,7 +88,7 @@ extension PlayerModel {
|
||||
guard let playerInstance = self.playerInstance else { return }
|
||||
let streamsInstance = video.streams.compactMap(\.instance).first
|
||||
|
||||
if video.streams.isEmpty || streamsInstance != playerInstance {
|
||||
if video.streams.isEmpty || streamsInstance.isNil || streamsInstance!.apiURLString != playerInstance.apiURLString {
|
||||
self.loadAvailableStreams(video) { [weak self] _ in
|
||||
self?.videoBeingOpened = nil
|
||||
}
|
||||
@@ -175,7 +178,7 @@ extension PlayerModel {
|
||||
|
||||
remove(newItem)
|
||||
|
||||
WatchNextViewModel.shared.hide()
|
||||
navigation.presentingChannelSheet = false
|
||||
currentItem = newItem
|
||||
currentItem.playbackTime = time
|
||||
|
||||
@@ -219,9 +222,11 @@ extension PlayerModel {
|
||||
let item = PlayerQueueItem(video, playbackTime: atTime)
|
||||
|
||||
if play {
|
||||
navigation.presentingChannelSheet = false
|
||||
|
||||
withAnimation {
|
||||
aspectRatio = VideoPlayerView.defaultAspectRatio
|
||||
WatchNextViewModel.shared.hide()
|
||||
navigation.presentingChannelSheet = false
|
||||
currentItem = item
|
||||
}
|
||||
videoBeingOpened = video
|
||||
@@ -359,10 +364,7 @@ extension PlayerModel {
|
||||
message: Text(message),
|
||||
primaryButton: .cancel { [weak self] in
|
||||
guard let self else { return }
|
||||
self.advancing = false
|
||||
self.videoBeingOpened = nil
|
||||
self.currentItem = nil
|
||||
self.hide()
|
||||
self.closeCurrentItem()
|
||||
},
|
||||
secondaryButton: retryButton
|
||||
)
|
||||
|
||||
@@ -32,6 +32,17 @@ extension PlayerModel {
|
||||
}
|
||||
}
|
||||
|
||||
var playerItemEndTimeWithSegments: CMTime? {
|
||||
if let duration = playerItemDuration,
|
||||
let segment = sponsorBlock.segments.last,
|
||||
segment.endTime.seconds >= duration.seconds - 3
|
||||
{
|
||||
return segment.endTime
|
||||
}
|
||||
|
||||
return playerItemDuration
|
||||
}
|
||||
|
||||
private func skip(_ segment: Segment, at time: CMTime) {
|
||||
if let duration = playerItemDuration, segment.endTime.seconds >= duration.seconds - 3 {
|
||||
logger.error("segment end time is: \(segment.end) when player item duration is: \(duration.seconds)")
|
||||
@@ -95,6 +106,8 @@ extension PlayerModel {
|
||||
|
||||
func resetSegments() {
|
||||
resetLastSegment()
|
||||
restoredSegments = []
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.restoredSegments = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ extension PlayerModel {
|
||||
self.logger.info("ignoring loaded streams from \(instance.description) as current video has changed")
|
||||
return
|
||||
}
|
||||
self.availableStreams += self.streamsWithInstance(instance: instance, streams: video.streams)
|
||||
self.availableStreams = self.streamsWithInstance(instance: instance, streams: video.streams)
|
||||
} else {
|
||||
self.logger.critical("no streams available from \(instance.description)")
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import Foundation
|
||||
import IOKit.pwr_mgt
|
||||
|
||||
struct ScreenSaverManager {
|
||||
static var shared = ScreenSaverManager()
|
||||
static var shared = Self()
|
||||
|
||||
var noSleepAssertion: IOPMAssertionID = 0
|
||||
var noSleepReturn: IOReturn?
|
||||
|
||||
@@ -6,7 +6,7 @@ import Foundation
|
||||
#endif
|
||||
|
||||
struct QualityProfilesModel {
|
||||
static let shared = QualityProfilesModel()
|
||||
static let shared = Self()
|
||||
|
||||
#if os(tvOS)
|
||||
var tvOSProfile: QualityProfile? {
|
||||
|
||||
@@ -39,7 +39,9 @@ final class RecentsModel: ObservableObject {
|
||||
|
||||
func addQuery(_ query: String) {
|
||||
if !query.isEmpty {
|
||||
NavigationModel.shared.tabSelection = .search
|
||||
if NavigationModel.shared.tabSelection != .search {
|
||||
NavigationModel.shared.tabSelection = .search
|
||||
}
|
||||
add(.init(from: query))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,23 @@ final class SearchModel: ObservableObject {
|
||||
@Published var querySuggestions = [String]()
|
||||
private var suggestionsDebouncer = Debouncer(.milliseconds(200))
|
||||
|
||||
@Published var focused = false
|
||||
|
||||
var accounts: AccountsModel { .shared }
|
||||
private var resource: Resource!
|
||||
|
||||
init() {
|
||||
#if os(iOS)
|
||||
addKeyboardDidHideNotificationObserver()
|
||||
#endif
|
||||
}
|
||||
|
||||
deinit {
|
||||
#if os(iOS)
|
||||
removeKeyboardDidHideNotificationObserver()
|
||||
#endif
|
||||
}
|
||||
|
||||
var isLoading: Bool {
|
||||
resource?.isLoading ?? false
|
||||
}
|
||||
@@ -136,4 +150,18 @@ final class SearchModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private func addKeyboardDidHideNotificationObserver() {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(onKeyboardDidHide), name: UIResponder.keyboardDidHideNotification, object: nil)
|
||||
}
|
||||
|
||||
@objc func onKeyboardDidHide() {
|
||||
focused = false
|
||||
}
|
||||
|
||||
private func removeKeyboardDidHideNotificationObserver() {
|
||||
NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardDidHideNotification, object: nil)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -54,13 +54,13 @@ final class SponsorBlockAPI: ObservableObject {
|
||||
"still frame or clip which are also seen in other videos by the same creator.").localized()
|
||||
|
||||
case "outro":
|
||||
return ("Typically near or at the end of the video when the credits pop up and/or endcards are shown.").localized()
|
||||
return "Typically near or at the end of the video when the credits pop up and/or endcards are shown.".localized()
|
||||
|
||||
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).").localized()
|
||||
return "Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video).".localized()
|
||||
|
||||
case "music_offtopic":
|
||||
return ("For videos which feature music as the primary content.").localized()
|
||||
return "For videos which feature music as the primary content.".localized()
|
||||
|
||||
default:
|
||||
return nil
|
||||
|
||||
@@ -176,6 +176,10 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
localURL != nil
|
||||
}
|
||||
|
||||
var isHLS: Bool {
|
||||
hlsURL != nil
|
||||
}
|
||||
|
||||
var quality: String {
|
||||
guard localURL.isNil else { return "Opened File" }
|
||||
return kind == .hls ? "adaptive (HLS)" : "\(resolution.name)\(kind == .stream ? " (\(kind.rawValue))" : "")"
|
||||
@@ -229,8 +233,14 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(videoAsset?.url)
|
||||
hasher.combine(audioAsset?.url)
|
||||
hasher.combine(hlsURL)
|
||||
if let url = videoAsset?.url {
|
||||
hasher.combine(url)
|
||||
}
|
||||
if let url = audioAsset?.url {
|
||||
hasher.combine(url)
|
||||
}
|
||||
if let url = hlsURL {
|
||||
hasher.combine(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,4 +41,8 @@ enum TrendingCategory: String, CaseIterable, Identifiable, Defaults.Serializable
|
||||
var controlLabel: String {
|
||||
id == "default" ? "All".localized() : title
|
||||
}
|
||||
|
||||
var type: String {
|
||||
rawValue.capitalized
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import Logging
|
||||
|
||||
struct URLBookmarkModel {
|
||||
static let bookmarkPrefix = "urlbookmark-"
|
||||
static var shared = URLBookmarkModel()
|
||||
static var shared = Self()
|
||||
|
||||
var logger = Logger(label: "stream.yattee.url-bookmark")
|
||||
|
||||
|
||||
@@ -32,4 +32,5 @@ final class UnwatchedFeedCountModel: ObservableObject {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// swiftlint:enable empty_count
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ struct Video: Identifiable, Equatable, Hashable {
|
||||
}
|
||||
|
||||
static func local(_ url: URL) -> Video {
|
||||
Video(
|
||||
Self(
|
||||
app: .local,
|
||||
videoID: url.absoluteString,
|
||||
streams: [.init(localURL: url)]
|
||||
@@ -167,7 +167,7 @@ struct Video: Identifiable, Equatable, Hashable {
|
||||
static func from(_ json: JSON) -> Self {
|
||||
let dateFormatter = ISO8601DateFormatter()
|
||||
|
||||
return Video(
|
||||
return Self(
|
||||
instanceID: json["instanceID"].stringValue,
|
||||
app: .init(rawValue: json["app"].stringValue) ?? AccountsModel.shared.current.app ?? .local,
|
||||
instanceURL: URL(string: json["instanceURL"].stringValue) ?? AccountsModel.shared.current.instance.apiURL,
|
||||
@@ -295,8 +295,8 @@ struct Video: Identifiable, Equatable, Hashable {
|
||||
}
|
||||
|
||||
var localStreamIsRemoteURL: Bool {
|
||||
guard let localStream else { return false }
|
||||
return !localStream.localURL.isFileURL
|
||||
guard let url = localStream?.localURL else { return false }
|
||||
return url.isFileURL
|
||||
}
|
||||
|
||||
var localStreamIsDirectory: Bool {
|
||||
|
||||
@@ -7,6 +7,7 @@ import Foundation
|
||||
final class Watch: NSManagedObject, Identifiable {
|
||||
@Default(.watchedThreshold) private var watchedThreshold
|
||||
@Default(.saveHistory) private var saveHistory
|
||||
@Default(.showWatchingProgress) private var showWatchingProgress
|
||||
}
|
||||
|
||||
extension Watch {
|
||||
@@ -14,7 +15,7 @@ extension Watch {
|
||||
NSFetchRequest<Watch>(entityName: "Watch")
|
||||
}
|
||||
|
||||
@nonobjc class func markAsWatched(videoID: String, account: Account, duration: Double, context: NSManagedObjectContext) {
|
||||
@nonobjc class func markAsWatched(videoID: String, account: Account, duration: Double, watchedAt: Date? = nil, context: NSManagedObjectContext) {
|
||||
let watchFetchRequest = Watch.fetchRequest()
|
||||
watchFetchRequest.predicate = NSPredicate(format: "videoID = %@", videoID as String)
|
||||
|
||||
@@ -36,7 +37,7 @@ extension Watch {
|
||||
|
||||
watch.videoDuration = duration
|
||||
watch.stoppedAt = duration
|
||||
watch.watchedAt = Date()
|
||||
watch.watchedAt = watchedAt ?? .init()
|
||||
|
||||
try? context.save()
|
||||
}
|
||||
@@ -51,7 +52,7 @@ extension Watch {
|
||||
@NSManaged var appName: String?
|
||||
@NSManaged var instanceURL: URL?
|
||||
|
||||
var app: VideosApp! {
|
||||
var app: VideosApp? {
|
||||
guard let appName else { return nil }
|
||||
return .init(rawValue: appName)
|
||||
}
|
||||
@@ -102,4 +103,8 @@ extension Watch {
|
||||
|
||||
return Video(app: app ?? AccountsModel.shared.current?.app ?? .local, instanceURL: instanceURL, videoID: videoID)
|
||||
}
|
||||
|
||||
var isShowingProgress: Bool {
|
||||
saveHistory && showWatchingProgress && (finished || progress > 0)
|
||||
}
|
||||
}
|
||||
|
||||
11
Model/WatchModel.swift
Normal file
11
Model/WatchModel.swift
Normal file
@@ -0,0 +1,11 @@
|
||||
import SwiftUI
|
||||
|
||||
final class WatchModel: ObservableObject {
|
||||
static let shared = WatchModel()
|
||||
|
||||
@Published var historyToken = UUID()
|
||||
|
||||
func watchesChanged() {
|
||||
historyToken = UUID()
|
||||
}
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
import Combine
|
||||
import Defaults
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
final class WatchNextViewModel: ObservableObject {
|
||||
enum Page: String, CaseIterable {
|
||||
case queue
|
||||
case related
|
||||
case history
|
||||
|
||||
var title: String {
|
||||
rawValue.capitalized.localized()
|
||||
}
|
||||
|
||||
var systemImageName: String {
|
||||
switch self {
|
||||
case .queue:
|
||||
return "list.and.film"
|
||||
case .related:
|
||||
return "rectangle.stack.fill"
|
||||
case .history:
|
||||
return "clock"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PresentationReason {
|
||||
case userInteracted
|
||||
case finishedWatching
|
||||
case closed
|
||||
}
|
||||
|
||||
static let animation = Animation.easeIn(duration: 0.25)
|
||||
static let shared = WatchNextViewModel()
|
||||
|
||||
@Published var item: PlayerQueueItem?
|
||||
@Published private(set) var isPresenting = false
|
||||
@Published var reason: PresentationReason?
|
||||
@Published var page = Page.queue
|
||||
|
||||
@Published var countdown = 0.0
|
||||
var countdownTimer: Timer?
|
||||
|
||||
var player = PlayerModel.shared
|
||||
|
||||
var autoplayTimer: Timer?
|
||||
|
||||
var isAutoplaying: Bool {
|
||||
reason == .finishedWatching
|
||||
}
|
||||
|
||||
var isHideable: Bool {
|
||||
reason == .userInteracted
|
||||
}
|
||||
|
||||
var isRestartable: Bool {
|
||||
player.currentItem != nil && reason != .userInteracted
|
||||
}
|
||||
|
||||
var canAutoplay: Bool {
|
||||
switch player.playbackMode {
|
||||
case .shuffle:
|
||||
return !player.queue.isEmpty
|
||||
default:
|
||||
return nextFromTheQueue != nil
|
||||
}
|
||||
}
|
||||
|
||||
func userInteractedOpen(_ item: PlayerQueueItem?) {
|
||||
self.item = item
|
||||
open(reason: .userInteracted)
|
||||
}
|
||||
|
||||
func finishedWatching(_ item: PlayerQueueItem?, timer: Timer? = nil) {
|
||||
if canAutoplay {
|
||||
countdown = TimeInterval(Defaults[.openWatchNextOnFinishedWatchingDelay]) ?? 5.0
|
||||
resetCountdownTimer()
|
||||
autoplayTimer?.invalidate()
|
||||
autoplayTimer = timer
|
||||
} else {
|
||||
timer?.invalidate()
|
||||
}
|
||||
self.item = item
|
||||
open(reason: .finishedWatching)
|
||||
}
|
||||
|
||||
func resetCountdownTimer() {
|
||||
countdownTimer?.invalidate()
|
||||
countdownTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in
|
||||
guard self.countdown > 0 else {
|
||||
timer.invalidate()
|
||||
return
|
||||
}
|
||||
self.countdown = max(0, self.countdown - 1)
|
||||
}
|
||||
}
|
||||
|
||||
func closed(_ item: PlayerQueueItem) {
|
||||
self.item = item
|
||||
open(reason: .closed)
|
||||
}
|
||||
|
||||
func keepFromAutoplaying() {
|
||||
userInteractedOpen(item)
|
||||
cancelAutoplay()
|
||||
}
|
||||
|
||||
func cancelAutoplay() {
|
||||
autoplayTimer?.invalidate()
|
||||
countdownTimer?.invalidate()
|
||||
}
|
||||
|
||||
func restart() {
|
||||
cancelAutoplay()
|
||||
|
||||
guard player.currentItem != nil else { return }
|
||||
|
||||
if reason == .closed {
|
||||
hide()
|
||||
return
|
||||
}
|
||||
|
||||
player.backend.seek(to: .zero, seekType: .loopRestart) { _ in
|
||||
self.hide()
|
||||
self.player.play()
|
||||
}
|
||||
}
|
||||
|
||||
private func open(reason: PresentationReason) {
|
||||
self.reason = reason
|
||||
setPageAfterOpening()
|
||||
|
||||
guard !isPresenting else { return }
|
||||
withAnimation(Self.animation) {
|
||||
isPresenting = true
|
||||
}
|
||||
}
|
||||
|
||||
private func setPageAfterOpening() {
|
||||
let firstAvailable = Page.allCases.first { isAvailable($0) } ?? .history
|
||||
|
||||
switch reason {
|
||||
case .finishedWatching:
|
||||
page = player.playbackMode == .related ? .queue : firstAvailable
|
||||
case .closed:
|
||||
page = player.playbackMode == .related ? .queue : firstAvailable
|
||||
default:
|
||||
page = firstAvailable
|
||||
}
|
||||
}
|
||||
|
||||
func close() {
|
||||
let close = {
|
||||
self.player.closeCurrentItem()
|
||||
self.player.hide()
|
||||
Delay.by(0.5) {
|
||||
self.isPresenting = false
|
||||
}
|
||||
}
|
||||
if reason == .closed {
|
||||
close()
|
||||
return
|
||||
}
|
||||
if canAutoplay {
|
||||
cancelAutoplay()
|
||||
hide()
|
||||
} else {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
func hide() {
|
||||
guard isPresenting else { return }
|
||||
withAnimation(Self.animation) {
|
||||
isPresenting = false
|
||||
}
|
||||
}
|
||||
|
||||
func resetItem() {
|
||||
item = nil
|
||||
}
|
||||
|
||||
func isAvailable(_ page: Page) -> Bool {
|
||||
switch page {
|
||||
case .queue:
|
||||
return !player.queue.isEmpty
|
||||
case .related:
|
||||
guard let video = item?.video else { return false }
|
||||
return !video.related.isEmpty
|
||||
case .history:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
var nextFromTheQueue: PlayerQueueItem? {
|
||||
if player.playbackMode == .related {
|
||||
return player.autoplayItem
|
||||
} else if player.playbackMode == .queue {
|
||||
return player.queue.first
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ final class ShareViewController: SLComposeServiceViewController {
|
||||
self.open(url: url)
|
||||
}
|
||||
|
||||
self.extensionContext!.completeRequest(returningItems: nil, completionHandler: nil)
|
||||
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,12 +37,6 @@ struct ChannelCell: View {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
var label: some View {
|
||||
labelContent
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
|
||||
.contentShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
var labelContent: some View {
|
||||
VStack {
|
||||
WebImage(url: channel.thumbnailURL, options: [.lowPriority])
|
||||
|
||||
@@ -6,19 +6,29 @@ struct ChannelPlaylistCell: View {
|
||||
|
||||
@Environment(\.navigationStyle) private var navigationStyle
|
||||
|
||||
var navigation = NavigationModel.shared
|
||||
|
||||
var body: some View {
|
||||
if navigationStyle == .tab {
|
||||
NavigationLink(destination: ChannelPlaylistView(playlist: playlist)) { cell }
|
||||
} else {
|
||||
Button {
|
||||
NavigationModel.shared.openChannelPlaylist(playlist, navigationStyle: navigationStyle)
|
||||
} label: {
|
||||
cell
|
||||
#if os(tvOS)
|
||||
button
|
||||
#else
|
||||
if navigationStyle == .tab {
|
||||
navigationLink
|
||||
} else {
|
||||
button
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
}
|
||||
|
||||
var navigationLink: some View {
|
||||
NavigationLink(destination: ChannelPlaylistView(playlist: playlist)) { cell }
|
||||
}
|
||||
|
||||
var button: some View {
|
||||
Button {
|
||||
NavigationModel.shared.openChannelPlaylist(playlist, navigationStyle: navigationStyle)
|
||||
} label: {
|
||||
cell
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
var cell: some View {
|
||||
|
||||
@@ -3,81 +3,68 @@ import Siesta
|
||||
import SwiftUI
|
||||
|
||||
struct ChannelPlaylistView: View {
|
||||
var playlist: ChannelPlaylist?
|
||||
var playlist: ChannelPlaylist
|
||||
var showCloseButton = false
|
||||
|
||||
@State private var presentingShareSheet = false
|
||||
@State private var shareURL: URL?
|
||||
|
||||
@StateObject private var store = Store<ChannelPlaylist>()
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.navigationStyle) private var navigationStyle
|
||||
@Default(.channelPlaylistListingStyle) private var channelPlaylistListingStyle
|
||||
@Default(.hideShorts) private var hideShorts
|
||||
|
||||
@ObservedObject private var accounts = AccountsModel.shared
|
||||
var player = PlayerModel.shared
|
||||
@ObservedObject private var recents = RecentsModel.shared
|
||||
|
||||
@State private var isLoading = false
|
||||
|
||||
private var items: [ContentItem] {
|
||||
ContentItem.array(of: store.item?.videos ?? [])
|
||||
}
|
||||
|
||||
private var presentedPlaylist: ChannelPlaylist? {
|
||||
playlist ?? recents.presentedPlaylist
|
||||
}
|
||||
|
||||
private var resource: Resource? {
|
||||
guard let playlist = presentedPlaylist else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let resource = accounts.api.channelPlaylist(playlist.id)
|
||||
resource?.addObserver(store)
|
||||
|
||||
return resource
|
||||
accounts.api.channelPlaylist(playlist.id)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
#if os(tvOS)
|
||||
HStack {
|
||||
if let playlist = presentedPlaylist {
|
||||
ThumbnailView(url: store.item?.thumbnailURL ?? playlist.thumbnailURL)
|
||||
.frame(width: 140, height: 80)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 2))
|
||||
ThumbnailView(url: store.item?.thumbnailURL ?? playlist.thumbnailURL)
|
||||
.frame(width: 140, height: 80)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 2))
|
||||
|
||||
Text(playlist.title)
|
||||
.font(.headline)
|
||||
.frame(alignment: .leading)
|
||||
.lineLimit(1)
|
||||
Text(playlist.title)
|
||||
.font(.headline)
|
||||
.frame(alignment: .leading)
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
|
||||
FavoriteButton(item: FavoriteItem(section: .channelPlaylist(accounts.app.appType.rawValue, playlist.id, playlist.title)))
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
FavoriteButton(item: FavoriteItem(section: .channelPlaylist(accounts.app.appType.rawValue, playlist.id, playlist.title)))
|
||||
.labelStyle(.iconOnly)
|
||||
|
||||
playButtons
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
#endif
|
||||
VerticalCells(items: items)
|
||||
VerticalCells(items: items, isLoading: isLoading)
|
||||
.environment(\.inChannelPlaylistView, true)
|
||||
}
|
||||
.environment(\.listingStyle, channelPlaylistListingStyle)
|
||||
.onAppear {
|
||||
if let playlist = presentedPlaylist,
|
||||
let cache = ChannelPlaylistsCacheModel.shared.retrievePlaylist(playlist)
|
||||
{
|
||||
if let cache = ChannelPlaylistsCacheModel.shared.retrievePlaylist(playlist) {
|
||||
store.replace(cache)
|
||||
}
|
||||
resource?.loadIfNeeded()?.onSuccess { response in
|
||||
if let playlist: ChannelPlaylist = response.typedContent() {
|
||||
ChannelPlaylistsCacheModel.shared.storePlaylist(playlist: playlist)
|
||||
isLoading = true
|
||||
resource?
|
||||
.load()
|
||||
.onSuccess { response in
|
||||
if let playlist: ChannelPlaylist = response.typedContent() {
|
||||
ChannelPlaylistsCacheModel.shared.storePlaylist(playlist: playlist)
|
||||
store.replace(playlist)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onCompletion { _ in isLoading = false }
|
||||
}
|
||||
#if os(tvOS)
|
||||
.background(Color.background(scheme: colorScheme))
|
||||
@@ -89,7 +76,6 @@ struct ChannelPlaylistView: View {
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#if os(macOS)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
if showCloseButton {
|
||||
@@ -101,11 +87,14 @@ struct ChannelPlaylistView: View {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
#if os(macOS)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: playlistButtonsPlacement) {
|
||||
HStack {
|
||||
ListingStyleButtons(listingStyle: $channelPlaylistListingStyle)
|
||||
HideShortsButtons(hide: $hideShorts)
|
||||
HideWatchedButtons()
|
||||
HideShortsButtons()
|
||||
ShareButton(contentItem: contentItem)
|
||||
|
||||
favoriteButton
|
||||
@@ -114,14 +103,12 @@ struct ChannelPlaylistView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(label)
|
||||
.navigationTitle(playlist.title)
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder private var favoriteButton: some View {
|
||||
if let playlist = presentedPlaylist {
|
||||
FavoriteButton(item: FavoriteItem(section: .channelPlaylist(accounts.app.appType.rawValue, playlist.id, playlist.title)))
|
||||
}
|
||||
FavoriteButton(item: FavoriteItem(section: .channelPlaylist(accounts.app.appType.rawValue, playlist.id, playlist.title)))
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
@@ -134,7 +121,8 @@ struct ChannelPlaylistView: View {
|
||||
ListingStyleButtons(listingStyle: $channelPlaylistListingStyle)
|
||||
|
||||
Section {
|
||||
HideShortsButtons(hide: $hideShorts)
|
||||
HideWatchedButtons()
|
||||
HideShortsButtons()
|
||||
}
|
||||
|
||||
Section {
|
||||
@@ -142,13 +130,13 @@ struct ChannelPlaylistView: View {
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
if let url = store.item?.thumbnailURL ?? playlist?.thumbnailURL {
|
||||
if let url = store.item?.thumbnailURL ?? playlist.thumbnailURL {
|
||||
ThumbnailView(url: url)
|
||||
.frame(width: 60, height: 30)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 2))
|
||||
}
|
||||
|
||||
Text(label)
|
||||
Text(playlist.title)
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
@@ -162,10 +150,6 @@ struct ChannelPlaylistView: View {
|
||||
}
|
||||
#endif
|
||||
|
||||
private var label: String {
|
||||
presentedPlaylist?.title ?? ""
|
||||
}
|
||||
|
||||
private var playlistButtonsPlacement: ToolbarItemPlacement {
|
||||
#if os(iOS)
|
||||
.navigationBarTrailing
|
||||
|
||||
@@ -4,8 +4,9 @@ import Siesta
|
||||
import SwiftUI
|
||||
|
||||
struct ChannelVideosView: View {
|
||||
var channel: Channel?
|
||||
var channel: Channel
|
||||
var showCloseButton = false
|
||||
var inNavigationView = true
|
||||
|
||||
@State private var presentingShareSheet = false
|
||||
@State private var shareURL: URL?
|
||||
@@ -20,10 +21,6 @@ struct ChannelVideosView: View {
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
#if os(iOS)
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
#endif
|
||||
|
||||
@ObservedObject private var accounts = AccountsModel.shared
|
||||
@ObservedObject private var feed = FeedModel.shared
|
||||
@ObservedObject private var navigation = NavigationModel.shared
|
||||
@@ -33,14 +30,13 @@ struct ChannelVideosView: View {
|
||||
|
||||
@Default(.channelPlaylistListingStyle) private var channelPlaylistListingStyle
|
||||
@Default(.expandChannelDescription) private var expandChannelDescription
|
||||
@Default(.hideShorts) private var hideShorts
|
||||
|
||||
var presentedChannel: Channel? {
|
||||
store.item?.channel ?? channel ?? recents.presentedChannel
|
||||
store.item?.channel ?? channel
|
||||
}
|
||||
|
||||
var contentItems: [ContentItem] {
|
||||
return contentTypeItems.collection
|
||||
contentTypeItems.collection
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -69,7 +65,7 @@ struct ChannelVideosView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
#endif
|
||||
|
||||
VerticalCells(items: contentItems) {
|
||||
VerticalCells(items: contentItems, isLoading: resource?.isLoading ?? false, edgesIgnoringSafeArea: verticalCellsEdgesIgnoringSafeArea) {
|
||||
if let description = presentedChannel?.description, !description.isEmpty {
|
||||
Button {
|
||||
withAnimation(.spring()) {
|
||||
@@ -101,7 +97,6 @@ struct ChannelVideosView: View {
|
||||
.environment(\.loadMoreContentHandler) { loadNextPage() }
|
||||
.environment(\.inChannelView, true)
|
||||
.environment(\.listingStyle, channelPlaylistListingStyle)
|
||||
.environment(\.hideShorts, hideShorts)
|
||||
#if os(tvOS)
|
||||
.prefersDefaultFocus(in: focusNamespace)
|
||||
#endif
|
||||
@@ -119,51 +114,50 @@ struct ChannelVideosView: View {
|
||||
Button {
|
||||
withAnimation(Constants.overlayAnimation) {
|
||||
navigation.presentingChannel = false
|
||||
navigation.presentingChannelSheet = false
|
||||
}
|
||||
} label: {
|
||||
Label("Close", systemImage: "xmark")
|
||||
}
|
||||
#if !os(macOS)
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#if !os(iOS)
|
||||
#if os(macOS)
|
||||
ToolbarItem(placement: .navigation) {
|
||||
thumbnail
|
||||
}
|
||||
ToolbarItem {
|
||||
ToolbarItemGroup {
|
||||
if !inNavigationView {
|
||||
Text(navigationTitle)
|
||||
.fontWeight(.bold)
|
||||
}
|
||||
|
||||
ListingStyleButtons(listingStyle: $channelPlaylistListingStyle)
|
||||
}
|
||||
ToolbarItem {
|
||||
HideShortsButtons(hide: $hideShorts)
|
||||
}
|
||||
ToolbarItem {
|
||||
HideWatchedButtons()
|
||||
HideShortsButtons()
|
||||
contentTypePicker
|
||||
}
|
||||
|
||||
ToolbarItem {
|
||||
ToolbarItemGroup {
|
||||
HStack(spacing: 3) {
|
||||
subscriptionsLabel
|
||||
viewsLabel
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem {
|
||||
if let contentItem = presentedChannel?.contentItem {
|
||||
ShareButton(contentItem: contentItem)
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem {
|
||||
subscriptionToggleButton
|
||||
.layoutPriority(2)
|
||||
}
|
||||
|
||||
ToolbarItem {
|
||||
favoriteButton
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
|
||||
ToolbarItem {
|
||||
toggleWatchedButton
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -171,10 +165,7 @@ struct ChannelVideosView: View {
|
||||
.onAppear {
|
||||
descriptionExpanded = expandChannelDescription
|
||||
|
||||
if let channel,
|
||||
let cache = ChannelsCacheModel.shared.retrieve(channel.cacheKey),
|
||||
store.item.isNil
|
||||
{
|
||||
if let cache = ChannelsCacheModel.shared.retrieve(channel.cacheKey), store.item.isNil {
|
||||
store.replace(cache)
|
||||
}
|
||||
|
||||
@@ -205,6 +196,14 @@ struct ChannelVideosView: View {
|
||||
}
|
||||
}
|
||||
|
||||
var verticalCellsEdgesIgnoringSafeArea: Edge.Set {
|
||||
#if os(tvOS)
|
||||
return .horizontal
|
||||
#else
|
||||
return .init()
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder var favoriteButton: some View {
|
||||
if let presentedChannel {
|
||||
FavoriteButton(item: FavoriteItem(section: .channel(accounts.app.appType.rawValue, presentedChannel.id, presentedChannel.name)))
|
||||
@@ -234,14 +233,14 @@ struct ChannelVideosView: View {
|
||||
Group {
|
||||
if let subscribers = store.item?.channel?.subscriptionsString {
|
||||
HStack(spacing: 0) {
|
||||
Text(subscribers)
|
||||
Image(systemName: "person.2.fill")
|
||||
Text(subscribers)
|
||||
}
|
||||
} else if store.item.isNil {
|
||||
HStack(spacing: 0) {
|
||||
Image(systemName: "person.2.fill")
|
||||
Text("1234")
|
||||
.redacted(reason: .placeholder)
|
||||
Image(systemName: "person.2.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -252,10 +251,10 @@ struct ChannelVideosView: View {
|
||||
var viewsLabel: some View {
|
||||
HStack(spacing: 0) {
|
||||
if let views = store.item?.channel?.totalViewsString {
|
||||
Text(views)
|
||||
|
||||
Image(systemName: "eye.fill")
|
||||
.imageScale(.small)
|
||||
|
||||
Text(views)
|
||||
}
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
@@ -278,7 +277,8 @@ struct ChannelVideosView: View {
|
||||
ListingStyleButtons(listingStyle: $channelPlaylistListingStyle)
|
||||
|
||||
Section {
|
||||
HideShortsButtons(hide: $hideShorts)
|
||||
HideWatchedButtons()
|
||||
HideShortsButtons()
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
@@ -320,7 +320,7 @@ struct ChannelVideosView: View {
|
||||
|
||||
private var contentTypePicker: some View {
|
||||
Picker("Content type", selection: $contentType) {
|
||||
if let channel = presentedChannel {
|
||||
if presentedChannel != nil {
|
||||
ForEach(Channel.ContentType.allCases, id: \.self) { type in
|
||||
if typeAvailable(type) {
|
||||
Label(type.description, systemImage: type.systemImage).tag(type)
|
||||
@@ -328,6 +328,7 @@ struct ChannelVideosView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
}
|
||||
|
||||
private func typeAvailable(_ type: Channel.ContentType) -> Bool {
|
||||
@@ -424,18 +425,20 @@ struct ChannelVideosView: View {
|
||||
}
|
||||
|
||||
func load() {
|
||||
resource?.load().onSuccess { response in
|
||||
if let page: ChannelPage = response.typedContent() {
|
||||
if let channel = page.channel {
|
||||
ChannelsCacheModel.shared.store(channel)
|
||||
resource?
|
||||
.load()
|
||||
.onSuccess { response in
|
||||
if let page: ChannelPage = response.typedContent() {
|
||||
if let channel = page.channel {
|
||||
ChannelsCacheModel.shared.store(channel)
|
||||
}
|
||||
self.page = page
|
||||
self.contentTypeItems.replace(page.results)
|
||||
}
|
||||
self.page = page
|
||||
self.contentTypeItems.replace(page.results)
|
||||
}
|
||||
}
|
||||
.onFailure { error in
|
||||
navigation.presentAlert(title: "Could not load channel data", message: error.userMessage)
|
||||
}
|
||||
.onFailure { error in
|
||||
navigation.presentAlert(title: "Could not load channel data", message: error.userMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func loadNextPage() {
|
||||
@@ -463,7 +466,7 @@ struct ChannelVideosView: View {
|
||||
struct ChannelVideosView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
#if os(macOS)
|
||||
ChannelVideosView(channel: Video.fixture.channel)
|
||||
ChannelVideosView(channel: Video.fixture.channel, showCloseButton: true, inNavigationView: false)
|
||||
.environment(\.navigationStyle, .sidebar)
|
||||
#else
|
||||
NavigationView {
|
||||
|
||||
@@ -13,6 +13,14 @@ struct Constants {
|
||||
#endif
|
||||
}
|
||||
|
||||
static var isIPad: Bool {
|
||||
#if os(iOS)
|
||||
UIDevice.current.userInterfaceIdiom == .pad
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
|
||||
static var progressViewScale: Double {
|
||||
#if os(macOS)
|
||||
0.4
|
||||
@@ -53,14 +61,6 @@ struct Constants {
|
||||
#endif
|
||||
}
|
||||
|
||||
static var nextSystemImage: String {
|
||||
if #available(iOS 16, macOS 13, tvOS 16, *) {
|
||||
return "film.stack"
|
||||
} else {
|
||||
return "list.and.film"
|
||||
}
|
||||
}
|
||||
|
||||
static func seekIcon(_ type: String, _ interval: TimeInterval) -> String {
|
||||
let interval = Int(interval)
|
||||
let allVersions = [10, 15, 30, 45, 60, 75, 90]
|
||||
|
||||
@@ -28,7 +28,6 @@ extension Defaults.Keys {
|
||||
static let showFavoritesInHome = Key<Bool>("showFavoritesInHome", default: true)
|
||||
#if os(iOS)
|
||||
static let showDocuments = Key<Bool>("showDocuments", default: false)
|
||||
static let homeRecentDocumentsItems = Key<Int>("homeRecentDocumentsItems", default: 3)
|
||||
#endif
|
||||
static let homeHistoryItems = Key<Int>("homeHistoryItems", default: 10)
|
||||
static let favorites = Key<[FavoriteItem]>("favorites", default: [])
|
||||
@@ -52,6 +51,8 @@ extension Defaults.Keys {
|
||||
static let lockPortraitWhenBrowsing = Key<Bool>("lockPortraitWhenBrowsing", default: UIDevice.current.userInterfaceIdiom == .phone)
|
||||
#endif
|
||||
static let showUnwatchedFeedBadges = Key<Bool>("showUnwatchedFeedBadges", default: false)
|
||||
static let keepChannelsWithUnwatchedFeedOnTop = Key<Bool>("keepChannelsWithUnwatchedFeedOnTop", default: true)
|
||||
static let showToggleWatchedStatusButton = Key<Bool>("showToggleWatchedStatusButton", default: false)
|
||||
static let expandChannelDescription = Key<Bool>("expandChannelDescription", default: false)
|
||||
static let channelOnThumbnail = Key<Bool>("channelOnThumbnail", default: false)
|
||||
static let timeOnThumbnail = Key<Bool>("timeOnThumbnail", default: true)
|
||||
@@ -71,10 +72,12 @@ extension Defaults.Keys {
|
||||
static let qualityProfilesDefault = UIDevice.current.userInterfaceIdiom == .pad ? [
|
||||
hd2160pMPVProfile,
|
||||
hd1080pMPVProfile,
|
||||
hd720pMPVProfile,
|
||||
hd720pAVPlayerProfile,
|
||||
sd360pAVPlayerProfile
|
||||
] : [
|
||||
hd1080pMPVProfile,
|
||||
hd720pMPVProfile,
|
||||
hd720pAVPlayerProfile,
|
||||
sd360pAVPlayerProfile
|
||||
]
|
||||
@@ -87,6 +90,7 @@ extension Defaults.Keys {
|
||||
static let qualityProfilesDefault = [
|
||||
hd2160pMPVProfile,
|
||||
hd1080pMPVProfile,
|
||||
hd720pMPVProfile,
|
||||
hd720pAVPlayerProfile
|
||||
]
|
||||
static let batteryCellularProfileDefault = hd1080pMPVProfile.id
|
||||
@@ -97,6 +101,7 @@ extension Defaults.Keys {
|
||||
static let qualityProfilesDefault = [
|
||||
hd2160pMPVProfile,
|
||||
hd1080pMPVProfile,
|
||||
hd720pMPVProfile,
|
||||
hd720pAVPlayerProfile
|
||||
]
|
||||
static let batteryCellularProfileDefault = hd1080pMPVProfile.id
|
||||
@@ -127,14 +132,22 @@ extension Defaults.Keys {
|
||||
|
||||
static let playerControlsLayout = Key<PlayerControlsLayout>("playerControlsLayout", default: playerControlsLayoutDefault)
|
||||
static let fullScreenPlayerControlsLayout = Key<PlayerControlsLayout>("fullScreenPlayerControlsLayout", default: fullScreenPlayerControlsLayoutDefault)
|
||||
static let avPlayerUsesSystemControls = Key<Bool>("avPlayerUsesSystemControls", default: true)
|
||||
static let horizontalPlayerGestureEnabled = Key<Bool>("horizontalPlayerGestureEnabled", default: true)
|
||||
static let seekGestureSpeed = Key<Double>("seekGestureSpeed", default: 0.5)
|
||||
static let seekGestureSensitivity = Key<Double>("seekGestureSensitivity", default: 30.0)
|
||||
static let showKeywords = Key<Bool>("showKeywords", default: false)
|
||||
#if !os(tvOS)
|
||||
static let commentsPlacement = Key<CommentsPlacement>("commentsPlacement", default: .separate)
|
||||
static let showScrollToTopInComments = Key<Bool>("showScrollToTopInComments", default: true)
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
static let expandVideoDescriptionDefault = Constants.isIPad
|
||||
#else
|
||||
static let expandVideoDescriptionDefault = true
|
||||
#endif
|
||||
static let expandVideoDescription = Key<Bool>("expandVideoDescription", default: expandVideoDescriptionDefault)
|
||||
|
||||
#if os(tvOS)
|
||||
static let pauseOnHidingPlayerDefault = true
|
||||
#else
|
||||
@@ -145,6 +158,7 @@ extension Defaults.Keys {
|
||||
#if !os(macOS)
|
||||
static let pauseOnEnteringBackground = Key<Bool>("pauseOnEnteringBackground", default: true)
|
||||
#endif
|
||||
static let closeVideoOnEOF = Key<Bool>("closeVideoOnEOF", default: false)
|
||||
static let closePiPOnNavigation = Key<Bool>("closePiPOnNavigation", default: false)
|
||||
static let closePiPOnOpeningPlayer = Key<Bool>("closePiPOnOpeningPlayer", default: false)
|
||||
#if !os(macOS)
|
||||
@@ -172,14 +186,19 @@ extension Defaults.Keys {
|
||||
static let trendingCountry = Key<Country>("trendingCountry", default: .us)
|
||||
|
||||
static let visibleSections = Key<Set<VisibleSection>>("visibleSections", default: [.subscriptions, .trending, .playlists])
|
||||
static let startupSection = Key<StartupSection>("startupSection", default: .home)
|
||||
|
||||
#if os(iOS)
|
||||
static let honorSystemOrientationLock = Key<Bool>("honorSystemOrientationLock", default: true)
|
||||
static let enterFullscreenInLandscape = Key<Bool>("enterFullscreenInLandscape", default: UIDevice.current.userInterfaceIdiom == .phone)
|
||||
static let rotateToPortraitOnExitFullScreen = Key<Bool>("rotateToPortraitOnExitFullScreen", default: UIDevice.current.userInterfaceIdiom == .phone)
|
||||
static let rotateToLandscapeOnEnterFullScreen = Key<FullScreenRotationSetting>(
|
||||
"rotateToLandscapeOnEnterFullScreen",
|
||||
default: UIDevice.current.userInterfaceIdiom == .phone ? .landscapeRight : .disabled
|
||||
)
|
||||
#endif
|
||||
|
||||
static let showMPVPlaybackStats = Key<Bool>("showMPVPlaybackStats", default: false)
|
||||
static let showPlayNowInBackendContextMenu = Key<Bool>("showPlayNowInBackendContextMenu", default: false)
|
||||
|
||||
#if os(macOS)
|
||||
static let playerDetailsPageButtonLabelStyleDefault = ButtonLabelStyle.iconAndText
|
||||
@@ -199,10 +218,14 @@ extension Defaults.Keys {
|
||||
static let actionButtonAddToPlaylistEnabled = Key<Bool>("actionButtonAddToPlaylistEnabled", default: true)
|
||||
static let actionButtonSubscribeEnabled = Key<Bool>("actionButtonSubscribeEnabled", default: false)
|
||||
static let actionButtonSettingsEnabled = Key<Bool>("actionButtonSettingsEnabled", default: true)
|
||||
static let actionButtonNextEnabled = Key<Bool>("actionButtonNextEnabled", default: true)
|
||||
static let actionButtonHideEnabled = Key<Bool>("actionButtonHideEnabled", default: false)
|
||||
static let actionButtonCloseEnabled = Key<Bool>("actionButtonCloseEnabled", default: true)
|
||||
static let actionButtonNextQueueCountEnabled = Key<Bool>("actionButtonNextQueueCountEnabled", default: true)
|
||||
static let actionButtonFullScreenEnabled = Key<Bool>("actionButtonFullScreenEnabled", default: false)
|
||||
static let actionButtonPipEnabled = Key<Bool>("actionButtonPipEnabled", default: false)
|
||||
static let actionButtonLockOrientationEnabled = Key<Bool>("actionButtonLockOrientationEnabled", default: false)
|
||||
static let actionButtonRestartEnabled = Key<Bool>("actionButtonRestartEnabled", default: false)
|
||||
static let actionButtonAdvanceToNextItemEnabled = Key<Bool>("actionButtonAdvanceToNextItemEnabled", default: false)
|
||||
static let actionButtonMusicModeEnabled = Key<Bool>("actionButtonMusicModeEnabled", default: true)
|
||||
|
||||
#if os(iOS)
|
||||
static let playerControlsLockOrientationEnabled = Key<Bool>("playerControlsLockOrientationEnabled", default: true)
|
||||
@@ -217,8 +240,7 @@ extension Defaults.Keys {
|
||||
static let playerControlsRestartEnabled = Key<Bool>("playerControlsRestartEnabled", default: false)
|
||||
static let playerControlsAdvanceToNextEnabled = Key<Bool>("playerControlsAdvanceToNextEnabled", default: false)
|
||||
static let playerControlsPlaybackModeEnabled = Key<Bool>("playerControlsPlaybackModeEnabled", default: false)
|
||||
static let playerControlsNextEnabled = Key<Bool>("playerControlsNextEnabled", default: true)
|
||||
static let playerControlsMusicModeEnabled = Key<Bool>("playerControlsMusicModeEnabled", default: true)
|
||||
static let playerControlsMusicModeEnabled = Key<Bool>("playerControlsMusicModeEnabled", default: false)
|
||||
|
||||
static let mpvCacheSecs = Key<String>("mpvCacheSecs", default: "120")
|
||||
static let mpvCachePauseWait = Key<String>("mpvCachePauseWait", default: "3")
|
||||
@@ -232,15 +254,13 @@ extension Defaults.Keys {
|
||||
static let subscriptionsListingStyle = Key<ListingStyle>("subscriptionsListingStyle", default: .cells)
|
||||
static let popularListingStyle = Key<ListingStyle>("popularListingStyle", default: .cells)
|
||||
static let trendingListingStyle = Key<ListingStyle>("trendingListingStyle", default: .cells)
|
||||
static let playlistListingStyle = Key<ListingStyle>("playlistListingStyle", default: .cells)
|
||||
static let playlistListingStyle = Key<ListingStyle>("playlistListingStyle", default: .list)
|
||||
static let channelPlaylistListingStyle = Key<ListingStyle>("channelPlaylistListingStyle", default: .cells)
|
||||
static let searchListingStyle = Key<ListingStyle>("searchListingStyle", default: .cells)
|
||||
|
||||
static let openWatchNextOnFinishedWatching = Key<Bool>("openWatchNextOnFinishedWatching", default: true)
|
||||
static let openWatchNextOnClose = Key<Bool>("openWatchNextOnClose", default: false)
|
||||
static let openWatchNextOnFinishedWatchingDelay = Key<String>("openWatchNextOnFinishedWatchingDelay", default: "5")
|
||||
|
||||
static let hideShorts = Key<Bool>("hideShorts", default: false)
|
||||
static let hideWatched = Key<Bool>("hideWatched", default: false)
|
||||
static let showInspector = Key<ShowInspectorSetting>("showInspector", default: .onlyLocal)
|
||||
static let widgetsSettings = Key<[WidgetSettings]>("widgetsSettings", default: [])
|
||||
}
|
||||
|
||||
enum ResolutionSetting: String, CaseIterable, Defaults.Serializable {
|
||||
@@ -323,6 +343,31 @@ enum VisibleSection: String, CaseIterable, Comparable, Defaults.Serializable {
|
||||
}
|
||||
}
|
||||
|
||||
enum StartupSection: String, CaseIterable, Defaults.Serializable {
|
||||
case home, subscriptions, popular, trending, playlists, search
|
||||
|
||||
var label: String {
|
||||
rawValue.capitalized.localized()
|
||||
}
|
||||
|
||||
var tabSelection: TabSelection {
|
||||
switch self {
|
||||
case .home:
|
||||
return .home
|
||||
case .subscriptions:
|
||||
return .subscriptions
|
||||
case .popular:
|
||||
return .popular
|
||||
case .trending:
|
||||
return .trending
|
||||
case .playlists:
|
||||
return .playlists
|
||||
case .search:
|
||||
return .search
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum WatchedVideoStyle: String, Defaults.Serializable {
|
||||
case nothing, badge, decreasedOpacity, both
|
||||
|
||||
@@ -343,12 +388,6 @@ enum WatchedVideoPlayNowBehavior: String, Defaults.Serializable {
|
||||
case `continue`, restart
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
enum CommentsPlacement: String, CaseIterable, Defaults.Serializable {
|
||||
case info, separate
|
||||
}
|
||||
#endif
|
||||
|
||||
enum ButtonLabelStyle: String, CaseIterable, Defaults.Serializable {
|
||||
case iconOnly, iconAndText
|
||||
|
||||
@@ -411,3 +450,83 @@ enum PlayerTapGestureAction: String, CaseIterable, Defaults.Serializable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum FullScreenRotationSetting: String, CaseIterable, Defaults.Serializable {
|
||||
case disabled
|
||||
case landscapeLeft
|
||||
case landscapeRight
|
||||
|
||||
#if os(iOS)
|
||||
var interaceOrientation: UIInterfaceOrientation {
|
||||
switch self {
|
||||
case .landscapeLeft:
|
||||
return .landscapeLeft
|
||||
case .landscapeRight:
|
||||
return .landscapeRight
|
||||
default:
|
||||
return .portrait
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
var isRotating: Bool {
|
||||
self != .disabled
|
||||
}
|
||||
}
|
||||
|
||||
struct WidgetSettings: Defaults.Serializable {
|
||||
static let defaultLimit = 10
|
||||
static let maxLimit: [WidgetListingStyle: Int] = [
|
||||
.horizontalCells: 50,
|
||||
.list: 50
|
||||
]
|
||||
|
||||
static var bridge = WidgetSettingsBridge()
|
||||
|
||||
var id: String
|
||||
var listingStyle = WidgetListingStyle.horizontalCells
|
||||
var limit = Self.defaultLimit
|
||||
|
||||
var viewID: String {
|
||||
"\(id)-\(listingStyle.rawValue)-\(limit)"
|
||||
}
|
||||
|
||||
static func maxLimit(_ style: WidgetListingStyle) -> Int {
|
||||
Self.maxLimit[style] ?? Self.defaultLimit
|
||||
}
|
||||
}
|
||||
|
||||
struct WidgetSettingsBridge: Defaults.Bridge {
|
||||
typealias Value = WidgetSettings
|
||||
typealias Serializable = [String: String]
|
||||
|
||||
func serialize(_ value: Value?) -> Serializable? {
|
||||
guard let value else { return nil }
|
||||
|
||||
return [
|
||||
"id": value.id,
|
||||
"listingStyle": value.listingStyle.rawValue,
|
||||
"limit": String(value.limit)
|
||||
]
|
||||
}
|
||||
|
||||
func deserialize(_ object: Serializable?) -> Value? {
|
||||
guard let object, let id = object["id"], !id.isEmpty else { return nil }
|
||||
var listingStyle = WidgetListingStyle.horizontalCells
|
||||
if let style = object["listingStyle"] {
|
||||
listingStyle = WidgetListingStyle(rawValue: style) ?? .horizontalCells
|
||||
}
|
||||
let limit = Int(object["limit"] ?? "\(WidgetSettings.defaultLimit)") ?? WidgetSettings.defaultLimit
|
||||
|
||||
return Value(
|
||||
id: id,
|
||||
listingStyle: listingStyle,
|
||||
limit: limit
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum WidgetListingStyle: String, CaseIterable, Defaults.Serializable {
|
||||
case horizontalCells
|
||||
case list
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ struct DocumentsView: View {
|
||||
|
||||
Group {
|
||||
if model.isDirectory(standardizedURL) {
|
||||
NavigationLink(destination: DocumentsView(directoryURL: url)) {
|
||||
NavigationLink(destination: Self(directoryURL: url)) {
|
||||
VideoBanner(video: video)
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -62,14 +62,6 @@ private struct LoadMoreContentHandler: EnvironmentKey {
|
||||
static let defaultValue: LoadMoreContentHandlerType = {}
|
||||
}
|
||||
|
||||
private struct ScrollViewBottomPaddingKey: EnvironmentKey {
|
||||
static let defaultValue: Double = 30
|
||||
}
|
||||
|
||||
private struct HideShortsKey: EnvironmentKey {
|
||||
static let defaultValue = false
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var inChannelView: Bool {
|
||||
get { self[InChannelViewKey.self] }
|
||||
@@ -101,11 +93,6 @@ extension EnvironmentValues {
|
||||
set { self[LoadMoreContentHandler.self] = newValue }
|
||||
}
|
||||
|
||||
var scrollViewBottomPadding: Double {
|
||||
get { self[ScrollViewBottomPaddingKey.self] }
|
||||
set { self[ScrollViewBottomPaddingKey.self] = newValue }
|
||||
}
|
||||
|
||||
var listingStyle: ListingStyle {
|
||||
get { self[ListingStyleKey.self] }
|
||||
set { self[ListingStyleKey.self] = newValue }
|
||||
@@ -125,9 +112,4 @@ extension EnvironmentValues {
|
||||
get { self[NoListingDividersKey.self] }
|
||||
set { self[NoListingDividersKey.self] = newValue }
|
||||
}
|
||||
|
||||
var hideShorts: Bool {
|
||||
get { self[HideShortsKey.self] }
|
||||
set { self[HideShortsKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,21 @@ struct FavoriteItemView: View {
|
||||
@Environment(\.navigationStyle) private var navigationStyle
|
||||
@StateObject private var store = FavoriteResourceObserver()
|
||||
|
||||
@Default(.favorites) private var favorites
|
||||
|
||||
@ObservedObject private var accounts = AccountsModel.shared
|
||||
private var playlists = PlaylistsModel.shared
|
||||
private var favoritesModel = FavoritesModel.shared
|
||||
private var navigation = NavigationModel.shared
|
||||
@ObservedObject private var player = PlayerModel.shared
|
||||
@ObservedObject private var watchModel = WatchModel.shared
|
||||
|
||||
@FetchRequest(sortDescriptors: [.init(key: "watchedAt", ascending: false)])
|
||||
var watches: FetchedResults<Watch>
|
||||
@State private var visibleWatches = [Watch]()
|
||||
|
||||
@Default(.hideShorts) private var hideShorts
|
||||
@Default(.hideWatched) private var hideWatched
|
||||
@Default(.widgetsSettings) private var widgetsSettings
|
||||
@Default(.visibleSections) private var visibleSections
|
||||
|
||||
init(item: FavoriteItem) {
|
||||
self.item = item
|
||||
@@ -25,13 +34,7 @@ struct FavoriteItemView: View {
|
||||
if isVisible {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
itemControl
|
||||
.contextMenu {
|
||||
Button {
|
||||
favoritesModel.remove(item)
|
||||
} label: {
|
||||
Label("Remove from Favorites", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.contextMenu { contextMenu }
|
||||
.contentShape(Rectangle())
|
||||
#if os(tvOS)
|
||||
.padding(.leading, 40)
|
||||
@@ -39,20 +42,148 @@ struct FavoriteItemView: View {
|
||||
.padding(.leading, 15)
|
||||
#endif
|
||||
|
||||
HorizontalCells(items: store.contentItems)
|
||||
if limitedItems.isEmpty {
|
||||
EmptyItems(isLoading: resource?.isLoading ?? false) { reloadVisibleWatches() }
|
||||
.padding(.vertical, 10)
|
||||
#if os(tvOS)
|
||||
.padding(.horizontal, 40)
|
||||
#else
|
||||
.padding(.horizontal, 15)
|
||||
#endif
|
||||
} else {
|
||||
Group {
|
||||
switch widgetListingStyle {
|
||||
case .horizontalCells:
|
||||
HorizontalCells(items: limitedItems)
|
||||
case .list:
|
||||
ListView(items: limitedItems)
|
||||
.padding(.vertical, 10)
|
||||
#if os(tvOS)
|
||||
.padding(.leading, 40)
|
||||
#else
|
||||
.padding(.leading, 15)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.environment(\.inChannelView, inChannelView)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
|
||||
.onAppear {
|
||||
resource?.addObserver(store)
|
||||
loadCacheAndResource()
|
||||
if item.section == .history {
|
||||
reloadVisibleWatches()
|
||||
} else {
|
||||
resource?.addObserver(store)
|
||||
loadCacheAndResource()
|
||||
}
|
||||
}
|
||||
.onChange(of: player.currentVideo) { _ in reloadVisibleWatches() }
|
||||
.onChange(of: hideShorts) { _ in reloadVisibleWatches() }
|
||||
.onChange(of: hideWatched) { _ in reloadVisibleWatches() }
|
||||
}
|
||||
}
|
||||
.id(watchModel.historyToken)
|
||||
.onChange(of: accounts.current) { _ in
|
||||
resource?.addObserver(store)
|
||||
loadCacheAndResource(force: true)
|
||||
}
|
||||
.onChange(of: watchModel.historyToken) { _ in
|
||||
Delay.by(0.5) {
|
||||
reloadVisibleWatches()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
Defaults.observe(.widgetsSettings) { _ in
|
||||
watchModel.watchesChanged()
|
||||
}
|
||||
.tieToLifetime(of: accounts)
|
||||
}
|
||||
}
|
||||
|
||||
var contextMenu: some View {
|
||||
Group {
|
||||
if item.section == .history {
|
||||
Section {
|
||||
Button {
|
||||
navigation.presentAlert(
|
||||
Alert(
|
||||
title: Text("Are you sure you want to clear history of watched videos?"),
|
||||
message: Text("This cannot be reverted"),
|
||||
primaryButton: .destructive(Text("Clear All")) {
|
||||
PlayerModel.shared.removeHistory()
|
||||
visibleWatches = []
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
)
|
||||
} label: {
|
||||
Label("Clear History", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
favoritesModel.remove(item)
|
||||
} label: {
|
||||
Label("Remove from Favorites", systemImage: "trash")
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
Button("Cancel", role: .cancel) {}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func reloadVisibleWatches() {
|
||||
guard item.section == .history else { return }
|
||||
|
||||
visibleWatches = []
|
||||
|
||||
let watches = Array(
|
||||
watches
|
||||
.filter { $0.videoID != player.currentVideo?.videoID && itemVisible(.init(video: $0.video)) }
|
||||
.prefix(favoritesModel.limit(item))
|
||||
)
|
||||
let last = watches.last
|
||||
watches.forEach { watch in
|
||||
player.loadHistoryVideoDetails(watch) {
|
||||
guard let video = player.historyVideo(watch.videoID), itemVisible(.init(video: video)) else { return }
|
||||
visibleWatches.append(watch)
|
||||
guard watch == last else { return }
|
||||
visibleWatches.sort { $0.watchedAt ?? Date() > $1.watchedAt ?? Date() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var limitedItems: [ContentItem] {
|
||||
var items: [ContentItem]
|
||||
if item.section == .history {
|
||||
items = visibleWatches.map { ContentItem(video: player.historyVideo($0.videoID) ?? $0.video) }
|
||||
} else {
|
||||
items = store.contentItems.filter { itemVisible($0) }
|
||||
}
|
||||
return Array(items.prefix(favoritesModel.limit(item)))
|
||||
}
|
||||
|
||||
func itemVisible(_ item: ContentItem) -> Bool {
|
||||
if hideWatched, watch(item)?.finished ?? false {
|
||||
return false
|
||||
}
|
||||
|
||||
guard hideShorts, item.contentType == .video, let video = item.video else {
|
||||
return true
|
||||
}
|
||||
|
||||
return !video.short
|
||||
}
|
||||
|
||||
func watch(_ item: ContentItem) -> Watch? {
|
||||
watches.first { $0.videoID == item.video.videoID }
|
||||
}
|
||||
|
||||
var widgetListingStyle: WidgetListingStyle {
|
||||
favoritesModel.listingStyle(item)
|
||||
}
|
||||
|
||||
func loadCacheAndResource(force: Bool = false) {
|
||||
@@ -88,6 +219,12 @@ struct FavoriteItemView: View {
|
||||
channel.videos = videos
|
||||
ChannelsCacheModel.shared.store(channel)
|
||||
store.contentItems = ContentItem.array(of: videos)
|
||||
} else if let channelPage: ChannelPage = response.typedContent() {
|
||||
if let channel = channelPage.channel {
|
||||
ChannelsCacheModel.shared.store(channel)
|
||||
}
|
||||
|
||||
store.contentItems = channelPage.results
|
||||
}
|
||||
}
|
||||
case let .channelPlaylist(_, id, title):
|
||||
@@ -123,6 +260,21 @@ struct FavoriteItemView: View {
|
||||
}
|
||||
}
|
||||
|
||||
var navigatableItem: Bool {
|
||||
switch item.section {
|
||||
case .history:
|
||||
return false
|
||||
case .trending:
|
||||
return visibleSections.contains(.trending)
|
||||
case .subscriptions:
|
||||
return visibleSections.contains(.subscriptions) && accounts.signedIn
|
||||
case .popular:
|
||||
return visibleSections.contains(.popular) && accounts.app.supportsPopular
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
var inChannelView: Bool {
|
||||
switch item.section {
|
||||
case .channel:
|
||||
@@ -134,15 +286,20 @@ struct FavoriteItemView: View {
|
||||
|
||||
var itemControl: some View {
|
||||
VStack {
|
||||
#if os(tvOS)
|
||||
itemButton
|
||||
#else
|
||||
if itemIsNavigationLink {
|
||||
itemNavigationLink
|
||||
} else {
|
||||
if navigatableItem {
|
||||
#if os(tvOS)
|
||||
itemButton
|
||||
}
|
||||
#endif
|
||||
#else
|
||||
if itemIsNavigationLink {
|
||||
itemNavigationLink
|
||||
} else {
|
||||
itemButton
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
itemLabel
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +308,9 @@ struct FavoriteItemView: View {
|
||||
itemLabel
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
}
|
||||
|
||||
var itemNavigationLink: some View {
|
||||
@@ -216,6 +375,8 @@ struct FavoriteItemView: View {
|
||||
navigation.openSearchQuery(text)
|
||||
case let .playlist(_, id):
|
||||
navigation.tabSelection = .playlist(id)
|
||||
case .history:
|
||||
print("should not happen")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,8 +384,10 @@ struct FavoriteItemView: View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.title3.bold())
|
||||
Image(systemName: "chevron.right")
|
||||
.imageScale(.small)
|
||||
if navigatableItem {
|
||||
Image(systemName: "chevron.right")
|
||||
.imageScale(.small)
|
||||
}
|
||||
}
|
||||
.lineLimit(1)
|
||||
.padding(.trailing, 10)
|
||||
@@ -251,6 +414,8 @@ struct FavoriteItemView: View {
|
||||
|
||||
private var resource: Resource? {
|
||||
switch item.section {
|
||||
case .history:
|
||||
return nil
|
||||
case .subscriptions:
|
||||
if accounts.app.supportsSubscriptions {
|
||||
return accounts.api.feed(1)
|
||||
|
||||
@@ -8,6 +8,12 @@ struct HistoryView: View {
|
||||
|
||||
@ObservedObject private var player = PlayerModel.shared
|
||||
|
||||
@State private var visibleWatches = [Watch]()
|
||||
|
||||
init(limit: Int = 10) {
|
||||
self.limit = limit
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
LazyVStack {
|
||||
if visibleWatches.isEmpty {
|
||||
@@ -18,26 +24,19 @@ struct HistoryView: View {
|
||||
}.foregroundColor(.secondary)
|
||||
}
|
||||
} else {
|
||||
ForEach(visibleWatches, id: \.videoID) { watch in
|
||||
let video = player.historyVideo(watch.videoID) ?? watch.video
|
||||
|
||||
ContentItemView(item: .init(video: video))
|
||||
.environment(\.listingStyle, .list)
|
||||
.contextMenu {
|
||||
VideoContextMenuView(video: video)
|
||||
}
|
||||
}
|
||||
ListView(items: contentItems, limit: limit)
|
||||
}
|
||||
}
|
||||
.animation(nil, value: visibleWatches)
|
||||
.onAppear {
|
||||
visibleWatches
|
||||
.forEach(player.loadHistoryVideoDetails)
|
||||
}
|
||||
.onChange(of: player.currentVideo) { _ in reloadVisibleWatches() }
|
||||
}
|
||||
|
||||
private var visibleWatches: [Watch] {
|
||||
Array(watches.filter { $0.videoID != player.currentVideo?.videoID }.prefix(limit))
|
||||
var contentItems: [ContentItem] {
|
||||
visibleWatches.map { .init(video: player.historyVideo($0.videoID) ?? $0.video) }
|
||||
}
|
||||
|
||||
func reloadVisibleWatches() {
|
||||
visibleWatches = Array(watches.filter { $0.videoID != player.currentVideo?.videoID }.prefix(limit))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import UniformTypeIdentifiers
|
||||
struct HomeView: View {
|
||||
@ObservedObject private var accounts = AccountsModel.shared
|
||||
|
||||
@State private var presentingEditFavorites = false
|
||||
@State private var presentingHomeSettings = false
|
||||
@State private var favoritesChanged = false
|
||||
|
||||
@FetchRequest(sortDescriptors: [.init(key: "watchedAt", ascending: false)])
|
||||
@@ -20,9 +20,7 @@ struct HomeView: View {
|
||||
|
||||
#if !os(tvOS)
|
||||
@Default(.favorites) private var favorites
|
||||
#endif
|
||||
#if os(iOS)
|
||||
@Default(.homeRecentDocumentsItems) private var homeRecentDocumentsItems
|
||||
@Default(.widgetsSettings) private var widgetsSettings
|
||||
#endif
|
||||
@Default(.homeHistoryItems) private var homeHistoryItems
|
||||
@Default(.showFavoritesInHome) private var showFavoritesInHome
|
||||
@@ -33,33 +31,45 @@ struct HomeView: View {
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
HStack {
|
||||
#if os(tvOS)
|
||||
Group {
|
||||
if showOpenActionsInHome {
|
||||
AccentButton(text: "Open Video", imageSystemName: "globe") {
|
||||
NavigationModel.shared.presentingOpenVideos = true
|
||||
VStack {
|
||||
HStack {
|
||||
#if os(tvOS)
|
||||
Group {
|
||||
if showOpenActionsInHome {
|
||||
AccentButton(text: "Open Video", imageSystemName: "globe") {
|
||||
NavigationModel.shared.presentingOpenVideos = true
|
||||
}
|
||||
}
|
||||
AccentButton(text: "Locations", imageSystemName: "globe") {
|
||||
NavigationModel.shared.presentingAccounts = true
|
||||
}
|
||||
|
||||
AccentButton(text: "Settings", imageSystemName: "gear") {
|
||||
NavigationModel.shared.presentingSettings = true
|
||||
}
|
||||
}
|
||||
AccentButton(text: "Locations", imageSystemName: "globe") {
|
||||
NavigationModel.shared.presentingAccounts = true
|
||||
#else
|
||||
if showOpenActionsInHome {
|
||||
AccentButton(text: "Files", imageSystemName: "folder") {
|
||||
NavigationModel.shared.presentingFileImporter = true
|
||||
}
|
||||
AccentButton(text: "Paste", imageSystemName: "doc.on.clipboard.fill") {
|
||||
OpenVideosModel.shared.openURLsFromClipboard(playbackMode: .playNow)
|
||||
}
|
||||
AccentButton(imageSystemName: "ellipsis") {
|
||||
NavigationModel.shared.presentingOpenVideos = true
|
||||
}
|
||||
.frame(maxWidth: 40)
|
||||
}
|
||||
AccentButton(text: "Settings", imageSystemName: "gear") {
|
||||
NavigationModel.shared.presentingSettings = true
|
||||
}
|
||||
}
|
||||
#else
|
||||
if showOpenActionsInHome {
|
||||
AccentButton(text: "Files", imageSystemName: "folder") {
|
||||
NavigationModel.shared.presentingFileImporter = true
|
||||
}
|
||||
AccentButton(text: "Paste", imageSystemName: "doc.on.clipboard.fill") {
|
||||
OpenVideosModel.shared.openURLsFromClipboard(playbackMode: .playNow)
|
||||
}
|
||||
AccentButton(imageSystemName: "ellipsis") {
|
||||
NavigationModel.shared.presentingOpenVideos = true
|
||||
}
|
||||
.frame(maxWidth: 40)
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
HStack {
|
||||
Spacer()
|
||||
HideWatchedButtons()
|
||||
HideShortsButtons()
|
||||
HomeSettingsButton()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -80,7 +90,7 @@ struct HomeView: View {
|
||||
}
|
||||
|
||||
if !accounts.current.isNil, showFavoritesInHome {
|
||||
LazyVStack(alignment: .leading) {
|
||||
VStack(alignment: .leading) {
|
||||
#if os(tvOS)
|
||||
ForEach(Defaults[.favorites]) { item in
|
||||
FavoriteItemView(item: item)
|
||||
@@ -96,87 +106,6 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
if homeRecentDocumentsItems > 0 {
|
||||
VStack {
|
||||
HStack {
|
||||
NavigationLink(destination: DocumentsView()) {
|
||||
HStack {
|
||||
Text("Documents")
|
||||
.font(.title3.bold())
|
||||
Image(systemName: "chevron.right")
|
||||
.imageScale(.small)
|
||||
}
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.leading, 15)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
recentDocumentsID = UUID()
|
||||
} label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
.font(.headline)
|
||||
.labelStyle(.iconOnly)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
RecentDocumentsView(limit: homeRecentDocumentsItems)
|
||||
.id(recentDocumentsID)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
#if os(tvOS)
|
||||
.padding(.trailing, 40)
|
||||
#else
|
||||
.padding(.trailing, 15)
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
if homeHistoryItems > 0 {
|
||||
VStack {
|
||||
HStack {
|
||||
sectionLabel("History")
|
||||
Spacer()
|
||||
Button {
|
||||
navigation.presentAlert(
|
||||
Alert(
|
||||
title: Text("Are you sure you want to clear history of watched videos?"),
|
||||
message: Text("It cannot be reverted"),
|
||||
primaryButton: .destructive(Text("Clear All")) {
|
||||
PlayerModel.shared.removeHistory()
|
||||
historyID = UUID()
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
)
|
||||
} label: {
|
||||
Label("Clear History", systemImage: "trash")
|
||||
.font(.headline)
|
||||
.labelStyle(.iconOnly)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
#if os(tvOS)
|
||||
.padding(.trailing, 40)
|
||||
#else
|
||||
.padding(.trailing, 15)
|
||||
#endif
|
||||
|
||||
HistoryView(limit: homeHistoryItems)
|
||||
#if os(tvOS)
|
||||
.padding(.horizontal, 40)
|
||||
#else
|
||||
.padding(.horizontal, 15)
|
||||
#endif
|
||||
.id(historyID)
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
Color.clear.padding(.bottom, 60)
|
||||
#endif
|
||||
@@ -186,6 +115,10 @@ struct HomeView: View {
|
||||
favoritesChanged.toggle()
|
||||
}
|
||||
.tieToLifetime(of: accounts)
|
||||
Defaults.observe(.widgetsSettings) { _ in
|
||||
favoritesChanged.toggle()
|
||||
}
|
||||
.tieToLifetime(of: accounts)
|
||||
}
|
||||
|
||||
.redrawOn(change: favoritesChanged)
|
||||
@@ -198,9 +131,21 @@ struct HomeView: View {
|
||||
#if os(macOS)
|
||||
.background(Color.secondaryBackground)
|
||||
.frame(minWidth: 360)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .automatic) {
|
||||
HideWatchedButtons()
|
||||
HideShortsButtons()
|
||||
HomeSettingsButton()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(RefreshControl.navigationBarTitleDisplayMode)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
homeMenu
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#if !os(macOS)
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
|
||||
@@ -220,16 +165,47 @@ struct HomeView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
var homeMenu: some View {
|
||||
Menu {
|
||||
Section {
|
||||
HideWatchedButtons()
|
||||
HideShortsButtons()
|
||||
}
|
||||
Section {
|
||||
Button {
|
||||
navigation.presentingHomeSettings = true
|
||||
} label: {
|
||||
Label("Home Settings", systemImage: "gear")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Text("Home")
|
||||
.foregroundColor(.primary)
|
||||
.font(.headline)
|
||||
|
||||
Image(systemName: "chevron.down.circle.fill")
|
||||
.foregroundColor(.accentColor)
|
||||
.imageScale(.small)
|
||||
}
|
||||
.transaction { t in t.animation = nil }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
struct Home_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
TabView {
|
||||
HomeView()
|
||||
.injectFixtureEnvironmentObjects()
|
||||
.tabItem {
|
||||
Label("Home", systemImage: "house")
|
||||
}
|
||||
NavigationView {
|
||||
HomeView()
|
||||
.injectFixtureEnvironmentObjects()
|
||||
.tabItem {
|
||||
Label("Home", systemImage: "house")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,15 +28,8 @@ struct QueueView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
LazyVStack(alignment: .leading) {
|
||||
ForEach(limitedItems) { item in
|
||||
ContentItemView(item: .init(video: item.video))
|
||||
.environment(\.listingStyle, .list)
|
||||
.environment(\.inQueueListing, true)
|
||||
.environment(\.noListingDividers, limit == 1)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
ListView(items: items, limit: limit)
|
||||
.environment(\.inQueueListing, true)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, items.isEmpty ? 0 : 15)
|
||||
@@ -44,22 +37,14 @@ struct QueueView: View {
|
||||
|
||||
var label: String {
|
||||
if items.count < 2 {
|
||||
return "Next in Queue"
|
||||
return "Next in Queue".localized()
|
||||
}
|
||||
|
||||
return "Next in Queue (\(items.count))"
|
||||
return "Next in Queue".localized() + " (\(items.count))"
|
||||
}
|
||||
|
||||
var limitedItems: [ContentItem] {
|
||||
if let limit {
|
||||
return Array(items.prefix(limit).map(\.contentItem))
|
||||
}
|
||||
|
||||
return items.map(\.contentItem)
|
||||
}
|
||||
|
||||
var items: [PlayerQueueItem] {
|
||||
player.queue
|
||||
var items: [ContentItem] {
|
||||
player.queue.map(\.contentItem)
|
||||
}
|
||||
|
||||
var limit: Int? {
|
||||
|
||||
@@ -30,10 +30,16 @@ struct PlayerOverlayModifier: ViewModifier {
|
||||
}
|
||||
}
|
||||
.animation(.easeIn, value: player.videoForDisplay)
|
||||
.opacity(player.videoForDisplay == nil ? 0 : 1)
|
||||
.opacity(opacity)
|
||||
}
|
||||
}
|
||||
|
||||
var opacity: Double {
|
||||
guard !player.closing else { return 0 }
|
||||
|
||||
return player.videoForDisplay == nil ? 0 : 1
|
||||
}
|
||||
|
||||
var maxWidth: Double {
|
||||
playerBarMaxWidth == "0" ? .infinity : (Double(playerBarMaxWidth) ?? 600)
|
||||
}
|
||||
|
||||
@@ -5,10 +5,8 @@ struct AccountViewButton: View {
|
||||
@ObservedObject private var model = AccountsModel.shared
|
||||
private var navigation = NavigationModel.shared
|
||||
|
||||
@Default(.accounts) private var accounts
|
||||
@Default(.instances) private var instances
|
||||
@Default(.accountPickerDisplaysUsername) private var accountPickerDisplaysUsername
|
||||
@Default(.accountPickerDisplaysAnonymousAccounts) private var accountPickerDisplaysAnonymousAccounts
|
||||
|
||||
@ViewBuilder var body: some View {
|
||||
if !instances.isEmpty {
|
||||
|
||||
@@ -84,6 +84,9 @@ struct AccountsView: View {
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
#if os(tvOS)
|
||||
.padding(.horizontal, 50)
|
||||
#endif
|
||||
}
|
||||
|
||||
var closeButton: some View {
|
||||
|
||||
@@ -29,8 +29,4 @@ final class AccountsViewModel: ObservableObject {
|
||||
var currentAccount: Account? { AccountsModel.shared.current }
|
||||
|
||||
var instances: [Instance] { InstancesModel.shared.all }
|
||||
|
||||
func accountsOfInstance(_ instance: Instance) -> [Account] {
|
||||
accounts.filter { $0.instance.apiURL == instance.apiURL }.sorted { $0.name < $1.name }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import SwiftUI
|
||||
#endif
|
||||
|
||||
struct AppSidebarNavigation: View {
|
||||
@ObservedObject private var accounts = AccountsModel.shared
|
||||
private var navigation: NavigationModel { .shared }
|
||||
|
||||
#if os(iOS)
|
||||
|
||||
@@ -2,7 +2,6 @@ import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct AppSidebarRecents: View {
|
||||
@ObservedObject private var navigation = NavigationModel.shared
|
||||
var recents = RecentsModel.shared
|
||||
|
||||
@Default(.recentlyOpened) private var recentItems
|
||||
|
||||
@@ -6,7 +6,6 @@ struct AppSidebarSubscriptions: View {
|
||||
@ObservedObject private var feed = FeedModel.shared
|
||||
@ObservedObject private var feedCount = UnwatchedFeedCountModel.shared
|
||||
@ObservedObject private var subscriptions = SubscribedChannelsModel.shared
|
||||
@ObservedObject private var accounts = AccountsModel.shared
|
||||
|
||||
@Default(.showUnwatchedFeedBadges) private var showUnwatchedFeedBadges
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ struct AppTabNavigation: View {
|
||||
@ObservedObject private var navigation = NavigationModel.shared
|
||||
private var player = PlayerModel.shared
|
||||
@ObservedObject private var feed = FeedModel.shared
|
||||
@ObservedObject private var subscriptions = SubscribedChannelsModel.shared
|
||||
@ObservedObject private var feedCount = UnwatchedFeedCountModel.shared
|
||||
private var recents = RecentsModel.shared
|
||||
|
||||
@Default(.showHome) private var showHome
|
||||
@Default(.showDocuments) private var showDocuments
|
||||
@@ -176,9 +176,9 @@ struct AppTabNavigation: View {
|
||||
}
|
||||
|
||||
@ViewBuilder private var channelView: some View {
|
||||
if navigation.presentingChannel {
|
||||
if navigation.presentingChannel, let channel = recents.presentedChannel {
|
||||
NavigationView {
|
||||
ChannelVideosView(showCloseButton: true)
|
||||
ChannelVideosView(channel: channel, showCloseButton: true)
|
||||
}
|
||||
.environment(\.managedObjectContext, persistenceController.container.viewContext)
|
||||
.environment(\.inChannelView, true)
|
||||
@@ -190,9 +190,9 @@ struct AppTabNavigation: View {
|
||||
}
|
||||
|
||||
@ViewBuilder private var playlistView: some View {
|
||||
if navigation.presentingPlaylist {
|
||||
if navigation.presentingPlaylist, let playlist = recents.presentedPlaylist {
|
||||
NavigationView {
|
||||
ChannelPlaylistView(showCloseButton: true)
|
||||
ChannelPlaylistView(playlist: playlist, showCloseButton: true)
|
||||
}
|
||||
.environment(\.managedObjectContext, persistenceController.container.viewContext)
|
||||
.id("channelPlaylist")
|
||||
|
||||
@@ -8,36 +8,43 @@ import Siesta
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@ObservedObject private var accounts = AccountsModel.shared
|
||||
@ObservedObject private var navigation = NavigationModel.shared
|
||||
@ObservedObject private var player = PlayerModel.shared
|
||||
private var playlists = PlaylistsModel.shared
|
||||
private var subscriptions = SubscribedChannelsModel.shared
|
||||
|
||||
#if os(iOS)
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
#endif
|
||||
|
||||
var playerControls: PlayerControlsModel { .shared }
|
||||
|
||||
let persistenceController = PersistenceController.shared
|
||||
@Default(.avPlayerUsesSystemControls) private var avPlayerUsesSystemControls
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
if Constants.isIPhone {
|
||||
AppTabNavigation()
|
||||
} else {
|
||||
if horizontalSizeClass == .compact {
|
||||
AppTabNavigation()
|
||||
} else {
|
||||
AppSidebarNavigation()
|
||||
GeometryReader { proxy in
|
||||
Group {
|
||||
#if os(iOS)
|
||||
Group {
|
||||
if Constants.isIPhone {
|
||||
AppTabNavigation()
|
||||
} else {
|
||||
if horizontalSizeClass == .compact {
|
||||
AppTabNavigation()
|
||||
} else {
|
||||
AppSidebarNavigation()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#elseif os(macOS)
|
||||
AppSidebarNavigation()
|
||||
#elseif os(tvOS)
|
||||
TVNavigationView()
|
||||
#elseif os(macOS)
|
||||
AppSidebarNavigation()
|
||||
#elseif os(tvOS)
|
||||
TVNavigationView()
|
||||
#endif
|
||||
}
|
||||
#if !os(macOS)
|
||||
.onAppear {
|
||||
SafeAreaModel.shared.safeArea = proxy.safeAreaInsets
|
||||
}
|
||||
.onChange(of: proxy.safeAreaInsets) { newValue in
|
||||
SafeAreaModel.shared.safeArea = newValue
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(iOS)
|
||||
@@ -66,6 +73,37 @@ struct ContentView: View {
|
||||
AccountsView()
|
||||
}
|
||||
)
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingHomeSettings) {
|
||||
#if os(macOS)
|
||||
VStack(alignment: .leading) {
|
||||
Button("Done") {
|
||||
navigation.presentingHomeSettings = false
|
||||
}
|
||||
.padding()
|
||||
.keyboardShortcut(.cancelAction)
|
||||
|
||||
HomeSettings()
|
||||
}
|
||||
.frame(width: 500, height: 800)
|
||||
#else
|
||||
NavigationView {
|
||||
HomeSettings()
|
||||
#if os(iOS)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigation) {
|
||||
Button {
|
||||
navigation.presentingHomeSettings = false
|
||||
} label: {
|
||||
Text("Done")
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
)
|
||||
#if !os(tvOS)
|
||||
.fileImporter(
|
||||
isPresented: $navigation.presentingFileImporter,
|
||||
@@ -95,7 +133,7 @@ struct ContentView: View {
|
||||
}
|
||||
.onOpenURL { url in
|
||||
URLBookmarkModel.shared.saveBookmark(url)
|
||||
OpenURLHandler.shared.handle(url)
|
||||
OpenURLHandler(navigationStyle: navigationStyle).handle(url)
|
||||
}
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingAddToPlaylist) {
|
||||
@@ -120,7 +158,19 @@ struct ContentView: View {
|
||||
OpenVideosView()
|
||||
}
|
||||
)
|
||||
#if !os(macOS)
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingChannelSheet) {
|
||||
NavigationView {
|
||||
ChannelVideosView(channel: navigation.channelPresentedInSheet, showCloseButton: true)
|
||||
}
|
||||
}
|
||||
)
|
||||
#endif
|
||||
.alert(isPresented: $navigation.presentingAlert) { navigation.alert }
|
||||
#if os(iOS)
|
||||
.statusBarHidden(player.playingFullScreen)
|
||||
#endif
|
||||
}
|
||||
|
||||
var navigationStyle: NavigationStyle {
|
||||
@@ -138,9 +188,11 @@ struct ContentView: View {
|
||||
playerView
|
||||
.transition(.asymmetric(insertion: .identity, removal: .opacity))
|
||||
.zIndex(3)
|
||||
} else if player.activeBackend == .appleAVPlayer {
|
||||
} else if player.activeBackend == .appleAVPlayer,
|
||||
avPlayerUsesSystemControls || player.avPlayerBackend.isStartingPiP
|
||||
{
|
||||
#if os(iOS)
|
||||
playerView.offset(y: UIScreen.main.bounds.height)
|
||||
AppleAVPlayerLayerView().offset(y: UIScreen.main.bounds.height)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import Siesta
|
||||
|
||||
struct OpenURLHandler {
|
||||
static var firstHandle = true
|
||||
static var shared = OpenURLHandler()
|
||||
static let yatteeProtocol = "yattee://"
|
||||
|
||||
var accounts: AccountsModel { .shared }
|
||||
@@ -12,9 +11,16 @@ struct OpenURLHandler {
|
||||
var recents: RecentsModel { .shared }
|
||||
var player: PlayerModel { .shared }
|
||||
var search: SearchModel { .shared }
|
||||
var navigationStyle = NavigationStyle.sidebar
|
||||
var navigationStyle: NavigationStyle
|
||||
|
||||
func handle(_ url: URL) {
|
||||
if Self.firstHandle {
|
||||
Self.firstHandle = false
|
||||
|
||||
Delay.by(1) { handle(url) }
|
||||
return
|
||||
}
|
||||
|
||||
if accounts.current.isNil {
|
||||
accounts.setCurrent(accounts.any)
|
||||
}
|
||||
@@ -100,7 +106,8 @@ struct OpenURLHandler {
|
||||
#endif
|
||||
|
||||
let video = Video(app: accounts.current.app!, videoID: id)
|
||||
player.videoBeingOpened = video
|
||||
player.videoBeingOpened = .init(app: accounts.current.app!, videoID: id, title: "Loading video...")
|
||||
player.show()
|
||||
|
||||
player
|
||||
.playerAPI(video)?
|
||||
@@ -109,8 +116,9 @@ struct OpenURLHandler {
|
||||
.onSuccess { response in
|
||||
if let video: Video = response.typedContent() {
|
||||
let time = parser.time.isNil ? nil : CMTime.secondsInDefaultTimescale(TimeInterval(parser.time!))
|
||||
self.player.playNow(video, at: time)
|
||||
self.player.show()
|
||||
Delay.by(0.5) {
|
||||
self.player.playNow(video, at: time)
|
||||
}
|
||||
} else {
|
||||
navigation.presentAlert(title: "Error", message: "This video could not be opened")
|
||||
}
|
||||
@@ -163,7 +171,9 @@ struct OpenURLHandler {
|
||||
resource
|
||||
.load()
|
||||
.onSuccess { response in
|
||||
if let channel: Channel = response.typedContent() {
|
||||
if let page: ChannelPage = response.typedContent(),
|
||||
let channel = page.channel
|
||||
{
|
||||
DispatchQueue.main.async {
|
||||
NavigationModel.shared.openChannel(
|
||||
channel,
|
||||
|
||||
@@ -2,16 +2,133 @@ import AVKit
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
#if !os(macOS)
|
||||
final class AppleAVPlayerViewControllerDelegate: NSObject, AVPlayerViewControllerDelegate {
|
||||
#if os(iOS)
|
||||
@Default(.rotateToLandscapeOnEnterFullScreen) private var rotateToLandscapeOnEnterFullScreen
|
||||
@Default(.avPlayerUsesSystemControls) private var avPlayerUsesSystemControls
|
||||
#endif
|
||||
|
||||
var player: PlayerModel { .shared }
|
||||
|
||||
func playerViewControllerShouldAutomaticallyDismissAtPictureInPictureStart(_: AVPlayerViewController) -> Bool {
|
||||
false
|
||||
}
|
||||
|
||||
func playerViewController(_: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator _: UIViewControllerTransitionCoordinator) {
|
||||
#if os(iOS)
|
||||
guard rotateToLandscapeOnEnterFullScreen.isRotating else { return }
|
||||
if PlayerModel.shared.currentVideoIsLandscape {
|
||||
let delay = PlayerModel.shared.activeBackend == .appleAVPlayer && avPlayerUsesSystemControls ? 0.8 : 0
|
||||
// not sure why but first rotation call is ignore so doing rotate to same orientation first
|
||||
Delay.by(delay) {
|
||||
let orientation = OrientationTracker.shared.currentDeviceOrientation.isLandscape ? OrientationTracker.shared.currentInterfaceOrientation : self.rotateToLandscapeOnEnterFullScreen.interaceOrientation
|
||||
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: OrientationTracker.shared.currentInterfaceOrientation)
|
||||
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: orientation)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func playerViewController(_: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
|
||||
let wasPlaying = player.isPlaying
|
||||
coordinator.animate(alongsideTransition: nil) { context in
|
||||
#if os(iOS)
|
||||
if wasPlaying {
|
||||
self.player.play()
|
||||
}
|
||||
#endif
|
||||
if !context.isCancelled {
|
||||
#if os(iOS)
|
||||
self.player.lockedOrientation = nil
|
||||
|
||||
if Constants.isIPhone {
|
||||
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: .portrait)
|
||||
}
|
||||
|
||||
if wasPlaying {
|
||||
self.player.play()
|
||||
}
|
||||
|
||||
self.player.playingFullScreen = false
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func playerViewControllerWillStartPictureInPicture(_: AVPlayerViewController) {}
|
||||
|
||||
func playerViewControllerWillStopPictureInPicture(_: AVPlayerViewController) {}
|
||||
|
||||
func playerViewControllerDidStartPictureInPicture(_: AVPlayerViewController) {
|
||||
player.playingInPictureInPicture = true
|
||||
player.avPlayerBackend.startPictureInPictureOnPlay = false
|
||||
player.avPlayerBackend.startPictureInPictureOnSwitch = false
|
||||
player.controls.objectWillChange.send()
|
||||
|
||||
if Defaults[.closePlayerOnOpeningPiP] { Delay.by(0.1) { self.player.hide() } }
|
||||
}
|
||||
|
||||
func playerViewControllerDidStopPictureInPicture(_: AVPlayerViewController) {
|
||||
player.playingInPictureInPicture = false
|
||||
player.controls.objectWillChange.send()
|
||||
}
|
||||
|
||||
func playerViewController(_: AVPlayerViewController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {
|
||||
player.presentingPlayer = true
|
||||
withAnimation(.linear(duration: 0.3)) {
|
||||
self.player.playingInPictureInPicture = false
|
||||
Delay.by(0.5) {
|
||||
completionHandler(true)
|
||||
Delay.by(0.2) {
|
||||
self.player.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func playerViewController(_: AVPlayerViewController, restoreUserInterfaceForFullScreenExitWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {
|
||||
withAnimation(nil) {
|
||||
player.presentingPlayer = true
|
||||
}
|
||||
|
||||
completionHandler(true)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
struct AppleAVPlayerView: UIViewRepresentable {
|
||||
struct AppleAVPlayerView: UIViewControllerRepresentable {
|
||||
@State private var controller = AVPlayerViewController()
|
||||
|
||||
func makeUIViewController(context _: Context) -> AVPlayerViewController {
|
||||
setupController()
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_: AVPlayerViewController, context _: Context) {
|
||||
setupController()
|
||||
}
|
||||
|
||||
func setupController() {
|
||||
controller.delegate = PlayerModel.shared.appleAVPlayerViewControllerDelegate
|
||||
controller.allowsPictureInPicturePlayback = true
|
||||
if #available(iOS 14.2, *) {
|
||||
controller.canStartPictureInPictureAutomaticallyFromInline = true
|
||||
}
|
||||
PlayerModel.shared.avPlayerBackend.controller = controller
|
||||
}
|
||||
}
|
||||
|
||||
struct AppleAVPlayerLayerView: UIViewRepresentable {
|
||||
func makeUIView(context _: Context) -> some UIView {
|
||||
let playerLayerView = PlayerLayerView(frame: .zero)
|
||||
return playerLayerView
|
||||
PlayerLayerView(frame: .zero)
|
||||
}
|
||||
|
||||
func updateUIView(_: UIViewType, context _: Context) {}
|
||||
}
|
||||
#else
|
||||
|
||||
#elseif os(tvOS)
|
||||
struct AppleAVPlayerView: UIViewControllerRepresentable {
|
||||
func makeUIViewController(context _: Context) -> AppleAVPlayerViewController {
|
||||
let controller = AppleAVPlayerViewController()
|
||||
@@ -24,4 +141,25 @@ import SwiftUI
|
||||
PlayerModel.shared.rebuildTVMenu()
|
||||
}
|
||||
}
|
||||
#else
|
||||
struct AppleAVPlayerView: NSViewRepresentable {
|
||||
func makeNSView(context _: Context) -> some NSView {
|
||||
let view = AVPlayerView()
|
||||
view.player = PlayerModel.shared.avPlayerBackend.avPlayer
|
||||
view.showsFullScreenToggleButton = true
|
||||
view.allowsPictureInPicturePlayback = true
|
||||
view.pictureInPictureDelegate = MacOSPiPDelegate.shared
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_: NSViewType, context _: Context) {}
|
||||
}
|
||||
|
||||
struct AppleAVPlayerLayerView: NSViewRepresentable {
|
||||
func makeNSView(context _: Context) -> some NSView {
|
||||
PlayerLayerView(frame: .zero)
|
||||
}
|
||||
|
||||
func updateNSView(_: NSViewType, context _: Context) {}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -6,10 +6,6 @@ struct Buffering: View {
|
||||
var reason = "Buffering stream...".localized()
|
||||
var state: String?
|
||||
|
||||
#if os(iOS)
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
#endif
|
||||
|
||||
@ObservedObject private var player = PlayerModel.shared
|
||||
|
||||
@Default(.playerControlsLayout) private var regularPlayerControlsLayout
|
||||
|
||||
@@ -24,7 +24,7 @@ struct OpeningStream: View {
|
||||
|
||||
if let selection = player.streamSelection {
|
||||
if selection.isLocal {
|
||||
return "Opening file..."
|
||||
return "Opening file...".localized()
|
||||
} else {
|
||||
return String(format: "Opening %@ stream...".localized(), selection.shortQuality)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ struct PlayerControls: View {
|
||||
|
||||
#if os(iOS)
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
@ObservedObject private var safeAreaModel = SafeAreaModel.shared
|
||||
#elseif os(tvOS)
|
||||
enum Field: Hashable {
|
||||
case seekOSD
|
||||
@@ -28,7 +29,6 @@ struct PlayerControls: View {
|
||||
|
||||
@Default(.playerControlsLayout) private var regularPlayerControlsLayout
|
||||
@Default(.fullScreenPlayerControlsLayout) private var fullScreenPlayerControlsLayout
|
||||
@Default(.openWatchNextOnClose) private var openWatchNextOnClose
|
||||
@Default(.buttonBackwardSeekDuration) private var buttonBackwardSeekDuration
|
||||
@Default(.buttonForwardSeekDuration) private var buttonForwardSeekDuration
|
||||
|
||||
@@ -40,9 +40,10 @@ struct PlayerControls: View {
|
||||
@Default(.playerControlsRestartEnabled) private var playerControlsRestartEnabled
|
||||
@Default(.playerControlsAdvanceToNextEnabled) private var playerControlsAdvanceToNextEnabled
|
||||
@Default(.playerControlsPlaybackModeEnabled) private var playerControlsPlaybackModeEnabled
|
||||
@Default(.playerControlsNextEnabled) private var playerControlsNextEnabled
|
||||
@Default(.playerControlsMusicModeEnabled) private var playerControlsMusicModeEnabled
|
||||
|
||||
@Default(.avPlayerUsesSystemControls) private var avPlayerUsesSystemControls
|
||||
|
||||
private let controlsOverlayModel = ControlOverlaysModel.shared
|
||||
private var navigation = NavigationModel.shared
|
||||
|
||||
@@ -50,22 +51,28 @@ struct PlayerControls: View {
|
||||
player.playingFullScreen ? fullScreenPlayerControlsLayout : regularPlayerControlsLayout
|
||||
}
|
||||
|
||||
var showControls: Bool {
|
||||
player.activeBackend == .mpv || !avPlayerUsesSystemControls || player.musicMode
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
Seek()
|
||||
.zIndex(4)
|
||||
.transition(.opacity)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
#if os(tvOS)
|
||||
.focused($focusedField, equals: .seekOSD)
|
||||
.onChange(of: player.seek.lastSeekTime) { _ in
|
||||
if !model.presentingControls {
|
||||
focusedField = .seekOSD
|
||||
if showControls {
|
||||
Seek()
|
||||
.zIndex(4)
|
||||
.transition(.opacity)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
#if os(tvOS)
|
||||
.focused($focusedField, equals: .seekOSD)
|
||||
.onChange(of: player.seek.lastSeekTime) { _ in
|
||||
if !model.presentingControls {
|
||||
focusedField = .seekOSD
|
||||
}
|
||||
}
|
||||
}
|
||||
#else
|
||||
#else
|
||||
.offset(y: 2)
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
|
||||
VStack {
|
||||
ZStack {
|
||||
@@ -79,109 +86,108 @@ struct PlayerControls: View {
|
||||
}
|
||||
.offset(y: playerControlsLayout.osdVerticalOffset + 5)
|
||||
|
||||
Section {
|
||||
#if !os(tvOS)
|
||||
HStack {
|
||||
seekBackwardButton
|
||||
Spacer()
|
||||
togglePlayButton
|
||||
Spacer()
|
||||
seekForwardButton
|
||||
}
|
||||
.font(.system(size: playerControlsLayout.bigButtonFontSize))
|
||||
#endif
|
||||
|
||||
ZStack(alignment: .bottom) {
|
||||
VStack(spacing: 4) {
|
||||
#if !os(tvOS)
|
||||
buttonsBar
|
||||
|
||||
HStack {
|
||||
if !player.currentVideo.isNil, player.playingFullScreen {
|
||||
Button {
|
||||
withAnimation(Self.animation) {
|
||||
model.presentingDetailsOverlay = true
|
||||
}
|
||||
} label: {
|
||||
ControlsBar(fullScreen: $model.presentingDetailsOverlay, expansionState: .constant(.full), presentingControls: false, detailsTogglePlayer: false, detailsToggleFullScreen: false)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
.frame(maxWidth: 300, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
#endif
|
||||
|
||||
Spacer()
|
||||
|
||||
if playerControlsLayout.displaysTitleLine {
|
||||
VStack(alignment: .leading) {
|
||||
Text(player.videoForDisplay?.displayTitle ?? "Not Playing")
|
||||
.shadow(radius: 10)
|
||||
.font(.system(size: playerControlsLayout.titleLineFontSize).bold())
|
||||
.lineLimit(1)
|
||||
|
||||
Text(player.currentVideo?.displayAuthor ?? "")
|
||||
.fontWeight(.semibold)
|
||||
.shadow(radius: 10)
|
||||
.foregroundColor(.init(white: 0.8))
|
||||
.font(.system(size: playerControlsLayout.authorLineFontSize))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.offset(y: -40)
|
||||
}
|
||||
|
||||
timeline
|
||||
.padding(.bottom, 2)
|
||||
}
|
||||
.zIndex(1)
|
||||
.padding(.top, 2)
|
||||
.transition(.opacity)
|
||||
|
||||
HStack(spacing: playerControlsLayout.buttonsSpacing) {
|
||||
#if os(tvOS)
|
||||
togglePlayButton
|
||||
if showControls {
|
||||
Section {
|
||||
#if !os(tvOS)
|
||||
HStack {
|
||||
seekBackwardButton
|
||||
Spacer()
|
||||
togglePlayButton
|
||||
Spacer()
|
||||
seekForwardButton
|
||||
#endif
|
||||
if playerControlsAdvanceToNextEnabled {
|
||||
restartVideoButton
|
||||
}
|
||||
if playerControlsAdvanceToNextEnabled {
|
||||
advanceToNextItemButton
|
||||
}
|
||||
Spacer()
|
||||
#if os(tvOS)
|
||||
if playerControlsSettingsEnabled {
|
||||
settingsButton
|
||||
.font(.system(size: playerControlsLayout.bigButtonFontSize))
|
||||
#endif
|
||||
|
||||
ZStack(alignment: .bottom) {
|
||||
VStack(spacing: 4) {
|
||||
#if !os(tvOS)
|
||||
buttonsBar
|
||||
|
||||
HStack {
|
||||
if !player.currentVideo.isNil, player.playingFullScreen {
|
||||
Button {
|
||||
withAnimation(Self.animation) {
|
||||
model.presentingDetailsOverlay = true
|
||||
}
|
||||
} label: {
|
||||
ControlsBar(fullScreen: $model.presentingDetailsOverlay, expansionState: .constant(.full), presentingControls: false, detailsTogglePlayer: false, detailsToggleFullScreen: false)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
.frame(maxWidth: 300, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
#endif
|
||||
|
||||
Spacer()
|
||||
|
||||
if playerControlsLayout.displaysTitleLine {
|
||||
VStack(alignment: .leading) {
|
||||
Text(player.videoForDisplay?.displayTitle ?? "Not Playing")
|
||||
.shadow(radius: 10)
|
||||
.font(.system(size: playerControlsLayout.titleLineFontSize).bold())
|
||||
.lineLimit(1)
|
||||
|
||||
Text(player.currentVideo?.displayAuthor ?? "")
|
||||
.fontWeight(.semibold)
|
||||
.shadow(radius: 10)
|
||||
.foregroundColor(.init(white: 0.8))
|
||||
.font(.system(size: playerControlsLayout.authorLineFontSize))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.offset(y: -40)
|
||||
}
|
||||
#endif
|
||||
if playerControlsPlaybackModeEnabled {
|
||||
playbackModeButton
|
||||
|
||||
timeline
|
||||
.padding(.bottom, 2)
|
||||
}
|
||||
if playerControlsNextEnabled {
|
||||
watchNextButton
|
||||
.zIndex(1)
|
||||
.padding(.top, 2)
|
||||
.transition(.opacity)
|
||||
|
||||
HStack(spacing: playerControlsLayout.buttonsSpacing) {
|
||||
#if os(tvOS)
|
||||
togglePlayButton
|
||||
seekBackwardButton
|
||||
seekForwardButton
|
||||
#endif
|
||||
if playerControlsRestartEnabled {
|
||||
restartVideoButton
|
||||
}
|
||||
if playerControlsAdvanceToNextEnabled {
|
||||
advanceToNextItemButton
|
||||
}
|
||||
Spacer()
|
||||
#if os(tvOS)
|
||||
if playerControlsSettingsEnabled {
|
||||
settingsButton
|
||||
}
|
||||
#endif
|
||||
if playerControlsPlaybackModeEnabled {
|
||||
playbackModeButton
|
||||
}
|
||||
#if os(tvOS)
|
||||
closeVideoButton
|
||||
#else
|
||||
if playerControlsMusicModeEnabled {
|
||||
musicModeButton
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.zIndex(0)
|
||||
#if os(tvOS)
|
||||
closeVideoButton
|
||||
.offset(y: -playerControlsLayout.timelineHeight - 30)
|
||||
#else
|
||||
if playerControlsMusicModeEnabled {
|
||||
musicModeButton
|
||||
}
|
||||
.offset(y: -playerControlsLayout.timelineHeight - 5)
|
||||
#endif
|
||||
}
|
||||
.zIndex(0)
|
||||
#if os(tvOS)
|
||||
.offset(y: -playerControlsLayout.timelineHeight - 30)
|
||||
#else
|
||||
.offset(y: -playerControlsLayout.timelineHeight - 5)
|
||||
#endif
|
||||
}
|
||||
.opacity(model.presentingControls && !player.availableStreams.isEmpty ? 1 : 0)
|
||||
}
|
||||
.opacity(model.presentingControls ? 1 : 0)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -234,7 +240,7 @@ struct PlayerControls: View {
|
||||
guard player.playerSize.height.isFinite else { return 200 }
|
||||
var inset = 0.0
|
||||
#if os(iOS)
|
||||
inset = SafeArea.insets.bottom
|
||||
inset = safeAreaModel.safeArea.bottom
|
||||
#endif
|
||||
return [player.playerSize.height - inset, 500].min()!
|
||||
}
|
||||
@@ -334,7 +340,7 @@ struct PlayerControls: View {
|
||||
var fullscreenButton: some View {
|
||||
button(
|
||||
"Fullscreen",
|
||||
systemImage: player.playingFullScreen ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right"
|
||||
systemImage: player.fullscreenImage
|
||||
) {
|
||||
player.toggleFullscreen(player.playingFullScreen, showControls: false)
|
||||
}
|
||||
@@ -360,12 +366,7 @@ struct PlayerControls: View {
|
||||
|
||||
private var closeVideoButton: some View {
|
||||
button("Close", systemImage: "xmark") {
|
||||
if openWatchNextOnClose {
|
||||
player.pause()
|
||||
WatchNextViewModel.shared.closed(player.currentItem)
|
||||
} else {
|
||||
player.closeCurrentItem()
|
||||
}
|
||||
player.closeCurrentItem()
|
||||
}
|
||||
#if os(tvOS)
|
||||
.focused($focusedField, equals: .close)
|
||||
@@ -377,28 +378,13 @@ struct PlayerControls: View {
|
||||
}
|
||||
|
||||
private var pipButton: some View {
|
||||
let image = player.transitioningToPiP ? "pip.fill" : player.pipController?.isPictureInPictureActive ?? false ? "pip.exit" : "pip.enter"
|
||||
return button("PiP", systemImage: image) {
|
||||
(player.pipController?.isPictureInPictureActive ?? false) ? player.closePiP() : player.startPiP()
|
||||
}
|
||||
.disabled(!player.pipPossible)
|
||||
button("PiP", systemImage: player.pipImage, action: player.togglePiPAction)
|
||||
.disabled(!player.pipPossible)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private var lockOrientationButton: some View {
|
||||
button("Lock Rotation", systemImage: player.lockedOrientation.isNil ? "lock.rotation.open" : "lock.rotation", active: !player.lockedOrientation.isNil) {
|
||||
if player.lockedOrientation.isNil {
|
||||
let orientationMask = OrientationTracker.shared.currentInterfaceOrientationMask
|
||||
player.lockedOrientation = orientationMask
|
||||
let orientation = OrientationTracker.shared.currentInterfaceOrientation
|
||||
Orientation.lockOrientation(orientationMask, andRotateTo: .landscapeLeft)
|
||||
// iOS 16 workaround
|
||||
Orientation.lockOrientation(orientationMask, andRotateTo: orientation)
|
||||
} else {
|
||||
player.lockedOrientation = nil
|
||||
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: OrientationTracker.shared.currentInterfaceOrientation)
|
||||
}
|
||||
}
|
||||
button("Lock Rotation", systemImage: player.lockOrientationImage, active: !player.lockedOrientation.isNil, action: player.lockOrientationAction)
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -409,12 +395,6 @@ struct PlayerControls: View {
|
||||
}
|
||||
}
|
||||
|
||||
var watchNextButton: some View {
|
||||
button("Watch Next", systemImage: Constants.nextSystemImage) {
|
||||
WatchNextViewModel.shared.userInteractedOpen(player.currentItem)
|
||||
}
|
||||
}
|
||||
|
||||
var seekBackwardButton: some View {
|
||||
var foregroundColor: Color?
|
||||
var fontSize: Double?
|
||||
@@ -472,9 +452,7 @@ struct PlayerControls: View {
|
||||
}
|
||||
|
||||
private var restartVideoButton: some View {
|
||||
button("Restart video", systemImage: "backward.end.fill", cornerRadius: 5) {
|
||||
player.backend.seek(to: 0.0, seekType: .userInteracted)
|
||||
}
|
||||
button("Restart video", systemImage: "backward.end.fill", cornerRadius: 5, action: player.replayAction)
|
||||
}
|
||||
|
||||
private var togglePlayButton: some View {
|
||||
|
||||
@@ -13,7 +13,8 @@ struct ProgressBar: View {
|
||||
Rectangle().frame(width: min(Double(self.value) * geometry.size.width, geometry.size.width), height: geometry.size.height)
|
||||
.foregroundColor(Color.accentColor)
|
||||
.animation(.linear)
|
||||
}.cornerRadius(45.0)
|
||||
}
|
||||
.cornerRadius(45.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import SwiftUI
|
||||
struct TVControls: UIViewRepresentable {
|
||||
var model: PlayerControlsModel!
|
||||
var player: PlayerModel { .shared }
|
||||
var safeArea: SafeAreaModel { .shared }
|
||||
var thumbnails: ThumbnailsModel { .shared }
|
||||
|
||||
@State private var direction = ""
|
||||
@@ -32,10 +33,10 @@ struct TVControls: UIViewRepresentable {
|
||||
|
||||
let controls = UIHostingController(rootView: PlayerControls())
|
||||
controls.view.frame = .init(
|
||||
origin: .init(x: SafeArea.insets.left, y: SafeArea.insets.top),
|
||||
origin: .init(x: safeArea.safeArea.leading, y: safeArea.safeArea.top),
|
||||
size: .init(
|
||||
width: UIScreen.main.bounds.width - SafeArea.horizontalInsets,
|
||||
height: UIScreen.main.bounds.height - SafeArea.verticalInset
|
||||
width: UIScreen.main.bounds.width - safeArea.horizontalInsets,
|
||||
height: UIScreen.main.bounds.height - safeArea.verticalInsets
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -5,8 +5,9 @@ struct VideoDetailsOverlay: View {
|
||||
@ObservedObject private var controls = PlayerControlsModel.shared
|
||||
|
||||
var body: some View {
|
||||
VideoDetails(video: controls.player.videoForDisplay, fullScreen: fullScreenBinding)
|
||||
VideoDetails(video: controls.player.videoForDisplay, fullScreen: fullScreenBinding, sidebarQueue: .constant(false))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
.id(controls.player.currentVideo?.cacheKey)
|
||||
}
|
||||
|
||||
var fullScreenBinding: Binding<Bool> {
|
||||
|
||||
@@ -63,7 +63,14 @@ struct PlaybackSettings: View {
|
||||
}
|
||||
|
||||
HStack {
|
||||
controlsHeader("Rate")
|
||||
controlsHeader("Playback Mode".localized())
|
||||
Spacer()
|
||||
playbackModeControl
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
|
||||
HStack {
|
||||
controlsHeader("Rate".localized())
|
||||
Spacer()
|
||||
HStack(spacing: rateButtonsSpacing) {
|
||||
decreaseRateButton
|
||||
@@ -77,10 +84,9 @@ struct PlaybackSettings: View {
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
if player.activeBackend == .mpv {
|
||||
HStack {
|
||||
controlsHeader("Captions")
|
||||
controlsHeader("Captions".localized())
|
||||
Spacer()
|
||||
captionsButton
|
||||
#if os(tvOS)
|
||||
@@ -281,6 +287,40 @@ struct PlaybackSettings: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder var playbackModeControl: some View {
|
||||
#if os(tvOS)
|
||||
Button {
|
||||
player.playbackMode = player.playbackMode.next()
|
||||
} label: {
|
||||
Label(player.playbackMode.description.localized(), systemImage: player.playbackMode.systemImage)
|
||||
.transaction { t in t.animation = nil }
|
||||
.frame(minWidth: 350)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
playbackModePicker
|
||||
.modifier(SettingsPickerModifier())
|
||||
#if os(macOS)
|
||||
.frame(maxWidth: 150)
|
||||
#endif
|
||||
#else
|
||||
Menu {
|
||||
playbackModePicker
|
||||
} label: {
|
||||
Label(player.playbackMode.description.localized(), systemImage: player.playbackMode.systemImage)
|
||||
}
|
||||
.transaction { t in t.animation = .none }
|
||||
#endif
|
||||
}
|
||||
|
||||
var playbackModePicker: some View {
|
||||
Picker("Playback Mode", selection: $player.playbackMode) {
|
||||
ForEach(PlayerModel.PlaybackMode.allCases, id: \.rawValue) { mode in
|
||||
Label(mode.description.localized(), systemImage: mode.systemImage).tag(mode)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
}
|
||||
|
||||
@ViewBuilder private var qualityProfileButton: some View {
|
||||
#if os(macOS)
|
||||
qualityProfilePicker
|
||||
@@ -397,7 +437,7 @@ struct PlaybackSettings: View {
|
||||
|
||||
@ViewBuilder private var captionsPicker: some View {
|
||||
let captions = player.currentVideo?.captions ?? []
|
||||
Picker("Captions", selection: $player.captions) {
|
||||
Picker("Captions".localized(), selection: $player.captions) {
|
||||
if captions.isEmpty {
|
||||
Text("Not available")
|
||||
} else {
|
||||
|
||||
@@ -5,7 +5,7 @@ extension Backport where Content: View {
|
||||
@ViewBuilder func playbackSettingsPresentationDetents() -> some View {
|
||||
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, *) {
|
||||
content
|
||||
.presentationDetents([.height(350), .large])
|
||||
.presentationDetents([.height(400), .large])
|
||||
} else {
|
||||
content
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct PlayerBackendView: View {
|
||||
#if os(iOS)
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
@ObservedObject private var safeAreaModel = SafeAreaModel.shared
|
||||
#endif
|
||||
@ObservedObject private var player = PlayerModel.shared
|
||||
|
||||
@Default(.avPlayerUsesSystemControls) private var avPlayerUsesSystemControls
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .top) {
|
||||
Group {
|
||||
@@ -15,13 +19,24 @@ struct PlayerBackendView: View {
|
||||
case .mpv:
|
||||
player.mpvPlayerView
|
||||
case .appleAVPlayer:
|
||||
player.avPlayerView
|
||||
#if os(tvOS)
|
||||
AppleAVPlayerView()
|
||||
#else
|
||||
if avPlayerUsesSystemControls,
|
||||
!player.playingInPictureInPicture,
|
||||
!player.avPlayerBackend.isStartingPiP
|
||||
{
|
||||
AppleAVPlayerView()
|
||||
} else if !avPlayerUsesSystemControls ||
|
||||
player.playingInPictureInPicture ||
|
||||
player.avPlayerBackend.isStartingPiP
|
||||
{
|
||||
AppleAVPlayerLayerView()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.zIndex(0)
|
||||
|
||||
ControlsGradientView()
|
||||
.zIndex(1)
|
||||
}
|
||||
}
|
||||
.overlay(GeometryReader { proxy in
|
||||
@@ -30,12 +45,11 @@ struct PlayerBackendView: View {
|
||||
.onChange(of: proxy.size) { _ in player.playerSize = proxy.size }
|
||||
.onChange(of: player.controls.presentingOverlays) { _ in player.playerSize = proxy.size }
|
||||
})
|
||||
#if os(iOS)
|
||||
.padding(.top, player.playingFullScreen && verticalSizeClass == .regular ? 20 : 0)
|
||||
#endif
|
||||
|
||||
#if !os(tvOS)
|
||||
PlayerGestures()
|
||||
if player.activeBackend == .mpv || !avPlayerUsesSystemControls {
|
||||
PlayerGestures()
|
||||
}
|
||||
PlayerControls()
|
||||
#if os(iOS)
|
||||
.padding(.top, controlsTopPadding)
|
||||
@@ -55,19 +69,17 @@ struct PlayerBackendView: View {
|
||||
guard player.playingFullScreen else { return 0 }
|
||||
|
||||
if UIDevice.current.userInterfaceIdiom != .pad {
|
||||
return verticalSizeClass == .compact ? SafeArea.insets.top : 0
|
||||
return verticalSizeClass == .compact ? safeAreaModel.safeArea.top : 0
|
||||
} else {
|
||||
return SafeArea.insets.top.isZero ? SafeArea.insets.bottom : SafeArea.insets.top
|
||||
return safeAreaModel.safeArea.top.isZero ? safeAreaModel.safeArea.bottom : safeAreaModel.safeArea.top
|
||||
}
|
||||
}
|
||||
|
||||
var controlsBottomPadding: Double {
|
||||
guard player.playingFullScreen else { return 0 }
|
||||
|
||||
if UIDevice.current.userInterfaceIdiom != .pad {
|
||||
return player.playingFullScreen && verticalSizeClass == .compact ? SafeArea.insets.bottom : 0
|
||||
return player.playingFullScreen || verticalSizeClass == .compact ? safeAreaModel.safeArea.bottom : 0
|
||||
} else {
|
||||
return player.playingFullScreen ? SafeArea.insets.bottom : 0
|
||||
return player.playingFullScreen ? safeAreaModel.safeArea.bottom : 0
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -3,7 +3,7 @@ import SwiftUI
|
||||
|
||||
extension VideoPlayerView {
|
||||
var playerDragGesture: some Gesture {
|
||||
DragGesture(minimumDistance: 0, coordinateSpace: .global)
|
||||
DragGesture(minimumDistance: 30, coordinateSpace: .global)
|
||||
#if os(iOS)
|
||||
.updating($dragGestureOffset) { value, state, _ in
|
||||
guard isVerticalDrag else { return }
|
||||
@@ -17,7 +17,8 @@ extension VideoPlayerView {
|
||||
}
|
||||
.onChanged { value in
|
||||
guard player.presentingPlayer,
|
||||
!controlsOverlayModel.presenting else { return }
|
||||
!controlsOverlayModel.presenting,
|
||||
dragGestureState else { return }
|
||||
|
||||
if player.controls.presentingControls, !player.musicMode {
|
||||
player.controls.presentingControls = false
|
||||
@@ -36,7 +37,12 @@ extension VideoPlayerView {
|
||||
}
|
||||
#endif
|
||||
|
||||
if !isVerticalDrag, horizontalPlayerGestureEnabled, abs(horizontalDrag) > seekGestureSensitivity, !isHorizontalDrag {
|
||||
if !isVerticalDrag,
|
||||
horizontalPlayerGestureEnabled,
|
||||
abs(horizontalDrag) > seekGestureSensitivity,
|
||||
!isHorizontalDrag,
|
||||
player.activeBackend == .mpv || !avPlayerUsesSystemControls
|
||||
{
|
||||
isHorizontalDrag = true
|
||||
player.seek.onSeekGestureStart()
|
||||
viewDragOffset = 0
|
||||
@@ -63,7 +69,7 @@ extension VideoPlayerView {
|
||||
{
|
||||
player.exitFullScreen(showControls: false)
|
||||
#if os(iOS)
|
||||
if Defaults[.rotateToPortraitOnExitFullScreen] {
|
||||
if Constants.isIPhone {
|
||||
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: .portrait)
|
||||
}
|
||||
#endif
|
||||
@@ -80,6 +86,16 @@ extension VideoPlayerView {
|
||||
player.seek.onSeekGestureEnd()
|
||||
}
|
||||
|
||||
if viewDragOffset > 60,
|
||||
player.playingFullScreen
|
||||
{
|
||||
#if os(iOS)
|
||||
player.lockedOrientation = nil
|
||||
#endif
|
||||
player.exitFullScreen(showControls: false)
|
||||
viewDragOffset = 0
|
||||
return
|
||||
}
|
||||
isVerticalDrag = false
|
||||
|
||||
guard player.presentingPlayer,
|
||||
|
||||
@@ -5,42 +5,50 @@ struct PlayerGestures: View {
|
||||
private var player = PlayerModel.shared
|
||||
@ObservedObject private var model = PlayerControlsModel.shared
|
||||
|
||||
@Default(.avPlayerUsesSystemControls) private var avPlayerUsesSystemControls
|
||||
|
||||
var showGestures: Bool {
|
||||
player.activeBackend == .mpv || !avPlayerUsesSystemControls
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
gestureRectangle
|
||||
.tapRecognizer(
|
||||
tapSensitivity: 0.2,
|
||||
doubleTapAction: {
|
||||
model.presentingControls = false
|
||||
let interval = TimeInterval(Defaults[.gestureBackwardSeekDuration]) ?? 10
|
||||
player.backend.seek(relative: .secondsInDefaultTimescale(-interval), seekType: .userInteracted)
|
||||
},
|
||||
anyTapAction: {
|
||||
singleTapAction()
|
||||
model.update()
|
||||
}
|
||||
)
|
||||
if showGestures {
|
||||
gestureRectangle
|
||||
.tapRecognizer(
|
||||
tapSensitivity: 0.2,
|
||||
doubleTapAction: {
|
||||
model.presentingControls = false
|
||||
let interval = TimeInterval(Defaults[.gestureBackwardSeekDuration]) ?? 10
|
||||
player.backend.seek(relative: .secondsInDefaultTimescale(-interval), seekType: .userInteracted)
|
||||
},
|
||||
anyTapAction: {
|
||||
singleTapAction()
|
||||
model.update()
|
||||
}
|
||||
)
|
||||
|
||||
gestureRectangle
|
||||
.tapRecognizer(
|
||||
tapSensitivity: 0.2,
|
||||
doubleTapAction: {
|
||||
model.presentingControls = false
|
||||
player.backend.togglePlay()
|
||||
},
|
||||
anyTapAction: singleTapAction
|
||||
)
|
||||
gestureRectangle
|
||||
.tapRecognizer(
|
||||
tapSensitivity: 0.2,
|
||||
doubleTapAction: {
|
||||
model.presentingControls = false
|
||||
player.backend.togglePlay()
|
||||
},
|
||||
anyTapAction: singleTapAction
|
||||
)
|
||||
|
||||
gestureRectangle
|
||||
.tapRecognizer(
|
||||
tapSensitivity: 0.2,
|
||||
doubleTapAction: {
|
||||
model.presentingControls = false
|
||||
let interval = TimeInterval(Defaults[.gestureForwardSeekDuration]) ?? 10
|
||||
player.backend.seek(relative: .secondsInDefaultTimescale(interval), seekType: .userInteracted)
|
||||
},
|
||||
anyTapAction: singleTapAction
|
||||
)
|
||||
gestureRectangle
|
||||
.tapRecognizer(
|
||||
tapSensitivity: 0.2,
|
||||
doubleTapAction: {
|
||||
model.presentingControls = false
|
||||
let interval = TimeInterval(Defaults[.gestureForwardSeekDuration]) ?? 10
|
||||
player.backend.seek(relative: .secondsInDefaultTimescale(interval), seekType: .userInteracted)
|
||||
},
|
||||
anyTapAction: singleTapAction
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,22 +7,17 @@ struct PlayerQueueRow: View {
|
||||
let item: PlayerQueueItem
|
||||
var history = false
|
||||
var autoplay = false
|
||||
var watch: Watch?
|
||||
|
||||
private var player = PlayerModel.shared
|
||||
|
||||
@Default(.closePiPOnNavigation) var closePiPOnNavigation
|
||||
|
||||
@FetchRequest private var watchRequest: FetchedResults<Watch>
|
||||
|
||||
init(item: PlayerQueueItem, history: Bool = false, autoplay: Bool = false) {
|
||||
init(item: PlayerQueueItem, history: Bool = false, autoplay: Bool = false, watch: Watch? = nil) {
|
||||
self.item = item
|
||||
self.history = history
|
||||
self.autoplay = autoplay
|
||||
_watchRequest = FetchRequest<Watch>(
|
||||
entity: Watch.entity(),
|
||||
sortDescriptors: [],
|
||||
predicate: NSPredicate(format: "videoID = %@", item.videoID)
|
||||
)
|
||||
self.watch = watch
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -80,10 +75,6 @@ struct PlayerQueueRow: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
private var watch: Watch? {
|
||||
watchRequest.first
|
||||
}
|
||||
|
||||
private var watchStoppedAt: CMTime? {
|
||||
guard let seconds = watch?.stoppedAt else {
|
||||
return nil
|
||||
|
||||
@@ -5,9 +5,9 @@ struct RelatedView: View {
|
||||
@ObservedObject private var player = PlayerModel.shared
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if let related = player.currentVideo?.related {
|
||||
Section(header: Text("Related")) {
|
||||
LazyVStack {
|
||||
if let related = player.videoForDisplay?.related {
|
||||
Section(header: header) {
|
||||
ForEach(related) { video in
|
||||
PlayerQueueRow(item: PlayerQueueItem(video))
|
||||
.listRowBackground(Color.clear)
|
||||
@@ -34,6 +34,15 @@ struct RelatedView: View {
|
||||
.listStyle(.plain)
|
||||
#endif
|
||||
}
|
||||
|
||||
var header: some View {
|
||||
Text("Related")
|
||||
#if !os(macOS)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct RelatedView_Previews: PreviewProvider {
|
||||
|
||||
@@ -11,6 +11,21 @@ struct ChapterView: View {
|
||||
Button {
|
||||
player.backend.seek(to: chapter.start, seekType: .userInteracted)
|
||||
} label: {
|
||||
Group {
|
||||
#if os(tvOS)
|
||||
horizontalChapter
|
||||
#else
|
||||
verticalChapter
|
||||
#endif
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
|
||||
var horizontalChapter: some View {
|
||||
HStack(spacing: 12) {
|
||||
if !chapter.image.isNil {
|
||||
smallImage(chapter)
|
||||
@@ -25,10 +40,26 @@ struct ChapterView: View {
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
#else
|
||||
var verticalChapter: some View {
|
||||
VStack(spacing: 12) {
|
||||
if !chapter.image.isNil {
|
||||
smallImage(chapter)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(chapter.title)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
.font(.headline)
|
||||
Text(chapter.start.formattedAsPlaybackTime(allowZero: true) ?? "")
|
||||
.font(.system(.subheadline).monospacedDigit())
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: Self.thumbnailWidth, alignment: .leading)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@ViewBuilder func smallImage(_ chapter: Chapter) -> some View {
|
||||
WebImage(url: chapter.image, options: [.lowPriority])
|
||||
@@ -37,21 +68,20 @@ struct ChapterView: View {
|
||||
ProgressView()
|
||||
}
|
||||
.indicator(.activity)
|
||||
.frame(width: Self.thumbnailWidth, height: Self.thumbnailHeight)
|
||||
#if os(tvOS)
|
||||
.frame(width: thumbnailWidth, height: 140)
|
||||
.mask(RoundedRectangle(cornerRadius: 12))
|
||||
#else
|
||||
.frame(width: thumbnailWidth, height: 60)
|
||||
.mask(RoundedRectangle(cornerRadius: 6))
|
||||
#endif
|
||||
}
|
||||
|
||||
private var thumbnailWidth: Double {
|
||||
#if os(tvOS)
|
||||
250
|
||||
#else
|
||||
100
|
||||
#endif
|
||||
static var thumbnailWidth: Double {
|
||||
250
|
||||
}
|
||||
|
||||
static var thumbnailHeight: Double {
|
||||
thumbnailWidth / 1.7777
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,24 +5,45 @@ import SwiftUI
|
||||
struct ChaptersView: View {
|
||||
@ObservedObject private var player = PlayerModel.shared
|
||||
|
||||
var chapters: [Chapter] {
|
||||
player.videoForDisplay?.chapters ?? []
|
||||
}
|
||||
|
||||
var chaptersHaveImages: Bool {
|
||||
chapters.allSatisfy { $0.image != nil }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if let chapters = player.currentVideo?.chapters, !chapters.isEmpty {
|
||||
List {
|
||||
Section {
|
||||
ForEach(chapters) { chapter in
|
||||
ChapterView(chapter: chapter)
|
||||
if !chapters.isEmpty {
|
||||
#if os(tvOS)
|
||||
List {
|
||||
Section {
|
||||
ForEach(chapters) { chapter in
|
||||
ChapterView(chapter: chapter)
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
#if os(macOS)
|
||||
.listStyle(.inset)
|
||||
#elseif os(iOS)
|
||||
.listStyle(.grouped)
|
||||
.backport
|
||||
.scrollContentBackground(false)
|
||||
.listStyle(.plain)
|
||||
#else
|
||||
.listStyle(.plain)
|
||||
if chaptersHaveImages {
|
||||
ScrollView(.horizontal) {
|
||||
LazyHStack(spacing: 20) {
|
||||
ForEach(chapters) { chapter in
|
||||
ChapterView(chapter: chapter)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 15)
|
||||
}
|
||||
.frame(minHeight: ChapterView.thumbnailHeight + 100)
|
||||
} else {
|
||||
Section {
|
||||
ForEach(chapters) { chapter in
|
||||
ChapterView(chapter: chapter)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
NoCommentsView(text: "No chapters information available".localized(), systemImage: "xmark.circle.fill")
|
||||
|
||||
@@ -204,7 +204,7 @@ struct CommentView: View {
|
||||
Group {
|
||||
let last = comments.replies.last
|
||||
ForEach(comments.replies) { comment in
|
||||
CommentView(comment: comment, repliesID: $repliesID)
|
||||
Self(comment: comment, repliesID: $repliesID)
|
||||
#if os(tvOS)
|
||||
.focusable()
|
||||
#endif
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CommentsView: View {
|
||||
var embedInScrollView = false
|
||||
@State private var repliesID: Comment.ID?
|
||||
|
||||
@ObservedObject private var comments = CommentsModel.shared
|
||||
@@ -16,7 +15,7 @@ struct CommentsView: View {
|
||||
PlaceholderProgressView()
|
||||
} else {
|
||||
let last = comments.all.last
|
||||
let commentsStack = LazyVStack {
|
||||
LazyVStack {
|
||||
ForEach(comments.all) { comment in
|
||||
CommentView(comment: comment, repliesID: $repliesID)
|
||||
.onAppear {
|
||||
@@ -25,16 +24,6 @@ struct CommentsView: View {
|
||||
.borderBottom(height: comment != last ? 0.5 : 0, color: Color("ControlsBorderColor"))
|
||||
}
|
||||
}
|
||||
.padding(.top, 55)
|
||||
|
||||
if embedInScrollView {
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
commentsStack
|
||||
Color.clear.frame(height: 50)
|
||||
}
|
||||
} else {
|
||||
commentsStack
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
@@ -6,7 +6,7 @@ struct InspectorView: View {
|
||||
@ObservedObject private var player = PlayerModel.shared
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
Section(header: header) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if let video {
|
||||
VStack(spacing: 4) {
|
||||
@@ -15,7 +15,7 @@ struct InspectorView: View {
|
||||
|
||||
videoDetailRow("Format", value: player.mpvBackend.videoFormat)
|
||||
videoDetailRow("Codec", value: player.mpvBackend.videoCodec)
|
||||
videoDetailRow("Hardware Decoder", value: player.mpvBackend.hwDecoder)
|
||||
videoDetailRow("Hardware decoder", value: player.mpvBackend.hwDecoder)
|
||||
videoDetailRow("Driver", value: player.mpvBackend.currentVo)
|
||||
videoDetailRow("Size", value: player.formattedSize)
|
||||
videoDetailRow("FPS", value: player.mpvBackend.formattedOutputFps)
|
||||
@@ -53,10 +53,14 @@ struct InspectorView: View {
|
||||
NoCommentsView(text: "Not playing", systemImage: "stop.circle.fill")
|
||||
}
|
||||
}
|
||||
.padding(.top, 60)
|
||||
.padding(.bottom, 50)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
var header: some View {
|
||||
Text("Inspector".localized())
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
@ViewBuilder func videoDetailGroupHeading(_ heading: String, image systemName: String? = nil) -> some View {
|
||||
|
||||
@@ -13,7 +13,7 @@ struct PlayerQueueView: View {
|
||||
@Default(.saveHistory) private var saveHistory
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Group {
|
||||
Group {
|
||||
if player.playbackMode == .related {
|
||||
autoplaying
|
||||
@@ -34,15 +34,6 @@ struct PlayerQueueView: View {
|
||||
.listRowSeparator(false)
|
||||
}
|
||||
.environment(\.inNavigationView, false)
|
||||
#if os(macOS)
|
||||
.listStyle(.inset)
|
||||
#elseif os(iOS)
|
||||
.listStyle(.grouped)
|
||||
.backport
|
||||
.scrollContentBackground(false)
|
||||
#else
|
||||
.listStyle(.plain)
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder var autoplaying: some View {
|
||||
@@ -65,6 +56,8 @@ struct PlayerQueueView: View {
|
||||
var autoplayingHeader: some View {
|
||||
HStack {
|
||||
Text("Autoplaying Next")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption)
|
||||
Spacer()
|
||||
Button {
|
||||
player.setRelatedAutoplayItem()
|
||||
@@ -78,7 +71,7 @@ struct PlayerQueueView: View {
|
||||
}
|
||||
|
||||
var playingNext: some View {
|
||||
Section(header: Text("Queue")) {
|
||||
Section(header: queueHeader) {
|
||||
if player.queue.isEmpty {
|
||||
Text("Queue is empty")
|
||||
.foregroundColor(.secondary)
|
||||
@@ -96,6 +89,15 @@ struct PlayerQueueView: View {
|
||||
}
|
||||
}
|
||||
|
||||
var queueHeader: some View {
|
||||
Text("Queue".localized())
|
||||
#if !os(macOS)
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var visibleWatches: [Watch] {
|
||||
watches.filter { $0.videoID != player.currentVideo?.videoID }
|
||||
}
|
||||
|
||||
@@ -6,8 +6,15 @@ struct VideoActions: View {
|
||||
case share
|
||||
case addToPlaylist
|
||||
case subscribe
|
||||
case fullScreen
|
||||
case pip
|
||||
#if os(iOS)
|
||||
case lockOrientation
|
||||
#endif
|
||||
case restart
|
||||
case advanceToNextItem
|
||||
case musicMode
|
||||
case settings
|
||||
case next
|
||||
case hide
|
||||
case close
|
||||
}
|
||||
@@ -19,17 +26,20 @@ struct VideoActions: View {
|
||||
|
||||
var video: Video?
|
||||
|
||||
@Default(.openWatchNextOnClose) private var openWatchNextOnClose
|
||||
@Default(.playerActionsButtonLabelStyle) private var playerActionsButtonLabelStyle
|
||||
|
||||
@Default(.actionButtonShareEnabled) private var actionButtonShareEnabled
|
||||
@Default(.actionButtonAddToPlaylistEnabled) private var actionButtonAddToPlaylistEnabled
|
||||
@Default(.actionButtonSubscribeEnabled) private var actionButtonSubscribeEnabled
|
||||
@Default(.actionButtonSettingsEnabled) private var actionButtonSettingsEnabled
|
||||
@Default(.actionButtonNextEnabled) private var actionButtonNextEnabled
|
||||
@Default(.actionButtonFullScreenEnabled) private var actionButtonFullScreenEnabled
|
||||
@Default(.actionButtonPipEnabled) private var actionButtonPipEnabled
|
||||
@Default(.actionButtonLockOrientationEnabled) private var actionButtonLockOrientationEnabled
|
||||
@Default(.actionButtonRestartEnabled) private var actionButtonRestartEnabled
|
||||
@Default(.actionButtonAdvanceToNextItemEnabled) private var actionButtonAdvanceToNextItemEnabled
|
||||
@Default(.actionButtonMusicModeEnabled) private var actionButtonMusicModeEnabled
|
||||
@Default(.actionButtonHideEnabled) private var actionButtonHideEnabled
|
||||
@Default(.actionButtonCloseEnabled) private var actionButtonCloseEnabled
|
||||
@Default(.actionButtonNextQueueCountEnabled) private var actionButtonNextQueueCountEnabled
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
@@ -55,8 +65,20 @@ struct VideoActions: View {
|
||||
return actionButtonSubscribeEnabled
|
||||
case .settings:
|
||||
return actionButtonSettingsEnabled
|
||||
case .next:
|
||||
return actionButtonNextEnabled
|
||||
case .fullScreen:
|
||||
return actionButtonFullScreenEnabled
|
||||
case .pip:
|
||||
return actionButtonPipEnabled
|
||||
#if os(iOS)
|
||||
case .lockOrientation:
|
||||
return actionButtonLockOrientationEnabled
|
||||
#endif
|
||||
case .restart:
|
||||
return actionButtonRestartEnabled
|
||||
case .advanceToNextItem:
|
||||
return actionButtonAdvanceToNextItemEnabled
|
||||
case .musicMode:
|
||||
return actionButtonMusicModeEnabled
|
||||
case .hide:
|
||||
return actionButtonHideEnabled
|
||||
case .close:
|
||||
@@ -69,11 +91,21 @@ struct VideoActions: View {
|
||||
case .share:
|
||||
return video?.isShareable ?? false
|
||||
case .addToPlaylist:
|
||||
return !(video?.isLocal ?? true)
|
||||
return !(video?.isLocal ?? true) && accounts.signedIn
|
||||
case .subscribe:
|
||||
return !(video?.isLocal ?? true) && accounts.signedIn && accounts.app.supportsSubscriptions
|
||||
case .settings:
|
||||
return video != nil
|
||||
case .fullScreen:
|
||||
return video != nil
|
||||
case .pip:
|
||||
return video != nil
|
||||
case .advanceToNextItem:
|
||||
return player.isAdvanceToNextItemAvailable
|
||||
case .restart:
|
||||
return video != nil
|
||||
case .musicMode:
|
||||
return video != nil
|
||||
default:
|
||||
return true
|
||||
}
|
||||
@@ -116,6 +148,23 @@ struct VideoActions: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
case .fullScreen:
|
||||
actionButton("Fullscreen", systemImage: player.fullscreenImage, action: player.toggleFullScreenAction)
|
||||
case .pip:
|
||||
actionButton("PiP", systemImage: player.pipImage, action: player.togglePiPAction)
|
||||
#if os(iOS)
|
||||
case .lockOrientation:
|
||||
actionButton("Lock", systemImage: player.lockOrientationImage, active: player.lockedOrientation != nil, action: player.lockOrientationAction)
|
||||
#endif
|
||||
case .restart:
|
||||
actionButton("Replay", systemImage: "backward.end.fill", action: player.replayAction)
|
||||
case .advanceToNextItem:
|
||||
actionButton("Next", systemImage: "forward.fill") {
|
||||
player.advanceToNextItem()
|
||||
}
|
||||
case .musicMode:
|
||||
actionButton("Music", systemImage: "music.note", active: player.musicMode, action: player.toggleMusicMode)
|
||||
|
||||
case .settings:
|
||||
actionButton("Settings", systemImage: "gear") {
|
||||
withAnimation(ControlOverlaysModel.animation) {
|
||||
@@ -126,10 +175,6 @@ struct VideoActions: View {
|
||||
#endif
|
||||
}
|
||||
}
|
||||
case .next:
|
||||
actionButton(nextLabel, systemImage: Constants.nextSystemImage) {
|
||||
WatchNextViewModel.shared.userInteractedOpen(player.currentItem)
|
||||
}
|
||||
case .hide:
|
||||
actionButton("Hide", systemImage: "chevron.down") {
|
||||
player.hide(animate: true)
|
||||
@@ -137,12 +182,7 @@ struct VideoActions: View {
|
||||
|
||||
case .close:
|
||||
actionButton("Close", systemImage: "xmark") {
|
||||
if player.presentingPlayer, openWatchNextOnClose {
|
||||
player.pause()
|
||||
WatchNextViewModel.shared.closed(player.currentItem)
|
||||
} else {
|
||||
player.closeCurrentItem()
|
||||
}
|
||||
player.closeCurrentItem()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,28 +190,23 @@ struct VideoActions: View {
|
||||
}
|
||||
}
|
||||
|
||||
var nextLabel: String {
|
||||
if actionButtonNextQueueCountEnabled, !player.queue.isEmpty {
|
||||
return "\("Next".localized()) • \(player.queue.count)"
|
||||
}
|
||||
|
||||
return "Next".localized()
|
||||
}
|
||||
|
||||
func actionButton(
|
||||
_ name: String,
|
||||
systemImage: String,
|
||||
active: Bool = false,
|
||||
action: @escaping () -> Void = {}
|
||||
) -> some View {
|
||||
Button(action: action) {
|
||||
VStack(spacing: 3) {
|
||||
Image(systemName: systemImage)
|
||||
.frame(width: 20, height: 20)
|
||||
.foregroundColor(active ? Color("AppRedColor") : .accentColor)
|
||||
if playerActionsButtonLabelStyle.text {
|
||||
Text(name.localized())
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(active ? Color("AppRedColor") : .secondary)
|
||||
.font(.caption2)
|
||||
.allowsTightening(true)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, playerActionsButtonLabelStyle.text ? 6 : 12)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user