Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto.swift
Original file line number Diff line number Diff line change
Expand Up @@ -517,3 +517,16 @@ extension BaseItemDto {
return response.value
}
}

extension BaseItemDto {

/// Determines if this item supports shuffle playback
var canShuffle: Bool {
switch type {
case .series, .boxSet, .collectionFolder, .folder, .playlist:
return true
default:
return false
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class AVMediaPlayerProxy: VideoMediaPlayerProxy {
private var statusObserver: NSKeyValueObservation!
private var timeControlStatusObserver: NSKeyValueObservation!
private var timeObserver: Any!
private var playbackEndObserver: NSObjectProtocol?
private var managerItemObserver: AnyCancellable?
private var managerStateObserver: AnyCancellable?

Expand Down Expand Up @@ -129,6 +130,12 @@ extension AVMediaPlayerProxy {

private func playbackStopped() {
player.pause()

if let playbackEndObserver {
NotificationCenter.default.removeObserver(playbackEndObserver)
self.playbackEndObserver = nil
}

guard let timeObserver else { return }
player.removeTimeObserver(timeObserver)
// rateObserver.invalidate()
Expand All @@ -144,6 +151,21 @@ extension AVMediaPlayerProxy {

player.replaceCurrentItem(with: newAVPlayerItem)

// Remove previous observer if any
if let playbackEndObserver {
NotificationCenter.default.removeObserver(playbackEndObserver)
}

// Observe when playback ends naturally
playbackEndObserver = NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime,
object: newAVPlayerItem,
queue: .main
) { [weak self] _ in
guard let self else { return }
self.manager?.ended()
}

// TODO: protect against paused
// rateObserver = player.observe(\.rate, options: [.new, .initial]) { _, value in
// DispatchQueue.main.async {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,15 @@ class NowPlayableObserver: ViewModel, MediaPlayerObserver {
.skipBackward,
.skipForward,
.changePlaybackPosition,
// TODO: only register next/previous if there is a queue
// .nextTrack,
// .previousTrack,
.nextTrack,
.previousTrack,
]
}

private var queueCommands: [NowPlayableCommand] {
[
.nextTrack,
.previousTrack,
]
}

Expand Down Expand Up @@ -69,6 +75,10 @@ class NowPlayableObserver: ViewModel, MediaPlayerObserver {
.sink { [weak self] newValue in self?.secondsDidChange(newValue) }
.store(in: &cancellables)

manager.$queue
.sink { [weak self] newValue in self?.queueDidChange(newValue) }
.store(in: &cancellables)

Notifications[.avAudioSessionInterruption]
.publisher
.sink { i in
Expand Down Expand Up @@ -106,6 +116,31 @@ class NowPlayableObserver: ViewModel, MediaPlayerObserver {
)
}

private func queueDidChange(_ newQueue: AnyMediaPlayerQueue?) {
if let queue = newQueue {
queue.$hasNextItem
.sink { [weak self] hasNext in
self?.updateQueueCommandAvailability(hasNext: hasNext, hasPrevious: queue.hasPreviousItem)
}
.store(in: &cancellables)

queue.$hasPreviousItem
.sink { [weak self] hasPrevious in
self?.updateQueueCommandAvailability(hasNext: queue.hasNextItem, hasPrevious: hasPrevious)
}
.store(in: &cancellables)

updateQueueCommandAvailability(hasNext: queue.hasNextItem, hasPrevious: queue.hasPreviousItem)
} else {
updateQueueCommandAvailability(hasNext: false, hasPrevious: false)
}
}

private func updateQueueCommandAvailability(hasNext: Bool, hasPrevious: Bool) {
NowPlayableCommand.nextTrack.isEnabled(hasNext)
NowPlayableCommand.previousTrack.isEnabled(hasPrevious)
}

private func actionDidChange(_ newAction: MediaPlayerManager._Action) {
switch newAction {
case .stop, .error:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,10 @@ extension EpisodeMediaPlayerQueue {
manager.playNewItem(provider: provider)
}

var tvOSView: some View { EmptyView() }
var tvOSView: some View {
// TODO: Implement tvOS episodes row once player UI issues are resolved
EmptyView()
}

var iOSView: some View {
CompactOrRegularView(
Expand Down Expand Up @@ -248,7 +251,7 @@ extension EpisodeMediaPlayerQueue {
insets: .init(top: 0, leading: 0, bottom: EdgeInsets.edgePadding, trailing: 0)
)
) { item in
EpisodeRow(episode: item) {
MediaPlayerQueueItemViews.ItemRow(item: item) {
action(item)
}
.edgePadding(.horizontal)
Expand Down Expand Up @@ -292,25 +295,16 @@ extension EpisodeMediaPlayerQueue {

private struct _Body: View {

@Environment(\.safeAreaInsets)
private var safeAreaInsets: EdgeInsets

@ObservedObject
var selectionViewModel: SeasonItemViewModel

let action: (BaseItemDto) -> Void

var body: some View {
CollectionHStack(
uniqueElements: selectionViewModel.elements,
id: \.unwrappedIDHashOrZero
) { item in
EpisodeButton(episode: item) {
action(item)
}
.frame(height: 150)
}
.insets(horizontal: max(safeAreaInsets.leading, safeAreaInsets.trailing) + EdgeInsets.edgePadding)
MediaPlayerQueueItemViews.QueueHStack(
items: selectionViewModel.elements,
action: action
)
}
}

Expand Down Expand Up @@ -414,134 +408,4 @@ extension EpisodeMediaPlayerQueue {
}
}
}

private struct EpisodePreview: View {

@Default(.accentColor)
private var accentColor

@Environment(\.isSelected)
private var isSelected: Bool

let episode: BaseItemDto

var body: some View {
ZStack {
Rectangle()
.fill(.complexSecondary)

ImageView(episode.imageSource(.primary, maxWidth: 200))
.failure {
SystemImageContentView(systemName: episode.systemImage)
}
}
.overlay {
if isSelected {
ContainerRelativeShape()
.stroke(
accentColor,
lineWidth: 8
)
.clipped()
}
}
.posterStyle(.landscape)
}
}

private struct EpisodeDescription: View {

let episode: BaseItemDto

var body: some View {
DotHStack {
if let seasonEpisodeLabel = episode.seasonEpisodeLabel {
Text(seasonEpisodeLabel)
}

if let runtime = episode.runTimeLabel {
Text(runtime)
}
}
.font(.caption)
.foregroundStyle(.secondary)
}
}

private struct EpisodeRow: View {

@Default(.accentColor)
private var accentColor

@EnvironmentObject
private var manager: MediaPlayerManager

let episode: BaseItemDto
let action: () -> Void

private var isCurrentEpisode: Bool {
manager.item.id == episode.id
}

var body: some View {
ListRow(insets: .init(horizontal: EdgeInsets.edgePadding)) {
EpisodePreview(episode: episode)
.frame(width: 110)
.padding(.vertical, 8)
} content: {
VStack(alignment: .leading, spacing: 5) {
Text(episode.displayTitle)
.font(.subheadline)
.fontWeight(.semibold)
.foregroundStyle(.primary)
.lineLimit(2)
.multilineTextAlignment(.leading)

EpisodeDescription(episode: episode)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.onSelect(perform: action)
.isSelected(isCurrentEpisode)
}
}

private struct EpisodeButton: View {

@Default(.accentColor)
private var accentColor

@EnvironmentObject
private var manager: MediaPlayerManager

let episode: BaseItemDto
let action: () -> Void

private var isCurrentEpisode: Bool {
manager.item.id == episode.id
}

var body: some View {
Button(action: action) {
VStack(alignment: .leading, spacing: 5) {
EpisodePreview(episode: episode)

VStack(alignment: .leading, spacing: 5) {
Text(episode.displayTitle)
.font(.subheadline)
.fontWeight(.semibold)
.lineLimit(1)
.foregroundStyle(.primary)
.frame(height: 15)

EpisodeDescription(episode: episode)
.frame(height: 20, alignment: .top)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.foregroundStyle(.primary, .secondary)
.isSelected(isCurrentEpisode)
}
}
}
Loading