mirror of
https://github.com/yattee/yattee.git
synced 2025-12-12 19:18:16 +00:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
36
CHANGELOG.md
36
CHANGELOG.md
@@ -1,12 +1,40 @@
|
||||
## Build 139
|
||||
* Fixed issue where channels in Favorites would not refresh contents
|
||||
* Added Japanese localization
|
||||
## Build 140
|
||||
* Improved player layout
|
||||
- Video titles can now span multiple lines for readability
|
||||
- Channel details and video dates/likes/dislikes displayed below title
|
||||
- Segmented picker between Info page and Comments
|
||||
- Info page combines description, chapters, inspector, and related
|
||||
- Description is collapsed by default, tap to expand
|
||||
- Chapters are displayed in horizontal scroll view
|
||||
- Gesture to toggle fullscreen size of details is changed to double tap above action buttons
|
||||
* Opening channel from current video, related or from comments will open it in sheet above player instead of in browser (iOS)
|
||||
* Added settings toggles for enabling more action buttons:
|
||||
- Toggle fullscreen
|
||||
- Toggle PiP
|
||||
- Lock orientation
|
||||
- Restart video
|
||||
- Play next video
|
||||
- Music mode
|
||||
* Added browsing setting to toggle visibility of button to change video watch status
|
||||
* Added player setting to show Inspector always or only for local videos
|
||||
* Added player setting to show video descriptions expanded (now gets collapsed by default)
|
||||
* Added playback mode menu to Playback Settings
|
||||
* Changed layout to vertical and added configuration buttons for remaining views on tvOS (Popular, Trending, Playlists, Search)
|
||||
* Simplified animation on closing player
|
||||
* Removed "Watch Next" view
|
||||
* Fixed reported crashes
|
||||
* Fixed issues with opening channel URLs
|
||||
* Fixed issue where account username would get truncated
|
||||
* Fixed issue where marking all feed videos as watched/unwatched would not refresh actions in Subscriptions menu
|
||||
* Fixed issue where closing channel would require multiple back presses
|
||||
* Other minor changes and improvements
|
||||
|
||||
### Previous Builds
|
||||
* Added pagination/infinite scroll for channel contents (Invidious and Piped)
|
||||
* Added support for channel tabs for Invidious (previously available only for Piped)
|
||||
* Added filter to hide Short videos, available via view menu/toolbar button
|
||||
* Added localizations: Arabic, Portugese, Portuguese (Brazil)
|
||||
* Added localizations: Arabic, Japanese, Portugese, Portuguese (Brazil)
|
||||
* Added browsing setting: "Show unwatched feed badges"
|
||||
* Fixed reported crashes
|
||||
* Fixed issue where channels in Favorites would not refresh contents
|
||||
* Other minor changes and improvements
|
||||
|
||||
25
Gemfile.lock
25
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.752.0)
|
||||
aws-sdk-core (3.171.0)
|
||||
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.63.0)
|
||||
aws-sdk-core (~> 3, >= 3.165.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sdk-s3 (1.119.1)
|
||||
aws-sdk-s3 (1.121.0)
|
||||
aws-sdk-core (~> 3, >= 3.165.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.4)
|
||||
@@ -66,7 +66,7 @@ GEM
|
||||
faraday_middleware (1.2.0)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.2.6)
|
||||
fastlane (2.212.1)
|
||||
fastlane (2.212.2)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
artifactory (~> 3.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.39.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)
|
||||
@@ -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? {
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,7 +2,7 @@ import Defaults
|
||||
import Foundation
|
||||
|
||||
struct FavoritesModel {
|
||||
static let shared = FavoritesModel()
|
||||
static let shared = Self()
|
||||
|
||||
@Default(.showFavoritesInHome) var showFavoritesInHome
|
||||
@Default(.favorites) var all
|
||||
|
||||
@@ -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) }
|
||||
@@ -264,6 +260,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)
|
||||
|
||||
@@ -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:
|
||||
@@ -84,6 +84,9 @@ final class NavigationModel: ObservableObject {
|
||||
@Published var presentingAccounts = false
|
||||
@Published var presentingWelcomeScreen = false
|
||||
|
||||
@Published var presentingChannelSheet = false
|
||||
@Published var channelPresentedInSheet: Channel!
|
||||
|
||||
@Published var presentingShareSheet = false
|
||||
@Published var shareURL: URL?
|
||||
|
||||
@@ -103,7 +106,6 @@ final class NavigationModel: ObservableObject {
|
||||
|
||||
hideKeyboard()
|
||||
let presentingPlayer = player.presentingPlayer
|
||||
player.hide()
|
||||
presentingChannel = false
|
||||
|
||||
#if os(macOS)
|
||||
@@ -113,20 +115,30 @@ 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()
|
||||
}
|
||||
#else
|
||||
navigateToChannel()
|
||||
#endif
|
||||
}
|
||||
|
||||
func openChannelPlaylist(_ playlist: ChannelPlaylist, navigationStyle: NavigationStyle) {
|
||||
@@ -273,6 +285,11 @@ final class NavigationModel: ObservableObject {
|
||||
shareURL = url
|
||||
presentingShareSheet = true
|
||||
}
|
||||
|
||||
func presentChannelInSheet(_ channel: Channel) {
|
||||
channelPresentedInSheet = channel
|
||||
presentingChannelSheet = true
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -105,47 +105,27 @@ 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 os(tvOS)
|
||||
if model.activeBackend == .appleAVPlayer {
|
||||
model.avPlayerBackend.controller?.dismiss(animated: false)
|
||||
}
|
||||
#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()
|
||||
}
|
||||
if model.queue.isEmpty {
|
||||
#if os(tvOS)
|
||||
if model.activeBackend == .appleAVPlayer {
|
||||
model.avPlayerBackend.controller?.dismiss(animated: false)
|
||||
}
|
||||
#endif
|
||||
model.resetQueue()
|
||||
model.hide()
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -334,7 +334,7 @@ final class PlayerModel: ObservableObject {
|
||||
pause()
|
||||
videoBeingOpened = video
|
||||
|
||||
WatchNextViewModel.shared.hide()
|
||||
navigation.presentingChannelSheet = false
|
||||
|
||||
var changeBackendHandler: (() -> Void)?
|
||||
|
||||
@@ -664,6 +664,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
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -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
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -14,7 +14,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 +36,7 @@ extension Watch {
|
||||
|
||||
watch.videoDuration = duration
|
||||
watch.stoppedAt = duration
|
||||
watch.watchedAt = Date()
|
||||
watch.watchedAt = watchedAt ?? .init()
|
||||
|
||||
try? context.save()
|
||||
}
|
||||
@@ -51,7 +51,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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,8 +6,6 @@ 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 }
|
||||
|
||||
@@ -6,13 +6,9 @@ struct ChannelPlaylistView: View {
|
||||
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
|
||||
|
||||
@@ -170,7 +166,7 @@ struct ChannelPlaylistView: View {
|
||||
#if os(iOS)
|
||||
.navigationBarTrailing
|
||||
#else
|
||||
.automatic
|
||||
.automatic
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import SwiftUI
|
||||
struct ChannelVideosView: View {
|
||||
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
|
||||
@@ -40,7 +37,7 @@ struct ChannelVideosView: View {
|
||||
}
|
||||
|
||||
var contentItems: [ContentItem] {
|
||||
return contentTypeItems.collection
|
||||
contentTypeItems.collection
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -119,51 +116,49 @@ 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 {
|
||||
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
|
||||
}
|
||||
@@ -234,14 +229,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 +247,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)
|
||||
@@ -320,7 +315,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 +323,7 @@ struct ChannelVideosView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
}
|
||||
|
||||
private func typeAvailable(_ type: Channel.ContentType) -> Bool {
|
||||
@@ -424,18 +420,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 +461,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]
|
||||
|
||||
@@ -52,6 +52,7 @@ 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 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
|
||||
@@ -131,9 +136,12 @@ extension Defaults.Keys {
|
||||
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)
|
||||
#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
|
||||
@@ -199,10 +207,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 +229,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 +243,11 @@ 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 showInspector = Key<ShowInspectorSetting>("showInspector", default: .onlyLocal)
|
||||
}
|
||||
|
||||
enum ResolutionSetting: String, CaseIterable, Defaults.Serializable {
|
||||
@@ -280,7 +287,7 @@ enum PlayerSidebarSetting: String, CaseIterable, Defaults.Serializable {
|
||||
#if os(macOS)
|
||||
.always
|
||||
#else
|
||||
.whenFits
|
||||
.whenFits
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -343,12 +350,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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -9,8 +9,6 @@ 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
|
||||
|
||||
@@ -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,7 +6,6 @@ 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
|
||||
|
||||
@Default(.showHome) private var showHome
|
||||
|
||||
@@ -8,20 +8,13 @@ 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
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
@@ -120,6 +113,15 @@ 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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import Siesta
|
||||
|
||||
struct OpenURLHandler {
|
||||
static var firstHandle = true
|
||||
static var shared = OpenURLHandler()
|
||||
static var shared = Self()
|
||||
static let yatteeProtocol = "yattee://"
|
||||
|
||||
var accounts: AccountsModel { .shared }
|
||||
@@ -163,7 +163,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,
|
||||
|
||||
@@ -5,8 +5,7 @@ import SwiftUI
|
||||
#if os(iOS)
|
||||
struct AppleAVPlayerView: UIViewRepresentable {
|
||||
func makeUIView(context _: Context) -> some UIView {
|
||||
let playerLayerView = PlayerLayerView(frame: .zero)
|
||||
return playerLayerView
|
||||
PlayerLayerView(frame: .zero)
|
||||
}
|
||||
|
||||
func updateUIView(_: UIViewType, context _: Context) {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -28,7 +28,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,7 +39,6 @@ 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
|
||||
|
||||
private let controlsOverlayModel = ControlOverlaysModel.shared
|
||||
@@ -147,7 +145,7 @@ struct PlayerControls: View {
|
||||
seekBackwardButton
|
||||
seekForwardButton
|
||||
#endif
|
||||
if playerControlsAdvanceToNextEnabled {
|
||||
if playerControlsRestartEnabled {
|
||||
restartVideoButton
|
||||
}
|
||||
if playerControlsAdvanceToNextEnabled {
|
||||
@@ -162,9 +160,6 @@ struct PlayerControls: View {
|
||||
if playerControlsPlaybackModeEnabled {
|
||||
playbackModeButton
|
||||
}
|
||||
if playerControlsNextEnabled {
|
||||
watchNextButton
|
||||
}
|
||||
#if os(tvOS)
|
||||
closeVideoButton
|
||||
#else
|
||||
@@ -181,7 +176,7 @@ struct PlayerControls: View {
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.opacity(model.presentingControls ? 1 : 0)
|
||||
.opacity(model.presentingControls && !player.availableStreams.isEmpty ? 1 : 0)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -334,7 +329,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 +355,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 +367,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 +384,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 +441,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 {
|
||||
|
||||
@@ -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,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,32 @@ import SwiftUI
|
||||
struct ChaptersView: View {
|
||||
@ObservedObject private var player = PlayerModel.shared
|
||||
|
||||
var chapters: [Chapter] {
|
||||
player.videoForDisplay?.chapters ?? []
|
||||
}
|
||||
|
||||
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)
|
||||
ScrollView(.horizontal) {
|
||||
LazyHStack(spacing: 20) {
|
||||
ForEach(chapters) { chapter in
|
||||
ChapterView(chapter: chapter)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 15)
|
||||
}
|
||||
.frame(minHeight: ChapterView.thumbnailHeight + 100)
|
||||
#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
|
||||
|
||||
@@ -25,7 +25,6 @@ struct CommentsView: View {
|
||||
.borderBottom(height: comment != last ? 0.5 : 0, color: Color("ControlsBorderColor"))
|
||||
}
|
||||
}
|
||||
.padding(.top, 55)
|
||||
|
||||
if embedInScrollView {
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
|
||||
@@ -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) {
|
||||
@@ -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:
|
||||
@@ -74,6 +96,8 @@ struct VideoActions: View {
|
||||
return !(video?.isLocal ?? true) && accounts.signedIn && accounts.app.supportsSubscriptions
|
||||
case .settings:
|
||||
return video != nil
|
||||
case .advanceToNextItem:
|
||||
return player.isAdvanceToNextItemAvailable
|
||||
default:
|
||||
return true
|
||||
}
|
||||
@@ -116,6 +140,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 +167,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 +174,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,26 +182,20 @@ 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)
|
||||
}
|
||||
|
||||
@@ -6,27 +6,55 @@ import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct VideoDescription: View {
|
||||
static let collapsedLines = 5
|
||||
|
||||
private var search: SearchModel { .shared }
|
||||
@Default(.showKeywords) private var showKeywords
|
||||
@Default(.expandVideoDescription) private var expandVideoDescription
|
||||
|
||||
var video: Video
|
||||
var detailsSize: CGSize?
|
||||
@Binding var expand: Bool
|
||||
|
||||
var description: String {
|
||||
video.description ?? ""
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if !expandVideoDescription && !expand {
|
||||
Button {
|
||||
expand = true
|
||||
} label: {
|
||||
descriptionView
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
descriptionView
|
||||
}
|
||||
}
|
||||
.id(video.videoID)
|
||||
}
|
||||
|
||||
var descriptionView: some View {
|
||||
VStack {
|
||||
#if os(iOS)
|
||||
ActiveLabelDescriptionRepresentable(description: description, detailsSize: detailsSize)
|
||||
ActiveLabelDescriptionRepresentable(
|
||||
description: description,
|
||||
detailsSize: detailsSize,
|
||||
expand: shouldExpand
|
||||
)
|
||||
#else
|
||||
textDescription
|
||||
#endif
|
||||
|
||||
keywords
|
||||
}
|
||||
.id(video.videoID)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
var shouldExpand: Bool {
|
||||
expandVideoDescription || expand
|
||||
}
|
||||
|
||||
@ViewBuilder var textDescription: some View {
|
||||
@@ -34,14 +62,18 @@ struct VideoDescription: View {
|
||||
Group {
|
||||
if #available(macOS 12, *) {
|
||||
Text(description)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.lineLimit(shouldExpand ? 500 : Self.collapsedLines)
|
||||
#if !os(tvOS)
|
||||
.textSelection(.enabled)
|
||||
#endif
|
||||
} else {
|
||||
Text(description)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.lineLimit(shouldExpand ? 500 : Self.collapsedLines)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.multilineTextAlignment(.leading)
|
||||
.font(.system(size: 14))
|
||||
.lineSpacing(3)
|
||||
#endif
|
||||
@@ -89,6 +121,7 @@ struct VideoDescription: View {
|
||||
struct ActiveLabelDescriptionRepresentable: UIViewRepresentable {
|
||||
var description: String
|
||||
var detailsSize: CGSize?
|
||||
var expand: Bool
|
||||
|
||||
@State private var label = ActiveLabel()
|
||||
|
||||
@@ -103,12 +136,12 @@ struct VideoDescription: View {
|
||||
|
||||
func updateUIView(_: UIViewType, context _: Context) {
|
||||
updatePreferredMaxLayoutWidth()
|
||||
updateNumberOfLines()
|
||||
}
|
||||
|
||||
func customizeLabel() {
|
||||
label.customize { label in
|
||||
label.enabledTypes = [.url, .timestamp]
|
||||
label.numberOfLines = 0
|
||||
label.text = description
|
||||
label.contentMode = .scaleAspectFill
|
||||
label.font = .systemFont(ofSize: 14)
|
||||
@@ -119,12 +152,17 @@ struct VideoDescription: View {
|
||||
label.handleURLTap(urlTapHandler(_:))
|
||||
label.handleTimestampTap(timestampTapHandler(_:))
|
||||
}
|
||||
updateNumberOfLines()
|
||||
}
|
||||
|
||||
func updatePreferredMaxLayoutWidth() {
|
||||
label.preferredMaxLayoutWidth = (detailsSize?.width ?? 330) - 30
|
||||
}
|
||||
|
||||
func updateNumberOfLines() {
|
||||
label.numberOfLines = expand ? 0 : VideoDescription.collapsedLines
|
||||
}
|
||||
|
||||
func urlTapHandler(_ url: URL) {
|
||||
var urlToOpen = url
|
||||
|
||||
@@ -156,7 +194,7 @@ struct VideoDescription: View {
|
||||
|
||||
struct VideoDescription_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
VideoDescription(video: .fixture)
|
||||
VideoDescription(video: .fixture, expand: .constant(false))
|
||||
.injectFixtureEnvironmentObjects()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,21 +4,146 @@ import SDWebImageSwiftUI
|
||||
import SwiftUI
|
||||
|
||||
struct VideoDetails: View {
|
||||
enum DetailsPage: String, CaseIterable, Defaults.Serializable {
|
||||
case info, comments, chapters, inspector
|
||||
struct TitleView: View {
|
||||
@ObservedObject private var model = PlayerModel.shared
|
||||
@State private var titleSize = CGSize.zero
|
||||
|
||||
var systemImageName: String {
|
||||
switch self {
|
||||
case .info:
|
||||
return "info.circle"
|
||||
case .inspector:
|
||||
return "wand.and.stars"
|
||||
case .comments:
|
||||
return "text.bubble"
|
||||
case .chapters:
|
||||
return "bookmark"
|
||||
var video: Video? { model.videoForDisplay }
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
Text(model.videoForDisplay?.displayTitle ?? "Not playing")
|
||||
.font(.title3.bold())
|
||||
.lineLimit(4)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
struct ChannelView: View {
|
||||
@ObservedObject private var model = PlayerModel.shared
|
||||
|
||||
var video: Video? { model.videoForDisplay }
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Button {
|
||||
guard let channel = video?.channel else { return }
|
||||
NavigationModel.shared.openChannel(channel, navigationStyle: .sidebar)
|
||||
} label: {
|
||||
ChannelAvatarView(
|
||||
channel: video?.channel,
|
||||
video: video
|
||||
)
|
||||
.frame(maxWidth: 40, maxHeight: 40)
|
||||
.padding(.trailing, 5)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack {
|
||||
Text(model.videoForDisplay?.channel.name ?? "Yattee")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.lineLimit(1)
|
||||
|
||||
if let video, !video.isLocal {
|
||||
Group {
|
||||
Text("•")
|
||||
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: "person.2.fill")
|
||||
|
||||
if let channel = model.videoForDisplay?.channel {
|
||||
if let subscriptions = channel.subscriptionsString {
|
||||
Text(subscriptions)
|
||||
} else {
|
||||
Text("1234").redacted(reason: .placeholder)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if video != nil {
|
||||
VideoMetadataView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct VideoMetadataView: View {
|
||||
@ObservedObject private var model = PlayerModel.shared
|
||||
@Default(.enableReturnYouTubeDislike) private var enableReturnYouTubeDislike
|
||||
|
||||
var video: Video? { model.videoForDisplay }
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
publishedDateSection
|
||||
|
||||
Text("•")
|
||||
|
||||
HStack(spacing: 4) {
|
||||
if model.videoBeingOpened != nil || video?.viewsCount != nil {
|
||||
Image(systemName: "eye")
|
||||
}
|
||||
|
||||
if let views = video?.viewsCount {
|
||||
Text(views)
|
||||
} else if model.videoBeingOpened != nil {
|
||||
Text("1,234M").redacted(reason: .placeholder)
|
||||
}
|
||||
|
||||
if model.videoBeingOpened != nil || video?.likesCount != nil {
|
||||
Image(systemName: "hand.thumbsup")
|
||||
}
|
||||
|
||||
if let likes = video?.likesCount, !likes.isEmpty {
|
||||
Text(likes)
|
||||
} else {
|
||||
Text("1,234M").redacted(reason: .placeholder)
|
||||
}
|
||||
|
||||
if enableReturnYouTubeDislike {
|
||||
if model.videoBeingOpened != nil || video?.dislikesCount != nil {
|
||||
Image(systemName: "hand.thumbsdown")
|
||||
}
|
||||
|
||||
if let dislikes = video?.dislikesCount, !dislikes.isEmpty {
|
||||
Text(dislikes)
|
||||
} else {
|
||||
Text("1,234M").redacted(reason: .placeholder)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
var publishedDateSection: some View {
|
||||
Group {
|
||||
if let video {
|
||||
HStack(spacing: 4) {
|
||||
if let published = video.publishedDate {
|
||||
Text(published)
|
||||
} else {
|
||||
Text("1 century ago").redacted(reason: .placeholder)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum DetailsPage: String, CaseIterable, Defaults.Serializable {
|
||||
case info, comments, queue
|
||||
|
||||
var title: String {
|
||||
rawValue.capitalized.localized()
|
||||
@@ -28,13 +153,14 @@ struct VideoDetails: View {
|
||||
var video: Video?
|
||||
|
||||
@Binding var fullScreen: Bool
|
||||
var bottomPadding = false
|
||||
@Binding var sidebarQueue: Bool
|
||||
|
||||
@State private var detailsSize = CGSize.zero
|
||||
@State private var detailsVisibility = Constants.detailsVisibility
|
||||
@State private var subscribed = false
|
||||
@State private var subscriptionToggleButtonDisabled = false
|
||||
@State private var page = DetailsPage.info
|
||||
@State private var descriptionExpanded = false
|
||||
|
||||
@Environment(\.navigationStyle) private var navigationStyle
|
||||
#if os(iOS)
|
||||
@@ -49,22 +175,41 @@ struct VideoDetails: View {
|
||||
|
||||
@Default(.enableReturnYouTubeDislike) private var enableReturnYouTubeDislike
|
||||
@Default(.playerSidebar) private var playerSidebar
|
||||
@Default(.showInspector) private var showInspector
|
||||
@Default(.expandVideoDescription) private var expandVideoDescription
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ControlsBar(
|
||||
fullScreen: $fullScreen,
|
||||
expansionState: .constant(.full),
|
||||
presentingControls: false,
|
||||
backgroundEnabled: false,
|
||||
borderTop: false,
|
||||
detailsTogglePlayer: false,
|
||||
detailsToggleFullScreen: true
|
||||
)
|
||||
.animation(nil, value: player.currentItem)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
TitleView()
|
||||
if video != nil, !video!.isLocal {
|
||||
ChannelView()
|
||||
.layoutPriority(1)
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
.padding(.horizontal, 16)
|
||||
#if !os(tvOS)
|
||||
.tapRecognizer(
|
||||
tapSensitivity: 0.2,
|
||||
doubleTapAction: {
|
||||
withAnimation(.default) {
|
||||
fullScreen.toggle()
|
||||
}
|
||||
}
|
||||
)
|
||||
#endif
|
||||
|
||||
VideoActions(video: player.videoForDisplay)
|
||||
.padding(.vertical, 5)
|
||||
.frame(maxHeight: 50)
|
||||
.frame(maxWidth: .infinity)
|
||||
.borderTop(height: 0.5, color: Color("ControlsBorderColor"))
|
||||
.borderBottom(height: 0.5, color: Color("ControlsBorderColor"))
|
||||
.animation(nil, value: player.currentItem)
|
||||
.frame(minWidth: 0, maxWidth: .infinity)
|
||||
|
||||
pageView
|
||||
#if os(iOS)
|
||||
@@ -100,217 +245,151 @@ struct VideoDetails: View {
|
||||
}
|
||||
|
||||
@ViewBuilder var pageMenu: some View {
|
||||
#if os(macOS)
|
||||
pagePicker
|
||||
.labelsHidden()
|
||||
.offset(x: 15, y: 15)
|
||||
.frame(maxWidth: 200)
|
||||
#elseif os(iOS)
|
||||
Menu {
|
||||
pagePicker
|
||||
} label: {
|
||||
HStack {
|
||||
Label(page.title, systemImage: page.systemImageName)
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.imageScale(.small)
|
||||
}
|
||||
.padding(10)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.modifier(ControlBackgroundModifier())
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
.frame(width: 200, alignment: .leading)
|
||||
.transaction { t in t.animation = nil }
|
||||
}
|
||||
.animation(nil, value: detailsVisibility)
|
||||
.modifier(SettingsPickerModifier())
|
||||
.offset(x: 15, y: 5)
|
||||
#endif
|
||||
}
|
||||
|
||||
var pagePicker: some View {
|
||||
Picker("Page", selection: $page) {
|
||||
ForEach(DetailsPage.allCases.filter { pageAvailable($0) }, id: \.rawValue) { page in
|
||||
Label(page.title, systemImage: page.systemImageName).tag(page)
|
||||
Text(page.title).tag(page)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
}
|
||||
|
||||
func pageAvailable(_ page: DetailsPage) -> Bool {
|
||||
guard let video else { return false }
|
||||
|
||||
switch page {
|
||||
case .inspector:
|
||||
return true
|
||||
case .queue:
|
||||
return !sidebarQueue && player.isAdvanceToNextItemAvailable
|
||||
default:
|
||||
return !video.isLocal
|
||||
}
|
||||
}
|
||||
|
||||
var pageView: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
switch page {
|
||||
case .info:
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
if let video {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
videoProperties
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
.padding(.bottom, 12)
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
LazyVStack {
|
||||
pageMenu
|
||||
.padding(5)
|
||||
|
||||
if !player.videoBeingOpened.isNil && (video.description.isNil || video.description!.isEmpty) {
|
||||
VStack {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
switch page {
|
||||
case .info:
|
||||
Group {
|
||||
if let video {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if !player.videoBeingOpened.isNil && (video.description.isNil || video.description!.isEmpty) {
|
||||
VStack {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
} else if let description = video.description, !description.isEmpty {
|
||||
Section(header: descriptionHeader) {
|
||||
VideoDescription(video: video, detailsSize: detailsSize, expand: $descriptionExpanded)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
} else if !video.isLocal {
|
||||
Text("No description")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
if player.videoBeingOpened.isNil,
|
||||
!video.isLocal,
|
||||
!video.chapters.isEmpty
|
||||
{
|
||||
Section(header: chaptersHeader) {
|
||||
ChaptersView()
|
||||
}
|
||||
}
|
||||
|
||||
if player.videoBeingOpened.isNil,
|
||||
video.isLocal || showInspector == .always
|
||||
{
|
||||
InspectorView(video: player.videoForDisplay)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
if player.videoBeingOpened.isNil,
|
||||
!sidebarQueue,
|
||||
!(player.videoForDisplay?.related.isEmpty ?? true)
|
||||
{
|
||||
RelatedView()
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 20)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
} else if video.description != nil, !video.description!.isEmpty {
|
||||
VideoDescription(video: video, detailsSize: detailsSize)
|
||||
#if os(iOS)
|
||||
.padding(.bottom, player.playingFullScreen ? 10 : SafeArea.insets.bottom)
|
||||
#endif
|
||||
} else if !video.isLocal {
|
||||
Text("No description")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.bottom, 60)
|
||||
}
|
||||
.padding(.top, 18)
|
||||
.padding(.bottom, 60)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if video != nil, !pageAvailable(page) {
|
||||
page = .inspector
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.onAppear {
|
||||
if fullScreen {
|
||||
if let video, video.isLocal {
|
||||
page = .inspector
|
||||
}
|
||||
detailsVisibility = true
|
||||
return
|
||||
}
|
||||
Delay.by(0.4) { withAnimation(.easeIn(duration: 0.25)) { self.detailsVisibility = true } }
|
||||
}
|
||||
#endif
|
||||
.transition(.opacity)
|
||||
.animation(nil, value: player.currentItem)
|
||||
.padding(.horizontal)
|
||||
#if os(iOS)
|
||||
.frame(maxWidth: YatteeApp.isForPreviews ? .infinity : maxWidth)
|
||||
#endif
|
||||
|
||||
case .inspector:
|
||||
InspectorView(video: video)
|
||||
|
||||
case .chapters:
|
||||
ChaptersView()
|
||||
|
||||
case .comments:
|
||||
CommentsView(embedInScrollView: true)
|
||||
.onAppear {
|
||||
comments.loadIfNeeded()
|
||||
if video != nil, !pageAvailable(page) {
|
||||
page = .info
|
||||
}
|
||||
}
|
||||
}
|
||||
.transition(.opacity)
|
||||
.animation(nil, value: player.currentItem)
|
||||
#if os(iOS)
|
||||
.frame(maxWidth: YatteeApp.isForPreviews ? .infinity : maxWidth)
|
||||
#endif
|
||||
|
||||
pageMenu
|
||||
.font(.headline)
|
||||
.foregroundColor(.accentColor)
|
||||
.zIndex(1)
|
||||
case .queue:
|
||||
PlayerQueueView(sidebarQueue: false)
|
||||
.padding(.horizontal)
|
||||
|
||||
#if !os(tvOS)
|
||||
if #available(iOS 16, macOS 13, *) {
|
||||
Rectangle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
gradient: .init(colors: [fadePlaceholderStartColor, .clear]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.zIndex(0)
|
||||
.frame(maxHeight: 22)
|
||||
case .comments:
|
||||
CommentsView(embedInScrollView: false)
|
||||
.onAppear {
|
||||
comments.loadIfNeeded()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.onAppear {
|
||||
if fullScreen {
|
||||
if let video, video.isLocal {
|
||||
page = .info
|
||||
}
|
||||
detailsVisibility = true
|
||||
return
|
||||
}
|
||||
Delay.by(0.8) { withAnimation(.easeIn(duration: 0.25)) { self.detailsVisibility = true } }
|
||||
}
|
||||
}
|
||||
|
||||
var fadePlaceholderStartColor: Color {
|
||||
#if os(macOS)
|
||||
.secondaryBackground
|
||||
#elseif os(iOS)
|
||||
.background
|
||||
#else
|
||||
.clear
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder var videoProperties: some View {
|
||||
HStack(spacing: 4) {
|
||||
Spacer()
|
||||
publishedDateSection
|
||||
|
||||
Text("•")
|
||||
|
||||
HStack(spacing: 4) {
|
||||
if player.videoBeingOpened != nil || video?.viewsCount != nil {
|
||||
Image(systemName: "eye")
|
||||
}
|
||||
|
||||
if let views = video?.viewsCount {
|
||||
Text(views)
|
||||
} else if player.videoBeingOpened != nil {
|
||||
Text("1,234M").redacted(reason: .placeholder)
|
||||
}
|
||||
|
||||
if player.videoBeingOpened != nil || video?.likesCount != nil {
|
||||
Image(systemName: "hand.thumbsup")
|
||||
}
|
||||
|
||||
if let likes = video?.likesCount {
|
||||
Text(likes)
|
||||
} else if player.videoBeingOpened == nil {
|
||||
Text("1,234M").redacted(reason: .placeholder)
|
||||
}
|
||||
|
||||
if enableReturnYouTubeDislike {
|
||||
if player.videoBeingOpened != nil || video?.dislikesCount != nil {
|
||||
Image(systemName: "hand.thumbsdown")
|
||||
}
|
||||
|
||||
if let dislikes = video?.dislikesCount {
|
||||
Text(dislikes)
|
||||
} else if player.videoBeingOpened == nil {
|
||||
Text("1,234M").redacted(reason: .placeholder)
|
||||
}
|
||||
}
|
||||
.onChange(of: player.queue) { _ in
|
||||
if video != nil, !pageAvailable(page) {
|
||||
page = .info
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var descriptionHeader: some View {
|
||||
HStack {
|
||||
Text("Description".localized())
|
||||
|
||||
if !expandVideoDescription, !descriptionExpanded {
|
||||
Spacer()
|
||||
Image(systemName: "arrow.up.and.down")
|
||||
.imageScale(.small)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
var publishedDateSection: some View {
|
||||
Group {
|
||||
if let video {
|
||||
HStack(spacing: 4) {
|
||||
if let published = video.publishedDate {
|
||||
Text(published)
|
||||
} else {
|
||||
Text("1 century ago").redacted(reason: .placeholder)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var chaptersHeader: some View {
|
||||
Text("Chapters".localized())
|
||||
.padding(.horizontal)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
struct VideoDetails_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
VideoDetails(video: .fixture, fullScreen: .constant(false))
|
||||
VideoDetails(video: .fixture, fullScreen: .constant(false), sidebarQueue: .constant(false))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,18 +5,15 @@ struct VideoDetailsPaddingModifier: ViewModifier {
|
||||
static var defaultAdditionalDetailsPadding = 0.0
|
||||
|
||||
let playerSize: CGSize
|
||||
let minimumHeightLeft: Double
|
||||
let additionalPadding: Double
|
||||
let fullScreen: Bool
|
||||
|
||||
init(
|
||||
playerSize: CGSize,
|
||||
minimumHeightLeft: Double? = nil,
|
||||
additionalPadding: Double? = nil,
|
||||
fullScreen: Bool = false
|
||||
) {
|
||||
self.playerSize = playerSize
|
||||
self.minimumHeightLeft = minimumHeightLeft ?? VideoPlayerView.defaultMinimumHeightLeft
|
||||
self.additionalPadding = additionalPadding ?? Self.defaultAdditionalDetailsPadding
|
||||
self.fullScreen = fullScreen
|
||||
}
|
||||
|
||||
@@ -53,7 +53,6 @@ struct VideoPlayerView: View {
|
||||
@State internal var isVerticalDrag = false
|
||||
@State internal var viewDragOffset = Self.hiddenOffset
|
||||
@State internal var orientationObserver: Any?
|
||||
@State internal var orientationNotification: Any?
|
||||
#endif
|
||||
|
||||
@ObservedObject internal var player = PlayerModel.shared
|
||||
@@ -80,8 +79,6 @@ struct VideoPlayerView: View {
|
||||
#endif
|
||||
|
||||
overlay
|
||||
|
||||
WatchNextView()
|
||||
}
|
||||
.onAppear {
|
||||
if player.musicMode {
|
||||
@@ -92,6 +89,14 @@ struct VideoPlayerView: View {
|
||||
.onChange(of: playerSidebar) { _ in
|
||||
updateSidebarQueue()
|
||||
}
|
||||
#if os(macOS)
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingChannelSheet) {
|
||||
ChannelVideosView(channel: navigation.channelPresentedInSheet, showCloseButton: true, inNavigationView: false)
|
||||
.frame(minWidth: 1000, minHeight: 700)
|
||||
}
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
var videoPlayer: some View {
|
||||
@@ -123,8 +128,6 @@ struct VideoPlayerView: View {
|
||||
.onChange(of: player.presentingPlayer) { newValue in
|
||||
if newValue {
|
||||
viewDragOffset = 0
|
||||
} else {
|
||||
viewDragOffset = Self.hiddenOffset
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
@@ -223,7 +226,7 @@ struct VideoPlayerView: View {
|
||||
#endif
|
||||
|
||||
#if !os(tvOS)
|
||||
if !fullScreenPlayer && sidebarQueue {
|
||||
if !fullScreenPlayer, sidebarQueue {
|
||||
Spacer()
|
||||
}
|
||||
#endif
|
||||
@@ -323,7 +326,7 @@ struct VideoPlayerView: View {
|
||||
VideoDetails(
|
||||
video: player.videoForDisplay,
|
||||
fullScreen: $fullScreenDetails,
|
||||
bottomPadding: detailsNeedBottomPadding
|
||||
sidebarQueue: $sidebarQueue
|
||||
)
|
||||
#if os(iOS)
|
||||
.ignoresSafeArea(.all, edges: .bottom)
|
||||
@@ -337,6 +340,7 @@ struct VideoPlayerView: View {
|
||||
player.setNeedsDrawing(true)
|
||||
}
|
||||
}
|
||||
.id(player.currentVideo?.cacheKey)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
@@ -386,16 +390,29 @@ struct VideoPlayerView: View {
|
||||
if !fullScreenPlayer {
|
||||
#if os(iOS)
|
||||
if sidebarQueue {
|
||||
PlayerQueueView(sidebarQueue: true)
|
||||
.frame(maxWidth: 350)
|
||||
.background(colorScheme == .dark ? Color.black : Color.white)
|
||||
.transition(.move(edge: .bottom))
|
||||
List {
|
||||
PlayerQueueView(sidebarQueue: true)
|
||||
}
|
||||
#if os(macOS)
|
||||
.listStyle(.inset)
|
||||
#elseif os(iOS)
|
||||
.listStyle(.grouped)
|
||||
.backport
|
||||
.scrollContentBackground(false)
|
||||
#else
|
||||
.listStyle(.plain)
|
||||
#endif
|
||||
.frame(maxWidth: 350)
|
||||
.background(colorScheme == .dark ? Color.black : Color.white)
|
||||
.transition(.move(edge: .bottom))
|
||||
}
|
||||
#elseif os(macOS)
|
||||
if Defaults[.playerSidebar] != .never {
|
||||
PlayerQueueView(sidebarQueue: true)
|
||||
.frame(width: 350)
|
||||
.background(colorScheme == .dark ? Color.black : Color.white)
|
||||
List {
|
||||
PlayerQueueView(sidebarQueue: true)
|
||||
}
|
||||
.frame(maxWidth: 450)
|
||||
.background(colorScheme == .dark ? Color.black : Color.white)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -413,14 +430,7 @@ struct VideoPlayerView: View {
|
||||
}
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
var detailsNeedBottomPadding: Bool {
|
||||
#if os(iOS)
|
||||
return true
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
.ignoresSafeArea(edges: .horizontal)
|
||||
}
|
||||
|
||||
var fullScreenPlayer: Bool {
|
||||
|
||||
@@ -1,363 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct WatchNextView: View {
|
||||
@ObservedObject private var model = WatchNextViewModel.shared
|
||||
@ObservedObject private var player = PlayerModel.shared
|
||||
|
||||
@Default(.saveHistory) private var saveHistory
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if model.isPresenting {
|
||||
#if os(iOS)
|
||||
NavigationView {
|
||||
watchNext
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
watchNextMenu
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
#else
|
||||
VStack {
|
||||
HStack {
|
||||
hideCloseButton
|
||||
.labelStyle(.iconOnly)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Spacer()
|
||||
|
||||
watchNextMenu
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack {
|
||||
#if os(macOS)
|
||||
Text("Mode")
|
||||
.foregroundColor(.secondary)
|
||||
#endif
|
||||
|
||||
playbackModeControl
|
||||
|
||||
HStack {
|
||||
if model.isRestartable {
|
||||
reopenButton
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
#if os(macOS)
|
||||
.padding()
|
||||
#endif
|
||||
watchNext
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.transition(.opacity)
|
||||
.zIndex(0)
|
||||
#if os(tvOS)
|
||||
.background(Color.background(scheme: colorScheme))
|
||||
#else
|
||||
.background(Color.background)
|
||||
#endif
|
||||
}
|
||||
|
||||
var watchNext: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
if model.isAutoplaying,
|
||||
let item = model.nextFromTheQueue
|
||||
{
|
||||
HStack {
|
||||
Text("Playing Next in \(Int(model.countdown.rounded()))...")
|
||||
.font(.headline.monospacedDigit())
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
model.keepFromAutoplaying()
|
||||
} label: {
|
||||
Label("Cancel", systemImage: "pause.fill")
|
||||
#if os(iOS)
|
||||
.imageScale(.large)
|
||||
.padding([.vertical, .leading])
|
||||
.font(.headline.bold())
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
.padding(.top, 10)
|
||||
#endif
|
||||
|
||||
PlayerQueueRow(item: item)
|
||||
|
||||
Divider()
|
||||
.padding(.vertical, 5)
|
||||
}
|
||||
|
||||
moreVideos
|
||||
.padding(.top, 15)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
#if !os(macOS)
|
||||
.navigationTitle(model.page.title)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
hideCloseButton
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
reopenButton
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
var watchNextMenu: some View {
|
||||
#if os(tvOS)
|
||||
Button {
|
||||
model.page = model.page.next()
|
||||
} label: {
|
||||
menuLabel
|
||||
}
|
||||
#elseif os(macOS)
|
||||
pagePicker
|
||||
.modifier(SettingsPickerModifier())
|
||||
#if os(macOS)
|
||||
.frame(maxWidth: 150)
|
||||
#endif
|
||||
#else
|
||||
Menu {
|
||||
pagePicker
|
||||
playbackModePicker
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
menuLabel
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Image(systemName: "chevron.down.circle.fill")
|
||||
.foregroundColor(.accentColor)
|
||||
.imageScale(.small)
|
||||
}
|
||||
.transaction { t in t.animation = nil }
|
||||
}
|
||||
|
||||
#endif
|
||||
}
|
||||
|
||||
var menuLabel: some View {
|
||||
HStack {
|
||||
Image(systemName: model.page.systemImageName)
|
||||
.imageScale(.small)
|
||||
Text(model.page == .queue ? queueTitle : model.page.title)
|
||||
.font(.headline)
|
||||
}
|
||||
}
|
||||
|
||||
var pagePicker: some View {
|
||||
Picker("Page", selection: $model.page) {
|
||||
ForEach(WatchNextViewModel.Page.allCases, id: \.rawValue) { page in
|
||||
Label(
|
||||
page == .queue ? queueTitle : page.title,
|
||||
systemImage: page.systemImageName
|
||||
)
|
||||
.tag(page)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var queueTitle: String {
|
||||
"\(WatchNextViewModel.Page.queue.title) • \(player.queue.count)"
|
||||
}
|
||||
|
||||
@ViewBuilder var hideCloseButton: some View {
|
||||
Group {
|
||||
if model.isHideable {
|
||||
hideButton
|
||||
} else {
|
||||
closeButton
|
||||
}
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
#endif
|
||||
}
|
||||
|
||||
var hideButton: some View {
|
||||
Button {
|
||||
model.hide()
|
||||
} label: {
|
||||
Label("Hide", systemImage: "xmark")
|
||||
}
|
||||
}
|
||||
|
||||
var closeButton: some View {
|
||||
Button {
|
||||
model.close()
|
||||
} label: {
|
||||
Label("Close", systemImage: "xmark")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder var reopenButton: some View {
|
||||
if model.isRestartable {
|
||||
Button {
|
||||
model.restart()
|
||||
} label: {
|
||||
Label(model.reason == .userInteracted ? "Back" : "Reopen", systemImage: "arrow.counterclockwise")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var queueForMoreVideos: [ContentItem] {
|
||||
guard !player.queue.isEmpty else { return [] }
|
||||
|
||||
let suffix = player.playbackMode == .queue && model.isAutoplaying && model.canAutoplay ? 1 : 0
|
||||
return player.queue.suffix(from: suffix).map(\.contentItem)
|
||||
}
|
||||
|
||||
@ViewBuilder var moreVideos: some View {
|
||||
VStack(spacing: 12) {
|
||||
switch model.page {
|
||||
case .queue:
|
||||
|
||||
if player.playbackMode == .related, !(model.isAutoplaying && model.canAutoplay) {
|
||||
autoplaying
|
||||
|
||||
Divider()
|
||||
}
|
||||
|
||||
if (model.isAutoplaying && model.canAutoplay && !queueForMoreVideos.isEmpty) ||
|
||||
(!model.isAutoplaying && !queueForMoreVideos.isEmpty)
|
||||
{
|
||||
HStack {
|
||||
Text("Next in queue")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Spacer()
|
||||
|
||||
ClearQueueButton()
|
||||
}
|
||||
}
|
||||
|
||||
if !queueForMoreVideos.isEmpty {
|
||||
LazyVStack {
|
||||
ForEach(queueForMoreVideos) { item in
|
||||
ContentItemView(item: item)
|
||||
.environment(\.inQueueListing, true)
|
||||
.environment(\.listingStyle, .list)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Label(
|
||||
model.isAutoplaying ? "Nothing more in the queue" : "Queue is empty",
|
||||
systemImage: WatchNextViewModel.Page.queue.systemImageName
|
||||
)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
case .related:
|
||||
if let item = model.item {
|
||||
ForEach(item.video.related) { video in
|
||||
ContentItemView(item: .init(video: video))
|
||||
.environment(\.listingStyle, .list)
|
||||
}
|
||||
} else {
|
||||
Label("Nothing was played",
|
||||
systemImage: WatchNextViewModel.Page.related.systemImageName)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
case .history:
|
||||
if saveHistory {
|
||||
HistoryView(limit: 15)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder var playbackModeControl: some View {
|
||||
#if os(tvOS)
|
||||
Button {
|
||||
player.playbackMode = player.playbackMode.next()
|
||||
} label: {
|
||||
Label(player.playbackMode.description, 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, systemImage: player.playbackMode.systemImage)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
var playbackModePicker: some View {
|
||||
Picker("Playback Mode", selection: $model.player.playbackMode) {
|
||||
ForEach(PlayerModel.PlaybackMode.allCases, id: \.rawValue) { mode in
|
||||
Label(mode.description, systemImage: mode.systemImage).tag(mode)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
}
|
||||
|
||||
@ViewBuilder var autoplaying: some View {
|
||||
Section(header: autoplayingHeader) {
|
||||
if let item = player.autoplayItem {
|
||||
PlayerQueueRow(item: item, autoplay: true)
|
||||
} else {
|
||||
Group {
|
||||
if player.currentItem.isNil {
|
||||
Text("Not Playing")
|
||||
} else {
|
||||
Text("Finding something to play...")
|
||||
}
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var autoplayingHeader: some View {
|
||||
HStack {
|
||||
Text("Autoplaying Next")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Button {
|
||||
player.setRelatedAutoplayItem()
|
||||
} label: {
|
||||
Label("Find Other", systemImage: "arrow.triangle.2.circlepath.circle")
|
||||
.labelStyle(.iconOnly)
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
.disabled(player.currentItem.isNil)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WatchNextView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
WatchNextView()
|
||||
.onAppear {
|
||||
WatchNextViewModel.shared.finishedWatching(.init(.fixture))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,6 @@ struct AddToPlaylistView: View {
|
||||
|
||||
@State private var selectedPlaylistID: Playlist.ID = ""
|
||||
|
||||
@State private var error = ""
|
||||
@State private var presentingErrorAlert = false
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
|
||||
@@ -118,7 +115,7 @@ struct AddToPlaylistView: View {
|
||||
#if os(tvOS)
|
||||
.trailing
|
||||
#else
|
||||
.center
|
||||
.center
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ struct PlaylistVideosView: View {
|
||||
#if os(iOS)
|
||||
.navigationBarTrailing
|
||||
#else
|
||||
.automatic
|
||||
.automatic
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,41 +63,17 @@ struct PlaylistsView: View {
|
||||
|
||||
var body: some View {
|
||||
SignInRequiredView(title: "Playlists".localized()) {
|
||||
Section {
|
||||
VStack {
|
||||
#if os(tvOS)
|
||||
toolbar
|
||||
#endif
|
||||
if currentPlaylist != nil, items.isEmpty {
|
||||
hintText("Playlist is empty\n\nTap and hold on a video and then \n\"Add to Playlist\"".localized())
|
||||
} else if model.all.isEmpty {
|
||||
hintText("You have no playlists\n\nTap on \"New Playlist\" to create one".localized())
|
||||
} else {
|
||||
Group {
|
||||
#if os(tvOS)
|
||||
HorizontalCells(items: items)
|
||||
.padding(.top, 40)
|
||||
Spacer()
|
||||
#else
|
||||
VerticalCells(items: items) {
|
||||
if showCacheStatus {
|
||||
HStack {
|
||||
Spacer()
|
||||
VStack {
|
||||
VerticalCells(items: items, allowEmpty: true) { if shouldDisplayHeader { header } }
|
||||
.environment(\.scrollViewBottomPadding, 70)
|
||||
.environment(\.currentPlaylistID, currentPlaylist?.id)
|
||||
.environment(\.listingStyle, playlistListingStyle)
|
||||
.environment(\.hideShorts, hideShorts)
|
||||
|
||||
CacheStatusHeader(
|
||||
refreshTime: cache.getFormattedPlaylistTime(account: accounts.current),
|
||||
isLoading: model.isLoading
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.environment(\.scrollViewBottomPadding, 70)
|
||||
#endif
|
||||
}
|
||||
.environment(\.currentPlaylistID, currentPlaylist?.id)
|
||||
.environment(\.listingStyle, playlistListingStyle)
|
||||
.environment(\.hideShorts, hideShorts)
|
||||
}
|
||||
if currentPlaylist != nil, items.isEmpty {
|
||||
hintText("Playlist is empty\n\nTap and hold on a video and then \n\"Add to Playlist\"".localized())
|
||||
} else if model.all.isEmpty {
|
||||
hintText("You have no playlists\n\nTap on \"New Playlist\" to create one".localized())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -268,37 +244,6 @@ struct PlaylistsView: View {
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(tvOS)
|
||||
var toolbar: some View {
|
||||
HStack {
|
||||
if model.isEmpty {
|
||||
Text("No Playlists")
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Text("Current Playlist")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
selectPlaylistButton
|
||||
}
|
||||
|
||||
if let playlist = currentPlaylist {
|
||||
editPlaylistButton
|
||||
|
||||
FavoriteButton(item: FavoriteItem(section: .playlist(accounts.current.id, playlist.id)))
|
||||
.labelStyle(.iconOnly)
|
||||
|
||||
playButtons
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
newPlaylistButton
|
||||
.padding(.leading, 40)
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
#endif
|
||||
|
||||
func hintText(_ text: String) -> some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
@@ -341,12 +286,15 @@ struct PlaylistsView: View {
|
||||
|
||||
var selectPlaylistButton: some View {
|
||||
#if os(tvOS)
|
||||
Button(currentPlaylist?.title ?? "Select playlist") {
|
||||
Button {
|
||||
guard currentPlaylist != nil else {
|
||||
return
|
||||
}
|
||||
|
||||
selectedPlaylistID = model.all.next(after: currentPlaylist!)?.id ?? ""
|
||||
} label: {
|
||||
Text(currentPlaylist?.title ?? "Select playlist")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.lineLimit(1)
|
||||
.contextMenu {
|
||||
@@ -405,6 +353,64 @@ struct PlaylistsView: View {
|
||||
}
|
||||
return model.find(id: selectedPlaylistID) ?? model.all.first
|
||||
}
|
||||
|
||||
var shouldDisplayHeader: Bool {
|
||||
#if os(tvOS)
|
||||
true
|
||||
#else
|
||||
showCacheStatus
|
||||
#endif
|
||||
}
|
||||
|
||||
var header: some View {
|
||||
HStack {
|
||||
if model.isEmpty {
|
||||
Text("No Playlists")
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
selectPlaylistButton
|
||||
}
|
||||
|
||||
if let playlist = currentPlaylist {
|
||||
editPlaylistButton
|
||||
|
||||
FavoriteButton(item: FavoriteItem(section: .playlist(accounts.current.id, playlist.id)))
|
||||
.labelStyle(.iconOnly)
|
||||
|
||||
playButtons
|
||||
}
|
||||
|
||||
newPlaylistButton
|
||||
|
||||
Spacer()
|
||||
|
||||
ListingStyleButtons(listingStyle: $playlistListingStyle)
|
||||
HideShortsButtons(hide: $hideShorts)
|
||||
|
||||
if let account = accounts.current, showCacheStatus {
|
||||
CacheStatusHeader(
|
||||
refreshTime: cache.getFormattedPlaylistTime(account: account),
|
||||
isLoading: model.isLoading
|
||||
)
|
||||
}
|
||||
|
||||
Button {
|
||||
model.load(force: true)
|
||||
loadResource()
|
||||
} label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.font(.caption)
|
||||
.imageScale(.small)
|
||||
.padding(.leading, 30)
|
||||
#if os(tvOS)
|
||||
.padding(.bottom, 15)
|
||||
.padding(.trailing, 30)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct PlaylistsView_Provider: PreviewProvider {
|
||||
|
||||
@@ -236,27 +236,12 @@ struct SearchView: View {
|
||||
if showRecentQueries {
|
||||
recentQueries
|
||||
} else {
|
||||
#if os(tvOS)
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
HStack(spacing: 0) {
|
||||
if accounts.app.supportsSearchFilters {
|
||||
filtersHorizontalStack
|
||||
}
|
||||
|
||||
FavoriteButton(item: favoriteItem)
|
||||
.id(favoriteItem?.id)
|
||||
.labelStyle(.iconOnly)
|
||||
.font(.system(size: 25))
|
||||
}
|
||||
|
||||
HorizontalCells(items: state.store.collection)
|
||||
.environment(\.loadMoreContentHandler) { state.loadNextPage() }
|
||||
VerticalCells(items: state.store.collection, allowEmpty: state.query.isEmpty) {
|
||||
if shouldDisplayHeader {
|
||||
header
|
||||
}
|
||||
.edgesIgnoringSafeArea(.horizontal)
|
||||
#else
|
||||
VerticalCells(items: state.store.collection, allowEmpty: state.query.isEmpty)
|
||||
.environment(\.loadMoreContentHandler) { state.loadNextPage() }
|
||||
#endif
|
||||
}
|
||||
.environment(\.loadMoreContentHandler) { state.loadNextPage() }
|
||||
|
||||
if noResults {
|
||||
Text("No results")
|
||||
@@ -561,6 +546,48 @@ struct SearchView: View {
|
||||
searchSortOrder.rawValue
|
||||
))
|
||||
}
|
||||
|
||||
var shouldDisplayHeader: Bool {
|
||||
#if os(tvOS)
|
||||
!state.query.isEmpty
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
|
||||
var header: some View {
|
||||
HStack {
|
||||
clearButton
|
||||
|
||||
#if os(tvOS)
|
||||
if accounts.app.supportsSearchFilters {
|
||||
filtersHorizontalStack
|
||||
}
|
||||
#endif
|
||||
FavoriteButton(item: favoriteItem)
|
||||
.id(favoriteItem?.id)
|
||||
.labelStyle(.iconOnly)
|
||||
.font(.system(size: 25))
|
||||
|
||||
Spacer()
|
||||
ListingStyleButtons(listingStyle: $searchListingStyle)
|
||||
HideShortsButtons(hide: $hideShorts)
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.padding(.leading, 30)
|
||||
.padding(.bottom, 15)
|
||||
.padding(.trailing, 30)
|
||||
}
|
||||
|
||||
var clearButton: some View {
|
||||
Button {
|
||||
state.queryText = ""
|
||||
} label: {
|
||||
Label("Clear", systemImage: "xmark")
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
struct SearchView_Previews: PreviewProvider {
|
||||
|
||||
@@ -16,7 +16,6 @@ struct AccountForm: View {
|
||||
@State private var validationDebounce = Debounce()
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.openURL) private var openURL
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
|
||||
var body: some View {
|
||||
|
||||
@@ -9,7 +9,6 @@ struct AdvancedSettings: View {
|
||||
@Default(.showCacheStatus) private var showCacheStatus
|
||||
@Default(.feedCacheSize) private var feedCacheSize
|
||||
|
||||
@State private var countries = [String]()
|
||||
@State private var filesToShare = [MPVClient.logFile]
|
||||
@State private var presentingShareSheet = false
|
||||
|
||||
@@ -138,12 +137,13 @@ struct AdvancedSettings: View {
|
||||
HStack {
|
||||
Text("Maximum feed items")
|
||||
.frame(minWidth: 200, alignment: .leading)
|
||||
.multilineTextAlignment(.leading)
|
||||
TextField("Limit", text: $feedCacheSize)
|
||||
.multilineTextAlignment(.trailing)
|
||||
#if !os(macOS)
|
||||
.keyboardType(.numberPad)
|
||||
#endif
|
||||
}
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
|
||||
private var showCacheStatusToggle: some View {
|
||||
@@ -168,7 +168,7 @@ struct AdvancedSettings: View {
|
||||
}
|
||||
|
||||
var cacheSize: some View {
|
||||
Text(String(format: "Total size: %@", BaseCacheModel.shared.totalSizeFormatted))
|
||||
Text(String(format: "Total size: %@".localized(), BaseCacheModel.shared.totalSizeFormatted))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ struct BrowsingSettings: View {
|
||||
@Default(.thumbnailsQuality) private var thumbnailsQuality
|
||||
@Default(.channelOnThumbnail) private var channelOnThumbnail
|
||||
@Default(.timeOnThumbnail) private var timeOnThumbnail
|
||||
@Default(.showToggleWatchedStatusButton) private var showToggleWatchedStatusButton
|
||||
@Default(.showHome) private var showHome
|
||||
@Default(.showFavoritesInHome) private var showFavoritesInHome
|
||||
@Default(.showQueueInHome) private var showQueueInHome
|
||||
@@ -165,8 +166,8 @@ struct BrowsingSettings: View {
|
||||
Section(header: SettingsHeader(text: "Player Bar".localized()), footer: playerBarFooter) {
|
||||
Toggle("Open expanded", isOn: $playerButtonIsExpanded)
|
||||
Toggle("Always show controls buttons", isOn: $playerButtonShowsControlButtonsWhenMinimized)
|
||||
playerBarGesturePicker("Single tap gesture", selection: $playerButtonSingleTapGesture)
|
||||
playerBarGesturePicker("Double tap gesture", selection: $playerButtonDoubleTapGesture)
|
||||
playerBarGesturePicker("Single tap gesture".localized(), selection: $playerButtonSingleTapGesture)
|
||||
playerBarGesturePicker("Double tap gesture".localized(), selection: $playerButtonDoubleTapGesture)
|
||||
HStack {
|
||||
Text("Maximum width expanded")
|
||||
Spacer()
|
||||
@@ -184,7 +185,7 @@ struct BrowsingSettings: View {
|
||||
func playerBarGesturePicker(_ label: String, selection: Binding<PlayerTapGestureAction>) -> some View {
|
||||
Picker(label, selection: selection) {
|
||||
ForEach(PlayerTapGestureAction.allCases, id: \.rawValue) { action in
|
||||
Text(action.label).tag(action)
|
||||
Text(action.label.localized()).tag(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -244,6 +245,9 @@ struct BrowsingSettings: View {
|
||||
#endif
|
||||
Toggle("Show channel name", isOn: $channelOnThumbnail)
|
||||
Toggle("Show video length", isOn: $timeOnThumbnail)
|
||||
#if !os(tvOS)
|
||||
Toggle("Show toggle watch status button", isOn: $showToggleWatchedStatusButton)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,12 +15,16 @@ struct PlayerControlsSettings: View {
|
||||
@Default(.systemControlsSeekDuration) private var systemControlsSeekDuration
|
||||
@Default(.actionButtonShareEnabled) private var actionButtonShareEnabled
|
||||
@Default(.actionButtonSubscribeEnabled) private var actionButtonSubscribeEnabled
|
||||
@Default(.actionButtonNextEnabled) private var actionButtonNextEnabled
|
||||
@Default(.actionButtonCloseEnabled) private var actionButtonCloseEnabled
|
||||
@Default(.actionButtonAddToPlaylistEnabled) private var actionButtonAddToPlaylistEnabled
|
||||
@Default(.actionButtonSettingsEnabled) private var actionButtonSettingsEnabled
|
||||
@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(.actionButtonNextQueueCountEnabled) private var actionButtonNextQueueCountEnabled
|
||||
|
||||
#if os(iOS)
|
||||
@Default(.playerControlsLockOrientationEnabled) private var playerControlsLockOrientationEnabled
|
||||
@@ -30,7 +34,6 @@ struct PlayerControlsSettings: 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
|
||||
|
||||
private var player = PlayerModel.shared
|
||||
@@ -109,8 +112,6 @@ struct PlayerControlsSettings: View {
|
||||
Section(header: SettingsHeader(text: "Actions Buttons")) {
|
||||
actionButtonToggles
|
||||
}
|
||||
|
||||
actionButtonNextQueueCountEnabledToggle
|
||||
}
|
||||
|
||||
private var systemControlsCommandsPicker: some View {
|
||||
@@ -216,6 +217,7 @@ struct PlayerControlsSettings: View {
|
||||
.labelStyle(.iconOnly)
|
||||
.padding(7)
|
||||
.foregroundColor(.accentColor)
|
||||
.accessibilityAddTraits(.isButton)
|
||||
#if os(iOS)
|
||||
.background(RoundedRectangle(cornerRadius: 4).strokeBorder(lineWidth: 1).foregroundColor(.accentColor))
|
||||
#endif
|
||||
@@ -251,6 +253,7 @@ struct PlayerControlsSettings: View {
|
||||
.labelStyle(.iconOnly)
|
||||
.padding(7)
|
||||
.foregroundColor(.accentColor)
|
||||
.accessibilityAddTraits(.isButton)
|
||||
#if os(iOS)
|
||||
.frame(minHeight: 35)
|
||||
.background(RoundedRectangle(cornerRadius: 4).strokeBorder(lineWidth: 1).foregroundColor(.accentColor))
|
||||
@@ -270,13 +273,24 @@ struct PlayerControlsSettings: View {
|
||||
}
|
||||
|
||||
@ViewBuilder private var actionButtonToggles: some View {
|
||||
Toggle("Share", isOn: $actionButtonShareEnabled)
|
||||
Toggle("Add to Playlist", isOn: $actionButtonAddToPlaylistEnabled)
|
||||
Toggle("Subscribe/Unsubscribe", isOn: $actionButtonSubscribeEnabled)
|
||||
Toggle("Settings", isOn: $actionButtonSettingsEnabled)
|
||||
Toggle("Watch Next", isOn: $actionButtonNextEnabled)
|
||||
Toggle("Hide player", isOn: $actionButtonHideEnabled)
|
||||
Toggle("Close video", isOn: $actionButtonCloseEnabled)
|
||||
Group {
|
||||
Toggle("Share", isOn: $actionButtonShareEnabled)
|
||||
Toggle("Add to Playlist", isOn: $actionButtonAddToPlaylistEnabled)
|
||||
Toggle("Subscribe/Unsubscribe", isOn: $actionButtonSubscribeEnabled)
|
||||
Toggle("Settings", isOn: $actionButtonSettingsEnabled)
|
||||
Toggle("Fullscreen", isOn: $actionButtonFullScreenEnabled)
|
||||
Toggle("Picture in Picture", isOn: $actionButtonPipEnabled)
|
||||
}
|
||||
Group {
|
||||
#if os(iOS)
|
||||
Toggle("Lock orientation", isOn: $actionButtonLockOrientationEnabled)
|
||||
#endif
|
||||
Toggle("Restart", isOn: $actionButtonRestartEnabled)
|
||||
Toggle("Play next item", isOn: $actionButtonAdvanceToNextItemEnabled)
|
||||
Toggle("Music Mode", isOn: $actionButtonMusicModeEnabled)
|
||||
Toggle("Hide player", isOn: $actionButtonHideEnabled)
|
||||
Toggle("Close video", isOn: $actionButtonCloseEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var controlButtonToggles: some View {
|
||||
@@ -289,16 +303,11 @@ struct PlayerControlsSettings: View {
|
||||
#endif
|
||||
Toggle("Restart", isOn: $playerControlsRestartEnabled)
|
||||
Toggle("Play next item", isOn: $playerControlsAdvanceToNextEnabled)
|
||||
Toggle("Watch Next", isOn: $playerControlsNextEnabled)
|
||||
Toggle("Playback mode", isOn: $playerControlsPlaybackModeEnabled)
|
||||
Toggle("Playback Mode", isOn: $playerControlsPlaybackModeEnabled)
|
||||
#if !os(tvOS)
|
||||
Toggle("Music mode", isOn: $playerControlsMusicModeEnabled)
|
||||
Toggle("Music Mode", isOn: $playerControlsMusicModeEnabled)
|
||||
#endif
|
||||
}
|
||||
|
||||
var actionButtonNextQueueCountEnabledToggle: some View {
|
||||
Toggle("Count of items in queue in Watch Next button", isOn: $actionButtonNextQueueCountEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
struct PlayerControlsSettings_Previews: PreviewProvider {
|
||||
|
||||
@@ -8,6 +8,7 @@ struct PlayerSettings: View {
|
||||
@Default(.playerSidebar) private var playerSidebar
|
||||
|
||||
@Default(.showKeywords) private var showKeywords
|
||||
@Default(.expandVideoDescription) private var expandVideoDescription
|
||||
@Default(.pauseOnHidingPlayer) private var pauseOnHidingPlayer
|
||||
#if os(iOS)
|
||||
@Default(.honorSystemOrientationLock) private var honorSystemOrientationLock
|
||||
@@ -24,9 +25,7 @@ struct PlayerSettings: View {
|
||||
|
||||
@Default(.enableReturnYouTubeDislike) private var enableReturnYouTubeDislike
|
||||
|
||||
@Default(.openWatchNextOnClose) private var openWatchNextOnClose
|
||||
@Default(.openWatchNextOnFinishedWatching) private var openWatchNextOnFinishedWatching
|
||||
@Default(.openWatchNextOnFinishedWatchingDelay) private var openWatchNextOnFinishedWatchingDelay
|
||||
@Default(.showInspector) private var showInspector
|
||||
|
||||
@ObservedObject private var accounts = AccountsModel.shared
|
||||
|
||||
@@ -68,11 +67,11 @@ struct PlayerSettings: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
Section(header: SettingsHeader(text: "Watch Next")) {
|
||||
openWatchNextOnFinishedWatchingToggle
|
||||
openWatchNextOnFinishedWatchingDelayTextField
|
||||
openWatchNextOnCloseToggle
|
||||
}
|
||||
#if !os(tvOS)
|
||||
Section(header: SettingsHeader(text: "Inspector".localized())) {
|
||||
inspectorVisibilityPicker
|
||||
}
|
||||
#endif
|
||||
|
||||
let interface = Section(header: SettingsHeader(text: "Interface".localized())) {
|
||||
#if os(iOS)
|
||||
@@ -87,6 +86,9 @@ struct PlayerSettings: View {
|
||||
|
||||
if !accounts.isEmpty {
|
||||
keywordsToggle
|
||||
#if !os(tvOS)
|
||||
expandVideoDescriptionToggle
|
||||
#endif
|
||||
returnYouTubeDislikeToggle
|
||||
}
|
||||
}
|
||||
@@ -143,33 +145,6 @@ struct PlayerSettings: View {
|
||||
.modifier(SettingsPickerModifier())
|
||||
}
|
||||
|
||||
private var openWatchNextOnCloseToggle: some View {
|
||||
Toggle("Open after manual close of video", isOn: $openWatchNextOnClose)
|
||||
}
|
||||
|
||||
private var openWatchNextOnFinishedWatchingToggle: some View {
|
||||
Toggle("Open after watching video", isOn: $openWatchNextOnFinishedWatching)
|
||||
}
|
||||
|
||||
private var openWatchNextOnFinishedWatchingDelayTextField: some View {
|
||||
HStack {
|
||||
Text("Autoplay delay")
|
||||
.frame(minWidth: 140, alignment: .leading)
|
||||
#if !os(iOS)
|
||||
Spacer()
|
||||
#endif
|
||||
TextField("Delay", text: $openWatchNextOnFinishedWatchingDelay)
|
||||
#if !os(iOS)
|
||||
.frame(maxWidth: 100, alignment: .trailing)
|
||||
#endif
|
||||
.labelsHidden()
|
||||
#if !os(macOS)
|
||||
.keyboardType(.numberPad)
|
||||
#endif
|
||||
}
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
|
||||
private var sidebarPicker: some View {
|
||||
Picker("Sidebar", selection: $playerSidebar) {
|
||||
#if os(macOS)
|
||||
@@ -189,6 +164,10 @@ struct PlayerSettings: View {
|
||||
Toggle("Show keywords", isOn: $showKeywords)
|
||||
}
|
||||
|
||||
private var expandVideoDescriptionToggle: some View {
|
||||
Toggle("Open video description expanded", isOn: $expandVideoDescription)
|
||||
}
|
||||
|
||||
private var returnYouTubeDislikeToggle: some View {
|
||||
Toggle("Enable Return YouTube Dislike", isOn: $enableReturnYouTubeDislike)
|
||||
}
|
||||
@@ -235,6 +214,16 @@ struct PlayerSettings: View {
|
||||
Toggle("Close PiP and open player when application enters foreground", isOn: $closePiPAndOpenPlayerOnEnteringForeground)
|
||||
}
|
||||
#endif
|
||||
|
||||
private var inspectorVisibilityPicker: some View {
|
||||
Picker("Visibility", selection: $showInspector) {
|
||||
Text("Always").tag(ShowInspectorSetting.always)
|
||||
Text("Only for local files and URLs").tag(ShowInspectorSetting.onlyLocal)
|
||||
}
|
||||
#if os(macOS)
|
||||
.labelsHidden()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct PlayerSettings_Previews: PreviewProvider {
|
||||
|
||||
@@ -247,7 +247,7 @@ struct SettingsView: View {
|
||||
case .player:
|
||||
return 450
|
||||
case .controls:
|
||||
return 850
|
||||
return 920
|
||||
case .quality:
|
||||
return 420
|
||||
case .history:
|
||||
|
||||
@@ -7,6 +7,7 @@ struct ChannelsView: View {
|
||||
@ObservedObject private var subscriptions = SubscribedChannelsModel.shared
|
||||
@ObservedObject private var accounts = AccountsModel.shared
|
||||
@ObservedObject private var feedCount = UnwatchedFeedCountModel.shared
|
||||
private var navigation = NavigationModel.shared
|
||||
|
||||
@Default(.showCacheStatus) private var showCacheStatus
|
||||
@Default(.showUnwatchedFeedBadges) private var showUnwatchedFeedBadges
|
||||
@@ -15,19 +16,31 @@ struct ChannelsView: View {
|
||||
List {
|
||||
Section(header: header) {
|
||||
ForEach(subscriptions.all) { channel in
|
||||
NavigationLink(destination: ChannelVideosView(channel: channel)) {
|
||||
HStack {
|
||||
if let url = channel.thumbnailURLOrCached {
|
||||
ThumbnailView(url: url)
|
||||
.frame(width: 35, height: 35)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 35))
|
||||
Text(channel.name)
|
||||
} else {
|
||||
Label(channel.name, systemImage: RecentsModel.symbolSystemImage(channel.name))
|
||||
}
|
||||
let label = HStack {
|
||||
if let url = channel.thumbnailURLOrCached {
|
||||
ThumbnailView(url: url)
|
||||
.frame(width: 35, height: 35)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 35))
|
||||
Text(channel.name)
|
||||
} else {
|
||||
Label(channel.name, systemImage: RecentsModel.symbolSystemImage(channel.name))
|
||||
}
|
||||
.backport
|
||||
.badge(showUnwatchedFeedBadges ? feedCount.unwatchedByChannelText(channel) : nil)
|
||||
}
|
||||
.backport
|
||||
.badge(showUnwatchedFeedBadges ? feedCount.unwatchedByChannelText(channel) : nil)
|
||||
|
||||
Group {
|
||||
#if os(tvOS)
|
||||
Button {
|
||||
navigation.openChannel(channel, navigationStyle: .tab)
|
||||
} label: {
|
||||
label
|
||||
}
|
||||
#else
|
||||
NavigationLink(destination: ChannelVideosView(channel: channel)) {
|
||||
label
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.contextMenu {
|
||||
if subscriptions.isSubscribing(channel.id) {
|
||||
@@ -111,7 +124,7 @@ struct ChannelsView: View {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
.labelStyle(.iconOnly)
|
||||
.imageScale(.small)
|
||||
.font(.caption2)
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@@ -4,7 +4,6 @@ import SwiftUI
|
||||
|
||||
struct FeedView: View {
|
||||
@ObservedObject private var feed = FeedModel.shared
|
||||
@ObservedObject private var accounts = AccountsModel.shared
|
||||
|
||||
@Default(.showCacheStatus) private var showCacheStatus
|
||||
|
||||
@@ -68,16 +67,13 @@ struct FeedView: View {
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
if !showCacheStatus {
|
||||
Spacer()
|
||||
}
|
||||
Button {
|
||||
feed.loadResources(force: true)
|
||||
} label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
.labelStyle(.iconOnly)
|
||||
.imageScale(.small)
|
||||
.font(.caption2)
|
||||
.font(.caption)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ struct SubscriptionsPageButton: View {
|
||||
} label: {
|
||||
Text(subscriptionsViewPage.rawValue.capitalized)
|
||||
.frame(maxWidth: .infinity)
|
||||
.font(.caption2)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,20 +44,13 @@ struct SubscriptionsView: View {
|
||||
#endif
|
||||
#if os(macOS)
|
||||
.toolbar {
|
||||
ToolbarItem {
|
||||
ToolbarItemGroup {
|
||||
ListingStyleButtons(listingStyle: $subscriptionsListingStyle)
|
||||
}
|
||||
|
||||
ToolbarItem {
|
||||
HideShortsButtons(hide: $hideShorts)
|
||||
}
|
||||
|
||||
ToolbarItem {
|
||||
toggleWatchedButton
|
||||
}
|
||||
|
||||
ToolbarItem {
|
||||
.id(feed.watchedId)
|
||||
playUnwatchedButton
|
||||
.id(feed.watchedId)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -39,20 +39,9 @@ struct TrendingView: View {
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
VStack(spacing: 0) {
|
||||
#if os(tvOS)
|
||||
toolbar
|
||||
HorizontalCells(items: trending)
|
||||
.padding(.top, 40)
|
||||
|
||||
Spacer()
|
||||
#else
|
||||
VerticalCells(items: trending)
|
||||
.environment(\.scrollViewBottomPadding, 70)
|
||||
#endif
|
||||
}
|
||||
.environment(\.listingStyle, trendingListingStyle)
|
||||
.environment(\.hideShorts, hideShorts)
|
||||
VerticalCells(items: trending) { if shouldDisplayHeader { header } }
|
||||
.environment(\.listingStyle, trendingListingStyle)
|
||||
.environment(\.hideShorts, hideShorts)
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
@@ -66,9 +55,7 @@ struct TrendingView: View {
|
||||
.id(favoriteItem.id)
|
||||
}
|
||||
|
||||
if accounts.app.supportsTrendingCategories {
|
||||
categoryButton
|
||||
}
|
||||
categoryButton
|
||||
countryButton
|
||||
}
|
||||
#endif
|
||||
@@ -182,9 +169,7 @@ struct TrendingView: View {
|
||||
Menu {
|
||||
countryButton
|
||||
|
||||
if accounts.app.supportsTrendingCategories {
|
||||
categoryButton
|
||||
}
|
||||
categoryButton
|
||||
|
||||
ListingStyleButtons(listingStyle: $trendingListingStyle)
|
||||
|
||||
@@ -210,26 +195,28 @@ struct TrendingView: View {
|
||||
}
|
||||
#endif
|
||||
|
||||
private var categoryButton: some View {
|
||||
#if os(tvOS)
|
||||
Button(category.name) {
|
||||
self.category = category.next()
|
||||
}
|
||||
.contextMenu {
|
||||
ForEach(TrendingCategory.allCases) { category in
|
||||
Button(category.controlLabel) { self.category = category }
|
||||
@ViewBuilder private var categoryButton: some View {
|
||||
if accounts.app.supportsTrendingCategories {
|
||||
#if os(tvOS)
|
||||
Button(category.name) {
|
||||
self.category = category.next()
|
||||
}
|
||||
.contextMenu {
|
||||
ForEach(TrendingCategory.allCases) { category in
|
||||
Button(category.controlLabel) { self.category = category }
|
||||
}
|
||||
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
|
||||
#else
|
||||
Picker(category.controlLabel, selection: $category) {
|
||||
ForEach(TrendingCategory.allCases) { category in
|
||||
Label(category.controlLabel, systemImage: category.systemImage).tag(category)
|
||||
#else
|
||||
Picker(category.controlLabel, selection: $category) {
|
||||
ForEach(TrendingCategory.allCases) { category in
|
||||
Label(category.controlLabel, systemImage: category.systemImage).tag(category)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private var countryButton: some View {
|
||||
@@ -249,6 +236,42 @@ struct TrendingView: View {
|
||||
private func updateFavoriteItem() {
|
||||
favoriteItem = FavoriteItem(section: .trending(country.rawValue, category.rawValue))
|
||||
}
|
||||
|
||||
var header: some View {
|
||||
HStack {
|
||||
Group {
|
||||
categoryButton
|
||||
countryButton
|
||||
}
|
||||
.font(.caption)
|
||||
|
||||
Spacer()
|
||||
ListingStyleButtons(listingStyle: $trendingListingStyle)
|
||||
HideShortsButtons(hide: $hideShorts)
|
||||
|
||||
Button {
|
||||
resource.load()
|
||||
.onFailure { self.error = $0 }
|
||||
.onSuccess { _ in self.error = nil }
|
||||
} label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
.labelStyle(.iconOnly)
|
||||
.imageScale(.small)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.padding(.leading, 30)
|
||||
.padding(.bottom, 15)
|
||||
.padding(.trailing, 30)
|
||||
}
|
||||
|
||||
var shouldDisplayHeader: Bool {
|
||||
#if os(tvOS)
|
||||
true
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct TrendingView_Previews: PreviewProvider {
|
||||
|
||||
@@ -26,7 +26,7 @@ struct VerticalCells<Header: View>: View {
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.vertical, showsIndicators: scrollViewShowsIndicators) {
|
||||
LazyVGrid(columns: columns, alignment: .center) {
|
||||
LazyVGrid(columns: adaptiveItem, alignment: .center) {
|
||||
Section(header: header) {
|
||||
ForEach(contentItems) { item in
|
||||
ContentItemView(item: item)
|
||||
@@ -58,14 +58,6 @@ struct VerticalCells<Header: View>: View {
|
||||
}
|
||||
}
|
||||
|
||||
var columns: [GridItem] {
|
||||
#if os(tvOS)
|
||||
contentItems.count < 3 ? Array(repeating: GridItem(.fixed(500)), count: [contentItems.count, 1].max()!) : adaptiveItem
|
||||
#else
|
||||
adaptiveItem
|
||||
#endif
|
||||
}
|
||||
|
||||
var adaptiveItem: [GridItem] {
|
||||
if listingStyle == .list {
|
||||
return [.init(.flexible())]
|
||||
|
||||
@@ -197,7 +197,7 @@ struct VideoBanner: View {
|
||||
private var contentOpacity: Double {
|
||||
guard saveHistory,
|
||||
!watch.isNil,
|
||||
watchedVideoStyle == .decreasedOpacity || watchedVideoStyle == .both
|
||||
watchedVideoStyle.isDecreasingOpacity
|
||||
else {
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ struct VideoCell: View {
|
||||
private var contentOpacity: Double {
|
||||
guard saveHistory,
|
||||
!watch.isNil,
|
||||
watchedVideoStyle == .decreasedOpacity || watchedVideoStyle == .both
|
||||
watchedVideoStyle.isDecreasingOpacity
|
||||
else {
|
||||
return 1
|
||||
}
|
||||
@@ -348,7 +348,7 @@ struct VideoCell: View {
|
||||
DetailBadge(text: video.author, style: .prominent)
|
||||
.foregroundColor(.primary)
|
||||
} else {
|
||||
Text(video.channel.name)
|
||||
Text(verbatim: video.channel.name)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
@@ -473,7 +473,7 @@ struct VideoCell: View {
|
||||
}
|
||||
|
||||
private func videoDetail(_ text: String, lineLimit: Int = 1) -> some View {
|
||||
Text(text)
|
||||
Text(verbatim: text)
|
||||
.fontWeight(.bold)
|
||||
.lineLimit(lineLimit)
|
||||
.truncationMode(.middle)
|
||||
|
||||
@@ -8,21 +8,28 @@ struct WatchView: View {
|
||||
var duration: Double
|
||||
|
||||
@Default(.watchedVideoBadgeColor) private var watchedVideoBadgeColor
|
||||
@Default(.showToggleWatchedStatusButton) private var showToggleWatchedStatusButton
|
||||
|
||||
var backgroundContext = PersistenceController.shared.container.newBackgroundContext()
|
||||
|
||||
var body: some View {
|
||||
#if os(tvOS)
|
||||
if showToggleWatchedStatusButton {
|
||||
#if os(tvOS)
|
||||
if finished {
|
||||
image
|
||||
}
|
||||
#else
|
||||
Button(action: toggleWatch) {
|
||||
image
|
||||
}
|
||||
.opacity(finished ? 1 : 0.4)
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
} else {
|
||||
if finished {
|
||||
image
|
||||
}
|
||||
#else
|
||||
Button(action: toggleWatch) {
|
||||
image
|
||||
}
|
||||
.opacity(finished ? 1 : 0.4)
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
var image: some View {
|
||||
|
||||
@@ -17,8 +17,6 @@ struct ControlsBar: View {
|
||||
|
||||
var presentingControls = true
|
||||
var backgroundEnabled = true
|
||||
var borderTop = true
|
||||
var borderBottom = true
|
||||
var detailsTogglePlayer = true
|
||||
var detailsToggleFullScreen = false
|
||||
var playerBar = false
|
||||
|
||||
@@ -17,7 +17,7 @@ struct HideShortsButtons: View {
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
.font(.caption2)
|
||||
.font(.caption)
|
||||
.imageScale(.small)
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ struct ListingStyleButtons: View {
|
||||
} label: {
|
||||
Label(listingStyle.rawValue.capitalized, systemImage: listingStyle.systemImage)
|
||||
#if os(tvOS)
|
||||
.font(.caption2)
|
||||
.font(.caption)
|
||||
.imageScale(.small)
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ struct PopularView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VerticalCells(items: videos)
|
||||
VerticalCells(items: videos) { if shouldDisplayHeader { header } }
|
||||
.onAppear {
|
||||
resource?.addObserver(store)
|
||||
resource?.loadIfNeeded()?
|
||||
@@ -116,6 +116,36 @@ struct PopularView: View {
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
var shouldDisplayHeader: Bool {
|
||||
#if os(tvOS)
|
||||
true
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
|
||||
var header: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
ListingStyleButtons(listingStyle: $popularListingStyle)
|
||||
HideShortsButtons(hide: $hideShorts)
|
||||
|
||||
Button {
|
||||
resource?.load()
|
||||
.onFailure { self.error = $0 }
|
||||
.onSuccess { _ in self.error = nil }
|
||||
} label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
.labelStyle(.iconOnly)
|
||||
.imageScale(.small)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.padding(.leading, 30)
|
||||
.padding(.bottom, 15)
|
||||
.padding(.trailing, 30)
|
||||
}
|
||||
}
|
||||
|
||||
struct PopularView_Previews: PreviewProvider {
|
||||
|
||||
@@ -23,7 +23,6 @@ struct VideoContextMenuView: View {
|
||||
@Default(.saveHistory) private var saveHistory
|
||||
|
||||
private var backgroundContext = PersistenceController.shared.container.newBackgroundContext()
|
||||
private var viewContext: NSManagedObjectContext = PersistenceController.shared.container.viewContext
|
||||
|
||||
init(video: Video) {
|
||||
self.video = video
|
||||
|
||||
@@ -32,9 +32,11 @@ struct YatteeApp: App {
|
||||
|
||||
@State private var configured = false
|
||||
|
||||
@StateObject private var accounts = AccountsModel.shared
|
||||
@StateObject private var comments = CommentsModel.shared
|
||||
@StateObject private var instances = InstancesModel.shared
|
||||
@StateObject private var menu = MenuModel.shared
|
||||
@StateObject private var navigation = NavigationModel.shared
|
||||
@StateObject private var networkState = NetworkStateModel.shared
|
||||
@StateObject private var player = PlayerModel.shared
|
||||
@StateObject private var playlists = PlaylistsModel.shared
|
||||
@@ -110,7 +112,6 @@ struct YatteeApp: App {
|
||||
.onDisappear { player.presentingPlayer = false }
|
||||
.environment(\.managedObjectContext, persistenceController.container.viewContext)
|
||||
.environment(\.navigationStyle, .sidebar)
|
||||
|
||||
.handlesExternalEvents(preferring: Set(["player", "*"]), allowing: Set(["player", "*"]))
|
||||
}
|
||||
.handlesExternalEvents(matching: Set(["player", "*"]))
|
||||
|
||||
@@ -496,9 +496,9 @@
|
||||
"Video" = "";
|
||||
"Audio" = "";
|
||||
"Honor orientation lock" = "";
|
||||
"Proxy videos" = "";
|
||||
"Seek gesture speed" = "";
|
||||
"Seek with horizontal swipe on video" = "";
|
||||
"System controls show buttons for %@" = "";
|
||||
"Wiki" = "";
|
||||
"Proxy videos" = "توكيل الفيديوهات";
|
||||
"Seek gesture speed" = "سرعة بحث الإيماءة";
|
||||
"Seek with horizontal swipe on video" = "للبحث بالتمرير الأفقي على الفيديو";
|
||||
"System controls show buttons for %@" = "تعرض ضوابط النظام أزرارًا لـ %@";
|
||||
"Wiki" = "ويكي";
|
||||
"Sample Rate" = "";
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
"Custom Locations" = "Vlastní umístění";
|
||||
"Delete" = "Smazat";
|
||||
"Disabled" = "Deaktivováno";
|
||||
"Discord Server" = "Server Discordu";
|
||||
"Discord Server" = "Discord Server";
|
||||
"Decrease rate" = "Snížit tempo";
|
||||
"Decreased opacity" = "Snížená průhlednost";
|
||||
"Don't use public locations" = "Nepoužívat veřejné lokace";
|
||||
@@ -94,7 +94,7 @@
|
||||
"Find Other" = "Najít ostatní";
|
||||
"Frontend URL" = "URL Frontendu";
|
||||
"Gaming" = "Hry";
|
||||
"Help" = "Pomoc";
|
||||
"Help" = "Nápověda";
|
||||
"Hide sidebar" = "Skrýt postranní panel";
|
||||
"High" = "Vysoká";
|
||||
"Highest" = "Nejvyšší";
|
||||
@@ -107,7 +107,7 @@
|
||||
"I have a feature request" = "Mám prosbu o přidání funkce";
|
||||
"I like this app!" = "Tato aplikace se mi líbí!";
|
||||
"If you are interested what's coming in future updates, you can track project Milestones." = "Pokud vás zajímá co přijde v budoucích aktualizacích, můžete sledovat Milestony projektu.";
|
||||
"Info" = "Info";
|
||||
"Info" = "Informace";
|
||||
"Instance of current account" = "Instance aktuálního účtu";
|
||||
|
||||
/* SponsorBlock category name */
|
||||
@@ -224,7 +224,7 @@
|
||||
"Search..." = "Hledat...";
|
||||
"Sections" = "Sekce";
|
||||
"Seek gesture sensitivity" = "Sensitivita gest posouvání";
|
||||
"Seek with horizontal swipe on video" = "Posouvání pomocí horizontálního potažení na videu";
|
||||
"Seek with horizontal swipe on video" = "Posouvání ve videu potažením do stran";
|
||||
"Select location closest to you:" = "Vyberte nejbližší lokaci:";
|
||||
"Share %@ link" = "Sdílet %@ odkaz";
|
||||
"Share %@ link with time" = "Sdílet %@ odkaz s časem";
|
||||
@@ -431,7 +431,7 @@
|
||||
"Sort" = "Třídit";
|
||||
"SponsorBlock" = "SponsorBlock";
|
||||
"You can use automatic profile selection based on current device status or switch it in video playback settings controls." = "Můžete použít automatický výběr profilu podle aktuálního stavu zařízení nebo jej přepnout v nastavení přehrávání.";
|
||||
"Edit Favorites…" = "Upravit Oblíbené…";
|
||||
"Edit Favorites…" = "Upravit seznam oblíbených…";
|
||||
"Reload manifest" = "Znovu načíst manifest";
|
||||
"Paste" = "Vložit";
|
||||
"Playback Mode" = "Režim přehrávání";
|
||||
@@ -446,11 +446,11 @@
|
||||
"Are you sure you want to remove %@ location?" = "Opravdu chcete odstranit umístění %@?";
|
||||
"Recent Documents" = "Poslední dokumenty";
|
||||
"Recent History" = "Nedávná historie";
|
||||
"Show Favorites" = "Zobrazit Oblíbené";
|
||||
"Show Open Videos quick actions" = "Zobrazit rychlé akce Otevřít videa";
|
||||
"Home" = "Domov";
|
||||
"Share files from Finder on a Mac\nor iTunes on Windows" = "Sdílejte soubory z Finderu na Macu\\nor iTunes na Windows";
|
||||
"Show Home" = "Zobrazit Domov";
|
||||
"Show Favorites" = "Zobrazit oblíbené";
|
||||
"Show Open Videos quick actions" = "Zobrazit tlačítka pro rychlé otevření videa";
|
||||
"Home" = "Domů";
|
||||
"Share files from Finder on a Mac\nor iTunes on Windows" = "Sdílejte soubory z Finderu na Macu \nnebo iTunes na Windows";
|
||||
"Show Home" = "Zobrazit kartu Domů";
|
||||
"Pages toolbar position" = "Pozice panelu nástrojů";
|
||||
"Video Details" = "Podrobnosti o videu";
|
||||
"Show Inspector" = "Zobrazit Inspektor";
|
||||
@@ -498,7 +498,45 @@
|
||||
"Default Profile" = "Výchozí profil";
|
||||
"Copy%@link" = "Zkopírovat%@odkaz";
|
||||
"Share%@link" = "Sdílet%@odkaz";
|
||||
"Verified" = "";
|
||||
"Shorts" = "";
|
||||
"Channel" = "";
|
||||
"Live Streams" = "";
|
||||
"Verified" = "Ověřeno";
|
||||
"Shorts" = "Shorts";
|
||||
"Channel" = "Kanál";
|
||||
"Live Streams" = "Živé vysílání";
|
||||
"Open expanded" = "Otevřít rozbalené";
|
||||
"Gesture settings control skipping interval for double tap gesture on left/right side of the player. Changing system controls settings requires restart." = "Nastavení gest řídí interval přeskakování pro gesto dvojitého klepnutí na levé/pravé straně přehrávače. Změna nastavení ovládání systému vyžaduje restart.";
|
||||
"Right click channel thumbnail to open context menu with more actions" = "Kliknutím pravým tlačítkem myši na miniaturu kanálu otevřete kontextovou nabídku s dalšími akcemi";
|
||||
"Actions Buttons" = "Tlačítka akcí";
|
||||
"Play next item" = "Přehrát další položku";
|
||||
"Lock orientation" = "Zámek orientace";
|
||||
"Music Mode" = "Hudební režim";
|
||||
"Gesture settings control skipping interval for remote arrow buttons (for 2nd generation Siri Remote or newer). Changing system controls settings requires restart." = "Nastavení gesta řídí interval přeskakování pomocí tlačítek se šipkami na dálkovém ovladači (pro 2. generaci ovladače Siri Remote nebo novější). Změna nastavení ovládání systému vyžaduje restart.";
|
||||
"Mark channel feed as watched" = "Označit kanál jako shlédnutý";
|
||||
"Player Bar" = "Panel přehrávače";
|
||||
"Short videos: visible" = "Krátká videa: viditelná";
|
||||
"Short videos: hidden" = "Krátká videa: skrytá";
|
||||
"Mark channel feed as unwatched" = "Označit kanál jako neshlédnutý";
|
||||
"Play all unwatched" = "Přehrát všechny neshlédnuté";
|
||||
"Double tap gesture" = "Gesto dvojitého klepnutí";
|
||||
"Tap and hold channel thumbnail to open context menu with more actions" = "Klepnutím a podržením miniatury kanálu otevřete kontextovou nabídku s dalšími akcemi";
|
||||
"Clear all" = "Vymazat vše";
|
||||
"Hide player" = "Skrýt přehrávač";
|
||||
"Close video" = "Zavřít video";
|
||||
"Total size: %@" = "Celková velikost: %@";
|
||||
"Show cache status" = "Zobrazit stav mezipaměti";
|
||||
"Maximum feed items" = "Maximální počet položek ve feedu";
|
||||
"Open channels with description expanded" = "Otevírat kanály s rozbaleným popisem";
|
||||
"Are you sure you want to clear cache?" = "Určitě chcete vymazat mezipaměť?";
|
||||
"Always show controls buttons" = "Vždy zobrazit ovládací tlačítka";
|
||||
"Single tap gesture" = "Gesto jednoho klepnutí";
|
||||
"Maximum width expanded" = "Maximální šířka";
|
||||
"Show unwatched feed badges" = "Zobrazit odznaky nesledovaných kanálů";
|
||||
"Seeking" = "Hledání";
|
||||
"Gesture: fowards" = "Gesto: dopředu";
|
||||
"Controls Buttons" = "Ovládací tlačítka";
|
||||
"System controls" = "Ovládání systému";
|
||||
"Controls button: backwards" = "Ovládací tlačítko: dozadu";
|
||||
"Controls button: forwards" = "Ovládací tlačítko: dopředu";
|
||||
"Gesture: backwards" = "Gesto: dozadu";
|
||||
"Gesture settings control skipping interval for double click on left/right side of the player. Changing system controls settings requires restart." = "Nastavení gest řídí interval přeskakování pro gesto dvojitého klepnutí na levé/pravé straně přehrávače. Změna nastavení ovládání systému vyžaduje restart.";
|
||||
"Cache" = "Mezipaměť";
|
||||
"Subscribe/Unsubscribe" = "Odebírat/Odhlásit odběr";
|
||||
|
||||
@@ -538,3 +538,26 @@
|
||||
"Show cache status" = "Show cache status";
|
||||
"Maximum feed items" = "Maximum feed items";
|
||||
"Are you sure you want to clear cache?" = "Are you sure you want to clear cache?";
|
||||
"Show Next in Queue" = "Show Next in Queue";
|
||||
"Show toggle watch status button" = "Show toggle watch status button";
|
||||
"Next in Queue" = "Next in Queue";
|
||||
"List" = "List";
|
||||
"Cells" = "Cells";
|
||||
"Toggle size" = "Toggle size";
|
||||
"Toggle player" = "Toggle player";
|
||||
"Do nothing" = "Do nothing";
|
||||
"Feed" = "Feed";
|
||||
"Open channel" = "Open channel";
|
||||
"Inspector" = "Inspector";
|
||||
"Open video description expanded" = "Open video description expanded";
|
||||
"Mark all as unwatched" = "Mark all as unwatched";
|
||||
"Mark all as watched" = "Mark all as watched";
|
||||
"Queue - shuffled" = "Queue - shuffled";
|
||||
"Playback Settings" = "Playback Settings";
|
||||
"Replay" = "Replay";
|
||||
"Fullscreen" = "Fullscreen";
|
||||
"Lock" = "Lock";
|
||||
"Description" = "Description";
|
||||
"Loop one" = "Loop one";
|
||||
"Autoplay next" = "Autoplay next";
|
||||
"Stream" = "Stream";
|
||||
|
||||
@@ -492,7 +492,7 @@
|
||||
"Are you sure you want to remove %@ location?" = "";
|
||||
"Show only icons" = "";
|
||||
"Audio" = "";
|
||||
"Share files from Finder on a Mac\nor iTunes on Windows" = "";
|
||||
"Share files from Finder on a Mac\nor iTunes on Windows" = "Partager des fichiers depuis Finder sur Mac\nou iTunes sur Windows";
|
||||
"Channel" = "";
|
||||
"Inspector visibility" = "";
|
||||
"Video" = "";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user