Improve history, resume videos, mark watched videos (fixes #42)

This commit is contained in:
Arkadiusz Fal 2021-12-26 22:14:46 +01:00
parent adcebb77a5
commit ac1c6685a1
31 changed files with 775 additions and 344 deletions

View File

@ -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
View 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()
}
}

View 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)")
}
}
}
}

View File

@ -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)
controller?.playerView.dismiss(animated: false) { [weak self] in
self?.controller?.dismiss(animated: true)
}
#else
hide()
#endif
hide()
} 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)
}

View File

@ -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,20 +148,15 @@ 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) {
var time = item.playbackTime
@ -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
}
}
}
}
}

View File

@ -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

View File

@ -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
View 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())
}
}

View 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>

View File

@ -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"

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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"

View File

@ -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

View File

@ -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()
}

View File

@ -80,7 +80,7 @@ struct PlaybackBar: View {
private var closeButton: some View {
Button {
presentationMode.wrappedValue.dismiss()
player.hide()
} label: {
Label(
"Close",

View File

@ -14,7 +14,7 @@ struct PlayerQueueRow: View {
var body: some View {
Group {
Button {
player.addCurrentItemToHistory()
player.prepareCurrentItemForHistory()
if history {
player.playHistory(item)

View File

@ -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,23 +50,33 @@ 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)
.contextMenu {
removeButton(item, history: true)
removeAllButton(history: true)
}
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 {
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")
}
}
}

View File

@ -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

View File

@ -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)

View 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()
}
}

View File

@ -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()
}

View File

@ -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)

View File

@ -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) {
thumbnailImage
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
)
}
}
}

View File

@ -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)

View File

@ -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")
}

View File

@ -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

View File

@ -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 */;
}

View File

@ -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()