mirror of
https://github.com/yattee/yattee.git
synced 2025-12-15 12:38:15 +00:00
Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fe8a32fb8 | ||
|
|
5a0c1bbae3 | ||
|
|
4ce9dc6729 | ||
|
|
b783db30b6 | ||
|
|
7741e531f4 | ||
|
|
4b21cd48e3 | ||
|
|
d67e375f16 | ||
|
|
db2417e455 | ||
|
|
7f8aa51c78 | ||
|
|
4038f7fdb9 | ||
|
|
8a7e4c84b5 | ||
|
|
eb491a890d | ||
|
|
c6724472a6 | ||
|
|
201de81351 | ||
|
|
ae12eefafc | ||
|
|
46f89db11a | ||
|
|
c9d20d28de | ||
|
|
094461d359 | ||
|
|
b9649b6356 | ||
|
|
3d8feda808 | ||
|
|
d9aa5105fa | ||
|
|
42110f32da | ||
|
|
a6c5c3905a | ||
|
|
7b484e80b8 | ||
|
|
68f3d5c631 | ||
|
|
9d291cca28 | ||
|
|
7c108f18ff | ||
|
|
1a3012853d | ||
|
|
5b64290bc5 | ||
|
|
34989a4f0f | ||
|
|
4ab60080f6 | ||
|
|
a3a198a32d | ||
|
|
b54044cbc5 | ||
|
|
ebc48fde90 | ||
|
|
7c50db426f | ||
|
|
a1bde07ee1 | ||
|
|
fba01e35a3 | ||
|
|
16bf83274f | ||
|
|
d8c8f8084b | ||
|
|
2590f041c2 | ||
|
|
790cb5ce1d | ||
|
|
7b7f877fa5 | ||
|
|
1d86154012 | ||
|
|
03fbb4933a | ||
|
|
bb1d3cd273 | ||
|
|
fa978dc6d0 | ||
|
|
3da081b40c | ||
|
|
0d5a907517 | ||
|
|
ef7a486fd4 | ||
|
|
54915dcea1 | ||
|
|
86b91916a7 | ||
|
|
4144a29608 | ||
|
|
c41b635276 | ||
|
|
c118c77c14 | ||
|
|
626652c421 | ||
|
|
fb42b80276 | ||
|
|
00baf60970 | ||
|
|
0230106a1e | ||
|
|
169b9451ed | ||
|
|
ae65acdd16 | ||
|
|
b5ac760af2 | ||
|
|
321eaecd21 | ||
|
|
0e7d66849d | ||
|
|
25208c2b5c | ||
|
|
f3637e2426 | ||
|
|
dd6106447f | ||
|
|
1f667818db | ||
|
|
784893048d | ||
|
|
6ec516dc3d | ||
|
|
1c7da30caf |
29
CHANGELOG.md
29
CHANGELOG.md
@@ -1,10 +1,25 @@
|
||||
## Build 181
|
||||
* History Setting: hide the recent activity in the sidebar or limit the number of items shown (by @rickykresslein)
|
||||
* Fix issues with empty comments (by @stonerl)
|
||||
* Improved Invidious comments (by @stonerl)
|
||||
* Downgrade MPVKit to 0.36.0-1 due to issues with WebVTT subtitles
|
||||
* Don't show related in sidebar when disabled in settings by @stonerl in https://github.com/yattee/yattee/pull/635
|
||||
* Handle audio session interrupts by other media by @stonerl in https://github.com/yattee/yattee/pull/640
|
||||
* Only show Queue header in sidebar view by @stonerl in https://github.com/yattee/yattee/pull/642
|
||||
* SponsorBlock Improvements by @stonerl in https://github.com/yattee/yattee/pull/639
|
||||
* Chapter title on jump by @stonerl in https://github.com/yattee/yattee/pull/655
|
||||
* Restart finished video by @stonerl in https://github.com/yattee/yattee/pull/646
|
||||
* SponsorBlock jump to end instead of pausing by @stonerl in https://github.com/yattee/yattee/pull/648
|
||||
* Call correct class of SDImageAWebPCoder by @stonerl in https://github.com/yattee/yattee/pull/664
|
||||
* Fix handling and displaying captions by @stonerl in https://github.com/yattee/yattee/pull/636
|
||||
* Advanced settings: make number fields .numPad by @stonerl in https://github.com/yattee/yattee/pull/661
|
||||
* Preserve time on stream change by @stonerl in https://github.com/yattee/yattee/pull/651
|
||||
* Switch to previous backend when leaving PiP by @stonerl in https://github.com/yattee/yattee/pull/641
|
||||
* Handle deep links by @timonus in https://github.com/yattee/yattee/pull/645
|
||||
* Music Mode: don't bindPlayerToLayer when entering foreground by @stonerl in https://github.com/yattee/yattee/pull/644
|
||||
* Allow user to disable thumbnails and jump to current chapter in horizontal view by @stonerl in https://github.com/yattee/yattee/pull/665
|
||||
* Rework qualitiy settings by @stonerl in https://github.com/yattee/yattee/pull/650
|
||||
* HLS: set target bitrate / AVPlayer: higher resolution by @stonerl in https://github.com/yattee/yattee/pull/667
|
||||
* Fix #619: Remove ports from shared YouTube links by @0x000C in https://github.com/yattee/yattee/pull/627
|
||||
* XCode enable IDEPreferLogStreaming by @stonerl in https://github.com/yattee/yattee/pull/638
|
||||
* Updated localizations
|
||||
* Updated dependencies
|
||||
* Upgraded dependencies
|
||||
|
||||
## Previous builds
|
||||
* Add skip, play/pause, and fullscreen shortcuts to macOS player (by @rickykresslein)
|
||||
@@ -16,6 +31,10 @@
|
||||
* Added Controls setting "Action button labels" icon or icon and text
|
||||
* Added Advanced setting for MPV: "deinterlace"
|
||||
* Add help text to all header buttons (by @rickykresslein)
|
||||
* History Setting: hide the recent activity in the sidebar or limit the number of items shown (by @rickykresslein)
|
||||
* Fix issues with empty comments (by @stonerl)
|
||||
* Improved Invidious comments (by @stonerl)
|
||||
* Downgrade MPVKit to 0.36.0-1 due to issues with WebVTT subtitles
|
||||
* Add Chinese (Traditional) localization (by @rexcsk)
|
||||
* Localization fixes
|
||||
* Updated localizations
|
||||
|
||||
@@ -17,13 +17,13 @@ extension String {
|
||||
|
||||
var outputText = self
|
||||
|
||||
results.reversed().forEach { match in
|
||||
(1 ..< match.numberOfRanges).reversed().forEach { rangeIndex in
|
||||
for match in results.reversed() {
|
||||
for rangeIndex in (1 ..< match.numberOfRanges).reversed() {
|
||||
let matchingGroup: String = (self as NSString).substring(with: match.range(at: rangeIndex))
|
||||
let rangeBounds = match.range(at: rangeIndex)
|
||||
|
||||
guard let range = Range(rangeBounds, in: self) else {
|
||||
return
|
||||
continue
|
||||
}
|
||||
let replacement = replacementStringClosure(matchingGroup) ?? matchingGroup
|
||||
|
||||
|
||||
@@ -4,11 +4,11 @@ extension URL {
|
||||
func byReplacingYatteeProtocol(with urlProtocol: String = "https") -> URL! {
|
||||
var urlAbsoluteString = absoluteString
|
||||
|
||||
guard urlAbsoluteString.hasPrefix(Constants.yatteeProtocol) else {
|
||||
guard urlAbsoluteString.hasPrefix(Strings.yatteeProtocol) else {
|
||||
return self
|
||||
}
|
||||
|
||||
urlAbsoluteString = String(urlAbsoluteString.dropFirst(Constants.yatteeProtocol.count))
|
||||
urlAbsoluteString = String(urlAbsoluteString.dropFirst(Strings.yatteeProtocol.count))
|
||||
if absoluteString.contains("://") {
|
||||
return URL(string: urlAbsoluteString)
|
||||
}
|
||||
|
||||
24
Gemfile.lock
24
Gemfile.lock
@@ -57,17 +57,17 @@ GEM
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.906.0)
|
||||
aws-sdk-core (3.191.6)
|
||||
aws-partitions (1.929.0)
|
||||
aws-sdk-core (3.196.1)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.651.0)
|
||||
aws-sigv4 (~> 1.8)
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.78.0)
|
||||
aws-sdk-core (~> 3, >= 3.191.0)
|
||||
aws-sdk-kms (1.81.0)
|
||||
aws-sdk-core (~> 3, >= 3.193.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sdk-s3 (1.146.1)
|
||||
aws-sdk-core (~> 3, >= 3.191.0)
|
||||
aws-sdk-s3 (1.151.0)
|
||||
aws-sdk-core (~> 3, >= 3.194.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.8)
|
||||
aws-sigv4 (1.8.0)
|
||||
@@ -157,27 +157,28 @@ GEM
|
||||
domain_name (~> 0.5)
|
||||
httpclient (2.8.3)
|
||||
jmespath (1.6.2)
|
||||
json (2.7.1)
|
||||
json (2.7.2)
|
||||
jwt (2.8.1)
|
||||
base64
|
||||
mini_magick (4.12.0)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.15.0)
|
||||
multipart-post (2.4.0)
|
||||
multipart-post (2.4.1)
|
||||
nanaimo (0.3.0)
|
||||
naturally (2.2.1)
|
||||
nkf (0.2.0)
|
||||
optparse (0.4.0)
|
||||
optparse (0.5.0)
|
||||
os (1.1.4)
|
||||
plist (3.7.1)
|
||||
public_suffix (5.0.5)
|
||||
rake (13.2.0)
|
||||
rake (13.2.1)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
retriable (3.1.2)
|
||||
rexml (3.2.6)
|
||||
rexml (3.2.8)
|
||||
strscan (>= 3.0.9)
|
||||
rouge (2.0.7)
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.3.2)
|
||||
@@ -190,6 +191,7 @@ GEM
|
||||
simctl (1.6.10)
|
||||
CFPropertyList
|
||||
naturally
|
||||
strscan (3.1.0)
|
||||
terminal-notifier (2.0.0)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
|
||||
@@ -654,7 +654,8 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
resolution: Stream.Resolution.from(resolution: videoStream["resolution"].stringValue),
|
||||
kind: .adaptive,
|
||||
encoding: videoStream["encoding"].string,
|
||||
videoFormat: videoStream["type"].string
|
||||
videoFormat: videoStream["type"].string,
|
||||
bitrate: videoStream["bitrate"].int
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -687,6 +687,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
let fps = qualityComponents.count > 1 ? Int(qualityComponents[1]) : 30
|
||||
let resolution = Stream.Resolution.from(resolution: quality, fps: fps)
|
||||
let videoFormat = videoStream.dictionaryValue["format"]?.string
|
||||
let bitrate = videoStream.dictionaryValue["bitrate"]?.int
|
||||
|
||||
if videoOnly {
|
||||
streams.append(
|
||||
@@ -696,7 +697,8 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
videoAsset: videoAsset,
|
||||
resolution: resolution,
|
||||
kind: .adaptive,
|
||||
videoFormat: videoFormat
|
||||
videoFormat: videoFormat,
|
||||
bitrate: bitrate
|
||||
)
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -66,7 +66,7 @@ protocol VideosAPI {
|
||||
failureHandler: ((RequestError) -> Void)?,
|
||||
completionHandler: @escaping (PlayerQueueItem) -> Void
|
||||
)
|
||||
func shareURL(_ item: ContentItem, frontendHost: String?, time: CMTime?) -> URL?
|
||||
func shareURL(_ item: ContentItem, frontendURLString: String?, time: CMTime?) -> URL?
|
||||
|
||||
func comments(_ id: Video.ID, page: String?) -> Resource?
|
||||
}
|
||||
@@ -108,14 +108,19 @@ extension VideosAPI {
|
||||
.onFailure { failureHandler?($0) }
|
||||
}
|
||||
|
||||
func shareURL(_ item: ContentItem, frontendHost: String? = nil, time: CMTime? = nil) -> URL? {
|
||||
guard let frontendHost = frontendHost ?? account?.instance?.frontendHost,
|
||||
var urlComponents = account?.instance?.urlComponents
|
||||
else {
|
||||
return nil
|
||||
func shareURL(_ item: ContentItem, frontendURLString: String? = nil, time: CMTime? = nil) -> URL? {
|
||||
var urlComponents: URLComponents?
|
||||
if let frontendURLString,
|
||||
let frontendURL = URL(string: frontendURLString)
|
||||
{
|
||||
urlComponents = URLComponents(url: frontendURL, resolvingAgainstBaseURL: false)
|
||||
} else if let instanceComponents = account?.instance?.urlComponents {
|
||||
urlComponents = instanceComponents
|
||||
}
|
||||
|
||||
urlComponents.host = frontendHost
|
||||
guard var urlComponents else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var queryItems = [URLQueryItem]()
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ struct BrowsingSettingsGroupImporter {
|
||||
}
|
||||
|
||||
if let favorites = json["favorites"].array {
|
||||
favorites.forEach { favoriteJSON in
|
||||
for favoriteJSON in favorites {
|
||||
if let jsonString = favoriteJSON.rawString(options: []),
|
||||
let item = FavoriteItem.bridge.deserialize(jsonString)
|
||||
{
|
||||
@@ -32,7 +32,7 @@ struct BrowsingSettingsGroupImporter {
|
||||
}
|
||||
|
||||
if let widgetsFavorites = json["widgetsSettings"].array {
|
||||
widgetsFavorites.forEach { widgetJSON in
|
||||
for widgetJSON in widgetsFavorites {
|
||||
let dict = widgetJSON.dictionaryValue.mapValues { json in json.stringValue }
|
||||
if let item = WidgetSettingsBridge().deserialize(dict) {
|
||||
FavoritesModel.shared.updateWidgetSettings(item)
|
||||
|
||||
@@ -56,7 +56,7 @@ struct LocationsSettingsGroupImporter {
|
||||
}
|
||||
|
||||
if let accounts = json["accounts"].array {
|
||||
accounts.forEach { accountJSON in
|
||||
for accountJSON in accounts {
|
||||
let dict = accountJSON.dictionaryValue.mapValues { json in json.stringValue }
|
||||
if let account = AccountsBridge().deserialize(dict),
|
||||
includedAccountsIDs.contains(account.id)
|
||||
|
||||
@@ -26,7 +26,7 @@ struct QualitySettingsGroupImporter {
|
||||
}
|
||||
|
||||
if let qualityProfiles = json["qualityProfiles"].array {
|
||||
qualityProfiles.forEach { qualityProfileJSON in
|
||||
for qualityProfileJSON in qualityProfiles {
|
||||
let dict = qualityProfileJSON.dictionaryValue.mapValues { json in json.stringValue }
|
||||
if let item = QualityProfileBridge().deserialize(dict) {
|
||||
QualityProfilesModel.shared.update(item, item)
|
||||
|
||||
@@ -6,7 +6,7 @@ struct RecentlyOpenedImporter {
|
||||
|
||||
func performImport() {
|
||||
if let recentlyOpened = json["recentlyOpened"].array {
|
||||
recentlyOpened.forEach { recentlyOpenedJSON in
|
||||
for recentlyOpenedJSON in recentlyOpened {
|
||||
let dict = recentlyOpenedJSON.dictionaryValue.mapValues { json in json.stringValue }
|
||||
if let item = RecentItemBridge().deserialize(dict) {
|
||||
RecentsModel.shared.add(item)
|
||||
|
||||
@@ -147,7 +147,7 @@ struct OpenVideosModel {
|
||||
if prepending {
|
||||
videos.reverse()
|
||||
}
|
||||
videos.forEach { video in
|
||||
for video in videos {
|
||||
player.enqueueVideo(video, play: false, prepending: prepending, loadDetails: false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,16 +116,6 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
#endif
|
||||
}
|
||||
|
||||
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting) -> Stream? {
|
||||
let sortedByResolution = streams
|
||||
.filter { ($0.kind == .adaptive || $0.kind == .stream) && $0.resolution <= maxResolution.value }
|
||||
.sorted { $0.resolution > $1.resolution }
|
||||
|
||||
return streams.first { $0.kind == .hls } ??
|
||||
sortedByResolution.first { $0.kind == .stream } ??
|
||||
sortedByResolution.first
|
||||
}
|
||||
|
||||
func canPlay(_ stream: Stream) -> Bool {
|
||||
stream.kind == .hls || stream.kind == .stream || (stream.kind == .adaptive && stream.format == .mp4)
|
||||
}
|
||||
@@ -134,7 +124,7 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
_ stream: Stream,
|
||||
of video: Video,
|
||||
preservingTime: Bool,
|
||||
upgrading _: Bool
|
||||
upgrading: Bool
|
||||
) {
|
||||
isLoadingVideo = true
|
||||
|
||||
@@ -145,7 +135,7 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
_ = url.startAccessingSecurityScopedResource()
|
||||
}
|
||||
|
||||
loadSingleAsset(url, stream: stream, of: video, preservingTime: preservingTime)
|
||||
loadSingleAsset(url, stream: stream, of: video, preservingTime: preservingTime, upgrading: upgrading)
|
||||
} else {
|
||||
model.logger.info("playing stream with many assets:")
|
||||
model.logger.info("composition audio asset: \(stream.audioAsset.url)")
|
||||
@@ -160,6 +150,13 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
return
|
||||
}
|
||||
|
||||
// After the video has ended, hitting play restarts the video from the beginning.
|
||||
if currentTime?.seconds.formattedAsPlaybackTime() == model.playerTime.duration.seconds.formattedAsPlaybackTime() &&
|
||||
currentTime!.seconds > 0 && model.playerTime.duration.seconds > 0
|
||||
{
|
||||
seek(to: 0, seekType: .loopRestart)
|
||||
}
|
||||
|
||||
avPlayer.play()
|
||||
model.objectWillChange.send()
|
||||
}
|
||||
@@ -219,7 +216,8 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
_ url: URL,
|
||||
stream: Stream,
|
||||
of video: Video,
|
||||
preservingTime: Bool = false
|
||||
preservingTime: Bool = false,
|
||||
upgrading: Bool = false
|
||||
) {
|
||||
asset?.cancelLoading()
|
||||
asset = AVURLAsset(url: url)
|
||||
@@ -228,7 +226,7 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
switch self?.asset?.statusOfValue(forKey: "duration", error: &error) {
|
||||
case .loaded:
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.insertPlayerItem(stream, for: video, preservingTime: preservingTime)
|
||||
self?.insertPlayerItem(stream, for: video, preservingTime: preservingTime, upgrading: upgrading)
|
||||
}
|
||||
case .failed:
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
@@ -303,11 +301,17 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
private func insertPlayerItem(
|
||||
_ stream: Stream,
|
||||
for video: Video,
|
||||
preservingTime: Bool = false
|
||||
preservingTime: Bool = false,
|
||||
upgrading: Bool = false
|
||||
) {
|
||||
removeItemDidPlayToEndTimeObserver()
|
||||
|
||||
model.playerItem = playerItem(stream)
|
||||
|
||||
if stream.isHLS {
|
||||
model.playerItem?.preferredPeakBitRate = Double(model.qualityProfile?.resolution.value.bitrate ?? 0)
|
||||
}
|
||||
|
||||
guard model.playerItem != nil else {
|
||||
return
|
||||
}
|
||||
@@ -387,7 +391,7 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
}
|
||||
|
||||
if preservingTime {
|
||||
if model.preservedTime.isNil {
|
||||
if model.preservedTime.isNil || upgrading {
|
||||
model.saveTime {
|
||||
replaceItemAndSeek()
|
||||
startPlaying()
|
||||
|
||||
@@ -201,29 +201,6 @@ final class MPVBackend: PlayerBackend {
|
||||
|
||||
typealias AreInIncreasingOrder = (Stream, Stream) -> Bool
|
||||
|
||||
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting) -> Stream? {
|
||||
streams
|
||||
.filter { $0.kind != .hls && $0.resolution <= maxResolution.value }
|
||||
.max { lhs, rhs in
|
||||
let predicates: [AreInIncreasingOrder] = [
|
||||
{ $0.resolution < $1.resolution },
|
||||
{ $0.format > $1.format }
|
||||
]
|
||||
|
||||
for predicate in predicates {
|
||||
if !predicate(lhs, rhs), !predicate(rhs, lhs) {
|
||||
continue
|
||||
}
|
||||
|
||||
return predicate(lhs, rhs)
|
||||
}
|
||||
|
||||
return false
|
||||
} ??
|
||||
streams.first { $0.kind == .hls } ??
|
||||
streams.first
|
||||
}
|
||||
|
||||
func canPlay(_ stream: Stream) -> Bool {
|
||||
stream.resolution != .unknown && stream.format != .av1
|
||||
}
|
||||
@@ -254,7 +231,18 @@ final class MPVBackend: PlayerBackend {
|
||||
|
||||
let startPlaying = {
|
||||
#if !os(macOS)
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(self.handleAudioSessionInterruption(_:)),
|
||||
name: AVAudioSession.interruptionNotification,
|
||||
object: nil
|
||||
)
|
||||
} catch {
|
||||
self.logger.error("Error setting up audio session: \(error)")
|
||||
}
|
||||
#endif
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
@@ -264,6 +252,10 @@ final class MPVBackend: PlayerBackend {
|
||||
|
||||
self.startClientUpdates()
|
||||
|
||||
// Captions should only be displayed when selected by the user,
|
||||
// not when the video starts. So, we remove them.
|
||||
self.client?.removeSubs()
|
||||
|
||||
if !preservingTime,
|
||||
!upgrading,
|
||||
let segment = self.model.sponsorBlock.segments.first,
|
||||
@@ -309,7 +301,7 @@ final class MPVBackend: PlayerBackend {
|
||||
}
|
||||
}
|
||||
|
||||
client.loadFile(url, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
|
||||
client.loadFile(url, bitrate: stream.bitrate, kind: stream.kind, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
|
||||
self?.isLoadingVideo = true
|
||||
}
|
||||
} else {
|
||||
@@ -321,7 +313,7 @@ final class MPVBackend: PlayerBackend {
|
||||
let fileToLoad = self.model.musicMode ? stream.audioAsset.url : stream.videoAsset.url
|
||||
let audioTrack = self.model.musicMode ? nil : stream.audioAsset.url
|
||||
|
||||
client.loadFile(fileToLoad, audio: audioTrack, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
|
||||
client.loadFile(fileToLoad, audio: audioTrack, bitrate: stream.bitrate, kind: stream.kind, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
|
||||
self?.isLoadingVideo = true
|
||||
self?.pause()
|
||||
}
|
||||
@@ -330,7 +322,7 @@ final class MPVBackend: PlayerBackend {
|
||||
}
|
||||
|
||||
if preservingTime {
|
||||
if model.preservedTime.isNil {
|
||||
if model.preservedTime.isNil || upgrading {
|
||||
model.saveTime {
|
||||
replaceItem(self.model.preservedTime)
|
||||
}
|
||||
@@ -354,6 +346,13 @@ final class MPVBackend: PlayerBackend {
|
||||
|
||||
setRate(model.currentRate)
|
||||
|
||||
// After the video has ended, hitting play restarts the video from the beginning.
|
||||
if currentTime?.seconds.formattedAsPlaybackTime() == model.playerTime.duration.seconds.formattedAsPlaybackTime() &&
|
||||
currentTime!.seconds > 0 && model.playerTime.duration.seconds > 0
|
||||
{
|
||||
seek(to: 0, seekType: .loopRestart)
|
||||
}
|
||||
|
||||
client?.play()
|
||||
}
|
||||
|
||||
@@ -519,8 +518,6 @@ final class MPVBackend: PlayerBackend {
|
||||
guard client.eofReached else {
|
||||
return
|
||||
}
|
||||
|
||||
getTimeUpdates()
|
||||
eofPlaybackModeAction()
|
||||
}
|
||||
|
||||
@@ -627,4 +624,33 @@ final class MPVBackend: PlayerBackend {
|
||||
logger.info("MPV backend received unhandled property: \(name)")
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(macOS)
|
||||
@objc func handleAudioSessionInterruption(_ notification: Notification) {
|
||||
logger.info("Audio session interruption received.")
|
||||
|
||||
guard let info = notification.userInfo,
|
||||
let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt
|
||||
else {
|
||||
logger.info("AVAudioSessionInterruptionTypeKey is missing or not a UInt in userInfo.")
|
||||
return
|
||||
}
|
||||
|
||||
let type = AVAudioSession.InterruptionType(rawValue: typeValue)
|
||||
|
||||
logger.info("Interruption type received: \(String(describing: type))")
|
||||
|
||||
switch type {
|
||||
case .began:
|
||||
pause()
|
||||
logger.info("Audio session interrupted.")
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -128,6 +128,8 @@ final class MPVClient: ObservableObject {
|
||||
func loadFile(
|
||||
_ url: URL,
|
||||
audio: URL? = nil,
|
||||
bitrate: Int? = nil,
|
||||
kind: Stream.Kind,
|
||||
sub: URL? = nil,
|
||||
time: CMTime? = nil,
|
||||
forceSeekable: Bool = false,
|
||||
@@ -138,6 +140,10 @@ final class MPVClient: ObservableObject {
|
||||
|
||||
args.append("replace")
|
||||
|
||||
// needed since mpvkit 0.38.0
|
||||
// https://github.com/mpv-player/mpv/issues/13806#issuecomment-2029818905
|
||||
args.append("-1")
|
||||
|
||||
if let time, time.seconds > 0 {
|
||||
options.append("start=\(Int(time.seconds))")
|
||||
}
|
||||
@@ -160,6 +166,10 @@ final class MPVClient: ObservableObject {
|
||||
args.append(options.joined(separator: ","))
|
||||
}
|
||||
|
||||
if kind == .hls, bitrate != 0 {
|
||||
checkError(mpv_set_option_string(mpv, "hls-bitrate", String(describing: bitrate)))
|
||||
}
|
||||
|
||||
command("loadfile", args: args, returnValueCallback: completionHandler)
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ protocol PlayerBackend {
|
||||
var videoWidth: Double? { get }
|
||||
var videoHeight: Double? { get }
|
||||
|
||||
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting) -> Stream?
|
||||
func canPlay(_ stream: Stream) -> Bool
|
||||
func canPlayAtRate(_ rate: Double) -> Bool
|
||||
|
||||
@@ -131,6 +130,52 @@ extension PlayerBackend {
|
||||
}
|
||||
}
|
||||
|
||||
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting, formatOrder: [QualityProfile.Format]) -> Stream? {
|
||||
// filter out non HLS streams
|
||||
let nonHLSStreams = streams.filter { $0.kind != .hls }
|
||||
|
||||
// find max resolution from non HLS streams
|
||||
let bestResolution = nonHLSStreams
|
||||
.filter { $0.resolution <= maxResolution.value }
|
||||
.max { $0.resolution < $1.resolution }
|
||||
|
||||
// finde max bitrate from non HLS streams
|
||||
let bestBitrate = nonHLSStreams
|
||||
.filter { $0.resolution <= maxResolution.value }
|
||||
.max { $0.bitrate ?? 0 < $1.bitrate ?? 0 }
|
||||
|
||||
return streams.map { stream in
|
||||
if stream.kind == .hls {
|
||||
stream.resolution = bestResolution?.resolution ?? maxResolution.value
|
||||
stream.bitrate = bestBitrate?.bitrate ?? (bestResolution?.resolution.bitrate ?? maxResolution.value.bitrate)
|
||||
stream.format = .hls
|
||||
} else if stream.kind == .stream {
|
||||
stream.format = .stream
|
||||
}
|
||||
return stream
|
||||
}
|
||||
.filter { stream in
|
||||
stream.resolution <= maxResolution.value
|
||||
}
|
||||
.max { lhs, rhs in
|
||||
if lhs.resolution == rhs.resolution {
|
||||
guard let lhsFormat = QualityProfile.Format(rawValue: lhs.format.rawValue),
|
||||
let rhsFormat = QualityProfile.Format(rawValue: rhs.format.rawValue)
|
||||
else {
|
||||
print("Failed to extract lhsFormat or rhsFormat")
|
||||
return false
|
||||
}
|
||||
|
||||
let lhsFormatIndex = formatOrder.firstIndex(of: lhsFormat) ?? Int.max
|
||||
let rhsFormatIndex = formatOrder.firstIndex(of: rhsFormat) ?? Int.max
|
||||
|
||||
return lhsFormatIndex > rhsFormatIndex
|
||||
}
|
||||
|
||||
return lhs.resolution < rhs.resolution
|
||||
}
|
||||
}
|
||||
|
||||
func updateControls(completionHandler: (() -> Void)? = nil) {
|
||||
print("updating controls")
|
||||
|
||||
|
||||
@@ -76,6 +76,8 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
var previousActiveBackend: PlayerBackendType?
|
||||
|
||||
lazy var playerBackendView = PlayerBackendView()
|
||||
|
||||
@Published var playerSize: CGSize = .zero { didSet {
|
||||
@@ -176,7 +178,7 @@ final class PlayerModel: ObservableObject {
|
||||
@Default(.resetWatchedStatusOnPlaying) var resetWatchedStatusOnPlaying
|
||||
@Default(.playerRate) var playerRate
|
||||
@Default(.systemControlsSeekDuration) var systemControlsSeekDuration
|
||||
|
||||
|
||||
#if os(macOS)
|
||||
@Default(.buttonBackwardSeekDuration) private var buttonBackwardSeekDuration
|
||||
@Default(.buttonForwardSeekDuration) private var buttonForwardSeekDuration
|
||||
@@ -192,7 +194,7 @@ final class PlayerModel: ObservableObject {
|
||||
var onPlayStream = [(Stream) -> Void]()
|
||||
var rateToRestore: Float?
|
||||
private var remoteCommandCenterConfigured = false
|
||||
|
||||
|
||||
#if os(macOS)
|
||||
var keyPressMonitor: Any?
|
||||
#endif
|
||||
@@ -532,7 +534,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func changeActiveBackend(from: PlayerBackendType, to: PlayerBackendType, changingStream: Bool = true) {
|
||||
func changeActiveBackend(from: PlayerBackendType, to: PlayerBackendType, changingStream: Bool = true, isInClosePip: Bool = false) {
|
||||
guard activeBackend != to else {
|
||||
return
|
||||
}
|
||||
@@ -541,7 +543,7 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
let wasPlaying = isPlaying
|
||||
|
||||
if to == .mpv {
|
||||
if to == .mpv && !isInClosePip {
|
||||
closePiP()
|
||||
}
|
||||
|
||||
@@ -664,6 +666,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
func startPiP() {
|
||||
previousActiveBackend = activeBackend
|
||||
avPlayerBackend.startPictureInPictureOnPlay = false
|
||||
avPlayerBackend.startPictureInPictureOnSwitch = false
|
||||
|
||||
@@ -673,7 +676,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
guard let video = currentVideo else { return }
|
||||
guard let stream = avPlayerBackend.bestPlayable(availableStreams, maxResolution: .hd720p30) else { return }
|
||||
guard let stream = backend.bestPlayable(availableStreams, maxResolution: .hd720p30, formatOrder: qualityProfile!.formats) else { return }
|
||||
|
||||
exitFullScreen()
|
||||
|
||||
@@ -716,6 +719,12 @@ final class PlayerModel: ObservableObject {
|
||||
#endif
|
||||
|
||||
backend.closePiP()
|
||||
if previousActiveBackend == .mpv {
|
||||
saveTime {
|
||||
self.changeActiveBackend(from: self.activeBackend, to: .mpv, isInClosePip: true)
|
||||
self.controls.resetTimer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var pipImage: String {
|
||||
@@ -771,10 +780,12 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
func handleCurrentItemChange() {
|
||||
if currentItem == nil {
|
||||
captions = nil
|
||||
FeedModel.shared.calculateUnwatchedFeed()
|
||||
}
|
||||
|
||||
// Captions need to be set to nil on item change, to clear the previus values.
|
||||
captions = nil
|
||||
|
||||
#if os(macOS)
|
||||
Windows.player.window?.title = windowTitle
|
||||
#endif
|
||||
@@ -935,7 +946,10 @@ final class PlayerModel: ObservableObject {
|
||||
#else
|
||||
func handleEnterForeground() {
|
||||
setNeedsDrawing(presentingPlayer)
|
||||
avPlayerBackend.bindPlayerToLayer()
|
||||
|
||||
if !musicMode, activeBackend == .appleAVPlayer {
|
||||
avPlayerBackend.bindPlayerToLayer()
|
||||
}
|
||||
|
||||
guard closePiPAndOpenPlayerOnEnteringForeground, playingInPictureInPicture else {
|
||||
return
|
||||
@@ -1158,7 +1172,7 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
#if os(macOS)
|
||||
private func assignKeyPressMonitor() {
|
||||
keyPressMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { keyEvent -> NSEvent? in
|
||||
@@ -1188,12 +1202,13 @@ final class PlayerModel: ObservableObject {
|
||||
if !self.controls.isLoadingVideo {
|
||||
self.backend.togglePlay()
|
||||
}
|
||||
default: return keyEvent
|
||||
default:
|
||||
return keyEvent
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func destroyKeyPressMonitor() {
|
||||
if let keyPressMonitor = keyPressMonitor {
|
||||
NSEvent.removeMonitor(keyPressMonitor)
|
||||
|
||||
@@ -127,12 +127,12 @@ extension PlayerModel {
|
||||
|
||||
if let streamPreferredForProfile = backend.bestPlayable(
|
||||
availableStreams.filter { backend.canPlay($0) && profile.isPreferred($0) },
|
||||
maxResolution: profile.resolution
|
||||
maxResolution: profile.resolution, formatOrder: profile.formats
|
||||
) {
|
||||
return streamPreferredForProfile
|
||||
}
|
||||
|
||||
return backend.bestPlayable(availableStreams.filter { backend.canPlay($0) }, maxResolution: profile.resolution)
|
||||
return backend.bestPlayable(availableStreams.filter { backend.canPlay($0) }, maxResolution: profile.resolution, formatOrder: profile.formats)
|
||||
}
|
||||
|
||||
func advanceToNextItem() {
|
||||
|
||||
@@ -44,22 +44,6 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
private func skip(_ segment: Segment, at time: CMTime) {
|
||||
if let duration = playerItemDuration, segment.endTime.seconds >= duration.seconds - 3 {
|
||||
logger.error("segment end time is: \(segment.end) when player item duration is: \(duration.seconds)")
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
|
||||
self.pause()
|
||||
|
||||
self.backend.eofPlaybackModeAction()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
backend.seek(to: segment.endTime, seekType: .segmentSkip(segment.category))
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
@@ -69,6 +53,14 @@ extension PlayerModel {
|
||||
self?.segmentRestorationTime = time
|
||||
}
|
||||
logger.info("SponsorBlock skipping to: \(segment.end)")
|
||||
|
||||
if let duration = playerItemDuration, segment.endTime.seconds >= duration.seconds - 3 {
|
||||
logger.error("Segment end time is: \(segment.end) when player item duration is: \(duration.seconds)")
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.backend.eofPlaybackModeAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldSkip(_ segment: Segment, at time: CMTime) -> Bool {
|
||||
|
||||
@@ -3,13 +3,13 @@ import Foundation
|
||||
|
||||
struct QualityProfile: Hashable, Identifiable, Defaults.Serializable {
|
||||
static var bridge = QualityProfileBridge()
|
||||
static var defaultProfile = Self(id: "default", backend: .mpv, resolution: .hd720p60, formats: [.stream])
|
||||
static var defaultProfile = Self(id: "default", backend: .mpv, resolution: .hd720p60, formats: [.stream], order: Array(Format.allCases.indices))
|
||||
|
||||
enum Format: String, CaseIterable, Identifiable, Defaults.Serializable {
|
||||
case hls
|
||||
case stream
|
||||
case mp4
|
||||
case avc1
|
||||
case mp4
|
||||
case av1
|
||||
case webm
|
||||
|
||||
@@ -23,7 +23,6 @@ struct QualityProfile: Hashable, Identifiable, Defaults.Serializable {
|
||||
return "Stream"
|
||||
case .webm:
|
||||
return "WebM"
|
||||
|
||||
default:
|
||||
return rawValue.uppercased()
|
||||
}
|
||||
@@ -35,14 +34,14 @@ struct QualityProfile: Hashable, Identifiable, Defaults.Serializable {
|
||||
return nil
|
||||
case .stream:
|
||||
return nil
|
||||
case .mp4:
|
||||
return .mp4
|
||||
case .webm:
|
||||
return .webm
|
||||
case .avc1:
|
||||
return .avc1
|
||||
case .mp4:
|
||||
return .mp4
|
||||
case .av1:
|
||||
return .av1
|
||||
case .webm:
|
||||
return .webm
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,7 +52,7 @@ struct QualityProfile: Hashable, Identifiable, Defaults.Serializable {
|
||||
var backend: PlayerBackendType
|
||||
var resolution: ResolutionSetting
|
||||
var formats: [Format]
|
||||
|
||||
var order: [Int]
|
||||
var description: String {
|
||||
if let name, !name.isEmpty { return name }
|
||||
return "\(backend.label) - \(resolution.description) - \(formatsDescription)"
|
||||
@@ -101,7 +100,8 @@ struct QualityProfileBridge: Defaults.Bridge {
|
||||
"name": value.name ?? "",
|
||||
"backend": value.backend.rawValue,
|
||||
"resolution": value.resolution.rawValue,
|
||||
"formats": value.formats.map { $0.rawValue }.joined(separator: Self.formatsSeparator)
|
||||
"formats": value.formats.map { $0.rawValue }.joined(separator: Self.formatsSeparator),
|
||||
"order": value.order.map { String($0) }.joined(separator: Self.formatsSeparator) // New line
|
||||
]
|
||||
}
|
||||
|
||||
@@ -116,7 +116,8 @@ struct QualityProfileBridge: Defaults.Bridge {
|
||||
|
||||
let name = object["name"]
|
||||
let formats = (object["formats"] ?? "").components(separatedBy: Self.formatsSeparator).compactMap { QualityProfile.Format(rawValue: $0) }
|
||||
let order = (object["order"] ?? "").components(separatedBy: Self.formatsSeparator).compactMap { Int($0) }
|
||||
|
||||
return .init(id: id, name: name, backend: backend, resolution: resolution, formats: formats)
|
||||
return .init(id: id, name: name, backend: backend, resolution: resolution, formats: formats, order: order)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,13 +71,13 @@ final class SeekModel: ObservableObject {
|
||||
func showOSD() {
|
||||
guard !presentingOSD else { return }
|
||||
|
||||
withAnimation(.easeIn(duration: 0.1)) { self.presentingOSD = true }
|
||||
presentingOSD = true
|
||||
}
|
||||
|
||||
func hideOSD() {
|
||||
guard presentingOSD else { return }
|
||||
|
||||
withAnimation(.easeIn(duration: 0.1)) { self.presentingOSD = false }
|
||||
presentingOSD = false
|
||||
}
|
||||
|
||||
func hideOSDWithDelay() {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
enum SeekType: Equatable {
|
||||
case chapterSkip(String)
|
||||
case segmentSkip(String)
|
||||
case segmentRestore
|
||||
case userInteracted
|
||||
|
||||
@@ -5,7 +5,7 @@ import Logging
|
||||
import SwiftyJSON
|
||||
|
||||
final class SponsorBlockAPI: ObservableObject {
|
||||
static let categories = ["sponsor", "selfpromo", "intro", "outro", "interaction", "music_offtopic"]
|
||||
static let categories = ["sponsor", "selfpromo", "interaction", "intro", "outro", "preview", "filler", "music_offtopic"]
|
||||
|
||||
let logger = Logger(label: "stream.yattee.app.sb")
|
||||
|
||||
@@ -21,15 +21,19 @@ final class SponsorBlockAPI: ObservableObject {
|
||||
case "sponsor":
|
||||
return "Sponsor".localized()
|
||||
case "selfpromo":
|
||||
return "Self-promotion".localized()
|
||||
case "intro":
|
||||
return "Intro".localized()
|
||||
case "outro":
|
||||
return "Outro".localized()
|
||||
return "Unpaid/Self Promotion".localized()
|
||||
case "interaction":
|
||||
return "Interaction".localized()
|
||||
return "Interaction Reminder (Subscribe)".localized()
|
||||
case "intro":
|
||||
return "Intermission/Intro Animation".localized()
|
||||
case "outro":
|
||||
return "Endcards/Credits".localized()
|
||||
case "preview":
|
||||
return "Preview/Recap/Hook".localized()
|
||||
case "filler":
|
||||
return "Filler Tangent/Jokes".localized()
|
||||
case "music_offtopic":
|
||||
return "Offtopic in Music Videos".localized()
|
||||
return "Music: Non-Music Section".localized()
|
||||
default:
|
||||
return name.capitalized
|
||||
}
|
||||
@@ -46,9 +50,14 @@ final class SponsorBlockAPI: ObservableObject {
|
||||
"The creator will receive payment or compensation in the form of money or free products.").localized()
|
||||
|
||||
case "selfpromo":
|
||||
return ("Promoting a product or service that is directly related to the creator themselves. " +
|
||||
return ("The creator will not receive any payment in exchange for this promotion. " +
|
||||
"This includes charity drives or free shout outs for products or other people they like.\n\n" +
|
||||
"Promoting a product or service that is directly related to the creator themselves. " +
|
||||
"This usually includes merchandise or promotion of monetized platforms.").localized()
|
||||
|
||||
case "interaction":
|
||||
return "Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video).".localized()
|
||||
|
||||
case "intro":
|
||||
return ("Segments typically found at the start of a video that include an animation, " +
|
||||
"still frame or clip which are also seen in other videos by the same creator.").localized()
|
||||
@@ -56,8 +65,11 @@ final class SponsorBlockAPI: ObservableObject {
|
||||
case "outro":
|
||||
return "Typically near or at the end of the video when the credits pop up and/or endcards are shown.".localized()
|
||||
|
||||
case "interaction":
|
||||
return "Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video).".localized()
|
||||
case "preview":
|
||||
return "Collection of clips that show what is coming up in in this video or other videos in a series where all information is repeated later in the video".localized()
|
||||
|
||||
case "filler":
|
||||
return "Filler Tangent/ Jokes is only for tangential scenes added only for filler or humor that are not required to understand the main content of the video.".localized()
|
||||
|
||||
case "music_offtopic":
|
||||
return "For videos which feature music as the primary content.".localized()
|
||||
@@ -100,8 +112,8 @@ final class SponsorBlockAPI: ObservableObject {
|
||||
self.segments = JSON(value).arrayValue.map(SponsorBlockSegment.init).sorted { $0.end < $1.end }
|
||||
|
||||
self.logger.info("loaded \(self.segments.count) SponsorBlock segments")
|
||||
self.segments.forEach {
|
||||
self.logger.info("\($0.start) -> \($0.end)")
|
||||
for segment in self.segments {
|
||||
self.logger.info("\(segment.start) -> \(segment.end)")
|
||||
}
|
||||
case let .failure(error):
|
||||
self.segments = []
|
||||
|
||||
@@ -54,6 +54,32 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
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 {
|
||||
switch self {
|
||||
case .hd2160p60, .hd2160p50, .hd2160p48, .hd2160p30:
|
||||
return 56_000_000 // 56 Mbit/s
|
||||
case .hd1440p60, .hd1440p50, .hd1440p48, .hd1440p30:
|
||||
return 24_000_000 // 24 Mbit/s
|
||||
case .hd1080p60, .hd1080p50, .hd1080p48, .hd1080p30:
|
||||
return 12_000_000 // 12 Mbit/s
|
||||
case .hd720p60, .hd720p50, .hd720p48, .hd720p30:
|
||||
return 9_500_000 // 9.5 Mbit/s
|
||||
case .sd480p30:
|
||||
return 4_000_000 // 4 Mbit/s
|
||||
case .sd360p30:
|
||||
return 1_500_000 // 1.5 Mbit/s
|
||||
case .sd240p30:
|
||||
return 1_000_000 // 1 Mbit/s
|
||||
case .sd144p30:
|
||||
return 600_000 // 0.6 Mbit/s
|
||||
case .unknown:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
static func from(resolution: String, fps: Int? = nil) -> Self {
|
||||
allCases.first { $0.rawValue.contains(resolution) && $0.refreshRate == (fps ?? 30) } ?? .unknown
|
||||
}
|
||||
@@ -64,7 +90,7 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
}
|
||||
|
||||
enum Kind: String, Comparable {
|
||||
case stream, adaptive, hls
|
||||
case hls, adaptive, stream
|
||||
|
||||
private var sortOrder: Int {
|
||||
switch self {
|
||||
@@ -82,37 +108,23 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
enum Format: String, Comparable {
|
||||
case webm
|
||||
enum Format: String {
|
||||
case avc1
|
||||
case av1
|
||||
case mp4
|
||||
case av1
|
||||
case webm
|
||||
case hls
|
||||
case stream
|
||||
case unknown
|
||||
|
||||
private var sortOrder: Int {
|
||||
switch self {
|
||||
case .mp4:
|
||||
return 0
|
||||
case .avc1:
|
||||
return 1
|
||||
case .av1:
|
||||
return 2
|
||||
case .webm:
|
||||
return 3
|
||||
case .unknown:
|
||||
return 4
|
||||
}
|
||||
}
|
||||
|
||||
static func < (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.sortOrder < rhs.sortOrder
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .webm:
|
||||
return "WebM"
|
||||
|
||||
case .hls:
|
||||
return "adaptive (HLS)"
|
||||
case .stream:
|
||||
return "Stream"
|
||||
default:
|
||||
return rawValue.uppercased()
|
||||
}
|
||||
@@ -121,17 +133,23 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
static func from(_ string: String) -> Self {
|
||||
let lowercased = string.lowercased()
|
||||
|
||||
if lowercased.contains("webm") {
|
||||
return .webm
|
||||
}
|
||||
if lowercased.contains("avc1") {
|
||||
return .avc1
|
||||
}
|
||||
if lowercased.contains("mpeg_4") || lowercased.contains("mp4") {
|
||||
return .mp4
|
||||
}
|
||||
if lowercased.contains("av01") {
|
||||
return .av1
|
||||
}
|
||||
if lowercased.contains("mpeg_4") || lowercased.contains("mp4") {
|
||||
return .mp4
|
||||
if lowercased.contains("webm") {
|
||||
return .webm
|
||||
}
|
||||
if lowercased.contains("stream") {
|
||||
return .stream
|
||||
}
|
||||
if lowercased.contains("hls") {
|
||||
return .hls
|
||||
}
|
||||
return .unknown
|
||||
}
|
||||
@@ -151,6 +169,7 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
|
||||
var encoding: String?
|
||||
var videoFormat: String?
|
||||
var bitrate: Int?
|
||||
|
||||
init(
|
||||
instance: Instance? = nil,
|
||||
@@ -161,7 +180,8 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
resolution: Resolution? = nil,
|
||||
kind: Kind = .hls,
|
||||
encoding: String? = nil,
|
||||
videoFormat: String? = nil
|
||||
videoFormat: String? = nil,
|
||||
bitrate: Int? = nil
|
||||
) {
|
||||
self.instance = instance
|
||||
self.audioAsset = audioAsset
|
||||
@@ -172,6 +192,7 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
self.kind = kind
|
||||
self.encoding = encoding
|
||||
format = .from(videoFormat ?? "")
|
||||
self.bitrate = bitrate
|
||||
}
|
||||
|
||||
var isLocal: Bool {
|
||||
@@ -184,22 +205,31 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
|
||||
var quality: String {
|
||||
guard localURL.isNil else { return "Opened File" }
|
||||
return kind == .hls ? "adaptive (HLS)" : "\(resolution.name)\(kind == .stream ? " (\(kind.rawValue))" : "")"
|
||||
|
||||
if kind == .hls {
|
||||
return "adaptive (HLS)"
|
||||
}
|
||||
|
||||
return resolution.name
|
||||
}
|
||||
|
||||
var shortQuality: String {
|
||||
guard localURL.isNil else { return "File" }
|
||||
|
||||
if kind == .hls {
|
||||
return "HLS"
|
||||
return "adaptive (HLS)"
|
||||
}
|
||||
return resolution?.name ?? "?"
|
||||
|
||||
if kind == .stream {
|
||||
return resolution.name
|
||||
}
|
||||
return resolutionAndFormat
|
||||
}
|
||||
|
||||
var description: String {
|
||||
guard localURL.isNil else { return resolutionAndFormat }
|
||||
let instanceString = instance.isNil ? "" : " - (\(instance!.description))"
|
||||
return "\(resolutionAndFormat)\(instanceString)"
|
||||
return format != .hls ? "\(resolutionAndFormat)\(instanceString)" : "adaptive (HLS)\(instanceString)"
|
||||
}
|
||||
|
||||
var resolutionAndFormat: String {
|
||||
|
||||
@@ -114,7 +114,7 @@ struct URLBookmarkModel {
|
||||
func refreshAll() {
|
||||
logger.info("refreshing all bookmarks")
|
||||
|
||||
allURLs.forEach { url in
|
||||
for url in allURLs {
|
||||
if loadBookmark(url) != nil {
|
||||
logger.info("bookmark for \(url) exists")
|
||||
} else {
|
||||
|
||||
@@ -57,7 +57,7 @@ final class URLParserTests: XCTestCase {
|
||||
]
|
||||
|
||||
func testUrlsParsing() throws {
|
||||
Self.urls.forEach { urlString in
|
||||
for urlString in Self.urls {
|
||||
let url = URL(string: urlString)!
|
||||
let parser = URLParser(url: url)
|
||||
XCTAssertEqual(parser.destination, .fileURL)
|
||||
@@ -66,7 +66,7 @@ final class URLParserTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testVideosParsing() throws {
|
||||
Self.videos.forEach { url, id in
|
||||
for (url, id) in Self.videos {
|
||||
let parser = URLParser(url: URL(string: url)!)
|
||||
XCTAssertEqual(parser.destination, .video)
|
||||
XCTAssertEqual(parser.videoID, id)
|
||||
@@ -74,7 +74,7 @@ final class URLParserTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testChannelsByNameParsing() throws {
|
||||
Self.channelsByName.forEach { url, name in
|
||||
for (url, name) in Self.channelsByName {
|
||||
let parser = URLParser(url: URL(string: url)!)
|
||||
XCTAssertEqual(parser.destination, .channel)
|
||||
XCTAssertEqual(parser.channelName, name)
|
||||
@@ -83,7 +83,7 @@ final class URLParserTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testChannelsByIdParsing() throws {
|
||||
Self.channelsByID.forEach { url, id in
|
||||
for (url, id) in Self.channelsByID {
|
||||
let parser = URLParser(url: URL(string: url)!)
|
||||
XCTAssertEqual(parser.destination, .channel)
|
||||
XCTAssertEqual(parser.channelID, id)
|
||||
@@ -92,7 +92,7 @@ final class URLParserTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testUsersParsing() throws {
|
||||
Self.users.forEach { url, user in
|
||||
for (url, user) in Self.users {
|
||||
let parser = URLParser(url: URL(string: url)!)
|
||||
XCTAssertEqual(parser.destination, .channel)
|
||||
XCTAssertNil(parser.channelID)
|
||||
@@ -102,7 +102,7 @@ final class URLParserTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testPlaylistsParsing() throws {
|
||||
Self.playlists.forEach { url, id in
|
||||
for (url, id) in Self.playlists {
|
||||
let parser = URLParser(url: URL(string: url)!)
|
||||
XCTAssertEqual(parser.destination, .playlist)
|
||||
XCTAssertEqual(parser.playlistID, id)
|
||||
@@ -110,7 +110,7 @@ final class URLParserTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testSearchesParsing() throws {
|
||||
Self.searches.forEach { url, query in
|
||||
for (url, query) in Self.searches {
|
||||
let parser = URLParser(url: URL(string: url)!)
|
||||
XCTAssertEqual(parser.destination, .search)
|
||||
XCTAssertEqual(parser.searchQuery, query)
|
||||
@@ -127,7 +127,7 @@ final class URLParserTests: XCTestCase {
|
||||
"watch?v=IUTGFQpKaPU&t=30s": 30
|
||||
]
|
||||
|
||||
samples.forEach { url, time in
|
||||
for (url, time) in samples {
|
||||
XCTAssertEqual(
|
||||
URLParser(url: URL(string: url)!).time,
|
||||
time
|
||||
|
||||
@@ -53,7 +53,6 @@ struct ChannelPlaylistCell: View {
|
||||
|
||||
Text("\(playlist.videosCount ?? playlist.videos.count) videos")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
.frame(height: 20)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,24 @@ import Foundation
|
||||
import SwiftUI
|
||||
|
||||
enum Constants {
|
||||
static let yatteeProtocol = "yattee://"
|
||||
static let overlayAnimation = Animation.linear(duration: 0.2)
|
||||
|
||||
static var isAppleTV: Bool {
|
||||
#if os(iOS)
|
||||
UIDevice.current.userInterfaceIdiom == .tv
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
|
||||
static var isMac: Bool {
|
||||
#if os(iOS)
|
||||
UIDevice.current.userInterfaceIdiom == .mac
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
|
||||
static var isIPhone: Bool {
|
||||
#if os(iOS)
|
||||
UIDevice.current.userInterfaceIdiom == .phone
|
||||
@@ -81,6 +97,20 @@ enum Constants {
|
||||
#endif
|
||||
}
|
||||
|
||||
static var defaultNavigationStyle: NavigationStyle {
|
||||
#if os(macOS)
|
||||
return .sidebar
|
||||
#elseif os(iOS)
|
||||
if isIPad {
|
||||
return .sidebar
|
||||
} else {
|
||||
return .tab
|
||||
}
|
||||
#else
|
||||
return .tab
|
||||
#endif
|
||||
}
|
||||
|
||||
static func seekIcon(_ type: String, _ interval: TimeInterval) -> String {
|
||||
let interval = Int(interval)
|
||||
let allVersions = [10, 15, 30, 45, 60, 75, 90]
|
||||
|
||||
@@ -77,6 +77,8 @@ extension Defaults.Keys {
|
||||
static let collapsedLinesDescription = Key<Int>("collapsedLinesDescription", default: 5)
|
||||
|
||||
static let showChapters = Key<Bool>("showChapters", default: true)
|
||||
static let showChapterThumbnails = Key<Bool>("showChapterThumbnails", default: true)
|
||||
static let showChapterThumbnailsOnlyWhenDifferent = Key<Bool>("showChapterThumbnailsOnlyWhenDifferent", default: true)
|
||||
static let expandChapters = Key<Bool>("expandChapters", default: true)
|
||||
static let showRelated = Key<Bool>("showRelated", default: true)
|
||||
static let showInspector = Key<ShowInspectorSetting>("showInspector", default: .onlyLocal)
|
||||
@@ -165,11 +167,11 @@ extension Defaults.Keys {
|
||||
|
||||
// MARK: GROUP - Quality
|
||||
|
||||
static let hd2160pMPVProfile = QualityProfile(id: "hd2160pMPVProfile", backend: .mpv, resolution: .hd2160p60, formats: QualityProfile.Format.allCases)
|
||||
static let hd1080pMPVProfile = QualityProfile(id: "hd1080pMPVProfile", backend: .mpv, resolution: .hd1080p60, formats: QualityProfile.Format.allCases)
|
||||
static let hd720pMPVProfile = QualityProfile(id: "hd720pMPVProfile", backend: .mpv, resolution: .hd720p60, formats: QualityProfile.Format.allCases)
|
||||
static let hd720pAVPlayerProfile = QualityProfile(id: "hd720pAVPlayerProfile", backend: .appleAVPlayer, resolution: .hd720p60, formats: [.hls, .stream])
|
||||
static let sd360pAVPlayerProfile = QualityProfile(id: "sd360pAVPlayerProfile", backend: .appleAVPlayer, resolution: .sd360p30, formats: [.hls, .stream])
|
||||
static let hd2160pMPVProfile = QualityProfile(id: "hd2160pMPVProfile", backend: .mpv, resolution: .hd2160p60, formats: QualityProfile.Format.allCases, order: Array(QualityProfile.Format.allCases.indices))
|
||||
static let hd1080pMPVProfile = QualityProfile(id: "hd1080pMPVProfile", backend: .mpv, resolution: .hd1080p60, formats: QualityProfile.Format.allCases, order: Array(QualityProfile.Format.allCases.indices))
|
||||
static let hd720pMPVProfile = QualityProfile(id: "hd720pMPVProfile", backend: .mpv, resolution: .hd720p60, formats: QualityProfile.Format.allCases, order: Array(QualityProfile.Format.allCases.indices))
|
||||
static let hd720pAVPlayerProfile = QualityProfile(id: "hd720pAVPlayerProfile", backend: .appleAVPlayer, resolution: .hd720p60, formats: [.hls, .stream], order: Array(QualityProfile.Format.allCases.indices))
|
||||
static let sd360pAVPlayerProfile = QualityProfile(id: "sd360pAVPlayerProfile", backend: .appleAVPlayer, resolution: .sd360p30, formats: [.hls, .stream], order: Array(QualityProfile.Format.allCases.indices))
|
||||
|
||||
#if os(iOS)
|
||||
static let qualityProfilesDefault = UIDevice.current.userInterfaceIdiom == .pad ? [
|
||||
@@ -243,6 +245,10 @@ extension Defaults.Keys {
|
||||
|
||||
static let sponsorBlockInstance = Key<String>("sponsorBlockInstance", default: "https://sponsor.ajay.app")
|
||||
static let sponsorBlockCategories = Key<Set<String>>("sponsorBlockCategories", default: Set(SponsorBlockAPI.categories))
|
||||
static let sponsorBlockColors = Key<[String: String]>("sponsorBlockColors", default: SponsorBlockColors.dictionary)
|
||||
static let sponsorBlockShowTimeWithSkipsRemoved = Key<Bool>("sponsorBlockShowTimeWithSkipsRemoved", default: false)
|
||||
static let sponsorBlockShowCategoriesInTimeline = Key<Bool>("sponsorBlockShowCategoriesInTimeline", default: true)
|
||||
static let sponsorBlockShowNoticeAfterSkip = Key<Bool>("sponsorBlockShowNoticeAfterSkip", default: true)
|
||||
|
||||
// MARK: GROUP - Locations
|
||||
|
||||
@@ -580,3 +586,26 @@ enum WidgetListingStyle: String, CaseIterable, Defaults.Serializable {
|
||||
case horizontalCells
|
||||
case list
|
||||
}
|
||||
|
||||
enum SponsorBlockColors: String {
|
||||
case sponsor = "#00D400" // Green
|
||||
case selfpromo = "#FFFF00" // Yellow
|
||||
case interaction = "#CC00FF" // Purple
|
||||
case intro = "#00FFFF" // Cyan
|
||||
case outro = "#0202ED" // Dark Blue
|
||||
case preview = "#008FD6" // Light Blue
|
||||
case filler = "#7300FF" // Violet
|
||||
case music_offtopic = "#FF9900" // Orange
|
||||
|
||||
// Define all cases, can be used to iterate over the colors
|
||||
static let allCases: [SponsorBlockColors] = [.sponsor, .selfpromo, .interaction, .intro, .outro, .preview, .filler, .music_offtopic]
|
||||
|
||||
// Create a dictionary with the category names as keys and colors as values
|
||||
static let dictionary: [String: String] = {
|
||||
var dict = [String: String]()
|
||||
for item in allCases {
|
||||
dict[String(describing: item)] = item.rawValue
|
||||
}
|
||||
return dict
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -81,7 +81,6 @@ struct FavoriteItemView: View {
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
|
||||
.onAppear {
|
||||
if item.section == .history {
|
||||
reloadVisibleWatches()
|
||||
@@ -165,7 +164,7 @@ struct FavoriteItemView: View {
|
||||
.prefix(favoritesModel.limit(item))
|
||||
)
|
||||
let last = watches.last
|
||||
watches.forEach { watch in
|
||||
for watch in watches {
|
||||
player.loadHistoryVideoDetails(watch) {
|
||||
guard let video = player.historyVideo(watch.videoID), itemVisible(.init(video: video)) else { return }
|
||||
visibleWatches.append(watch)
|
||||
|
||||
@@ -4,8 +4,6 @@ import Siesta
|
||||
|
||||
struct OpenURLHandler {
|
||||
static var firstHandle = true
|
||||
static let yatteeProtocol = "yattee://"
|
||||
|
||||
var accounts: AccountsModel { .shared }
|
||||
var navigation: NavigationModel { .shared }
|
||||
var recents: RecentsModel { .shared }
|
||||
|
||||
@@ -312,7 +312,6 @@ struct ControlsOverlay: View {
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
.transaction { t in t.animation = .none }
|
||||
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.primary)
|
||||
.frame(width: 240, height: 40)
|
||||
@@ -374,12 +373,12 @@ struct ControlsOverlay: View {
|
||||
let captions = player.currentVideo?.captions ?? []
|
||||
Picker("Captions", selection: captionsBinding) {
|
||||
if captions.isEmpty {
|
||||
Text("Not available")
|
||||
Text("Not available").tag(Captions?.none)
|
||||
} else {
|
||||
Text("Disabled").tag(Captions?.none)
|
||||
}
|
||||
ForEach(captions) { caption in
|
||||
Text(caption.description).tag(Optional(caption))
|
||||
ForEach(captions) { caption in
|
||||
Text(caption.description).tag(Optional(caption))
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(captions.isEmpty)
|
||||
|
||||
@@ -13,6 +13,18 @@ struct Seek: View {
|
||||
|
||||
@Default(.playerControlsLayout) private var regularPlayerControlsLayout
|
||||
@Default(.fullScreenPlayerControlsLayout) private var fullScreenPlayerControlsLayout
|
||||
@Default(.sponsorBlockColors) private var sponsorBlockColors
|
||||
@Default(.sponsorBlockShowNoticeAfterSkip) private var showNoticeAfterSkip
|
||||
|
||||
private func getColor(for category: String) -> Color {
|
||||
if let hexString = sponsorBlockColors[category], let rgbValue = Int(hexString.dropFirst(), radix: 16) {
|
||||
let r = Double((rgbValue >> 16) & 0xFF) / 255.0
|
||||
let g = Double((rgbValue >> 8) & 0xFF) / 255.0
|
||||
let b = Double(rgbValue & 0xFF) / 255.0
|
||||
return Color(red: r, green: g, blue: b)
|
||||
}
|
||||
return Color("AppRedColor") // Fallback color if no match found
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
@@ -25,6 +37,7 @@ struct Seek: View {
|
||||
#endif
|
||||
}
|
||||
.opacity(visible || YatteeApp.isForPreviews ? 1 : 0)
|
||||
.animation(.easeIn)
|
||||
}
|
||||
|
||||
var content: some View {
|
||||
@@ -51,7 +64,8 @@ struct Seek: View {
|
||||
if let segment = projectedSegment {
|
||||
Text(SponsorBlockAPI.categoryDescription(segment.category) ?? "Sponsor")
|
||||
.font(.system(size: playerControlsLayout.segmentFontSize))
|
||||
.foregroundColor(Color("AppRedColor"))
|
||||
.foregroundColor(getColor(for: segment.category))
|
||||
.padding(.bottom, 3)
|
||||
}
|
||||
} else {
|
||||
#if !os(tvOS)
|
||||
@@ -69,7 +83,16 @@ struct Seek: View {
|
||||
Divider()
|
||||
Text(SponsorBlockAPI.categoryDescription(category) ?? "Sponsor")
|
||||
.font(.system(size: playerControlsLayout.segmentFontSize))
|
||||
.foregroundColor(getColor(for: category))
|
||||
.padding(.bottom, 3)
|
||||
case let .chapterSkip(chapter):
|
||||
Divider()
|
||||
Text(chapter)
|
||||
.font(.system(size: playerControlsLayout.segmentFontSize))
|
||||
.truncationMode(.tail)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(Color("AppRedColor"))
|
||||
.padding(.bottom, 3)
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
@@ -117,6 +140,7 @@ struct Seek: View {
|
||||
var visible: Bool {
|
||||
guard !(model.lastSeekTime.isNil && !model.isSeeking) else { return false }
|
||||
if let type = model.lastSeekType, !type.presentable { return false }
|
||||
if !showNoticeAfterSkip { if case .segmentSkip? = model.lastSeekType { return false }}
|
||||
|
||||
return !controls.presentingControls && !controls.presentingOverlays && model.presentingOSD
|
||||
}
|
||||
|
||||
@@ -51,11 +51,24 @@ struct TimelineView: View {
|
||||
|
||||
@Default(.playerControlsLayout) private var regularPlayerControlsLayout
|
||||
@Default(.fullScreenPlayerControlsLayout) private var fullScreenPlayerControlsLayout
|
||||
@Default(.sponsorBlockColors) private var sponsorBlockColors
|
||||
@Default(.sponsorBlockShowTimeWithSkipsRemoved) private var showTimeWithSkipsRemoved
|
||||
@Default(.sponsorBlockShowCategoriesInTimeline) private var showCategoriesInTimeline
|
||||
|
||||
var playerControlsLayout: PlayerControlsLayout {
|
||||
player.playingFullScreen ? fullScreenPlayerControlsLayout : regularPlayerControlsLayout
|
||||
}
|
||||
|
||||
private func getColor(for category: String) -> Color {
|
||||
if let hexString = sponsorBlockColors[category], let rgbValue = Int(hexString.dropFirst(), radix: 16) {
|
||||
let r = Double((rgbValue >> 16) & 0xFF) / 255.0
|
||||
let g = Double((rgbValue >> 8) & 0xFF) / 255.0
|
||||
let b = Double(rgbValue & 0xFF) / 255.0
|
||||
return Color(red: r, green: g, blue: b)
|
||||
}
|
||||
return Color("AppRedColor") // Fallback color if no match found
|
||||
}
|
||||
|
||||
var chapters: [Chapter] {
|
||||
player.currentVideo?.chapters ?? []
|
||||
}
|
||||
@@ -73,13 +86,15 @@ struct TimelineView: View {
|
||||
Group {
|
||||
VStack(spacing: 3) {
|
||||
if dragging {
|
||||
if let segment = projectedSegment,
|
||||
let description = SponsorBlockAPI.categoryDescription(segment.category)
|
||||
{
|
||||
Text(description)
|
||||
.font(.system(size: playerControlsLayout.segmentFontSize))
|
||||
.fixedSize()
|
||||
.foregroundColor(Color("AppRedColor"))
|
||||
if showCategoriesInTimeline {
|
||||
if let segment = projectedSegment,
|
||||
let description = SponsorBlockAPI.categoryDescription(segment.category)
|
||||
{
|
||||
Text(description)
|
||||
.font(.system(size: playerControlsLayout.segmentFontSize))
|
||||
.fixedSize()
|
||||
.foregroundColor(getColor(for: segment.category))
|
||||
}
|
||||
}
|
||||
if let chapter = projectedChapter {
|
||||
Text(chapter.title)
|
||||
@@ -145,8 +160,10 @@ struct TimelineView: View {
|
||||
.frame(width: (dragging ? projectedValue : current) * oneUnitWidth)
|
||||
.zIndex(1)
|
||||
|
||||
segmentsLayers
|
||||
.zIndex(2)
|
||||
if showCategoriesInTimeline {
|
||||
segmentsLayers
|
||||
.zIndex(2)
|
||||
}
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
|
||||
|
||||
@@ -236,7 +253,7 @@ struct TimelineView: View {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(dragging ? playerTime.durationPlaybackTime : playerTime.withoutSegmentsPlaybackTime)
|
||||
Text(dragging || !showTimeWithSkipsRemoved ? playerTime.durationPlaybackTime : playerTime.withoutSegmentsPlaybackTime)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 3))
|
||||
.frame(minWidth: 35)
|
||||
}
|
||||
@@ -299,7 +316,7 @@ struct TimelineView: View {
|
||||
ForEach(segments, id: \.uuid) { segment in
|
||||
Rectangle()
|
||||
.offset(x: segmentLayerHorizontalOffset(segment))
|
||||
.foregroundColor(Color("AppRedColor"))
|
||||
.foregroundColor(getColor(for: segment.category))
|
||||
.frame(maxHeight: height)
|
||||
.frame(width: segmentLayerWidth(segment))
|
||||
}
|
||||
@@ -314,9 +331,9 @@ struct TimelineView: View {
|
||||
}
|
||||
|
||||
var chaptersLayers: some View {
|
||||
ForEach(chapters) { chapter in
|
||||
ForEach(chapters.filter { $0.start != 0 }) { chapter in
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color.orange)
|
||||
.fill(Color("AppRedColor"))
|
||||
.frame(maxWidth: 2, maxHeight: height)
|
||||
.offset(x: (chapter.start * oneUnitWidth) - 1)
|
||||
}
|
||||
|
||||
@@ -142,7 +142,6 @@ struct PlaybackSettings: View {
|
||||
}
|
||||
.animation(nil, value: player.activeBackend)
|
||||
.frame(alignment: .topLeading)
|
||||
|
||||
.ignoresSafeArea(.all, edges: .bottom)
|
||||
.backport
|
||||
.playbackSettingsPresentationDetents()
|
||||
@@ -235,7 +234,6 @@ struct PlaybackSettings: View {
|
||||
#if os(iOS)
|
||||
.padding(12)
|
||||
.frame(width: 40, height: 40)
|
||||
|
||||
.background(RoundedRectangle(cornerRadius: 4).strokeBorder(Color.accentColor, lineWidth: 1))
|
||||
.contentShape(Rectangle())
|
||||
#endif
|
||||
@@ -261,7 +259,6 @@ struct PlaybackSettings: View {
|
||||
#if os(iOS)
|
||||
.padding(12)
|
||||
.frame(width: 40, height: 40)
|
||||
|
||||
.background(RoundedRectangle(cornerRadius: 4).strokeBorder(Color.accentColor, lineWidth: 1))
|
||||
.contentShape(Rectangle())
|
||||
#endif
|
||||
@@ -433,12 +430,12 @@ struct PlaybackSettings: View {
|
||||
let captions = player.currentVideo?.captions ?? []
|
||||
Picker("Captions".localized(), selection: $player.captions) {
|
||||
if captions.isEmpty {
|
||||
Text("Not available")
|
||||
Text("Not available").tag(Captions?.none)
|
||||
} else {
|
||||
Text("Disabled").tag(Captions?.none)
|
||||
}
|
||||
ForEach(captions) { caption in
|
||||
Text(caption.description).tag(Optional(caption))
|
||||
ForEach(captions) { caption in
|
||||
Text(caption.description).tag(Optional(caption))
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(captions.isEmpty)
|
||||
|
||||
@@ -9,13 +9,18 @@ import SwiftUI
|
||||
var chapterIndex: Int
|
||||
@ObservedObject private var player = PlayerModel.shared
|
||||
|
||||
var showThumbnail: Bool
|
||||
|
||||
var isCurrentChapter: Bool {
|
||||
player.currentChapterIndex == chapterIndex
|
||||
if let currentChapterIndex = player.currentChapterIndex {
|
||||
return currentChapterIndex == chapterIndex
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: {
|
||||
player.backend.seek(to: chapter.start, seekType: .userInteracted)
|
||||
player.backend.seek(to: chapter.start, seekType: .chapterSkip(chapter.title))
|
||||
}) {
|
||||
Group {
|
||||
verticalChapter
|
||||
@@ -27,7 +32,7 @@ import SwiftUI
|
||||
|
||||
var verticalChapter: some View {
|
||||
VStack(spacing: 12) {
|
||||
if !chapter.image.isNil {
|
||||
if !chapter.image.isNil, showThumbnail {
|
||||
smallImage(chapter)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
@@ -40,7 +45,7 @@ import SwiftUI
|
||||
.font(.system(.subheadline).monospacedDigit())
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: !chapter.image.isNil ? Self.thumbnailWidth : nil, alignment: .leading)
|
||||
.frame(maxWidth: !chapter.image.isNil && showThumbnail ? Self.thumbnailWidth : nil, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +57,6 @@ import SwiftUI
|
||||
}
|
||||
.indicator(.activity)
|
||||
.frame(width: Self.thumbnailWidth, height: Self.thumbnailHeight)
|
||||
|
||||
.mask(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
|
||||
@@ -72,7 +76,7 @@ import SwiftUI
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
player.backend.seek(to: chapter.start, seekType: .userInteracted)
|
||||
player.backend.seek(to: chapter.start, seekType: .chapterSkip(chapter.title))
|
||||
} label: {
|
||||
Group {
|
||||
horizontalChapter
|
||||
@@ -126,7 +130,7 @@ struct ChapterView_Preview: PreviewProvider {
|
||||
ChapterViewTVOS(chapter: .init(title: "Chapter", start: 30))
|
||||
.injectFixtureEnvironmentObjects()
|
||||
#else
|
||||
ChapterView(chapter: .init(title: "Chapter", start: 30), chapterIndex: 0)
|
||||
ChapterView(chapter: .init(title: "Chapter", start: 30), chapterIndex: 0, showThumbnail: true)
|
||||
.injectFixtureEnvironmentObjects()
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -5,18 +5,16 @@ import SwiftUI
|
||||
struct ChaptersView: View {
|
||||
@ObservedObject private var player = PlayerModel.shared
|
||||
@Binding var expand: Bool
|
||||
let chaptersHaveImages: Bool
|
||||
let showThumbnails: Bool
|
||||
|
||||
var chapters: [Chapter] {
|
||||
player.videoForDisplay?.chapters ?? []
|
||||
}
|
||||
|
||||
var chaptersHaveImages: Bool {
|
||||
chapters.allSatisfy { $0.image != nil }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if !chapters.isEmpty {
|
||||
if chaptersHaveImages {
|
||||
if chaptersHaveImages, showThumbnails {
|
||||
#if os(tvOS)
|
||||
List {
|
||||
Section {
|
||||
@@ -29,7 +27,22 @@ struct ChaptersView: View {
|
||||
.listStyle(.plain)
|
||||
#else
|
||||
ScrollView(.horizontal) {
|
||||
LazyHStack(spacing: 20) { chapterViews(for: chapters[...]) }.padding(.horizontal, 15)
|
||||
ScrollViewReader { scrollViewProxy in
|
||||
LazyHStack(spacing: 20) {
|
||||
chapterViews(for: chapters[...], scrollViewProxy: scrollViewProxy)
|
||||
}
|
||||
.padding(.horizontal, 15)
|
||||
.onAppear {
|
||||
if let currentChapterIndex = player.currentChapterIndex {
|
||||
scrollViewProxy.scrollTo(currentChapterIndex, anchor: .center)
|
||||
}
|
||||
}
|
||||
.onChange(of: player.currentChapterIndex) { currentChapterIndex in
|
||||
if let index = currentChapterIndex {
|
||||
scrollViewProxy.scrollTo(index, anchor: .center)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
} else if expand {
|
||||
@@ -67,10 +80,11 @@ struct ChaptersView: View {
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
private func chapterViews(for chaptersToShow: ArraySlice<Chapter>, opacity: Double = 1.0, clickable: Bool = true) -> some View {
|
||||
private func chapterViews(for chaptersToShow: ArraySlice<Chapter>, opacity: Double = 1.0, clickable: Bool = true, scrollViewProxy _: ScrollViewProxy? = nil) -> some View {
|
||||
ForEach(Array(chaptersToShow.indices), id: \.self) { index in
|
||||
let chapter = chaptersToShow[index]
|
||||
ChapterView(chapter: chapter, chapterIndex: index)
|
||||
ChapterView(chapter: chapter, chapterIndex: index, showThumbnail: showThumbnails)
|
||||
.id(index)
|
||||
.opacity(index == 0 ? 1.0 : opacity)
|
||||
.allowsHitTesting(clickable)
|
||||
}
|
||||
@@ -80,7 +94,7 @@ struct ChaptersView: View {
|
||||
|
||||
struct ChaptersView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ChaptersView(expand: .constant(false))
|
||||
ChaptersView(expand: .constant(false), chaptersHaveImages: false, showThumbnails: true)
|
||||
.injectFixtureEnvironmentObjects()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ struct PlayerQueueView: View {
|
||||
@ObservedObject private var player = PlayerModel.shared
|
||||
|
||||
@Default(.saveHistory) private var saveHistory
|
||||
@Default(.showRelated) private var showRelated
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
@@ -19,7 +20,7 @@ struct PlayerQueueView: View {
|
||||
autoplaying
|
||||
}
|
||||
playingNext
|
||||
if sidebarQueue {
|
||||
if sidebarQueue, showRelated {
|
||||
related
|
||||
}
|
||||
}
|
||||
@@ -90,10 +91,9 @@ struct PlayerQueueView: View {
|
||||
}
|
||||
|
||||
var queueHeader: some View {
|
||||
Text("Queue".localized())
|
||||
Text(sidebarQueue ? "Queue".localized() : "")
|
||||
#if !os(macOS)
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ struct VideoDescription: View {
|
||||
var formattedString: AttributedString {
|
||||
var attrString = AttributedString(description)
|
||||
let words = description.unicodeScalars.split(whereSeparator: separators.contains).map(String.init)
|
||||
words.forEach { word in
|
||||
for word in words {
|
||||
if word.hasPrefix("https://") || word.hasPrefix("http://"), let url = URL(string: String(word)) {
|
||||
if let range = attrString.range(of: word) {
|
||||
attrString[range].link = url
|
||||
|
||||
@@ -186,6 +186,8 @@ struct VideoDetails: View {
|
||||
@Default(.playerSidebar) private var playerSidebar
|
||||
@Default(.showInspector) private var showInspector
|
||||
@Default(.showChapters) private var showChapters
|
||||
@Default(.showChapterThumbnails) private var showChapterThumbnails
|
||||
@Default(.showChapterThumbnailsOnlyWhenDifferent) private var showChapterThumbnailsOnlyWhenDifferent
|
||||
@Default(.showRelated) private var showRelated
|
||||
#if !os(tvOS)
|
||||
@Default(.showScrollToTopInComments) private var showScrollToTopInComments
|
||||
@@ -287,6 +289,63 @@ struct VideoDetails: View {
|
||||
}
|
||||
}
|
||||
|
||||
func infoView(video: Video) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if !player.videoBeingOpened.isNil && (video.description.isNil || video.description!.isEmpty) {
|
||||
VStack {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
} else if let description = video.description, !description.isEmpty {
|
||||
Section(header: descriptionHeader) {
|
||||
VideoDescription(video: video, detailsSize: detailsSize, expand: $descriptionExpanded)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
} else if !video.isLocal {
|
||||
Text("No description")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
if player.videoBeingOpened.isNil {
|
||||
if showChapters,
|
||||
!video.isLocal,
|
||||
!video.chapters.isEmpty
|
||||
{
|
||||
Section(header: chaptersHeader) {
|
||||
ChaptersView(expand: $chaptersExpanded, chaptersHaveImages: chaptersHaveImages, showThumbnails: showThumbnails)
|
||||
}
|
||||
}
|
||||
|
||||
if showInspector == .always || video.isLocal {
|
||||
InspectorView(video: player.videoForDisplay)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
if showRelated,
|
||||
!sidebarQueue,
|
||||
!(player.videoForDisplay?.related.isEmpty ?? true)
|
||||
{
|
||||
RelatedView()
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 20)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if !pageAvailable(page) {
|
||||
page = .info
|
||||
}
|
||||
}
|
||||
.transition(.opacity)
|
||||
.animation(nil, value: player.currentItem)
|
||||
#if os(iOS)
|
||||
.frame(maxWidth: YatteeApp.isForPreviews ? .infinity : maxWidth)
|
||||
#endif
|
||||
}
|
||||
|
||||
var pageView: some View {
|
||||
ScrollView(.vertical) {
|
||||
LazyVStack {
|
||||
@@ -296,69 +355,12 @@ struct VideoDetails: View {
|
||||
|
||||
switch page {
|
||||
case .info:
|
||||
Group {
|
||||
if let video {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if !player.videoBeingOpened.isNil && (video.description.isNil || video.description!.isEmpty) {
|
||||
VStack {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
} else if let description = video.description, !description.isEmpty {
|
||||
Section(header: descriptionHeader) {
|
||||
VideoDescription(video: video, detailsSize: detailsSize, expand: $descriptionExpanded)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
} else if !video.isLocal {
|
||||
Text("No description")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
if player.videoBeingOpened.isNil {
|
||||
if showChapters,
|
||||
!video.isLocal,
|
||||
!video.chapters.isEmpty
|
||||
{
|
||||
Section(header: chaptersHeader) {
|
||||
ChaptersView(expand: $chaptersExpanded)
|
||||
}
|
||||
}
|
||||
|
||||
if showInspector == .always || video.isLocal {
|
||||
InspectorView(video: player.videoForDisplay)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
if showRelated,
|
||||
!sidebarQueue,
|
||||
!(player.videoForDisplay?.related.isEmpty ?? true)
|
||||
{
|
||||
RelatedView()
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 20)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let video = self.video {
|
||||
infoView(video: video)
|
||||
}
|
||||
.onAppear {
|
||||
if video != nil, !pageAvailable(page) {
|
||||
page = .info
|
||||
}
|
||||
}
|
||||
.transition(.opacity)
|
||||
.animation(nil, value: player.currentItem)
|
||||
#if os(iOS)
|
||||
.frame(maxWidth: YatteeApp.isForPreviews ? .infinity : maxWidth)
|
||||
#endif
|
||||
|
||||
case .queue:
|
||||
PlayerQueueView(sidebarQueue: false)
|
||||
.padding(.horizontal)
|
||||
|
||||
case .comments:
|
||||
CommentsView()
|
||||
.onAppear {
|
||||
@@ -447,9 +449,27 @@ struct VideoDetails: View {
|
||||
player.videoForDisplay?.chapters.allSatisfy { $0.image != nil } ?? false
|
||||
}
|
||||
|
||||
var chapterImagesTheSame: Bool {
|
||||
guard let firstChapterURL = player.videoForDisplay?.chapters.first?.image else {
|
||||
return false
|
||||
}
|
||||
|
||||
return player.videoForDisplay?.chapters.allSatisfy { $0.image == firstChapterURL } ?? false
|
||||
}
|
||||
|
||||
var showThumbnails: Bool {
|
||||
if !chaptersHaveImages || !showChapterThumbnails {
|
||||
return false
|
||||
}
|
||||
if showChapterThumbnailsOnlyWhenDifferent {
|
||||
return !chapterImagesTheSame
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
var chaptersHeader: some View {
|
||||
Group {
|
||||
if !chaptersHaveImages {
|
||||
if !chaptersHaveImages || !showThumbnails {
|
||||
#if canImport(UIKit)
|
||||
Button(action: {
|
||||
chaptersExpanded.toggle()
|
||||
|
||||
@@ -263,7 +263,6 @@ struct VideoPlayerView: View {
|
||||
#else
|
||||
GeometryReader { geometry in
|
||||
player.playerBackendView
|
||||
|
||||
.modifier(
|
||||
VideoPlayerSizeModifier(
|
||||
geometry: geometry,
|
||||
|
||||
@@ -198,7 +198,7 @@ struct PlaylistFormView: View {
|
||||
}
|
||||
}
|
||||
#else
|
||||
Button(self.visibility.name) {
|
||||
Button(visibility.name) {
|
||||
self.visibility = self.visibility.next()
|
||||
}
|
||||
.contextMenu {
|
||||
|
||||
@@ -73,7 +73,7 @@ struct AdvancedSettings: View {
|
||||
.frame(minWidth: 140, alignment: .leading)
|
||||
TextField("cache-secs", text: $mpvCacheSecs)
|
||||
#if !os(macOS)
|
||||
.keyboardType(.URL)
|
||||
.keyboardType(.numberPad)
|
||||
#endif
|
||||
}
|
||||
.multilineTextAlignment(.trailing)
|
||||
@@ -83,7 +83,7 @@ struct AdvancedSettings: View {
|
||||
.frame(minWidth: 140, alignment: .leading)
|
||||
TextField("cache-pause-wait", text: $mpvCachePauseWait)
|
||||
#if !os(macOS)
|
||||
.keyboardType(.URL)
|
||||
.keyboardType(.numberPad)
|
||||
#endif
|
||||
}
|
||||
.multilineTextAlignment(.trailing)
|
||||
|
||||
@@ -61,23 +61,23 @@ struct HistorySettings: View {
|
||||
Toggle("Save history of played videos", isOn: $saveHistory)
|
||||
Toggle("Show recents in sidebar", isOn: $showRecents)
|
||||
#if os(macOS)
|
||||
HStack {
|
||||
Toggle("Limit recents shown", isOn: $limitRecents)
|
||||
.frame(minWidth: 140, alignment: .leading)
|
||||
.disabled(!showRecents)
|
||||
Spacer()
|
||||
counterButtons(for: $limitRecentsAmount)
|
||||
.disabled(!limitRecents)
|
||||
}
|
||||
HStack {
|
||||
Toggle("Limit recents shown", isOn: $limitRecents)
|
||||
.frame(minWidth: 140, alignment: .leading)
|
||||
.disabled(!showRecents)
|
||||
Spacer()
|
||||
counterButtons(for: $limitRecentsAmount)
|
||||
.disabled(!limitRecents)
|
||||
}
|
||||
#else
|
||||
Toggle("Limit recents shown", isOn: $limitRecents)
|
||||
.disabled(!showRecents)
|
||||
HStack {
|
||||
Text("Recents shown")
|
||||
Spacer()
|
||||
counterButtons(for: $limitRecentsAmount)
|
||||
.disabled(!limitRecents)
|
||||
}
|
||||
Toggle("Limit recents shown", isOn: $limitRecents)
|
||||
.disabled(!showRecents)
|
||||
HStack {
|
||||
Text("Recents shown")
|
||||
Spacer()
|
||||
counterButtons(for: $limitRecentsAmount)
|
||||
.disabled(!limitRecents)
|
||||
}
|
||||
#endif
|
||||
Toggle("Show progress of watching on thumbnails", isOn: $showWatchingProgress)
|
||||
.disabled(!saveHistory)
|
||||
@@ -196,7 +196,7 @@ struct HistorySettings: View {
|
||||
private func counterButtons(for _value: Binding<Int>) -> some View {
|
||||
var value: Binding<Int> {
|
||||
Binding(
|
||||
get: { return _value.wrappedValue },
|
||||
get: { _value.wrappedValue },
|
||||
set: {
|
||||
if $0 < 1 {
|
||||
_value.wrappedValue = 1
|
||||
|
||||
@@ -68,7 +68,7 @@ struct HomeSettings: View {
|
||||
}
|
||||
.help("Add to Favorites")
|
||||
#if !os(tvOS)
|
||||
.buttonStyle(.borderless)
|
||||
.buttonStyle(.borderless)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ImportSettings: View {
|
||||
|
||||
@@ -9,9 +9,23 @@ struct MultiselectRow: View {
|
||||
@State private var toggleChecked = false
|
||||
|
||||
var body: some View {
|
||||
#if os(macOS)
|
||||
#if os(tvOS)
|
||||
Button(action: { action(!selected) }) {
|
||||
HStack {
|
||||
Text(self.title)
|
||||
Spacer()
|
||||
if selected {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.disabled(disabled)
|
||||
#else
|
||||
Toggle(title, isOn: $toggleChecked)
|
||||
#if os(macOS)
|
||||
.toggleStyle(.checkbox)
|
||||
#endif
|
||||
.onAppear {
|
||||
guard !disabled else { return }
|
||||
toggleChecked = selected
|
||||
@@ -19,24 +33,6 @@ struct MultiselectRow: View {
|
||||
.onChange(of: toggleChecked) { new in
|
||||
action(new)
|
||||
}
|
||||
#else
|
||||
Button(action: { action(!selected) }) {
|
||||
HStack {
|
||||
Text(self.title)
|
||||
Spacer()
|
||||
if selected {
|
||||
Image(systemName: "checkmark")
|
||||
#if os(iOS)
|
||||
.foregroundColor(.accentColor)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.disabled(disabled)
|
||||
#if !os(tvOS)
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,7 +261,6 @@ struct PlayerControlsSettings: View {
|
||||
TextField("Duration", text: value)
|
||||
.frame(width: textFieldWidth, alignment: .trailing)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
.labelsHidden()
|
||||
#if !os(macOS)
|
||||
.keyboardType(.numberPad)
|
||||
|
||||
@@ -32,6 +32,8 @@ struct PlayerSettings: View {
|
||||
|
||||
@Default(.showInspector) private var showInspector
|
||||
@Default(.showChapters) private var showChapters
|
||||
@Default(.showChapterThumbnails) private var showThumbnails
|
||||
@Default(.showChapterThumbnailsOnlyWhenDifferent) private var showThumbnailsOnlyWhenDifferent
|
||||
@Default(.expandChapters) private var expandChapters
|
||||
@Default(.showRelated) private var showRelated
|
||||
|
||||
@@ -80,8 +82,6 @@ struct PlayerSettings: View {
|
||||
Section(header: SettingsHeader(text: "Info".localized())) {
|
||||
expandVideoDescriptionToggle
|
||||
collapsedLineDescriptionStepper
|
||||
showChaptersToggle
|
||||
expandChaptersToggle
|
||||
showRelatedToggle
|
||||
#if os(macOS)
|
||||
HStack {
|
||||
@@ -93,6 +93,13 @@ struct PlayerSettings: View {
|
||||
inspectorVisibilityPicker
|
||||
#endif
|
||||
}
|
||||
|
||||
Section(header: SettingsHeader(text: "Chapters".localized())) {
|
||||
showChaptersToggle
|
||||
showThumbnailsToggle
|
||||
showThumbnailsWhenDifferentToggle
|
||||
expandChaptersToggle
|
||||
}
|
||||
#endif
|
||||
|
||||
let interface = Section(header: SettingsHeader(text: "Interface".localized())) {
|
||||
@@ -284,7 +291,19 @@ struct PlayerSettings: View {
|
||||
}
|
||||
|
||||
private var showChaptersToggle: some View {
|
||||
Toggle("Chapters (if available)", isOn: $showChapters)
|
||||
Toggle("Show chapters", isOn: $showChapters)
|
||||
}
|
||||
|
||||
private var showThumbnailsToggle: some View {
|
||||
Toggle("Show thumbnails", isOn: $showThumbnails)
|
||||
.disabled(!showChapters)
|
||||
.foregroundColor(showChapters ? .primary : .secondary)
|
||||
}
|
||||
|
||||
private var showThumbnailsWhenDifferentToggle: some View {
|
||||
Toggle("Show thumbnails only when unique", isOn: $showThumbnailsOnlyWhenDifferent)
|
||||
.disabled(!showChapters || !showThumbnails)
|
||||
.foregroundColor(showChapters && showThumbnails ? .primary : .secondary)
|
||||
}
|
||||
|
||||
private var expandChaptersToggle: some View {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct FormatState: Equatable {
|
||||
let format: QualityProfile.Format
|
||||
var isActive: Bool
|
||||
}
|
||||
|
||||
struct QualityProfileForm: View {
|
||||
@Binding var qualityProfileID: QualityProfile.ID?
|
||||
|
||||
@@ -15,6 +20,7 @@ struct QualityProfileForm: View {
|
||||
@State private var backend = PlayerBackendType.mpv
|
||||
@State private var resolution = ResolutionSetting.hd1080p60
|
||||
@State private var formats = [QualityProfile.Format]()
|
||||
@State private var orderedFormats: [FormatState] = []
|
||||
|
||||
@Default(.qualityProfiles) private var qualityProfiles
|
||||
|
||||
@@ -26,6 +32,7 @@ struct QualityProfileForm: View {
|
||||
return nil
|
||||
}
|
||||
|
||||
// swiftlint:disable trailing_closure
|
||||
var body: some View {
|
||||
VStack {
|
||||
Group {
|
||||
@@ -40,8 +47,10 @@ struct QualityProfileForm: View {
|
||||
#endif
|
||||
|
||||
.onAppear(perform: initializeForm)
|
||||
.onChange(of: backend, perform: backendChanged)
|
||||
.onChange(of: formats) { _ in validate() }
|
||||
.onChange(of: backend, perform: { _ in backendChanged(self.backend); updateActiveFormats(); validate() })
|
||||
.onChange(of: name, perform: { _ in validate() })
|
||||
.onChange(of: resolution, perform: { _ in validate() })
|
||||
.onChange(of: orderedFormats, perform: { _ in validate() })
|
||||
#if os(iOS)
|
||||
.padding(.vertical)
|
||||
#elseif os(tvOS)
|
||||
@@ -53,6 +62,8 @@ struct QualityProfileForm: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
// swiftlint:enable trailing_closure
|
||||
|
||||
var header: some View {
|
||||
HStack {
|
||||
Text(editing ? "Edit Quality Profile" : "Add Quality Profile")
|
||||
@@ -124,9 +135,20 @@ struct QualityProfileForm: View {
|
||||
}
|
||||
|
||||
var formatsFooter: some View {
|
||||
Text("Formats will be selected in order as listed.\nHLS is an adaptive format (resolution setting does not apply).")
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
VStack(alignment: .leading) {
|
||||
Text("Formats can be reordered and will be selected in this order.")
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text("**Note:** HLS is an adaptive format where specific resolution settings don't apply.")
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.top)
|
||||
Text("Yattee attempts to match the quality that is closest to the set resolution, but exact results cannot be guaranteed.")
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.top, 0.1)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
@ViewBuilder var qualityPicker: some View {
|
||||
@@ -199,17 +221,25 @@ struct QualityProfileForm: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
var filteredFormatList: some View {
|
||||
ForEach(Array(orderedFormats.enumerated()), id: \.element.format) { idx, element in
|
||||
let format = element.format
|
||||
MultiselectRow(
|
||||
title: format.description,
|
||||
selected: element.isActive
|
||||
) { value in
|
||||
orderedFormats[idx].isActive = value
|
||||
}
|
||||
}
|
||||
.onMove { source, destination in
|
||||
orderedFormats.move(fromOffsets: source, toOffset: destination)
|
||||
validate()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder var formatsPicker: some View {
|
||||
#if os(macOS)
|
||||
let list = ForEach(QualityProfile.Format.allCases, id: \.self) { format in
|
||||
MultiselectRow(
|
||||
title: format.description,
|
||||
selected: isFormatSelected(format),
|
||||
disabled: isFormatDisabled(format)
|
||||
) { value in
|
||||
toggleFormat(format, value: value)
|
||||
}
|
||||
}
|
||||
let list = filteredFormatList
|
||||
|
||||
Group {
|
||||
if #available(macOS 12.0, *) {
|
||||
@@ -222,28 +252,19 @@ struct QualityProfileForm: View {
|
||||
}
|
||||
Spacer()
|
||||
#else
|
||||
ForEach(QualityProfile.Format.allCases, id: \.self) { format in
|
||||
MultiselectRow(
|
||||
title: format.description,
|
||||
selected: isFormatSelected(format),
|
||||
disabled: isFormatDisabled(format)
|
||||
) { value in
|
||||
toggleFormat(format, value: value)
|
||||
}
|
||||
}
|
||||
filteredFormatList
|
||||
#endif
|
||||
}
|
||||
|
||||
func isFormatSelected(_ format: QualityProfile.Format) -> Bool {
|
||||
(initialized || qualityProfile.isNil ? formats : qualityProfile.formats).contains(format)
|
||||
return orderedFormats.first { $0.format == format }?.isActive ?? false
|
||||
}
|
||||
|
||||
func toggleFormat(_ format: QualityProfile.Format, value: Bool) {
|
||||
if let index = formats.firstIndex(where: { $0 == format }), !value {
|
||||
formats.remove(at: index)
|
||||
} else if value {
|
||||
formats.append(format)
|
||||
if let index = orderedFormats.firstIndex(where: { $0.format == format }) {
|
||||
orderedFormats[index].isActive = value
|
||||
}
|
||||
validate() // Check validity after a toggle operation
|
||||
}
|
||||
|
||||
var footer: some View {
|
||||
@@ -274,34 +295,52 @@ struct QualityProfileForm: View {
|
||||
return !avPlayerFormats.contains(format)
|
||||
}
|
||||
|
||||
func updateActiveFormats() {
|
||||
for (index, format) in orderedFormats.enumerated() where isFormatDisabled(format.format) {
|
||||
orderedFormats[index].isActive = false
|
||||
}
|
||||
}
|
||||
|
||||
func isResolutionDisabled(_ resolution: ResolutionSetting) -> Bool {
|
||||
guard backend == .appleAVPlayer else { return false }
|
||||
|
||||
return resolution.value > .hd720p30
|
||||
return resolution.value > .hd1080p60
|
||||
}
|
||||
|
||||
func initializeForm() {
|
||||
guard editing else {
|
||||
validate()
|
||||
return
|
||||
if editing {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
self.name = qualityProfile.name ?? ""
|
||||
self.backend = qualityProfile.backend
|
||||
self.resolution = qualityProfile.resolution
|
||||
self.orderedFormats = qualityProfile.order.map { order in
|
||||
let format = QualityProfile.Format.allCases[order]
|
||||
let isActive = qualityProfile.formats.contains(format)
|
||||
return FormatState(format: format, isActive: isActive)
|
||||
}
|
||||
self.initialized = true
|
||||
}
|
||||
} else {
|
||||
name = ""
|
||||
backend = .mpv
|
||||
resolution = .hd720p60
|
||||
orderedFormats = QualityProfile.Format.allCases.map {
|
||||
FormatState(format: $0, isActive: true)
|
||||
}
|
||||
initialized = true
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
self.name = qualityProfile.name ?? ""
|
||||
self.backend = qualityProfile.backend
|
||||
self.resolution = qualityProfile.resolution
|
||||
self.formats = .init(qualityProfile.formats)
|
||||
self.initialized = true
|
||||
}
|
||||
|
||||
validate()
|
||||
}
|
||||
|
||||
func backendChanged(_: PlayerBackendType) {
|
||||
formats.filter { isFormatDisabled($0) }.forEach { format in
|
||||
if let index = formats.firstIndex(where: { $0 == format }) {
|
||||
formats.remove(at: index)
|
||||
}
|
||||
let defaultFormats = QualityProfile.Format.allCases.map {
|
||||
FormatState(format: $0, isActive: true)
|
||||
}
|
||||
|
||||
if backend == .appleAVPlayer {
|
||||
orderedFormats = orderedFormats.filter { !isFormatDisabled($0.format) }
|
||||
} else {
|
||||
orderedFormats = defaultFormats
|
||||
}
|
||||
|
||||
if isResolutionDisabled(resolution),
|
||||
@@ -312,20 +351,33 @@ struct QualityProfileForm: View {
|
||||
}
|
||||
|
||||
func validate() {
|
||||
valid = !formats.isEmpty
|
||||
if !initialized {
|
||||
valid = false
|
||||
} else if editing {
|
||||
let savedOrderFormats = qualityProfile.order.map { order in
|
||||
let format = QualityProfile.Format.allCases[order]
|
||||
let isActive = qualityProfile.formats.contains(format)
|
||||
return FormatState(format: format, isActive: isActive)
|
||||
}
|
||||
valid = name != qualityProfile.name
|
||||
|| backend != qualityProfile.backend
|
||||
|| resolution != qualityProfile.resolution
|
||||
|| orderedFormats != savedOrderFormats
|
||||
} else { valid = true }
|
||||
}
|
||||
|
||||
func submitForm() {
|
||||
guard valid else { return }
|
||||
|
||||
formats = formats.unique()
|
||||
let activeFormats = orderedFormats.filter { $0.isActive }.map { $0.format }
|
||||
|
||||
let formProfile = QualityProfile(
|
||||
id: qualityProfile?.id ?? UUID().uuidString,
|
||||
name: name,
|
||||
backend: backend,
|
||||
resolution: resolution,
|
||||
formats: Array(formats)
|
||||
formats: activeFormats,
|
||||
order: orderedFormats.map { QualityProfile.Format.allCases.firstIndex(of: $0.format)! }
|
||||
)
|
||||
|
||||
if editing {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
static let matrixURL = URL(string: "https://tinyurl.com/matrix-yattee")!
|
||||
static let discordURL = URL(string: "https://yattee.stream/discord")!
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
struct SponsorBlockSettings: View {
|
||||
@ObservedObject private var settings = SettingsModel.shared
|
||||
|
||||
@Default(.sponsorBlockInstance) private var sponsorBlockInstance
|
||||
@Default(.sponsorBlockCategories) private var sponsorBlockCategories
|
||||
@Default(.sponsorBlockColors) private var sponsorBlockColors
|
||||
@Default(.sponsorBlockShowTimeWithSkipsRemoved) private var showTimeWithSkipsRemoved
|
||||
@Default(.sponsorBlockShowCategoriesInTimeline) private var showCategoriesInTimeline
|
||||
@Default(.sponsorBlockShowNoticeAfterSkip) private var showNoticeAfterSkip
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(macOS)
|
||||
sections
|
||||
|
||||
Spacer()
|
||||
#else
|
||||
List {
|
||||
@@ -35,41 +43,75 @@ struct SponsorBlockSettings: View {
|
||||
.labelsHidden()
|
||||
#if !os(macOS)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
.keyboardType(.URL)
|
||||
#endif
|
||||
}
|
||||
|
||||
Section(header: SettingsHeader(text: "Categories to Skip".localized()), footer: categoriesDetails) {
|
||||
#if os(macOS)
|
||||
let list = ForEach(SponsorBlockAPI.categories, id: \.self) { category in
|
||||
MultiselectRow(
|
||||
title: SponsorBlockAPI.categoryDescription(category) ?? "Unknown",
|
||||
selected: sponsorBlockCategories.contains(category)
|
||||
) { value in
|
||||
toggleCategory(category, value: value)
|
||||
}
|
||||
}
|
||||
Section(header: Text("Playback")) {
|
||||
Toggle("Categories in timeline", isOn: $showCategoriesInTimeline)
|
||||
Toggle("Post-skip notice", isOn: $showNoticeAfterSkip)
|
||||
Toggle("Adjusted total time", isOn: $showTimeWithSkipsRemoved)
|
||||
}
|
||||
|
||||
Group {
|
||||
if #available(macOS 12.0, *) {
|
||||
list
|
||||
.listStyle(.inset(alternatesRowBackgrounds: true))
|
||||
} else {
|
||||
list
|
||||
.listStyle(.inset)
|
||||
}
|
||||
Section(header: SettingsHeader(text: "Categories to Skip".localized())) {
|
||||
categoryRows
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
colorSection
|
||||
|
||||
Button {
|
||||
settings.presentAlert(
|
||||
Alert(
|
||||
title: Text("Restore Default Colors?"),
|
||||
message: Text("This action will reset all custom colors back to their original defaults. " +
|
||||
"Any custom color changes you've made will be lost."),
|
||||
primaryButton: .destructive(Text("Restore")) {
|
||||
resetColors()
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
)
|
||||
} label: {
|
||||
Text("Restore Default Colors …")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
#endif
|
||||
|
||||
Section(footer: categoriesDetails) {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private var colorSection: some View {
|
||||
Section(header: SettingsHeader(text: "Colors for Categories")) {
|
||||
ForEach(SponsorBlockAPI.categories, id: \.self) { category in
|
||||
LazyVStack(alignment: .leading) {
|
||||
ColorPicker(
|
||||
SponsorBlockAPI.categoryDescription(category) ?? "Unknown",
|
||||
selection: Binding(
|
||||
get: { getColor(for: category) },
|
||||
set: { setColor($0, for: category) }
|
||||
)
|
||||
)
|
||||
}
|
||||
Spacer()
|
||||
#else
|
||||
ForEach(SponsorBlockAPI.categories, id: \.self) { category in
|
||||
MultiselectRow(
|
||||
title: SponsorBlockAPI.categoryDescription(category) ?? "Unknown",
|
||||
selected: sponsorBlockCategories.contains(category)
|
||||
) { value in
|
||||
toggleCategory(category, value: value)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private var categoryRows: some View {
|
||||
ForEach(SponsorBlockAPI.categories, id: \.self) { category in
|
||||
LazyVStack(alignment: .leading) {
|
||||
MultiselectRow(
|
||||
title: SponsorBlockAPI.categoryDescription(category) ?? "Unknown",
|
||||
selected: sponsorBlockCategories.contains(category)
|
||||
) { value in
|
||||
toggleCategory(category, value: value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,17 +121,17 @@ struct SponsorBlockSettings: View {
|
||||
ForEach(SponsorBlockAPI.categories, id: \.self) { category in
|
||||
Text(SponsorBlockAPI.categoryDescription(category) ?? "Category")
|
||||
.fontWeight(.bold)
|
||||
.padding(.bottom, 0.5)
|
||||
#if os(tvOS)
|
||||
.focusable()
|
||||
#endif
|
||||
|
||||
Text(SponsorBlockAPI.categoryDetails(category) ?? "Details")
|
||||
.padding(.bottom, 3)
|
||||
.padding(.bottom, 10)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.top, 3)
|
||||
}
|
||||
|
||||
func toggleCategory(_ category: String, value: Bool) {
|
||||
@@ -99,6 +141,42 @@ struct SponsorBlockSettings: View {
|
||||
sponsorBlockCategories.insert(category)
|
||||
}
|
||||
}
|
||||
|
||||
private func getColor(for category: String) -> Color {
|
||||
if let hexString = sponsorBlockColors[category], let rgbValue = Int(hexString.dropFirst(), radix: 16) {
|
||||
let r = Double((rgbValue >> 16) & 0xFF) / 255.0
|
||||
let g = Double((rgbValue >> 8) & 0xFF) / 255.0
|
||||
let b = Double(rgbValue & 0xFF) / 255.0
|
||||
return Color(red: r, green: g, blue: b)
|
||||
}
|
||||
return Color("AppRedColor") // Fallback color if no match found
|
||||
}
|
||||
|
||||
#if canImport(UIKit)
|
||||
private func setColor(_ color: Color, for category: String) {
|
||||
let uiColor = UIColor(color)
|
||||
|
||||
// swiftlint:disable no_cgfloat
|
||||
var red: CGFloat = 0
|
||||
var green: CGFloat = 0
|
||||
var blue: CGFloat = 0
|
||||
var alpha: CGFloat = 0
|
||||
// swiftlint:enable no_cgfloat
|
||||
|
||||
uiColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
|
||||
|
||||
let r = Int(red * 255.0)
|
||||
let g = Int(green * 255.0)
|
||||
let b = Int(blue * 255.0)
|
||||
|
||||
let rgbValue = (r << 16) | (g << 8) | b
|
||||
sponsorBlockColors[category] = String(format: "#%06x", rgbValue)
|
||||
}
|
||||
#endif
|
||||
|
||||
private func resetColors() {
|
||||
sponsorBlockColors = SponsorBlockColors.dictionary
|
||||
}
|
||||
}
|
||||
|
||||
struct SponsorBlockSettings_Previews: PreviewProvider {
|
||||
|
||||
3
Shared/Strings.swift
Normal file
3
Shared/Strings.swift
Normal file
@@ -0,0 +1,3 @@
|
||||
enum Strings {
|
||||
static let yatteeProtocol = "yattee://"
|
||||
}
|
||||
@@ -176,7 +176,7 @@ struct URLParser {
|
||||
private func removePrefixes(_ value: String, _ prefixes: [String]) -> String {
|
||||
var value = value
|
||||
|
||||
prefixes.forEach { prefix in
|
||||
for prefix in prefixes {
|
||||
if value.hasPrefix(prefix) {
|
||||
value.removeFirst(prefix.count)
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ struct ShareButton<LabelView: View>: View {
|
||||
|
||||
private var youtubeActions: some View {
|
||||
Group {
|
||||
if let url = accounts.api.shareURL(contentItem, frontendHost: "www.youtube.com") {
|
||||
if let url = accounts.api.shareURL(contentItem, frontendURLString: "https://www.youtube.com") {
|
||||
Button(labelForShareURL("YouTube")) {
|
||||
shareAction(url)
|
||||
}
|
||||
@@ -87,7 +87,7 @@ struct ShareButton<LabelView: View>: View {
|
||||
shareAction(
|
||||
accounts.api.shareURL(
|
||||
contentItem,
|
||||
frontendHost: "www.youtube.com",
|
||||
frontendURLString: "https://www.youtube.com",
|
||||
time: player.backend.currentTime
|
||||
)!
|
||||
)
|
||||
|
||||
@@ -153,7 +153,7 @@ struct YatteeApp: App {
|
||||
#if DEBUG
|
||||
SiestaLog.Category.enabled = .common
|
||||
#endif
|
||||
SDImageCodersManager.shared.addCoder(SDImageWebPCoder.shared)
|
||||
SDImageCodersManager.shared.addCoder(SDImageAWebPCoder.shared)
|
||||
SDWebImageManager.defaultImageCache = PINCache(name: "stream.yattee.app")
|
||||
|
||||
if !Defaults[.lastAccountIsPublic] {
|
||||
@@ -204,6 +204,7 @@ struct YatteeApp: App {
|
||||
URLBookmarkModel.shared.refreshAll()
|
||||
|
||||
migrateHomeHistoryItems()
|
||||
migrateQualityProfiles()
|
||||
}
|
||||
|
||||
func migrateHomeHistoryItems() {
|
||||
@@ -221,6 +222,16 @@ struct YatteeApp: App {
|
||||
Defaults[.homeHistoryItems] = -1
|
||||
}
|
||||
|
||||
@Default(.qualityProfiles) private var qualityProfilesData
|
||||
|
||||
func migrateQualityProfiles() {
|
||||
for profile in qualityProfilesData where profile.order.isEmpty {
|
||||
var updatedProfile = profile
|
||||
updatedProfile.order = Array(QualityProfile.Format.allCases.indices)
|
||||
QualityProfilesModel.shared.update(profile, updatedProfile)
|
||||
}
|
||||
}
|
||||
|
||||
var navigationStyle: NavigationStyle {
|
||||
#if os(iOS)
|
||||
return horizontalSizeClass == .compact ? .tab : .sidebar
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
"Enter fullscreen in landscape" = "Enter fullscreen in landscape";
|
||||
"Error" = "Error";
|
||||
"Error when accessing playlist" = "Error when accessing playlist";
|
||||
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video).\n";
|
||||
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video).";
|
||||
"Favorites" = "Favorites";
|
||||
"Filter" = "Filter";
|
||||
"Filter: active" = "Filter: active";
|
||||
|
||||
@@ -483,3 +483,7 @@
|
||||
"Not Playing" = "پخش نمیشود";
|
||||
"Video Details" = "جزییات ویدیو";
|
||||
"Live Streams" = "پخش زنده";
|
||||
"Backend" = "Backend";
|
||||
"Bugs and great feature ideas can be sent to the GitHub issues tracker. " = "ایرادها و پیشنهادی خوب برای امکانات را میتوانید به GitHub issues tracker بفرستید. ";
|
||||
"Copy %@ link" = "پیوند %@ را کپی کنید";
|
||||
"Copy %@ link with time" = "پیوند %@ با مهرزمان کپی کنید";
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
"Add Account" = "Adicionar Conta";
|
||||
"Add Account..." = "Adicionar Conta…";
|
||||
"Add Location" = "Adicionar Localização";
|
||||
"Add Location..." = "Adicionar Localização…";
|
||||
"Add Location..." = "Adicionar Localização...";
|
||||
"Add profile..." = "Adicionar perfil…";
|
||||
"Add Quality Profile" = "Adicionar Perfil de Qualidade";
|
||||
"Add to %@" = "Adicionar a %@";
|
||||
@@ -148,7 +148,7 @@
|
||||
"Enter fullscreen in landscape" = "Entrar no ecrã inteiro em modo paisagem";
|
||||
"Error" = "Erro";
|
||||
"Error when accessing playlist" = "Erro ao acessar playlist";
|
||||
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "Lembretes explícitos de dar like, se inscrever ou interagir com eles em qualquer plataforma, paga ou grátis (p.ex. clique num vídeo).";
|
||||
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "Lembretes explícitos para dar gosto, subscrever ou interagir com eles em qualquer plataforma, paga ou grátis (p.ex. clicar num vídeo).\n";
|
||||
"Favorites" = "Favoritos";
|
||||
"Filter" = "Filtro";
|
||||
"Filter: active" = "Filtro: ativo";
|
||||
@@ -604,3 +604,9 @@
|
||||
"Add %@" = "Adicionar %@";
|
||||
"No preview" = "Sem prévia";
|
||||
"Chapters (if available)" = "Capítulos (se disponível)";
|
||||
"Import Settings..." = "Definições de Importação...";
|
||||
"Export Settings" = "Definições de Exportação";
|
||||
"Accounts passwords (unencrypted)" = "Palavras-passe das contas (não encriptadas)";
|
||||
"Other" = "Outro";
|
||||
"Other data" = "Outros dados";
|
||||
"Export..." = "Exportar…";
|
||||
|
||||
@@ -26,7 +26,7 @@ extension UIView {
|
||||
}
|
||||
|
||||
private func viewsInHierarchy<ViewType: UIView>(views: inout [ViewType]) {
|
||||
subviews.forEach { eachSubView in
|
||||
for eachSubView in subviews {
|
||||
if let matchingView = eachSubView as? ViewType {
|
||||
views.append(matchingView)
|
||||
}
|
||||
|
||||
@@ -82,7 +82,6 @@
|
||||
3709528A29283E14001ECA40 /* NoDocumentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3709528929283E14001ECA40 /* NoDocumentsView.swift */; };
|
||||
37095E82291DC85400301883 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37095E81291DC85400301883 /* ShareViewController.swift */; };
|
||||
37095E89291DC85400301883 /* Open in Yattee.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 37095E7F291DC85400301883 /* Open in Yattee.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
37095E8D291DD5DA00301883 /* URLBookmarkModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F5E8B5291BE9D0006C15F5 /* URLBookmarkModel.swift */; };
|
||||
370B79C9286279810045DB77 /* NSObject+Swizzle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 370B79C8286279810045DB77 /* NSObject+Swizzle.swift */; };
|
||||
370B79CC286279BA0045DB77 /* UIViewController+HideHomeIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 370B79CB286279BA0045DB77 /* UIViewController+HideHomeIndicator.swift */; };
|
||||
370E990A2A1EA8C500D144E9 /* WatchModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 370E99092A1EA8C500D144E9 /* WatchModel.swift */; };
|
||||
@@ -378,6 +377,12 @@
|
||||
3761ABFD26F0F8DE00AA496F /* EnvironmentValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3761ABFC26F0F8DE00AA496F /* EnvironmentValues.swift */; };
|
||||
3761ABFE26F0F8DE00AA496F /* EnvironmentValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3761ABFC26F0F8DE00AA496F /* EnvironmentValues.swift */; };
|
||||
3761ABFF26F0F8DE00AA496F /* EnvironmentValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3761ABFC26F0F8DE00AA496F /* EnvironmentValues.swift */; };
|
||||
3762C46D2BF66CDD008E50B8 /* EnvironmentValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3761ABFC26F0F8DE00AA496F /* EnvironmentValues.swift */; };
|
||||
3762C4772BF66F04008E50B8 /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3762C4762BF66F04008E50B8 /* Strings.swift */; };
|
||||
3762C4782BF66F04008E50B8 /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3762C4762BF66F04008E50B8 /* Strings.swift */; };
|
||||
3762C4792BF66F04008E50B8 /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3762C4762BF66F04008E50B8 /* Strings.swift */; };
|
||||
3762C47A2BF66F04008E50B8 /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3762C4762BF66F04008E50B8 /* Strings.swift */; };
|
||||
3762C47D2BF66FF7008E50B8 /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = 3762C47C2BF66FF7008E50B8 /* Defaults */; };
|
||||
3763495126DFF59D00B9A393 /* AppSidebarRecents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3763495026DFF59D00B9A393 /* AppSidebarRecents.swift */; };
|
||||
3763495226DFF59D00B9A393 /* AppSidebarRecents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3763495026DFF59D00B9A393 /* AppSidebarRecents.swift */; };
|
||||
37635FE4291EA6CF00C11E79 /* AccentButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37635FE3291EA6CF00C11E79 /* AccentButton.swift */; };
|
||||
@@ -1059,8 +1064,6 @@
|
||||
37FD77002932C4DA00D91A5F /* URL+ByReplacingYatteeProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FD76FF2932C4DA00D91A5F /* URL+ByReplacingYatteeProtocol.swift */; };
|
||||
37FD77012932C4DA00D91A5F /* URL+ByReplacingYatteeProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FD76FF2932C4DA00D91A5F /* URL+ByReplacingYatteeProtocol.swift */; };
|
||||
37FD77022932C4DA00D91A5F /* URL+ByReplacingYatteeProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FD76FF2932C4DA00D91A5F /* URL+ByReplacingYatteeProtocol.swift */; };
|
||||
37FD77032932C5EC00D91A5F /* URL+ByReplacingYatteeProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FD76FF2932C4DA00D91A5F /* URL+ByReplacingYatteeProtocol.swift */; };
|
||||
37FD77042932C5FC00D91A5F /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3754B01428B7F84D009717C8 /* Constants.swift */; };
|
||||
37FEF11327EFD8580033912F /* PlaceholderCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FEF11227EFD8580033912F /* PlaceholderCell.swift */; };
|
||||
37FEF11427EFD8580033912F /* PlaceholderCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FEF11227EFD8580033912F /* PlaceholderCell.swift */; };
|
||||
37FEF11527EFD8580033912F /* PlaceholderCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FEF11227EFD8580033912F /* PlaceholderCell.swift */; };
|
||||
@@ -1265,6 +1268,7 @@
|
||||
375EC971289F2ABF00751258 /* MultiselectRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiselectRow.swift; sourceTree = "<group>"; };
|
||||
375F740F289DC35A00747050 /* PlayerBackendView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerBackendView.swift; sourceTree = "<group>"; };
|
||||
3761ABFC26F0F8DE00AA496F /* EnvironmentValues.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EnvironmentValues.swift; sourceTree = "<group>"; };
|
||||
3762C4762BF66F04008E50B8 /* Strings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = "<group>"; };
|
||||
3763495026DFF59D00B9A393 /* AppSidebarRecents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSidebarRecents.swift; sourceTree = "<group>"; };
|
||||
37635FE3291EA6CF00C11E79 /* AccentButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccentButton.swift; sourceTree = "<group>"; };
|
||||
3763C988290C7A50004D3B5F /* OpenVideosView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenVideosView.swift; sourceTree = "<group>"; };
|
||||
@@ -1542,6 +1546,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
3762C47D2BF66FF7008E50B8 /* Defaults in Frameworks */,
|
||||
378CC2E32B669489006309F4 /* Logging in Frameworks */,
|
||||
377F9F85294417FA0043F856 /* SwiftyJSON in Frameworks */,
|
||||
377F9F83294417B40043F856 /* Cache in Frameworks */,
|
||||
@@ -2276,6 +2281,7 @@
|
||||
371AAE2726CEBF4700901972 /* Videos */,
|
||||
371AAE2826CEC7D900901972 /* Views */,
|
||||
3754B01428B7F84D009717C8 /* Constants.swift */,
|
||||
3762C4762BF66F04008E50B8 /* Strings.swift */,
|
||||
375168D52700FAFF008F96A6 /* Debounce.swift */,
|
||||
372915E52687E3B900F5A35B /* Defaults.swift */,
|
||||
37D2E0D328B67EFC00F64D52 /* Delay.swift */,
|
||||
@@ -2501,6 +2507,7 @@
|
||||
377F9F82294417B40043F856 /* Cache */,
|
||||
377F9F84294417FA0043F856 /* SwiftyJSON */,
|
||||
378CC2E22B669489006309F4 /* Logging */,
|
||||
3762C47C2BF66FF7008E50B8 /* Defaults */,
|
||||
);
|
||||
productName = "Open in Yattee";
|
||||
productReference = 37095E7F291DC85400301883 /* Open in Yattee.appex */;
|
||||
@@ -3000,10 +3007,9 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
3738535729451E0C00D2D0CB /* BookmarksCacheModel.swift in Sources */,
|
||||
37FD77042932C5FC00D91A5F /* Constants.swift in Sources */,
|
||||
3762C46D2BF66CDD008E50B8 /* EnvironmentValues.swift in Sources */,
|
||||
37095E82291DC85400301883 /* ShareViewController.swift in Sources */,
|
||||
37FD77032932C5EC00D91A5F /* URL+ByReplacingYatteeProtocol.swift in Sources */,
|
||||
37095E8D291DD5DA00301883 /* URLBookmarkModel.swift in Sources */,
|
||||
3762C47A2BF66F04008E50B8 /* Strings.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -3330,6 +3336,7 @@
|
||||
37D4B0E42671614900C925CA /* YatteeApp.swift in Sources */,
|
||||
37C3A241272359900087A57A /* Double+Format.swift in Sources */,
|
||||
3784CDE227772EE40055BBF2 /* Watch.swift in Sources */,
|
||||
3762C4772BF66F04008E50B8 /* Strings.swift in Sources */,
|
||||
37FB285E272225E800A57617 /* ContentItemView.swift in Sources */,
|
||||
3797758B2689345500DD52A8 /* Store.swift in Sources */,
|
||||
3773B80D2ADC076800B5FEF3 /* FramePreferenceKey.swift in Sources */,
|
||||
@@ -3401,6 +3408,7 @@
|
||||
37192D5828B179D60012EEDD /* ChaptersView.swift in Sources */,
|
||||
37E75CCC2B6AEB01003A6237 /* RecentlyOpenedExporter.swift in Sources */,
|
||||
3784CDE327772EE40055BBF2 /* Watch.swift in Sources */,
|
||||
3762C4782BF66F04008E50B8 /* Strings.swift in Sources */,
|
||||
371AC0B7294D1D6E0085989E /* PlayingIndicatorView.swift in Sources */,
|
||||
3773B8182ADC081300B5FEF3 /* VisualEffectBlur-macOS.swift in Sources */,
|
||||
37E80F3D287B107F00561799 /* VideoDetailsOverlay.swift in Sources */,
|
||||
@@ -3826,6 +3834,7 @@
|
||||
37769250294630110055EC18 /* ChannelAvatarView.swift in Sources */,
|
||||
37030FFD27B0398000ECDDAA /* MPVClient.swift in Sources */,
|
||||
378E9C4229455A5800B2D696 /* ChannelsView.swift in Sources */,
|
||||
3762C4792BF66F04008E50B8 /* Strings.swift in Sources */,
|
||||
37192D5928B179D60012EEDD /* ChaptersView.swift in Sources */,
|
||||
37B767DD2677C3CA0098BAA8 /* PlayerModel.swift in Sources */,
|
||||
373CFAF12697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */,
|
||||
@@ -4062,7 +4071,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "Open in Yattee/Open in Yattee.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 181;
|
||||
CURRENT_PROJECT_VERSION = 182;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "Open in Yattee/Info.plist";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Open in Yattee";
|
||||
@@ -4093,7 +4102,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 181;
|
||||
CURRENT_PROJECT_VERSION = 182;
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 78Z5H3M6RJ;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "Open in Yattee/Info.plist";
|
||||
@@ -4124,7 +4133,7 @@
|
||||
buildSettings = {
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 181;
|
||||
CURRENT_PROJECT_VERSION = 182;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
||||
@@ -4144,7 +4153,7 @@
|
||||
buildSettings = {
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 181;
|
||||
CURRENT_PROJECT_VERSION = 182;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
||||
@@ -4308,7 +4317,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "iOS/Yattee (iOS).entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 181;
|
||||
CURRENT_PROJECT_VERSION = 182;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
@@ -4361,7 +4370,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 181;
|
||||
CURRENT_PROJECT_VERSION = 182;
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 78Z5H3M6RJ;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = "GLES_SILENCE_DEPRECATION=1";
|
||||
@@ -4413,7 +4422,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 181;
|
||||
CURRENT_PROJECT_VERSION = 182;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
@@ -4452,7 +4461,7 @@
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "3rd Party Mac Developer Application";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 181;
|
||||
CURRENT_PROJECT_VERSION = 182;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
"DEVELOPMENT_TEAM[sdk=macosx*]" = 78Z5H3M6RJ;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
@@ -4487,7 +4496,7 @@
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 181;
|
||||
CURRENT_PROJECT_VERSION = 182;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -4511,7 +4520,7 @@
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 181;
|
||||
CURRENT_PROJECT_VERSION = 182;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -4537,7 +4546,7 @@
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 181;
|
||||
CURRENT_PROJECT_VERSION = 182;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -4562,7 +4571,7 @@
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 181;
|
||||
CURRENT_PROJECT_VERSION = 182;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -4588,7 +4597,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 181;
|
||||
CURRENT_PROJECT_VERSION = 182;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -4628,7 +4637,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
"CODE_SIGN_IDENTITY[sdk=appletvos*]" = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 181;
|
||||
CURRENT_PROJECT_VERSION = 182;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
"DEVELOPMENT_TEAM[sdk=appletvos*]" = 78Z5H3M6RJ;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -4669,7 +4678,7 @@
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 181;
|
||||
CURRENT_PROJECT_VERSION = 182;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
@@ -4693,7 +4702,7 @@
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 181;
|
||||
CURRENT_PROJECT_VERSION = 182;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
@@ -4867,8 +4876,8 @@
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/lorenzofiamingo/swiftui-cached-async-image";
|
||||
requirement = {
|
||||
branch = main;
|
||||
kind = branch;
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 2.1.1;
|
||||
};
|
||||
};
|
||||
372915E22687E33E00F5A35B /* XCRemoteSwiftPackageReference "Defaults" */ = {
|
||||
@@ -4963,8 +4972,8 @@
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/SDWebImage/SDWebImage";
|
||||
requirement = {
|
||||
branch = master;
|
||||
kind = branch;
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 5.19.1;
|
||||
};
|
||||
};
|
||||
37D4B19B2671817900C925CA /* XCRemoteSwiftPackageReference "SwiftyJSON" */ = {
|
||||
@@ -5003,8 +5012,8 @@
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/cxfksword/MPVKit.git";
|
||||
requirement = {
|
||||
kind = revision;
|
||||
revision = dca1e345a26d09a3d621d7656a94e6427f3f7b83;
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 0.38.0;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
@@ -5110,6 +5119,11 @@
|
||||
package = 375B8AAF28B57F4200397B31 /* XCRemoteSwiftPackageReference "KeychainAccess" */;
|
||||
productName = KeychainAccess;
|
||||
};
|
||||
3762C47C2BF66FF7008E50B8 /* Defaults */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 372915E22687E33E00F5A35B /* XCRemoteSwiftPackageReference "Defaults" */;
|
||||
productName = Defaults;
|
||||
};
|
||||
3765917B27237D21009F956E /* PINCache */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 3765917827237D07009F956E /* XCRemoteSwiftPackageReference "PINCache" */;
|
||||
|
||||
@@ -60,7 +60,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/cxfksword/MPVKit.git",
|
||||
"state" : {
|
||||
"revision" : "dca1e345a26d09a3d621d7656a94e6427f3f7b83"
|
||||
"revision" : "f646e4b625e9c8a2ff22a7e0bb5557306300be5d",
|
||||
"version" : "0.38.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -69,7 +70,7 @@
|
||||
"location" : "https://github.com/pinterest/PINCache",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "97a5dbd3f1e69605bcd4103fdb32ca855887c47a"
|
||||
"revision" : "2fb85948463292c2e824148cf17dc62a4c217a94"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -77,8 +78,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pinterest/PINOperation.git",
|
||||
"state" : {
|
||||
"revision" : "40504c156a68b20f98f7ddc73a115cbb7893be25",
|
||||
"version" : "1.2.2"
|
||||
"revision" : "a74f978733bdaf982758bfa23d70a189f4b4c1b6",
|
||||
"version" : "1.2.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -86,8 +87,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/ashleymills/Reachability.swift",
|
||||
"state" : {
|
||||
"revision" : "7b7018a69c84ea94ac2a38dff626e16ea81d1db9",
|
||||
"version" : "5.2.1"
|
||||
"revision" : "57da4b1270cab7c2228919eabc0e4e1bf93e48ea",
|
||||
"version" : "5.2.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -104,8 +105,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/SDWebImage/SDWebImage",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "f6afa0132961d593f07970d84e2d8b588c29ea04"
|
||||
"revision" : "5642d1ffe3dbe628592443bd14154e31929727b4",
|
||||
"version" : "5.19.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -131,8 +132,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/SDWebImage/SDWebImageWebPCoder.git",
|
||||
"state" : {
|
||||
"revision" : "8a33fb3ca75a01267f775f891f7d61f675e95072",
|
||||
"version" : "0.14.5"
|
||||
"revision" : "f534cfe830a7807ecc3d0332127a502426cfa067",
|
||||
"version" : "0.14.6"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -158,8 +159,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/lorenzofiamingo/swiftui-cached-async-image",
|
||||
"state" : {
|
||||
"branch" : "main",
|
||||
"revision" : "2abb11839f80ebb07a58ac5e146a1da664260c16"
|
||||
"revision" : "467a3d17479887943ab917a379e62bbaff60ac8a",
|
||||
"version" : "2.1.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -60,6 +60,13 @@
|
||||
ReferencedContainer = "container:Yattee.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "IDEPreferLogStreaming"
|
||||
value = "Yes"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
|
||||
@@ -19,4 +19,12 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
|
||||
func application(_: UIApplication, open url: URL, options _: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
||||
if url.scheme == "yattee" {
|
||||
OpenURLHandler(navigationStyle: Constants.defaultNavigationStyle).handle(url)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,7 +153,6 @@ struct InstancesSettings: View {
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
.onAppear {
|
||||
selectedInstanceID = instances.first?.id
|
||||
frontendURL = selectedInstanceFrontendURL
|
||||
|
||||
@@ -31,7 +31,7 @@ struct VerticalScrollingFixWrapper<Content>: View where Content: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VerticalScrollingFixViewRepresentable(content: self.content())
|
||||
VerticalScrollingFixViewRepresentable(content: content())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user