Player controls UI changes

WIP on controls

Chapters

working

Add previews variable

Add lists ids

WIP
This commit is contained in:
Arkadiusz Fal 2022-06-18 14:39:49 +02:00
parent 9c98cf9558
commit 321c265a11
60 changed files with 2524 additions and 1320 deletions

View File

@ -5,9 +5,19 @@ disabled_rules:
- opening_brace
- number_separator
- multiline_arguments
opt_in_rules:
- conditional_returns_on_newline
- implicit_return
excluded:
- Vendor
- Tests Apple TV
- Tests iOS
- Tests macOS
conditional_returns_on_newline:
if_only: true
implicit_return:
included:
- function
- getter

View File

@ -0,0 +1,15 @@
import SwiftUI
extension ShapeStyle where Self == Color {
static var debug: Color {
#if DEBUG
return Color(
red: .random(in: 0 ... 1),
green: .random(in: 0 ... 1),
blue: .random(in: 0 ... 1)
)
#else
return Color(.clear)
#endif
}
}

View File

@ -1,8 +1,8 @@
import Foundation
extension Double {
func formattedAsPlaybackTime() -> String? {
guard !isZero, isFinite else {
func formattedAsPlaybackTime(allowZero: Bool = false) -> String? {
guard allowZero || !isZero, isFinite else {
return nil
}

View File

@ -2,7 +2,7 @@ import UIKit
extension UIViewController {
@objc var swizzle_prefersHomeIndicatorAutoHidden: Bool {
return true
true
}
public class func swizzleHomeIndicatorProperty() {

View File

@ -6,6 +6,7 @@ extension Video {
static var fixture: Video {
let thumbnailURL = "https://yt3.ggpht.com/ytc/AKedOLR-pT_JEsz_hcaA4Gjx8DHcqJ8mS42aTRqcVy6P7w=s88-c-k-c0x00ffffff-no-rj-mo"
let chapterImageURL = URL(string: "https://pipedproxy.kavin.rocks/vi/rr2XfL_df3o/hqdefault_29633.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg%3D%3D&rs=AOn4CLDFDm9D5SvsIA7D3v5n5KZahLs_UA&host=i.ytimg.com")!
return Video(
videoID: fixtureID,
@ -29,7 +30,12 @@ extension Video {
publishedAt: Date(),
likes: 37333,
dislikes: 30,
keywords: ["very", "cool", "video", "msfs 2020", "757", "747", "A380", "737-900", "MOD", "Zibo", "MD80", "MD11", "Rotate", "Laminar", "787", "A350", "MSFS", "MS2020", "Microsoft Flight Simulator", "Microsoft", "Flight", "Simulator", "SIM", "World", "Ortho", "Flying", "Boeing MAX"]
keywords: ["very", "cool", "video", "msfs 2020", "757", "747", "A380", "737-900", "MOD", "Zibo", "MD80", "MD11", "Rotate", "Laminar", "787", "A350", "MSFS", "MS2020", "Microsoft Flight Simulator", "Microsoft", "Flight", "Simulator", "SIM", "World", "Ortho", "Flying", "Boeing MAX"],
chapters: [
.init(title: "A good chapter name", image: chapterImageURL, start: 20),
.init(title: "Other fine but incredibly too long chapter name, I don't know what else to say", image: chapterImageURL, start: 30),
.init(title: "Short", image: chapterImageURL, start: 60)
]
)
}

View File

@ -9,9 +9,11 @@ struct FixtureEnvironmentObjectsModifier: ViewModifier {
.environmentObject(InstancesModel())
.environmentObject(invidious)
.environmentObject(NavigationModel())
.environmentObject(NetworkStateModel())
.environmentObject(PipedAPI())
.environmentObject(player)
.environmentObject(PlayerControlsModel())
.environmentObject(playerControls)
.environmentObject(PlayerTimeModel())
.environmentObject(PlaylistsModel())
.environmentObject(RecentsModel())
.environmentObject(SearchModel())
@ -37,6 +39,10 @@ struct FixtureEnvironmentObjectsModifier: ViewModifier {
return player
}
private var playerControls: PlayerControlsModel {
PlayerControlsModel(presentingControls: true, player: player)
}
private var subscriptions: SubscriptionsModel {
let subscriptions = SubscriptionsModel()

View File

@ -383,6 +383,8 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
id = videoID
}
let description = json["description"].stringValue
return Video(
id: id,
videoID: videoID,
@ -391,7 +393,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
length: json["lengthSeconds"].doubleValue,
published: json["publishedText"].stringValue,
views: json["viewCount"].intValue,
description: json["description"].stringValue,
description: description,
genre: json["genre"].stringValue,
channel: extractChannel(from: json),
thumbnails: extractThumbnails(from: json),
@ -403,7 +405,8 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
dislikes: json["dislikeCount"].int,
keywords: json["keywords"].arrayValue.compactMap { $0.string },
streams: extractStreams(from: json),
related: extractRelated(from: json)
related: extractRelated(from: json),
chapters: extractChapters(from: description)
)
}

View File

@ -409,6 +409,13 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
let live = details["livestream"]?.bool ?? (details["duration"]?.int == -1)
let description = extractDescription(from: content) ?? ""
var chapters = extractChapters(from: content)
if chapters.isEmpty, !description.isEmpty {
chapters = extractChapters(from: description)
}
return Video(
videoID: extractID(from: content),
title: details["title"]?.string ?? "",
@ -416,14 +423,15 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
length: details["duration"]?.double ?? 0,
published: published ?? "",
views: details["views"]?.int ?? 0,
description: extractDescription(from: content),
description: description,
channel: Channel(id: channelId, name: author, thumbnailURL: authorThumbnailURL, subscriptionsCount: subscriptionsCount),
thumbnails: thumbnails,
live: live,
likes: details["likes"]?.int,
dislikes: details["dislikes"]?.int,
streams: extractStreams(from: content),
related: extractRelated(from: content)
related: extractRelated(from: content),
chapters: extractChapters(from: content)
)
}
@ -571,4 +579,21 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
channel: Channel(id: channelId, name: author)
)
}
private func extractChapters(from content: JSON) -> [Chapter] {
guard let chapters = content.dictionaryValue["chapters"]?.array else {
return .init()
}
return chapters.compactMap { chapter in
guard let title = chapter["title"].string,
let image = chapter["image"].url,
let start = chapter["start"].double
else {
return nil
}
return Chapter(title: title, image: image, start: start)
}
}
}

View File

@ -116,4 +116,54 @@ extension VideosAPI {
return urlComponents.url
}
func extractChapters(from description: String) -> [Chapter] {
guard let chaptersRegularExpression = try? NSRegularExpression(
pattern: "(?<start>(?:[0-9]+:){1,}(?:[0-9]+))(?:\\s)+(?:- ?)?(?<title>.*)",
options: .caseInsensitive
) else { return [] }
let chapterLines = chaptersRegularExpression.matches(
in: description,
range: NSRange(description.startIndex..., in: description)
)
return chapterLines.compactMap { line in
let titleRange = line.range(withName: "title")
let startRange = line.range(withName: "start")
guard let titleSubstringRange = Range(titleRange, in: description),
let startSubstringRange = Range(startRange, in: description),
let titleCapture = String(description[titleSubstringRange]),
let startCapture = String(description[startSubstringRange]) else { return nil }
let startComponents = startCapture.components(separatedBy: ":")
guard startComponents.count <= 3 else { return nil }
var hours: Double?
var minutes: Double?
var seconds: Double?
if startComponents.count == 3 {
hours = Double(startComponents[0])
minutes = Double(startComponents[1])
seconds = Double(startComponents[2])
} else if startComponents.count == 2 {
minutes = Double(startComponents[0])
seconds = Double(startComponents[1])
}
guard var startSeconds = seconds else { return nil }
if let minutes = minutes {
startSeconds += 60 * minutes
}
if let hours = hours {
startSeconds += 60 * 60 * hours
}
return .init(title: titleCapture, start: startSeconds)
}
}
}

8
Model/Chapter.swift Normal file
View File

@ -0,0 +1,8 @@
import Foundation
struct Chapter: Identifiable, Equatable {
var id = UUID()
var title: String
var image: URL?
var start: Double
}

View File

@ -40,12 +40,12 @@ final class CommentsModel: ObservableObject {
}
func load(page: String? = nil) {
guard Self.enabled else {
guard Self.enabled, !loaded else {
return
}
guard !Self.instance.isNil,
!(player?.currentVideo.isNil ?? true)
let video = player.currentVideo
else {
return
}
@ -56,7 +56,7 @@ final class CommentsModel: ObservableObject {
firstPage = page.isNil || page!.isEmpty
api?.comments(player.currentVideo!.videoID, page: page)?
api?.comments(video.videoID, page: page)?
.load()
.onSuccess { [weak self] response in
if let page: CommentsPage = response.typedContent() {

View File

@ -66,6 +66,10 @@ final class NavigationModel: ObservableObject {
@Published var presentingSettings = false
@Published var presentingWelcomeScreen = false
@Published var presentingAlert = false
@Published var alertTitle = ""
@Published var alertMessage = ""
static func openChannel(
_ channel: Channel,
player: PlayerModel,
@ -181,6 +185,12 @@ final class NavigationModel: ObservableObject {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
#endif
}
func presentAlert(title: String, message: String) {
alertTitle = title
alertMessage = message
presentingAlert = true
}
}
typealias TabSelection = NavigationModel.TabSelection

View File

@ -0,0 +1,42 @@
import Foundation
final class NetworkStateModel: ObservableObject {
@Published var pausedForCache = false
@Published var cacheDuration = 0.0
@Published var bufferingState = 0.0
var player: PlayerModel!
var fullStateText: String? {
guard let bufferingStateText = bufferingStateText,
let cacheDurationText = cacheDurationText
else {
return nil
}
return "\(bufferingStateText) (\(cacheDurationText))"
}
var bufferingStateText: String? {
guard detailsAvailable else { return nil }
return String(format: "%.0f%%", bufferingState)
}
var cacheDurationText: String? {
guard detailsAvailable else { return nil }
return String(format: "%.2fs", cacheDuration)
}
var detailsAvailable: Bool {
guard let player = player else { return false }
return player.activeBackend.supportsNetworkStateBufferingDetails
}
var needsUpdates: Bool {
if let player = player {
return pausedForCache || player.isSeeking || player.isLoadingVideo
}
return pausedForCache
}
}

View File

@ -11,6 +11,8 @@ final class AVPlayerBackend: PlayerBackend {
var model: PlayerModel!
var controls: PlayerControlsModel!
var playerTime: PlayerTimeModel!
var networkState: NetworkStateModel!
var stream: Stream?
var video: Video?
@ -31,6 +33,11 @@ final class AVPlayerBackend: PlayerBackend {
avPlayer.timeControlStatus == .playing
}
var isSeeking: Bool {
// TODO: implement this maybe?
false
}
var playerItemDuration: CMTime? {
avPlayer.currentItem?.asset.duration
}
@ -52,9 +59,10 @@ final class AVPlayerBackend: PlayerBackend {
private var timeObserverThrottle = Throttle(interval: 2)
init(model: PlayerModel, controls: PlayerControlsModel?) {
init(model: PlayerModel, controls: PlayerControlsModel?, playerTime: PlayerTimeModel?) {
self.model = model
self.controls = controls
self.playerTime = playerTime
addFrequentTimeObserver()
addInfrequentTimeObserver()
@ -493,8 +501,8 @@ final class AVPlayerBackend: PlayerBackend {
return
}
self.controls.duration = self.playerItemDuration ?? .zero
self.controls.currentTime = self.currentTime ?? .zero
self.playerTime.duration = self.playerItemDuration ?? .zero
self.playerTime.currentTime = self.currentTime ?? .zero
#if !os(tvOS)
self.model.updateNowPlayingInfo()
@ -581,4 +589,5 @@ final class AVPlayerBackend: PlayerBackend {
func stopControlsUpdates() {}
func setNeedsDrawing(_: Bool) {}
func setSize(_: Double, _: Double) {}
func updateNetworkState() {}
}

View File

@ -12,6 +12,8 @@ final class MPVBackend: PlayerBackend {
var model: PlayerModel!
var controls: PlayerControlsModel!
var playerTime: PlayerTimeModel!
var networkState: NetworkStateModel!
var stream: Stream?
var video: Video?
@ -24,17 +26,22 @@ final class MPVBackend: PlayerBackend {
return
}
self.controls.isLoadingVideo = self.isLoadingVideo
self.controls?.isLoadingVideo = self.isLoadingVideo
self.updateNetworkState()
if !self.isLoadingVideo {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.handleEOF = true
}
}
self.model?.objectWillChange.send()
}
}}
var isPlaying = true { didSet {
updateNetworkState()
if isPlaying {
startClientUpdates()
} else {
@ -49,6 +56,15 @@ final class MPVBackend: PlayerBackend {
}
#endif
}}
var isSeeking = false {
didSet {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.model.isSeeking = self.isSeeking
}
}
}
var playerItemDuration: CMTime?
#if !os(macOS)
@ -88,9 +104,16 @@ final class MPVBackend: PlayerBackend {
client?.cacheDuration ?? 0
}
init(model: PlayerModel, controls: PlayerControlsModel? = nil) {
init(
model: PlayerModel,
controls: PlayerControlsModel? = nil,
playerTime: PlayerTimeModel? = nil,
networkState: NetworkStateModel? = nil
) {
self.model = model
self.controls = controls
self.playerTime = playerTime
self.networkState = networkState
clientTimer = .init(timeInterval: Self.controlsUpdateInterval)
clientTimer.eventHandler = getClientUpdates
@ -155,7 +178,6 @@ final class MPVBackend: PlayerBackend {
if !preservingTime,
let segment = self.model.sponsorBlock.segments.first,
segment.end > 4,
self.model.lastSkipped.isNil
{
self.seek(to: segment.endTime) { finished in
@ -202,7 +224,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
self.client.loadFile(fileToLoad, audio: audioTrack, time: time) { [weak self] _ in
self.client?.loadFile(fileToLoad, audio: audioTrack, time: time) { [weak self] _ in
self?.isLoadingVideo = true
self?.pause()
}
@ -229,7 +251,7 @@ final class MPVBackend: PlayerBackend {
isPlaying = true
startClientUpdates()
if controls.presentingControls {
if controls?.presentingControls ?? false {
startControlsUpdates()
}
@ -254,7 +276,7 @@ final class MPVBackend: PlayerBackend {
}
func seek(to time: CMTime, completionHandler: ((Bool) -> Void)?) {
client.seek(to: time) { [weak self] _ in
client?.seek(to: time) { [weak self] _ in
self?.getClientUpdates()
self?.updateControls()
completionHandler?(true)
@ -262,7 +284,7 @@ final class MPVBackend: PlayerBackend {
}
func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
client.seek(relative: time) { [weak self] _ in
client?.seek(relative: time) { [weak self] _ in
self?.getClientUpdates()
self?.updateControls()
completionHandler?(true)
@ -280,13 +302,7 @@ final class MPVBackend: PlayerBackend {
}
func enterFullScreen() {
model.toggleFullscreen(controls?.playingFullscreen ?? false)
#if os(iOS)
if Defaults[.lockOrientationInFullScreen] {
Orientation.lockOrientation(.landscape, andRotateTo: UIDevice.current.orientation.isLandscape ? nil : .landscapeRight)
}
#endif
model.toggleFullscreen(model?.playingFullScreen ?? false)
}
func exitFullScreen() {}
@ -297,15 +313,13 @@ final class MPVBackend: PlayerBackend {
guard model.presentingPlayer else {
return
}
DispatchQueue.main.async { [weak self] in
guard let self = self else {
return
}
self.logger.info("updating controls")
self.controls.currentTime = self.currentTime ?? .zero
self.controls.duration = self.playerItemDuration ?? .zero
self.playerTime.currentTime = self.currentTime ?? .zero
self.playerTime.duration = self.playerItemDuration ?? .zero
}
}
@ -375,13 +389,22 @@ final class MPVBackend: PlayerBackend {
case MPV_EVENT_PLAYBACK_RESTART:
isLoadingVideo = false
isSeeking = false
onFileLoaded?()
startClientUpdates()
onFileLoaded = nil
case MPV_EVENT_PAUSE:
updateNetworkState()
case MPV_EVENT_UNPAUSE:
isLoadingVideo = false
isSeeking = false
updateNetworkState()
case MPV_EVENT_SEEK:
isSeeking = true
case MPV_EVENT_END_FILE:
DispatchQueue.main.async { [weak self] in
@ -417,18 +440,41 @@ final class MPVBackend: PlayerBackend {
}
func setSize(_ width: Double, _ height: Double) {
self.client?.setSize(width, height)
client?.setSize(width, height)
}
func addVideoTrack(_ url: URL) {
self.client?.addVideoTrack(url)
client?.addVideoTrack(url)
}
func setVideoToAuto() {
self.client?.setVideoToAuto()
client?.setVideoToAuto()
}
func setVideoToNo() {
self.client?.setVideoToNo()
client?.setVideoToNo()
}
func updateNetworkState() {
guard let client = client, let networkState = networkState else {
return
}
DispatchQueue.main.async {
networkState.pausedForCache = client.pausedForCache
networkState.cacheDuration = client.cacheDuration
networkState.bufferingState = client.bufferingState
}
if networkState.needsUpdates {
dispatchNetworkUpdate()
}
}
func dispatchNetworkUpdate() {
print("dispatching network update")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
self?.updateNetworkState()
}
}
}

View File

@ -45,6 +45,9 @@ final class MPVClient: ObservableObject {
checkError(mpv_set_option_string(mpv, "input-media-keys", "yes"))
#endif
checkError(mpv_set_option_string(mpv, "cache-pause-initial", "yes"))
checkError(mpv_set_option_string(mpv, "cache-secs", "20"))
checkError(mpv_set_option_string(mpv, "cache-pause-wait", "2"))
checkError(mpv_set_option_string(mpv, "hwdec", "auto-safe"))
checkError(mpv_set_option_string(mpv, "vo", "libmpv"))
@ -167,6 +170,10 @@ final class MPVClient: ObservableObject {
CMTime.secondsInDefaultTimescale(mpv.isNil ? -1 : getDouble("duration"))
}
var pausedForCache: Bool {
mpv.isNil ? false : getFlag("paused-for-cache")
}
func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
guard !seeking else {
logger.warning("ignoring seek, another in progress")
@ -262,6 +269,12 @@ final class MPVClient: ObservableObject {
Int(getString("track-list/count") ?? "-1") ?? -1
}
private func getFlag(_ name: String) -> Bool {
var data = Int64()
mpv_get_property(mpv, name, MPV_FORMAT_FLAG, &data)
return data > 0
}
private func setFlagAsync(_ name: String, _ flag: Bool) {
var data: Int = flag ? 1 : 0
mpv_set_property_async(mpv, 0, name, MPV_FORMAT_FLAG, &data)

View File

@ -5,6 +5,8 @@ import Foundation
protocol PlayerBackend {
var model: PlayerModel! { get set }
var controls: PlayerControlsModel! { get set }
var playerTime: PlayerTimeModel! { get set }
var networkState: NetworkStateModel! { get set }
var stream: Stream? { get set }
var video: Video? { get set }
@ -14,6 +16,7 @@ protocol PlayerBackend {
var isLoadingVideo: Bool { get }
var isPlaying: Bool { get }
var isSeeking: Bool { get }
var playerItemDuration: CMTime? { get }
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting) -> Stream?
@ -49,6 +52,8 @@ protocol PlayerBackend {
func startControlsUpdates()
func stopControlsUpdates()
func updateNetworkState()
func setNeedsDrawing(_ needsDrawing: Bool)
func setSize(_ width: Double, _ height: Double)
}

View File

@ -13,4 +13,8 @@ enum PlayerBackendType: String, CaseIterable, Defaults.Serializable {
return "AVPlayer"
}
}
var supportsNetworkStateBufferingDetails: Bool {
self == .mpv
}
}

View File

@ -5,37 +5,26 @@ import SwiftUI
final class PlayerControlsModel: ObservableObject {
@Published var isLoadingVideo = false
@Published var isPlaying = true
@Published var currentTime = CMTime.zero
@Published var duration = CMTime.zero
@Published var presentingControls = false { didSet { handlePresentationChange() } }
@Published var presentingControlsOverlay = false
@Published var timer: Timer?
@Published var playingFullscreen = false
var player: PlayerModel!
var playbackTime: String {
guard let current = currentTime.seconds.formattedAsPlaybackTime(),
let duration = duration.seconds.formattedAsPlaybackTime()
else {
return "--:-- / --:--"
}
var withoutSegments = ""
if let withoutSegmentsDuration = playerItemDurationWithoutSponsorSegments,
self.duration.seconds != withoutSegmentsDuration
{
withoutSegments = " (\(withoutSegmentsDuration.formattedAsPlaybackTime() ?? "--:--"))"
}
return "\(current) / \(duration)\(withoutSegments)"
}
var playerItemDurationWithoutSponsorSegments: Double? {
guard let duration = player.playerItemDurationWithoutSponsorSegments else {
return nil
}
return duration.seconds
init(
isLoadingVideo: Bool = false,
isPlaying: Bool = true,
presentingControls: Bool = false,
presentingControlsOverlay: Bool = false,
timer: Timer? = nil,
player: PlayerModel? = nil
) {
self.isLoadingVideo = isLoadingVideo
self.isPlaying = isPlaying
self.presentingControls = presentingControls
self.presentingControlsOverlay = presentingControlsOverlay
self.timer = timer
self.player = player
}
func handlePresentationChange() {
@ -45,7 +34,7 @@ final class PlayerControlsModel: ObservableObject {
self?.resetTimer()
}
} else {
player.backend.stopControlsUpdates()
player?.backend.stopControlsUpdates()
timer?.invalidate()
timer = nil
}
@ -91,11 +80,6 @@ final class PlayerControlsModel: ObservableObject {
presentingControls ? hide() : show()
}
func reset() {
currentTime = .zero
duration = .zero
}
func resetTimer() {
#if os(tvOS)
if !presentingControls {

View File

@ -53,6 +53,7 @@ final class PlayerModel: ObservableObject {
@Published var queue = [PlayerQueueItem]() { didSet { Defaults[.queue] = queue } }
@Published var currentItem: PlayerQueueItem! { didSet { handleCurrentItemChange() } }
@Published var videoBeingOpened: Video?
@Published var historyVideos = [Video]()
@Published var preservedTime: CMTime?
@ -65,6 +66,10 @@ final class PlayerModel: ObservableObject {
@Published var musicMode = false
@Published var returnYouTubeDislike = ReturnYouTubeDislikeAPI()
@Published var isSeeking = false { didSet {
backend.updateNetworkState()
}}
#if os(iOS)
@Published var motionManager: CMMotionManager!
@Published var lockedOrientation: UIInterfaceOrientation?
@ -79,9 +84,24 @@ final class PlayerModel: ObservableObject {
backend.controls = controls
}
}}
var playerTime: PlayerTimeModel { didSet {
backends.forEach { backend in
var backend = backend
backend.playerTime = playerTime
backend.playerTime.player = self
}
}}
var networkState: NetworkStateModel { didSet {
backends.forEach { backend in
var backend = backend
backend.networkState = networkState
backend.networkState.player = self
}
}}
var context: NSManagedObjectContext = PersistenceController.shared.container.viewContext
var backgroundContext = PersistenceController.shared.container.newBackgroundContext()
@Published var playingFullScreen = false
@Published var playingInPictureInPicture = false
var pipController: AVPictureInPictureController?
var pipDelegate = PiPDelegate()
@ -108,13 +128,31 @@ final class PlayerModel: ObservableObject {
var playerLayerView: PlayerLayerView!
#endif
init(accounts: AccountsModel? = nil, comments: CommentsModel? = nil, controls: PlayerControlsModel? = nil) {
self.accounts = accounts ?? AccountsModel()
self.comments = comments ?? CommentsModel()
self.controls = controls ?? PlayerControlsModel()
var onPresentPlayer: (() -> Void)?
self.avPlayerBackend = AVPlayerBackend(model: self, controls: controls)
self.mpvBackend = MPVBackend(model: self)
init(
accounts: AccountsModel = AccountsModel(),
comments: CommentsModel = CommentsModel(),
controls: PlayerControlsModel = PlayerControlsModel(),
playerTime: PlayerTimeModel = PlayerTimeModel(),
networkState: NetworkStateModel = NetworkStateModel()
) {
self.accounts = accounts
self.comments = comments
self.controls = controls
self.playerTime = playerTime
self.networkState = networkState
self.avPlayerBackend = AVPlayerBackend(
model: self,
controls: controls,
playerTime: playerTime
)
self.mpvBackend = MPVBackend(
model: self,
playerTime: playerTime,
networkState: networkState
)
Defaults[.activeBackend] = .mpv
}
@ -136,7 +174,7 @@ final class PlayerModel: ObservableObject {
}
func hide() {
controls.playingFullscreen = false
playingFullScreen = false
presentingPlayer = false
#if os(iOS)
@ -176,11 +214,19 @@ final class PlayerModel: ObservableObject {
}
var playerItemDuration: CMTime? {
backend.playerItemDuration
guard !currentItem.isNil else {
return nil
}
return backend.playerItemDuration
}
var playerItemDurationWithoutSponsorSegments: CMTime? {
(backend.playerItemDuration ?? .zero) - .secondsInDefaultTimescale(
guard let playerItemDuration = playerItemDuration, !playerItemDuration.seconds.isZero else {
return nil
}
return playerItemDuration - .secondsInDefaultTimescale(
sponsorBlock.segments.reduce(0) { $0 + $1.duration }
)
}
@ -212,18 +258,15 @@ final class PlayerModel: ObservableObject {
func play(_ video: Video, at time: CMTime? = nil, showingPlayer: Bool = true) {
pause()
var delay = 0.0
#if os(iOS)
delay = 0.5
#endif
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
guard let self = self else {
if !playingInPictureInPicture, showingPlayer {
onPresentPlayer = { [weak self] in self?.playNow(video, at: time) }
show()
return
}
#endif
self.playNow(video, at: time)
}
playNow(video, at: time)
guard !playingInPictureInPicture else {
return
@ -260,7 +303,7 @@ final class PlayerModel: ObservableObject {
}
}
controls.reset()
playerTime.reset()
backend.playStream(
stream,
@ -468,10 +511,13 @@ final class PlayerModel: ObservableObject {
func handleEnterBackground() {
setNeedsDrawing(false)
if !playingInPictureInPicture, !musicMode {
pause()
}
}
func enterFullScreen() {
guard !controls.playingFullscreen else {
guard !playingFullScreen else {
return
}
@ -481,13 +527,13 @@ final class PlayerModel: ObservableObject {
}
func exitFullScreen() {
guard controls.playingFullscreen else {
guard playingFullScreen else {
return
}
logger.info("exiting fullscreen")
if controls.playingFullscreen {
if playingFullScreen {
toggleFullscreen(true)
}
@ -559,14 +605,14 @@ final class PlayerModel: ObservableObject {
setNeedsDrawing(false)
#endif
controls.playingFullscreen = !isFullScreen
playingFullScreen = !isFullScreen
#if os(iOS)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
self?.setNeedsDrawing(true)
}
if controls.playingFullscreen {
if playingFullScreen {
guard !(UIApplication.shared.windows.first?.windowScene?.interfaceOrientation.isLandscape ?? true) else {
return
}
@ -590,12 +636,6 @@ final class PlayerModel: ObservableObject {
avPlayerBackend.switchToMPVOnPipClose = false
closePiP()
}
#if os(macOS)
// TODO: initialize mpv on startup on mac
if mpvBackend.client.isNil {
Windows.player.open()
}
#endif
changeActiveBackend(from: .appleAVPlayer, to: .mpv)
controls.presentingControls = true
controls.removeTimer()

View File

@ -20,22 +20,14 @@ extension PlayerModel {
}
videosToPlay.dropFirst().reversed().forEach { video in
enqueueVideo(video, prepending: true) { _, item in
if item.video == first {
self.advanceToItem(item)
}
}
enqueueVideo(video, prepending: true, loadDetails: false)
}
show()
}
func playNext(_ video: Video) {
enqueueVideo(video, prepending: true) { _, item in
if self.currentItem.isNil {
self.advanceToItem(item)
}
}
enqueueVideo(video, play: currentItem.isNil, prepending: true)
}
func playNow(_ video: Video, at time: CMTime? = nil) {
@ -45,12 +37,12 @@ extension PlayerModel {
prepareCurrentItemForHistory()
enqueueVideo(video, prepending: true) { _, item in
enqueueVideo(video, play: true, atTime: time, prepending: true) { _, item in
self.advanceToItem(item, at: time)
}
}
func playItem(_ item: PlayerQueueItem, video: Video? = nil, at time: CMTime? = nil) {
func playItem(_ item: PlayerQueueItem, at time: CMTime? = nil) {
if !playingInPictureInPicture {
backend.closeItem()
}
@ -65,32 +57,25 @@ extension PlayerModel {
currentItem.playbackTime = .zero
}
if video != nil {
currentItem.video = video!
}
preservedTime = currentItem.playbackTime
DispatchQueue.main.async { [weak self] in
guard let video = self?.currentVideo else {
return
}
self?.videoBeingOpened = nil
if video.streams.isEmpty {
self?.loadAvailableStreams(video)
} else {
guard let instance = self?.accounts.current?.instance ?? InstancesModel.forPlayer ?? InstancesModel.all.first else { return }
self?.availableStreams = self?.streamsWithInstance(instance: instance, streams: video.streams) ?? video.streams
}
}
}
func preferredStream(_ streams: [Stream]) -> Stream? {
let quality = Defaults[.quality]
var streams = streams
if let id = Defaults[.playerInstanceID] {
streams = streams.filter { $0.instance.id == id }
}
streams = streams.filter { backend.canPlay($0) }
return backend.bestPlayable(streams, maxResolution: quality)
backend.bestPlayable(streams.filter { backend.canPlay($0) }, maxResolution: Defaults[.quality])
}
func advanceToNextItem() {
@ -109,7 +94,7 @@ extension PlayerModel {
currentItem = newItem
accounts.api.loadDetails(newItem) { newItem in
self.playItem(newItem, video: newItem.video, at: time)
self.playItem(newItem, at: time)
}
}
@ -140,24 +125,30 @@ extension PlayerModel {
play: Bool = false,
atTime: CMTime? = nil,
prepending: Bool = false,
loadDetails: Bool = true,
videoDetailsLoadHandler: @escaping (Video, PlayerQueueItem) -> Void = { _, _ in }
) -> PlayerQueueItem? {
let item = PlayerQueueItem(video, playbackTime: atTime)
if play {
currentItem = item
// pause playing current video as it's going to be replaced with next one
videoBeingOpened = video
}
queue.insert(item, at: prepending ? 0 : queue.endIndex)
accounts.api.loadDetails(item) { newItem in
if loadDetails {
accounts.api.loadDetails(item) { [weak self] newItem in
guard let self = self else { return }
videoDetailsLoadHandler(newItem.video, newItem)
if play {
self.playItem(newItem, video: video)
self.playItem(newItem)
} else {
self.queue.insert(newItem, at: prepending ? 0 : self.queue.endIndex)
}
}
} else {
queue.insert(item, at: prepending ? 0 : queue.endIndex)
}
return item
}

View File

@ -2,14 +2,16 @@ import AVFAudio
import CoreMedia
import Defaults
import Foundation
import SwiftUI
extension PlayerModel {
func handleSegments(at time: CMTime) {
if let segment = lastSkipped {
if time > .secondsInDefaultTimescale(segment.end + 10) {
if time > .secondsInDefaultTimescale(segment.end + 5) {
resetLastSegment()
}
}
guard let firstSegment = sponsorBlock.segments.first(where: { $0.timeInSegment(time) }) else {
return
}
@ -60,7 +62,9 @@ extension PlayerModel {
backend.seek(to: segment.endTime)
DispatchQueue.main.async { [weak self] in
withAnimation {
self?.lastSkipped = segment
}
self?.segmentRestorationTime = time
}
logger.info("SponsorBlock skipping to: \(segment.end)")
@ -69,8 +73,7 @@ extension PlayerModel {
private func shouldSkip(_ segment: Segment, at time: CMTime) -> Bool {
guard isPlaying,
!restoredSegments.contains(segment),
Defaults[.sponsorBlockCategories].contains(segment.category),
segment.end > 4
Defaults[.sponsorBlockCategories].contains(segment.category)
else {
return false
}
@ -92,7 +95,9 @@ extension PlayerModel {
private func resetLastSegment() {
DispatchQueue.main.async { [weak self] in
withAnimation {
self?.lastSkipped = nil
}
self?.segmentRestorationTime = nil
}
}

View File

@ -17,20 +17,14 @@ extension PlayerModel {
func loadAvailableStreams(_ video: Video) {
availableStreams = []
let playerInstance = InstancesModel.forPlayer ?? InstancesModel.all.first
guard !playerInstance.isNil else {
guard let playerInstance = InstancesModel.forPlayer ?? InstancesModel.all.first else {
return
}
logger.info("loading streams from \(playerInstance!.description)")
logger.info("loading streams from \(playerInstance.description)")
fetchStreams(playerInstance!.anonymous.video(video.videoID), instance: playerInstance!, video: video) { _ in
InstancesModel.all.filter { $0 != playerInstance }.forEach { instance in
self.logger.info("loading streams from \(instance.description)")
self.fetchStreams(instance.anonymous.video(video.videoID), instance: instance, video: video)
}
}
fetchStreams(playerInstance.anonymous.video(video.videoID), instance: playerInstance, video: video)
}
private func fetchStreams(
@ -60,8 +54,12 @@ extension PlayerModel {
stream.instance = instance
if instance.app == .invidious {
stream.audioAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: stream.audioAsset)
stream.videoAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: stream.videoAsset)
if let audio = stream.audioAsset {
stream.audioAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: audio)
}
if let video = stream.videoAsset {
stream.videoAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: video)
}
}
return stream

View File

@ -0,0 +1,50 @@
import CoreMedia
import Foundation
final class PlayerTimeModel: ObservableObject {
static let timePlaceholder = "--:--"
@Published var currentTime = CMTime.zero
@Published var duration = CMTime.zero
var player: PlayerModel?
var currentPlaybackTime: String {
if player?.currentItem.isNil ?? true || duration.seconds.isZero {
return Self.timePlaceholder
}
return currentTime.seconds.formattedAsPlaybackTime(allowZero: true) ?? Self.timePlaceholder
}
var durationPlaybackTime: String {
if player?.currentItem.isNil ?? true {
return Self.timePlaceholder
}
return duration.seconds.formattedAsPlaybackTime() ?? Self.timePlaceholder
}
var withoutSegmentsPlaybackTime: String {
guard let withoutSegmentsDuration = player?.playerItemDurationWithoutSponsorSegments?.seconds else {
return Self.timePlaceholder
}
return withoutSegmentsDuration.formattedAsPlaybackTime() ?? Self.timePlaceholder
}
var durationAndWithoutSegmentsPlaybackTime: String {
var durationAndWithoutSegmentsPlaybackTime = "\(durationPlaybackTime)"
if withoutSegmentsPlaybackTime != durationPlaybackTime {
durationAndWithoutSegmentsPlaybackTime += " (\(withoutSegmentsPlaybackTime))"
}
return durationAndWithoutSegmentsPlaybackTime
}
func reset() {
currentTime = .zero
duration = .zero
}
}

View File

@ -1,3 +1,4 @@
import Defaults
import Foundation
import Siesta
import SwiftUI
@ -16,6 +17,10 @@ final class PlaylistsModel: ObservableObject {
playlists.sorted { $0.title.lowercased() < $1.title.lowercased() }
}
var lastUsed: Playlist? {
find(id: Defaults[.lastUsedPlaylistID])
}
func find(id: Playlist.ID?) -> Playlist? {
if id.isNil {
return nil
@ -57,9 +62,19 @@ final class PlaylistsModel: ObservableObject {
playlistID: Playlist.ID,
videoID: Video.ID,
onSuccess: @escaping () -> Void = {},
onFailure: @escaping (RequestError) -> Void = { _ in }
navigation: NavigationModel?,
onFailure: ((RequestError) -> Void)? = nil
) {
accounts.api.addVideoToPlaylist(
videoID,
playlistID,
onFailure: onFailure ?? { requestError in
navigation?.presentAlert(
title: "Error when adding to playlist",
message: "(\(requestError.httpStatusCode ?? -1)) \(requestError.userMessage)"
)
}
) {
accounts.api.addVideoToPlaylist(videoID, playlistID, onFailure: onFailure) {
self.load(force: true) {
self.reloadPlaylists.toggle()
onSuccess()

View File

@ -21,6 +21,14 @@ class Segment: ObservableObject, Hashable {
end - start
}
var durationText: String {
let formatter = NumberFormatter()
formatter.minimumFractionDigits = 0
formatter.maximumFractionDigits = 1
return formatter.string(from: NSNumber(value: duration)) ?? ""
}
var endTime: CMTime {
.secondsInDefaultTimescale(end)
}

View File

@ -91,13 +91,13 @@ class Stream: Equatable, Hashable, Identifiable {
private var sortOrder: Int {
switch self {
case .webm:
return 0
case .mp4:
return 1
return 0
case .avc1:
return 2
return 1
case .av1:
return 2
case .webm:
return 3
case .unknown:
return 4
@ -160,17 +160,11 @@ class Stream: Equatable, Hashable, Identifiable {
}
var quality: String {
if resolution == .hd2160p30 {
return "4K (2160p)"
}
return kind == .hls ? "adaptive (HLS)" : "\(resolution.name)\(kind == .stream ? " (\(kind.rawValue))" : "")"
kind == .hls ? "adaptive (HLS)" : "\(resolution.name)\(kind == .stream ? " (\(kind.rawValue))" : "")"
}
var shortQuality: String {
if resolution?.height == 2160 {
return "4K"
} else if kind == .hls {
if kind == .hls {
return "HLS"
} else {
return resolution?.name ?? "?"

View File

@ -16,7 +16,7 @@ final class ThumbnailsModel: ObservableObject {
}
func best(_ video: Video) -> URL? {
let qualities = [Thumbnail.Quality.maxresdefault, .medium, .default]
let qualities = [Thumbnail.Quality.default]
for quality in qualities {
let url = video.thumbnailURL(quality: quality)

View File

@ -32,6 +32,7 @@ struct Video: Identifiable, Equatable, Hashable {
var channel: Channel
var related = [Video]()
var chapters = [Chapter]()
init(
id: String? = nil,
@ -53,7 +54,8 @@ struct Video: Identifiable, Equatable, Hashable {
dislikes: Int? = nil,
keywords: [String] = [],
streams: [Stream] = [],
related: [Video] = []
related: [Video] = [],
chapters: [Chapter] = []
) {
self.id = id ?? UUID().uuidString
self.videoID = videoID
@ -75,6 +77,7 @@ struct Video: Identifiable, Equatable, Hashable {
self.keywords = keywords
self.streams = streams
self.related = related
self.chapters = chapters
}
var publishedDate: String? {

View File

@ -1,5 +1,6 @@
import Defaults
import Foundation
import SwiftUI
#if os(iOS)
import UIKit
#endif
@ -11,6 +12,12 @@ extension Defaults.Keys {
static let defaultForPauseOnHidingPlayer = false
#endif
#if os(macOS)
static let defaultForPlayerDetailsPageButtonLabelStyle = PlayerDetailsPageButtonLabelStyle.iconAndText
#else
static let defaultForPlayerDetailsPageButtonLabelStyle = UIDevice.current.userInterfaceIdiom == .phone ? PlayerDetailsPageButtonLabelStyle.iconOnly : .iconAndText
#endif
static let kavinPipedInstanceID = "kavin-piped"
static let instances = Key<[Instance]>("instances", default: [
.init(
@ -89,6 +96,10 @@ extension Defaults.Keys {
#endif
static let showMPVPlaybackStats = Key<Bool>("showMPVPlaybackStats", default: false)
static let playerDetailsPageButtonLabelStyle = Key<PlayerDetailsPageButtonLabelStyle>("playerDetailsPageButtonLabelStyle", default: defaultForPlayerDetailsPageButtonLabelStyle)
static let controlsBarInPlayer = Key<Bool>("controlsBarInPlayer", default: true)
}
enum ResolutionSetting: String, CaseIterable, Defaults.Serializable {
@ -200,3 +211,11 @@ enum WatchedVideoPlayNowBehavior: String, Defaults.Serializable {
case info, separate
}
#endif
enum PlayerDetailsPageButtonLabelStyle: String, CaseIterable, Defaults.Serializable {
case iconOnly, iconAndText
var text: Bool {
self == .iconAndText
}
}

View File

@ -70,7 +70,13 @@ struct FavoritesView: View {
struct Favorites_Previews: PreviewProvider {
static var previews: some View {
TabView {
FavoritesView()
.overlay(VideoPlayerView().injectFixtureEnvironmentObjects())
.injectFixtureEnvironmentObjects()
.tabItem {
Label("a", systemImage: "")
}
}
}
}

View File

@ -14,7 +14,6 @@ struct AppSidebarNavigation: View {
@EnvironmentObject<InstancesModel> private var instances
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<PlayerControlsModel> private var playerControls
@EnvironmentObject<PlaylistsModel> private var playlists
@EnvironmentObject<RecentsModel> private var recents
@EnvironmentObject<SearchModel> private var search
@ -50,7 +49,6 @@ struct AppSidebarNavigation: View {
.frame(minWidth: sidebarMinWidth)
VStack {
BrowserPlayerControls {
HStack(alignment: .center) {
Spacer()
Image(systemName: "4k.tv")
@ -61,7 +59,6 @@ struct AppSidebarNavigation: View {
}
}
}
}
.environment(\.navigationStyle, .sidebar)
}

View File

@ -7,7 +7,6 @@ struct AppTabNavigation: View {
@EnvironmentObject<InstancesModel> private var instances
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<PlayerControlsModel> private var playerControls
@EnvironmentObject<PlaylistsModel> private var playlists
@EnvironmentObject<RecentsModel> private var recents
@EnvironmentObject<SearchModel> private var search
@ -130,20 +129,6 @@ struct AppTabNavigation: View {
.tag(TabSelection.search)
}
private var videoPlayer: some View {
VideoPlayerView()
.environmentObject(accounts)
.environmentObject(comments)
.environmentObject(instances)
.environmentObject(navigation)
.environmentObject(player)
.environmentObject(playerControls)
.environmentObject(playlists)
.environmentObject(recents)
.environmentObject(subscriptions)
.environmentObject(thumbnailsModel)
}
var toolbarContent: some ToolbarContent {
#if os(iOS)
Group {

View File

@ -12,8 +12,10 @@ struct ContentView: View {
@EnvironmentObject<CommentsModel> private var comments
@EnvironmentObject<InstancesModel> private var instances
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<NetworkStateModel> private var networkState
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<PlayerControlsModel> private var playerControls
@EnvironmentObject<PlayerTimeModel> private var playerTime
@EnvironmentObject<PlaylistsModel> private var playlists
@EnvironmentObject<RecentsModel> private var recents
@EnvironmentObject<SearchModel> private var search
@ -42,7 +44,6 @@ struct ContentView: View {
TVNavigationView()
#endif
}
.onAppear(perform: configure)
.onChange(of: accounts.signedIn) { _ in
subscriptions.load(force: true)
playlists.load(force: true)
@ -52,7 +53,9 @@ struct ContentView: View {
.environmentObject(comments)
.environmentObject(instances)
.environmentObject(navigation)
.environmentObject(networkState)
.environmentObject(player)
.environmentObject(playerTime)
.environmentObject(playlists)
.environmentObject(recents)
.environmentObject(search)
@ -107,117 +110,8 @@ struct ContentView: View {
secondaryButton: .cancel()
)
}
}
func configure() {
SiestaLog.Category.enabled = .common
SDImageCodersManager.shared.addCoder(SDImageWebPCoder.shared)
SDWebImageManager.defaultImageCache = PINCache(name: "stream.yattee.app")
#if !os(macOS)
setupNowPlayingInfoCenter()
#endif
#if os(iOS)
if Defaults[.lockPortraitWhenBrowsing] {
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
}
#endif
if let account = accounts.lastUsed ??
instances.lastUsed?.anonymousAccount ??
InstancesModel.all.first?.anonymousAccount
{
accounts.setCurrent(account)
}
if accounts.current.isNil {
navigation.presentingWelcomeScreen = true
}
playlists.accounts = accounts
search.accounts = accounts
subscriptions.accounts = accounts
comments.player = player
menu.accounts = accounts
menu.navigation = navigation
menu.player = player
playerControls.player = player
player.accounts = accounts
player.comments = comments
player.controls = playerControls
if !accounts.current.isNil {
player.restoreQueue()
}
if !Defaults[.saveRecents] {
recents.clear()
}
var section = Defaults[.visibleSections].min()?.tabSelection
#if os(macOS)
if section == .playlists {
section = .search
}
#endif
navigation.tabSelection = section ?? .search
subscriptions.load()
playlists.load()
}
func setupNowPlayingInfoCenter() {
#if !os(macOS)
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
UIApplication.shared.beginReceivingRemoteControlEvents()
#endif
MPRemoteCommandCenter.shared().playCommand.addTarget { _ in
player.play()
return .success
}
MPRemoteCommandCenter.shared().pauseCommand.addTarget { _ in
player.pause()
return .success
}
MPRemoteCommandCenter.shared().previousTrackCommand.isEnabled = false
MPRemoteCommandCenter.shared().nextTrackCommand.isEnabled = false
MPRemoteCommandCenter.shared().changePlaybackPositionCommand.addTarget { remoteEvent in
guard let event = remoteEvent as? MPChangePlaybackPositionCommandEvent
else {
return .commandFailed
}
player.backend.seek(to: event.positionTime)
return .success
}
let skipForwardCommand = MPRemoteCommandCenter.shared().skipForwardCommand
skipForwardCommand.isEnabled = true
skipForwardCommand.preferredIntervals = [10]
skipForwardCommand.addTarget { _ in
player.backend.seek(relative: .secondsInDefaultTimescale(10))
return .success
}
let skipBackwardCommand = MPRemoteCommandCenter.shared().skipBackwardCommand
skipBackwardCommand.isEnabled = true
skipBackwardCommand.preferredIntervals = [10]
skipBackwardCommand.addTarget { _ in
player.backend.seek(relative: .secondsInDefaultTimescale(-10))
return .success
.alert(isPresented: $navigation.presentingAlert) {
Alert(title: Text(navigation.alertTitle), message: Text(navigation.alertMessage))
}
}

View File

@ -0,0 +1,83 @@
import Foundation
import SDWebImageSwiftUI
import SwiftUI
struct ChaptersView: View {
@EnvironmentObject<PlayerModel> private var player
var body: some View {
List {
if let chapters = player.currentVideo?.chapters, !chapters.isEmpty {
Section(header: Text("Chapters")) {
ForEach(chapters) { chapter in
Button {
player.backend.seek(to: chapter.start)
} label: {
chapterButtonLabel(chapter)
}
.buttonStyle(.plain)
}
}
} else {
Text(player.currentVideo?.title ?? "")
}
}
.id(UUID())
#if os(macOS)
.listStyle(.inset)
#elseif os(iOS)
.listStyle(.grouped)
#else
.listStyle(.plain)
#endif
}
@ViewBuilder func chapterButtonLabel(_ chapter: Chapter) -> some View {
HStack(spacing: 12) {
if !chapter.image.isNil {
smallImage(chapter)
}
VStack(alignment: .leading, spacing: 4) {
Text(chapter.title)
.font(.headline)
Text(chapter.start.formattedAsPlaybackTime(allowZero: true) ?? "")
.font(.system(.subheadline).monospacedDigit())
.foregroundColor(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
@ViewBuilder func smallImage(_ chapter: Chapter) -> some View {
WebImage(url: chapter.image)
.resizable()
.placeholder {
ProgressView()
}
.indicator(.activity)
#if os(tvOS)
.frame(width: thumbnailWidth, height: 140)
.mask(RoundedRectangle(cornerRadius: 12))
#else
.frame(width: thumbnailWidth, height: 60)
.mask(RoundedRectangle(cornerRadius: 6))
#endif
}
private var thumbnailWidth: Double {
#if os(tvOS)
250
#else
100
#endif
}
}
struct ChaptersView_Preview: PreviewProvider {
static var previews: some View {
ChaptersView()
.injectFixtureEnvironmentObjects()
}
}

View File

@ -14,9 +14,6 @@ struct CommentsView: View {
NoCommentsView(text: "No comments", systemImage: "0.circle.fill")
} else if !comments.loaded {
PlaceholderProgressView()
.onAppear {
comments.load()
}
} else {
let last = comments.all.last
let commentsStack = LazyVStack {

View File

@ -0,0 +1,22 @@
import Foundation
import SwiftUI
struct ControlBackgroundModifier: ViewModifier {
var enabled = true
var edgesIgnoringSafeArea = Edge.Set()
func body(content: Content) -> some View {
if enabled {
content
#if os(macOS)
.background(VisualEffectBlur(material: .hudWindow))
#elseif os(iOS)
.background(VisualEffectBlur(blurStyle: .systemThinMaterial).edgesIgnoringSafeArea(edgesIgnoringSafeArea))
#else
.background(.thinMaterial)
#endif
} else {
content
}
}
}

View File

@ -0,0 +1,185 @@
import Defaults
import SwiftUI
struct ControlsOverlay: View {
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<PlayerControlsModel> private var model
@Default(.showMPVPlaybackStats) private var showMPVPlaybackStats
var body: some View {
VStack(spacing: 6) {
HStack {
backendButtons
}
qualityButton
HStack {
decreaseRateButton
rateButton
increaseRateButton
}
.foregroundColor(.white)
if player.activeBackend == .mpv,
showMPVPlaybackStats
{
mpvPlaybackStats
}
}
}
private var backendButtons: some View {
ForEach(PlayerBackendType.allCases, id: \.self) { backend in
backendButton(backend)
}
}
private func backendButton(_ backend: PlayerBackendType) -> some View {
Button {
player.saveTime {
player.changeActiveBackend(from: player.activeBackend, to: backend)
model.resetTimer()
}
} label: {
Text(backend.label)
.padding(6)
.foregroundColor(player.activeBackend == backend ? .accentColor : .secondary)
}
.buttonStyle(.plain)
}
private var increaseRateButton: some View {
let increasedRate = PlayerModel.availableRates.first { $0 > player.currentRate }
return Button {
if let rate = increasedRate {
player.currentRate = rate
}
} label: {
Label("Increase rate", systemImage: "plus")
.labelStyle(.iconOnly)
.padding(.horizontal, 8)
.contentShape(Rectangle())
}
#if os(macOS)
.buttonStyle(.bordered)
#else
.frame(height: 30)
.modifier(ControlBackgroundModifier())
.clipShape(RoundedRectangle(cornerRadius: 4))
#endif
.disabled(increasedRate.isNil)
}
private var decreaseRateButton: some View {
let decreasedRate = PlayerModel.availableRates.last { $0 < player.currentRate }
return Button {
if let rate = decreasedRate {
player.currentRate = rate
}
} label: {
Label("Decrease rate", systemImage: "minus")
.labelStyle(.iconOnly)
.padding(.horizontal, 8)
.contentShape(Rectangle())
}
#if os(macOS)
.buttonStyle(.bordered)
#else
.frame(height: 30)
.modifier(ControlBackgroundModifier())
.clipShape(RoundedRectangle(cornerRadius: 4))
#endif
.disabled(decreasedRate.isNil)
}
@ViewBuilder private var qualityButton: some View {
#if os(macOS)
StreamControl()
.labelsHidden()
.frame(maxWidth: 300)
#elseif os(iOS)
Menu {
StreamControl()
.frame(width: 45, height: 30)
#if os(iOS)
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
#endif
.mask(RoundedRectangle(cornerRadius: 3))
} label: {
Text(player.streamSelection?.shortQuality ?? "loading")
.frame(width: 140, height: 30)
.foregroundColor(.primary)
}
.buttonStyle(.plain)
.foregroundColor(.primary)
.frame(width: 140, height: 30)
#if os(macOS)
.background(VisualEffectBlur(material: .hudWindow))
#elseif os(iOS)
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
#endif
.mask(RoundedRectangle(cornerRadius: 3))
#endif
}
@ViewBuilder private var rateButton: some View {
#if os(macOS)
ratePicker
.labelsHidden()
.frame(maxWidth: 100)
#elseif os(iOS)
Menu {
ratePicker
.frame(width: 100, height: 30)
.mask(RoundedRectangle(cornerRadius: 3))
} label: {
Text(player.rateLabel(player.currentRate))
.foregroundColor(.primary)
}
.buttonStyle(.plain)
.foregroundColor(.primary)
.frame(width: 100, height: 30)
.modifier(ControlBackgroundModifier())
.mask(RoundedRectangle(cornerRadius: 3))
#endif
}
var ratePicker: some View {
Picker("Rate", selection: rateBinding) {
ForEach(PlayerModel.availableRates, id: \.self) { rate in
Text(player.rateLabel(rate)).tag(rate)
}
}
.transaction { t in t.animation = .none }
}
private var rateBinding: Binding<Float> {
.init(get: { player.currentRate }, set: { rate in player.currentRate = rate })
}
var mpvPlaybackStats: some View {
Group {
VStack(alignment: .leading, spacing: 6) {
Text("hw decoder: \(player.mpvBackend.hwDecoder)")
Text("dropped: \(player.mpvBackend.frameDropCount)")
Text("video: \(String(format: "%.2ffps", player.mpvBackend.outputFps))")
Text("buffering: \(String(format: "%.0f%%", player.mpvBackend.bufferingState))")
Text("cache: \(String(format: "%.2fs", player.mpvBackend.cacheDuration))")
}
.mask(RoundedRectangle(cornerRadius: 3))
}
#if !os(tvOS)
.font(.system(size: 9))
#endif
}
}
struct ControlsOverlay_Previews: PreviewProvider {
static var previews: some View {
ControlsOverlay()
.environmentObject(PlayerModel())
.environmentObject(PlayerControlsModel())
}
}

View File

@ -0,0 +1,37 @@
import Foundation
import SwiftUI
struct Buffering: View {
var reason = "Buffering stream..."
var state: String?
var body: some View {
VStack(spacing: 2) {
ProgressView()
#if os(macOS)
.scaleEffect(0.4)
#else
.scaleEffect(0.7)
#endif
.frame(maxHeight: 14)
.progressViewStyle(.circular)
Text(reason)
.font(.caption)
if let state = state {
Text(state)
.font(.caption2.monospacedDigit())
}
}
.padding(8)
.modifier(ControlBackgroundModifier())
.clipShape(RoundedRectangle(cornerRadius: 4))
.foregroundColor(.secondary)
}
}
struct Buffering_Previews: PreviewProvider {
static var previews: some View {
Buffering(state: "100% (2.95s)")
}
}

View File

@ -0,0 +1,22 @@
import SwiftUI
struct NetworkState: View {
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<NetworkStateModel> private var model
var body: some View {
Buffering(state: model.fullStateText)
.opacity(model.pausedForCache || player.isSeeking ? 1 : 0)
}
}
struct NetworkState_Previews: PreviewProvider {
static var previews: some View {
let networkState = NetworkStateModel()
networkState.bufferingState = 30
return NetworkState()
.environmentObject(networkState)
.environmentObject(PlayerModel())
}
}

View File

@ -0,0 +1,37 @@
import SwiftUI
struct OpeningStream: View {
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<NetworkStateModel> private var model
var body: some View {
Buffering(reason: reason, state: state)
.opacity(visible ? 1 : 0)
}
var visible: Bool {
(!player.currentItem.isNil && !player.videoBeingOpened.isNil) || (player.isLoadingVideo && !model.pausedForCache && !player.isSeeking)
}
var reason: String {
player.videoBeingOpened.isNil ? "Opening\(streamQuality)stream..." : "Loading streams..."
}
var state: String? {
player.videoBeingOpened.isNil ? model.bufferingStateText : nil
}
var streamQuality: String {
guard let stream = player.streamSelection else { return " " }
guard !player.musicMode else { return " audio " }
return " \(stream.shortQuality) "
}
}
struct OpeningStream_Previews: PreviewProvider {
static var previews: some View {
OpeningStream()
.injectFixtureEnvironmentObjects()
}
}

View File

@ -23,7 +23,7 @@ struct PlayerControls: View {
@FocusState private var focusedField: Field?
#endif
@Default(.showMPVPlaybackStats) private var showMPVPlaybackStats
@Default(.controlsBarInPlayer) private var controlsBarInPlayer
init(player: PlayerModel, thumbnails: ThumbnailsModel) {
self.player = player
@ -31,53 +31,56 @@ struct PlayerControls: View {
}
var body: some View {
ZStack(alignment: .topTrailing) {
VStack {
ZStack(alignment: .bottom) {
VStack(alignment: .trailing, spacing: 4) {
#if !os(tvOS)
ZStack(alignment: .center) {
OpeningStream()
NetworkState()
Group {
VStack(spacing: 4) {
buttonsBar
HStack(spacing: 4) {
qualityButton
backendButton
if let video = player.currentVideo, player.playingFullScreen {
// if let video = Video.fixture {
VStack(alignment: .leading, spacing: 8) {
Text(video.title)
.font(.title2.bold())
Text(video.author)
.font(.title3)
.foregroundColor(.secondary)
}
.padding(12)
.modifier(ControlBackgroundModifier())
.clipShape(RoundedRectangle(cornerRadius: 3))
.frame(maxWidth: .infinity, alignment: .leading)
}
#else
Text(player.stream?.description ?? "")
#endif
Spacer()
mediumButtonsBar
Spacer()
Group {
if player.activeBackend == .mpv, showMPVPlaybackStats {
mpvPlaybackStats
}
ZStack(alignment: .bottom) {
floatingControls
.padding(.top, 20)
.padding(4)
.modifier(ControlBackgroundModifier())
.clipShape(RoundedRectangle(cornerRadius: 4))
timeline
.offset(y: 10)
.padding(4)
.offset(y: -25)
.zIndex(1)
HStack {
Spacer()
bottomBar
#if os(macOS)
.background(VisualEffectBlur(material: .hudWindow))
#elseif os(iOS)
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
#endif
.mask(RoundedRectangle(cornerRadius: 3))
}
.frame(maxWidth: 500)
.padding(.bottom, 2)
}
}
.padding(.horizontal, 16)
.padding(.top, 2)
.padding(.horizontal, 2)
}
.opacity(model.presentingControlsOverlay ? 1 : model.presentingControls ? 1 : 0)
}
.padding(.top, 4)
.padding(.horizontal, 4)
.opacity(model.presentingControls ? 1 : 0)
}
#if os(tvOS)
.onChange(of: model.presentingControls) { _ in
@ -92,13 +95,43 @@ struct PlayerControls: View {
.background(PlayerGestures())
.background(controlsBackground)
#endif
.environment(\.colorScheme, .dark)
ControlsOverlay()
.padding()
.modifier(ControlBackgroundModifier(enabled: true))
.clipShape(RoundedRectangle(cornerRadius: 4))
.offset(x: -2, y: 40)
.opacity(model.presentingControlsOverlay ? 1 : 0)
Button {
player.restoreLastSkippedSegment()
} label: {
HStack(spacing: 10) {
if let segment = player.lastSkipped {
Image(systemName: "arrow.counterclockwise")
Text("Skipped \(segment.durationText) seconds of \(SponsorBlockAPI.categoryDescription(segment.category)?.lowercased() ?? "segment")")
.frame(alignment: .bottomLeading)
}
}
.padding(.vertical, 4)
.padding(.horizontal, 5)
.font(.system(size: 10))
.foregroundColor(.secondary)
.modifier(ControlBackgroundModifier())
.clipShape(RoundedRectangle(cornerRadius: 2))
.offset(x: -2, y: -2)
}
.buttonStyle(.plain)
.opacity(model.presentingControls ? 0 : player.lastSkipped.isNil ? 0 : 1)
}
}
@ViewBuilder var controlsBackground: some View {
if player.musicMode,
let item = self.player.currentItem,
let url = thumbnails.best(item.video)
let video = item.video,
let url = thumbnails.best(video)
{
WebImage(url: url)
.resizable()
@ -110,48 +143,8 @@ struct PlayerControls: View {
}
}
var mpvPlaybackStats: some View {
HStack {
Group {
Text("hw decoder: \(player.mpvBackend.hwDecoder)")
Text("dropped: \(player.mpvBackend.frameDropCount)")
Text("video: \(String(format: "%.2ffps", player.mpvBackend.outputFps))")
Text("buffering: \(String(format: "%.0f%%", player.mpvBackend.bufferingState))")
Text("cache: \(String(format: "%.2fs", player.mpvBackend.cacheDuration))")
}
.padding(4)
#if os(macOS)
.background(VisualEffectBlur(material: .hudWindow))
#elseif os(iOS)
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
#else
.background(.thinMaterial)
#endif
.mask(RoundedRectangle(cornerRadius: 3))
Spacer()
}
#if !os(tvOS)
.font(.system(size: 9))
#endif
}
var timeline: some View {
TimelineView(duration: durationBinding, current: currentTimeBinding, cornerRadius: 0)
}
var durationBinding: Binding<Double> {
Binding<Double>(
get: { model.duration.seconds },
set: { value in model.duration = .secondsInDefaultTimescale(value) }
)
}
var currentTimeBinding: Binding<Double> {
Binding<Double>(
get: { model.currentTime.seconds },
set: { value in model.currentTime = .secondsInDefaultTimescale(value) }
)
TimelineView(context: .player).foregroundColor(.primary)
}
private var hidePlayerButton: some View {
@ -195,20 +188,20 @@ struct PlayerControls: View {
}
var buttonsBar: some View {
HStack {
HStack(spacing: 20) {
#if !os(tvOS)
fullscreenButton
#if os(iOS)
pipButton
.padding(.leading, 5)
#endif
Spacer()
rateButton
button("overlay", systemImage: "info.circle") {}
musicModeButton
button("settings", systemImage: "gearshape", active: model.presentingControlsOverlay) {
withAnimation(Self.animation) {
model.presentingControlsOverlay.toggle()
}
}
closeVideoButton
#endif
@ -227,74 +220,6 @@ struct PlayerControls: View {
#endif
}
@ViewBuilder private var rateButton: some View {
#if os(macOS)
ratePicker
.labelsHidden()
.frame(maxWidth: 70)
#elseif os(iOS)
Menu {
ratePicker
.frame(width: 45, height: 30)
#if os(iOS)
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
#endif
.mask(RoundedRectangle(cornerRadius: 3))
} label: {
Text(player.rateLabel(player.currentRate))
.foregroundColor(.primary)
}
.buttonStyle(.plain)
.foregroundColor(.primary)
.frame(width: 50, height: 30)
#if os(macOS)
.background(VisualEffectBlur(material: .hudWindow))
#elseif os(iOS)
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
#endif
.mask(RoundedRectangle(cornerRadius: 3))
#endif
}
@ViewBuilder private var qualityButton: some View {
#if os(macOS)
StreamControl()
.labelsHidden()
.frame(maxWidth: 300)
#elseif os(iOS)
Menu {
StreamControl()
.frame(width: 45, height: 30)
#if os(iOS)
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
#endif
.mask(RoundedRectangle(cornerRadius: 3))
} label: {
Text(player.streamSelection?.shortQuality ?? "loading")
.frame(width: 140, height: 30)
.foregroundColor(.primary)
}
.buttonStyle(.plain)
.foregroundColor(.primary)
.frame(width: 140, height: 30)
#if os(macOS)
.background(VisualEffectBlur(material: .hudWindow))
#elseif os(iOS)
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
#endif
.mask(RoundedRectangle(cornerRadius: 3))
#endif
}
private var backendButton: some View {
button(player.activeBackend.label, width: 100) {
player.saveTime {
player.changeActiveBackend(from: player.activeBackend, to: player.activeBackend.next())
model.resetTimer()
}
}
}
private var closeVideoButton: some View {
button("Close", systemImage: "xmark") {
player.pause()
@ -313,54 +238,72 @@ struct PlayerControls: View {
}
private var musicModeButton: some View {
button("Music Mode", systemImage: "music.note", active: player.musicMode, action: player.toggleMusicMode)
button("Music Mode", systemImage: "music.note", background: false, active: player.musicMode, action: player.toggleMusicMode)
.disabled(player.activeBackend == .appleAVPlayer)
}
var ratePicker: some View {
Picker("Rate", selection: rateBinding) {
ForEach(PlayerModel.availableRates, id: \.self) { rate in
Text(player.rateLabel(rate)).tag(rate)
}
}
.transaction { t in t.animation = .none }
}
private var rateBinding: Binding<Float> {
.init(get: { player.currentRate }, set: { rate in player.currentRate = rate })
}
private var pipButton: some View {
button("PiP", systemImage: "pip") {
model.startPiP()
}
}
var mediumButtonsBar: some View {
var floatingControls: some View {
HStack {
#if !os(tvOS)
restartVideoButton
.padding(.trailing, 15)
HStack(spacing: 20) {
togglePlayButton
seekBackwardButton
seekForwardButton
}
.frame(maxWidth: .infinity, alignment: .leading)
button("Seek Backward", systemImage: "gobackward.10", size: 30, cornerRadius: 5) {
player.backend.seek(relative: .secondsInDefaultTimescale(-10))
Spacer()
HStack(spacing: 20) {
restartVideoButton
advanceToNextItemButton
musicModeButton
}
.frame(maxWidth: .infinity, alignment: .trailing)
}
.font(.system(size: 20))
}
var seekBackwardButton: some View {
button("Seek Backward", systemImage: "gobackward.10", size: 25, cornerRadius: 5, background: false) {
player.backend.seek(relative: .secondsInDefaultTimescale(-10))
}
#if os(tvOS)
.focused($focusedField, equals: .backward)
#else
.keyboardShortcut("k", modifiers: [])
.keyboardShortcut(KeyEquivalent.leftArrow, modifiers: [])
#endif
}
var seekForwardButton: some View {
button("Seek Forward", systemImage: "goforward.10", size: 25, cornerRadius: 5, background: false) {
player.backend.seek(relative: .secondsInDefaultTimescale(10))
}
#if os(tvOS)
.focused($focusedField, equals: .forward)
#else
.keyboardShortcut("l", modifiers: [])
.keyboardShortcut(KeyEquivalent.rightArrow, modifiers: [])
#endif
}
Spacer()
private var restartVideoButton: some View {
button("Restart video", systemImage: "backward.end.fill", size: 25, cornerRadius: 5, background: false) {
player.backend.seek(to: 0.0)
}
}
private var togglePlayButton: some View {
button(
model.isPlaying ? "Pause" : "Play",
systemImage: model.isPlaying ? "pause.fill" : "play.fill",
size: 30, cornerRadius: 5
size: 25, cornerRadius: 5, background: false
) {
player.backend.togglePlay()
}
@ -371,58 +314,23 @@ struct PlayerControls: View {
.keyboardShortcut(.space)
#endif
.disabled(model.isLoadingVideo)
Spacer()
#if !os(tvOS)
button("Seek Forward", systemImage: "goforward.10", size: 30, cornerRadius: 5) {
player.backend.seek(relative: .secondsInDefaultTimescale(10))
}
#if os(tvOS)
.focused($focusedField, equals: .forward)
#else
.keyboardShortcut("l", modifiers: [])
.keyboardShortcut(KeyEquivalent.rightArrow, modifiers: [])
#endif
advanceToNextItemButton
.padding(.leading, 15)
#endif
}
.font(.system(size: 20))
}
private var restartVideoButton: some View {
button("Restart video", systemImage: "backward.end.fill", size: 30, cornerRadius: 5) {
player.backend.seek(to: 0.0)
}
}
private var advanceToNextItemButton: some View {
button("Next", systemImage: "forward.fill", size: 30, cornerRadius: 5) {
button("Next", systemImage: "forward.fill", size: 25, cornerRadius: 5, background: false) {
player.advanceToNextItem()
}
.disabled(player.queue.isEmpty)
}
var bottomBar: some View {
HStack {
Text(model.playbackTime)
}
.font(.system(size: 15))
.padding(.horizontal, 5)
.padding(.vertical, 3)
.labelStyle(.iconOnly)
.foregroundColor(.primary)
}
func button(
_ label: String,
systemImage: String? = nil,
size: Double = 30,
size: Double = 25,
width: Double? = nil,
height: Double? = nil,
cornerRadius: Double = 3,
background: Bool = true,
active: Bool = false,
action: @escaping () -> Void = {}
) -> some View {
@ -442,39 +350,30 @@ struct PlayerControls: View {
.padding()
.contentShape(Rectangle())
}
.font(.system(size: 13))
.buttonStyle(.plain)
.foregroundColor(active ? .accentColor : .primary)
.foregroundColor(active ? Color("AppRedColor") : .primary)
.frame(width: width ?? size, height: height ?? size)
#if os(macOS)
.background(VisualEffectBlur(material: .hudWindow))
#elseif os(iOS)
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
#endif
.mask(RoundedRectangle(cornerRadius: cornerRadius))
.modifier(ControlBackgroundModifier(enabled: background))
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
}
var fullScreenLayout: Bool {
#if os(iOS)
model.playingFullscreen || verticalSizeClass == .compact
player.playingFullScreen || verticalSizeClass == .compact
#else
model.playingFullscreen
player.playingFullScreen
#endif
}
}
struct PlayerControls_Previews: PreviewProvider {
static var previews: some View {
let model = PlayerControlsModel()
model.presentingControls = true
model.currentTime = .secondsInDefaultTimescale(0)
model.duration = .secondsInDefaultTimescale(120)
return ZStack {
ZStack {
Color.gray
PlayerControls(player: PlayerModel(), thumbnails: ThumbnailsModel())
.injectFixtureEnvironmentObjects()
.environmentObject(model)
}
}
}

View File

@ -11,7 +11,7 @@ final class MPVOGLView: GLKView {
var needsDrawing = true
override init(frame: CGRect) {
guard let context = EAGLContext(api: .openGLES3) else {
guard let context = EAGLContext(api: .openGLES2) else {
print("Failed to initialize OpenGLES 2.0 context")
exit(1)
}
@ -20,10 +20,12 @@ final class MPVOGLView: GLKView {
super.init(frame: frame, context: context)
EAGLContext.setCurrent(context)
self.context = context
bindDrawable()
defaultFBO = -1
isOpaque = false
isOpaque = true
enableSetNeedsDisplay = false
fillBlack()
}

View File

@ -2,7 +2,6 @@ import UIKit
final class MPVViewController: UIViewController {
var client: MPVClient!
var glView: MPVOGLView!
init() {
client = MPVClient()
@ -17,9 +16,8 @@ final class MPVViewController: UIViewController {
super.loadView()
client.create(frame: view.frame)
glView = client.glView
view.addSubview(glView)
view.addSubview(client.glView)
super.viewDidLoad()
}

View File

@ -14,7 +14,7 @@ struct NoCommentsView: View {
.font(.system(size: 12))
#endif
}
.frame(minWidth: 0, maxWidth: .infinity)
.frame(minWidth: 0, maxWidth: .infinity, maxHeight: .infinity)
#if !os(tvOS)
.foregroundColor(.secondary)
#endif

View File

@ -6,7 +6,7 @@ import SwiftUI
struct PlayerQueueRow: View {
let item: PlayerQueueItem
var history = false
@Binding var fullScreen: Bool
var fullScreen: Bool
@EnvironmentObject<PlayerModel> private var player
@ -14,10 +14,10 @@ struct PlayerQueueRow: View {
@FetchRequest private var watchRequest: FetchedResults<Watch>
init(item: PlayerQueueItem, history: Bool = false, fullScreen: Binding<Bool> = .constant(false)) {
init(item: PlayerQueueItem, history: Bool = false, fullScreen: Bool = false) {
self.item = item
self.history = history
_fullScreen = fullScreen
self.fullScreen = fullScreen
_watchRequest = FetchRequest<Watch>(
entity: Watch.entity(),
sortDescriptors: [],
@ -32,6 +32,8 @@ struct PlayerQueueRow: View {
player.avPlayerBackend.startPictureInPictureOnPlay = player.playingInPictureInPicture
player.videoBeingOpened = item.video
if history {
player.playHistory(item, at: watchStoppedAt)
} else {
@ -39,9 +41,9 @@ struct PlayerQueueRow: View {
}
if fullScreen {
withAnimation {
fullScreen = false
}
// withAnimation {
// fullScreen = false
// }
}
if closePiPOnNavigation, player.playingInPictureInPicture {

View File

@ -3,8 +3,8 @@ import Foundation
import SwiftUI
struct PlayerQueueView: View {
@Binding var sidebarQueue: Bool
@Binding var fullScreen: Bool
var sidebarQueue: Bool
var fullScreen: Bool
@FetchRequest(sortDescriptors: [.init(key: "watchedAt", ascending: false)])
var watches: FetchedResults<Watch>
@ -49,7 +49,7 @@ struct PlayerQueueView: View {
}
ForEach(player.queue) { item in
PlayerQueueRow(item: item, fullScreen: $fullScreen)
PlayerQueueRow(item: item, fullScreen: fullScreen)
.contextMenu {
removeButton(item)
removeAllButton()
@ -70,7 +70,7 @@ struct PlayerQueueView: View {
PlayerQueueRow(
item: PlayerQueueItem.from(watch, video: player.historyVideo(watch.videoID)),
history: true,
fullScreen: $fullScreen
fullScreen: fullScreen
)
.onAppear {
player.loadHistoryVideoDetails(watch.videoID)
@ -89,7 +89,7 @@ struct PlayerQueueView: View {
if !player.currentVideo.isNil, !player.currentVideo!.related.isEmpty {
Section(header: Text("Related")) {
ForEach(player.currentVideo!.related) { video in
PlayerQueueRow(item: PlayerQueueItem(video), fullScreen: $fullScreen)
PlayerQueueRow(item: PlayerQueueItem(video), fullScreen: fullScreen)
.contextMenu {
Button {
player.playNext(video)
@ -137,7 +137,7 @@ struct PlayerQueueView: View {
struct PlayerQueueView_Previews: PreviewProvider {
static var previews: some View {
VStack {
PlayerQueueView(sidebarQueue: .constant(true), fullScreen: .constant(true))
PlayerQueueView(sidebarQueue: true, fullScreen: true)
}
.injectFixtureEnvironmentObjects()
}

View File

@ -1,15 +1,20 @@
import Defaults
import SwiftUI
struct RelatedView: View {
@EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<PlaylistsModel> private var playlists
var body: some View {
List {
if !player.currentVideo.isNil, !player.currentVideo!.related.isEmpty {
if let related = player.currentVideo?.related {
Section(header: Text("Related")) {
ForEach(player.currentVideo!.related) { video in
PlayerQueueRow(item: PlayerQueueItem(video), fullScreen: .constant(false))
ForEach(related) { video in
PlayerQueueRow(item: PlayerQueueItem(video))
.contextMenu {
Section {
Button {
player.playNext(video)
} label: {
@ -21,6 +26,25 @@ struct RelatedView: View {
Label("Play Last", systemImage: "text.append")
}
}
if accounts.app.supportsUserPlaylists && accounts.signedIn {
Section {
Button {
navigation.presentAddToPlaylist(video)
} label: {
Label("Add to playlist...", systemImage: "text.badge.plus")
}
if let playlist = playlists.lastUsed {
Button {
playlists.addVideo(playlistID: playlist.id, videoID: video.videoID, navigation: navigation)
} label: {
Label("Add to \(playlist.title)", systemImage: "text.badge.star")
}
}
}
}
}
}
}
}

View File

@ -1,11 +1,34 @@
import SwiftUI
struct TimelineView: View {
@Binding private var duration: Double
@Binding private var current: Double
enum Context {
case controls
case player
}
private var duration: Double {
playerTime.duration.seconds
}
private var current: Double {
get {
playerTime.currentTime.seconds
}
set(value) {
playerTime.currentTime = .secondsInDefaultTimescale(value)
}
}
@State private var size = CGSize.zero
@State private var dragging = false
@State private var tooltipSize = CGSize.zero
@State private var dragging = false { didSet {
if dragging {
player.backend.stopControlsUpdates()
} else {
player.backend.startControlsUpdates()
}
}}
@State private var dragOffset: Double = 0
@State private var draggedFrom: Double = 0
@ -13,38 +36,119 @@ struct TimelineView: View {
private var height = 8.0
var cornerRadius: Double
var thumbTooltipWidth: Double = 100
var thumbAreaWidth: Double = 40
var context: Context
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<PlayerControlsModel> private var controls
@EnvironmentObject<PlayerTimeModel> private var playerTime
init(duration: Binding<Double>, current: Binding<Double>, cornerRadius: Double = 10.0) {
_duration = duration
_current = current
var chapters: [Chapter] {
player.currentVideo?.chapters ?? []
}
init(
cornerRadius: Double = 10.0,
context: Context = .controls
) {
self.cornerRadius = cornerRadius
self.context = context
}
var body: some View {
ZStack(alignment: .leading) {
VStack {
Group {
RoundedRectangle(cornerRadius: cornerRadius)
.foregroundColor(.blue)
.frame(maxHeight: height)
VStack(spacing: 3) {
if dragging {
if let segment = projectedSegment,
let description = SponsorBlockAPI.categoryDescription(segment.category)
{
Text(description)
.font(.system(size: 8))
.fixedSize()
.lineLimit(1)
.foregroundColor(Color("AppRedColor"))
}
if let chapter = projectedChapter {
Text(chapter.title)
.lineLimit(3)
.font(.system(size: 11).bold())
.frame(maxWidth: 250)
.fixedSize()
}
}
Text((dragging ? projectedValue : current).formattedAsPlaybackTime(allowZero: true) ?? PlayerTimeModel.timePlaceholder)
.font(.system(size: 11).monospacedDigit())
}
RoundedRectangle(cornerRadius: cornerRadius)
.fill(Color.green)
.padding(.vertical, 3)
.padding(.horizontal, 8)
.background(
RoundedRectangle(cornerRadius: 3)
.foregroundColor(.black)
)
.foregroundColor(.white)
}
.animation(.easeInOut(duration: 0.2))
.frame(maxHeight: 300, alignment: .bottom)
.offset(x: thumbTooltipOffset)
.overlay(GeometryReader { proxy in
Color.clear
.onAppear {
tooltipSize = proxy.size
}
.onChange(of: proxy.size) { _ in
tooltipSize = proxy.size
}
})
.frame(height: 80)
.opacity(dragging ? 1 : 0)
.animation(.easeOut, value: thumbTooltipOffset)
HStack(spacing: 4) {
Text((dragging ? projectedValue : nil)?.formattedAsPlaybackTime(allowZero: true) ?? playerTime.currentPlaybackTime)
.frame(minWidth: 35)
ZStack(alignment: .center) {
ZStack(alignment: .leading) {
ZStack(alignment: .leading) {
Rectangle()
.fill(Color.gray.opacity(0.1))
.frame(maxHeight: height)
.zIndex(1)
Rectangle()
.fill(Color.gray.opacity(0.5))
.frame(maxHeight: height)
.frame(width: current * oneUnitWidth)
.zIndex(1)
segmentsLayers
.zIndex(2)
}
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
chaptersLayers
.zIndex(3)
}
Circle()
.strokeBorder(.gray, lineWidth: 1)
.background(Circle().fill(dragging ? .gray : .white))
.contentShape(Rectangle())
.foregroundColor(.clear)
.background(
ZStack {
Circle()
.fill(dragging ? .white : .gray)
.frame(maxWidth: 8)
Circle()
.fill(dragging ? .gray : .white)
.frame(maxWidth: 6)
}
)
.offset(x: thumbOffset)
.foregroundColor(.red.opacity(0.6))
.frame(maxHeight: height * 4)
.frame(maxWidth: thumbAreaWidth, minHeight: thumbAreaWidth)
#if !os(tvOS)
.gesture(
@ -69,9 +173,10 @@ struct TimelineView: View {
}
}
.onEnded { _ in
current = projectedValue
if abs(dragOffset) > 0 {
playerTime.currentTime = .secondsInDefaultTimescale(projectedValue)
player.backend.seek(to: projectedValue)
}
dragging = false
dragOffset = 0.0
@ -80,18 +185,8 @@ struct TimelineView: View {
}
)
#endif
ZStack {
RoundedRectangle(cornerRadius: cornerRadius)
.frame(maxWidth: thumbTooltipWidth, maxHeight: 30)
Text(projectedValue.formattedAsPlaybackTime() ?? "--:--")
.foregroundColor(.black)
}
.animation(.linear(duration: 0.1))
.opacity(dragging ? 1 : 0)
.offset(x: thumbTooltipOffset, y: -(height * 2) - 7)
}
.background(GeometryReader { proxy in
Color.clear
.onAppear {
@ -101,59 +196,117 @@ struct TimelineView: View {
self.size = size
}
})
.frame(maxHeight: 20)
#if !os(tvOS)
.gesture(DragGesture(minimumDistance: 0).onEnded { value in
let target = (value.location.x / size.width) * units
current = target
self.playerTime.currentTime = .secondsInDefaultTimescale(target)
player.backend.seek(to: target)
})
#endif
Text(dragging ? playerTime.durationPlaybackTime : playerTime.withoutSegmentsPlaybackTime)
.clipShape(RoundedRectangle(cornerRadius: 3))
.frame(minWidth: 35)
}
.clipShape(RoundedRectangle(cornerRadius: 3))
.font(.system(size: 9).monospacedDigit())
.zIndex(2)
}
}
var tooltipVeritcalOffset: Double {
var offset = -20.0
if !projectedChapter.isNil {
offset -= 8.0
}
if !projectedSegment.isNil {
offset -= 6.5
}
return offset
}
var projectedValue: Double {
let change = (dragOffset / size.width) * units
let projected = draggedFrom + change
return projected.isFinite ? (duration - projected < (0.01 * duration) ? duration : projected) : start
guard projected.isFinite && projected >= 0 && projected <= duration else {
return 0.0
}
return projected.clamped(to: 0 ... duration)
}
var thumbOffset: Double {
let offset = dragging ? (draggedThumbHorizontalOffset + dragOffset) : thumbHorizontalOffset
let offset = dragging ? draggedThumbHorizontalOffset : thumbHorizontalOffset
return offset.isFinite ? offset : thumbLeadingOffset
}
var thumbTooltipOffset: Double {
let offset = (dragging ? ((current * oneUnitWidth) + dragOffset) : (current * oneUnitWidth)) - (thumbTooltipWidth / 2)
let leadingOffset = size.width / 2 - (tooltipSize.width / 2)
let offsetForThumb = thumbOffset - thumbLeadingOffset
return offset.clamped(to: minThumbTooltipOffset ... maxThumbTooltipOffset)
guard offsetForThumb > tooltipSize.width / 2 else {
return -leadingOffset
}
var minThumbTooltipOffset: Double = -10
return thumbOffset.clamped(to: -leadingOffset ... leadingOffset)
}
var minThumbTooltipOffset: Double {
60
}
var maxThumbTooltipOffset: Double {
max(minThumbTooltipOffset, (units * oneUnitWidth) - thumbTooltipWidth + 10)
max(minThumbTooltipOffset, units * oneUnitWidth)
}
var segments: [Segment] {
// [.init(category: "outro", segment: [25,30], uuid: UUID().uuidString, videoDuration: 100)] ??
player.sponsorBlock.segments
}
var segmentsLayers: some View {
ForEach(player.sponsorBlock.segments, id: \.uuid) { segment in
RoundedRectangle(cornerRadius: cornerRadius)
ForEach(segments, id: \.uuid) { segment in
Rectangle()
.offset(x: segmentLayerHorizontalOffset(segment))
.foregroundColor(.red)
.foregroundColor(Color("AppRedColor"))
.frame(maxHeight: height)
.frame(width: segmentLayerWidth(segment))
}
}
var projectedSegment: Segment? {
segments.first { $0.timeInSegment(.secondsInDefaultTimescale(projectedValue)) }
}
var projectedChapter: Chapter? {
chapters.last { $0.start <= projectedValue }
}
var chaptersLayers: some View {
ForEach(chapters) { chapter in
RoundedRectangle(cornerRadius: 4)
.fill(Color("AppBlueColor"))
.frame(maxWidth: 2, maxHeight: 12)
.offset(x: (chapter.start * oneUnitWidth) - 1)
}
}
func segmentLayerHorizontalOffset(_ segment: Segment) -> Double {
segment.start * oneUnitWidth
}
func segmentLayerWidth(_ segment: Segment) -> Double {
let width = segment.duration * oneUnitWidth
return width.isFinite ? width : thumbLeadingOffset
return width.isFinite ? width : 1
}
var draggedThumbHorizontalOffset: Double {
thumbLeadingOffset + (draggedFrom * oneUnitWidth)
thumbLeadingOffset + (draggedFrom * oneUnitWidth) + dragOffset
}
var thumbHorizontalOffset: Double {
@ -161,7 +314,7 @@ struct TimelineView: View {
}
var thumbLeadingOffset: Double {
-(size.width / 2)
-size.width / 2
}
var oneUnitWidth: Double {
@ -172,26 +325,33 @@ struct TimelineView: View {
var units: Double {
duration - start
}
func setCurrent(_ current: Double) {
withAnimation {
self.current = current
}
}
}
struct TimelineView_Previews: PreviewProvider {
static var duration = 100.0
static var current = 0.0
static var durationBinding: Binding<Double> = .init(
get: { duration },
set: { value in duration = value }
)
static var currentBinding = Binding<Double>(
get: { current },
set: { value in current = value }
)
static var previews: some View {
VStack(spacing: 40) {
TimelineView(duration: .constant(100), current: .constant(0))
TimelineView(duration: .constant(100), current: .constant(1))
TimelineView(duration: .constant(100), current: .constant(30))
TimelineView(duration: .constant(100), current: .constant(50))
TimelineView(duration: .constant(100), current: .constant(66))
TimelineView(duration: .constant(100), current: .constant(90))
TimelineView(duration: .constant(100), current: .constant(100))
let playerModel = PlayerModel()
playerModel.currentItem = .init(Video.fixture)
let playerTimeModel = PlayerTimeModel()
playerTimeModel.player = playerModel
playerTimeModel.currentTime = .secondsInDefaultTimescale(33)
playerTimeModel.duration = .secondsInDefaultTimescale(100)
return VStack(spacing: 40) {
TimelineView()
}
.environmentObject(PlayerModel())
.environmentObject(playerModel)
.environmentObject(playerTimeModel)
.environmentObject(PlayerControlsModel())
.padding()
}
}

View File

@ -2,14 +2,30 @@ import Defaults
import Foundation
import SDWebImageSwiftUI
import SwiftUI
import SwiftUIPager
struct VideoDetails: View {
enum Page {
case info, comments, related, queue
enum DetailsPage: CaseIterable {
case info, chapters, comments, related, queue
var index: Int {
switch self {
case .info:
return 0
case .chapters:
return 1
case .comments:
return 2
case .related:
return 3
case .queue:
return 4
}
}
}
@Binding var sidebarQueue: Bool
@Binding var fullScreen: Bool
var sidebarQueue: Bool
var fullScreen: Bool
@State private var subscribed = false
@State private var subscriptionToggleButtonDisabled = false
@ -18,89 +34,82 @@ struct VideoDetails: View {
@State private var presentingShareSheet = false
@State private var shareURL: URL?
@State private var currentPage = Page.info
@StateObject private var page: Page = .first()
@Environment(\.presentationMode) private var presentationMode
@Environment(\.navigationStyle) private var navigationStyle
@EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<CommentsModel> private var comments
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<RecentsModel> private var recents
@EnvironmentObject<SubscriptionsModel> private var subscriptions
@Default(.showKeywords) private var showKeywords
@Default(.playerDetailsPageButtonLabelStyle) private var playerDetailsPageButtonLabelStyle
@Default(.controlsBarInPlayer) private var controlsBarInPlayer
init(
sidebarQueue: Binding<Bool>? = nil,
fullScreen: Binding<Bool>? = nil
) {
_sidebarQueue = sidebarQueue ?? .constant(true)
_fullScreen = fullScreen ?? .constant(false)
var currentPage: DetailsPage {
DetailsPage.allCases.first { $0.index == page.index } ?? .info
}
var video: Video? {
player.currentVideo
}
var body: some View {
VStack(alignment: .leading) {
Group {
Group {
HStack(spacing: 0) {
title
func pageButton(
_ label: String,
_ symbolName: String,
_ destination: DetailsPage,
pageChangeAction: (() -> Void)? = nil
) -> some View {
Button(action: {
page.update(.new(index: destination.index))
pageChangeAction?()
}) {
HStack {
Spacer()
toggleFullScreenDetailsButton
}
#if os(macOS)
.padding(.top, 10)
#endif
HStack(spacing: 4) {
Image(systemName: symbolName)
if !video.isNil {
Divider()
if playerDetailsPageButtonLabelStyle.text {
Text(label)
}
}
.frame(minHeight: 15)
.lineLimit(1)
.padding(.vertical, 4)
.foregroundColor(currentPage == destination ? .white : .accentColor)
subscriptionsSection
.onChange(of: video) { video in
if let video = video {
subscribed = subscriptions.isSubscribing(video.channel.id)
}
}
}
.padding(.horizontal)
if !sidebarQueue ||
(CommentsModel.enabled && CommentsModel.placement == .separate)
{
pagePicker
.padding(.horizontal)
}
Spacer()
}
.contentShape(Rectangle())
.onSwipeGesture(
up: {
withAnimation {
fullScreen = true
}
},
down: {
withAnimation {
if fullScreen {
fullScreen = false
} else {
self.player.hide()
}
}
}
.background(currentPage == destination ? Color.accentColor : .clear)
.buttonStyle(.plain)
.font(.system(size: 10).bold())
.overlay(
RoundedRectangle(cornerRadius: 2)
.stroke(Color.accentColor, lineWidth: 2)
.foregroundColor(.clear)
)
.frame(maxWidth: .infinity)
}
switch currentPage {
@ViewBuilder func detailsByPage(_ page: DetailsPage) -> some View {
Group {
switch page {
case .info:
ScrollView(.vertical, showsIndicators: false) {
detailsPage
}
case .chapters:
ChaptersView()
.edgesIgnoringSafeArea(.horizontal)
case .queue:
PlayerQueueView(sidebarQueue: $sidebarQueue, fullScreen: $fullScreen)
PlayerQueueView(sidebarQueue: sidebarQueue, fullScreen: fullScreen)
.edgesIgnoringSafeArea(.horizontal)
case .related:
@ -111,9 +120,54 @@ struct VideoDetails: View {
.edgesIgnoringSafeArea(.horizontal)
}
}
.contentShape(Rectangle())
}
var body: some View {
VStack(alignment: .leading) {
Group {
// Group {
// subscriptionsSection
// .border(.red, width: 4)
//
// .onChange(of: video) { video in
// if let video = video {
// subscribed = subscriptions.isSubscribing(video.channel.id)
// }
// }
// }
// .padding(.top, 4)
// .padding(.horizontal)
HStack(spacing: 4) {
pageButton("Info", "info.circle", .info)
pageButton("Chapters", "bookmark", .chapters)
pageButton("Comments", "text.bubble", .comments) { comments.load() }
pageButton("Related", "rectangle.stack.fill", .related)
pageButton("Queue", "list.number", .queue)
}
.onChange(of: player.currentItem) { _ in
page.update(.moveToFirst)
}
.padding(.horizontal)
.padding(.top, 8)
}
.contentShape(Rectangle())
Pager(page: page, data: DetailsPage.allCases, id: \.self) {
detailsByPage($0)
}
.onPageWillChange { pageIndex in
if pageIndex == DetailsPage.comments.index {
comments.load()
} else {
print("comments not loading")
}
}
}
.onAppear {
if video.isNil && !sidebarQueue {
currentPage = .queue
page.update(.new(index: DetailsPage.queue.index))
}
guard video != nil, accounts.app.supportsSubscriptions else {
@ -124,91 +178,56 @@ struct VideoDetails: View {
.onChange(of: sidebarQueue) { queue in
if queue {
if currentPage == .related || currentPage == .queue {
currentPage = .info
page.update(.moveToFirst)
}
} else if video.isNil {
currentPage = .queue
page.update(.moveToLast)
}
}
.edgesIgnoringSafeArea(.horizontal)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .topLeading)
}
var title: some View {
Group {
if video != nil {
Text(video!.title)
.onAppear {
currentPage = .info
}
.contextMenu {
Button {
player.closeCurrentItem()
if !sidebarQueue {
currentPage = .queue
} else {
currentPage = .info
}
} label: {
Label("Close Video", systemImage: "xmark.circle")
}
.disabled(player.currentItem.isNil)
}
.font(.title2.bold())
} else {
Text("Not playing")
.foregroundColor(.secondary)
}
Spacer()
}
}
var toggleFullScreenDetailsButton: some View {
Button {
withAnimation {
fullScreen.toggle()
}
} label: {
Label("Resize", systemImage: fullScreen ? "chevron.down" : "chevron.up")
.labelStyle(.iconOnly)
}
.help("Toggle fullscreen details")
.buttonStyle(.plain)
.keyboardShortcut("t")
var showAddToPlaylistButton: Bool {
accounts.app.supportsUserPlaylists && accounts.signedIn
}
var subscriptionsSection: some View {
Group {
if video != nil {
if let video = video {
HStack(alignment: .center) {
HStack(spacing: 10) {
Group {
ZStack(alignment: .bottomTrailing) {
authorAvatar
// ZStack(alignment: .bottomTrailing) {
// authorAvatar
//
// if subscribed {
// Image(systemName: "star.circle.fill")
// .background(Color.background)
// .clipShape(Circle())
// .foregroundColor(.secondary)
// }
// }
if subscribed {
Image(systemName: "star.circle.fill")
.background(Color.background)
.clipShape(Circle())
.foregroundColor(.secondary)
}
}
VStack(alignment: .leading) {
Text(video!.channel.name)
.font(.system(size: 14))
.bold()
Group {
if let subscribers = video!.channel.subscriptionsString {
Text("\(subscribers) subscribers")
}
}
.foregroundColor(.secondary)
.font(.caption2)
}
// VStack(alignment: .leading, spacing: 4) {
// Text(video.title)
// .font(.system(size: 11))
// .fontWeight(.bold)
//
// HStack(spacing: 4) {
// Text(video.channel.name)
//
// if let subscribers = video.channel.subscriptionsString {
// Text("")
// .foregroundColor(.secondary)
// .opacity(0.3)
//
// Text("\(subscribers) subscribers")
// }
// }
// .foregroundColor(.secondary)
// .font(.caption2)
// }
}
}
.contentShape(RoundedRectangle(cornerRadius: 12))
@ -227,80 +246,8 @@ struct VideoDetails: View {
}
}
}
if accounts.app.supportsSubscriptions, accounts.signedIn {
Spacer()
Section {
if subscribed {
Button("Unsubscribe") {
presentingUnsubscribeAlert = true
}
#if os(iOS)
.backport
.tint(.gray)
#endif
.alert(isPresented: $presentingUnsubscribeAlert) {
Alert(
title: Text(
"Are you sure you want to unsubscribe from \(video!.channel.name)?"
),
primaryButton: .destructive(Text("Unsubscribe")) {
subscriptionToggleButtonDisabled = true
subscriptions.unsubscribe(video!.channel.id) {
withAnimation {
subscriptionToggleButtonDisabled = false
subscribed.toggle()
}
}
},
secondaryButton: .cancel()
)
}
} else {
Button("Subscribe") {
subscriptionToggleButtonDisabled = true
subscriptions.subscribe(video!.channel.id) {
withAnimation {
subscriptionToggleButtonDisabled = false
subscribed.toggle()
}
}
}
.backport
.tint(subscriptionToggleButtonDisabled ? .gray : .blue)
}
}
.disabled(subscriptionToggleButtonDisabled)
.font(.system(size: 13))
.buttonStyle(.borderless)
}
}
}
}
}
var pagePicker: some View {
Picker("Page", selection: $currentPage) {
if !video.isNil {
Text("Info").tag(Page.info)
if CommentsModel.enabled, CommentsModel.placement == .separate {
Text("Comments").tag(Page.comments)
}
if !sidebarQueue {
Text("Related").tag(Page.related)
}
}
if !sidebarQueue {
Text("Queue").tag(Page.queue)
}
}
.labelsHidden()
.pickerStyle(.segmented)
.onDisappear {
currentPage = .info
}
}
@ -311,30 +258,9 @@ struct VideoDetails: View {
if let published = video.publishedDate {
Text(published)
}
if let date = video.publishedAt {
if video.publishedDate != nil {
Text("")
.foregroundColor(.secondary)
.opacity(0.3)
}
Text(formattedPublishedAt(date))
}
}
.font(.system(size: 12))
.padding(.bottom, -1)
.foregroundColor(.secondary)
}
}
}
func formattedPublishedAt(_ date: Date) -> String {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .short
dateFormatter.timeStyle = .none
return dateFormatter.string(from: date)
}
var countsSection: some View {
@ -386,13 +312,6 @@ struct VideoDetails: View {
.foregroundColor(.secondary)
}
}
.background(
EmptyView().sheet(isPresented: $presentingAddToPlaylist) {
if let video = video {
AddToPlaylistView(video: video)
}
}
)
#if os(iOS)
.background(
EmptyView().sheet(isPresented: $presentingShareSheet) {
@ -419,31 +338,60 @@ struct VideoDetails: View {
.retryOnAppear(true)
.indicator(.activity)
.clipShape(Circle())
.frame(width: 45, height: 45, alignment: .leading)
.frame(width: 35, height: 35, alignment: .leading)
}
}
}
var videoProperties: some View {
HStack(spacing: 2) {
publishedDateSection
Spacer()
HStack(spacing: 4) {
if let views = video?.viewsCount {
Image(systemName: "eye")
Text(views)
}
if let likes = video?.likesCount {
Image(systemName: "hand.thumbsup")
Text(likes)
}
if let likes = video?.dislikesCount {
Image(systemName: "hand.thumbsdown")
Text(likes)
}
}
}
.font(.system(size: 12))
.foregroundColor(.secondary)
}
var detailsPage: some View {
Group {
VStack(alignment: .leading, spacing: 0) {
if let video = player.currentVideo {
VStack(spacing: 6) {
HStack {
publishedDateSection
Spacer()
}
Divider()
countsSection
videoProperties
Divider()
}
.padding(.bottom, 6)
VStack(alignment: .leading, spacing: 10) {
if let description = video.description {
if !player.videoBeingOpened.isNil && (video.description.isNil || video.description!.isEmpty) {
VStack(alignment: .leading, spacing: 0) {
ForEach(1 ... Int.random(in: 3 ... 5), id: \.self) { _ in
Text(String(repeating: Video.fixture.description!, count: Int.random(in: 1 ... 4)))
.redacted(reason: .placeholder)
}
}
} else if let description = video.description {
Group {
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
Text(description)
@ -531,7 +479,7 @@ struct VideoDetails: View {
struct VideoDetails_Previews: PreviewProvider {
static var previews: some View {
VideoDetails(sidebarQueue: .constant(true))
VideoDetails(sidebarQueue: true, fullScreen: false)
.injectFixtureEnvironmentObjects()
}
}

View File

@ -8,7 +8,7 @@ import SwiftUI
struct VideoPlayerView: View {
#if os(iOS)
static let hiddenOffset = max(UIScreen.main.bounds.height, UIScreen.main.bounds.width) + 100
static let hiddenOffset = YatteeApp.isForPreviews ? 0 : max(UIScreen.main.bounds.height, UIScreen.main.bounds.width) + 100
#endif
static let defaultAspectRatio = 16 / 9.0
@ -20,20 +20,22 @@ struct VideoPlayerView: View {
#endif
}
@State private var playerSize: CGSize = .zero
@State private var playerSize: CGSize = .zero { didSet {
if playerSize.width > 900 && Defaults[.playerSidebar] == .whenFits {
sidebarQueue = true
} else {
sidebarQueue = false
}
}}
@State private var hoveringPlayer = false
@State private var fullScreenDetails = false
@State private var sidebarQueue = false
@Environment(\.colorScheme) private var colorScheme
#if os(iOS)
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.verticalSizeClass) private var verticalSizeClass
@Default(.enterFullscreenInLandscape) private var enterFullscreenInLandscape
@Default(.honorSystemOrientationLock) private var honorSystemOrientationLock
@Default(.lockOrientationInFullScreen) private var lockOrientationInFullScreen
@State private var motionManager: CMMotionManager!
@State private var orientation = UIInterfaceOrientation.portrait
@State private var lastOrientation: UIInterfaceOrientation?
@ -46,19 +48,29 @@ struct VideoPlayerView: View {
#endif
@EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<PlayerControlsModel> private var playerControls
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<ThumbnailsModel> private var thumbnails
init() {
if Defaults[.playerSidebar] == .always {
sidebarQueue = true
}
}
var body: some View {
// TODO: remove
if #available(iOS 15.0, macOS 12.0, *) {
_ = Self._printChanges()
}
#if os(macOS)
HSplitView {
return HSplitView {
content
}
.onOpenURL { OpenURLHandler(accounts: accounts, player: player).handle($0) }
.frame(minWidth: 950, minHeight: 700)
#else
GeometryReader { geometry in
return GeometryReader { geometry in
HStack(spacing: 0) {
content
.onAppear {
@ -79,6 +91,11 @@ struct VideoPlayerView: View {
if newValue {
viewVerticalOffset = 0
configureOrientationUpdatesBasedOnAccelerometer()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak player] in
player?.onPresentPlayer?()
player?.onPresentPlayer = nil
}
} else {
if Defaults[.lockPortraitWhenBrowsing] {
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
@ -95,7 +112,7 @@ struct VideoPlayerView: View {
}
#if os(iOS)
.offset(y: viewVerticalOffset)
.animation(.easeIn(duration: 0.2), value: viewVerticalOffset)
.animation(.easeOut(duration: 0.3), value: viewVerticalOffset)
.backport
.persistentSystemOverlays(!fullScreenLayout)
#endif
@ -104,7 +121,7 @@ struct VideoPlayerView: View {
var content: some View {
Group {
Group {
ZStack(alignment: .bottomLeading) {
#if os(tvOS)
playerView
.ignoresSafeArea(.all, edges: .all)
@ -138,17 +155,17 @@ struct VideoPlayerView: View {
VideoPlayerSizeModifier(
geometry: geometry,
aspectRatio: player.avPlayerBackend.controller?.aspectRatio,
fullScreen: playerControls.playingFullscreen
fullScreen: player.playingFullScreen
)
)
.overlay(playerPlaceholder(geometry: geometry))
// .overlay(playerPlaceholder(geometry: geometry))
#endif
}
}
.frame(maxWidth: fullScreenLayout ? .infinity : nil, maxHeight: fullScreenLayout ? .infinity : nil)
.onHover { hovering in
hoveringPlayer = hovering
hovering ? playerControls.show() : playerControls.hide()
// hovering ? playerControls.show() : playerControls.hide()
}
#if !os(macOS)
.gesture(
@ -169,10 +186,8 @@ struct VideoPlayerView: View {
return
}
withAnimation(.easeInOut(duration: 0.2)) {
viewVerticalOffset = drag
}
}
.onEnded { _ in
if viewVerticalOffset > 100 {
player.backend.setNeedsDrawing(false)
@ -185,29 +200,30 @@ struct VideoPlayerView: View {
}
)
#else
.onAppear(perform: {
NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved]) {
if hoveringPlayer {
playerControls.resetTimer()
}
return $0
}
})
// .onAppear(perform: {
// NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved]) {
// if hoveringPlayer {
// playerControls.resetTimer()
// }
//
// return $0
// }
// })
#endif
.background(Color.black)
.background(Color.black)
#if !os(tvOS)
if !playerControls.playingFullscreen {
Group {
if !player.playingFullScreen {
VStack(spacing: 0) {
#if os(iOS)
if verticalSizeClass == .regular {
VideoDetails(sidebarQueue: sidebarQueueBinding, fullScreen: $fullScreenDetails)
VideoDetails(sidebarQueue: sidebarQueue, fullScreen: fullScreenDetails)
}
#else
VideoDetails(sidebarQueue: sidebarQueueBinding, fullScreen: $fullScreenDetails)
VideoDetails(sidebarQueue: sidebarQueue, fullScreen: fullScreenDetails)
#endif
}
.background(colorScheme == .dark ? Color.black : Color.white)
@ -220,28 +236,35 @@ struct VideoPlayerView: View {
#endif
}
#endif
#if !os(tvOS)
if !fullScreenLayout {
ControlsBar()
}
#endif
}
.background(((colorScheme == .dark || fullScreenLayout) ? Color.black : Color.white).edgesIgnoringSafeArea(.all))
#if os(macOS)
.frame(minWidth: 650)
#endif
if !playerControls.playingFullscreen {
if !player.playingFullScreen {
#if os(iOS)
if sidebarQueue {
PlayerQueueView(sidebarQueue: .constant(true), fullScreen: $fullScreenDetails)
PlayerQueueView(sidebarQueue: true, fullScreen: fullScreenDetails)
.frame(maxWidth: 350)
}
#elseif os(macOS)
if Defaults[.playerSidebar] != .never {
PlayerQueueView(sidebarQueue: sidebarQueueBinding, fullScreen: $fullScreenDetails)
PlayerQueueView(sidebarQueue: true, fullScreen: fullScreenDetails)
.frame(minWidth: 300)
}
#endif
}
}
.transition(.asymmetric(insertion: .slide, removal: .identity))
.ignoresSafeArea(.all, edges: fullScreenLayout ? .vertical : Edge.Set())
#if os(iOS)
.statusBar(hidden: playerControls.playingFullscreen)
.statusBar(hidden: player.playingFullScreen)
.navigationBarHidden(true)
#endif
}
@ -285,9 +308,9 @@ struct VideoPlayerView: View {
var fullScreenLayout: Bool {
#if os(iOS)
playerControls.playingFullscreen || verticalSizeClass == .compact
player.playingFullScreen || verticalSizeClass == .compact
#else
playerControls.playingFullscreen
player.playingFullScreen
#endif
}
@ -357,29 +380,11 @@ struct VideoPlayerView: View {
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: geometry.size.width / Self.defaultAspectRatio)
}
var sidebarQueue: Bool {
switch Defaults[.playerSidebar] {
case .never:
return false
case .always:
return true
case .whenFits:
return playerSize.width > 900
}
}
var sidebarQueueBinding: Binding<Bool> {
Binding(
get: { sidebarQueue },
set: { _ in }
)
}
#if os(iOS)
private func configureOrientationUpdatesBasedOnAccelerometer() {
if UIDevice.current.orientation.isLandscape,
enterFullscreenInLandscape,
!playerControls.playingFullscreen,
Defaults[.enterFullscreenInLandscape],
!player.playingFullScreen,
!player.playingInPictureInPicture
{
DispatchQueue.main.async {
@ -387,7 +392,7 @@ struct VideoPlayerView: View {
}
}
guard !honorSystemOrientationLock, motionManager.isNil else {
guard !Defaults[.honorSystemOrientationLock], motionManager.isNil else {
return
}
@ -422,7 +427,7 @@ struct VideoPlayerView: View {
if orientation.isLandscape {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
guard enterFullscreenInLandscape else {
guard Defaults[.enterFullscreenInLandscape] else {
return
}
@ -433,7 +438,7 @@ struct VideoPlayerView: View {
Orientation.lockOrientation(orientationLockMask, andRotateTo: orientation)
guard lockOrientationInFullScreen else {
guard Defaults[.lockOrientationInFullScreen] else {
return
}
@ -442,8 +447,8 @@ struct VideoPlayerView: View {
} else {
guard abs(acceleration.z) <= 0.74,
player.lockedOrientation.isNil,
enterFullscreenInLandscape,
!lockOrientationInFullScreen
Defaults[.enterFullscreenInLandscape],
!Defaults[.lockOrientationInFullScreen]
else {
return
}
@ -462,14 +467,14 @@ struct VideoPlayerView: View {
let newOrientation = UIApplication.shared.windows.first?.windowScene?.interfaceOrientation
if newOrientation?.isLandscape ?? false,
player.presentingPlayer,
lockOrientationInFullScreen,
Defaults[.lockOrientationInFullScreen],
!player.lockedOrientation.isNil
{
Orientation.lockOrientation(.landscape, andRotateTo: newOrientation)
return
}
guard player.presentingPlayer, enterFullscreenInLandscape, honorSystemOrientationLock else {
guard player.presentingPlayer, Defaults[.enterFullscreenInLandscape], Defaults[.honorSystemOrientationLock] else {
return
}

View File

@ -9,11 +9,11 @@ struct AddToPlaylistView: View {
@State private var error = ""
@State private var presentingErrorAlert = false
@State private var submitButtonDisabled = false
@Environment(\.colorScheme) private var colorScheme
@Environment(\.presentationMode) private var presentationMode
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlaylistsModel> private var model
var body: some View {
@ -123,14 +123,8 @@ struct AddToPlaylistView: View {
HStack {
Spacer()
Button("Add to Playlist", action: addToPlaylist)
.disabled(submitButtonDisabled || selectedPlaylist.isNil)
.disabled(selectedPlaylist.isNil)
.padding(.top, 30)
.alert(isPresented: $presentingErrorAlert) {
Alert(
title: Text("Error when accessing playlist"),
message: Text(error)
)
}
#if !os(tvOS)
.keyboardShortcut(.defaultAction)
#endif
@ -166,20 +160,9 @@ struct AddToPlaylistView: View {
Defaults[.lastUsedPlaylistID] = id
submitButtonDisabled = true
model.addVideo(playlistID: id, videoID: video.videoID, navigation: navigation)
model.addVideo(
playlistID: id,
videoID: video.videoID,
onSuccess: {
presentationMode.wrappedValue.dismiss()
},
onFailure: { requestError in
error = "(\(requestError.httpStatusCode ?? -1)) \(requestError.userMessage)"
presentingErrorAlert = true
submitButtonDisabled = false
}
)
}
private var selectedPlaylist: Playlist? {

View File

@ -66,6 +66,7 @@ struct SearchSuggestions: View {
#endif
}
}
.id(UUID())
#if os(macOS)
.buttonStyle(.link)
#endif

View File

@ -297,6 +297,7 @@ struct SearchView: View {
}
.redrawOn(change: recentsChanged)
}
.id(UUID())
}
#if os(iOS)
.listStyle(.insetGrouped)

View File

@ -1,140 +1,222 @@
import Foundation
import SDWebImageSwiftUI
import SwiftUI
struct BrowserPlayerControls<Content: View, Toolbar: View>: View {
let content: Content
let toolbar: Toolbar?
@Environment(\.navigationStyle) private var navigationStyle
@EnvironmentObject<PlayerControlsModel> private var playerControls
@EnvironmentObject<PlayerModel> private var model
init(@ViewBuilder toolbar: @escaping () -> Toolbar? = { nil }, @ViewBuilder content: @escaping () -> Content) {
self.content = content()
self.toolbar = toolbar()
enum Context {
case browser, player
}
init(@ViewBuilder content: @escaping () -> Content) where Toolbar == EmptyView {
self.init(toolbar: { EmptyView() }, content: content)
let content: Content
init(
context _: Context? = nil,
@ViewBuilder toolbar: @escaping () -> Toolbar? = { nil },
@ViewBuilder content: @escaping () -> Content
) {
self.content = content()
}
init(
context: Context? = nil,
@ViewBuilder content: @escaping () -> Content
) where Toolbar == EmptyView {
self.init(context: context, toolbar: { EmptyView() }, content: content)
}
var body: some View {
ZStack(alignment: .bottomLeading) {
if #available(iOS 15.0, macOS 12.0, *) {
_ = Self._printChanges()
}
return VStack(spacing: 0) {
content
#if !os(tvOS)
.frame(minHeight: 0, maxHeight: .infinity)
#endif
Group {
#if !os(tvOS)
#if !os(macOS)
toolbar
.frame(height: 100)
.offset(x: 0, y: -28)
#endif
controls
ControlsBar()
.edgesIgnoringSafeArea(.bottom)
#endif
}
.borderTop(height: 0.4, color: Color("ControlsBorderColor"))
#if os(macOS)
.background(VisualEffectBlur(material: .sidebar))
#elseif os(iOS)
.background(VisualEffectBlur(blurStyle: .systemThinMaterial).edgesIgnoringSafeArea(.all))
#endif
}
}
private var controls: some View {
HStack {
Button(action: {
model.togglePlayer()
}) {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text(model.currentVideo?.title ?? "Not playing")
.font(.system(size: 14).bold())
.foregroundColor(model.currentItem.isNil ? .secondary : .accentColor)
.lineLimit(1)
if let video = model.currentVideo {
Text(video.author)
.fontWeight(.bold)
.font(.system(size: 10))
.foregroundColor(.secondary)
.lineLimit(1)
}
}
Spacer()
}
.padding(.vertical)
.contentShape(Rectangle())
}
.padding(.vertical, 20)
HStack {
Group {
if !model.currentItem.isNil {
Button {
model.closeCurrentItem()
model.closePiP()
} label: {
Label("Close Video", systemImage: "xmark")
}
}
if playerControls.isPlaying {
Button(action: {
model.pause()
}) {
Label("Pause", systemImage: "pause.fill")
}
} else {
Button(action: {
model.play()
}) {
Label("Play", systemImage: "play.fill")
}
}
}
.disabled(playerControls.isLoadingVideo || model.currentItem.isNil)
.font(.system(size: 30))
.frame(minWidth: 30)
Button(action: { model.advanceToNextItem() }) {
Label("Next", systemImage: "forward.fill")
.padding(.vertical)
.contentShape(Rectangle())
}
.disabled(model.queue.isEmpty)
}
}
.buttonStyle(.plain)
.labelStyle(.iconOnly)
.padding(.horizontal)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: 55)
.padding(.vertical, 0)
.borderTop(height: 0.4, color: Color("ControlsBorderColor"))
.borderBottom(height: navigationStyle == .sidebar ? 0 : 0.4, color: Color("ControlsBorderColor"))
#if !os(tvOS)
.onSwipeGesture(up: {
model.show()
})
#endif
}
private var progressViewValue: Double {
[model.time?.seconds, model.videoDuration].compactMap { $0 }.min() ?? 0
}
private var progressViewTotal: Double {
model.videoDuration ?? 100
}
}
// struct BrowserPlayerControls<Content: View, Toolbar: View>: View {
// enum Context {
// case browser, player
// }
//
// let context: Context
// let content: Content
// let toolbar: Toolbar?
//
// @Environment(\.navigationStyle) private var navigationStyle
// @EnvironmentObject<PlayerControlsModel> private var playerControls
// @EnvironmentObject<PlayerModel> private var model
//
// var barHeight: Double {
// 75
// }
//
// init(
// context: Context? = nil,
// @ViewBuilder toolbar: @escaping () -> Toolbar? = { nil },
// @ViewBuilder content: @escaping () -> Content
// ) {
// self.context = context ?? .browser
// self.content = content()
// self.toolbar = toolbar()
// }
//
// init(
// context: Context? = nil,
// @ViewBuilder content: @escaping () -> Content
// ) where Toolbar == EmptyView {
// self.init(context: context, toolbar: { EmptyView() }, content: content)
// }
//
// var body: some View {
// ZStack(alignment: .bottomLeading) {
// VStack(spacing: 0) {
// content
//
// Color.clear.frame(height: barHeight)
// }
// #if !os(tvOS)
// .frame(minHeight: 0, maxHeight: .infinity)
// #endif
//
//
// VStack {
// #if !os(tvOS)
// #if !os(macOS)
// toolbar
// .frame(height: 100)
// .offset(x: 0, y: -28)
// #endif
//
// if context != .player || !playerControls.playingFullscreen {
// controls
// }
// #endif
// }
// .borderTop(height: 0.4, color: Color("ControlsBorderColor"))
// #if os(macOS)
// .background(VisualEffectBlur(material: .sidebar))
// #elseif os(iOS)
// .background(VisualEffectBlur(blurStyle: .systemThinMaterial).edgesIgnoringSafeArea(.all))
// #endif
// }
// .background(Color.debug)
// }
//
// private var controls: some View {
// VStack(spacing: 0) {
// TimelineView(duration: playerControls.durationBinding, current: playerControls.currentTimeBinding)
// .foregroundColor(.secondary)
//
// Button(action: {
// model.togglePlayer()
// }) {
// HStack(spacing: 8) {
// authorAvatar
//
// VStack(alignment: .leading, spacing: 5) {
// Text(model.currentVideo?.title ?? "Not playing")
// .font(.headline)
// .foregroundColor(model.currentVideo.isNil ? .secondary : .accentColor)
// .lineLimit(1)
//
// Text(model.currentVideo?.author ?? "")
// .font(.subheadline)
// .foregroundColor(.secondary)
// .lineLimit(1)
// }
//
// Spacer()
//
// HStack {
// Group {
// if !model.currentItem.isNil {
// Button {
// model.closeCurrentItem()
// model.closePiP()
// } label: {
// Label("Close Video", systemImage: "xmark")
// .padding(.horizontal, 4)
// .contentShape(Rectangle())
// }
// }
//
// if playerControls.isPlaying {
// Button(action: {
// model.pause()
// }) {
// Label("Pause", systemImage: "pause.fill")
// .padding(.horizontal, 4)
// .contentShape(Rectangle())
// }
// } else {
// Button(action: {
// model.play()
// }) {
// Label("Play", systemImage: "play.fill")
// .padding(.horizontal, 4)
// .contentShape(Rectangle())
// }
// }
// }
// .disabled(playerControls.isLoadingVideo || model.currentItem.isNil)
// .font(.system(size: 30))
// .frame(minWidth: 30)
//
// Button(action: { model.advanceToNextItem() }) {
// Label("Next", systemImage: "forward.fill")
// .padding(.vertical)
// .contentShape(Rectangle())
// }
// .disabled(model.queue.isEmpty)
// }
// }
// .buttonStyle(.plain)
// .contentShape(Rectangle())
// }
// }
// .buttonStyle(.plain)
// .labelStyle(.iconOnly)
// .padding(.horizontal)
// .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: barHeight)
// .borderTop(height: 0.4, color: Color("ControlsBorderColor"))
// .borderBottom(height: navigationStyle == .sidebar ? 0 : 0.4, color: Color("ControlsBorderColor"))
// }
//
// private var authorAvatar: some View {
// Group {
// if let video = model.currentItem?.video, let url = video.channel.thumbnailURL {
// WebImage(url: url)
// .resizable()
// .placeholder {
// Rectangle().fill(Color("PlaceholderColor"))
// }
// .retryOnAppear(true)
// .indicator(.activity)
// .clipShape(Circle())
// .frame(width: 44, height: 44, alignment: .leading)
// }
// }
// }
//
// private var progressViewValue: Double {
// [model.time?.seconds, model.videoDuration].compactMap { $0 }.min() ?? 0
// }
//
// private var progressViewTotal: Double {
// model.videoDuration ?? 100
// }
// }
//
struct PlayerControlsView_Previews: PreviewProvider {
static var previews: some View {
BrowserPlayerControls(context: .player) {
BrowserPlayerControls {
VStack {
Spacer()
@ -142,6 +224,8 @@ struct PlayerControlsView_Previews: PreviewProvider {
Spacer()
}
}
.offset(y: -100)
}
.injectFixtureEnvironmentObjects()
}
}

View File

@ -0,0 +1,221 @@
import Defaults
import SDWebImageSwiftUI
import SwiftUI
import SwiftUIPager
struct ControlsBar: View {
enum Pages: CaseIterable {
case details, controls
}
@Environment(\.navigationStyle) private var navigationStyle
@EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlayerControlsModel> private var playerControls
@EnvironmentObject<PlayerModel> private var model
@EnvironmentObject<PlaylistsModel> private var playlists
@EnvironmentObject<RecentsModel> private var recents
@StateObject private var controlsPage = Page.first()
var body: some View {
VStack(spacing: 0) {
Pager(page: controlsPage, data: Pages.allCases, id: \.self) { index in
switch index {
case .details:
details
default:
controls
}
}
.pagingPriority(.simultaneous)
}
.buttonStyle(.plain)
.labelStyle(.iconOnly)
.padding(.horizontal)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: barHeight)
.borderTop(height: 0.4, color: Color("ControlsBorderColor"))
.borderBottom(height: navigationStyle == .sidebar ? 0 : 0.4, color: Color("ControlsBorderColor"))
.modifier(ControlBackgroundModifier(edgesIgnoringSafeArea: .bottom))
}
var controls: some View {
HStack(spacing: 4) {
Group {
Button {
model.closeCurrentItem()
model.closePiP()
} label: {
Label("Close Video", systemImage: "xmark")
.padding(.horizontal, 4)
.contentShape(Rectangle())
}
Spacer()
Button(action: { model.backend.seek(to: 0) }) {
Label("Restart", systemImage: "backward.end.fill")
.contentShape(Rectangle())
}
Spacer()
Button {
model.backend.seek(relative: .secondsInDefaultTimescale(-10))
} label: {
Label("Backward", systemImage: "gobackward.10")
}
Spacer()
if playerControls.isPlaying {
Button(action: {
model.pause()
}) {
Label("Pause", systemImage: "pause.fill")
.padding(.horizontal, 4)
.contentShape(Rectangle())
}
} else {
Button(action: {
model.play()
}) {
Label("Play", systemImage: "play.fill")
.padding(.horizontal, 4)
.contentShape(Rectangle())
}
}
Spacer()
Button {
model.backend.seek(relative: .secondsInDefaultTimescale(10))
} label: {
Label("Forward", systemImage: "goforward.10")
}
Spacer()
}
.disabled(playerControls.isLoadingVideo || model.currentItem.isNil)
Button(action: { model.advanceToNextItem() }) {
Label("Next", systemImage: "forward.fill")
.contentShape(Rectangle())
}
.disabled(model.queue.isEmpty)
Spacer()
}
.padding(.vertical)
.font(.system(size: 24))
.frame(maxWidth: .infinity)
}
var barHeight: Double {
75
}
var details: some View {
HStack {
HStack(spacing: 8) {
authorAvatar
.contextMenu {
if let video = model.currentVideo {
Group {
Section {
Text(video.title)
if accounts.app.supportsUserPlaylists && accounts.signedIn {
Section {
Button {
navigation.presentAddToPlaylist(video)
} label: {
Label("Add to Playlist...", systemImage: "text.badge.plus")
}
if let playlist = playlists.lastUsed, let video = model.currentVideo {
Button {
playlists.addVideo(playlistID: playlist.id, videoID: video.videoID, navigation: navigation)
} label: {
Label("Add to \(playlist.title)", systemImage: "text.badge.star")
}
}
Button {} label: {
Label("Share", systemImage: "square.and.arrow.up")
}
}
}
Section {
Button {
NavigationModel.openChannel(
video.channel,
player: model,
recents: recents,
navigation: navigation,
navigationStyle: navigationStyle
)
} label: {
Label("\(video.author) Channel", systemImage: "rectangle.stack.fill.badge.person.crop")
}
Button {} label: {
Label("Unsubscribe", systemImage: "xmark.circle")
}
}
}
}
.labelStyle(.automatic)
}
}
VStack(alignment: .leading, spacing: 5) {
Text(model.currentVideo?.title ?? "Not playing")
.font(.system(size: 14))
.fontWeight(.semibold)
.foregroundColor(model.currentVideo.isNil ? .secondary : .accentColor)
.lineLimit(1)
Text(model.currentVideo?.author ?? "")
.font(.system(size: 12))
.foregroundColor(.secondary)
.lineLimit(1)
}
}
.buttonStyle(.plain)
.padding(.vertical)
Spacer()
}
}
private var authorAvatar: some View {
Button {
model.togglePlayer()
} label: {
if let video = model.currentItem?.video, let url = video.channel.thumbnailURL {
WebImage(url: url)
.resizable()
.placeholder {
Rectangle().fill(Color("PlaceholderColor"))
}
.retryOnAppear(true)
.indicator(.activity)
} else {
Image(systemName: "play.rectangle")
.foregroundColor(.accentColor)
.font(.system(size: 30))
}
}
.frame(width: 44, height: 44, alignment: .leading)
.clipShape(Circle())
}
}
struct ControlsBar_Previews: PreviewProvider {
static var previews: some View {
ControlsBar()
.injectFixtureEnvironmentObjects()
}
}

View File

@ -77,9 +77,10 @@ struct VideoContextMenuView: View {
}
}
if accounts.app.supportsUserPlaylists {
if accounts.app.supportsUserPlaylists, accounts.signedIn {
Section {
addToPlaylistButton
addToLastPlaylistButton
if let id = navigation.tabSelection?.playlistID ?? playlistID {
removeFromPlaylistButton(playlistID: id)
@ -116,7 +117,7 @@ struct VideoContextMenuView: View {
Button {
player.play(video, at: .secondsInDefaultTimescale(watch!.stoppedAt))
} label: {
Label("Continue from \(watch!.stoppedAt.formattedAsPlaybackTime() ?? "where I left off")", systemImage: "playpause")
Label("Continue from \(watch!.stoppedAt.formattedAsPlaybackTime(allowZero: true) ?? "where I left off")", systemImage: "playpause")
}
}
@ -230,6 +231,16 @@ struct VideoContextMenuView: View {
}
}
@ViewBuilder private var addToLastPlaylistButton: some View {
if let playlist = playlists.lastUsed {
Button {
playlists.addVideo(playlistID: playlist.id, videoID: video.videoID, navigation: navigation)
} label: {
Label("Add to \(playlist.title)", systemImage: "text.badge.star")
}
}
}
func removeFromPlaylistButton(playlistID: String) -> some View {
Button {
playlists.removeVideo(index: video.indexID!, playlistID: playlistID)

View File

@ -1,4 +1,9 @@
import Defaults
import MediaPlayer
import PINCache
import SDWebImage
import SDWebImageWebPCoder
import Siesta
import SwiftUI
@main
@ -11,19 +16,27 @@ struct YatteeApp: App {
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown"
}
static var isForPreviews: Bool {
ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
}
#if os(macOS)
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
#elseif os(iOS)
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
#endif
@State private var configured = false
@StateObject private var accounts = AccountsModel()
@StateObject private var comments = CommentsModel()
@StateObject private var instances = InstancesModel()
@StateObject private var menu = MenuModel()
@StateObject private var navigation = NavigationModel()
@StateObject private var networkState = NetworkStateModel()
@StateObject private var player = PlayerModel()
@StateObject private var playerControls = PlayerControlsModel()
@StateObject private var playerTime = PlayerTimeModel()
@StateObject private var playlists = PlaylistsModel()
@StateObject private var recents = RecentsModel()
@StateObject private var search = SearchModel()
@ -35,13 +48,16 @@ struct YatteeApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onAppear(perform: configure)
.environment(\.managedObjectContext, persistenceController.container.viewContext)
.environmentObject(accounts)
.environmentObject(comments)
.environmentObject(instances)
.environmentObject(navigation)
.environmentObject(networkState)
.environmentObject(player)
.environmentObject(playerControls)
.environmentObject(playerTime)
.environmentObject(playlists)
.environmentObject(recents)
.environmentObject(subscriptions)
@ -86,6 +102,7 @@ struct YatteeApp: App {
#if os(macOS)
WindowGroup(player.windowTitle) {
VideoPlayerView()
.onAppear(perform: configure)
.background(
HostingWindowFinder { window in
Windows.playerWindow = window
@ -96,7 +113,7 @@ struct YatteeApp: App {
queue: OperationQueue.main
) { _ in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.player.controls.playingFullscreen = false
self.player.playingFullScreen = false
}
}
}
@ -109,8 +126,10 @@ struct YatteeApp: App {
.environmentObject(comments)
.environmentObject(instances)
.environmentObject(navigation)
.environmentObject(networkState)
.environmentObject(player)
.environmentObject(playerControls)
.environmentObject(playerTime)
.environmentObject(playlists)
.environmentObject(recents)
.environmentObject(subscriptions)
@ -129,4 +148,132 @@ struct YatteeApp: App {
}
#endif
}
func configure() {
guard !Self.isForPreviews, !configured else {
return
}
configured = true
SiestaLog.Category.enabled = .common
SDImageCodersManager.shared.addCoder(SDImageWebPCoder.shared)
SDWebImageManager.defaultImageCache = PINCache(name: "stream.yattee.app")
#if !os(macOS)
configureNowPlayingInfoCenter()
#endif
#if os(iOS)
if Defaults[.lockPortraitWhenBrowsing] {
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
}
#endif
if let account = accounts.lastUsed ??
instances.lastUsed?.anonymousAccount ??
InstancesModel.all.first?.anonymousAccount
{
accounts.setCurrent(account)
}
if accounts.current.isNil {
navigation.presentingWelcomeScreen = true
}
playlists.accounts = accounts
search.accounts = accounts
subscriptions.accounts = accounts
comments.player = player
menu.accounts = accounts
menu.navigation = navigation
menu.player = player
playerControls.player = player
player.accounts = accounts
player.comments = comments
player.controls = playerControls
player.networkState = networkState
player.playerTime = playerTime
if !accounts.current.isNil {
player.restoreQueue()
}
if !Defaults[.saveRecents] {
recents.clear()
}
var section = Defaults[.visibleSections].min()?.tabSelection
#if os(macOS)
if section == .playlists {
section = .search
}
#endif
navigation.tabSelection = section ?? .search
subscriptions.load()
playlists.load()
#if os(macOS)
Windows.player.open()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
Windows.main.focus()
}
#endif
}
func configureNowPlayingInfoCenter() {
#if !os(macOS)
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
UIApplication.shared.beginReceivingRemoteControlEvents()
#endif
MPRemoteCommandCenter.shared().playCommand.addTarget { _ in
player.play()
return .success
}
MPRemoteCommandCenter.shared().pauseCommand.addTarget { _ in
player.pause()
return .success
}
MPRemoteCommandCenter.shared().previousTrackCommand.isEnabled = false
MPRemoteCommandCenter.shared().nextTrackCommand.isEnabled = false
MPRemoteCommandCenter.shared().changePlaybackPositionCommand.addTarget { remoteEvent in
guard let event = remoteEvent as? MPChangePlaybackPositionCommandEvent
else {
return .commandFailed
}
player.backend.seek(to: event.positionTime)
return .success
}
let skipForwardCommand = MPRemoteCommandCenter.shared().skipForwardCommand
skipForwardCommand.isEnabled = true
skipForwardCommand.preferredIntervals = [10]
skipForwardCommand.addTarget { _ in
player.backend.seek(relative: .secondsInDefaultTimescale(10))
return .success
}
let skipBackwardCommand = MPRemoteCommandCenter.shared().skipBackwardCommand
skipBackwardCommand.isEnabled = true
skipBackwardCommand.preferredIntervals = [10]
skipBackwardCommand.addTarget { _ in
player.backend.seek(relative: .secondsInDefaultTimescale(-10))
return .success
}
}
}

View File

@ -141,6 +141,7 @@
3711403F26B206A6005B3555 /* SearchModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3711403E26B206A6005B3555 /* SearchModel.swift */; };
3711404026B206A6005B3555 /* SearchModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3711403E26B206A6005B3555 /* SearchModel.swift */; };
3711404126B206A6005B3555 /* SearchModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3711403E26B206A6005B3555 /* SearchModel.swift */; };
371264432865FFD700D77974 /* CMTime+DefaultTimescale.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C0697D2725C8D400F7F6CB /* CMTime+DefaultTimescale.swift */; };
37130A5B277657090033018A /* Yattee.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 37130A59277657090033018A /* Yattee.xcdatamodeld */; };
37130A5C277657090033018A /* Yattee.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 37130A59277657090033018A /* Yattee.xcdatamodeld */; };
37130A5D277657090033018A /* Yattee.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 37130A59277657090033018A /* Yattee.xcdatamodeld */; };
@ -190,6 +191,8 @@
372915E62687E3B900F5A35B /* Defaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 372915E52687E3B900F5A35B /* Defaults.swift */; };
372915E72687E3B900F5A35B /* Defaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 372915E52687E3B900F5A35B /* Defaults.swift */; };
372915E82687E3B900F5A35B /* Defaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 372915E52687E3B900F5A35B /* Defaults.swift */; };
372CFD15285F2E2A00B0B54B /* ControlsBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 372CFD14285F2E2A00B0B54B /* ControlsBar.swift */; };
372CFD16285F2E2A00B0B54B /* ControlsBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 372CFD14285F2E2A00B0B54B /* ControlsBar.swift */; };
372D85DE283841B800FF3C7D /* PiPDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373031F428383A89000CFD59 /* PiPDelegate.swift */; };
372D85DF283842EC00FF3C7D /* PiPDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373031F428383A89000CFD59 /* PiPDelegate.swift */; };
372D85E0283842EE00FF3C7D /* PlayerLayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373031F22838388A000CFD59 /* PlayerLayerView.swift */; };
@ -288,6 +291,18 @@
3751BA8327E6914F007B1A60 /* ReturnYouTubeDislikeAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3751BA8227E6914F007B1A60 /* ReturnYouTubeDislikeAPI.swift */; };
3751BA8427E6914F007B1A60 /* ReturnYouTubeDislikeAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3751BA8227E6914F007B1A60 /* ReturnYouTubeDislikeAPI.swift */; };
3751BA8527E6914F007B1A60 /* ReturnYouTubeDislikeAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3751BA8227E6914F007B1A60 /* ReturnYouTubeDislikeAPI.swift */; };
37520699285E8DD300CA655F /* Chapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37520698285E8DD300CA655F /* Chapter.swift */; };
3752069A285E8DD300CA655F /* Chapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37520698285E8DD300CA655F /* Chapter.swift */; };
3752069B285E8DD300CA655F /* Chapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37520698285E8DD300CA655F /* Chapter.swift */; };
3752069D285E910600CA655F /* ChaptersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3752069C285E910600CA655F /* ChaptersView.swift */; };
3752069E285E910600CA655F /* ChaptersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3752069C285E910600CA655F /* ChaptersView.swift */; };
3752069F285E910600CA655F /* ChaptersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3752069C285E910600CA655F /* ChaptersView.swift */; };
3756C2A62861131100E4B059 /* NetworkState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3756C2A52861131100E4B059 /* NetworkState.swift */; };
3756C2A72861131100E4B059 /* NetworkState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3756C2A52861131100E4B059 /* NetworkState.swift */; };
3756C2A82861131100E4B059 /* NetworkState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3756C2A52861131100E4B059 /* NetworkState.swift */; };
3756C2AA2861151C00E4B059 /* NetworkStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3756C2A92861151C00E4B059 /* NetworkStateModel.swift */; };
3756C2AB2861151C00E4B059 /* NetworkStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3756C2A92861151C00E4B059 /* NetworkStateModel.swift */; };
3756C2AC2861151C00E4B059 /* NetworkStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3756C2A92861151C00E4B059 /* NetworkStateModel.swift */; };
37579D5D27864F5F00FD0B98 /* Help.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37579D5C27864F5F00FD0B98 /* Help.swift */; };
37579D5E27864F5F00FD0B98 /* Help.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37579D5C27864F5F00FD0B98 /* Help.swift */; };
37579D5F27864F5F00FD0B98 /* Help.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37579D5C27864F5F00FD0B98 /* Help.swift */; };
@ -313,6 +328,9 @@
3761ABFF26F0F8DE00AA496F /* EnvironmentValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3761ABFC26F0F8DE00AA496F /* EnvironmentValues.swift */; };
3763495126DFF59D00B9A393 /* AppSidebarRecents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3763495026DFF59D00B9A393 /* AppSidebarRecents.swift */; };
3763495226DFF59D00B9A393 /* AppSidebarRecents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3763495026DFF59D00B9A393 /* AppSidebarRecents.swift */; };
376527BB285F60F700102284 /* PlayerTimeModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376527BA285F60F700102284 /* PlayerTimeModel.swift */; };
376527BC285F60F700102284 /* PlayerTimeModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376527BA285F60F700102284 /* PlayerTimeModel.swift */; };
376527BD285F60F700102284 /* PlayerTimeModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376527BA285F60F700102284 /* PlayerTimeModel.swift */; };
376578852685429C00D4EA09 /* CaseIterable+Next.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376578842685429C00D4EA09 /* CaseIterable+Next.swift */; };
376578862685429C00D4EA09 /* CaseIterable+Next.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376578842685429C00D4EA09 /* CaseIterable+Next.swift */; };
376578872685429C00D4EA09 /* CaseIterable+Next.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376578842685429C00D4EA09 /* CaseIterable+Next.swift */; };
@ -370,8 +388,6 @@
37732FF42703D32400F04329 /* Sidebar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37732FF32703D32400F04329 /* Sidebar.swift */; };
37732FF52703D32400F04329 /* Sidebar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37732FF32703D32400F04329 /* Sidebar.swift */; };
37737786276F9858000521C1 /* Windows.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37737785276F9858000521C1 /* Windows.swift */; };
3774122A27387B6C00423605 /* InstancesModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3774122927387B6C00423605 /* InstancesModelTests.swift */; };
3774122F27387C7600423605 /* VideosApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376A33DF2720CAD6000C1D6B /* VideosApp.swift */; };
3774123327387CB000423605 /* Defaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 372915E52687E3B900F5A35B /* Defaults.swift */; };
3774123427387CC100423605 /* InvidiousAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37977582268922F600DD52A8 /* InvidiousAPI.swift */; };
3774123527387CC700423605 /* PipedAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3700155A271B0D4D0049C794 /* PipedAPI.swift */; };
@ -394,14 +410,9 @@
3774125927387D2300423605 /* ChannelPlaylist.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C3A24427235DA70087A57A /* ChannelPlaylist.swift */; };
3774125A27387D2300423605 /* FavoritesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37599F33272B44000087F250 /* FavoritesModel.swift */; };
3774125B27387D2300423605 /* SingleAssetStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CEE4BC2677B670005A1EFE /* SingleAssetStream.swift */; };
3774125D27387D2D00423605 /* Instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378E50FA26FE8B9F00F49626 /* Instance.swift */; };
3774125E27387D2D00423605 /* InstancesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375DFB5726F9DA010013F468 /* InstancesModel.swift */; };
3774125F27387D2D00423605 /* Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376A33E32720CB35000C1D6B /* Account.swift */; };
3774126027387D2D00423605 /* AccountsBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37169AA52729E2CC0011DE61 /* AccountsBridge.swift */; };
3774126127387D2D00423605 /* AccountsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37001562271B1F250049C794 /* AccountsModel.swift */; };
3774126227387D2D00423605 /* AccountValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37484C3026FCB8F900287258 /* AccountValidator.swift */; };
3774126327387D2D00423605 /* InstancesBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37169AA12729D98A0011DE61 /* InstancesBridge.swift */; };
3774126427387D4A00423605 /* VideosAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D526DD2720AC4400ED2F5E /* VideosAPI.swift */; };
3774126527387D6D00423605 /* Int+Format.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BA794E26DC3E0E002A0235 /* Int+Format.swift */; };
3774126627387D6D00423605 /* Array+Next.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379775922689365600DD52A8 /* Array+Next.swift */; };
3774126727387D6D00423605 /* View+Borders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3743CA51270F284F00E4D32B /* View+Borders.swift */; };
@ -462,6 +473,11 @@
378E50FD26FE8B9F00F49626 /* Instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378E50FA26FE8B9F00F49626 /* Instance.swift */; };
378E50FF26FE8EEE00F49626 /* AccountsMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378E50FE26FE8EEE00F49626 /* AccountsMenuView.swift */; };
378E510026FE8EEE00F49626 /* AccountsMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378E50FE26FE8EEE00F49626 /* AccountsMenuView.swift */; };
378FFBC428660172009E3FBE /* URLParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378FFBC328660172009E3FBE /* URLParser.swift */; };
378FFBC528660172009E3FBE /* URLParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378FFBC328660172009E3FBE /* URLParser.swift */; };
378FFBC628660172009E3FBE /* URLParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378FFBC328660172009E3FBE /* URLParser.swift */; };
378FFBC728660172009E3FBE /* URLParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378FFBC328660172009E3FBE /* URLParser.swift */; };
378FFBC92866018A009E3FBE /* URLParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378FFBC82866018A009E3FBE /* URLParserTests.swift */; };
3795593627B08538007FF8F4 /* StreamControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3795593527B08538007FF8F4 /* StreamControl.swift */; };
3795593727B08538007FF8F4 /* StreamControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3795593527B08538007FF8F4 /* StreamControl.swift */; };
3797757D268922D100DD52A8 /* Siesta in Frameworks */ = {isa = PBXBuildFile; productRef = 3797757C268922D100DD52A8 /* Siesta */; };
@ -482,6 +498,11 @@
37A3B19627257503000FB5EE /* content.js in Resources */ = {isa = PBXBuildFile; fileRef = 37A3B16427255E7F000FB5EE /* content.js */; };
37A3B1982725750B000FB5EE /* manifest.json in Resources */ = {isa = PBXBuildFile; fileRef = 37A3B16027255E7F000FB5EE /* manifest.json */; };
37A3B19B2725750F000FB5EE /* images in Resources */ = {isa = PBXBuildFile; fileRef = 37A3B15E27255E7F000FB5EE /* images */; };
37A5DBC4285DFF5400CA4DD1 /* SwiftUIPager in Frameworks */ = {isa = PBXBuildFile; productRef = 37A5DBC3285DFF5400CA4DD1 /* SwiftUIPager */; };
37A5DBC6285E06B100CA4DD1 /* SwiftUIPager in Frameworks */ = {isa = PBXBuildFile; productRef = 37A5DBC5285E06B100CA4DD1 /* SwiftUIPager */; };
37A5DBC8285E371400CA4DD1 /* ControlBackgroundModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37A5DBC7285E371400CA4DD1 /* ControlBackgroundModifier.swift */; };
37A5DBC9285E371400CA4DD1 /* ControlBackgroundModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37A5DBC7285E371400CA4DD1 /* ControlBackgroundModifier.swift */; };
37A5DBCA285E371400CA4DD1 /* ControlBackgroundModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37A5DBC7285E371400CA4DD1 /* ControlBackgroundModifier.swift */; };
37A9965A26D6F8CA006E3224 /* HorizontalCells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37A9965926D6F8CA006E3224 /* HorizontalCells.swift */; };
37A9965B26D6F8CA006E3224 /* HorizontalCells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37A9965926D6F8CA006E3224 /* HorizontalCells.swift */; };
37A9965C26D6F8CA006E3224 /* HorizontalCells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37A9965926D6F8CA006E3224 /* HorizontalCells.swift */; };
@ -575,6 +596,7 @@
37C0698227260B2100F7F6CB /* ThumbnailsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C0698127260B2100F7F6CB /* ThumbnailsModel.swift */; };
37C0698327260B2100F7F6CB /* ThumbnailsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C0698127260B2100F7F6CB /* ThumbnailsModel.swift */; };
37C0698427260B2100F7F6CB /* ThumbnailsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C0698127260B2100F7F6CB /* ThumbnailsModel.swift */; };
37C0C0FF28665EAC007F6F78 /* VideosApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376A33DF2720CAD6000C1D6B /* VideosApp.swift */; };
37C194C726F6A9C8005D3B96 /* RecentsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C194C626F6A9C8005D3B96 /* RecentsModel.swift */; };
37C194C826F6A9C8005D3B96 /* RecentsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C194C626F6A9C8005D3B96 /* RecentsModel.swift */; };
37C2211D27ADA33300305B41 /* MPVViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C2211C27ADA33300305B41 /* MPVViewController.swift */; };
@ -604,10 +626,6 @@
37C7A1D6267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C7A1D4267BFD9D0010EAD6 /* SponsorBlockSegment.swift */; };
37C7A1D7267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C7A1D4267BFD9D0010EAD6 /* SponsorBlockSegment.swift */; };
37C7A1DA267CACF50010EAD6 /* TrendingCountry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3705B17F267B4DFB00704544 /* TrendingCountry.swift */; };
37CB12792724C76D00213B45 /* VideoURLParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CB12782724C76D00213B45 /* VideoURLParser.swift */; };
37CB127A2724C76D00213B45 /* VideoURLParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CB12782724C76D00213B45 /* VideoURLParser.swift */; };
37CB128B2724CC1F00213B45 /* VideoURLParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CB127B2724C79D00213B45 /* VideoURLParserTests.swift */; };
37CB128C2724CC8400213B45 /* VideoURLParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CB12782724C76D00213B45 /* VideoURLParser.swift */; };
37CC3F45270CE30600608308 /* PlayerQueueItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CC3F44270CE30600608308 /* PlayerQueueItem.swift */; };
37CC3F46270CE30600608308 /* PlayerQueueItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CC3F44270CE30600608308 /* PlayerQueueItem.swift */; };
37CC3F47270CE30600608308 /* PlayerQueueItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CC3F44270CE30600608308 /* PlayerQueueItem.swift */; };
@ -700,10 +718,22 @@
37EF9A77275BEB8E0043B585 /* CommentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37EF9A75275BEB8E0043B585 /* CommentView.swift */; };
37EF9A78275BEB8E0043B585 /* CommentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37EF9A75275BEB8E0043B585 /* CommentView.swift */; };
37EF9A79275BEB8E0043B585 /* CommentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37EF9A75275BEB8E0043B585 /* CommentView.swift */; };
37F13B62285E43C000B137E4 /* ControlsOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F13B61285E43C000B137E4 /* ControlsOverlay.swift */; };
37F13B63285E43C000B137E4 /* ControlsOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F13B61285E43C000B137E4 /* ControlsOverlay.swift */; };
37F13B64285E43C000B137E4 /* ControlsOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F13B61285E43C000B137E4 /* ControlsOverlay.swift */; };
37F49BA326CAA59B00304AC0 /* Playlist+Fixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F49BA226CAA59B00304AC0 /* Playlist+Fixtures.swift */; };
37F49BA426CAA59B00304AC0 /* Playlist+Fixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F49BA226CAA59B00304AC0 /* Playlist+Fixtures.swift */; };
37F49BA526CAA59B00304AC0 /* Playlist+Fixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F49BA226CAA59B00304AC0 /* Playlist+Fixtures.swift */; };
37F49BA826CB0FCE00304AC0 /* PlaylistFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFAEA26975CBF003CB2C6 /* PlaylistFormView.swift */; };
37F4AD1B28612B23004D0F66 /* OpeningStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F4AD1A28612B23004D0F66 /* OpeningStream.swift */; };
37F4AD1C28612B23004D0F66 /* OpeningStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F4AD1A28612B23004D0F66 /* OpeningStream.swift */; };
37F4AD1D28612B23004D0F66 /* OpeningStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F4AD1A28612B23004D0F66 /* OpeningStream.swift */; };
37F4AD1F28612DFD004D0F66 /* Buffering.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F4AD1E28612DFD004D0F66 /* Buffering.swift */; };
37F4AD2028612DFD004D0F66 /* Buffering.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F4AD1E28612DFD004D0F66 /* Buffering.swift */; };
37F4AD2128612DFD004D0F66 /* Buffering.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F4AD1E28612DFD004D0F66 /* Buffering.swift */; };
37F4AD2628613B81004D0F66 /* Color+Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F4AD2528613B81004D0F66 /* Color+Debug.swift */; };
37F4AD2728613B81004D0F66 /* Color+Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F4AD2528613B81004D0F66 /* Color+Debug.swift */; };
37F4AD2828613B81004D0F66 /* Color+Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F4AD2528613B81004D0F66 /* Color+Debug.swift */; };
37F4AE7226828F0900BD60EA /* VerticalCells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F4AE7126828F0900BD60EA /* VerticalCells.swift */; };
37F4AE7326828F0900BD60EA /* VerticalCells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F4AE7126828F0900BD60EA /* VerticalCells.swift */; };
37F4AE7426828F0900BD60EA /* VerticalCells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F4AE7126828F0900BD60EA /* VerticalCells.swift */; };
@ -847,6 +877,7 @@
370F500A27CC176F001B35DC /* BridgingHeader.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BridgingHeader.h; sourceTree = "<group>"; };
371114EA27B94C8800C2EF7B /* RepeatingTimer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepeatingTimer.swift; sourceTree = "<group>"; };
3711403E26B206A6005B3555 /* SearchModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchModel.swift; sourceTree = "<group>"; };
3712643B2865FF4500D77974 /* Shared Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Shared Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
37130A5A277657090033018A /* Yattee.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Yattee.xcdatamodel; sourceTree = "<group>"; };
37130A5E277657300033018A /* PersistenceController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; };
37136CAB286273060095C0CF /* PersistentSystemOverlays+Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersistentSystemOverlays+Backport.swift"; sourceTree = "<group>"; };
@ -867,6 +898,7 @@
3727B74927872A920021C15E /* VisualEffectBlur-iOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VisualEffectBlur-iOS.swift"; sourceTree = "<group>"; };
3729037D2739E47400EA99F6 /* MenuCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuCommands.swift; sourceTree = "<group>"; };
372915E52687E3B900F5A35B /* Defaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Defaults.swift; sourceTree = "<group>"; };
372CFD14285F2E2A00B0B54B /* ControlsBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlsBar.swift; sourceTree = "<group>"; };
373031F22838388A000CFD59 /* PlayerLayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerLayerView.swift; sourceTree = "<group>"; };
373031F428383A89000CFD59 /* PiPDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiPDelegate.swift; sourceTree = "<group>"; };
3730D89F2712E2B70020ED53 /* NowPlayingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NowPlayingView.swift; sourceTree = "<group>"; };
@ -919,6 +951,10 @@
3751BA7D27E63F1D007B1A60 /* MPVOGLView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MPVOGLView.swift; sourceTree = "<group>"; };
3751BA7F27E64244007B1A60 /* VideoLayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoLayer.swift; sourceTree = "<group>"; };
3751BA8227E6914F007B1A60 /* ReturnYouTubeDislikeAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReturnYouTubeDislikeAPI.swift; sourceTree = "<group>"; };
37520698285E8DD300CA655F /* Chapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Chapter.swift; sourceTree = "<group>"; };
3752069C285E910600CA655F /* ChaptersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChaptersView.swift; sourceTree = "<group>"; };
3756C2A52861131100E4B059 /* NetworkState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkState.swift; sourceTree = "<group>"; };
3756C2A92861151C00E4B059 /* NetworkStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkStateModel.swift; sourceTree = "<group>"; };
37579D5C27864F5F00FD0B98 /* Help.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Help.swift; sourceTree = "<group>"; };
37599F2F272B42810087F250 /* FavoriteItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteItem.swift; sourceTree = "<group>"; };
37599F33272B44000087F250 /* FavoritesModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesModel.swift; sourceTree = "<group>"; };
@ -928,6 +964,7 @@
375E45F727B1AC4700BA7902 /* PlayerControlsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerControlsModel.swift; sourceTree = "<group>"; };
3761ABFC26F0F8DE00AA496F /* EnvironmentValues.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EnvironmentValues.swift; sourceTree = "<group>"; };
3763495026DFF59D00B9A393 /* AppSidebarRecents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSidebarRecents.swift; sourceTree = "<group>"; };
376527BA285F60F700102284 /* PlayerTimeModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerTimeModel.swift; sourceTree = "<group>"; };
376578842685429C00D4EA09 /* CaseIterable+Next.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CaseIterable+Next.swift"; sourceTree = "<group>"; };
376578882685471400D4EA09 /* Playlist.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Playlist.swift; sourceTree = "<group>"; };
376578902685490700D4EA09 /* PlaylistsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistsView.swift; sourceTree = "<group>"; };
@ -960,7 +997,6 @@
37732FEF2703A26300F04329 /* AccountValidationStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountValidationStatus.swift; sourceTree = "<group>"; };
37732FF32703D32400F04329 /* Sidebar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar.swift; sourceTree = "<group>"; };
37737785276F9858000521C1 /* Windows.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Windows.swift; sourceTree = "<group>"; };
3774122927387B6C00423605 /* InstancesModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstancesModelTests.swift; sourceTree = "<group>"; };
377A20A82693C9A2002842B8 /* TypedContentAccessors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypedContentAccessors.swift; sourceTree = "<group>"; };
3782B94E27553A6700990149 /* SearchSuggestions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchSuggestions.swift; sourceTree = "<group>"; };
3782B9512755667600990149 /* String+Format.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Format.swift"; sourceTree = "<group>"; };
@ -972,6 +1008,8 @@
378AE942274EF00A006A4EE1 /* Color+Background.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Background.swift"; sourceTree = "<group>"; };
378E50FA26FE8B9F00F49626 /* Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Instance.swift; sourceTree = "<group>"; };
378E50FE26FE8EEE00F49626 /* AccountsMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsMenuView.swift; sourceTree = "<group>"; };
378FFBC328660172009E3FBE /* URLParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLParser.swift; sourceTree = "<group>"; };
378FFBC82866018A009E3FBE /* URLParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLParserTests.swift; sourceTree = "<group>"; };
3795593527B08538007FF8F4 /* StreamControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamControl.swift; sourceTree = "<group>"; };
37977582268922F600DD52A8 /* InvidiousAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvidiousAPI.swift; sourceTree = "<group>"; };
3797758A2689345500DD52A8 /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = "<group>"; };
@ -985,6 +1023,7 @@
37A3B16C27255E7F000FB5EE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
37A3B16D27255E7F000FB5EE /* Open in Yattee.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Open in Yattee.entitlements"; sourceTree = "<group>"; };
37A3B1792725735F000FB5EE /* Open in Yattee (iOS).appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Open in Yattee (iOS).appex"; sourceTree = BUILT_PRODUCTS_DIR; };
37A5DBC7285E371400CA4DD1 /* ControlBackgroundModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlBackgroundModifier.swift; sourceTree = "<group>"; };
37A9965926D6F8CA006E3224 /* HorizontalCells.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HorizontalCells.swift; sourceTree = "<group>"; };
37A9965D26D6F9B9006E3224 /* FavoritesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesView.swift; sourceTree = "<group>"; };
37AAF27D26737323007FC770 /* PopularView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopularView.swift; sourceTree = "<group>"; };
@ -1042,8 +1081,6 @@
37C3A24C272360470087A57A /* ChannelPlaylist+Fixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChannelPlaylist+Fixtures.swift"; sourceTree = "<group>"; };
37C3A250272366440087A57A /* ChannelPlaylistView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelPlaylistView.swift; sourceTree = "<group>"; };
37C7A1D4267BFD9D0010EAD6 /* SponsorBlockSegment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SponsorBlockSegment.swift; sourceTree = "<group>"; };
37CB12782724C76D00213B45 /* VideoURLParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoURLParser.swift; sourceTree = "<group>"; };
37CB127B2724C79D00213B45 /* VideoURLParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoURLParserTests.swift; sourceTree = "<group>"; };
37CC3F44270CE30600608308 /* PlayerQueueItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerQueueItem.swift; sourceTree = "<group>"; };
37CC3F4B270CFE1700608308 /* PlayerQueueView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerQueueView.swift; sourceTree = "<group>"; };
37CC3F4F270D010D00608308 /* VideoBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoBanner.swift; sourceTree = "<group>"; };
@ -1091,7 +1128,11 @@
37EBD8C927AF26C200F1C24B /* MPVBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MPVBackend.swift; sourceTree = "<group>"; };
37EF5C212739D37B00B03725 /* MenuModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuModel.swift; sourceTree = "<group>"; };
37EF9A75275BEB8E0043B585 /* CommentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentView.swift; sourceTree = "<group>"; };
37F13B61285E43C000B137E4 /* ControlsOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlsOverlay.swift; sourceTree = "<group>"; };
37F49BA226CAA59B00304AC0 /* Playlist+Fixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Playlist+Fixtures.swift"; sourceTree = "<group>"; };
37F4AD1A28612B23004D0F66 /* OpeningStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpeningStream.swift; sourceTree = "<group>"; };
37F4AD1E28612DFD004D0F66 /* Buffering.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Buffering.swift; sourceTree = "<group>"; };
37F4AD2528613B81004D0F66 /* Color+Debug.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Debug.swift"; sourceTree = "<group>"; };
37F4AE7126828F0900BD60EA /* VerticalCells.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerticalCells.swift; sourceTree = "<group>"; };
37F64FE326FE70A60081B69E /* RedrawOnModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedrawOnModifier.swift; sourceTree = "<group>"; };
37F9619A27BD89E000058149 /* TapRecognizerViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapRecognizerViewModifier.swift; sourceTree = "<group>"; };
@ -1106,6 +1147,13 @@
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
371264382865FF4500D77974 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
37A3B15427255E7F000FB5EE /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@ -1152,6 +1200,7 @@
37BD07B92698AB2E003EBB87 /* Siesta in Frameworks */,
37BD07C72698B27B003EBB87 /* Introspect in Frameworks */,
37FB284D2722099E00A57617 /* SDWebImageWebPCoder in Frameworks */,
37A5DBC4285DFF5400CA4DD1 /* SwiftUIPager in Frameworks */,
3749BF8A27ADA135000480FF /* libavformat.a in Frameworks */,
377FC7DB267A080300A6BBAF /* Logging in Frameworks */,
37CF8B8428535E4F00B71E37 /* SDWebImage in Frameworks */,
@ -1190,6 +1239,7 @@
370F4FD927CC16CB001B35DC /* libxcb-shm.0.0.0.dylib in Frameworks */,
370F4FCA27CC16CB001B35DC /* libXau.6.dylib in Frameworks */,
370F4FD527CC16CB001B35DC /* libfontconfig.1.dylib in Frameworks */,
37A5DBC6285E06B100CA4DD1 /* SwiftUIPager in Frameworks */,
370F4FCE27CC16CB001B35DC /* libswresample.4.3.100.dylib in Frameworks */,
370F4FDA27CC16CB001B35DC /* libmpv.dylib in Frameworks */,
370F4FD827CC16CB001B35DC /* libcrypto.3.dylib in Frameworks */,
@ -1312,11 +1362,22 @@
371114F227B9552400C2EF7B /* Controls */ = {
isa = PBXGroup;
children = (
3756C2A428610F6D00E4B059 /* OSD */,
37030FFE27B04DCC00ECDDAA /* PlayerControls.swift */,
37A5DBC7285E371400CA4DD1 /* ControlBackgroundModifier.swift */,
37F13B61285E43C000B137E4 /* ControlsOverlay.swift */,
);
path = Controls;
sourceTree = "<group>";
};
3712643C2865FF4500D77974 /* Shared Tests */ = {
isa = PBXGroup;
children = (
378FFBC82866018A009E3FBE /* URLParserTests.swift */,
);
path = "Shared Tests";
sourceTree = "<group>";
};
371AAE2326CEB9E800901972 /* Navigation */ = {
isa = PBXGroup;
children = (
@ -1339,6 +1400,7 @@
375E45F327B1973400BA7902 /* MPV */,
37BE0BD226A1D4780092E2DB /* AppleAVPlayerView.swift */,
37BE0BD526A1D4A90092E2DB /* AppleAVPlayerViewController.swift */,
3752069C285E910600CA655F /* ChaptersView.swift */,
371B7E602759706A00D21217 /* CommentsView.swift */,
37EF9A75275BEB8E0043B585 /* CommentView.swift */,
37DD9DA22785BBC900539416 /* NoCommentsView.swift */,
@ -1397,6 +1459,7 @@
37C3A250272366440087A57A /* ChannelPlaylistView.swift */,
37BA793E26DB8F97002A0235 /* ChannelVideosView.swift */,
37FB285D272225E800A57617 /* ContentItemView.swift */,
372CFD14285F2E2A00B0B54B /* ControlsBar.swift */,
3748186D26A769D60084E870 /* DetailBadge.swift */,
37599F37272B4D740087F250 /* FavoriteButton.swift */,
37152EE926EFEB95004FB96D /* LazyView.swift */,
@ -1453,6 +1516,7 @@
374C053A2724614F009BDDBE /* PlayerTVMenu.swift */,
37C069772725962F00F7F6CB /* ScreenSaverManager.swift */,
375E45F727B1AC4700BA7902 /* PlayerControlsModel.swift */,
376527BA285F60F700102284 /* PlayerTimeModel.swift */,
);
path = Player;
sourceTree = "<group>";
@ -1574,6 +1638,16 @@
path = ReturnYouTubeDislike;
sourceTree = "<group>";
};
3756C2A428610F6D00E4B059 /* OSD */ = {
isa = PBXGroup;
children = (
3756C2A52861131100E4B059 /* NetworkState.swift */,
37F4AD1A28612B23004D0F66 /* OpeningStream.swift */,
37F4AD1E28612DFD004D0F66 /* Buffering.swift */,
);
path = OSD;
sourceTree = "<group>";
};
375E45F327B1973400BA7902 /* MPV */ = {
isa = PBXGroup;
children = (
@ -1717,6 +1791,7 @@
376578842685429C00D4EA09 /* CaseIterable+Next.swift */,
37C0697D2725C8D400F7F6CB /* CMTime+DefaultTimescale.swift */,
378AE942274EF00A006A4EE1 /* Color+Background.swift */,
37F4AD2528613B81004D0F66 /* Color+Debug.swift */,
37E8B0EF27B326F30024006F /* Comparable+Clamped.swift */,
37C3A240272359900087A57A /* Double+Format.swift */,
37BA794E26DC3E0E002A0235 /* Int+Format.swift */,
@ -1743,6 +1818,7 @@
37DD9DCC2785EE6F00539416 /* Vendor */,
3748186426A762300084E870 /* Fixtures */,
37A3B15827255E7F000FB5EE /* Open in Yattee */,
3712643C2865FF4500D77974 /* Shared Tests */,
377FC7D1267A080300A6BBAF /* Frameworks */,
37D4B0CA2671614900C925CA /* Products */,
37D4B174267164B000C925CA /* Tests Apple TV */,
@ -1774,7 +1850,7 @@
371114EA27B94C8800C2EF7B /* RepeatingTimer.swift */,
3700155E271B12DD0049C794 /* SiestaConfiguration.swift */,
37FFC43F272734C3009FFD26 /* Throttle.swift */,
37CB12782724C76D00213B45 /* VideoURLParser.swift */,
378FFBC328660172009E3FBE /* URLParser.swift */,
37D4B0C22671614700C925CA /* YatteeApp.swift */,
37D4B0C42671614800C925CA /* Assets.xcassets */,
37BD07C42698ADEE003EBB87 /* Yattee.entitlements */,
@ -1793,6 +1869,7 @@
37D4B171267164B000C925CA /* Tests (tvOS).xctest */,
37A3B15727255E7F000FB5EE /* Open in Yattee (macOS).appex */,
37A3B1792725735F000FB5EE /* Open in Yattee (iOS).appex */,
3712643B2865FF4500D77974 /* Shared Tests.xctest */,
);
name = Products;
sourceTree = "<group>";
@ -1809,9 +1886,7 @@
isa = PBXGroup;
children = (
37BA796C26DC4105002A0235 /* Extensions */,
3774122927387B6C00423605 /* InstancesModelTests.swift */,
37D4B0E22671614900C925CA /* Tests_macOS.swift */,
37CB127B2724C79D00213B45 /* VideoURLParserTests.swift */,
);
path = "Tests macOS";
sourceTree = "<group>";
@ -1849,6 +1924,7 @@
374C0539272436DA009BDDBE /* SponsorBlock */,
37AAF28F26740715007FC770 /* Channel.swift */,
37C3A24427235DA70087A57A /* ChannelPlaylist.swift */,
37520698285E8DD300CA655F /* Chapter.swift */,
371B7E5B27596B8400D21217 /* Comment.swift */,
371B7E692759791900D21217 /* CommentsModel.swift */,
373C8FE3275B955100CB5936 /* CommentsPage.swift */,
@ -1859,6 +1935,7 @@
37BC50AB2778BCBA00510953 /* HistoryModel.swift */,
37EF5C212739D37B00B03725 /* MenuModel.swift */,
371F2F19269B43D300E4A7AB /* NavigationModel.swift */,
3756C2A92861151C00E4B059 /* NetworkStateModel.swift */,
37130A5E277657300033018A /* PersistenceController.swift */,
376578882685471400D4EA09 /* Playlist.swift */,
37BA794226DBA973002A0235 /* PlaylistsModel.swift */,
@ -1987,6 +2064,23 @@
/* End PBXHeadersBuildPhase section */
/* Begin PBXNativeTarget section */
3712643A2865FF4500D77974 /* Shared Tests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 371264412865FF4500D77974 /* Build configuration list for PBXNativeTarget "Shared Tests" */;
buildPhases = (
371264372865FF4500D77974 /* Sources */,
371264382865FF4500D77974 /* Frameworks */,
371264392865FF4500D77974 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = "Shared Tests";
productName = "Shared Tests";
productReference = 3712643B2865FF4500D77974 /* Shared Tests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
37A3B15627255E7F000FB5EE /* Open in Yattee (macOS) */ = {
isa = PBXNativeTarget;
buildConfigurationList = 37A3B17427255E7F000FB5EE /* Build configuration list for PBXNativeTarget "Open in Yattee (macOS)" */;
@ -2050,6 +2144,7 @@
37FB285527220D9000A57617 /* SDWebImagePINPlugin */,
3765917B27237D21009F956E /* PINCache */,
37CF8B8328535E4F00B71E37 /* SDWebImage */,
37A5DBC3285DFF5400CA4DD1 /* SwiftUIPager */,
);
productName = "Yattee (iOS)";
productReference = 37D4B0C92671614900C925CA /* Yattee.app */;
@ -2082,6 +2177,7 @@
3703206727D2BB45007A0CB8 /* Defaults */,
3703206927D2BB49007A0CB8 /* Alamofire */,
37CF8B8528535E5A00B71E37 /* SDWebImage */,
37A5DBC5285E06B100CA4DD1 /* SwiftUIPager */,
);
productName = "Yattee (macOS)";
productReference = 37D4B0CF2671614900C925CA /* Yattee.app */;
@ -2186,9 +2282,12 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1310;
LastSwiftUpdateCheck = 1400;
LastUpgradeCheck = 1400;
TargetAttributes = {
3712643A2865FF4500D77974 = {
CreatedOnToolsVersion = 14.0;
};
37A3B15627255E7F000FB5EE = {
CreatedOnToolsVersion = 13.1;
};
@ -2248,6 +2347,7 @@
37FB285227220D8400A57617 /* XCRemoteSwiftPackageReference "SDWebImagePINPlugin" */,
3765917827237D07009F956E /* XCRemoteSwiftPackageReference "PINCache" */,
37CF8B8228535E4F00B71E37 /* XCRemoteSwiftPackageReference "SDWebImage" */,
37A5DBC2285DFF5400CA4DD1 /* XCRemoteSwiftPackageReference "SwiftUIPager" */,
);
productRefGroup = 37D4B0CA2671614900C925CA /* Products */;
projectDirPath = "";
@ -2264,11 +2364,19 @@
37D4B0D32671614900C925CA /* Tests (iOS) */,
37D4B0DD2671614900C925CA /* Tests (macOS) */,
37D4B170267164B000C925CA /* Tests (tvOS) */,
3712643A2865FF4500D77974 /* Shared Tests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
371264392865FF4500D77974 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
37A3B15527255E7F000FB5EE /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@ -2447,6 +2555,17 @@
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
371264372865FF4500D77974 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
371264432865FFD700D77974 /* CMTime+DefaultTimescale.swift in Sources */,
378FFBC728660172009E3FBE /* URLParser.swift in Sources */,
37C0C0FF28665EAC007F6F78 /* VideosApp.swift in Sources */,
378FFBC92866018A009E3FBE /* URLParserTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
37A3B15327255E7F000FB5EE /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@ -2474,15 +2593,17 @@
371B7E6A2759791900D21217 /* CommentsModel.swift in Sources */,
37E8B0F027B326F30024006F /* Comparable+Clamped.swift in Sources */,
37CC3F45270CE30600608308 /* PlayerQueueItem.swift in Sources */,
372CFD15285F2E2A00B0B54B /* ControlsBar.swift in Sources */,
37BD07C82698B71C003EBB87 /* AppTabNavigation.swift in Sources */,
37599F38272B4D740087F250 /* FavoriteButton.swift in Sources */,
37CB12792724C76D00213B45 /* VideoURLParser.swift in Sources */,
378FFBC428660172009E3FBE /* URLParser.swift in Sources */,
3784B23D2728B85300B09468 /* ShareButton.swift in Sources */,
37EAD86B267B9C5600D9E01B /* SponsorBlockAPI.swift in Sources */,
3743CA52270F284F00E4D32B /* View+Borders.swift in Sources */,
3763495126DFF59D00B9A393 /* AppSidebarRecents.swift in Sources */,
376CD21626FBE18D001E1AC1 /* Instance+Fixtures.swift in Sources */,
37BA793B26DB8EE4002A0235 /* PlaylistVideosView.swift in Sources */,
37A5DBC8285E371400CA4DD1 /* ControlBackgroundModifier.swift in Sources */,
37BD07B52698AA4D003EBB87 /* ContentView.swift in Sources */,
37130A5B277657090033018A /* Yattee.xcdatamodeld in Sources */,
37152EEA26EFEB95004FB96D /* LazyView.swift in Sources */,
@ -2524,6 +2645,7 @@
37030FF727B0347C00ECDDAA /* MPVPlayerView.swift in Sources */,
37BA794726DC2E56002A0235 /* AppSidebarSubscriptions.swift in Sources */,
377FC7DC267A081800A6BBAF /* PopularView.swift in Sources */,
3752069D285E910600CA655F /* ChaptersView.swift in Sources */,
3751B4B227836902000B7DF4 /* SearchPage.swift in Sources */,
37CC3F4C270CFE1700608308 /* PlayerQueueView.swift in Sources */,
37FFC440272734C3009FFD26 /* Throttle.swift in Sources */,
@ -2531,8 +2653,11 @@
3705B182267B4E4900704544 /* TrendingCategory.swift in Sources */,
378AE940274EDFB5006A4EE1 /* Tint+Backport.swift in Sources */,
376BE50927347B5F009AD608 /* SettingsHeader.swift in Sources */,
376527BB285F60F700102284 /* PlayerTimeModel.swift in Sources */,
3722AEBE274DA401005EA4D6 /* Backport.swift in Sources */,
37F4AD2628613B81004D0F66 /* Color+Debug.swift in Sources */,
3700155F271B12DD0049C794 /* SiestaConfiguration.swift in Sources */,
37F13B62285E43C000B137E4 /* ControlsOverlay.swift in Sources */,
375E45F827B1AC4700BA7902 /* PlayerControlsModel.swift in Sources */,
37EAD86F267B9ED100D9E01B /* Segment.swift in Sources */,
375168D62700FAFF008F96A6 /* Debounce.swift in Sources */,
@ -2560,6 +2685,7 @@
37BF661C27308859008CCFB0 /* DropFavorite.swift in Sources */,
371114EB27B94C8800C2EF7B /* RepeatingTimer.swift in Sources */,
376A33E42720CB35000C1D6B /* Account.swift in Sources */,
3756C2A62861131100E4B059 /* NetworkState.swift in Sources */,
37BA794326DBA973002A0235 /* PlaylistsModel.swift in Sources */,
37BC50AC2778BCBA00510953 /* HistoryModel.swift in Sources */,
37AAF29026740715007FC770 /* Channel.swift in Sources */,
@ -2578,9 +2704,11 @@
37DD9DA32785BBC900539416 /* NoCommentsView.swift in Sources */,
377A20A92693C9A2002842B8 /* TypedContentAccessors.swift in Sources */,
37484C3126FCB8F900287258 /* AccountValidator.swift in Sources */,
37520699285E8DD300CA655F /* Chapter.swift in Sources */,
37319F0527103F94004ECCD0 /* PlayerQueue.swift in Sources */,
376B2E0726F920D600B1D64D /* SignInRequiredView.swift in Sources */,
37C0697E2725C8D400F7F6CB /* CMTime+DefaultTimescale.swift in Sources */,
37F4AD1F28612DFD004D0F66 /* Buffering.swift in Sources */,
3743CA4E270EFE3400E4D32B /* PlayerQueueRow.swift in Sources */,
371B7E5C27596B8400D21217 /* Comment.swift in Sources */,
3703100227B0713600ECDDAA /* PlayerGestures.swift in Sources */,
@ -2616,6 +2744,7 @@
37599F30272B42810087F250 /* FavoriteItem.swift in Sources */,
373197D92732015300EF734F /* RelatedView.swift in Sources */,
37F9619B27BD89E000058149 /* TapRecognizerViewModifier.swift in Sources */,
37F4AD1B28612B23004D0F66 /* OpeningStream.swift in Sources */,
373CFAEB26975CBF003CB2C6 /* PlaylistFormView.swift in Sources */,
372915E62687E3B900F5A35B /* Defaults.swift in Sources */,
37E084AC2753D95F00039B7D /* AccountsNavigationLink.swift in Sources */,
@ -2631,6 +2760,7 @@
3769C02E2779F18600DDB3EA /* PlaceholderProgressView.swift in Sources */,
37579D5D27864F5F00FD0B98 /* Help.swift in Sources */,
37030FFB27B0398000ECDDAA /* MPVClient.swift in Sources */,
3756C2AA2861151C00E4B059 /* NetworkStateModel.swift in Sources */,
3758638A2721B0A9000CB14E /* ChannelCell.swift in Sources */,
37001563271B1F250049C794 /* AccountsModel.swift in Sources */,
3795593627B08538007FF8F4 /* StreamControl.swift in Sources */,
@ -2678,7 +2808,9 @@
3782B95027553A6700990149 /* SearchSuggestions.swift in Sources */,
371B7E6B2759791900D21217 /* CommentsModel.swift in Sources */,
371F2F1B269B43D300E4A7AB /* NavigationModel.swift in Sources */,
3756C2AB2861151C00E4B059 /* NetworkStateModel.swift in Sources */,
37001564271B1F250049C794 /* AccountsModel.swift in Sources */,
378FFBC528660172009E3FBE /* URLParser.swift in Sources */,
3761ABFE26F0F8DE00AA496F /* EnvironmentValues.swift in Sources */,
37BA795026DC3E0E002A0235 /* Int+Format.swift in Sources */,
3743CA4F270EFE3400E4D32B /* PlayerQueueRow.swift in Sources */,
@ -2690,11 +2822,13 @@
3705B183267B4E4900704544 /* TrendingCategory.swift in Sources */,
37FB285F272225E800A57617 /* ContentItemView.swift in Sources */,
37FD43DC270470B70073EE42 /* InstancesSettings.swift in Sources */,
3756C2A72861131100E4B059 /* NetworkState.swift in Sources */,
3729037F2739E47400EA99F6 /* MenuCommands.swift in Sources */,
37C0698327260B2100F7F6CB /* ThumbnailsModel.swift in Sources */,
376B2E0826F920D600B1D64D /* SignInRequiredView.swift in Sources */,
37CC3F4D270CFE1700608308 /* PlayerQueueView.swift in Sources */,
37B81B0026D2CA3700675966 /* VideoDetails.swift in Sources */,
3752069A285E8DD300CA655F /* Chapter.swift in Sources */,
37484C1A26FC837400287258 /* PlayerSettings.swift in Sources */,
37BD07C32698AD4F003EBB87 /* ContentView.swift in Sources */,
37484C3226FCB8F900287258 /* AccountValidator.swift in Sources */,
@ -2704,8 +2838,10 @@
3788AC2826F6840700F6BAA9 /* FavoriteItemView.swift in Sources */,
378AE93A274EDFAF006A4EE1 /* Badge+Backport.swift in Sources */,
37599F35272B44000087F250 /* FavoritesModel.swift in Sources */,
376527BC285F60F700102284 /* PlayerTimeModel.swift in Sources */,
37F64FE526FE70A60081B69E /* RedrawOnModifier.swift in Sources */,
3795593727B08538007FF8F4 /* StreamControl.swift in Sources */,
372CFD16285F2E2A00B0B54B /* ControlsBar.swift in Sources */,
37FFC441272734C3009FFD26 /* Throttle.swift in Sources */,
37169AA72729E2CC0011DE61 /* AccountsBridge.swift in Sources */,
37BA793C26DB8EE4002A0235 /* PlaylistVideosView.swift in Sources */,
@ -2715,10 +2851,12 @@
376BE50727347B57009AD608 /* SettingsHeader.swift in Sources */,
378AE93C274EDFB2006A4EE1 /* Backport.swift in Sources */,
37152EEB26EFEB95004FB96D /* LazyView.swift in Sources */,
37F4AD2028612DFD004D0F66 /* Buffering.swift in Sources */,
377FC7E2267A084A00A6BBAF /* VideoCell.swift in Sources */,
37CC3F51270D010D00608308 /* VideoBanner.swift in Sources */,
37F961A027BD90BB00058149 /* PlayerBackendType.swift in Sources */,
37BC50AD2778BCBA00510953 /* HistoryModel.swift in Sources */,
3752069E285E910600CA655F /* ChaptersView.swift in Sources */,
37030FF827B0347C00ECDDAA /* MPVPlayerView.swift in Sources */,
378E50FC26FE8B9F00F49626 /* Instance.swift in Sources */,
37169AA32729D98A0011DE61 /* InstancesBridge.swift in Sources */,
@ -2734,6 +2872,7 @@
374C0543272496E4009BDDBE /* AppDelegate.swift in Sources */,
373CFACC26966264003CB2C6 /* SearchQuery.swift in Sources */,
37AAF29126740715007FC770 /* Channel.swift in Sources */,
37F4AD1C28612B23004D0F66 /* OpeningStream.swift in Sources */,
376A33E12720CAD6000C1D6B /* VideosApp.swift in Sources */,
37BD07BC2698AB60003EBB87 /* AppSidebarNavigation.swift in Sources */,
37579D5E27864F5F00FD0B98 /* Help.swift in Sources */,
@ -2758,6 +2897,7 @@
37F4AE7326828F0900BD60EA /* VerticalCells.swift in Sources */,
37001560271B12DD0049C794 /* SiestaConfiguration.swift in Sources */,
372D85DE283841B800FF3C7D /* PiPDelegate.swift in Sources */,
37F13B63285E43C000B137E4 /* ControlsOverlay.swift in Sources */,
37B81AFD26D2C9C900675966 /* VideoDetailsPaddingModifier.swift in Sources */,
37C0697F2725C8D400F7F6CB /* CMTime+DefaultTimescale.swift in Sources */,
37A9965B26D6F8CA006E3224 /* HorizontalCells.swift in Sources */,
@ -2772,6 +2912,7 @@
37C7A1D6267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */,
37319F0627103F94004ECCD0 /* PlayerQueue.swift in Sources */,
37B767DC2677C3CA0098BAA8 /* PlayerModel.swift in Sources */,
37A5DBC9285E371400CA4DD1 /* ControlBackgroundModifier.swift in Sources */,
3797758C2689345500DD52A8 /* Store.swift in Sources */,
371B7E622759706A00D21217 /* CommentsView.swift in Sources */,
37141674267A8E10006CA35D /* Country.swift in Sources */,
@ -2780,6 +2921,7 @@
37FD43E42704847C0073EE42 /* View+Fixtures.swift in Sources */,
37C069782725962F00F7F6CB /* ScreenSaverManager.swift in Sources */,
37AAF2A126741C97007FC770 /* SubscriptionsView.swift in Sources */,
37F4AD2728613B81004D0F66 /* Color+Debug.swift in Sources */,
37732FF12703A26300F04329 /* AccountValidationStatus.swift in Sources */,
37BA794C26DC30EC002A0235 /* AppSidebarPlaylists.swift in Sources */,
37CC3F46270CE30600608308 /* PlayerQueueItem.swift in Sources */,
@ -2824,7 +2966,6 @@
3763495226DFF59D00B9A393 /* AppSidebarRecents.swift in Sources */,
371B7E672759786B00D21217 /* Comment+Fixtures.swift in Sources */,
3769C02F2779F18600DDB3EA /* PlaceholderProgressView.swift in Sources */,
37CB127A2724C76D00213B45 /* VideoURLParser.swift in Sources */,
37BA794426DBA973002A0235 /* PlaylistsModel.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -2842,32 +2983,25 @@
buildActionMask = 2147483647;
files = (
3774124C27387D2300423605 /* RecentsModel.swift in Sources */,
3774122A27387B6C00423605 /* InstancesModelTests.swift in Sources */,
371B7E642759706A00D21217 /* CommentsView.swift in Sources */,
3774124927387D2300423605 /* Channel.swift in Sources */,
3774125727387D2300423605 /* FavoriteItem.swift in Sources */,
3774126B27387D6D00423605 /* CMTime+DefaultTimescale.swift in Sources */,
3774126027387D2D00423605 /* AccountsBridge.swift in Sources */,
3774125827387D2300423605 /* TrendingCategory.swift in Sources */,
3774125F27387D2D00423605 /* Account.swift in Sources */,
3774126827387D6D00423605 /* Double+Format.swift in Sources */,
3774126E27387D8800423605 /* PlayerQueueItem.swift in Sources */,
3774125627387D2300423605 /* Segment.swift in Sources */,
373C8FE7275B955100CB5936 /* CommentsPage.swift in Sources */,
3774126427387D4A00423605 /* VideosAPI.swift in Sources */,
3774124D27387D2300423605 /* PlaylistsModel.swift in Sources */,
3774123427387CC100423605 /* InvidiousAPI.swift in Sources */,
3774124B27387D2300423605 /* ThumbnailsModel.swift in Sources */,
37CB128B2724CC1F00213B45 /* VideoURLParserTests.swift in Sources */,
3774125427387D2300423605 /* Store.swift in Sources */,
3774125027387D2300423605 /* Video.swift in Sources */,
37EF9A79275BEB8E0043B585 /* CommentView.swift in Sources */,
3774125327387D2300423605 /* Country.swift in Sources */,
3774125E27387D2D00423605 /* InstancesModel.swift in Sources */,
37CB128C2724CC8400213B45 /* VideoURLParser.swift in Sources */,
3774127227387E0B00423605 /* SiestaConfiguration.swift in Sources */,
3774126D27387D8500423605 /* SponsorBlockAPI.swift in Sources */,
3774126327387D2D00423605 /* InstancesBridge.swift in Sources */,
3774125127387D2300423605 /* NavigationModel.swift in Sources */,
3774124A27387D2300423605 /* ContentItem.swift in Sources */,
3774126227387D2D00423605 /* AccountValidator.swift in Sources */,
@ -2876,14 +3010,12 @@
3774126A27387D6D00423605 /* TypedContentAccessors.swift in Sources */,
3774127027387D9A00423605 /* SponsorBlockSegment.swift in Sources */,
3774125A27387D2300423605 /* FavoritesModel.swift in Sources */,
3774125D27387D2D00423605 /* Instance.swift in Sources */,
3774125927387D2300423605 /* ChannelPlaylist.swift in Sources */,
3774125527387D2300423605 /* Stream.swift in Sources */,
371B7E5F27596B8400D21217 /* Comment.swift in Sources */,
3774126F27387D8D00423605 /* SearchQuery.swift in Sources */,
3774127127387D9E00423605 /* PlayerQueueItemBridge.swift in Sources */,
3774125227387D2300423605 /* Thumbnail.swift in Sources */,
3774122F27387C7600423605 /* VideosApp.swift in Sources */,
37D4B0E32671614900C925CA /* Tests_macOS.swift in Sources */,
3774126527387D6D00423605 /* Int+Format.swift in Sources */,
3774126627387D6D00423605 /* Array+Next.swift in Sources */,
@ -2927,7 +3059,9 @@
3782B95727557E6E00990149 /* SearchSuggestions.swift in Sources */,
37BD07C92698FBDB003EBB87 /* ContentView.swift in Sources */,
37BC50AA2778A84700510953 /* HistorySettings.swift in Sources */,
37F13B64285E43C000B137E4 /* ControlsOverlay.swift in Sources */,
376B2E0926F920D600B1D64D /* SignInRequiredView.swift in Sources */,
378FFBC628660172009E3FBE /* URLParser.swift in Sources */,
37141671267A8ACC006CA35D /* TrendingView.swift in Sources */,
37C3A24727235DA70087A57A /* ChannelPlaylist.swift in Sources */,
3788AC2926F6840700F6BAA9 /* FavoriteItemView.swift in Sources */,
@ -2937,6 +3071,7 @@
37DD87C9271C9CFE0027CBF9 /* PlayerStreams.swift in Sources */,
378AE93E274EDFB4006A4EE1 /* Tint+Backport.swift in Sources */,
37FFC442272734C3009FFD26 /* Throttle.swift in Sources */,
37F4AD2828613B81004D0F66 /* Color+Debug.swift in Sources */,
37E8B0F227B326F30024006F /* Comparable+Clamped.swift in Sources */,
375168D82700FDB9008F96A6 /* Debounce.swift in Sources */,
37BA794126DB8F97002A0235 /* ChannelVideosView.swift in Sources */,
@ -2946,14 +3081,18 @@
37C3A243272359900087A57A /* Double+Format.swift in Sources */,
37AAF29226740715007FC770 /* Channel.swift in Sources */,
37EAD86D267B9C5600D9E01B /* SponsorBlockAPI.swift in Sources */,
376527BD285F60F700102284 /* PlayerTimeModel.swift in Sources */,
371B7E5E27596B8400D21217 /* Comment.swift in Sources */,
37732FF22703A26300F04329 /* AccountValidationStatus.swift in Sources */,
3756C2AC2861151C00E4B059 /* NetworkStateModel.swift in Sources */,
37A5DBCA285E371400CA4DD1 /* ControlBackgroundModifier.swift in Sources */,
37C0698427260B2100F7F6CB /* ThumbnailsModel.swift in Sources */,
37666BAA27023AF000F869E5 /* AccountSelectionView.swift in Sources */,
3765788B2685471400D4EA09 /* Playlist.swift in Sources */,
376A33E22720CAD6000C1D6B /* VideosApp.swift in Sources */,
373CFADD269663F1003CB2C6 /* Thumbnail.swift in Sources */,
37E64DD326D597EB00C71877 /* SubscriptionsModel.swift in Sources */,
3752069B285E8DD300CA655F /* Chapter.swift in Sources */,
37B044B926F7AB9000E1419D /* SettingsView.swift in Sources */,
3743B86A27216D3600261544 /* ChannelCell.swift in Sources */,
3751BA8527E6914F007B1A60 /* ReturnYouTubeDislikeAPI.swift in Sources */,
@ -2981,6 +3120,7 @@
37599F3A272B4D740087F250 /* FavoriteButton.swift in Sources */,
37EF5C242739D37B00B03725 /* MenuModel.swift in Sources */,
37C7A1D7267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */,
3756C2A82861131100E4B059 /* NetworkState.swift in Sources */,
376578932685490700D4EA09 /* PlaylistsView.swift in Sources */,
37CC3F47270CE30600608308 /* PlayerQueueItem.swift in Sources */,
37001561271B12DD0049C794 /* SiestaConfiguration.swift in Sources */,
@ -3012,9 +3152,12 @@
37FEF11527EFD8580033912F /* PlaceholderCell.swift in Sources */,
37FD43F02704A9C00073EE42 /* RecentsModel.swift in Sources */,
379775952689365600DD52A8 /* Array+Next.swift in Sources */,
3752069F285E910600CA655F /* ChaptersView.swift in Sources */,
37F4AD1D28612B23004D0F66 /* OpeningStream.swift in Sources */,
3705B180267B4DFB00704544 /* TrendingCountry.swift in Sources */,
373CFACD26966264003CB2C6 /* SearchQuery.swift in Sources */,
37C3A24B27235FAA0087A57A /* ChannelPlaylistCell.swift in Sources */,
37F4AD2128612DFD004D0F66 /* Buffering.swift in Sources */,
37FD43E52704847C0073EE42 /* View+Fixtures.swift in Sources */,
37FAE000272ED58000330459 /* EditFavorites.swift in Sources */,
37F961A127BD90BB00058149 /* PlayerBackendType.swift in Sources */,
@ -3081,6 +3224,48 @@
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
3712643F2865FF4500D77974 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MACOSX_DEPLOYMENT_TARGET = 11.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "net.arekf.Shared-Tests";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
371264402865FF4500D77974 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MACOSX_DEPLOYMENT_TARGET = 11.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "net.arekf.Shared-Tests";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
37A3B17227255E7F000FB5EE /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@ -3088,7 +3273,7 @@
CODE_SIGN_ENTITLEMENTS = "Open in Yattee/Open in Yattee.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 50;
CURRENT_PROJECT_VERSION = 54;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "";
ENABLE_HARDENED_RUNTIME = YES;
@ -3123,7 +3308,7 @@
CODE_SIGN_ENTITLEMENTS = "Open in Yattee/Open in Yattee.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 50;
CURRENT_PROJECT_VERSION = 54;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "";
ENABLE_HARDENED_RUNTIME = YES;
@ -3156,7 +3341,7 @@
buildSettings = {
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 50;
CURRENT_PROJECT_VERSION = 54;
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Open in Yattee/Info.plist";
@ -3188,7 +3373,7 @@
buildSettings = {
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 50;
CURRENT_PROJECT_VERSION = 54;
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Open in Yattee/Info.plist";
@ -3356,7 +3541,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_CXX_LANGUAGE_STANDARD = "c++14";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 50;
CURRENT_PROJECT_VERSION = 54;
DEVELOPMENT_TEAM = "";
ENABLE_PREVIEWS = YES;
GCC_PREPROCESSOR_DEFINITIONS = (
@ -3366,6 +3551,7 @@
);
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = iOS/Info.plist;
INFOPLIST_KEY_NSCameraUsageDescription = "Need camera access to take pictures";
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UIRequiresFullScreen = NO;
@ -3401,12 +3587,13 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 50;
CURRENT_PROJECT_VERSION = 54;
DEVELOPMENT_TEAM = "";
ENABLE_PREVIEWS = YES;
GCC_PREPROCESSOR_DEFINITIONS = "GLES_SILENCE_DEPRECATION=1";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = iOS/Info.plist;
INFOPLIST_KEY_NSCameraUsageDescription = "Need camera access to take pictures";
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UIRequiresFullScreen = NO;
@ -3442,11 +3629,12 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGN_ENTITLEMENTS = Shared/Yattee.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 50;
CURRENT_PROJECT_VERSION = 54;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "";
ENABLE_APP_SANDBOX = YES;
@ -3482,11 +3670,12 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGN_ENTITLEMENTS = Shared/Yattee.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 50;
CURRENT_PROJECT_VERSION = 54;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "";
ENABLE_APP_SANDBOX = YES;
@ -3626,7 +3815,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 50;
CURRENT_PROJECT_VERSION = 54;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = "";
ENABLE_PREVIEWS = YES;
@ -3665,7 +3854,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 50;
CURRENT_PROJECT_VERSION = 54;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = "";
ENABLE_PREVIEWS = YES;
@ -3793,6 +3982,15 @@
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
371264412865FF4500D77974 /* Build configuration list for PBXNativeTarget "Shared Tests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
3712643F2865FF4500D77974 /* Debug */,
371264402865FF4500D77974 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
37A3B17427255E7F000FB5EE /* Build configuration list for PBXNativeTarget "Open in Yattee (macOS)" */ = {
isa = XCConfigurationList;
buildConfigurations = (
@ -3928,6 +4126,14 @@
minimumVersion = 1.5.0;
};
};
37A5DBC2285DFF5400CA4DD1 /* XCRemoteSwiftPackageReference "SwiftUIPager" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/fermoya/SwiftUIPager.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.0.0;
};
};
37B767DE2678C5BF0098BAA8 /* XCRemoteSwiftPackageReference "swift-log" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/apple/swift-log.git";
@ -4095,6 +4301,16 @@
package = 3797757B268922D100DD52A8 /* XCRemoteSwiftPackageReference "siesta" */;
productName = Siesta;
};
37A5DBC3285DFF5400CA4DD1 /* SwiftUIPager */ = {
isa = XCSwiftPackageProductDependency;
package = 37A5DBC2285DFF5400CA4DD1 /* XCRemoteSwiftPackageReference "SwiftUIPager" */;
productName = SwiftUIPager;
};
37A5DBC5285E06B100CA4DD1 /* SwiftUIPager */ = {
isa = XCSwiftPackageProductDependency;
package = 37A5DBC2285DFF5400CA4DD1 /* XCRemoteSwiftPackageReference "SwiftUIPager" */;
productName = SwiftUIPager;
};
37B767DF2678C5BF0098BAA8 /* Logging */ = {
isa = XCSwiftPackageProductDependency;
package = 37B767DE2678C5BF0098BAA8 /* XCRemoteSwiftPackageReference "swift-log" */;

View File

@ -1,122 +0,0 @@
{
"pins" : [
{
"identity" : "alamofire",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Alamofire/Alamofire.git",
"state" : {
"revision" : "8dd85aee02e39dd280c75eef88ffdb86eed4b07b",
"version" : "5.6.2"
}
},
{
"identity" : "defaults",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sindresorhus/Defaults",
"state" : {
"revision" : "981ccb0a01c54abbe3c12ccb8226108527bbf115",
"version" : "6.3.0"
}
},
{
"identity" : "libwebp-xcode",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/libwebp-Xcode.git",
"state" : {
"revision" : "0f3bdb28a1edc5e8e43876d3835d20c601ef331f",
"version" : "1.2.3"
}
},
{
"identity" : "pincache",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pinterest/PINCache",
"state" : {
"branch" : "master",
"revision" : "9ca06045b5aff12ee8c0ef5880aa8469c4896144"
}
},
{
"identity" : "pinoperation",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pinterest/PINOperation.git",
"state" : {
"revision" : "44d8ca154a4e75a028a5548c31ff3a53b90cef15",
"version" : "1.2.1"
}
},
{
"identity" : "sdwebimage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/SDWebImage.git",
"state" : {
"branch" : "master",
"revision" : "eeb25d6e9c1ecedbcbdc6694a6e40eaa8dcddbb5"
}
},
{
"identity" : "sdwebimagepinplugin",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/SDWebImagePINPlugin.git",
"state" : {
"revision" : "bd73a4fb30352ec311303d811559c9c46df4caa4",
"version" : "0.3.0"
}
},
{
"identity" : "sdwebimageswiftui",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/SDWebImageSwiftUI.git",
"state" : {
"revision" : "cd8625b7cf11a97698e180d28bb7d5d357196678",
"version" : "2.0.2"
}
},
{
"identity" : "sdwebimagewebpcoder",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/SDWebImageWebPCoder.git",
"state" : {
"revision" : "8a0c5e1ae08ed763739262b9dcef64cfb241c14b",
"version" : "0.9.0"
}
},
{
"identity" : "siesta",
"kind" : "remoteSourceControl",
"location" : "https://github.com/bustoutsolutions/siesta",
"state" : {
"revision" : "43f34046ebb5beb6802200353c473af303bbc31e",
"version" : "1.5.2"
}
},
{
"identity" : "swift-log",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log.git",
"state" : {
"revision" : "5d66f7ba25daf4f94100e7022febf3c75e37a6c7",
"version" : "1.4.2"
}
},
{
"identity" : "swiftui-introspect",
"kind" : "remoteSourceControl",
"location" : "https://github.com/siteline/SwiftUI-Introspect.git",
"state" : {
"revision" : "f2616860a41f9d9932da412a8978fec79c06fe24",
"version" : "0.1.4"
}
},
{
"identity" : "swiftyjson",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SwiftyJSON/SwiftyJSON.git",
"state" : {
"revision" : "b3dcd7dbd0d488e1a7077cb33b00f2083e382f07",
"version" : "5.0.1"
}
}
],
"version" : 2
}