Compare commits

..

31 Commits

Author SHA1 Message Date
Toni Förster
7e02b08933 update SwiftUI-Introspect 2024-09-11 20:55:00 +02:00
Arkadiusz Fal
8596ee8811 Bump build number to 196 2024-09-11 09:37:13 +02:00
Arkadiusz Fal
894439ad5e Update CHANGELOG 2024-09-11 09:35:32 +02:00
Arkadiusz Fal
5dad7a1b47 Update dependencies 2024-09-11 09:31:24 +02:00
Arkadiusz Fal
6d48a825cd Merge pull request #809 from yattee/refactor-search
refactor Search
2024-09-11 09:29:43 +02:00
Arkadiusz Fal
ed11e593ff Merge pull request #810 from yattee/auto-retry-video-loading
Retry loading video before presenting error
2024-09-11 09:29:28 +02:00
Arkadiusz Fal
102dfba751 Merge pull request #805 from yattee/mpv-better-performance
MPV: improved A/V sync
2024-09-11 09:29:14 +02:00
Arkadiusz Fal
4202b27c03 Merge pull request #807 from yattee/more-robust-resolution-handling
more robust resolution handling
2024-09-11 09:29:00 +02:00
Arkadiusz Fal
2f937f74fa Merge pull request #806 from yattee/orientation-location-cleanup
Orientation/Fullscreen fixes and cleanup
2024-09-11 09:28:39 +02:00
Toni Förster
34a957b28e use system background color for background
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-11 08:55:18 +02:00
Toni Förster
0bef798341 add border around search field
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-10 19:01:41 +02:00
Toni Förster
28a7b6e981 auto retry loading the video before showing error
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-10 11:07:20 +02:00
Toni Förster
4663aab3da refactor Search
- have a separate body view for each os
- macOS: set navigation title for search
- macOS: set min width to 835 for main content
- macOS: set main window min height to 600
- macOS: don’t have text behind the cancel button
- split SearchTextField into macOS and iOS/tvOS
- iOS: move search menu to the right
- iOS: unified search field
- make min width a constant
- add option to disable search suggestions

Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-10 09:38:14 +02:00
Toni Förster
0de0445805 check if subtitles are added before removing them
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-09 14:18:49 +02:00
Toni Förster
9cb0325503 more robust resolution handling
Currently, we have a hard-coded list of resolutions. Since Invidious reports the actual resolution of a stream and does not hard-code them to a fixed value anymore, resolutions that are not in the list won’t be handled, and the stream cannot be played back.

Instead of hard-coding even more resolutions (and inadvertently might not cover all), we revert the list back to a finite set of resolutions, the users can select from. All other resolutions are handled dynamically and compared to the existing set of defined resolutions when selecting the best stream for playback.

Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-09 12:59:39 +02:00
Toni Förster
5e85fd294c MPV: improved A/V sync
- use displays refresh rate
- execute needs drawing with higher priority
- run create() with higher priority
- determine the number of threads used for rendering
- enable VSYNC and change video-sync  to display-resample
- iOS/tvOS: set new display refresh rate on change
- run setSize with higher priority
- add more options to MPVClient
- get refresh rate updates
- sync refresh rate to fps
- update CADisplayLink to current refresh rate
- update refresh rate on macOS
- Add experimental feature to sync display  with content fps

Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-08 15:59:42 +02:00
Toni Förster
b2421da95d apply new fullscreen logic to AppleAVPlayerView
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-06 17:13:08 +02:00
Toni Förster
4e4add3c42 fix double rotation
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-06 16:47:15 +02:00
Toni Förster
2185718d50 orientation fullscreen code cleanup
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-06 15:46:58 +02:00
Arkadiusz Fal
b0264aaabe Bump build number to 195 2024-09-05 23:01:29 +02:00
Arkadiusz Fal
035f3503c4 Update CHANGELOG 2024-09-05 23:01:19 +02:00
Arkadiusz Fal
e3ac11c172 Merge pull request #786 from stonerl/simplified-fullscreen-and-orientation
iOS: Simplified fullscreen and orientation
2024-09-05 22:59:54 +02:00
Arkadiusz Fal
7aed6ac0d9 Merge pull request #799 from stonerl/controls-background
player controls: add background opacity selection
2024-09-05 22:54:30 +02:00
Arkadiusz Fal
457c0ce7b3 Merge pull request #797 from stonerl/shorts-resolutions
add missing Shorts resolutions
2024-09-05 22:53:42 +02:00
Arkadiusz Fal
747baf3edd Merge pull request #801 from stonerl/O2-for-macOS
use -O1 on macOS
2024-09-05 22:53:26 +02:00
Arkadiusz Fal
cd24a0322f Merge pull request #802 from stonerl/buttons-interfere-with-search
macOS: only apply player shortcuts when window is active
2024-09-05 22:53:16 +02:00
Toni Förster
d525a22215 macOS only apply player shortcuts when window is active 2024-09-05 21:53:25 +02:00
Toni Förster
322a550666 simplified fullscreen and orientation handling
- iPad: rotate to device orientation on startup
- fixed controls in portrait fullscreen
- iOS: don’t call setNeedsDrawing multiple times
- On iOS we call set needs drawing only once.
- Added cooldown time to MPV.Client setNeedsDrawing to avoid multiple successive calls
- make fullscreen animation smoother
- dragGesture now calls toggleFullScreenAction
- fix tvOS and macOS build

Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-05 18:17:14 +02:00
Toni Förster
98fa0b98e5 use -O1 on macOS
On macOS optimisation level -O3 seems to be a bit aggressive and can cause crashes when opening MPV.

