mirror of
https://github.com/yattee/yattee.git
synced 2024-12-22 13:33:42 +00:00
Improve history, resume videos, mark watched videos (fixes #42)
This commit is contained in:
parent
adcebb77a5
commit
ac1c6685a1
@ -32,7 +32,6 @@ struct FixtureEnvironmentObjectsModifier: ViewModifier {
|
||||
|
||||
player.currentItem = PlayerQueueItem(Video.fixture)
|
||||
player.queue = Video.allFixtures.map { PlayerQueueItem($0) }
|
||||
player.history = player.queue
|
||||
|
||||
return player
|
||||
}
|
||||
|
73
Model/HistoryModel.swift
Normal file
73
Model/HistoryModel.swift
Normal file
@ -0,0 +1,73 @@
|
||||
import CoreData
|
||||
import CoreMedia
|
||||
import Foundation
|
||||
|
||||
extension PlayerModel {
|
||||
func historyVideo(_ id: String) -> Video? {
|
||||
historyVideos.first { $0.videoID == id }
|
||||
}
|
||||
|
||||
func loadHistoryVideoDetails(_ id: Video.ID) {
|
||||
guard historyVideo(id).isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
accounts.api.video(id).load().onSuccess { [weak self] response in
|
||||
guard let video: Video = response.typedContent() else {
|
||||
return
|
||||
}
|
||||
|
||||
self?.historyVideos.append(video)
|
||||
}
|
||||
}
|
||||
|
||||
func updateWatch(finished: Bool = false) {
|
||||
guard let id = currentVideo?.videoID else {
|
||||
return
|
||||
}
|
||||
|
||||
let time = player.currentTime().seconds
|
||||
|
||||
let watch: Watch!
|
||||
let watchFetchRequest = Watch.fetchRequest()
|
||||
watchFetchRequest.predicate = NSPredicate(format: "videoID = %@", id as String)
|
||||
|
||||
let results = try? context.fetch(watchFetchRequest)
|
||||
|
||||
if results?.isEmpty ?? true {
|
||||
if time < 1 {
|
||||
return
|
||||
}
|
||||
watch = Watch(context: context)
|
||||
watch.videoID = id
|
||||
} else {
|
||||
watch = results?.first
|
||||
}
|
||||
|
||||
if let seconds = playerItemDuration?.seconds {
|
||||
watch.videoDuration = seconds
|
||||
}
|
||||
|
||||
if finished {
|
||||
watch.stoppedAt = watch.videoDuration
|
||||
} else if time.isFinite, time > 0 {
|
||||
watch.stoppedAt = time
|
||||
}
|
||||
|
||||
watch.watchedAt = Date()
|
||||
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
func removeWatch(_ watch: Watch) {
|
||||
context.delete(watch)
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
func removeAllWatches() {
|
||||
let watchesFetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "Watch")
|
||||
let deleteRequest = NSBatchDeleteRequest(fetchRequest: watchesFetchRequest)
|
||||
_ = try? context.execute(deleteRequest)
|
||||
_ = try? context.save()
|
||||
}
|
||||
}
|
47
Model/PersistenceController.swift
Normal file
47
Model/PersistenceController.swift
Normal file
@ -0,0 +1,47 @@
|
||||
import CoreData
|
||||
|
||||
struct PersistenceController {
|
||||
static let shared = PersistenceController()
|
||||
|
||||
static var preview: PersistenceController = {
|
||||
let result = PersistenceController(inMemory: true)
|
||||
let viewContext = result.container.viewContext
|
||||
|
||||
do {
|
||||
try viewContext.save()
|
||||
} catch {
|
||||
// Replace this implementation with code to handle the error appropriately.
|
||||
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
|
||||
let nsError = error as NSError
|
||||
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
|
||||
}
|
||||
return result
|
||||
}()
|
||||
|
||||
let container: NSPersistentContainer
|
||||
|
||||
init(inMemory: Bool = false) {
|
||||
container = NSPersistentContainer(name: "Yattee")
|
||||
if inMemory {
|
||||
container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
|
||||
}
|
||||
container.viewContext.automaticallyMergesChangesFromParent = true
|
||||
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
||||
container.loadPersistentStores { _, error in
|
||||
if let error = error as NSError? {
|
||||
// Replace this implementation with code to handle the error appropriately.
|
||||
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
|
||||
|
||||
/*
|
||||
Typical reasons for an error here include:
|
||||
* The parent directory does not exist, cannot be created, or disallows writing.
|
||||
* The persistent store is not accessible, due to permissions or data protection when the device is locked.
|
||||
* The device is out of space.
|
||||
* The store could not be migrated to the current model version.
|
||||
Check the error message to determine what the actual problem was.
|
||||
*/
|
||||
fatalError("Unresolved error \(error), \(error.userInfo)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import AVKit
|
||||
import CoreData
|
||||
import Defaults
|
||||
import Foundation
|
||||
import Logging
|
||||
@ -27,8 +28,8 @@ final class PlayerModel: ObservableObject {
|
||||
@Published var streamSelection: Stream? { didSet { rebuildTVMenu() } }
|
||||
|
||||
@Published var queue = [PlayerQueueItem]() { didSet { Defaults[.queue] = queue } }
|
||||
@Published var currentItem: PlayerQueueItem! { didSet { Defaults[.lastPlayed] = currentItem } }
|
||||
@Published var history = [PlayerQueueItem]() { didSet { Defaults[.history] = history } }
|
||||
@Published var currentItem: PlayerQueueItem!
|
||||
@Published var historyVideos = [Video]()
|
||||
|
||||
@Published var preservedTime: CMTime?
|
||||
|
||||
@ -47,6 +48,8 @@ final class PlayerModel: ObservableObject {
|
||||
var composition = AVMutableComposition()
|
||||
var loadedCompositionAssets = [AVMediaType]()
|
||||
|
||||
var context: NSManagedObjectContext = PersistenceController.shared.container.viewContext
|
||||
|
||||
private var currentArtwork: MPMediaItemArtwork?
|
||||
private var frequentTimeObserver: Any?
|
||||
private var infrequentTimeObserver: Any?
|
||||
@ -131,7 +134,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
var live: Bool {
|
||||
currentItem?.video?.live ?? false
|
||||
currentVideo?.live ?? false
|
||||
}
|
||||
|
||||
var playerItemDuration: CMTime? {
|
||||
@ -162,9 +165,17 @@ final class PlayerModel: ObservableObject {
|
||||
player.pause()
|
||||
}
|
||||
|
||||
func upgradeToStream(_ stream: Stream) {
|
||||
if !self.stream.isNil, self.stream != stream {
|
||||
playStream(stream, of: currentVideo!, preservingTime: true, upgrading: true)
|
||||
func play(_ video: Video, at time: TimeInterval? = nil, inNavigationView: Bool = false) {
|
||||
playNow(video, at: time)
|
||||
|
||||
guard !playingInPictureInPicture else {
|
||||
return
|
||||
}
|
||||
|
||||
if inNavigationView {
|
||||
playerNavigationLinkActive = true
|
||||
} else {
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
@ -205,6 +216,12 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func upgradeToStream(_ stream: Stream) {
|
||||
if !self.stream.isNil, self.stream != stream {
|
||||
playStream(stream, of: currentVideo!, preservingTime: true, upgrading: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleAvailableStreamsChange() {
|
||||
rebuildTVMenu()
|
||||
|
||||
@ -469,18 +486,20 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
@objc func itemDidPlayToEndTime() {
|
||||
currentItem.playbackTime = playerItemDuration
|
||||
prepareCurrentItemForHistory(finished: true)
|
||||
|
||||
if queue.isEmpty {
|
||||
#if !os(macOS)
|
||||
try? AVAudioSession.sharedInstance().setActive(false)
|
||||
#endif
|
||||
addCurrentItemToHistory()
|
||||
resetQueue()
|
||||
#if os(tvOS)
|
||||
controller?.dismiss(animated: true)
|
||||
#endif
|
||||
controller?.playerView.dismiss(animated: false) { [weak self] in
|
||||
self?.controller?.dismiss(animated: true)
|
||||
}
|
||||
#else
|
||||
hide()
|
||||
#endif
|
||||
} else {
|
||||
advanceToNextItem()
|
||||
}
|
||||
@ -551,7 +570,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
self.timeObserverThrottle.execute {
|
||||
self.updateCurrentItemIntervals()
|
||||
self.updateWatch()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -581,26 +600,15 @@ final class PlayerModel: ObservableObject {
|
||||
#endif
|
||||
|
||||
self.timeObserverThrottle.execute {
|
||||
self.updateCurrentItemIntervals()
|
||||
self.updateWatch()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateCurrentItemIntervals() {
|
||||
currentItem?.playbackTime = player.currentTime()
|
||||
currentItem?.videoDuration = player.currentItem?.asset.duration.seconds
|
||||
}
|
||||
|
||||
fileprivate func updateNowPlayingInfo() {
|
||||
var duration: Int?
|
||||
if !currentItem.video.live {
|
||||
let itemDuration = currentItem.videoDuration ?? 0
|
||||
duration = itemDuration.isFinite ? Int(itemDuration) : nil
|
||||
}
|
||||
var nowPlayingInfo: [String: AnyObject] = [
|
||||
MPMediaItemPropertyTitle: currentItem.video.title as AnyObject,
|
||||
MPMediaItemPropertyArtist: currentItem.video.author as AnyObject,
|
||||
MPMediaItemPropertyPlaybackDuration: duration as AnyObject,
|
||||
MPNowPlayingInfoPropertyIsLiveStream: currentItem.video.live as AnyObject,
|
||||
MPNowPlayingInfoPropertyElapsedPlaybackTime: player.currentTime().seconds as AnyObject,
|
||||
MPNowPlayingInfoPropertyPlaybackQueueCount: queue.count as AnyObject,
|
||||
@ -611,6 +619,15 @@ final class PlayerModel: ObservableObject {
|
||||
nowPlayingInfo[MPMediaItemPropertyArtwork] = currentArtwork as AnyObject
|
||||
}
|
||||
|
||||
if !currentItem.video.live {
|
||||
let itemDuration = currentItem.videoDuration ?? 0
|
||||
let duration = itemDuration.isFinite ? Int(itemDuration) : nil
|
||||
|
||||
if !duration.isNil {
|
||||
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration as AnyObject
|
||||
}
|
||||
}
|
||||
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
||||
}
|
||||
|
||||
@ -647,7 +664,7 @@ final class PlayerModel: ObservableObject {
|
||||
if let channel: Channel = response.typedContent() {
|
||||
self?.channelWithDetails = channel
|
||||
withAnimation {
|
||||
self?.currentItem.video.channel = channel
|
||||
self?.currentItem?.video.channel = channel
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -671,7 +688,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
func closeCurrentItem() {
|
||||
addCurrentItemToHistory()
|
||||
prepareCurrentItemForHistory()
|
||||
currentItem = nil
|
||||
player.replaceCurrentItem(with: nil)
|
||||
}
|
||||
|
@ -29,11 +29,11 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
func playNow(_ video: Video, at time: TimeInterval? = nil) {
|
||||
if !playingInPictureInPicture || closePiPOnNavigation {
|
||||
if playingInPictureInPicture, closePiPOnNavigation {
|
||||
closePiP()
|
||||
}
|
||||
|
||||
addCurrentItemToHistory()
|
||||
prepareCurrentItemForHistory()
|
||||
|
||||
enqueueVideo(video, prepending: true) { _, item in
|
||||
self.advanceToItem(item, at: time)
|
||||
@ -83,7 +83,7 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
func advanceToNextItem() {
|
||||
addCurrentItemToHistory()
|
||||
prepareCurrentItemForHistory()
|
||||
|
||||
if let nextItem = queue.first {
|
||||
advanceToItem(nextItem)
|
||||
@ -91,7 +91,7 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
func advanceToItem(_ newItem: PlayerQueueItem, at time: TimeInterval? = nil) {
|
||||
addCurrentItemToHistory()
|
||||
prepareCurrentItemForHistory()
|
||||
|
||||
remove(newItem)
|
||||
|
||||
@ -148,18 +148,13 @@ extension PlayerModel {
|
||||
return item
|
||||
}
|
||||
|
||||
func addCurrentItemToHistory() {
|
||||
if let item = currentItem, Defaults[.saveHistory] {
|
||||
addItemToHistory(item)
|
||||
func prepareCurrentItemForHistory(finished: Bool = false) {
|
||||
if !currentItem.isNil, Defaults[.saveHistory] {
|
||||
if let video = currentVideo, !historyVideos.contains(where: { $0 == video }) {
|
||||
historyVideos.append(video)
|
||||
}
|
||||
updateWatch(finished: finished)
|
||||
}
|
||||
|
||||
func addItemToHistory(_ item: PlayerQueueItem) {
|
||||
if let index = history.firstIndex(where: { $0.video?.videoID == item.video?.videoID }) {
|
||||
history.remove(at: index)
|
||||
}
|
||||
|
||||
history.insert(currentItem, at: 0)
|
||||
}
|
||||
|
||||
func playHistory(_ item: PlayerQueueItem) {
|
||||
@ -172,67 +167,9 @@ extension PlayerModel {
|
||||
let newItem = enqueueVideo(item.video, atTime: time, prepending: true)
|
||||
|
||||
advanceToItem(newItem!)
|
||||
|
||||
if let historyItemIndex = history.firstIndex(of: item) {
|
||||
history.remove(at: historyItemIndex)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult func removeHistory(_ item: PlayerQueueItem) -> PlayerQueueItem? {
|
||||
if let index = history.firstIndex(where: { $0 == item }) {
|
||||
return history.remove(at: index)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeQueueItems() {
|
||||
queue.removeAll()
|
||||
}
|
||||
|
||||
func removeHistoryItems() {
|
||||
history.removeAll()
|
||||
}
|
||||
|
||||
func loadHistoryDetails() {
|
||||
guard !accounts.current.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
queue = Defaults[.queue]
|
||||
queue.forEach { item in
|
||||
accounts.api.loadDetails(item) { newItem in
|
||||
if let index = self.queue.firstIndex(where: { $0.id == item.id }) {
|
||||
self.queue[index] = newItem
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var savedHistory = Defaults[.history]
|
||||
|
||||
if let lastPlayed = Defaults[.lastPlayed] {
|
||||
if let index = savedHistory.firstIndex(where: { $0.videoID == lastPlayed.videoID }) {
|
||||
var updatedLastPlayed = savedHistory[index]
|
||||
|
||||
updatedLastPlayed.playbackTime = lastPlayed.playbackTime
|
||||
updatedLastPlayed.videoDuration = lastPlayed.videoDuration
|
||||
|
||||
savedHistory.remove(at: index)
|
||||
savedHistory.insert(updatedLastPlayed, at: 0)
|
||||
} else {
|
||||
savedHistory.insert(lastPlayed, at: 0)
|
||||
}
|
||||
|
||||
Defaults[.lastPlayed] = nil
|
||||
}
|
||||
|
||||
history = savedHistory
|
||||
history.forEach { item in
|
||||
accounts.api.loadDetails(item) { newItem in
|
||||
if let index = self.history.firstIndex(where: { $0.id == item.id }) {
|
||||
self.history[index] = newItem
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -11,6 +11,15 @@ struct PlayerQueueItem: Hashable, Identifiable, Defaults.Serializable {
|
||||
var playbackTime: CMTime?
|
||||
var videoDuration: TimeInterval?
|
||||
|
||||
static func from(_ watch: Watch, video: Video? = nil) -> Self {
|
||||
.init(
|
||||
video,
|
||||
videoID: watch.videoID,
|
||||
playbackTime: CMTime.secondsInDefaultTimescale(watch.stoppedAt),
|
||||
videoDuration: watch.videoDuration
|
||||
)
|
||||
}
|
||||
|
||||
init(_ video: Video? = nil, videoID: Video.ID? = nil, playbackTime: CMTime? = nil, videoDuration: TimeInterval? = nil) {
|
||||
self.video = video
|
||||
self.videoID = videoID ?? video!.videoID
|
||||
|
@ -1,6 +1,7 @@
|
||||
import Alamofire
|
||||
import AVKit
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import SwiftyJSON
|
||||
|
||||
struct Video: Identifiable, Equatable, Hashable {
|
||||
@ -105,10 +106,24 @@ struct Video: Identifiable, Equatable, Hashable {
|
||||
}
|
||||
|
||||
static func == (lhs: Video, rhs: Video) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
let videoIDIsEqual = lhs.videoID == rhs.videoID
|
||||
|
||||
if !lhs.indexID.isNil, !rhs.indexID.isNil {
|
||||
return videoIDIsEqual && lhs.indexID == rhs.indexID
|
||||
}
|
||||
|
||||
return videoIDIsEqual
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
|
||||
var watchFetchRequest: FetchRequest<Watch> {
|
||||
FetchRequest<Watch>(
|
||||
entity: Watch.entity(),
|
||||
sortDescriptors: [],
|
||||
predicate: NSPredicate(format: "videoID = %@", videoID)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
47
Model/Watch.swift
Normal file
47
Model/Watch.swift
Normal file
@ -0,0 +1,47 @@
|
||||
import CoreData
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
@objc(Watch)
|
||||
final class Watch: NSManagedObject, Identifiable {
|
||||
@Default(.watchedThreshold) private var watchedThreshold
|
||||
}
|
||||
|
||||
extension Watch {
|
||||
@nonobjc class func fetchRequest() -> NSFetchRequest<Watch> {
|
||||
NSFetchRequest<Watch>(entityName: "Watch")
|
||||
}
|
||||
|
||||
@NSManaged var videoID: String
|
||||
@NSManaged var videoDuration: Double
|
||||
|
||||
@NSManaged var watchedAt: Date?
|
||||
@NSManaged var stoppedAt: Double
|
||||
|
||||
var progress: Double {
|
||||
guard videoDuration.isFinite, !videoDuration.isZero else {
|
||||
return 0
|
||||
}
|
||||
|
||||
let progress = (stoppedAt / videoDuration) * 100
|
||||
return min(max(progress, 0), 100)
|
||||
}
|
||||
|
||||
var finished: Bool {
|
||||
progress >= Double(watchedThreshold)
|
||||
}
|
||||
|
||||
var watchedAtString: String? {
|
||||
guard let watchedAt = watchedAt else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if watchedAt.timeIntervalSinceNow < 5 {
|
||||
return "just now"
|
||||
}
|
||||
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .full
|
||||
return formatter.localizedString(for: watchedAt, relativeTo: Date())
|
||||
}
|
||||
}
|
17
Model/Yattee.xcdatamodeld/Yattee.xcdatamodel/contents
Normal file
17
Model/Yattee.xcdatamodeld/Yattee.xcdatamodel/contents
Normal file
@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="19574" systemVersion="21D5025f" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="1">
|
||||
<entity name="Watch" representedClassName="Watch" syncable="YES">
|
||||
<attribute name="stoppedAt" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||
<attribute name="videoDuration" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||
<attribute name="videoID" attributeType="String"/>
|
||||
<attribute name="watchedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="videoID"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<elements>
|
||||
<element name="Watch" positionX="-63" positionY="-18" width="128" height="89"/>
|
||||
</elements>
|
||||
</model>
|
@ -5,9 +5,9 @@
|
||||
"color-space" : "display-p3",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.781",
|
||||
"green" : "0.781",
|
||||
"red" : "0.781"
|
||||
"blue" : "0.757",
|
||||
"green" : "0.761",
|
||||
"red" : "0.757"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
@ -23,9 +23,9 @@
|
||||
"color-space" : "display-p3",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.311",
|
||||
"green" : "0.311",
|
||||
"red" : "0.311"
|
||||
"blue" : "0.259",
|
||||
"green" : "0.259",
|
||||
"red" : "0.259"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
|
@ -1,36 +0,0 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "extended-gray",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"white" : "0.724"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "display-p3",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.328",
|
||||
"green" : "0.328",
|
||||
"red" : "0.325"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
@ -1,38 +0,0 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.537",
|
||||
"green" : "0.522",
|
||||
"red" : "1.000"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.537",
|
||||
"green" : "0.522",
|
||||
"red" : "1.000"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
@ -1,38 +0,0 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.824",
|
||||
"green" : "0.659",
|
||||
"red" : "0.455"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.824",
|
||||
"green" : "0.659",
|
||||
"red" : "0.455"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
@ -1,38 +0,0 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.329",
|
||||
"green" : "0.224",
|
||||
"red" : "0.043"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.329",
|
||||
"green" : "0.224",
|
||||
"red" : "0.043"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
@ -5,9 +5,9 @@
|
||||
"color-space" : "display-p3",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.638",
|
||||
"green" : "0.638",
|
||||
"red" : "0.638"
|
||||
"blue" : "0.263",
|
||||
"green" : "0.290",
|
||||
"red" : "0.859"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
@ -23,9 +23,9 @@
|
||||
"color-space" : "display-p3",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.256",
|
||||
"green" : "0.256",
|
||||
"red" : "0.253"
|
||||
"blue" : "0.263",
|
||||
"green" : "0.290",
|
||||
"red" : "0.859"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
@ -54,10 +54,12 @@ extension Defaults.Keys {
|
||||
static let recentlyOpened = Key<[RecentItem]>("recentlyOpened", default: [])
|
||||
|
||||
static let queue = Key<[PlayerQueueItem]>("queue", default: [])
|
||||
static let history = Key<[PlayerQueueItem]>("history", default: [])
|
||||
static let lastPlayed = Key<PlayerQueueItem?>("lastPlayed")
|
||||
|
||||
static let saveHistory = Key<Bool>("saveHistory", default: true)
|
||||
static let showWatchingProgress = Key<Bool>("showWatchingProgress", default: true)
|
||||
static let watchedThreshold = Key<Int>("watchedThreshold", default: 90)
|
||||
static let watchedVideoStyle = Key<WatchedVideoStyle>("watchedVideoStyle", default: .badge)
|
||||
static let watchedVideoPlayNowBehavior = Key<WatchedVideoPlayNowBehavior>("watchedVideoPlayNowBehavior", default: .continue)
|
||||
static let saveRecents = Key<Bool>("saveRecents", default: true)
|
||||
|
||||
static let trendingCategory = Key<TrendingCategory>("trendingCategory", default: .default)
|
||||
@ -146,6 +148,14 @@ enum VisibleSection: String, CaseIterable, Comparable, Defaults.Serializable {
|
||||
}
|
||||
}
|
||||
|
||||
enum WatchedVideoStyle: String, Defaults.Serializable {
|
||||
case nothing, badge, decreasedOpacity
|
||||
}
|
||||
|
||||
enum WatchedVideoPlayNowBehavior: String, Defaults.Serializable {
|
||||
case `continue`, restart
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
enum CommentsPlacement: String, CaseIterable, Defaults.Serializable {
|
||||
case info, separate
|
||||
|
@ -84,6 +84,7 @@ struct ContentView: View {
|
||||
SettingsView()
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(instances)
|
||||
.environmentObject(player)
|
||||
}
|
||||
)
|
||||
#endif
|
||||
@ -132,10 +133,6 @@ struct ContentView: View {
|
||||
player.accounts = accounts
|
||||
player.comments = comments
|
||||
|
||||
if !accounts.current.isNil {
|
||||
player.loadHistoryDetails()
|
||||
}
|
||||
|
||||
if !Defaults[.saveRecents] {
|
||||
recents.clear()
|
||||
}
|
||||
|
@ -80,7 +80,7 @@ struct PlaybackBar: View {
|
||||
|
||||
private var closeButton: some View {
|
||||
Button {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
player.hide()
|
||||
} label: {
|
||||
Label(
|
||||
"Close",
|
||||
|
@ -14,7 +14,7 @@ struct PlayerQueueRow: View {
|
||||
var body: some View {
|
||||
Group {
|
||||
Button {
|
||||
player.addCurrentItemToHistory()
|
||||
player.prepareCurrentItemForHistory()
|
||||
|
||||
if history {
|
||||
player.playHistory(item)
|
||||
|
@ -6,6 +6,10 @@ struct PlayerQueueView: View {
|
||||
@Binding var sidebarQueue: Bool
|
||||
@Binding var fullScreen: Bool
|
||||
|
||||
@FetchRequest(sortDescriptors: [.init(key: "watchedAt", ascending: false)])
|
||||
var watches: FetchedResults<Watch>
|
||||
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
|
||||
@Default(.saveHistory) private var saveHistory
|
||||
@ -46,22 +50,32 @@ struct PlayerQueueView: View {
|
||||
ForEach(player.queue) { item in
|
||||
PlayerQueueRow(item: item, fullScreen: $fullScreen)
|
||||
.contextMenu {
|
||||
removeButton(item, history: false)
|
||||
removeAllButton(history: false)
|
||||
removeButton(item)
|
||||
removeAllButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var visibleWatches: [Watch] {
|
||||
watches.filter { $0.videoID != player.currentVideo?.videoID }
|
||||
}
|
||||
|
||||
var playedPreviously: some View {
|
||||
Group {
|
||||
if !player.history.isEmpty {
|
||||
if !visibleWatches.isEmpty {
|
||||
Section(header: Text("Played Previously")) {
|
||||
ForEach(player.history) { item in
|
||||
PlayerQueueRow(item: item, history: true, fullScreen: $fullScreen)
|
||||
ForEach(visibleWatches, id: \.videoID) { watch in
|
||||
PlayerQueueRow(
|
||||
item: PlayerQueueItem.from(watch, video: player.historyVideo(watch.videoID)),
|
||||
history: true,
|
||||
fullScreen: $fullScreen
|
||||
)
|
||||
.onAppear {
|
||||
player.loadHistoryVideoDetails(watch.videoID)
|
||||
}
|
||||
.contextMenu {
|
||||
removeButton(item, history: true)
|
||||
removeAllButton(history: true)
|
||||
removeHistoryButton(watch)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -94,28 +108,28 @@ struct PlayerQueueView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func removeButton(_ item: PlayerQueueItem, history: Bool) -> some View {
|
||||
private func removeButton(_ item: PlayerQueueItem) -> some View {
|
||||
Button {
|
||||
removeButtonAction(item, history: history)
|
||||
player.remove(item)
|
||||
} label: {
|
||||
Label("Remove", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
|
||||
private func removeButtonAction(_ item: PlayerQueueItem, history: Bool) {
|
||||
_ = history ? player.removeHistory(item) : player.remove(item)
|
||||
}
|
||||
|
||||
private func removeAllButton(history: Bool) -> some View {
|
||||
private func removeAllButton() -> some View {
|
||||
Button {
|
||||
removeAllButtonAction(history: history)
|
||||
player.removeQueueItems()
|
||||
} label: {
|
||||
Label("Remove All", systemImage: "trash.fill")
|
||||
}
|
||||
}
|
||||
|
||||
private func removeAllButtonAction(history: Bool) {
|
||||
_ = history ? player.removeHistoryItems() : player.removeQueueItems()
|
||||
private func removeHistoryButton(_ watch: Watch) -> some View {
|
||||
Button {
|
||||
player.removeWatch(watch)
|
||||
} label: {
|
||||
Label("Remove", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -41,7 +41,7 @@ struct VideoDetails: View {
|
||||
}
|
||||
|
||||
var video: Video? {
|
||||
player.currentItem?.video
|
||||
player.currentVideo
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@ -423,7 +423,7 @@ struct VideoDetails: View {
|
||||
var detailsPage: some View {
|
||||
Group {
|
||||
Group {
|
||||
if let video = player.currentItem?.video {
|
||||
if let video = player.currentVideo {
|
||||
Group {
|
||||
HStack {
|
||||
publishedDateSection
|
||||
|
@ -4,8 +4,6 @@ import SwiftUI
|
||||
struct BrowsingSettings: View {
|
||||
@Default(.channelOnThumbnail) private var channelOnThumbnail
|
||||
@Default(.timeOnThumbnail) private var timeOnThumbnail
|
||||
@Default(.saveRecents) private var saveRecents
|
||||
@Default(.saveHistory) private var saveHistory
|
||||
@Default(.visibleSections) private var visibleSections
|
||||
|
||||
var body: some View {
|
||||
@ -13,8 +11,6 @@ struct BrowsingSettings: View {
|
||||
Section(header: SettingsHeader(text: "Browsing")) {
|
||||
Toggle("Show channel name on thumbnail", isOn: $channelOnThumbnail)
|
||||
Toggle("Show video length on thumbnail", isOn: $timeOnThumbnail)
|
||||
Toggle("Save recent queries and channels", isOn: $saveRecents)
|
||||
Toggle("Save history of played videos", isOn: $saveHistory)
|
||||
}
|
||||
Section(header: SettingsHeader(text: "Sections")) {
|
||||
#if os(macOS)
|
||||
|
142
Shared/Settings/HistorySettings.swift
Normal file
142
Shared/Settings/HistorySettings.swift
Normal file
@ -0,0 +1,142 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct HistorySettings: View {
|
||||
static let watchedThresholds = [50, 60, 70, 80, 90, 95, 100]
|
||||
|
||||
@State private var presentingClearHistoryConfirmation = false
|
||||
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
|
||||
@Default(.saveRecents) private var saveRecents
|
||||
@Default(.saveHistory) private var saveHistory
|
||||
@Default(.showWatchingProgress) private var showWatchingProgress
|
||||
@Default(.watchedThreshold) private var watchedThreshold
|
||||
@Default(.watchedVideoStyle) private var watchedVideoStyle
|
||||
@Default(.watchedVideoPlayNowBehavior) private var watchedVideoPlayNowBehavior
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
Section(header: SettingsHeader(text: "History")) {
|
||||
Toggle("Save recent queries and channels", isOn: $saveRecents)
|
||||
Toggle("Save history of played videos", isOn: $saveHistory)
|
||||
Toggle("Show progress of watching on thumbnails", isOn: $showWatchingProgress)
|
||||
.disabled(!saveHistory)
|
||||
|
||||
#if !os(tvOS)
|
||||
watchedThresholdPicker
|
||||
watchedVideoStylePicker
|
||||
watchedVideoPlayNowBehaviorPicker
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
watchedThresholdPicker
|
||||
watchedVideoStylePicker
|
||||
watchedVideoPlayNowBehaviorPicker
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
Spacer()
|
||||
#endif
|
||||
|
||||
clearHistoryButton
|
||||
}
|
||||
}
|
||||
|
||||
private var watchedThresholdPicker: some View {
|
||||
Section(header: header("Mark video as watched after playing")) {
|
||||
Picker("Mark video as watched after playing", selection: $watchedThreshold) {
|
||||
ForEach(Self.watchedThresholds, id: \.self) { threshold in
|
||||
Text("\(threshold)%").tag(threshold)
|
||||
}
|
||||
}
|
||||
.disabled(!saveHistory)
|
||||
.labelsHidden()
|
||||
|
||||
#if os(iOS)
|
||||
.pickerStyle(.automatic)
|
||||
#elseif os(tvOS)
|
||||
.pickerStyle(.inline)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private var watchedVideoStylePicker: some View {
|
||||
Section(header: header("Mark watched videos with")) {
|
||||
Picker("Mark watched videos with", selection: $watchedVideoStyle) {
|
||||
Text("Nothing").tag(WatchedVideoStyle.nothing)
|
||||
Text("Badge").tag(WatchedVideoStyle.badge)
|
||||
Text("Decreased opacity").tag(WatchedVideoStyle.decreasedOpacity)
|
||||
}
|
||||
.disabled(!saveHistory)
|
||||
.labelsHidden()
|
||||
|
||||
#if os(iOS)
|
||||
.pickerStyle(.automatic)
|
||||
#elseif os(tvOS)
|
||||
.pickerStyle(.inline)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private var watchedVideoPlayNowBehaviorPicker: some View {
|
||||
Section(header: header("When partially watched video is played")) {
|
||||
Picker("When partially watched video is played", selection: $watchedVideoPlayNowBehavior) {
|
||||
Text("Continue").tag(WatchedVideoPlayNowBehavior.continue)
|
||||
Text("Restart").tag(WatchedVideoPlayNowBehavior.restart)
|
||||
}
|
||||
.disabled(!saveHistory)
|
||||
.labelsHidden()
|
||||
|
||||
#if os(iOS)
|
||||
.pickerStyle(.automatic)
|
||||
#elseif os(tvOS)
|
||||
.pickerStyle(.inline)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private var clearHistoryButton: some View {
|
||||
Button("Clear History") {
|
||||
presentingClearHistoryConfirmation = true
|
||||
}
|
||||
.alert(isPresented: $presentingClearHistoryConfirmation) {
|
||||
Alert(
|
||||
title: Text(
|
||||
"Are you sure you want to clear history of watched videos?"
|
||||
),
|
||||
message: Text(
|
||||
"This cannot be undone. You might need to switch between views or restart the app to see changes."
|
||||
),
|
||||
primaryButton: .destructive(Text("Clear All")) {
|
||||
player.removeAllWatches()
|
||||
presentingClearHistoryConfirmation = false
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
.foregroundColor(.red)
|
||||
.disabled(!saveHistory)
|
||||
}
|
||||
|
||||
private func header(_ text: String) -> some View {
|
||||
#if os(iOS)
|
||||
return EmptyView()
|
||||
#elseif os(macOS)
|
||||
return Text(text)
|
||||
.opacity(saveHistory ? 1 : 0.3)
|
||||
#else
|
||||
return Text(text)
|
||||
.foregroundColor(.secondary)
|
||||
.opacity(saveHistory ? 1 : 0.2)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct HistorySettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
HistorySettings()
|
||||
.injectFixtureEnvironmentObjects()
|
||||
}
|
||||
}
|
@ -5,7 +5,7 @@ import SwiftUI
|
||||
struct SettingsView: View {
|
||||
#if os(macOS)
|
||||
private enum Tabs: Hashable {
|
||||
case instances, browsing, playback, services, updates
|
||||
case instances, browsing, history, playback, services, updates
|
||||
}
|
||||
#endif
|
||||
|
||||
@ -42,6 +42,14 @@ struct SettingsView: View {
|
||||
}
|
||||
.tag(Tabs.browsing)
|
||||
|
||||
Form {
|
||||
HistorySettings()
|
||||
}
|
||||
.tabItem {
|
||||
Label("History", systemImage: "clock.arrow.circlepath")
|
||||
}
|
||||
.tag(Tabs.history)
|
||||
|
||||
Form {
|
||||
PlaybackSettings()
|
||||
}
|
||||
@ -89,6 +97,7 @@ struct SettingsView: View {
|
||||
}
|
||||
|
||||
BrowsingSettings()
|
||||
HistorySettings()
|
||||
PlaybackSettings()
|
||||
ServicesSettings()
|
||||
}
|
||||
|
@ -96,7 +96,7 @@ struct VideoBanner: View {
|
||||
|
||||
private var progressView: some View {
|
||||
Group {
|
||||
if !playbackTime.isNil {
|
||||
if !playbackTime.isNil, !(video?.live ?? false) {
|
||||
ProgressView(value: progressViewValue, total: progressViewTotal)
|
||||
.progressViewStyle(.linear)
|
||||
.frame(maxWidth: thumbnailWidth)
|
||||
|
@ -3,7 +3,7 @@ import SDWebImageSwiftUI
|
||||
import SwiftUI
|
||||
|
||||
struct VideoCell: View {
|
||||
var video: Video
|
||||
private var video: Video
|
||||
|
||||
@Environment(\.inNavigationView) private var inNavigationView
|
||||
|
||||
@ -18,25 +18,25 @@ struct VideoCell: View {
|
||||
|
||||
@Default(.channelOnThumbnail) private var channelOnThumbnail
|
||||
@Default(.timeOnThumbnail) private var timeOnThumbnail
|
||||
@Default(.saveHistory) private var saveHistory
|
||||
@Default(.showWatchingProgress) private var showWatchingProgress
|
||||
@Default(.watchedVideoStyle) private var watchedVideoStyle
|
||||
@Default(.watchedVideoPlayNowBehavior) private var watchedVideoPlayNowBehavior
|
||||
|
||||
@FetchRequest private var watchRequest: FetchedResults<Watch>
|
||||
|
||||
init(video: Video) {
|
||||
self.video = video
|
||||
_watchRequest = video.watchFetchRequest
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
Button(action: {
|
||||
player.playNow(video)
|
||||
|
||||
guard !player.playingInPictureInPicture else {
|
||||
return
|
||||
}
|
||||
|
||||
if inNavigationView {
|
||||
player.playerNavigationLinkActive = true
|
||||
} else {
|
||||
player.show()
|
||||
}
|
||||
}) {
|
||||
Button(action: playAction) {
|
||||
content
|
||||
}
|
||||
}
|
||||
.opacity(contentOpacity)
|
||||
.buttonStyle(.plain)
|
||||
.contentShape(RoundedRectangle(cornerRadius: 12))
|
||||
.contextMenu {
|
||||
@ -48,7 +48,50 @@ struct VideoCell: View {
|
||||
}
|
||||
}
|
||||
|
||||
var content: some View {
|
||||
private func playAction() {
|
||||
if watchingNow {
|
||||
if !player.playingInPictureInPicture {
|
||||
player.show()
|
||||
}
|
||||
|
||||
if !playNowContinues {
|
||||
player.player.seek(to: .zero)
|
||||
}
|
||||
|
||||
player.play()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var playAt: TimeInterval?
|
||||
|
||||
if playNowContinues,
|
||||
!watch.isNil,
|
||||
!watch!.finished
|
||||
{
|
||||
playAt = watch!.stoppedAt
|
||||
}
|
||||
|
||||
player.play(video, at: playAt, inNavigationView: inNavigationView)
|
||||
}
|
||||
|
||||
private var playNowContinues: Bool {
|
||||
watchedVideoPlayNowBehavior == .continue
|
||||
}
|
||||
|
||||
private var watch: Watch? {
|
||||
watchRequest.first
|
||||
}
|
||||
|
||||
private var finished: Bool {
|
||||
watch?.finished ?? false
|
||||
}
|
||||
|
||||
private var watchingNow: Bool {
|
||||
player.currentVideo == video
|
||||
}
|
||||
|
||||
private var content: some View {
|
||||
VStack {
|
||||
#if os(iOS)
|
||||
if verticalSizeClass == .compact, !horizontalCells {
|
||||
@ -66,8 +109,19 @@ struct VideoCell: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
private var contentOpacity: Double {
|
||||
guard saveHistory,
|
||||
!watch.isNil,
|
||||
watchedVideoStyle == .decreasedOpacity
|
||||
else {
|
||||
return 1
|
||||
}
|
||||
|
||||
return watch!.finished ? 0.5 : 1
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
var horizontalRow: some View {
|
||||
private var horizontalRow: some View {
|
||||
HStack(alignment: .top, spacing: 2) {
|
||||
Section {
|
||||
#if os(tvOS)
|
||||
@ -151,7 +205,7 @@ struct VideoCell: View {
|
||||
}
|
||||
#endif
|
||||
|
||||
var verticalRow: some View {
|
||||
private var verticalRow: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
thumbnail
|
||||
|
||||
@ -201,7 +255,7 @@ struct VideoCell: View {
|
||||
}
|
||||
}
|
||||
|
||||
if let time = video.length.formattedAsPlaybackTime(), !timeOnThumbnail {
|
||||
if let time = time, !timeOnThumbnail {
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 2) {
|
||||
@ -225,13 +279,30 @@ struct VideoCell: View {
|
||||
}
|
||||
}
|
||||
|
||||
var additionalDetailsAvailable: Bool {
|
||||
video.publishedDate != nil || video.views != 0 || (!timeOnThumbnail && !video.length.formattedAsPlaybackTime().isNil)
|
||||
private var additionalDetailsAvailable: Bool {
|
||||
video.publishedDate != nil || video.views != 0 ||
|
||||
(!timeOnThumbnail && !video.length.formattedAsPlaybackTime().isNil)
|
||||
}
|
||||
|
||||
var thumbnail: some View {
|
||||
private var thumbnail: some View {
|
||||
ZStack(alignment: .leading) {
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
thumbnailImage
|
||||
if saveHistory, showWatchingProgress, watch?.progress ?? 0 > 0 {
|
||||
ProgressView(value: watch!.progress, total: 100)
|
||||
.progressViewStyle(LinearProgressViewStyle(tint: Color("WatchProgressBarColor")))
|
||||
#if os(tvOS)
|
||||
.padding(.horizontal, 16)
|
||||
#else
|
||||
.padding(.horizontal, 10)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
.offset(x: 0, y: 4)
|
||||
#else
|
||||
.offset(x: 0, y: -3)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
VStack {
|
||||
HStack(alignment: .top) {
|
||||
@ -247,24 +318,49 @@ struct VideoCell: View {
|
||||
DetailBadge(text: video.author, style: .prominent)
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
.padding(16)
|
||||
#else
|
||||
.padding(10)
|
||||
#endif
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(alignment: .top) {
|
||||
HStack(alignment: .center) {
|
||||
if saveHistory,
|
||||
watchedVideoStyle == .badge,
|
||||
watch?.finished ?? false
|
||||
{
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(Color("WatchProgressBarColor"))
|
||||
.background(Color.white)
|
||||
.clipShape(Circle())
|
||||
#if os(tvOS)
|
||||
.font(.system(size: 40))
|
||||
#else
|
||||
.font(.system(size: 30))
|
||||
#endif
|
||||
}
|
||||
Spacer()
|
||||
|
||||
if timeOnThumbnail, let time = video.length.formattedAsPlaybackTime() {
|
||||
if timeOnThumbnail,
|
||||
!video.live,
|
||||
let time = time
|
||||
{
|
||||
DetailBadge(text: time, style: .prominent)
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
.padding(16)
|
||||
#else
|
||||
.padding(10)
|
||||
#endif
|
||||
}
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
var thumbnailImage: some View {
|
||||
private var thumbnailImage: some View {
|
||||
Group {
|
||||
if let url = thumbnails.best(video) {
|
||||
WebImage(url: url)
|
||||
@ -293,7 +389,29 @@ struct VideoCell: View {
|
||||
.modifier(AspectRatioModifier())
|
||||
}
|
||||
|
||||
func videoDetail(_ text: String, lineLimit: Int = 1) -> some View {
|
||||
private var time: String? {
|
||||
guard var videoTime = video.length.formattedAsPlaybackTime() else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !saveHistory || !showWatchingProgress || watch?.finished ?? false {
|
||||
return videoTime
|
||||
}
|
||||
|
||||
if let stoppedAt = watch?.stoppedAt,
|
||||
stoppedAt.isFinite,
|
||||
let stoppedAtFormatted = stoppedAt.formattedAsPlaybackTime()
|
||||
{
|
||||
if watch?.videoDuration ?? 0 > 0 {
|
||||
videoTime = watch!.videoDuration.formattedAsPlaybackTime() ?? "?"
|
||||
}
|
||||
return "\(stoppedAtFormatted) / \(videoTime)"
|
||||
}
|
||||
|
||||
return videoTime
|
||||
}
|
||||
|
||||
private func videoDetail(_ text: String, lineLimit: Int = 1) -> some View {
|
||||
Text(text)
|
||||
.fontWeight(.bold)
|
||||
.lineLimit(lineLimit)
|
||||
@ -309,7 +427,10 @@ struct VideoCell: View {
|
||||
content
|
||||
} else {
|
||||
content
|
||||
.aspectRatio(1.777, contentMode: .fill)
|
||||
.aspectRatio(
|
||||
VideoPlayerView.defaultAspectRatio,
|
||||
contentMode: .fill
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -31,12 +31,12 @@ struct PlayerControlsView<Content: View>: View {
|
||||
}) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(model.currentItem?.video?.title ?? "Not playing")
|
||||
Text(model.currentVideo?.title ?? "Not playing")
|
||||
.font(.system(size: 14).bold())
|
||||
.foregroundColor(model.currentItem.isNil ? .secondary : .accentColor)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(model.currentItem?.video?.author ?? "Yattee v\(appVersion) (build \(appBuild))")
|
||||
Text(model.currentVideo?.author ?? "Yattee v\(appVersion) (build \(appBuild))")
|
||||
.fontWeight(model.currentItem.isNil ? .light : .bold)
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.secondary)
|
||||
|
@ -1,3 +1,4 @@
|
||||
import CoreData
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
@ -19,7 +20,35 @@ struct VideoContextMenuView: View {
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||
|
||||
@FetchRequest private var watchRequest: FetchedResults<Watch>
|
||||
|
||||
@Default(.saveHistory) private var saveHistory
|
||||
|
||||
private var viewContext: NSManagedObjectContext = PersistenceController.shared.container.viewContext
|
||||
|
||||
init(video: Video, playerNavigationLinkActive: Binding<Bool>) {
|
||||
self.video = video
|
||||
_playerNavigationLinkActive = playerNavigationLinkActive
|
||||
_watchRequest = video.watchFetchRequest
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if saveHistory {
|
||||
Section {
|
||||
if let watchedAtString = watchedAtString {
|
||||
Text(watchedAtString)
|
||||
}
|
||||
|
||||
if !watch.isNil, !watch!.finished, !watchingNow {
|
||||
continueButton
|
||||
}
|
||||
|
||||
if !watch.isNil, !watchingNow {
|
||||
removeFromHistoryButton
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
playNowButton
|
||||
}
|
||||
@ -54,19 +83,49 @@ struct VideoContextMenuView: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
private var playNowButton: some View {
|
||||
Button {
|
||||
player.playNow(video)
|
||||
private var watch: Watch? {
|
||||
watchRequest.first
|
||||
}
|
||||
|
||||
guard !player.playingInPictureInPicture else {
|
||||
private var watchingNow: Bool {
|
||||
player.currentVideo == video
|
||||
}
|
||||
|
||||
private var watchedAtString: String? {
|
||||
if watchingNow {
|
||||
return "Watching now"
|
||||
}
|
||||
|
||||
if let watch = watch, let watchedAtString = watch.watchedAtString {
|
||||
return "Watched \(watchedAtString)"
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private var continueButton: some View {
|
||||
Button {
|
||||
player.play(video, at: watch!.stoppedAt, inNavigationView: inNavigationView)
|
||||
} label: {
|
||||
Label("Continue from \(watch!.stoppedAt.formattedAsPlaybackTime() ?? "where I left off")", systemImage: "playpause")
|
||||
}
|
||||
}
|
||||
|
||||
var removeFromHistoryButton: some View {
|
||||
Button {
|
||||
guard let watch = watch else {
|
||||
return
|
||||
}
|
||||
|
||||
if inNavigationView {
|
||||
playerNavigationLinkActive = true
|
||||
} else {
|
||||
player.show()
|
||||
player.removeWatch(watch)
|
||||
} label: {
|
||||
Label("Remove from history", systemImage: "delete.left.fill")
|
||||
}
|
||||
}
|
||||
|
||||
private var playNowButton: some View {
|
||||
Button {
|
||||
player.play(video, inNavigationView: inNavigationView)
|
||||
} label: {
|
||||
Label("Play Now", systemImage: "play")
|
||||
}
|
||||
|
@ -20,9 +20,12 @@ struct YatteeApp: App {
|
||||
@StateObject private var subscriptions = SubscriptionsModel()
|
||||
@StateObject private var thumbnails = ThumbnailsModel()
|
||||
|
||||
let persistenceController = PersistenceController.shared
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environment(\.managedObjectContext, persistenceController.container.viewContext)
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(comments)
|
||||
.environmentObject(instances)
|
||||
@ -70,6 +73,7 @@ struct YatteeApp: App {
|
||||
VideoPlayerView()
|
||||
.onAppear { player.presentingPlayer = true }
|
||||
.onDisappear { player.presentingPlayer = false }
|
||||
.environment(\.managedObjectContext, persistenceController.container.viewContext)
|
||||
.environment(\.navigationStyle, .sidebar)
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(comments)
|
||||
@ -86,8 +90,10 @@ struct YatteeApp: App {
|
||||
|
||||
Settings {
|
||||
SettingsView()
|
||||
.environment(\.managedObjectContext, persistenceController.container.viewContext)
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(instances)
|
||||
.environmentObject(player)
|
||||
.environmentObject(updater)
|
||||
}
|
||||
#endif
|
||||
|
@ -59,6 +59,12 @@
|
||||
3711403F26B206A6005B3555 /* SearchModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3711403E26B206A6005B3555 /* SearchModel.swift */; };
|
||||
3711404026B206A6005B3555 /* SearchModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3711403E26B206A6005B3555 /* SearchModel.swift */; };
|
||||
3711404126B206A6005B3555 /* SearchModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3711403E26B206A6005B3555 /* SearchModel.swift */; };
|
||||
37130A5B277657090033018A /* Yattee.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 37130A59277657090033018A /* Yattee.xcdatamodeld */; };
|
||||
37130A5C277657090033018A /* Yattee.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 37130A59277657090033018A /* Yattee.xcdatamodeld */; };
|
||||
37130A5D277657090033018A /* Yattee.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 37130A59277657090033018A /* Yattee.xcdatamodeld */; };
|
||||
37130A5F277657300033018A /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37130A5E277657300033018A /* PersistenceController.swift */; };
|
||||
37130A60277657300033018A /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37130A5E277657300033018A /* PersistenceController.swift */; };
|
||||
37130A61277657300033018A /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37130A5E277657300033018A /* PersistenceController.swift */; };
|
||||
3714166F267A8ACC006CA35D /* TrendingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3714166E267A8ACC006CA35D /* TrendingView.swift */; };
|
||||
37141670267A8ACC006CA35D /* TrendingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3714166E267A8ACC006CA35D /* TrendingView.swift */; };
|
||||
37141671267A8ACC006CA35D /* TrendingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3714166E267A8ACC006CA35D /* TrendingView.swift */; };
|
||||
@ -298,6 +304,9 @@
|
||||
3784B23B272894DA00B09468 /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3784B23A272894DA00B09468 /* ShareSheet.swift */; };
|
||||
3784B23D2728B85300B09468 /* ShareButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3784B23C2728B85300B09468 /* ShareButton.swift */; };
|
||||
3784B23E2728B85300B09468 /* ShareButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3784B23C2728B85300B09468 /* ShareButton.swift */; };
|
||||
3784CDE227772EE40055BBF2 /* Watch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3784CDDE27772EE40055BBF2 /* Watch.swift */; };
|
||||
3784CDE327772EE40055BBF2 /* Watch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3784CDDE27772EE40055BBF2 /* Watch.swift */; };
|
||||
3784CDE427772EE40055BBF2 /* Watch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3784CDDE27772EE40055BBF2 /* Watch.swift */; };
|
||||
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 */; };
|
||||
@ -387,6 +396,12 @@
|
||||
37BADCA52699FB72009BE4FB /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = 37BADCA42699FB72009BE4FB /* Alamofire */; };
|
||||
37BADCA7269A552E009BE4FB /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = 37BADCA6269A552E009BE4FB /* Alamofire */; };
|
||||
37BADCA9269A570B009BE4FB /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = 37BADCA8269A570B009BE4FB /* Alamofire */; };
|
||||
37BC50A82778A84700510953 /* HistorySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BC50A72778A84700510953 /* HistorySettings.swift */; };
|
||||
37BC50A92778A84700510953 /* HistorySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BC50A72778A84700510953 /* HistorySettings.swift */; };
|
||||
37BC50AA2778A84700510953 /* HistorySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BC50A72778A84700510953 /* HistorySettings.swift */; };
|
||||
37BC50AC2778BCBA00510953 /* HistoryModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BC50AB2778BCBA00510953 /* HistoryModel.swift */; };
|
||||
37BC50AD2778BCBA00510953 /* HistoryModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BC50AB2778BCBA00510953 /* HistoryModel.swift */; };
|
||||
37BC50AE2778BCBA00510953 /* HistoryModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BC50AB2778BCBA00510953 /* HistoryModel.swift */; };
|
||||
37BD07B52698AA4D003EBB87 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BD07B42698AA4D003EBB87 /* ContentView.swift */; };
|
||||
37BD07B72698AB2E003EBB87 /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = 37BD07B62698AB2E003EBB87 /* Defaults */; };
|
||||
37BD07B92698AB2E003EBB87 /* Siesta in Frameworks */ = {isa = PBXBuildFile; productRef = 37BD07B82698AB2E003EBB87 /* Siesta */; };
|
||||
@ -584,6 +599,8 @@
|
||||
3705B17F267B4DFB00704544 /* TrendingCountry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingCountry.swift; sourceTree = "<group>"; };
|
||||
3705B181267B4E4900704544 /* TrendingCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingCategory.swift; sourceTree = "<group>"; };
|
||||
3711403E26B206A6005B3555 /* SearchModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchModel.swift; sourceTree = "<group>"; };
|
||||
37130A5A277657090033018A /* Yattee.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Yattee.xcdatamodel; sourceTree = "<group>"; };
|
||||
37130A5E277657300033018A /* PersistenceController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; };
|
||||
3714166E267A8ACC006CA35D /* TrendingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingView.swift; sourceTree = "<group>"; };
|
||||
37141672267A8E10006CA35D /* Country.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Country.swift; sourceTree = "<group>"; };
|
||||
37152EE926EFEB95004FB96D /* LazyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyView.swift; sourceTree = "<group>"; };
|
||||
@ -652,6 +669,7 @@
|
||||
3782B95C2755858100990149 /* NSTextField+FocusRingType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSTextField+FocusRingType.swift"; sourceTree = "<group>"; };
|
||||
3784B23A272894DA00B09468 /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheet.swift; sourceTree = "<group>"; };
|
||||
3784B23C2728B85300B09468 /* ShareButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareButton.swift; sourceTree = "<group>"; };
|
||||
3784CDDE27772EE40055BBF2 /* Watch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Watch.swift; sourceTree = "<group>"; };
|
||||
3788AC2626F6840700F6BAA9 /* FavoriteItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteItemView.swift; sourceTree = "<group>"; };
|
||||
378AE942274EF00A006A4EE1 /* Color+Background.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Background.swift"; sourceTree = "<group>"; };
|
||||
378E50FA26FE8B9F00F49626 /* Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Instance.swift; sourceTree = "<group>"; };
|
||||
@ -691,6 +709,8 @@
|
||||
37BA794E26DC3E0E002A0235 /* Int+Format.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Int+Format.swift"; sourceTree = "<group>"; };
|
||||
37BA796D26DC412E002A0235 /* Int+FormatTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Int+FormatTests.swift"; sourceTree = "<group>"; };
|
||||
37BAB54B269B39FD00E75ED1 /* TVNavigationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVNavigationView.swift; sourceTree = "<group>"; };
|
||||
37BC50A72778A84700510953 /* HistorySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistorySettings.swift; sourceTree = "<group>"; };
|
||||
37BC50AB2778BCBA00510953 /* HistoryModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = HistoryModel.swift; path = Model/HistoryModel.swift; sourceTree = SOURCE_ROOT; };
|
||||
37BD07B42698AA4D003EBB87 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
37BD07BA2698AB60003EBB87 /* AppSidebarNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSidebarNavigation.swift; sourceTree = "<group>"; };
|
||||
37BD07C42698ADEE003EBB87 /* Yattee.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Yattee.entitlements; sourceTree = "<group>"; };
|
||||
@ -1016,6 +1036,7 @@
|
||||
37E084AB2753D95F00039B7D /* AccountsNavigationLink.swift */,
|
||||
37732FEF2703A26300F04329 /* AccountValidationStatus.swift */,
|
||||
376BE50A27349108009AD608 /* BrowsingSettings.swift */,
|
||||
37BC50A72778A84700510953 /* HistorySettings.swift */,
|
||||
37484C2426FC83E000287258 /* InstanceForm.swift */,
|
||||
37484C2C26FC844700287258 /* InstanceSettings.swift */,
|
||||
37484C1826FC837400287258 /* PlaybackSettings.swift */,
|
||||
@ -1275,8 +1296,10 @@
|
||||
37141672267A8E10006CA35D /* Country.swift */,
|
||||
37599F2F272B42810087F250 /* FavoriteItem.swift */,
|
||||
37599F33272B44000087F250 /* FavoritesModel.swift */,
|
||||
37BC50AB2778BCBA00510953 /* HistoryModel.swift */,
|
||||
37EF5C212739D37B00B03725 /* MenuModel.swift */,
|
||||
371F2F19269B43D300E4A7AB /* NavigationModel.swift */,
|
||||
37130A5E277657300033018A /* PersistenceController.swift */,
|
||||
376578882685471400D4EA09 /* Playlist.swift */,
|
||||
37BA794226DBA973002A0235 /* PlaylistsModel.swift */,
|
||||
37C194C626F6A9C8005D3B96 /* RecentsModel.swift */,
|
||||
@ -1289,6 +1312,8 @@
|
||||
37C0698127260B2100F7F6CB /* ThumbnailsModel.swift */,
|
||||
3705B181267B4E4900704544 /* TrendingCategory.swift */,
|
||||
37D4B19626717E1500C925CA /* Video.swift */,
|
||||
3784CDDE27772EE40055BBF2 /* Watch.swift */,
|
||||
37130A59277657090033018A /* Yattee.xcdatamodeld */,
|
||||
);
|
||||
path = Model;
|
||||
sourceTree = "<group>";
|
||||
@ -1797,6 +1822,7 @@
|
||||
376CD21626FBE18D001E1AC1 /* Instance+Fixtures.swift in Sources */,
|
||||
37BA793B26DB8EE4002A0235 /* PlaylistVideosView.swift in Sources */,
|
||||
37BD07B52698AA4D003EBB87 /* ContentView.swift in Sources */,
|
||||
37130A5B277657090033018A /* Yattee.xcdatamodeld in Sources */,
|
||||
37152EEA26EFEB95004FB96D /* LazyView.swift in Sources */,
|
||||
3761ABFD26F0F8DE00AA496F /* EnvironmentValues.swift in Sources */,
|
||||
3782B94F27553A6700990149 /* SearchSuggestions.swift in Sources */,
|
||||
@ -1804,6 +1830,7 @@
|
||||
37169AA62729E2CC0011DE61 /* AccountsBridge.swift in Sources */,
|
||||
37C7A1DA267CACF50010EAD6 /* TrendingCountry.swift in Sources */,
|
||||
37977583268922F600DD52A8 /* InvidiousAPI.swift in Sources */,
|
||||
37130A5F277657300033018A /* PersistenceController.swift in Sources */,
|
||||
37FD43E32704847C0073EE42 /* View+Fixtures.swift in Sources */,
|
||||
37BE0BD626A1D4A90092E2DB /* PlayerViewController.swift in Sources */,
|
||||
37BA793F26DB8F97002A0235 /* ChannelVideosView.swift in Sources */,
|
||||
@ -1815,6 +1842,7 @@
|
||||
376A33E02720CAD6000C1D6B /* VideosApp.swift in Sources */,
|
||||
37B81AF926D2C9A700675966 /* VideoPlayerSizeModifier.swift in Sources */,
|
||||
37C0698227260B2100F7F6CB /* ThumbnailsModel.swift in Sources */,
|
||||
37BC50A82778A84700510953 /* HistorySettings.swift in Sources */,
|
||||
37DD87C7271C9CFE0027CBF9 /* PlayerStreams.swift in Sources */,
|
||||
371B7E662759786B00D21217 /* Comment+Fixtures.swift in Sources */,
|
||||
37BE0BD326A1D4780092E2DB /* Player.swift in Sources */,
|
||||
@ -1855,6 +1883,7 @@
|
||||
37BF661C27308859008CCFB0 /* DropFavorite.swift in Sources */,
|
||||
376A33E42720CB35000C1D6B /* Account.swift in Sources */,
|
||||
37BA794326DBA973002A0235 /* PlaylistsModel.swift in Sources */,
|
||||
37BC50AC2778BCBA00510953 /* HistoryModel.swift in Sources */,
|
||||
37AAF29026740715007FC770 /* Channel.swift in Sources */,
|
||||
3748186A26A764FB0084E870 /* Thumbnail+Fixtures.swift in Sources */,
|
||||
37B81AFF26D2CA3700675966 /* VideoDetails.swift in Sources */,
|
||||
@ -1918,6 +1947,7 @@
|
||||
37C0697A2725C09E00F7F6CB /* PlayerQueueItemBridge.swift in Sources */,
|
||||
37D4B0E42671614900C925CA /* YatteeApp.swift in Sources */,
|
||||
37C3A241272359900087A57A /* Double+Format.swift in Sources */,
|
||||
3784CDE227772EE40055BBF2 /* Watch.swift in Sources */,
|
||||
37FB285E272225E800A57617 /* ContentItemView.swift in Sources */,
|
||||
3797758B2689345500DD52A8 /* Store.swift in Sources */,
|
||||
37B795902771DAE0001CF27B /* OpenURLHandler.swift in Sources */,
|
||||
@ -1954,6 +1984,7 @@
|
||||
3743CA4F270EFE3400E4D32B /* PlayerQueueRow.swift in Sources */,
|
||||
374C053C2724614F009BDDBE /* PlayerTVMenu.swift in Sources */,
|
||||
377FC7DD267A081A00A6BBAF /* PopularView.swift in Sources */,
|
||||
3784CDE327772EE40055BBF2 /* Watch.swift in Sources */,
|
||||
375DFB5926F9DA010013F468 /* InstancesModel.swift in Sources */,
|
||||
3705B183267B4E4900704544 /* TrendingCategory.swift in Sources */,
|
||||
37FB285F272225E800A57617 /* ContentItemView.swift in Sources */,
|
||||
@ -1984,6 +2015,7 @@
|
||||
37152EEB26EFEB95004FB96D /* LazyView.swift in Sources */,
|
||||
377FC7E2267A084A00A6BBAF /* VideoCell.swift in Sources */,
|
||||
37CC3F51270D010D00608308 /* VideoBanner.swift in Sources */,
|
||||
37BC50AD2778BCBA00510953 /* HistoryModel.swift in Sources */,
|
||||
378E50FC26FE8B9F00F49626 /* Instance.swift in Sources */,
|
||||
37BE7AF12760013C00DBECED /* UpdatesSettings.swift in Sources */,
|
||||
37169AA32729D98A0011DE61 /* InstancesBridge.swift in Sources */,
|
||||
@ -2034,6 +2066,7 @@
|
||||
3797758C2689345500DD52A8 /* Store.swift in Sources */,
|
||||
371B7E622759706A00D21217 /* CommentsView.swift in Sources */,
|
||||
37141674267A8E10006CA35D /* Country.swift in Sources */,
|
||||
37130A5C277657090033018A /* Yattee.xcdatamodeld in Sources */,
|
||||
37FD43E42704847C0073EE42 /* View+Fixtures.swift in Sources */,
|
||||
37C069782725962F00F7F6CB /* ScreenSaverManager.swift in Sources */,
|
||||
37AAF2A126741C97007FC770 /* SubscriptionsView.swift in Sources */,
|
||||
@ -2046,6 +2079,7 @@
|
||||
37D4B19826717E1500C925CA /* Video.swift in Sources */,
|
||||
371B7E5D27596B8400D21217 /* Comment.swift in Sources */,
|
||||
37EF5C232739D37B00B03725 /* MenuModel.swift in Sources */,
|
||||
37BC50A92778A84700510953 /* HistorySettings.swift in Sources */,
|
||||
37599F31272B42810087F250 /* FavoriteItem.swift in Sources */,
|
||||
3730F75A2733481E00F385FC /* RelatedView.swift in Sources */,
|
||||
37E04C0F275940FB00172673 /* VerticalScrollingFix.swift in Sources */,
|
||||
@ -2053,6 +2087,7 @@
|
||||
37D526DF2720AC4400ED2F5E /* VideosAPI.swift in Sources */,
|
||||
373C8FE5275B955100CB5936 /* CommentsPage.swift in Sources */,
|
||||
37D4B0E52671614900C925CA /* YatteeApp.swift in Sources */,
|
||||
37130A60277657300033018A /* PersistenceController.swift in Sources */,
|
||||
37BD07C12698AD3B003EBB87 /* TrendingCountry.swift in Sources */,
|
||||
37BA794026DB8F97002A0235 /* ChannelVideosView.swift in Sources */,
|
||||
3711404026B206A6005B3555 /* SearchModel.swift in Sources */,
|
||||
@ -2169,6 +2204,7 @@
|
||||
37A9965C26D6F8CA006E3224 /* HorizontalCells.swift in Sources */,
|
||||
3782B95727557E6E00990149 /* SearchSuggestions.swift in Sources */,
|
||||
37BD07C92698FBDB003EBB87 /* ContentView.swift in Sources */,
|
||||
37BC50AA2778A84700510953 /* HistorySettings.swift in Sources */,
|
||||
376B2E0926F920D600B1D64D /* SignInRequiredView.swift in Sources */,
|
||||
37141671267A8ACC006CA35D /* TrendingView.swift in Sources */,
|
||||
37C3A24727235DA70087A57A /* ChannelPlaylist.swift in Sources */,
|
||||
@ -2183,6 +2219,7 @@
|
||||
37BA794126DB8F97002A0235 /* ChannelVideosView.swift in Sources */,
|
||||
37C0697C2725C09E00F7F6CB /* PlayerQueueItemBridge.swift in Sources */,
|
||||
378AE93D274EDFB3006A4EE1 /* Backport.swift in Sources */,
|
||||
37130A5D277657090033018A /* Yattee.xcdatamodeld in Sources */,
|
||||
37C3A243272359900087A57A /* Double+Format.swift in Sources */,
|
||||
37AAF29226740715007FC770 /* Channel.swift in Sources */,
|
||||
37EAD86D267B9C5600D9E01B /* SponsorBlockAPI.swift in Sources */,
|
||||
@ -2198,6 +2235,7 @@
|
||||
3743B86A27216D3600261544 /* ChannelCell.swift in Sources */,
|
||||
37B767DD2677C3CA0098BAA8 /* PlayerModel.swift in Sources */,
|
||||
373CFAF12697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */,
|
||||
3784CDE427772EE40055BBF2 /* Watch.swift in Sources */,
|
||||
3730D8A02712E2B70020ED53 /* NowPlayingView.swift in Sources */,
|
||||
37169AA42729D98A0011DE61 /* InstancesBridge.swift in Sources */,
|
||||
37D4B18E26717B3800C925CA /* VideoCell.swift in Sources */,
|
||||
@ -2209,6 +2247,7 @@
|
||||
37A9966026D6F9B9006E3224 /* FavoritesView.swift in Sources */,
|
||||
37001565271B1F250049C794 /* AccountsModel.swift in Sources */,
|
||||
374C0541272472C0009BDDBE /* PlayerSponsorBlock.swift in Sources */,
|
||||
37130A61277657300033018A /* PersistenceController.swift in Sources */,
|
||||
37E70929271CDDAE00D34DDE /* OpenSettingsButton.swift in Sources */,
|
||||
376A33E62720CB35000C1D6B /* Account.swift in Sources */,
|
||||
37599F3A272B4D740087F250 /* FavoriteButton.swift in Sources */,
|
||||
@ -2256,6 +2295,7 @@
|
||||
37D4B19926717E1500C925CA /* Video.swift in Sources */,
|
||||
378E50FD26FE8B9F00F49626 /* Instance.swift in Sources */,
|
||||
37169AA82729E2CC0011DE61 /* AccountsBridge.swift in Sources */,
|
||||
37BC50AE2778BCBA00510953 /* HistoryModel.swift in Sources */,
|
||||
37D526E02720AC4400ED2F5E /* VideosAPI.swift in Sources */,
|
||||
37599F36272B44000087F250 /* FavoritesModel.swift in Sources */,
|
||||
3705B184267B4E4900704544 /* TrendingCategory.swift in Sources */,
|
||||
@ -3323,6 +3363,19 @@
|
||||
productName = SDWebImagePINPlugin;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
|
||||
/* Begin XCVersionGroup section */
|
||||
37130A59277657090033018A /* Yattee.xcdatamodeld */ = {
|
||||
isa = XCVersionGroup;
|
||||
children = (
|
||||
37130A5A277657090033018A /* Yattee.xcdatamodel */,
|
||||
);
|
||||
currentVersion = 37130A5A277657090033018A /* Yattee.xcdatamodel */;
|
||||
path = Yattee.xcdatamodeld;
|
||||
sourceTree = "<group>";
|
||||
versionGroupType = wrapper.xcdatamodel;
|
||||
};
|
||||
/* End XCVersionGroup section */
|
||||
};
|
||||
rootObject = 37D4B0BD2671614700C925CA /* Project object */;
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import CoreMedia
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
@ -11,6 +12,9 @@ struct NowPlayingView: View {
|
||||
|
||||
@State private var repliesID: Comment.ID?
|
||||
|
||||
@FetchRequest(sortDescriptors: [.init(key: "watchedAt", ascending: false)])
|
||||
var watches: FetchedResults<Watch>
|
||||
|
||||
@EnvironmentObject<CommentsModel> private var comments
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
@ -94,22 +98,27 @@ struct NowPlayingView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if sections.contains(.playedPreviously), saveHistory, !player.history.isEmpty {
|
||||
if sections.contains(.playedPreviously), saveHistory, !visibleWatches.isEmpty {
|
||||
Section(header: Text("Played Previously")) {
|
||||
ForEach(player.history) { item in
|
||||
ForEach(visibleWatches, id: \.videoID) { watch in
|
||||
Button {
|
||||
player.playHistory(item)
|
||||
player.playHistory(
|
||||
PlayerQueueItem.from(watch, video: player.historyVideo(watch.videoID))
|
||||
)
|
||||
player.show()
|
||||
} label: {
|
||||
VideoBanner(video: item.video, playbackTime: item.playbackTime, videoDuration: item.videoDuration)
|
||||
VideoBanner(
|
||||
video: player.historyVideo(watch.videoID),
|
||||
playbackTime: CMTime.secondsInDefaultTimescale(watch.stoppedAt),
|
||||
videoDuration: watch.videoDuration
|
||||
)
|
||||
}
|
||||
.onAppear {
|
||||
player.loadHistoryVideoDetails(watch.videoID)
|
||||
}
|
||||
.contextMenu {
|
||||
Button("Remove", role: .destructive) {
|
||||
player.removeHistory(item)
|
||||
}
|
||||
|
||||
Button("Remove All", role: .destructive) {
|
||||
player.removeHistoryItems()
|
||||
player.removeWatch(watch)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -141,6 +150,10 @@ struct NowPlayingView: View {
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 560, maxHeight: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var visibleWatches: [Watch] {
|
||||
watches.filter { $0.videoID != player.currentVideo?.videoID }
|
||||
}
|
||||
|
||||
private var progressView: some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
|
Loading…
Reference in New Issue
Block a user