mirror of
https://github.com/yattee/yattee.git
synced 2025-12-13 03:28:14 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b38bd3f444 | ||
|
|
d8e079ac90 | ||
|
|
75812906c1 | ||
|
|
82570b7f34 | ||
|
|
e43eddc8e7 | ||
|
|
c5137a8af8 | ||
|
|
9177abb0ec | ||
|
|
65e86d30ec | ||
|
|
0c4609bcf1 | ||
|
|
36190e62f5 | ||
|
|
e6e69eb757 | ||
|
|
41a33634ee | ||
|
|
aa703f6531 | ||
|
|
db80b6adbb | ||
|
|
6591d503d4 | ||
|
|
1eba731283 | ||
|
|
0913c6d73c | ||
|
|
997de6468d | ||
|
|
1397a2fee6 | ||
|
|
660891f2a5 | ||
|
|
2e27dcd2cf | ||
|
|
5f53e53c7a | ||
|
|
73295e726a | ||
|
|
b0dfd2f9d2 |
32
CHANGELOG.md
32
CHANGELOG.md
@@ -1,3 +1,35 @@
|
|||||||
|
## Build 211
|
||||||
|
|
||||||
|
## 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
|
## Build 210
|
||||||
|
|
||||||
## What's Changed
|
## What's Changed
|
||||||
|
|||||||
17
Gemfile.lock
17
Gemfile.lock
@@ -2,13 +2,14 @@ GEM
|
|||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
CFPropertyList (3.0.8)
|
CFPropertyList (3.0.8)
|
||||||
|
abbrev (0.1.2)
|
||||||
addressable (2.8.7)
|
addressable (2.8.7)
|
||||||
public_suffix (>= 2.0.2, < 7.0)
|
public_suffix (>= 2.0.2, < 7.0)
|
||||||
artifactory (3.0.17)
|
artifactory (3.0.17)
|
||||||
atomos (0.1.3)
|
atomos (0.1.3)
|
||||||
aws-eventstream (1.4.0)
|
aws-eventstream (1.4.0)
|
||||||
aws-partitions (1.1182.0)
|
aws-partitions (1.1187.0)
|
||||||
aws-sdk-core (3.237.0)
|
aws-sdk-core (3.239.1)
|
||||||
aws-eventstream (~> 1, >= 1.3.0)
|
aws-eventstream (~> 1, >= 1.3.0)
|
||||||
aws-partitions (~> 1, >= 1.992.0)
|
aws-partitions (~> 1, >= 1.992.0)
|
||||||
aws-sigv4 (~> 1.9)
|
aws-sigv4 (~> 1.9)
|
||||||
@@ -16,10 +17,10 @@ GEM
|
|||||||
bigdecimal
|
bigdecimal
|
||||||
jmespath (~> 1, >= 1.6.1)
|
jmespath (~> 1, >= 1.6.1)
|
||||||
logger
|
logger
|
||||||
aws-sdk-kms (1.117.0)
|
aws-sdk-kms (1.118.0)
|
||||||
aws-sdk-core (~> 3, >= 3.234.0)
|
aws-sdk-core (~> 3, >= 3.239.1)
|
||||||
aws-sigv4 (~> 1.5)
|
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-core (~> 3, >= 3.234.0)
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
@@ -33,6 +34,7 @@ GEM
|
|||||||
colored2 (3.1.2)
|
colored2 (3.1.2)
|
||||||
commander (4.6.0)
|
commander (4.6.0)
|
||||||
highline (~> 2.0.0)
|
highline (~> 2.0.0)
|
||||||
|
csv (3.3.5)
|
||||||
declarative (0.0.20)
|
declarative (0.0.20)
|
||||||
digest-crc (0.7.0)
|
digest-crc (0.7.0)
|
||||||
rake (>= 12.0.0, < 14.0.0)
|
rake (>= 12.0.0, < 14.0.0)
|
||||||
@@ -69,8 +71,9 @@ GEM
|
|||||||
faraday_middleware (1.2.1)
|
faraday_middleware (1.2.1)
|
||||||
faraday (~> 1.0)
|
faraday (~> 1.0)
|
||||||
fastimage (2.4.0)
|
fastimage (2.4.0)
|
||||||
fastlane (2.228.0)
|
fastlane (2.229.0)
|
||||||
CFPropertyList (>= 2.3, < 4.0.0)
|
CFPropertyList (>= 2.3, < 4.0.0)
|
||||||
|
abbrev (~> 0.1.2)
|
||||||
addressable (>= 2.8, < 3.0.0)
|
addressable (>= 2.8, < 3.0.0)
|
||||||
artifactory (~> 3.0)
|
artifactory (~> 3.0)
|
||||||
aws-sdk-s3 (~> 1.0)
|
aws-sdk-s3 (~> 1.0)
|
||||||
@@ -78,6 +81,7 @@ GEM
|
|||||||
bundler (>= 1.12.0, < 3.0.0)
|
bundler (>= 1.12.0, < 3.0.0)
|
||||||
colored (~> 1.2)
|
colored (~> 1.2)
|
||||||
commander (~> 4.6)
|
commander (~> 4.6)
|
||||||
|
csv (~> 3.3)
|
||||||
dotenv (>= 2.1.1, < 3.0.0)
|
dotenv (>= 2.1.1, < 3.0.0)
|
||||||
emoji_regex (>= 0.1, < 4.0)
|
emoji_regex (>= 0.1, < 4.0)
|
||||||
excon (>= 0.71.0, < 1.0.0)
|
excon (>= 0.71.0, < 1.0.0)
|
||||||
@@ -97,6 +101,7 @@ GEM
|
|||||||
jwt (>= 2.1.0, < 3)
|
jwt (>= 2.1.0, < 3)
|
||||||
mini_magick (>= 4.9.4, < 5.0.0)
|
mini_magick (>= 4.9.4, < 5.0.0)
|
||||||
multipart-post (>= 2.0.0, < 3.0.0)
|
multipart-post (>= 2.0.0, < 3.0.0)
|
||||||
|
mutex_m (~> 0.3.0)
|
||||||
naturally (~> 2.2)
|
naturally (~> 2.2)
|
||||||
optparse (>= 0.1.1, < 1.0.0)
|
optparse (>= 0.1.1, < 1.0.0)
|
||||||
plist (>= 3.1.0, < 4.0.0)
|
plist (>= 3.1.0, < 4.0.0)
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
|
|||||||
var frontendURL: String?
|
var frontendURL: String?
|
||||||
var proxiesVideos: Bool
|
var proxiesVideos: Bool
|
||||||
var invidiousCompanion: 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.app = app
|
||||||
self.id = id ?? UUID().uuidString
|
self.id = id ?? UUID().uuidString
|
||||||
self.name = name ?? app.rawValue
|
self.name = name ?? app.rawValue
|
||||||
@@ -20,6 +21,7 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
|
|||||||
self.frontendURL = frontendURL
|
self.frontendURL = frontendURL
|
||||||
self.proxiesVideos = proxiesVideos
|
self.proxiesVideos = proxiesVideos
|
||||||
self.invidiousCompanion = invidiousCompanion
|
self.invidiousCompanion = invidiousCompanion
|
||||||
|
self.hideVideosWithoutDuration = hideVideosWithoutDuration
|
||||||
}
|
}
|
||||||
|
|
||||||
var apiURL: URL! {
|
var apiURL: URL! {
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ struct InstancesBridge: Defaults.Bridge {
|
|||||||
"apiURL": value.apiURLString,
|
"apiURL": value.apiURLString,
|
||||||
"frontendURL": value.frontendURL ?? "",
|
"frontendURL": value.frontendURL ?? "",
|
||||||
"proxiesVideos": value.proxiesVideos ? "true" : "false",
|
"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 frontendURL: String? = object["frontendURL"]!.isEmpty ? nil : object["frontendURL"]
|
||||||
let proxiesVideos = object["proxiesVideos"] == "true"
|
let proxiesVideos = object["proxiesVideos"] == "true"
|
||||||
let invidiousCompanion = object["invidiousCompanion"] == "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
|
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) {
|
func remove(_ instance: Instance) {
|
||||||
let accounts = accounts(instance.id)
|
let accounts = accounts(instance.id)
|
||||||
if let index = Defaults[.instances].firstIndex(where: { $0.id == 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
|
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
|
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
|
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))
|
return ContentItem(playlist: self.extractChannelPlaylist(from: json))
|
||||||
}
|
}
|
||||||
if type == "video" {
|
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
|
return nil
|
||||||
@@ -101,7 +107,8 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
|||||||
|
|
||||||
configureTransformer(pathPattern("auth/feed"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
|
configureTransformer(pathPattern("auth/feed"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
|
||||||
if let feedVideos = content.json.dictionaryValue["videos"] {
|
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 []
|
return []
|
||||||
@@ -402,7 +409,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
|||||||
func search(_ query: SearchQuery, page: String?) -> Resource {
|
func search(_ query: SearchQuery, page: String?) -> Resource {
|
||||||
var resource = resource(baseURL: account.url, path: basePathAppending("search"))
|
var resource = resource(baseURL: account.url, path: basePathAppending("search"))
|
||||||
.withParam("q", searchQuery(query.query))
|
.withParam("q", searchQuery(query.query))
|
||||||
.withParam("sort_by", query.sortBy.parameter)
|
.withParam("sort", query.sortBy.parameter)
|
||||||
.withParam("type", "all")
|
.withParam("type", "all")
|
||||||
|
|
||||||
if let date = query.date, date != .any {
|
if let date = query.date, date != .any {
|
||||||
@@ -851,7 +858,14 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
|||||||
|
|
||||||
private func extractCaptions(from content: JSON) -> [Captions] {
|
private func extractCaptions(from content: JSON) -> [Captions] {
|
||||||
content["captions"].arrayValue.compactMap { details in
|
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(
|
return Captions(
|
||||||
label: details["label"].stringValue,
|
label: details["label"].stringValue,
|
||||||
@@ -875,7 +889,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
|||||||
return ContentItem(playlist: extractChannelPlaylist(from: json))
|
return ContentItem(playlist: extractChannelPlaylist(from: json))
|
||||||
}
|
}
|
||||||
if type == "video" {
|
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
|
return nil
|
||||||
|
|||||||
@@ -114,6 +114,10 @@ extension VideosAPI {
|
|||||||
let frontendURL = URL(string: frontendURLString)
|
let frontendURL = URL(string: frontendURLString)
|
||||||
{
|
{
|
||||||
urlComponents = URLComponents(url: frontendURL, resolvingAgainstBaseURL: false)
|
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 {
|
} else if let instanceComponents = account?.instance?.urlComponents {
|
||||||
urlComponents = instanceComponents
|
urlComponents = instanceComponents
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ final class AdvancedSettingsGroupExporter: SettingsGroupExporter {
|
|||||||
[
|
[
|
||||||
"showPlayNowInBackendContextMenu": Defaults[.showPlayNowInBackendContextMenu],
|
"showPlayNowInBackendContextMenu": Defaults[.showPlayNowInBackendContextMenu],
|
||||||
"videoLoadingRetryCount": Defaults[.videoLoadingRetryCount],
|
"videoLoadingRetryCount": Defaults[.videoLoadingRetryCount],
|
||||||
|
"avPlayerAllowsNonStreamableFormats": Defaults[.avPlayerAllowsNonStreamableFormats],
|
||||||
"showMPVPlaybackStats": Defaults[.showMPVPlaybackStats],
|
"showMPVPlaybackStats": Defaults[.showMPVPlaybackStats],
|
||||||
"mpvEnableLogging": Defaults[.mpvEnableLogging],
|
"mpvEnableLogging": Defaults[.mpvEnableLogging],
|
||||||
"mpvCacheSecs": Defaults[.mpvCacheSecs],
|
"mpvCacheSecs": Defaults[.mpvCacheSecs],
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ struct AdvancedSettingsGroupImporter {
|
|||||||
Defaults[.videoLoadingRetryCount] = videoLoadingRetryCount
|
Defaults[.videoLoadingRetryCount] = videoLoadingRetryCount
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let avPlayerAllowsNonStreamableFormats = json["avPlayerAllowsNonStreamableFormats"].bool {
|
||||||
|
Defaults[.avPlayerAllowsNonStreamableFormats] = avPlayerAllowsNonStreamableFormats
|
||||||
|
}
|
||||||
|
|
||||||
if let showMPVPlaybackStats = json["showMPVPlaybackStats"].bool {
|
if let showMPVPlaybackStats = json["showMPVPlaybackStats"].bool {
|
||||||
Defaults[.showMPVPlaybackStats] = showMPVPlaybackStats
|
Defaults[.showMPVPlaybackStats] = showMPVPlaybackStats
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import SwiftUI
|
|||||||
final class AVPlayerBackend: PlayerBackend {
|
final class AVPlayerBackend: PlayerBackend {
|
||||||
static let assetKeysToLoad = ["tracks", "playable", "duration"]
|
static let assetKeysToLoad = ["tracks", "playable", "duration"]
|
||||||
|
|
||||||
|
@Default(.avPlayerAllowsNonStreamableFormats) private var allowsNonStreamableFormats
|
||||||
|
|
||||||
private var logger = Logger(label: "avplayer-backend")
|
private var logger = Logger(label: "avplayer-backend")
|
||||||
|
|
||||||
var model: PlayerModel { .shared }
|
var model: PlayerModel { .shared }
|
||||||
@@ -150,7 +152,36 @@ final class AVPlayerBackend: PlayerBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func canPlay(_ stream: Stream) -> Bool {
|
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(
|
func playStream(
|
||||||
@@ -304,12 +335,12 @@ final class AVPlayerBackend: PlayerBackend {
|
|||||||
preservingTime: Bool = false,
|
preservingTime: Bool = false,
|
||||||
model: PlayerModel
|
model: PlayerModel
|
||||||
) {
|
) {
|
||||||
|
model.logger.info("loading \(type.rawValue) track")
|
||||||
|
|
||||||
asset.loadValuesAsynchronously(forKeys: Self.assetKeysToLoad) { [weak self] in
|
asset.loadValuesAsynchronously(forKeys: Self.assetKeysToLoad) { [weak self] in
|
||||||
guard let self else {
|
guard let self else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
model.logger.info("loading \(type.rawValue) track")
|
|
||||||
|
|
||||||
let assetTracks = asset.tracks(withMediaType: type)
|
let assetTracks = asset.tracks(withMediaType: type)
|
||||||
|
|
||||||
guard let compositionTrack = self.composition.addMutableTrack(
|
guard let compositionTrack = self.composition.addMutableTrack(
|
||||||
|
|||||||
@@ -133,6 +133,26 @@ extension PlayerModel {
|
|||||||
|
|
||||||
let profile = qualityProfile ?? .defaultProfile
|
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`
|
// First attempt: Filter by both `canPlay` and `isPreferred`
|
||||||
if let streamPreferredForProfile = backend.bestPlayable(
|
if let streamPreferredForProfile = backend.bestPlayable(
|
||||||
availableStreams.filter { backend.canPlay($0) && profile.isPreferred($0) },
|
availableStreams.filter { backend.canPlay($0) && profile.isPreferred($0) },
|
||||||
|
|||||||
@@ -25,11 +25,9 @@ final class SearchModel: ObservableObject {
|
|||||||
var accounts: AccountsModel { .shared }
|
var accounts: AccountsModel { .shared }
|
||||||
private var resource: Resource!
|
private var resource: Resource!
|
||||||
|
|
||||||
init() {
|
init() {}
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
deinit {}
|
||||||
}
|
|
||||||
|
|
||||||
var isLoading: Bool {
|
var isLoading: Bool {
|
||||||
resource?.isLoading ?? false
|
resource?.isLoading ?? false
|
||||||
|
|||||||
@@ -47,9 +47,9 @@ final class SearchQuery: ObservableObject {
|
|||||||
var parameter: String {
|
var parameter: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .uploadDate:
|
case .uploadDate:
|
||||||
return "upload_date"
|
return "date"
|
||||||
case .viewCount:
|
case .viewCount:
|
||||||
return "view_count"
|
return "views"
|
||||||
default:
|
default:
|
||||||
return rawValue
|
return rawValue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,24 @@ struct ChannelPlaylistView: View {
|
|||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
private var playlistMenu: some View {
|
private var playlistMenu: some View {
|
||||||
|
ZStack {
|
||||||
|
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)
|
||||||
|
|
||||||
Menu {
|
Menu {
|
||||||
playButtons
|
playButtons
|
||||||
|
|
||||||
@@ -141,9 +159,11 @@ struct ChannelPlaylistView: View {
|
|||||||
.imageScale(.small)
|
.imageScale(.small)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: 320)
|
.frame(maxWidth: 320)
|
||||||
.transaction { t in t.animation = nil }
|
.opacity(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.transaction { t in t.animation = nil }
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
private var playlistButtonsPlacement: ToolbarItemPlacement {
|
private var playlistButtonsPlacement: ToolbarItemPlacement {
|
||||||
|
|||||||
@@ -259,6 +259,40 @@ struct ChannelVideosView: View {
|
|||||||
|
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
var channelMenu: some View {
|
var channelMenu: some View {
|
||||||
|
ZStack {
|
||||||
|
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)
|
||||||
|
|
||||||
Menu {
|
Menu {
|
||||||
if let channel = presentedChannel {
|
if let channel = presentedChannel {
|
||||||
contentTypePicker
|
contentTypePicker
|
||||||
@@ -311,6 +345,8 @@ struct ChannelVideosView: View {
|
|||||||
.imageScale(.small)
|
.imageScale(.small)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: 320)
|
.frame(maxWidth: 320)
|
||||||
|
.opacity(0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ enum Constants {
|
|||||||
static let overlayAnimation = Animation.linear(duration: 0.2)
|
static let overlayAnimation = Animation.linear(duration: 0.2)
|
||||||
static let aspectRatio16x9 = 16.0 / 9.0
|
static let aspectRatio16x9 = 16.0 / 9.0
|
||||||
static let aspectRatio4x3 = 4.0 / 3.0
|
static let aspectRatio4x3 = 4.0 / 3.0
|
||||||
|
static let notificationCenterZoneHeight: Double = 60
|
||||||
|
|
||||||
static var isAppleTV: Bool {
|
static var isAppleTV: Bool {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
|
|||||||
@@ -192,7 +192,8 @@ extension Defaults.Keys {
|
|||||||
hd1080p60MPVProfile,
|
hd1080p60MPVProfile,
|
||||||
hd1080pMPVProfile,
|
hd1080pMPVProfile,
|
||||||
hd720p60MPVProfile,
|
hd720p60MPVProfile,
|
||||||
hd720pMPVProfile
|
hd720pMPVProfile,
|
||||||
|
hd720pAVPlayerProfile
|
||||||
]
|
]
|
||||||
|
|
||||||
static let batteryCellularProfileDefault = hd720pMPVProfile.id
|
static let batteryCellularProfileDefault = hd720pMPVProfile.id
|
||||||
@@ -208,7 +209,8 @@ extension Defaults.Keys {
|
|||||||
hd1080pMPVProfile,
|
hd1080pMPVProfile,
|
||||||
hd720p60MPVProfile,
|
hd720p60MPVProfile,
|
||||||
hd720pMPVProfile,
|
hd720pMPVProfile,
|
||||||
sd360pMPVProfile
|
sd360pMPVProfile,
|
||||||
|
hd720pAVPlayerProfile
|
||||||
]
|
]
|
||||||
|
|
||||||
static let batteryCellularProfileDefault = sd360pMPVProfile.id
|
static let batteryCellularProfileDefault = sd360pMPVProfile.id
|
||||||
@@ -361,6 +363,7 @@ extension Defaults.Keys {
|
|||||||
|
|
||||||
static let showPlayNowInBackendContextMenu = Key<Bool>("showPlayNowInBackendContextMenu", default: false)
|
static let showPlayNowInBackendContextMenu = Key<Bool>("showPlayNowInBackendContextMenu", default: false)
|
||||||
static let videoLoadingRetryCount = Key<Int>("videoLoadingRetryCount", default: 10)
|
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 showMPVPlaybackStats = Key<Bool>("showMPVPlaybackStats", default: false)
|
||||||
static let mpvEnableLogging = Key<Bool>("mpvEnableLogging", default: false)
|
static let mpvEnableLogging = Key<Bool>("mpvEnableLogging", default: false)
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ struct FavoriteItemView: View {
|
|||||||
#else
|
#else
|
||||||
.padding(.horizontal, 15)
|
.padding(.horizontal, 15)
|
||||||
#endif
|
#endif
|
||||||
.frame(height: expectedContentHeight)
|
|
||||||
} else {
|
} else {
|
||||||
ZStack(alignment: .topLeading) {
|
ZStack(alignment: .topLeading) {
|
||||||
// Reserve space immediately to prevent layout shift
|
// Reserve space immediately to prevent layout shift
|
||||||
|
|||||||
@@ -211,6 +211,17 @@ struct HomeView: View {
|
|||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
var homeMenu: some View {
|
var homeMenu: some View {
|
||||||
|
ZStack {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text("Home")
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Image(systemName: "chevron.down.circle.fill")
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
.imageScale(.small)
|
||||||
|
}
|
||||||
|
|
||||||
Menu {
|
Menu {
|
||||||
Section {
|
Section {
|
||||||
HideWatchedButtons()
|
HideWatchedButtons()
|
||||||
@@ -233,9 +244,11 @@ struct HomeView: View {
|
|||||||
.foregroundColor(.accentColor)
|
.foregroundColor(.accentColor)
|
||||||
.imageScale(.small)
|
.imageScale(.small)
|
||||||
}
|
}
|
||||||
.transaction { t in t.animation = nil }
|
.opacity(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.transaction { t in t.animation = nil }
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ struct ControlsOverlay: View {
|
|||||||
@ObservedObject private var player = PlayerModel.shared
|
@ObservedObject private var player = PlayerModel.shared
|
||||||
private var model = PlayerControlsModel.shared
|
private var model = PlayerControlsModel.shared
|
||||||
|
|
||||||
@State private var availableCaptions: [Captions] = []
|
|
||||||
@State private var isLoadingCaptions = true
|
|
||||||
@State private var contentSize: CGSize = .zero
|
@State private var contentSize: CGSize = .zero
|
||||||
|
|
||||||
@Default(.showMPVPlaybackStats) private var showMPVPlaybackStats
|
@Default(.showMPVPlaybackStats) private var showMPVPlaybackStats
|
||||||
@@ -23,7 +21,10 @@ struct ControlsOverlay: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@FocusState private var focusedField: Field?
|
@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
|
#endif
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -104,19 +105,9 @@ struct ControlsOverlay: View {
|
|||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
.padding(.horizontal, 40)
|
.padding(.horizontal, 40)
|
||||||
#endif
|
#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)
|
#if os(tvOS)
|
||||||
.alert(isPresented: $presentingButtonHintAlert) {
|
|
||||||
Alert(title: Text("Press and hold to open this menu"))
|
|
||||||
}
|
|
||||||
.onAppear {
|
.onAppear {
|
||||||
focusedField = .qualityProfile
|
focusedField = .qualityProfile
|
||||||
}
|
}
|
||||||
@@ -127,14 +118,6 @@ struct ControlsOverlay: View {
|
|||||||
player.activeBackend == .mpv ? "Rate & Captions" : "Playback Rate"
|
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 {
|
private func controlsHeader(_ text: String) -> some View {
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.system(.caption))
|
.font(.system(.caption))
|
||||||
@@ -279,24 +262,26 @@ struct ControlsOverlay: View {
|
|||||||
.modifier(ControlBackgroundModifier())
|
.modifier(ControlBackgroundModifier())
|
||||||
.mask(RoundedRectangle(cornerRadius: 3))
|
.mask(RoundedRectangle(cornerRadius: 3))
|
||||||
#else
|
#else
|
||||||
ControlsOverlayButton(focusedField: $focusedField, field: .qualityProfile) {
|
ControlsOverlayButton(
|
||||||
|
focusedField: $focusedField,
|
||||||
|
field: .qualityProfile,
|
||||||
|
onSelect: { presentingQualityProfileMenu = true }
|
||||||
|
) {
|
||||||
Text(player.qualityProfileSelection?.description ?? "Automatic".localized())
|
Text(player.qualityProfileSelection?.description ?? "Automatic".localized())
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.frame(maxWidth: 320)
|
.frame(maxWidth: 320)
|
||||||
}
|
}
|
||||||
.contextMenu {
|
.alert("Quality Profile", isPresented: $presentingQualityProfileMenu) {
|
||||||
Button("Automatic") { player.qualityProfileSelection = nil }
|
Button("Automatic") { player.qualityProfileSelection = nil }
|
||||||
|
|
||||||
ForEach(qualityProfiles) { qualityProfile in
|
ForEach(qualityProfiles) { qualityProfile in
|
||||||
Button {
|
Button(qualityProfile.description) {
|
||||||
player.qualityProfileSelection = qualityProfile
|
player.qualityProfileSelection = qualityProfile
|
||||||
} label: {
|
}
|
||||||
Text(qualityProfile.description)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("Cancel", role: .cancel) {}
|
Button("Cancel", role: .cancel) {}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,7 +315,7 @@ struct ControlsOverlay: View {
|
|||||||
.modifier(ControlBackgroundModifier())
|
.modifier(ControlBackgroundModifier())
|
||||||
.mask(RoundedRectangle(cornerRadius: 3))
|
.mask(RoundedRectangle(cornerRadius: 3))
|
||||||
#else
|
#else
|
||||||
StreamControl(focusedField: $focusedField)
|
StreamControl(focusedField: $focusedField, presentingStreamMenu: $presentingStreamMenu)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,29 +354,31 @@ struct ControlsOverlay: View {
|
|||||||
.modifier(ControlBackgroundModifier())
|
.modifier(ControlBackgroundModifier())
|
||||||
.mask(RoundedRectangle(cornerRadius: 3))
|
.mask(RoundedRectangle(cornerRadius: 3))
|
||||||
#else
|
#else
|
||||||
ControlsOverlayButton(focusedField: $focusedField, field: .captions) {
|
ControlsOverlayButton(
|
||||||
|
focusedField: $focusedField,
|
||||||
|
field: .captions,
|
||||||
|
onSelect: { presentingCaptionsMenu = true }
|
||||||
|
) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: "text.bubble")
|
Image(systemName: "text.bubble")
|
||||||
if let captions = captionsBinding.wrappedValue,
|
if let captions = captionsBinding.wrappedValue,
|
||||||
let language = LanguageCodes(rawValue: captions.code)
|
let language = LanguageCodes(rawValue: captions.code)
|
||||||
{
|
{
|
||||||
Text("\(language.description.capitalized) (\(language.rawValue))")
|
Text("\(language.description.capitalized) (\(language.rawValue))")
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
} else {
|
|
||||||
if captionsBinding.wrappedValue == nil {
|
|
||||||
Text("Not available")
|
|
||||||
} else {
|
} else {
|
||||||
|
if player.currentVideo?.captions.isEmpty == false {
|
||||||
Text("Disabled")
|
Text("Disabled")
|
||||||
.foregroundColor(.accentColor)
|
} else {
|
||||||
|
Text("Not available")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: 320)
|
.frame(maxWidth: 320)
|
||||||
}
|
}
|
||||||
.contextMenu {
|
.alert("Captions", isPresented: $presentingCaptionsMenu) {
|
||||||
Button("Disabled") { captionsBinding.wrappedValue = nil }
|
Button("Disabled") { captionsBinding.wrappedValue = nil }
|
||||||
|
|
||||||
ForEach(availableCaptions) { caption in
|
ForEach(player.currentVideo?.captions ?? []) { caption in
|
||||||
Button(caption.description) { captionsBinding.wrappedValue = caption }
|
Button(caption.description) { captionsBinding.wrappedValue = caption }
|
||||||
}
|
}
|
||||||
Button("Cancel", role: .cancel) {}
|
Button("Cancel", role: .cancel) {}
|
||||||
@@ -400,7 +387,7 @@ struct ControlsOverlay: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private var captionsPicker: some View {
|
@ViewBuilder private var captionsPicker: some View {
|
||||||
let captions = availableCaptions
|
let captions = player.currentVideo?.captions ?? []
|
||||||
Picker("Captions", selection: captionsBinding) {
|
Picker("Captions", selection: captionsBinding) {
|
||||||
if captions.isEmpty {
|
if captions.isEmpty {
|
||||||
Text("Not available").tag(Captions?.none)
|
Text("Not available").tag(Captions?.none)
|
||||||
@@ -412,31 +399,6 @@ struct ControlsOverlay: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.disabled(captions.isEmpty)
|
.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?> {
|
private var captionsBinding: Binding<Captions?> {
|
||||||
@@ -467,11 +429,15 @@ struct ControlsOverlay: View {
|
|||||||
.frame(maxWidth: 240, alignment: .trailing)
|
.frame(maxWidth: 240, alignment: .trailing)
|
||||||
.frame(height: 40)
|
.frame(height: 40)
|
||||||
#else
|
#else
|
||||||
ControlsOverlayButton(focusedField: $focusedField, field: .audioTrack) {
|
ControlsOverlayButton(
|
||||||
|
focusedField: $focusedField,
|
||||||
|
field: .audioTrack,
|
||||||
|
onSelect: { presentingAudioTrackMenu = true }
|
||||||
|
) {
|
||||||
Text(player.selectedAudioTrack?.displayLanguage ?? "Original")
|
Text(player.selectedAudioTrack?.displayLanguage ?? "Original")
|
||||||
.frame(maxWidth: 320)
|
.frame(maxWidth: 320)
|
||||||
}
|
}
|
||||||
.contextMenu {
|
.alert("Audio Track", isPresented: $presentingAudioTrackMenu) {
|
||||||
ForEach(Array(player.availableAudioTracks.enumerated()), id: \.offset) { index, track in
|
ForEach(Array(player.availableAudioTracks.enumerated()), id: \.offset) { index, track in
|
||||||
Button(track.description) { player.selectedAudioTrackIndex = index }
|
Button(track.description) { player.selectedAudioTrackIndex = index }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -199,6 +199,16 @@ struct TimelineView: View {
|
|||||||
.gesture(
|
.gesture(
|
||||||
DragGesture(minimumDistance: 5, coordinateSpace: .global)
|
DragGesture(minimumDistance: 5, coordinateSpace: .global)
|
||||||
.onChanged { value in
|
.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 {
|
if !dragging {
|
||||||
controls.removeTimer()
|
controls.removeTimer()
|
||||||
draggedFrom = current
|
draggedFrom = current
|
||||||
|
|||||||
@@ -209,16 +209,22 @@ struct PlaybackSettings: View {
|
|||||||
.controlSize(.large)
|
.controlSize(.large)
|
||||||
.frame(width: 100, alignment: .center)
|
.frame(width: 100, alignment: .center)
|
||||||
#elseif os(iOS)
|
#elseif os(iOS)
|
||||||
|
ZStack {
|
||||||
|
Text(player.rateLabel(player.currentRate))
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.frame(width: 70)
|
||||||
|
|
||||||
Menu {
|
Menu {
|
||||||
ratePicker
|
ratePicker
|
||||||
} label: {
|
} label: {
|
||||||
Text(player.rateLabel(player.currentRate))
|
Text(player.rateLabel(player.currentRate))
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(.primary)
|
||||||
.frame(width: 70)
|
.frame(width: 70)
|
||||||
|
.opacity(0)
|
||||||
}
|
}
|
||||||
.transaction { t in t.animation = .none }
|
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.foregroundColor(.primary)
|
.transaction { t in t.animation = .none }
|
||||||
|
}
|
||||||
.frame(width: 70, height: 40)
|
.frame(width: 70, height: 40)
|
||||||
#else
|
#else
|
||||||
Text(player.rateLabel(player.currentRate))
|
Text(player.rateLabel(player.currentRate))
|
||||||
@@ -331,12 +337,20 @@ struct PlaybackSettings: View {
|
|||||||
.controlSize(.large)
|
.controlSize(.large)
|
||||||
.frame(width: 300, alignment: .trailing)
|
.frame(width: 300, alignment: .trailing)
|
||||||
#else
|
#else
|
||||||
|
ZStack {
|
||||||
|
Label(player.playbackMode.description.localized(), systemImage: player.playbackMode.systemImage)
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
|
||||||
Menu {
|
Menu {
|
||||||
playbackModePicker
|
playbackModePicker
|
||||||
} label: {
|
} label: {
|
||||||
Label(player.playbackMode.description.localized(), systemImage: player.playbackMode.systemImage)
|
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
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,16 +370,23 @@ struct PlaybackSettings: View {
|
|||||||
.controlSize(.large)
|
.controlSize(.large)
|
||||||
.frame(width: 300, alignment: .trailing)
|
.frame(width: 300, alignment: .trailing)
|
||||||
#elseif os(iOS)
|
#elseif os(iOS)
|
||||||
|
ZStack {
|
||||||
|
Text(player.qualityProfileSelection?.description ?? "Automatic".localized())
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
.frame(maxWidth: 240, alignment: .trailing)
|
||||||
|
|
||||||
Menu {
|
Menu {
|
||||||
qualityProfilePicker
|
qualityProfilePicker
|
||||||
} label: {
|
} label: {
|
||||||
Text(player.qualityProfileSelection?.description ?? "Automatic".localized())
|
Text(player.qualityProfileSelection?.description ?? "Automatic".localized())
|
||||||
.frame(maxWidth: 240, alignment: .trailing)
|
|
||||||
}
|
|
||||||
.transaction { t in t.animation = .none }
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(.accentColor)
|
||||||
.frame(maxWidth: 240, alignment: .trailing)
|
.frame(maxWidth: 240, alignment: .trailing)
|
||||||
|
.opacity(0)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.transaction { t in t.animation = .none }
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 240, alignment: .trailing)
|
||||||
.frame(height: 40)
|
.frame(height: 40)
|
||||||
#else
|
#else
|
||||||
ControlsOverlayButton(focusedField: $focusedField, field: .qualityProfile) {
|
ControlsOverlayButton(focusedField: $focusedField, field: .qualityProfile) {
|
||||||
@@ -406,15 +427,22 @@ struct PlaybackSettings: View {
|
|||||||
.controlSize(.large)
|
.controlSize(.large)
|
||||||
.frame(width: 300, alignment: .trailing)
|
.frame(width: 300, alignment: .trailing)
|
||||||
#elseif os(iOS)
|
#elseif os(iOS)
|
||||||
|
ZStack {
|
||||||
|
Text(player.streamSelection?.resolutionAndFormat ?? "loading...")
|
||||||
|
.foregroundColor(player.streamSelection == nil ? .secondary : .accentColor)
|
||||||
|
.frame(width: 140, height: 40, alignment: .trailing)
|
||||||
|
|
||||||
Menu {
|
Menu {
|
||||||
StreamControl()
|
StreamControl()
|
||||||
} label: {
|
} label: {
|
||||||
Text(player.streamSelection?.resolutionAndFormat ?? "loading...")
|
Text(player.streamSelection?.resolutionAndFormat ?? "loading...")
|
||||||
.frame(width: 140, height: 40, alignment: .trailing)
|
|
||||||
.foregroundColor(player.streamSelection == nil ? .secondary : .accentColor)
|
.foregroundColor(player.streamSelection == nil ? .secondary : .accentColor)
|
||||||
|
.frame(width: 140, height: 40, alignment: .trailing)
|
||||||
|
.opacity(0)
|
||||||
}
|
}
|
||||||
.transaction { t in t.animation = .none }
|
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.transaction { t in t.animation = .none }
|
||||||
|
}
|
||||||
.frame(width: 140, height: 40, alignment: .trailing)
|
.frame(width: 140, height: 40, alignment: .trailing)
|
||||||
#else
|
#else
|
||||||
StreamControl(focusedField: $focusedField)
|
StreamControl(focusedField: $focusedField)
|
||||||
@@ -429,6 +457,25 @@ struct PlaybackSettings: View {
|
|||||||
.controlSize(.large)
|
.controlSize(.large)
|
||||||
.frame(width: 300, alignment: .trailing)
|
.frame(width: 300, alignment: .trailing)
|
||||||
#elseif os(iOS)
|
#elseif os(iOS)
|
||||||
|
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))")
|
||||||
|
} else {
|
||||||
|
if videoCaptions?.isEmpty == true {
|
||||||
|
Text("Not available")
|
||||||
|
} else {
|
||||||
|
Text("Disabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
.frame(alignment: .trailing)
|
||||||
|
.frame(height: 40)
|
||||||
|
|
||||||
Menu {
|
Menu {
|
||||||
if videoCaptions?.isEmpty == false {
|
if videoCaptions?.isEmpty == false {
|
||||||
captionsPicker
|
captionsPicker
|
||||||
@@ -440,7 +487,6 @@ struct PlaybackSettings: View {
|
|||||||
let language = LanguageCodes(rawValue: captions.code)
|
let language = LanguageCodes(rawValue: captions.code)
|
||||||
{
|
{
|
||||||
Text("\(language.description.capitalized) (\(language.rawValue))")
|
Text("\(language.description.capitalized) (\(language.rawValue))")
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
} else {
|
} else {
|
||||||
if videoCaptions?.isEmpty == true {
|
if videoCaptions?.isEmpty == true {
|
||||||
Text("Not available")
|
Text("Not available")
|
||||||
@@ -449,13 +495,15 @@ struct PlaybackSettings: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
.frame(alignment: .trailing)
|
.frame(alignment: .trailing)
|
||||||
.frame(height: 40)
|
.frame(height: 40)
|
||||||
|
.opacity(0)
|
||||||
.disabled(videoCaptions?.isEmpty == true)
|
.disabled(videoCaptions?.isEmpty == true)
|
||||||
}
|
}
|
||||||
.transaction { t in t.animation = .none }
|
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.foregroundColor(.accentColor)
|
.transaction { t in t.animation = .none }
|
||||||
|
}
|
||||||
#else
|
#else
|
||||||
ControlsOverlayButton(focusedField: $focusedField, field: .captions) {
|
ControlsOverlayButton(focusedField: $focusedField, field: .captions) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
@@ -500,16 +548,23 @@ struct PlaybackSettings: View {
|
|||||||
.controlSize(.large)
|
.controlSize(.large)
|
||||||
.frame(width: 300, alignment: .trailing)
|
.frame(width: 300, alignment: .trailing)
|
||||||
#elseif os(iOS)
|
#elseif os(iOS)
|
||||||
|
ZStack {
|
||||||
|
Text(player.selectedAudioTrack?.displayLanguage ?? "Original")
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
.frame(maxWidth: 240, alignment: .trailing)
|
||||||
|
|
||||||
Menu {
|
Menu {
|
||||||
audioTrackPicker
|
audioTrackPicker
|
||||||
} label: {
|
} label: {
|
||||||
Text(player.selectedAudioTrack?.displayLanguage ?? "Original")
|
Text(player.selectedAudioTrack?.displayLanguage ?? "Original")
|
||||||
.frame(maxWidth: 240, alignment: .trailing)
|
|
||||||
}
|
|
||||||
.transaction { t in t.animation = .none }
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(.accentColor)
|
||||||
.frame(maxWidth: 240, alignment: .trailing)
|
.frame(maxWidth: 240, alignment: .trailing)
|
||||||
|
.opacity(0)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.transaction { t in t.animation = .none }
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 240, alignment: .trailing)
|
||||||
.frame(height: 40)
|
.frame(height: 40)
|
||||||
#else
|
#else
|
||||||
ControlsOverlayButton(focusedField: $focusedField, field: .audioTrack) {
|
ControlsOverlayButton(focusedField: $focusedField, field: .audioTrack) {
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ extension VideoPlayerView {
|
|||||||
state = true
|
state = true
|
||||||
}
|
}
|
||||||
.onChanged { value in
|
.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,
|
guard player.presentingPlayer,
|
||||||
!controlsOverlayModel.presenting,
|
!controlsOverlayModel.presenting,
|
||||||
dragGestureState,
|
dragGestureState,
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import SwiftUI
|
|||||||
struct StreamControl: View {
|
struct StreamControl: View {
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
var focusedField: FocusState<ControlsOverlay.Field?>.Binding?
|
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
|
self.focusedField = focusedField
|
||||||
|
_presentingStreamMenu = presentingStreamMenu
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -45,16 +47,20 @@ struct StreamControl: View {
|
|||||||
.fixedSize()
|
.fixedSize()
|
||||||
#endif
|
#endif
|
||||||
#else
|
#else
|
||||||
ControlsOverlayButton(focusedField: focusedField!, field: .stream) {
|
ControlsOverlayButton(
|
||||||
|
focusedField: focusedField!,
|
||||||
|
field: .stream,
|
||||||
|
onSelect: { presentingStreamMenu = true }
|
||||||
|
) {
|
||||||
Text(player.streamSelection?.shortQuality ?? "loading")
|
Text(player.streamSelection?.shortQuality ?? "loading")
|
||||||
.frame(maxWidth: 320)
|
.frame(maxWidth: 320)
|
||||||
}
|
}
|
||||||
.contextMenu {
|
.alert("Stream Quality", isPresented: $presentingStreamMenu) {
|
||||||
ForEach(streams) { stream in
|
ForEach(streams) { stream in
|
||||||
Button(stream.description) { player.streamSelection = stream }
|
Button(stream.description) { player.streamSelection = stream }
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("Close", role: .cancel) {}
|
Button("Cancel", role: .cancel) {}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@@ -79,7 +85,7 @@ struct StreamControl: View {
|
|||||||
struct StreamControl_Previews: PreviewProvider {
|
struct StreamControl_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
StreamControl(focusedField: .none)
|
StreamControl(focusedField: .none, presentingStreamMenu: .constant(false))
|
||||||
.injectFixtureEnvironmentObjects()
|
.injectFixtureEnvironmentObjects()
|
||||||
#else
|
#else
|
||||||
StreamControl()
|
StreamControl()
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ struct VideoDetails: View {
|
|||||||
@Environment(\.navigationStyle) private var navigationStyle
|
@Environment(\.navigationStyle) private var navigationStyle
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||||
|
@ObservedObject private var safeAreaModel = SafeAreaModel.shared
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@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)
|
.padding(.bottom, 60)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
|||||||
@@ -190,7 +190,24 @@ struct PlaylistsView: View {
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
var playlistsMenu: some View {
|
var playlistsMenu: some View {
|
||||||
let title = currentPlaylist?.title ?? "Playlists"
|
let title = currentPlaylist?.title ?? "Playlists"
|
||||||
return Menu {
|
return ZStack {
|
||||||
|
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)
|
||||||
|
|
||||||
|
Menu {
|
||||||
Menu {
|
Menu {
|
||||||
selectPlaylistButton
|
selectPlaylistButton
|
||||||
} label: {
|
} label: {
|
||||||
@@ -239,10 +256,12 @@ struct PlaylistsView: View {
|
|||||||
.imageScale(.small)
|
.imageScale(.small)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.frame(maxWidth: 320)
|
.frame(maxWidth: 320)
|
||||||
.transaction { t in t.animation = nil }
|
.opacity(0)
|
||||||
}
|
}
|
||||||
.disabled(!accounts.signedIn)
|
.disabled(!accounts.signedIn)
|
||||||
}
|
}
|
||||||
|
.transaction { t in t.animation = nil }
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
func hintText(_ text: String) -> some View {
|
func hintText(_ text: String) -> some View {
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ struct AdvancedSettings: View {
|
|||||||
@Default(.feedCacheSize) private var feedCacheSize
|
@Default(.feedCacheSize) private var feedCacheSize
|
||||||
@Default(.showPlayNowInBackendContextMenu) private var showPlayNowInBackendContextMenu
|
@Default(.showPlayNowInBackendContextMenu) private var showPlayNowInBackendContextMenu
|
||||||
@Default(.videoLoadingRetryCount) private var videoLoadingRetryCount
|
@Default(.videoLoadingRetryCount) private var videoLoadingRetryCount
|
||||||
|
@Default(.avPlayerAllowsNonStreamableFormats) private var avPlayerAllowsNonStreamableFormats
|
||||||
|
|
||||||
@State private var filesToShare = [MPVClient.logFile]
|
@State private var filesToShare = [MPVClient.logFile]
|
||||||
@State private var presentingShareSheet = false
|
@State private var presentingShareSheet = false
|
||||||
|
|
||||||
|
@ObservedObject private var player = PlayerModel.shared
|
||||||
private var settings = SettingsModel.shared
|
private var settings = SettingsModel.shared
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -73,6 +75,10 @@ struct AdvancedSettings: View {
|
|||||||
videoLoadingRetryCountField
|
videoLoadingRetryCountField
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section(header: SettingsHeader(text: "AVPlayer"), footer: avPlayerNonStreamableFormatsFooter) {
|
||||||
|
avPlayerAllowsNonStreamableFormatsToggle
|
||||||
|
}
|
||||||
|
|
||||||
Section(header: SettingsHeader(text: "MPV"), footer: mpvFooter) {
|
Section(header: SettingsHeader(text: "MPV"), footer: mpvFooter) {
|
||||||
showMPVPlaybackStatsToggle
|
showMPVPlaybackStatsToggle
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
@@ -370,6 +376,22 @@ struct AdvancedSettings: View {
|
|||||||
Text(String(format: "Total size: %@".localized(), BaseCacheModel.shared.totalSizeFormatted))
|
Text(String(format: "Total size: %@".localized(), BaseCacheModel.shared.totalSizeFormatted))
|
||||||
.foregroundColor(.secondary)
|
.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 {
|
struct AdvancedSettings_Previews: PreviewProvider {
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ struct BrowsingSettings: View {
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
Toggle("Show Documents", isOn: $showDocuments)
|
Toggle("Show Documents", isOn: $showDocuments)
|
||||||
|
|
||||||
if Constants.isIPad {
|
if Constants.isIPhone {
|
||||||
Toggle("Lock portrait mode", isOn: $lockPortraitWhenBrowsing)
|
Toggle("Lock portrait mode", isOn: $lockPortraitWhenBrowsing)
|
||||||
.onChange(of: lockPortraitWhenBrowsing) { lock in
|
.onChange(of: lockPortraitWhenBrowsing) { lock in
|
||||||
if lock {
|
if lock {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ struct InstanceSettings: View {
|
|||||||
@State private var frontendURL = ""
|
@State private var frontendURL = ""
|
||||||
@State private var proxiesVideos = false
|
@State private var proxiesVideos = false
|
||||||
@State private var invidiousCompanion = false
|
@State private var invidiousCompanion = false
|
||||||
|
@State private var hideVideosWithoutDuration = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
List {
|
||||||
@@ -97,6 +98,16 @@ struct InstanceSettings: View {
|
|||||||
.onChange(of: invidiousCompanion) { newValue in
|
.onChange(of: invidiousCompanion) { newValue in
|
||||||
InstancesModel.shared.setInvidiousCompanion(instance, newValue)
|
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)
|
#if os(tvOS)
|
||||||
@@ -116,6 +127,10 @@ struct InstanceSettings: View {
|
|||||||
Toggle("Invidious companion", isOn: $invidiousCompanion)
|
Toggle("Invidious companion", isOn: $invidiousCompanion)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var hideVideosWithoutDurationToggle: some View {
|
||||||
|
Toggle("Experimental: Hide videos without duration", isOn: $hideVideosWithoutDuration)
|
||||||
|
}
|
||||||
|
|
||||||
private func removeAccount(_ account: Account) {
|
private func removeAccount(_ account: Account) {
|
||||||
AccountsModel.remove(account)
|
AccountsModel.remove(account)
|
||||||
accountsChanged.toggle()
|
accountsChanged.toggle()
|
||||||
|
|||||||
@@ -360,7 +360,7 @@ struct SettingsView: View {
|
|||||||
case .locations:
|
case .locations:
|
||||||
return 600
|
return 600
|
||||||
case .advanced:
|
case .advanced:
|
||||||
return 630
|
return 700
|
||||||
case .importExport:
|
case .importExport:
|
||||||
return 580
|
return 580
|
||||||
case .help:
|
case .help:
|
||||||
|
|||||||
@@ -168,6 +168,18 @@ struct TrendingView: View {
|
|||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
var trendingMenu: some View {
|
var trendingMenu: some View {
|
||||||
|
ZStack {
|
||||||
|
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)
|
||||||
|
|
||||||
Menu {
|
Menu {
|
||||||
countryButton
|
countryButton
|
||||||
|
|
||||||
@@ -194,6 +206,8 @@ struct TrendingView: View {
|
|||||||
.imageScale(.small)
|
.imageScale(.small)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: 320)
|
.frame(maxWidth: 320)
|
||||||
|
.opacity(0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -144,10 +144,15 @@ struct OpenVideosView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
#endif
|
#endif
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
|
ZStack {
|
||||||
|
Text(playbackMode.description)
|
||||||
|
|
||||||
Menu {
|
Menu {
|
||||||
playbackModePicker
|
playbackModePicker
|
||||||
} label: {
|
} label: {
|
||||||
Text(playbackMode.description)
|
Text(playbackMode.description)
|
||||||
|
.opacity(0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
playbackModePicker
|
playbackModePicker
|
||||||
|
|||||||
@@ -90,6 +90,23 @@ struct PopularView: View {
|
|||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
private var popularMenu: some View {
|
private var popularMenu: some View {
|
||||||
|
ZStack {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
Menu {
|
Menu {
|
||||||
ListingStyleButtons(listingStyle: $popularListingStyle)
|
ListingStyleButtons(listingStyle: $popularListingStyle)
|
||||||
|
|
||||||
@@ -117,9 +134,11 @@ struct PopularView: View {
|
|||||||
.foregroundColor(.accentColor)
|
.foregroundColor(.accentColor)
|
||||||
.imageScale(.small)
|
.imageScale(.small)
|
||||||
}
|
}
|
||||||
.transaction { t in t.animation = nil }
|
.opacity(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.transaction { t in t.animation = nil }
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
var shouldDisplayHeader: Bool {
|
var shouldDisplayHeader: Bool {
|
||||||
|
|||||||
@@ -33,23 +33,21 @@ struct VideoContextMenuView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
|
#if os(iOS)
|
||||||
// Conditional overlay to block taps on underlying views
|
// Conditional overlay to block taps on underlying views
|
||||||
if isOverlayVisible {
|
if isOverlayVisible {
|
||||||
Color.clear
|
Color.clear
|
||||||
.contentShape(Rectangle())
|
.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 {
|
.onTapGesture {
|
||||||
// Dismiss overlay without triggering other interactions
|
// Dismiss overlay without triggering other interactions
|
||||||
isOverlayVisible = false
|
isOverlayVisible = false
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
.ignoresSafeArea() // Ensure overlay covers the entire screen
|
.ignoresSafeArea() // Ensure overlay covers the entire screen
|
||||||
.accessibilityLabel("Dismiss context menu")
|
.accessibilityLabel("Dismiss context menu")
|
||||||
.accessibilityHint("Tap to close the context")
|
.accessibilityHint("Tap to close the context")
|
||||||
.accessibilityAddTraits(.isButton)
|
.accessibilityAddTraits(.isButton)
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
if video.videoID != Video.fixtureID {
|
if video.videoID != Video.fixtureID {
|
||||||
contextMenu
|
contextMenu
|
||||||
@@ -156,7 +154,9 @@ struct VideoContextMenuView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
|
if #unavailable(tvOS 18.0) {
|
||||||
Button("Cancel", role: .cancel) {}
|
Button("Cancel", role: .cancel) {}
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2847,7 +2847,7 @@
|
|||||||
3765917827237D07009F956E /* XCRemoteSwiftPackageReference "PINCache" */,
|
3765917827237D07009F956E /* XCRemoteSwiftPackageReference "PINCache" */,
|
||||||
37CF8B8228535E4F00B71E37 /* XCRemoteSwiftPackageReference "SDWebImage" */,
|
37CF8B8228535E4F00B71E37 /* XCRemoteSwiftPackageReference "SDWebImage" */,
|
||||||
372AA40E286D067B0000B1DC /* XCRemoteSwiftPackageReference "Repeat" */,
|
372AA40E286D067B0000B1DC /* XCRemoteSwiftPackageReference "Repeat" */,
|
||||||
37EE6DC328A305AD00BFD632 /* XCRemoteSwiftPackageReference "Reachability.swift" */,
|
37EE6DC328A305AD00BFD632 /* XCRemoteSwiftPackageReference "Reachability" */,
|
||||||
3799AC0728B03CEC001376F9 /* XCRemoteSwiftPackageReference "ActiveLabel.swift" */,
|
3799AC0728B03CEC001376F9 /* XCRemoteSwiftPackageReference "ActiveLabel.swift" */,
|
||||||
375B8AAF28B57F4200397B31 /* XCRemoteSwiftPackageReference "KeychainAccess" */,
|
375B8AAF28B57F4200397B31 /* XCRemoteSwiftPackageReference "KeychainAccess" */,
|
||||||
3797104728D3D10600D5F53C /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */,
|
3797104728D3D10600D5F53C /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */,
|
||||||
@@ -4168,7 +4168,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = "Open in Yattee/Open in Yattee.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "Open in Yattee/Open in Yattee.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 210;
|
CURRENT_PROJECT_VERSION = 211;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = "Open in Yattee/Info.plist";
|
INFOPLIST_FILE = "Open in Yattee/Info.plist";
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Open in Yattee";
|
INFOPLIST_KEY_CFBundleDisplayName = "Open in Yattee";
|
||||||
@@ -4199,7 +4199,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
CURRENT_PROJECT_VERSION = 210;
|
CURRENT_PROJECT_VERSION = 211;
|
||||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 78Z5H3M6RJ;
|
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 78Z5H3M6RJ;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = "Open in Yattee/Info.plist";
|
INFOPLIST_FILE = "Open in Yattee/Info.plist";
|
||||||
@@ -4230,7 +4230,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 210;
|
CURRENT_PROJECT_VERSION = 211;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
@@ -4250,7 +4250,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 210;
|
CURRENT_PROJECT_VERSION = 211;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
@@ -4414,7 +4414,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = "iOS/Yattee (iOS).entitlements";
|
CODE_SIGN_ENTITLEMENTS = "iOS/Yattee (iOS).entitlements";
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 210;
|
CURRENT_PROJECT_VERSION = 211;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
"DEBUG=1",
|
"DEBUG=1",
|
||||||
@@ -4468,7 +4468,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
CURRENT_PROJECT_VERSION = 210;
|
CURRENT_PROJECT_VERSION = 211;
|
||||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 78Z5H3M6RJ;
|
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 78Z5H3M6RJ;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GCC_PREPROCESSOR_DEFINITIONS = "GLES_SILENCE_DEPRECATION=1";
|
GCC_PREPROCESSOR_DEFINITIONS = "GLES_SILENCE_DEPRECATION=1";
|
||||||
@@ -4522,7 +4522,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 210;
|
CURRENT_PROJECT_VERSION = 211;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@@ -4561,7 +4561,7 @@
|
|||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "3rd Party Mac Developer Application";
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "3rd Party Mac Developer Application";
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 210;
|
CURRENT_PROJECT_VERSION = 211;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
"DEVELOPMENT_TEAM[sdk=macosx*]" = 78Z5H3M6RJ;
|
"DEVELOPMENT_TEAM[sdk=macosx*]" = 78Z5H3M6RJ;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
@@ -4596,7 +4596,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 210;
|
CURRENT_PROJECT_VERSION = 211;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@@ -4619,7 +4619,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 210;
|
CURRENT_PROJECT_VERSION = 211;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@@ -4644,7 +4644,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 210;
|
CURRENT_PROJECT_VERSION = 211;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@@ -4668,7 +4668,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 210;
|
CURRENT_PROJECT_VERSION = 211;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@@ -4694,7 +4694,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 210;
|
CURRENT_PROJECT_VERSION = 211;
|
||||||
DEVELOPMENT_ASSET_PATHS = "";
|
DEVELOPMENT_ASSET_PATHS = "";
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -4734,7 +4734,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
"CODE_SIGN_IDENTITY[sdk=appletvos*]" = "iPhone Distribution";
|
"CODE_SIGN_IDENTITY[sdk=appletvos*]" = "iPhone Distribution";
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
CURRENT_PROJECT_VERSION = 210;
|
CURRENT_PROJECT_VERSION = 211;
|
||||||
DEVELOPMENT_ASSET_PATHS = "";
|
DEVELOPMENT_ASSET_PATHS = "";
|
||||||
"DEVELOPMENT_TEAM[sdk=appletvos*]" = 78Z5H3M6RJ;
|
"DEVELOPMENT_TEAM[sdk=appletvos*]" = 78Z5H3M6RJ;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -4774,7 +4774,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 210;
|
CURRENT_PROJECT_VERSION = 211;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
@@ -4797,7 +4797,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 210;
|
CURRENT_PROJECT_VERSION = 211;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
@@ -5079,7 +5079,7 @@
|
|||||||
minimumVersion = 5.0.2;
|
minimumVersion = 5.0.2;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
37EE6DC328A305AD00BFD632 /* XCRemoteSwiftPackageReference "Reachability.swift" */ = {
|
37EE6DC328A305AD00BFD632 /* XCRemoteSwiftPackageReference "Reachability" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/ashleymills/Reachability.swift";
|
repositoryURL = "https://github.com/ashleymills/Reachability.swift";
|
||||||
requirement = {
|
requirement = {
|
||||||
@@ -5361,7 +5361,7 @@
|
|||||||
};
|
};
|
||||||
37EE6DC428A305AD00BFD632 /* Reachability */ = {
|
37EE6DC428A305AD00BFD632 /* Reachability */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = 37EE6DC328A305AD00BFD632 /* XCRemoteSwiftPackageReference "Reachability.swift" */;
|
package = 37EE6DC328A305AD00BFD632 /* XCRemoteSwiftPackageReference "Reachability" */;
|
||||||
productName = Reachability;
|
productName = Reachability;
|
||||||
};
|
};
|
||||||
37FB2848272207F000A57617 /* SDWebImageWebPCoder */ = {
|
37FB2848272207F000A57617 /* SDWebImageWebPCoder */ = {
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
"location" : "https://github.com/mpvkit/MPVKit.git",
|
"location" : "https://github.com/mpvkit/MPVKit.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"branch" : "main",
|
"branch" : "main",
|
||||||
"revision" : "360b5002bf607a94f24ec8977db94bd9811d5357"
|
"revision" : "fef0f54bfd7e37e0547e057880b28992540ddbcc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ enum Orientation {
|
|||||||
static var logger = Logger(label: "stream.yattee.orientation")
|
static var logger = Logger(label: "stream.yattee.orientation")
|
||||||
|
|
||||||
static func lockOrientation(_ orientation: UIInterfaceOrientationMask) {
|
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 {
|
if let delegate = AppDelegate.instance {
|
||||||
delegate.orientationLock = orientation
|
delegate.orientationLock = orientation
|
||||||
|
|
||||||
@@ -18,6 +24,12 @@ enum Orientation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func lockOrientation(_ orientation: UIInterfaceOrientationMask, andRotateTo rotateOrientation: UIInterfaceOrientation? = nil) {
|
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)
|
lockOrientation(orientation)
|
||||||
|
|
||||||
guard let rotateOrientation else {
|
guard let rotateOrientation else {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ struct InstancesSettings: View {
|
|||||||
@State private var frontendURL = ""
|
@State private var frontendURL = ""
|
||||||
@State private var proxiesVideos = false
|
@State private var proxiesVideos = false
|
||||||
@State private var invidiousCompanion = false
|
@State private var invidiousCompanion = false
|
||||||
|
@State private var hideVideosWithoutDuration = false
|
||||||
|
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
@ObservedObject private var accounts = AccountsModel.shared
|
@ObservedObject private var accounts = AccountsModel.shared
|
||||||
@@ -109,6 +110,18 @@ struct InstancesSettings: View {
|
|||||||
.onChange(of: invidiousCompanion) { newValue in
|
.onChange(of: invidiousCompanion) { newValue in
|
||||||
InstancesModel.shared.setInvidiousCompanion(selectedInstance, newValue)
|
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 {
|
if selectedInstance != nil, !selectedInstance.app.supportsAccounts {
|
||||||
@@ -201,6 +214,10 @@ struct InstancesSettings: View {
|
|||||||
private var invidiousCompanionToggle: some View {
|
private var invidiousCompanionToggle: some View {
|
||||||
Toggle("Invidious companion", isOn: $invidiousCompanion)
|
Toggle("Invidious companion", isOn: $invidiousCompanion)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var hideVideosWithoutDurationToggle: some View {
|
||||||
|
Toggle("Experimental: Hide videos without duration", isOn: $hideVideosWithoutDuration)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct InstancesSettingsView_Previews: PreviewProvider {
|
struct InstancesSettingsView_Previews: PreviewProvider {
|
||||||
|
|||||||
@@ -4,25 +4,52 @@ struct ControlsOverlayButton<LabelView: View>: View {
|
|||||||
var focusedField: FocusState<ControlsOverlay.Field?>.Binding
|
var focusedField: FocusState<ControlsOverlay.Field?>.Binding
|
||||||
var field: ControlsOverlay.Field
|
var field: ControlsOverlay.Field
|
||||||
let label: LabelView
|
let label: LabelView
|
||||||
|
var onSelect: (() -> Void)?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
focusedField: FocusState<ControlsOverlay.Field?>.Binding,
|
focusedField: FocusState<ControlsOverlay.Field?>.Binding,
|
||||||
field: ControlsOverlay.Field,
|
field: ControlsOverlay.Field,
|
||||||
|
onSelect: (() -> Void)? = nil,
|
||||||
@ViewBuilder label: @escaping () -> LabelView
|
@ViewBuilder label: @escaping () -> LabelView
|
||||||
) {
|
) {
|
||||||
self.focusedField = focusedField
|
self.focusedField = focusedField
|
||||||
self.field = field
|
self.field = field
|
||||||
|
self.onSelect = onSelect
|
||||||
self.label = label()
|
self.label = label()
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
let isFocused = focusedField.wrappedValue == field
|
||||||
|
|
||||||
|
if let onSelect {
|
||||||
|
Button(action: onSelect) {
|
||||||
label
|
label
|
||||||
|
.foregroundColor(isFocused ? .black : .white)
|
||||||
|
.padding()
|
||||||
|
.frame(width: 400)
|
||||||
|
}
|
||||||
|
.buttonStyle(TVButtonStyle(isFocused: isFocused))
|
||||||
|
.focused(focusedField, equals: field)
|
||||||
|
} else {
|
||||||
|
label
|
||||||
|
.foregroundColor(isFocused ? .black : .white)
|
||||||
.padding()
|
.padding()
|
||||||
.frame(width: 400)
|
.frame(width: 400)
|
||||||
.focusable()
|
.focusable()
|
||||||
.focused(focusedField, equals: field)
|
.focused(focusedField, equals: field)
|
||||||
.background(focusedField.wrappedValue == field ? Color.white : Color.secondary)
|
.background(isFocused ? Color.white : Color.gray.opacity(0.5))
|
||||||
.foregroundColor(focusedField.wrappedValue == field ? Color.black : Color.white)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
.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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user