- fixes #783

Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-05 17:35:52 +02:00
Toni Förster
5313e4ead0 player controls: add background opacity selection
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-05 15:14:39 +02:00
Toni Förster
fa7b897e76 add missing Shorts resolutions
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-04 12:44:43 +02:00
47 changed files with 1217 additions and 702 deletions

View File

@@ -1,11 +1,10 @@
## Build 194 ## Build 196
* Gestures: swipe up toggles fullscreen by @stonerl in https://github.com/yattee/yattee/pull/778 * Orientation/Fullscreen fixes and cleanup by @stonerl in https://github.com/yattee/yattee/pull/806
* dont open video when dismissing context menu by @stonerl in https://github.com/yattee/yattee/pull/780 * More robust resolution handling by @stonerl in https://github.com/yattee/yattee/pull/807
* mpv: remove video layer when entering background by @stonerl in https://github.com/yattee/yattee/pull/793 * MPV: improved A/V sync by @stonerl in https://github.com/yattee/yattee/pull/805
* hi-res invidious logos by @stonerl in https://github.com/yattee/yattee/pull/791 * Retry loading video before presenting error by @stonerl in https://github.com/yattee/yattee/pull/810
* enable -O3 by @stonerl in https://github.com/yattee/yattee/pull/794 * Refactor Search by @stonerl in https://github.com/yattee/yattee/pull/809
* Better audio ducking by @stonerl in https://github.com/yattee/yattee/pull/779 * Updated dependencies
* fix picture in picture by @stonerl in https://github.com/yattee/yattee/pull/789
## 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)
@@ -24,6 +23,18 @@
* 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
* dont 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 * 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 * 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 * Circular Invidious logo by @stonerl in https://github.com/yattee/yattee/pull/769

View File

@@ -10,16 +10,16 @@ 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.203.0) 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.89.0) aws-sdk-kms (1.90.0)
aws-sdk-core (~> 3, >= 3.203.0) aws-sdk-core (~> 3, >= 3.203.0)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.160.0) aws-sdk-s3 (1.161.0)
aws-sdk-core (~> 3, >= 3.203.0) aws-sdk-core (~> 3, >= 3.203.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: []),

View File

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

View File

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

View File

