mirror of
https://github.com/yattee/yattee.git
synced 2026-03-19 14:56:56 +00:00
Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20d0cfc0c7 | ||
|
|
7fe99b09ef | ||
|
|
78f155a3b9 | ||
|
|
6f696c9262 | ||
|
|
b38bd3f444 | ||
|
|
d8e079ac90 | ||
|
|
75812906c1 | ||
|
|
82570b7f34 | ||
|
|
e43eddc8e7 | ||
|
|
c5137a8af8 | ||
|
|
9177abb0ec | ||
|
|
65e86d30ec | ||
|
|
0c4609bcf1 | ||
|
|
36190e62f5 | ||
|
|
e6e69eb757 | ||
|
|
41a33634ee | ||
|
|
aa703f6531 | ||
|
|
db80b6adbb | ||
|
|
6591d503d4 | ||
|
|
1eba731283 | ||
|
|
0913c6d73c | ||
|
|
997de6468d | ||
|
|
1397a2fee6 | ||
|
|
660891f2a5 | ||
|
|
2e27dcd2cf | ||
|
|
5f53e53c7a | ||
|
|
73295e726a | ||
|
|
b0dfd2f9d2 | ||
|
|
735e7d62b6 | ||
|
|
320c16fcc7 | ||
|
|
8c5c503df2 | ||
|
|
36738572da | ||
|
|
9a8ccc366c | ||
|
|
e9ca36f1db | ||
|
|
5b607687d9 | ||
|
|
e723bb9147 | ||
|
|
a3747a0975 | ||
|
|
bb2bd86c07 | ||
|
|
680ac9a8a0 | ||
|
|
c1b23d20f2 | ||
|
|
b8f6dabbc9 | ||
|
|
1c168bd982 | ||
|
|
42d53c30db | ||
|
|
a55adb2e65 | ||
|
|
cea296c4b7 |
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
# lane: ['mac beta', 'ios beta', 'tvos beta']
|
||||
lane: ['ios beta', 'tvos beta']
|
||||
name: Releasing ${{ matrix.lane }} version to TestFlight
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-26
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ruby/setup-ruby@v1
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
mac_notarized:
|
||||
name: Build and notarize macOS app
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-26
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ruby/setup-ruby@v1
|
||||
|
||||
@@ -8,6 +8,15 @@ disabled_rules:
|
||||
- multiline_arguments
|
||||
- implicit_return
|
||||
- closure_end_indentation
|
||||
- discarded_notification_center_observer # Observer intentionally lives for app lifetime
|
||||
# Disable deprecated rules in favor of their renamed versions
|
||||
- operator_whitespace # renamed to function_name_whitespace
|
||||
- redundant_optional_initialization # renamed to implicit_optional_initialization
|
||||
|
||||
opt_in_rules:
|
||||
- function_name_whitespace
|
||||
- implicit_optional_initialization
|
||||
|
||||
excluded:
|
||||
- Vendor
|
||||
- Tests Apple TV
|
||||
|
||||
46
CHANGELOG.md
46
CHANGELOG.md
@@ -1,7 +1,49 @@
|
||||
## Build 209
|
||||
## Build 241
|
||||
|
||||
## What's Changed
|
||||
|
||||
### iOS Fixes
|
||||
* Fix menu text disappearing in navigation headers and playback settings
|
||||
* Fix fullscreen gesture collision with notification center by adding 60pt safe zone at top
|
||||
* Fix comments scrolling issue - comments at bottom of video details view are now fully accessible
|
||||
* Restrict orientation locking to iPhone only (hide on iPad)
|
||||
|
||||
### tvOS Fixes
|
||||
* Improve controls overlay with single-press menus for quality, stream, captions, and audio track selection
|
||||
* Fix controls overlay button text legibility
|
||||
* Fix captions list always showing as unavailable in MPV
|
||||
|
||||
### API & Backend Fixes
|
||||
* Fix Invidious search API parameters (sort_by→sort, upload_date→date, view_count→views)
|
||||
* Fix Invidious captions URL when companion is enabled
|
||||
* Fix YouTube share links incorrectly including port from Invidious instance
|
||||
|
||||
### UI & Layout
|
||||
* Fix home view empty sections taking excessive vertical space
|
||||
|
||||
### Advanced Settings
|
||||
* Add experimental setting to hide videos without duration in Invidious instance settings (can be used to filter shorts)
|
||||
* Add optional AVPlayer support for non-streamable MP4/AVC1 formats in advanced settings with warnings about slow loading
|
||||
|
||||
### Dependencies
|
||||
* Update dependencies
|
||||
|
||||
## Previous builds
|
||||
|
||||
## Build 210
|
||||
|
||||
## What's Changed
|
||||
|
||||
* Trending and Hide Shorts was disabled due to changes in the video apps API
|
||||
* Fix iPad iOS 18 keyboard dismissal issue in search
|
||||
* Fix audio session interrupting other apps on launch
|
||||
* Fix thumbnail loading for video details
|
||||
* Fix thumbnail aspect ratio to prevent stretching and layout jumps
|
||||
* Fix keyboard shortcut conflict for Show Player command
|
||||
|
||||
## Previous builds
|
||||
|
||||
**Build 209:**
|
||||
* Fix Now Playing controls for both MPV and AVPlayer backends
|
||||
* Fix thumbnail sizing and aspect ratio issues in video cells (#896)
|
||||
* Adjust tvOS video cell dimensions for better layout
|
||||
@@ -10,8 +52,6 @@
|
||||
* Simplify fullscreen handling for iOS
|
||||
* Add macOS-specific entitlements for MPV backend
|
||||
|
||||
## Previous builds
|
||||
|
||||
**Build 208:**
|
||||
* Enable resizable windows on iPad
|
||||
* Improve iPad UI behavior and settings layout
|
||||
|
||||
5
Extensions/Notification+Names.swift
Normal file
5
Extensions/Notification+Names.swift
Normal file
@@ -0,0 +1,5 @@
|
||||
import Foundation
|
||||
|
||||
extension Notification.Name {
|
||||
static let accountConfigurationComplete = Notification.Name("accountConfigurationComplete")
|
||||
}
|
||||
17
Gemfile.lock
17
Gemfile.lock
@@ -2,13 +2,14 @@ GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.8)
|
||||
abbrev (0.1.2)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1182.0)
|
||||
aws-sdk-core (3.237.0)
|
||||
aws-partitions (1.1187.0)
|
||||
aws-sdk-core (3.239.1)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
@@ -16,10 +17,10 @@ GEM
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
aws-sdk-kms (1.117.0)
|
||||
aws-sdk-core (~> 3, >= 3.234.0)
|
||||
aws-sdk-kms (1.118.0)
|
||||
aws-sdk-core (~> 3, >= 3.239.1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.203.1)
|
||||
aws-sdk-s3 (1.205.0)
|
||||
aws-sdk-core (~> 3, >= 3.234.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
@@ -33,6 +34,7 @@ GEM
|
||||
colored2 (3.1.2)
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
csv (3.3.5)
|
||||
declarative (0.0.20)
|
||||
digest-crc (0.7.0)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
@@ -69,8 +71,9 @@ GEM
|
||||
faraday_middleware (1.2.1)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.4.0)
|
||||
fastlane (2.228.0)
|
||||
fastlane (2.229.0)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
abbrev (~> 0.1.2)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.0)
|
||||
@@ -78,6 +81,7 @@ GEM
|
||||
bundler (>= 1.12.0, < 3.0.0)
|
||||
colored (~> 1.2)
|
||||
commander (~> 4.6)
|
||||
csv (~> 3.3)
|
||||
dotenv (>= 2.1.1, < 3.0.0)
|
||||
emoji_regex (>= 0.1, < 4.0)
|
||||
excon (>= 0.71.0, < 1.0.0)
|
||||
@@ -97,6 +101,7 @@ GEM
|
||||
jwt (>= 2.1.0, < 3)
|
||||
mini_magick (>= 4.9.4, < 5.0.0)
|
||||
multipart-post (>= 2.0.0, < 3.0.0)
|
||||
mutex_m (~> 0.3.0)
|
||||
naturally (~> 2.2)
|
||||
optparse (>= 0.1.1, < 1.0.0)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
|
||||
@@ -11,8 +11,9 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
|
||||
var frontendURL: String?
|
||||
var proxiesVideos: Bool
|
||||
var invidiousCompanion: Bool
|
||||
var hideVideosWithoutDuration: Bool
|
||||
|
||||
init(app: VideosApp, id: String? = nil, name: String? = nil, apiURLString: String, frontendURL: String? = nil, proxiesVideos: Bool = false, invidiousCompanion: Bool = false) {
|
||||
init(app: VideosApp, id: String? = nil, name: String? = nil, apiURLString: String, frontendURL: String? = nil, proxiesVideos: Bool = false, invidiousCompanion: Bool = false, hideVideosWithoutDuration: Bool = false) {
|
||||
self.app = app
|
||||
self.id = id ?? UUID().uuidString
|
||||
self.name = name ?? app.rawValue
|
||||
@@ -20,6 +21,7 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
|
||||
self.frontendURL = frontendURL
|
||||
self.proxiesVideos = proxiesVideos
|
||||
self.invidiousCompanion = invidiousCompanion
|
||||
self.hideVideosWithoutDuration = hideVideosWithoutDuration
|
||||
}
|
||||
|
||||
var apiURL: URL! {
|
||||
|
||||
@@ -17,7 +17,8 @@ struct InstancesBridge: Defaults.Bridge {
|
||||
"apiURL": value.apiURLString,
|
||||
"frontendURL": value.frontendURL ?? "",
|
||||
"proxiesVideos": value.proxiesVideos ? "true" : "false",
|
||||
"invidiousCompanion": value.invidiousCompanion ? "true" : "false"
|
||||
"invidiousCompanion": value.invidiousCompanion ? "true" : "false",
|
||||
"hideVideosWithoutDuration": value.hideVideosWithoutDuration ? "true" : "false"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -35,7 +36,8 @@ struct InstancesBridge: Defaults.Bridge {
|
||||
let frontendURL: String? = object["frontendURL"]!.isEmpty ? nil : object["frontendURL"]
|
||||
let proxiesVideos = object["proxiesVideos"] == "true"
|
||||
let invidiousCompanion = object["invidiousCompanion"] == "true"
|
||||
let hideVideosWithoutDuration = object["hideVideosWithoutDuration"] == "true"
|
||||
|
||||
return Instance(app: app, id: id, name: name, apiURLString: apiURL, frontendURL: frontendURL, proxiesVideos: proxiesVideos, invidiousCompanion: invidiousCompanion)
|
||||
return Instance(app: app, id: id, name: name, apiURLString: apiURL, frontendURL: frontendURL, proxiesVideos: proxiesVideos, invidiousCompanion: invidiousCompanion, hideVideosWithoutDuration: hideVideosWithoutDuration)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,17 @@ final class InstancesModel: ObservableObject {
|
||||
Defaults[.instances][index] = instance
|
||||
}
|
||||
|
||||
func setHideVideosWithoutDuration(_ instance: Instance, _ hideVideosWithoutDuration: Bool) {
|
||||
guard let index = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) else {
|
||||
return
|
||||
}
|
||||
|
||||
var instance = Defaults[.instances][index]
|
||||
instance.hideVideosWithoutDuration = hideVideosWithoutDuration
|
||||
|
||||
Defaults[.instances][index] = instance
|
||||
}
|
||||
|
||||
func remove(_ instance: Instance) {
|
||||
let accounts = accounts(instance.id)
|
||||
if let index = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) {
|
||||
|
||||
@@ -52,11 +52,13 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("popular"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
|
||||
content.json.arrayValue.map(self.extractVideo)
|
||||
let videos = content.json.arrayValue.map(self.extractVideo)
|
||||
return self.account.instance.hideVideosWithoutDuration ? videos.filter { $0.length > 0 } : videos
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("trending"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
|
||||
content.json.arrayValue.map(self.extractVideo)
|
||||
let videos = content.json.arrayValue.map(self.extractVideo)
|
||||
return self.account.instance.hideVideosWithoutDuration ? videos.filter { $0.length > 0 } : videos
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("search"), requestMethods: [.get]) { (content: Entity<JSON>) -> SearchPage in
|
||||
@@ -70,7 +72,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
return ContentItem(playlist: self.extractChannelPlaylist(from: json))
|
||||
}
|
||||
if type == "video" {
|
||||
return ContentItem(video: self.extractVideo(from: json))
|
||||
let video = self.extractVideo(from: json)
|
||||
if self.account.instance.hideVideosWithoutDuration, video.length == 0 {
|
||||
return nil
|
||||
}
|
||||
return ContentItem(video: video)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -101,7 +107,8 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
configureTransformer(pathPattern("auth/feed"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
|
||||
if let feedVideos = content.json.dictionaryValue["videos"] {
|
||||
return feedVideos.arrayValue.map(self.extractVideo)
|
||||
let videos = feedVideos.arrayValue.map(self.extractVideo)
|
||||
return self.account.instance.hideVideosWithoutDuration ? videos.filter { $0.length > 0 } : videos
|
||||
}
|
||||
|
||||
return []
|
||||
@@ -152,6 +159,10 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
FeedModel.shared.onAccountChange()
|
||||
SubscribedChannelsModel.shared.onAccountChange()
|
||||
PlaylistsModel.shared.onAccountChange()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,6 +171,9 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
guard !account.anonymous,
|
||||
(account.token?.isEmpty ?? true) || force
|
||||
else {
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -172,6 +186,9 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
title: "Account Error",
|
||||
message: "Remove and add your account again in Settings."
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -212,6 +229,8 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
self.configure()
|
||||
|
||||
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,7 +409,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
func search(_ query: SearchQuery, page: String?) -> Resource {
|
||||
var resource = resource(baseURL: account.url, path: basePathAppending("search"))
|
||||
.withParam("q", searchQuery(query.query))
|
||||
.withParam("sort_by", query.sortBy.parameter)
|
||||
.withParam("sort", query.sortBy.parameter)
|
||||
.withParam("type", "all")
|
||||
|
||||
if let date = query.date, date != .any {
|
||||
@@ -593,6 +612,9 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
// Some instances are not configured properly and return thumbnail links
|
||||
// with an incorrect scheme or a missing port.
|
||||
components.scheme = accountUrlComponents.scheme
|
||||
if (components.host ?? "") == "" {
|
||||
components.host = accountUrlComponents.host
|
||||
}
|
||||
components.port = accountUrlComponents.port
|
||||
|
||||
// If basic HTTP authentication is used,
|
||||
@@ -839,7 +861,14 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
private func extractCaptions(from content: JSON) -> [Captions] {
|
||||
content["captions"].arrayValue.compactMap { details in
|
||||
guard let url = URL(string: details["url"].stringValue, relativeTo: account.url) else { return nil }
|
||||
var urlString = details["url"].stringValue
|
||||
|
||||
// Prefix with /companion if enabled
|
||||
if account.instance.invidiousCompanion {
|
||||
urlString = "/companion" + urlString
|
||||
}
|
||||
|
||||
guard let url = URL(string: urlString, relativeTo: account.url) else { return nil }
|
||||
|
||||
return Captions(
|
||||
label: details["label"].stringValue,
|
||||
@@ -863,7 +892,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
return ContentItem(playlist: extractChannelPlaylist(from: json))
|
||||
}
|
||||
if type == "video" {
|
||||
return ContentItem(video: extractVideo(from: json))
|
||||
let video = extractVideo(from: json)
|
||||
if account.instance.hideVideosWithoutDuration, video.length == 0 {
|
||||
return nil
|
||||
}
|
||||
return ContentItem(video: video)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -174,6 +174,9 @@ final class PeerTubeAPI: Service, ObservableObject, VideosAPI {
|
||||
guard !account.anonymous,
|
||||
(account.token?.isEmpty ?? true) || force
|
||||
else {
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -186,6 +189,9 @@ final class PeerTubeAPI: Service, ObservableObject, VideosAPI {
|
||||
title: "Account Error",
|
||||
message: "Remove and add your account again in Settings."
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -194,6 +200,7 @@ final class PeerTubeAPI: Service, ObservableObject, VideosAPI {
|
||||
title: "Account Error",
|
||||
message: message ?? "\(response?.response?.statusCode ?? -1) - \(response?.error?.errorDescription ?? "unknown")\nIf this issue persists, try removing and adding your account again in Settings."
|
||||
)
|
||||
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
|
||||
}
|
||||
|
||||
AF
|
||||
@@ -226,6 +233,8 @@ final class PeerTubeAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
self.configure()
|
||||
|
||||
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -135,6 +135,10 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
FeedModel.shared.onAccountChange()
|
||||
SubscribedChannelsModel.shared.onAccountChange()
|
||||
PlaylistsModel.shared.onAccountChange()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +153,9 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
let username,
|
||||
let password
|
||||
else {
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -184,11 +191,14 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
self.configure()
|
||||
|
||||
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
|
||||
|
||||
case let .failure(error):
|
||||
NavigationModel.shared.presentAlert(
|
||||
title: "Account Error",
|
||||
message: error.localizedDescription
|
||||
)
|
||||
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -532,7 +542,10 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
let channelId = details["uploaderUrl"]?.string?.components(separatedBy: "/").last ?? "unknown"
|
||||
|
||||
let thumbnails: [Thumbnail] = Thumbnail.Quality.allCases.compactMap {
|
||||
let qualities = [
|
||||
Thumbnail.Quality.maxresdefault, .high, .medium, .default, .start, .middle, .end
|
||||
]
|
||||
let thumbnails: [Thumbnail] = qualities.compactMap {
|
||||
if let url = buildThumbnailURL(from: content, quality: $0) {
|
||||
return Thumbnail(url: url, quality: $0)
|
||||
}
|
||||
|
||||
@@ -114,6 +114,10 @@ extension VideosAPI {
|
||||
let frontendURL = URL(string: frontendURLString)
|
||||
{
|
||||
urlComponents = URLComponents(url: frontendURL, resolvingAgainstBaseURL: false)
|
||||
// Ensure port is not included when sharing to external frontends like YouTube
|
||||
if frontendURLString.contains("youtube.com") {
|
||||
urlComponents?.port = nil
|
||||
}
|
||||
} else if let instanceComponents = account?.instance?.urlComponents {
|
||||
urlComponents = instanceComponents
|
||||
}
|
||||
|
||||
@@ -235,7 +235,7 @@ final class FeedModel: ObservableObject, CacheModel {
|
||||
let watches = watchFetchRequestResult(videos, context: backgroundContext)
|
||||
let watchesIDs = watches.map(\.videoID)
|
||||
let unwatched = videos.filter { video in
|
||||
if Defaults[.hideShorts], video.short {
|
||||
if FeatureFlags.hideShortsEnabled, Defaults[.hideShorts], video.short {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ final class AdvancedSettingsGroupExporter: SettingsGroupExporter {
|
||||
[
|
||||
"showPlayNowInBackendContextMenu": Defaults[.showPlayNowInBackendContextMenu],
|
||||
"videoLoadingRetryCount": Defaults[.videoLoadingRetryCount],
|
||||
"avPlayerAllowsNonStreamableFormats": Defaults[.avPlayerAllowsNonStreamableFormats],
|
||||
"showMPVPlaybackStats": Defaults[.showMPVPlaybackStats],
|
||||
"mpvEnableLogging": Defaults[.mpvEnableLogging],
|
||||
"mpvCacheSecs": Defaults[.mpvCacheSecs],
|
||||
|
||||
@@ -13,6 +13,10 @@ struct AdvancedSettingsGroupImporter {
|
||||
Defaults[.videoLoadingRetryCount] = videoLoadingRetryCount
|
||||
}
|
||||
|
||||
if let avPlayerAllowsNonStreamableFormats = json["avPlayerAllowsNonStreamableFormats"].bool {
|
||||
Defaults[.avPlayerAllowsNonStreamableFormats] = avPlayerAllowsNonStreamableFormats
|
||||
}
|
||||
|
||||
if let showMPVPlaybackStats = json["showMPVPlaybackStats"].bool {
|
||||
Defaults[.showMPVPlaybackStats] = showMPVPlaybackStats
|
||||
}
|
||||
|
||||
@@ -318,7 +318,7 @@ final class NavigationModel: ObservableObject {
|
||||
func multipleTapHandler() {
|
||||
switch tabSelection {
|
||||
case .search:
|
||||
search.focused = true
|
||||
break
|
||||
default:
|
||||
print("not implemented")
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import SwiftUI
|
||||
final class AVPlayerBackend: PlayerBackend {
|
||||
static let assetKeysToLoad = ["tracks", "playable", "duration"]
|
||||
|
||||
@Default(.avPlayerAllowsNonStreamableFormats) private var allowsNonStreamableFormats
|
||||
|
||||
private var logger = Logger(label: "avplayer-backend")
|
||||
|
||||
var model: PlayerModel { .shared }
|
||||
@@ -150,7 +152,36 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
}
|
||||
|
||||
func canPlay(_ stream: Stream) -> Bool {
|
||||
stream.kind == .hls || stream.kind == .stream
|
||||
// AVPlayer has a fundamental limitation with MP4/AVC1 progressive downloads:
|
||||
// If the moov atom is at the end of the file (common case), it must download
|
||||
// the entire file before playback can start. MPV doesn't have this limitation.
|
||||
// By default, reject non-HLS MP4/AVC1 streams unless user explicitly enables them.
|
||||
|
||||
// Check if this is a non-streamable format (MP4/AVC1) that isn't HLS
|
||||
let isNonStreamableFormat = stream.kind != .hls && (stream.format == .mp4 || stream.format == .avc1)
|
||||
|
||||
if isNonStreamableFormat && !allowsNonStreamableFormats {
|
||||
return false
|
||||
}
|
||||
|
||||
// If non-streamable formats are enabled, allow MP4/AVC1 adaptive streams
|
||||
// but limit to 1080p maximum (higher resolutions can't be played properly)
|
||||
if isNonStreamableFormat && allowsNonStreamableFormats {
|
||||
let maxHeight = 1080
|
||||
if let resolution = stream.resolution, resolution.height > maxHeight {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// AVPlayer works well with HLS and stream formats
|
||||
return stream.kind == .hls || stream.kind == .stream
|
||||
}
|
||||
|
||||
func isFastLoadingFormat(_ stream: Stream) -> Bool {
|
||||
// HLS and stream formats load quickly
|
||||
// Non-streamable MP4/AVC1 formats may take a long time
|
||||
return stream.kind == .hls || stream.kind == .stream
|
||||
}
|
||||
|
||||
func playStream(
|
||||
@@ -304,12 +335,12 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
preservingTime: Bool = false,
|
||||
model: PlayerModel
|
||||
) {
|
||||
model.logger.info("loading \(type.rawValue) track")
|
||||
|
||||
asset.loadValuesAsynchronously(forKeys: Self.assetKeysToLoad) { [weak self] in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
model.logger.info("loading \(type.rawValue) track")
|
||||
|
||||
let assetTracks = asset.tracks(withMediaType: type)
|
||||
|
||||
guard let compositionTrack = self.composition.addMutableTrack(
|
||||
|
||||
@@ -135,12 +135,6 @@ final class MPVClient: ObservableObject {
|
||||
|
||||
checkError(mpv_initialize(mpv))
|
||||
|
||||
#if !os(macOS)
|
||||
// Set up audio session for Now Playing support
|
||||
backend?.model.setupAudioSessionForNowPlaying()
|
||||
backend?.model.updateNowPlayingInfo()
|
||||
#endif
|
||||
|
||||
let api = UnsafeMutableRawPointer(mutating: (MPV_RENDER_API_TYPE_OPENGL as NSString).utf8String)
|
||||
var initParams = mpv_opengl_init_params(
|
||||
get_proc_address: getProcAddress,
|
||||
|
||||
@@ -650,7 +650,7 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
// When switching away from AVPlayer, clear its current item to release Now Playing control
|
||||
#if !os(macOS)
|
||||
if from == .appleAVPlayer && to == .mpv {
|
||||
if from == .appleAVPlayer, to == .mpv {
|
||||
avPlayerBackend.avPlayer.replaceCurrentItem(with: nil)
|
||||
|
||||
// Clear Now Playing info entirely before MPV takes over
|
||||
@@ -1394,7 +1394,11 @@ final class PlayerModel: ObservableObject {
|
||||
func setAudioSessionActive(_ setActive: Bool) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setActive(setActive)
|
||||
let audioSession = AVAudioSession.sharedInstance()
|
||||
if setActive {
|
||||
try audioSession.setCategory(.playback, mode: .moviePlayback)
|
||||
}
|
||||
try audioSession.setActive(setActive)
|
||||
} catch {
|
||||
self.logger.error("Error setting audio session to \(setActive): \(error)")
|
||||
}
|
||||
|
||||
@@ -133,6 +133,26 @@ extension PlayerModel {
|
||||
|
||||
let profile = qualityProfile ?? .defaultProfile
|
||||
|
||||
// For AVPlayer, prefer fast-loading formats (HLS/stream) over non-streamable formats
|
||||
// to avoid long loading times when switching backends
|
||||
if activeBackend == .appleAVPlayer, let avBackend = backend as? AVPlayerBackend {
|
||||
// Try to find a fast-loading stream first
|
||||
let fastLoadingStreams = availableStreams.filter { backend.canPlay($0) && avBackend.isFastLoadingFormat($0) }
|
||||
if let fastStream = backend.bestPlayable(
|
||||
fastLoadingStreams.filter { profile.isPreferred($0) },
|
||||
maxResolution: profile.resolution, formatOrder: profile.formats
|
||||
) {
|
||||
return fastStream
|
||||
}
|
||||
// Fallback to any fast-loading stream
|
||||
if let fastStream = backend.bestPlayable(
|
||||
fastLoadingStreams,
|
||||
maxResolution: profile.resolution, formatOrder: profile.formats
|
||||
) {
|
||||
return fastStream
|
||||
}
|
||||
}
|
||||
|
||||
// First attempt: Filter by both `canPlay` and `isPreferred`
|
||||
if let streamPreferredForProfile = backend.bestPlayable(
|
||||
availableStreams.filter { backend.canPlay($0) && profile.isPreferred($0) },
|
||||
|
||||
@@ -16,30 +16,18 @@ final class SearchModel: ObservableObject {
|
||||
@Published var querySuggestions = [String]()
|
||||
private var suggestionsDebouncer = Debouncer(.milliseconds(200))
|
||||
|
||||
@Published var focused = false
|
||||
|
||||
@Default(.showSearchSuggestions) private var showSearchSuggestions
|
||||
|
||||
#if os(iOS)
|
||||
var textField: UITextField!
|
||||
#elseif os(macOS)
|
||||
#if os(macOS)
|
||||
var textField: NSTextField!
|
||||
#endif
|
||||
|
||||
var accounts: AccountsModel { .shared }
|
||||
private var resource: Resource!
|
||||
|
||||
init() {
|
||||
#if os(iOS)
|
||||
addKeyboardDidHideNotificationObserver()
|
||||
#endif
|
||||
}
|
||||
init() {}
|
||||
|
||||
deinit {
|
||||
#if os(iOS)
|
||||
removeKeyboardDidHideNotificationObserver()
|
||||
#endif
|
||||
}
|
||||
deinit {}
|
||||
|
||||
var isLoading: Bool {
|
||||
resource?.isLoading ?? false
|
||||
@@ -158,18 +146,4 @@ final class SearchModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private func addKeyboardDidHideNotificationObserver() {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(onKeyboardDidHide), name: UIResponder.keyboardDidHideNotification, object: nil)
|
||||
}
|
||||
|
||||
@objc func onKeyboardDidHide() {
|
||||
focused = false
|
||||
}
|
||||
|
||||
private func removeKeyboardDidHideNotificationObserver() {
|
||||
NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardDidHideNotification, object: nil)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -47,9 +47,9 @@ final class SearchQuery: ObservableObject {
|
||||
var parameter: String {
|
||||
switch self {
|
||||
case .uploadDate:
|
||||
return "upload_date"
|
||||
return "date"
|
||||
case .viewCount:
|
||||
return "view_count"
|
||||
return "views"
|
||||
default:
|
||||
return rawValue
|
||||
}
|
||||
|
||||
@@ -109,22 +109,7 @@ struct ChannelPlaylistView: View {
|
||||
|
||||
#if os(iOS)
|
||||
private var playlistMenu: some View {
|
||||
Menu {
|
||||
playButtons
|
||||
|
||||
favoriteButton
|
||||
|
||||
ListingStyleButtons(listingStyle: $channelPlaylistListingStyle)
|
||||
|
||||
Section {
|
||||
HideWatchedButtons()
|
||||
HideShortsButtons()
|
||||
}
|
||||
|
||||
Section {
|
||||
SettingsButtons()
|
||||
}
|
||||
} label: {
|
||||
ZStack {
|
||||
HStack(spacing: 12) {
|
||||
if let url = store.item?.thumbnailURL ?? playlist.thumbnailURL {
|
||||
ThumbnailView(url: url)
|
||||
@@ -141,8 +126,43 @@ struct ChannelPlaylistView: View {
|
||||
.imageScale(.small)
|
||||
}
|
||||
.frame(maxWidth: 320)
|
||||
.transaction { t in t.animation = nil }
|
||||
|
||||
Menu {
|
||||
playButtons
|
||||
|
||||
favoriteButton
|
||||
|
||||
ListingStyleButtons(listingStyle: $channelPlaylistListingStyle)
|
||||
|
||||
Section {
|
||||
HideWatchedButtons()
|
||||
HideShortsButtons()
|
||||
}
|
||||
|
||||
Section {
|
||||
SettingsButtons()
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
if let url = store.item?.thumbnailURL ?? playlist.thumbnailURL {
|
||||
ThumbnailView(url: url)
|
||||
.frame(width: 60, height: 30)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 2))
|
||||
}
|
||||
|
||||
Text(playlist.title)
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Image(systemName: "chevron.down.circle.fill")
|
||||
.foregroundColor(.accentColor)
|
||||
.imageScale(.small)
|
||||
}
|
||||
.frame(maxWidth: 320)
|
||||
.opacity(0)
|
||||
}
|
||||
}
|
||||
.transaction { t in t.animation = nil }
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
@@ -259,26 +259,7 @@ struct ChannelVideosView: View {
|
||||
|
||||
#if !os(tvOS)
|
||||
var channelMenu: some View {
|
||||
Menu {
|
||||
if let channel = presentedChannel {
|
||||
contentTypePicker
|
||||
Section {
|
||||
subscriptionToggleButton
|
||||
FavoriteButton(item: FavoriteItem(section: .channel(accounts.app.appType.rawValue, channel.id, channel.name)))
|
||||
}
|
||||
|
||||
if subscriptions.isSubscribing(channel.id) {
|
||||
toggleWatchedButton
|
||||
}
|
||||
|
||||
ListingStyleButtons(listingStyle: $channelPlaylistListingStyle)
|
||||
|
||||
Section {
|
||||
HideWatchedButtons()
|
||||
HideShortsButtons()
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
ZStack {
|
||||
HStack(spacing: 12) {
|
||||
thumbnail
|
||||
|
||||
@@ -311,6 +292,61 @@ struct ChannelVideosView: View {
|
||||
.imageScale(.small)
|
||||
}
|
||||
.frame(maxWidth: 320)
|
||||
|
||||
Menu {
|
||||
if let channel = presentedChannel {
|
||||
contentTypePicker
|
||||
Section {
|
||||
subscriptionToggleButton
|
||||
FavoriteButton(item: FavoriteItem(section: .channel(accounts.app.appType.rawValue, channel.id, channel.name)))
|
||||
}
|
||||
|
||||
if subscriptions.isSubscribing(channel.id) {
|
||||
toggleWatchedButton
|
||||
}
|
||||
|
||||
ListingStyleButtons(listingStyle: $channelPlaylistListingStyle)
|
||||
|
||||
Section {
|
||||
HideWatchedButtons()
|
||||
HideShortsButtons()
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
thumbnail
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text(presentedChannel?.name ?? "Channel")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
.layoutPriority(1)
|
||||
.frame(minWidth: 160, alignment: .leading)
|
||||
|
||||
Group {
|
||||
HStack(spacing: 12) {
|
||||
subscriptionsLabel
|
||||
|
||||
if presentedChannel?.verified ?? false {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.imageScale(.small)
|
||||
}
|
||||
|
||||
viewsLabel
|
||||
}
|
||||
.frame(minWidth: 160, alignment: .leading)
|
||||
}
|
||||
.font(.caption2.bold())
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Image(systemName: "chevron.down.circle.fill")
|
||||
.foregroundColor(.accentColor)
|
||||
.imageScale(.small)
|
||||
}
|
||||
.frame(maxWidth: 320)
|
||||
.opacity(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -6,6 +6,7 @@ enum Constants {
|
||||
static let overlayAnimation = Animation.linear(duration: 0.2)
|
||||
static let aspectRatio16x9 = 16.0 / 9.0
|
||||
static let aspectRatio4x3 = 4.0 / 3.0
|
||||
static let notificationCenterZoneHeight: Double = 60
|
||||
|
||||
static var isAppleTV: Bool {
|
||||
#if os(iOS)
|
||||
|
||||
@@ -192,7 +192,8 @@ extension Defaults.Keys {
|
||||
hd1080p60MPVProfile,
|
||||
hd1080pMPVProfile,
|
||||
hd720p60MPVProfile,
|
||||
hd720pMPVProfile
|
||||
hd720pMPVProfile,
|
||||
hd720pAVPlayerProfile
|
||||
]
|
||||
|
||||
static let batteryCellularProfileDefault = hd720pMPVProfile.id
|
||||
@@ -208,7 +209,8 @@ extension Defaults.Keys {
|
||||
hd1080pMPVProfile,
|
||||
hd720p60MPVProfile,
|
||||
hd720pMPVProfile,
|
||||
sd360pMPVProfile
|
||||
sd360pMPVProfile,
|
||||
hd720pAVPlayerProfile
|
||||
]
|
||||
|
||||
static let batteryCellularProfileDefault = sd360pMPVProfile.id
|
||||
@@ -361,6 +363,7 @@ extension Defaults.Keys {
|
||||
|
||||
static let showPlayNowInBackendContextMenu = Key<Bool>("showPlayNowInBackendContextMenu", default: false)
|
||||
static let videoLoadingRetryCount = Key<Int>("videoLoadingRetryCount", default: 10)
|
||||
static let avPlayerAllowsNonStreamableFormats = Key<Bool>("avPlayerAllowsNonStreamableFormats", default: false)
|
||||
|
||||
static let showMPVPlaybackStats = Key<Bool>("showMPVPlaybackStats", default: false)
|
||||
static let mpvEnableLogging = Key<Bool>("mpvEnableLogging", default: false)
|
||||
|
||||
12
Shared/FeatureFlags.swift
Normal file
12
Shared/FeatureFlags.swift
Normal file
@@ -0,0 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
/// Feature flags for enabling/disabling functionality across the app
|
||||
enum FeatureFlags {
|
||||
/// Controls whether the "Hide Shorts" functionality is available
|
||||
/// Set to false when the API changes prevent reliable detection of short videos
|
||||
static let hideShortsEnabled = false
|
||||
|
||||
/// Controls whether the "Trending" section is available
|
||||
/// Set to false to disable trending functionality across the app
|
||||
static let trendingEnabled = false
|
||||
}
|
||||
@@ -50,9 +50,11 @@ struct FavoriteItemView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if hideShorts || hideWatched {
|
||||
if (FeatureFlags.hideShortsEnabled && hideShorts) || hideWatched {
|
||||
AccentButton(text: "Disable filters", maxWidth: nil, verticalPadding: 0, minHeight: 30) {
|
||||
hideShorts = false
|
||||
if FeatureFlags.hideShortsEnabled {
|
||||
hideShorts = false
|
||||
}
|
||||
hideWatched = false
|
||||
reloadVisibleWatches()
|
||||
}
|
||||
@@ -64,7 +66,6 @@ struct FavoriteItemView: View {
|
||||
#else
|
||||
.padding(.horizontal, 15)
|
||||
#endif
|
||||
.frame(height: expectedContentHeight)
|
||||
} else {
|
||||
ZStack(alignment: .topLeading) {
|
||||
// Reserve space immediately to prevent layout shift
|
||||
@@ -107,7 +108,7 @@ struct FavoriteItemView: View {
|
||||
resource?.removeObservers(ownedBy: store)
|
||||
}
|
||||
.onChange(of: player.currentVideo) { _ in if !player.presentingPlayer { reloadVisibleWatches() } }
|
||||
.onChange(of: hideShorts) { _ in if !player.presentingPlayer { reloadVisibleWatches() } }
|
||||
.onChange(of: hideShorts) { _ in if !player.presentingPlayer && FeatureFlags.hideShortsEnabled { reloadVisibleWatches() } }
|
||||
.onChange(of: hideWatched) { _ in if !player.presentingPlayer { reloadVisibleWatches() } }
|
||||
// Delay is necessary to update the list with the new items.
|
||||
.onChange(of: favoritesChanged) { _ in if !player.presentingPlayer { Delay.by(1.0) { reloadVisibleWatches() } } }
|
||||
@@ -135,9 +136,9 @@ struct FavoriteItemView: View {
|
||||
|
||||
var emptyItemsText: String {
|
||||
var filterText = ""
|
||||
if hideShorts && hideWatched {
|
||||
if FeatureFlags.hideShortsEnabled && hideShorts && hideWatched {
|
||||
filterText = "(watched and shorts hidden)"
|
||||
} else if hideShorts {
|
||||
} else if FeatureFlags.hideShortsEnabled && hideShorts {
|
||||
filterText = "(shorts hidden)"
|
||||
} else if hideWatched {
|
||||
filterText = "(watched hidden)"
|
||||
@@ -227,7 +228,7 @@ struct FavoriteItemView: View {
|
||||
return false
|
||||
}
|
||||
|
||||
guard hideShorts, item.contentType == .video, let video = item.video else {
|
||||
guard FeatureFlags.hideShortsEnabled, hideShorts, item.contentType == .video, let video = item.video else {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -351,7 +352,7 @@ struct FavoriteItemView: View {
|
||||
case .history:
|
||||
return false
|
||||
case .trending:
|
||||
return visibleSections.contains(.trending)
|
||||
return FeatureFlags.trendingEnabled && visibleSections.contains(.trending)
|
||||
case .subscriptions:
|
||||
return visibleSections.contains(.subscriptions) && accounts.signedIn
|
||||
case .popular:
|
||||
|
||||
@@ -211,19 +211,7 @@ struct HomeView: View {
|
||||
|
||||
#if os(iOS)
|
||||
var homeMenu: some View {
|
||||
Menu {
|
||||
Section {
|
||||
HideWatchedButtons()
|
||||
HideShortsButtons()
|
||||
}
|
||||
Section {
|
||||
Button {
|
||||
navigation.presentingHomeSettings = true
|
||||
} label: {
|
||||
Label("Home Settings", systemImage: "gear")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
ZStack {
|
||||
HStack(spacing: 12) {
|
||||
Text("Home")
|
||||
.foregroundColor(.primary)
|
||||
@@ -233,8 +221,33 @@ struct HomeView: View {
|
||||
.foregroundColor(.accentColor)
|
||||
.imageScale(.small)
|
||||
}
|
||||
.transaction { t in t.animation = nil }
|
||||
|
||||
Menu {
|
||||
Section {
|
||||
HideWatchedButtons()
|
||||
HideShortsButtons()
|
||||
}
|
||||
Section {
|
||||
Button {
|
||||
navigation.presentingHomeSettings = true
|
||||
} label: {
|
||||
Label("Home Settings", systemImage: "gear")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Text("Home")
|
||||
.foregroundColor(.primary)
|
||||
.font(.headline)
|
||||
|
||||
Image(systemName: "chevron.down.circle.fill")
|
||||
.foregroundColor(.accentColor)
|
||||
.imageScale(.small)
|
||||
}
|
||||
.opacity(0)
|
||||
}
|
||||
}
|
||||
.transaction { t in t.animation = nil }
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -36,10 +36,12 @@ struct MenuCommands: Commands {
|
||||
.disabled(!AccountsModel.shared.app.supportsPopular)
|
||||
.keyboardShortcut("3")
|
||||
|
||||
Button("Trending") {
|
||||
setTabSelection(.trending)
|
||||
if FeatureFlags.trendingEnabled {
|
||||
Button("Trending") {
|
||||
setTabSelection(.trending)
|
||||
}
|
||||
.keyboardShortcut("4")
|
||||
}
|
||||
.keyboardShortcut("4")
|
||||
|
||||
Button("Search") {
|
||||
setTabSelection(.search)
|
||||
@@ -76,7 +78,7 @@ struct MenuCommands: Commands {
|
||||
Button(togglePlayerLabel) {
|
||||
PlayerModel.shared.togglePlayer()
|
||||
}
|
||||
.keyboardShortcut("o")
|
||||
.keyboardShortcut("p", modifiers: [.command, .shift])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ struct AppSidebarNavigation: View {
|
||||
|
||||
var body: some View {
|
||||
#if os(iOS)
|
||||
content.introspect(.viewController, on: .iOS(.v15, .v16, .v17, .v18)) { viewController in
|
||||
content.introspect(.viewController, on: .iOS(.v15, .v16, .v17, .v18, .v26)) { viewController in
|
||||
// workaround for an empty supplementary view on launch
|
||||
// the supplementary view is determined by the default selection inside the
|
||||
// primary view, but the primary view is not loaded so its selection is not read
|
||||
|
||||
@@ -37,7 +37,7 @@ struct AppTabNavigation: View {
|
||||
popularNavigationView
|
||||
}
|
||||
|
||||
if visibleSections.contains(.trending) {
|
||||
if FeatureFlags.trendingEnabled && visibleSections.contains(.trending) {
|
||||
trendingNavigationView
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ struct AppTabNavigation: View {
|
||||
.toolbar { toolbarContent }
|
||||
}
|
||||
.tabItem {
|
||||
Label("Popular", systemImage: "arrow.up.right.circle.fill")
|
||||
Label("Popular", systemImage: "chart.bar.fill")
|
||||
.accessibility(label: Text("Popular"))
|
||||
}
|
||||
.tag(TabSelection.popular)
|
||||
@@ -126,7 +126,7 @@ struct AppTabNavigation: View {
|
||||
LazyView(TrendingView())
|
||||
}
|
||||
.tabItem {
|
||||
Label("Trending", systemImage: "chart.bar.fill")
|
||||
Label("Trending", systemImage: "arrow.up.right.circle.fill")
|
||||
.accessibility(label: Text("Trending"))
|
||||
}
|
||||
.tag(TabSelection.trending)
|
||||
|
||||
@@ -95,15 +95,15 @@ struct Sidebar: View {
|
||||
|
||||
if visibleSections.contains(.popular), accounts.app.supportsPopular {
|
||||
NavigationLink(destination: LazyView(PopularView()), tag: TabSelection.popular, selection: $navigation.tabSelection) {
|
||||
Label("Popular", systemImage: "arrow.up.right.circle")
|
||||
Label("Popular", systemImage: "chart.bar.fill")
|
||||
.accessibility(label: Text("Popular"))
|
||||
}
|
||||
.id("popular")
|
||||
}
|
||||
|
||||
if visibleSections.contains(.trending) {
|
||||
if FeatureFlags.trendingEnabled && visibleSections.contains(.trending) {
|
||||
NavigationLink(destination: LazyView(TrendingView()), tag: TabSelection.trending, selection: $navigation.tabSelection) {
|
||||
Label("Trending", systemImage: "chart.bar")
|
||||
Label("Trending", systemImage: "arrow.up.right.circle.fill")
|
||||
.accessibility(label: Text("Trending"))
|
||||
}
|
||||
.id("trending")
|
||||
|
||||
@@ -74,6 +74,7 @@ struct OpenURLHandler {
|
||||
focusMainWindow()
|
||||
#endif
|
||||
case .trending:
|
||||
guard FeatureFlags.trendingEnabled else { return }
|
||||
navigation.hideViewsAboveBrowser()
|
||||
navigation.tabSelection = .trending
|
||||
#if os(macOS)
|
||||
|
||||
@@ -5,8 +5,6 @@ struct ControlsOverlay: View {
|
||||
@ObservedObject private var player = PlayerModel.shared
|
||||
private var model = PlayerControlsModel.shared
|
||||
|
||||
@State private var availableCaptions: [Captions] = []
|
||||
@State private var isLoadingCaptions = true
|
||||
@State private var contentSize: CGSize = .zero
|
||||
|
||||
@Default(.showMPVPlaybackStats) private var showMPVPlaybackStats
|
||||
@@ -23,7 +21,10 @@ struct ControlsOverlay: View {
|
||||
}
|
||||
|
||||
@FocusState private var focusedField: Field?
|
||||
@State private var presentingButtonHintAlert = false
|
||||
@State private var presentingQualityProfileMenu = false
|
||||
@State private var presentingStreamMenu = false
|
||||
@State private var presentingCaptionsMenu = false
|
||||
@State private var presentingAudioTrackMenu = false
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
@@ -104,19 +105,9 @@ struct ControlsOverlay: View {
|
||||
#if os(tvOS)
|
||||
.padding(.horizontal, 40)
|
||||
#endif
|
||||
|
||||
#if os(tvOS)
|
||||
Text("Press and hold remote button to open captions and quality menus")
|
||||
.frame(maxWidth: 400)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
#endif
|
||||
}
|
||||
.frame(maxHeight: overlayHeight)
|
||||
.frame(maxHeight: contentSize.height)
|
||||
#if os(tvOS)
|
||||
.alert(isPresented: $presentingButtonHintAlert) {
|
||||
Alert(title: Text("Press and hold to open this menu"))
|
||||
}
|
||||
.onAppear {
|
||||
focusedField = .qualityProfile
|
||||
}
|
||||
@@ -127,14 +118,6 @@ struct ControlsOverlay: View {
|
||||
player.activeBackend == .mpv ? "Rate & Captions" : "Playback Rate"
|
||||
}
|
||||
|
||||
private var overlayHeight: Double {
|
||||
#if os(tvOS)
|
||||
contentSize.height + 80.0
|
||||
#else
|
||||
contentSize.height
|
||||
#endif
|
||||
}
|
||||
|
||||
private func controlsHeader(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.system(.caption))
|
||||
@@ -279,23 +262,25 @@ struct ControlsOverlay: View {
|
||||
.modifier(ControlBackgroundModifier())
|
||||
.mask(RoundedRectangle(cornerRadius: 3))
|
||||
#else
|
||||
ControlsOverlayButton(focusedField: $focusedField, field: .qualityProfile) {
|
||||
ControlsOverlayButton(
|
||||
focusedField: $focusedField,
|
||||
field: .qualityProfile,
|
||||
onSelect: { presentingQualityProfileMenu = true }
|
||||
) {
|
||||
Text(player.qualityProfileSelection?.description ?? "Automatic".localized())
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: 320)
|
||||
}
|
||||
.contextMenu {
|
||||
.alert("Quality Profile", isPresented: $presentingQualityProfileMenu) {
|
||||
Button("Automatic") { player.qualityProfileSelection = nil }
|
||||
|
||||
ForEach(qualityProfiles) { qualityProfile in
|
||||
Button {
|
||||
Button(qualityProfile.description) {
|
||||
player.qualityProfileSelection = qualityProfile
|
||||
} label: {
|
||||
Text(qualityProfile.description)
|
||||
}
|
||||
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -330,7 +315,7 @@ struct ControlsOverlay: View {
|
||||
.modifier(ControlBackgroundModifier())
|
||||
.mask(RoundedRectangle(cornerRadius: 3))
|
||||
#else
|
||||
StreamControl(focusedField: $focusedField)
|
||||
StreamControl(focusedField: $focusedField, presentingStreamMenu: $presentingStreamMenu)
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -369,29 +354,31 @@ struct ControlsOverlay: View {
|
||||
.modifier(ControlBackgroundModifier())
|
||||
.mask(RoundedRectangle(cornerRadius: 3))
|
||||
#else
|
||||
ControlsOverlayButton(focusedField: $focusedField, field: .captions) {
|
||||
ControlsOverlayButton(
|
||||
focusedField: $focusedField,
|
||||
field: .captions,
|
||||
onSelect: { presentingCaptionsMenu = true }
|
||||
) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "text.bubble")
|
||||
if let captions = captionsBinding.wrappedValue,
|
||||
let language = LanguageCodes(rawValue: captions.code)
|
||||
{
|
||||
Text("\(language.description.capitalized) (\(language.rawValue))")
|
||||
.foregroundColor(.accentColor)
|
||||
} else {
|
||||
if captionsBinding.wrappedValue == nil {
|
||||
Text("Not available")
|
||||
} else {
|
||||
if player.currentVideo?.captions.isEmpty == false {
|
||||
Text("Disabled")
|
||||
.foregroundColor(.accentColor)
|
||||
} else {
|
||||
Text("Not available")
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 320)
|
||||
}
|
||||
.contextMenu {
|
||||
.alert("Captions", isPresented: $presentingCaptionsMenu) {
|
||||
Button("Disabled") { captionsBinding.wrappedValue = nil }
|
||||
|
||||
ForEach(availableCaptions) { caption in
|
||||
ForEach(player.currentVideo?.captions ?? []) { caption in
|
||||
Button(caption.description) { captionsBinding.wrappedValue = caption }
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
@@ -400,7 +387,7 @@ struct ControlsOverlay: View {
|
||||
}
|
||||
|
||||
@ViewBuilder private var captionsPicker: some View {
|
||||
let captions = availableCaptions
|
||||
let captions = player.currentVideo?.captions ?? []
|
||||
Picker("Captions", selection: captionsBinding) {
|
||||
if captions.isEmpty {
|
||||
Text("Not available").tag(Captions?.none)
|
||||
@@ -412,31 +399,6 @@ struct ControlsOverlay: View {
|
||||
}
|
||||
}
|
||||
.disabled(captions.isEmpty)
|
||||
.onAppear {
|
||||
loadCaptions()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadCaptions() {
|
||||
isLoadingCaptions = true
|
||||
|
||||
// Fetch captions asynchronously
|
||||
Task {
|
||||
let fetchedCaptions = await fetchCaptions()
|
||||
await MainActor.run {
|
||||
// Update state on the main thread
|
||||
self.availableCaptions = fetchedCaptions
|
||||
self.isLoadingCaptions = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchCaptions() async -> [Captions] {
|
||||
// Access currentVideo from the main actor context
|
||||
await MainActor.run {
|
||||
// Safely access the main actor-isolated currentVideo property
|
||||
player.currentVideo?.captions ?? []
|
||||
}
|
||||
}
|
||||
|
||||
private var captionsBinding: Binding<Captions?> {
|
||||
@@ -467,11 +429,15 @@ struct ControlsOverlay: View {
|
||||
.frame(maxWidth: 240, alignment: .trailing)
|
||||
.frame(height: 40)
|
||||
#else
|
||||
ControlsOverlayButton(focusedField: $focusedField, field: .audioTrack) {
|
||||
ControlsOverlayButton(
|
||||
focusedField: $focusedField,
|
||||
field: .audioTrack,
|
||||
onSelect: { presentingAudioTrackMenu = true }
|
||||
) {
|
||||
Text(player.selectedAudioTrack?.displayLanguage ?? "Original")
|
||||
.frame(maxWidth: 320)
|
||||
}
|
||||
.contextMenu {
|
||||
.alert("Audio Track", isPresented: $presentingAudioTrackMenu) {
|
||||
ForEach(Array(player.availableAudioTracks.enumerated()), id: \.offset) { index, track in
|
||||
Button(track.description) { player.selectedAudioTrackIndex = index }
|
||||
}
|
||||
|
||||
@@ -199,6 +199,16 @@ struct TimelineView: View {
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 5, coordinateSpace: .global)
|
||||
.onChanged { value in
|
||||
#if os(iOS)
|
||||
// In fullscreen, ignore gestures that start in the top notification center area
|
||||
// to allow system notification center gesture to work
|
||||
if player.playingFullScreen {
|
||||
if value.startLocation.y < Constants.notificationCenterZoneHeight {
|
||||
return
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
if !dragging {
|
||||
controls.removeTimer()
|
||||
draggedFrom = current
|
||||
|
||||
@@ -209,16 +209,22 @@ struct PlaybackSettings: View {
|
||||
.controlSize(.large)
|
||||
.frame(width: 100, alignment: .center)
|
||||
#elseif os(iOS)
|
||||
Menu {
|
||||
ratePicker
|
||||
} label: {
|
||||
ZStack {
|
||||
Text(player.rateLabel(player.currentRate))
|
||||
.foregroundColor(.primary)
|
||||
.frame(width: 70)
|
||||
|
||||
Menu {
|
||||
ratePicker
|
||||
} label: {
|
||||
Text(player.rateLabel(player.currentRate))
|
||||
.foregroundColor(.primary)
|
||||
.frame(width: 70)
|
||||
.opacity(0)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.transaction { t in t.animation = .none }
|
||||
}
|
||||
.transaction { t in t.animation = .none }
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.primary)
|
||||
.frame(width: 70, height: 40)
|
||||
#else
|
||||
Text(player.rateLabel(player.currentRate))
|
||||
@@ -331,12 +337,20 @@ struct PlaybackSettings: View {
|
||||
.controlSize(.large)
|
||||
.frame(width: 300, alignment: .trailing)
|
||||
#else
|
||||
Menu {
|
||||
playbackModePicker
|
||||
} label: {
|
||||
ZStack {
|
||||
Label(player.playbackMode.description.localized(), systemImage: player.playbackMode.systemImage)
|
||||
.foregroundColor(.accentColor)
|
||||
|
||||
Menu {
|
||||
playbackModePicker
|
||||
} label: {
|
||||
Label(player.playbackMode.description.localized(), systemImage: player.playbackMode.systemImage)
|
||||
.foregroundColor(.accentColor)
|
||||
.opacity(0)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.transaction { t in t.animation = .none }
|
||||
}
|
||||
.transaction { t in t.animation = .none }
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -356,15 +370,22 @@ struct PlaybackSettings: View {
|
||||
.controlSize(.large)
|
||||
.frame(width: 300, alignment: .trailing)
|
||||
#elseif os(iOS)
|
||||
Menu {
|
||||
qualityProfilePicker
|
||||
} label: {
|
||||
ZStack {
|
||||
Text(player.qualityProfileSelection?.description ?? "Automatic".localized())
|
||||
.foregroundColor(.accentColor)
|
||||
.frame(maxWidth: 240, alignment: .trailing)
|
||||
|
||||
Menu {
|
||||
qualityProfilePicker
|
||||
} label: {
|
||||
Text(player.qualityProfileSelection?.description ?? "Automatic".localized())
|
||||
.foregroundColor(.accentColor)
|
||||
.frame(maxWidth: 240, alignment: .trailing)
|
||||
.opacity(0)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.transaction { t in t.animation = .none }
|
||||
}
|
||||
.transaction { t in t.animation = .none }
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.accentColor)
|
||||
.frame(maxWidth: 240, alignment: .trailing)
|
||||
.frame(height: 40)
|
||||
#else
|
||||
@@ -406,15 +427,22 @@ struct PlaybackSettings: View {
|
||||
.controlSize(.large)
|
||||
.frame(width: 300, alignment: .trailing)
|
||||
#elseif os(iOS)
|
||||
Menu {
|
||||
StreamControl()
|
||||
} label: {
|
||||
ZStack {
|
||||
Text(player.streamSelection?.resolutionAndFormat ?? "loading...")
|
||||
.frame(width: 140, height: 40, alignment: .trailing)
|
||||
.foregroundColor(player.streamSelection == nil ? .secondary : .accentColor)
|
||||
.frame(width: 140, height: 40, alignment: .trailing)
|
||||
|
||||
Menu {
|
||||
StreamControl()
|
||||
} label: {
|
||||
Text(player.streamSelection?.resolutionAndFormat ?? "loading...")
|
||||
.foregroundColor(player.streamSelection == nil ? .secondary : .accentColor)
|
||||
.frame(width: 140, height: 40, alignment: .trailing)
|
||||
.opacity(0)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.transaction { t in t.animation = .none }
|
||||
}
|
||||
.transaction { t in t.animation = .none }
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 140, height: 40, alignment: .trailing)
|
||||
#else
|
||||
StreamControl(focusedField: $focusedField)
|
||||
@@ -429,18 +457,13 @@ struct PlaybackSettings: View {
|
||||
.controlSize(.large)
|
||||
.frame(width: 300, alignment: .trailing)
|
||||
#elseif os(iOS)
|
||||
Menu {
|
||||
if videoCaptions?.isEmpty == false {
|
||||
captionsPicker
|
||||
}
|
||||
} label: {
|
||||
ZStack {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "text.bubble")
|
||||
if let captions = player.captions,
|
||||
let language = LanguageCodes(rawValue: captions.code)
|
||||
{
|
||||
Text("\(language.description.capitalized) (\(language.rawValue))")
|
||||
.foregroundColor(.accentColor)
|
||||
} else {
|
||||
if videoCaptions?.isEmpty == true {
|
||||
Text("Not available")
|
||||
@@ -449,13 +472,38 @@ struct PlaybackSettings: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.foregroundColor(.accentColor)
|
||||
.frame(alignment: .trailing)
|
||||
.frame(height: 40)
|
||||
.disabled(videoCaptions?.isEmpty == true)
|
||||
|
||||
Menu {
|
||||
if videoCaptions?.isEmpty == false {
|
||||
captionsPicker
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "text.bubble")
|
||||
if let captions = player.captions,
|
||||
let language = LanguageCodes(rawValue: captions.code)
|
||||
{
|
||||
Text("\(language.description.capitalized) (\(language.rawValue))")
|
||||
} else {
|
||||
if videoCaptions?.isEmpty == true {
|
||||
Text("Not available")
|
||||
} else {
|
||||
Text("Disabled")
|
||||
}
|
||||
}
|
||||
}
|
||||
.foregroundColor(.accentColor)
|
||||
.frame(alignment: .trailing)
|
||||
.frame(height: 40)
|
||||
.opacity(0)
|
||||
.disabled(videoCaptions?.isEmpty == true)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.transaction { t in t.animation = .none }
|
||||
}
|
||||
.transaction { t in t.animation = .none }
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.accentColor)
|
||||
#else
|
||||
ControlsOverlayButton(focusedField: $focusedField, field: .captions) {
|
||||
HStack(spacing: 8) {
|
||||
@@ -500,15 +548,22 @@ struct PlaybackSettings: View {
|
||||
.controlSize(.large)
|
||||
.frame(width: 300, alignment: .trailing)
|
||||
#elseif os(iOS)
|
||||
Menu {
|
||||
audioTrackPicker
|
||||
} label: {
|
||||
ZStack {
|
||||
Text(player.selectedAudioTrack?.displayLanguage ?? "Original")
|
||||
.foregroundColor(.accentColor)
|
||||
.frame(maxWidth: 240, alignment: .trailing)
|
||||
|
||||
Menu {
|
||||
audioTrackPicker
|
||||
} label: {
|
||||
Text(player.selectedAudioTrack?.displayLanguage ?? "Original")
|
||||
.foregroundColor(.accentColor)
|
||||
.frame(maxWidth: 240, alignment: .trailing)
|
||||
.opacity(0)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.transaction { t in t.animation = .none }
|
||||
}
|
||||
.transaction { t in t.animation = .none }
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.accentColor)
|
||||
.frame(maxWidth: 240, alignment: .trailing)
|
||||
.frame(height: 40)
|
||||
#else
|
||||
|
||||
@@ -16,6 +16,16 @@ extension VideoPlayerView {
|
||||
state = true
|
||||
}
|
||||
.onChanged { value in
|
||||
#if os(iOS)
|
||||
// In fullscreen, ignore gestures that start in the top notification center area
|
||||
// to allow system notification center gesture to work
|
||||
if player.playingFullScreen {
|
||||
if value.startLocation.y < Constants.notificationCenterZoneHeight {
|
||||
return
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
guard player.presentingPlayer,
|
||||
!controlsOverlayModel.presenting,
|
||||
dragGestureState,
|
||||
|
||||
@@ -3,9 +3,11 @@ import SwiftUI
|
||||
struct StreamControl: View {
|
||||
#if os(tvOS)
|
||||
var focusedField: FocusState<ControlsOverlay.Field?>.Binding?
|
||||
@Binding var presentingStreamMenu: Bool
|
||||
|
||||
init(focusedField: FocusState<ControlsOverlay.Field?>.Binding?) {
|
||||
init(focusedField: FocusState<ControlsOverlay.Field?>.Binding?, presentingStreamMenu: Binding<Bool>) {
|
||||
self.focusedField = focusedField
|
||||
_presentingStreamMenu = presentingStreamMenu
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -45,16 +47,20 @@ struct StreamControl: View {
|
||||
.fixedSize()
|
||||
#endif
|
||||
#else
|
||||
ControlsOverlayButton(focusedField: focusedField!, field: .stream) {
|
||||
ControlsOverlayButton(
|
||||
focusedField: focusedField!,
|
||||
field: .stream,
|
||||
onSelect: { presentingStreamMenu = true }
|
||||
) {
|
||||
Text(player.streamSelection?.shortQuality ?? "loading")
|
||||
.frame(maxWidth: 320)
|
||||
}
|
||||
.contextMenu {
|
||||
.alert("Stream Quality", isPresented: $presentingStreamMenu) {
|
||||
ForEach(streams) { stream in
|
||||
Button(stream.description) { player.streamSelection = stream }
|
||||
}
|
||||
|
||||
Button("Close", role: .cancel) {}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -79,7 +85,7 @@ struct StreamControl: View {
|
||||
struct StreamControl_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
#if os(tvOS)
|
||||
StreamControl(focusedField: .none)
|
||||
StreamControl(focusedField: .none, presentingStreamMenu: .constant(false))
|
||||
.injectFixtureEnvironmentObjects()
|
||||
#else
|
||||
StreamControl()
|
||||
|
||||
@@ -189,6 +189,7 @@ struct VideoDetails: View {
|
||||
@Environment(\.navigationStyle) private var navigationStyle
|
||||
#if os(iOS)
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
@ObservedObject private var safeAreaModel = SafeAreaModel.shared
|
||||
#endif
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@@ -397,7 +398,11 @@ struct VideoDetails: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.padding(.bottom, fullScreen ? max(60, safeAreaModel.safeArea.bottom + 20) : player.playerSize.height + safeAreaModel.safeArea.bottom + 20)
|
||||
#else
|
||||
.padding(.bottom, 60)
|
||||
#endif
|
||||
}
|
||||
#if os(iOS)
|
||||
.onAppear {
|
||||
|
||||
@@ -190,40 +190,7 @@ struct PlaylistsView: View {
|
||||
#if os(iOS)
|
||||
var playlistsMenu: some View {
|
||||
let title = currentPlaylist?.title ?? "Playlists"
|
||||
return Menu {
|
||||
Menu {
|
||||
selectPlaylistButton
|
||||
} label: {
|
||||
Label(title, systemImage: "list.and.film")
|
||||
}
|
||||
Section {
|
||||
if let currentPlaylist {
|
||||
playButtons
|
||||
|
||||
editPlaylistButton
|
||||
|
||||
if let account = accounts.current {
|
||||
FavoriteButton(item: FavoriteItem(section: .playlist(account.id, currentPlaylist.id)))
|
||||
.id(currentPlaylist.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if accounts.signedIn {
|
||||
newPlaylistButton
|
||||
}
|
||||
|
||||
ListingStyleButtons(listingStyle: $playlistListingStyle)
|
||||
|
||||
Section {
|
||||
HideWatchedButtons()
|
||||
HideShortsButtons()
|
||||
}
|
||||
|
||||
Section {
|
||||
SettingsButtons()
|
||||
}
|
||||
} label: {
|
||||
return ZStack {
|
||||
HStack(spacing: 12) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "list.and.film")
|
||||
@@ -239,9 +206,61 @@ struct PlaylistsView: View {
|
||||
.imageScale(.small)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: 320)
|
||||
.transaction { t in t.animation = nil }
|
||||
|
||||
Menu {
|
||||
Menu {
|
||||
selectPlaylistButton
|
||||
} label: {
|
||||
Label(title, systemImage: "list.and.film")
|
||||
}
|
||||
Section {
|
||||
if let currentPlaylist {
|
||||
playButtons
|
||||
|
||||
editPlaylistButton
|
||||
|
||||
if let account = accounts.current {
|
||||
FavoriteButton(item: FavoriteItem(section: .playlist(account.id, currentPlaylist.id)))
|
||||
.id(currentPlaylist.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if accounts.signedIn {
|
||||
newPlaylistButton
|
||||
}
|
||||
|
||||
ListingStyleButtons(listingStyle: $playlistListingStyle)
|
||||
|
||||
Section {
|
||||
HideWatchedButtons()
|
||||
HideShortsButtons()
|
||||
}
|
||||
|
||||
Section {
|
||||
SettingsButtons()
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "list.and.film")
|
||||
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
}
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Image(systemName: "chevron.down.circle.fill")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
.imageScale(.small)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: 320)
|
||||
.opacity(0)
|
||||
}
|
||||
.disabled(!accounts.signedIn)
|
||||
}
|
||||
.disabled(!accounts.signedIn)
|
||||
.transaction { t in t.animation = nil }
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
@@ -3,27 +3,15 @@ import SwiftUI
|
||||
import SwiftUIIntrospect
|
||||
|
||||
struct FocusableSearchTextField: View {
|
||||
@ObservedObject private var state = SearchModel.shared
|
||||
|
||||
var body: some View {
|
||||
SearchTextField()
|
||||
#if os(macOS)
|
||||
.introspect(.textField, on: .macOS(.v12, .v13, .v14, .v15)) { textField in
|
||||
state.textField = textField
|
||||
SearchModel.shared.textField = textField
|
||||
}
|
||||
.onAppear {
|
||||
DispatchQueue.main.async {
|
||||
state.textField?.becomeFirstResponder()
|
||||
}
|
||||
}
|
||||
#elseif os(iOS)
|
||||
.introspect(.textField, on: .iOS(.v15, .v16, .v17, .v18)) { textField in
|
||||
state.textField = textField
|
||||
}
|
||||
.onChange(of: state.focused) { newValue in
|
||||
if newValue, let textField = state.textField, !textField.isFirstResponder {
|
||||
textField.becomeFirstResponder()
|
||||
textField.selectedTextRange = textField.textRange(from: textField.beginningOfDocument, to: textField.endOfDocument)
|
||||
SearchModel.shared.textField?.becomeFirstResponder()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -55,9 +55,9 @@ struct SearchTextField: View {
|
||||
TextField("Search...", text: $state.queryText) {
|
||||
state.changeQuery { query in
|
||||
query.query = state.queryText
|
||||
navigation.hideKeyboard()
|
||||
}
|
||||
RecentsModel.shared.addQuery(state.queryText)
|
||||
navigation.hideKeyboard()
|
||||
}
|
||||
.disableAutocorrection(true)
|
||||
.textFieldStyle(.plain)
|
||||
|
||||
@@ -49,10 +49,10 @@ struct SearchView: View {
|
||||
.opacity(state.queryText.isEmpty ? 0 : 1)
|
||||
} else {
|
||||
results
|
||||
.backport
|
||||
.scrollDismissesKeyboardInteractively()
|
||||
}
|
||||
}
|
||||
.backport
|
||||
.scrollDismissesKeyboardInteractively()
|
||||
}
|
||||
.environment(\.listingStyle, searchListingStyle)
|
||||
.toolbar {
|
||||
|
||||
@@ -16,10 +16,12 @@ struct AdvancedSettings: View {
|
||||
@Default(.feedCacheSize) private var feedCacheSize
|
||||
@Default(.showPlayNowInBackendContextMenu) private var showPlayNowInBackendContextMenu
|
||||
@Default(.videoLoadingRetryCount) private var videoLoadingRetryCount
|
||||
@Default(.avPlayerAllowsNonStreamableFormats) private var avPlayerAllowsNonStreamableFormats
|
||||
|
||||
@State private var filesToShare = [MPVClient.logFile]
|
||||
@State private var presentingShareSheet = false
|
||||
|
||||
@ObservedObject private var player = PlayerModel.shared
|
||||
private var settings = SettingsModel.shared
|
||||
|
||||
var body: some View {
|
||||
@@ -73,6 +75,10 @@ struct AdvancedSettings: View {
|
||||
videoLoadingRetryCountField
|
||||
}
|
||||
|
||||
Section(header: SettingsHeader(text: "AVPlayer"), footer: avPlayerNonStreamableFormatsFooter) {
|
||||
avPlayerAllowsNonStreamableFormatsToggle
|
||||
}
|
||||
|
||||
Section(header: SettingsHeader(text: "MPV"), footer: mpvFooter) {
|
||||
showMPVPlaybackStatsToggle
|
||||
#if !os(tvOS)
|
||||
@@ -370,6 +376,22 @@ struct AdvancedSettings: View {
|
||||
Text(String(format: "Total size: %@".localized(), BaseCacheModel.shared.totalSizeFormatted))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
var avPlayerAllowsNonStreamableFormatsToggle: some View {
|
||||
Toggle("Enable non-streamable formats (MP4/AVC1)", isOn: $avPlayerAllowsNonStreamableFormats)
|
||||
.onChange(of: avPlayerAllowsNonStreamableFormats) { _ in
|
||||
// Trigger refresh of available streams when setting changes
|
||||
if let video = player.currentVideo {
|
||||
player.loadAvailableStreams(video)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder var avPlayerNonStreamableFormatsFooter: some View {
|
||||
Text("Non-streamable video formats (MP4/AVC1) may take a long time to start playback with AVPlayer. These formats require downloading metadata before playback can begin. Limited to 1080p maximum. For better performance with these formats, use MPV backend instead.")
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
struct AdvancedSettings_Previews: PreviewProvider {
|
||||
|
||||
@@ -185,7 +185,7 @@ struct BrowsingSettings: View {
|
||||
#if os(iOS)
|
||||
Toggle("Show Documents", isOn: $showDocuments)
|
||||
|
||||
if Constants.isIPad {
|
||||
if Constants.isIPhone {
|
||||
Toggle("Lock portrait mode", isOn: $lockPortraitWhenBrowsing)
|
||||
.onChange(of: lockPortraitWhenBrowsing) { lock in
|
||||
if lock {
|
||||
@@ -258,11 +258,13 @@ struct BrowsingSettings: View {
|
||||
private var visibleSectionsSettings: some View {
|
||||
Section(header: SettingsHeader(text: "Sections".localized())) {
|
||||
ForEach(VisibleSection.allCases, id: \.self) { section in
|
||||
MultiselectRow(
|
||||
title: section.title,
|
||||
selected: visibleSections.contains(section)
|
||||
) { value in
|
||||
toggleSection(section, value: value)
|
||||
if section != .trending || FeatureFlags.trendingEnabled {
|
||||
MultiselectRow(
|
||||
title: section.title,
|
||||
selected: visibleSections.contains(section)
|
||||
) { value in
|
||||
toggleSection(section, value: value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -279,7 +281,9 @@ struct BrowsingSettings: View {
|
||||
Spacer()
|
||||
Picker("Startup section", selection: $startupSection) {
|
||||
ForEach(StartupSection.allCases, id: \.rawValue) { section in
|
||||
Text(section.label).tag(section)
|
||||
if section != .trending || FeatureFlags.trendingEnabled {
|
||||
Text(section.label).tag(section)
|
||||
}
|
||||
}
|
||||
}
|
||||
.modifier(SettingsPickerModifier())
|
||||
@@ -287,7 +291,9 @@ struct BrowsingSettings: View {
|
||||
#else
|
||||
Picker("Startup section", selection: $startupSection) {
|
||||
ForEach(StartupSection.allCases, id: \.rawValue) { section in
|
||||
Text(section.label).tag(section)
|
||||
if section != .trending || FeatureFlags.trendingEnabled {
|
||||
Text(section.label).tag(section)
|
||||
}
|
||||
}
|
||||
}
|
||||
.modifier(SettingsPickerModifier())
|
||||
|
||||
@@ -9,6 +9,7 @@ struct InstanceSettings: View {
|
||||
@State private var frontendURL = ""
|
||||
@State private var proxiesVideos = false
|
||||
@State private var invidiousCompanion = false
|
||||
@State private var hideVideosWithoutDuration = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
@@ -97,6 +98,16 @@ struct InstanceSettings: View {
|
||||
.onChange(of: invidiousCompanion) { newValue in
|
||||
InstancesModel.shared.setInvidiousCompanion(instance, newValue)
|
||||
}
|
||||
|
||||
Section(footer: Text("This can be used to hide shorts".localized())) {
|
||||
hideVideosWithoutDurationToggle
|
||||
.onAppear {
|
||||
hideVideosWithoutDuration = instance.hideVideosWithoutDuration
|
||||
}
|
||||
.onChange(of: hideVideosWithoutDuration) { newValue in
|
||||
InstancesModel.shared.setHideVideosWithoutDuration(instance, newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
@@ -116,6 +127,10 @@ struct InstanceSettings: View {
|
||||
Toggle("Invidious companion", isOn: $invidiousCompanion)
|
||||
}
|
||||
|
||||
private var hideVideosWithoutDurationToggle: some View {
|
||||
Toggle("Experimental: Hide videos without duration", isOn: $hideVideosWithoutDuration)
|
||||
}
|
||||
|
||||
private func removeAccount(_ account: Account) {
|
||||
AccountsModel.remove(account)
|
||||
accountsChanged.toggle()
|
||||
|
||||
@@ -360,7 +360,7 @@ struct SettingsView: View {
|
||||
case .locations:
|
||||
return 600
|
||||
case .advanced:
|
||||
return 630
|
||||
return 700
|
||||
case .importExport:
|
||||
return 580
|
||||
case .help:
|
||||
|
||||
@@ -223,6 +223,7 @@ struct FeedView: View {
|
||||
var header: some View {
|
||||
HStack(spacing: 16) {
|
||||
#if os(tvOS)
|
||||
// swiftlint:disable:next deployment_target
|
||||
if #available(tvOS 17.0, *) {
|
||||
Menu {
|
||||
accountsPicker
|
||||
|
||||
@@ -168,22 +168,7 @@ struct TrendingView: View {
|
||||
|
||||
#if os(iOS)
|
||||
var trendingMenu: some View {
|
||||
Menu {
|
||||
countryButton
|
||||
|
||||
categoryButton
|
||||
|
||||
ListingStyleButtons(listingStyle: $trendingListingStyle)
|
||||
|
||||
Section {
|
||||
HideWatchedButtons()
|
||||
HideShortsButtons()
|
||||
}
|
||||
|
||||
Section {
|
||||
SettingsButtons()
|
||||
}
|
||||
} label: {
|
||||
ZStack {
|
||||
HStack(spacing: 12) {
|
||||
Text("\(country.flag) \(country.name)")
|
||||
.font(.headline)
|
||||
@@ -194,6 +179,35 @@ struct TrendingView: View {
|
||||
.imageScale(.small)
|
||||
}
|
||||
.frame(maxWidth: 320)
|
||||
|
||||
Menu {
|
||||
countryButton
|
||||
|
||||
categoryButton
|
||||
|
||||
ListingStyleButtons(listingStyle: $trendingListingStyle)
|
||||
|
||||
Section {
|
||||
HideWatchedButtons()
|
||||
HideShortsButtons()
|
||||
}
|
||||
|
||||
Section {
|
||||
SettingsButtons()
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Text("\(country.flag) \(country.name)")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Image(systemName: "chevron.down.circle.fill")
|
||||
.foregroundColor(.accentColor)
|
||||
.imageScale(.small)
|
||||
}
|
||||
.frame(maxWidth: 320)
|
||||
.opacity(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -76,6 +76,8 @@ struct ThumbnailView: View {
|
||||
}
|
||||
|
||||
var placeholder: some View {
|
||||
Rectangle().fill(Color("PlaceholderColor"))
|
||||
Rectangle()
|
||||
.fill(Color("PlaceholderColor"))
|
||||
.aspectRatio(Constants.aspectRatio16x9, contentMode: .fill)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,14 +432,11 @@ struct VideoCell: View {
|
||||
}
|
||||
|
||||
private var thumbnailImage: some View {
|
||||
Group {
|
||||
VideoCellThumbnail(video: video)
|
||||
|
||||
#if os(tvOS)
|
||||
.frame(minHeight: 320)
|
||||
#endif
|
||||
}
|
||||
.mask(RoundedRectangle(cornerRadius: thumbnailRoundingCornerRadius))
|
||||
VideoCellThumbnail(video: video)
|
||||
#if os(tvOS)
|
||||
.frame(minHeight: 320)
|
||||
#endif
|
||||
.mask(RoundedRectangle(cornerRadius: thumbnailRoundingCornerRadius))
|
||||
}
|
||||
|
||||
private var time: String? {
|
||||
@@ -477,11 +474,10 @@ struct VideoCellThumbnail: View {
|
||||
private var thumbnails: ThumbnailsModel { .shared }
|
||||
|
||||
var body: some View {
|
||||
let (url, quality) = thumbnails.best(video)
|
||||
let aspectRatio = (quality == .default || quality == .high) ? Constants.aspectRatio4x3 : Constants.aspectRatio16x9
|
||||
let (url, _) = thumbnails.best(video)
|
||||
|
||||
ThumbnailView(url: url)
|
||||
.aspectRatio(aspectRatio, contentMode: .fill)
|
||||
.aspectRatio(Constants.aspectRatio16x9, contentMode: .fill)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ struct ContentItemView: View {
|
||||
return false
|
||||
}
|
||||
|
||||
guard hideShorts, item.contentType == .video, let video = item.video else {
|
||||
guard FeatureFlags.hideShortsEnabled, hideShorts, item.contentType == .video, let video = item.video else {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -5,22 +5,24 @@ struct HideShortsButtons: View {
|
||||
@Default(.hideShorts) private var hideShorts
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
hideShorts.toggle()
|
||||
} label: {
|
||||
Group {
|
||||
if hideShorts {
|
||||
Label("Short videos: hidden", systemImage: "bolt.slash.fill")
|
||||
.help("Short videos: hidden")
|
||||
} else {
|
||||
Label("Short videos: visible", systemImage: "bolt.fill")
|
||||
.help("Short videos: visible")
|
||||
if FeatureFlags.hideShortsEnabled {
|
||||
Button {
|
||||
hideShorts.toggle()
|
||||
} label: {
|
||||
Group {
|
||||
if hideShorts {
|
||||
Label("Short videos: hidden", systemImage: "bolt.slash.fill")
|
||||
.help("Short videos: hidden")
|
||||
} else {
|
||||
Label("Short videos: visible", systemImage: "bolt.fill")
|
||||
.help("Short videos: visible")
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
.font(.caption)
|
||||
.imageScale(.small)
|
||||
#endif
|
||||
}
|
||||
#if os(tvOS)
|
||||
.font(.caption)
|
||||
.imageScale(.small)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,10 +144,15 @@ struct OpenVideosView: View {
|
||||
Spacer()
|
||||
#endif
|
||||
#if os(iOS)
|
||||
Menu {
|
||||
playbackModePicker
|
||||
} label: {
|
||||
ZStack {
|
||||
Text(playbackMode.description)
|
||||
|
||||
Menu {
|
||||
playbackModePicker
|
||||
} label: {
|
||||
Text(playbackMode.description)
|
||||
.opacity(0)
|
||||
}
|
||||
}
|
||||
#else
|
||||
playbackModePicker
|
||||
|
||||
@@ -90,21 +90,10 @@ struct PopularView: View {
|
||||
|
||||
#if os(iOS)
|
||||
private var popularMenu: some View {
|
||||
Menu {
|
||||
ListingStyleButtons(listingStyle: $popularListingStyle)
|
||||
|
||||
Section {
|
||||
HideWatchedButtons()
|
||||
HideShortsButtons()
|
||||
}
|
||||
|
||||
Section {
|
||||
SettingsButtons()
|
||||
}
|
||||
} label: {
|
||||
ZStack {
|
||||
HStack(spacing: 12) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "arrow.up.right.circle.fill")
|
||||
Image(systemName: "chart.bar.fill")
|
||||
.foregroundColor(.primary)
|
||||
.imageScale(.small)
|
||||
|
||||
@@ -117,8 +106,38 @@ struct PopularView: View {
|
||||
.foregroundColor(.accentColor)
|
||||
.imageScale(.small)
|
||||
}
|
||||
.transaction { t in t.animation = nil }
|
||||
|
||||
Menu {
|
||||
ListingStyleButtons(listingStyle: $popularListingStyle)
|
||||
|
||||
Section {
|
||||
HideWatchedButtons()
|
||||
HideShortsButtons()
|
||||
}
|
||||
|
||||
Section {
|
||||
SettingsButtons()
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "chart.bar.fill")
|
||||
.foregroundColor(.primary)
|
||||
.imageScale(.small)
|
||||
|
||||
Text("Popular")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
|
||||
Image(systemName: "chevron.down.circle.fill")
|
||||
.foregroundColor(.accentColor)
|
||||
.imageScale(.small)
|
||||
}
|
||||
.opacity(0)
|
||||
}
|
||||
}
|
||||
.transaction { t in t.animation = nil }
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
@@ -33,23 +33,21 @@ struct VideoContextMenuView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Conditional overlay to block taps on underlying views
|
||||
if isOverlayVisible {
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
#if !os(tvOS)
|
||||
// This is not available on tvOS < 16 so we leave out.
|
||||
// TODO: remove #if when setting the minimum deployment target to >= 16
|
||||
.onTapGesture {
|
||||
// Dismiss overlay without triggering other interactions
|
||||
isOverlayVisible = false
|
||||
}
|
||||
#endif
|
||||
.ignoresSafeArea() // Ensure overlay covers the entire screen
|
||||
.accessibilityLabel("Dismiss context menu")
|
||||
.accessibilityHint("Tap to close the context")
|
||||
.accessibilityAddTraits(.isButton)
|
||||
}
|
||||
#if os(iOS)
|
||||
// Conditional overlay to block taps on underlying views
|
||||
if isOverlayVisible {
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
// Dismiss overlay without triggering other interactions
|
||||
isOverlayVisible = false
|
||||
}
|
||||
.ignoresSafeArea() // Ensure overlay covers the entire screen
|
||||
.accessibilityLabel("Dismiss context menu")
|
||||
.accessibilityHint("Tap to close the context")
|
||||
.accessibilityAddTraits(.isButton)
|
||||
}
|
||||
#endif
|
||||
|
||||
if video.videoID != Video.fixtureID {
|
||||
contextMenu
|
||||
@@ -156,7 +154,9 @@ struct VideoContextMenuView: View {
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
Button("Cancel", role: .cancel) {}
|
||||
if #unavailable(tvOS 18.0) {
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -162,6 +162,23 @@ struct YatteeApp: App {
|
||||
|
||||
SDWebImageManager.defaultImageCache = PINCache(name: "stream.yattee.app")
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: .accountConfigurationComplete,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { _ in
|
||||
let startupSection = Defaults[.startupSection]
|
||||
var section: TabSelection? = startupSection.tabSelection
|
||||
|
||||
#if os(macOS)
|
||||
if section == .playlists {
|
||||
section = .search
|
||||
}
|
||||
#endif
|
||||
|
||||
NavigationModel.shared.tabSelection = section ?? .search
|
||||
}
|
||||
|
||||
if !Defaults[.lastAccountIsPublic] {
|
||||
AccountsModel.shared.configureAccount()
|
||||
}
|
||||
@@ -180,17 +197,6 @@ struct YatteeApp: App {
|
||||
}
|
||||
}
|
||||
|
||||
let startupSection = Defaults[.startupSection]
|
||||
var section: TabSelection? = startupSection.tabSelection
|
||||
|
||||
#if os(macOS)
|
||||
if section == .playlists {
|
||||
section = .search
|
||||
}
|
||||
#endif
|
||||
|
||||
NavigationModel.shared.tabSelection = section ?? .search
|
||||
|
||||
DispatchQueue.main.async {
|
||||
playlists.load()
|
||||
}
|
||||
@@ -231,6 +237,10 @@ struct YatteeApp: App {
|
||||
self.migrateQualityProfiles()
|
||||
}
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
self.cleanupDisabledFeatures()
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
self.migrateRotateToLandscapeOnEnterFullScreen()
|
||||
@@ -285,6 +295,34 @@ struct YatteeApp: App {
|
||||
}
|
||||
#endif
|
||||
|
||||
func cleanupDisabledFeatures() {
|
||||
// Remove trending from visible sections if feature flag is disabled
|
||||
if !FeatureFlags.trendingEnabled {
|
||||
var visibleSections = Defaults[.visibleSections]
|
||||
if visibleSections.contains(.trending) {
|
||||
visibleSections.remove(.trending)
|
||||
Defaults[.visibleSections] = visibleSections
|
||||
}
|
||||
|
||||
// Reset startup section if set to trending
|
||||
if Defaults[.startupSection] == .trending {
|
||||
Defaults[.startupSection] = .home
|
||||
}
|
||||
|
||||
// Remove trending favorites
|
||||
let trendingFavorites = favorites.all.filter { item in
|
||||
if case .trending = item.section {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
for favorite in trendingFavorites {
|
||||
favorites.remove(favorite)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var navigationStyle: NavigationStyle {
|
||||
#if os(iOS)
|
||||
return horizontalSizeClass == .compact ? .tab : .sidebar
|
||||
|
||||
@@ -43,6 +43,9 @@
|
||||
/* End PBXAggregateTarget section */
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
1B81344D4D2A0B0363850A9E /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D886FD1371688A42060DF82 /* FeatureFlags.swift */; };
|
||||
2446210B2B03C320154634A5 /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D886FD1371688A42060DF82 /* FeatureFlags.swift */; };
|
||||
3528A0FEB2B02A52B715041C /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D886FD1371688A42060DF82 /* FeatureFlags.swift */; };
|
||||
3700155B271B0D4D0049C794 /* PipedAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3700155A271B0D4D0049C794 /* PipedAPI.swift */; };
|
||||
3700155C271B0D4D0049C794 /* PipedAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3700155A271B0D4D0049C794 /* PipedAPI.swift */; };
|
||||
3700155D271B0D4D0049C794 /* PipedAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3700155A271B0D4D0049C794 /* PipedAPI.swift */; };
|
||||
@@ -207,6 +210,9 @@
|
||||
37319F0627103F94004ECCD0 /* PlayerQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37319F0427103F94004ECCD0 /* PlayerQueue.swift */; };
|
||||
37319F0727103F94004ECCD0 /* PlayerQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37319F0427103F94004ECCD0 /* PlayerQueue.swift */; };
|
||||
3732BFD028B83763009F3F4D /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 3732BFCF28B83763009F3F4D /* KeychainAccess */; };
|
||||
3736882B2ECE7947006B1D1F /* Notification+Names.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3736882A2ECE7947006B1D1F /* Notification+Names.swift */; };
|
||||
3736882C2ECE7947006B1D1F /* Notification+Names.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3736882A2ECE7947006B1D1F /* Notification+Names.swift */; };
|
||||
3736882D2ECE7947006B1D1F /* Notification+Names.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3736882A2ECE7947006B1D1F /* Notification+Names.swift */; };
|
||||
3738535429451DC800D2D0CB /* BookmarksCacheModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3738535329451DC800D2D0CB /* BookmarksCacheModel.swift */; };
|
||||
3738535529451DC800D2D0CB /* BookmarksCacheModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3738535329451DC800D2D0CB /* BookmarksCacheModel.swift */; };
|
||||
3738535629451DC800D2D0CB /* BookmarksCacheModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3738535329451DC800D2D0CB /* BookmarksCacheModel.swift */; };
|
||||
@@ -1071,6 +1077,8 @@
|
||||
37FFC440272734C3009FFD26 /* Throttle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FFC43F272734C3009FFD26 /* Throttle.swift */; };
|
||||
37FFC441272734C3009FFD26 /* Throttle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FFC43F272734C3009FFD26 /* Throttle.swift */; };
|
||||
37FFC442272734C3009FFD26 /* Throttle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FFC43F272734C3009FFD26 /* Throttle.swift */; };
|
||||
4EDC5582D5232B58E0E6A3CD /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D886FD1371688A42060DF82 /* FeatureFlags.swift */; };
|
||||
C61471C67790128B7638173B /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D886FD1371688A42060DF82 /* FeatureFlags.swift */; };
|
||||
E24DC6582BFA124100BF6187 /* UserAgentManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24DC6572BFA124100BF6187 /* UserAgentManager.swift */; };
|
||||
E24DC6592BFA124100BF6187 /* UserAgentManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24DC6572BFA124100BF6187 /* UserAgentManager.swift */; };
|
||||
E24DC65A2BFA124100BF6187 /* UserAgentManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24DC6572BFA124100BF6187 /* UserAgentManager.swift */; };
|
||||
@@ -1086,6 +1094,9 @@
|
||||
E27568B92BFAAC2000BDF0AF /* LanguageCodes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27568B82BFAAC2000BDF0AF /* LanguageCodes.swift */; };
|
||||
E27568BA2BFAAC2000BDF0AF /* LanguageCodes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27568B82BFAAC2000BDF0AF /* LanguageCodes.swift */; };
|
||||
E27568BB2BFAAC2000BDF0AF /* LanguageCodes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27568B82BFAAC2000BDF0AF /* LanguageCodes.swift */; };
|
||||
E69D11698A85867A28CD6A5A /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D886FD1371688A42060DF82 /* FeatureFlags.swift */; };
|
||||
F18DFC08B722DE4D5ACB791A /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D886FD1371688A42060DF82 /* FeatureFlags.swift */; };
|
||||
F3BFD18BABAA233ADA094AC6 /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D886FD1371688A42060DF82 /* FeatureFlags.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -1215,6 +1226,7 @@
|
||||
373197D82732015300EF734F /* RelatedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelatedView.swift; sourceTree = "<group>"; };
|
||||
37319F0427103F94004ECCD0 /* PlayerQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerQueue.swift; sourceTree = "<group>"; };
|
||||
37367E582B8F63C200436163 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||
3736882A2ECE7947006B1D1F /* Notification+Names.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+Names.swift"; sourceTree = "<group>"; };
|
||||
3738535329451DC800D2D0CB /* BookmarksCacheModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksCacheModel.swift; sourceTree = "<group>"; };
|
||||
373C8FE3275B955100CB5936 /* CommentsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsPage.swift; sourceTree = "<group>"; };
|
||||
373CFACA26966264003CB2C6 /* SearchQuery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchQuery.swift; sourceTree = "<group>"; };
|
||||
@@ -1558,6 +1570,7 @@
|
||||
3DA101AD287C30F50027D920 /* DEVELOPMENT_TEAM.template.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = DEVELOPMENT_TEAM.template.xcconfig; sourceTree = "<group>"; };
|
||||
3DA101AE287C30F50027D920 /* DEVELOPMENT_TEAM.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = DEVELOPMENT_TEAM.xcconfig; sourceTree = "<group>"; };
|
||||
3DA101AF287C30F50027D920 /* Shared.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Shared.xcconfig; sourceTree = "<group>"; };
|
||||
5D886FD1371688A42060DF82 /* FeatureFlags.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeatureFlags.swift; sourceTree = "<group>"; };
|
||||
E24DC6572BFA124100BF6187 /* UserAgentManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAgentManager.swift; sourceTree = "<group>"; };
|
||||
E25028AF2BF790F5002CB9FC /* HTTPStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPStatus.swift; sourceTree = "<group>"; };
|
||||
E258F3892BF61BD2005B8C28 /* URLTester.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLTester.swift; sourceTree = "<group>"; };
|
||||
@@ -2240,6 +2253,7 @@
|
||||
37C7A9022679058300E721B4 /* Extensions */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3736882A2ECE7947006B1D1F /* Notification+Names.swift */,
|
||||
379775922689365600DD52A8 /* Array+Next.swift */,
|
||||
37DCD3162A191A180059A470 /* AVPlayerViewController+FullScreen.swift */,
|
||||
376578842685429C00D4EA09 /* CaseIterable+Next.swift */,
|
||||
@@ -2325,6 +2339,7 @@
|
||||
37D4B0C22671614700C925CA /* YatteeApp.swift */,
|
||||
37D4B0C42671614800C925CA /* Assets.xcassets */,
|
||||
37BD07C42698ADEE003EBB87 /* Yattee.entitlements */,
|
||||
5D886FD1371688A42060DF82 /* FeatureFlags.swift */,
|
||||
);
|
||||
path = Shared;
|
||||
sourceTree = "<group>";
|
||||
@@ -3064,6 +3079,7 @@
|
||||
3762C46D2BF66CDD008E50B8 /* EnvironmentValues.swift in Sources */,
|
||||
37095E82291DC85400301883 /* ShareViewController.swift in Sources */,
|
||||
3762C47A2BF66F04008E50B8 /* Strings.swift in Sources */,
|
||||
C61471C67790128B7638173B /* FeatureFlags.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -3076,6 +3092,7 @@
|
||||
37C0C0FF28665EAC007F6F78 /* VideosApp.swift in Sources */,
|
||||
378FFBC92866018A009E3FBE /* URLParserTests.swift in Sources */,
|
||||
371B88F82A1A310100D57683 /* String+Format.swift in Sources */,
|
||||
3528A0FEB2B02A52B715041C /* FeatureFlags.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -3297,6 +3314,7 @@
|
||||
37BA794F26DC3E0E002A0235 /* Int+Format.swift in Sources */,
|
||||
378E9C3C2945565500B2D696 /* SubscriptionsView.swift in Sources */,
|
||||
37D6025928C17375009E8D98 /* PlaybackStatsView.swift in Sources */,
|
||||
3736882D2ECE7947006B1D1F /* Notification+Names.swift in Sources */,
|
||||
37F49BA326CAA59B00304AC0 /* Playlist+Fixtures.swift in Sources */,
|
||||
374C053B2724614F009BDDBE /* PlayerTVMenu.swift in Sources */,
|
||||
37A9965A26D6F8CA006E3224 /* HorizontalCells.swift in Sources */,
|
||||
@@ -3403,6 +3421,7 @@
|
||||
37B795902771DAE0001CF27B /* OpenURLHandler.swift in Sources */,
|
||||
37732FF02703A26300F04329 /* AccountValidationStatus.swift in Sources */,
|
||||
37A7D72F2B681011009CB1ED /* OtherDataSettingsGroupImporter.swift in Sources */,
|
||||
2446210B2B03C320154634A5 /* FeatureFlags.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -3644,6 +3663,7 @@
|
||||
37A81BFA294BD1440081D322 /* WatchView.swift in Sources */,
|
||||
3700155C271B0D4D0049C794 /* PipedAPI.swift in Sources */,
|
||||
376BE50C27349108009AD608 /* BrowsingSettings.swift in Sources */,
|
||||
3736882C2ECE7947006B1D1F /* Notification+Names.swift in Sources */,
|
||||
3710A55629488C7D006F8025 /* PlaceholderListItem.swift in Sources */,
|
||||
37EBD8CB27AF26C200F1C24B /* MPVBackend.swift in Sources */,
|
||||
37D4B19826717E1500C925CA /* Video.swift in Sources */,
|
||||
@@ -3710,6 +3730,7 @@
|
||||
3769C02F2779F18600DDB3EA /* PlaceholderProgressView.swift in Sources */,
|
||||
37BA794426DBA973002A0235 /* PlaylistsModel.swift in Sources */,
|
||||
37A362BF29537AAA00BDF328 /* PlaybackSettings.swift in Sources */,
|
||||
4EDC5582D5232B58E0E6A3CD /* FeatureFlags.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -3718,6 +3739,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
37D4B0D92671614900C925CA /* Tests_iOS.swift in Sources */,
|
||||
F3BFD18BABAA233ADA094AC6 /* FeatureFlags.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -3771,6 +3793,7 @@
|
||||
3766AFD2273DA97D00686348 /* Int+FormatTests.swift in Sources */,
|
||||
3774124F27387D2300423605 /* SubscribedChannelsModel.swift in Sources */,
|
||||
3774126127387D2D00423605 /* AccountsModel.swift in Sources */,
|
||||
F18DFC08B722DE4D5ACB791A /* FeatureFlags.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -3942,6 +3965,7 @@
|
||||
37C7A1D7267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */,
|
||||
3756C2A82861131100E4B059 /* NetworkState.swift in Sources */,
|
||||
376578932685490700D4EA09 /* PlaylistsView.swift in Sources */,
|
||||
3736882B2ECE7947006B1D1F /* Notification+Names.swift in Sources */,
|
||||
37E75CCD2B6AEB01003A6237 /* RecentlyOpenedExporter.swift in Sources */,
|
||||
377FF891291A99580028EB0B /* HistoryView.swift in Sources */,
|
||||
37CC3F47270CE30600608308 /* PlayerQueueItem.swift in Sources */,
|
||||
@@ -4064,6 +4088,7 @@
|
||||
3797758D2689345500DD52A8 /* Store.swift in Sources */,
|
||||
37484C2F26FC844700287258 /* InstanceSettings.swift in Sources */,
|
||||
37A7D7312B681011009CB1ED /* OtherDataSettingsGroupImporter.swift in Sources */,
|
||||
1B81344D4D2A0B0363850A9E /* FeatureFlags.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -4072,6 +4097,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
37D4B176267164B000C925CA /* YatteeUITests.swift in Sources */,
|
||||
E69D11698A85867A28CD6A5A /* FeatureFlags.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -4142,7 +4168,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "Open in Yattee/Open in Yattee.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 209;
|
||||
CURRENT_PROJECT_VERSION = 241;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "Open in Yattee/Info.plist";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Open in Yattee";
|
||||
@@ -4173,7 +4199,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 209;
|
||||
CURRENT_PROJECT_VERSION = 241;
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 78Z5H3M6RJ;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "Open in Yattee/Info.plist";
|
||||
@@ -4204,7 +4230,7 @@
|
||||
buildSettings = {
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 209;
|
||||
CURRENT_PROJECT_VERSION = 241;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
@@ -4224,7 +4250,7 @@
|
||||
buildSettings = {
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 209;
|
||||
CURRENT_PROJECT_VERSION = 241;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
@@ -4388,7 +4414,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "iOS/Yattee (iOS).entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 209;
|
||||
CURRENT_PROJECT_VERSION = 241;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
@@ -4442,7 +4468,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 209;
|
||||
CURRENT_PROJECT_VERSION = 241;
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 78Z5H3M6RJ;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = "GLES_SILENCE_DEPRECATION=1";
|
||||
@@ -4496,7 +4522,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 209;
|
||||
CURRENT_PROJECT_VERSION = 241;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
@@ -4535,7 +4561,7 @@
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "3rd Party Mac Developer Application";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 209;
|
||||
CURRENT_PROJECT_VERSION = 241;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
"DEVELOPMENT_TEAM[sdk=macosx*]" = 78Z5H3M6RJ;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
@@ -4570,7 +4596,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 209;
|
||||
CURRENT_PROJECT_VERSION = 241;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -4593,7 +4619,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 209;
|
||||
CURRENT_PROJECT_VERSION = 241;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -4618,7 +4644,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 209;
|
||||
CURRENT_PROJECT_VERSION = 241;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -4642,7 +4668,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 209;
|
||||
CURRENT_PROJECT_VERSION = 241;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -4668,7 +4694,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 209;
|
||||
CURRENT_PROJECT_VERSION = 241;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -4708,7 +4734,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
"CODE_SIGN_IDENTITY[sdk=appletvos*]" = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 209;
|
||||
CURRENT_PROJECT_VERSION = 241;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
"DEVELOPMENT_TEAM[sdk=appletvos*]" = 78Z5H3M6RJ;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -4748,7 +4774,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 209;
|
||||
CURRENT_PROJECT_VERSION = 241;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
@@ -4771,7 +4797,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 209;
|
||||
CURRENT_PROJECT_VERSION = 241;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
@@ -5034,7 +5060,7 @@
|
||||
repositoryURL = "https://github.com/siteline/SwiftUI-Introspect.git";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 1.3.0;
|
||||
minimumVersion = 26.0.0;
|
||||
};
|
||||
};
|
||||
37CF8B8228535E4F00B71E37 /* XCRemoteSwiftPackageReference "SDWebImage" */ = {
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
"location" : "https://github.com/mpvkit/MPVKit.git",
|
||||
"state" : {
|
||||
"branch" : "main",
|
||||
"revision" : "360b5002bf607a94f24ec8977db94bd9811d5357"
|
||||
"revision" : "fef0f54bfd7e37e0547e057880b28992540ddbcc"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -168,8 +168,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/siteline/SwiftUI-Introspect.git",
|
||||
"state" : {
|
||||
"revision" : "807f73ce09a9b9723f12385e592b4e0aaebd3336",
|
||||
"version" : "1.3.0"
|
||||
"revision" : "a08b87f96b41055577721a6e397562b21ad52454",
|
||||
"version" : "26.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -22,13 +22,6 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
OrientationTracker.shared.startDeviceOrientationTracking()
|
||||
OrientationModel.shared.startOrientationUpdates()
|
||||
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
} catch {
|
||||
logger.error("Failed to set audio session category: \(error)")
|
||||
}
|
||||
|
||||
UIApplication.shared.beginReceivingRemoteControlEvents()
|
||||
#endif
|
||||
|
||||
|
||||
@@ -6,6 +6,12 @@ enum Orientation {
|
||||
static var logger = Logger(label: "stream.yattee.orientation")
|
||||
|
||||
static func lockOrientation(_ orientation: UIInterfaceOrientationMask) {
|
||||
// Orientation locking is only for iPhone, not iPad
|
||||
guard Constants.isIPhone else {
|
||||
logger.info("skipping orientation lock on iPad")
|
||||
return
|
||||
}
|
||||
|
||||
if let delegate = AppDelegate.instance {
|
||||
delegate.orientationLock = orientation
|
||||
|
||||
@@ -18,6 +24,12 @@ enum Orientation {
|
||||
}
|
||||
|
||||
static func lockOrientation(_ orientation: UIInterfaceOrientationMask, andRotateTo rotateOrientation: UIInterfaceOrientation? = nil) {
|
||||
// Orientation locking and rotation is only for iPhone, not iPad
|
||||
guard Constants.isIPhone else {
|
||||
logger.info("skipping orientation lock and rotation on iPad")
|
||||
return
|
||||
}
|
||||
|
||||
lockOrientation(orientation)
|
||||
|
||||
guard let rotateOrientation else {
|
||||
|
||||
@@ -12,6 +12,7 @@ struct InstancesSettings: View {
|
||||
@State private var frontendURL = ""
|
||||
@State private var proxiesVideos = false
|
||||
@State private var invidiousCompanion = false
|
||||
@State private var hideVideosWithoutDuration = false
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@ObservedObject private var accounts = AccountsModel.shared
|
||||
@@ -109,6 +110,18 @@ struct InstancesSettings: View {
|
||||
.onChange(of: invidiousCompanion) { newValue in
|
||||
InstancesModel.shared.setInvidiousCompanion(selectedInstance, newValue)
|
||||
}
|
||||
|
||||
hideVideosWithoutDurationToggle
|
||||
.onAppear {
|
||||
hideVideosWithoutDuration = selectedInstance.hideVideosWithoutDuration
|
||||
}
|
||||
.onChange(of: hideVideosWithoutDuration) { newValue in
|
||||
InstancesModel.shared.setHideVideosWithoutDuration(selectedInstance, newValue)
|
||||
}
|
||||
|
||||
Text("This can be used to hide shorts")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if selectedInstance != nil, !selectedInstance.app.supportsAccounts {
|
||||
@@ -201,6 +214,10 @@ struct InstancesSettings: View {
|
||||
private var invidiousCompanionToggle: some View {
|
||||
Toggle("Invidious companion", isOn: $invidiousCompanion)
|
||||
}
|
||||
|
||||
private var hideVideosWithoutDurationToggle: some View {
|
||||
Toggle("Experimental: Hide videos without duration", isOn: $hideVideosWithoutDuration)
|
||||
}
|
||||
}
|
||||
|
||||
struct InstancesSettingsView_Previews: PreviewProvider {
|
||||
|
||||
@@ -4,25 +4,52 @@ struct ControlsOverlayButton<LabelView: View>: View {
|
||||
var focusedField: FocusState<ControlsOverlay.Field?>.Binding
|
||||
var field: ControlsOverlay.Field
|
||||
let label: LabelView
|
||||
var onSelect: (() -> Void)?
|
||||
|
||||
init(
|
||||
focusedField: FocusState<ControlsOverlay.Field?>.Binding,
|
||||
field: ControlsOverlay.Field,
|
||||
onSelect: (() -> Void)? = nil,
|
||||
@ViewBuilder label: @escaping () -> LabelView
|
||||
) {
|
||||
self.focusedField = focusedField
|
||||
self.field = field
|
||||
self.onSelect = onSelect
|
||||
self.label = label()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
label
|
||||
.padding()
|
||||
.frame(width: 400)
|
||||
.focusable()
|
||||
let isFocused = focusedField.wrappedValue == field
|
||||
|
||||
if let onSelect {
|
||||
Button(action: onSelect) {
|
||||
label
|
||||
.foregroundColor(isFocused ? .black : .white)
|
||||
.padding()
|
||||
.frame(width: 400)
|
||||
}
|
||||
.buttonStyle(TVButtonStyle(isFocused: isFocused))
|
||||
.focused(focusedField, equals: field)
|
||||
.background(focusedField.wrappedValue == field ? Color.white : Color.secondary)
|
||||
.foregroundColor(focusedField.wrappedValue == field ? Color.black : Color.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
} else {
|
||||
label
|
||||
.foregroundColor(isFocused ? .black : .white)
|
||||
.padding()
|
||||
.frame(width: 400)
|
||||
.focusable()
|
||||
.focused(focusedField, equals: field)
|
||||
.background(isFocused ? Color.white : Color.gray.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TVButtonStyle: ButtonStyle {
|
||||
let isFocused: Bool
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.background(isFocused ? Color.white : Color.gray.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
.scaleEffect(configuration.isPressed ? 0.95 : 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ struct TVNavigationView: View {
|
||||
.tag(TabSelection.popular)
|
||||
}
|
||||
|
||||
if visibleSections.contains(.trending) {
|
||||
if FeatureFlags.trendingEnabled && visibleSections.contains(.trending) {
|
||||
LazyView(TrendingView())
|
||||
.tabItem { Text("Trending") }
|
||||
.tag(TabSelection.trending)
|
||||
|
||||
Reference in New Issue
Block a user