mirror of
https://github.com/yattee/yattee.git
synced 2025-08-06 10:44:06 +00:00
Opening videos by URL and local files
This commit is contained in:
@@ -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)
|
||||
|
@@ -63,4 +63,8 @@ enum VideosApp: String, CaseIterable {
|
||||
var allowsDisablingVidoesProxying: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var supportsOpeningVideosByID: Bool {
|
||||
self != .demoApp
|
||||
}
|
||||
}
|
||||
|
25
Model/CacheModel.swift
Normal file
25
Model/CacheModel.swift
Normal 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)
|
||||
}
|
||||
}
|
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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
109
Model/OpenVideosModel.swift
Normal 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
|
||||
}
|
||||
}
|
@@ -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 ?? "")
|
||||
]
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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)
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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))"
|
||||
}
|
||||
}
|
||||
|
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@@ -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) {
|
||||
|
@@ -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,
|
||||
|
@@ -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 {
|
||||
|
59
Model/URLBookmarkModel.swift
Normal file
59
Model/URLBookmarkModel.swift
Normal 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
|
||||
}
|
||||
}
|
@@ -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)
|
||||
}
|
||||
}
|
||||
|
23
Model/VideoCacheModel.swift
Normal file
23
Model/VideoCacheModel.swift
Normal 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
|
||||
}
|
||||
}
|
@@ -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)
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user