From 402d1a2f79d5b497d1ef4fe26542567199deb430 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Thu, 10 Nov 2022 18:11:28 +0100 Subject: [PATCH] Opening videos by URL and local files --- Fixtures/View+Fixtures.swift | 2 +- Model/Applications/VideosAPI.swift | 6 + Model/Applications/VideosApp.swift | 4 + Model/CacheModel.swift | 25 +++ Model/HistoryModel.swift | 32 +++- Model/NavigationModel.swift | 1 + Model/OpenVideosModel.swift | 109 ++++++++++++ Model/Player/Backends/AVPlayerBackend.swift | 32 +++- Model/Player/Backends/MPVBackend.swift | 59 +++++++ Model/Player/Backends/MPVClient.swift | 48 +++++ Model/Player/Backends/PlayerBackend.swift | 3 + Model/Player/PlayerModel.swift | 17 +- Model/Player/PlayerQueue.swift | 54 +++++- Model/Player/PlayerQueueItem.swift | 3 +- Model/Player/PlayerQueueItemBridge.swift | 26 ++- Model/Stream.swift | 17 +- Model/URLBookmarkModel.swift | 59 +++++++ Model/Video.swift | 71 ++++++++ Model/VideoCacheModel.swift | 23 +++ Model/Watch.swift | 9 +- Shared/Defaults.swift | 1 + Shared/Home/HomeView.swift | 3 +- Shared/MenuCommands.swift | 8 + Shared/Navigation/AppSidebarNavigation.swift | 20 ++- Shared/Navigation/AppTabNavigation.swift | 3 + Shared/Navigation/ContentView.swift | 5 + Shared/OpenURLHandler.swift | 5 + .../Player/Controls/PlaybackStatsView.swift | 2 +- Shared/Player/PlayerQueueRow.swift | 9 + Shared/Player/VideoDetails.swift | 111 ++++++++++-- Shared/Player/VideoPlayerView.swift | 1 - Shared/Videos/VideoBanner.swift | 86 ++++++--- Shared/Views/ControlsBar.swift | 88 +++++---- Shared/Views/OpenVideosView.swift | 167 ++++++++++++++++++ Shared/Views/VideoContextMenuView.swift | 4 +- Shared/Yattee.entitlements | 8 +- Yattee.xcodeproj/project.pbxproj | 81 +++++++++ .../xcshareddata/swiftpm/Package.resolved | 9 + iOS/Info.plist | 58 +++++- macOS/Info.plist | 15 ++ 40 files changed, 1158 insertions(+), 126 deletions(-) create mode 100644 Model/CacheModel.swift create mode 100644 Model/OpenVideosModel.swift create mode 100644 Model/URLBookmarkModel.swift create mode 100644 Model/VideoCacheModel.swift create mode 100644 Shared/Views/OpenVideosView.swift diff --git a/Fixtures/View+Fixtures.swift b/Fixtures/View+Fixtures.swift index 6bc15d0e..bc6b807c 100644 --- a/Fixtures/View+Fixtures.swift +++ b/Fixtures/View+Fixtures.swift @@ -44,7 +44,7 @@ struct FixtureEnvironmentObjectsModifier: ViewModifier { player.currentItem = PlayerQueueItem( Video( - videoID: "", + videoID: "https://a/b/c", title: "Video Name", author: "", length: 0, diff --git a/Model/Applications/VideosAPI.swift b/Model/Applications/VideosAPI.swift index c46bf07d..c7b5932b 100644 --- a/Model/Applications/VideosAPI.swift +++ b/Model/Applications/VideosAPI.swift @@ -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) diff --git a/Model/Applications/VideosApp.swift b/Model/Applications/VideosApp.swift index 0c6d7b75..080490fd 100644 --- a/Model/Applications/VideosApp.swift +++ b/Model/Applications/VideosApp.swift @@ -63,4 +63,8 @@ enum VideosApp: String, CaseIterable { var allowsDisablingVidoesProxying: Bool { self == .invidious } + + var supportsOpeningVideosByID: Bool { + self != .demoApp + } } diff --git a/Model/CacheModel.swift b/Model/CacheModel.swift new file mode 100644 index 00000000..af9f5d99 --- /dev/null +++ b/Model/CacheModel.swift @@ -0,0 +1,25 @@ +import Cache +import Foundation +import SwiftyJSON + +struct CacheModel { + static var shared = CacheModel() + + var urlBookmarksStorage: Storage? + var videoStorage: Storage? + + 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(toData: toData, fromData: fromData) + videoStorage = try? Storage(diskConfig: videoStorageConfig, memoryConfig: videoStorageMemoryConfig, transformer: jsonTransformer) + } +} diff --git a/Model/HistoryModel.swift b/Model/HistoryModel.swift index 9be2a36d..53316bd9 100644 --- a/Model/HistoryModel.swift +++ b/Model/HistoryModel.swift @@ -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) + } } } diff --git a/Model/NavigationModel.swift b/Model/NavigationModel.swift index 613689f1..08213a64 100644 --- a/Model/NavigationModel.swift +++ b/Model/NavigationModel.swift @@ -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 diff --git a/Model/OpenVideosModel.swift b/Model/OpenVideosModel.swift new file mode 100644 index 00000000..04d6eebf --- /dev/null +++ b/Model/OpenVideosModel.swift @@ -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 + } +} diff --git a/Model/Player/Backends/AVPlayerBackend.swift b/Model/Player/Backends/AVPlayerBackend.swift index 280684e2..56f7a26a 100644 --- a/Model/Player/Backends/AVPlayerBackend.swift +++ b/Model/Player/Backends/AVPlayerBackend.swift @@ -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 ?? "") ] diff --git a/Model/Player/Backends/MPVBackend.swift b/Model/Player/Backends/MPVBackend.swift index 659c3674..ab9a6d47 100644 --- a/Model/Player/Backends/MPVBackend.swift +++ b/Model/Player/Backends/MPVBackend.swift @@ -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 } diff --git a/Model/Player/Backends/MPVClient.swift b/Model/Player/Backends/MPVClient.swift index 87036116..182654e6 100644 --- a/Model/Player/Backends/MPVClient.swift +++ b/Model/Player/Backends/MPVClient.swift @@ -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) diff --git a/Model/Player/Backends/PlayerBackend.swift b/Model/Player/Backends/PlayerBackend.swift index 6134c5f4..6cd22927 100644 --- a/Model/Player/Backends/PlayerBackend.swift +++ b/Model/Player/Backends/PlayerBackend.swift @@ -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 diff --git a/Model/Player/PlayerModel.swift b/Model/Player/PlayerModel.swift index 8f7baa56..0abdef50 100644 --- a/Model/Player/PlayerModel.swift +++ b/Model/Player/PlayerModel.swift @@ -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))" + } } diff --git a/Model/Player/PlayerQueue.swift b/Model/Player/PlayerQueue.swift index 133cb723..14d45961 100644 --- a/Model/Player/PlayerQueue.swift +++ b/Model/Player/PlayerQueue.swift @@ -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) } }) } diff --git a/Model/Player/PlayerQueueItem.swift b/Model/Player/PlayerQueueItem.swift index d40a7ad8..c81be6ff 100644 --- a/Model/Player/PlayerQueueItem.swift +++ b/Model/Player/PlayerQueueItem.swift @@ -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) { diff --git a/Model/Player/PlayerQueueItemBridge.swift b/Model/Player/PlayerQueueItemBridge.swift index c66a1b92..d4c76289 100644 --- a/Model/Player/PlayerQueueItemBridge.swift +++ b/Model/Player/PlayerQueueItemBridge.swift @@ -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, diff --git a/Model/Stream.swift b/Model/Stream.swift index 1cc97449..31b90519 100644 --- a/Model/Stream.swift +++ b/Model/Stream.swift @@ -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 { diff --git a/Model/URLBookmarkModel.swift b/Model/URLBookmarkModel.swift new file mode 100644 index 00000000..95b817a8 --- /dev/null +++ b/Model/URLBookmarkModel.swift @@ -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 + } +} diff --git a/Model/Video.swift b/Model/Video.swift index a73568dd..e010a2a3 100644 --- a/Model/Video.swift +++ b/Model/Video.swift @@ -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) + } } diff --git a/Model/VideoCacheModel.swift b/Model/VideoCacheModel.swift new file mode 100644 index 00000000..e1ac70e5 --- /dev/null +++ b/Model/VideoCacheModel.swift @@ -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 + } +} diff --git a/Model/Watch.swift b/Model/Watch.swift index 7bf967a0..dc8a4091 100644 --- a/Model/Watch.swift +++ b/Model/Watch.swift @@ -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) } } diff --git a/Shared/Defaults.swift b/Shared/Defaults.swift index 094892d1..179a0a7f 100644 --- a/Shared/Defaults.swift +++ b/Shared/Defaults.swift @@ -21,6 +21,7 @@ extension Defaults.Keys { static let enableReturnYouTubeDislike = Key("enableReturnYouTubeDislike", default: false) + static let homeHistoryItems = Key("homeHistoryItems", default: 30) static let favorites = Key<[FavoriteItem]>("favorites", default: []) #if !os(tvOS) diff --git a/Shared/Home/HomeView.swift b/Shared/Home/HomeView.swift index 13855271..3179802c 100644 --- a/Shared/Home/HomeView.swift +++ b/Shared/Home/HomeView.swift @@ -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) diff --git a/Shared/MenuCommands.swift b/Shared/MenuCommands.swift index f71f105d..c0f8e85c 100644 --- a/Shared/MenuCommands.swift +++ b/Shared/MenuCommands.swift @@ -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") { diff --git a/Shared/Navigation/AppSidebarNavigation.swift b/Shared/Navigation/AppSidebarNavigation.swift index 03fbb4fb..f25acec6 100644 --- a/Shared/Navigation/AppSidebarNavigation.swift +++ b/Shared/Navigation/AppSidebarNavigation.swift @@ -6,13 +6,13 @@ import SwiftUI struct AppSidebarNavigation: View { @EnvironmentObject private var accounts + @EnvironmentObject private var navigation #if os(iOS) @State private var didApplyPrimaryViewWorkAround = false @EnvironmentObject private var comments @EnvironmentObject private var instances - @EnvironmentObject private var navigation @EnvironmentObject private var player @EnvironmentObject private var playlists @EnvironmentObject 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 diff --git a/Shared/Navigation/AppTabNavigation.swift b/Shared/Navigation/AppTabNavigation.swift index 0d9e083c..ce61c68d 100644 --- a/Shared/Navigation/AppTabNavigation.swift +++ b/Shared/Navigation/AppTabNavigation.swift @@ -139,6 +139,9 @@ struct AppTabNavigation: View { } ToolbarItemGroup(placement: .navigationBarTrailing) { + Button(action: { navigation.presentingOpenVideos = true }) { + Label("Open Videos", systemImage: "play.circle.fill") + } AccountsMenuView() } } diff --git a/Shared/Navigation/ContentView.swift b/Shared/Navigation/ContentView.swift index d164e471..04b79ee1 100644 --- a/Shared/Navigation/ContentView.swift +++ b/Shared/Navigation/ContentView.swift @@ -119,6 +119,11 @@ struct ContentView: View { } ) #endif + .background( + EmptyView().sheet(isPresented: $navigation.presentingOpenVideos) { + OpenVideosView() + } + ) .background(playerViewInitialize) .alert(isPresented: $navigation.presentingAlert) { navigation.alert } } diff --git a/Shared/OpenURLHandler.swift b/Shared/OpenURLHandler.swift index d79e4d3e..45c84f59 100644 --- a/Shared/OpenURLHandler.swift +++ b/Shared/OpenURLHandler.swift @@ -27,6 +27,11 @@ struct OpenURLHandler { } #endif + if url.isFileURL { + OpenVideosModel.shared.open(url) + return + } + let parser = URLParser(url: urlByReplacingYatteeProtocol(url)) switch parser.destination { diff --git a/Shared/Player/Controls/PlaybackStatsView.swift b/Shared/Player/Controls/PlaybackStatsView.swift index ad0c431a..2a988225 100644 --- a/Shared/Player/Controls/PlaybackStatsView.swift +++ b/Shared/Player/Controls/PlaybackStatsView.swift @@ -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) diff --git a/Shared/Player/PlayerQueueRow.swift b/Shared/Player/PlayerQueueRow.swift index 0abf7af9..5363d621 100644 --- a/Shared/Player/PlayerQueueRow.swift +++ b/Shared/Player/PlayerQueueRow.swift @@ -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")!) + )) + } +} diff --git a/Shared/Player/VideoDetails.swift b/Shared/Player/VideoDetails.swift index df790c14..d4c1d969 100644 --- a/Shared/Player/VideoDetails.swift +++ b/Shared/Player/VideoDetails.swift @@ -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 diff --git a/Shared/Player/VideoPlayerView.swift b/Shared/Player/VideoPlayerView.swift index 5929be67..19a60aab 100644 --- a/Shared/Player/VideoPlayerView.swift +++ b/Shared/Player/VideoPlayerView.swift @@ -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) diff --git a/Shared/Videos/VideoBanner.swift b/Shared/Videos/VideoBanner.swift index 8b91292f..b34e1e35 100644 --- a/Shared/Videos/VideoBanner.swift +++ b/Shared/Videos/VideoBanner.swift @@ -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) } diff --git a/Shared/Views/ControlsBar.swift b/Shared/Views/ControlsBar.swift index faf24ccc..25f6bfa5 100644 --- a/Shared/Views/ControlsBar.swift +++ b/Shared/Views/ControlsBar.swift @@ -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()) } } } diff --git a/Shared/Views/OpenVideosView.swift b/Shared/Views/OpenVideosView.swift new file mode 100644 index 00000000..103c55d9 --- /dev/null +++ b/Shared/Views/OpenVideosView.swift @@ -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 private var accounts + @EnvironmentObject private var navigation + @EnvironmentObject private var player + @EnvironmentObject private var recents + @EnvironmentObject 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 + } +} diff --git a/Shared/Views/VideoContextMenuView.swift b/Shared/Views/VideoContextMenuView.swift index 23812292..0a8bd203 100644 --- a/Shared/Views/VideoContextMenuView.swift +++ b/Shared/Views/VideoContextMenuView.swift @@ -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 diff --git a/Shared/Yattee.entitlements b/Shared/Yattee.entitlements index d858aaec..0924bde0 100644 --- a/Shared/Yattee.entitlements +++ b/Shared/Yattee.entitlements @@ -4,12 +4,16 @@ com.apple.security.app-sandbox + com.apple.security.files.user-selected.read-only + + com.apple.security.files.bookmarks.app-scope + + com.apple.security.network.client + com.apple.security.temporary-exception.mach-lookup.global-name $(PRODUCT_BUNDLE_IDENTIFIER)-spki $(PRODUCT_BUNDLE_IDENTIFIER)-spks - com.apple.security.network.client - diff --git a/Yattee.xcodeproj/project.pbxproj b/Yattee.xcodeproj/project.pbxproj index ed51d128..33a5b996 100644 --- a/Yattee.xcodeproj/project.pbxproj +++ b/Yattee.xcodeproj/project.pbxproj @@ -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 = ""; }; 3761ABFC26F0F8DE00AA496F /* EnvironmentValues.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EnvironmentValues.swift; sourceTree = ""; }; 3763495026DFF59D00B9A393 /* AppSidebarRecents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSidebarRecents.swift; sourceTree = ""; }; + 3763C988290C7A50004D3B5F /* OpenVideosView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenVideosView.swift; sourceTree = ""; }; 37648B68286CF5F1003D330B /* TVControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVControls.swift; sourceTree = ""; }; 376527BA285F60F700102284 /* PlayerTimeModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerTimeModel.swift; sourceTree = ""; }; 376578842685429C00D4EA09 /* CaseIterable+Next.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CaseIterable+Next.swift"; sourceTree = ""; }; @@ -1147,6 +1166,7 @@ 37658ED428E1C567004BF6A2 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; 37666BA927023AF000F869E5 /* AccountSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSelectionView.swift; sourceTree = ""; }; 376787BA291C43CD00D356A4 /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; + 376787BB291C4B7B00D356A4 /* VideoCacheModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoCacheModel.swift; sourceTree = ""; }; 3768122C28E8D0BC0036FC8D /* hi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hi; path = hi.lproj/Localizable.strings; sourceTree = ""; }; 3769C02D2779F18600DDB3EA /* PlaceholderProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderProgressView.swift; sourceTree = ""; }; 376A33DF2720CAD6000C1D6B /* VideosApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideosApp.swift; sourceTree = ""; }; @@ -1176,6 +1196,7 @@ 377ABC43286E4B74009C986F /* ManifestedInstance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManifestedInstance.swift; sourceTree = ""; }; 377ABC47286E5887009C986F /* Sequence+Unique.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sequence+Unique.swift"; sourceTree = ""; }; 377ABC4B286E6A78009C986F /* LocationsSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationsSettings.swift; sourceTree = ""; }; + 377FF88A291A60310028EB0B /* OpenVideosModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenVideosModel.swift; sourceTree = ""; }; 377FF88E291A99580028EB0B /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; 3782B94E27553A6700990149 /* SearchSuggestions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchSuggestions.swift; sourceTree = ""; }; 3782B9512755667600990149 /* String+Format.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Format.swift"; sourceTree = ""; }; @@ -1326,6 +1347,8 @@ 37F4AD1E28612DFD004D0F66 /* Buffering.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Buffering.swift; sourceTree = ""; }; 37F4AD2528613B81004D0F66 /* Color+Debug.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Debug.swift"; sourceTree = ""; }; 37F4AE7126828F0900BD60EA /* VerticalCells.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerticalCells.swift; sourceTree = ""; }; + 37F5E8B5291BE9D0006C15F5 /* URLBookmarkModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLBookmarkModel.swift; sourceTree = ""; }; + 37F5E8B9291BEF69006C15F5 /* CacheModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheModel.swift; sourceTree = ""; }; 37F64FE326FE70A60081B69E /* RedrawOnModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedrawOnModifier.swift; sourceTree = ""; }; 37F7AB4C28A9361F00FB46B5 /* UIDevice+Cellular.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIDevice+Cellular.swift"; sourceTree = ""; }; 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 = ""; @@ -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 = ""; @@ -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" */; diff --git a/Yattee.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Yattee.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d9088fb7..0c51f5b2 100644 --- a/Yattee.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Yattee.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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", diff --git a/iOS/Info.plist b/iOS/Info.plist index a2b6c39d..7f4468ae 100644 --- a/iOS/Info.plist +++ b/iOS/Info.plist @@ -2,6 +2,39 @@ + CFBundleDocumentTypes + + + CFBundleTypeName + mpeg4Movie + LSHandlerRank + Default + LSItemContentTypes + + public.mpeg-4 + + + + CFBundleTypeName + url + LSHandlerRank + Default + LSItemContentTypes + + public.url + + + + CFBundleTypeName + fileURL + LSHandlerRank + Default + LSItemContentTypes + + public.file-url + + + CFBundleURLTypes @@ -15,14 +48,6 @@ - UIApplicationSceneManifest - - UIBackgroundModes - - audio - - NSCameraUsageDescription - Need camera access to take pictures ITSAppUsesNonExemptEncryption NSAppTransportSecurity @@ -30,5 +55,22 @@ NSAllowsArbitraryLoads + UIApplicationSceneManifest + + UIBackgroundModes + + audio + + UTImportedTypeDeclarations + + + UTTypeDescription + + UTTypeIconFiles + + UTTypeTagSpecification + + + diff --git a/macOS/Info.plist b/macOS/Info.plist index 6d3cf46c..e040d760 100644 --- a/macOS/Info.plist +++ b/macOS/Info.plist @@ -2,6 +2,21 @@ + CFBundleDocumentTypes + + + CFBundleTypeName + mpeg4Movie + CFBundleTypeRole + Editor + LSHandlerRank + Default + LSItemContentTypes + + public.mpeg-4 + + + CFBundleURLTypes