diff --git a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto.swift b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto.swift index 62a252b35d..ecbbc2ae89 100644 --- a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto.swift +++ b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto.swift @@ -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 + } + } +} diff --git a/Shared/Objects/MediaPlayerManager/MediaPlayerProxy/MediaPlayerProxy+AVPlayer.swift b/Shared/Objects/MediaPlayerManager/MediaPlayerProxy/MediaPlayerProxy+AVPlayer.swift index 2d115bb729..824c9fb0e1 100644 --- a/Shared/Objects/MediaPlayerManager/MediaPlayerProxy/MediaPlayerProxy+AVPlayer.swift +++ b/Shared/Objects/MediaPlayerManager/MediaPlayerProxy/MediaPlayerProxy+AVPlayer.swift @@ -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? @@ -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() @@ -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 { diff --git a/Shared/Objects/MediaPlayerManager/NowPlayable/NowPlayableObserver.swift b/Shared/Objects/MediaPlayerManager/NowPlayable/NowPlayableObserver.swift index 05e24e5e13..dce9d88f06 100644 --- a/Shared/Objects/MediaPlayerManager/NowPlayable/NowPlayableObserver.swift +++ b/Shared/Objects/MediaPlayerManager/NowPlayable/NowPlayableObserver.swift @@ -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, ] } @@ -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 @@ -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: diff --git a/Shared/Objects/MediaPlayerManager/Supplements/EpisodeMediaPlayerQueue.swift b/Shared/Objects/MediaPlayerManager/Supplements/EpisodeMediaPlayerQueue.swift index 277e329d02..efa40202f1 100644 --- a/Shared/Objects/MediaPlayerManager/Supplements/EpisodeMediaPlayerQueue.swift +++ b/Shared/Objects/MediaPlayerManager/Supplements/EpisodeMediaPlayerQueue.swift @@ -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( @@ -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) @@ -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 + ) } } @@ -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) - } - } } diff --git a/Shared/Objects/MediaPlayerManager/Supplements/MediaPlayerQueueItemViews.swift b/Shared/Objects/MediaPlayerManager/Supplements/MediaPlayerQueueItemViews.swift new file mode 100644 index 0000000000..cab8ff6a41 --- /dev/null +++ b/Shared/Objects/MediaPlayerManager/Supplements/MediaPlayerQueueItemViews.swift @@ -0,0 +1,190 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import CollectionHStack +import Defaults +import JellyfinAPI +import SwiftUI + +enum MediaPlayerQueueItemViews { + + struct ItemPreview: View { + + @Default(.accentColor) + private var accentColor + + @Environment(\.isSelected) + private var isSelected: Bool + + let item: BaseItemDto + + var body: some View { + ZStack { + Rectangle() + .fill(.complexSecondary) + + ImageView(item.imageSource(.primary, maxWidth: 200)) + .failure { + SystemImageContentView(systemName: item.systemImage) + } + } + .overlay { + if isSelected { + ContainerRelativeShape() + .stroke( + accentColor, + lineWidth: 8 + ) + .clipped() + } + } + .posterStyle(.landscape) + } + } + + struct ItemDescription: View { + + let item: BaseItemDto + + var body: some View { + DotHStack { + if item.type == .episode, let seasonEpisodeLabel = item.seasonEpisodeLabel { + Text(seasonEpisodeLabel) + } + + if let runtime = item.runTimeLabel { + Text(runtime) + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + } + + struct ItemRow: View { + + @Default(.accentColor) + private var accentColor + + @EnvironmentObject + private var manager: MediaPlayerManager + + let item: BaseItemDto + let action: () -> Void + + private var isCurrentItem: Bool { + manager.item.id == item.id + } + + var body: some View { + ListRow(insets: .init(horizontal: EdgeInsets.edgePadding)) { + ItemPreview(item: item) + .frame(width: 110) + .padding(.vertical, 8) + } content: { + VStack(alignment: .leading, spacing: 5) { + Text(item.displayTitle) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundStyle(.primary) + .lineLimit(2) + .multilineTextAlignment(.leading) + + ItemDescription(item: item) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .onSelect(perform: action) + .isSelected(isCurrentItem) + } + } + + struct ItemButton: View { + + @Default(.accentColor) + private var accentColor + + @EnvironmentObject + private var manager: MediaPlayerManager + + let item: BaseItemDto + let action: () -> Void + + private var isCurrentItem: Bool { + manager.item.id == item.id + } + + var body: some View { + Button(action: action) { + VStack(alignment: .leading, spacing: 5) { + ItemPreview(item: item) + + VStack(alignment: .leading, spacing: 5) { + Text(item.displayTitle) + .font(.subheadline) + .fontWeight(.semibold) + .lineLimit(1) + .foregroundStyle(.primary) + .frame(height: 15) + + ItemDescription(item: item) + .frame(height: 20, alignment: .top) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .foregroundStyle(.primary, .secondary) + .isSelected(isCurrentItem) + } + } + + struct QueueHStack: View where Data.Element == BaseItemDto, Data.Index == Int { + + @Environment(\.safeAreaInsets) + private var safeAreaInsets: EdgeInsets + + @EnvironmentObject + private var manager: MediaPlayerManager + + @StateObject + private var proxy = CollectionHStackProxy() + + let items: Data + let action: (BaseItemDto) -> Void + + var body: some View { + CollectionHStack( + uniqueElements: items, + id: \.unwrappedIDHashOrZero + ) { item in + ItemButton(item: item) { + action(item) + } + .frame(height: 150) + } + .scrollBehavior(.continuousLeadingEdge) + .insets(horizontal: max(safeAreaInsets.leading, safeAreaInsets.trailing) + EdgeInsets.edgePadding) + .proxy(proxy) + .onAppear { + scrollToCurrentItem() + } + .onChange(of: manager.item.id) { _ in + scrollToCurrentItem() + } + .onChange(of: items.count) { _ in + scrollToCurrentItem() + } + } + + private func scrollToCurrentItem() { + guard let currentItemID = manager.item.id else { return } + guard let currentItem = items.first(where: { $0.id == currentItemID }) else { return } + proxy.scrollTo(id: currentItem.unwrappedIDHashOrZero, animated: false) + } + } +} diff --git a/Shared/Objects/MediaPlayerManager/Supplements/ShuffleMediaPlayerQueue.swift b/Shared/Objects/MediaPlayerManager/Supplements/ShuffleMediaPlayerQueue.swift new file mode 100644 index 0000000000..a1e5064a2a --- /dev/null +++ b/Shared/Objects/MediaPlayerManager/Supplements/ShuffleMediaPlayerQueue.swift @@ -0,0 +1,286 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import CollectionHStack +import CollectionVGrid +import Combine +import Defaults +import Foundation +import JellyfinAPI +import Logging +import SwiftUI + +@MainActor +class ShuffleMediaPlayerQueue: ViewModel, MediaPlayerQueue { + + weak var manager: MediaPlayerManager? { + didSet { + cancellables = [] + guard let manager else { return } + manager.$playbackItem + .receive(on: DispatchQueue.main) + .sink { [weak self] newItem in + Task { @MainActor [weak self] in + self?.didReceive(newItem: newItem) + } + } + .store(in: &cancellables) + } + } + + let displayTitle: String = L10n.episodes + let id: String = "ShuffleMediaPlayerQueue" + + @Published + var nextItem: MediaPlayerItemProvider? = nil + @Published + var previousItem: MediaPlayerItemProvider? = nil + + @Published + var hasNextItem: Bool = false + @Published + var hasPreviousItem: Bool = false + + lazy var hasNextItemPublisher: Published.Publisher = $hasNextItem + lazy var hasPreviousItemPublisher: Published.Publisher = $hasPreviousItem + lazy var nextItemPublisher: Published.Publisher = $nextItem + lazy var previousItemPublisher: Published.Publisher = $previousItem + + private var shuffledItems: [BaseItemDto] = [] + private var currentIndex: Int = 0 + private var fetchMoreItems: (() async throws -> [BaseItemDto])? + private var excludeItemIDs: Set = [] + private var isFetchingMore = false + + init(items: [BaseItemDto], fetchMoreItems: (() async throws -> [BaseItemDto])? = nil) { + self.shuffledItems = items + self.fetchMoreItems = fetchMoreItems + self.excludeItemIDs = Set(items.compactMap(\.id)) + super.init() + updateAdjacentItems() + } + + var videoPlayerBody: some PlatformView { + ShuffleQueueOverlay(items: shuffledItems, currentIndex: currentIndex) + } + + private func didReceive(newItem: MediaPlayerItem?) { + guard let newItem else { + updateAdjacentItems() + return + } + + if let index = shuffledItems.firstIndex(where: { $0.id == newItem.baseItem.id }) { + currentIndex = index + } + + updateAdjacentItems() + } + + private func updateAdjacentItems() { + let hasPrevious = currentIndex > 0 + let remainingItems = shuffledItems.count - currentIndex - 1 + let hasNext = remainingItems > 0 + + logger + .info( + "Updating adjacent items: current index = \(currentIndex), hasNext = \(hasNext), hasPrevious = \(hasPrevious), remaining = \(remainingItems)" + ) + + if remainingItems <= ShuffleQueueConstants.fetchThreshold, let fetchMore = fetchMoreItems, !isFetchingMore { + Task { @MainActor [weak self] in + await self?.fetchMoreItemsIfNeeded() + } + } + + var nextProvider: MediaPlayerItemProvider? + var previousProvider: MediaPlayerItemProvider? + + if hasNext { + let nextItem = shuffledItems[currentIndex + 1] + logger.info("Next item: \(nextItem.displayTitle)") + nextProvider = MediaPlayerItemProvider(item: nextItem) { item in + try await MediaPlayerItem.build(for: item) { + $0.userData?.playbackPositionTicks = .zero + } + } + } else { + logger.info("No next item available") + } + + if hasPrevious { + let previousItem = shuffledItems[currentIndex - 1] + logger.info("Previous item: \(previousItem.displayTitle)") + previousProvider = MediaPlayerItemProvider(item: previousItem) { item in + try await MediaPlayerItem.build(for: item) { + $0.userData?.playbackPositionTicks = .zero + } + } + } + + self.nextItem = nextProvider + self.previousItem = previousProvider + self.hasNextItem = hasNext + self.hasPreviousItem = hasPrevious + + logger + .info( + "Updated: nextItem = \(nextProvider?.item.displayTitle ?? "nil"), previousItem = \(previousProvider?.item.displayTitle ?? "nil")" + ) + } + + private func fetchMoreItemsIfNeeded() async { + guard let fetchMore = fetchMoreItems, !isFetchingMore else { return } + + let remainingItems = shuffledItems.count - currentIndex - 1 + guard remainingItems <= ShuffleQueueConstants.fetchThreshold else { return } + + isFetchingMore = true + defer { isFetchingMore = false } + + do { + let newItems = try await fetchMore() + + let uniqueNewItems = newItems.filter { item in + guard let id = item.id else { return false } + return !excludeItemIDs.contains(id) + } + + guard !uniqueNewItems.isEmpty else { + logger.info("No new unique items to add (all were duplicates)") + return + } + + shuffledItems.append(contentsOf: uniqueNewItems) + excludeItemIDs.formUnion(uniqueNewItems.compactMap(\.id)) + + logger.info("Fetched \(uniqueNewItems.count) more items, queue size now: \(shuffledItems.count)") + + updateAdjacentItems() + } catch { + logger.error("Error fetching more items: \(error)") + } + } +} + +extension ShuffleMediaPlayerQueue { + + private struct ShuffleQueueOverlay: PlatformView { + + let items: [BaseItemDto] + let currentIndex: Int + + var iOSView: some View { + CompactOrRegularShuffleView(items: items, currentIndex: currentIndex) + } + + var tvOSView: some View { + EmptyView() + } + } + + private struct CompactOrRegularShuffleView: View { + + @EnvironmentObject + private var containerState: VideoPlayerContainerState + + let items: [BaseItemDto] + let currentIndex: Int + + var body: some View { + CompactOrRegularView(isCompact: containerState.isCompact) { + CompactShuffleView(items: items, currentIndex: currentIndex) + } regularView: { + RegularShuffleView(items: items, currentIndex: currentIndex) + } + } + } + + private struct CompactShuffleView: View { + + @EnvironmentObject + private var containerState: VideoPlayerContainerState + @EnvironmentObject + private var manager: MediaPlayerManager + + let items: [BaseItemDto] + let currentIndex: Int + + private func selectItem(_ item: BaseItemDto) { + let provider = MediaPlayerItemProvider(item: item) { item in + try await MediaPlayerItem.build(for: item) { + $0.userData?.playbackPositionTicks = .zero + } + } + + manager.playNewItem(provider: provider) + containerState.select(supplement: nil) + } + + var body: some View { + CollectionVGrid( + uniqueElements: items, + id: \.unwrappedIDHashOrZero, + layout: .columns( + 1, + insets: .init(top: 0, leading: 0, bottom: EdgeInsets.edgePadding, trailing: 0) + ) + ) { item in + MediaPlayerQueueItemViews.ItemRow(item: item) { + selectItem(item) + } + .edgePadding(.horizontal) + } + } + } + + private struct RegularShuffleView: View { + + @EnvironmentObject + private var containerState: VideoPlayerContainerState + @EnvironmentObject + private var manager: MediaPlayerManager + + let items: [BaseItemDto] + let currentIndex: Int + + private func selectItem(_ item: BaseItemDto) { + let provider = MediaPlayerItemProvider(item: item) { item in + try await MediaPlayerItem.build(for: item) { + $0.userData?.playbackPositionTicks = .zero + } + } + + manager.playNewItem(provider: provider) + containerState.select(supplement: nil) + } + + var body: some View { + MediaPlayerQueueItemViews.QueueHStack( + items: items, + action: selectItem + ) + } + } +} + +// MARK: - Constants + +enum ShuffleQueueConstants { + + /// Target number of items to maintain in the shuffle queue + static let targetQueueSize = 20 + + /// Number of items remaining before fetching more (half of target size) + static var fetchThreshold: Int { + targetQueueSize / 2 + } + + /// Page size for fetching items from the API + static let pageSize = 20 +} diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index 7a2a93487e..90b9c91e84 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -1380,6 +1380,10 @@ internal enum L10n { internal static let showUnwatched = L10n.tr("Localizable", "showUnwatched", fallback: "Show unwatched") /// Show watched internal static let showWatched = L10n.tr("Localizable", "showWatched", fallback: "Show watched") + /// Shuffle + internal static let shuffle = L10n.tr("Localizable", "shuffle", fallback: "Shuffle") + /// Shuffling... + internal static let shuffling = L10n.tr("Localizable", "shuffling", fallback: "Shuffling...") /// Shutdown server internal static let shutdownServer = L10n.tr("Localizable", "shutdownServer", fallback: "Shutdown server") /// Are you sure you want to shutdown the server? diff --git a/Shared/ViewModels/ItemViewModel/CollectionItemViewModel.swift b/Shared/ViewModels/ItemViewModel/CollectionItemViewModel.swift index 03af54c10e..dddcd10fcb 100644 --- a/Shared/ViewModels/ItemViewModel/CollectionItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/CollectionItemViewModel.swift @@ -63,4 +63,17 @@ final class CollectionItemViewModel: ItemViewModel { .elements .randomElement() } + + // MARK: - Get Shuffled Items + + @MainActor + override func getShuffledItems(excluding excludeItemIDs: [String] = []) async throws -> [BaseItemDto] { + try await ItemViewModel.fetchShuffledItemsPaginated( + userSession: userSession, + parentID: item.id, + includeItemTypes: [.episode, .movie, .video, .musicVideo, .trailer], + excludeItemIDs: excludeItemIDs, + filterPlayable: true + ) + } } diff --git a/Shared/ViewModels/ItemViewModel/ItemViewModel.swift b/Shared/ViewModels/ItemViewModel/ItemViewModel.swift index a6c7ba3965..dcb275c0d4 100644 --- a/Shared/ViewModels/ItemViewModel/ItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/ItemViewModel.swift @@ -34,6 +34,7 @@ class ItemViewModel: ViewModel, Stateful { enum BackgroundState: Hashable { case refresh + case shuffling } // MARK: State @@ -356,6 +357,187 @@ class ItemViewModel: ViewModel, Stateful { return response?.value.items ?? [] } + // MARK: - Get Shuffled Items + + @MainActor + func getShuffledItems(excluding excludeItemIDs: [String] = []) async throws -> [BaseItemDto] { + [] + } + + @MainActor + static func fetchShuffledItemsPaginated( + userSession: UserSession, + parentID: String?, + includeItemTypes: [BaseItemKind], + excludeItemIDs: [String] = [], + enableUserData: Bool = false, + isMissing: Bool? = nil, + filterPlayable: Bool = false, + applyParentParameters: ((Paths.GetItemsByUserIDParameters) -> Paths.GetItemsByUserIDParameters)? = nil + ) async throws -> [BaseItemDto] { + let pageSize = ShuffleQueueConstants.pageSize + let maxItems = ShuffleQueueConstants.targetQueueSize + var allItems: [BaseItemDto] = [] + var startIndex = 0 + var currentExcludeIDs = excludeItemIDs + + while allItems.count < maxItems { + var parameters = Paths.GetItemsByUserIDParameters() + parameters.enableUserData = enableUserData + parameters.fields = .MinimumFields + parameters.isRecursive = true + parameters.parentID = parentID + parameters.sortBy = [ItemSortBy.random.rawValue] + parameters.includeItemTypes = includeItemTypes + parameters.limit = min(pageSize, maxItems - allItems.count) + parameters.startIndex = startIndex + + if let isMissing = isMissing { + parameters.isMissing = isMissing + } + + if currentExcludeIDs.isNotEmpty { + parameters.excludeItemIDs = currentExcludeIDs + } + + if let applyParentParameters = applyParentParameters { + parameters = applyParentParameters(parameters) + } + + let request = Paths.getItemsByUserID( + userID: userSession.user.id, + parameters: parameters + ) + let response = try await userSession.client.send(request) + + var pageItems = response.value.items ?? [] + if filterPlayable { + pageItems = pageItems.filter(\.isPlayable) + } + allItems.append(contentsOf: pageItems) + + currentExcludeIDs.append(contentsOf: pageItems.compactMap(\.id)) + + if pageItems.count < pageSize || allItems.count >= maxItems { + break + } + + startIndex += pageSize + } + + return allItems + } + + // MARK: - Play Shuffle + + func playShuffle(router: NavigationCoordinator.Router) { + guard item.canShuffle else { + logger.error("Shuffle not supported for item type: \(String(describing: item.type))") + return + } + + backgroundStates.insert(.shuffling) + + Task { @MainActor [weak self] in + guard let self else { return } + do { + let shuffledItems = try await self.getShuffledItems() + + guard shuffledItems.isNotEmpty else { + self.logger.error("No items to shuffle") + self.backgroundStates.remove(.shuffling) + return + } + + guard var firstItem = shuffledItems.first else { + self.logger.error("No first item in shuffled list") + self.backgroundStates.remove(.shuffling) + return + } + + firstItem.userData?.playbackPositionTicks = 0 + + let fetchState = ShuffleActionHelper.FetchState(excludeItemIDs: Set(shuffledItems.compactMap(\.id))) + let fetchMoreItems: () async throws -> [BaseItemDto] = { [weak self, fetchState] in + guard let self else { return [] } + let newItems = try await self.getShuffledItems(excluding: Array(fetchState.excludeItemIDs)) + fetchState.excludeItemIDs.formUnion(newItems.compactMap(\.id)) + return newItems + } + + let queue = ShuffleMediaPlayerQueue(items: shuffledItems, fetchMoreItems: fetchMoreItems) + + let mediaSource: MediaSourceInfo? + #if os(tvOS) + let containerTypes: Set = [.series, .boxSet, .collectionFolder, .folder, .playlist] + if let itemType = self.item.type, containerTypes.contains(itemType) { + mediaSource = MediaSourceInfo() + } else { + guard let selectedMediaSource = self.selectedMediaSource else { + self.logger.error("Shuffle selected with no media source for playable item") + self.backgroundStates.remove(.shuffling) + return + } + mediaSource = selectedMediaSource + } + #else + mediaSource = nil + #endif + + let manager = self.createShuffleManager( + firstItem: firstItem, + queue: queue, + mediaSource: mediaSource + ) + + self.setupShuffleLoaderObservers(manager: manager) + router.route(to: .videoPlayer(manager: manager)) + } catch { + self.logger.error("Error shuffling items: \(error)") + self.backgroundStates.remove(.shuffling) + } + } + } + + @MainActor + private func createShuffleManager( + firstItem: BaseItemDto, + queue: ShuffleMediaPlayerQueue, + mediaSource: MediaSourceInfo? + ) -> MediaPlayerManager { + MediaPlayerManager( + item: firstItem, + queue: queue + ) { item in + if let mediaSource { + try await MediaPlayerItem.build(for: item, mediaSource: mediaSource) + } else { + try await MediaPlayerItem.build(for: item) { + $0.userData?.playbackPositionTicks = 0 + } + } + } + } + + @MainActor + private func setupShuffleLoaderObservers(manager: MediaPlayerManager) { + manager.$state + .sink { [weak self] state in + if state == .playback { + self?.backgroundStates.remove(.shuffling) + } + } + .store(in: &manager.cancellables) + + manager.$playbackItem + .sink { [weak self] playbackItem in + if playbackItem != nil { + self?.backgroundStates.remove(.shuffling) + } + } + .store(in: &manager.cancellables) + } + private func setIsPlayed(_ isPlayed: Bool) async throws { guard let itemID = item.id else { return } diff --git a/Shared/ViewModels/ItemViewModel/SeriesItemViewModel.swift b/Shared/ViewModels/ItemViewModel/SeriesItemViewModel.swift index d8313ce91f..53ea4dddab 100644 --- a/Shared/ViewModels/ItemViewModel/SeriesItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/SeriesItemViewModel.swift @@ -31,7 +31,7 @@ final class SeriesItemViewModel: ItemViewModel { switch action { case .backgroundRefresh, .refresh: - let parentState = super.respond(to: action) + _ = super.respond(to: action) seriesItemTask?.cancel() @@ -146,4 +146,18 @@ final class SeriesItemViewModel: ItemViewModel { return response.value.items ?? [] } + + // MARK: - Get Shuffled Items + + @MainActor + override func getShuffledItems(excluding excludeItemIDs: [String] = []) async throws -> [BaseItemDto] { + try await ItemViewModel.fetchShuffledItemsPaginated( + userSession: userSession, + parentID: item.id, + includeItemTypes: [.episode], + excludeItemIDs: excludeItemIDs, + enableUserData: true, + isMissing: Defaults[.Customization.shouldShowMissingEpisodes] ? nil : false + ) + } } diff --git a/Shared/ViewModels/ItemViewModel/ShuffleActionHelper.swift b/Shared/ViewModels/ItemViewModel/ShuffleActionHelper.swift new file mode 100644 index 0000000000..a9c85a986c --- /dev/null +++ b/Shared/ViewModels/ItemViewModel/ShuffleActionHelper.swift @@ -0,0 +1,302 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import Factory +import Foundation +import JellyfinAPI +import Logging +import SwiftUI + +@MainActor +struct ShuffleActionHelper { + + private let logger = Logger.swiftfin() + + final class FetchState { + var excludeItemIDs: Set + var excludeLibraryItemIDs: Set? + + init(excludeItemIDs: Set, excludeLibraryItemIDs: Set? = nil) { + self.excludeItemIDs = excludeItemIDs + self.excludeLibraryItemIDs = excludeLibraryItemIDs + } + } + + func shuffleItem( + _ item: BaseItemDto, + mediaSource: MediaSourceInfo, + viewModel: ItemViewModel + ) async throws -> (firstItem: BaseItemDto, queue: ShuffleMediaPlayerQueue)? { + guard item.canShuffle else { + logger.error("Shuffle not supported for item type: \(String(describing: item.type))") + return nil + } + + let shuffledItems = try await viewModel.getShuffledItems() + + guard shuffledItems.isNotEmpty else { + logger.error("No items to shuffle") + return nil + } + + guard var firstItem = shuffledItems.first else { + logger.error("No first item in shuffled list") + return nil + } + + firstItem.userData?.playbackPositionTicks = 0 + + let fetchState = FetchState(excludeItemIDs: Set(shuffledItems.compactMap(\.id))) + let fetchMoreItems: () async throws -> [BaseItemDto] = { [weak viewModel, fetchState] in + guard let viewModel else { return [] } + let newItems = try await viewModel.getShuffledItems(excluding: Array(fetchState.excludeItemIDs)) + fetchState.excludeItemIDs.formUnion(newItems.compactMap(\.id)) + return newItems + } + + let queue = ShuffleMediaPlayerQueue(items: shuffledItems, fetchMoreItems: fetchMoreItems) + + return (firstItem, queue) + } + + func shuffleAndPlay( + _ item: BaseItemDto, + mediaSource: MediaSourceInfo, + viewModel: ItemViewModel, + router: NavigationCoordinator.Router + ) async throws { + guard let result = try await shuffleItem(item, mediaSource: mediaSource, viewModel: viewModel) else { + return + } + + let manager = MediaPlayerManager( + item: result.firstItem, + queue: result.queue + ) { item in + try await MediaPlayerItem.build(for: item, mediaSource: mediaSource) + } + + await MainActor.run { + router.route(to: .videoPlayer(manager: manager)) + } + } + + func shuffleAndPlay( + _ item: BaseItemDto, + viewModel: ItemViewModel, + router: NavigationCoordinator.Router, + autoSelectMediaSource: Bool = false + ) async throws { + if autoSelectMediaSource { + guard let result = try await shuffleItem(item, mediaSource: MediaSourceInfo(), viewModel: viewModel) else { + return + } + + let provider = MediaPlayerItemProvider(item: result.firstItem) { item in + try await MediaPlayerItem.build(for: item) { + $0.userData?.playbackPositionTicks = 0 + } + } + + await MainActor.run { + router.route( + to: .videoPlayer( + provider: provider, + queue: result.queue + ) + ) + } + } else { + let containerTypes: Set = [.series, .boxSet, .collectionFolder, .folder, .playlist] + + let mediaSource: MediaSourceInfo + if let itemType = item.type, containerTypes.contains(itemType) { + mediaSource = MediaSourceInfo() + } else { + guard let selectedMediaSource = viewModel.selectedMediaSource else { + logger.error("Shuffle selected with no media source for playable item") + return + } + mediaSource = selectedMediaSource + } + + try await shuffleAndPlay( + item, + mediaSource: mediaSource, + viewModel: viewModel, + router: router + ) + } + } + + static func collectPlayableItems(from items: [BaseItemDto]) async throws -> [BaseItemDto] { + guard let userSession = Container.shared.currentUserSession() else { + throw JellyfinAPIError("No user session") + } + + var playableItems: [BaseItemDto] = [] + + for item in items { + if item.type == .series || item.type == .boxSet || item.type == .collectionFolder || item.type == .folder { + let contents = try await fetchVideoItemsPaginated(for: item, userSession: userSession) + playableItems.append(contentsOf: contents) + } else if item.isPlayable && item.mediaSources?.isNotEmpty == true { + playableItems.append(item) + } + } + + // .shuffled() needed to break up grouping from sequential container expansion + return playableItems.shuffled() + } + + private static func fetchVideoItemsPaginated( + for parent: BaseItemDto, + userSession: UserSession + ) async throws -> [BaseItemDto] { + let pageSize = ShuffleQueueConstants.pageSize + let maxItems = ShuffleQueueConstants.targetQueueSize + var allItems: [BaseItemDto] = [] + var startIndex = 0 + var excludeItemIDs: [String] = [] + + while allItems.count < maxItems { + var parameters = Paths.GetItemsByUserIDParameters() + parameters.fields = .MinimumFields + parameters.isRecursive = true + parameters.parentID = parent.id + parameters.sortBy = [ItemSortBy.random.rawValue] + parameters.includeItemTypes = [.episode, .movie, .video, .musicVideo, .trailer] + parameters.limit = min(pageSize, maxItems - allItems.count) + parameters.startIndex = startIndex + + if excludeItemIDs.isNotEmpty { + parameters.excludeItemIDs = excludeItemIDs + } + + let request = Paths.getItemsByUserID( + userID: userSession.user.id, + parameters: parameters + ) + let response = try await userSession.client.send(request) + + let pageItems = (response.value.items ?? []) + .filter { $0.isPlayable && $0.mediaSources?.isNotEmpty == true } + + allItems.append(contentsOf: pageItems) + excludeItemIDs.append(contentsOf: pageItems.compactMap(\.id)) + + if pageItems.count < pageSize || allItems.count >= maxItems { + break + } + + startIndex += pageSize + } + + return allItems + } + + static func createLibraryShuffleManager( + firstItem: BaseItemDto, + playableItems: [BaseItemDto], + originalLibraryItems: [BaseItemDto], + viewModel: ItemLibraryViewModel, + backgroundStates: inout Set.BackgroundState> + ) -> MediaPlayerManager { + var mutableFirstItem = firstItem + mutableFirstItem.userData?.playbackPositionTicks = 0 + + let excludeItemIDs = Set(playableItems.compactMap(\.id)) + let excludeLibraryItemIDs = Set(originalLibraryItems.compactMap(\.id)) + + let fetchState = FetchState(excludeItemIDs: excludeItemIDs, excludeLibraryItemIDs: excludeLibraryItemIDs) + let fetchMoreItems: () async throws -> [BaseItemDto] = { [weak viewModel, fetchState] in + guard let viewModel = viewModel as? ItemLibraryViewModel else { return [] } + guard let excludeLibraryItemIDs = fetchState.excludeLibraryItemIDs else { return [] } + let excludeArray = Array(excludeLibraryItemIDs) + let newLibraryItems = try await viewModel.getShuffledItems(excluding: excludeArray) + guard !newLibraryItems.isEmpty else { return [] } + + fetchState.excludeLibraryItemIDs?.formUnion(newLibraryItems.compactMap(\.id)) + + let playableItems = try await Self.collectPlayableItems(from: newLibraryItems) + + let uniquePlayableItems = playableItems.filter { item in + guard let id = item.id else { return false } + return !fetchState.excludeItemIDs.contains(id) + } + + fetchState.excludeItemIDs.formUnion(uniquePlayableItems.compactMap(\.id)) + + return uniquePlayableItems + } + + let queue = ShuffleMediaPlayerQueue(items: playableItems, fetchMoreItems: fetchMoreItems) + let manager = MediaPlayerManager( + item: mutableFirstItem, + queue: queue + ) { item in + try await MediaPlayerItem.build(for: item) { + $0.userData?.playbackPositionTicks = 0 + } + } + + manager.$state + .sink { [weak viewModel] state in + if state == .playback { + viewModel?.backgroundStates.remove(.shuffling) + } + } + .store(in: &manager.cancellables) + + manager.$playbackItem + .sink { [weak viewModel] playbackItem in + if playbackItem != nil { + viewModel?.backgroundStates.remove(.shuffling) + } + } + .store(in: &manager.cancellables) + + return manager + } + + func playLibraryShuffle( + items: [BaseItemDto], + viewModel: PagingLibraryViewModel, + router: NavigationCoordinator.Router, + namespace: Namespace.ID? = nil + ) async { + do { + let originalLibraryItems = items + let playableItems = try await Self.collectPlayableItems(from: items) + + guard let firstItem = playableItems.first else { + logger.warning("No playable items found after expanding containers") + return + } + + guard let libraryViewModel = viewModel as? ItemLibraryViewModel else { return } + + let manager = Self.createLibraryShuffleManager( + firstItem: firstItem, + playableItems: playableItems, + originalLibraryItems: originalLibraryItems, + viewModel: libraryViewModel, + backgroundStates: &viewModel.backgroundStates + ) + + await MainActor.run { + router.route( + to: .videoPlayer(manager: manager), + in: namespace + ) + } + } catch { + logger.error("Error playing shuffled items: \(error)") + } + } +} diff --git a/Shared/ViewModels/LibraryViewModel/ItemLibraryViewModel.swift b/Shared/ViewModels/LibraryViewModel/ItemLibraryViewModel.swift index c51a0413bb..53ff45d0c9 100644 --- a/Shared/ViewModels/LibraryViewModel/ItemLibraryViewModel.swift +++ b/Shared/ViewModels/LibraryViewModel/ItemLibraryViewModel.swift @@ -125,4 +125,18 @@ final class ItemLibraryViewModel: PagingLibraryViewModel { return response?.value.items?.first } + + // MARK: getShuffledItems + + @MainActor + override func getShuffledItems(excluding excludeItemIDs: [String] = []) async throws -> [BaseItemDto] { + try await ItemViewModel.fetchShuffledItemsPaginated( + userSession: userSession, + parentID: parent?.id, + includeItemTypes: [.series, .boxSet, .collectionFolder, .folder, .episode, .movie, .video, .musicVideo, .trailer], + excludeItemIDs: excludeItemIDs, + enableUserData: true, + applyParentParameters: parent?.setParentParameters + ) + } } diff --git a/Shared/ViewModels/LibraryViewModel/PagingLibraryViewModel.swift b/Shared/ViewModels/LibraryViewModel/PagingLibraryViewModel.swift index f8719bcc28..5fc2bcf165 100644 --- a/Shared/ViewModels/LibraryViewModel/PagingLibraryViewModel.swift +++ b/Shared/ViewModels/LibraryViewModel/PagingLibraryViewModel.swift @@ -66,6 +66,7 @@ class PagingLibraryViewModel: ViewModel, Eventful, Stateful { enum Event { case gotRandomItem(Element) + case gotShuffledItems([Element]) } // MARK: Action @@ -75,12 +76,14 @@ class PagingLibraryViewModel: ViewModel, Eventful, Stateful { case refresh case getNextPage case getRandomItem + case getShuffledItems } // MARK: BackgroundState enum BackgroundState: Hashable { case gettingNextPage + case shuffling } // MARK: State @@ -321,6 +324,31 @@ class PagingLibraryViewModel: ViewModel, Eventful, Stateful { } .asAnyCancellable() + return state + case .getShuffledItems: + + backgroundStates.insert(.shuffling) + + randomItemTask = Task { [weak self] in + do { + guard let shuffledItems = try await self?.getShuffledItems(), shuffledItems.isNotEmpty else { + self?.logger.warning("No shuffled items returned") + return + } + + guard !Task.isCancelled else { + return + } + + self?.eventSubject.send(.gotShuffledItems(shuffledItems)) + } catch { + // TODO: when a general toasting mechanism is implemented, add + // background errors for errors from other background tasks + self?.logger.error("Error getting shuffled items: \(error)") + } + } + .asAnyCancellable() + return state } } @@ -371,4 +399,11 @@ class PagingLibraryViewModel: ViewModel, Eventful, Stateful { func getRandomItem() async throws -> Element? { elements.randomElement() } + + /// Gets all items shuffled. Override if items should + /// come from another source instead. + @MainActor + func getShuffledItems(excluding excludeItemIDs: [String] = []) async throws -> [Element] { + elements.shuffled() + } } diff --git a/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift index 40da84e22f..b1371f6d69 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift @@ -6,6 +6,7 @@ // Copyright (c) 2025 Jellyfin & Jellyfin Contributors // +import JellyfinAPI import SwiftUI extension ItemView { @@ -21,8 +22,6 @@ extension ItemView { @StoredValue(.User.enabledTrailers) private var enabledTrailers: TrailerSelection - // MARK: - Observed, State, & Environment Objects - @Router private var router @@ -32,44 +31,30 @@ extension ItemView { @StateObject private var deleteViewModel: DeleteItemViewModel - // MARK: - Dialog States - @State private var showConfirmationDialog = false @State private var isPresentingEventAlert = false - // MARK: - Error State - @State private var error: Error? - // MARK: - Can Delete Item - private var canDelete: Bool { viewModel.userSession.user.permissions.items.canDelete(item: viewModel.item) } - // MARK: - Can Refresh Item - private var canRefresh: Bool { viewModel.userSession.user.permissions.items.canEditMetadata(item: viewModel.item) } - // MARK: - Can Manage Subtitles - private var canManageSubtitles: Bool { viewModel.userSession.user.permissions.items.canManageSubtitles(item: viewModel.item) } - // MARK: - Deletion or Refreshing is Enabled - private var enableMenu: Bool { - canDelete || canRefresh + canDelete || canRefresh || viewModel.item.canShuffle } - // MARK: - Has Trailers - private var hasTrailers: Bool { if enabledTrailers.contains(.local), viewModel.localTrailers.isNotEmpty { return true @@ -82,20 +67,14 @@ extension ItemView { return false } - // MARK: - Initializer - init(viewModel: ItemViewModel) { self.viewModel = viewModel self._deleteViewModel = StateObject(wrappedValue: .init(item: viewModel.item)) } - // MARK: - Body - var body: some View { HStack(alignment: .center, spacing: 20) { - // MARK: Toggle Played - if viewModel.item.canBePlayed { let isCheckmarkSelected = viewModel.item.userData?.isPlayed == true @@ -111,8 +90,6 @@ extension ItemView { .frame(minWidth: 100, maxWidth: .infinity) } - // MARK: Toggle Favorite - let isHeartSelected = viewModel.item.userData?.isFavorite == true ActionButton( @@ -126,8 +103,6 @@ extension ItemView { .isSelected(isHeartSelected) .frame(minWidth: 100, maxWidth: .infinity) - // MARK: Watch a Trailer - if hasTrailers { TrailerMenu( localTrailers: viewModel.localTrailers, @@ -135,10 +110,17 @@ extension ItemView { ) } - // MARK: Advanced Options - if enableMenu { ActionButton(L10n.advanced, icon: "ellipsis", isCompact: true) { + if viewModel.item.canShuffle { + Section { + Button(L10n.shuffle, systemImage: "shuffle") { + viewModel.playShuffle(router: router.router) + } + .disabled(viewModel.backgroundStates.contains(.shuffling)) + } + } + if canRefresh || canManageSubtitles { Section(L10n.manage) { if canRefresh { diff --git a/Swiftfin tvOS/Views/PagingLibraryView/PagingLibraryView.swift b/Swiftfin tvOS/Views/PagingLibraryView/PagingLibraryView.swift index bb23dd4074..d6cbffa7ab 100644 --- a/Swiftfin tvOS/Views/PagingLibraryView/PagingLibraryView.swift +++ b/Swiftfin tvOS/Views/PagingLibraryView/PagingLibraryView.swift @@ -117,6 +117,20 @@ struct PagingLibraryView: View { router.route(to: .library(viewModel: viewModel)) } + // MARK: - Shuffle Handling + + private func handleShuffledItems(_ items: [Element]) { + let baseItems = items.compactMap { $0 as? BaseItemDto } + + Task { + await ShuffleActionHelper().playLibraryShuffle( + items: baseItems, + viewModel: viewModel, + router: router.router + ) + } + } + // MARK: Make Layout private static func makeLayout( @@ -387,6 +401,8 @@ struct PagingLibraryView: View { default: assertionFailure("Used an unexpected type within a `PagingLibaryView`?") } + case let .gotShuffledItems(items): + handleShuffledItems(items) } } .onFirstAppear { diff --git a/Swiftfin/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift b/Swiftfin/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift index aadf9e6e94..0118be2348 100644 --- a/Swiftfin/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift +++ b/Swiftfin/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift @@ -26,8 +26,6 @@ extension ItemView { private let equalSpacing: Bool - // MARK: - Has Trailers - private var hasTrailers: Bool { if enabledTrailers.contains(.local), viewModel.localTrailers.isNotEmpty { return true @@ -40,22 +38,15 @@ extension ItemView { return false } - // MARK: - Initializer - init(viewModel: ItemViewModel, equalSpacing: Bool = true) { self.viewModel = viewModel self.equalSpacing = equalSpacing } - // MARK: - Body - var body: some View { HStack(alignment: .center, spacing: 10) { if viewModel.item.canBePlayed { - - // MARK: - Toggle Played - let isCheckmarkSelected = viewModel.item.userData?.isPlayed == true Button(L10n.played, systemImage: "checkmark") { @@ -69,8 +60,6 @@ extension ItemView { } } - // MARK: - Toggle Favorite - let isHeartSelected = viewModel.item.userData?.isFavorite == true Button(L10n.favorite, systemImage: isHeartSelected ? "heart.fill" : "heart") { @@ -83,8 +72,6 @@ extension ItemView { view.aspectRatio(1, contentMode: .fit) } - // MARK: - Select a Version - if let mediaSources = viewModel.playButtonItem?.mediaSources, mediaSources.count > 1 { @@ -99,8 +86,6 @@ extension ItemView { } } - // MARK: - Watch a Trailer - if hasTrailers { TrailerMenu( localTrailers: viewModel.localTrailers, diff --git a/Swiftfin/Views/ItemView/Components/PlayButton.swift b/Swiftfin/Views/ItemView/Components/PlayButton.swift index 49e4e30bd6..9499448e2b 100644 --- a/Swiftfin/Views/ItemView/Components/PlayButton.swift +++ b/Swiftfin/Views/ItemView/Components/PlayButton.swift @@ -26,14 +26,10 @@ extension ItemView { private let logger = Logger.swiftfin() - // MARK: - Validation - private var isEnabled: Bool { viewModel.selectedMediaSource != nil } - // MARK: - Title - private var title: String { /// Use the Season/Episode label for the Series ItemView if let seriesViewModel = viewModel as? SeriesItemViewModel, @@ -51,8 +47,6 @@ extension ItemView { } } - // MARK: - Media Source - private var source: String? { guard let sourceLabel = viewModel.selectedMediaSource?.displayTitle, viewModel.item.mediaSources?.count ?? 0 > 1 @@ -63,8 +57,6 @@ extension ItemView { return sourceLabel } - // MARK: - Body - var body: some View { Button { play() @@ -91,12 +83,26 @@ extension ItemView { play(fromBeginning: true) } } + + if viewModel.item.canShuffle { + Button { + viewModel.playShuffle(router: router.router) + } label: { + HStack { + if viewModel.backgroundStates.contains(.shuffling) { + ProgressView() + .scaleEffect(0.8) + .frame(width: 16, height: 16) + } + Label(L10n.shuffle, systemImage: "shuffle") + } + } + .disabled(viewModel.backgroundStates.contains(.shuffling)) + } } .disabled(!isEnabled) } - // MARK: - Play Content - private func play(fromBeginning: Bool = false) { guard let playButtonItem = viewModel.playButtonItem, let selectedMediaSource = viewModel.selectedMediaSource diff --git a/Swiftfin/Views/ItemView/ItemView.swift b/Swiftfin/Views/ItemView/ItemView.swift index b4989881a3..584a97cdd4 100644 --- a/Swiftfin/Views/ItemView/ItemView.swift +++ b/Swiftfin/Views/ItemView/ItemView.swift @@ -143,6 +143,21 @@ struct ItemView: View { case .initial, .refreshing: ProgressView() } + + // Show loading overlay when shuffling + if viewModel.backgroundStates.contains(.shuffling) { + Color.black.opacity(0.3) + .ignoresSafeArea() + + VStack(spacing: 10) { + ProgressView() + Text(L10n.shuffling) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding() + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10)) + } } .animation(.linear(duration: 0.1), value: viewModel.state) .navigationBarTitleDisplayMode(.inline) diff --git a/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift b/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift index 7cd675bdf9..7f9fa8af90 100644 --- a/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift +++ b/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift @@ -8,6 +8,7 @@ import CollectionVGrid import Defaults +import Factory import JellyfinAPI import Nuke import SwiftUI @@ -235,17 +236,34 @@ struct PagingLibraryView: View { @ViewBuilder private var innerContent: some View { - switch viewModel.state { - case .content: - if viewModel.elements.isEmpty { - Text(L10n.noResults) - } else { - elementsView + ZStack { + switch viewModel.state { + case .content: + if viewModel.elements.isEmpty { + Text(L10n.noResults) + } else { + elementsView + } + case .initial, .refreshing: + ProgressView() + default: + AssertionFailureView("Expected view for unexpected state") + } + + // Show loading overlay when shuffling + if viewModel.backgroundStates.contains(.shuffling) { + Color.black.opacity(0.3) + .ignoresSafeArea() + + VStack(spacing: 10) { + ProgressView() + Text(L10n.shuffling) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding() + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10)) } - case .initial, .refreshing: - ProgressView() - default: - AssertionFailureView("Expected view for unexpected state") } } @@ -436,6 +454,8 @@ struct PagingLibraryView: View { default: assertionFailure("Used an unexpected type within a `PagingLibaryView`?") } + case let .gotShuffledItems(items): + handleShuffledItems(items, in: namespace) } } .onFirstAppear { @@ -464,6 +484,28 @@ struct PagingLibraryView: View { viewModel.send(.getRandomItem) } .disabled(viewModel.elements.isEmpty) + + Button(L10n.shuffle, systemImage: "shuffle") { + viewModel.send(.getShuffledItems) + } + .disabled(viewModel.elements.isEmpty || viewModel.backgroundStates.contains(.gettingNextPage)) + } + } +} + +// MARK: - Shuffle + +private extension PagingLibraryView { + func handleShuffledItems(_ items: [Element], in namespace: Namespace.ID) { + let baseItems = items.compactMap { $0 as? BaseItemDto } + + Task { + await ShuffleActionHelper().playLibraryShuffle( + items: baseItems, + viewModel: viewModel, + router: router.router, + namespace: namespace + ) } } }