mirror of
https://github.com/yattee/yattee.git
synced 2025-12-16 21:18:16 +00:00
Compare commits
50 Commits
1.5.2-193
...
refined-au
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4855f9bead | ||
|
|
a65ed67751 | ||
|
|
8596ee8811 | ||
|
|
894439ad5e | ||
|
|
5dad7a1b47 | ||
|
|
6d48a825cd | ||
|
|
ed11e593ff | ||
|
|
102dfba751 | ||
|
|
4202b27c03 | ||
|
|
2f937f74fa | ||
|
|
34a957b28e | ||
|
|
0bef798341 | ||
|
|
28a7b6e981 | ||
|
|
4663aab3da | ||
|
|
0de0445805 | ||
|
|
9cb0325503 | ||
|
|
5e85fd294c | ||
|
|
b2421da95d | ||
|
|
4e4add3c42 | ||
|
|
2185718d50 | ||
|
|
b0264aaabe | ||
|
|
035f3503c4 | ||
|
|
e3ac11c172 | ||
|
|
7aed6ac0d9 | ||
|
|
457c0ce7b3 | ||
|
|
747baf3edd | ||
|
|
cd24a0322f | ||
|
|
d525a22215 | ||
|
|
322a550666 | ||
|
|
98fa0b98e5 | ||
|
|
5313e4ead0 | ||
|
|
fa7b897e76 | ||
|
|
9bf3df1a29 | ||
|
|
34a805b986 | ||
|
|
36f680be62 | ||
|
|
a27ab02433 | ||
|
|
59dd0785b3 | ||
|
|
d7be915e7e | ||
|
|
3752f67630 | ||
|
|
dfe7565138 | ||
|
|
4d02538cb9 | ||
|
|
3229528a09 | ||
|
|
fffc4f4a5f | ||
|
|
e85bfe5007 | ||
|
|
b00b733fd5 | ||
|
|
119c663436 | ||
|
|
e8fcee23ef | ||
|
|
d56ef74a99 | ||
|
|
98f5b1a22b | ||
|
|
f0b7bd3ab8 |
38
CHANGELOG.md
38
CHANGELOG.md
@@ -1,13 +1,10 @@
|
|||||||
## Build 193
|
## Build 196
|
||||||
* Invidious: propper HTTP basic auth support by @stonerl in https://github.com/yattee/yattee/pull/762
|
* Orientation/Fullscreen fixes and cleanup by @stonerl in https://github.com/yattee/yattee/pull/806
|
||||||
* Apply correct orientation by @stonerl in https://github.com/yattee/yattee/pull/770
|
* More robust resolution handling by @stonerl in https://github.com/yattee/yattee/pull/807
|
||||||
* Circular Invidious logo by @stonerl in https://github.com/yattee/yattee/pull/769
|
* MPV: improved A/V sync by @stonerl in https://github.com/yattee/yattee/pull/805
|
||||||
* Video Thumbnails: retry 3 times fetching from URL by @stonerl in https://github.com/yattee/yattee/pull/768
|
* Retry loading video before presenting error by @stonerl in https://github.com/yattee/yattee/pull/810
|
||||||
* Make thumbnail fill the view in music mode by @stonerl in https://github.com/yattee/yattee/pull/766
|
* Refactor Search by @stonerl in https://github.com/yattee/yattee/pull/809
|
||||||
* Changes to defaults by @stonerl in https://github.com/yattee/yattee/pull/767
|
* Updated dependencies
|
||||||
* Fixed fullscreen handling for backgrounding by @stonerl in https://github.com/yattee/yattee/pull/772
|
|
||||||
* Update now playing info when using system controls – Partial fix for 503 by @stonerl in https://github.com/yattee/yattee/pull/765
|
|
||||||
* Fix crash on HLS live playback by @stonerl in https://github.com/yattee/yattee/pull/775
|
|
||||||
|
|
||||||
## Previous builds
|
## Previous builds
|
||||||
* Add skip, play/pause, and fullscreen shortcuts to macOS player (by @rickykresslein)
|
* Add skip, play/pause, and fullscreen shortcuts to macOS player (by @rickykresslein)
|
||||||
@@ -26,6 +23,27 @@
|
|||||||
* Add import export of missing settings
|
* Add import export of missing settings
|
||||||
* macOS: Fix settings windows layout
|
* macOS: Fix settings windows layout
|
||||||
* Fix seek OSD layout on tvOS, revert OSD position
|
* Fix seek OSD layout on tvOS, revert OSD position
|
||||||
|
* iOS: Simplified fullscreen and orientation by @stonerl in https://github.com/yattee/yattee/pull/786
|
||||||
|
* macOS: only apply player shortcuts when window is active by @stonerl in https://github.com/yattee/yattee/pull/802
|
||||||
|
* player controls: add background opacity selection by @stonerl in https://github.com/yattee/yattee/pull/799
|
||||||
|
* add missing Shorts resolutions by @stonerl in https://github.com/yattee/yattee/pull/797
|
||||||
|
* use -O1 on macOS by @stonerl in https://github.com/yattee/yattee/pull/801
|
||||||
|
* Gestures: swipe up toggles fullscreen by @stonerl in https://github.com/yattee/yattee/pull/778
|
||||||
|
* don’t open video when dismissing context menu by @stonerl in https://github.com/yattee/yattee/pull/780
|
||||||
|
* mpv: remove video layer when entering background by @stonerl in https://github.com/yattee/yattee/pull/793
|
||||||
|
* hi-res invidious logos by @stonerl in https://github.com/yattee/yattee/pull/791
|
||||||
|
* enable -O3 by @stonerl in https://github.com/yattee/yattee/pull/794
|
||||||
|
* Better audio ducking by @stonerl in https://github.com/yattee/yattee/pull/779
|
||||||
|
* fix picture in picture by @stonerl in https://github.com/yattee/yattee/pull/789
|
||||||
|
* Invidious: propper HTTP basic auth support by @stonerl in https://github.com/yattee/yattee/pull/762
|
||||||
|
* Apply correct orientation by @stonerl in https://github.com/yattee/yattee/pull/770
|
||||||
|
* Circular Invidious logo by @stonerl in https://github.com/yattee/yattee/pull/769
|
||||||
|
* Video Thumbnails: retry 3 times fetching from URL by @stonerl in https://github.com/yattee/yattee/pull/768
|
||||||
|
* Make thumbnail fill the view in music mode by @stonerl in https://github.com/yattee/yattee/pull/766
|
||||||
|
* Changes to defaults by @stonerl in https://github.com/yattee/yattee/pull/767
|
||||||
|
* Fixed fullscreen handling for backgrounding by @stonerl in https://github.com/yattee/yattee/pull/772
|
||||||
|
* Update now playing info when using system controls – Partial fix for 503 by @stonerl in https://github.com/yattee/yattee/pull/765
|
||||||
|
* Fix crash on HLS live playback by @stonerl in https://github.com/yattee/yattee/pull/775
|
||||||
* Fix mpv crashing on macOS by @stonerl in https://github.com/yattee/yattee/pull/754
|
* Fix mpv crashing on macOS by @stonerl in https://github.com/yattee/yattee/pull/754
|
||||||
* Refreshed icons for iOS and macOS by @stonerl in https://github.com/yattee/yattee/pull/752
|
* Refreshed icons for iOS and macOS by @stonerl in https://github.com/yattee/yattee/pull/752
|
||||||
* Add new MPVKit repo by @stonerl in https://github.com/yattee/yattee/pull/753
|
* Add new MPVKit repo by @stonerl in https://github.com/yattee/yattee/pull/753
|
||||||
|
|||||||
16
Gemfile.lock
16
Gemfile.lock
@@ -10,17 +10,17 @@ GEM
|
|||||||
artifactory (3.0.17)
|
artifactory (3.0.17)
|
||||||
atomos (0.1.3)
|
atomos (0.1.3)
|
||||||
aws-eventstream (1.3.0)
|
aws-eventstream (1.3.0)
|
||||||
aws-partitions (1.970.0)
|
aws-partitions (1.973.0)
|
||||||
aws-sdk-core (3.202.2)
|
aws-sdk-core (3.204.0)
|
||||||
aws-eventstream (~> 1, >= 1.3.0)
|
aws-eventstream (~> 1, >= 1.3.0)
|
||||||
aws-partitions (~> 1, >= 1.651.0)
|
aws-partitions (~> 1, >= 1.651.0)
|
||||||
aws-sigv4 (~> 1.9)
|
aws-sigv4 (~> 1.9)
|
||||||
jmespath (~> 1, >= 1.6.1)
|
jmespath (~> 1, >= 1.6.1)
|
||||||
aws-sdk-kms (1.88.0)
|
aws-sdk-kms (1.90.0)
|
||||||
aws-sdk-core (~> 3, >= 3.201.0)
|
aws-sdk-core (~> 3, >= 3.203.0)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sdk-s3 (1.159.0)
|
aws-sdk-s3 (1.161.0)
|
||||||
aws-sdk-core (~> 3, >= 3.201.0)
|
aws-sdk-core (~> 3, >= 3.203.0)
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sigv4 (1.9.1)
|
aws-sigv4 (1.9.1)
|
||||||
@@ -171,8 +171,7 @@ GEM
|
|||||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||||
uber (< 0.2.0)
|
uber (< 0.2.0)
|
||||||
retriable (3.1.2)
|
retriable (3.1.2)
|
||||||
rexml (3.3.6)
|
rexml (3.3.7)
|
||||||
strscan
|
|
||||||
rouge (2.0.7)
|
rouge (2.0.7)
|
||||||
ruby2_keywords (0.0.5)
|
ruby2_keywords (0.0.5)
|
||||||
rubyzip (2.3.2)
|
rubyzip (2.3.2)
|
||||||
@@ -185,7 +184,6 @@ GEM
|
|||||||
simctl (1.6.10)
|
simctl (1.6.10)
|
||||||
CFPropertyList
|
CFPropertyList
|
||||||
naturally
|
naturally
|
||||||
strscan (3.1.0)
|
|
||||||
terminal-notifier (2.0.0)
|
terminal-notifier (2.0.0)
|
||||||
terminal-table (3.0.2)
|
terminal-table (3.0.2)
|
||||||
unicode-display_width (>= 1.1.1, < 3)
|
unicode-display_width (>= 1.1.1, < 3)
|
||||||
|
|||||||
@@ -515,7 +515,8 @@ final class PeerTubeAPI: Service, ObservableObject, VideosAPI {
|
|||||||
.dictionaryValue["files"]?.arrayValue.first?
|
.dictionaryValue["files"]?.arrayValue.first?
|
||||||
.dictionaryValue["fileUrl"]?.url
|
.dictionaryValue["fileUrl"]?.url
|
||||||
{
|
{
|
||||||
streams.append(SingleAssetStream(instance: account.instance, avAsset: AVURLAsset(url: fileURL), resolution: .hd720p30, kind: .stream))
|
let resolution = Stream.Resolution.predefined(.hd720p30)
|
||||||
|
streams.append(SingleAssetStream(instance: account.instance, avAsset: AVURLAsset(url: fileURL), resolution: resolution, kind: .stream))
|
||||||
}
|
}
|
||||||
|
|
||||||
return streams
|
return streams
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ final class AdvancedSettingsGroupExporter: SettingsGroupExporter {
|
|||||||
override var globalJSON: JSON {
|
override var globalJSON: JSON {
|
||||||
[
|
[
|
||||||
"showPlayNowInBackendContextMenu": Defaults[.showPlayNowInBackendContextMenu],
|
"showPlayNowInBackendContextMenu": Defaults[.showPlayNowInBackendContextMenu],
|
||||||
|
"videoLoadingRetryCount": Defaults[.videoLoadingRetryCount],
|
||||||
"showMPVPlaybackStats": Defaults[.showMPVPlaybackStats],
|
"showMPVPlaybackStats": Defaults[.showMPVPlaybackStats],
|
||||||
"mpvEnableLogging": Defaults[.mpvEnableLogging],
|
"mpvEnableLogging": Defaults[.mpvEnableLogging],
|
||||||
"mpvCacheSecs": Defaults[.mpvCacheSecs],
|
"mpvCacheSecs": Defaults[.mpvCacheSecs],
|
||||||
@@ -13,6 +14,7 @@ final class AdvancedSettingsGroupExporter: SettingsGroupExporter {
|
|||||||
"mpvDeinterlace": Defaults[.mpvDeinterlace],
|
"mpvDeinterlace": Defaults[.mpvDeinterlace],
|
||||||
"mpvHWdec": Defaults[.mpvHWdec],
|
"mpvHWdec": Defaults[.mpvHWdec],
|
||||||
"mpvDemuxerLavfProbeInfo": Defaults[.mpvDemuxerLavfProbeInfo],
|
"mpvDemuxerLavfProbeInfo": Defaults[.mpvDemuxerLavfProbeInfo],
|
||||||
|
"mpvSetRefreshToContentFPS": Defaults[.mpvSetRefreshToContentFPS],
|
||||||
"mpvInitialAudioSync": Defaults[.mpvInitialAudioSync],
|
"mpvInitialAudioSync": Defaults[.mpvInitialAudioSync],
|
||||||
"showCacheStatus": Defaults[.showCacheStatus],
|
"showCacheStatus": Defaults[.showCacheStatus],
|
||||||
"feedCacheSize": Defaults[.feedCacheSize]
|
"feedCacheSize": Defaults[.feedCacheSize]
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ final class BrowsingSettingsGroupExporter: SettingsGroupExporter {
|
|||||||
"favorites": Defaults[.favorites].compactMap { jsonFromString(FavoriteItem.bridge.serialize($0)) },
|
"favorites": Defaults[.favorites].compactMap { jsonFromString(FavoriteItem.bridge.serialize($0)) },
|
||||||
"widgetsSettings": Defaults[.widgetsSettings].compactMap { widgetSettingsJSON($0) },
|
"widgetsSettings": Defaults[.widgetsSettings].compactMap { widgetSettingsJSON($0) },
|
||||||
"startupSection": Defaults[.startupSection].rawValue,
|
"startupSection": Defaults[.startupSection].rawValue,
|
||||||
|
"showSearchSuggestions": Defaults[.showSearchSuggestions],
|
||||||
"visibleSections": Defaults[.visibleSections].compactMap { $0.rawValue },
|
"visibleSections": Defaults[.visibleSections].compactMap { $0.rawValue },
|
||||||
"showOpenActionsToolbarItem": Defaults[.showOpenActionsToolbarItem],
|
"showOpenActionsToolbarItem": Defaults[.showOpenActionsToolbarItem],
|
||||||
"accountPickerDisplaysAnonymousAccounts": Defaults[.accountPickerDisplaysAnonymousAccounts],
|
"accountPickerDisplaysAnonymousAccounts": Defaults[.accountPickerDisplaysAnonymousAccounts],
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ final class ConstrolsSettingsGroupExporter: SettingsGroupExporter {
|
|||||||
"seekGestureSpeed": Defaults[.seekGestureSpeed],
|
"seekGestureSpeed": Defaults[.seekGestureSpeed],
|
||||||
"playerControlsLayout": Defaults[.playerControlsLayout].rawValue,
|
"playerControlsLayout": Defaults[.playerControlsLayout].rawValue,
|
||||||
"fullScreenPlayerControlsLayout": Defaults[.fullScreenPlayerControlsLayout].rawValue,
|
"fullScreenPlayerControlsLayout": Defaults[.fullScreenPlayerControlsLayout].rawValue,
|
||||||
|
"playerControlsBackgroundOpacity": Defaults[.playerControlsBackgroundOpacity],
|
||||||
"systemControlsCommands": Defaults[.systemControlsCommands].rawValue,
|
"systemControlsCommands": Defaults[.systemControlsCommands].rawValue,
|
||||||
"buttonBackwardSeekDuration": Defaults[.buttonBackwardSeekDuration],
|
"buttonBackwardSeekDuration": Defaults[.buttonBackwardSeekDuration],
|
||||||
"buttonForwardSeekDuration": Defaults[.buttonForwardSeekDuration],
|
"buttonForwardSeekDuration": Defaults[.buttonForwardSeekDuration],
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ final class PlayerSettingsGroupExporter: SettingsGroupExporter {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
export["honorSystemOrientationLock"].bool = Defaults[.honorSystemOrientationLock]
|
export["isOrientationLocked"].bool = Defaults[.isOrientationLocked]
|
||||||
export["enterFullscreenInLandscape"].bool = Defaults[.enterFullscreenInLandscape]
|
export["enterFullscreenInLandscape"].bool = Defaults[.enterFullscreenInLandscape]
|
||||||
export["rotateToLandscapeOnEnterFullScreen"].string = Defaults[.rotateToLandscapeOnEnterFullScreen].rawValue
|
export["rotateToLandscapeOnEnterFullScreen"].string = Defaults[.rotateToLandscapeOnEnterFullScreen].rawValue
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ struct AdvancedSettingsGroupImporter {
|
|||||||
Defaults[.showPlayNowInBackendContextMenu] = showPlayNowInBackendContextMenu
|
Defaults[.showPlayNowInBackendContextMenu] = showPlayNowInBackendContextMenu
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let videoLoadingRetryCount = json["videoLoadingRetryCount"].int {
|
||||||
|
Defaults[.videoLoadingRetryCount] = videoLoadingRetryCount
|
||||||
|
}
|
||||||
|
|
||||||
if let showMPVPlaybackStats = json["showMPVPlaybackStats"].bool {
|
if let showMPVPlaybackStats = json["showMPVPlaybackStats"].bool {
|
||||||
Defaults[.showMPVPlaybackStats] = showMPVPlaybackStats
|
Defaults[.showMPVPlaybackStats] = showMPVPlaybackStats
|
||||||
}
|
}
|
||||||
@@ -41,6 +45,10 @@ struct AdvancedSettingsGroupImporter {
|
|||||||
Defaults[.mpvDemuxerLavfProbeInfo] = mpvDemuxerLavfProbeInfo
|
Defaults[.mpvDemuxerLavfProbeInfo] = mpvDemuxerLavfProbeInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let mpvSetRefreshToContentFPS = json["mpvSetRefreshToContentFPS"].bool {
|
||||||
|
Defaults[.mpvSetRefreshToContentFPS] = mpvSetRefreshToContentFPS
|
||||||
|
}
|
||||||
|
|
||||||
if let mpvInitialAudioSync = json["mpvInitialAudioSync"].bool {
|
if let mpvInitialAudioSync = json["mpvInitialAudioSync"].bool {
|
||||||
Defaults[.mpvInitialAudioSync] = mpvInitialAudioSync
|
Defaults[.mpvInitialAudioSync] = mpvInitialAudioSync
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ struct BrowsingSettingsGroupImporter {
|
|||||||
Defaults[.startupSection] = startupSection
|
Defaults[.startupSection] = startupSection
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let showSearchSuggestions = json["showSearchSuggestions"].bool {
|
||||||
|
Defaults[.showSearchSuggestions] = showSearchSuggestions
|
||||||
|
}
|
||||||
|
|
||||||
if let visibleSections = json["visibleSections"].array {
|
if let visibleSections = json["visibleSections"].array {
|
||||||
let sections = visibleSections.compactMap { visibleSectionJSON in
|
let sections = visibleSections.compactMap { visibleSectionJSON in
|
||||||
if let visibleSectionString = visibleSectionJSON.rawString(options: []),
|
if let visibleSectionString = visibleSectionJSON.rawString(options: []),
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ struct ConstrolsSettingsGroupImporter {
|
|||||||
Defaults[.fullScreenPlayerControlsLayout] = fullScreenPlayerControlsLayout
|
Defaults[.fullScreenPlayerControlsLayout] = fullScreenPlayerControlsLayout
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let playerControlsBackgroundOpacity = json["playerControlsBackgroundOpacity"].double {
|
||||||
|
Defaults[.playerControlsBackgroundOpacity] = playerControlsBackgroundOpacity
|
||||||
|
}
|
||||||
|
|
||||||
if let systemControlsCommandsString = json["systemControlsCommands"].string,
|
if let systemControlsCommandsString = json["systemControlsCommands"].string,
|
||||||
let systemControlsCommands = SystemControlsCommands(rawValue: systemControlsCommandsString)
|
let systemControlsCommands = SystemControlsCommands(rawValue: systemControlsCommandsString)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -97,8 +97,8 @@ struct PlayerSettingsGroupImporter {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
if let honorSystemOrientationLock = json["honorSystemOrientationLock"].bool {
|
if let isOrientationLocked = json["isOrientationLocked"].bool {
|
||||||
Defaults[.honorSystemOrientationLock] = honorSystemOrientationLock
|
Defaults[.isOrientationLocked] = isOrientationLocked
|
||||||
}
|
}
|
||||||
|
|
||||||
if let enterFullscreenInLandscape = json["enterFullscreenInLandscape"].bool {
|
if let enterFullscreenInLandscape = json["enterFullscreenInLandscape"].bool {
|
||||||
|
|||||||
@@ -181,7 +181,9 @@ final class AVPlayerBackend: PlayerBackend {
|
|||||||
{
|
{
|
||||||
seek(to: 0, seekType: .loopRestart)
|
seek(to: 0, seekType: .loopRestart)
|
||||||
}
|
}
|
||||||
|
#if !os(macOS)
|
||||||
|
model.setAudioSessionActive(true)
|
||||||
|
#endif
|
||||||
avPlayer.play()
|
avPlayer.play()
|
||||||
|
|
||||||
// Setting hasStarted to true the first time player started
|
// Setting hasStarted to true the first time player started
|
||||||
@@ -196,7 +198,9 @@ final class AVPlayerBackend: PlayerBackend {
|
|||||||
guard avPlayer.timeControlStatus != .paused else {
|
guard avPlayer.timeControlStatus != .paused else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
#if !os(macOS)
|
||||||
|
model.setAudioSessionActive(false)
|
||||||
|
#endif
|
||||||
avPlayer.pause()
|
avPlayer.pause()
|
||||||
model.objectWillChange.send()
|
model.objectWillChange.send()
|
||||||
}
|
}
|
||||||
@@ -210,6 +214,9 @@ final class AVPlayerBackend: PlayerBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
|
#if !os(macOS)
|
||||||
|
model.setAudioSessionActive(false)
|
||||||
|
#endif
|
||||||
avPlayer.replaceCurrentItem(with: nil)
|
avPlayer.replaceCurrentItem(with: nil)
|
||||||
hasStarted = false
|
hasStarted = false
|
||||||
}
|
}
|
||||||
@@ -364,7 +371,7 @@ final class AVPlayerBackend: PlayerBackend {
|
|||||||
|
|
||||||
let startPlaying = {
|
let startPlaying = {
|
||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
try? AVAudioSession.sharedInstance().setActive(true)
|
self.model.setAudioSessionActive(true)
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
self.setRate(self.model.currentRate)
|
self.setRate(self.model.currentRate)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SwiftUI
|
|||||||
final class MPVBackend: PlayerBackend {
|
final class MPVBackend: PlayerBackend {
|
||||||
static var timeUpdateInterval = 0.5
|
static var timeUpdateInterval = 0.5
|
||||||
static var networkStateUpdateInterval = 0.1
|
static var networkStateUpdateInterval = 0.1
|
||||||
|
static var refreshRateUpdateInterval = 0.5
|
||||||
|
|
||||||
private var logger = Logger(label: "mpv-backend")
|
private var logger = Logger(label: "mpv-backend")
|
||||||
|
|
||||||
@@ -24,7 +25,9 @@ final class MPVBackend: PlayerBackend {
|
|||||||
var video: Video?
|
var video: Video?
|
||||||
var captions: Captions? { didSet {
|
var captions: Captions? { didSet {
|
||||||
guard let captions else {
|
guard let captions else {
|
||||||
|
if client?.areSubtitlesAdded == true {
|
||||||
client?.removeSubs()
|
client?.removeSubs()
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
addSubTrack(captions.url)
|
addSubTrack(captions.url)
|
||||||
@@ -89,6 +92,7 @@ final class MPVBackend: PlayerBackend {
|
|||||||
|
|
||||||
private var clientTimer: Repeater!
|
private var clientTimer: Repeater!
|
||||||
private var networkStateTimer: Repeater!
|
private var networkStateTimer: Repeater!
|
||||||
|
private var refreshRateTimer: Repeater!
|
||||||
|
|
||||||
private var onFileLoaded: (() -> Void)?
|
private var onFileLoaded: (() -> Void)?
|
||||||
|
|
||||||
@@ -184,27 +188,30 @@ final class MPVBackend: PlayerBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// swiftlint:disable shorthand_optional_binding
|
|
||||||
clientTimer = .init(interval: .seconds(Self.timeUpdateInterval), mode: .infinite) { [weak self] _ in
|
clientTimer = .init(interval: .seconds(Self.timeUpdateInterval), mode: .infinite) { [weak self] _ in
|
||||||
guard let self = self, self.model.activeBackend == .mpv else {
|
guard let self, self.model.activeBackend == .mpv else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self.getTimeUpdates()
|
self.getTimeUpdates()
|
||||||
}
|
}
|
||||||
|
|
||||||
networkStateTimer = .init(interval: .seconds(Self.networkStateUpdateInterval), mode: .infinite) { [weak self] _ in
|
networkStateTimer = .init(interval: .seconds(Self.networkStateUpdateInterval), mode: .infinite) { [weak self] _ in
|
||||||
guard let self = self, self.model.activeBackend == .mpv else {
|
guard let self, self.model.activeBackend == .mpv else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self.updateNetworkState()
|
self.updateNetworkState()
|
||||||
}
|
}
|
||||||
// swiftlint:enable shorthand_optional_binding
|
|
||||||
|
refreshRateTimer = .init(interval: .seconds(Self.refreshRateUpdateInterval), mode: .infinite) { [weak self] _ in
|
||||||
|
guard let self, self.model.activeBackend == .mpv else { return }
|
||||||
|
self.checkAndUpdateRefreshRate()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
typealias AreInIncreasingOrder = (Stream, Stream) -> Bool
|
typealias AreInIncreasingOrder = (Stream, Stream) -> Bool
|
||||||
|
|
||||||
func canPlay(_ stream: Stream) -> Bool {
|
func canPlay(_ stream: Stream) -> Bool {
|
||||||
stream.resolution != .unknown && stream.format != .av1
|
stream.format != .av1
|
||||||
}
|
}
|
||||||
|
|
||||||
func playStream(_ stream: Stream, of video: Video, preservingTime: Bool, upgrading: Bool) {
|
func playStream(_ stream: Stream, of video: Video, preservingTime: Bool, upgrading: Bool) {
|
||||||
@@ -246,18 +253,7 @@ final class MPVBackend: PlayerBackend {
|
|||||||
|
|
||||||
let startPlaying = {
|
let startPlaying = {
|
||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
do {
|
self.model.setAudioSessionActive(true)
|
||||||
try AVAudioSession.sharedInstance().setActive(true)
|
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
self,
|
|
||||||
selector: #selector(self.handleAudioSessionInterruption(_:)),
|
|
||||||
name: AVAudioSession.interruptionNotification,
|
|
||||||
object: nil
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
self.logger.error("Error setting up audio session: \(error)")
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
@@ -350,8 +346,20 @@ final class MPVBackend: PlayerBackend {
|
|||||||
startClientUpdates()
|
startClientUpdates()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func startRefreshRateUpdates() {
|
||||||
|
refreshRateTimer.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopRefreshRateUpdates() {
|
||||||
|
refreshRateTimer.pause()
|
||||||
|
}
|
||||||
|
|
||||||
func play() {
|
func play() {
|
||||||
|
#if !os(macOS)
|
||||||
|
model.setAudioSessionActive(true)
|
||||||
|
#endif
|
||||||
startClientUpdates()
|
startClientUpdates()
|
||||||
|
startRefreshRateUpdates()
|
||||||
|
|
||||||
if controls.presentingControls {
|
if controls.presentingControls {
|
||||||
startControlsUpdates()
|
startControlsUpdates()
|
||||||
@@ -378,7 +386,11 @@ final class MPVBackend: PlayerBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func pause() {
|
func pause() {
|
||||||
|
#if !os(macOS)
|
||||||
|
model.setAudioSessionActive(false)
|
||||||
|
#endif
|
||||||
stopClientUpdates()
|
stopClientUpdates()
|
||||||
|
stopRefreshRateUpdates()
|
||||||
|
|
||||||
client?.pause()
|
client?.pause()
|
||||||
isPaused = true
|
isPaused = true
|
||||||
@@ -398,6 +410,11 @@ final class MPVBackend: PlayerBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
|
#if !os(macOS)
|
||||||
|
model.setAudioSessionActive(false)
|
||||||
|
#endif
|
||||||
|
stopClientUpdates()
|
||||||
|
stopRefreshRateUpdates()
|
||||||
client?.stop()
|
client?.stop()
|
||||||
isPlaying = false
|
isPlaying = false
|
||||||
isPaused = false
|
isPaused = false
|
||||||
@@ -479,6 +496,52 @@ final class MPVBackend: PlayerBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func checkAndUpdateRefreshRate() {
|
||||||
|
guard let screenRefreshRate = client?.getScreenRefreshRate() else {
|
||||||
|
logger.warning("Failed to get screen refresh rate.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let contentFps = client?.currentContainerFps ?? screenRefreshRate
|
||||||
|
|
||||||
|
guard Defaults[.mpvSetRefreshToContentFPS] else {
|
||||||
|
// If the current refresh rate doesn't match the screen refresh rate, reset it
|
||||||
|
if client?.currentRefreshRate != screenRefreshRate {
|
||||||
|
client?.updateRefreshRate(to: screenRefreshRate)
|
||||||
|
client?.currentRefreshRate = screenRefreshRate
|
||||||
|
#if !os(macOS)
|
||||||
|
notifyViewToUpdateDisplayLink(with: screenRefreshRate)
|
||||||
|
#endif
|
||||||
|
logger.info("Reset refresh rate to screen's rate: \(screenRefreshRate) Hz")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust the refresh rate to match the content if it differs
|
||||||
|
if screenRefreshRate != contentFps {
|
||||||
|
client?.updateRefreshRate(to: contentFps)
|
||||||
|
client?.currentRefreshRate = contentFps
|
||||||
|
#if !os(macOS)
|
||||||
|
notifyViewToUpdateDisplayLink(with: contentFps)
|
||||||
|
#endif
|
||||||
|
logger.info("Adjusted screen refresh rate to match content: \(contentFps) Hz")
|
||||||
|
} else if client?.currentRefreshRate != screenRefreshRate {
|
||||||
|
// Ensure the refresh rate is set back to the screen's rate if no adjustment is needed
|
||||||
|
client?.updateRefreshRate(to: screenRefreshRate)
|
||||||
|
client?.currentRefreshRate = screenRefreshRate
|
||||||
|
#if !os(macOS)
|
||||||
|
notifyViewToUpdateDisplayLink(with: screenRefreshRate)
|
||||||
|
#endif
|
||||||
|
logger.info("Checked and reset refresh rate to screen's rate: \(screenRefreshRate) Hz")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if !os(macOS)
|
||||||
|
private func notifyViewToUpdateDisplayLink(with refreshRate: Int) {
|
||||||
|
NotificationCenter.default.post(name: .updateDisplayLinkFrameRate, object: nil, userInfo: ["refreshRate": refreshRate])
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
func handle(_ event: UnsafePointer<mpv_event>!) {
|
func handle(_ event: UnsafePointer<mpv_event>!) {
|
||||||
logger.info(.init(stringLiteral: "RECEIVED event: \(String(cString: mpv_event_name(event.pointee.event_id)))"))
|
logger.info(.init(stringLiteral: "RECEIVED event: \(String(cString: mpv_event_name(event.pointee.event_id)))"))
|
||||||
|
|
||||||
@@ -559,7 +622,9 @@ final class MPVBackend: PlayerBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func addSubTrack(_ url: URL) {
|
func addSubTrack(_ url: URL) {
|
||||||
|
if client?.areSubtitlesAdded == true {
|
||||||
client?.removeSubs()
|
client?.removeSubs()
|
||||||
|
}
|
||||||
client?.addSubTrack(url)
|
client?.addSubTrack(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,33 +714,4 @@ final class MPVBackend: PlayerBackend {
|
|||||||
logger.info("MPV backend received unhandled property: \(name)")
|
logger.info("MPV backend received unhandled property: \(name)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if !os(macOS)
|
|
||||||
@objc func handleAudioSessionInterruption(_ notification: Notification) {
|
|
||||||
logger.info("Audio session interruption received.")
|
|
||||||
|
|
||||||
guard let info = notification.userInfo,
|
|
||||||
let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt
|
|
||||||
else {
|
|
||||||
logger.info("AVAudioSessionInterruptionTypeKey is missing or not a UInt in userInfo.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let type = AVAudioSession.InterruptionType(rawValue: typeValue)
|
|
||||||
|
|
||||||
logger.info("Interruption type received: \(String(describing: type))")
|
|
||||||
|
|
||||||
switch type {
|
|
||||||
case .began:
|
|
||||||
pause()
|
|
||||||
logger.info("Audio session interrupted.")
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import Logging
|
|||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
import Siesta
|
import Siesta
|
||||||
import UIKit
|
import UIKit
|
||||||
|
#else
|
||||||
|
import AppKit
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
final class MPVClient: ObservableObject {
|
final class MPVClient: ObservableObject {
|
||||||
@@ -14,6 +16,8 @@ final class MPVClient: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var logger = Logger(label: "mpv-client")
|
private var logger = Logger(label: "mpv-client")
|
||||||
|
private var needsDrawingCooldown = false
|
||||||
|
private var needsDrawingWorkItem: DispatchWorkItem?
|
||||||
|
|
||||||
var mpv: OpaquePointer!
|
var mpv: OpaquePointer!
|
||||||
var mpvGL: OpaquePointer!
|
var mpvGL: OpaquePointer!
|
||||||
@@ -27,6 +31,7 @@ final class MPVClient: ObservableObject {
|
|||||||
var backend: MPVBackend!
|
var backend: MPVBackend!
|
||||||
|
|
||||||
var seeking = false
|
var seeking = false
|
||||||
|
var currentRefreshRate = 60
|
||||||
|
|
||||||
func create(frame: CGRect? = nil) {
|
func create(frame: CGRect? = nil) {
|
||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
@@ -37,7 +42,7 @@ final class MPVClient: ObservableObject {
|
|||||||
|
|
||||||
mpv = mpv_create()
|
mpv = mpv_create()
|
||||||
if mpv == nil {
|
if mpv == nil {
|
||||||
print("failed creating context\n")
|
logger.critical("failed creating context\n")
|
||||||
exit(1)
|
exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +79,27 @@ final class MPVClient: ObservableObject {
|
|||||||
checkError(mpv_set_option_string(mpv, "user-agent", UserAgentManager.shared.userAgent))
|
checkError(mpv_set_option_string(mpv, "user-agent", UserAgentManager.shared.userAgent))
|
||||||
checkError(mpv_set_option_string(mpv, "initial-audio-sync", Defaults[.mpvInitialAudioSync] ? "yes" : "no"))
|
checkError(mpv_set_option_string(mpv, "initial-audio-sync", Defaults[.mpvInitialAudioSync] ? "yes" : "no"))
|
||||||
|
|
||||||
|
// Enable VSYNC – needed for `video-sync`
|
||||||
|
checkError(mpv_set_option_string(mpv, "opengl-swapinterval", "1"))
|
||||||
|
checkError(mpv_set_option_string(mpv, "video-sync", "display-resample"))
|
||||||
|
checkError(mpv_set_option_string(mpv, "interpolation", "yes"))
|
||||||
|
checkError(mpv_set_option_string(mpv, "tscale", "mitchell"))
|
||||||
|
checkError(mpv_set_option_string(mpv, "tscale-window", "blackman"))
|
||||||
|
checkError(mpv_set_option_string(mpv, "vd-lavc-framedrop", "nonref"))
|
||||||
|
checkError(mpv_set_option_string(mpv, "display-fps-override", "\(String(getScreenRefreshRate()))"))
|
||||||
|
|
||||||
|
// CPU //
|
||||||
|
|
||||||
|
// Determine number of threads based on system core count
|
||||||
|
let numberOfCores = ProcessInfo.processInfo.processorCount
|
||||||
|
let threads = numberOfCores * 2
|
||||||
|
|
||||||
|
// Log the number of cores and threads
|
||||||
|
logger.info("Number of CPU cores: \(numberOfCores)")
|
||||||
|
|
||||||
|
// Set the number of threads dynamically
|
||||||
|
checkError(mpv_set_option_string(mpv, "vd-lavc-threads", "\(threads)"))
|
||||||
|
|
||||||
// GPU //
|
// GPU //
|
||||||
|
|
||||||
checkError(mpv_set_option_string(mpv, "hwdec", Defaults[.mpvHWdec]))
|
checkError(mpv_set_option_string(mpv, "hwdec", Defaults[.mpvHWdec]))
|
||||||
@@ -81,7 +107,6 @@ final class MPVClient: ObservableObject {
|
|||||||
|
|
||||||
// We set set everything to OpenGL so MPV doesn't have to probe for other APIs.
|
// We set set everything to OpenGL so MPV doesn't have to probe for other APIs.
|
||||||
checkError(mpv_set_option_string(mpv, "gpu-api", "opengl"))
|
checkError(mpv_set_option_string(mpv, "gpu-api", "opengl"))
|
||||||
checkError(mpv_set_option_string(mpv, "opengl-swapinterval", "0"))
|
|
||||||
|
|
||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
checkError(mpv_set_option_string(mpv, "opengl-es", "yes"))
|
checkError(mpv_set_option_string(mpv, "opengl-es", "yes"))
|
||||||
@@ -112,7 +137,7 @@ final class MPVClient: ObservableObject {
|
|||||||
get_proc_address_ctx: nil
|
get_proc_address_ctx: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
queue = DispatchQueue(label: "mpv")
|
queue = DispatchQueue(label: "mpv", qos: .userInteractive, attributes: [.concurrent])
|
||||||
|
|
||||||
withUnsafeMutablePointer(to: &initParams) { initParams in
|
withUnsafeMutablePointer(to: &initParams) { initParams in
|
||||||
var params = [
|
var params = [
|
||||||
@@ -122,7 +147,7 @@ final class MPVClient: ObservableObject {
|
|||||||
]
|
]
|
||||||
|
|
||||||
if mpv_render_context_create(&mpvGL, mpv, ¶ms) < 0 {
|
if mpv_render_context_create(&mpvGL, mpv, ¶ms) < 0 {
|
||||||
print("failed to initialize mpv GL context")
|
logger.critical("failed to initialize mpv GL context")
|
||||||
exit(1)
|
exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,6 +343,37 @@ final class MPVClient: ObservableObject {
|
|||||||
mpv.isNil ? false : getFlag("eof-reached")
|
mpv.isNil ? false : getFlag("eof-reached")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var currentContainerFps: Int {
|
||||||
|
guard !mpv.isNil else { return 30 }
|
||||||
|
let fps = getDouble("container-fps")
|
||||||
|
return Int(fps.rounded())
|
||||||
|
}
|
||||||
|
|
||||||
|
var areSubtitlesAdded: Bool {
|
||||||
|
guard !mpv.isNil else { return false }
|
||||||
|
|
||||||
|
// Retrieve the number of tracks
|
||||||
|
let trackCount = getInt("track-list/count")
|
||||||
|
guard trackCount > 0 else { return false }
|
||||||
|
|
||||||
|
for index in 0 ..< trackCount {
|
||||||
|
// Get the type of each track
|
||||||
|
if let trackType = getString("track-list/\(index)/type"), trackType == "sub" {
|
||||||
|
// Check if the subtitle track is currently selected
|
||||||
|
let selected = getInt("track-list/\(index)/selected")
|
||||||
|
if selected == 1 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func logCurrentFps() {
|
||||||
|
let fps = currentContainerFps
|
||||||
|
logger.info("Current container FPS: \(fps)")
|
||||||
|
}
|
||||||
|
|
||||||
func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
|
func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
|
||||||
guard !seeking else {
|
guard !seeking else {
|
||||||
logger.warning("ignoring seek, another in progress")
|
logger.warning("ignoring seek, another in progress")
|
||||||
@@ -361,7 +417,7 @@ final class MPVClient: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async(qos: .userInteractive) { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
let model = self.backend.model
|
let model = self.backend.model
|
||||||
let aspectRatio = self.aspectRatio > 0 && self.aspectRatio < VideoPlayerView.defaultAspectRatio ? self.aspectRatio : VideoPlayerView.defaultAspectRatio
|
let aspectRatio = self.aspectRatio > 0 && self.aspectRatio < VideoPlayerView.defaultAspectRatio ? self.aspectRatio : VideoPlayerView.defaultAspectRatio
|
||||||
@@ -389,10 +445,30 @@ final class MPVClient: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func setNeedsDrawing(_ needsDrawing: Bool) {
|
func setNeedsDrawing(_ needsDrawing: Bool) {
|
||||||
|
// Check if we are currently in a cooldown period
|
||||||
|
guard !needsDrawingCooldown else {
|
||||||
|
logger.info("Not drawing, cooldown in progress")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
logger.info("needs drawing: \(needsDrawing)")
|
logger.info("needs drawing: \(needsDrawing)")
|
||||||
|
|
||||||
|
// Set the cooldown flag to true and cancel any existing work item
|
||||||
|
needsDrawingCooldown = true
|
||||||
|
needsDrawingWorkItem?.cancel()
|
||||||
|
|
||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
glView?.needsDrawing = needsDrawing
|
glView?.needsDrawing = needsDrawing
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
// Create a new DispatchWorkItem to reset the cooldown flag after 0.1 seconds
|
||||||
|
let workItem = DispatchWorkItem { [weak self] in
|
||||||
|
self?.needsDrawingCooldown = false
|
||||||
|
}
|
||||||
|
needsDrawingWorkItem = workItem
|
||||||
|
|
||||||
|
// Schedule the cooldown reset after 0.1 seconds
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: workItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
func command(
|
func command(
|
||||||
@@ -420,6 +496,45 @@ final class MPVClient: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateRefreshRate(to refreshRate: Int) {
|
||||||
|
setString("display-fps-override", "\(String(refreshRate))")
|
||||||
|
logger.info("Updated refresh rate during playback to: \(refreshRate) Hz")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve the screen's current refresh rate dynamically.
|
||||||
|
func getScreenRefreshRate() -> Int {
|
||||||
|
var refreshRate = 60 // Default to 60 Hz in case of failure
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
// macOS implementation using NSScreen
|
||||||
|
if let screen = NSScreen.main,
|
||||||
|
let displayID = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID,
|
||||||
|
let mode = CGDisplayCopyDisplayMode(displayID),
|
||||||
|
mode.refreshRate > 0
|
||||||
|
{
|
||||||
|
refreshRate = Int(mode.refreshRate)
|
||||||
|
logger.info("Screen refresh rate: \(refreshRate) Hz")
|
||||||
|
} else {
|
||||||
|
logger.warning("Failed to get refresh rate from NSScreen.")
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
// iOS implementation using UIScreen with a failover
|
||||||
|
let mainScreen = UIScreen.main
|
||||||
|
refreshRate = mainScreen.maximumFramesPerSecond
|
||||||
|
|
||||||
|
// Failover: if maximumFramesPerSecond is 0 or an unexpected value
|
||||||
|
if refreshRate <= 0 {
|
||||||
|
refreshRate = 60 // Fallback to 60 Hz
|
||||||
|
logger.warning("Failed to get refresh rate from UIScreen, falling back to 60 Hz.")
|
||||||
|
} else {
|
||||||
|
logger.info("Screen refresh rate: \(refreshRate) Hz")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
currentRefreshRate = refreshRate
|
||||||
|
return refreshRate
|
||||||
|
}
|
||||||
|
|
||||||
func addVideoTrack(_ url: URL) {
|
func addVideoTrack(_ url: URL) {
|
||||||
command("video-add", args: [url.absoluteString])
|
command("video-add", args: [url.absoluteString])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,8 +153,9 @@ extension PlayerBackend {
|
|||||||
// Filter out non-HLS streams and streams with resolution more than maxResolution
|
// Filter out non-HLS streams and streams with resolution more than maxResolution
|
||||||
let nonHLSStreams = streams.filter {
|
let nonHLSStreams = streams.filter {
|
||||||
let isHLS = $0.kind == .hls
|
let isHLS = $0.kind == .hls
|
||||||
// Safely unwrap resolution and maxResolution.value to avoid crashes
|
// Check if the stream's resolution is within the maximum allowed resolution
|
||||||
let isWithinResolution = ($0.resolution != nil && maxResolution.value != nil) ? $0.resolution! <= maxResolution.value! : false
|
let isWithinResolution = $0.resolution.map { $0 <= maxResolution.value } ?? false
|
||||||
|
|
||||||
logger.info("Stream ID: \($0.id) - Kind: \(String(describing: $0.kind)) - Resolution: \(String(describing: $0.resolution)) - Bitrate: \($0.bitrate ?? 0)")
|
logger.info("Stream ID: \($0.id) - Kind: \(String(describing: $0.kind)) - Resolution: \(String(describing: $0.resolution)) - Bitrate: \($0.bitrate ?? 0)")
|
||||||
logger.info("Is HLS: \(isHLS), Is within resolution: \(isWithinResolution)")
|
logger.info("Is HLS: \(isHLS), Is within resolution: \(isWithinResolution)")
|
||||||
return !isHLS && isWithinResolution
|
return !isHLS && isWithinResolution
|
||||||
@@ -188,8 +189,8 @@ extension PlayerBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let filteredStreams = adjustedStreams.filter { stream in
|
let filteredStreams = adjustedStreams.filter { stream in
|
||||||
// Safely unwrap resolution and maxResolution.value to avoid crashes
|
// Check if the stream's resolution is within the maximum allowed resolution
|
||||||
let isWithinResolution = (stream.resolution != nil && maxResolution.value != nil) ? stream.resolution! <= maxResolution.value! : false
|
let isWithinResolution = stream.resolution <= maxResolution.value
|
||||||
logger.info("Filtered stream ID: \(stream.id) - Is within max resolution: \(isWithinResolution)")
|
logger.info("Filtered stream ID: \(stream.id) - Is within max resolution: \(isWithinResolution)")
|
||||||
return isWithinResolution
|
return isWithinResolution
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
|
|
||||||
static var shared = PlayerModel()
|
static var shared = PlayerModel()
|
||||||
|
|
||||||
let logger = Logger(label: "stream.yattee.app")
|
let logger = Logger(label: "stream.yattee.player.model")
|
||||||
|
|
||||||
var playerItem: AVPlayerItem?
|
var playerItem: AVPlayerItem?
|
||||||
|
|
||||||
@@ -56,7 +56,6 @@ final class PlayerModel: ObservableObject {
|
|||||||
@Published var presentingPlayer = false { didSet { handlePresentationChange() } }
|
@Published var presentingPlayer = false { didSet { handlePresentationChange() } }
|
||||||
@Published var activeBackend = PlayerBackendType.mpv
|
@Published var activeBackend = PlayerBackendType.mpv
|
||||||
@Published var forceBackendOnPlay: PlayerBackendType?
|
@Published var forceBackendOnPlay: PlayerBackendType?
|
||||||
@Published var wasFullscreen = false
|
|
||||||
|
|
||||||
var avPlayerBackend = AVPlayerBackend()
|
var avPlayerBackend = AVPlayerBackend()
|
||||||
var mpvBackend = MPVBackend()
|
var mpvBackend = MPVBackend()
|
||||||
@@ -131,7 +130,15 @@ final class PlayerModel: ObservableObject {
|
|||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@Published var lockedOrientation: UIInterfaceOrientationMask?
|
@Published var lockedOrientation: UIInterfaceOrientationMask?
|
||||||
@Default(.rotateToLandscapeOnEnterFullScreen) private var rotateToLandscapeOnEnterFullScreen
|
@Published var isOrientationLocked: Bool {
|
||||||
|
didSet {
|
||||||
|
Defaults[.isOrientationLocked] = isOrientationLocked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Default(.rotateToLandscapeOnEnterFullScreen) var rotateToLandscapeOnEnterFullScreen
|
||||||
|
@Default(.lockPortraitWhenBrowsing) var lockPortraitWhenBrowsing
|
||||||
|
var fullscreenInitiatedByButton = false
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@Published var currentChapterIndex: Int?
|
@Published var currentChapterIndex: Int?
|
||||||
@@ -196,14 +203,43 @@ final class PlayerModel: ObservableObject {
|
|||||||
var rateToRestore: Float?
|
var rateToRestore: Float?
|
||||||
private var remoteCommandCenterConfigured = false
|
private var remoteCommandCenterConfigured = false
|
||||||
|
|
||||||
|
// Used in the PlayerModel extension in PlayerQueue
|
||||||
|
var retryAttempts = [String: Int]()
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
var keyPressMonitor: Any?
|
var keyPressMonitor: Any?
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
#if os(iOS)
|
||||||
|
isOrientationLocked = Defaults[.isOrientationLocked]
|
||||||
|
|
||||||
|
if isOrientationLocked, lockPortraitWhenBrowsing {
|
||||||
|
lockedOrientation = UIInterfaceOrientationMask.portrait
|
||||||
|
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
|
||||||
|
} else if isOrientationLocked {
|
||||||
|
lockOrientationAction()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
mpvBackend.controller = mpvController
|
mpvBackend.controller = mpvController
|
||||||
mpvBackend.client = mpvController.client
|
mpvBackend.client = mpvController.client
|
||||||
|
|
||||||
|
// Register for audio session interruption notifications
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(handleAudioSessionInterruption(_:)),
|
||||||
|
name: AVAudioSession.interruptionNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
// Register for audio session route change notifications
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(handleRouteChange(_:)),
|
||||||
|
name: AVAudioSession.routeChangeNotification,
|
||||||
|
object: AVAudioSession.sharedInstance()
|
||||||
|
)
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
playbackMode = Defaults[.playbackMode]
|
playbackMode = Defaults[.playbackMode]
|
||||||
@@ -220,6 +256,20 @@ final class PlayerModel: ObservableObject {
|
|||||||
currentRate = playerRate
|
currentRate = playerRate
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if !os(macOS)
|
||||||
|
deinit {
|
||||||
|
NotificationCenter.default.removeObserver(
|
||||||
|
self, name: AVAudioSession.interruptionNotification, object: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
NotificationCenter.default.removeObserver(
|
||||||
|
self,
|
||||||
|
name: AVAudioSession.routeChangeNotification,
|
||||||
|
object: AVAudioSession.sharedInstance()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
func show() {
|
func show() {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
if presentingPlayer {
|
if presentingPlayer {
|
||||||
@@ -503,7 +553,10 @@ final class PlayerModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handlePresentationChange() {
|
private func handlePresentationChange() {
|
||||||
|
#if os(macOS)
|
||||||
|
// TODO: Check whether this is needed on macOS
|
||||||
backend.setNeedsDrawing(presentingPlayer)
|
backend.setNeedsDrawing(presentingPlayer)
|
||||||
|
#endif
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
if presentingPlayer, activeBackend == .appleAVPlayer, avPlayerUsesSystemControls, Constants.isIPhone {
|
if presentingPlayer, activeBackend == .appleAVPlayer, avPlayerUsesSystemControls, Constants.isIPhone {
|
||||||
@@ -532,13 +585,11 @@ final class PlayerModel: ObservableObject {
|
|||||||
|
|
||||||
if !presentingPlayer {
|
if !presentingPlayer {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
if Defaults[.lockPortraitWhenBrowsing] {
|
if lockPortraitWhenBrowsing {
|
||||||
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
|
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
|
||||||
} else {
|
} else {
|
||||||
Orientation.lockOrientation(.allButUpsideDown)
|
Orientation.lockOrientation(.all)
|
||||||
}
|
}
|
||||||
|
|
||||||
OrientationModel.shared.stopOrientationUpdates()
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -645,32 +696,37 @@ final class PlayerModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func closeCurrentItem(finished: Bool = false) {
|
func closeCurrentItem(finished: Bool = false) {
|
||||||
|
guard !closing else { return }
|
||||||
|
closing = true
|
||||||
|
|
||||||
|
if playingFullScreen { exitFullScreen() }
|
||||||
|
|
||||||
|
Delay.by(0.3) { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
pause()
|
pause()
|
||||||
videoBeingOpened = nil
|
videoBeingOpened = nil
|
||||||
advancing = false
|
advancing = false
|
||||||
forceBackendOnPlay = nil
|
forceBackendOnPlay = nil
|
||||||
|
|
||||||
closing = true
|
|
||||||
controls.presentingControls = false
|
controls.presentingControls = false
|
||||||
|
|
||||||
self.prepareCurrentItemForHistory(finished: finished)
|
self.prepareCurrentItemForHistory(finished: finished)
|
||||||
|
|
||||||
self.hide()
|
self.hide()
|
||||||
|
|
||||||
Delay.by(0.8) { [weak self] in
|
Delay.by(0.7) { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.closePiP()
|
if playingInPictureInPicture { self.closePiP() }
|
||||||
|
|
||||||
withAnimation {
|
withAnimation {
|
||||||
self.currentItem = nil
|
self.currentItem = nil
|
||||||
}
|
}
|
||||||
self.updateNowPlayingInfo()
|
|
||||||
|
|
||||||
|
self.updateNowPlayingInfo()
|
||||||
self.backend.closeItem()
|
self.backend.closeItem()
|
||||||
self.aspectRatio = VideoPlayerView.defaultAspectRatio
|
self.aspectRatio = VideoPlayerView.defaultAspectRatio
|
||||||
self.resetAutoplay()
|
self.resetAutoplay()
|
||||||
self.closing = false
|
self.closing = false
|
||||||
self.playingFullScreen = false
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -679,38 +735,24 @@ final class PlayerModel: ObservableObject {
|
|||||||
avPlayerBackend.startPictureInPictureOnPlay = false
|
avPlayerBackend.startPictureInPictureOnPlay = false
|
||||||
avPlayerBackend.startPictureInPictureOnSwitch = false
|
avPlayerBackend.startPictureInPictureOnSwitch = false
|
||||||
|
|
||||||
if activeBackend == .appleAVPlayer {
|
guard activeBackend != .appleAVPlayer else {
|
||||||
avPlayerBackend.tryStartingPictureInPicture()
|
avPlayerBackend.tryStartingPictureInPicture()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// First, we need to create an array with supported formats.
|
|
||||||
let formatOrderPiP: [QualityProfile.Format] = [.stream, .hls]
|
|
||||||
|
|
||||||
guard let video = currentVideo else { return }
|
|
||||||
guard let stream = avPlayerBackend.bestPlayable(availableStreams, maxResolution: .hd720p30, formatOrder: formatOrderPiP) else { return }
|
|
||||||
|
|
||||||
if avPlayerBackend.video == video {
|
|
||||||
if activeBackend != .appleAVPlayer {
|
|
||||||
avPlayerBackend.startPictureInPictureOnSwitch = true
|
avPlayerBackend.startPictureInPictureOnSwitch = true
|
||||||
}
|
|
||||||
changeActiveBackend(from: activeBackend, to: .appleAVPlayer)
|
|
||||||
} else {
|
|
||||||
avPlayerBackend.startPictureInPictureOnPlay = true
|
|
||||||
playStream(stream, of: video, preservingTime: true, upgrading: true, withBackend: avPlayerBackend)
|
|
||||||
}
|
|
||||||
|
|
||||||
var retryCount = 0
|
saveTime {
|
||||||
|
self.changeActiveBackend(from: .mpv, to: .appleAVPlayer)
|
||||||
_ = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] timer in
|
_ = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] timer in
|
||||||
if let pipController = self?.pipController, pipController.isPictureInPictureActive, self?.avPlayerBackend.isPlaying == true {
|
if let pipController = self?.pipController, pipController.isPictureInPictureActive, self?.avPlayerBackend.isPlaying == true {
|
||||||
self?.exitFullScreen()
|
self?.exitFullScreen()
|
||||||
self?.controls.objectWillChange.send()
|
self?.controls.objectWillChange.send()
|
||||||
timer.invalidate()
|
timer.invalidate()
|
||||||
} else if retryCount < 3, self?.activeBackend == .appleAVPlayer, self?.avPlayerBackend.startPictureInPictureOnSwitch == false {
|
} else if self?.activeBackend == .appleAVPlayer, self?.avPlayerBackend.startPictureInPictureOnSwitch == false {
|
||||||
// If PiP didn't start, try starting it again up to 3 times,
|
|
||||||
self?.avPlayerBackend.startPictureInPictureOnSwitch = true
|
self?.avPlayerBackend.startPictureInPictureOnSwitch = true
|
||||||
self?.avPlayerBackend.tryStartingPictureInPicture()
|
self?.avPlayerBackend.tryStartingPictureInPicture()
|
||||||
retryCount += 1
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -740,19 +782,27 @@ final class PlayerModel: ObservableObject {
|
|||||||
show()
|
show()
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
if previousActiveBackend == .mpv {
|
avPlayerBackend.closePiP()
|
||||||
|
_ = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] timer in
|
||||||
|
if self?.activeBackend == .appleAVPlayer, self?.avPlayerBackend.isPlaying == true, self?.playingInPictureInPicture == false {
|
||||||
|
timer.invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard previousActiveBackend == .mpv else { return }
|
||||||
|
|
||||||
saveTime {
|
saveTime {
|
||||||
self.changeActiveBackend(from: self.activeBackend, to: .mpv, isInClosePip: true)
|
self.changeActiveBackend(from: .appleAVPlayer, to: .mpv, isInClosePip: true)
|
||||||
_ = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] timer in
|
_ = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] timer in
|
||||||
if self?.activeBackend == .mpv, self?.mpvBackend.isPlaying == true {
|
if self?.activeBackend == .mpv, self?.mpvBackend.isPlaying == true {
|
||||||
self?.backend.closePiP()
|
|
||||||
self?.controls.resetTimer()
|
|
||||||
timer.invalidate()
|
timer.invalidate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
backend.closePiP()
|
// We need to remove the itme from the player, if not it will be displayed when next video goe to PiP.
|
||||||
|
Delay.by(1.0) {
|
||||||
|
self.avPlayerBackend.closeItem()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -765,7 +815,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func toggleFullScreenAction() {
|
func toggleFullScreenAction() {
|
||||||
toggleFullscreen(playingFullScreen, showControls: false)
|
toggleFullscreen(playingFullScreen, showControls: false, initiatedByButton: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func togglePiPAction() {
|
func togglePiPAction() {
|
||||||
@@ -778,20 +828,21 @@ final class PlayerModel: ObservableObject {
|
|||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
var lockOrientationImage: String {
|
var lockOrientationImage: String {
|
||||||
lockedOrientation.isNil ? "lock.rotation.open" : "lock.rotation"
|
isOrientationLocked ? "lock.rotation" : "lock.rotation.open"
|
||||||
}
|
}
|
||||||
|
|
||||||
func lockOrientationAction() {
|
func lockOrientationAction() {
|
||||||
if lockedOrientation.isNil {
|
// This makes toggling orientation lock more robust
|
||||||
|
if lockedOrientation.isNil || !isOrientationLocked {
|
||||||
|
isOrientationLocked = true
|
||||||
let orientationMask = OrientationTracker.shared.currentInterfaceOrientationMask
|
let orientationMask = OrientationTracker.shared.currentInterfaceOrientationMask
|
||||||
lockedOrientation = orientationMask
|
lockedOrientation = orientationMask
|
||||||
let orientation = OrientationTracker.shared.currentInterfaceOrientation
|
let orientation = OrientationTracker.shared.currentInterfaceOrientation
|
||||||
Orientation.lockOrientation(orientationMask, andRotateTo: .landscapeLeft)
|
Orientation.lockOrientation(orientationMask, andRotateTo: playingFullScreen ? nil : orientation)
|
||||||
// iOS 16 workaround
|
|
||||||
Orientation.lockOrientation(orientationMask, andRotateTo: orientation)
|
|
||||||
} else {
|
} else {
|
||||||
|
isOrientationLocked = false
|
||||||
lockedOrientation = nil
|
lockedOrientation = nil
|
||||||
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: OrientationTracker.shared.currentInterfaceOrientation)
|
Orientation.lockOrientation(.all)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -977,21 +1028,19 @@ final class PlayerModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
func handleEnterForeground() {
|
func handleEnterForeground() {
|
||||||
setNeedsDrawing(presentingPlayer)
|
DispatchQueue.global(qos: .userInteractive).async { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
|
||||||
if !musicMode, activeBackend == .appleAVPlayer {
|
if !self.musicMode, self.activeBackend == .mpv {
|
||||||
avPlayerBackend.bindPlayerToLayer()
|
self.mpvBackend.addVideoTrackFromStream()
|
||||||
|
self.mpvBackend.setVideoToAuto()
|
||||||
|
self.mpvBackend.controls.resetTimer()
|
||||||
|
} else if !self.musicMode, self.activeBackend == .appleAVPlayer {
|
||||||
|
self.avPlayerBackend.bindPlayerToLayer()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
if wasFullscreen {
|
OrientationTracker.shared.startDeviceOrientationTracking()
|
||||||
wasFullscreen = false
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
Delay.by(0.3) {
|
|
||||||
self?.enterFullScreen()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
guard closePiPAndOpenPlayerOnEnteringForeground, playingInPictureInPicture else {
|
guard closePiPAndOpenPlayerOnEnteringForeground, playingInPictureInPicture else {
|
||||||
@@ -999,24 +1048,24 @@ final class PlayerModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
show()
|
show()
|
||||||
closePiP()
|
// Needs to be delayed a bit, otherwise the PiP windows stays open
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||||
|
self?.closePiP()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleEnterBackground() {
|
func handleEnterBackground() {
|
||||||
|
#if os(iOS)
|
||||||
|
OrientationTracker.shared.stopDeviceOrientationTracking()
|
||||||
|
#endif
|
||||||
|
|
||||||
if Defaults[.pauseOnEnteringBackground], !playingInPictureInPicture, !musicMode {
|
if Defaults[.pauseOnEnteringBackground], !playingInPictureInPicture, !musicMode {
|
||||||
pause()
|
pause()
|
||||||
} else if !playingInPictureInPicture {
|
} else if !playingInPictureInPicture, activeBackend == .appleAVPlayer {
|
||||||
avPlayerBackend.removePlayerFromLayer()
|
avPlayerBackend.removePlayerFromLayer()
|
||||||
|
} else if activeBackend == .mpv, !musicMode {
|
||||||
|
mpvBackend.setVideoToNo()
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
|
||||||
guard playingFullScreen else { return }
|
|
||||||
wasFullscreen = playingFullScreen
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
Delay.by(0.3) {
|
|
||||||
self?.exitFullScreen(showControls: false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -1107,7 +1156,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
task.resume()
|
task.resume()
|
||||||
}
|
}
|
||||||
|
|
||||||
func toggleFullscreen(_ isFullScreen: Bool, showControls: Bool = true) {
|
func toggleFullscreen(_ isFullScreen: Bool, showControls: Bool = true, initiatedByButton: Bool = false) {
|
||||||
controls.presentingControls = showControls && isFullScreen
|
controls.presentingControls = showControls && isFullScreen
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@@ -1119,18 +1168,27 @@ final class PlayerModel: ObservableObject {
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
if playingFullScreen {
|
if playingFullScreen {
|
||||||
if activeBackend == .appleAVPlayer, avPlayerUsesSystemControls {
|
if activeBackend == .appleAVPlayer, avPlayerUsesSystemControls {
|
||||||
|
fullscreenInitiatedByButton = initiatedByButton
|
||||||
avPlayerBackend.controller.enterFullScreen(animated: true)
|
avPlayerBackend.controller.enterFullScreen(animated: true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard rotateToLandscapeOnEnterFullScreen.isRotating else { return }
|
let lockOrientation = rotateToLandscapeOnEnterFullScreen.interfaceOrientation
|
||||||
if currentVideoIsLandscape {
|
if currentVideoIsLandscape {
|
||||||
let delay = activeBackend == .appleAVPlayer && avPlayerUsesSystemControls ? 0.8 : 0
|
if initiatedByButton {
|
||||||
// not sure why but first rotation call is ignore so doing rotate to same orientation first
|
Orientation.lockOrientation(isOrientationLocked
|
||||||
Delay.by(delay) {
|
? (lockOrientation == .landscapeRight ? .landscapeRight : .landscapeLeft)
|
||||||
let orientation = OrientationTracker.shared.currentDeviceOrientation.isLandscape ? OrientationTracker.shared.currentInterfaceOrientation : self.rotateToLandscapeOnEnterFullScreen.interaceOrientation
|
: .landscape)
|
||||||
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: OrientationTracker.shared.currentInterfaceOrientation)
|
|
||||||
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: orientation)
|
|
||||||
}
|
}
|
||||||
|
let orientation = OrientationTracker.shared.currentDeviceOrientation.isLandscape
|
||||||
|
? OrientationTracker.shared.currentInterfaceOrientation
|
||||||
|
: rotateToLandscapeOnEnterFullScreen.interfaceOrientation
|
||||||
|
|
||||||
|
Orientation.lockOrientation(
|
||||||
|
isOrientationLocked
|
||||||
|
? (lockOrientation == .landscapeRight ? .landscapeRight : .landscapeLeft)
|
||||||
|
: .all,
|
||||||
|
andRotateTo: orientation
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if activeBackend == .appleAVPlayer, avPlayerUsesSystemControls {
|
if activeBackend == .appleAVPlayer, avPlayerUsesSystemControls {
|
||||||
@@ -1138,10 +1196,12 @@ final class PlayerModel: ObservableObject {
|
|||||||
avPlayerBackend.controller.dismiss(animated: true)
|
avPlayerBackend.controller.dismiss(animated: true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let rotationOrientation = Constants.isIPhone ? UIInterfaceOrientation.portrait : nil
|
if lockPortraitWhenBrowsing {
|
||||||
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: rotationOrientation)
|
lockedOrientation = UIInterfaceOrientationMask.portrait
|
||||||
|
}
|
||||||
|
let rotationOrientation = lockPortraitWhenBrowsing ? UIInterfaceOrientation.portrait : nil
|
||||||
|
Orientation.lockOrientation(lockPortraitWhenBrowsing ? .portrait : .all, andRotateTo: rotationOrientation)
|
||||||
}
|
}
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1231,9 +1291,143 @@ final class PlayerModel: ObservableObject {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if !os(macOS)
|
||||||
|
func setAudioSessionActive(_ setActive: Bool) {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||||
|
do {
|
||||||
|
try AVAudioSession.sharedInstance().setActive(setActive)
|
||||||
|
} catch {
|
||||||
|
self.logger.error("Error setting up audio session: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func handleAudioSessionInterruption(_ notification: Notification) {
|
||||||
|
logger.info("Audio session interruption received.")
|
||||||
|
logger.info("Notification object: \(String(describing: notification.object))")
|
||||||
|
|
||||||
|
guard let info = notification.userInfo else {
|
||||||
|
logger.info("userInfo is missing in the notification.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the interruption type
|
||||||
|
guard let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||||
|
let type = AVAudioSession.InterruptionType(rawValue: typeValue)
|
||||||
|
else {
|
||||||
|
logger.info("AVAudioSessionInterruptionTypeKey is missing or not a UInt in userInfo.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Interruption type received: \(type)")
|
||||||
|
|
||||||
|
// Check availability for iOS 14.5 or newer to handle interruption reason
|
||||||
|
// Currently only for debugging purpose
|
||||||
|
#if os(iOS)
|
||||||
|
if #available(iOS 14.5, *) {
|
||||||
|
// Extract the interruption reason, if available
|
||||||
|
if let reasonValue = info[AVAudioSessionInterruptionReasonKey] as? UInt,
|
||||||
|
let reason = AVAudioSession.InterruptionReason(rawValue: reasonValue)
|
||||||
|
{
|
||||||
|
logger.info("Interruption reason received: \(reason)")
|
||||||
|
switch reason {
|
||||||
|
case .default:
|
||||||
|
logger.info("Interruption reason: Default or unspecified interruption occurred.")
|
||||||
|
case .appWasSuspended:
|
||||||
|
logger.info("Interruption reason: The app was suspended during the interruption.")
|
||||||
|
@unknown default:
|
||||||
|
logger.info("Unknown interruption reason received.")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info("AVAudioSessionInterruptionReasonKey is missing or not a UInt in userInfo.")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info("Interruption reason handling is not available on this iOS version.")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Handle the specific interruption type
|
||||||
|
switch type {
|
||||||
|
case .began:
|
||||||
|
pause()
|
||||||
|
logger.info("Audio session interrupted (began).")
|
||||||
|
case .ended:
|
||||||
|
// Extract any interruption options, if available
|
||||||
|
if let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt {
|
||||||
|
logger.info("Interruption options received: \(optionsValue)")
|
||||||
|
if optionsValue & AVAudioSession.InterruptionOptions.shouldResume.rawValue != 0 {
|
||||||
|
play()
|
||||||
|
logger.info("Interruption option indicates playback should resume automatically.")
|
||||||
|
} else {
|
||||||
|
logger.info("Interruption option indicates playback should not resume automatically.")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info("AVAudioSessionInterruptionOptionKey is missing or not a UInt in userInfo.")
|
||||||
|
}
|
||||||
|
logger.info("Audio session interruption ended.")
|
||||||
|
// Check if audio was resumed or if there's any indication of ducking
|
||||||
|
let currentVolume = AVAudioSession.sharedInstance().outputVolume
|
||||||
|
logger.info("Current output volume: \(currentVolume)")
|
||||||
|
default:
|
||||||
|
logger.info("Unknown interruption type received.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func handleRouteChange(_ notification: Notification) {
|
||||||
|
logger.info("Audio route change received.")
|
||||||
|
|
||||||
|
guard let info = notification.userInfo else {
|
||||||
|
logger.info("userInfo is missing in the notification.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let reasonValue = info[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
||||||
|
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue)
|
||||||
|
else {
|
||||||
|
logger.info("AVAudioSessionRouteChangeReasonKey is missing or not a UInt in userInfo.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Route change reason received: \(reason)")
|
||||||
|
|
||||||
|
let currentCategory = AVAudioSession.sharedInstance().category
|
||||||
|
logger.info("Current audio session category before change: \(currentCategory)")
|
||||||
|
|
||||||
|
switch reason {
|
||||||
|
case .categoryChange:
|
||||||
|
logger.info("Audio session category changed.")
|
||||||
|
let newCategory = AVAudioSession.sharedInstance().category
|
||||||
|
logger.info("New audio session category: \(newCategory)")
|
||||||
|
case .oldDeviceUnavailable, .newDeviceAvailable:
|
||||||
|
logger.info("Audio route change may indicate ducking or device change.")
|
||||||
|
let currentRoute = AVAudioSession.sharedInstance().currentRoute
|
||||||
|
logger.info("Current audio route: \(currentRoute)")
|
||||||
|
|
||||||
|
for output in currentRoute.outputs {
|
||||||
|
logger.info("Output port type: \(output.portType), UID: \(output.uid)")
|
||||||
|
switch output.portType {
|
||||||
|
case .headphones, .bluetoothA2DP:
|
||||||
|
logger.info("Detected port type \(output.portType). Executing play().")
|
||||||
|
play()
|
||||||
|
default:
|
||||||
|
logger.info("Detected port type \(output.portType). Executing pause().")
|
||||||
|
pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .noSuitableRouteForCategory:
|
||||||
|
logger.info("No suitable route for the current category.")
|
||||||
|
default:
|
||||||
|
logger.info("Unhandled route change reason: \(reason)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
private func assignKeyPressMonitor() {
|
private func assignKeyPressMonitor() {
|
||||||
keyPressMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { keyEvent -> NSEvent? in
|
keyPressMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] keyEvent -> NSEvent? in
|
||||||
|
// Check if the player window is the key window
|
||||||
|
guard let self, let window = Windows.playerWindow, window.isKeyWindow else { return keyEvent }
|
||||||
|
|
||||||
switch keyEvent.keyCode {
|
switch keyEvent.keyCode {
|
||||||
case 124:
|
case 124:
|
||||||
if !self.liveStreamInAVPlayer {
|
if !self.liveStreamInAVPlayer {
|
||||||
|
|||||||
@@ -359,6 +359,31 @@ extension PlayerModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func videoLoadFailureHandler(_ error: RequestError, video: Video? = nil) {
|
private func videoLoadFailureHandler(_ error: RequestError, video: Video? = nil) {
|
||||||
|
guard let video else {
|
||||||
|
presentErrorAlert(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let videoID = video.videoID
|
||||||
|
let currentRetry = retryAttempts[videoID] ?? 0
|
||||||
|
|
||||||
|
if currentRetry < Defaults[.videoLoadingRetryCount] {
|
||||||
|
retryAttempts[videoID] = currentRetry + 1
|
||||||
|
|
||||||
|
logger.info("Retry attempt \(currentRetry + 1) for video \(videoID) due to error: \(error)")
|
||||||
|
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.enqueueVideo(video, play: true, prepending: true, loadDetails: true)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
retryAttempts[videoID] = 0
|
||||||
|
presentErrorAlert(error, video: video)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentErrorAlert(_ error: RequestError, video: Video? = nil) {
|
||||||
var message = error.userMessage
|
var message = error.userMessage
|
||||||
if let errorDictionary = error.json.dictionaryObject,
|
if let errorDictionary = error.json.dictionaryObject,
|
||||||
let errorMessage = errorDictionary["message"] ?? errorDictionary["error"],
|
let errorMessage = errorDictionary["message"] ?? errorDictionary["error"],
|
||||||
|
|||||||
@@ -76,7 +76,8 @@ struct QualityProfile: Hashable, Identifiable, Defaults.Serializable {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
let resolutionMatch = !stream.resolution.isNil && resolution.value >= stream.resolution
|
let defaultResolution = Stream.Resolution.custom(height: 720, refreshRate: 30)
|
||||||
|
let resolutionMatch = resolution.value ?? defaultResolution >= stream.resolution
|
||||||
|
|
||||||
if resolutionMatch, formats.contains(.stream), stream.kind == .stream {
|
if resolutionMatch, formats.contains(.stream), stream.kind == .stream {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ final class SearchModel: ObservableObject {
|
|||||||
|
|
||||||
@Published var focused = false
|
@Published var focused = false
|
||||||
|
|
||||||
|
@Default(.showSearchSuggestions) private var showSearchSuggestions
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
var textField: UITextField!
|
var textField: UITextField!
|
||||||
#elseif os(macOS)
|
#elseif os(macOS)
|
||||||
@@ -102,7 +104,7 @@ final class SearchModel: ObservableObject {
|
|||||||
}}
|
}}
|
||||||
|
|
||||||
func loadSuggestions(_ query: String) {
|
func loadSuggestions(_ query: String) {
|
||||||
guard accounts.app.supportsSearchSuggestions else {
|
guard accounts.app.supportsSearchSuggestions, showSearchSuggestions else {
|
||||||
querySuggestions.removeAll()
|
querySuggestions.removeAll()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,288 +4,126 @@ import Foundation
|
|||||||
|
|
||||||
// swiftlint:disable:next final_class
|
// swiftlint:disable:next final_class
|
||||||
class Stream: Equatable, Hashable, Identifiable {
|
class Stream: Equatable, Hashable, Identifiable {
|
||||||
enum Resolution: String, CaseIterable, Comparable, Defaults.Serializable {
|
enum Resolution: Comparable, Codable, Defaults.Serializable {
|
||||||
// Some 16:19 and 16:10 resolutions are also used in 2:1 videos
|
case predefined(PredefinedResolution)
|
||||||
|
case custom(height: Int, refreshRate: Int)
|
||||||
|
|
||||||
|
enum PredefinedResolution: String, CaseIterable, Codable {
|
||||||
// 8K UHD (16:9) Resolutions
|
// 8K UHD (16:9) Resolutions
|
||||||
case hd4320p60
|
case hd4320p60, hd4320p30
|
||||||
case hd4320p50
|
|
||||||
case hd4320p48
|
|
||||||
case hd4320p30
|
|
||||||
case hd4320p25
|
|
||||||
case hd4320p24
|
|
||||||
|
|
||||||
// 5K (16:9) Resolutions
|
// 4K UHD (16:9) Resolutions
|
||||||
case hd2560p60
|
case hd2160p60, hd2160p30
|
||||||
case hd2560p50
|
|
||||||
case hd2560p48
|
|
||||||
case hd2560p30
|
|
||||||
case hd2560p25
|
|
||||||
case hd2560p24
|
|
||||||
|
|
||||||
// 2:1 Aspect Ratio (Univisium) Resolutions
|
// 1440p (16:9) Resolutions
|
||||||
case hd2880p60
|
case hd1440p60, hd1440p30
|
||||||
case hd2880p50
|
|
||||||
case hd2880p48
|
|
||||||
case hd2880p30
|
|
||||||
case hd2880p25
|
|
||||||
case hd2880p24
|
|
||||||
|
|
||||||
// 16:10 Resolutions
|
// 1080p (Full HD, 16:9) Resolutions
|
||||||
case hd2400p60
|
case hd1080p60, hd1080p30
|
||||||
case hd2400p50
|
|
||||||
case hd2400p48
|
|
||||||
case hd2400p30
|
|
||||||
case hd2400p25
|
|
||||||
case hd2400p24
|
|
||||||
|
|
||||||
// 16:9 Resolutions
|
// 720p (HD, 16:9) Resolutions
|
||||||
case hd2160p60
|
case hd720p60, hd720p30
|
||||||
case hd2160p50
|
|
||||||
case hd2160p48
|
|
||||||
case hd2160p30
|
|
||||||
case hd2160p25
|
|
||||||
case hd2160p24
|
|
||||||
|
|
||||||
// 16:10 Resolutions
|
|
||||||
case hd1600p60
|
|
||||||
case hd1600p50
|
|
||||||
case hd1600p48
|
|
||||||
case hd1600p30
|
|
||||||
case hd1600p25
|
|
||||||
case hd1600p24
|
|
||||||
|
|
||||||
// 16:9 Resolutions
|
|
||||||
case hd1440p60
|
|
||||||
case hd1440p50
|
|
||||||
case hd1440p48
|
|
||||||
case hd1440p30
|
|
||||||
case hd1440p25
|
|
||||||
case hd1440p24
|
|
||||||
|
|
||||||
// 16:10 Resolutions
|
|
||||||
case hd1280p60
|
|
||||||
case hd1280p50
|
|
||||||
case hd1280p48
|
|
||||||
case hd1280p30
|
|
||||||
case hd1280p25
|
|
||||||
case hd1280p24
|
|
||||||
|
|
||||||
// 16:10 Resolutions
|
|
||||||
case hd1200p60
|
|
||||||
case hd1200p50
|
|
||||||
case hd1200p48
|
|
||||||
case hd1200p30
|
|
||||||
case hd1200p25
|
|
||||||
case hd1200p24
|
|
||||||
|
|
||||||
// 16:9 Resolutions
|
|
||||||
case hd1080p60
|
|
||||||
case hd1080p50
|
|
||||||
case hd1080p48
|
|
||||||
case hd1080p30
|
|
||||||
case hd1080p25
|
|
||||||
case hd1080p24
|
|
||||||
|
|
||||||
// 16:10 Resolutions
|
|
||||||
case hd1050p60
|
|
||||||
case hd1050p50
|
|
||||||
case hd1050p48
|
|
||||||
case hd1050p30
|
|
||||||
case hd1050p25
|
|
||||||
case hd1050p24
|
|
||||||
|
|
||||||
// 16:9 Resolutions
|
|
||||||
case hd960p60
|
|
||||||
case hd960p50
|
|
||||||
case hd960p48
|
|
||||||
case hd960p30
|
|
||||||
case hd960p25
|
|
||||||
case hd960p24
|
|
||||||
|
|
||||||
// 16:10 Resolutions
|
|
||||||
case hd900p60
|
|
||||||
case hd900p50
|
|
||||||
case hd900p48
|
|
||||||
case hd900p30
|
|
||||||
case hd900p25
|
|
||||||
case hd900p24
|
|
||||||
|
|
||||||
// 16:10 Resolutions
|
|
||||||
case hd800p60
|
|
||||||
case hd800p50
|
|
||||||
case hd800p48
|
|
||||||
case hd800p30
|
|
||||||
case hd800p25
|
|
||||||
case hd800p24
|
|
||||||
|
|
||||||
// 16:9 Resolutions
|
|
||||||
case hd720p60
|
|
||||||
case hd720p50
|
|
||||||
case hd720p48
|
|
||||||
case hd720p30
|
|
||||||
case hd720p25
|
|
||||||
case hd720p24
|
|
||||||
|
|
||||||
// Standard Definition (SD) Resolutions
|
// Standard Definition (SD) Resolutions
|
||||||
case sd854p30
|
|
||||||
case sd854p25
|
|
||||||
case sd768p30
|
|
||||||
case sd768p25
|
|
||||||
case sd640p30
|
|
||||||
case sd640p25
|
|
||||||
case sd480p30
|
case sd480p30
|
||||||
case sd480p25
|
|
||||||
|
|
||||||
case sd428p30
|
|
||||||
case sd428p25
|
|
||||||
case sd360p30
|
case sd360p30
|
||||||
case sd360p25
|
|
||||||
case sd320p30
|
|
||||||
case sd320p25
|
|
||||||
case sd240p30
|
case sd240p30
|
||||||
case sd240p25
|
|
||||||
case sd214p30
|
|
||||||
case sd214p25
|
|
||||||
case sd144p30
|
case sd144p30
|
||||||
case sd144p25
|
}
|
||||||
case sd128p30
|
|
||||||
case sd128p25
|
|
||||||
|
|
||||||
case unknown
|
|
||||||
|
|
||||||
var name: String {
|
var name: String {
|
||||||
"\(height)p\(refreshRate != -1 && refreshRate != 30 ? ", \(refreshRate) fps" : "")"
|
switch self {
|
||||||
|
case let .predefined(predefined):
|
||||||
|
return predefined.rawValue
|
||||||
|
case let .custom(height, refreshRate):
|
||||||
|
return "\(height)p\(refreshRate != 30 ? ", \(refreshRate) fps" : "")"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var height: Int {
|
var height: Int {
|
||||||
if self == .unknown {
|
switch self {
|
||||||
return -1
|
case let .predefined(predefined):
|
||||||
|
return predefined.height
|
||||||
|
case let .custom(height, _):
|
||||||
|
return height
|
||||||
}
|
}
|
||||||
|
|
||||||
let resolutionPart = rawValue.components(separatedBy: "p").first!
|
|
||||||
return Int(resolutionPart.components(separatedBy: CharacterSet.decimalDigits.inverted).joined())!
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var refreshRate: Int {
|
var refreshRate: Int {
|
||||||
if self == .unknown {
|
switch self {
|
||||||
return -1
|
case let .predefined(predefined):
|
||||||
|
return predefined.refreshRate
|
||||||
|
case let .custom(_, refreshRate):
|
||||||
|
return refreshRate
|
||||||
}
|
}
|
||||||
|
|
||||||
let refreshRatePart = rawValue.components(separatedBy: "p")[1]
|
|
||||||
|
|
||||||
if refreshRatePart.isEmpty {
|
|
||||||
return 30
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Int(refreshRatePart.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? -1
|
|
||||||
}
|
|
||||||
|
|
||||||
// These values are an approximation.
|
|
||||||
// https://support.google.com/youtube/answer/1722171?hl=en#zippy=%2Cbitrate
|
|
||||||
|
|
||||||
var bitrate: Int {
|
var bitrate: Int {
|
||||||
switch self {
|
switch self {
|
||||||
// 8K UHD (16:9) Resolutions
|
case let .predefined(predefined):
|
||||||
case .hd4320p60, .hd4320p50, .hd4320p48, .hd4320p30, .hd4320p25, .hd4320p24:
|
return predefined.bitrate
|
||||||
return 85_000_000 // 85 Mbit/s
|
case let .custom(height, refreshRate):
|
||||||
|
// Find the closest predefined resolution based on height and refresh rate
|
||||||
// 5K (16:9) Resolutions
|
let closestPredefined = Stream.Resolution.PredefinedResolution.allCases.min {
|
||||||
case .hd2880p60, .hd2880p50, .hd2880p48, .hd2880p30, .hd2880p25, .hd2880p24:
|
abs($0.height - height) + abs($0.refreshRate - refreshRate) <
|
||||||
return 45_000_000 // 45 Mbit/s
|
abs($1.height - height) + abs($1.refreshRate - refreshRate)
|
||||||
|
}
|
||||||
// 2:1 Aspect Ratio (Univisium) Resolutions
|
// Return the bitrate of the closest predefined resolution or a default bitrate if no close match is found
|
||||||
case .hd2560p60, .hd2560p50, .hd2560p48, .hd2560p30, .hd2560p25, .hd2560p24:
|
return closestPredefined?.bitrate ?? 5_000_000
|
||||||
return 30_000_000 // 30 Mbit/s
|
|
||||||
|
|
||||||
// 16:10 Resolutions
|
|
||||||
case .hd2400p60, .hd2400p50, .hd2400p48, .hd2400p30, .hd2400p25, .hd2400p24:
|
|
||||||
return 35_000_000 // 35 Mbit/s
|
|
||||||
|
|
||||||
// 4K UHD (16:9) Resolutions
|
|
||||||
case .hd2160p60, .hd2160p50, .hd2160p48, .hd2160p30, .hd2160p25, .hd2160p24:
|
|
||||||
return 56_000_000 // 56 Mbit/s
|
|
||||||
|
|
||||||
// 16:10 Resolutions
|
|
||||||
case .hd1600p60, .hd1600p50, .hd1600p48, .hd1600p30, .hd1600p25, .hd1600p24:
|
|
||||||
return 20_000_000 // 20 Mbit/s
|
|
||||||
|
|
||||||
// 1440p (16:9) Resolutions
|
|
||||||
case .hd1440p60, .hd1440p50, .hd1440p48, .hd1440p30, .hd1440p25, .hd1440p24:
|
|
||||||
return 24_000_000 // 24 Mbit/s
|
|
||||||
|
|
||||||
// 1280p (16:10) Resolutions
|
|
||||||
case .hd1280p60, .hd1280p50, .hd1280p48, .hd1280p30, .hd1280p25, .hd1280p24:
|
|
||||||
return 15_000_000 // 15 Mbit/s
|
|
||||||
|
|
||||||
// 1200p (16:10) Resolutions
|
|
||||||
case .hd1200p60, .hd1200p50, .hd1200p48, .hd1200p30, .hd1200p25, .hd1200p24:
|
|
||||||
return 18_000_000 // 18 Mbit/s
|
|
||||||
|
|
||||||
// 1080p (16:9) Resolutions
|
|
||||||
case .hd1080p60, .hd1080p50, .hd1080p48, .hd1080p30, .hd1080p25, .hd1080p24:
|
|
||||||
return 12_000_000 // 12 Mbit/s
|
|
||||||
|
|
||||||
// 1050p (16:10) Resolutions
|
|
||||||
case .hd1050p60, .hd1050p50, .hd1050p48, .hd1050p30, .hd1050p25, .hd1050p24:
|
|
||||||
return 10_000_000 // 10 Mbit/s
|
|
||||||
|
|
||||||
// 960p Resolutions
|
|
||||||
case .hd960p60, .hd960p50, .hd960p48, .hd960p30, .hd960p25, .hd960p24:
|
|
||||||
return 8_000_000 // 8 Mbit/s
|
|
||||||
|
|
||||||
// 900p (16:10) Resolutions
|
|
||||||
case .hd900p60, .hd900p50, .hd900p48, .hd900p30, .hd900p25, .hd900p24:
|
|
||||||
return 7_000_000 // 7 Mbit/s
|
|
||||||
|
|
||||||
// 800p (16:10) Resolutions
|
|
||||||
case .hd800p60, .hd800p50, .hd800p48, .hd800p30, .hd800p25, .hd800p24:
|
|
||||||
return 6_000_000 // 6 Mbit/s
|
|
||||||
|
|
||||||
// 720p (16:9) Resolutions
|
|
||||||
case .hd720p60, .hd720p50, .hd720p48, .hd720p30, .hd720p25, .hd720p24:
|
|
||||||
return 9_500_000 // 9.5 Mbit/s
|
|
||||||
|
|
||||||
// Standard Definition (SD) Resolutions
|
|
||||||
case .sd854p30, .sd854p25, .sd768p30, .sd768p25, .sd640p30, .sd640p25:
|
|
||||||
return 4_000_000 // 4 Mbit/s
|
|
||||||
|
|
||||||
case .sd480p30, .sd480p25:
|
|
||||||
return 2_500_000 // 2.5 Mbit/s
|
|
||||||
|
|
||||||
case .sd428p30, .sd428p25:
|
|
||||||
return 2_000_000 // 2 Mbit/s
|
|
||||||
|
|
||||||
case .sd360p30, .sd360p25:
|
|
||||||
return 1_500_000 // 1.5 Mbit/s
|
|
||||||
|
|
||||||
case .sd320p30, .sd320p25:
|
|
||||||
return 1_200_000 // 1.2 Mbit/s
|
|
||||||
|
|
||||||
case .sd240p30, .sd240p25:
|
|
||||||
return 1_000_000 // 1 Mbit/s
|
|
||||||
|
|
||||||
case .sd214p30, .sd214p25:
|
|
||||||
return 800_000 // 0.8 Mbit/s
|
|
||||||
|
|
||||||
case .sd144p30, .sd144p25:
|
|
||||||
return 600_000 // 0.6 Mbit/s
|
|
||||||
|
|
||||||
case .sd128p30, .sd128p25:
|
|
||||||
return 400_000 // 0.4 Mbit/s
|
|
||||||
|
|
||||||
case .unknown:
|
|
||||||
return 0
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func from(resolution: String, fps: Int? = nil) -> Self {
|
static func from(resolution: String, fps: Int? = nil) -> Self {
|
||||||
allCases.first { $0.rawValue.contains(resolution) && $0.refreshRate == (fps ?? 30) } ?? .unknown
|
if let predefined = PredefinedResolution(rawValue: resolution) {
|
||||||
|
return .predefined(predefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to parse height and refresh rate
|
||||||
|
if let height = Int(resolution.components(separatedBy: "p").first ?? ""), height > 0 {
|
||||||
|
let refreshRate = fps ?? 30
|
||||||
|
return .custom(height: height, refreshRate: refreshRate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default behavior if parsing fails
|
||||||
|
return .custom(height: 720, refreshRate: 30)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func < (lhs: Self, rhs: Self) -> Bool {
|
static func < (lhs: Self, rhs: Self) -> Bool {
|
||||||
lhs.height == rhs.height ? (lhs.refreshRate < rhs.refreshRate) : (lhs.height < rhs.height)
|
lhs.height == rhs.height ? (lhs.refreshRate < rhs.refreshRate) : (lhs.height < rhs.height)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case predefined
|
||||||
|
case custom
|
||||||
|
case height
|
||||||
|
case refreshRate
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
|
if let predefinedValue = try? container.decode(PredefinedResolution.self, forKey: .predefined) {
|
||||||
|
self = .predefined(predefinedValue)
|
||||||
|
} else if let height = try? container.decode(Int.self, forKey: .height),
|
||||||
|
let refreshRate = try? container.decode(Int.self, forKey: .refreshRate)
|
||||||
|
{
|
||||||
|
self = .custom(height: height, refreshRate: refreshRate)
|
||||||
|
} else {
|
||||||
|
// Set default resolution to 720p 30 if decoding fails
|
||||||
|
self = .custom(height: 720, refreshRate: 30)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
switch self {
|
||||||
|
case let .predefined(predefinedValue):
|
||||||
|
try container.encode(predefinedValue, forKey: .predefined)
|
||||||
|
case let .custom(height, refreshRate):
|
||||||
|
try container.encode(height, forKey: .height)
|
||||||
|
try container.encode(refreshRate, forKey: .refreshRate)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Kind: String, Comparable {
|
enum Kind: String, Comparable {
|
||||||
@@ -478,3 +316,97 @@ class Stream: Equatable, Hashable, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Stream.Resolution.PredefinedResolution {
|
||||||
|
var height: Int {
|
||||||
|
switch self {
|
||||||
|
// 8K UHD (16:9) Resolutions
|
||||||
|
case .hd4320p60, .hd4320p30:
|
||||||
|
return 4320
|
||||||
|
|
||||||
|
// 4K UHD (16:9) Resolutions
|
||||||
|
case .hd2160p60, .hd2160p30:
|
||||||
|
return 2160
|
||||||
|
|
||||||
|
// 1440p (16:9) Resolutions
|
||||||
|
case .hd1440p60, .hd1440p30:
|
||||||
|
return 1440
|
||||||
|
|
||||||
|
// 1080p (Full HD, 16:9) Resolutions
|
||||||
|
case .hd1080p60, .hd1080p30:
|
||||||
|
return 1080
|
||||||
|
|
||||||
|
// 720p (HD, 16:9) Resolutions
|
||||||
|
case .hd720p60, .hd720p30:
|
||||||
|
return 720
|
||||||
|
|
||||||
|
// Standard Definition (SD) Resolutions
|
||||||
|
case .sd480p30:
|
||||||
|
return 480
|
||||||
|
|
||||||
|
case .sd360p30:
|
||||||
|
return 360
|
||||||
|
|
||||||
|
case .sd240p30:
|
||||||
|
return 240
|
||||||
|
|
||||||
|
case .sd144p30:
|
||||||
|
return 144
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var refreshRate: Int {
|
||||||
|
switch self {
|
||||||
|
// 60 fps Resolutions
|
||||||
|
case .hd4320p60, .hd2160p60, .hd1440p60, .hd1080p60, .hd720p60:
|
||||||
|
return 60
|
||||||
|
|
||||||
|
// 30 fps Resolutions
|
||||||
|
case .hd4320p30, .hd2160p30, .hd1440p30, .hd1080p30, .hd720p30,
|
||||||
|
.sd480p30, .sd360p30, .sd240p30, .sd144p30:
|
||||||
|
return 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// These values are an approximation.
|
||||||
|
// https://support.google.com/youtube/answer/1722171?hl=en#zippy=%2Cbitrate
|
||||||
|
|
||||||
|
var bitrate: Int {
|
||||||
|
switch self {
|
||||||
|
// 8K UHD (16:9) Resolutions
|
||||||
|
case .hd4320p60:
|
||||||
|
return 180_000_000 // Midpoint between 120 Mbps and 240 Mbps
|
||||||
|
case .hd4320p30:
|
||||||
|
return 120_000_000 // Midpoint between 80 Mbps and 160 Mbps
|
||||||
|
// 4K UHD (16:9) Resolutions
|
||||||
|
case .hd2160p60:
|
||||||
|
return 60_500_000 // Midpoint between 53 Mbps and 68 Mbps
|
||||||
|
case .hd2160p30:
|
||||||
|
return 40_000_000 // Midpoint between 35 Mbps and 45 Mbps
|
||||||
|
// 1440p (2K) Resolutions
|
||||||
|
case .hd1440p60:
|
||||||
|
return 24_000_000 // 24 Mbps
|
||||||
|
case .hd1440p30:
|
||||||
|
return 16_000_000 // 16 Mbps
|
||||||
|
// 1080p (Full HD, 16:9) Resolutions
|
||||||
|
case .hd1080p60:
|
||||||
|
return 12_000_000 // 12 Mbps
|
||||||
|
case .hd1080p30:
|
||||||
|
return 8_000_000 // 8 Mbps
|
||||||
|
// 720p (HD, 16:9) Resolutions
|
||||||
|
case .hd720p60:
|
||||||
|
return 7_500_000 // 7.5 Mbps
|
||||||
|
case .hd720p30:
|
||||||
|
return 5_000_000 // 5 Mbps
|
||||||
|
// Standard Definition (SD) Resolutions
|
||||||
|
case .sd480p30:
|
||||||
|
return 2_500_000 // 2.5 Mbps
|
||||||
|
case .sd360p30:
|
||||||
|
return 1_000_000 // 1 Mbps
|
||||||
|
case .sd240p30:
|
||||||
|
return 1_000_000 // 1 Mbps
|
||||||
|
case .sd144p30:
|
||||||
|
return 600_000 // 0.6 Mbps
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename" : "Invidious.svg",
|
"filename" : "Invidious_512x512@1x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "Invidious_512x512@2x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "2x"
|
"scale" : "2x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "Invidious_512x512@3x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "3x"
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!-- Generated by Pixelmator Pro 3.6.7 -->
|
|
||||||
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g id="Group">
|
|
||||||
<path id="Path" fill="#f0f0f0" stroke="none" d="M 244.186371 511.752167 C 219.045975 510.71109 195.004303 506.137482 171.587616 497.941071 C 94.144188 470.833344 33.538929 407.477814 10.268302 329.279663 C 0.239193 295.592224 -2.512759 258.122925 2.318441 221.024231 C 7.031626 184.829193 19.597385 150.432068 39.58955 118.998993 C 54.919968 94.894897 76.601517 71.145599 99.579987 53.286163 C 146.440094 16.865601 208.748688 -2.762817 267.733124 0.314728 C 300.60672 2.029694 331.167175 9.238464 360.594604 22.219849 C 371.003937 26.811676 386.029724 34.994751 395.774933 41.379883 C 413.748718 53.155853 424.186218 61.823517 439.575043 77.75174 C 456.410675 95.178497 467.682678 109.774475 478.1875 127.753906 C 487.343475 143.423645 496.096527 163.56778 501.34256 181.042023 C 503.374359 187.809723 506.984924 202.749298 508.564056 210.923828 C 511.600952 226.643677 511.993439 231.662842 511.999939 254.866028 C 512.007507 279.289337 511.412323 287.069458 508.295135 303.353882 C 496.447205 365.24649 463.100311 419.655823 413.19043 458.533966 C 384.211426 481.106567 349.644592 497.493866 313.417664 505.834595 C 292.186981 510.723083 268.424774 512.753723 244.192581 511.750305 Z M 199.601273 407.824738 C 199.600616 407.13028 199.507141 405.112122 199.394073 403.339905 L 199.188583 400.117706 L 193.216202 399.771149 C 188.074692 399.472839 187.123169 399.331085 186.376404 398.752106 C 183.806091 396.759216 184.51181 390.745789 189.233658 374.405304 C 190.33078 370.608765 193.472549 359.471619 196.215607 349.656189 C 198.958557 339.840759 202.82106 326.12854 204.798935 319.183411 C 206.776825 312.238525 210.127289 300.343872 212.2444 292.751038 C 214.361496 285.15802 216.835648 276.394104 217.742447 273.275696 C 218.649307 270.157227 221.881256 258.716736 224.924591 247.853851 C 231.209076 225.419739 235.292999 211.284149 236.285294 208.529846 C 236.943924 206.701843 236.981201 206.664764 237.55249 207.272522 C 237.876221 207.616882 242.438049 216.990021 247.689819 228.101257 C 252.941574 239.212921 264.315857 263.153992 272.964874 281.302307 C 294.797607 327.11499 321.04184 382.317078 327.916321 396.885345 L 333.677551 409.096344 L 348.10614 408.978271 C 356.041901 408.913391 362.859833 408.719421 363.258698 408.547302 C 363.971802 408.238831 363.946777 408.156982 361.515564 402.851898 C 360.158997 399.891571 351.171295 380.953369 341.54248 360.767029 C 279.69873 231.107727 263.778931 197.38205 255.30777 178.09668 C 249.3349 164.497955 246.53923 158.564606 245.509338 157.30484 C 244.455933 156.015533 243.436447 155.901581 242.498398 156.96814 C 240.974991 158.700165 237.284607 170.24234 230.574875 194.259399 C 227.962112 203.611725 222.271103 223.840454 217.928177 239.210693 C 209.49437 269.060883 207.108093 277.513733 199.725769 303.692749 C 197.14035 312.859924 193.631577 325.285278 191.928467 331.303101 C 190.225357 337.321899 186.805634 349.519958 184.329178 358.409424 C 178.862122 378.033875 176.535034 385.964355 174.94397 390.397858 C 172.229355 397.960846 171.676529 398.746796 168.692398 399.28656 C 167.563736 399.490662 165.63089 399.658478 164.39711 399.659515 C 161.603485 399.663513 159.888535 400.138885 159.245316 401.092468 C 158.709564 401.88678 158.528641 407.530029 159.013474 408.322784 C 159.274811 408.750031 162.147385 408.816345 188.66066 409.00708 L 199.603806 409.085815 L 199.602936 407.82312 Z M 246.283508 136.628906 C 251.781326 135.410889 257.030548 130.108551 258.271179 124.519989 C 258.735718 122.427612 258.68457 117.95636 258.17337 115.97229 C 257.092316 111.775818 254.02124 107.673767 250.502441 105.726105 C 245.661484 103.0466 238.49118 103.04895 233.643967 105.732697 C 226.044434 109.939087 223.284454 120.360321 227.562363 128.69577 C 230.991348 135.376801 238.182877 138.424713 246.28302 136.630219 Z"/>
|
|
||||||
<path id="Circle" fill="#575757" stroke="none" d="M 256 0 C 114.61525 0 0 114.615257 0 256 C 0 397.384735 114.61525 512 256 512 C 397.384735 512 512 397.384735 512 256 C 512 114.615257 397.384735 0 256 0 Z M 256 4 C 395.175446 4 508 116.824524 508 256 C 508 395.175446 395.175446 508 256 508 C 116.824524 508 4 395.175446 4 256 C 4 116.824524 116.824524 4 256 4 Z"/>
|
|
||||||
</g>
|
|
||||||
<g id="g1">
|
|
||||||
<path id="path1" fill="#00b6f0" stroke="#00b6f0" stroke-width="0.297331" d="M 234.067764 106.178009 C 223.288239 112.003052 223.375183 129.030151 234.328568 134.765594 C 241.804688 138.70871 251.367157 136.199432 255.800674 129.209381 C 260.842682 121.41275 258.060883 110.300354 249.976257 106.088379 C 245.54274 103.758362 238.501282 103.758362 234.067764 106.178009 Z"/>
|
|
||||||
<path id="path2" fill="#575757" stroke="none" d="M 242.34436 157.257843 C 241.282883 158.735199 236.77153 172.585571 233.321655 185.235535 C 230.667953 194.83847 224.387421 217.55304 218.72612 237.405212 C 216.956955 243.776398 213.595551 255.779999 211.207184 264.182556 C 208.907288 272.585114 205.545883 284.588745 203.688263 290.9599 C 201.919098 297.331055 198.557724 309.334686 196.169357 317.737244 C 193.869431 326.139801 190.508026 338.143433 188.650406 344.514587 C 186.881271 350.885742 183.608307 362.52005 181.485321 370.368591 C 176.266296 389.482056 173.258743 397.976929 171.312653 398.992645 C 170.428085 399.546631 168.216629 399.915985 166.359024 399.915985 C 159.901581 399.915985 158.928543 400.654663 159.193924 404.9021 L 159.459305 408.687897 L 179.627701 408.964874 L 199.796112 409.149567 L 199.530731 404.809784 L 199.265381 400.377686 L 192.807953 400.100647 C 186.969711 399.823669 186.262039 399.638977 185.377472 397.607605 C 184.227524 395.022217 185.377472 388.0047 188.650406 376.832092 C 189.800354 373.046326 192.807953 362.427704 195.28476 353.286499 C 197.761581 344.145264 201.122986 332.049255 202.803696 326.509125 C 204.395935 320.876648 207.757339 308.872986 210.322601 299.731781 C 212.799438 290.590576 216.160843 278.494598 217.841522 272.954437 C 219.433777 267.32196 222.795181 255.318329 225.360458 246.177094 C 232.879379 218.753387 236.240784 207.488464 236.948441 206.565094 C 237.390732 206.103394 238.45224 207.58078 239.425278 209.796844 C 240.309845 212.012909 256.40918 246.084747 275.073822 285.419769 C 293.738434 324.754761 314.614532 368.706543 321.337311 383.110901 L 333.632965 409.149567 L 348.493896 409.149567 C 356.632019 409.149567 363.354828 408.780212 363.354828 408.410889 C 363.354828 408.041534 356.72049 393.821838 348.670807 376.832092 C 296.657532 267.598999 262.955078 196.038818 257.293793 182.927185 C 254.728485 177.110016 251.19017 168.984467 249.421021 164.921692 C 245.52887 156.149841 244.290451 154.764771 242.34436 157.257843 Z"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 6.6 KiB |
BIN
Shared/Assets.xcassets/Invidious.imageset/Invidious_512x512@1x.png
vendored
Normal file
BIN
Shared/Assets.xcassets/Invidious.imageset/Invidious_512x512@1x.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
BIN
Shared/Assets.xcassets/Invidious.imageset/Invidious_512x512@2x.png
vendored
Normal file
BIN
Shared/Assets.xcassets/Invidious.imageset/Invidious_512x512@2x.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
BIN
Shared/Assets.xcassets/Invidious.imageset/Invidious_512x512@3x.png
vendored
Normal file
BIN
Shared/Assets.xcassets/Invidious.imageset/Invidious_512x512@3x.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 146 KiB |
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "1.000",
|
||||||
|
"green" : "1.000",
|
||||||
|
"red" : "1.000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.110",
|
||||||
|
"green" : "0.110",
|
||||||
|
"red" : "0.118"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,6 +63,14 @@ enum Constants {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static var detailsVisibility: Bool {
|
||||||
|
#if os(iOS)
|
||||||
|
false
|
||||||
|
#else
|
||||||
|
true
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
static var progressViewScale: Double {
|
static var progressViewScale: Double {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
0.4
|
0.4
|
||||||
@@ -95,11 +103,11 @@ enum Constants {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
static var detailsVisibility: Bool {
|
static var contentViewMinWidth: Double {
|
||||||
#if os(iOS)
|
#if os(macOS)
|
||||||
false
|
835
|
||||||
#else
|
#else
|
||||||
true
|
0
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ extension Defaults.Keys {
|
|||||||
static let favorites = Key<[FavoriteItem]>("favorites", default: [])
|
static let favorites = Key<[FavoriteItem]>("favorites", default: [])
|
||||||
static let widgetsSettings = Key<[WidgetSettings]>("widgetsSettings", default: [])
|
static let widgetsSettings = Key<[WidgetSettings]>("widgetsSettings", default: [])
|
||||||
static let startupSection = Key<StartupSection>("startupSection", default: .home)
|
static let startupSection = Key<StartupSection>("startupSection", default: .home)
|
||||||
|
static let showSearchSuggestions = Key<Bool>("showSearchSuggestions", default: true)
|
||||||
static let visibleSections = Key<Set<VisibleSection>>("visibleSections", default: [.subscriptions, .trending, .playlists])
|
static let visibleSections = Key<Set<VisibleSection>>("visibleSections", default: [.subscriptions, .trending, .playlists])
|
||||||
|
|
||||||
static let showOpenActionsToolbarItem = Key<Bool>("showOpenActionsToolbarItem", default: false)
|
static let showOpenActionsToolbarItem = Key<Bool>("showOpenActionsToolbarItem", default: false)
|
||||||
@@ -93,12 +94,9 @@ extension Defaults.Keys {
|
|||||||
static let enableReturnYouTubeDislike = Key<Bool>("enableReturnYouTubeDislike", default: false)
|
static let enableReturnYouTubeDislike = Key<Bool>("enableReturnYouTubeDislike", default: false)
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
static let honorSystemOrientationLock = Key<Bool>("honorSystemOrientationLock", default: true)
|
static let isOrientationLocked = Key<Bool>("isOrientationLocked", default: Constants.isIPhone)
|
||||||
static let enterFullscreenInLandscape = Key<Bool>("enterFullscreenInLandscape", default: Constants.isIPhone)
|
static let enterFullscreenInLandscape = Key<Bool>("enterFullscreenInLandscape", default: Constants.isIPhone)
|
||||||
static let rotateToLandscapeOnEnterFullScreen = Key<FullScreenRotationSetting>(
|
static let rotateToLandscapeOnEnterFullScreen = Key<FullScreenRotationSetting>("rotateToLandscapeOnEnterFullScreen", default: .landscapeRight)
|
||||||
"rotateToLandscapeOnEnterFullScreen",
|
|
||||||
default: Constants.isIPhone ? .landscapeRight : .disabled
|
|
||||||
)
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
static let closePiPOnNavigation = Key<Bool>("closePiPOnNavigation", default: false)
|
static let closePiPOnNavigation = Key<Bool>("closePiPOnNavigation", default: false)
|
||||||
@@ -134,6 +132,7 @@ extension Defaults.Keys {
|
|||||||
|
|
||||||
static let playerControlsLayout = Key<PlayerControlsLayout>("playerControlsLayout", default: playerControlsLayoutDefault)
|
static let playerControlsLayout = Key<PlayerControlsLayout>("playerControlsLayout", default: playerControlsLayoutDefault)
|
||||||
static let fullScreenPlayerControlsLayout = Key<PlayerControlsLayout>("fullScreenPlayerControlsLayout", default: fullScreenPlayerControlsLayoutDefault)
|
static let fullScreenPlayerControlsLayout = Key<PlayerControlsLayout>("fullScreenPlayerControlsLayout", default: fullScreenPlayerControlsLayoutDefault)
|
||||||
|
static let playerControlsBackgroundOpacity = Key<Double>("playerControlsBackgroundOpacity", default: 0.2)
|
||||||
|
|
||||||
static let systemControlsCommands = Key<SystemControlsCommands>("systemControlsCommands", default: .restartAndAdvanceToNext)
|
static let systemControlsCommands = Key<SystemControlsCommands>("systemControlsCommands", default: .restartAndAdvanceToNext)
|
||||||
|
|
||||||
@@ -360,6 +359,7 @@ extension Defaults.Keys {
|
|||||||
// MARK: Group - Advanced
|
// MARK: Group - Advanced
|
||||||
|
|
||||||
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 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)
|
||||||
@@ -370,6 +370,7 @@ extension Defaults.Keys {
|
|||||||
static let mpvHWdec = Key<String>("hwdec", default: "auto-safe")
|
static let mpvHWdec = Key<String>("hwdec", default: "auto-safe")
|
||||||
static let mpvDemuxerLavfProbeInfo = Key<String>("mpvDemuxerLavfProbeInfo", default: "no")
|
static let mpvDemuxerLavfProbeInfo = Key<String>("mpvDemuxerLavfProbeInfo", default: "no")
|
||||||
static let mpvInitialAudioSync = Key<Bool>("mpvInitialAudioSync", default: true)
|
static let mpvInitialAudioSync = Key<Bool>("mpvInitialAudioSync", default: true)
|
||||||
|
static let mpvSetRefreshToContentFPS = Key<Bool>("mpvSetRefreshToContentFPS", default: false)
|
||||||
|
|
||||||
static let showCacheStatus = Key<Bool>("showCacheStatus", default: false)
|
static let showCacheStatus = Key<Bool>("showCacheStatus", default: false)
|
||||||
static let feedCacheSize = Key<String>("feedCacheSize", default: "50")
|
static let feedCacheSize = Key<String>("feedCacheSize", default: "50")
|
||||||
@@ -426,18 +427,34 @@ enum ResolutionSetting: String, CaseIterable, Defaults.Serializable {
|
|||||||
case sd240p30
|
case sd240p30
|
||||||
case sd144p30
|
case sd144p30
|
||||||
|
|
||||||
var value: Stream.Resolution! {
|
var value: Stream.Resolution {
|
||||||
.init(rawValue: rawValue)
|
if let predefined = Stream.Resolution.PredefinedResolution(rawValue: rawValue) {
|
||||||
|
return .predefined(predefined)
|
||||||
|
}
|
||||||
|
// Provide a default value of 720p 30
|
||||||
|
return .custom(height: 720, refreshRate: 30)
|
||||||
}
|
}
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
switch self {
|
let resolution = value
|
||||||
case .hd2160p60:
|
let height = resolution.height
|
||||||
return "4K, 60fps"
|
let refreshRate = resolution.refreshRate
|
||||||
case .hd2160p30:
|
|
||||||
return "4K"
|
// Superscript labels
|
||||||
|
let superscript4K = "⁴ᴷ"
|
||||||
|
let superscriptHD = "ᴴᴰ"
|
||||||
|
|
||||||
|
// Special handling for specific resolutions
|
||||||
|
switch height {
|
||||||
|
case 2160:
|
||||||
|
// 4K superscript after the refresh rate
|
||||||
|
return refreshRate == 30 ? "2160p \(superscript4K)" : "2160p\(refreshRate) \(superscript4K)"
|
||||||
|
case 1440, 1080:
|
||||||
|
// HD superscript after the refresh rate
|
||||||
|
return refreshRate == 30 ? "\(height)p \(superscriptHD)" : "\(height)p\(refreshRate) \(superscriptHD)"
|
||||||
default:
|
default:
|
||||||
return value.name
|
// Default formatting for other resolutions
|
||||||
|
return refreshRate == 30 ? "\(height)p" : "\(height)p\(refreshRate)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -612,26 +629,19 @@ enum PlayerTapGestureAction: String, CaseIterable, Defaults.Serializable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum FullScreenRotationSetting: String, CaseIterable, Defaults.Serializable {
|
enum FullScreenRotationSetting: String, CaseIterable, Defaults.Serializable {
|
||||||
case disabled
|
|
||||||
case landscapeLeft
|
case landscapeLeft
|
||||||
case landscapeRight
|
case landscapeRight
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
var interaceOrientation: UIInterfaceOrientation {
|
var interfaceOrientation: UIInterfaceOrientation {
|
||||||
switch self {
|
switch self {
|
||||||
case .landscapeLeft:
|
case .landscapeLeft:
|
||||||
return .landscapeLeft
|
return .landscapeLeft
|
||||||
case .landscapeRight:
|
case .landscapeRight:
|
||||||
return .landscapeRight
|
return .landscapeRight
|
||||||
default:
|
|
||||||
return .portrait
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
var isRotating: Bool {
|
|
||||||
self != .disabled
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WidgetSettings: Defaults.Serializable {
|
struct WidgetSettings: Defaults.Serializable {
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ struct HomeView: View {
|
|||||||
#endif
|
#endif
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.background(Color.secondaryBackground)
|
.background(Color.secondaryBackground)
|
||||||
.frame(minWidth: 360)
|
.frame(minWidth: Constants.contentViewMinWidth)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItemGroup(placement: .automatic) {
|
ToolbarItemGroup(placement: .automatic) {
|
||||||
HideWatchedButtons()
|
HideWatchedButtons()
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ struct ContentView: View {
|
|||||||
.statusBarHidden(player.playingFullScreen)
|
.statusBarHidden(player.playingFullScreen)
|
||||||
#endif
|
#endif
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.frame(minWidth: 1200)
|
.frame(minWidth: 1200, minHeight: 600)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,6 @@ import SwiftUI
|
|||||||
|
|
||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
final class AppleAVPlayerViewControllerDelegate: NSObject, AVPlayerViewControllerDelegate {
|
final class AppleAVPlayerViewControllerDelegate: NSObject, AVPlayerViewControllerDelegate {
|
||||||
#if os(iOS)
|
|
||||||
@Default(.rotateToLandscapeOnEnterFullScreen) private var rotateToLandscapeOnEnterFullScreen
|
|
||||||
@Default(.avPlayerUsesSystemControls) private var avPlayerUsesSystemControls
|
|
||||||
#endif
|
|
||||||
|
|
||||||
var player: PlayerModel { .shared }
|
var player: PlayerModel { .shared }
|
||||||
|
|
||||||
func playerViewControllerShouldAutomaticallyDismissAtPictureInPictureStart(_: AVPlayerViewController) -> Bool {
|
func playerViewControllerShouldAutomaticallyDismissAtPictureInPictureStart(_: AVPlayerViewController) -> Bool {
|
||||||
@@ -17,15 +12,23 @@ import SwiftUI
|
|||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
func playerViewController(_: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator _: UIViewControllerTransitionCoordinator) {
|
func playerViewController(_: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator _: UIViewControllerTransitionCoordinator) {
|
||||||
guard rotateToLandscapeOnEnterFullScreen.isRotating else { return }
|
let lockOrientation = player.rotateToLandscapeOnEnterFullScreen.interfaceOrientation
|
||||||
if PlayerModel.shared.currentVideoIsLandscape {
|
if player.currentVideoIsLandscape {
|
||||||
let delay = PlayerModel.shared.activeBackend == .appleAVPlayer && avPlayerUsesSystemControls ? 0.8 : 0
|
if player.fullscreenInitiatedByButton {
|
||||||
// not sure why but first rotation call is ignore so doing rotate to same orientation first
|
Orientation.lockOrientation(player.isOrientationLocked
|
||||||
Delay.by(delay) {
|
? (lockOrientation == .landscapeRight ? .landscapeRight : .landscapeLeft)
|
||||||
let orientation = OrientationTracker.shared.currentDeviceOrientation.isLandscape ? OrientationTracker.shared.currentInterfaceOrientation : self.rotateToLandscapeOnEnterFullScreen.interaceOrientation
|
: .landscape)
|
||||||
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: OrientationTracker.shared.currentInterfaceOrientation)
|
|
||||||
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: orientation)
|
|
||||||
}
|
}
|
||||||
|
let orientation = OrientationTracker.shared.currentDeviceOrientation.isLandscape
|
||||||
|
? OrientationTracker.shared.currentInterfaceOrientation
|
||||||
|
: player.rotateToLandscapeOnEnterFullScreen.interfaceOrientation
|
||||||
|
|
||||||
|
Orientation.lockOrientation(
|
||||||
|
player.isOrientationLocked
|
||||||
|
? (lockOrientation == .landscapeRight ? .landscapeRight : .landscapeLeft)
|
||||||
|
: .all,
|
||||||
|
andRotateTo: orientation
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,11 +40,11 @@ import SwiftUI
|
|||||||
}
|
}
|
||||||
if !context.isCancelled {
|
if !context.isCancelled {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
self.player.lockedOrientation = nil
|
if self.player.lockPortraitWhenBrowsing {
|
||||||
|
self.player.lockedOrientation = UIInterfaceOrientationMask.portrait
|
||||||
if Constants.isIPhone {
|
|
||||||
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: .portrait)
|
|
||||||
}
|
}
|
||||||
|
let rotationOrientation = self.player.lockPortraitWhenBrowsing ? UIInterfaceOrientation.portrait : nil
|
||||||
|
Orientation.lockOrientation(self.player.lockPortraitWhenBrowsing ? .portrait : .all, andRotateTo: rotationOrientation)
|
||||||
|
|
||||||
if wasPlaying {
|
if wasPlaying {
|
||||||
self.player.play()
|
self.player.play()
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ struct PlayerControls: View {
|
|||||||
|
|
||||||
@Default(.playerControlsLayout) private var regularPlayerControlsLayout
|
@Default(.playerControlsLayout) private var regularPlayerControlsLayout
|
||||||
@Default(.fullScreenPlayerControlsLayout) private var fullScreenPlayerControlsLayout
|
@Default(.fullScreenPlayerControlsLayout) private var fullScreenPlayerControlsLayout
|
||||||
|
@Default(.playerControlsBackgroundOpacity) private var playerControlsBackgroundOpacity
|
||||||
@Default(.buttonBackwardSeekDuration) private var buttonBackwardSeekDuration
|
@Default(.buttonBackwardSeekDuration) private var buttonBackwardSeekDuration
|
||||||
@Default(.buttonForwardSeekDuration) private var buttonForwardSeekDuration
|
@Default(.buttonForwardSeekDuration) private var buttonForwardSeekDuration
|
||||||
|
|
||||||
@@ -270,6 +271,9 @@ struct PlayerControls: View {
|
|||||||
}
|
}
|
||||||
} else if player.videoForDisplay == nil {
|
} else if player.videoForDisplay == nil {
|
||||||
Color.black
|
Color.black
|
||||||
|
} else if model.presentingControls {
|
||||||
|
Color.black.opacity(playerControlsBackgroundOpacity)
|
||||||
|
.edgesIgnoringSafeArea(.all)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -383,13 +387,13 @@ struct PlayerControls: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var pipButton: some View {
|
private var pipButton: some View {
|
||||||
button("PiP", systemImage: player.pipImage, action: player.togglePiPAction)
|
button("PiP", systemImage: player.pipImage, active: player.playingInPictureInPicture, action: player.togglePiPAction)
|
||||||
.disabled(!player.pipPossible)
|
.disabled(!player.pipPossible)
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
private var lockOrientationButton: some View {
|
private var lockOrientationButton: some View {
|
||||||
button("Lock Rotation", systemImage: player.lockOrientationImage, active: !player.lockedOrientation.isNil, action: player.lockOrientationAction)
|
button("Lock Rotation", systemImage: player.lockOrientationImage, active: player.isOrientationLocked, action: player.lockOrientationAction)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import OpenGLES
|
|||||||
final class MPVOGLView: GLKView {
|
final class MPVOGLView: GLKView {
|
||||||
private var logger = Logger(label: "stream.yattee.mpv.oglview")
|
private var logger = Logger(label: "stream.yattee.mpv.oglview")
|
||||||
private var defaultFBO: GLint?
|
private var defaultFBO: GLint?
|
||||||
|
private var displayLink: CADisplayLink?
|
||||||
|
|
||||||
var mpvGL: UnsafeMutableRawPointer?
|
var mpvGL: UnsafeMutableRawPointer?
|
||||||
var queue = DispatchQueue(label: "stream.yattee.opengl")
|
var queue = DispatchQueue(label: "stream.yattee.opengl", qos: .userInteractive)
|
||||||
var needsDrawing = true
|
var needsDrawing = true
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
@@ -29,6 +30,69 @@ final class MPVOGLView: GLKView {
|
|||||||
enableSetNeedsDisplay = false
|
enableSetNeedsDisplay = false
|
||||||
|
|
||||||
fillBlack()
|
fillBlack()
|
||||||
|
setupDisplayLink()
|
||||||
|
setupNotifications()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder aDecoder: NSCoder) {
|
||||||
|
super.init(coder: aDecoder)
|
||||||
|
setupDisplayLink()
|
||||||
|
setupNotifications()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupDisplayLink() {
|
||||||
|
displayLink = CADisplayLink(target: self, selector: #selector(updateFrame))
|
||||||
|
displayLink?.add(to: .main, forMode: .common)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up observers to detect display changes and custom refresh rate updates.
|
||||||
|
private func setupNotifications() {
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(updateDisplayLinkFromNotification(_:)), name: .updateDisplayLinkFrameRate, object: nil)
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(screenDidChange), name: UIScreen.didConnectNotification, object: nil)
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(screenDidChange), name: UIScreen.didDisconnectNotification, object: nil)
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(screenDidChange), name: UIScreen.modeDidChangeNotification, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func screenDidChange(_: Notification) {
|
||||||
|
// Update the display link refresh rate when the screen configuration changes
|
||||||
|
updateDisplayLinkFrameRate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the display link frame rate from the notification.
|
||||||
|
@objc private func updateDisplayLinkFromNotification(_ notification: Notification) {
|
||||||
|
guard let userInfo = notification.userInfo,
|
||||||
|
let refreshRate = userInfo["refreshRate"] as? Int else { return }
|
||||||
|
displayLink?.preferredFramesPerSecond = refreshRate
|
||||||
|
logger.info("Updated CADisplayLink frame rate to: \(refreshRate) from backend notification.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the display link's preferred frame rate based on the current screen refresh rate.
|
||||||
|
private func updateDisplayLinkFrameRate() {
|
||||||
|
guard let displayLink else { return }
|
||||||
|
let refreshRate = getScreenRefreshRate()
|
||||||
|
displayLink.preferredFramesPerSecond = refreshRate
|
||||||
|
logger.info("Updated CADisplayLink preferred frames per second to: \(refreshRate)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve the screen's current refresh rate dynamically.
|
||||||
|
private func getScreenRefreshRate() -> Int {
|
||||||
|
// Use the main screen's maximumFramesPerSecond property
|
||||||
|
let refreshRate = UIScreen.main.maximumFramesPerSecond
|
||||||
|
logger.info("Screen refresh rate: \(refreshRate) Hz")
|
||||||
|
return refreshRate
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func updateFrame() {
|
||||||
|
// Trigger the drawing process if needed
|
||||||
|
if needsDrawing {
|
||||||
|
setNeedsDisplay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
// Invalidate the display link and remove observers to avoid memory leaks
|
||||||
|
displayLink?.invalidate()
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
func fillBlack() {
|
func fillBlack() {
|
||||||
@@ -37,35 +101,40 @@ final class MPVOGLView: GLKView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func draw(_: CGRect) {
|
override func draw(_: CGRect) {
|
||||||
guard needsDrawing, let mpvGL else {
|
guard needsDrawing, let mpvGL else { return }
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Bind the default framebuffer
|
||||||
glGetIntegerv(UInt32(GL_FRAMEBUFFER_BINDING), &defaultFBO!)
|
glGetIntegerv(UInt32(GL_FRAMEBUFFER_BINDING), &defaultFBO!)
|
||||||
|
|
||||||
|
// Get the current viewport dimensions
|
||||||
var dims: [GLint] = [0, 0, 0, 0]
|
var dims: [GLint] = [0, 0, 0, 0]
|
||||||
glGetIntegerv(GLenum(GL_VIEWPORT), &dims)
|
glGetIntegerv(GLenum(GL_VIEWPORT), &dims)
|
||||||
|
|
||||||
|
// Set up the OpenGL FBO data
|
||||||
var data = mpv_opengl_fbo(
|
var data = mpv_opengl_fbo(
|
||||||
fbo: Int32(defaultFBO!),
|
fbo: Int32(defaultFBO!),
|
||||||
w: Int32(dims[2]),
|
w: Int32(dims[2]),
|
||||||
h: Int32(dims[3]),
|
h: Int32(dims[3]),
|
||||||
internal_format: 0
|
internal_format: 0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Flip Y coordinate for proper rendering
|
||||||
var flip: CInt = 1
|
var flip: CInt = 1
|
||||||
withUnsafeMutablePointer(to: &flip) { flip in
|
|
||||||
withUnsafeMutablePointer(to: &data) { data in
|
// Render with the provided OpenGL FBO parameters
|
||||||
|
withUnsafeMutablePointer(to: &flip) { flipPtr in
|
||||||
|
withUnsafeMutablePointer(to: &data) { dataPtr in
|
||||||
var params = [
|
var params = [
|
||||||
mpv_render_param(type: MPV_RENDER_PARAM_OPENGL_FBO, data: data),
|
mpv_render_param(type: MPV_RENDER_PARAM_OPENGL_FBO, data: dataPtr),
|
||||||
mpv_render_param(type: MPV_RENDER_PARAM_FLIP_Y, data: flip),
|
mpv_render_param(type: MPV_RENDER_PARAM_FLIP_Y, data: flipPtr),
|
||||||
mpv_render_param()
|
mpv_render_param()
|
||||||
]
|
]
|
||||||
mpv_render_context_render(OpaquePointer(mpvGL), ¶ms)
|
mpv_render_context_render(OpaquePointer(mpvGL), ¶ms)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
required init?(coder aDecoder: NSCoder) {
|
|
||||||
super.init(coder: aDecoder)
|
extension Notification.Name {
|
||||||
}
|
static let updateDisplayLinkFrameRate = Notification.Name("updateDisplayLinkFrameRate")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ extension VideoPlayerView {
|
|||||||
.updating($dragGestureOffset) { value, state, _ in
|
.updating($dragGestureOffset) { value, state, _ in
|
||||||
guard isVerticalDrag else { return }
|
guard isVerticalDrag else { return }
|
||||||
var translation = value.translation
|
var translation = value.translation
|
||||||
translation.height = max(0, translation.height)
|
translation.height = max(-translation.height, translation.height)
|
||||||
state = translation
|
state = translation
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -18,7 +18,8 @@ extension VideoPlayerView {
|
|||||||
.onChanged { value in
|
.onChanged { value in
|
||||||
guard player.presentingPlayer,
|
guard player.presentingPlayer,
|
||||||
!controlsOverlayModel.presenting,
|
!controlsOverlayModel.presenting,
|
||||||
dragGestureState else { return }
|
dragGestureState,
|
||||||
|
!disableToggleGesture else { return }
|
||||||
|
|
||||||
if player.controls.presentingControls, !player.musicMode {
|
if player.controls.presentingControls, !player.musicMode {
|
||||||
player.controls.presentingControls = false
|
player.controls.presentingControls = false
|
||||||
@@ -61,19 +62,18 @@ extension VideoPlayerView {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard verticalDrag > 0 else { return }
|
// Toggle fullscreen on upward drag only when not disabled
|
||||||
viewDragOffset = verticalDrag
|
if verticalDrag < -50 {
|
||||||
|
player.toggleFullScreenAction()
|
||||||
|
disableGestureTemporarily()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if verticalDrag > 60,
|
// Ignore downward swipes when in fullscreen
|
||||||
player.playingFullScreen
|
guard verticalDrag > 0 && !player.playingFullScreen else {
|
||||||
{
|
return
|
||||||
player.exitFullScreen(showControls: false)
|
|
||||||
#if os(iOS)
|
|
||||||
if Constants.isIPhone {
|
|
||||||
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: .portrait)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
viewDragOffset = verticalDrag
|
||||||
}
|
}
|
||||||
.onEnded { _ in
|
.onEnded { _ in
|
||||||
onPlayerDragGestureEnded()
|
onPlayerDragGestureEnded()
|
||||||
@@ -86,16 +86,6 @@ extension VideoPlayerView {
|
|||||||
player.seek.onSeekGestureEnd()
|
player.seek.onSeekGestureEnd()
|
||||||
}
|
}
|
||||||
|
|
||||||
if viewDragOffset > 60,
|
|
||||||
player.playingFullScreen
|
|
||||||
{
|
|
||||||
#if os(iOS)
|
|
||||||
player.lockedOrientation = nil
|
|
||||||
#endif
|
|
||||||
player.exitFullScreen(showControls: false)
|
|
||||||
viewDragOffset = 0
|
|
||||||
return
|
|
||||||
}
|
|
||||||
isVerticalDrag = false
|
isVerticalDrag = false
|
||||||
|
|
||||||
guard player.presentingPlayer,
|
guard player.presentingPlayer,
|
||||||
@@ -117,4 +107,12 @@ extension VideoPlayerView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Function to temporarily disable the toggle gesture after a fullscreen change
|
||||||
|
private func disableGestureTemporarily() {
|
||||||
|
disableToggleGesture = true
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
||||||
|
disableToggleGesture = false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,10 +155,10 @@ struct VideoActions: View {
|
|||||||
case .fullScreen:
|
case .fullScreen:
|
||||||
actionButton("Fullscreen", systemImage: player.fullscreenImage, action: player.toggleFullScreenAction)
|
actionButton("Fullscreen", systemImage: player.fullscreenImage, action: player.toggleFullScreenAction)
|
||||||
case .pip:
|
case .pip:
|
||||||
actionButton("PiP", systemImage: player.pipImage, action: player.togglePiPAction)
|
actionButton("PiP", systemImage: player.pipImage, active: player.playingInPictureInPicture, action: player.togglePiPAction)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
case .lockOrientation:
|
case .lockOrientation:
|
||||||
actionButton("Lock", systemImage: player.lockOrientationImage, active: player.lockedOrientation != nil, action: player.lockOrientationAction)
|
actionButton("Lock", systemImage: player.lockOrientationImage, active: player.isOrientationLocked, action: player.lockOrientationAction)
|
||||||
#endif
|
#endif
|
||||||
case .restart:
|
case .restart:
|
||||||
actionButton("Replay", systemImage: "backward.end.fill", action: player.replayAction)
|
actionButton("Replay", systemImage: "backward.end.fill", action: player.replayAction)
|
||||||
|
|||||||
@@ -47,11 +47,18 @@ struct VideoPlayerView: View {
|
|||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
@GestureState var dragGestureState = false
|
@GestureState var dragGestureState = false
|
||||||
@GestureState var dragGestureOffset = CGSize.zero
|
@GestureState var dragGestureOffset = CGSize.zero
|
||||||
@State var isHorizontalDrag = false // swiftlint:disable:this swiftui_state_private
|
// swiftlint:disable private_swiftui_state
|
||||||
@State var isVerticalDrag = false // swiftlint:disable:this swiftui_state_private
|
@State var isHorizontalDrag = false
|
||||||
@State var viewDragOffset = Self.hiddenOffset // swiftlint:disable:this swiftui_state_private
|
@State var isVerticalDrag = false
|
||||||
|
@State var viewDragOffset = Self.hiddenOffset
|
||||||
|
// swiftlint:enable private_swiftui_state
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
// swiftlint:disable private_swiftui_state
|
||||||
|
@State var disableToggleGesture = false
|
||||||
|
// swiftlint:enable private_swiftui_state
|
||||||
|
|
||||||
@ObservedObject var player = PlayerModel.shared // swiftlint:disable:this swiftui_state_private
|
@ObservedObject var player = PlayerModel.shared // swiftlint:disable:this swiftui_state_private
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@@ -104,9 +111,6 @@ struct VideoPlayerView: View {
|
|||||||
.onChange(of: geometry.size) { _ in
|
.onChange(of: geometry.size) { _ in
|
||||||
self.playerSize = geometry.size
|
self.playerSize = geometry.size
|
||||||
}
|
}
|
||||||
.onChange(of: fullScreenDetails) { value in
|
|
||||||
player.backend.setNeedsDrawing(!value)
|
|
||||||
}
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.onChange(of: player.presentingPlayer) { newValue in
|
.onChange(of: player.presentingPlayer) { newValue in
|
||||||
if newValue {
|
if newValue {
|
||||||
@@ -120,19 +124,6 @@ struct VideoPlayerView: View {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
viewDragOffset = 0
|
viewDragOffset = 0
|
||||||
|
|
||||||
Delay.by(0.2) {
|
|
||||||
orientationModel.configureOrientationUpdatesBasedOnAccelerometer()
|
|
||||||
|
|
||||||
if let orientationMask = player.lockedOrientation {
|
|
||||||
Orientation.lockOrientation(
|
|
||||||
orientationMask,
|
|
||||||
andRotateTo: orientationMask == .landscapeLeft ? .landscapeLeft : orientationMask == .landscapeRight ? .landscapeRight : .portrait
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Orientation.lockOrientation(.allButUpsideDown)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.onAnimationCompleted(for: viewDragOffset) {
|
.onAnimationCompleted(for: viewDragOffset) {
|
||||||
guard !dragGestureState else { return }
|
guard !dragGestureState else { return }
|
||||||
@@ -306,11 +297,14 @@ struct VideoPlayerView: View {
|
|||||||
playerSize: player.playerSize,
|
playerSize: player.playerSize,
|
||||||
fullScreen: fullScreenDetails
|
fullScreen: fullScreenDetails
|
||||||
))
|
))
|
||||||
|
#if os(macOS)
|
||||||
|
// TODO: Check whether this is needed on macOS.
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
if player.presentingPlayer {
|
if player.presentingPlayer {
|
||||||
player.setNeedsDrawing(true)
|
player.setNeedsDrawing(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
.id(player.currentVideo?.cacheKey)
|
.id(player.currentVideo?.cacheKey)
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,25 +1,23 @@
|
|||||||
import Repeat
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct SearchTextField: View {
|
struct SearchTextField: View {
|
||||||
private var navigation = NavigationModel.shared
|
private var navigation = NavigationModel.shared
|
||||||
@ObservedObject private var state = SearchModel.shared
|
@ObservedObject private var state = SearchModel.shared
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
#if os(macOS)
|
|
||||||
fieldBorder
|
fieldBorder
|
||||||
#endif
|
|
||||||
|
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
#if os(macOS)
|
|
||||||
Image(systemName: "magnifyingglass")
|
Image(systemName: "magnifyingglass")
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFill()
|
.scaledToFill()
|
||||||
.frame(width: 12, height: 12)
|
.frame(width: 12, height: 12)
|
||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 6)
|
||||||
.opacity(0.8)
|
.opacity(0.8)
|
||||||
#endif
|
|
||||||
|
GeometryReader { geometry in
|
||||||
TextField("Search...", text: $state.queryText) {
|
TextField("Search...", text: $state.queryText) {
|
||||||
state.changeQuery { query in
|
state.changeQuery { query in
|
||||||
query.query = state.queryText
|
query.query = state.queryText
|
||||||
@@ -28,37 +26,74 @@ struct SearchTextField: View {
|
|||||||
RecentsModel.shared.addQuery(state.queryText)
|
RecentsModel.shared.addQuery(state.queryText)
|
||||||
}
|
}
|
||||||
.disableAutocorrection(true)
|
.disableAutocorrection(true)
|
||||||
#if os(macOS)
|
.frame(maxWidth: geometry.size.width - 5)
|
||||||
.frame(maxWidth: 190)
|
|
||||||
.textFieldStyle(.plain)
|
.textFieldStyle(.plain)
|
||||||
#else
|
.padding(.vertical, 8)
|
||||||
.frame(minWidth: 200)
|
.frame(height: 27, alignment: .center)
|
||||||
.textFieldStyle(.roundedBorder)
|
}
|
||||||
.padding(.horizontal, 5)
|
|
||||||
.padding(.trailing, state.queryText.isEmpty ? 0 : 10)
|
|
||||||
#endif
|
|
||||||
|
|
||||||
if !state.queryText.isEmpty {
|
if !state.queryText.isEmpty {
|
||||||
clearButton
|
clearButton
|
||||||
} else {
|
} else {
|
||||||
#if os(macOS)
|
|
||||||
clearButton
|
clearButton
|
||||||
.opacity(0)
|
.opacity(0)
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.transaction { t in t.animation = nil }
|
.transaction { t in t.animation = nil }
|
||||||
}
|
}
|
||||||
|
#else
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
HStack {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
.padding(.leading, 5)
|
||||||
|
.padding(.trailing, 5)
|
||||||
|
.imageScale(.medium)
|
||||||
|
|
||||||
|
TextField("Search...", text: $state.queryText) {
|
||||||
|
state.changeQuery { query in
|
||||||
|
query.query = state.queryText
|
||||||
|
navigation.hideKeyboard()
|
||||||
|
}
|
||||||
|
RecentsModel.shared.addQuery(state.queryText)
|
||||||
|
}
|
||||||
|
.disableAutocorrection(true)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.padding(.vertical, 7)
|
||||||
|
|
||||||
|
if !state.queryText.isEmpty {
|
||||||
|
clearButton
|
||||||
|
.padding(.leading, 5)
|
||||||
|
.padding(.trailing, 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(Color("SearchTextFieldBackground"))
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.stroke(Color(UIColor.secondarySystemBackground), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 0)
|
||||||
|
}
|
||||||
|
.transaction { t in t.animation = nil }
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
private var fieldBorder: some View {
|
private var fieldBorder: some View {
|
||||||
RoundedRectangle(cornerRadius: 5, style: .continuous)
|
RoundedRectangle(cornerRadius: 5, style: .continuous)
|
||||||
.fill(Color.background)
|
.fill(Color.background)
|
||||||
.frame(width: 250, height: 32)
|
.frame(width: 250, height: 27)
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 5, style: .continuous)
|
RoundedRectangle(cornerRadius: 5, style: .continuous)
|
||||||
.stroke(Color.gray.opacity(0.4), lineWidth: 1)
|
.stroke(Color.gray.opacity(0.4), lineWidth: 1)
|
||||||
.frame(width: 250, height: 31)
|
.frame(width: 250, height: 27)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,15 +102,14 @@ struct SearchTextField: View {
|
|||||||
self.state.queryText = ""
|
self.state.queryText = ""
|
||||||
}) {
|
}) {
|
||||||
Image(systemName: "xmark.circle.fill")
|
Image(systemName: "xmark.circle.fill")
|
||||||
#if os(macOS)
|
|
||||||
.imageScale(.small)
|
|
||||||
#else
|
|
||||||
.imageScale(.medium)
|
.imageScale(.medium)
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(PlainButtonStyle())
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.padding(.trailing, 10)
|
.padding(.trailing, 5)
|
||||||
|
#elseif os(iOS)
|
||||||
|
.padding(.trailing, 5)
|
||||||
|
.foregroundColor(.gray)
|
||||||
#endif
|
#endif
|
||||||
.opacity(0.7)
|
.opacity(0.7)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ struct SearchView: View {
|
|||||||
@Default(.saveRecents) private var saveRecents
|
@Default(.saveRecents) private var saveRecents
|
||||||
@Default(.showHome) private var showHome
|
@Default(.showHome) private var showHome
|
||||||
@Default(.searchListingStyle) private var searchListingStyle
|
@Default(.searchListingStyle) private var searchListingStyle
|
||||||
|
@Default(.showSearchSuggestions) private var showSearchSuggestions
|
||||||
|
|
||||||
private var videos = [Video]()
|
private var videos = [Video]()
|
||||||
|
|
||||||
@@ -38,9 +39,9 @@ struct SearchView: View {
|
|||||||
self.videos = videos
|
self.videos = videos
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
#if os(iOS)
|
|
||||||
VStack {
|
VStack {
|
||||||
if accounts.app.supportsSearchSuggestions, state.query.query != state.queryText {
|
if accounts.app.supportsSearchSuggestions, state.query.query != state.queryText {
|
||||||
SearchSuggestions()
|
SearchSuggestions()
|
||||||
@@ -51,27 +52,155 @@ struct SearchView: View {
|
|||||||
}
|
}
|
||||||
.backport
|
.backport
|
||||||
.scrollDismissesKeyboardInteractively()
|
.scrollDismissesKeyboardInteractively()
|
||||||
#else
|
}
|
||||||
|
.environment(\.listingStyle, searchListingStyle)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .principal) {
|
||||||
|
if #available(iOS 15, *) {
|
||||||
|
FocusableSearchTextField()
|
||||||
|
} else {
|
||||||
|
SearchTextField()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
searchMenu
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.ignoresSafeArea(.keyboard, edges: .bottom)
|
||||||
|
.navigationTitle("Search")
|
||||||
|
.onAppear {
|
||||||
|
if let query {
|
||||||
|
state.queryText = query.query
|
||||||
|
state.resetQuery(query)
|
||||||
|
updateFavoriteItem()
|
||||||
|
}
|
||||||
|
|
||||||
|
if !videos.isEmpty {
|
||||||
|
state.store.replace(ContentItem.array(of: videos))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: accounts.current) { _ in
|
||||||
|
state.reloadQuery()
|
||||||
|
}
|
||||||
|
.onChange(of: state.queryText) { newQuery in
|
||||||
|
if newQuery.isEmpty {
|
||||||
|
favoriteItem = nil
|
||||||
|
state.resetQuery()
|
||||||
|
} else {
|
||||||
|
updateFavoriteItem()
|
||||||
|
}
|
||||||
|
state.loadSuggestions(newQuery)
|
||||||
|
}
|
||||||
|
.onChange(of: searchSortOrder) { order in
|
||||||
|
state.changeQuery { query in
|
||||||
|
query.sortBy = order
|
||||||
|
updateFavoriteItem()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: searchDate) { date in
|
||||||
|
state.changeQuery { query in
|
||||||
|
query.date = date
|
||||||
|
updateFavoriteItem()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: searchDuration) { duration in
|
||||||
|
state.changeQuery { query in
|
||||||
|
query.duration = duration
|
||||||
|
updateFavoriteItem()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#elseif os(tvOS)
|
||||||
|
var body: some View {
|
||||||
|
VStack {
|
||||||
ZStack {
|
ZStack {
|
||||||
results
|
results
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.environment(\.listingStyle, searchListingStyle)
|
||||||
|
.onAppear {
|
||||||
|
if let query {
|
||||||
|
state.queryText = query.query
|
||||||
|
state.resetQuery(query)
|
||||||
|
updateFavoriteItem()
|
||||||
|
}
|
||||||
|
|
||||||
#if os(macOS)
|
if !videos.isEmpty {
|
||||||
if accounts.app.supportsSearchSuggestions, state.query.query != state.queryText {
|
state.store.replace(ContentItem.array(of: videos))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: accounts.current) { _ in
|
||||||
|
state.reloadQuery()
|
||||||
|
}
|
||||||
|
.onChange(of: state.queryText) { newQuery in
|
||||||
|
if newQuery.isEmpty {
|
||||||
|
favoriteItem = nil
|
||||||
|
state.resetQuery()
|
||||||
|
} else {
|
||||||
|
updateFavoriteItem()
|
||||||
|
}
|
||||||
|
if showSearchSuggestions {
|
||||||
|
state.loadSuggestions(newQuery)
|
||||||
|
}
|
||||||
|
searchDebounce.invalidate()
|
||||||
|
recentsDebounce.invalidate()
|
||||||
|
|
||||||
|
searchDebounce.debouncing(2) {
|
||||||
|
state.changeQuery { query in
|
||||||
|
query.query = newQuery
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recentsDebounce.debouncing(10) {
|
||||||
|
recents.addQuery(newQuery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: searchSortOrder) { order in
|
||||||
|
state.changeQuery { query in
|
||||||
|
query.sortBy = order
|
||||||
|
updateFavoriteItem()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: searchDate) { date in
|
||||||
|
state.changeQuery { query in
|
||||||
|
query.date = date
|
||||||
|
updateFavoriteItem()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: searchDuration) { duration in
|
||||||
|
state.changeQuery { query in
|
||||||
|
query.duration = duration
|
||||||
|
updateFavoriteItem()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.searchable(text: $state.queryText) {
|
||||||
|
if !state.queryText.isEmpty {
|
||||||
|
ForEach(state.querySuggestions, id: \.self) { suggestion in
|
||||||
|
Text(suggestion)
|
||||||
|
.searchCompletion(suggestion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#elseif os(macOS)
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
results
|
||||||
|
if accounts.app.supportsSearchSuggestions, state.query.query != state.queryText, showSearchSuggestions {
|
||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
SearchSuggestions()
|
SearchSuggestions()
|
||||||
.borderLeading(width: 1, color: Color("ControlsBorderColor"))
|
.borderLeading(width: 1, color: Color("ControlsBorderColor"))
|
||||||
.frame(maxWidth: 280)
|
.frame(maxWidth: 262)
|
||||||
.opacity(state.queryText.isEmpty ? 0 : 1)
|
.opacity(state.queryText.isEmpty ? 0 : 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
.environment(\.listingStyle, searchListingStyle)
|
.environment(\.listingStyle, searchListingStyle)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
#if os(macOS)
|
|
||||||
ToolbarItemGroup(placement: toolbarPlacement) {
|
ToolbarItemGroup(placement: toolbarPlacement) {
|
||||||
ListingStyleButtons(listingStyle: $searchListingStyle)
|
ListingStyleButtons(listingStyle: $searchListingStyle)
|
||||||
HideWatchedButtons()
|
HideWatchedButtons()
|
||||||
@@ -84,7 +213,6 @@ struct SearchView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
Text("Sort:")
|
Text("Sort:")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
searchSortOrderPicker
|
searchSortOrderPicker
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,7 +229,6 @@ struct SearchView: View {
|
|||||||
SearchTextField()
|
SearchTextField()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if let query {
|
if let query {
|
||||||
@@ -124,23 +251,7 @@ struct SearchView: View {
|
|||||||
} else {
|
} else {
|
||||||
updateFavoriteItem()
|
updateFavoriteItem()
|
||||||
}
|
}
|
||||||
|
|
||||||
state.loadSuggestions(newQuery)
|
state.loadSuggestions(newQuery)
|
||||||
|
|
||||||
#if os(tvOS)
|
|
||||||
searchDebounce.invalidate()
|
|
||||||
recentsDebounce.invalidate()
|
|
||||||
|
|
||||||
searchDebounce.debouncing(2) {
|
|
||||||
state.changeQuery { query in
|
|
||||||
query.query = newQuery
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
recentsDebounce.debouncing(10) {
|
|
||||||
recents.addQuery(newQuery)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
.onChange(of: searchSortOrder) { order in
|
.onChange(of: searchSortOrder) { order in
|
||||||
state.changeQuery { query in
|
state.changeQuery { query in
|
||||||
@@ -160,35 +271,10 @@ struct SearchView: View {
|
|||||||
updateFavoriteItem()
|
updateFavoriteItem()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#if os(tvOS)
|
.frame(minWidth: Constants.contentViewMinWidth)
|
||||||
.searchable(text: $state.queryText) {
|
|
||||||
if !state.queryText.isEmpty {
|
|
||||||
ForEach(state.querySuggestions, id: \.self) { suggestion in
|
|
||||||
Text(suggestion)
|
|
||||||
.searchCompletion(suggestion)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
.ignoresSafeArea(.keyboard, edges: .bottom)
|
|
||||||
.navigationTitle("Search")
|
.navigationTitle("Search")
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
#if os(iOS)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
|
||||||
searchMenu
|
|
||||||
}
|
|
||||||
ToolbarItem(placement: .principal) {
|
|
||||||
if #available(iOS 15, *) {
|
|
||||||
FocusableSearchTextField()
|
|
||||||
} else {
|
|
||||||
SearchTextField()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
var searchMenu: some View {
|
var searchMenu: some View {
|
||||||
@@ -230,11 +316,10 @@ struct SearchView: View {
|
|||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "magnifyingglass")
|
|
||||||
Image(systemName: "chevron.down.circle.fill")
|
Image(systemName: "chevron.down.circle.fill")
|
||||||
}
|
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(.accentColor)
|
||||||
.imageScale(.medium)
|
.imageScale(.large)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ struct AdvancedSettings: View {
|
|||||||
@Default(.mpvHWdec) private var mpvHWdec
|
@Default(.mpvHWdec) private var mpvHWdec
|
||||||
@Default(.mpvDemuxerLavfProbeInfo) private var mpvDemuxerLavfProbeInfo
|
@Default(.mpvDemuxerLavfProbeInfo) private var mpvDemuxerLavfProbeInfo
|
||||||
@Default(.mpvInitialAudioSync) private var mpvInitialAudioSync
|
@Default(.mpvInitialAudioSync) private var mpvInitialAudioSync
|
||||||
|
@Default(.mpvSetRefreshToContentFPS) private var mpvSetRefreshToContentFPS
|
||||||
@Default(.showCacheStatus) private var showCacheStatus
|
@Default(.showCacheStatus) private var showCacheStatus
|
||||||
@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
|
||||||
|
|
||||||
@State private var filesToShare = [MPVClient.logFile]
|
@State private var filesToShare = [MPVClient.logFile]
|
||||||
@State private var presentingShareSheet = false
|
@State private var presentingShareSheet = false
|
||||||
@@ -64,6 +66,7 @@ struct AdvancedSettings: View {
|
|||||||
@ViewBuilder var advancedSettings: some View {
|
@ViewBuilder var advancedSettings: some View {
|
||||||
Section(header: SettingsHeader(text: "Advanced")) {
|
Section(header: SettingsHeader(text: "Advanced")) {
|
||||||
showPlayNowInBackendButtonsToggle
|
showPlayNowInBackendButtonsToggle
|
||||||
|
videoLoadingRetryCountField
|
||||||
}
|
}
|
||||||
|
|
||||||
Section(header: SettingsHeader(text: "MPV"), footer: mpvFooter) {
|
Section(header: SettingsHeader(text: "MPV"), footer: mpvFooter) {
|
||||||
@@ -245,6 +248,12 @@ struct AdvancedSettings: View {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Toggle(isOn: $mpvSetRefreshToContentFPS) {
|
||||||
|
HStack {
|
||||||
|
Text("Sync refresh rate with content FPS – EXPERIMENTAL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if mpvEnableLogging {
|
if mpvEnableLogging {
|
||||||
logButton
|
logButton
|
||||||
}
|
}
|
||||||
@@ -281,6 +290,19 @@ struct AdvancedSettings: View {
|
|||||||
Toggle("Show video context menu options to force selected backend", isOn: $showPlayNowInBackendContextMenu)
|
Toggle("Show video context menu options to force selected backend", isOn: $showPlayNowInBackendContextMenu)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var videoLoadingRetryCountField: some View {
|
||||||
|
HStack {
|
||||||
|
Text("Maximum retries for video loading")
|
||||||
|
.frame(minWidth: 200, alignment: .leading)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
TextField("Limit", value: $videoLoadingRetryCount, formatter: NumberFormatter())
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
#if !os(macOS)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var showMPVPlaybackStatsToggle: some View {
|
var showMPVPlaybackStatsToggle: some View {
|
||||||
Toggle("Show playback statistics", isOn: $showMPVPlaybackStats)
|
Toggle("Show playback statistics", isOn: $showMPVPlaybackStats)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ struct BrowsingSettings: View {
|
|||||||
@Default(.showUnwatchedFeedBadges) private var showUnwatchedFeedBadges
|
@Default(.showUnwatchedFeedBadges) private var showUnwatchedFeedBadges
|
||||||
@Default(.keepChannelsWithUnwatchedFeedOnTop) private var keepChannelsWithUnwatchedFeedOnTop
|
@Default(.keepChannelsWithUnwatchedFeedOnTop) private var keepChannelsWithUnwatchedFeedOnTop
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
|
@Default(.enterFullscreenInLandscape) private var enterFullscreenInLandscape
|
||||||
@Default(.lockPortraitWhenBrowsing) private var lockPortraitWhenBrowsing
|
@Default(.lockPortraitWhenBrowsing) private var lockPortraitWhenBrowsing
|
||||||
@Default(.showDocuments) private var showDocuments
|
@Default(.showDocuments) private var showDocuments
|
||||||
#endif
|
#endif
|
||||||
@@ -19,6 +20,7 @@ struct BrowsingSettings: View {
|
|||||||
@Default(.showOpenActionsToolbarItem) private var showOpenActionsToolbarItem
|
@Default(.showOpenActionsToolbarItem) private var showOpenActionsToolbarItem
|
||||||
@Default(.visibleSections) private var visibleSections
|
@Default(.visibleSections) private var visibleSections
|
||||||
@Default(.startupSection) private var startupSection
|
@Default(.startupSection) private var startupSection
|
||||||
|
@Default(.showSearchSuggestions) private var showSearchSuggestions
|
||||||
@Default(.playerButtonSingleTapGesture) private var playerButtonSingleTapGesture
|
@Default(.playerButtonSingleTapGesture) private var playerButtonSingleTapGesture
|
||||||
@Default(.playerButtonDoubleTapGesture) private var playerButtonDoubleTapGesture
|
@Default(.playerButtonDoubleTapGesture) private var playerButtonDoubleTapGesture
|
||||||
@Default(.playerButtonShowsControlButtonsWhenMinimized) private var playerButtonShowsControlButtonsWhenMinimized
|
@Default(.playerButtonShowsControlButtonsWhenMinimized) private var playerButtonShowsControlButtonsWhenMinimized
|
||||||
@@ -66,6 +68,7 @@ struct BrowsingSettings: View {
|
|||||||
homeSettings
|
homeSettings
|
||||||
if !accounts.isEmpty {
|
if !accounts.isEmpty {
|
||||||
startupSectionPicker
|
startupSectionPicker
|
||||||
|
showSearchSuggestionsToggle
|
||||||
visibleSectionsSettings
|
visibleSectionsSettings
|
||||||
}
|
}
|
||||||
let interface = interfaceSettings
|
let interface = interfaceSettings
|
||||||
@@ -161,12 +164,16 @@ struct BrowsingSettings: View {
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
Toggle("Show Documents", isOn: $showDocuments)
|
Toggle("Show Documents", isOn: $showDocuments)
|
||||||
|
|
||||||
|
if Constants.isIPad {
|
||||||
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 {
|
||||||
|
enterFullscreenInLandscape = true
|
||||||
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
|
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
|
||||||
} else {
|
} else {
|
||||||
Orientation.lockOrientation(.allButUpsideDown)
|
enterFullscreenInLandscape = false
|
||||||
|
Orientation.lockOrientation(.all)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -241,6 +248,10 @@ struct BrowsingSettings: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var showSearchSuggestionsToggle: some View {
|
||||||
|
Toggle("Show search suggestions", isOn: $showSearchSuggestions)
|
||||||
|
}
|
||||||
|
|
||||||
private func toggleSection(_ section: VisibleSection, value: Bool) {
|
private func toggleSection(_ section: VisibleSection, value: Bool) {
|
||||||
if value {
|
if value {
|
||||||
visibleSections.insert(section)
|
visibleSections.insert(section)
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ struct PlayerControlsSettings: View {
|
|||||||
@Default(.playerControlsAdvanceToNextEnabled) private var playerControlsAdvanceToNextEnabled
|
@Default(.playerControlsAdvanceToNextEnabled) private var playerControlsAdvanceToNextEnabled
|
||||||
@Default(.playerControlsPlaybackModeEnabled) private var playerControlsPlaybackModeEnabled
|
@Default(.playerControlsPlaybackModeEnabled) private var playerControlsPlaybackModeEnabled
|
||||||
@Default(.playerControlsMusicModeEnabled) private var playerControlsMusicModeEnabled
|
@Default(.playerControlsMusicModeEnabled) private var playerControlsMusicModeEnabled
|
||||||
|
@Default(.playerControlsBackgroundOpacity) private var playerControlsBackgroundOpacity
|
||||||
|
|
||||||
private var player = PlayerModel.shared
|
private var player = PlayerModel.shared
|
||||||
|
|
||||||
@@ -76,6 +77,8 @@ struct PlayerControlsSettings: View {
|
|||||||
playerControlsLayoutPicker
|
playerControlsLayoutPicker
|
||||||
SettingsHeader(text: "Fullscreen size".localized(), secondary: true)
|
SettingsHeader(text: "Fullscreen size".localized(), secondary: true)
|
||||||
fullScreenPlayerControlsLayoutPicker
|
fullScreenPlayerControlsLayoutPicker
|
||||||
|
SettingsHeader(text: "Background opacity".localized(), secondary: true)
|
||||||
|
playerControlsBackgroundOpacityPicker
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -202,6 +205,15 @@ struct PlayerControlsSettings: View {
|
|||||||
.modifier(SettingsPickerModifier())
|
.modifier(SettingsPickerModifier())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var playerControlsBackgroundOpacityPicker: some View {
|
||||||
|
Picker("Background opacity", selection: $playerControlsBackgroundOpacity) {
|
||||||
|
ForEach(Array(stride(from: 0.0, through: 1.0, by: 0.1)), id: \.self) { value in
|
||||||
|
Text("\(Int(value * 100))%").tag(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.modifier(SettingsPickerModifier())
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder private var seekingSection: some View {
|
@ViewBuilder private var seekingSection: some View {
|
||||||
seekingDurationSetting("System controls", $systemControlsSeekDuration)
|
seekingDurationSetting("System controls", $systemControlsSeekDuration)
|
||||||
.foregroundColor(systemControlsCommands == .restartAndAdvanceToNext ? .secondary : .primary)
|
.foregroundColor(systemControlsCommands == .restartAndAdvanceToNext ? .secondary : .primary)
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ struct PlayerSettings: View {
|
|||||||
@Default(.pauseOnHidingPlayer) private var pauseOnHidingPlayer
|
@Default(.pauseOnHidingPlayer) private var pauseOnHidingPlayer
|
||||||
@Default(.closeVideoOnEOF) private var closeVideoOnEOF
|
@Default(.closeVideoOnEOF) private var closeVideoOnEOF
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@Default(.honorSystemOrientationLock) private var honorSystemOrientationLock
|
|
||||||
@Default(.enterFullscreenInLandscape) private var enterFullscreenInLandscape
|
@Default(.enterFullscreenInLandscape) private var enterFullscreenInLandscape
|
||||||
|
@Default(.lockPortraitWhenBrowsing) private var lockPortraitWhenBrowsing
|
||||||
@Default(.rotateToLandscapeOnEnterFullScreen) private var rotateToLandscapeOnEnterFullScreen
|
@Default(.rotateToLandscapeOnEnterFullScreen) private var rotateToLandscapeOnEnterFullScreen
|
||||||
#endif
|
#endif
|
||||||
@Default(.closePiPOnNavigation) private var closePiPOnNavigation
|
@Default(.closePiPOnNavigation) private var closePiPOnNavigation
|
||||||
@@ -87,7 +87,7 @@ struct PlayerSettings: View {
|
|||||||
}
|
}
|
||||||
pauseOnHidingPlayerToggle
|
pauseOnHidingPlayerToggle
|
||||||
closeVideoOnEOFToggle
|
closeVideoOnEOFToggle
|
||||||
#if !os(tvOS)
|
#if os(macOS)
|
||||||
exitFullscreenOnEOFToggle
|
exitFullscreenOnEOFToggle
|
||||||
#endif
|
#endif
|
||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
@@ -202,11 +202,12 @@ struct PlayerSettings: View {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
Section(header: SettingsHeader(text: "Orientation".localized())) {
|
Section(header: SettingsHeader(text: "Fullscreen".localized())) {
|
||||||
if idiom == .pad {
|
if Constants.isIPad {
|
||||||
enterFullscreenInLandscapeToggle
|
enterFullscreenInLandscapeToggle
|
||||||
}
|
}
|
||||||
honorSystemOrientationLockToggle
|
|
||||||
|
exitFullscreenOnEOFToggle
|
||||||
rotateToLandscapeOnEnterFullScreenPicker
|
rotateToLandscapeOnEnterFullScreenPicker
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -318,20 +319,15 @@ struct PlayerSettings: View {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
private var honorSystemOrientationLockToggle: some View {
|
|
||||||
Toggle("Honor orientation lock", isOn: $honorSystemOrientationLock)
|
|
||||||
.disabled(!enterFullscreenInLandscape)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var enterFullscreenInLandscapeToggle: some View {
|
private var enterFullscreenInLandscapeToggle: some View {
|
||||||
Toggle("Enter fullscreen in landscape", isOn: $enterFullscreenInLandscape)
|
Toggle("Enter fullscreen in landscape orientation", isOn: $enterFullscreenInLandscape)
|
||||||
|
.disabled(lockPortraitWhenBrowsing)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var rotateToLandscapeOnEnterFullScreenPicker: some View {
|
private var rotateToLandscapeOnEnterFullScreenPicker: some View {
|
||||||
Picker("Rotate when entering fullscreen on landscape video", selection: $rotateToLandscapeOnEnterFullScreen) {
|
Picker("Default orientation", selection: $rotateToLandscapeOnEnterFullScreen) {
|
||||||
Text("Landscape left").tag(FullScreenRotationSetting.landscapeLeft)
|
Text("Landscape left").tag(FullScreenRotationSetting.landscapeLeft)
|
||||||
Text("Landscape right").tag(FullScreenRotationSetting.landscapeRight)
|
Text("Landscape right").tag(FullScreenRotationSetting.landscapeRight)
|
||||||
Text("No rotation").tag(FullScreenRotationSetting.disabled)
|
|
||||||
}
|
}
|
||||||
.modifier(SettingsPickerModifier())
|
.modifier(SettingsPickerModifier())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -315,7 +315,9 @@ struct QualityProfileForm: View {
|
|||||||
func isResolutionDisabled(_ resolution: ResolutionSetting) -> Bool {
|
func isResolutionDisabled(_ resolution: ResolutionSetting) -> Bool {
|
||||||
guard backend == .appleAVPlayer else { return false }
|
guard backend == .appleAVPlayer else { return false }
|
||||||
|
|
||||||
return resolution.value > .hd720p30
|
let hd720p30 = Stream.Resolution.predefined(.hd720p30)
|
||||||
|
|
||||||
|
return resolution.value > hd720p30
|
||||||
}
|
}
|
||||||
|
|
||||||
func initializeForm() {
|
func initializeForm() {
|
||||||
|
|||||||
@@ -38,12 +38,14 @@ struct SubscriptionsView: View {
|
|||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
.labelStyle(.titleOnly)
|
.labelStyle(.titleOnly)
|
||||||
|
|
||||||
subscriptionsMenu
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: 500)
|
.frame(maxWidth: 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
subscriptionsMenu
|
||||||
|
}
|
||||||
|
|
||||||
ToolbarItem {
|
ToolbarItem {
|
||||||
RequestErrorButton(error: requestError)
|
RequestErrorButton(error: requestError)
|
||||||
}
|
}
|
||||||
@@ -88,7 +90,7 @@ struct SubscriptionsView: View {
|
|||||||
SettingsButtons()
|
SettingsButtons()
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 12) {
|
HStack {
|
||||||
Image(systemName: "chevron.down.circle.fill")
|
Image(systemName: "chevron.down.circle.fill")
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(.accentColor)
|
||||||
.imageScale(.large)
|
.imageScale(.large)
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ struct VerticalCells<Header: View>: View {
|
|||||||
.edgesIgnoringSafeArea(edgesIgnoringSafeArea)
|
.edgesIgnoringSafeArea(edgesIgnoringSafeArea)
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.background(Color.secondaryBackground)
|
.background(Color.secondaryBackground)
|
||||||
.frame(minWidth: 360)
|
.frame(minWidth: Constants.contentViewMinWidth)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,14 +24,42 @@ struct VideoContextMenuView: View {
|
|||||||
|
|
||||||
private var backgroundContext = PersistenceController.shared.container.newBackgroundContext()
|
private var backgroundContext = PersistenceController.shared.container.newBackgroundContext()
|
||||||
|
|
||||||
|
@State private var isOverlayVisible = false
|
||||||
|
|
||||||
init(video: Video) {
|
init(video: Video) {
|
||||||
self.video = video
|
self.video = video
|
||||||
_watchRequest = video.watchFetchRequest
|
_watchRequest = video.watchFetchRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some 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 video.videoID != Video.fixtureID {
|
if video.videoID != Video.fixtureID {
|
||||||
contextMenu
|
contextMenu
|
||||||
|
.onAppear {
|
||||||
|
isOverlayVisible = true
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
isOverlayVisible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -204,9 +204,14 @@ struct YatteeApp: App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||||
if Defaults[.lockPortraitWhenBrowsing] {
|
if Defaults[.lockPortraitWhenBrowsing] {
|
||||||
Orientation.lockOrientation(.all, andRotateTo: .portrait)
|
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
|
||||||
|
} else {
|
||||||
|
let rotationOrientation =
|
||||||
|
OrientationTracker.shared.currentDeviceOrientation.rawValue == 4 ? UIInterfaceOrientation.landscapeRight :
|
||||||
|
(OrientationTracker.shared.currentDeviceOrientation.rawValue == 3 ? UIInterfaceOrientation.landscapeLeft : UIInterfaceOrientation.portrait)
|
||||||
|
Orientation.lockOrientation(.all, andRotateTo: rotationOrientation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -225,6 +230,17 @@ struct YatteeApp: App {
|
|||||||
DispatchQueue.global(qos: .userInitiated).async {
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
self.migrateQualityProfiles()
|
self.migrateQualityProfiles()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
self.migrateRotateToLandscapeOnEnterFullScreen()
|
||||||
|
}
|
||||||
|
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
self.migrateLockPortraitWhenBrowsing()
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,6 +269,22 @@ struct YatteeApp: App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
func migrateRotateToLandscapeOnEnterFullScreen() {
|
||||||
|
if Defaults[.rotateToLandscapeOnEnterFullScreen] != .landscapeRight || Defaults[.rotateToLandscapeOnEnterFullScreen] != .landscapeLeft {
|
||||||
|
Defaults[.rotateToLandscapeOnEnterFullScreen] = .landscapeRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateLockPortraitWhenBrowsing() {
|
||||||
|
if Constants.isIPhone {
|
||||||
|
Defaults[.lockPortraitWhenBrowsing] = true
|
||||||
|
} else if Constants.isIPad, Defaults[.lockPortraitWhenBrowsing] {
|
||||||
|
Defaults[.enterFullscreenInLandscape] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
var navigationStyle: NavigationStyle {
|
var navigationStyle: NavigationStyle {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
return horizontalSizeClass == .compact ? .tab : .sidebar
|
return horizontalSizeClass == .compact ? .tab : .sidebar
|
||||||
|
|||||||
@@ -4103,7 +4103,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 = 193;
|
CURRENT_PROJECT_VERSION = 196;
|
||||||
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";
|
||||||
@@ -4134,7 +4134,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 = 193;
|
CURRENT_PROJECT_VERSION = 196;
|
||||||
"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";
|
||||||
@@ -4165,7 +4165,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 = 193;
|
CURRENT_PROJECT_VERSION = 196;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
||||||
@@ -4185,7 +4185,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 = 193;
|
CURRENT_PROJECT_VERSION = 196;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
||||||
@@ -4326,6 +4326,7 @@
|
|||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
GCC_NO_COMMON_BLOCKS = YES;
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_OPTIMIZATION_LEVEL = 3;
|
||||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
@@ -4348,7 +4349,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 = 193;
|
CURRENT_PROJECT_VERSION = 196;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
"DEBUG=1",
|
"DEBUG=1",
|
||||||
@@ -4365,7 +4366,8 @@
|
|||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UIRequiresFullScreen = YES;
|
INFOPLIST_KEY_UIRequiresFullScreen = YES;
|
||||||
INFOPLIST_KEY_UIStatusBarHidden = NO;
|
INFOPLIST_KEY_UIStatusBarHidden = NO;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortraitUpsideDown";
|
INFOPLIST_KEY_UIStatusBarStyle = "";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
@@ -4400,7 +4402,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 = 193;
|
CURRENT_PROJECT_VERSION = 196;
|
||||||
"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";
|
||||||
@@ -4414,7 +4416,8 @@
|
|||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UIRequiresFullScreen = YES;
|
INFOPLIST_KEY_UIRequiresFullScreen = YES;
|
||||||
INFOPLIST_KEY_UIStatusBarHidden = NO;
|
INFOPLIST_KEY_UIStatusBarHidden = NO;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortraitUpsideDown";
|
INFOPLIST_KEY_UIStatusBarStyle = "";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
@@ -4452,7 +4455,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 = 193;
|
CURRENT_PROJECT_VERSION = 196;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@@ -4491,13 +4494,14 @@
|
|||||||
"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 = 193;
|
CURRENT_PROJECT_VERSION = 196;
|
||||||
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;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SELECTED_FILES = readonly;
|
ENABLE_USER_SELECTED_FILES = readonly;
|
||||||
|
GCC_OPTIMIZATION_LEVEL = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = macOS/Info.plist;
|
INFOPLIST_FILE = macOS/Info.plist;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.video";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.video";
|
||||||
@@ -4525,7 +4529,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 193;
|
CURRENT_PROJECT_VERSION = 196;
|
||||||
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 = (
|
||||||
@@ -4548,7 +4552,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 193;
|
CURRENT_PROJECT_VERSION = 196;
|
||||||
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 = (
|
||||||
@@ -4573,7 +4577,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 193;
|
CURRENT_PROJECT_VERSION = 196;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@@ -4597,7 +4601,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 193;
|
CURRENT_PROJECT_VERSION = 196;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@@ -4623,7 +4627,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 = 193;
|
CURRENT_PROJECT_VERSION = 196;
|
||||||
DEVELOPMENT_ASSET_PATHS = "";
|
DEVELOPMENT_ASSET_PATHS = "";
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -4663,7 +4667,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 = 193;
|
CURRENT_PROJECT_VERSION = 196;
|
||||||
DEVELOPMENT_ASSET_PATHS = "";
|
DEVELOPMENT_ASSET_PATHS = "";
|
||||||
"DEVELOPMENT_TEAM[sdk=appletvos*]" = 78Z5H3M6RJ;
|
"DEVELOPMENT_TEAM[sdk=appletvos*]" = 78Z5H3M6RJ;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -4703,7 +4707,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 193;
|
CURRENT_PROJECT_VERSION = 196;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
@@ -4726,7 +4730,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 193;
|
CURRENT_PROJECT_VERSION = 196;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
|
import Defaults
|
||||||
import Foundation
|
import Foundation
|
||||||
import Logging
|
import Logging
|
||||||
import UIKit
|
import UIKit
|
||||||
@@ -6,11 +7,11 @@ import UIKit
|
|||||||
final class AppDelegate: UIResponder, UIApplicationDelegate {
|
final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
var orientationLock = UIInterfaceOrientationMask.all
|
var orientationLock = UIInterfaceOrientationMask.all
|
||||||
|
|
||||||
private var logger = Logger(label: "stream.yattee.app.delegalate")
|
private var logger = Logger(label: "stream.yattee.app.delegate")
|
||||||
private(set) static var instance: AppDelegate!
|
private(set) static var instance: AppDelegate!
|
||||||
|
|
||||||
func application(_: UIApplication, supportedInterfaceOrientationsFor _: UIWindow?) -> UIInterfaceOrientationMask {
|
func application(_: UIApplication, supportedInterfaceOrientationsFor _: UIWindow?) -> UIInterfaceOrientationMask {
|
||||||
orientationLock
|
return orientationLock
|
||||||
}
|
}
|
||||||
|
|
||||||
func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { // swiftlint:disable:this discouraged_optional_collection
|
func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { // swiftlint:disable:this discouraged_optional_collection
|
||||||
@@ -19,6 +20,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
UIViewController.swizzleHomeIndicatorProperty()
|
UIViewController.swizzleHomeIndicatorProperty()
|
||||||
OrientationTracker.shared.startDeviceOrientationTracking()
|
OrientationTracker.shared.startDeviceOrientationTracking()
|
||||||
|
OrientationModel.shared.startOrientationUpdates()
|
||||||
|
|
||||||
// Configure the audio session for playback
|
// Configure the audio session for playback
|
||||||
do {
|
do {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import CoreMotion
|
import CoreMotion
|
||||||
import Defaults
|
|
||||||
import Logging
|
import Logging
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@@ -35,7 +34,7 @@ enum Orientation {
|
|||||||
let rotateOrientationMask = rotateOrientation == .portrait ? UIInterfaceOrientationMask.portrait :
|
let rotateOrientationMask = rotateOrientation == .portrait ? UIInterfaceOrientationMask.portrait :
|
||||||
rotateOrientation == .landscapeLeft ? .landscapeLeft :
|
rotateOrientation == .landscapeLeft ? .landscapeLeft :
|
||||||
rotateOrientation == .landscapeRight ? .landscapeRight :
|
rotateOrientation == .landscapeRight ? .landscapeRight :
|
||||||
.allButUpsideDown
|
.all
|
||||||
|
|
||||||
windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: rotateOrientationMask)) { error in
|
windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: rotateOrientationMask)) { error in
|
||||||
print("denied rotation \(error)")
|
print("denied rotation \(error)")
|
||||||
|
|||||||
@@ -1,91 +1,86 @@
|
|||||||
import Defaults
|
import Defaults
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Logging
|
||||||
import Repeat
|
import Repeat
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
final class OrientationModel {
|
final class OrientationModel {
|
||||||
static var shared = OrientationModel()
|
static var shared = OrientationModel()
|
||||||
|
let logger = Logger(label: "stream.yattee.orientation.model")
|
||||||
|
|
||||||
var orientation = UIInterfaceOrientation.portrait
|
var orientation = UIInterfaceOrientation.portrait
|
||||||
var lastOrientation: UIInterfaceOrientation?
|
var lastOrientation: UIInterfaceOrientation?
|
||||||
var orientationDebouncer = Debouncer(.milliseconds(300))
|
var orientationDebouncer = Debouncer(.milliseconds(300))
|
||||||
var orientationObserver: Any?
|
var orientationObserver: Any?
|
||||||
|
|
||||||
|
@Default(.lockPortraitWhenBrowsing) private var lockPortraitWhenBrowsing
|
||||||
|
@Default(.enterFullscreenInLandscape) private var enterFullscreenInLandscape
|
||||||
|
|
||||||
private var player = PlayerModel.shared
|
private var player = PlayerModel.shared
|
||||||
|
|
||||||
func configureOrientationUpdatesBasedOnAccelerometer() {
|
func startOrientationUpdates() {
|
||||||
let currentOrientation = OrientationTracker.shared.currentInterfaceOrientation
|
// Ensure the orientation observer is active
|
||||||
if currentOrientation.isLandscape,
|
|
||||||
Defaults[.enterFullscreenInLandscape],
|
|
||||||
!Defaults[.honorSystemOrientationLock],
|
|
||||||
!player.playingFullScreen,
|
|
||||||
!player.currentItem.isNil,
|
|
||||||
player.lockedOrientation.isNil || player.lockedOrientation!.contains(.landscape),
|
|
||||||
!player.playingInPictureInPicture,
|
|
||||||
player.presentingPlayer
|
|
||||||
{
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.player.controls.presentingControls = false
|
|
||||||
self.player.enterFullScreen(showControls: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
player.onPresentPlayer.append {
|
|
||||||
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: currentOrientation)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
orientationObserver = NotificationCenter.default.addObserver(
|
orientationObserver = NotificationCenter.default.addObserver(
|
||||||
forName: OrientationTracker.deviceOrientationChangedNotification,
|
forName: OrientationTracker.deviceOrientationChangedNotification,
|
||||||
object: nil,
|
object: nil,
|
||||||
queue: .main
|
queue: .main
|
||||||
) { _ in
|
) { _ in
|
||||||
guard !Defaults[.honorSystemOrientationLock],
|
self.logger.info("Notification received: Device orientation changed.")
|
||||||
self.player.presentingPlayer,
|
|
||||||
!self.player.playingInPictureInPicture,
|
// We only allow .portrait and are not showing the player
|
||||||
self.player.lockedOrientation.isNil
|
guard (!self.player.presentingPlayer && !self.lockPortraitWhenBrowsing) || self.player.presentingPlayer
|
||||||
else {
|
else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let orientation = OrientationTracker.shared.currentInterfaceOrientation
|
let orientation = OrientationTracker.shared.currentInterfaceOrientation
|
||||||
|
self.logger.info("Current interface orientation: \(orientation)")
|
||||||
|
|
||||||
guard self.lastOrientation != orientation else {
|
// Always update lastOrientation to keep track of the latest state
|
||||||
return
|
if self.lastOrientation != orientation {
|
||||||
|
self.lastOrientation = orientation
|
||||||
|
self.logger.info("Orientation changed to: \(orientation)")
|
||||||
|
} else {
|
||||||
|
self.logger.info("Orientation has not changed.")
|
||||||
}
|
}
|
||||||
|
|
||||||
self.lastOrientation = orientation
|
// Only take action if the player is active and presenting
|
||||||
|
guard (!self.player.isOrientationLocked && !self.player.playingInPictureInPicture) || (!self.lockPortraitWhenBrowsing && !self.player.presentingPlayer) || (!self.lockPortraitWhenBrowsing && self.player.presentingPlayer && !self.player.isOrientationLocked)
|
||||||
|
else {
|
||||||
|
self.logger.info("Only updating orientation without actions.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
guard Defaults[.enterFullscreenInLandscape],
|
|
||||||
self.player.presentingPlayer
|
|
||||||
else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
self.orientationDebouncer.callback = {
|
self.orientationDebouncer.callback = {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
if orientation.isLandscape {
|
if orientation.isLandscape {
|
||||||
|
if self.enterFullscreenInLandscape, self.player.presentingPlayer {
|
||||||
|
self.logger.info("Entering fullscreen because orientation is landscape.")
|
||||||
self.player.controls.presentingControls = false
|
self.player.controls.presentingControls = false
|
||||||
self.player.enterFullScreen(showControls: false)
|
self.player.enterFullScreen(showControls: false)
|
||||||
|
}
|
||||||
Orientation.lockOrientation(OrientationTracker.shared.currentInterfaceOrientationMask, andRotateTo: orientation)
|
Orientation.lockOrientation(OrientationTracker.shared.currentInterfaceOrientationMask, andRotateTo: orientation)
|
||||||
} else {
|
} else {
|
||||||
|
self.logger.info("Exiting fullscreen because orientation is portrait.")
|
||||||
|
if self.player.playingFullScreen {
|
||||||
self.player.exitFullScreen(showControls: false)
|
self.player.exitFullScreen(showControls: false)
|
||||||
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: .portrait)
|
}
|
||||||
|
if self.lockPortraitWhenBrowsing {
|
||||||
|
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
|
||||||
|
} else {
|
||||||
|
Orientation.lockOrientation(OrientationTracker.shared.currentInterfaceOrientationMask, andRotateTo: orientation)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.orientationDebouncer.call()
|
self.orientationDebouncer.call()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopOrientationUpdates() {
|
|
||||||
guard let observer = orientationObserver else { return }
|
|
||||||
NotificationCenter.default.removeObserver(observer)
|
|
||||||
}
|
|
||||||
|
|
||||||
func lockOrientation(_ orientation: UIInterfaceOrientationMask, andRotateTo rotateOrientation: UIInterfaceOrientation? = nil) {
|
func lockOrientation(_ orientation: UIInterfaceOrientationMask, andRotateTo rotateOrientation: UIInterfaceOrientation? = nil) {
|
||||||
|
logger.info("Locking orientation to: \(orientation), rotating to: \(String(describing: rotateOrientation)).")
|
||||||
if let rotateOrientation {
|
if let rotateOrientation {
|
||||||
self.orientation = rotateOrientation
|
self.orientation = rotateOrientation
|
||||||
lastOrientation = rotateOrientation
|
lastOrientation = rotateOrientation
|
||||||
|
|||||||
Reference in New Issue
Block a user