@@ -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 {
client?.removeSubs() if client?.areSubtitlesAdded == true {
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) {
@@ -343,8 +350,17 @@ final class MPVBackend: PlayerBackend {
startClientUpdates() startClientUpdates()
} }
func startRefreshRateUpdates() {
refreshRateTimer.start()
}
func stopRefreshRateUpdates() {
refreshRateTimer.pause()
}
func play() { func play() {
startClientUpdates() startClientUpdates()
startRefreshRateUpdates()
if controls.presentingControls { if controls.presentingControls {
startControlsUpdates() startControlsUpdates()
@@ -372,6 +388,7 @@ final class MPVBackend: PlayerBackend {
func pause() { func pause() {
stopClientUpdates() stopClientUpdates()
stopRefreshRateUpdates()
client?.pause() client?.pause()
isPaused = true isPaused = true
@@ -391,6 +408,8 @@ final class MPVBackend: PlayerBackend {
} }
func stop() { func stop() {
stopClientUpdates()
stopRefreshRateUpdates()
client?.stop() client?.stop()
isPlaying = false isPlaying = false
isPaused = false isPaused = false
@@ -472,6 +491,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)))"))
@@ -552,7 +617,9 @@ final class MPVBackend: PlayerBackend {
} }
func addSubTrack(_ url: URL) { func addSubTrack(_ url: URL) {
client?.removeSubs() if client?.areSubtitlesAdded == true {
client?.removeSubs()
}
client?.addSubTrack(url) client?.addSubTrack(url)
} }

View File

@@ -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, &params) < 0 { if mpv_render_context_create(&mpvGL, mpv, &params) < 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])
} }

View File

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

View File

@@ -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,11 +203,24 @@ 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
@@ -517,7 +537,10 @@ final class PlayerModel: ObservableObject {
} }
private func handlePresentationChange() { private func handlePresentationChange() {
backend.setNeedsDrawing(presentingPlayer) #if os(macOS)
// TODO: Check whether this is needed on macOS
backend.setNeedsDrawing(presentingPlayer)
#endif
#if os(iOS) #if os(iOS)
if presentingPlayer, activeBackend == .appleAVPlayer, avPlayerUsesSystemControls, Constants.isIPhone { if presentingPlayer, activeBackend == .appleAVPlayer, avPlayerUsesSystemControls, Constants.isIPhone {
@@ -546,13 +569,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
} }
} }
@@ -659,32 +680,37 @@ final class PlayerModel: ObservableObject {
} }
func closeCurrentItem(finished: Bool = false) { func closeCurrentItem(finished: Bool = false) {
pause() guard !closing else { return }
videoBeingOpened = nil
advancing = false
forceBackendOnPlay = nil
closing = true closing = true
controls.presentingControls = false
self.prepareCurrentItemForHistory(finished: finished) if playingFullScreen { exitFullScreen() }
self.hide() Delay.by(0.3) { [weak self] in
Delay.by(0.8) { [weak self] in
guard let self else { return } guard let self else { return }
self.closePiP() pause()
videoBeingOpened = nil
advancing = false
forceBackendOnPlay = nil
withAnimation { controls.presentingControls = false
self.currentItem = nil
self.prepareCurrentItemForHistory(finished: finished)
self.hide()
Delay.by(0.7) { [weak self] in
guard let self else { return }
if playingInPictureInPicture { self.closePiP() }
withAnimation {
self.currentItem = nil
}
self.updateNowPlayingInfo()
self.backend.closeItem()
self.aspectRatio = VideoPlayerView.defaultAspectRatio
self.resetAutoplay()
self.closing = false
} }
self.updateNowPlayingInfo()
self.backend.closeItem()
self.aspectRatio = VideoPlayerView.defaultAspectRatio
self.resetAutoplay()
self.closing = false
self.playingFullScreen = false
} }
} }
@@ -773,7 +799,7 @@ final class PlayerModel: ObservableObject {
} }
func toggleFullScreenAction() { func toggleFullScreenAction() {
toggleFullscreen(playingFullScreen, showControls: false) toggleFullscreen(playingFullScreen, showControls: false, initiatedByButton: true)
} }
func togglePiPAction() { func togglePiPAction() {
@@ -786,20 +812,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
@@ -985,25 +1012,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 == .mpv { if !self.musicMode, self.activeBackend == .mpv {
mpvBackend.addVideoTrackFromStream() self.mpvBackend.addVideoTrackFromStream()
mpvBackend.setVideoToAuto() self.mpvBackend.setVideoToAuto()
mpvBackend.controls.resetTimer() self.mpvBackend.controls.resetTimer()
} else if !musicMode, activeBackend == .appleAVPlayer { } else if !self.musicMode, self.activeBackend == .appleAVPlayer {
avPlayerBackend.bindPlayerToLayer() self.avPlayerBackend.bindPlayerToLayer()
}
#if os(iOS)
if wasFullscreen {
wasFullscreen = false
DispatchQueue.main.async { [weak self] in
Delay.by(0.3) {
self?.enterFullScreen()
}
}
} }
}
#if os(iOS)
OrientationTracker.shared.startDeviceOrientationTracking()
#endif #endif
guard closePiPAndOpenPlayerOnEnteringForeground, playingInPictureInPicture else { guard closePiPAndOpenPlayerOnEnteringForeground, playingInPictureInPicture else {
@@ -1018,6 +1039,10 @@ final class PlayerModel: ObservableObject {
} }
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, activeBackend == .appleAVPlayer { } else if !playingInPictureInPicture, activeBackend == .appleAVPlayer {
@@ -1025,15 +1050,6 @@ final class PlayerModel: ObservableObject {
} else if activeBackend == .mpv, !musicMode { } else if activeBackend == .mpv, !musicMode {
mpvBackend.setVideoToNo() 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
@@ -1124,7 +1140,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)
@@ -1136,18 +1152,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 {
@@ -1155,10 +1180,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
} }
@@ -1286,7 +1313,10 @@ final class PlayerModel: ObservableObject {
#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 {

View File

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

View File

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

View File

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

View File

@@ -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)
// 8K UHD (16:9) Resolutions enum PredefinedResolution: String, CaseIterable, Codable {
case hd4320p60 // 8K UHD (16:9) Resolutions
case hd4320p50 case hd4320p60, hd4320p30
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 // Standard Definition (SD) Resolutions
case hd1600p60 case sd480p30
case hd1600p50 case sd360p30
case hd1600p48 case sd240p30
case hd1600p30 case sd144p30
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
case sd854p30
case sd854p25
case sd768p30
case sd768p25
case sd640p30
case sd640p25
case sd480p30
case sd480p25
case sd428p30
case sd428p25
case sd360p30
case sd360p25
case sd320p30
case sd320p25
case sd240p30
case sd240p25
case sd214p30
case sd214p25
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
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ struct AppSidebarNavigation: View {
var body: some View { var body: some View {
#if os(iOS) #if os(iOS)
content.introspect(.viewController, on: .iOS(.v15, .v16, .v17)) { viewController in content.introspect(.viewController, on: .iOS(.v15, .v16, .v17, .v18)) { viewController in
// workaround for an empty supplementary view on launch // workaround for an empty supplementary view on launch
// the supplementary view is determined by the default selection inside the // the supplementary view is determined by the default selection inside the
// primary view, but the primary view is not loaded so its selection is not read // primary view, but the primary view is not loaded so its selection is not read

View File

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

View File

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

View File

@@ -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)
} }
} }
} }
@@ -389,7 +393,7 @@ struct PlayerControls: View {
#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

View File

@@ -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), &params) mpv_render_context_render(OpaquePointer(mpvGL), &params)
} }
} }
} }
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder) extension Notification.Name {
} static let updateDisplayLinkFrameRate = Notification.Name("updateDisplayLinkFrameRate")
} }

