Compare commits

...

40 Commits

Author SHA1 Message Date
Arkadiusz Fal
7cfbe5ae5a Add missing bundle platform 2023-04-22 23:47:56 +02:00
Arkadiusz Fal
bf80c4024c Bump build number to 140 2023-04-22 23:39:44 +02:00
Arkadiusz Fal
e70808c463 Update fastlane 2023-04-22 23:39:43 +02:00
Arkadiusz Fal
ce68c0f5b4 Update CHANGELOG 2023-04-22 23:39:43 +02:00
Arkadiusz Fal
8e829ed3b1 Localizations fixes 2023-04-22 23:39:43 +02:00
Arkadiusz Fal
5c0cf7452c Remove unused code 2023-04-22 23:39:43 +02:00
Arkadiusz Fal
2d02d9b472 Fix possible crashes 2023-04-22 23:39:43 +02:00
Arkadiusz Fal
72a98314c1 Fix accounts switcher padding on tvOS 2023-04-22 23:39:43 +02:00
Arkadiusz Fal
ea997ffdb9 Fix #425 2023-04-22 23:39:43 +02:00
Arkadiusz Fal
83dfdd6c0e tvOS filters for all views
Vertical list for trending, popular, playlists, search

Fix #413, #415
2023-04-22 23:39:43 +02:00
Arkadiusz Fal
6596a440a5 New chapters layout 2023-04-22 23:39:43 +02:00
Arkadiusz Fal
d52ccf2ce6 Add video description expanding 2023-04-22 23:39:43 +02:00
Arkadiusz Fal
b19918e219 Disable offset animation on hiding player 2023-04-22 23:39:43 +02:00
Arkadiusz Fal
2fe211edb4 Add setting for toggle video watch status 2023-04-22 23:39:43 +02:00
Arkadiusz Fal
f852782f5e Fix handling watch statuses 2023-04-22 23:39:42 +02:00
Arkadiusz Fal
c8feeca41f Add playback mode to playback settings 2023-04-22 23:39:42 +02:00
Arkadiusz Fal
9a594b4a8d Account name handling fix 2023-04-22 23:39:42 +02:00
Arkadiusz Fal
c48301c788 Performance fix 2023-04-22 23:39:42 +02:00
Arkadiusz Fal
9936d9dd9e Fix details reload 2023-04-22 23:39:42 +02:00
Arkadiusz Fal
8f9fb7ba82 New actions buttons 2023-04-22 23:39:42 +02:00
Arkadiusz Fal
a7763c5802 Fix controls buttons settings 2023-04-22 23:10:28 +02:00
Arkadiusz Fal
160ea86298 Fix url opening 2023-04-22 23:10:28 +02:00
Arkadiusz Fal
a9e9fa3a6d Code style changes 2023-04-22 23:10:28 +02:00
Arkadiusz Fal
afa0049333 Improve placeholders 2023-04-22 23:10:27 +02:00
Arkadiusz Fal
28f346dee2 Remove Watch Next 2023-04-22 23:10:27 +02:00
Arkadiusz Fal
67690bc435 Video details changes and channel sheet 2023-04-22 23:10:27 +02:00
Arkadiusz Fal
5db74a3997 Code style fixes 2023-04-22 23:10:27 +02:00
Arkadiusz Fal
8ef016d792 Minor performance improvement 2023-04-22 23:10:27 +02:00
Arkadiusz Fal
b59baa6fab Update packages 2023-04-22 23:10:27 +02:00
Arkadiusz Fal
3657d732d9 Merge pull request #430 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2023-04-22 23:09:29 +02:00
Arkadiusz Fal
309e4a3281 Translated using Weblate (Polish)
Currently translated at 100.0% (496 of 496 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/pl/
2023-04-22 23:08:38 +02:00
Anonymous
f6e5486412 Translated using Weblate (English)
Currently translated at 100.0% (496 of 496 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/en/
2023-04-22 23:08:38 +02:00
Anonymous
d58a68cd66 Translated using Weblate (English)
Currently translated at 100.0% (476 of 476 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/en/
2023-04-22 22:48:23 +02:00
Arkadiusz Fal
faa7d82b8f Merge pull request #418 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2023-04-22 09:47:22 +02:00
jonnysemon
3dd9ff837e Translated using Weblate (Arabic)
Currently translated at 90.6% (429 of 473 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ar/
2023-04-06 16:48:37 +02:00
oguska
73a62ea76e Translated using Weblate (Turkish)
Currently translated at 50.1% (237 of 473 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/tr/
2023-04-01 12:37:58 +02:00
Lachtan
7ce37fd5dd Translated using Weblate (Czech)
Currently translated at 100.0% (473 of 473 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/cs/
2023-03-30 21:39:47 +02:00
Ophiushi
25ca69f17d Translated using Weblate (French)
Currently translated at 83.7% (396 of 473 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/fr/
2023-03-30 21:39:47 +02:00
maboroshin
578c5a8a61 Translated using Weblate (Japanese)
Currently translated at 97.8% (463 of 473 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ja/
2023-03-13 11:37:38 +01:00
ssantos
c3e81d1b67 Translated using Weblate (Portuguese)
Currently translated at 100.0% (473 of 473 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/pt/
2023-03-13 11:37:38 +01:00
107 changed files with 1332 additions and 1485 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -234,6 +234,8 @@ extension Country {
}
}
// swiftlint:enable switch_case_on_newline
var flag: String {
let unicodeScalars = rawValue
.unicodeScalars

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ import Foundation
#endif
struct QualityProfilesModel {
static let shared = QualityProfilesModel()
static let shared = Self()
#if os(tvOS)
var tvOSProfile: QualityProfile? {

View File

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

View File

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

View File

@@ -32,4 +32,5 @@ final class UnwatchedFeedCountModel: ObservableObject {
}
return nil
}
// swiftlint:enable empty_count
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -84,6 +84,9 @@ struct AccountsView: View {
.contentShape(Rectangle())
}
.buttonStyle(.plain)
#if os(tvOS)
.padding(.horizontal, 50)
#endif
}
var closeButton: some View {

View File

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

View File

@@ -5,7 +5,6 @@ import SwiftUI
#endif
struct AppSidebarNavigation: View {
@ObservedObject private var accounts = AccountsModel.shared
private var navigation: NavigationModel { .shared }
#if os(iOS)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -107,7 +107,7 @@ struct PlaylistVideosView: View {
#if os(iOS)
.navigationBarTrailing
#else
.automatic
.automatic
#endif
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -247,7 +247,7 @@ struct SettingsView: View {
case .player:
return 450
case .controls:
return 850
return 920
case .quality:
return 420
case .history:

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ struct SubscriptionsPageButton: View {
} label: {
Text(subscriptionsViewPage.rawValue.capitalized)
.frame(maxWidth: .infinity)
.font(.caption2)
.font(.caption)
}
}
}

View File

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

View File

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

View File

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

View File

@@ -197,7 +197,7 @@ struct VideoBanner: View {
private var contentOpacity: Double {
guard saveHistory,
!watch.isNil,
watchedVideoStyle == .decreasedOpacity || watchedVideoStyle == .both
watchedVideoStyle.isDecreasingOpacity
else {
return 1
}

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,7 @@ struct HideShortsButtons: View {
}
}
#if os(tvOS)
.font(.caption2)
.font(.caption)
.imageScale(.small)
#endif
}

View File

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

View File

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

View File

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

View File

@@ -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", "*"]))

View File

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

View File

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

View File

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

View File

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