Opening videos by URL and local files

This commit is contained in:
Arkadiusz Fal 2022-11-10 18:11:28 +01:00
parent 34f7621f36
commit 402d1a2f79
40 changed files with 1158 additions and 126 deletions

View File

@ -44,7 +44,7 @@ struct FixtureEnvironmentObjectsModifier: ViewModifier {
player.currentItem = PlayerQueueItem(
Video(
videoID: "",
videoID: "https://a/b/c",
title: "Video Name",
author: "",
length: 0,

View File

@ -80,6 +80,11 @@ extension VideosAPI {
return
}
if let video = item.video, video.isLocal {
completionHandler(item)
return
}
video(item.videoID).load()
.onSuccess { response in
guard let video: Video = response.typedContent() else {
@ -87,6 +92,7 @@ extension VideosAPI {
}
var newItem = item
newItem.id = UUID()
newItem.video = video
completionHandler(newItem)

View File

@ -63,4 +63,8 @@ enum VideosApp: String, CaseIterable {
var allowsDisablingVidoesProxying: Bool {
self == .invidious
}
var supportsOpeningVideosByID: Bool {
self != .demoApp
}
}

25
Model/CacheModel.swift Normal file
View File

@ -0,0 +1,25 @@
import Cache
import Foundation
import SwiftyJSON
struct CacheModel {
static var shared = CacheModel()
var urlBookmarksStorage: Storage<String, Data>?
var videoStorage: Storage<Video.ID, JSON>?
init() {
let urlBookmarksStorageConfig = DiskConfig(name: "URLBookmarks", expiry: .never)
let urlBookmarksMemoryConfig = MemoryConfig(expiry: .never, countLimit: 100, totalCostLimit: 100)
urlBookmarksStorage = try? Storage(diskConfig: urlBookmarksStorageConfig, memoryConfig: urlBookmarksMemoryConfig, transformer: TransformerFactory.forData())
let videoStorageConfig = DiskConfig(name: "VideoStorage", expiry: .never)
let videoStorageMemoryConfig = MemoryConfig(expiry: .never, countLimit: 100, totalCostLimit: 100)
let toData: (JSON) throws -> Data = { try $0.rawData() }
let fromData: (Data) throws -> JSON = { try JSON(data: $0) }
let jsonTransformer = Transformer<JSON>(toData: toData, fromData: fromData)
videoStorage = try? Storage<Video.ID, JSON>(diskConfig: videoStorageConfig, memoryConfig: videoStorageMemoryConfig, transformer: jsonTransformer)
}
}

View File

@ -2,6 +2,7 @@ import CoreData
import CoreMedia
import Defaults
import Foundation
import SwiftyJSON
extension PlayerModel {
func historyVideo(_ id: String) -> Video? {
@ -13,12 +14,37 @@ extension PlayerModel {
return
}
if !Video.VideoID.isValid(id), let url = URL(string: id) {
historyVideos.append(.local(url))
return
}
if historyItemBeingLoaded == nil {
logger.info("loading history details: \(id)")
historyItemBeingLoaded = id
} else {
logger.info("POSTPONING history load: \(id)")
historyItemsToLoad.append(id)
return
}
playerAPI.video(id).load().onSuccess { [weak self] response in
guard let video: Video = response.typedContent() else {
return
guard let self else { return }
if let video: Video = response.typedContent() {
self.historyVideos.append(video)
}
}.onCompletion { _ in
self.logger.info("LOADED history details: \(id)")
if self.historyItemBeingLoaded == id {
self.logger.info("setting no history loaded")
self.historyItemBeingLoaded = nil
}
self?.historyVideos.append(video)
if let id = self.historyItemsToLoad.popLast() {
self.loadHistoryVideoDetails(id)
}
}
}

View File

@ -72,6 +72,7 @@ final class NavigationModel: ObservableObject {
@Published var presentingPlaylist = false
@Published var sidebarSectionChanged = false
@Published var presentingOpenVideos = false
@Published var presentingSettings = false
@Published var presentingWelcomeScreen = false

109
Model/OpenVideosModel.swift Normal file
View File

@ -0,0 +1,109 @@
import Foundation
import Logging
struct OpenVideosModel {
enum PlaybackMode: String, CaseIterable {
case playNow
case shuffleAll
case playNext
case playLast
var description: String {
switch self {
case .playNow:
return "Play Now".localized()
case .shuffleAll:
return "Shuffle All".localized()
case .playNext:
return "Play Next".localized()
case .playLast:
return "Play Last".localized()
}
}
var allowsRemovingQueueItems: Bool {
self == .playNow || self == .shuffleAll
}
var allowedWhenQueueIsEmpty: Bool {
self == .playNow || self == .shuffleAll
}
}
static let shared = OpenVideosModel()
var player: PlayerModel! = .shared
var logger = Logger(label: "stream.yattee.open-videos")
func open(_ url: URL) {
if url.startAccessingSecurityScopedResource() {
let video = Video.local(url)
player.play([video], shuffling: false)
}
}
func openURLs(_ urls: [URL], removeQueueItems: Bool, playbackMode: OpenVideosModel.PlaybackMode) {
logger.info("opening \(urls.count) urls")
urls.forEach { logger.info("\($0.absoluteString)") }
if removeQueueItems, playbackMode.allowsRemovingQueueItems {
player.removeQueueItems()
logger.info("removing queue items")
}
switch playbackMode {
case .playNow:
player.playbackMode = .queue
case .shuffleAll:
player.playbackMode = .shuffle
case .playNext:
player.playbackMode = .queue
case .playLast:
player.playbackMode = .queue
}
enqueue(
urls,
prepending: playbackMode == .playNow || playbackMode == .playNext
)
if playbackMode == .playNow || playbackMode == .shuffleAll {
player.show()
player.advanceToNextItem()
}
}
func enqueue(_ urls: [URL], prepending: Bool = false) {
var videos = urls.compactMap { url in
var video: Video!
if canOpenVideosByID {
let parser = URLParser(url: url)
if parser.destination == .video, let id = parser.videoID {
video = Video(videoID: id)
logger.info("identified remote video: \(id)")
} else {
video = .local(url)
logger.info("identified local video: \(url.absoluteString)")
}
} else {
video = .local(url)
logger.info("identified local video: \(url.absoluteString)")
}
return video
}
if prepending {
videos.reverse()
}
videos.forEach { video in
player.enqueueVideo(video, play: false, prepending: prepending, loadDetails: false)
}
}
var canOpenVideosByID: Bool {
guard let app = player.accounts.current?.app else { return false }
return !player.accounts.isEmpty && app.supportsOpeningVideosByID
}
}

View File

@ -37,9 +37,24 @@ final class AVPlayerBackend: PlayerBackend {
avPlayer.timeControlStatus == .playing
}
var videoWidth: Double? {
if let width = avPlayer.currentItem?.presentationSize.width {
return Double(width)
}
return nil
}
var videoHeight: Double? {
if let height = avPlayer.currentItem?.presentationSize.height {
return Double(height)
}
return nil
}
var aspectRatio: Double {
#if os(iOS)
playerLayer.videoRect.width / playerLayer.videoRect.height
videoWidth! / videoHeight!
#else
VideoPlayerView.defaultAspectRatio
#endif
@ -104,8 +119,17 @@ final class AVPlayerBackend: PlayerBackend {
preservingTime: Bool,
upgrading _: Bool
) {
if let url = stream.singleAssetURL {
if var url = stream.singleAssetURL {
model.logger.info("playing stream with one asset\(stream.kind == .hls ? " (HLS)" : ""): \(url)")
if video.isLocal, video.localStreamIsFile, let localURL = video.localStream?.localURL {
guard localURL.startAccessingSecurityScopedResource() else {
model.navigation.presentAlert(title: "Could not open file")
return
}
url = localURL
}
loadSingleAsset(url, stream: stream, of: video, preservingTime: preservingTime)
} else {
model.logger.info("playing stream with many assets:")
@ -317,6 +341,7 @@ final class AVPlayerBackend: PlayerBackend {
guard video == self.model.currentVideo else {
return
}
self.avPlayer.replaceCurrentItem(with: self.model.playerItem)
self.seekToPreservedTime { finished in
guard finished else {
@ -373,7 +398,8 @@ final class AVPlayerBackend: PlayerBackend {
#if !os(macOS)
var externalMetadata = [
makeMetadataItem(.commonIdentifierTitle, value: video.title),
makeMetadataItem(.commonIdentifierTitle, value: video.displayTitle),
makeMetadataItem(.commonIdentifierArtist, value: video.displayAuthor),
makeMetadataItem(.quickTimeMetadataGenre, value: video.genre ?? ""),
makeMetadataItem(.commonIdentifierDescription, value: video.description ?? "")
]

View File

@ -108,6 +108,10 @@ final class MPVBackend: PlayerBackend {
client?.outputFps ?? 0
}
var formattedOutputFps: String {
String(format: "%.2ffps", outputFps)
}
var hwDecoder: String {
client?.hwDecoder ?? "unknown"
}
@ -120,6 +124,54 @@ final class MPVBackend: PlayerBackend {
client?.cacheDuration ?? 0
}
var videoFormat: String {
client?.videoFormat ?? "unknown"
}
var videoCodec: String {
client?.videoCodec ?? "unknown"
}
var currentVo: String {
client?.currentVo ?? "unknown"
}
var videoWidth: Double? {
if let width = client?.width, width != "unknown" {
return Double(width)
}
return nil
}
var videoHeight: Double? {
if let height = client?.height, height != "unknown" {
return Double(height)
}
return nil
}
var audioFormat: String {
client?.audioFormat ?? "unknown"
}
var audioCodec: String {
client?.audioCodec ?? "unknown"
}
var currentAo: String {
client?.currentAo ?? "unknown"
}
var audioChannels: String {
client?.audioChannels ?? "unknown"
}
var audioSampleRate: String {
client?.audioSampleRate ?? "unknown"
}
init() {
clientTimer = .init(interval: .seconds(Self.timeUpdateInterval), mode: .infinite) { [weak self] _ in
self?.getTimeUpdates()
@ -230,6 +282,13 @@ final class MPVBackend: PlayerBackend {
startPlaying()
}
if video.isLocal, video.localStreamIsFile, let localStream = video.localStream {
guard localStream.localURL.startAccessingSecurityScopedResource() else {
self.model.navigation.presentAlert(title: "Could not open file")
return
}
}
self.client.loadFile(url, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
self?.isLoadingVideo = true
}

View File

@ -198,6 +198,50 @@ final class MPVClient: ObservableObject {
mpv.isNil ? 0.0 : getDouble("demuxer-cache-duration")
}
var videoFormat: String {
stringOrUnknown("video-format")
}
var videoCodec: String {
stringOrUnknown("video-codec")
}
var currentVo: String {
stringOrUnknown("current-vo")
}
var width: String {
stringOrUnknown("width")
}
var height: String {
stringOrUnknown("height")
}
var videoBitrate: Double {
mpv.isNil ? 0.0 : getDouble("video-bitrate")
}
var audioFormat: String {
stringOrUnknown("audio-params/format")
}
var audioCodec: String {
stringOrUnknown("audio-codec")
}
var currentAo: String {
stringOrUnknown("current-ao")
}
var audioChannels: String {
stringOrUnknown("audio-params/channels")
}
var audioSampleRate: String {
stringOrUnknown("audio-params/samplerate")
}
var aspectRatio: Double {
guard !mpv.isNil else { return VideoPlayerView.defaultAspectRatio }
let aspect = getDouble("video-params/aspect")
@ -407,6 +451,10 @@ final class MPVClient: ObservableObject {
}
}
private func stringOrUnknown(_ name: String) -> String {
mpv.isNil ? "unknown" : (getString(name) ?? "unknown")
}
private var machine: String {
var systeminfo = utsname()
uname(&systeminfo)

View File

@ -25,6 +25,9 @@ protocol PlayerBackend {
var aspectRatio: Double { get }
var controlsUpdates: Bool { get }
var videoWidth: Double? { get }
var videoHeight: Double? { get }
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting) -> Stream?
func canPlay(_ stream: Stream) -> Bool

View File

@ -97,6 +97,10 @@ final class PlayerModel: ObservableObject {
@Published var currentItem: PlayerQueueItem! { didSet { handleCurrentItemChange() } }
@Published var videoBeingOpened: Video? { didSet { seek.reset() } }
@Published var historyVideos = [Video]()
@Published var queueItemBeingLoaded: PlayerQueueItem?
@Published var queueItemsToLoad = [PlayerQueueItem]()
@Published var historyItemBeingLoaded: Video.ID?
@Published var historyItemsToLoad = [Video.ID]()
@Published var preservedTime: CMTime?
@ -373,7 +377,7 @@ final class PlayerModel: ObservableObject {
withBackend: PlayerBackend? = nil
) {
playerError = nil
if !upgrading {
if !upgrading, !video.isLocal {
resetSegments()
DispatchQueue.main.async { [weak self] in
@ -440,7 +444,7 @@ final class PlayerModel: ObservableObject {
changeActiveBackend(from: activeBackend, to: backend)
}
guard let stream = streamByQualityProfile else {
guard let stream = ((availableStreams.count == 1 && availableStreams.first!.isLocal) ? availableStreams.first : nil) ?? streamByQualityProfile else {
return
}
@ -842,8 +846,8 @@ final class PlayerModel: ObservableObject {
let currentTime = (backend.currentTime?.seconds.isFinite ?? false) ? backend.currentTime!.seconds : 0
var nowPlayingInfo: [String: AnyObject] = [
MPMediaItemPropertyTitle: video.title as AnyObject,
MPMediaItemPropertyArtist: video.author as AnyObject,
MPMediaItemPropertyTitle: video.displayTitle as AnyObject,
MPMediaItemPropertyArtist: video.displayAuthor as AnyObject,
MPNowPlayingInfoPropertyIsLiveStream: live as AnyObject,
MPNowPlayingInfoPropertyElapsedPlaybackTime: currentTime as AnyObject,
MPNowPlayingInfoPropertyPlaybackQueueCount: queue.count as AnyObject,
@ -952,4 +956,9 @@ final class PlayerModel: ObservableObject {
}
#endif
}
var formattedSize: String {
guard let videoWidth = backend?.videoWidth, let videoHeight = backend?.videoHeight else { return "unknown" }
return "\(String(format: "%.2f", videoWidth))\u{d7}\(String(format: "%.2f", videoHeight))"
}
}

View File

@ -68,7 +68,7 @@ extension PlayerModel {
guard let playerInstance = self.playerInstance else { return }
let streamsInstance = video.streams.compactMap(\.instance).first
if video.streams.isEmpty || streamsInstance != playerInstance {
if !video.isLocal, video.streams.isEmpty || streamsInstance != playerInstance {
self.loadAvailableStreams(video)
} else {
self.availableStreams = self.streamsWithInstance(instance: playerInstance, streams: video.streams)
@ -203,6 +203,7 @@ extension PlayerModel {
}
}
} else {
videoDetailsLoadHandler(video, item)
queue.insert(item, at: prepending ? 0 : queue.endIndex)
}
@ -210,11 +211,22 @@ extension PlayerModel {
}
func prepareCurrentItemForHistory(finished: Bool = false) {
if !currentItem.isNil, Defaults[.saveHistory] {
if let video = currentVideo, !historyVideos.contains(where: { $0 == video }) {
historyVideos.append(video)
if let currentItem {
if Defaults[.saveHistory] {
if let video = currentVideo, !historyVideos.contains(where: { $0 == video }) {
historyVideos.append(video)
}
updateWatch(finished: finished)
}
if let video = currentItem.video,
video.isLocal,
video.localStreamIsFile,
let localURL = video.localStream?.localURL
{
logger.info("stopping security scoped resource access for \(localURL)")
localURL.stopAccessingSecurityScopedResource()
}
updateWatch(finished: finished)
}
}
@ -253,9 +265,35 @@ extension PlayerModel {
func loadQueueVideoDetails(_ item: PlayerQueueItem) {
guard !accounts.current.isNil, !item.hasDetailsLoaded else { return }
playerAPI.loadDetails(item, completionHandler: { newItem in
if let index = self.queue.firstIndex(where: { $0.id == item.id }) {
self.queue[index] = newItem
let videoID = item.video?.videoID ?? item.videoID
if queueItemBeingLoaded == nil {
logger.info("loading queue details: \(videoID)")
queueItemBeingLoaded = item
} else {
logger.info("POSTPONING details load: \(videoID)")
queueItemsToLoad.append(item)
return
}
playerAPI.loadDetails(item, completionHandler: { [weak self] newItem in
guard let self else { return }
self.queue.filter { $0.videoID == item.videoID }.forEach { item in
if let index = self.queue.firstIndex(of: item) {
self.queue[index] = newItem
}
}
self.logger.info("LOADED queue details: \(videoID)")
if self.queueItemBeingLoaded == item {
self.logger.info("setting nothing loaded")
self.queueItemBeingLoaded = nil
}
if let item = self.queueItemsToLoad.popLast() {
self.loadQueueVideoDetails(item)
}
})
}

View File

@ -42,7 +42,8 @@ struct PlayerQueueItem: Hashable, Identifiable, Defaults.Serializable {
}
var hasDetailsLoaded: Bool {
!video.isNil
guard let video else { return false }
return !video.streams.isEmpty
}
func hash(into hasher: inout Hasher) {

View File

@ -25,7 +25,13 @@ struct PlayerQueueItemBridge: Defaults.Bridge {
}
}
var localURL = ""
if let video = value.video, video.isLocal {
localURL = video.localStream?.localURL.absoluteString ?? ""
}
return [
"localURL": localURL,
"videoID": value.videoID,
"playbackTime": playbackTime,
"videoDuration": videoDuration
@ -33,12 +39,7 @@ struct PlayerQueueItemBridge: Defaults.Bridge {
}
func deserialize(_ object: Serializable?) -> Value? {
guard
let object,
let videoID = object["videoID"]
else {
return nil
}
guard let object else { return nil }
var playbackTime: CMTime?
var videoDuration: TimeInterval?
@ -56,6 +57,19 @@ struct PlayerQueueItemBridge: Defaults.Bridge {
videoDuration = TimeInterval(duration)
}
if let localUrlString = object["localURL"],
!localUrlString.isEmpty,
let localURL = URL(string: localUrlString)
{
return PlayerQueueItem(
.local(localURL),
playbackTime: playbackTime,
videoDuration: videoDuration
)
}
guard let videoID = object["videoID"] else { return nil }
return PlayerQueueItem(
videoID: videoID,
playbackTime: playbackTime,

View File

@ -141,6 +141,7 @@ class Stream: Equatable, Hashable, Identifiable {
var audioAsset: AVURLAsset!
var videoAsset: AVURLAsset!
var hlsURL: URL!
var localURL: URL!
var resolution: Resolution!
var kind: Kind!
@ -154,6 +155,7 @@ class Stream: Equatable, Hashable, Identifiable {
audioAsset: AVURLAsset? = nil,
videoAsset: AVURLAsset? = nil,
hlsURL: URL? = nil,
localURL: URL? = nil,
resolution: Resolution? = nil,
kind: Kind = .hls,
encoding: String? = nil,
@ -163,17 +165,25 @@ class Stream: Equatable, Hashable, Identifiable {
self.audioAsset = audioAsset
self.videoAsset = videoAsset
self.hlsURL = hlsURL
self.localURL = localURL
self.resolution = resolution
self.kind = kind
self.encoding = encoding
format = .from(videoFormat ?? "")
}
var isLocal: Bool {
localURL != nil
}
var quality: String {
kind == .hls ? "adaptive (HLS)" : "\(resolution.name)\(kind == .stream ? " (\(kind.rawValue))" : "")"
guard localURL.isNil else { return "Opened File" }
return kind == .hls ? "adaptive (HLS)" : "\(resolution.name)\(kind == .stream ? " (\(kind.rawValue))" : "")"
}
var shortQuality: String {
guard localURL.isNil else { return "File" }
if kind == .hls {
return "HLS"
} else {
@ -182,6 +192,7 @@ class Stream: Equatable, Hashable, Identifiable {
}
var description: String {
guard localURL.isNil else { return resolutionAndFormat }
let instanceString = instance.isNil ? "" : " - (\(instance!.description))"
return "\(resolutionAndFormat)\(instanceString)"
}
@ -200,6 +211,10 @@ class Stream: Equatable, Hashable, Identifiable {
}
var singleAssetURL: URL? {
guard localURL.isNil else {
return URLBookmarkModel.shared.loadBookmark(localURL) ?? localURL
}
if kind == .hls {
return hlsURL
} else if videoAssetContainsAudio {

View File

@ -0,0 +1,59 @@
import Cache
import Foundation
import Logging
struct URLBookmarkModel {
static var shared = URLBookmarkModel()
var logger = Logger(label: "stream.yattee.url-bookmark")
func saveBookmark(_ url: URL) {
if let bookmarkData = try? url.bookmarkData(options: bookmarkCreationOptions, includingResourceValuesForKeys: nil, relativeTo: nil) {
try? CacheModel.shared.urlBookmarksStorage?.setObject(bookmarkData, forKey: url.absoluteString)
logger.info("saved bookmark for \(url.absoluteString)")
}
}
func loadBookmark(_ url: URL) -> URL? {
logger.info("loading bookmark for \(url.absoluteString)")
guard let data = try? CacheModel.shared.urlBookmarksStorage?.object(forKey: url.absoluteString) else {
logger.info("bookmark for \(url.absoluteString) not found")
return nil
}
do {
var isStale = false
let url = try URL(
resolvingBookmarkData: data,
options: bookmarkResolutionOptions,
relativeTo: nil,
bookmarkDataIsStale: &isStale
)
if isStale {
saveBookmark(url)
}
logger.info("loaded bookmark for \(url.absoluteString)")
return url
} catch {
print("Error resolving bookmark:", error)
return nil
}
}
var bookmarkCreationOptions: URL.BookmarkCreationOptions {
#if os(macOS)
return [.withSecurityScope, .securityScopeAllowOnlyReadAccess]
#else
return []
#endif
}
var bookmarkResolutionOptions: URL.BookmarkResolutionOptions {
#if os(macOS)
return [.withSecurityScope]
#else
return []
#endif
}
}

View File

@ -5,6 +5,12 @@ import SwiftUI
import SwiftyJSON
struct Video: Identifiable, Equatable, Hashable {
enum VideoID {
static func isValid(_ id: Video.ID) -> Bool {
id.count == 11
}
}
let id: String
let videoID: String
var title: String
@ -84,6 +90,33 @@ struct Video: Identifiable, Equatable, Hashable {
self.captions = captions
}
static func local(_ url: URL) -> Video {
Video(
videoID: url.absoluteString,
streams: [.init(localURL: url)]
)
}
var isLocal: Bool {
!VideoID.isValid(videoID)
}
var displayTitle: String {
if isLocal {
return localStreamFileName ?? localStream?.description ?? title
}
return title
}
var displayAuthor: String {
if isLocal, localStreamIsRemoteURL {
return remoteUrlHost ?? "Unknown"
}
return author
}
var publishedDate: String? {
(published.isEmpty || published == "0 seconds ago") ? nil : published
}
@ -133,4 +166,42 @@ struct Video: Identifiable, Equatable, Hashable {
predicate: NSPredicate(format: "videoID = %@", videoID)
)
}
var localStream: Stream? {
guard isLocal else { return nil }
return streams.first
}
var localStreamIsFile: Bool {
guard let localStream else { return false }
return localStream.localURL.isFileURL
}
var localStreamIsRemoteURL: Bool {
guard let localStream else { return false }
return !localStream.localURL.isFileURL
}
var remoteUrlHost: String? {
localStreamURLComponents?.host
}
var localStreamFileName: String? {
guard let path = localStream?.localURL?.lastPathComponent else { return nil }
if let localStreamFileExtension {
return String(path.dropLast(localStreamFileExtension.count + 1))
}
return String(path)
}
var localStreamFileExtension: String? {
guard let path = localStreamURLComponents?.path else { return nil }
return path.contains(".") ? path.components(separatedBy: ".").last?.uppercased() : nil
}
private var localStreamURLComponents: URLComponents? {
guard let localStream else { return nil }
return URLComponents(url: localStream.localURL, resolvingAgainstBaseURL: false)
}
}

View File

@ -0,0 +1,23 @@
import Foundation
import Logging
import SwiftyJSON
struct VideoCacheModel {
static let shared = VideoCacheModel()
var logger = Logger(label: "stream.yattee.video-cache")
func saveVideo(id: Video.ID, app: VideosApp, json: JSON) {
guard !json.isEmpty else { return }
var jsonWithApp = json
jsonWithApp["app"].string = app.rawValue
try! CacheModel.shared.videoStorage!.setObject(jsonWithApp, forKey: id)
logger.info("saving video \(id)")
}
func loadVideo(id: Video.ID) -> JSON? {
logger.info("loading video \(id)")
let json = try? CacheModel.shared.videoStorage?.object(forKey: id)
return json
}
}

View File

@ -79,9 +79,10 @@ extension Watch {
}
var video: Video {
Video(
videoID: videoID, title: "", author: "",
length: 0, published: "", views: -1, channel: Channel(id: "", name: "")
)
if !Video.VideoID.isValid(videoID), let url = URL(string: videoID) {
return .local(url)
}
return Video(videoID: videoID)
}
}

View File

@ -21,6 +21,7 @@ extension Defaults.Keys {
static let enableReturnYouTubeDislike = Key<Bool>("enableReturnYouTubeDislike", default: false)
static let homeHistoryItems = Key<Int>("homeHistoryItems", default: 30)
static let favorites = Key<[FavoriteItem]>("favorites", default: [])
#if !os(tvOS)

View File

@ -17,6 +17,7 @@ struct HomeView: View {
#if !os(tvOS)
@Default(.favorites) private var favorites
#endif
@Default(.homeHistoryItems) private var homeHistoryItems
private var navigation: NavigationModel { .shared }
@ -56,7 +57,7 @@ struct HomeView: View {
.frame(maxWidth: .infinity, alignment: .leading)
.foregroundColor(.secondary)
HistoryView(limit: 100)
HistoryView(limit: homeHistoryItems)
}
#if os(tvOS)

View File

@ -5,10 +5,18 @@ struct MenuCommands: Commands {
@Binding var model: MenuModel
var body: some Commands {
openVideosMenu
navigationMenu
playbackMenu
}
private var openVideosMenu: some Commands {
CommandGroup(after: .newItem) {
Button("Open Videos...") { model.navigation?.presentingOpenVideos = true }
.keyboardShortcut("t")
}
}
private var navigationMenu: some Commands {
CommandGroup(before: .windowSize) {
Button("Home") {

View File

@ -6,13 +6,13 @@ import SwiftUI
struct AppSidebarNavigation: View {
@EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<NavigationModel> private var navigation
#if os(iOS)
@State private var didApplyPrimaryViewWorkAround = false
@EnvironmentObject<CommentsModel> private var comments
@EnvironmentObject<InstancesModel> private var instances
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<PlaylistsModel> private var playlists
@EnvironmentObject<RecentsModel> private var recents
@ -74,7 +74,15 @@ struct AppSidebarNavigation: View {
}
#endif
ToolbarItem(placement: accountsMenuToolbarItemPlacement) {
ToolbarItemGroup(placement: openVideosToolbarItemPlacement) {
Button {
navigation.presentingOpenVideos = true
} label: {
Label("Open Videos", systemImage: "play.circle.fill")
}
}
ToolbarItemGroup(placement: accountsMenuToolbarItemPlacement) {
AccountsMenuView()
.help(
"Switch Instances and Accounts\n" +
@ -96,6 +104,14 @@ struct AppSidebarNavigation: View {
}
}
var openVideosToolbarItemPlacement: ToolbarItemPlacement {
#if os(iOS)
return .navigationBarLeading
#else
return .automatic
#endif
}
var accountsMenuToolbarItemPlacement: ToolbarItemPlacement {
#if os(iOS)
return .bottomBar

View File

@ -139,6 +139,9 @@ struct AppTabNavigation: View {
}
ToolbarItemGroup(placement: .navigationBarTrailing) {
Button(action: { navigation.presentingOpenVideos = true }) {
Label("Open Videos", systemImage: "play.circle.fill")
}
AccountsMenuView()
}
}

View File

@ -119,6 +119,11 @@ struct ContentView: View {
}
)
#endif
.background(
EmptyView().sheet(isPresented: $navigation.presentingOpenVideos) {
OpenVideosView()
}
)
.background(playerViewInitialize)
.alert(isPresented: $navigation.presentingAlert) { navigation.alert }
}

View File

@ -27,6 +27,11 @@ struct OpenURLHandler {
}
#endif
if url.isFileURL {
OpenVideosModel.shared.open(url)
return
}
let parser = URLParser(url: urlByReplacingYatteeProtocol(url))
switch parser.destination {

View File

@ -9,7 +9,7 @@ struct PlaybackStatsView: View {
VStack(alignment: .leading, spacing: 6) {
mpvPlaybackStatRow("Hardware decoder".localized(), player.mpvBackend.hwDecoder)
mpvPlaybackStatRow("Dropped frames".localized(), String(player.mpvBackend.frameDropCount))
mpvPlaybackStatRow("Stream FPS".localized(), String(format: "%.2ffps", player.mpvBackend.outputFps))
mpvPlaybackStatRow("Stream FPS".localized(), player.mpvBackend.formattedOutputFps)
mpvPlaybackStatRow("Cached time".localized(), String(format: "%.2fs", player.mpvBackend.cacheDuration))
}
.padding(.top, 2)

View File

@ -34,6 +34,7 @@ struct PlayerQueueRow: View {
player.avPlayerBackend.startPictureInPictureOnPlay = player.playingInPictureInPicture
player.videoBeingOpened = item.video
player.show()
if history {
player.playHistory(item, at: watchStoppedAt)
@ -72,3 +73,11 @@ struct PlayerQueueRow: View {
return .secondsInDefaultTimescale(seconds)
}
}
struct PlayerQueueRow_Previews: PreviewProvider {
static var previews: some View {
PlayerQueueRow(item: .init(
.local(URL(string: "https://apple.com")!)
))
}
}

View File

@ -74,18 +74,20 @@ struct VideoDetails: View {
"Info".localized(),
"info.circle", .info, !video.isNil
)
pageButton(
"Chapters".localized(),
"bookmark", .chapters, !(video?.chapters.isEmpty ?? true)
)
pageButton(
"Comments".localized(),
"text.bubble", .comments, !video.isNil
) { comments.load() }
pageButton(
"Related".localized(),
"rectangle.stack.fill", .related, !video.isNil
)
if let video, !video.isLocal {
pageButton(
"Chapters".localized(),
"bookmark", .chapters, !video.chapters.isEmpty && !video.isLocal
)
pageButton(
"Comments".localized(),
"text.bubble", .comments, !video.isLocal
) { comments.load() }
pageButton(
"Related".localized(),
"rectangle.stack.fill", .related, !video.isLocal
)
}
pageButton(
"Queue".localized(),
"list.number", .queue, !player.queue.isEmpty
@ -100,6 +102,11 @@ struct VideoDetails: View {
Pager(page: page, data: DetailsPage.allCases, id: \.self) {
if !player.currentItem.isNil || page.index == DetailsPage.queue.index {
detailsByPage($0)
#if os(iOS)
.padding(.bottom, SafeArea.insets.bottom)
#else
.padding(.bottom, 6)
#endif
} else {
VStack {}
}
@ -156,7 +163,7 @@ struct VideoDetails: View {
}
private var contentItem: ContentItem {
ContentItem(video: player.currentVideo!)
ContentItem(video: player.currentVideo)
}
func pageButton(
@ -228,12 +235,14 @@ struct VideoDetails: View {
var detailsPage: some View {
VStack(alignment: .leading, spacing: 0) {
if let video {
VStack(spacing: 6) {
videoProperties
if !video.isLocal {
VStack(spacing: 6) {
videoProperties
Divider()
Divider()
}
.padding(.bottom, 6)
}
.padding(.bottom, 6)
VStack(alignment: .leading, spacing: 10) {
if !player.videoBeingOpened.isNil && (video.description.isNil || video.description!.isEmpty) {
@ -248,16 +257,82 @@ struct VideoDetails: View {
#if os(iOS)
.padding(.bottom, player.playingFullScreen ? 10 : SafeArea.insets.bottom)
#endif
} else {
} else if !video.isLocal {
Text("No description")
.foregroundColor(.secondary)
}
}
VStack(spacing: 4) {
Group {
if player.activeBackend == .mpv, player.mpvBackend.videoFormat != "unknown" {
videoDetailGroupHeading("Video")
videoDetailRow("Format", value: player.mpvBackend.videoFormat)
videoDetailRow("Codec", value: player.mpvBackend.videoCodec)
videoDetailRow("Hardware Decoder", value: player.mpvBackend.hwDecoder)
videoDetailRow("Driver", value: player.mpvBackend.currentVo)
videoDetailRow("Size", value: player.formattedSize)
videoDetailRow("FPS", value: player.mpvBackend.formattedOutputFps)
} else if player.activeBackend == .appleAVPlayer, let width = player.backend.videoWidth, width > 0 {
videoDetailGroupHeading("Video")
videoDetailRow("Size", value: player.formattedSize)
}
}
if player.activeBackend == .mpv, player.mpvBackend.audioFormat != "unknown" {
Group {
videoDetailGroupHeading("Audio")
videoDetailRow("Format", value: player.mpvBackend.audioFormat)
videoDetailRow("Codec", value: player.mpvBackend.audioCodec)
videoDetailRow("Driver", value: player.mpvBackend.currentAo)
videoDetailRow("Channels", value: player.mpvBackend.audioChannels)
videoDetailRow("Sample Rate", value: player.mpvBackend.audioSampleRate)
}
}
if video.localStream != nil || video.localStreamFileExtension != nil {
videoDetailGroupHeading("File")
}
if let fileExtension = video.localStreamFileExtension {
videoDetailRow("File Extension", value: fileExtension)
}
if let url = video.localStream?.localURL, video.localStreamIsRemoteURL {
videoDetailRow("URL", value: url.absoluteString)
}
}
.padding(.bottom, 6)
}
}
.padding(.horizontal)
}
@ViewBuilder func videoDetailGroupHeading(_ heading: String) -> some View {
Text(heading.uppercased())
.font(.footnote)
.foregroundColor(.secondary)
}
@ViewBuilder func videoDetailRow(_ detail: String, value: String) -> some View {
HStack {
Text(detail)
.foregroundColor(.secondary)
Spacer()
let value = Text(value)
if #available(iOS 15.0, macOS 12.0, *) {
value
#if !os(tvOS)
.textSelection(.enabled)
#endif
} else {
value
}
}
.font(.caption)
}
@ViewBuilder var videoProperties: some View {
HStack(spacing: 2) {
publishedDateSection

View File

@ -328,7 +328,6 @@ struct VideoPlayerView: View {
if !fullScreenPlayer {
VideoDetails(sidebarQueue: sidebarQueue, fullScreen: $fullScreenDetails)
#if os(iOS)
// .zIndex(-1)
.ignoresSafeArea(.all, edges: .bottom)
#endif
.background(colorScheme == .dark ? Color.black : Color.white)

View File

@ -24,15 +24,38 @@ struct VideoBanner: View {
#endif
}
VStack(alignment: .leading, spacing: 4) {
Text(video?.title ?? "Loading...".localized())
.truncationMode(.middle)
.lineLimit(2)
.font(.headline)
.frame(alignment: .leading)
Group {
if let video {
HStack(alignment: .top) {
Text(video.displayTitle + "\n")
if video.isLocal, let fileExtension = video.localStreamFileExtension {
Spacer()
Text(fileExtension)
.foregroundColor(.secondary)
}
}
} else {
Text("Loading contents of the video, please wait")
.redacted(reason: .placeholder)
}
}
.truncationMode(.middle)
.lineLimit(2)
.font(.headline)
.frame(alignment: .leading)
HStack {
Text(video?.author ?? "")
.lineLimit(1)
Group {
if let video {
if !video.isLocal || video.localStreamIsRemoteURL {
Text(video.displayAuthor)
}
} else {
Text("Video Author")
.redacted(reason: .placeholder)
}
}
.lineLimit(1)
Spacer()
@ -40,10 +63,8 @@ struct VideoBanner: View {
progressView
#endif
if let time = (videoDuration ?? video?.length ?? 0).formattedAsPlaybackTime() {
Text(time)
.fontWeight(.light)
}
Text((videoDuration ?? video?.length ?? 0).formattedAsPlaybackTime() ?? PlayerTimeModel.timePlaceholder)
.fontWeight(.light)
}
.foregroundColor(.secondary)
}
@ -71,20 +92,30 @@ struct VideoBanner: View {
}
@ViewBuilder private var smallThumbnail: some View {
let url = video?.thumbnailURL(quality: .medium)
WebImage(url: url)
.resizable()
.placeholder {
ProgressView()
Group {
if let video {
if let thumbnail = video.thumbnailURL(quality: .medium) {
WebImage(url: thumbnail)
.resizable()
.placeholder {
ProgressView()
}
.indicator(.activity)
} else if video.localStreamIsFile {
Image(systemName: "folder")
} else if video.localStreamIsRemoteURL {
Image(systemName: "globe")
}
} else {
Image(systemName: "ellipsis")
}
.indicator(.activity)
}
#if os(tvOS)
.frame(width: thumbnailWidth, height: 140)
.mask(RoundedRectangle(cornerRadius: 12))
.frame(width: thumbnailWidth, height: thumbnailHeight)
.mask(RoundedRectangle(cornerRadius: 12))
#else
.frame(width: thumbnailWidth, height: 60)
.mask(RoundedRectangle(cornerRadius: 6))
.frame(width: thumbnailWidth, height: thumbnailHeight)
.mask(RoundedRectangle(cornerRadius: 6))
#endif
}
@ -96,6 +127,14 @@ struct VideoBanner: View {
#endif
}
private var thumbnailHeight: Double {
#if os(tvOS)
140
#else
60
#endif
}
private var progressView: some View {
Group {
if !playbackTime.isNil, !(video?.live ?? false) {
@ -120,6 +159,9 @@ struct VideoBanner_Previews: PreviewProvider {
VStack(spacing: 20) {
VideoBanner(video: Video.fixture, playbackTime: CMTime(seconds: 400, preferredTimescale: 10000))
VideoBanner(video: Video.fixtureUpcomingWithoutPublishedOrViews)
VideoBanner(video: .local(URL(string: "https://apple.com/a/directory/of/video+that+has+very+long+title+that+will+likely.mp4")!))
VideoBanner(video: .local(URL(string: "file://a/b/c/d/e/f.mkv")!))
VideoBanner()
}
.frame(maxWidth: 900)
}

View File

@ -157,7 +157,7 @@ struct ControlsBar: View {
if let video = model.currentVideo {
Group {
Section {
if accounts.app.supportsUserPlaylists && accounts.signedIn {
if accounts.app.supportsUserPlaylists && accounts.signedIn, !video.isLocal {
Section {
Button {
navigation.presentAddToPlaylist(video)
@ -180,36 +180,38 @@ struct ControlsBar: View {
#endif
Section {
Button {
NavigationModel.openChannel(
video.channel,
player: model,
recents: recents,
navigation: navigation,
navigationStyle: navigationStyle
)
} label: {
Label("\(video.author) Channel", systemImage: "rectangle.stack.fill.badge.person.crop")
}
if !video.isLocal {
Button {
NavigationModel.openChannel(
video.channel,
player: model,
recents: recents,
navigation: navigation,
navigationStyle: navigationStyle
)
} label: {
Label("\(video.author) Channel", systemImage: "rectangle.stack.fill.badge.person.crop")
}
if accounts.app.supportsSubscriptions, accounts.signedIn {
if subscriptions.isSubscribing(video.channel.id) {
Button {
#if os(tvOS)
subscriptions.unsubscribe(video.channel.id)
#else
navigation.presentUnsubscribeAlert(video.channel, subscriptions: subscriptions)
#endif
} label: {
Label("Unsubscribe", systemImage: "xmark.circle")
}
} else {
Button {
subscriptions.subscribe(video.channel.id) {
navigation.sidebarSectionChanged.toggle()
if accounts.app.supportsSubscriptions, accounts.signedIn {
if subscriptions.isSubscribing(video.channel.id) {
Button {
#if os(tvOS)
subscriptions.unsubscribe(video.channel.id)
#else
navigation.presentUnsubscribeAlert(video.channel, subscriptions: subscriptions)
#endif
} label: {
Label("Unsubscribe", systemImage: "xmark.circle")
}
} else {
Button {
subscriptions.subscribe(video.channel.id) {
navigation.sidebarSectionChanged.toggle()
}
} label: {
Label("Subscribe", systemImage: "star.circle")
}
} label: {
Label("Subscribe", systemImage: "star.circle")
}
}
}
@ -228,7 +230,7 @@ struct ControlsBar: View {
VStack(alignment: .leading, spacing: 0) {
let notPlaying = "Not Playing".localized()
Text(model.currentVideo?.title ?? notPlaying)
Text(model.currentVideo?.displayTitle ?? notPlaying)
.font(.system(size: 14))
.fontWeight(.semibold)
.foregroundColor(model.currentVideo.isNil ? .secondary : .accentColor)
@ -236,12 +238,12 @@ struct ControlsBar: View {
.lineLimit(titleLineLimit)
.multilineTextAlignment(.leading)
if let video = model.currentVideo {
if let video = model.currentVideo, !video.localStreamIsFile {
HStack(spacing: 2) {
Text(video.author)
Text(video.displayAuthor)
.font(.system(size: 12))
if !presentingControls {
if !presentingControls && !video.isLocal {
HStack(spacing: 2) {
Image(systemName: "person.2.fill")
@ -271,7 +273,7 @@ struct ControlsBar: View {
private var authorAvatar: some View {
Group {
if let video = model.currentItem?.video, let url = video.channel.thumbnailURL {
if let url = model.currentItem?.video?.channel.thumbnailURL {
WebImage(url: url)
.resizable()
.placeholder {
@ -284,10 +286,20 @@ struct ControlsBar: View {
Color(white: 0.6)
.opacity(0.5)
Image(systemName: "play.rectangle")
.foregroundColor(.accentColor)
.font(.system(size: 20))
.contentShape(Rectangle())
Group {
if let video = model.currentItem?.video, video.isLocal {
if video.localStreamIsFile {
Image(systemName: "folder")
} else if video.localStreamIsRemoteURL {
Image(systemName: "globe")
}
} else {
Image(systemName: "play.rectangle")
}
}
.foregroundColor(.accentColor)
.font(.system(size: 20))
.contentShape(Rectangle())
}
}
}

View File

@ -0,0 +1,167 @@
import SwiftUI
struct OpenVideosView: View {
@State private var presentingFileImporter = false
@State private var urlsToOpenText = "https://r.yattee.stream/demo/mp4/1.mp4\nhttps://r.yattee.stream/demo/mp4/2.mp4\nhttps://r.yattee.stream/demo/mp4/3.mp4\nhttps://www.youtube.com/watch?v=N9WHp8DG2WY"
@State private var playbackMode = OpenVideosModel.PlaybackMode.playNow
@State private var removeQueueItems = false
@EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<RecentsModel> private var recents
@EnvironmentObject<SearchModel> private var search
@Environment(\.openURL) private var openURL
@Environment(\.presentationMode) private var presentationMode
var body: some View {
#if os(macOS)
openVideos
.frame(minWidth: 600, maxWidth: 800, minHeight: 250)
#else
NavigationView {
openVideos
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(action: { presentationMode.wrappedValue.dismiss() }) {
Label("Close", systemImage: "xmark")
}
#if !os(tvOS)
.keyboardShortcut(.cancelAction)
#endif
}
}
.navigationTitle("Open Videos")
}
#endif
}
var openVideos: some View {
VStack(alignment: .leading) {
ZStack(alignment: .topLeading) {
#if os(tvOS)
TextField("URLs to Open", text: $urlsToOpenText)
#else
TextEditor(text: $urlsToOpenText)
.padding(2)
.border(Color(white: 0.8), width: 1)
.frame(maxHeight: 200)
#if !os(macOS)
.keyboardType(.URL)
#endif
#endif
}
Text("Enter or paste URLs to open, one per line")
.font(.caption2)
.foregroundColor(.secondary)
Picker("Playback Mode", selection: $playbackMode) {
ForEach(OpenVideosModel.PlaybackMode.allCases, id: \.rawValue) { mode in
Text(mode.description).tag(mode)
}
}
.labelsHidden()
.padding(.bottom, 5)
.frame(maxWidth: .infinity, alignment: .center)
Toggle(isOn: $removeQueueItems) {
Text("Clear queue before opening")
}
.disabled(!playbackMode.allowsRemovingQueueItems)
.padding(.bottom)
HStack {
Group {
Button {
openURLs(urlsToOpenFromText)
} label: {
HStack {
Image(systemName: "network")
Text("Open URLs")
.fontWeight(.bold)
.padding(.vertical, 10)
}
.padding(.horizontal, 20)
}
.disabled(urlsToOpenFromText.isEmpty)
#if !os(tvOS)
.keyboardShortcut(.defaultAction)
#endif
Spacer()
Button {
presentingFileImporter = true
} label: {
HStack {
Image(systemName: "folder")
Text("Open Files")
.fontWeight(.bold)
.padding(.vertical, 10)
}
.padding(.horizontal, 20)
}
}
.foregroundColor(.accentColor)
.background(
RoundedRectangle(cornerRadius: 4)
.foregroundColor(Color.accentColor.opacity(0.33))
)
}
.buttonStyle(.plain)
Spacer()
}
.padding()
#if !os(tvOS)
.fileImporter(
isPresented: $presentingFileImporter,
allowedContentTypes: [.audiovisualContent],
allowsMultipleSelection: true
) { result in
do {
let selectedFiles = try result.get()
let urlsToOpen = selectedFiles.map { url in
if let bookmarkURL = URLBookmarkModel.shared.loadBookmark(url) {
return bookmarkURL
}
if url.startAccessingSecurityScopedResource() {
URLBookmarkModel.shared.saveBookmark(url)
}
return url
}
openURLs(selectedFiles)
} catch {
NavigationModel.shared.presentAlert(title: "Could not open Files")
}
presentationMode.wrappedValue.dismiss()
}
#endif
}
var urlsToOpenFromText: [URL] {
urlsToOpenText.split(whereSeparator: \.isNewline).compactMap { URL(string: String($0)) }
}
func openURLs(_ urls: [URL]) {
OpenVideosModel.shared.openURLs(urls, removeQueueItems: removeQueueItems, playbackMode: playbackMode)
presentationMode.wrappedValue.dismiss()
}
}
struct OpenVideosView_Previews: PreviewProvider {
static var previews: some View {
OpenVideosView()
#if os(iOS)
.navigationViewStyle(.stack)
#endif
}
}

View File

@ -70,7 +70,7 @@ struct VideoContextMenuView: View {
addToQueueButton
}
if accounts.app.supportsUserPlaylists, accounts.signedIn {
if accounts.app.supportsUserPlaylists, accounts.signedIn, !video.isLocal {
Section {
addToPlaylistButton
addToLastPlaylistButton
@ -87,7 +87,7 @@ struct VideoContextMenuView: View {
}
#endif
if !inChannelView, !inChannelPlaylistView {
if !inChannelView, !inChannelPlaylistView, !video.isLocal {
Section {
openChannelButton

View File

@ -4,12 +4,16 @@
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>com.apple.security.files.bookmarks.app-scope</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)-spki</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)-spks</string>
</array>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>

View File

@ -394,6 +394,9 @@
3761ABFF26F0F8DE00AA496F /* EnvironmentValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3761ABFC26F0F8DE00AA496F /* EnvironmentValues.swift */; };
3763495126DFF59D00B9A393 /* AppSidebarRecents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3763495026DFF59D00B9A393 /* AppSidebarRecents.swift */; };
3763495226DFF59D00B9A393 /* AppSidebarRecents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3763495026DFF59D00B9A393 /* AppSidebarRecents.swift */; };
3763C989290C7A50004D3B5F /* OpenVideosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3763C988290C7A50004D3B5F /* OpenVideosView.swift */; };
3763C98A290C7A50004D3B5F /* OpenVideosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3763C988290C7A50004D3B5F /* OpenVideosView.swift */; };
3763C98B290C7A50004D3B5F /* OpenVideosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3763C988290C7A50004D3B5F /* OpenVideosView.swift */; };
37648B69286CF5F1003D330B /* TVControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37648B68286CF5F1003D330B /* TVControls.swift */; };
376527BB285F60F700102284 /* PlayerTimeModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376527BA285F60F700102284 /* PlayerTimeModel.swift */; };
376527BC285F60F700102284 /* PlayerTimeModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376527BA285F60F700102284 /* PlayerTimeModel.swift */; };
@ -411,6 +414,9 @@
3765917E27237D2A009F956E /* PINCache in Frameworks */ = {isa = PBXBuildFile; productRef = 3765917D27237D2A009F956E /* PINCache */; };
37666BAA27023AF000F869E5 /* AccountSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37666BA927023AF000F869E5 /* AccountSelectionView.swift */; };
3766AFD2273DA97D00686348 /* Int+FormatTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BA796D26DC412E002A0235 /* Int+FormatTests.swift */; };
376787BC291C4B7B00D356A4 /* VideoCacheModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376787BB291C4B7B00D356A4 /* VideoCacheModel.swift */; };
376787BD291C4B7B00D356A4 /* VideoCacheModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376787BB291C4B7B00D356A4 /* VideoCacheModel.swift */; };
376787BE291C4B7B00D356A4 /* VideoCacheModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376787BB291C4B7B00D356A4 /* VideoCacheModel.swift */; };
3769537928A877C4005D87C3 /* StreamControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3795593527B08538007FF8F4 /* StreamControl.swift */; };
3769C02E2779F18600DDB3EA /* PlaceholderProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3769C02D2779F18600DDB3EA /* PlaceholderProgressView.swift */; };
3769C02F2779F18600DDB3EA /* PlaceholderProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3769C02D2779F18600DDB3EA /* PlaceholderProgressView.swift */; };
@ -517,6 +523,9 @@
377FC7E3267A084A00A6BBAF /* VideoCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D4B18B26717B3800C925CA /* VideoCell.swift */; };
377FC7E4267A084E00A6BBAF /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AAF27F26737550007FC770 /* SearchView.swift */; };
377FC7E5267A084E00A6BBAF /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AAF27F26737550007FC770 /* SearchView.swift */; };
377FF88B291A60310028EB0B /* OpenVideosModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377FF88A291A60310028EB0B /* OpenVideosModel.swift */; };
377FF88C291A60310028EB0B /* OpenVideosModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377FF88A291A60310028EB0B /* OpenVideosModel.swift */; };
377FF88D291A60310028EB0B /* OpenVideosModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377FF88A291A60310028EB0B /* OpenVideosModel.swift */; };
377FF88F291A99580028EB0B /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377FF88E291A99580028EB0B /* HistoryView.swift */; };
377FF890291A99580028EB0B /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377FF88E291A99580028EB0B /* HistoryView.swift */; };
377FF891291A99580028EB0B /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377FF88E291A99580028EB0B /* HistoryView.swift */; };
@ -537,6 +546,8 @@
3788AC2726F6840700F6BAA9 /* FavoriteItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3788AC2626F6840700F6BAA9 /* FavoriteItemView.swift */; };
3788AC2826F6840700F6BAA9 /* FavoriteItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3788AC2626F6840700F6BAA9 /* FavoriteItemView.swift */; };
3788AC2926F6840700F6BAA9 /* FavoriteItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3788AC2626F6840700F6BAA9 /* FavoriteItemView.swift */; };
3788AD3E291D042D00C53C9B /* Cache in Frameworks */ = {isa = PBXBuildFile; productRef = 3788AD3D291D042D00C53C9B /* Cache */; };
3788AD40291D043200C53C9B /* Cache in Frameworks */ = {isa = PBXBuildFile; productRef = 3788AD3F291D043200C53C9B /* Cache */; };
378AE93A274EDFAF006A4EE1 /* Badge+Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3722AEBB274DA396005EA4D6 /* Badge+Backport.swift */; };
378AE93C274EDFB2006A4EE1 /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3722AEBD274DA401005EA4D6 /* Backport.swift */; };
378AE93D274EDFB3006A4EE1 /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3722AEBD274DA401005EA4D6 /* Backport.swift */; };
@ -854,6 +865,13 @@
37F4AE7226828F0900BD60EA /* VerticalCells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F4AE7126828F0900BD60EA /* VerticalCells.swift */; };
37F4AE7326828F0900BD60EA /* VerticalCells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F4AE7126828F0900BD60EA /* VerticalCells.swift */; };
37F4AE7426828F0900BD60EA /* VerticalCells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F4AE7126828F0900BD60EA /* VerticalCells.swift */; };
37F5E8B4291BE97A006C15F5 /* Cache in Frameworks */ = {isa = PBXBuildFile; productRef = 37F5E8B3291BE97A006C15F5 /* Cache */; };
37F5E8B6291BE9D0006C15F5 /* URLBookmarkModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F5E8B5291BE9D0006C15F5 /* URLBookmarkModel.swift */; };
37F5E8B7291BE9D0006C15F5 /* URLBookmarkModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F5E8B5291BE9D0006C15F5 /* URLBookmarkModel.swift */; };
37F5E8B8291BE9D0006C15F5 /* URLBookmarkModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F5E8B5291BE9D0006C15F5 /* URLBookmarkModel.swift */; };
37F5E8BA291BEF69006C15F5 /* CacheModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F5E8B9291BEF69006C15F5 /* CacheModel.swift */; };
37F5E8BB291BEF69006C15F5 /* CacheModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F5E8B9291BEF69006C15F5 /* CacheModel.swift */; };
37F5E8BC291BEF69006C15F5 /* CacheModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F5E8B9291BEF69006C15F5 /* CacheModel.swift */; };
37F64FE426FE70A60081B69E /* RedrawOnModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F64FE326FE70A60081B69E /* RedrawOnModifier.swift */; };
37F64FE526FE70A60081B69E /* RedrawOnModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F64FE326FE70A60081B69E /* RedrawOnModifier.swift */; };
37F64FE626FE70A60081B69E /* RedrawOnModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F64FE326FE70A60081B69E /* RedrawOnModifier.swift */; };
@ -1139,6 +1157,7 @@
375F740F289DC35A00747050 /* PlayerBackendView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerBackendView.swift; sourceTree = "<group>"; };
3761ABFC26F0F8DE00AA496F /* EnvironmentValues.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EnvironmentValues.swift; sourceTree = "<group>"; };
3763495026DFF59D00B9A393 /* AppSidebarRecents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSidebarRecents.swift; sourceTree = "<group>"; };
3763C988290C7A50004D3B5F /* OpenVideosView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenVideosView.swift; sourceTree = "<group>"; };
37648B68286CF5F1003D330B /* TVControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVControls.swift; sourceTree = "<group>"; };
376527BA285F60F700102284 /* PlayerTimeModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerTimeModel.swift; sourceTree = "<group>"; };
376578842685429C00D4EA09 /* CaseIterable+Next.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CaseIterable+Next.swift"; sourceTree = "<group>"; };
@ -1147,6 +1166,7 @@
37658ED428E1C567004BF6A2 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
37666BA927023AF000F869E5 /* AccountSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSelectionView.swift; sourceTree = "<group>"; };
376787BA291C43CD00D356A4 /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = "<group>"; };
376787BB291C4B7B00D356A4 /* VideoCacheModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoCacheModel.swift; sourceTree = "<group>"; };
3768122C28E8D0BC0036FC8D /* hi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hi; path = hi.lproj/Localizable.strings; sourceTree = "<group>"; };
3769C02D2779F18600DDB3EA /* PlaceholderProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderProgressView.swift; sourceTree = "<group>"; };
376A33DF2720CAD6000C1D6B /* VideosApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideosApp.swift; sourceTree = "<group>"; };
@ -1176,6 +1196,7 @@
377ABC43286E4B74009C986F /* ManifestedInstance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManifestedInstance.swift; sourceTree = "<group>"; };
377ABC47286E5887009C986F /* Sequence+Unique.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sequence+Unique.swift"; sourceTree = "<group>"; };
377ABC4B286E6A78009C986F /* LocationsSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationsSettings.swift; sourceTree = "<group>"; };
377FF88A291A60310028EB0B /* OpenVideosModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenVideosModel.swift; sourceTree = "<group>"; };
377FF88E291A99580028EB0B /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
3782B94E27553A6700990149 /* SearchSuggestions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchSuggestions.swift; sourceTree = "<group>"; };
3782B9512755667600990149 /* String+Format.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Format.swift"; sourceTree = "<group>"; };
@ -1326,6 +1347,8 @@
37F4AD1E28612DFD004D0F66 /* Buffering.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Buffering.swift; sourceTree = "<group>"; };
37F4AD2528613B81004D0F66 /* Color+Debug.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Debug.swift"; sourceTree = "<group>"; };
37F4AE7126828F0900BD60EA /* VerticalCells.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerticalCells.swift; sourceTree = "<group>"; };
37F5E8B5291BE9D0006C15F5 /* URLBookmarkModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLBookmarkModel.swift; sourceTree = "<group>"; };
37F5E8B9291BEF69006C15F5 /* CacheModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheModel.swift; sourceTree = "<group>"; };
37F64FE326FE70A60081B69E /* RedrawOnModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedrawOnModifier.swift; sourceTree = "<group>"; };
37F7AB4C28A9361F00FB46B5 /* UIDevice+Cellular.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIDevice+Cellular.swift"; sourceTree = "<group>"; };
37F7AB4E28A94E0600FB46B5 /* IOKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IOKit.framework; path = System/Library/Frameworks/IOKit.framework; sourceTree = SDKROOT; };
@ -1392,6 +1415,7 @@
3736A21A286BB72300C9E5EE /* libmpv.xcframework in Frameworks */,
3765917C27237D21009F956E /* PINCache in Frameworks */,
37BD07B72698AB2E003EBB87 /* Defaults in Frameworks */,
37F5E8B4291BE97A006C15F5 /* Cache in Frameworks */,
3736A20C286BB72300C9E5EE /* libavutil.xcframework in Frameworks */,
37FB285627220D9000A57617 /* SDWebImagePINPlugin in Frameworks */,
3736A212286BB72300C9E5EE /* libswresample.xcframework in Frameworks */,
@ -1431,6 +1455,7 @@
370F4FC927CC16CB001B35DC /* libssl.3.dylib in Frameworks */,
3703206827D2BB45007A0CB8 /* Defaults in Frameworks */,
3703206A27D2BB49007A0CB8 /* Alamofire in Frameworks */,
3788AD3E291D042D00C53C9B /* Cache in Frameworks */,
370F4FD427CC16CB001B35DC /* libfreetype.6.dylib in Frameworks */,
3797104B28D3D18800D5F53C /* SDWebImageSwiftUI in Frameworks */,
370F4FE227CC16CB001B35DC /* libXdmcp.6.dylib in Frameworks */,
@ -1492,6 +1517,7 @@
37FB2849272207F000A57617 /* SDWebImageWebPCoder in Frameworks */,
3736A20D286BB72300C9E5EE /* libavutil.xcframework in Frameworks */,
3736A213286BB72300C9E5EE /* libswresample.xcframework in Frameworks */,
3788AD40291D043200C53C9B /* Cache in Frameworks */,
37FB285427220D8400A57617 /* SDWebImagePINPlugin in Frameworks */,
3732BFD028B83763009F3F4D /* KeychainAccess in Frameworks */,
3772003927E8EEB700CB2475 /* AVFoundation.framework in Frameworks */,
@ -1699,6 +1725,7 @@
37AAF29F26741C97007FC770 /* SubscriptionsView.swift */,
37B17D9F268A1F25006AEE9B /* VideoContextMenuView.swift */,
37E70922271CD43000D34DDE /* WelcomeScreen.swift */,
3763C988290C7A50004D3B5F /* OpenVideosView.swift */,
);
path = Views;
sourceTree = "<group>";
@ -2143,6 +2170,7 @@
3751BA8127E69131007B1A60 /* ReturnYouTubeDislike */,
37FB283F2721B20800A57617 /* Search */,
374C0539272436DA009BDDBE /* SponsorBlock */,
37F5E8B9291BEF69006C15F5 /* CacheModel.swift */,
3776ADD5287381240078EBC4 /* Captions.swift */,
37AAF28F26740715007FC770 /* Channel.swift */,
37C3A24427235DA70087A57A /* ChannelPlaylist.swift */,
@ -2161,6 +2189,7 @@
37EF5C212739D37B00B03725 /* MenuModel.swift */,
371F2F19269B43D300E4A7AB /* NavigationModel.swift */,
3756C2A92861151C00E4B059 /* NetworkStateModel.swift */,
377FF88A291A60310028EB0B /* OpenVideosModel.swift */,
37130A5E277657300033018A /* PersistenceController.swift */,
376578882685471400D4EA09 /* Playlist.swift */,
37BA794226DBA973002A0235 /* PlaylistsModel.swift */,
@ -2178,9 +2207,11 @@
373CFADA269663F1003CB2C6 /* Thumbnail.swift */,
37C0698127260B2100F7F6CB /* ThumbnailsModel.swift */,
3705B181267B4E4900704544 /* TrendingCategory.swift */,
37F5E8B5291BE9D0006C15F5 /* URLBookmarkModel.swift */,
37D4B19626717E1500C925CA /* Video.swift */,
3784CDDE27772EE40055BBF2 /* Watch.swift */,
37130A59277657090033018A /* Yattee.xcdatamodeld */,
376787BB291C4B7B00D356A4 /* VideoCacheModel.swift */,
);
path = Model;
sourceTree = "<group>";
@ -2383,6 +2414,7 @@
3799AC0828B03CED001376F9 /* ActiveLabel */,
375B8AB028B57F4200397B31 /* KeychainAccess */,
3797104828D3D10600D5F53C /* SDWebImageSwiftUI */,
37F5E8B3291BE97A006C15F5 /* Cache */,
);
productName = "Yattee (iOS)";
productReference = 37D4B0C92671614900C925CA /* Yattee.app */;
@ -2420,6 +2452,7 @@
372AA413286D06A10000B1DC /* Repeat */,
375B8AB628B583BD00397B31 /* KeychainAccess */,
3797104A28D3D18800D5F53C /* SDWebImageSwiftUI */,
3788AD3D291D042D00C53C9B /* Cache */,
);
productName = "Yattee (macOS)";
productReference = 37D4B0CF2671614900C925CA /* Yattee.app */;
@ -2497,6 +2530,7 @@
37E80F42287B7AAF00561799 /* SwiftUIPager */,
3732BFCF28B83763009F3F4D /* KeychainAccess */,
3797104C28D3D19100D5F53C /* SDWebImageSwiftUI */,
3788AD3F291D043200C53C9B /* Cache */,
);
productName = Yattee;
productReference = 37D4B158267164AE00C925CA /* Yattee.app */;
@ -2605,6 +2639,7 @@
3799AC0728B03CEC001376F9 /* XCRemoteSwiftPackageReference "ActiveLabel.swift" */,
375B8AAF28B57F4200397B31 /* XCRemoteSwiftPackageReference "KeychainAccess" */,
3797104728D3D10600D5F53C /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */,
37F5E8B2291BE97A006C15F5 /* XCRemoteSwiftPackageReference "Cache" */,
);
productRefGroup = 37D4B0CA2671614900C925CA /* Products */;
projectDirPath = "";
@ -2866,7 +2901,9 @@
3743CA52270F284F00E4D32B /* View+Borders.swift in Sources */,
3763495126DFF59D00B9A393 /* AppSidebarRecents.swift in Sources */,
376CD21626FBE18D001E1AC1 /* Instance+Fixtures.swift in Sources */,
376787BC291C4B7B00D356A4 /* VideoCacheModel.swift in Sources */,
37BA793B26DB8EE4002A0235 /* PlaylistVideosView.swift in Sources */,
377FF88B291A60310028EB0B /* OpenVideosModel.swift in Sources */,
37A5DBC8285E371400CA4DD1 /* ControlBackgroundModifier.swift in Sources */,
377ABC40286E4AD5009C986F /* InstancesManifest.swift in Sources */,
37BD07B52698AA4D003EBB87 /* ContentView.swift in Sources */,
@ -3019,6 +3056,7 @@
375DFB5826F9DA010013F468 /* InstancesModel.swift in Sources */,
3751BA8327E6914F007B1A60 /* ReturnYouTubeDislikeAPI.swift in Sources */,
373031F528383A89000CFD59 /* PiPDelegate.swift in Sources */,
37F5E8BA291BEF69006C15F5 /* CacheModel.swift in Sources */,
37DD9DC62785D63A00539416 /* UIResponder+Extensions.swift in Sources */,
370015A928BBAE7F000149FD /* ProgressBar.swift in Sources */,
37C3A24927235FAA0087A57A /* ChannelPlaylistCell.swift in Sources */,
@ -3031,6 +3069,7 @@
377ABC4C286E6A78009C986F /* LocationsSettings.swift in Sources */,
3744A96028B99ADD005DE0A7 /* PlayerControlsLayout.swift in Sources */,
376BE50B27349108009AD608 /* BrowsingSettings.swift in Sources */,
3763C989290C7A50004D3B5F /* OpenVideosView.swift in Sources */,
37F0F4EE286F734400C06C2E /* AdvancedSettings.swift in Sources */,
37AAF2A026741C97007FC770 /* SubscriptionsView.swift in Sources */,
37EF5C222739D37B00B03725 /* MenuModel.swift in Sources */,
@ -3051,6 +3090,7 @@
375E45F527B1976B00BA7902 /* MPVOGLView.swift in Sources */,
37BE0BCF26A0E2D50092E2DB /* VideoPlayerView.swift in Sources */,
3769C02E2779F18600DDB3EA /* PlaceholderProgressView.swift in Sources */,
37F5E8B6291BE9D0006C15F5 /* URLBookmarkModel.swift in Sources */,
37579D5D27864F5F00FD0B98 /* Help.swift in Sources */,
37030FFB27B0398000ECDDAA /* MPVClient.swift in Sources */,
3756C2AA2861151C00E4B059 /* NetworkStateModel.swift in Sources */,
@ -3132,6 +3172,7 @@
3756C2A72861131100E4B059 /* NetworkState.swift in Sources */,
37D6025A28C17375009E8D98 /* PlaybackStatsView.swift in Sources */,
3729037F2739E47400EA99F6 /* MenuCommands.swift in Sources */,
3763C98A290C7A50004D3B5F /* OpenVideosView.swift in Sources */,
37C0698327260B2100F7F6CB /* ThumbnailsModel.swift in Sources */,
374AB3DC28BCAF7E00DF56FB /* SeekType.swift in Sources */,
376B2E0826F920D600B1D64D /* SignInRequiredView.swift in Sources */,
@ -3145,6 +3186,7 @@
37F49BA426CAA59B00304AC0 /* Playlist+Fixtures.swift in Sources */,
37EAD870267B9ED100D9E01B /* Segment.swift in Sources */,
3788AC2826F6840700F6BAA9 /* FavoriteItemView.swift in Sources */,
377FF88C291A60310028EB0B /* OpenVideosModel.swift in Sources */,
378AE93A274EDFAF006A4EE1 /* Badge+Backport.swift in Sources */,
37599F35272B44000087F250 /* FavoritesModel.swift in Sources */,
376527BC285F60F700102284 /* PlayerTimeModel.swift in Sources */,
@ -3175,6 +3217,7 @@
378E50FC26FE8B9F00F49626 /* Instance.swift in Sources */,
37169AA32729D98A0011DE61 /* InstancesBridge.swift in Sources */,
37B044B826F7AB9000E1419D /* SettingsView.swift in Sources */,
37F5E8BB291BEF69006C15F5 /* CacheModel.swift in Sources */,
3765788A2685471400D4EA09 /* Playlist.swift in Sources */,
37030FFC27B0398000ECDDAA /* MPVClient.swift in Sources */,
3751B4B327836902000B7DF4 /* SearchPage.swift in Sources */,
@ -3183,6 +3226,7 @@
37BF662027308884008CCFB0 /* DropFavoriteOutside.swift in Sources */,
3776ADD7287381240078EBC4 /* Captions.swift in Sources */,
37E70924271CD43000D34DDE /* WelcomeScreen.swift in Sources */,
37F5E8B7291BE9D0006C15F5 /* URLBookmarkModel.swift in Sources */,
374C0543272496E4009BDDBE /* AppDelegate.swift in Sources */,
373CFACC26966264003CB2C6 /* SearchQuery.swift in Sources */,
37AAF29126740715007FC770 /* Channel.swift in Sources */,
@ -3278,6 +3322,7 @@
3743B86927216D3600261544 /* ChannelCell.swift in Sources */,
3748186B26A764FB0084E870 /* Thumbnail+Fixtures.swift in Sources */,
3782B95E2755858100990149 /* NSTextField+FocusRingType.swift in Sources */,
376787BD291C4B7B00D356A4 /* VideoCacheModel.swift in Sources */,
37C3A252272366440087A57A /* ChannelPlaylistView.swift in Sources */,
3754B01628B7F84D009717C8 /* Constants.swift in Sources */,
37270F1D28E06E3E00856150 /* String+Localizable.swift in Sources */,
@ -3373,6 +3418,7 @@
37648B69286CF5F1003D330B /* TVControls.swift in Sources */,
374C053D2724614F009BDDBE /* PlayerTVMenu.swift in Sources */,
37BE0BD426A1D47D0092E2DB /* AppleAVPlayerView.swift in Sources */,
37F5E8BC291BEF69006C15F5 /* CacheModel.swift in Sources */,
37977585268922F600DD52A8 /* InvidiousAPI.swift in Sources */,
3769537928A877C4005D87C3 /* StreamControl.swift in Sources */,
3700155D271B0D4D0049C794 /* PipedAPI.swift in Sources */,
@ -3408,6 +3454,7 @@
37DD87C9271C9CFE0027CBF9 /* PlayerStreams.swift in Sources */,
378AE93E274EDFB4006A4EE1 /* Tint+Backport.swift in Sources */,
37FFC442272734C3009FFD26 /* Throttle.swift in Sources */,
377FF88D291A60310028EB0B /* OpenVideosModel.swift in Sources */,
37F4AD2828613B81004D0F66 /* Color+Debug.swift in Sources */,
37E8B0F227B326F30024006F /* Comparable+Clamped.swift in Sources */,
375168D82700FDB9008F96A6 /* Debounce.swift in Sources */,
@ -3422,6 +3469,7 @@
371B7E5E27596B8400D21217 /* Comment.swift in Sources */,
37732FF22703A26300F04329 /* AccountValidationStatus.swift in Sources */,
3756C2AC2861151C00E4B059 /* NetworkStateModel.swift in Sources */,
37F5E8B8291BE9D0006C15F5 /* URLBookmarkModel.swift in Sources */,
37A5DBCA285E371400CA4DD1 /* ControlBackgroundModifier.swift in Sources */,
37E80F46287B7AEC00561799 /* PlayerQueueView.swift in Sources */,
37C0698427260B2100F7F6CB /* ThumbnailsModel.swift in Sources */,
@ -3445,6 +3493,7 @@
3784CDE427772EE40055BBF2 /* Watch.swift in Sources */,
3730D8A02712E2B70020ED53 /* NowPlayingView.swift in Sources */,
37169AA42729D98A0011DE61 /* InstancesBridge.swift in Sources */,
376787BE291C4B7B00D356A4 /* VideoCacheModel.swift in Sources */,
37D4B18E26717B3800C925CA /* VideoCell.swift in Sources */,
375E45F627B1976B00BA7902 /* MPVOGLView.swift in Sources */,
375EC95B289EEB8200751258 /* QualityProfileForm.swift in Sources */,
@ -3463,6 +3512,7 @@
37E70929271CDDAE00D34DDE /* OpenSettingsButton.swift in Sources */,
370F4FAA27CC163B001B35DC /* PlayerBackend.swift in Sources */,
376A33E62720CB35000C1D6B /* Account.swift in Sources */,
3763C98B290C7A50004D3B5F /* OpenVideosView.swift in Sources */,
375F7412289DC35A00747050 /* PlayerBackendView.swift in Sources */,
37599F3A272B4D740087F250 /* FavoriteButton.swift in Sources */,
37EF5C242739D37B00B03725 /* MenuModel.swift in Sources */,
@ -3942,6 +3992,9 @@
);
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = iOS/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.video";
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
INFOPLIST_KEY_NSCameraUsageDescription = "Need camera access to take pictures";
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@ -3985,6 +4038,9 @@
GCC_PREPROCESSOR_DEFINITIONS = "GLES_SILENCE_DEPRECATION=1";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = iOS/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.video";
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
INFOPLIST_KEY_NSCameraUsageDescription = "Need camera access to take pictures";
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@ -4210,6 +4266,7 @@
INFOPLIST_KEY_CFBundleDisplayName = Yattee;
INFOPLIST_KEY_CFBundleName = "Yattee (Apple TV)";
INFOPLIST_KEY_CFBundleVersion = 1;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.video";
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UIUserInterfaceStyle = Automatic;
LD_RUNPATH_SEARCH_PATHS = (
@ -4247,6 +4304,7 @@
INFOPLIST_KEY_CFBundleDisplayName = Yattee;
INFOPLIST_KEY_CFBundleName = "Yattee (Apple TV)";
INFOPLIST_KEY_CFBundleVersion = 1;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.video";
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UIUserInterfaceStyle = Automatic;
LD_RUNPATH_SEARCH_PATHS = (
@ -4591,6 +4649,14 @@
minimumVersion = 5.1.0;
};
};
37F5E8B2291BE97A006C15F5 /* XCRemoteSwiftPackageReference "Cache" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/hyperoslo/Cache.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 6.0.0;
};
};
37FB2847272207F000A57617 /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/SDWebImage/SDWebImageWebPCoder.git";
@ -4730,6 +4796,16 @@
package = 37B767DE2678C5BF0098BAA8 /* XCRemoteSwiftPackageReference "swift-log" */;
productName = Logging;
};
3788AD3D291D042D00C53C9B /* Cache */ = {
isa = XCSwiftPackageProductDependency;
package = 37F5E8B2291BE97A006C15F5 /* XCRemoteSwiftPackageReference "Cache" */;
productName = Cache;
};
3788AD3F291D043200C53C9B /* Cache */ = {
isa = XCSwiftPackageProductDependency;
package = 37F5E8B2291BE97A006C15F5 /* XCRemoteSwiftPackageReference "Cache" */;
productName = Cache;
};
3797104828D3D10600D5F53C /* SDWebImageSwiftUI */ = {
isa = XCSwiftPackageProductDependency;
package = 3797104728D3D10600D5F53C /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */;
@ -4825,6 +4901,11 @@
package = 37EE6DC328A305AD00BFD632 /* XCRemoteSwiftPackageReference "Reachability" */;
productName = Reachability;
};
37F5E8B3291BE97A006C15F5 /* Cache */ = {
isa = XCSwiftPackageProductDependency;
package = 37F5E8B2291BE97A006C15F5 /* XCRemoteSwiftPackageReference "Cache" */;
productName = Cache;
};
37FB2848272207F000A57617 /* SDWebImageWebPCoder */ = {
isa = XCSwiftPackageProductDependency;
package = 37FB2847272207F000A57617 /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */;

View File

@ -18,6 +18,15 @@
"version" : "5.6.2"
}
},
{
"identity" : "cache",
"kind" : "remoteSourceControl",
"location" : "https://github.com/hyperoslo/Cache.git",
"state" : {
"revision" : "c7f4d633049c3bd649a353bad36f6c17e9df085f",
"version" : "6.0.0"
}
},
{
"identity" : "defaults",
"kind" : "remoteSourceControl",

View File

@ -2,6 +2,39 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>mpeg4Movie</string>
<key>LSHandlerRank</key>
<string>Default</string>
<key>LSItemContentTypes</key>
<array>
<string>public.mpeg-4</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>url</string>
<key>LSHandlerRank</key>
<string>Default</string>
<key>LSItemContentTypes</key>
<array>
<string>public.url</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>fileURL</string>
<key>LSHandlerRank</key>
<string>Default</string>
<key>LSItemContentTypes</key>
<array>
<string>public.file-url</string>
</array>
</dict>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>
@ -15,14 +48,6 @@
</array>
</dict>
</array>
<key>UIApplicationSceneManifest</key>
<dict/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>NSCameraUsageDescription</key>
<string>Need camera access to take pictures</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSAppTransportSecurity</key>
@ -30,5 +55,22 @@
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>UIApplicationSceneManifest</key>
<dict/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UTImportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeDescription</key>
<string></string>
<key>UTTypeIconFiles</key>
<array/>
<key>UTTypeTagSpecification</key>
<dict/>
</dict>
</array>
</dict>
</plist>

View File

@ -2,6 +2,21 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>mpeg4Movie</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Default</string>
<key>LSItemContentTypes</key>
<array>
<string>public.mpeg-4</string>
</array>
</dict>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>