View File

@@ -64,11 +64,7 @@ extension VideoPlayerView {
// Toggle fullscreen on upward drag only when not disabled // Toggle fullscreen on upward drag only when not disabled
if verticalDrag < -50 { if verticalDrag < -50 {
if player.playingFullScreen { player.toggleFullScreenAction()
player.exitFullScreen(showControls: false)
} else {
player.enterFullScreen()
}
disableGestureTemporarily() disableGestureTemporarily()
return return
} }

View File

@@ -158,7 +158,7 @@ struct VideoActions: View {
actionButton("PiP", systemImage: player.pipImage, active: player.playingInPictureInPicture, 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)

View File

@@ -111,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 {
@@ -127,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 }
@@ -313,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 {

View File

@@ -9,7 +9,7 @@ struct FocusableSearchTextField: View {
var body: some View { var body: some View {
SearchTextField() SearchTextField()
#if os(macOS) #if os(macOS)
.introspect(.textField, on: .macOS(.v12, .v13, .v14)) { textField in .introspect(.textField, on: .macOS(.v12, .v13, .v14, .v15)) { textField in
state.textField = textField state.textField = textField
} }
.onAppear { .onAppear {
@@ -18,7 +18,7 @@ struct FocusableSearchTextField: View {
} }
} }
#elseif os(iOS) #elseif os(iOS)
.introspect(.textField, on: .iOS(.v15, .v16, .v17)) { textField in .introspect(.textField, on: .iOS(.v15, .v16, .v17, .v18)) { textField in
state.textField = textField state.textField = textField
} }
.onChange(of: state.focused) { newValue in .onChange(of: state.focused) { newValue in

View File

@@ -1,64 +1,99 @@
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
var body: some View { #if os(macOS)
ZStack { var body: some View {
#if os(macOS) ZStack {
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
TextField("Search...", text: $state.queryText) {
state.changeQuery { query in
query.query = state.queryText
navigation.hideKeyboard()
}
RecentsModel.shared.addQuery(state.queryText)
}
.disableAutocorrection(true)
#if os(macOS)
.frame(maxWidth: 190)
.textFieldStyle(.plain)
#else
.frame(minWidth: 200)
.textFieldStyle(.roundedBorder)
.padding(.horizontal, 5)
.padding(.trailing, state.queryText.isEmpty ? 0 : 10)
#endif
if !state.queryText.isEmpty { GeometryReader { geometry in
clearButton TextField("Search...", text: $state.queryText) {
} else { state.changeQuery { query in
#if os(macOS) query.query = state.queryText
navigation.hideKeyboard()
}
RecentsModel.shared.addQuery(state.queryText)
}
.disableAutocorrection(true)
.frame(maxWidth: geometry.size.width - 5)
.textFieldStyle(.plain)
.padding(.vertical, 8)
.frame(height: 27, alignment: .center)
}
if !state.queryText.isEmpty {
clearButton
} else {
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)
} }

View File

@@ -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
} }
var body: some View { #if os(iOS)
VStack { var body: some View {
#if os(iOS) VStack {
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
#if os(macOS)
if accounts.app.supportsSearchSuggestions, state.query.query != state.queryText {
HStack {
Spacer()
SearchSuggestions()
.borderLeading(width: 1, color: Color("ControlsBorderColor"))
.frame(maxWidth: 280)
.opacity(state.queryText.isEmpty ? 0 : 1)
}
}
#endif
} }
#endif }
.environment(\.listingStyle, searchListingStyle)
.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()
}
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)
}
}
}
} }
.environment(\.listingStyle, searchListingStyle)
.toolbar { #elseif os(macOS)
#if os(macOS) var body: some View {
ZStack {
results
if accounts.app.supportsSearchSuggestions, state.query.query != state.queryText, showSearchSuggestions {
HStack {
Spacer()
SearchSuggestions()
.borderLeading(width: 1, color: Color("ControlsBorderColor"))
.frame(maxWidth: 262)
.opacity(state.queryText.isEmpty ? 0 : 1)
}
}
}
.environment(\.listingStyle, searchListingStyle)
.toolbar {
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,94 +229,52 @@ struct SearchView: View {
SearchTextField() SearchTextField()
} }
} }
#endif
}
.onAppear {
if let query {
state.queryText = query.query
state.resetQuery(query)
updateFavoriteItem()
} }
.onAppear {
if !videos.isEmpty { if let query {
state.store.replace(ContentItem.array(of: videos)) state.queryText = query.query
} state.resetQuery(query)
} updateFavoriteItem()
.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)
#if os(tvOS)
searchDebounce.invalidate()
recentsDebounce.invalidate()
searchDebounce.debouncing(2) {
state.changeQuery { query in
query.query = newQuery
}
} }
recentsDebounce.debouncing(10) { if !videos.isEmpty {
recents.addQuery(newQuery) state.store.replace(ContentItem.array(of: videos))
}
#endif
}
.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()
}
}
#if os(tvOS)
.searchable(text: $state.queryText) {
if !state.queryText.isEmpty {
ForEach(state.querySuggestions, id: \.self) { suggestion in
Text(suggestion)
.searchCompletion(suggestion)
} }
} }
} .onChange(of: accounts.current) { _ in
#else state.reloadQuery()
.ignoresSafeArea(.keyboard, edges: .bottom)
.navigationTitle("Search")
#endif
#if os(iOS)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
searchMenu
} }
ToolbarItem(placement: .principal) { .onChange(of: state.queryText) { newQuery in
if #available(iOS 15, *) { if newQuery.isEmpty {
FocusableSearchTextField() favoriteItem = nil
state.resetQuery()
} else { } else {
SearchTextField() 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()
}
}
.frame(minWidth: Constants.contentViewMinWidth)
.navigationTitle("Search")
} }
.navigationBarTitleDisplayMode(.inline) #endif
#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)
.imageScale(.large)
} }
.foregroundColor(.accentColor)
.imageScale(.medium)
} }
} }
#endif #endif

