From 321c265a1198b0769ca1a8865257ae83673748b1 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sat, 18 Jun 2022 14:39:49 +0200 Subject: [PATCH] Player controls UI changes WIP on controls Chapters working Add previews variable Add lists ids WIP --- .swiftlint.yml | 12 +- Extensions/Color+Debug.swift | 15 + Extensions/Double+Format.swift | 4 +- .../UIViewController+HideHomeIndicator.swift | 2 +- Fixtures/Video+Fixtures.swift | 8 +- Fixtures/View+Fixtures.swift | 8 +- Model/Applications/InvidiousAPI.swift | 7 +- Model/Applications/PipedAPI.swift | 29 +- Model/Applications/VideosAPI.swift | 50 ++ Model/Chapter.swift | 8 + Model/CommentsModel.swift | 6 +- Model/NavigationModel.swift | 10 + Model/NetworkStateModel.swift | 42 ++ Model/Player/Backends/AVPlayerBackend.swift | 15 +- Model/Player/Backends/MPVBackend.swift | 90 +++- Model/Player/Backends/MPVClient.swift | 13 + Model/Player/Backends/PlayerBackend.swift | 5 + Model/Player/Backends/PlayerBackendType.swift | 4 + Model/Player/PlayerControlsModel.swift | 48 +- Model/Player/PlayerModel.swift | 98 ++-- Model/Player/PlayerQueue.swift | 61 ++- Model/Player/PlayerSponsorBlock.swift | 15 +- Model/Player/PlayerStreams.swift | 20 +- Model/Player/PlayerTimeModel.swift | 50 ++ Model/PlaylistsModel.swift | 19 +- Model/Segment.swift | 8 + Model/Stream.swift | 18 +- Model/ThumbnailsModel.swift | 2 +- Model/Video.swift | 5 +- Shared/Defaults.swift | 19 + Shared/Favorites/FavoritesView.swift | 10 +- Shared/Navigation/AppSidebarNavigation.swift | 17 +- Shared/Navigation/AppTabNavigation.swift | 15 - Shared/Navigation/ContentView.swift | 118 +---- Shared/Player/ChaptersView.swift | 83 ++++ Shared/Player/CommentsView.swift | 3 - .../Controls/ControlBackgroundModifier.swift | 22 + Shared/Player/Controls/ControlsOverlay.swift | 185 ++++++++ Shared/Player/Controls/OSD/Buffering.swift | 37 ++ Shared/Player/Controls/OSD/NetworkState.swift | 22 + .../Player/Controls/OSD/OpeningStream.swift | 37 ++ Shared/Player/Controls/PlayerControls.swift | 417 +++++++---------- Shared/Player/MPV/MPVOGLView.swift | 8 +- Shared/Player/MPV/MPVViewController.swift | 4 +- Shared/Player/NoCommentsView.swift | 2 +- Shared/Player/PlayerQueueRow.swift | 14 +- Shared/Player/PlayerQueueView.swift | 12 +- Shared/Player/RelatedView.swift | 46 +- Shared/Player/TimelineView.swift | 378 +++++++++++----- Shared/Player/VideoDetails.swift | 426 ++++++++---------- Shared/Player/VideoPlayerView.swift | 135 +++--- Shared/Playlists/AddToPlaylistView.swift | 25 +- Shared/Search/SearchSuggestions.swift | 3 +- Shared/Search/SearchView.swift | 1 + Shared/Views/BrowserPlayerControls.swift | 332 +++++++++----- Shared/Views/ControlsBar.swift | 221 +++++++++ Shared/Views/VideoContextMenuView.swift | 15 +- Shared/YatteeApp.swift | 149 +++++- Yattee.xcodeproj/project.pbxproj | 294 ++++++++++-- .../xcshareddata/swiftpm/Package.resolved | 122 ----- 60 files changed, 2524 insertions(+), 1320 deletions(-) create mode 100644 Extensions/Color+Debug.swift create mode 100644 Model/Chapter.swift create mode 100644 Model/NetworkStateModel.swift create mode 100644 Model/Player/PlayerTimeModel.swift create mode 100644 Shared/Player/ChaptersView.swift create mode 100644 Shared/Player/Controls/ControlBackgroundModifier.swift create mode 100644 Shared/Player/Controls/ControlsOverlay.swift create mode 100644 Shared/Player/Controls/OSD/Buffering.swift create mode 100644 Shared/Player/Controls/OSD/NetworkState.swift create mode 100644 Shared/Player/Controls/OSD/OpeningStream.swift create mode 100644 Shared/Views/ControlsBar.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index 9697a52e..b0dd1c15 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -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 diff --git a/Extensions/Color+Debug.swift b/Extensions/Color+Debug.swift new file mode 100644 index 00000000..2d629fc3 --- /dev/null +++ b/Extensions/Color+Debug.swift @@ -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 + } +} diff --git a/Extensions/Double+Format.swift b/Extensions/Double+Format.swift index 874fc28d..c3ebc79f 100644 --- a/Extensions/Double+Format.swift +++ b/Extensions/Double+Format.swift @@ -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 } diff --git a/Extensions/UIViewController+HideHomeIndicator.swift b/Extensions/UIViewController+HideHomeIndicator.swift index 01151e87..0e6463f5 100644 --- a/Extensions/UIViewController+HideHomeIndicator.swift +++ b/Extensions/UIViewController+HideHomeIndicator.swift @@ -2,7 +2,7 @@ import UIKit extension UIViewController { @objc var swizzle_prefersHomeIndicatorAutoHidden: Bool { - return true + true } public class func swizzleHomeIndicatorProperty() { diff --git a/Fixtures/Video+Fixtures.swift b/Fixtures/Video+Fixtures.swift index b72bd2f8..3a694284 100644 --- a/Fixtures/Video+Fixtures.swift +++ b/Fixtures/Video+Fixtures.swift @@ -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) + ] ) } diff --git a/Fixtures/View+Fixtures.swift b/Fixtures/View+Fixtures.swift index 8ea95717..751e3baa 100644 --- a/Fixtures/View+Fixtures.swift +++ b/Fixtures/View+Fixtures.swift @@ -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() diff --git a/Model/Applications/InvidiousAPI.swift b/Model/Applications/InvidiousAPI.swift index 479d9721..78852641 100644 --- a/Model/Applications/InvidiousAPI.swift +++ b/Model/Applications/InvidiousAPI.swift @@ -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) ) } diff --git a/Model/Applications/PipedAPI.swift b/Model/Applications/PipedAPI.swift index 18b7504f..c214c863 100644 --- a/Model/Applications/PipedAPI.swift +++ b/Model/Applications/PipedAPI.swift @@ -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) + } + } } diff --git a/Model/Applications/VideosAPI.swift b/Model/Applications/VideosAPI.swift index 10d6edf9..21ef4403 100644 --- a/Model/Applications/VideosAPI.swift +++ b/Model/Applications/VideosAPI.swift @@ -116,4 +116,54 @@ extension VideosAPI { return urlComponents.url } + + func extractChapters(from description: String) -> [Chapter] { + guard let chaptersRegularExpression = try? NSRegularExpression( + pattern: "(?(?:[0-9]+:){1,}(?:[0-9]+))(?:\\s)+(?:- ?)?(?.*)", + 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) + } + } } diff --git a/Model/Chapter.swift b/Model/Chapter.swift new file mode 100644 index 00000000..353bfae7 --- /dev/null +++ b/Model/Chapter.swift @@ -0,0 +1,8 @@ +import Foundation + +struct Chapter: Identifiable, Equatable { + var id = UUID() + var title: String + var image: URL? + var start: Double +} diff --git a/Model/CommentsModel.swift b/Model/CommentsModel.swift index 561c2bfe..4849aeb4 100644 --- a/Model/CommentsModel.swift +++ b/Model/CommentsModel.swift @@ -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() { diff --git a/Model/NavigationModel.swift b/Model/NavigationModel.swift index 0f1d48b6..67da0a18 100644 --- a/Model/NavigationModel.swift +++ b/Model/NavigationModel.swift @@ -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 diff --git a/Model/NetworkStateModel.swift b/Model/NetworkStateModel.swift new file mode 100644 index 00000000..1cc1c46e --- /dev/null +++ b/Model/NetworkStateModel.swift @@ -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 + } +} diff --git a/Model/Player/Backends/AVPlayerBackend.swift b/Model/Player/Backends/AVPlayerBackend.swift index b05d6ae9..a51c4fa0 100644 --- a/Model/Player/Backends/AVPlayerBackend.swift +++ b/Model/Player/Backends/AVPlayerBackend.swift @@ -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() {} } diff --git a/Model/Player/Backends/MPVBackend.swift b/Model/Player/Backends/MPVBackend.swift index 29a4843b..1e7c4a70 100644 --- a/Model/Player/Backends/MPVBackend.swift +++ b/Model/Player/Backends/MPVBackend.swift @@ -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() + } } } diff --git a/Model/Player/Backends/MPVClient.swift b/Model/Player/Backends/MPVClient.swift index 560dd3f0..358c6dd7 100644 --- a/Model/Player/Backends/MPVClient.swift +++ b/Model/Player/Backends/MPVClient.swift @@ -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) diff --git a/Model/Player/Backends/PlayerBackend.swift b/Model/Player/Backends/PlayerBackend.swift index 4c869173..0f63105e 100644 --- a/Model/Player/Backends/PlayerBackend.swift +++ b/Model/Player/Backends/PlayerBackend.swift @@ -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) } diff --git a/Model/Player/Backends/PlayerBackendType.swift b/Model/Player/Backends/PlayerBackendType.swift index 51ffd73f..12aa259c 100644 --- a/Model/Player/Backends/PlayerBackendType.swift +++ b/Model/Player/Backends/PlayerBackendType.swift @@ -13,4 +13,8 @@ enum PlayerBackendType: String, CaseIterable, Defaults.Serializable { return "AVPlayer" } } + + var supportsNetworkStateBufferingDetails: Bool { + self == .mpv + } } diff --git a/Model/Player/PlayerControlsModel.swift b/Model/Player/PlayerControlsModel.swift index 6d2acd76..1e108b1e 100644 --- a/Model/Player/PlayerControlsModel.swift +++ b/Model/Player/PlayerControlsModel.swift @@ -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 { diff --git a/Model/Player/PlayerModel.swift b/Model/Player/PlayerModel.swift index ec2842ea..b7f235f0 100644 --- a/Model/Player/PlayerModel.swift +++ b/Model/Player/PlayerModel.swift @@ -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() diff --git a/Model/Player/PlayerQueue.swift b/Model/Player/PlayerQueue.swift index 71d838be..62ca6c39 100644 --- a/Model/Player/PlayerQueue.swift +++ b/Model/Player/PlayerQueue.swift @@ -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 - self?.loadAvailableStreams(video) + 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,23 +125,29 @@ 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) + if loadDetails { + accounts.api.loadDetails(item) { [weak self] newItem in + guard let self = self else { return } + videoDetailsLoadHandler(newItem.video, newItem) - accounts.api.loadDetails(item) { newItem in - videoDetailsLoadHandler(newItem.video, newItem) - - if play { - self.playItem(newItem, video: video) + if play { + 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 diff --git a/Model/Player/PlayerSponsorBlock.swift b/Model/Player/PlayerSponsorBlock.swift index a231a3b6..0614b02f 100644 --- a/Model/Player/PlayerSponsorBlock.swift +++ b/Model/Player/PlayerSponsorBlock.swift @@ -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 - self?.lastSkipped = segment + 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 - self?.lastSkipped = nil + withAnimation { + self?.lastSkipped = nil + } self?.segmentRestorationTime = nil } } diff --git a/Model/Player/PlayerStreams.swift b/Model/Player/PlayerStreams.swift index 18a01d83..0e3bc516 100644 --- a/Model/Player/PlayerStreams.swift +++ b/Model/Player/PlayerStreams.swift @@ -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 diff --git a/Model/Player/PlayerTimeModel.swift b/Model/Player/PlayerTimeModel.swift new file mode 100644 index 00000000..39dafd30 --- /dev/null +++ b/Model/Player/PlayerTimeModel.swift @@ -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 + } +} diff --git a/Model/PlaylistsModel.swift b/Model/PlaylistsModel.swift index 1aeb4f41..d222cf01 100644 --- a/Model/PlaylistsModel.swift +++ b/Model/PlaylistsModel.swift @@ -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) { + accounts.api.addVideoToPlaylist( + videoID, + playlistID, + onFailure: onFailure ?? { requestError in + navigation?.presentAlert( + title: "Error when adding to playlist", + message: "(\(requestError.httpStatusCode ?? -1)) \(requestError.userMessage)" + ) + } + ) { self.load(force: true) { self.reloadPlaylists.toggle() onSuccess() diff --git a/Model/Segment.swift b/Model/Segment.swift index 951926a2..6fe3b26e 100644 --- a/Model/Segment.swift +++ b/Model/Segment.swift @@ -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) } diff --git a/Model/Stream.swift b/Model/Stream.swift index 12a916f7..c15df422 100644 --- a/Model/Stream.swift +++ b/Model/Stream.swift @@ -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 ?? "?" diff --git a/Model/ThumbnailsModel.swift b/Model/ThumbnailsModel.swift index 8421db49..26532c24 100644 --- a/Model/ThumbnailsModel.swift +++ b/Model/ThumbnailsModel.swift @@ -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) diff --git a/Model/Video.swift b/Model/Video.swift index 6b534d38..d7bcd37a 100644 --- a/Model/Video.swift +++ b/Model/Video.swift @@ -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? { diff --git a/Shared/Defaults.swift b/Shared/Defaults.swift index 02e63738..1fc7e29d 100644 --- a/Shared/Defaults.swift +++ b/Shared/Defaults.swift @@ -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 + } +} diff --git a/Shared/Favorites/FavoritesView.swift b/Shared/Favorites/FavoritesView.swift index fe0b980a..e247c418 100644 --- a/Shared/Favorites/FavoritesView.swift +++ b/Shared/Favorites/FavoritesView.swift @@ -70,7 +70,13 @@ struct FavoritesView: View { struct Favorites_Previews: PreviewProvider { static var previews: some View { - FavoritesView() - .injectFixtureEnvironmentObjects() + TabView { + FavoritesView() + .overlay(VideoPlayerView().injectFixtureEnvironmentObjects()) + .injectFixtureEnvironmentObjects() + .tabItem { + Label("a", systemImage: "") + } + } } } diff --git a/Shared/Navigation/AppSidebarNavigation.swift b/Shared/Navigation/AppSidebarNavigation.swift index 7b75c720..d8e76909 100644 --- a/Shared/Navigation/AppSidebarNavigation.swift +++ b/Shared/Navigation/AppSidebarNavigation.swift @@ -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,15 +49,13 @@ struct AppSidebarNavigation: View { .frame(minWidth: sidebarMinWidth) VStack { - BrowserPlayerControls { - HStack(alignment: .center) { - Spacer() - Image(systemName: "4k.tv") - .renderingMode(.original) - .font(.system(size: 60)) - .foregroundColor(.accentColor) - Spacer() - } + HStack(alignment: .center) { + Spacer() + Image(systemName: "4k.tv") + .renderingMode(.original) + .font(.system(size: 60)) + .foregroundColor(.accentColor) + Spacer() } } } diff --git a/Shared/Navigation/AppTabNavigation.swift b/Shared/Navigation/AppTabNavigation.swift index 26b2cdfb..6ffceb98 100644 --- a/Shared/Navigation/AppTabNavigation.swift +++ b/Shared/Navigation/AppTabNavigation.swift @@ -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 { diff --git a/Shared/Navigation/ContentView.swift b/Shared/Navigation/ContentView.swift index 30616063..0431fd2d 100644 --- a/Shared/Navigation/ContentView.swift +++ b/Shared/Navigation/ContentView.swift @@ -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,118 +110,9 @@ 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) + .alert(isPresented: $navigation.presentingAlert) { + Alert(title: Text(navigation.alertTitle), message: Text(navigation.alertMessage)) } - #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 - } } func openWelcomeScreenIfAccountEmpty() { diff --git a/Shared/Player/ChaptersView.swift b/Shared/Player/ChaptersView.swift new file mode 100644 index 00000000..8d24402b --- /dev/null +++ b/Shared/Player/ChaptersView.swift @@ -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() + } +} diff --git a/Shared/Player/CommentsView.swift b/Shared/Player/CommentsView.swift index 56290414..536c1daf 100644 --- a/Shared/Player/CommentsView.swift +++ b/Shared/Player/CommentsView.swift @@ -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 { diff --git a/Shared/Player/Controls/ControlBackgroundModifier.swift b/Shared/Player/Controls/ControlBackgroundModifier.swift new file mode 100644 index 00000000..42dc8c23 --- /dev/null +++ b/Shared/Player/Controls/ControlBackgroundModifier.swift @@ -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 + } + } +} diff --git a/Shared/Player/Controls/ControlsOverlay.swift b/Shared/Player/Controls/ControlsOverlay.swift new file mode 100644 index 00000000..00f0e56f --- /dev/null +++ b/Shared/Player/Controls/ControlsOverlay.swift @@ -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()) + } +} diff --git a/Shared/Player/Controls/OSD/Buffering.swift b/Shared/Player/Controls/OSD/Buffering.swift new file mode 100644 index 00000000..2532394e --- /dev/null +++ b/Shared/Player/Controls/OSD/Buffering.swift @@ -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)") + } +} diff --git a/Shared/Player/Controls/OSD/NetworkState.swift b/Shared/Player/Controls/OSD/NetworkState.swift new file mode 100644 index 00000000..bbc01f93 --- /dev/null +++ b/Shared/Player/Controls/OSD/NetworkState.swift @@ -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()) + } +} diff --git a/Shared/Player/Controls/OSD/OpeningStream.swift b/Shared/Player/Controls/OSD/OpeningStream.swift new file mode 100644 index 00000000..a629d4c4 --- /dev/null +++ b/Shared/Player/Controls/OSD/OpeningStream.swift @@ -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() + } +} diff --git a/Shared/Player/Controls/PlayerControls.swift b/Shared/Player/Controls/PlayerControls.swift index 084243a0..e8c35b6c 100644 --- a/Shared/Player/Controls/PlayerControls.swift +++ b/Shared/Player/Controls/PlayerControls.swift @@ -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,74 +31,107 @@ struct PlayerControls: View { } var body: some View { - VStack { - ZStack(alignment: .bottom) { - VStack(alignment: .trailing, spacing: 4) { - #if !os(tvOS) - buttonsBar - - HStack(spacing: 4) { - qualityButton - backendButton - } - #else - Text(player.stream?.description ?? "") - #endif - - Spacer() - - mediumButtonsBar - - Spacer() + ZStack(alignment: .topTrailing) { + VStack { + ZStack(alignment: .center) { + OpeningStream() + NetworkState() Group { - if player.activeBackend == .mpv, showMPVPlaybackStats { - mpvPlaybackStats - } + VStack(spacing: 4) { + buttonsBar - timeline - .offset(y: 10) - .zIndex(1) + 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) + } - HStack { Spacer() - bottomBar - #if os(macOS) - .background(VisualEffectBlur(material: .hudWindow)) - #elseif os(iOS) - .background(VisualEffectBlur(blurStyle: .systemThinMaterial)) - #endif - .mask(RoundedRectangle(cornerRadius: 3)) + Group { + ZStack(alignment: .bottom) { + floatingControls + .padding(.top, 20) + .padding(4) + .modifier(ControlBackgroundModifier()) + .clipShape(RoundedRectangle(cornerRadius: 4)) + + timeline + .padding(4) + .offset(y: -25) + .zIndex(1) + } + .frame(maxWidth: 500) + .padding(.bottom, 2) + } } + .padding(.top, 2) + .padding(.horizontal, 2) } - .padding(.horizontal, 16) + .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 - if model.presentingControls { - focusedField = .play + #if os(tvOS) + .onChange(of: model.presentingControls) { _ in + if model.presentingControls { + focusedField = .play + } } + .onChange(of: focusedField) { _ in + model.resetTimer() + } + #else + .background(PlayerGestures()) + .background(controlsBackground) + #endif + + 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) } - .onChange(of: focusedField) { _ in - model.resetTimer() - } - #else - .background(PlayerGestures()) - .background(controlsBackground) - #endif - .environment(\.colorScheme, .dark) } @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 + pipButton 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,116 +238,99 @@ 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) - - button("Seek Backward", systemImage: "gobackward.10", size: 30, cornerRadius: 5) { - player.backend.seek(relative: .secondsInDefaultTimescale(-10)) - } - - #if os(tvOS) - .focused($focusedField, equals: .backward) - #else - .keyboardShortcut("k", modifiers: []) - .keyboardShortcut(KeyEquivalent.leftArrow, modifiers: []) - #endif - - #endif - - Spacer() - - button( - model.isPlaying ? "Pause" : "Play", - systemImage: model.isPlaying ? "pause.fill" : "play.fill", - size: 30, cornerRadius: 5 - ) { - player.backend.togglePlay() + HStack(spacing: 20) { + togglePlayButton + seekBackwardButton + seekForwardButton } - #if os(tvOS) - .focused($focusedField, equals: .play) - #else - .keyboardShortcut("p") - .keyboardShortcut(.space) - #endif - .disabled(model.isLoadingVideo) + .frame(maxWidth: .infinity, alignment: .leading) 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 - + HStack(spacing: 20) { + restartVideoButton advanceToNextItemButton - .padding(.leading, 15) - #endif + 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 + } + private var restartVideoButton: some View { - button("Restart video", systemImage: "backward.end.fill", size: 30, cornerRadius: 5) { + 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: 25, cornerRadius: 5, background: false + ) { + player.backend.togglePlay() + } + #if os(tvOS) + .focused($focusedField, equals: .play) + #else + .keyboardShortcut("p") + .keyboardShortcut(.space) + #endif + .disabled(model.isLoadingVideo) + } + 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) } } } diff --git a/Shared/Player/MPV/MPVOGLView.swift b/Shared/Player/MPV/MPVOGLView.swift index fccdd422..61cc7c39 100644 --- a/Shared/Player/MPV/MPVOGLView.swift +++ b/Shared/Player/MPV/MPVOGLView.swift @@ -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() } diff --git a/Shared/Player/MPV/MPVViewController.swift b/Shared/Player/MPV/MPVViewController.swift index 6244cb47..ef63c4c4 100644 --- a/Shared/Player/MPV/MPVViewController.swift +++ b/Shared/Player/MPV/MPVViewController.swift @@ -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() } diff --git a/Shared/Player/NoCommentsView.swift b/Shared/Player/NoCommentsView.swift index b1b3cc5f..06e6ba7e 100644 --- a/Shared/Player/NoCommentsView.swift +++ b/Shared/Player/NoCommentsView.swift @@ -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 diff --git a/Shared/Player/PlayerQueueRow.swift b/Shared/Player/PlayerQueueRow.swift index 307175a6..2e553b76 100644 --- a/Shared/Player/PlayerQueueRow.swift +++ b/Shared/Player/PlayerQueueRow.swift @@ -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 { diff --git a/Shared/Player/PlayerQueueView.swift b/Shared/Player/PlayerQueueView.swift index 26be5486..04c536c2 100644 --- a/Shared/Player/PlayerQueueView.swift +++ b/Shared/Player/PlayerQueueView.swift @@ -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() } diff --git a/Shared/Player/RelatedView.swift b/Shared/Player/RelatedView.swift index e2869b73..36003640 100644 --- a/Shared/Player/RelatedView.swift +++ b/Shared/Player/RelatedView.swift @@ -1,24 +1,48 @@ +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 { - Button { - player.playNext(video) - } label: { - Label("Play Next", systemImage: "text.insert") + Section { + Button { + player.playNext(video) + } label: { + Label("Play Next", systemImage: "text.insert") + } + Button { + player.enqueueVideo(video) + } label: { + Label("Play Last", systemImage: "text.append") + } } - Button { - player.enqueueVideo(video) - } label: { - 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") + } + } + } } } } diff --git a/Shared/Player/TimelineView.swift b/Shared/Player/TimelineView.swift index 98758aa0..cded7dd7 100644 --- a/Shared/Player/TimelineView.swift +++ b/Shared/Player/TimelineView.swift @@ -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,147 +36,277 @@ 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) - - RoundedRectangle(cornerRadius: cornerRadius) - .fill(Color.green) - .frame(maxHeight: height) - .frame(width: current * oneUnitWidth) - - segmentsLayers - } - - Circle() - .strokeBorder(.gray, lineWidth: 1) - .background(Circle().fill(dragging ? .gray : .white)) - .offset(x: thumbOffset) - .foregroundColor(.red.opacity(0.6)) - .frame(maxHeight: height * 4) - - #if !os(tvOS) - .gesture( - DragGesture(minimumDistance: 0) - .onChanged { value in - if !dragging { - controls.removeTimer() - draggedFrom = current - } - - dragging = true - - let drag = value.translation.width - let change = (drag / size.width) * units - let changedCurrent = current + change - - guard changedCurrent >= start, changedCurrent <= duration else { - return - } - withAnimation(Animation.linear(duration: 0.2)) { - dragOffset = drag - } + 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")) } - .onEnded { _ in - current = projectedValue - - player.backend.seek(to: projectedValue) - - dragging = false - dragOffset = 0.0 - draggedFrom = 0.0 - controls.resetTimer() + 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()) + } + + .padding(.vertical, 3) + .padding(.horizontal, 8) + .background( + RoundedRectangle(cornerRadius: 3) + .foregroundColor(.black) ) - #endif - ZStack { - RoundedRectangle(cornerRadius: cornerRadius) - .frame(maxWidth: thumbTooltipWidth, maxHeight: 30) - - Text(projectedValue.formattedAsPlaybackTime() ?? "--:--") - .foregroundColor(.black) + .foregroundColor(.white) } - .animation(.linear(duration: 0.1)) + .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) - .offset(x: thumbTooltipOffset, y: -(height * 2) - 7) + .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() + .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) + .frame(maxWidth: thumbAreaWidth, minHeight: thumbAreaWidth) + + #if !os(tvOS) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + if !dragging { + controls.removeTimer() + draggedFrom = current + } + + dragging = true + + let drag = value.translation.width + let change = (drag / size.width) * units + let changedCurrent = current + change + + guard changedCurrent >= start, changedCurrent <= duration else { + return + } + withAnimation(Animation.linear(duration: 0.2)) { + dragOffset = drag + } + } + .onEnded { _ in + if abs(dragOffset) > 0 { + playerTime.currentTime = .secondsInDefaultTimescale(projectedValue) + player.backend.seek(to: projectedValue) + } + + dragging = false + dragOffset = 0.0 + draggedFrom = 0.0 + controls.resetTimer() + } + ) + #endif + } + + .background(GeometryReader { proxy in + Color.clear + .onAppear { + self.size = proxy.size + } + .onChange(of: proxy.size) { size in + self.size = size + } + }) + .frame(maxHeight: 20) + #if !os(tvOS) + .gesture(DragGesture(minimumDistance: 0).onEnded { value in + let target = (value.location.x / size.width) * units + 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) } - .background(GeometryReader { proxy in - Color.clear - .onAppear { - self.size = proxy.size - } - .onChange(of: proxy.size) { size in - self.size = size - } - }) - #if !os(tvOS) - .gesture(DragGesture(minimumDistance: 0).onEnded { value in - let target = (value.location.x / size.width) * units - current = target - player.backend.seek(to: target) - }) - #endif + } + + 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 + } + + return thumbOffset.clamped(to: -leadingOffset ... leadingOffset) } - var minThumbTooltipOffset: Double = -10 + 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() } } diff --git a/Shared/Player/VideoDetails.swift b/Shared/Player/VideoDetails.swift index 82119412..31cf4c4a 100644 --- a/Shared/Player/VideoDetails.swift +++ b/Shared/Player/VideoDetails.swift @@ -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 + HStack(spacing: 4) { + Image(systemName: symbolName) + + if playerDetailsPageButtonLabelStyle.text { + Text(label) } - #if os(macOS) - .padding(.top, 10) - #endif - - if !video.isNil { - Divider() - } - - subscriptionsSection - .onChange(of: video) { video in - if let video = video { - subscribed = subscriptions.isSubscribing(video.channel.id) - } - } } - .padding(.horizontal) + .frame(minHeight: 15) + .lineLimit(1) + .padding(.vertical, 4) + .foregroundColor(currentPage == destination ? .white : .accentColor) - 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,83 +246,11 @@ 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 - } - } - var publishedDateSection: some View { Group { if let video = player.currentVideo { @@ -311,32 +258,11 @@ 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 { Group { if let video = player.currentVideo { @@ -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() } } diff --git a/Shared/Player/VideoPlayerView.swift b/Shared/Player/VideoPlayerView.swift index 1c2960de..bb8d0171 100644 --- a/Shared/Player/VideoPlayerView.swift +++ b/Shared/Player/VideoPlayerView.swift @@ -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,9 +186,7 @@ struct VideoPlayerView: View { return } - withAnimation(.easeInOut(duration: 0.2)) { - viewVerticalOffset = drag - } + viewVerticalOffset = drag } .onEnded { _ in if viewVerticalOffset > 100 { @@ -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 } diff --git a/Shared/Playlists/AddToPlaylistView.swift b/Shared/Playlists/AddToPlaylistView.swift index d4c551cb..d07f477b 100644 --- a/Shared/Playlists/AddToPlaylistView.swift +++ b/Shared/Playlists/AddToPlaylistView.swift @@ -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 - } - ) + presentationMode.wrappedValue.dismiss() } private var selectedPlaylist: Playlist? { diff --git a/Shared/Search/SearchSuggestions.swift b/Shared/Search/SearchSuggestions.swift index 6fbec88a..2ba6806a 100644 --- a/Shared/Search/SearchSuggestions.swift +++ b/Shared/Search/SearchSuggestions.swift @@ -66,8 +66,9 @@ struct SearchSuggestions: View { #endif } } + .id(UUID()) #if os(macOS) - .buttonStyle(.link) + .buttonStyle(.link) #endif } diff --git a/Shared/Search/SearchView.swift b/Shared/Search/SearchView.swift index 31314e11..6de8c815 100644 --- a/Shared/Search/SearchView.swift +++ b/Shared/Search/SearchView.swift @@ -297,6 +297,7 @@ struct SearchView: View { } .redrawOn(change: recentsChanged) } + .id(UUID()) } #if os(iOS) .listStyle(.insetGrouped) diff --git a/Shared/Views/BrowserPlayerControls.swift b/Shared/Views/BrowserPlayerControls.swift index 95f3372c..17025d0b 100644 --- a/Shared/Views/BrowserPlayerControls.swift +++ b/Shared/Views/BrowserPlayerControls.swift @@ -1,146 +1,230 @@ 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 - - #endif - } - .borderTop(height: 0.4, color: Color("ControlsBorderColor")) - #if os(macOS) - .background(VisualEffectBlur(material: .sidebar)) - #elseif os(iOS) - .background(VisualEffectBlur(blurStyle: .systemThinMaterial).edgesIgnoringSafeArea(.all)) + ControlsBar() + .edgesIgnoringSafeArea(.bottom) #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 { - VStack { - Spacer() - Text("Hello") - Spacer() + BrowserPlayerControls(context: .player) { + BrowserPlayerControls { + VStack { + Spacer() + Text("Hello") + Spacer() + } } + .offset(y: -100) } .injectFixtureEnvironmentObjects() } diff --git a/Shared/Views/ControlsBar.swift b/Shared/Views/ControlsBar.swift new file mode 100644 index 00000000..e635b166 --- /dev/null +++ b/Shared/Views/ControlsBar.swift @@ -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() + } +} diff --git a/Shared/Views/VideoContextMenuView.swift b/Shared/Views/VideoContextMenuView.swift index d8c8ae93..2c8237be 100644 --- a/Shared/Views/VideoContextMenuView.swift +++ b/Shared/Views/VideoContextMenuView.swift @@ -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) diff --git a/Shared/YatteeApp.swift b/Shared/YatteeApp.swift index 8d294f46..db9761f4 100644 --- a/Shared/YatteeApp.swift +++ b/Shared/YatteeApp.swift @@ -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 + } + } } diff --git a/Yattee.xcodeproj/project.pbxproj b/Yattee.xcodeproj/project.pbxproj index 5f9eabce..2b71c5e6 100644 --- a/Yattee.xcodeproj/project.pbxproj +++ b/Yattee.xcodeproj/project.pbxproj @@ -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" */; diff --git a/Yattee.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Yattee.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0ac08c7d..e69de29b 100644 --- a/Yattee.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Yattee.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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 -}