diff --git a/Model/Player/Backends/AVPlayerBackend.swift b/Model/Player/Backends/AVPlayerBackend.swift index 1a411012..9b71a9af 100644 --- a/Model/Player/Backends/AVPlayerBackend.swift +++ b/Model/Player/Backends/AVPlayerBackend.swift @@ -492,26 +492,7 @@ final class AVPlayerBackend: PlayerBackend { model.prepareCurrentItemForHistory(finished: true) } - if model.queue.isEmpty { - #if !os(macOS) - try? AVAudioSession.sharedInstance().setActive(false) - #endif - if Defaults[.closeLastItemOnPlaybackEnd] { - model.resetQueue() - #if os(tvOS) - controller?.playerView.dismiss(animated: false) { [weak self] in - self?.controller?.dismiss(animated: true) - } - #else - model.hide() - #endif - } - } else { - if model.playingInPictureInPicture { - startPictureInPictureOnPlay = true - } - model.advanceToNextItem() - } + eofPlaybackModeAction() } private func addFrequentTimeObserver() { diff --git a/Model/Player/Backends/MPVBackend.swift b/Model/Player/Backends/MPVBackend.swift index 6332e337..2c264ffa 100644 --- a/Model/Player/Backends/MPVBackend.swift +++ b/Model/Player/Backends/MPVBackend.swift @@ -444,22 +444,7 @@ final class MPVBackend: PlayerBackend { getClientUpdates() - if Defaults[.closeLastItemOnPlaybackEnd] { - model.prepareCurrentItemForHistory(finished: true) - } - - if model.queue.isEmpty { - #if !os(macOS) - try? AVAudioSession.sharedInstance().setActive(false) - #endif - - if Defaults[.closeLastItemOnPlaybackEnd] { - model.resetQueue() - model.hide() - } - } else { - model.advanceToNextItem() - } + eofPlaybackModeAction() } func setNeedsDrawing(_ needsDrawing: Bool) { diff --git a/Model/Player/Backends/PlayerBackend.swift b/Model/Player/Backends/PlayerBackend.swift index bd788af4..3b2a4179 100644 --- a/Model/Player/Backends/PlayerBackend.swift +++ b/Model/Player/Backends/PlayerBackend.swift @@ -72,4 +72,30 @@ extension PlayerBackend { func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)? = nil) { seek(relative: time, completionHandler: completionHandler) } + + func eofPlaybackModeAction() { + switch model.playbackMode { + case .queue, .shuffle: + if Defaults[.closeLastItemOnPlaybackEnd] { + model.prepareCurrentItemForHistory(finished: true) + } + + if model.queue.isEmpty { + if Defaults[.closeLastItemOnPlaybackEnd] { + model.resetQueue() + model.hide() + } + } else { + model.advanceToNextItem() + } + case .loopOne: + model.backend.seek(to: .zero) { _ in + self.model.play() + } + case .related: + guard let item = model.autoplayItem else { return } + model.resetAutoplay() + model.advanceToItem(item) + } + } } diff --git a/Model/Player/PlayerModel.swift b/Model/Player/PlayerModel.swift index 6f254f49..23285de3 100644 --- a/Model/Player/PlayerModel.swift +++ b/Model/Player/PlayerModel.swift @@ -15,6 +15,23 @@ import SwiftyJSON #endif final class PlayerModel: ObservableObject { + enum PlaybackMode: String, CaseIterable, Defaults.Serializable { + case queue, shuffle, loopOne, related + + var systemImage: String { + switch self { + case .queue: + return "list.number" + case .shuffle: + return "shuffle" + case .loopOne: + return "repeat.1" + case .related: + return "infinity" + } + } + } + static let availableRates: [Float] = [0.5, 0.67, 0.8, 1, 1.25, 1.5, 2] let logger = Logger(label: "stream.yattee.app") @@ -65,6 +82,11 @@ final class PlayerModel: ObservableObject { @Published var restoredSegments = [Segment]() @Published var musicMode = false + @Published var playbackMode = PlaybackMode.queue { didSet { handlePlaybackModeChange() }} + @Published var autoplayItem: PlayerQueueItem? + @Published var autoplayItemSource: Video? + @Published var advancing = false + @Published var returnYouTubeDislike = ReturnYouTubeDislikeAPI() @Published var isSeeking = false { didSet { @@ -160,6 +182,7 @@ final class PlayerModel: ObservableObject { ) Defaults[.activeBackend] = .mpv + playbackMode = Defaults[.playbackMode] } func show() { @@ -489,6 +512,7 @@ final class PlayerModel: ObservableObject { backend.closeItem() aspectRatio = VideoPlayerView.defaultAspectRatio + resetAutoplay() } func closePiP() { @@ -518,10 +542,50 @@ final class PlayerModel: ObservableObject { #endif DispatchQueue.main.async(qos: .background) { [weak self] in - Defaults[.lastPlayed] = self?.currentItem + guard let self = self else { return } + Defaults[.lastPlayed] = self.currentItem + + if self.playbackMode == .related, + let video = self.currentVideo, + self.autoplayItemSource.isNil || self.autoplayItemSource?.videoID != video.videoID + { + self.setRelatedAutoplayItem() + } } } + func handlePlaybackModeChange() { + Defaults[.playbackMode] = playbackMode + + guard playbackMode == .related else { + autoplayItem = nil + return + } + setRelatedAutoplayItem() + } + + func setRelatedAutoplayItem() { + guard let video = currentVideo?.related.randomElement() else { return } + + let item = PlayerQueueItem(video) + autoplayItem = item + autoplayItemSource = video + + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in + guard let self = self else { return } + self.accounts.api.loadDetails(item, completionHandler: { newItem in + guard newItem.videoID == self.autoplayItem?.videoID else { return } + self.autoplayItem = newItem + self.controls.objectWillChange.send() + }) + } + } + + func resetAutoplay() { + autoplayItem = nil + autoplayItemSource = nil + } + #if os(macOS) var windowTitle: String { currentVideo.isNil ? "Not playing" : "\(currentVideo!.title) - \(currentVideo!.author)" diff --git a/Model/Player/PlayerQueue.swift b/Model/Player/PlayerQueue.swift index e84ad63e..70e063ef 100644 --- a/Model/Player/PlayerQueue.swift +++ b/Model/Player/PlayerQueue.swift @@ -8,20 +8,16 @@ extension PlayerModel { currentItem?.video } - func play(_ videos: [Video], shuffling: Bool = false) { - let videosToPlay = shuffling ? videos.shuffled() : videos - - guard let first = videosToPlay.first else { - return + func play(_ videos: [Video]) { + videos.forEach { video in + enqueueVideo(video, loadDetails: false) } - enqueueVideo(first, prepending: true) { _, item in - self.advanceToItem(item) - } - - videosToPlay.dropFirst().reversed().forEach { video in - enqueueVideo(video, prepending: true, loadDetails: false) - } + #if os(iOS) + onPresentPlayer = { [weak self] in self?.advanceToNextItem() } + #else + advanceToNextItem() + #endif show() } @@ -43,6 +39,8 @@ extension PlayerModel { } func playItem(_ item: PlayerQueueItem, at time: CMTime? = nil) { + advancing = false + if !playingInPictureInPicture { backend.closeItem() } @@ -79,13 +77,42 @@ extension PlayerModel { } func advanceToNextItem() { + guard !advancing else { + return + } + advancing = true prepareCurrentItemForHistory() - if let nextItem = queue.first { + var nextItem: PlayerQueueItem? + switch playbackMode { + case .queue: + nextItem = queue.first + case .shuffle: + nextItem = queue.randomElement() + case .related: + nextItem = autoplayItem + case .loopOne: + nextItem = nil + } + + resetAutoplay() + + if let nextItem = nextItem { advanceToItem(nextItem) } } + var isAdvanceToNextItemAvailable: Bool { + switch playbackMode { + case .loopOne: + return false + case .queue, .shuffle: + return !queue.isEmpty + case .related: + return !autoplayItem.isNil + } + } + func advanceToItem(_ newItem: PlayerQueueItem, at time: CMTime? = nil) { prepareCurrentItemForHistory() @@ -206,6 +233,7 @@ extension PlayerModel { private func videoLoadFailureHandler(_ error: RequestError) { navigation.presentAlert(title: "Could not load video", message: error.userMessage) + advancing = false videoBeingOpened = nil currentItem = nil } diff --git a/Model/Player/PlayerSponsorBlock.swift b/Model/Player/PlayerSponsorBlock.swift index e6d98ed5..cced8304 100644 --- a/Model/Player/PlayerSponsorBlock.swift +++ b/Model/Player/PlayerSponsorBlock.swift @@ -43,22 +43,7 @@ extension PlayerModel { self.pause() - if Defaults[.closeLastItemOnPlaybackEnd] { - self.prepareCurrentItemForHistory(finished: true) - } - - if self.queue.isEmpty { - #if !os(macOS) - try? AVAudioSession.sharedInstance().setActive(false) - #endif - - if Defaults[.closeLastItemOnPlaybackEnd] { - self.resetQueue() - self.hide() - } - } else { - self.advanceToNextItem() - } + self.backend.eofPlaybackModeAction() } return diff --git a/Shared/Defaults.swift b/Shared/Defaults.swift index 9a263009..36e505d0 100644 --- a/Shared/Defaults.swift +++ b/Shared/Defaults.swift @@ -74,6 +74,7 @@ extension Defaults.Keys { static let queue = Key<[PlayerQueueItem]>("queue", default: []) static let lastPlayed = Key("lastPlayed") + static let playbackMode = Key("playbackMode", default: .queue) static let saveHistory = Key("saveHistory", default: true) static let showWatchingProgress = Key("showWatchingProgress", default: true) diff --git a/Shared/Navigation/AppSidebarPlaylists.swift b/Shared/Navigation/AppSidebarPlaylists.swift index 4dd254c2..a34defb4 100644 --- a/Shared/Navigation/AppSidebarPlaylists.swift +++ b/Shared/Navigation/AppSidebarPlaylists.swift @@ -18,9 +18,6 @@ struct AppSidebarPlaylists: View { Button("Play All") { player.play(playlists.find(id: playlist.id)?.videos ?? []) } - Button("Shuffle All") { - player.play(playlists.find(id: playlist.id)?.videos ?? [], shuffling: true) - } Button("Edit") { navigation.presentEditPlaylistForm(playlists.find(id: playlist.id)) } diff --git a/Shared/Player/Controls/PlayerControls.swift b/Shared/Player/Controls/PlayerControls.swift index 5d1da19b..236263fe 100644 --- a/Shared/Player/Controls/PlayerControls.swift +++ b/Shared/Player/Controls/PlayerControls.swift @@ -275,6 +275,7 @@ struct PlayerControls: View { Spacer() HStack(spacing: 20) { + playbackModeButton restartVideoButton advanceToNextItemButton #if !os(tvOS) @@ -286,6 +287,12 @@ struct PlayerControls: View { .font(.system(size: 20)) } + var playbackModeButton: some View { + button("Playback Mode", systemImage: player.playbackMode.systemImage, background: false) { + player.playbackMode = player.playbackMode.next() + } + } + var seekBackwardButton: some View { button("Seek Backward", systemImage: "gobackward.10", size: 25, cornerRadius: 5, background: false) { player.backend.seek(relative: .secondsInDefaultTimescale(-10)) @@ -337,7 +344,7 @@ struct PlayerControls: View { button("Next", systemImage: "forward.fill", size: 25, cornerRadius: 5, background: false) { player.advanceToNextItem() } - .disabled(player.queue.isEmpty) + .disabled(!player.isAdvanceToNextItemAvailable) } func button( diff --git a/Shared/Playlists/PlaylistsView.swift b/Shared/Playlists/PlaylistsView.swift index 64e333e7..f0f36ee2 100644 --- a/Shared/Playlists/PlaylistsView.swift +++ b/Shared/Playlists/PlaylistsView.swift @@ -80,12 +80,8 @@ struct PlaylistsView: View { Spacer() if currentPlaylist != nil { - HStack(spacing: 0) { - playButton - - shuffleButton - } - .offset(x: 10) + playButton + .offset(x: 10) } } .padding(.horizontal) @@ -180,7 +176,6 @@ struct PlaylistsView: View { .labelStyle(.iconOnly) playButton - shuffleButton } Spacer() @@ -293,6 +288,7 @@ struct PlaylistsView: View { private var playButton: some View { Button { + player.playbackMode = .queue player.play(items.compactMap(\.video)) } label: { Image(systemName: "play") @@ -301,16 +297,6 @@ struct PlaylistsView: View { } } - private var shuffleButton: some View { - Button { - player.play(items.compactMap(\.video), shuffling: true) - } label: { - Image(systemName: "shuffle") - .padding(8) - .contentShape(Rectangle()) - } - } - private var currentPlaylist: Playlist? { model.find(id: selectedPlaylistID) ?? model.all.first } diff --git a/Shared/Views/ChannelPlaylistView.swift b/Shared/Views/ChannelPlaylistView.swift index 06cbdd2e..417f1b2f 100644 --- a/Shared/Views/ChannelPlaylistView.swift +++ b/Shared/Views/ChannelPlaylistView.swift @@ -86,8 +86,6 @@ struct ChannelPlaylistView: View { playButton .labelStyle(.iconOnly) - shuffleButton - .labelStyle(.iconOnly) } #endif VerticalCells(items: items) @@ -119,7 +117,6 @@ struct ChannelPlaylistView: View { } playButton - shuffleButton } } } @@ -137,20 +134,13 @@ struct ChannelPlaylistView: View { private var playButton: some View { Button { + player.playbackMode = .queue player.play(videos) } label: { Label("Play All", systemImage: "play") } } - private var shuffleButton: some View { - Button { - player.play(videos, shuffling: true) - } label: { - Label("Shuffle", systemImage: "shuffle") - } - } - private var videos: [Video] { items.compactMap(\.video) } diff --git a/Shared/Views/ControlsBar.swift b/Shared/Views/ControlsBar.swift index 5183181f..0dbea1a4 100644 --- a/Shared/Views/ControlsBar.swift +++ b/Shared/Views/ControlsBar.swift @@ -109,7 +109,7 @@ struct ControlsBar: View { .frame(maxWidth: .infinity) .contentShape(Rectangle()) } - .disabled(model.queue.isEmpty) + .disabled(!model.isAdvanceToNextItemAvailable) Button { model.closeCurrentItem() diff --git a/Shared/Views/PlaylistVideosView.swift b/Shared/Views/PlaylistVideosView.swift index e0c7d11d..33cea394 100644 --- a/Shared/Views/PlaylistVideosView.swift +++ b/Shared/Views/PlaylistVideosView.swift @@ -65,16 +65,11 @@ struct PlaylistVideosView: View { FavoriteButton(item: FavoriteItem(section: .channelPlaylist(playlist.id, playlist.title))) Button { + player.playbackMode = .queue player.play(videos) } label: { Label("Play All", systemImage: "play") } - - Button { - player.play(videos, shuffling: true) - } label: { - Label("Shuffle", systemImage: "shuffle") - } } } }