mirror of
https://github.com/yattee/yattee.git
synced 2025-12-20 11:40:15 +00:00
Compare commits
35 Commits
v1.4-alpha
...
v1.4-alpha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84a283ff1d | ||
|
|
13688adb6e | ||
|
|
f5b6c97003 | ||
|
|
915cae2f5a | ||
|
|
586fb2cee1 | ||
|
|
da0b25b14d | ||
|
|
d31aeea52a | ||
|
|
812e2aef87 | ||
|
|
382cb30f2a | ||
|
|
91bc909b37 | ||
|
|
725dadf7c5 | ||
|
|
4d72844975 | ||
|
|
dc18e189aa | ||
|
|
02349723a1 | ||
|
|
4094abb8ba | ||
|
|
0b213e54ad | ||
|
|
622dd5ba8a | ||
|
|
a77d72e2b6 | ||
|
|
755497eda5 | ||
|
|
48487f9707 | ||
|
|
5ef47fc2a9 | ||
|
|
a26d02271d | ||
|
|
10e5975b37 | ||
|
|
4178275e66 | ||
|
|
207ba49977 | ||
|
|
4193972669 | ||
|
|
2f395f19ad | ||
|
|
fc3ab0d8ae | ||
|
|
931107b8d5 | ||
|
|
a2d84a905c | ||
|
|
3327ee3fbd | ||
|
|
4227bb304e | ||
|
|
8d3e5b2a2d | ||
|
|
ac1218a612 | ||
|
|
7a6820dde2 |
@@ -1,8 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
extension Video {
|
||||
static var fixtureID: Video.ID = "video-fixture"
|
||||
static var fixtureChannelID: Channel.ID = "channel-fixture"
|
||||
static var fixtureID: Video.ID {
|
||||
"FIXTURE"
|
||||
}
|
||||
|
||||
static var fixture: Video {
|
||||
let thumbnailURL = "https://yt3.ggpht.com/ytc/AKedOLR-pT_JEsz_hcaA4Gjx8DHcqJ8mS42aTRqcVy6P7w=s88-c-k-c0x00ffffff-no-rj-mo"
|
||||
@@ -17,7 +18,7 @@ extension Video {
|
||||
description: "Some relaxing live piano music",
|
||||
genre: "Music",
|
||||
channel: Channel(
|
||||
id: fixtureChannelID,
|
||||
id: "AbCdEFgHI",
|
||||
name: "The Channel",
|
||||
thumbnailURL: URL(string: thumbnailURL)!,
|
||||
subscriptionsCount: 2300,
|
||||
|
||||
@@ -53,7 +53,7 @@ final class InstancesModel: ObservableObject {
|
||||
}
|
||||
|
||||
static func remove(_ instance: Instance) {
|
||||
let accounts = Self.accounts(instance.id)
|
||||
let accounts = InstancesModel.accounts(instance.id)
|
||||
if let index = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) {
|
||||
Defaults[.instances].remove(at: index)
|
||||
accounts.forEach { AccountsModel.remove($0) }
|
||||
|
||||
@@ -160,11 +160,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
private func pathPattern(_ path: String) -> String {
|
||||
"**\(Self.basePath)/\(path)"
|
||||
"**\(InvidiousAPI.basePath)/\(path)"
|
||||
}
|
||||
|
||||
private func basePathAppending(_ path: String) -> String {
|
||||
"\(Self.basePath)/\(path)"
|
||||
"\(InvidiousAPI.basePath)/\(path)"
|
||||
}
|
||||
|
||||
private var cookieHeader: String {
|
||||
@@ -172,11 +172,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
var popular: Resource? {
|
||||
resource(baseURL: account.url, path: "\(Self.basePath)/popular")
|
||||
resource(baseURL: account.url, path: "\(InvidiousAPI.basePath)/popular")
|
||||
}
|
||||
|
||||
func trending(country: Country, category: TrendingCategory?) -> Resource {
|
||||
resource(baseURL: account.url, path: "\(Self.basePath)/trending")
|
||||
resource(baseURL: account.url, path: "\(InvidiousAPI.basePath)/trending")
|
||||
.withParam("type", category?.name)
|
||||
.withParam("region", country.rawValue)
|
||||
}
|
||||
@@ -186,7 +186,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
var feed: Resource? {
|
||||
resource(baseURL: account.url, path: "\(Self.basePath)/auth/feed")
|
||||
resource(baseURL: account.url, path: "\(InvidiousAPI.basePath)/auth/feed")
|
||||
}
|
||||
|
||||
var subscriptions: Resource? {
|
||||
@@ -239,66 +239,6 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
playlist(playlistID)?.child("videos").child(videoID)
|
||||
}
|
||||
|
||||
func addVideoToPlaylist(
|
||||
_ videoID: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void = { _ in },
|
||||
onSuccess: @escaping () -> Void = {}
|
||||
) {
|
||||
let resource = playlistVideos(playlistID)
|
||||
let body = ["videoId": videoID]
|
||||
|
||||
resource?
|
||||
.request(.post, json: body)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func removeVideoFromPlaylist(
|
||||
_ index: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
) {
|
||||
let resource = playlistVideo(playlistID, index)
|
||||
|
||||
resource?
|
||||
.request(.delete)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func playlistForm(
|
||||
_ name: String,
|
||||
_ visibility: String,
|
||||
playlist: Playlist?,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping (Playlist?) -> Void
|
||||
) {
|
||||
let body = ["title": name, "privacy": visibility]
|
||||
let resource = !playlist.isNil ? self.playlist(playlist!.id) : playlists
|
||||
|
||||
resource?
|
||||
.request(!playlist.isNil ? .patch : .post, json: body)
|
||||
.onSuccess { response in
|
||||
if let modifiedPlaylist: Playlist = response.typedContent() {
|
||||
onSuccess(modifiedPlaylist)
|
||||
}
|
||||
}
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func deletePlaylist(
|
||||
_ playlist: Playlist,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
) {
|
||||
self.playlist(playlist.id)?
|
||||
.request(.delete)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func channelPlaylist(_ id: String) -> Resource? {
|
||||
resource(baseURL: account.url, path: basePathAppending("playlists/\(id)"))
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import Siesta
|
||||
import SwiftyJSON
|
||||
|
||||
final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
static var authorizedEndpoints = ["subscriptions", "subscribe", "unsubscribe", "user/playlists"]
|
||||
static var authorizedEndpoints = ["subscriptions", "subscribe", "unsubscribe"]
|
||||
|
||||
@Published var account: Account!
|
||||
|
||||
@@ -43,11 +43,6 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
self.extractChannelPlaylist(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("user/playlists/create")) { (_: Entity<JSON>) in }
|
||||
configureTransformer(pathPattern("user/playlists/delete")) { (_: Entity<JSON>) in }
|
||||
configureTransformer(pathPattern("user/playlists/add")) { (_: Entity<JSON>) in }
|
||||
configureTransformer(pathPattern("user/playlists/remove")) { (_: Entity<JSON>) in }
|
||||
|
||||
configureTransformer(pathPattern("streams/*")) { (content: Entity<JSON>) -> Video? in
|
||||
self.extractVideo(from: content.json)
|
||||
}
|
||||
@@ -86,10 +81,6 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
return CommentsPage(comments: comments, nextPage: nextPage, disabled: disabled)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("user/playlists")) { (content: Entity<JSON>) -> [Playlist] in
|
||||
content.json.arrayValue.map { self.extractUserPlaylist(from: $0)! }
|
||||
}
|
||||
|
||||
if account.token.isNil {
|
||||
updateToken()
|
||||
}
|
||||
@@ -179,9 +170,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
var home: Resource? { nil }
|
||||
var popular: Resource? { nil }
|
||||
var playlists: Resource? {
|
||||
resource(baseURL: account.instance.apiURL, path: "user/playlists")
|
||||
}
|
||||
var playlists: Resource? { nil }
|
||||
|
||||
func subscribe(_ channelID: String, onCompletion: @escaping () -> Void = {}) {
|
||||
resource(baseURL: account.instance.apiURL, path: "subscribe")
|
||||
@@ -195,79 +184,10 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
.onCompletion { _ in onCompletion() }
|
||||
}
|
||||
|
||||
func playlist(_ id: String) -> Resource? {
|
||||
channelPlaylist(id)
|
||||
}
|
||||
|
||||
func playlist(_: String) -> Resource? { nil }
|
||||
func playlistVideo(_: String, _: String) -> Resource? { nil }
|
||||
func playlistVideos(_: String) -> Resource? { nil }
|
||||
|
||||
func addVideoToPlaylist(
|
||||
_ videoID: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void = { _ in },
|
||||
onSuccess: @escaping () -> Void = {}
|
||||
) {
|
||||
let resource = resource(baseURL: account.instance.apiURL, path: "user/playlists/add")
|
||||
let body = ["videoId": videoID, "playlistId": playlistID]
|
||||
|
||||
resource
|
||||
.request(.post, json: body)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func removeVideoFromPlaylist(
|
||||
_ index: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
) {
|
||||
let resource = resource(baseURL: account.instance.apiURL, path: "user/playlists/remove")
|
||||
let body: [String: Any] = ["index": Int(index)!, "playlistId": playlistID]
|
||||
|
||||
resource
|
||||
.request(.post, json: body)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func playlistForm(
|
||||
_ name: String,
|
||||
_: String,
|
||||
playlist: Playlist?,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping (Playlist?) -> Void
|
||||
) {
|
||||
let body = ["name": name]
|
||||
let resource = playlist.isNil ? resource(baseURL: account.instance.apiURL, path: "user/playlists/create") : nil
|
||||
|
||||
resource?
|
||||
.request(.post, json: body)
|
||||
.onSuccess { response in
|
||||
if let modifiedPlaylist: Playlist = response.typedContent() {
|
||||
onSuccess(modifiedPlaylist)
|
||||
} else {
|
||||
onSuccess(nil)
|
||||
}
|
||||
}
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func deletePlaylist(
|
||||
_ playlist: Playlist,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
) {
|
||||
let resource = resource(baseURL: account.instance.apiURL, path: "user/playlists/delete")
|
||||
let body = ["playlistId": playlist.id]
|
||||
|
||||
resource
|
||||
.request(.post, json: body)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func comments(_ id: Video.ID, page: String?) -> Resource? {
|
||||
let path = page.isNil ? "comments/\(id)" : "nextpage/comments/\(id)"
|
||||
let resource = resource(baseURL: account.url, path: path)
|
||||
@@ -364,7 +284,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
return ChannelPlaylist(
|
||||
id: id,
|
||||
title: details["name"]?.stringValue ?? "",
|
||||
title: details["name"]!.stringValue,
|
||||
thumbnailURL: thumbnailURL,
|
||||
channel: extractChannel(from: json)!,
|
||||
videos: videos,
|
||||
@@ -394,7 +314,6 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
let author = details["uploaderName"]?.stringValue ?? details["uploader"]!.stringValue
|
||||
let authorThumbnailURL = details["avatarUrl"]?.url ?? details["uploaderAvatar"]?.url ?? details["avatar"]?.url
|
||||
let subscriptionsCount = details["uploaderSubscriberCount"]?.int
|
||||
|
||||
let uploaded = details["uploaded"]?.doubleValue
|
||||
var published = uploaded.isNil ? nil : (uploaded! / 1000).formattedAsRelativeTime()
|
||||
@@ -412,7 +331,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
published: published!,
|
||||
views: details["views"]!.intValue,
|
||||
description: extractDescription(from: content),
|
||||
channel: Channel(id: channelId, name: author, thumbnailURL: authorThumbnailURL, subscriptionsCount: subscriptionsCount),
|
||||
channel: Channel(id: channelId, name: author, thumbnailURL: authorThumbnailURL),
|
||||
thumbnails: thumbnails,
|
||||
live: live,
|
||||
likes: details["likes"]?.int,
|
||||
@@ -444,14 +363,6 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
)!
|
||||
}
|
||||
|
||||
private func extractUserPlaylist(from json: JSON) -> Playlist? {
|
||||
let id = json["id"].stringValue
|
||||
let title = json["name"].stringValue
|
||||
let visibility = Playlist.Visibility.private
|
||||
|
||||
return Playlist(id: id, title: title, visibility: visibility)
|
||||
}
|
||||
|
||||
private func extractDescription(from content: JSON) -> String? {
|
||||
guard var description = content.dictionaryValue["description"]?.string else {
|
||||
return nil
|
||||
|
||||
@@ -27,34 +27,6 @@ protocol VideosAPI {
|
||||
func playlistVideo(_ playlistID: String, _ videoID: String) -> Resource?
|
||||
func playlistVideos(_ id: String) -> Resource?
|
||||
|
||||
func addVideoToPlaylist(
|
||||
_ videoID: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
)
|
||||
|
||||
func removeVideoFromPlaylist(
|
||||
_ index: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
)
|
||||
|
||||
func playlistForm(
|
||||
_ name: String,
|
||||
_ visibility: String,
|
||||
playlist: Playlist?,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping (Playlist?) -> Void
|
||||
)
|
||||
|
||||
func deletePlaylist(
|
||||
_ playlist: Playlist,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
)
|
||||
|
||||
func channelPlaylist(_ id: String) -> Resource?
|
||||
|
||||
func loadDetails(_ item: PlayerQueueItem, completionHandler: @escaping (PlayerQueueItem) -> Void)
|
||||
|
||||
@@ -32,18 +32,6 @@ enum VideosApp: String, CaseIterable {
|
||||
}
|
||||
|
||||
var supportsUserPlaylists: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
var userPlaylistsEndpointIncludesVideos: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var userPlaylistsHaveVisibility: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var userPlaylistsAreEditable: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
|
||||
@@ -74,13 +74,6 @@ final class NavigationModel: ObservableObject {
|
||||
navigationStyle: NavigationStyle,
|
||||
delay: Bool = true
|
||||
) {
|
||||
guard channel.id != Video.fixtureChannelID else {
|
||||
return
|
||||
}
|
||||
|
||||
player.presentingPlayer = false
|
||||
navigation.presentingChannel = false
|
||||
|
||||
let recent = RecentItem(from: channel)
|
||||
#if os(macOS)
|
||||
Windows.main.open()
|
||||
@@ -116,8 +109,6 @@ final class NavigationModel: ObservableObject {
|
||||
navigationStyle: NavigationStyle,
|
||||
delay: Bool = false
|
||||
) {
|
||||
navigation.presentingPlaylist = false
|
||||
|
||||
let recent = RecentItem(from: playlist)
|
||||
#if os(macOS)
|
||||
Windows.main.open()
|
||||
|
||||
@@ -36,9 +36,8 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
}
|
||||
|
||||
private(set) var avPlayer = AVPlayer()
|
||||
|
||||
var controller: AppleAVPlayerViewController?
|
||||
var startPictureInPictureOnPlay = false
|
||||
var switchToMPVOnPipClose = false
|
||||
|
||||
private var asset: AVURLAsset?
|
||||
private var composition = AVMutableComposition()
|
||||
@@ -169,7 +168,8 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
}
|
||||
#else
|
||||
func closePiP(wasPlaying: Bool) {
|
||||
model.pipController?.stopPictureInPicture()
|
||||
controller?.playerView.player = nil
|
||||
controller?.playerView.player = avPlayer
|
||||
|
||||
guard wasPlaying else {
|
||||
return
|
||||
@@ -322,8 +322,6 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.setRate(self.model.currentRate)
|
||||
}
|
||||
|
||||
let replaceItemAndSeek = {
|
||||
@@ -443,7 +441,7 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
self,
|
||||
selector: #selector(itemDidPlayToEndTime),
|
||||
name: NSNotification.Name.AVPlayerItemDidPlayToEndTime,
|
||||
object: model.playerItem
|
||||
object: playerItem
|
||||
)
|
||||
}
|
||||
|
||||
@@ -451,7 +449,7 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
NotificationCenter.default.removeObserver(
|
||||
self,
|
||||
name: NSNotification.Name.AVPlayerItemDidPlayToEndTime,
|
||||
object: model.playerItem
|
||||
object: playerItem
|
||||
)
|
||||
}
|
||||
|
||||
@@ -471,9 +469,6 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
model.hide()
|
||||
#endif
|
||||
} else {
|
||||
if model.playingInPictureInPicture {
|
||||
startPictureInPictureOnPlay = true
|
||||
}
|
||||
model.advanceToNextItem()
|
||||
}
|
||||
}
|
||||
@@ -540,26 +535,13 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
}
|
||||
|
||||
if player.timeControlStatus != .waitingToPlayAtSpecifiedRate {
|
||||
if let controller = self.model.pipController {
|
||||
if controller.isPictureInPicturePossible {
|
||||
if self.startPictureInPictureOnPlay {
|
||||
self.startPictureInPictureOnPlay = false
|
||||
DispatchQueue.main.async {
|
||||
self.model.pipController?.startPictureInPicture()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.model.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
|
||||
if player.timeControlStatus == .playing {
|
||||
if player.rate != self.model.currentRate {
|
||||
player.rate = self.model.currentRate
|
||||
}
|
||||
if player.timeControlStatus == .playing, player.rate != self.model.currentRate {
|
||||
player.rate = self.model.currentRate
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import AVFAudio
|
||||
import CoreMedia
|
||||
import Defaults
|
||||
import Foundation
|
||||
import Logging
|
||||
import SwiftUI
|
||||
@@ -42,9 +41,7 @@ final class MPVBackend: PlayerBackend {
|
||||
updateControlsIsPlaying()
|
||||
|
||||
#if !os(macOS)
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.isIdleTimerDisabled = self.model.presentingPlayer && self.isPlaying
|
||||
}
|
||||
UIApplication.shared.isIdleTimerDisabled = model.presentingPlayer && isPlaying
|
||||
#endif
|
||||
}}
|
||||
var playerItemDuration: CMTime?
|
||||
@@ -70,33 +67,16 @@ final class MPVBackend: PlayerBackend {
|
||||
clientTimer.eventHandler = getClientUpdates
|
||||
}
|
||||
|
||||
typealias AreInIncreasingOrder = (Stream, Stream) -> Bool
|
||||
|
||||
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting) -> Stream? {
|
||||
streams
|
||||
.filter { $0.kind != .hls && $0.resolution <= maxResolution.value }
|
||||
.max { lhs, rhs in
|
||||
let predicates: [AreInIncreasingOrder] = [
|
||||
{ $0.resolution < $1.resolution },
|
||||
{ $0.format > $1.format }
|
||||
]
|
||||
|
||||
for predicate in predicates {
|
||||
if !predicate(lhs, rhs), !predicate(rhs, lhs) {
|
||||
continue
|
||||
}
|
||||
|
||||
return predicate(lhs, rhs)
|
||||
}
|
||||
|
||||
return false
|
||||
} ??
|
||||
.filter { $0.kind == .adaptive && $0.resolution <= maxResolution.value }
|
||||
.max { $0.resolution < $1.resolution } ??
|
||||
streams.first { $0.kind == .hls } ??
|
||||
streams.first
|
||||
}
|
||||
|
||||
func canPlay(_ stream: Stream) -> Bool {
|
||||
stream.resolution != .unknown && stream.format != .av1
|
||||
stream.resolution != .unknown && stream.format != "AV1"
|
||||
}
|
||||
|
||||
func playStream(_ stream: Stream, of video: Video, preservingTime: Bool, upgrading _: Bool) {
|
||||
@@ -129,7 +109,7 @@ final class MPVBackend: PlayerBackend {
|
||||
|
||||
if !preservingTime,
|
||||
let segment = self.model.sponsorBlock.segments.first,
|
||||
segment.start > 4,
|
||||
segment.start < 3,
|
||||
self.model.lastSkipped.isNil
|
||||
{
|
||||
self.seek(to: segment.endTime) { finished in
|
||||
@@ -202,8 +182,6 @@ final class MPVBackend: PlayerBackend {
|
||||
startControlsUpdates()
|
||||
}
|
||||
|
||||
setRate(model.currentRate)
|
||||
|
||||
client?.play()
|
||||
}
|
||||
|
||||
@@ -238,25 +216,13 @@ final class MPVBackend: PlayerBackend {
|
||||
}
|
||||
}
|
||||
|
||||
func setRate(_ rate: Float) {
|
||||
client?.setDoubleAsync("speed", Double(rate))
|
||||
func setRate(_: Float) {
|
||||
// TODO: Implement rate change
|
||||
}
|
||||
|
||||
func closeItem() {
|
||||
handleEOF = false
|
||||
client?.pause()
|
||||
client?.stop()
|
||||
}
|
||||
func closeItem() {}
|
||||
|
||||
func enterFullScreen() {
|
||||
model.toggleFullscreen(controls?.playingFullscreen ?? false)
|
||||
|
||||
#if os(iOS)
|
||||
if Defaults[.lockOrientationInFullScreen] {
|
||||
Orientation.lockOrientation(.landscape, andRotateTo: UIDevice.current.orientation.isLandscape ? nil : .landscapeRight)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
func enterFullScreen() {}
|
||||
|
||||
func exitFullScreen() {}
|
||||
|
||||
@@ -284,8 +250,6 @@ final class MPVBackend: PlayerBackend {
|
||||
clientTimer.resume()
|
||||
}
|
||||
|
||||
private var handleSegmentsThrottle = Throttle(interval: 1)
|
||||
|
||||
private func getClientUpdates() {
|
||||
self.logger.info("getting client updates")
|
||||
|
||||
@@ -298,10 +262,8 @@ final class MPVBackend: PlayerBackend {
|
||||
|
||||
model.updateNowPlayingInfo()
|
||||
|
||||
handleSegmentsThrottle.execute {
|
||||
if let currentTime = currentTime {
|
||||
model.handleSegments(at: currentTime)
|
||||
}
|
||||
if let currentTime = currentTime {
|
||||
model.handleSegments(at: currentTime)
|
||||
}
|
||||
|
||||
timeObserverThrottle.execute {
|
||||
|
||||
@@ -134,11 +134,11 @@ final class MPVClient: ObservableObject {
|
||||
}
|
||||
|
||||
var currentTime: CMTime {
|
||||
CMTime.secondsInDefaultTimescale(mpv.isNil ? -1 : getDouble("time-pos"))
|
||||
CMTime.secondsInDefaultTimescale(getDouble("time-pos"))
|
||||
}
|
||||
|
||||
var duration: CMTime {
|
||||
CMTime.secondsInDefaultTimescale(mpv.isNil ? -1 : getDouble("duration"))
|
||||
CMTime.secondsInDefaultTimescale(getDouble("duration"))
|
||||
}
|
||||
|
||||
func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
|
||||
@@ -225,11 +225,6 @@ final class MPVClient: ObservableObject {
|
||||
mpv_set_property_async(mpv, 0, name, MPV_FORMAT_FLAG, &data)
|
||||
}
|
||||
|
||||
func setDoubleAsync(_ name: String, _ value: Double) {
|
||||
var data = value
|
||||
mpv_set_property_async(mpv, 0, name, MPV_FORMAT_DOUBLE, &data)
|
||||
}
|
||||
|
||||
private func getDouble(_ name: String) -> Double {
|
||||
var data = Double()
|
||||
mpv_get_property(mpv, name, MPV_FORMAT_DOUBLE, &data)
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import AVKit
|
||||
import Foundation
|
||||
|
||||
final class PiPDelegate: NSObject, AVPictureInPictureControllerDelegate {
|
||||
var player: PlayerModel!
|
||||
|
||||
func pictureInPictureController(
|
||||
_: AVPictureInPictureController,
|
||||
failedToStartPictureInPictureWithError error: Error
|
||||
) {
|
||||
print(error.localizedDescription)
|
||||
}
|
||||
|
||||
func pictureInPictureControllerWillStartPictureInPicture(_: AVPictureInPictureController) {}
|
||||
|
||||
func pictureInPictureControllerDidStartPictureInPicture(_: AVPictureInPictureController) {
|
||||
player?.playingInPictureInPicture = true
|
||||
player?.avPlayerBackend.startPictureInPictureOnPlay = false
|
||||
}
|
||||
|
||||
func pictureInPictureControllerDidStopPictureInPicture(_: AVPictureInPictureController) {
|
||||
guard let player = player else {
|
||||
return
|
||||
}
|
||||
|
||||
if player.avPlayerBackend.switchToMPVOnPipClose,
|
||||
!player.currentItem.isNil
|
||||
{
|
||||
DispatchQueue.main.async {
|
||||
player.avPlayerBackend.switchToMPVOnPipClose = false
|
||||
player.saveTime {
|
||||
player.changeActiveBackend(from: .appleAVPlayer, to: .mpv)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
player.playingInPictureInPicture = false
|
||||
}
|
||||
|
||||
func pictureInPictureControllerWillStopPictureInPicture(_: AVPictureInPictureController) {}
|
||||
|
||||
func pictureInPictureController(
|
||||
_: AVPictureInPictureController,
|
||||
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void
|
||||
) {
|
||||
if !player.currentItem.isNil {
|
||||
player?.show()
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
completionHandler(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import Foundation
|
||||
import SwiftUI
|
||||
|
||||
final class PlayerControlsModel: ObservableObject {
|
||||
@Published var isLoadingVideo = false
|
||||
@Published var isLoadingVideo = true
|
||||
@Published var isPlaying = true
|
||||
@Published var currentTime = CMTime.zero
|
||||
@Published var duration = CMTime.zero
|
||||
@@ -43,7 +43,7 @@ final class PlayerControlsModel: ObservableObject {
|
||||
func handlePresentationChange() {
|
||||
if presentingControls {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.player?.backend.startControlsUpdates()
|
||||
self?.player.backend.startControlsUpdates()
|
||||
self?.resetTimer()
|
||||
}
|
||||
} else {
|
||||
@@ -94,11 +94,9 @@ final class PlayerControlsModel: ObservableObject {
|
||||
}
|
||||
|
||||
func resetTimer() {
|
||||
#if os(tvOS)
|
||||
if !presentingControls {
|
||||
show()
|
||||
}
|
||||
#endif
|
||||
if !presentingControls {
|
||||
show()
|
||||
}
|
||||
|
||||
removeTimer()
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false) { _ in
|
||||
@@ -109,29 +107,6 @@ final class PlayerControlsModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func startPiP(startImmediately: Bool = true) {
|
||||
if player.activeBackend == .mpv {
|
||||
player.avPlayerBackend.switchToMPVOnPipClose = true
|
||||
}
|
||||
|
||||
#if !os(macOS)
|
||||
player.exitFullScreen()
|
||||
#endif
|
||||
|
||||
if player.activeBackend != PlayerBackendType.appleAVPlayer {
|
||||
player.saveTime { [weak player] in
|
||||
player?.changeActiveBackend(from: .mpv, to: .appleAVPlayer)
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak player] in
|
||||
player?.avPlayerBackend.startPictureInPictureOnPlay = true
|
||||
if startImmediately {
|
||||
player?.pipController?.startPictureInPicture()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeTimer() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
|
||||
@@ -57,6 +57,8 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
@Published var preservedTime: CMTime?
|
||||
|
||||
@Published var playerNavigationLinkActive = false { didSet { handleNavigationViewPlayerPresentationChange() } }
|
||||
|
||||
@Published var sponsorBlock = SponsorBlockAPI()
|
||||
@Published var segmentRestorationTime: CMTime?
|
||||
@Published var lastSkipped: Segment? { didSet { rebuildTVMenu() } }
|
||||
@@ -64,6 +66,8 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
@Published var returnYouTubeDislike = ReturnYouTubeDislikeAPI()
|
||||
|
||||
@Published var channelWithDetails: Channel?
|
||||
|
||||
#if os(iOS)
|
||||
@Published var motionManager: CMMotionManager!
|
||||
@Published var lockedOrientation: UIInterfaceOrientation?
|
||||
@@ -81,8 +85,6 @@ final class PlayerModel: ObservableObject {
|
||||
var context: NSManagedObjectContext = PersistenceController.shared.container.viewContext
|
||||
|
||||
@Published var playingInPictureInPicture = false
|
||||
var pipController: AVPictureInPictureController?
|
||||
var pipDelegate = PiPDelegate()
|
||||
|
||||
@Published var presentingErrorDetails = false
|
||||
var playerError: Error? { didSet {
|
||||
@@ -102,9 +104,6 @@ final class PlayerModel: ObservableObject {
|
||||
#endif
|
||||
|
||||
private var currentArtwork: MPMediaItemArtwork?
|
||||
#if !os(macOS)
|
||||
var playerLayerView: PlayerLayerView!
|
||||
#endif
|
||||
|
||||
init(accounts: AccountsModel? = nil, comments: CommentsModel? = nil, controls: PlayerControlsModel? = nil) {
|
||||
self.accounts = accounts ?? AccountsModel()
|
||||
@@ -118,24 +117,23 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
func show() {
|
||||
#if os(macOS)
|
||||
if presentingPlayer {
|
||||
guard !presentingPlayer else {
|
||||
#if os(macOS)
|
||||
Windows.player.focus()
|
||||
return
|
||||
}
|
||||
#endif
|
||||
|
||||
presentingPlayer = true
|
||||
|
||||
#endif
|
||||
return
|
||||
}
|
||||
#if os(macOS)
|
||||
Windows.player.open()
|
||||
Windows.player.focus()
|
||||
#endif
|
||||
presentingPlayer = true
|
||||
}
|
||||
|
||||
func hide() {
|
||||
controls.playingFullscreen = false
|
||||
presentingPlayer = false
|
||||
playerNavigationLinkActive = false
|
||||
|
||||
#if os(iOS)
|
||||
if Defaults[.lockPortraitWhenBrowsing] {
|
||||
@@ -205,25 +203,16 @@ final class PlayerModel: ObservableObject {
|
||||
backend.pause()
|
||||
}
|
||||
|
||||
func play(_ video: Video, at time: CMTime? = nil, showingPlayer: Bool = true) {
|
||||
var delay = 0.0
|
||||
#if !os(macOS)
|
||||
delay = 0.3
|
||||
#endif
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
self.playNow(video, at: time)
|
||||
}
|
||||
func play(_ video: Video, at time: TimeInterval? = nil, inNavigationView: Bool = false) {
|
||||
playNow(video, at: time)
|
||||
|
||||
guard !playingInPictureInPicture else {
|
||||
return
|
||||
}
|
||||
|
||||
if showingPlayer {
|
||||
if inNavigationView {
|
||||
playerNavigationLinkActive = true
|
||||
} else {
|
||||
show()
|
||||
}
|
||||
}
|
||||
@@ -242,7 +231,11 @@ final class PlayerModel: ObservableObject {
|
||||
self?.sponsorBlock.loadSegments(
|
||||
videoID: video.videoID,
|
||||
categories: Defaults[.sponsorBlockCategories]
|
||||
)
|
||||
) {
|
||||
if Defaults[.showChannelSubscribers] {
|
||||
self?.loadCurrentItemChannelDetails()
|
||||
}
|
||||
}
|
||||
|
||||
guard Defaults[.enableReturnYouTubeDislike] else {
|
||||
return
|
||||
@@ -270,7 +263,6 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
func saveTime(completionHandler: @escaping () -> Void = {}) {
|
||||
guard let currentTime = backend.currentTime, currentTime.seconds > 0 else {
|
||||
completionHandler()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -281,12 +273,8 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
func upgradeToStream(_ stream: Stream, force: Bool = false) {
|
||||
guard let video = currentVideo else {
|
||||
return
|
||||
}
|
||||
|
||||
if !self.stream.isNil, force || self.stream != stream {
|
||||
playStream(stream, of: video, preservingTime: true, upgrading: true)
|
||||
playStream(stream, of: currentVideo!, preservingTime: true, upgrading: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,18 +298,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func handlePresentationChange() {
|
||||
var delay = 0.0
|
||||
|
||||
#if os(iOS)
|
||||
if presentingPlayer {
|
||||
delay = 0.2
|
||||
}
|
||||
#endif
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
self?.backend.setNeedsDrawing(self?.presentingPlayer ?? false)
|
||||
}
|
||||
|
||||
backend.setNeedsDrawing(presentingPlayer)
|
||||
controls.hide()
|
||||
|
||||
#if !os(macOS)
|
||||
@@ -347,11 +324,18 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func changeActiveBackend(from: PlayerBackendType, to: PlayerBackendType) {
|
||||
guard activeBackend != to else {
|
||||
return
|
||||
}
|
||||
private func handleNavigationViewPlayerPresentationChange() {
|
||||
backend.setNeedsDrawing(playerNavigationLinkActive)
|
||||
controls.hide()
|
||||
|
||||
if pauseOnHidingPlayer, !playingInPictureInPicture, !playerNavigationLinkActive {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
self.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func changeActiveBackend(from: PlayerBackendType, to: PlayerBackendType) {
|
||||
Defaults[.activeBackend] = to
|
||||
self.activeBackend = to
|
||||
|
||||
@@ -378,7 +362,7 @@ final class PlayerModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
if !backend.canPlay(stream) || (to == .mpv && !stream.hlsURL.isNil) {
|
||||
if !backend.canPlay(stream) {
|
||||
guard let preferredStream = preferredStream(availableStreams) else {
|
||||
return
|
||||
}
|
||||
@@ -388,11 +372,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
self.upgradeToStream(stream, force: true)
|
||||
self.setNeedsDrawing(self.presentingPlayer)
|
||||
self?.upgradeToStream(stream, force: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,6 +380,36 @@ final class PlayerModel: ObservableObject {
|
||||
[activeBackend == PlayerBackendType.mpv ? avPlayerBackend : mpvBackend]
|
||||
}
|
||||
|
||||
func loadCurrentItemChannelDetails() {
|
||||
guard let video = currentVideo,
|
||||
!video.channel.detailsLoaded
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
if restoreLoadedChannel() {
|
||||
return
|
||||
}
|
||||
|
||||
accounts.api.channel(video.channel.id).load().onSuccess { [weak self] response in
|
||||
if let channel: Channel = response.typedContent() {
|
||||
self?.channelWithDetails = channel
|
||||
withAnimation {
|
||||
self?.currentItem?.video?.channel = channel
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult func restoreLoadedChannel() -> Bool {
|
||||
if !currentVideo.isNil, channelWithDetails?.id == currentVideo!.channel.id {
|
||||
currentItem.video.channel = channelWithDetails!
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func rateLabel(_ rate: Float) -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.minimumFractionDigits = 0
|
||||
@@ -469,10 +479,6 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
logger.info("exiting fullscreen")
|
||||
|
||||
if controls.playingFullscreen {
|
||||
toggleFullscreen(true)
|
||||
}
|
||||
|
||||
backend.exitFullScreen()
|
||||
}
|
||||
#endif
|
||||
@@ -549,8 +555,4 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func setNeedsDrawing(_ needsDrawing: Bool) {
|
||||
backends.forEach { $0.setNeedsDrawing(needsDrawing) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ extension PlayerModel {
|
||||
currentItem?.video
|
||||
}
|
||||
|
||||
func play(_ videos: [Video], shuffling: Bool = false) {
|
||||
func play(_ videos: [Video], shuffling: Bool = false, inNavigationView: Bool = false) {
|
||||
let videosToPlay = shuffling ? videos.shuffled() : videos
|
||||
|
||||
guard let first = videosToPlay.first else {
|
||||
@@ -27,7 +27,11 @@ extension PlayerModel {
|
||||
}
|
||||
}
|
||||
|
||||
show()
|
||||
if inNavigationView {
|
||||
playerNavigationLinkActive = true
|
||||
} else {
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
func playNext(_ video: Video) {
|
||||
@@ -38,7 +42,7 @@ extension PlayerModel {
|
||||
}
|
||||
}
|
||||
|
||||
func playNow(_ video: Video, at time: CMTime? = nil) {
|
||||
func playNow(_ video: Video, at time: TimeInterval? = nil) {
|
||||
if playingInPictureInPicture, closePiPOnNavigation {
|
||||
closePiP()
|
||||
}
|
||||
@@ -50,7 +54,7 @@ extension PlayerModel {
|
||||
}
|
||||
}
|
||||
|
||||
func playItem(_ item: PlayerQueueItem, video: Video? = nil, at time: CMTime? = nil) {
|
||||
func playItem(_ item: PlayerQueueItem, video: Video? = nil, at time: TimeInterval? = nil) {
|
||||
if !playingInPictureInPicture {
|
||||
backend.closeItem()
|
||||
}
|
||||
@@ -60,7 +64,7 @@ extension PlayerModel {
|
||||
currentItem = item
|
||||
|
||||
if !time.isNil {
|
||||
currentItem.playbackTime = time
|
||||
currentItem.playbackTime = .secondsInDefaultTimescale(time!)
|
||||
} else if currentItem.playbackTime.isNil {
|
||||
currentItem.playbackTime = .zero
|
||||
}
|
||||
@@ -70,6 +74,7 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
preservedTime = currentItem.playbackTime
|
||||
restoreLoadedChannel()
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let video = self?.currentVideo else {
|
||||
@@ -101,12 +106,13 @@ extension PlayerModel {
|
||||
}
|
||||
}
|
||||
|
||||
func advanceToItem(_ newItem: PlayerQueueItem, at time: CMTime? = nil) {
|
||||
func advanceToItem(_ newItem: PlayerQueueItem, at time: TimeInterval? = nil) {
|
||||
prepareCurrentItemForHistory()
|
||||
|
||||
remove(newItem)
|
||||
|
||||
currentItem = newItem
|
||||
pause()
|
||||
|
||||
accounts.api.loadDetails(newItem) { newItem in
|
||||
self.playItem(newItem, video: newItem.video, at: time)
|
||||
@@ -147,6 +153,7 @@ extension PlayerModel {
|
||||
if play {
|
||||
currentItem = item
|
||||
// pause playing current video as it's going to be replaced with next one
|
||||
pause()
|
||||
}
|
||||
|
||||
queue.insert(item, at: prepending ? 0 : queue.endIndex)
|
||||
@@ -171,8 +178,8 @@ extension PlayerModel {
|
||||
}
|
||||
}
|
||||
|
||||
func playHistory(_ item: PlayerQueueItem, at time: CMTime? = nil) {
|
||||
var time = time ?? item.playbackTime
|
||||
func playHistory(_ item: PlayerQueueItem) {
|
||||
var time = item.playbackTime
|
||||
|
||||
if item.shouldRestartPlaying {
|
||||
time = .zero
|
||||
@@ -192,17 +199,8 @@ extension PlayerModel {
|
||||
return
|
||||
}
|
||||
|
||||
var restoredQueue = [PlayerQueueItem?]()
|
||||
|
||||
if let lastPlayed = Defaults[.lastPlayed],
|
||||
!Defaults[.queue].contains(where: { $0.videoID == lastPlayed.videoID })
|
||||
{
|
||||
restoredQueue.append(lastPlayed)
|
||||
Defaults[.lastPlayed] = nil
|
||||
}
|
||||
|
||||
restoredQueue.append(contentsOf: Defaults[.queue])
|
||||
queue = restoredQueue.compactMap { $0 }
|
||||
queue = ([Defaults[.lastPlayed]] + Defaults[.queue]).compactMap { $0 }
|
||||
Defaults[.lastPlayed] = nil
|
||||
|
||||
queue.forEach { item in
|
||||
accounts.api.loadDetails(item) { newItem in
|
||||
|
||||
@@ -50,8 +50,7 @@ extension PlayerModel {
|
||||
private func shouldSkip(_ segment: Segment, at time: CMTime) -> Bool {
|
||||
guard isPlaying,
|
||||
!restoredSegments.contains(segment),
|
||||
Defaults[.sponsorBlockCategories].contains(segment.category),
|
||||
segment.start > 4
|
||||
Defaults[.sponsorBlockCategories].contains(segment.category)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -18,11 +18,11 @@ struct Playlist: Identifiable, Equatable, Hashable {
|
||||
var title: String
|
||||
var visibility: Visibility
|
||||
|
||||
var updated: TimeInterval?
|
||||
var updated: TimeInterval
|
||||
|
||||
var videos = [Video]()
|
||||
|
||||
init(id: String, title: String, visibility: Visibility, updated: TimeInterval? = nil, videos: [Video] = []) {
|
||||
init(id: String, title: String, visibility: Visibility, updated: TimeInterval, videos: [Video] = []) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.visibility = visibility
|
||||
|
||||
@@ -4,7 +4,6 @@ import SwiftUI
|
||||
|
||||
final class PlaylistsModel: ObservableObject {
|
||||
@Published var playlists = [Playlist]()
|
||||
@Published var reloadPlaylists = false
|
||||
|
||||
var accounts = AccountsModel()
|
||||
|
||||
@@ -59,20 +58,24 @@ final class PlaylistsModel: ObservableObject {
|
||||
onSuccess: @escaping () -> Void = {},
|
||||
onFailure: @escaping (RequestError) -> Void = { _ in }
|
||||
) {
|
||||
accounts.api.addVideoToPlaylist(videoID, playlistID, onFailure: onFailure) {
|
||||
self.load(force: true) {
|
||||
self.reloadPlaylists.toggle()
|
||||
let resource = accounts.api.playlistVideos(playlistID)
|
||||
let body = ["videoId": videoID]
|
||||
|
||||
resource?
|
||||
.request(.post, json: body)
|
||||
.onSuccess { _ in
|
||||
self.load(force: true)
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func removeVideo(index: String, playlistID: Playlist.ID, onSuccess: @escaping () -> Void = {}) {
|
||||
accounts.api.removeVideoFromPlaylist(index, playlistID, onFailure: { _ in }) {
|
||||
self.load(force: true) {
|
||||
self.reloadPlaylists.toggle()
|
||||
onSuccess()
|
||||
}
|
||||
func removeVideo(videoIndexID: String, playlistID: Playlist.ID, onSuccess: @escaping () -> Void = {}) {
|
||||
let resource = accounts.api.playlistVideo(playlistID, videoIndexID)
|
||||
|
||||
resource?.request(.delete).onSuccess { _ in
|
||||
self.load(force: true)
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import Foundation
|
||||
final class RecentsModel: ObservableObject {
|
||||
@Default(.recentlyOpened) var items
|
||||
@Default(.saveRecents) var saveRecents
|
||||
|
||||
func clear() {
|
||||
items = []
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ final class SponsorBlockAPI: ObservableObject {
|
||||
@Published var segments = [Segment]()
|
||||
|
||||
static func categoryDescription(_ name: String) -> String? {
|
||||
guard Self.categories.contains(name) else {
|
||||
guard SponsorBlockAPI.categories.contains(name) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ final class SponsorBlockAPI: ObservableObject {
|
||||
}
|
||||
|
||||
static func categoryDetails(_ name: String) -> String? {
|
||||
guard Self.categories.contains(name) else {
|
||||
guard SponsorBlockAPI.categories.contains(name) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -22,8 +22,4 @@ final class Store<Data>: ResourceObserver, ObservableObject {
|
||||
func replace(_ items: Data) {
|
||||
all = items
|
||||
}
|
||||
|
||||
func clear() {
|
||||
all = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,23 +5,12 @@ import Foundation
|
||||
// swiftlint:disable:next final_class
|
||||
class Stream: Equatable, Hashable, Identifiable {
|
||||
enum Resolution: String, CaseIterable, Comparable, Defaults.Serializable {
|
||||
case hd4320p60
|
||||
case hd4320p
|
||||
case hd2160p60
|
||||
case hd2160p50
|
||||
case hd2160p48
|
||||
case hd2160p
|
||||
case hd1440p60
|
||||
case hd1440p50
|
||||
case hd1440p48
|
||||
case hd1440p
|
||||
case hd1080p60
|
||||
case hd1080p50
|
||||
case hd1080p48
|
||||
case hd1080p
|
||||
case hd720p60
|
||||
case hd720p50
|
||||
case hd720p48
|
||||
case hd720p
|
||||
case sd480p
|
||||
case sd360p
|
||||
@@ -79,49 +68,6 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
enum Format: String, Comparable {
|
||||
case webm
|
||||
case avc1
|
||||
case av1
|
||||
case mp4
|
||||
case unknown
|
||||
|
||||
private var sortOrder: Int {
|
||||
switch self {
|
||||
case .webm:
|
||||
return 0
|
||||
case .mp4:
|
||||
return 1
|
||||
case .avc1:
|
||||
return 2
|
||||
case .av1:
|
||||
return 3
|
||||
case .unknown:
|
||||
return 4
|
||||
}
|
||||
}
|
||||
|
||||
static func < (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.sortOrder < rhs.sortOrder
|
||||
}
|
||||
|
||||
static func from(_ string: String) -> Self {
|
||||
let lowercased = string.lowercased()
|
||||
|
||||
if lowercased.contains("webm") {
|
||||
return .webm
|
||||
} else if lowercased.contains("avc1") {
|
||||
return .avc1
|
||||
} else if lowercased.contains("av01") {
|
||||
return .av1
|
||||
} else if lowercased.contains("mpeg_4") || lowercased.contains("mp4") {
|
||||
return .mp4
|
||||
} else {
|
||||
return .unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let id = UUID()
|
||||
|
||||
var instance: Instance!
|
||||
@@ -131,7 +77,6 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
|
||||
var resolution: Resolution!
|
||||
var kind: Kind!
|
||||
var format: Format!
|
||||
|
||||
var encoding: String!
|
||||
var videoFormat: String!
|
||||
@@ -153,7 +98,7 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
self.resolution = resolution
|
||||
self.kind = kind
|
||||
self.encoding = encoding
|
||||
format = .from(videoFormat ?? "")
|
||||
self.videoFormat = videoFormat
|
||||
}
|
||||
|
||||
var quality: String {
|
||||
@@ -164,8 +109,23 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
return kind == .hls ? "adaptive (HLS)" : "\(resolution.name)\(kind == .stream ? " (\(kind.rawValue))" : "")"
|
||||
}
|
||||
|
||||
var format: String {
|
||||
let lowercasedFormat = (videoFormat ?? "unknown").lowercased()
|
||||
if lowercasedFormat.contains("webm") {
|
||||
return "WEBM"
|
||||
} else if lowercasedFormat.contains("avc1") {
|
||||
return "avc1"
|
||||
} else if lowercasedFormat.contains("av01") {
|
||||
return "AV1"
|
||||
} else if lowercasedFormat.contains("mpeg_4") || lowercasedFormat.contains("mp4") {
|
||||
return "MP4"
|
||||
} else {
|
||||
return lowercasedFormat
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
let formatString = format == .unknown ? "" : " (\(format.rawValue))"
|
||||
let formatString = format == "unknown" ? "" : " (\(format))"
|
||||
return "\(quality)\(formatString) - \(instance?.description ?? "")"
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ struct Video: Identifiable, Equatable, Hashable {
|
||||
var genre: String?
|
||||
|
||||
// index used when in the Playlist
|
||||
var indexID: String?
|
||||
let indexID: String?
|
||||
|
||||
var live: Bool
|
||||
var upcoming: Bool
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import CoreData
|
||||
import CoreMedia
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
@objc(Watch)
|
||||
final class Watch: NSManagedObject, Identifiable {
|
||||
@Default(.watchedThreshold) private var watchedThreshold
|
||||
@Default(.saveHistory) private var saveHistory
|
||||
}
|
||||
|
||||
extension Watch {
|
||||
@@ -47,15 +45,4 @@ extension Watch {
|
||||
formatter.unitsStyle = .full
|
||||
return formatter.localizedString(for: watchedAt, relativeTo: Date())
|
||||
}
|
||||
|
||||
var timeToRestart: CMTime? {
|
||||
finished ? nil : saveHistory ? .secondsInDefaultTimescale(stoppedAt) : nil
|
||||
}
|
||||
|
||||
var video: Video {
|
||||
Video(
|
||||
videoID: videoID, title: "", author: "",
|
||||
length: 0, published: "", views: -1, channel: Channel(id: "", name: "")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
23
README.md
23
README.md
@@ -1,7 +1,4 @@
|
||||
<div align="center">
|
||||
<h3><strong>📣 <a href="https://yattee.stream/beta">TestFlight beta</a> now available 📣 </strong></h3>
|
||||
<hr />
|
||||
|
||||
<img src="https://r.yattee.stream/icons/yattee-150.png" width="150" height="150" alt="Yattee logo">
|
||||
<h1>Yattee</h1>
|
||||
<p>Alternative YouTube frontend for iOS, tvOS and macOS<br />built with <a href="https://github.com/iv-org/invidious">Invidious</a> and <a href="https://github.com/TeamPiped/Piped">Piped</a></p>
|
||||
@@ -22,32 +19,31 @@
|
||||
* Fullscreen playback, Picture in Picture and AirPlay support
|
||||
* Stream quality selection
|
||||
|
||||
### Features in development
|
||||
### Features in alpha testing
|
||||
* New player component with custom controls, gestures and support for 4K playback
|
||||
|
||||
You can leave your feedback in [discussion on v1.4.alpha.4 release](https://github.com/yattee/yattee/discussions/132) or join [Matrix Channel](https://matrix.to/#/#yattee:matrix.org) for a chat. Thanks!
|
||||
You can leave your feedback in [discussion on v1.4 release](https://github.com/yattee/yattee/discussions/93) or join [Matrix Channel](https://matrix.to/#/#yattee:matrix.org) for a chat. Thanks!
|
||||
|
||||
### Availability
|
||||
|| Invidious | Piped |
|
||||
| - | - | - |
|
||||
| User Accounts | ✅ | ✅ |
|
||||
| Subscriptions | ✅ | ✅ |
|
||||
| User Playlists | ✅ | ✅ |
|
||||
| Popular | ✅ | 🔴 |
|
||||
| User Playlists | ✅ | 🔴 |
|
||||
| Trending | ✅ | ✅ |
|
||||
| Channels | ✅ | ✅ |
|
||||
| Channel Playlists | ✅ | ✅ |
|
||||
| Search | ✅ | ✅ |
|
||||
| Search Suggestions | ✅ | ✅ |
|
||||
| Search Filters | ✅ | 🔴 |
|
||||
| Popular | ✅ | 🔴 |
|
||||
| Subtitles | 🔴 | ✅ |
|
||||
| Comments | 🔴 | ✅ |
|
||||
|
||||
You can browse and use accounts from one app and play videos with another (for example: use Invidious account for subscriptions and use Piped as playback source). Comments can be displayed from Piped even when Invidious is used for browsing/playing.
|
||||
|
||||
## Documentation
|
||||
* [Installation](https://github.com/yattee/yattee/wiki/Installation-Instructions)
|
||||
* [Building](https://github.com/yattee/yattee/wiki/Building-instructions)
|
||||
* [Installation Instructions](https://github.com/yattee/yattee/wiki/Installation-Instructions)
|
||||
* [FAQ](https://github.com/yattee/yattee/wiki)
|
||||
* [Screenshots Gallery](https://github.com/yattee/yattee/wiki/Screenshots-Gallery)
|
||||
* [Tips](https://github.com/yattee/yattee/wiki/Tips)
|
||||
@@ -57,16 +53,11 @@ You can browse and use accounts from one app and play videos with another (for e
|
||||
## Contributing
|
||||
If you're interestred in contributing, you can browse the [issues](https://github.com/yattee/yattee/issues) list or create a new one to discuss your feature idea. Every contribution is very welcome.
|
||||
|
||||
Use [building instructions](https://github.com/yattee/yattee/wiki/Building-instructions) or
|
||||
Join [Matrix Channel](https://matrix.to/#/#yattee:matrix.org) for a chat if you need an advice or want to discuss the project.
|
||||
## License and Liability
|
||||
|
||||
## License
|
||||
Yattee and its components is shared on [AGPL v3](https://www.gnu.org/licenses/agpl-3.0.en.html) license.
|
||||
|
||||
|
||||
## Disclaimer
|
||||
The Yattee project and its contents are not affiliated with, funded, authorized, endorsed by, or in any way accociated with YouTube, Google LLC or any of its affiliates and subsidaries. The official YouTube website can be found at www.youtube.com.
|
||||
|
||||
Any trademark, service mark, trade name, or other intellectual property rights used in the Yattee project are owned by the respective owners.
|
||||
Contributors take no responsibility for the use of the tool (Point 16. of the license). We strongly recommend you abide by the valid official regulations in your country. Furthermore, we refuse liability for any inappropriate use of the tool, such as downloading materials without proper consent.
|
||||
|
||||
This tool is an open source software built for learning and research purposes.
|
||||
|
||||
@@ -26,6 +26,12 @@ extension Defaults.Keys {
|
||||
static let enableReturnYouTubeDislike = Key<Bool>("enableReturnYouTubeDislike", default: false)
|
||||
|
||||
static let favorites = Key<[FavoriteItem]>("favorites", default: [
|
||||
.init(section: .trending("US", "default")),
|
||||
.init(section: .trending("GB", "default")),
|
||||
.init(section: .trending("ES", "default")),
|
||||
.init(section: .channel("UC-lHJZR3Gqxm24_Vd_AJ5Yw", "PewDiePie")),
|
||||
.init(section: .channel("UCXuqSBlHAE6Xw-yeJA0Tunw", "Linus Tech Tips")),
|
||||
.init(section: .channel("UCBJycsmduvYEL83R_U4JriQ", "Marques Brownlee")),
|
||||
.init(section: .channel("UCE_M8A5yxnLfW0KghEeajjw", "Apple"))
|
||||
])
|
||||
|
||||
@@ -45,6 +51,7 @@ extension Defaults.Keys {
|
||||
static let playerInstanceID = Key<Instance.ID?>("playerInstance")
|
||||
static let showKeywords = Key<Bool>("showKeywords", default: false)
|
||||
static let showHistoryInPlayer = Key<Bool>("showHistoryInPlayer", default: false)
|
||||
static let showChannelSubscribers = Key<Bool>("showChannelSubscribers", default: true)
|
||||
static let commentsInstanceID = Key<Instance.ID?>("commentsInstance", default: kavinPipedInstanceID)
|
||||
#if !os(tvOS)
|
||||
static let commentsPlacement = Key<CommentsPlacement>("commentsPlacement", default: .separate)
|
||||
@@ -83,15 +90,13 @@ extension Defaults.Keys {
|
||||
#if os(iOS)
|
||||
static let honorSystemOrientationLock = Key<Bool>("honorSystemOrientationLock", default: true)
|
||||
static let enterFullscreenInLandscape = Key<Bool>("enterFullscreenInLandscape", default: UIDevice.current.userInterfaceIdiom == .phone)
|
||||
static let lockOrientationInFullScreen = Key<Bool>("lockOrientationInFullScreen", default: false)
|
||||
static let lockLandscapeOnRotation = Key<Bool>("lockLandscapeOnRotation", default: false)
|
||||
static let lockLandscapeWhenEnteringFullscreen = Key<Bool>("lockLandscapeWhenEnteringFullscreen", default: false)
|
||||
#endif
|
||||
}
|
||||
|
||||
enum ResolutionSetting: String, CaseIterable, Defaults.Serializable {
|
||||
case best
|
||||
case hd4320p60
|
||||
case hd4320p
|
||||
case hd2160p60
|
||||
case hd2160p
|
||||
case hd1440p60
|
||||
case hd1440p
|
||||
@@ -107,7 +112,7 @@ enum ResolutionSetting: String, CaseIterable, Defaults.Serializable {
|
||||
var value: Stream.Resolution {
|
||||
switch self {
|
||||
case .best:
|
||||
return .hd4320p60
|
||||
return .hd2160p
|
||||
default:
|
||||
return Stream.Resolution(rawValue: rawValue)!
|
||||
}
|
||||
@@ -117,14 +122,8 @@ enum ResolutionSetting: String, CaseIterable, Defaults.Serializable {
|
||||
switch self {
|
||||
case .best:
|
||||
return "Best available quality"
|
||||
case .hd4320p60:
|
||||
return "8K, 60fps"
|
||||
case .hd4320p:
|
||||
return "8K"
|
||||
case .hd2160p60:
|
||||
return "4K, 60fps"
|
||||
case .hd2160p:
|
||||
return "4K"
|
||||
return "4K, 60fps"
|
||||
default:
|
||||
return value.name
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
private struct InNavigationViewKey: EnvironmentKey {
|
||||
static let defaultValue = false
|
||||
}
|
||||
|
||||
private struct InChannelViewKey: EnvironmentKey {
|
||||
static let defaultValue = false
|
||||
}
|
||||
@@ -36,6 +40,11 @@ private struct ScrollViewBottomPaddingKey: EnvironmentKey {
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var inNavigationView: Bool {
|
||||
get { self[InNavigationViewKey.self] }
|
||||
set { self[InNavigationViewKey.self] = newValue }
|
||||
}
|
||||
|
||||
var inChannelView: Bool {
|
||||
get { self[InChannelViewKey.self] }
|
||||
set { self[InChannelViewKey.self] = newValue }
|
||||
|
||||
@@ -63,6 +63,24 @@ struct AppSidebarNavigation: View {
|
||||
}
|
||||
}
|
||||
.environment(\.navigationStyle, .sidebar)
|
||||
#if os(iOS)
|
||||
.background(
|
||||
EmptyView().fullScreenCover(isPresented: $player.presentingPlayer) {
|
||||
VideoPlayerView()
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(comments)
|
||||
.environmentObject(instances)
|
||||
.environmentObject(navigation)
|
||||
.environmentObject(player)
|
||||
.environmentObject(playerControls)
|
||||
.environmentObject(playlists)
|
||||
.environmentObject(recents)
|
||||
.environmentObject(subscriptions)
|
||||
.environmentObject(thumbnailsModel)
|
||||
.environment(\.navigationStyle, .sidebar)
|
||||
}
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
var toolbarContent: some ToolbarContent {
|
||||
|
||||
@@ -11,7 +11,9 @@ struct AppSidebarPlaylists: View {
|
||||
NavigationLink(tag: TabSelection.playlist(playlist.id), selection: $navigation.tabSelection) {
|
||||
LazyView(PlaylistVideosView(playlist))
|
||||
} label: {
|
||||
playlistLabel(playlist)
|
||||
Label(playlist.title, systemImage: RecentsModel.symbolSystemImage(playlist.title))
|
||||
.backport
|
||||
.badge(Text("\(playlist.videos.count)"))
|
||||
}
|
||||
.id(playlist.id)
|
||||
.contextMenu {
|
||||
@@ -32,18 +34,6 @@ struct AppSidebarPlaylists: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func playlistLabel(_ playlist: Playlist) -> some View {
|
||||
let label = Label(playlist.title, systemImage: RecentsModel.symbolSystemImage(playlist.title))
|
||||
|
||||
if player.accounts.app.userPlaylistsEndpointIncludesVideos {
|
||||
label
|
||||
.backport
|
||||
.badge(Text("\(playlist.videos.count)"))
|
||||
} else {
|
||||
label
|
||||
}
|
||||
}
|
||||
|
||||
var newPlaylistButton: some View {
|
||||
Button(action: { navigation.presentNewPlaylistForm() }) {
|
||||
Label("New Playlist", systemImage: "plus.circle")
|
||||
|
||||
@@ -43,9 +43,51 @@ struct AppTabNavigation: View {
|
||||
searchNavigationView
|
||||
}
|
||||
.id(accounts.current?.id ?? "")
|
||||
.overlay(playlistView)
|
||||
.overlay(channelView)
|
||||
.environment(\.navigationStyle, .tab)
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingChannel) {
|
||||
if let channel = recents.presentedChannel {
|
||||
NavigationView {
|
||||
ChannelVideosView(channel: channel)
|
||||
.environment(\.managedObjectContext, persistenceController.container.viewContext)
|
||||
.environment(\.inChannelView, true)
|
||||
.environment(\.inNavigationView, true)
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(navigation)
|
||||
.environmentObject(player)
|
||||
.environmentObject(subscriptions)
|
||||
.environmentObject(thumbnailsModel)
|
||||
|
||||
.background(playerNavigationLink)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingPlaylist) {
|
||||
if let playlist = recents.presentedPlaylist {
|
||||
NavigationView {
|
||||
ChannelPlaylistView(playlist: playlist)
|
||||
.environment(\.managedObjectContext, persistenceController.container.viewContext)
|
||||
.environment(\.inNavigationView, true)
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(navigation)
|
||||
.environmentObject(player)
|
||||
.environmentObject(subscriptions)
|
||||
.environmentObject(thumbnailsModel)
|
||||
|
||||
.background(playerNavigationLink)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
.background(
|
||||
EmptyView().fullScreenCover(isPresented: $player.presentingPlayer) {
|
||||
videoPlayer
|
||||
.environment(\.managedObjectContext, persistenceController.container.viewContext)
|
||||
.environment(\.navigationStyle, .tab)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var favoritesNavigationView: some View {
|
||||
@@ -130,6 +172,15 @@ struct AppTabNavigation: View {
|
||||
.tag(TabSelection.search)
|
||||
}
|
||||
|
||||
private var playerNavigationLink: some View {
|
||||
NavigationLink(isActive: $player.playerNavigationLinkActive, destination: {
|
||||
videoPlayer
|
||||
.environment(\.inNavigationView, true)
|
||||
}) {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
private var videoPlayer: some View {
|
||||
VideoPlayerView()
|
||||
.environmentObject(accounts)
|
||||
@@ -159,26 +210,4 @@ struct AppTabNavigation: View {
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var channelView: some View {
|
||||
ChannelVideosView()
|
||||
.environment(\.managedObjectContext, persistenceController.container.viewContext)
|
||||
.environment(\.inChannelView, true)
|
||||
.environment(\.navigationStyle, .tab)
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(navigation)
|
||||
.environmentObject(player)
|
||||
.environmentObject(subscriptions)
|
||||
.environmentObject(thumbnailsModel)
|
||||
}
|
||||
|
||||
private var playlistView: some View {
|
||||
ChannelPlaylistView()
|
||||
.environment(\.managedObjectContext, persistenceController.container.viewContext)
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(navigation)
|
||||
.environmentObject(player)
|
||||
.environmentObject(subscriptions)
|
||||
.environmentObject(thumbnailsModel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,6 @@ struct ContentView: View {
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
#endif
|
||||
|
||||
let persistenceController = PersistenceController.shared
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
@@ -59,54 +57,50 @@ struct ContentView: View {
|
||||
.environmentObject(subscriptions)
|
||||
.environmentObject(thumbnailsModel)
|
||||
|
||||
#if os(iOS)
|
||||
.overlay(videoPlayer)
|
||||
#endif
|
||||
|
||||
// iOS 14 has problem with multiple sheets in one view
|
||||
// but it's ok when it's in background
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingWelcomeScreen) {
|
||||
WelcomeScreen()
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(navigation)
|
||||
}
|
||||
)
|
||||
#if !os(tvOS)
|
||||
.onOpenURL { OpenURLHandler(accounts: accounts, player: player).handle($0) }
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingAddToPlaylist) {
|
||||
AddToPlaylistView(video: navigation.videoToAddToPlaylist)
|
||||
.environmentObject(playlists)
|
||||
}
|
||||
)
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingPlaylistForm) {
|
||||
PlaylistFormView(playlist: $navigation.editedPlaylist)
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(playlists)
|
||||
}
|
||||
)
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingSettings, onDismiss: openWelcomeScreenIfAccountEmpty) {
|
||||
SettingsView()
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(instances)
|
||||
.environmentObject(player)
|
||||
}
|
||||
)
|
||||
#endif
|
||||
.alert(isPresented: $navigation.presentingUnsubscribeAlert) {
|
||||
Alert(
|
||||
title: Text(
|
||||
"Are you sure you want to unsubscribe from \(navigation.channelToUnsubscribe.name)?"
|
||||
),
|
||||
primaryButton: .destructive(Text("Unsubscribe")) {
|
||||
subscriptions.unsubscribe(navigation.channelToUnsubscribe.id)
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
// iOS 14 has problem with multiple sheets in one view
|
||||
// but it's ok when it's in background
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingWelcomeScreen) {
|
||||
WelcomeScreen()
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(navigation)
|
||||
}
|
||||
)
|
||||
#if !os(tvOS)
|
||||
.onOpenURL { OpenURLHandler(accounts: accounts, player: player).handle($0) }
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingAddToPlaylist) {
|
||||
AddToPlaylistView(video: navigation.videoToAddToPlaylist)
|
||||
.environmentObject(playlists)
|
||||
}
|
||||
)
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingPlaylistForm) {
|
||||
PlaylistFormView(playlist: $navigation.editedPlaylist)
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(playlists)
|
||||
}
|
||||
)
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingSettings, onDismiss: openWelcomeScreenIfAccountEmpty) {
|
||||
SettingsView()
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(instances)
|
||||
.environmentObject(player)
|
||||
}
|
||||
)
|
||||
#endif
|
||||
.alert(isPresented: $navigation.presentingUnsubscribeAlert) {
|
||||
Alert(
|
||||
title: Text(
|
||||
"Are you sure you want to unsubscribe from \(navigation.channelToUnsubscribe.name)?"
|
||||
),
|
||||
primaryButton: .destructive(Text("Unsubscribe")) {
|
||||
subscriptions.unsubscribe(navigation.channelToUnsubscribe.id)
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func configure() {
|
||||
@@ -228,21 +222,6 @@ struct ContentView: View {
|
||||
|
||||
navigation.presentingWelcomeScreen = true
|
||||
}
|
||||
|
||||
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)
|
||||
.environment(\.navigationStyle, .sidebar)
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentView_Previews: PreviewProvider {
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
import AVKit
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct AppleAVPlayerView: UIViewRepresentable {
|
||||
struct AppleAVPlayerView: UIViewControllerRepresentable {
|
||||
@EnvironmentObject<CommentsModel> private var comments
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||
|
||||
func makeUIView(context _: Context) -> some UIView {
|
||||
player.playerLayerView = PlayerLayerView(frame: .zero)
|
||||
return player.playerLayerView
|
||||
func makeUIViewController(context _: Context) -> UIViewController {
|
||||
let controller = AppleAVPlayerViewController()
|
||||
|
||||
controller.commentsModel = comments
|
||||
controller.navigationModel = navigation
|
||||
controller.playerModel = player
|
||||
controller.subscriptionsModel = subscriptions
|
||||
player.avPlayerBackend.controller = controller
|
||||
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIView(_: UIViewType, context _: Context) {}
|
||||
func updateUIViewController(_: UIViewController, context _: Context) {
|
||||
player.rebuildTVMenu()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ extension AppleAVPlayerViewController: AVPlayerViewControllerDelegate {
|
||||
willBeginFullScreenPresentationWithAnimationCoordinator context: UIViewControllerTransitionCoordinator
|
||||
) {
|
||||
#if os(iOS)
|
||||
if !context.isCancelled, Defaults[.lockOrientationInFullScreen] {
|
||||
if !context.isCancelled, Defaults[.lockLandscapeWhenEnteringFullscreen] {
|
||||
Orientation.lockOrientation(.landscape, andRotateTo: UIDevice.current.orientation.isLandscape ? nil : .landscapeRight)
|
||||
}
|
||||
#endif
|
||||
@@ -178,8 +178,11 @@ extension AppleAVPlayerViewController: AVPlayerViewControllerDelegate {
|
||||
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void
|
||||
) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
self.playerModel.show()
|
||||
self.playerModel.setNeedsDrawing(true)
|
||||
if self.navigationModel.presentingChannel {
|
||||
self.playerModel.playerNavigationLinkActive = true
|
||||
} else {
|
||||
self.playerModel.show()
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
if self.playerModel.playingInPictureInPicture {
|
||||
@@ -195,6 +198,7 @@ extension AppleAVPlayerViewController: AVPlayerViewControllerDelegate {
|
||||
|
||||
func playerViewControllerWillStartPictureInPicture(_: AVPlayerViewController) {
|
||||
playerModel.playingInPictureInPicture = true
|
||||
playerModel.playerNavigationLinkActive = false
|
||||
}
|
||||
|
||||
func playerViewControllerWillStopPictureInPicture(_: AVPlayerViewController) {
|
||||
|
||||
@@ -29,18 +29,14 @@ struct PlayerControls: View {
|
||||
ZStack(alignment: .bottom) {
|
||||
VStack(spacing: 0) {
|
||||
Group {
|
||||
HStack {
|
||||
statusBar
|
||||
.padding(3)
|
||||
#if os(macOS)
|
||||
.background(VisualEffectBlur(material: .hudWindow))
|
||||
#elseif os(iOS)
|
||||
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
|
||||
#endif
|
||||
.mask(RoundedRectangle(cornerRadius: 3))
|
||||
|
||||
Spacer()
|
||||
}
|
||||
statusBar
|
||||
.padding(3)
|
||||
#if os(macOS)
|
||||
.background(VisualEffectBlur(material: .hudWindow))
|
||||
#elseif os(iOS)
|
||||
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
|
||||
#endif
|
||||
.mask(RoundedRectangle(cornerRadius: 3))
|
||||
|
||||
buttonsBar
|
||||
.padding(.top, 4)
|
||||
@@ -53,24 +49,18 @@ struct PlayerControls: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
Group {
|
||||
timeline
|
||||
.offset(y: 10)
|
||||
.zIndex(1)
|
||||
timeline
|
||||
.offset(y: 10)
|
||||
.zIndex(1)
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
bottomBar
|
||||
|
||||
bottomBar
|
||||
#if os(macOS)
|
||||
.background(VisualEffectBlur(material: .hudWindow))
|
||||
#elseif os(iOS)
|
||||
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
|
||||
#endif
|
||||
.mask(RoundedRectangle(cornerRadius: 3))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
#if os(macOS)
|
||||
.background(VisualEffectBlur(material: .hudWindow))
|
||||
#elseif os(iOS)
|
||||
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
|
||||
#endif
|
||||
.mask(RoundedRectangle(cornerRadius: 3))
|
||||
}
|
||||
}
|
||||
.opacity(model.presentingControls ? 1 : 0)
|
||||
@@ -85,11 +75,18 @@ struct PlayerControls: View {
|
||||
model.resetTimer()
|
||||
}
|
||||
#else
|
||||
.background(PlayerGestures())
|
||||
.background(controlsBackground)
|
||||
#endif
|
||||
.environment(\.colorScheme, .dark)
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
var controlsBackground: some View {
|
||||
PlayerGestures()
|
||||
.background(Color.black.opacity(model.presentingControls ? 0.5 : 0))
|
||||
}
|
||||
#endif
|
||||
|
||||
var timeline: some View {
|
||||
TimelineView(duration: durationBinding, current: currentTimeBinding, cornerRadius: 0)
|
||||
}
|
||||
@@ -110,9 +107,12 @@ struct PlayerControls: View {
|
||||
|
||||
var statusBar: some View {
|
||||
HStack(spacing: 4) {
|
||||
#if os(iOS)
|
||||
hidePlayerButton
|
||||
#endif
|
||||
Text(playbackStatus)
|
||||
|
||||
Text("•")
|
||||
Spacer()
|
||||
|
||||
#if !os(tvOS)
|
||||
ToggleBackendButton()
|
||||
@@ -120,7 +120,7 @@ struct PlayerControls: View {
|
||||
|
||||
StreamControl()
|
||||
#if os(macOS)
|
||||
.frame(maxWidth: 300)
|
||||
.frame(maxWidth: 160)
|
||||
#endif
|
||||
#else
|
||||
Text(player.stream?.description ?? "")
|
||||
@@ -132,10 +132,11 @@ struct PlayerControls: View {
|
||||
}
|
||||
|
||||
private var hidePlayerButton: some View {
|
||||
button("Hide", systemImage: "chevron.down") {
|
||||
Button {
|
||||
player.hide()
|
||||
} label: {
|
||||
Image(systemName: "chevron.down.circle.fill")
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
#endif
|
||||
@@ -172,22 +173,11 @@ struct PlayerControls: View {
|
||||
}
|
||||
|
||||
var buttonsBar: some View {
|
||||
HStack(spacing: 20) {
|
||||
HStack {
|
||||
#if !os(tvOS)
|
||||
#if os(iOS)
|
||||
hidePlayerButton
|
||||
#endif
|
||||
|
||||
fullscreenButton
|
||||
#if os(iOS)
|
||||
pipButton
|
||||
#endif
|
||||
rateButton
|
||||
|
||||
closeVideoButton
|
||||
|
||||
Spacer()
|
||||
#endif
|
||||
Spacer()
|
||||
// button("Music Mode", systemImage: "music.note")
|
||||
}
|
||||
}
|
||||
@@ -204,78 +194,10 @@ 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
|
||||
}
|
||||
|
||||
private var closeVideoButton: some View {
|
||||
button("Close", systemImage: "xmark") {
|
||||
player.pause()
|
||||
|
||||
player.hide()
|
||||
player.closePiP()
|
||||
|
||||
var delay = 0.2
|
||||
#if os(macOS)
|
||||
delay = 0.0
|
||||
#endif
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
||||
player.closeCurrentItem()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
HStack {
|
||||
#if !os(tvOS)
|
||||
restartVideoButton
|
||||
.padding(.trailing, 15)
|
||||
|
||||
button("Seek Backward", systemImage: "gobackward.10", size: 30, cornerRadius: 5) {
|
||||
button("Seek Backward", systemImage: "gobackward.10", size: 50, cornerRadius: 10) {
|
||||
player.backend.seek(relative: .secondsInDefaultTimescale(-10))
|
||||
}
|
||||
|
||||
@@ -293,7 +215,8 @@ struct PlayerControls: View {
|
||||
button(
|
||||
model.isPlaying ? "Pause" : "Play",
|
||||
systemImage: model.isPlaying ? "pause.fill" : "play.fill",
|
||||
size: 30, cornerRadius: 5
|
||||
size: 50,
|
||||
cornerRadius: 10
|
||||
) {
|
||||
player.backend.togglePlay()
|
||||
}
|
||||
@@ -308,7 +231,7 @@ struct PlayerControls: View {
|
||||
Spacer()
|
||||
|
||||
#if !os(tvOS)
|
||||
button("Seek Forward", systemImage: "goforward.10", size: 30, cornerRadius: 5) {
|
||||
button("Seek Forward", systemImage: "goforward.10", size: 50, cornerRadius: 10) {
|
||||
player.backend.seek(relative: .secondsInDefaultTimescale(10))
|
||||
}
|
||||
#if os(tvOS)
|
||||
@@ -317,30 +240,16 @@ struct PlayerControls: View {
|
||||
.keyboardShortcut("l", modifiers: [])
|
||||
.keyboardShortcut(KeyEquivalent.rightArrow, modifiers: [])
|
||||
#endif
|
||||
|
||||
advanceToNextItemButton
|
||||
.padding(.leading, 15)
|
||||
#endif
|
||||
}
|
||||
.font(.system(size: 20))
|
||||
.font(.system(size: 30))
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
|
||||
private var restartVideoButton: some View {
|
||||
button("Restart video", systemImage: "backward.end.fill", size: 30, cornerRadius: 5) {
|
||||
player.backend.seek(to: 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
private var advanceToNextItemButton: some View {
|
||||
button("Next", systemImage: "forward.fill", size: 30, cornerRadius: 5) {
|
||||
player.advanceToNextItem()
|
||||
}
|
||||
.disabled(player.queue.isEmpty)
|
||||
}
|
||||
|
||||
var bottomBar: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
|
||||
Text(model.playbackTime)
|
||||
}
|
||||
.font(.system(size: 15))
|
||||
@@ -388,25 +297,6 @@ struct PlayerControls: View {
|
||||
|
||||
struct PlayerControls_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let model = PlayerControlsModel()
|
||||
model.presentingControls = true
|
||||
model.currentTime = .secondsInDefaultTimescale(0)
|
||||
model.duration = .secondsInDefaultTimescale(120)
|
||||
|
||||
let view = ZStack {
|
||||
Color.gray
|
||||
|
||||
PlayerControls(player: PlayerModel())
|
||||
.injectFixtureEnvironmentObjects()
|
||||
.environmentObject(model)
|
||||
}
|
||||
|
||||
return Group {
|
||||
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
|
||||
view.previewInterfaceOrientation(.landscapeLeft)
|
||||
} else {
|
||||
view
|
||||
}
|
||||
}
|
||||
PlayerControls(player: PlayerModel())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
final class PlayerLayerView: UIView {
|
||||
var playerLayer = AVPlayerLayer()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
layer.addSublayer(playerLayer)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
playerLayer.frame = bounds
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import CoreMedia
|
||||
import Defaults
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
@@ -12,30 +11,15 @@ struct PlayerQueueRow: View {
|
||||
|
||||
@Default(.closePiPOnNavigation) var closePiPOnNavigation
|
||||
|
||||
@FetchRequest private var watchRequest: FetchedResults<Watch>
|
||||
|
||||
init(item: PlayerQueueItem, history: Bool = false, fullScreen: Binding<Bool> = .constant(false)) {
|
||||
self.item = item
|
||||
self.history = history
|
||||
_fullScreen = fullScreen
|
||||
_watchRequest = FetchRequest<Watch>(
|
||||
entity: Watch.entity(),
|
||||
sortDescriptors: [],
|
||||
predicate: NSPredicate(format: "videoID = %@", item.videoID)
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
Button {
|
||||
player.prepareCurrentItemForHistory()
|
||||
|
||||
player.avPlayerBackend.startPictureInPictureOnPlay = player.playingInPictureInPicture
|
||||
|
||||
if history {
|
||||
player.playHistory(item, at: watchStoppedAt)
|
||||
player.playHistory(item)
|
||||
} else {
|
||||
player.advanceToItem(item, at: watchStoppedAt)
|
||||
player.advanceToItem(item)
|
||||
}
|
||||
|
||||
if fullScreen {
|
||||
@@ -48,21 +32,9 @@ struct PlayerQueueRow: View {
|
||||
player.closePiP()
|
||||
}
|
||||
} label: {
|
||||
VideoBanner(video: item.video, playbackTime: watchStoppedAt, videoDuration: watch?.videoDuration)
|
||||
VideoBanner(video: item.video, playbackTime: item.playbackTime, videoDuration: item.videoDuration)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private var watch: Watch? {
|
||||
watchRequest.first
|
||||
}
|
||||
|
||||
private var watchStoppedAt: CMTime? {
|
||||
guard let seconds = watch?.stoppedAt else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return .secondsInDefaultTimescale(seconds)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ struct StreamControl: View {
|
||||
Section(header: Text(instance.longDescription)) {
|
||||
ForEach(kinds, id: \.self) { key in
|
||||
ForEach(instanceStreams[key] ?? []) { stream in
|
||||
Text(stream.description).tag(Stream?.some(stream))
|
||||
Text(stream.quality).tag(Stream?.some(stream))
|
||||
}
|
||||
|
||||
if kinds.count > 1 {
|
||||
|
||||
@@ -10,7 +10,7 @@ struct TimelineView: View {
|
||||
@State private var draggedFrom: Double = 0
|
||||
|
||||
private var start: Double = 0.0
|
||||
private var height = 8.0
|
||||
private var height = 10.0
|
||||
|
||||
var cornerRadius: Double
|
||||
var thumbTooltipWidth: Double = 100
|
||||
@@ -26,25 +26,26 @@ struct TimelineView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .leading) {
|
||||
Group {
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.foregroundColor(.blue)
|
||||
.frame(maxHeight: height)
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.foregroundColor(.blue)
|
||||
.frame(maxHeight: height)
|
||||
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.fill(Color.green)
|
||||
.frame(maxHeight: height)
|
||||
.frame(width: current * oneUnitWidth)
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.fill(
|
||||
Color.green
|
||||
)
|
||||
.frame(maxHeight: height)
|
||||
.frame(width: current * oneUnitWidth)
|
||||
|
||||
segmentsLayers
|
||||
}
|
||||
segmentsLayers
|
||||
|
||||
Circle()
|
||||
.strokeBorder(.gray, lineWidth: 1)
|
||||
.background(Circle().fill(dragging ? .gray : .white))
|
||||
.offset(x: thumbOffset)
|
||||
.foregroundColor(.red.opacity(0.6))
|
||||
.frame(maxHeight: height * 4)
|
||||
|
||||
.frame(maxHeight: height * 2)
|
||||
|
||||
#if !os(tvOS)
|
||||
.gesture(
|
||||
@@ -113,7 +114,7 @@ struct TimelineView: View {
|
||||
var projectedValue: Double {
|
||||
let change = (dragOffset / size.width) * units
|
||||
let projected = draggedFrom + change
|
||||
return projected.isFinite ? (duration - projected < (0.01 * duration) ? duration : projected) : start
|
||||
return projected.isFinite ? projected : start
|
||||
}
|
||||
|
||||
var thumbOffset: Double {
|
||||
@@ -191,7 +192,6 @@ struct TimelineView_Previews: PreviewProvider {
|
||||
TimelineView(duration: .constant(100), current: .constant(90))
|
||||
TimelineView(duration: .constant(100), current: .constant(100))
|
||||
}
|
||||
.environmentObject(PlayerModel())
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ struct VideoDetails: View {
|
||||
@State private var currentPage = Page.info
|
||||
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
@Environment(\.inNavigationView) private var inNavigationView
|
||||
@Environment(\.navigationStyle) private var navigationStyle
|
||||
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@@ -29,6 +30,7 @@ struct VideoDetails: View {
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||
|
||||
@Default(.showChannelSubscribers) private var showChannelSubscribers
|
||||
@Default(.showKeywords) private var showKeywords
|
||||
|
||||
init(
|
||||
@@ -111,6 +113,7 @@ struct VideoDetails: View {
|
||||
.edgesIgnoringSafeArea(.horizontal)
|
||||
}
|
||||
}
|
||||
.padding(.top, inNavigationView && fullScreen ? 10 : 0)
|
||||
.onAppear {
|
||||
if video.isNil && !sidebarQueue {
|
||||
currentPage = .queue
|
||||
@@ -201,13 +204,15 @@ struct VideoDetails: View {
|
||||
.font(.system(size: 14))
|
||||
.bold()
|
||||
|
||||
Group {
|
||||
if let subscribers = video!.channel.subscriptionsString {
|
||||
Text("\(subscribers) subscribers")
|
||||
if showChannelSubscribers {
|
||||
Group {
|
||||
if let subscribers = video!.channel.subscriptionsString {
|
||||
Text("\(subscribers) subscribers")
|
||||
}
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption2)
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -426,7 +431,7 @@ struct VideoDetails: View {
|
||||
|
||||
var detailsPage: some View {
|
||||
Group {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Group {
|
||||
if let video = player.currentVideo {
|
||||
VStack(spacing: 6) {
|
||||
HStack {
|
||||
@@ -440,7 +445,6 @@ struct VideoDetails: View {
|
||||
|
||||
Divider()
|
||||
}
|
||||
.padding(.bottom, 6)
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if let description = video.description {
|
||||
|
||||
@@ -26,7 +26,7 @@ struct VideoDetailsPaddingModifier: ViewModifier {
|
||||
self.geometry = geometry
|
||||
self.aspectRatio = aspectRatio ?? VideoPlayerView.defaultAspectRatio
|
||||
self.minimumHeightLeft = minimumHeightLeft ?? VideoPlayerView.defaultMinimumHeightLeft
|
||||
self.additionalPadding = additionalPadding ?? Self.defaultAdditionalDetailsPadding
|
||||
self.additionalPadding = additionalPadding ?? VideoDetailsPaddingModifier.defaultAdditionalDetailsPadding
|
||||
self.fullScreen = fullScreen
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +7,6 @@ import Siesta
|
||||
import SwiftUI
|
||||
|
||||
struct VideoPlayerView: View {
|
||||
#if os(iOS)
|
||||
static let hiddenOffset = max(UIScreen.main.bounds.height, UIScreen.main.bounds.width) + 100
|
||||
#endif
|
||||
|
||||
static let defaultAspectRatio = 16 / 9.0
|
||||
static var defaultMinimumHeightLeft: Double {
|
||||
#if os(macOS)
|
||||
@@ -32,7 +28,7 @@ struct VideoPlayerView: View {
|
||||
|
||||
@Default(.enterFullscreenInLandscape) private var enterFullscreenInLandscape
|
||||
@Default(.honorSystemOrientationLock) private var honorSystemOrientationLock
|
||||
@Default(.lockOrientationInFullScreen) private var lockOrientationInFullScreen
|
||||
@Default(.lockLandscapeOnRotation) private var lockLandscapeOnRotation
|
||||
|
||||
@State private var motionManager: CMMotionManager!
|
||||
@State private var orientation = UIInterfaceOrientation.portrait
|
||||
@@ -41,10 +37,6 @@ struct VideoPlayerView: View {
|
||||
var mouseLocation: CGPoint { NSEvent.mouseLocation }
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
@State private var viewVerticalOffset = Self.hiddenOffset
|
||||
#endif
|
||||
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@EnvironmentObject<PlayerControlsModel> private var playerControls
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@@ -62,6 +54,10 @@ struct VideoPlayerView: View {
|
||||
content
|
||||
.onAppear {
|
||||
playerSize = geometry.size
|
||||
|
||||
#if os(iOS)
|
||||
configureOrientationUpdatesBasedOnAccelerometer()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.onChange(of: geometry.size) { size in
|
||||
@@ -74,28 +70,22 @@ struct VideoPlayerView: View {
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
|
||||
handleOrientationDidChangeNotification()
|
||||
}
|
||||
.onChange(of: player.presentingPlayer) { newValue in
|
||||
if newValue {
|
||||
viewVerticalOffset = 0
|
||||
configureOrientationUpdatesBasedOnAccelerometer()
|
||||
} else {
|
||||
if Defaults[.lockPortraitWhenBrowsing] {
|
||||
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
|
||||
} else {
|
||||
Orientation.lockOrientation(.allButUpsideDown)
|
||||
}
|
||||
|
||||
motionManager?.stopAccelerometerUpdates()
|
||||
motionManager = nil
|
||||
viewVerticalOffset = Self.hiddenOffset
|
||||
.onDisappear {
|
||||
guard !playerControls.playingFullscreen else {
|
||||
return // swiftlint:disable:this implicit_return
|
||||
}
|
||||
|
||||
if Defaults[.lockPortraitWhenBrowsing] {
|
||||
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
|
||||
} else {
|
||||
Orientation.lockOrientation(.allButUpsideDown)
|
||||
}
|
||||
|
||||
motionManager?.stopAccelerometerUpdates()
|
||||
motionManager = nil
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(iOS)
|
||||
.offset(y: viewVerticalOffset)
|
||||
.animation(.easeIn(duration: 0.2), value: viewVerticalOffset)
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -148,49 +138,29 @@ struct VideoPlayerView: View {
|
||||
hoveringPlayer = hovering
|
||||
hovering ? playerControls.show() : playerControls.hide()
|
||||
}
|
||||
#if !os(macOS)
|
||||
.gesture(
|
||||
DragGesture(coordinateSpace: .global)
|
||||
.onChanged { value in
|
||||
guard !fullScreenLayout else {
|
||||
return // swiftlint:disable:this implicit_return
|
||||
}
|
||||
|
||||
player.backend.setNeedsDrawing(false)
|
||||
let drag = value.translation.height
|
||||
|
||||
guard drag > 0 else {
|
||||
return // swiftlint:disable:this implicit_return
|
||||
}
|
||||
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
viewVerticalOffset = drag
|
||||
}
|
||||
}
|
||||
.onEnded { _ in
|
||||
if viewVerticalOffset > 100 {
|
||||
player.backend.setNeedsDrawing(false)
|
||||
player.hide()
|
||||
} else {
|
||||
viewVerticalOffset = 0
|
||||
player.backend.setNeedsDrawing(true)
|
||||
player.show()
|
||||
}
|
||||
#if os(iOS)
|
||||
.onSwipeGesture(
|
||||
up: {
|
||||
withAnimation {
|
||||
fullScreenDetails = true
|
||||
}
|
||||
},
|
||||
down: { player.hide() }
|
||||
)
|
||||
#else
|
||||
.onAppear(perform: {
|
||||
NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved]) {
|
||||
if hoveringPlayer {
|
||||
playerControls.resetTimer()
|
||||
}
|
||||
|
||||
return $0
|
||||
}
|
||||
})
|
||||
#elseif os(macOS)
|
||||
.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 {
|
||||
@@ -256,17 +226,6 @@ struct VideoPlayerView: View {
|
||||
})
|
||||
case .appleAVPlayer:
|
||||
player.avPlayerView
|
||||
#if os(iOS)
|
||||
.onAppear {
|
||||
player.pipController = .init(playerLayer: player.playerLayerView.playerLayer)
|
||||
let pipDelegate = PiPDelegate()
|
||||
pipDelegate.player = player
|
||||
|
||||
player.pipDelegate = pipDelegate
|
||||
player.pipController!.delegate = pipDelegate
|
||||
player.playerLayerView.playerLayer.player = player.avPlayerBackend.avPlayer
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
@@ -299,37 +258,23 @@ struct VideoPlayerView: View {
|
||||
}
|
||||
|
||||
func playerPlaceholder(geometry: GeometryProxy) -> some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
HStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
VStack {
|
||||
Spacer()
|
||||
VStack {
|
||||
Spacer()
|
||||
VStack(spacing: 10) {
|
||||
#if !os(tvOS)
|
||||
Image(systemName: "ticket")
|
||||
.font(.system(size: 120))
|
||||
#endif
|
||||
}
|
||||
Spacer()
|
||||
VStack(spacing: 10) {
|
||||
#if !os(tvOS)
|
||||
Image(systemName: "ticket")
|
||||
.font(.system(size: 120))
|
||||
#endif
|
||||
}
|
||||
.foregroundColor(.gray)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
Button {
|
||||
player.hide()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 40))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(10)
|
||||
.foregroundColor(.gray)
|
||||
#endif
|
||||
.foregroundColor(.gray)
|
||||
Spacer()
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: geometry.size.width / Self.defaultAspectRatio)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: geometry.size.width / VideoPlayerView.defaultAspectRatio)
|
||||
}
|
||||
|
||||
func pictureInPicturePlaceholder(geometry: GeometryProxy) -> some View {
|
||||
@@ -358,7 +303,7 @@ struct VideoPlayerView: View {
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: geometry.size.width / Self.defaultAspectRatio)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: geometry.size.width / VideoPlayerView.defaultAspectRatio)
|
||||
}
|
||||
|
||||
var sidebarQueue: Bool {
|
||||
@@ -437,7 +382,7 @@ struct VideoPlayerView: View {
|
||||
|
||||
Orientation.lockOrientation(orientationLockMask, andRotateTo: orientation)
|
||||
|
||||
guard lockOrientationInFullScreen else {
|
||||
guard lockLandscapeOnRotation else {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -446,8 +391,7 @@ struct VideoPlayerView: View {
|
||||
} else {
|
||||
guard abs(acceleration.z) <= 0.74,
|
||||
player.lockedOrientation.isNil,
|
||||
enterFullscreenInLandscape,
|
||||
!lockOrientationInFullScreen
|
||||
enterFullscreenInLandscape
|
||||
else {
|
||||
return
|
||||
}
|
||||
@@ -462,11 +406,10 @@ struct VideoPlayerView: View {
|
||||
}
|
||||
|
||||
private func handleOrientationDidChangeNotification() {
|
||||
viewVerticalOffset = viewVerticalOffset == 0 ? 0 : Self.hiddenOffset
|
||||
let newOrientation = UIApplication.shared.windows.first?.windowScene?.interfaceOrientation
|
||||
if newOrientation?.isLandscape ?? false,
|
||||
player.presentingPlayer,
|
||||
lockOrientationInFullScreen,
|
||||
lockLandscapeOnRotation,
|
||||
!player.lockedOrientation.isNil
|
||||
{
|
||||
Orientation.lockOrientation(.landscape, andRotateTo: newOrientation)
|
||||
|
||||
@@ -9,7 +9,6 @@ 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
|
||||
@@ -123,7 +122,7 @@ 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(
|
||||
@@ -166,8 +165,6 @@ struct AddToPlaylistView: View {
|
||||
|
||||
Defaults[.lastUsedPlaylistID] = id
|
||||
|
||||
submitButtonDisabled = true
|
||||
|
||||
model.addVideo(
|
||||
playlistID: id,
|
||||
videoID: video.videoID,
|
||||
@@ -177,7 +174,6 @@ struct AddToPlaylistView: View {
|
||||
onFailure: { requestError in
|
||||
error = "(\(requestError.httpStatusCode ?? -1)) \(requestError.userMessage)"
|
||||
presentingErrorAlert = true
|
||||
submitButtonDisabled = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,12 +43,9 @@ struct PlaylistFormView: View {
|
||||
TextField("Name", text: $name, onCommit: validate)
|
||||
.frame(maxWidth: 450)
|
||||
.padding(.leading, 10)
|
||||
.disabled(editing && !accounts.app.userPlaylistsAreEditable)
|
||||
|
||||
if accounts.app.userPlaylistsHaveVisibility {
|
||||
visibilityFormItem
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
visibilityFormItem
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
#if os(macOS)
|
||||
.padding(.horizontal)
|
||||
@@ -62,7 +59,7 @@ struct PlaylistFormView: View {
|
||||
Spacer()
|
||||
|
||||
Button("Save", action: submitForm)
|
||||
.disabled(!valid || (editing && !accounts.app.userPlaylistsAreEditable))
|
||||
.disabled(!valid)
|
||||
.alert(isPresented: $presentingErrorAlert) {
|
||||
Alert(
|
||||
title: Text("Error when accessing playlist"),
|
||||
@@ -78,7 +75,7 @@ struct PlaylistFormView: View {
|
||||
#if os(iOS)
|
||||
.padding(.vertical)
|
||||
#else
|
||||
.frame(width: 400, height: accounts.app.userPlaylistsHaveVisibility ? 150 : 120)
|
||||
.frame(width: 400, height: 150)
|
||||
#endif
|
||||
|
||||
#else
|
||||
@@ -122,24 +119,20 @@ struct PlaylistFormView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
TextField("Playlist Name", text: $name, onCommit: validate)
|
||||
.disabled(editing && !accounts.app.userPlaylistsAreEditable)
|
||||
}
|
||||
|
||||
if accounts.app.userPlaylistsHaveVisibility {
|
||||
HStack {
|
||||
Text("Visibility")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
HStack {
|
||||
Text("Visibility")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
visibilityFormItem
|
||||
}
|
||||
.padding(.top, 10)
|
||||
visibilityFormItem
|
||||
}
|
||||
.padding(.top, 10)
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
|
||||
Button("Save", action: submitForm)
|
||||
.disabled(!valid || (editing && !accounts.app.userPlaylistsAreEditable))
|
||||
Button("Save", action: submitForm).disabled(!valid)
|
||||
}
|
||||
.padding(.top, 40)
|
||||
|
||||
@@ -179,15 +172,27 @@ struct PlaylistFormView: View {
|
||||
return
|
||||
}
|
||||
|
||||
accounts.api.playlistForm(name, visibility.rawValue, playlist: playlist, onFailure: { error in
|
||||
formError = "(\(error.httpStatusCode ?? -1)) \(error.userMessage)"
|
||||
presentingErrorAlert = true
|
||||
}) { modifiedPlaylist in
|
||||
self.playlist = modifiedPlaylist
|
||||
playlists.load(force: true)
|
||||
let body = ["title": name, "privacy": visibility.rawValue]
|
||||
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
resource?
|
||||
.request(editing ? .patch : .post, json: body)
|
||||
.onSuccess { response in
|
||||
if let modifiedPlaylist: Playlist = response.typedContent() {
|
||||
playlist = modifiedPlaylist
|
||||
}
|
||||
|
||||
playlists.load(force: true)
|
||||
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
.onFailure { error in
|
||||
formError = "(\(error.httpStatusCode ?? -1)) \(error.userMessage)"
|
||||
presentingErrorAlert = true
|
||||
}
|
||||
}
|
||||
|
||||
var resource: Resource? {
|
||||
editing ? accounts.api.playlist(playlist.id) : accounts.api.playlists
|
||||
}
|
||||
|
||||
var visibilityFormItem: some View {
|
||||
@@ -231,14 +236,17 @@ struct PlaylistFormView: View {
|
||||
}
|
||||
|
||||
func deletePlaylistAndDismiss() {
|
||||
accounts.api.deletePlaylist(playlist, onFailure: { error in
|
||||
formError = "(\(error.httpStatusCode ?? -1)) \(error.localizedDescription)"
|
||||
presentingErrorAlert = true
|
||||
}) {
|
||||
playlist = nil
|
||||
playlists.load(force: true)
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
accounts.api.playlist(playlist.id)?
|
||||
.request(.delete)
|
||||
.onSuccess { _ in
|
||||
playlist = nil
|
||||
playlists.load(force: true)
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
.onFailure { error in
|
||||
formError = "(\(error.httpStatusCode ?? -1)) \(error.localizedDescription)"
|
||||
presentingErrorAlert = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,6 @@ struct PlaylistsView: View {
|
||||
@State private var showingEditPlaylist = false
|
||||
@State private var editedPlaylist: Playlist?
|
||||
|
||||
@StateObject private var store = Store<ChannelPlaylist>()
|
||||
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<PlaylistsModel> private var model
|
||||
@@ -20,36 +18,7 @@ struct PlaylistsView: View {
|
||||
@Namespace private var focusNamespace
|
||||
|
||||
var items: [ContentItem] {
|
||||
var videos = currentPlaylist?.videos ?? []
|
||||
|
||||
if videos.isEmpty {
|
||||
videos = store.item?.videos ?? []
|
||||
if !player.accounts.app.userPlaylistsEndpointIncludesVideos {
|
||||
var i = 0
|
||||
|
||||
for index in videos.indices {
|
||||
var video = videos[index]
|
||||
video.indexID = "\(i)"
|
||||
i += 1
|
||||
videos[index] = video
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ContentItem.array(of: videos)
|
||||
}
|
||||
|
||||
private var resource: Resource? {
|
||||
guard !player.accounts.app.userPlaylistsEndpointIncludesVideos,
|
||||
let playlist = currentPlaylist
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let resource = player.accounts.api.playlist(playlist.id)
|
||||
resource?.addObserver(store)
|
||||
|
||||
return resource
|
||||
ContentItem.array(of: currentPlaylist?.videos ?? [])
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -122,12 +91,6 @@ struct PlaylistsView: View {
|
||||
.onChange(of: accounts.current) { _ in
|
||||
model.load(force: true)
|
||||
}
|
||||
.onChange(of: selectedPlaylistID) { _ in
|
||||
resource?.load()
|
||||
}
|
||||
.onChange(of: model.reloadPlaylists) { _ in
|
||||
resource?.load()
|
||||
}
|
||||
#if os(tvOS)
|
||||
.fullScreenCover(isPresented: $showingNewPlaylist, onDismiss: selectCreatedPlaylist) {
|
||||
PlaylistFormView(playlist: $createdPlaylist)
|
||||
|
||||
@@ -204,7 +204,7 @@ struct SearchView: View {
|
||||
visibleSections.append(.subscriptions)
|
||||
}
|
||||
|
||||
if accounts.app.supportsUserPlaylists && accounts.signedIn && preferred.contains(.playlists) {
|
||||
if accounts.app.supportsUserPlaylists && preferred.contains(.playlists) {
|
||||
visibleSections.append(.playlists)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,13 @@ struct PlayerSettings: View {
|
||||
@Default(.playerSidebar) private var playerSidebar
|
||||
@Default(.showHistoryInPlayer) private var showHistory
|
||||
@Default(.showKeywords) private var showKeywords
|
||||
@Default(.showChannelSubscribers) private var channelSubscribers
|
||||
@Default(.pauseOnHidingPlayer) private var pauseOnHidingPlayer
|
||||
#if os(iOS)
|
||||
@Default(.honorSystemOrientationLock) private var honorSystemOrientationLock
|
||||
@Default(.lockOrientationInFullScreen) private var lockOrientationInFullScreen
|
||||
@Default(.lockLandscapeOnRotation) private var lockLandscapeOnRotation
|
||||
@Default(.enterFullscreenInLandscape) private var enterFullscreenInLandscape
|
||||
@Default(.lockLandscapeWhenEnteringFullscreen) private var lockLandscapeWhenEnteringFullscreen
|
||||
#endif
|
||||
@Default(.closePiPOnNavigation) private var closePiPOnNavigation
|
||||
@Default(.closePiPOnOpeningPlayer) private var closePiPOnOpeningPlayer
|
||||
@@ -83,6 +85,7 @@ struct PlayerSettings: View {
|
||||
|
||||
keywordsToggle
|
||||
showHistoryToggle
|
||||
channelSubscribersToggle
|
||||
returnYouTubeDislikeToggle
|
||||
}
|
||||
|
||||
@@ -95,12 +98,13 @@ struct PlayerSettings: View {
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
Section(header: SettingsHeader(text: "Orientation")) {
|
||||
Section(header: SettingsHeader(text: "Orientation"), footer: orientationFooter) {
|
||||
if idiom == .pad {
|
||||
enterFullscreenInLandscapeToggle
|
||||
}
|
||||
honorSystemOrientationLockToggle
|
||||
lockOrientationInFullScreenToggle
|
||||
lockLandscapeOnRotationToggle
|
||||
lockLandscapeWhenEnteringFullscreenToggle
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -195,6 +199,10 @@ struct PlayerSettings: View {
|
||||
Toggle("Show history", isOn: $showHistory)
|
||||
}
|
||||
|
||||
private var channelSubscribersToggle: some View {
|
||||
Toggle("Show subscribers count", isOn: $channelSubscribers)
|
||||
}
|
||||
|
||||
private var returnYouTubeDislikeToggle: some View {
|
||||
Toggle("Enable Return YouTube Dislike", isOn: $enableReturnYouTubeDislike)
|
||||
}
|
||||
@@ -213,10 +221,18 @@ struct PlayerSettings: View {
|
||||
Toggle("Enter fullscreen in landscape", isOn: $enterFullscreenInLandscape)
|
||||
}
|
||||
|
||||
private var lockOrientationInFullScreenToggle: some View {
|
||||
Toggle("Lock orientation in fullscreen", isOn: $lockOrientationInFullScreen)
|
||||
private var lockLandscapeOnRotationToggle: some View {
|
||||
Toggle("Lock landscape on rotation", isOn: $lockLandscapeOnRotation)
|
||||
.disabled(!enterFullscreenInLandscape)
|
||||
}
|
||||
|
||||
private var lockLandscapeWhenEnteringFullscreenToggle: some View {
|
||||
Toggle("Rotate and lock landscape on entering fullscreen", isOn: $lockLandscapeWhenEnteringFullscreen)
|
||||
}
|
||||
|
||||
private var orientationFooter: some View {
|
||||
Text("Orientation settings are experimental and do not yet work properly with all devices and iOS versions")
|
||||
}
|
||||
#endif
|
||||
|
||||
private var closePiPOnNavigationToggle: some View {
|
||||
|
||||
@@ -179,7 +179,7 @@ struct SettingsView: View {
|
||||
case .browsing:
|
||||
return 350
|
||||
case .player:
|
||||
return 450
|
||||
return 470
|
||||
case .history:
|
||||
return 480
|
||||
case .sponsorBlock:
|
||||
|
||||
@@ -16,9 +16,9 @@ struct TrendingCountry: View {
|
||||
#if !os(tvOS)
|
||||
HStack {
|
||||
if #available(iOS 15.0, macOS 12.0, *) {
|
||||
TextField("Country", text: $query, prompt: Text(Self.prompt))
|
||||
TextField("Country", text: $query, prompt: Text(TrendingCountry.prompt))
|
||||
} else {
|
||||
TextField(Self.prompt, text: $query)
|
||||
TextField(TrendingCountry.prompt, text: $query)
|
||||
}
|
||||
|
||||
Button("Done") { selectCountryAndDismiss() }
|
||||
@@ -30,7 +30,7 @@ struct TrendingCountry: View {
|
||||
countriesList
|
||||
}
|
||||
#if os(tvOS)
|
||||
.searchable(text: $query, placement: .automatic, prompt: Text(Self.prompt))
|
||||
.searchable(text: $query, placement: .automatic, prompt: Text(TrendingCountry.prompt))
|
||||
.background(Color.black)
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import CoreMedia
|
||||
import Foundation
|
||||
|
||||
struct VideoURLParser {
|
||||
@@ -12,7 +11,7 @@ struct VideoURLParser {
|
||||
return queryItemValue("v")
|
||||
}
|
||||
|
||||
var time: CMTime? {
|
||||
var time: TimeInterval? {
|
||||
guard let time = queryItemValue("t") else {
|
||||
return nil
|
||||
}
|
||||
@@ -25,13 +24,13 @@ struct VideoURLParser {
|
||||
let seconds = TimeInterval(timeComponents["seconds"] ?? "0")
|
||||
else {
|
||||
if let time = TimeInterval(time) {
|
||||
return .secondsInDefaultTimescale(time)
|
||||
return time
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return .secondsInDefaultTimescale(seconds + (minutes * 60) + (hours * 60 * 60))
|
||||
return seconds + (minutes * 60) + (hours * 60 * 60)
|
||||
}
|
||||
|
||||
func queryItemValue(_ name: String) -> String? {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import CoreMedia
|
||||
import Defaults
|
||||
import SDWebImageSwiftUI
|
||||
import SwiftUI
|
||||
@@ -6,8 +5,8 @@ import SwiftUI
|
||||
struct VideoCell: View {
|
||||
private var video: Video
|
||||
|
||||
@Environment(\.inNavigationView) private var inNavigationView
|
||||
@Environment(\.navigationStyle) private var navigationStyle
|
||||
@Environment(\.inChannelView) private var inChannelView
|
||||
|
||||
#if os(iOS)
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
@@ -46,8 +45,11 @@ struct VideoCell: View {
|
||||
.buttonStyle(.plain)
|
||||
.contentShape(RoundedRectangle(cornerRadius: thumbnailRoundingCornerRadius))
|
||||
.contextMenu {
|
||||
VideoContextMenuView(video: video)
|
||||
.environmentObject(accounts)
|
||||
VideoContextMenuView(
|
||||
video: video,
|
||||
playerNavigationLinkActive: $player.playerNavigationLinkActive
|
||||
)
|
||||
.environmentObject(accounts)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,39 +62,34 @@ struct VideoCell: View {
|
||||
}
|
||||
|
||||
private func playAction() {
|
||||
DispatchQueue.main.async {
|
||||
guard video.videoID != Video.fixtureID else {
|
||||
return
|
||||
}
|
||||
|
||||
if watchingNow {
|
||||
if !player.playingInPictureInPicture {
|
||||
player.show()
|
||||
}
|
||||
|
||||
if !playNowContinues {
|
||||
player.backend.seek(to: .zero)
|
||||
}
|
||||
|
||||
player.play()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var playAt: CMTime?
|
||||
|
||||
if saveHistory,
|
||||
playNowContinues,
|
||||
!watch.isNil,
|
||||
!watch!.finished
|
||||
{
|
||||
playAt = .secondsInDefaultTimescale(watch!.stoppedAt)
|
||||
}
|
||||
|
||||
player.avPlayerBackend.startPictureInPictureOnPlay = player.playingInPictureInPicture
|
||||
|
||||
player.play(video, at: playAt)
|
||||
guard video.videoID != Video.fixtureID else {
|
||||
return
|
||||
}
|
||||
|
||||
if watchingNow {
|
||||
if !player.playingInPictureInPicture {
|
||||
player.show()
|
||||
}
|
||||
|
||||
if !playNowContinues {
|
||||
player.backend.seek(to: .zero)
|
||||
}
|
||||
|
||||
player.play()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var playAt: TimeInterval?
|
||||
|
||||
if playNowContinues,
|
||||
!watch.isNil,
|
||||
!watch!.finished
|
||||
{
|
||||
playAt = watch!.stoppedAt
|
||||
}
|
||||
|
||||
player.play(video, at: playAt, inNavigationView: inNavigationView)
|
||||
}
|
||||
|
||||
private var playNowContinues: Bool {
|
||||
@@ -295,10 +292,6 @@ struct VideoCell: View {
|
||||
|
||||
private func channelButton(badge: Bool = true) -> some View {
|
||||
Button {
|
||||
guard !inChannelView else {
|
||||
return
|
||||
}
|
||||
|
||||
NavigationModel.openChannel(
|
||||
video.channel,
|
||||
player: player,
|
||||
|
||||
@@ -65,6 +65,15 @@ struct BrowserPlayerControls<Content: View, Toolbar: View>: View {
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
model.closeCurrentItem()
|
||||
} label: {
|
||||
Label("Close Video", systemImage: "xmark.circle")
|
||||
.labelStyle(.automatic)
|
||||
}
|
||||
.disabled(model.currentItem.isNil)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
@@ -73,41 +82,43 @@ struct BrowserPlayerControls<Content: View, Toolbar: View>: View {
|
||||
}
|
||||
.padding(.vertical, 20)
|
||||
|
||||
HStack {
|
||||
Group {
|
||||
if !model.currentItem.isNil {
|
||||
Button {
|
||||
model.closeCurrentItem()
|
||||
model.closePiP()
|
||||
} label: {
|
||||
Label("Close Video", systemImage: "xmark")
|
||||
ZStack(alignment: .bottom) {
|
||||
HStack {
|
||||
Group {
|
||||
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)
|
||||
.font(.system(size: 30))
|
||||
.frame(minWidth: 30)
|
||||
|
||||
if playerControls.isPlaying {
|
||||
Button(action: {
|
||||
model.pause()
|
||||
}) {
|
||||
Label("Pause", systemImage: "pause.fill")
|
||||
}
|
||||
} else {
|
||||
Button(action: {
|
||||
model.play()
|
||||
}) {
|
||||
Label("Play", systemImage: "play.fill")
|
||||
}
|
||||
Button(action: { model.advanceToNextItem() }) {
|
||||
Label("Next", systemImage: "forward.fill")
|
||||
.padding(.vertical)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.disabled(model.queue.isEmpty)
|
||||
}
|
||||
.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)
|
||||
ProgressView(value: progressViewValue, total: progressViewTotal)
|
||||
.progressViewStyle(.linear)
|
||||
#if os(iOS)
|
||||
.frame(maxWidth: 60)
|
||||
#else
|
||||
.offset(y: 6)
|
||||
.frame(maxWidth: 70)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
@@ -2,89 +2,55 @@ import Siesta
|
||||
import SwiftUI
|
||||
|
||||
struct ChannelPlaylistView: View {
|
||||
#if os(iOS)
|
||||
static let hiddenOffset = max(UIScreen.main.bounds.height, UIScreen.main.bounds.width) + 100
|
||||
#endif
|
||||
|
||||
var playlist: ChannelPlaylist?
|
||||
var playlist: ChannelPlaylist
|
||||
|
||||
@State private var presentingShareSheet = false
|
||||
@State private var shareURL: URL?
|
||||
|
||||
#if os(iOS)
|
||||
@State private var viewVerticalOffset = Self.hiddenOffset
|
||||
#endif
|
||||
|
||||
@StateObject private var store = Store<ChannelPlaylist>()
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.navigationStyle) private var navigationStyle
|
||||
@Environment(\.inNavigationView) private var inNavigationView
|
||||
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
|
||||
private var items: [ContentItem] {
|
||||
var items: [ContentItem] {
|
||||
ContentItem.array(of: store.item?.videos ?? [])
|
||||
}
|
||||
|
||||
private var presentedPlaylist: ChannelPlaylist? {
|
||||
playlist ?? recents.presentedPlaylist
|
||||
}
|
||||
|
||||
private var resource: Resource? {
|
||||
guard let playlist = presentedPlaylist else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let resource = accounts.api.channelPlaylist(playlist.id)
|
||||
resource?.addObserver(store)
|
||||
|
||||
return resource
|
||||
var resource: Resource? {
|
||||
accounts.api.channelPlaylist(playlist.id)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if navigationStyle == .tab {
|
||||
NavigationView {
|
||||
#if os(iOS)
|
||||
if inNavigationView {
|
||||
content
|
||||
} else {
|
||||
BrowserPlayerControls {
|
||||
content
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.onChange(of: navigation.presentingPlaylist) { newValue in
|
||||
if newValue {
|
||||
store.clear()
|
||||
viewVerticalOffset = 0
|
||||
resource?.load()
|
||||
} else {
|
||||
viewVerticalOffset = Self.hiddenOffset
|
||||
}
|
||||
}
|
||||
.offset(y: viewVerticalOffset)
|
||||
.animation(.easeIn(duration: 0.2), value: viewVerticalOffset)
|
||||
#endif
|
||||
} else {
|
||||
#else
|
||||
BrowserPlayerControls {
|
||||
content
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
var content: some View {
|
||||
VStack(alignment: .leading) {
|
||||
#if os(tvOS)
|
||||
HStack {
|
||||
if let playlist = presentedPlaylist {
|
||||
Text(playlist.title)
|
||||
.font(.title2)
|
||||
.frame(alignment: .leading)
|
||||
Text(playlist.title)
|
||||
.font(.title2)
|
||||
.frame(alignment: .leading)
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
|
||||
FavoriteButton(item: FavoriteItem(section: .channelPlaylist(playlist.id, playlist.title)))
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
FavoriteButton(item: FavoriteItem(section: .channelPlaylist(playlist.id, playlist.title)))
|
||||
.labelStyle(.iconOnly)
|
||||
|
||||
playButton
|
||||
.labelStyle(.iconOnly)
|
||||
@@ -103,6 +69,7 @@ struct ChannelPlaylistView: View {
|
||||
}
|
||||
#endif
|
||||
.onAppear {
|
||||
resource?.addObserver(store)
|
||||
resource?.loadIfNeeded()
|
||||
}
|
||||
#if os(tvOS)
|
||||
@@ -110,31 +77,26 @@ struct ChannelPlaylistView: View {
|
||||
#else
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigation) {
|
||||
if navigationStyle == .tab {
|
||||
Button("Done") {
|
||||
navigation.presentingPlaylist = false
|
||||
}
|
||||
}
|
||||
ShareButton(
|
||||
contentItem: contentItem,
|
||||
presentingShareSheet: $presentingShareSheet,
|
||||
shareURL: $shareURL
|
||||
)
|
||||
}
|
||||
|
||||
ToolbarItem(placement: playlistButtonsPlacement) {
|
||||
HStack {
|
||||
ShareButton(
|
||||
contentItem: contentItem,
|
||||
presentingShareSheet: $presentingShareSheet,
|
||||
shareURL: $shareURL
|
||||
)
|
||||
|
||||
if let playlist = presentedPlaylist {
|
||||
FavoriteButton(item: FavoriteItem(section: .channelPlaylist(playlist.id, playlist.title)))
|
||||
}
|
||||
FavoriteButton(item: FavoriteItem(section: .channelPlaylist(playlist.id, playlist.title)))
|
||||
|
||||
playButton
|
||||
shuffleButton
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(presentedPlaylist?.title ?? "")
|
||||
.navigationTitle(playlist.title)
|
||||
#if os(iOS)
|
||||
.navigationBarHidden(player.playerNavigationLinkActive)
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -148,7 +110,7 @@ struct ChannelPlaylistView: View {
|
||||
|
||||
private var playButton: some View {
|
||||
Button {
|
||||
player.play(videos)
|
||||
player.play(videos, inNavigationView: inNavigationView)
|
||||
} label: {
|
||||
Label("Play All", systemImage: "play")
|
||||
}
|
||||
@@ -156,7 +118,7 @@ struct ChannelPlaylistView: View {
|
||||
|
||||
private var shuffleButton: some View {
|
||||
Button {
|
||||
player.play(videos, shuffling: true)
|
||||
player.play(videos, shuffling: true, inNavigationView: inNavigationView)
|
||||
} label: {
|
||||
Label("Shuffle", systemImage: "shuffle")
|
||||
}
|
||||
|
||||
@@ -2,69 +2,46 @@ import Siesta
|
||||
import SwiftUI
|
||||
|
||||
struct ChannelVideosView: View {
|
||||
#if os(iOS)
|
||||
static let hiddenOffset = max(UIScreen.main.bounds.height, UIScreen.main.bounds.width) + 100
|
||||
#endif
|
||||
var channel: Channel?
|
||||
let channel: Channel
|
||||
|
||||
@State private var presentingShareSheet = false
|
||||
@State private var shareURL: URL?
|
||||
@State private var subscriptionToggleButtonDisabled = false
|
||||
|
||||
#if os(iOS)
|
||||
@State private var viewVerticalOffset = Self.hiddenOffset
|
||||
#endif
|
||||
|
||||
@StateObject private var store = Store<Channel>()
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.navigationStyle) private var navigationStyle
|
||||
|
||||
#if os(iOS)
|
||||
@Environment(\.inNavigationView) private var inNavigationView
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
#endif
|
||||
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||
|
||||
@Namespace private var focusNamespace
|
||||
|
||||
var presentedChannel: Channel? {
|
||||
channel ?? recents.presentedChannel
|
||||
}
|
||||
|
||||
var videos: [ContentItem] {
|
||||
ContentItem.array(of: store.item?.videos ?? [])
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if navigationStyle == .tab {
|
||||
NavigationView {
|
||||
#if os(iOS)
|
||||
if inNavigationView {
|
||||
content
|
||||
} else {
|
||||
BrowserPlayerControls {
|
||||
content
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.onChange(of: navigation.presentingChannel) { newValue in
|
||||
if newValue {
|
||||
store.clear()
|
||||
viewVerticalOffset = 0
|
||||
resource?.load()
|
||||
} else {
|
||||
viewVerticalOffset = Self.hiddenOffset
|
||||
}
|
||||
}
|
||||
.offset(y: viewVerticalOffset)
|
||||
.animation(.easeIn(duration: 0.2), value: viewVerticalOffset)
|
||||
#endif
|
||||
} else {
|
||||
#else
|
||||
BrowserPlayerControls {
|
||||
content
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
var content: some View {
|
||||
@@ -77,10 +54,8 @@ struct ChannelVideosView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
if let channel = presentedChannel {
|
||||
FavoriteButton(item: FavoriteItem(section: .channel(channel.id, channel.name)))
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
FavoriteButton(item: FavoriteItem(section: .channel(channel.id, channel.name)))
|
||||
.labelStyle(.iconOnly)
|
||||
|
||||
if let subscribers = store.item?.subscriptionsString {
|
||||
Text("**\(subscribers)** subscribers")
|
||||
@@ -102,35 +77,27 @@ struct ChannelVideosView: View {
|
||||
#if !os(tvOS)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigation) {
|
||||
if navigationStyle == .tab {
|
||||
Button("Done") {
|
||||
navigation.presentingChannel = false
|
||||
}
|
||||
}
|
||||
ShareButton(
|
||||
contentItem: contentItem,
|
||||
presentingShareSheet: $presentingShareSheet,
|
||||
shareURL: $shareURL
|
||||
)
|
||||
}
|
||||
|
||||
ToolbarItem {
|
||||
HStack {
|
||||
HStack(spacing: 3) {
|
||||
Text("\(store.item?.subscriptionsString ?? "")")
|
||||
Text("\(store.item?.subscriptionsString ?? "loading")")
|
||||
.fontWeight(.bold)
|
||||
Text(" subscribers")
|
||||
.allowsTightening(true)
|
||||
.foregroundColor(.secondary)
|
||||
.opacity(store.item?.subscriptionsString != nil ? 1 : 0)
|
||||
}
|
||||
|
||||
ShareButton(
|
||||
contentItem: contentItem,
|
||||
presentingShareSheet: $presentingShareSheet,
|
||||
shareURL: $shareURL
|
||||
)
|
||||
.allowsTightening(true)
|
||||
.foregroundColor(.secondary)
|
||||
.opacity(store.item?.subscriptionsString != nil ? 1 : 0)
|
||||
|
||||
subscriptionToggleButton
|
||||
|
||||
if let channel = presentedChannel {
|
||||
FavoriteButton(item: FavoriteItem(section: .channel(channel.id, channel.name)))
|
||||
}
|
||||
FavoriteButton(item: FavoriteItem(section: .channel(channel.id, channel.name)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,11 +110,15 @@ struct ChannelVideosView: View {
|
||||
}
|
||||
#endif
|
||||
.onAppear {
|
||||
resource?.loadIfNeeded()
|
||||
if store.item.isNil {
|
||||
resource.addObserver(store)
|
||||
resource.load()
|
||||
}
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.navigationTitle(navigationTitle)
|
||||
#if os(iOS)
|
||||
.navigationBarHidden(player.playerNavigationLinkActive)
|
||||
#endif
|
||||
.navigationTitle(navigationTitle)
|
||||
|
||||
return Group {
|
||||
if #available(macOS 12.0, *) {
|
||||
@@ -164,50 +135,44 @@ struct ChannelVideosView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var resource: Resource? {
|
||||
guard let channel = presentedChannel else {
|
||||
return nil
|
||||
}
|
||||
|
||||
private var resource: Resource {
|
||||
let resource = accounts.api.channel(channel.id)
|
||||
resource.addObserver(store)
|
||||
|
||||
return resource
|
||||
}
|
||||
|
||||
@ViewBuilder private var subscriptionToggleButton: some View {
|
||||
if let channel = presentedChannel {
|
||||
Group {
|
||||
if accounts.app.supportsSubscriptions && accounts.signedIn {
|
||||
if subscriptions.isSubscribing(channel.id) {
|
||||
Button("Unsubscribe") {
|
||||
subscriptionToggleButtonDisabled = true
|
||||
private var subscriptionToggleButton: some View {
|
||||
Group {
|
||||
if accounts.app.supportsSubscriptions && accounts.signedIn {
|
||||
if subscriptions.isSubscribing(channel.id) {
|
||||
Button("Unsubscribe") {
|
||||
subscriptionToggleButtonDisabled = true
|
||||
|
||||
subscriptions.unsubscribe(channel.id) {
|
||||
subscriptionToggleButtonDisabled = false
|
||||
}
|
||||
subscriptions.unsubscribe(channel.id) {
|
||||
subscriptionToggleButtonDisabled = false
|
||||
}
|
||||
} else {
|
||||
Button("Subscribe") {
|
||||
subscriptionToggleButtonDisabled = true
|
||||
}
|
||||
} else {
|
||||
Button("Subscribe") {
|
||||
subscriptionToggleButtonDisabled = true
|
||||
|
||||
subscriptions.subscribe(channel.id) {
|
||||
subscriptionToggleButtonDisabled = false
|
||||
navigation.sidebarSectionChanged.toggle()
|
||||
}
|
||||
subscriptions.subscribe(channel.id) {
|
||||
subscriptionToggleButtonDisabled = false
|
||||
navigation.sidebarSectionChanged.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(subscriptionToggleButtonDisabled)
|
||||
}
|
||||
.disabled(subscriptionToggleButtonDisabled)
|
||||
}
|
||||
|
||||
private var contentItem: ContentItem {
|
||||
ContentItem(channel: presentedChannel)
|
||||
ContentItem(channel: channel)
|
||||
}
|
||||
|
||||
private var navigationTitle: String {
|
||||
presentedChannel?.name ?? store.item?.name ?? "No channel"
|
||||
store.item?.name ?? channel.name
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,36 +4,11 @@ import SwiftUI
|
||||
struct PlaylistVideosView: View {
|
||||
let playlist: Playlist
|
||||
|
||||
@Environment(\.inNavigationView) private var inNavigationView
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<PlaylistsModel> private var model
|
||||
|
||||
@StateObject private var store = Store<ChannelPlaylist>()
|
||||
|
||||
var contentItems: [ContentItem] {
|
||||
var videos = playlist.videos
|
||||
|
||||
if videos.isEmpty {
|
||||
videos = store.item?.videos ?? []
|
||||
if !player.accounts.app.userPlaylistsEndpointIncludesVideos {
|
||||
var i = 0
|
||||
|
||||
for index in videos.indices {
|
||||
var video = videos[index]
|
||||
video.indexID = "\(i)"
|
||||
i += 1
|
||||
videos[index] = video
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ContentItem.array(of: videos)
|
||||
}
|
||||
|
||||
private var resource: Resource? {
|
||||
let resource = player.accounts.api.playlist(playlist.id)
|
||||
resource?.addObserver(store)
|
||||
|
||||
return resource
|
||||
ContentItem.array(of: playlist.videos)
|
||||
}
|
||||
|
||||
var videos: [Video] {
|
||||
@@ -47,14 +22,6 @@ struct PlaylistVideosView: View {
|
||||
var body: some View {
|
||||
BrowserPlayerControls {
|
||||
VerticalCells(items: contentItems)
|
||||
.onAppear {
|
||||
if !player.accounts.app.userPlaylistsEndpointIncludesVideos {
|
||||
resource?.load()
|
||||
}
|
||||
}
|
||||
.onChange(of: model.reloadPlaylists) { _ in
|
||||
resource?.load()
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.navigationTitle("\(playlist.title) Playlist")
|
||||
#endif
|
||||
@@ -65,13 +32,13 @@ struct PlaylistVideosView: View {
|
||||
FavoriteButton(item: FavoriteItem(section: .channelPlaylist(playlist.id, playlist.title)))
|
||||
|
||||
Button {
|
||||
player.play(videos)
|
||||
player.play(videos, inNavigationView: inNavigationView)
|
||||
} label: {
|
||||
Label("Play All", systemImage: "play")
|
||||
}
|
||||
|
||||
Button {
|
||||
player.play(videos, shuffling: true)
|
||||
player.play(videos, shuffling: true, inNavigationView: inNavigationView)
|
||||
} label: {
|
||||
Label("Shuffle", systemImage: "shuffle")
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import CoreData
|
||||
import CoreMedia
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct VideoContextMenuView: View {
|
||||
let video: Video
|
||||
|
||||
@Binding var playerNavigationLinkActive: Bool
|
||||
|
||||
@Environment(\.inNavigationView) private var inNavigationView
|
||||
@Environment(\.inChannelView) private var inChannelView
|
||||
@Environment(\.inChannelPlaylistView) private var inChannelPlaylistView
|
||||
@Environment(\.navigationStyle) private var navigationStyle
|
||||
@@ -24,8 +26,9 @@ struct VideoContextMenuView: View {
|
||||
|
||||
private var viewContext: NSManagedObjectContext = PersistenceController.shared.container.viewContext
|
||||
|
||||
init(video: Video) {
|
||||
init(video: Video, playerNavigationLinkActive: Binding<Bool>) {
|
||||
self.video = video
|
||||
_playerNavigationLinkActive = playerNavigationLinkActive
|
||||
_watchRequest = video.watchFetchRequest
|
||||
}
|
||||
|
||||
@@ -54,9 +57,6 @@ struct VideoContextMenuView: View {
|
||||
|
||||
Section {
|
||||
playNowButton
|
||||
#if os(iOS)
|
||||
playNowInPictureInPictureButton
|
||||
#endif
|
||||
}
|
||||
|
||||
Section {
|
||||
@@ -111,7 +111,7 @@ struct VideoContextMenuView: View {
|
||||
|
||||
private var continueButton: some View {
|
||||
Button {
|
||||
player.play(video, at: .secondsInDefaultTimescale(watch!.stoppedAt))
|
||||
player.play(video, at: watch!.stoppedAt, inNavigationView: inNavigationView)
|
||||
} label: {
|
||||
Label("Continue from \(watch!.stoppedAt.formattedAsPlaybackTime() ?? "where I left off")", systemImage: "playpause")
|
||||
}
|
||||
@@ -131,24 +131,12 @@ struct VideoContextMenuView: View {
|
||||
|
||||
private var playNowButton: some View {
|
||||
Button {
|
||||
player.play(video)
|
||||
player.play(video, inNavigationView: inNavigationView)
|
||||
} label: {
|
||||
Label("Play Now", systemImage: "play")
|
||||
}
|
||||
}
|
||||
|
||||
private var playNowInPictureInPictureButton: some View {
|
||||
Button {
|
||||
player.controls.startPiP(startImmediately: false)
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
player.play(video, at: watch?.timeToRestart, showingPlayer: false)
|
||||
}
|
||||
} label: {
|
||||
Label("Play in PiP", systemImage: "pip")
|
||||
}
|
||||
}
|
||||
|
||||
private var playNextButton: some View {
|
||||
Button {
|
||||
player.playNext(video)
|
||||
@@ -213,7 +201,7 @@ struct VideoContextMenuView: View {
|
||||
|
||||
func removeFromPlaylistButton(playlistID: String) -> some View {
|
||||
Button {
|
||||
playlists.removeVideo(index: video.indexID!, playlistID: playlistID)
|
||||
playlists.removeVideo(videoIndexID: video.indexID!, playlistID: playlistID)
|
||||
} label: {
|
||||
Label("Remove from Playlist", systemImage: "text.badge.minus")
|
||||
}
|
||||
|
||||
@@ -190,11 +190,6 @@
|
||||
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 */; };
|
||||
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 */; };
|
||||
373031F32838388A000CFD59 /* PlayerLayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373031F22838388A000CFD59 /* PlayerLayerView.swift */; };
|
||||
373031F528383A89000CFD59 /* PiPDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373031F428383A89000CFD59 /* PiPDelegate.swift */; };
|
||||
3730D8A02712E2B70020ED53 /* NowPlayingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3730D89F2712E2B70020ED53 /* NowPlayingView.swift */; };
|
||||
3730F75A2733481E00F385FC /* RelatedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373197D82732015300EF734F /* RelatedView.swift */; };
|
||||
373197D92732015300EF734F /* RelatedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373197D82732015300EF734F /* RelatedView.swift */; };
|
||||
@@ -869,8 +864,6 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
373197D82732015300EF734F /* RelatedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelatedView.swift; sourceTree = "<group>"; };
|
||||
37319F0427103F94004ECCD0 /* PlayerQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerQueue.swift; sourceTree = "<group>"; };
|
||||
@@ -1358,7 +1351,6 @@
|
||||
37BE0BCE26A0E2D50092E2DB /* VideoPlayerView.swift */,
|
||||
37E8B0EB27B326C00024006F /* TimelineView.swift */,
|
||||
37F9619A27BD89E000058149 /* TapRecognizerViewModifier.swift */,
|
||||
373031F22838388A000CFD59 /* PlayerLayerView.swift */,
|
||||
);
|
||||
path = Player;
|
||||
sourceTree = "<group>";
|
||||
@@ -1407,7 +1399,6 @@
|
||||
37152EE926EFEB95004FB96D /* LazyView.swift */,
|
||||
37030FF627B0347C00ECDDAA /* MPVPlayerView.swift */,
|
||||
37E70926271CDDAE00D34DDE /* OpenSettingsButton.swift */,
|
||||
37FEF11227EFD8580033912F /* PlaceholderCell.swift */,
|
||||
3769C02D2779F18600DDB3EA /* PlaceholderProgressView.swift */,
|
||||
37BA793A26DB8EE4002A0235 /* PlaylistVideosView.swift */,
|
||||
37AAF27D26737323007FC770 /* PopularView.swift */,
|
||||
@@ -1416,6 +1407,7 @@
|
||||
37AAF29F26741C97007FC770 /* SubscriptionsView.swift */,
|
||||
37B17D9F268A1F25006AEE9B /* VideoContextMenuView.swift */,
|
||||
37E70922271CD43000D34DDE /* WelcomeScreen.swift */,
|
||||
37FEF11227EFD8580033912F /* PlaceholderCell.swift */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
@@ -1447,7 +1439,6 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
37EBD8C227AF0D7C00F1C24B /* Backends */,
|
||||
373031F428383A89000CFD59 /* PiPDelegate.swift */,
|
||||
37B767DA2677C3CA0098BAA8 /* PlayerModel.swift */,
|
||||
37319F0427103F94004ECCD0 /* PlayerQueue.swift */,
|
||||
37CC3F44270CE30600608308 /* PlayerQueueItem.swift */,
|
||||
@@ -2557,7 +2548,6 @@
|
||||
37EF9A76275BEB8E0043B585 /* CommentView.swift in Sources */,
|
||||
373C8FE4275B955100CB5936 /* CommentsPage.swift in Sources */,
|
||||
3700155B271B0D4D0049C794 /* PipedAPI.swift in Sources */,
|
||||
373031F32838388A000CFD59 /* PlayerLayerView.swift in Sources */,
|
||||
37B044B726F7AB9000E1419D /* SettingsView.swift in Sources */,
|
||||
377FC7E3267A084A00A6BBAF /* VideoCell.swift in Sources */,
|
||||
37C3A251272366440087A57A /* ChannelPlaylistView.swift in Sources */,
|
||||
@@ -2610,7 +2600,6 @@
|
||||
3788AC2726F6840700F6BAA9 /* FavoriteItemView.swift in Sources */,
|
||||
375DFB5826F9DA010013F468 /* InstancesModel.swift in Sources */,
|
||||
3751BA8327E6914F007B1A60 /* ReturnYouTubeDislikeAPI.swift in Sources */,
|
||||
373031F528383A89000CFD59 /* PiPDelegate.swift in Sources */,
|
||||
37DD9DC62785D63A00539416 /* UIResponder+Extensions.swift in Sources */,
|
||||
37C3A24927235FAA0087A57A /* ChannelPlaylistCell.swift in Sources */,
|
||||
373CFACB26966264003CB2C6 /* SearchQuery.swift in Sources */,
|
||||
@@ -2769,7 +2758,6 @@
|
||||
37A9965F26D6F9B9006E3224 /* FavoritesView.swift in Sources */,
|
||||
37F4AE7326828F0900BD60EA /* VerticalCells.swift in Sources */,
|
||||
37001560271B12DD0049C794 /* SiestaConfiguration.swift in Sources */,
|
||||
372D85DE283841B800FF3C7D /* PiPDelegate.swift in Sources */,
|
||||
37B81AFD26D2C9C900675966 /* VideoDetailsPaddingModifier.swift in Sources */,
|
||||
37C0697F2725C8D400F7F6CB /* CMTime+DefaultTimescale.swift in Sources */,
|
||||
37A9965B26D6F8CA006E3224 /* HorizontalCells.swift in Sources */,
|
||||
@@ -3012,8 +3000,6 @@
|
||||
37B17DA0268A1F89006AEE9B /* VideoContextMenuView.swift in Sources */,
|
||||
37BE0BD726A1D4A90092E2DB /* AppleAVPlayerViewController.swift in Sources */,
|
||||
37484C3326FCB8F900287258 /* AccountValidator.swift in Sources */,
|
||||
372D85DF283842EC00FF3C7D /* PiPDelegate.swift in Sources */,
|
||||
372D85E0283842EE00FF3C7D /* PlayerLayerView.swift in Sources */,
|
||||
37CEE4C32677B697005A1EFE /* Stream.swift in Sources */,
|
||||
37F64FE626FE70A60081B69E /* RedrawOnModifier.swift in Sources */,
|
||||
37B2631C2735EAAB00FE0D40 /* FavoriteResourceObserver.swift in Sources */,
|
||||
@@ -3103,7 +3089,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "Open in Yattee/Open in Yattee.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 37;
|
||||
CURRENT_PROJECT_VERSION = 32;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -3116,7 +3102,7 @@
|
||||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
||||
MARKETING_VERSION = 1.4;
|
||||
MARKETING_VERSION = 1.4.alpha.3;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
SafariServices,
|
||||
@@ -3137,7 +3123,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "Open in Yattee/Open in Yattee.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 37;
|
||||
CURRENT_PROJECT_VERSION = 32;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -3150,7 +3136,7 @@
|
||||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
||||
MARKETING_VERSION = 1.4;
|
||||
MARKETING_VERSION = 1.4.alpha.3;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
SafariServices,
|
||||
@@ -3169,7 +3155,7 @@
|
||||
buildSettings = {
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 37;
|
||||
CURRENT_PROJECT_VERSION = 32;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "Open in Yattee/Info.plist";
|
||||
@@ -3181,7 +3167,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4;
|
||||
MARKETING_VERSION = 1.4.alpha.3;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
SafariServices,
|
||||
@@ -3201,7 +3187,7 @@
|
||||
buildSettings = {
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 37;
|
||||
CURRENT_PROJECT_VERSION = 32;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "Open in Yattee/Info.plist";
|
||||
@@ -3213,7 +3199,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4;
|
||||
MARKETING_VERSION = 1.4.alpha.3;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
SafariServices,
|
||||
@@ -3365,7 +3351,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "c++14";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 37;
|
||||
CURRENT_PROJECT_VERSION = 32;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
@@ -3389,9 +3375,9 @@
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/Vendor/mpv/iOS/lib",
|
||||
);
|
||||
MARKETING_VERSION = 1.4;
|
||||
MARKETING_VERSION = 1.4.alpha.3;
|
||||
OTHER_LDFLAGS = "-lstdc++";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app.alpha;
|
||||
PRODUCT_NAME = Yattee;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
@@ -3407,7 +3393,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 37;
|
||||
CURRENT_PROJECT_VERSION = 32;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = "GLES_SILENCE_DEPRECATION=1";
|
||||
@@ -3427,9 +3413,9 @@
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/Vendor/mpv/iOS/lib",
|
||||
);
|
||||
MARKETING_VERSION = 1.4;
|
||||
MARKETING_VERSION = 1.4.alpha.3;
|
||||
OTHER_LDFLAGS = "-lstdc++";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app.alpha;
|
||||
PRODUCT_NAME = Yattee;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
@@ -3449,7 +3435,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 37;
|
||||
CURRENT_PROJECT_VERSION = 32;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
@@ -3468,7 +3454,7 @@
|
||||
"$(PROJECT_DIR)/Vendor/mpv/macOS/lib",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
||||
MARKETING_VERSION = 1.4;
|
||||
MARKETING_VERSION = 1.4.alpha.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
|
||||
PRODUCT_NAME = Yattee;
|
||||
SDKROOT = macosx;
|
||||
@@ -3487,7 +3473,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 37;
|
||||
CURRENT_PROJECT_VERSION = 32;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
@@ -3506,7 +3492,7 @@
|
||||
"$(PROJECT_DIR)/Vendor/mpv/macOS/lib",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
||||
MARKETING_VERSION = 1.4;
|
||||
MARKETING_VERSION = 1.4.alpha.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
|
||||
PRODUCT_NAME = Yattee;
|
||||
SDKROOT = macosx;
|
||||
@@ -3530,7 +3516,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "stream.yattee.Tests-iOS";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -3555,7 +3541,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "stream.yattee.Tests-iOS";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -3582,7 +3568,7 @@
|
||||
"@loader_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MARKETING_VERSION = 1.4;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "stream.yattee.Tests-macOS";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = macosx;
|
||||
@@ -3607,7 +3593,7 @@
|
||||
"@loader_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MARKETING_VERSION = 1.4;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "stream.yattee.Tests-macOS";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = macosx;
|
||||
@@ -3623,7 +3609,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 37;
|
||||
CURRENT_PROJECT_VERSION = 32;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -3643,8 +3629,8 @@
|
||||
"$(PROJECT_DIR)/Vendor/mpv",
|
||||
"$(PROJECT_DIR)/Vendor/mpv/tvOS",
|
||||
);
|
||||
MARKETING_VERSION = 1.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
|
||||
MARKETING_VERSION = 1.4.alpha.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app.alpha;
|
||||
PRODUCT_NAME = Yattee;
|
||||
SDKROOT = appletvos;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
@@ -3661,7 +3647,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 37;
|
||||
CURRENT_PROJECT_VERSION = 32;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -3681,8 +3667,8 @@
|
||||
"$(PROJECT_DIR)/Vendor/mpv",
|
||||
"$(PROJECT_DIR)/Vendor/mpv/tvOS",
|
||||
);
|
||||
MARKETING_VERSION = 1.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
|
||||
MARKETING_VERSION = 1.4.alpha.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app.alpha;
|
||||
PRODUCT_NAME = Yattee;
|
||||
SDKROOT = appletvos;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
@@ -3707,7 +3693,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.YatteeUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = appletvos;
|
||||
@@ -3732,7 +3718,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.YatteeUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = appletvos;
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/Alamofire/Alamofire.git",
|
||||
"state" : {
|
||||
"revision" : "354dda32d89fc8cd4f5c46487f64957d355f53d8",
|
||||
"version" : "5.6.1"
|
||||
"revision" : "f82c23a8a7ef8dc1a49a8bfc6a96883e79121864",
|
||||
"version" : "5.5.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -96,7 +96,7 @@
|
||||
"location" : "https://github.com/sparkle-project/Sparkle",
|
||||
"state" : {
|
||||
"branch" : "2.x",
|
||||
"revision" : "503830bf24f679b7a9199be85ee7c8d012528d09"
|
||||
"revision" : "f250bead4b943ef9711c61274a1f52e380afa0e8"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -11,7 +11,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
}
|
||||
|
||||
func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { // swiftlint:disable:this discouraged_optional_collection
|
||||
Self.instance = self
|
||||
AppDelegate.instance = self
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,5 @@
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Need camera access to take pictures</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
Reference in New Issue
Block a user