View File

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

View File

@@ -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,14 +164,18 @@ struct BrowsingSettings: View {
#if os(iOS) #if os(iOS)
Toggle("Show Documents", isOn: $showDocuments) Toggle("Show Documents", isOn: $showDocuments)
Toggle("Lock portrait mode", isOn: $lockPortraitWhenBrowsing) if Constants.isIPad {
.onChange(of: lockPortraitWhenBrowsing) { lock in Toggle("Lock portrait mode", isOn: $lockPortraitWhenBrowsing)
if lock { .onChange(of: lockPortraitWhenBrowsing) { lock in
Orientation.lockOrientation(.portrait, andRotateTo: .portrait) if lock {
} else { enterFullscreenInLandscape = true
Orientation.lockOrientation(.allButUpsideDown) Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
} else {
enterFullscreenInLandscape = false
Orientation.lockOrientation(.all)
}
} }
} }
#endif #endif
if !accounts.isEmpty { if !accounts.isEmpty {
@@ -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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = 194; 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 = 194; 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 = 194; 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 = 194; 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;
@@ -4349,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 = 194; CURRENT_PROJECT_VERSION = 196;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GCC_PREPROCESSOR_DEFINITIONS = ( GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1", "DEBUG=1",
@@ -4366,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)",
@@ -4401,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 = 194; 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";
@@ -4415,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)",
@@ -4453,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 = 194; 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;
@@ -4492,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 = 194; 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";
@@ -4526,7 +4529,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 194; 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 = (
@@ -4549,7 +4552,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 194; 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 = (
@@ -4574,7 +4577,7 @@
buildSettings = { buildSettings = {
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 194; 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 = (
@@ -4598,7 +4601,7 @@
buildSettings = { buildSettings = {
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 194; 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 = (
@@ -4624,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 = 194; CURRENT_PROJECT_VERSION = 196;
DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_ASSET_PATHS = "";
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -4664,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 = 194; 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;
@@ -4704,7 +4707,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 194; CURRENT_PROJECT_VERSION = 196;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
@@ -4727,7 +4730,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 194; CURRENT_PROJECT_VERSION = 196;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
@@ -4910,7 +4913,7 @@
repositoryURL = "https://github.com/sindresorhus/Defaults"; repositoryURL = "https://github.com/sindresorhus/Defaults";
requirement = { requirement = {
kind = upToNextMajorVersion; kind = upToNextMajorVersion;
minimumVersion = 7.0.0; minimumVersion = 7.3.1;
}; };
}; };
372AA40E286D067B0000B1DC /* XCRemoteSwiftPackageReference "Repeat" */ = { 372AA40E286D067B0000B1DC /* XCRemoteSwiftPackageReference "Repeat" */ = {
@@ -4925,8 +4928,8 @@
isa = XCRemoteSwiftPackageReference; isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/hyperoslo/Cache.git"; repositoryURL = "https://github.com/hyperoslo/Cache.git";
requirement = { requirement = {
branch = master; kind = upToNextMajorVersion;
kind = branch; minimumVersion = 7.4.0;
}; };
}; };
375B8AAF28B57F4200397B31 /* XCRemoteSwiftPackageReference "KeychainAccess" */ = { 375B8AAF28B57F4200397B31 /* XCRemoteSwiftPackageReference "KeychainAccess" */ = {
@@ -4941,16 +4944,16 @@
isa = XCRemoteSwiftPackageReference; isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/pinterest/PINCache"; repositoryURL = "https://github.com/pinterest/PINCache";
requirement = { requirement = {
branch = master; kind = upToNextMajorVersion;
kind = branch; minimumVersion = 3.0.4;
}; };
}; };
379325D329A265A300181CF1 /* XCRemoteSwiftPackageReference "swift-log" */ = { 379325D329A265A300181CF1 /* XCRemoteSwiftPackageReference "swift-log" */ = {
isa = XCRemoteSwiftPackageReference; isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/yattee/swift-log.git"; repositoryURL = "https://github.com/apple/swift-log.git";
requirement = { requirement = {
branch = main; kind = upToNextMajorVersion;
kind = branch; minimumVersion = 1.6.1;
}; };
}; };
3797104728D3D10600D5F53C /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */ = { 3797104728D3D10600D5F53C /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */ = {
@@ -4958,7 +4961,7 @@
repositoryURL = "https://github.com/SDWebImage/SDWebImageSwiftUI.git"; repositoryURL = "https://github.com/SDWebImage/SDWebImageSwiftUI.git";
requirement = { requirement = {
kind = upToNextMajorVersion; kind = upToNextMajorVersion;
minimumVersion = 2.1.0; minimumVersion = 2.2.7;
}; };
}; };
3797757B268922D100DD52A8 /* XCRemoteSwiftPackageReference "siesta" */ = { 3797757B268922D100DD52A8 /* XCRemoteSwiftPackageReference "siesta" */ = {
@@ -4966,7 +4969,7 @@
repositoryURL = "https://github.com/bustoutsolutions/siesta"; repositoryURL = "https://github.com/bustoutsolutions/siesta";
requirement = { requirement = {
kind = upToNextMajorVersion; kind = upToNextMajorVersion;
minimumVersion = 1.5.0; minimumVersion = 1.5.2;
}; };
}; };
3799AC0728B03CEC001376F9 /* XCRemoteSwiftPackageReference "ActiveLabel.swift" */ = { 3799AC0728B03CEC001376F9 /* XCRemoteSwiftPackageReference "ActiveLabel.swift" */ = {
@@ -4982,7 +4985,7 @@
repositoryURL = "https://github.com/Alamofire/Alamofire.git"; repositoryURL = "https://github.com/Alamofire/Alamofire.git";
requirement = { requirement = {
kind = upToNextMajorVersion; kind = upToNextMajorVersion;
minimumVersion = 5.0.0; minimumVersion = 5.9.1;
}; };
}; };
37BD07C52698B27B003EBB87 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */ = { 37BD07C52698B27B003EBB87 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */ = {
@@ -4990,7 +4993,7 @@
repositoryURL = "https://github.com/siteline/SwiftUI-Introspect.git"; repositoryURL = "https://github.com/siteline/SwiftUI-Introspect.git";
requirement = { requirement = {
kind = upToNextMajorVersion; kind = upToNextMajorVersion;
minimumVersion = 0.1.3; minimumVersion = 1.3.0;
}; };
}; };
37CF8B8228535E4F00B71E37 /* XCRemoteSwiftPackageReference "SDWebImage" */ = { 37CF8B8228535E4F00B71E37 /* XCRemoteSwiftPackageReference "SDWebImage" */ = {
@@ -4998,7 +5001,7 @@
repositoryURL = "https://github.com/SDWebImage/SDWebImage"; repositoryURL = "https://github.com/SDWebImage/SDWebImage";
requirement = { requirement = {
kind = upToNextMajorVersion; kind = upToNextMajorVersion;
minimumVersion = 5.19.1; minimumVersion = 5.19.7;
}; };
}; };
37D4B19B2671817900C925CA /* XCRemoteSwiftPackageReference "SwiftyJSON" */ = { 37D4B19B2671817900C925CA /* XCRemoteSwiftPackageReference "SwiftyJSON" */ = {
@@ -5006,7 +5009,7 @@
repositoryURL = "https://github.com/SwiftyJSON/SwiftyJSON.git"; repositoryURL = "https://github.com/SwiftyJSON/SwiftyJSON.git";
requirement = { requirement = {
kind = upToNextMajorVersion; kind = upToNextMajorVersion;
minimumVersion = 5.0.0; minimumVersion = 5.0.2;
}; };
}; };
37EE6DC328A305AD00BFD632 /* XCRemoteSwiftPackageReference "Reachability" */ = { 37EE6DC328A305AD00BFD632 /* XCRemoteSwiftPackageReference "Reachability" */ = {
@@ -5014,7 +5017,7 @@
repositoryURL = "https://github.com/ashleymills/Reachability.swift"; repositoryURL = "https://github.com/ashleymills/Reachability.swift";
requirement = { requirement = {
kind = upToNextMajorVersion; kind = upToNextMajorVersion;
minimumVersion = 5.1.0; minimumVersion = 5.2.3;
}; };
}; };
37FB2847272207F000A57617 /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */ = { 37FB2847272207F000A57617 /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */ = {
@@ -5022,7 +5025,7 @@
repositoryURL = "https://github.com/SDWebImage/SDWebImageWebPCoder.git"; repositoryURL = "https://github.com/SDWebImage/SDWebImageWebPCoder.git";
requirement = { requirement = {
kind = upToNextMajorVersion; kind = upToNextMajorVersion;
minimumVersion = 0.8.4; minimumVersion = 0.14.6;
}; };
}; };
37FB285227220D8400A57617 /* XCRemoteSwiftPackageReference "SDWebImagePINPlugin" */ = { 37FB285227220D8400A57617 /* XCRemoteSwiftPackageReference "SDWebImagePINPlugin" */ = {

View File

@@ -1,5 +1,5 @@
{ {
"originHash" : "515d8e68c4a31658288fb3f94789ee539399b042082c08c39f4c03c27fd8860c", "originHash" : "173de1b718eb898698eaba0221b46be9781899a652725709c8400d3ddfb01980",
"pins" : [ "pins" : [
{ {
"identity" : "activelabel.swift", "identity" : "activelabel.swift",
@@ -24,8 +24,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/hyperoslo/Cache.git", "location" : "https://github.com/hyperoslo/Cache.git",
"state" : { "state" : {
"branch" : "master", "revision" : "24e47109e31b2031cb26e25cc1b81b607496066c",
"revision" : "81a0277cbc6b63f4e0cd6f42c4abefa1011bbfa9" "version" : "7.4.0"
} }
}, },
{ {
@@ -69,8 +69,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/pinterest/PINCache", "location" : "https://github.com/pinterest/PINCache",
"state" : { "state" : {
"branch" : "master", "revision" : "2fb85948463292c2e824148cf17dc62a4c217a94",
"revision" : "2fb85948463292c2e824148cf17dc62a4c217a94" "version" : "3.0.4"
} }
}, },
{ {
@@ -148,10 +148,10 @@
{ {
"identity" : "swift-log", "identity" : "swift-log",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/yattee/swift-log.git", "location" : "https://github.com/apple/swift-log.git",
"state" : { "state" : {
"branch" : "main", "revision" : "9cb486020ebf03bfa5b5df985387a14a98744537",
"revision" : "3f3dc1390a2f116894887c352792dc8d5fa9e875" "version" : "1.6.1"
} }
}, },
{ {
@@ -168,8 +168,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/siteline/SwiftUI-Introspect.git", "location" : "https://github.com/siteline/SwiftUI-Introspect.git",
"state" : { "state" : {
"revision" : "121c146fe591b1320238d054ae35c81ffa45f45a", "revision" : "807f73ce09a9b9723f12385e592b4e0aaebd3336",
"version" : "0.12.0" "version" : "1.3.0"
} }
}, },
{ {

View File

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

View File

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

View File

@@ -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
if self.lastOrientation != orientation {
self.lastOrientation = orientation
self.logger.info("Orientation changed to: \(orientation)")
} else {
self.logger.info("Orientation has not changed.")
}
// 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 return
} }
self.lastOrientation = orientation
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 {
self.player.controls.presentingControls = false if self.enterFullscreenInLandscape, self.player.presentingPlayer {
self.player.enterFullScreen(showControls: false) self.logger.info("Entering fullscreen because orientation is landscape.")
self.player.controls.presentingControls = false
self.player.enterFullScreen(showControls: false)
}
Orientation.lockOrientation(OrientationTracker.shared.currentInterfaceOrientationMask, andRotateTo: orientation) Orientation.lockOrientation(OrientationTracker.shared.currentInterfaceOrientationMask, andRotateTo: orientation)
} else { } else {
self.player.exitFullScreen(showControls: false) self.logger.info("Exiting fullscreen because orientation is portrait.")
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: .portrait) if self.player.playingFullScreen {
self.player.exitFullScreen(showControls: false)
}
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