mirror of
https://github.com/yattee/yattee.git
synced 2024-12-23 22:13:41 +00:00
Playback modes
This commit is contained in:
parent
a632a4296d
commit
e0620abf9f
@ -492,26 +492,7 @@ final class AVPlayerBackend: PlayerBackend {
|
|||||||
model.prepareCurrentItemForHistory(finished: true)
|
model.prepareCurrentItemForHistory(finished: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
if model.queue.isEmpty {
|
eofPlaybackModeAction()
|
||||||
#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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addFrequentTimeObserver() {
|
private func addFrequentTimeObserver() {
|
||||||
|
@ -444,22 +444,7 @@ final class MPVBackend: PlayerBackend {
|
|||||||
|
|
||||||
getClientUpdates()
|
getClientUpdates()
|
||||||
|
|
||||||
if Defaults[.closeLastItemOnPlaybackEnd] {
|
eofPlaybackModeAction()
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func setNeedsDrawing(_ needsDrawing: Bool) {
|
func setNeedsDrawing(_ needsDrawing: Bool) {
|
||||||
|
@ -72,4 +72,30 @@ extension PlayerBackend {
|
|||||||
func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
|
func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
|
||||||
seek(relative: time, completionHandler: completionHandler)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -15,6 +15,23 @@ import SwiftyJSON
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
final class PlayerModel: ObservableObject {
|
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]
|
static let availableRates: [Float] = [0.5, 0.67, 0.8, 1, 1.25, 1.5, 2]
|
||||||
let logger = Logger(label: "stream.yattee.app")
|
let logger = Logger(label: "stream.yattee.app")
|
||||||
|
|
||||||
@ -65,6 +82,11 @@ final class PlayerModel: ObservableObject {
|
|||||||
@Published var restoredSegments = [Segment]()
|
@Published var restoredSegments = [Segment]()
|
||||||
|
|
||||||
@Published var musicMode = false
|
@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 returnYouTubeDislike = ReturnYouTubeDislikeAPI()
|
||||||
|
|
||||||
@Published var isSeeking = false { didSet {
|
@Published var isSeeking = false { didSet {
|
||||||
@ -160,6 +182,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
)
|
)
|
||||||
|
|
||||||
Defaults[.activeBackend] = .mpv
|
Defaults[.activeBackend] = .mpv
|
||||||
|
playbackMode = Defaults[.playbackMode]
|
||||||
}
|
}
|
||||||
|
|
||||||
func show() {
|
func show() {
|
||||||
@ -489,6 +512,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
|
|
||||||
backend.closeItem()
|
backend.closeItem()
|
||||||
aspectRatio = VideoPlayerView.defaultAspectRatio
|
aspectRatio = VideoPlayerView.defaultAspectRatio
|
||||||
|
resetAutoplay()
|
||||||
}
|
}
|
||||||
|
|
||||||
func closePiP() {
|
func closePiP() {
|
||||||
@ -518,9 +542,49 @@ final class PlayerModel: ObservableObject {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
DispatchQueue.main.async(qos: .background) { [weak self] in
|
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)
|
#if os(macOS)
|
||||||
var windowTitle: String {
|
var windowTitle: String {
|
||||||
|
@ -8,20 +8,16 @@ extension PlayerModel {
|
|||||||
currentItem?.video
|
currentItem?.video
|
||||||
}
|
}
|
||||||
|
|
||||||
func play(_ videos: [Video], shuffling: Bool = false) {
|
func play(_ videos: [Video]) {
|
||||||
let videosToPlay = shuffling ? videos.shuffled() : videos
|
videos.forEach { video in
|
||||||
|
enqueueVideo(video, loadDetails: false)
|
||||||
guard let first = videosToPlay.first else {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enqueueVideo(first, prepending: true) { _, item in
|
#if os(iOS)
|
||||||
self.advanceToItem(item)
|
onPresentPlayer = { [weak self] in self?.advanceToNextItem() }
|
||||||
}
|
#else
|
||||||
|
advanceToNextItem()
|
||||||
videosToPlay.dropFirst().reversed().forEach { video in
|
#endif
|
||||||
enqueueVideo(video, prepending: true, loadDetails: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
show()
|
show()
|
||||||
}
|
}
|
||||||
@ -43,6 +39,8 @@ extension PlayerModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func playItem(_ item: PlayerQueueItem, at time: CMTime? = nil) {
|
func playItem(_ item: PlayerQueueItem, at time: CMTime? = nil) {
|
||||||
|
advancing = false
|
||||||
|
|
||||||
if !playingInPictureInPicture {
|
if !playingInPictureInPicture {
|
||||||
backend.closeItem()
|
backend.closeItem()
|
||||||
}
|
}
|
||||||
@ -79,13 +77,42 @@ extension PlayerModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func advanceToNextItem() {
|
func advanceToNextItem() {
|
||||||
|
guard !advancing else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
advancing = true
|
||||||
prepareCurrentItemForHistory()
|
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)
|
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) {
|
func advanceToItem(_ newItem: PlayerQueueItem, at time: CMTime? = nil) {
|
||||||
prepareCurrentItemForHistory()
|
prepareCurrentItemForHistory()
|
||||||
|
|
||||||
@ -206,6 +233,7 @@ extension PlayerModel {
|
|||||||
|
|
||||||
private func videoLoadFailureHandler(_ error: RequestError) {
|
private func videoLoadFailureHandler(_ error: RequestError) {
|
||||||
navigation.presentAlert(title: "Could not load video", message: error.userMessage)
|
navigation.presentAlert(title: "Could not load video", message: error.userMessage)
|
||||||
|
advancing = false
|
||||||
videoBeingOpened = nil
|
videoBeingOpened = nil
|
||||||
currentItem = nil
|
currentItem = nil
|
||||||
}
|
}
|
||||||
|
@ -43,22 +43,7 @@ extension PlayerModel {
|
|||||||
|
|
||||||
self.pause()
|
self.pause()
|
||||||
|
|
||||||
if Defaults[.closeLastItemOnPlaybackEnd] {
|
self.backend.eofPlaybackModeAction()
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
|
@ -74,6 +74,7 @@ extension Defaults.Keys {
|
|||||||
|
|
||||||
static let queue = Key<[PlayerQueueItem]>("queue", default: [])
|
static let queue = Key<[PlayerQueueItem]>("queue", default: [])
|
||||||
static let lastPlayed = Key<PlayerQueueItem?>("lastPlayed")
|
static let lastPlayed = Key<PlayerQueueItem?>("lastPlayed")
|
||||||
|
static let playbackMode = Key<PlayerModel.PlaybackMode>("playbackMode", default: .queue)
|
||||||
|
|
||||||
static let saveHistory = Key<Bool>("saveHistory", default: true)
|
static let saveHistory = Key<Bool>("saveHistory", default: true)
|
||||||
static let showWatchingProgress = Key<Bool>("showWatchingProgress", default: true)
|
static let showWatchingProgress = Key<Bool>("showWatchingProgress", default: true)
|
||||||
|
@ -18,9 +18,6 @@ struct AppSidebarPlaylists: View {
|
|||||||
Button("Play All") {
|
Button("Play All") {
|
||||||
player.play(playlists.find(id: playlist.id)?.videos ?? [])
|
player.play(playlists.find(id: playlist.id)?.videos ?? [])
|
||||||
}
|
}
|
||||||
Button("Shuffle All") {
|
|
||||||
player.play(playlists.find(id: playlist.id)?.videos ?? [], shuffling: true)
|
|
||||||
}
|
|
||||||
Button("Edit") {
|
Button("Edit") {
|
||||||
navigation.presentEditPlaylistForm(playlists.find(id: playlist.id))
|
navigation.presentEditPlaylistForm(playlists.find(id: playlist.id))
|
||||||
}
|
}
|
||||||
|
@ -275,6 +275,7 @@ struct PlayerControls: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
HStack(spacing: 20) {
|
HStack(spacing: 20) {
|
||||||
|
playbackModeButton
|
||||||
restartVideoButton
|
restartVideoButton
|
||||||
advanceToNextItemButton
|
advanceToNextItemButton
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
@ -286,6 +287,12 @@ struct PlayerControls: View {
|
|||||||
.font(.system(size: 20))
|
.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 {
|
var seekBackwardButton: some View {
|
||||||
button("Seek Backward", systemImage: "gobackward.10", size: 25, cornerRadius: 5, background: false) {
|
button("Seek Backward", systemImage: "gobackward.10", size: 25, cornerRadius: 5, background: false) {
|
||||||
player.backend.seek(relative: .secondsInDefaultTimescale(-10))
|
player.backend.seek(relative: .secondsInDefaultTimescale(-10))
|
||||||
@ -337,7 +344,7 @@ struct PlayerControls: View {
|
|||||||
button("Next", systemImage: "forward.fill", size: 25, cornerRadius: 5, background: false) {
|
button("Next", systemImage: "forward.fill", size: 25, cornerRadius: 5, background: false) {
|
||||||
player.advanceToNextItem()
|
player.advanceToNextItem()
|
||||||
}
|
}
|
||||||
.disabled(player.queue.isEmpty)
|
.disabled(!player.isAdvanceToNextItemAvailable)
|
||||||
}
|
}
|
||||||
|
|
||||||
func button(
|
func button(
|
||||||
|
@ -80,11 +80,7 @@ struct PlaylistsView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
if currentPlaylist != nil {
|
if currentPlaylist != nil {
|
||||||
HStack(spacing: 0) {
|
|
||||||
playButton
|
playButton
|
||||||
|
|
||||||
shuffleButton
|
|
||||||
}
|
|
||||||
.offset(x: 10)
|
.offset(x: 10)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -180,7 +176,6 @@ struct PlaylistsView: View {
|
|||||||
.labelStyle(.iconOnly)
|
.labelStyle(.iconOnly)
|
||||||
|
|
||||||
playButton
|
playButton
|
||||||
shuffleButton
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
@ -293,6 +288,7 @@ struct PlaylistsView: View {
|
|||||||
|
|
||||||
private var playButton: some View {
|
private var playButton: some View {
|
||||||
Button {
|
Button {
|
||||||
|
player.playbackMode = .queue
|
||||||
player.play(items.compactMap(\.video))
|
player.play(items.compactMap(\.video))
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "play")
|
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? {
|
private var currentPlaylist: Playlist? {
|
||||||
model.find(id: selectedPlaylistID) ?? model.all.first
|
model.find(id: selectedPlaylistID) ?? model.all.first
|
||||||
}
|
}
|
||||||
|
@ -86,8 +86,6 @@ struct ChannelPlaylistView: View {
|
|||||||
|
|
||||||
playButton
|
playButton
|
||||||
.labelStyle(.iconOnly)
|
.labelStyle(.iconOnly)
|
||||||
shuffleButton
|
|
||||||
.labelStyle(.iconOnly)
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
VerticalCells(items: items)
|
VerticalCells(items: items)
|
||||||
@ -119,7 +117,6 @@ struct ChannelPlaylistView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
playButton
|
playButton
|
||||||
shuffleButton
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -137,20 +134,13 @@ struct ChannelPlaylistView: View {
|
|||||||
|
|
||||||
private var playButton: some View {
|
private var playButton: some View {
|
||||||
Button {
|
Button {
|
||||||
|
player.playbackMode = .queue
|
||||||
player.play(videos)
|
player.play(videos)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Play All", systemImage: "play")
|
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] {
|
private var videos: [Video] {
|
||||||
items.compactMap(\.video)
|
items.compactMap(\.video)
|
||||||
}
|
}
|
||||||
|
@ -109,7 +109,7 @@ struct ControlsBar: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.disabled(model.queue.isEmpty)
|
.disabled(!model.isAdvanceToNextItemAvailable)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
model.closeCurrentItem()
|
model.closeCurrentItem()
|
||||||
|
@ -65,16 +65,11 @@ struct PlaylistVideosView: View {
|
|||||||
FavoriteButton(item: FavoriteItem(section: .channelPlaylist(playlist.id, playlist.title)))
|
FavoriteButton(item: FavoriteItem(section: .channelPlaylist(playlist.id, playlist.title)))
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
|
player.playbackMode = .queue
|
||||||
player.play(videos)
|
player.play(videos)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Play All", systemImage: "play")
|
Label("Play All", systemImage: "play")
|
||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
|
||||||
player.play(videos, shuffling: true)
|
|
||||||
} label: {
|
|
||||||
Label("Shuffle", systemImage: "shuffle")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user