From 5fb5c41d91e6f76d721205feb4d5423f85ac95d9 Mon Sep 17 00:00:00 2001 From: vikgor Date: Mon, 20 Oct 2025 17:00:18 +0500 Subject: [PATCH 1/4] Clean up code w/ Swiftformat --- Shared/Components/Marquee.swift | 4 ++-- Shared/Components/NativeVideoPlayer.swift | 2 +- Shared/Components/VideoPlayer.swift | 2 +- .../Navigation/NavigationRoute/NavigationRoute+Item.swift | 2 +- Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto.swift | 2 +- .../JellyfinAPI/BaseItemPerson/BaseItemPerson+Poster.swift | 2 +- Shared/Extensions/ViewExtensions/ViewExtensions.swift | 2 +- .../MediaPlayerItem/MediaPlayerItem+Build.swift | 2 +- Shared/Views/ConnecToServerView/ConnectToServerView.swift | 2 +- .../Gestures/VideoPlayerContainerView+PanGesture.swift | 2 +- 10 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Shared/Components/Marquee.swift b/Shared/Components/Marquee.swift index 7ea9975869..9a298e44e7 100644 --- a/Shared/Components/Marquee.swift +++ b/Shared/Components/Marquee.swift @@ -41,7 +41,7 @@ struct Marquee: View where Content: View { delay: Double = 2.0, gap: CGFloat = 50.0, animateWhenFocused: Bool = false, - fade: CGFloat = 10.0, + fade: CGFloat = 10.0 ) where Content == Text { self.speed = speed self.delay = delay @@ -225,7 +225,7 @@ private struct _OffsetEffect: GeometryEffect { set { offset = CGSize(width: newValue.first, height: newValue.second) } } - public func effectValue(size _: CGSize) -> ProjectionTransform { + func effectValue(size _: CGSize) -> ProjectionTransform { ProjectionTransform(CGAffineTransform(translationX: offset.width, y: offset.height)) } } diff --git a/Shared/Components/NativeVideoPlayer.swift b/Shared/Components/NativeVideoPlayer.swift index b977135752..b483afe072 100644 --- a/Shared/Components/NativeVideoPlayer.swift +++ b/Shared/Components/NativeVideoPlayer.swift @@ -57,7 +57,7 @@ struct NativeVideoPlayer: View { } .alert( L10n.error, - isPresented: .constant(manager.error != nil), + isPresented: .constant(manager.error != nil) ) { Button(L10n.close, role: .cancel) { Container.shared.mediaPlayerManager.reset() diff --git a/Shared/Components/VideoPlayer.swift b/Shared/Components/VideoPlayer.swift index 831e36e7fb..119f59cf94 100644 --- a/Shared/Components/VideoPlayer.swift +++ b/Shared/Components/VideoPlayer.swift @@ -122,7 +122,7 @@ struct VideoPlayer: View { .alert( L10n.error, - isPresented: .constant(manager.error != nil), + isPresented: .constant(manager.error != nil) ) { Button(L10n.close, role: .cancel) { Container.shared.mediaPlayerManager.reset() diff --git a/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Item.swift b/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Item.swift index d1be58940c..88a8963cac 100644 --- a/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Item.swift +++ b/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Item.swift @@ -177,7 +177,7 @@ extension NavigationRoute { ) { IdentifyItemView.RemoteSearchResultView( viewModel: viewModel, - result: result, + result: result ) } } diff --git a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto.swift b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto.swift index 92e94b48aa..89026b6b97 100644 --- a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto.swift +++ b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto.swift @@ -509,7 +509,7 @@ extension BaseItemDto { let request = Paths.getItem(itemID: id, userID: userSession.user.id) let response = try await userSession.client.send(request) - + // A check against `id` would typically be done, but a plugin // may have provided `self` or the response item and may not // be invariant over `id`. diff --git a/Shared/Extensions/JellyfinAPI/BaseItemPerson/BaseItemPerson+Poster.swift b/Shared/Extensions/JellyfinAPI/BaseItemPerson/BaseItemPerson+Poster.swift index 33ee8aec72..b582c2e0f6 100644 --- a/Shared/Extensions/JellyfinAPI/BaseItemPerson/BaseItemPerson+Poster.swift +++ b/Shared/Extensions/JellyfinAPI/BaseItemPerson/BaseItemPerson+Poster.swift @@ -40,7 +40,7 @@ extension BaseItemPerson: Poster { let imageRequestParameters = Paths.GetItemImageParameters( maxWidth: scaleWidth ?? Int(maxWidth), quality: quality, - tag: primaryImageTag, + tag: primaryImageTag ) let imageRequest = Paths.getItemImage( diff --git a/Shared/Extensions/ViewExtensions/ViewExtensions.swift b/Shared/Extensions/ViewExtensions/ViewExtensions.swift index 2d33ac4619..b964e289d5 100644 --- a/Shared/Extensions/ViewExtensions/ViewExtensions.swift +++ b/Shared/Extensions/ViewExtensions/ViewExtensions.swift @@ -214,7 +214,7 @@ extension View { _ radius: CGFloat, corners: RectangleCorner = .allCorners, style: RoundedCornerStyle = .circular, - container: Bool = false, + container: Bool = false ) -> some View { // Note: UnevenRoundedRectangle with all equal radii has // been found to perform worse than RoundedRectangle diff --git a/Shared/Objects/MediaPlayerManager/MediaPlayerItem/MediaPlayerItem+Build.swift b/Shared/Objects/MediaPlayerManager/MediaPlayerItem/MediaPlayerItem+Build.swift index dcf10a3062..6748b7dc6e 100644 --- a/Shared/Objects/MediaPlayerManager/MediaPlayerItem/MediaPlayerItem+Build.swift +++ b/Shared/Objects/MediaPlayerManager/MediaPlayerItem/MediaPlayerItem+Build.swift @@ -24,7 +24,7 @@ extension MediaPlayerItem { videoPlayerType: VideoPlayerType = Defaults[.VideoPlayer.videoPlayerType], requestedBitrate: PlaybackBitrate = Defaults[.VideoPlayer.Playback.appMaximumBitrate], compatibilityMode: PlaybackCompatibility = Defaults[.VideoPlayer.Playback.compatibilityMode], - modifyItem: ((inout BaseItemDto) -> Void)? = nil, + modifyItem: ((inout BaseItemDto) -> Void)? = nil ) async throws -> MediaPlayerItem { let logger = Logger.swiftfin() diff --git a/Shared/Views/ConnecToServerView/ConnectToServerView.swift b/Shared/Views/ConnecToServerView/ConnectToServerView.swift index eaaf4d6cad..f50750da1b 100644 --- a/Shared/Views/ConnecToServerView/ConnectToServerView.swift +++ b/Shared/Views/ConnecToServerView/ConnectToServerView.swift @@ -110,7 +110,7 @@ struct ConnectToServerView: View { } #else SplitLoginWindowView( - isLoading: viewModel.state == .connecting, + isLoading: viewModel.state == .connecting ) { connectSection } trailingContentView: { diff --git a/Swiftfin/Views/VideoPlayerContainerView/Gestures/VideoPlayerContainerView+PanGesture.swift b/Swiftfin/Views/VideoPlayerContainerView/Gestures/VideoPlayerContainerView+PanGesture.swift index 78512bbdbc..218951fbdb 100644 --- a/Swiftfin/Views/VideoPlayerContainerView/Gestures/VideoPlayerContainerView+PanGesture.swift +++ b/Swiftfin/Views/VideoPlayerContainerView/Gestures/VideoPlayerContainerView+PanGesture.swift @@ -287,7 +287,7 @@ extension VideoPlayer.UIVideoPlayerContainerViewController { private static var SupplementPanHandlingAction: PanHandlingAction { PanHandlingAction( - startValue: 0, + startValue: 0 ) { _, handlingState, containerState in containerState.containerView?.handleSupplementPanAction( translation: handlingState.translation, From 2ede4ab9f23caa7f200b9f6302955bc6830cf763 Mon Sep 17 00:00:00 2001 From: vikgor Date: Mon, 20 Oct 2025 22:34:12 +0500 Subject: [PATCH 2/4] Add shuffle playback support Implements shuffle for series, collections, and libraries with queue management & visual display on iOS; Introduces an Options menu in ActionButtonHStack that contains shuffle functionality and is designed to accommodate future actions. --- .../JellyfinAPI/BaseItemDto/BaseItemDto.swift | 13 + .../MediaPlayerProxy+AVPlayer.swift | 22 ++ .../NowPlayable/NowPlayableObserver.swift | 45 +++- .../Supplements/EpisodeMediaPlayerQueue.swift | 9 +- .../Supplements/ShuffleMediaPlayerQueue.swift | 236 ++++++++++++++++++ Shared/Strings/Strings.swift | 2 + .../CollectionItemViewModel.swift | 21 ++ .../ItemViewModel/SeriesItemViewModel.swift | 24 +- .../ItemViewModel/ShuffleActionHelper.swift | 209 ++++++++++++++++ .../ItemLibraryViewModel.swift | 26 ++ .../PagingLibraryViewModel.swift | 30 +++ .../ActionButtonHStack.swift | 6 + .../PlayButton/Components/OptionsMenu.swift | 76 ++++++ .../PagingLibraryView/PagingLibraryView.swift | 45 ++++ .../ActionButtonHStack.swift | 11 + .../Components/OptionsMenu.swift | 58 +++++ .../PagingLibraryView/PagingLibraryView.swift | 56 +++++ Translations/en.lproj/Localizable.strings | Bin 106612 -> 106684 bytes 18 files changed, 881 insertions(+), 8 deletions(-) create mode 100644 Shared/Objects/MediaPlayerManager/Supplements/ShuffleMediaPlayerQueue.swift create mode 100644 Shared/ViewModels/ItemViewModel/ShuffleActionHelper.swift create mode 100644 Swiftfin tvOS/Views/ItemView/Components/PlayButton/Components/OptionsMenu.swift create mode 100644 Swiftfin/Views/ItemView/Components/ActionButtonHStack/Components/OptionsMenu.swift diff --git a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto.swift b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto.swift index 89026b6b97..6f9b2257f1 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 4f893acf95..30f684b059 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..98df587a47 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,35 @@ class NowPlayableObserver: ViewModel, MediaPlayerObserver { ) } + private func queueDidChange(_ newQueue: AnyMediaPlayerQueue?) { + // Update next/previous command availability based on queue state + if let queue = newQueue { + // Subscribe to queue changes + 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) + + // Initial update + updateQueueCommandAvailability(hasNext: queue.hasNextItem, hasPrevious: queue.hasPreviousItem) + } else { + // No queue, disable commands + 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..d59e62b7d7 100644 --- a/Shared/Objects/MediaPlayerManager/Supplements/EpisodeMediaPlayerQueue.swift +++ b/Shared/Objects/MediaPlayerManager/Supplements/EpisodeMediaPlayerQueue.swift @@ -415,7 +415,7 @@ extension EpisodeMediaPlayerQueue { } } - private struct EpisodePreview: View { + struct EpisodePreview: View { @Default(.accentColor) private var accentColor @@ -449,7 +449,7 @@ extension EpisodeMediaPlayerQueue { } } - private struct EpisodeDescription: View { + struct EpisodeDescription: View { let episode: BaseItemDto @@ -468,7 +468,8 @@ extension EpisodeMediaPlayerQueue { } } - private struct EpisodeRow: View { + // Shared episode UI components (used by other queue types like ShuffleMediaPlayerQueue) + struct EpisodeRow: View { @Default(.accentColor) private var accentColor @@ -506,7 +507,7 @@ extension EpisodeMediaPlayerQueue { } } - private struct EpisodeButton: View { + struct EpisodeButton: View { @Default(.accentColor) private var accentColor diff --git a/Shared/Objects/MediaPlayerManager/Supplements/ShuffleMediaPlayerQueue.swift b/Shared/Objects/MediaPlayerManager/Supplements/ShuffleMediaPlayerQueue.swift new file mode 100644 index 0000000000..7b421fc6ab --- /dev/null +++ b/Shared/Objects/MediaPlayerManager/Supplements/ShuffleMediaPlayerQueue.swift @@ -0,0 +1,236 @@ +// +// 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 + .sink { [weak self] newItem in + self?.didReceive(newItem: newItem) + } + .store(in: &cancellables) + } + } + + // Use "Episodes" as display title for consistency with regular queue + 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 + + init(items: [BaseItemDto]) { + // Items are already shuffled by the caller, don't shuffle again + self.shuffledItems = items + super.init() + // Initialize the queue with adjacent items + updateAdjacentItems() + } + + var videoPlayerBody: some PlatformView { + ShuffleQueueOverlay(items: shuffledItems, currentIndex: currentIndex) + } + + private func didReceive(newItem: MediaPlayerItem?) { + guard let newItem else { + updateAdjacentItems() + return + } + + // Find the current item in our shuffled list + if let index = shuffledItems.firstIndex(where: { $0.id == newItem.baseItem.id }) { + currentIndex = index + } + + updateAdjacentItems() + } + + private func updateAdjacentItems() { + let hasPrevious = currentIndex > 0 + let hasNext = currentIndex < shuffledItems.count - 1 + + logger.info("Updating adjacent items: current index = \(currentIndex), hasNext = \(hasNext), hasPrevious = \(hasPrevious)") + + 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")" + ) + } +} + +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) { + // Compact (Portrait) - Vertical list + CompactShuffleView(items: items, currentIndex: currentIndex) + } regularView: { + // Regular (Landscape) - Horizontal row + RegularShuffleView(items: items, currentIndex: currentIndex) + } + } + } + + // Compact view - Vertical scrolling list + 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 + EpisodeMediaPlayerQueue.EpisodeRow(episode: item) { + selectItem(item) + } + .edgePadding(.horizontal) + } + } + } + + // Regular view - Horizontal scrolling row + private struct RegularShuffleView: View { + + @Environment(\.safeAreaInsets) + private var safeAreaInsets: EdgeInsets + + @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 { + CollectionHStack( + uniqueElements: items, + id: \.unwrappedIDHashOrZero + ) { item in + EpisodeMediaPlayerQueue.EpisodeButton(episode: item) { + selectItem(item) + } + .frame(height: 150) + } + .insets(horizontal: max(safeAreaInsets.leading, safeAreaInsets.trailing) + EdgeInsets.edgePadding) + } + } +} diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index 2213d9df2a..e0f3dfa33c 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -1430,6 +1430,8 @@ 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") /// 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..f1aad3713b 100644 --- a/Shared/ViewModels/ItemViewModel/CollectionItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/CollectionItemViewModel.swift @@ -63,4 +63,25 @@ final class CollectionItemViewModel: ItemViewModel { .elements .randomElement() } + + // MARK: - Get Shuffled Items + + func getShuffledItems() async throws -> [BaseItemDto] { + var parameters = Paths.GetItemsByUserIDParameters() + parameters.fields = .MinimumFields + parameters.isRecursive = true + parameters.parentID = item.id + parameters.sortBy = [ItemSortBy.sortName.rawValue] + parameters.sortOrder = [.ascending] + + let request = Paths.getItemsByUserID( + userID: userSession.user.id, + parameters: parameters + ) + let response = try await userSession.client.send(request) + + return (response.value.items ?? []) + .filter(\.isPlayable) + .shuffled() + } } diff --git a/Shared/ViewModels/ItemViewModel/SeriesItemViewModel.swift b/Shared/ViewModels/ItemViewModel/SeriesItemViewModel.swift index d8313ce91f..48b6738d9f 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,26 @@ final class SeriesItemViewModel: ItemViewModel { return response.value.items ?? [] } + + // MARK: - Get Shuffled Items + + func getShuffledItems() async throws -> [BaseItemDto] { + var parameters = Paths.GetItemsByUserIDParameters() + parameters.enableUserData = true + parameters.fields = .MinimumFields + parameters.includeItemTypes = [.episode] + parameters.isRecursive = true + parameters.isMissing = Defaults[.Customization.shouldShowMissingEpisodes] ? nil : false + parameters.parentID = item.id + parameters.sortBy = [ItemSortBy.sortName.rawValue] + parameters.sortOrder = [.ascending] + + let request = Paths.getItemsByUserID( + userID: userSession.user.id, + parameters: parameters + ) + let response = try await userSession.client.send(request) + + return (response.value.items ?? []).shuffled() + } } diff --git a/Shared/ViewModels/ItemViewModel/ShuffleActionHelper.swift b/Shared/ViewModels/ItemViewModel/ShuffleActionHelper.swift new file mode 100644 index 0000000000..d923aa72b7 --- /dev/null +++ b/Shared/ViewModels/ItemViewModel/ShuffleActionHelper.swift @@ -0,0 +1,209 @@ +// +// 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 + +/// Helper for performing shuffle actions on items +@MainActor +struct ShuffleActionHelper { + + private let logger = Logger.swiftfin() + + /// Initiates shuffle playback for the given item with the specified media source + /// - Parameters: + /// - item: The item to shuffle (Series, Season, Collection, etc.) + /// - mediaSource: The media source to use for playback + /// - viewModel: The ItemViewModel for the item + /// - Returns: A tuple containing the first item and the shuffle queue, or nil if shuffle is not supported + 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: [BaseItemDto] + + // Get shuffled items based on item type + if let seriesViewModel = viewModel as? SeriesItemViewModel { + shuffledItems = try await seriesViewModel.getShuffledItems() + } else if let collectionViewModel = viewModel as? CollectionItemViewModel { + shuffledItems = try await collectionViewModel.getShuffledItems() + } else { + logger.error("No shuffle implementation for this ItemViewModel type") + return nil + } + + 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 + } + + // Reset playback position for the first item + firstItem.userData?.playbackPositionTicks = 0 + + let queue = ShuffleMediaPlayerQueue(items: shuffledItems) + + return (firstItem, queue) + } + + /// Initiates shuffle playback and routes to the video player (tvOS/fixed media source) + /// - Parameters: + /// - item: The item to shuffle + /// - mediaSource: The media source to use for playback + /// - viewModel: The ItemViewModel for the item + /// - router: The router to use for navigation + 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)) + } + } + + /// Initiates shuffle playback and routes to the video player (iOS/auto-select media source) + /// - Parameters: + /// - item: The item to shuffle + /// - viewModel: The ItemViewModel for the item + /// - router: The router to use for navigation + func shuffleAndPlayWithAutoSource( + _ item: BaseItemDto, + viewModel: ItemViewModel, + router: NavigationCoordinator.Router + ) async throws { + guard let result = try await shuffleItem(item, mediaSource: MediaSourceInfo(), viewModel: viewModel) else { + return + } + + // iOS: Let each item auto-select its media source + 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 + ) + ) + } + } + + // MARK: - Library Shuffle Utilities + + /// Collects playable items from a mixed collection, expanding containers into their playable content. + /// + /// Containers (series, boxSets) cannot be played directly and are expanded into their playable content. + /// BoxSets may contain series, which are recursively expanded into episodes. + /// Other items (movies, episodes, etc.) are included as-is if playable and have media sources. + /// + /// - Parameter items: The items to process (may include series, collections, movies, episodes, etc.) + /// - Returns: A flat list of playable items with containers expanded into their playable content + 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 { + switch item.type { + case .series: + // Series are containers - fetch and include all their episodes + let episodes = try await fetchEpisodes(for: item, userSession: userSession) + playableItems.append(contentsOf: episodes) + case .boxSet: + // BoxSets are containers - fetch contents and recursively expand any series + let contents = try await fetchBoxSetContents(for: item, userSession: userSession) + let expandedContents = try await collectPlayableItems(from: contents) + playableItems.append(contentsOf: expandedContents) + default: + // Include directly playable items (movies, episodes, etc.) that have media sources + if item.isPlayable && item.mediaSources?.isNotEmpty == true { + playableItems.append(item) + } + } + } + + return playableItems + } + + /// Fetches all episodes for a given series + /// - Parameters: + /// - series: The series to fetch episodes for + /// - userSession: The current user session + /// - Returns: Array of episodes with media sources + private static func fetchEpisodes(for series: BaseItemDto, userSession: UserSession) async throws -> [BaseItemDto] { + var parameters = Paths.GetItemsByUserIDParameters() + parameters.fields = .MinimumFields + parameters.includeItemTypes = [.episode] + parameters.isRecursive = true + parameters.parentID = series.id + parameters.sortBy = [ItemSortBy.sortName.rawValue] + parameters.sortOrder = [.ascending] + + let request = Paths.getItemsByUserID( + userID: userSession.user.id, + parameters: parameters + ) + let response = try await userSession.client.send(request) + let episodes = response.value.items ?? [] + + return episodes.filter { $0.mediaSources?.isNotEmpty ?? false } + } + + /// Fetches all contents for a given boxSet/collection + /// - Parameters: + /// - boxSet: The boxSet to fetch contents for + /// - userSession: The current user session + /// - Returns: Array of items in the collection (may include series, movies, etc.) + private static func fetchBoxSetContents(for boxSet: BaseItemDto, userSession: UserSession) async throws -> [BaseItemDto] { + var parameters = Paths.GetItemsByUserIDParameters() + parameters.fields = .MinimumFields + parameters.isRecursive = true + parameters.parentID = boxSet.id + parameters.sortBy = [ItemSortBy.sortName.rawValue] + parameters.sortOrder = [.ascending] + + let request = Paths.getItemsByUserID( + userID: userSession.user.id, + parameters: parameters + ) + let response = try await userSession.client.send(request) + + return response.value.items ?? [] + } +} diff --git a/Shared/ViewModels/LibraryViewModel/ItemLibraryViewModel.swift b/Shared/ViewModels/LibraryViewModel/ItemLibraryViewModel.swift index c51a0413bb..c7f15b8ecd 100644 --- a/Shared/ViewModels/LibraryViewModel/ItemLibraryViewModel.swift +++ b/Shared/ViewModels/LibraryViewModel/ItemLibraryViewModel.swift @@ -125,4 +125,30 @@ final class ItemLibraryViewModel: PagingLibraryViewModel { return response?.value.items?.first } + + // MARK: getShuffledItems + + override func getShuffledItems() async throws -> [BaseItemDto] { + var parameters = Paths.GetItemsByUserIDParameters() + parameters.enableUserData = true + parameters.fields = .MinimumFields + parameters.isRecursive = true + parameters.sortBy = [ItemSortBy.sortName.rawValue] + parameters.sortOrder = [.ascending] + + // Set the parent if we're in a specific library/folder + if let parent { + parameters = parent.setParentParameters(parameters) + } + + let request = Paths.getItemsByUserID(userID: userSession.user.id, parameters: parameters) + let response = try await userSession.client.send(request) + + let allItems = response.value.items ?? [] + + // Use shared helper to expand containers (series, boxSets) into playable content + let playableItems = try await ShuffleActionHelper.collectPlayableItems(from: allItems) + + return playableItems.shuffled() + } } diff --git a/Shared/ViewModels/LibraryViewModel/PagingLibraryViewModel.swift b/Shared/ViewModels/LibraryViewModel/PagingLibraryViewModel.swift index 63846e401d..6ddd6cab70 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,6 +76,7 @@ class PagingLibraryViewModel: ViewModel, Eventful, Stateful { case refresh case getNextPage case getRandomItem + case getShuffledItems } // MARK: BackgroundState @@ -321,6 +323,28 @@ class PagingLibraryViewModel: ViewModel, Eventful, Stateful { } .asAnyCancellable() + return state + case .getShuffledItems: + + randomItemTask = Task { [weak self] in + do { + guard let shuffledItems = try await self?.getShuffledItems(), shuffledItems.isNotEmpty else { + 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 +395,10 @@ 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. + func getShuffledItems() 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..2ee98e9706 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift @@ -135,6 +135,12 @@ extension ItemView { ) } + // MARK: Shuffle + + if viewModel.item.canShuffle { + OptionsMenu(viewModel: viewModel) + } + // MARK: Advanced Options if enableMenu { diff --git a/Swiftfin tvOS/Views/ItemView/Components/PlayButton/Components/OptionsMenu.swift b/Swiftfin tvOS/Views/ItemView/Components/PlayButton/Components/OptionsMenu.swift new file mode 100644 index 0000000000..faa557a837 --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/Components/PlayButton/Components/OptionsMenu.swift @@ -0,0 +1,76 @@ +// +// 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 JellyfinAPI +import Logging +import SwiftUI + +extension ItemView { + + struct OptionsMenu: View { + + @Router + private var router + + @ObservedObject + var viewModel: ItemViewModel + + private let logger = Logger.swiftfin() + + // MARK: - Body + + var body: some View { + ActionButton(L10n.options, icon: "ellipsis.circle") { + Button(L10n.shuffle, systemImage: "shuffle") { + playShuffle() + } + } + } + + // MARK: - Play Shuffled + + private func playShuffle() { + guard viewModel.item.canShuffle else { + logger.error("Shuffle not supported for item type: \(String(describing: viewModel.item.type))") + return + } + + Task { + do { + let containerTypes: Set = [.series, .boxSet, .collectionFolder, .folder, .playlist] + let helper = ShuffleActionHelper() + + if let itemType = viewModel.item.type, containerTypes.contains(itemType) { + // Use a dummy media source for containers - it won't be used + try await helper.shuffleAndPlay( + viewModel.item, + mediaSource: MediaSourceInfo(), + viewModel: viewModel, + router: router.router + ) + } else { + // For playable items, require media source + guard let selectedMediaSource = viewModel.selectedMediaSource else { + logger.error("Shuffle selected with no media source for playable item") + return + } + + try await helper.shuffleAndPlay( + viewModel.item, + mediaSource: selectedMediaSource, + viewModel: viewModel, + router: router.router + ) + } + } catch { + logger.error("Error shuffling items: \(error)") + } + } + } + } +} diff --git a/Swiftfin tvOS/Views/PagingLibraryView/PagingLibraryView.swift b/Swiftfin tvOS/Views/PagingLibraryView/PagingLibraryView.swift index fb7f55eb35..e2070f2064 100644 --- a/Swiftfin tvOS/Views/PagingLibraryView/PagingLibraryView.swift +++ b/Swiftfin tvOS/Views/PagingLibraryView/PagingLibraryView.swift @@ -117,6 +117,49 @@ 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 playShuffledItems(baseItems) + } + } + + private func playShuffledItems(_ items: [BaseItemDto]) async { + do { + let playableItems = try await ShuffleActionHelper.collectPlayableItems(from: items) + let shuffledItems = playableItems.shuffled() + + guard let firstItem = shuffledItems.first else { return } + + await routeToVideoPlayer(withFirst: firstItem, queue: shuffledItems) + } catch { + // TODO: Handle error properly with user-visible error message + } + } + + private func routeToVideoPlayer( + withFirst firstItem: BaseItemDto, + queue items: [BaseItemDto] + ) async { + var mutableFirstItem = firstItem + mutableFirstItem.userData?.playbackPositionTicks = 0 + + let queue = ShuffleMediaPlayerQueue(items: items) + let manager = MediaPlayerManager( + item: mutableFirstItem, + queue: queue + ) { item in + try await MediaPlayerItem.build(for: item) + } + + await MainActor.run { + router.route(to: .videoPlayer(manager: manager)) + } + } + // MARK: Make Layout private static func makeLayout( @@ -394,6 +437,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..b5a2330b77 100644 --- a/Swiftfin/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift +++ b/Swiftfin/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift @@ -112,6 +112,17 @@ extension ItemView { view.aspectRatio(1, contentMode: .fit) } } + + // MARK: - Options Menu + + if viewModel.item.canShuffle { + OptionsMenu(viewModel: viewModel) + .menuStyle(.button) + .frame(maxWidth: .infinity) + .if(!equalSpacing) { view in + view.aspectRatio(1, contentMode: .fit) + } + } } .font(.title3) .fontWeight(.semibold) diff --git a/Swiftfin/Views/ItemView/Components/ActionButtonHStack/Components/OptionsMenu.swift b/Swiftfin/Views/ItemView/Components/ActionButtonHStack/Components/OptionsMenu.swift new file mode 100644 index 0000000000..6360cab966 --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/ActionButtonHStack/Components/OptionsMenu.swift @@ -0,0 +1,58 @@ +// +// 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 JellyfinAPI +import Logging +import SwiftUI + +extension ItemView { + + struct OptionsMenu: View { + + @Router + private var router + + @ObservedObject + var viewModel: ItemViewModel + + private let logger = Logger.swiftfin() + + // MARK: - Body + + var body: some View { + Menu { + Button(L10n.shuffle, systemImage: "shuffle") { + playShuffle() + } + } label: { + Label(L10n.options, systemImage: "ellipsis.circle") + } + } + + // MARK: - Play Shuffled + + private func playShuffle() { + guard viewModel.item.canShuffle else { + logger.error("Shuffle not supported for item type: \(String(describing: viewModel.item.type))") + return + } + + Task { + do { + try await ShuffleActionHelper().shuffleAndPlayWithAutoSource( + viewModel.item, + viewModel: viewModel, + router: router.router + ) + } catch { + logger.error("Error shuffling items: \(error)") + } + } + } + } +} diff --git a/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift b/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift index e9bae24e1d..bb44d3f1a3 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 @@ -441,6 +442,8 @@ struct PagingLibraryView: View { default: assertionFailure("Used an unexpected type within a `PagingLibaryView`?") } + case let .gotShuffledItems(items): + handleShuffledItems(items, in: namespace) } } .onFirstAppear { @@ -469,6 +472,59 @@ struct PagingLibraryView: View { viewModel.send(.getRandomItem) } .disabled(viewModel.elements.isEmpty) + + Button(L10n.shuffle, systemImage: "shuffle") { + viewModel.send(.getShuffledItems) + } + .disabled(viewModel.elements.isEmpty) + } + } +} + +// MARK: - Shuffle + +private extension PagingLibraryView { + func handleShuffledItems(_ items: [Element], in namespace: Namespace.ID) { + let baseItems = items.compactMap { $0 as? BaseItemDto } + + Task { + await playShuffledItems(baseItems, in: namespace) + } + } + + func playShuffledItems(_ items: [BaseItemDto], in namespace: Namespace.ID) async { + do { + let playableItems = try await ShuffleActionHelper.collectPlayableItems(from: items) + let shuffledItems = playableItems.shuffled() + + guard let firstItem = shuffledItems.first else { return } + + await routeToVideoPlayer(withFirst: firstItem, queue: shuffledItems, in: namespace) + } catch { + // TODO: Handle error properly with user-visible error message + } + } + + func routeToVideoPlayer( + withFirst firstItem: BaseItemDto, + queue items: [BaseItemDto], + in namespace: Namespace.ID + ) async { + let queue = ShuffleMediaPlayerQueue(items: items) + let provider = MediaPlayerItemProvider(item: firstItem) { item in + try await MediaPlayerItem.build(for: item) { + $0.userData?.playbackPositionTicks = 0 + } + } + + await MainActor.run { + router.route( + to: .videoPlayer( + provider: provider, + queue: queue + ), + in: namespace + ) } } } diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index f4f13c7a1b866752215d2ab00e68b92fbb9f4fb4..46f788214e6213f3d85a09f4a12aa883a3d04134 100644 GIT binary patch delta 67 zcmexzfNjq~wuUW?PJy~<3~3BG45gfiKt4o7i9vzE7KoJ?f??v5KL(0! Kw+Upl@CN``Z4g`l delta 17 YcmdmUknPI>wuUW?PJ!FI0vV0`0ZJtY8UO$Q From 9a42b0ff97cbce3b2e80eafa1ce2d12935ee25c0 Mon Sep 17 00:00:00 2001 From: vikgor Date: Tue, 21 Oct 2025 12:32:26 +0500 Subject: [PATCH 3/4] Clean up comments; remove OptionsMenu Move Shuffle to Play Button Context Menu (iOS) & Advanced Options Menu (tvOS); --- .../NowPlayable/NowPlayableObserver.swift | 4 - .../Supplements/EpisodeMediaPlayerQueue.swift | 1 - .../Supplements/ShuffleMediaPlayerQueue.swift | 8 -- .../ItemViewModel/ItemViewModel+Shuffle.swift | 39 ++++++++ .../ItemViewModel/ShuffleActionHelper.swift | 90 ++++++++----------- .../ActionButtonHStack.swift | 45 +++------- .../PlayButton/Components/OptionsMenu.swift | 76 ---------------- .../ActionButtonHStack.swift | 26 ------ .../Components/OptionsMenu.swift | 58 ------------ .../ItemView/Components/PlayButton.swift | 16 ++-- 10 files changed, 92 insertions(+), 271 deletions(-) create mode 100644 Shared/ViewModels/ItemViewModel/ItemViewModel+Shuffle.swift delete mode 100644 Swiftfin tvOS/Views/ItemView/Components/PlayButton/Components/OptionsMenu.swift delete mode 100644 Swiftfin/Views/ItemView/Components/ActionButtonHStack/Components/OptionsMenu.swift diff --git a/Shared/Objects/MediaPlayerManager/NowPlayable/NowPlayableObserver.swift b/Shared/Objects/MediaPlayerManager/NowPlayable/NowPlayableObserver.swift index 98df587a47..dce9d88f06 100644 --- a/Shared/Objects/MediaPlayerManager/NowPlayable/NowPlayableObserver.swift +++ b/Shared/Objects/MediaPlayerManager/NowPlayable/NowPlayableObserver.swift @@ -117,9 +117,7 @@ class NowPlayableObserver: ViewModel, MediaPlayerObserver { } private func queueDidChange(_ newQueue: AnyMediaPlayerQueue?) { - // Update next/previous command availability based on queue state if let queue = newQueue { - // Subscribe to queue changes queue.$hasNextItem .sink { [weak self] hasNext in self?.updateQueueCommandAvailability(hasNext: hasNext, hasPrevious: queue.hasPreviousItem) @@ -132,10 +130,8 @@ class NowPlayableObserver: ViewModel, MediaPlayerObserver { } .store(in: &cancellables) - // Initial update updateQueueCommandAvailability(hasNext: queue.hasNextItem, hasPrevious: queue.hasPreviousItem) } else { - // No queue, disable commands updateQueueCommandAvailability(hasNext: false, hasPrevious: false) } } diff --git a/Shared/Objects/MediaPlayerManager/Supplements/EpisodeMediaPlayerQueue.swift b/Shared/Objects/MediaPlayerManager/Supplements/EpisodeMediaPlayerQueue.swift index d59e62b7d7..e4e7d3dcdf 100644 --- a/Shared/Objects/MediaPlayerManager/Supplements/EpisodeMediaPlayerQueue.swift +++ b/Shared/Objects/MediaPlayerManager/Supplements/EpisodeMediaPlayerQueue.swift @@ -468,7 +468,6 @@ extension EpisodeMediaPlayerQueue { } } - // Shared episode UI components (used by other queue types like ShuffleMediaPlayerQueue) struct EpisodeRow: View { @Default(.accentColor) diff --git a/Shared/Objects/MediaPlayerManager/Supplements/ShuffleMediaPlayerQueue.swift b/Shared/Objects/MediaPlayerManager/Supplements/ShuffleMediaPlayerQueue.swift index 7b421fc6ab..ae0f74976e 100644 --- a/Shared/Objects/MediaPlayerManager/Supplements/ShuffleMediaPlayerQueue.swift +++ b/Shared/Objects/MediaPlayerManager/Supplements/ShuffleMediaPlayerQueue.swift @@ -30,7 +30,6 @@ class ShuffleMediaPlayerQueue: ViewModel, MediaPlayerQueue { } } - // Use "Episodes" as display title for consistency with regular queue let displayTitle: String = L10n.episodes let id: String = "ShuffleMediaPlayerQueue" @@ -53,10 +52,8 @@ class ShuffleMediaPlayerQueue: ViewModel, MediaPlayerQueue { private var currentIndex: Int = 0 init(items: [BaseItemDto]) { - // Items are already shuffled by the caller, don't shuffle again self.shuffledItems = items super.init() - // Initialize the queue with adjacent items updateAdjacentItems() } @@ -70,7 +67,6 @@ class ShuffleMediaPlayerQueue: ViewModel, MediaPlayerQueue { return } - // Find the current item in our shuffled list if let index = shuffledItems.firstIndex(where: { $0.id == newItem.baseItem.id }) { currentIndex = index } @@ -147,16 +143,13 @@ extension ShuffleMediaPlayerQueue { var body: some View { CompactOrRegularView(isCompact: containerState.isCompact) { - // Compact (Portrait) - Vertical list CompactShuffleView(items: items, currentIndex: currentIndex) } regularView: { - // Regular (Landscape) - Horizontal row RegularShuffleView(items: items, currentIndex: currentIndex) } } } - // Compact view - Vertical scrolling list private struct CompactShuffleView: View { @EnvironmentObject @@ -195,7 +188,6 @@ extension ShuffleMediaPlayerQueue { } } - // Regular view - Horizontal scrolling row private struct RegularShuffleView: View { @Environment(\.safeAreaInsets) diff --git a/Shared/ViewModels/ItemViewModel/ItemViewModel+Shuffle.swift b/Shared/ViewModels/ItemViewModel/ItemViewModel+Shuffle.swift new file mode 100644 index 0000000000..59ea19f926 --- /dev/null +++ b/Shared/ViewModels/ItemViewModel/ItemViewModel+Shuffle.swift @@ -0,0 +1,39 @@ +// +// 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 JellyfinAPI +import Logging + +extension ItemViewModel { + + func playShuffle(router: NavigationCoordinator.Router) { + guard item.canShuffle else { + logger.error("Shuffle not supported for item type: \(String(describing: item.type))") + return + } + + Task { @MainActor in + do { + #if os(tvOS) + let autoSelectMediaSource = false + #else + let autoSelectMediaSource = true + #endif + + try await ShuffleActionHelper().shuffleAndPlay( + item, + viewModel: self, + router: router, + autoSelectMediaSource: autoSelectMediaSource + ) + } catch { + logger.error("Error shuffling items: \(error)") + } + } + } +} diff --git a/Shared/ViewModels/ItemViewModel/ShuffleActionHelper.swift b/Shared/ViewModels/ItemViewModel/ShuffleActionHelper.swift index d923aa72b7..5080acf25b 100644 --- a/Shared/ViewModels/ItemViewModel/ShuffleActionHelper.swift +++ b/Shared/ViewModels/ItemViewModel/ShuffleActionHelper.swift @@ -11,18 +11,11 @@ import Foundation import JellyfinAPI import Logging -/// Helper for performing shuffle actions on items @MainActor struct ShuffleActionHelper { private let logger = Logger.swiftfin() - /// Initiates shuffle playback for the given item with the specified media source - /// - Parameters: - /// - item: The item to shuffle (Series, Season, Collection, etc.) - /// - mediaSource: The media source to use for playback - /// - viewModel: The ItemViewModel for the item - /// - Returns: A tuple containing the first item and the shuffle queue, or nil if shuffle is not supported func shuffleItem( _ item: BaseItemDto, mediaSource: MediaSourceInfo, @@ -35,7 +28,6 @@ struct ShuffleActionHelper { let shuffledItems: [BaseItemDto] - // Get shuffled items based on item type if let seriesViewModel = viewModel as? SeriesItemViewModel { shuffledItems = try await seriesViewModel.getShuffledItems() } else if let collectionViewModel = viewModel as? CollectionItemViewModel { @@ -55,7 +47,6 @@ struct ShuffleActionHelper { return nil } - // Reset playback position for the first item firstItem.userData?.playbackPositionTicks = 0 let queue = ShuffleMediaPlayerQueue(items: shuffledItems) @@ -63,12 +54,6 @@ struct ShuffleActionHelper { return (firstItem, queue) } - /// Initiates shuffle playback and routes to the video player (tvOS/fixed media source) - /// - Parameters: - /// - item: The item to shuffle - /// - mediaSource: The media source to use for playback - /// - viewModel: The ItemViewModel for the item - /// - router: The router to use for navigation func shuffleAndPlay( _ item: BaseItemDto, mediaSource: MediaSourceInfo, @@ -91,47 +76,59 @@ struct ShuffleActionHelper { } } - /// Initiates shuffle playback and routes to the video player (iOS/auto-select media source) - /// - Parameters: - /// - item: The item to shuffle - /// - viewModel: The ItemViewModel for the item - /// - router: The router to use for navigation - func shuffleAndPlayWithAutoSource( + func shuffleAndPlay( _ item: BaseItemDto, viewModel: ItemViewModel, - router: NavigationCoordinator.Router + router: NavigationCoordinator.Router, + autoSelectMediaSource: Bool = false ) async throws { - guard let result = try await shuffleItem(item, mediaSource: MediaSourceInfo(), viewModel: viewModel) else { - return - } + if autoSelectMediaSource { + guard let result = try await shuffleItem(item, mediaSource: MediaSourceInfo(), viewModel: viewModel) else { + return + } - // iOS: Let each item auto-select its media source - let provider = MediaPlayerItemProvider(item: result.firstItem) { item in - try await MediaPlayerItem.build(for: item) { - $0.userData?.playbackPositionTicks = 0 + 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 + 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 ) } } - // MARK: - Library Shuffle Utilities - /// Collects playable items from a mixed collection, expanding containers into their playable content. /// /// Containers (series, boxSets) cannot be played directly and are expanded into their playable content. /// BoxSets may contain series, which are recursively expanded into episodes. /// Other items (movies, episodes, etc.) are included as-is if playable and have media sources. - /// - /// - Parameter items: The items to process (may include series, collections, movies, episodes, etc.) - /// - Returns: A flat list of playable items with containers expanded into their playable content static func collectPlayableItems(from items: [BaseItemDto]) async throws -> [BaseItemDto] { guard let userSession = Container.shared.currentUserSession() else { throw JellyfinAPIError("No user session") @@ -142,16 +139,13 @@ struct ShuffleActionHelper { for item in items { switch item.type { case .series: - // Series are containers - fetch and include all their episodes let episodes = try await fetchEpisodes(for: item, userSession: userSession) playableItems.append(contentsOf: episodes) case .boxSet: - // BoxSets are containers - fetch contents and recursively expand any series let contents = try await fetchBoxSetContents(for: item, userSession: userSession) let expandedContents = try await collectPlayableItems(from: contents) playableItems.append(contentsOf: expandedContents) default: - // Include directly playable items (movies, episodes, etc.) that have media sources if item.isPlayable && item.mediaSources?.isNotEmpty == true { playableItems.append(item) } @@ -161,11 +155,6 @@ struct ShuffleActionHelper { return playableItems } - /// Fetches all episodes for a given series - /// - Parameters: - /// - series: The series to fetch episodes for - /// - userSession: The current user session - /// - Returns: Array of episodes with media sources private static func fetchEpisodes(for series: BaseItemDto, userSession: UserSession) async throws -> [BaseItemDto] { var parameters = Paths.GetItemsByUserIDParameters() parameters.fields = .MinimumFields @@ -185,11 +174,6 @@ struct ShuffleActionHelper { return episodes.filter { $0.mediaSources?.isNotEmpty ?? false } } - /// Fetches all contents for a given boxSet/collection - /// - Parameters: - /// - boxSet: The boxSet to fetch contents for - /// - userSession: The current user session - /// - Returns: Array of items in the collection (may include series, movies, etc.) private static func fetchBoxSetContents(for boxSet: BaseItemDto, userSession: UserSession) async throws -> [BaseItemDto] { var parameters = Paths.GetItemsByUserIDParameters() parameters.fields = .MinimumFields diff --git a/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift index 2ee98e9706..9c931a9301 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,16 +110,16 @@ extension ItemView { ) } - // MARK: Shuffle - - if viewModel.item.canShuffle { - OptionsMenu(viewModel: viewModel) - } - - // 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) + } + } + } + if canRefresh || canManageSubtitles { Section(L10n.manage) { if canRefresh { diff --git a/Swiftfin tvOS/Views/ItemView/Components/PlayButton/Components/OptionsMenu.swift b/Swiftfin tvOS/Views/ItemView/Components/PlayButton/Components/OptionsMenu.swift deleted file mode 100644 index faa557a837..0000000000 --- a/Swiftfin tvOS/Views/ItemView/Components/PlayButton/Components/OptionsMenu.swift +++ /dev/null @@ -1,76 +0,0 @@ -// -// 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 JellyfinAPI -import Logging -import SwiftUI - -extension ItemView { - - struct OptionsMenu: View { - - @Router - private var router - - @ObservedObject - var viewModel: ItemViewModel - - private let logger = Logger.swiftfin() - - // MARK: - Body - - var body: some View { - ActionButton(L10n.options, icon: "ellipsis.circle") { - Button(L10n.shuffle, systemImage: "shuffle") { - playShuffle() - } - } - } - - // MARK: - Play Shuffled - - private func playShuffle() { - guard viewModel.item.canShuffle else { - logger.error("Shuffle not supported for item type: \(String(describing: viewModel.item.type))") - return - } - - Task { - do { - let containerTypes: Set = [.series, .boxSet, .collectionFolder, .folder, .playlist] - let helper = ShuffleActionHelper() - - if let itemType = viewModel.item.type, containerTypes.contains(itemType) { - // Use a dummy media source for containers - it won't be used - try await helper.shuffleAndPlay( - viewModel.item, - mediaSource: MediaSourceInfo(), - viewModel: viewModel, - router: router.router - ) - } else { - // For playable items, require media source - guard let selectedMediaSource = viewModel.selectedMediaSource else { - logger.error("Shuffle selected with no media source for playable item") - return - } - - try await helper.shuffleAndPlay( - viewModel.item, - mediaSource: selectedMediaSource, - viewModel: viewModel, - router: router.router - ) - } - } catch { - logger.error("Error shuffling items: \(error)") - } - } - } - } -} diff --git a/Swiftfin/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift b/Swiftfin/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift index b5a2330b77..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, @@ -112,17 +97,6 @@ extension ItemView { view.aspectRatio(1, contentMode: .fit) } } - - // MARK: - Options Menu - - if viewModel.item.canShuffle { - OptionsMenu(viewModel: viewModel) - .menuStyle(.button) - .frame(maxWidth: .infinity) - .if(!equalSpacing) { view in - view.aspectRatio(1, contentMode: .fit) - } - } } .font(.title3) .fontWeight(.semibold) diff --git a/Swiftfin/Views/ItemView/Components/ActionButtonHStack/Components/OptionsMenu.swift b/Swiftfin/Views/ItemView/Components/ActionButtonHStack/Components/OptionsMenu.swift deleted file mode 100644 index 6360cab966..0000000000 --- a/Swiftfin/Views/ItemView/Components/ActionButtonHStack/Components/OptionsMenu.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// 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 JellyfinAPI -import Logging -import SwiftUI - -extension ItemView { - - struct OptionsMenu: View { - - @Router - private var router - - @ObservedObject - var viewModel: ItemViewModel - - private let logger = Logger.swiftfin() - - // MARK: - Body - - var body: some View { - Menu { - Button(L10n.shuffle, systemImage: "shuffle") { - playShuffle() - } - } label: { - Label(L10n.options, systemImage: "ellipsis.circle") - } - } - - // MARK: - Play Shuffled - - private func playShuffle() { - guard viewModel.item.canShuffle else { - logger.error("Shuffle not supported for item type: \(String(describing: viewModel.item.type))") - return - } - - Task { - do { - try await ShuffleActionHelper().shuffleAndPlayWithAutoSource( - viewModel.item, - viewModel: viewModel, - router: router.router - ) - } catch { - logger.error("Error shuffling items: \(error)") - } - } - } - } -} diff --git a/Swiftfin/Views/ItemView/Components/PlayButton.swift b/Swiftfin/Views/ItemView/Components/PlayButton.swift index 49e4e30bd6..96399a3372 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,16 @@ extension ItemView { play(fromBeginning: true) } } + + if viewModel.item.canShuffle { + Button(L10n.shuffle, systemImage: "shuffle") { + viewModel.playShuffle(router: router.router) + } + } } .disabled(!isEnabled) } - // MARK: - Play Content - private func play(fromBeginning: Bool = false) { guard let playButtonItem = viewModel.playButtonItem, let selectedMediaSource = viewModel.selectedMediaSource From 6e908e6a34ac98ff45d5e58086cceca23cc8fd95 Mon Sep 17 00:00:00 2001 From: vikgor Date: Mon, 17 Nov 2025 23:52:58 +0500 Subject: [PATCH 4/4] Refactor shuffle queue management --- .../Supplements/EpisodeMediaPlayerQueue.swift | 154 +----------- .../MediaPlayerQueueItemViews.swift | 190 ++++++++++++++ .../Supplements/ShuffleMediaPlayerQueue.swift | 96 ++++++-- Shared/Strings/Strings.swift | 2 + .../CollectionItemViewModel.swift | 24 +- .../ItemViewModel/ItemViewModel+Shuffle.swift | 39 --- .../ItemViewModel/ItemViewModel.swift | 182 ++++++++++++++ .../ItemViewModel/SeriesItemViewModel.swift | 26 +- .../ItemViewModel/ShuffleActionHelper.swift | 231 +++++++++++++----- .../ItemLibraryViewModel.swift | 32 +-- .../PagingLibraryViewModel.swift | 7 +- .../ActionButtonHStack.swift | 1 + .../PagingLibraryView/PagingLibraryView.swift | 41 +--- .../ItemView/Components/PlayButton.swift | 12 +- Swiftfin/Views/ItemView/ItemView.swift | 15 ++ .../PagingLibraryView/PagingLibraryView.swift | 80 +++--- Translations/en.lproj/Localizable.strings | Bin 106684 -> 106780 bytes 17 files changed, 729 insertions(+), 403 deletions(-) create mode 100644 Shared/Objects/MediaPlayerManager/Supplements/MediaPlayerQueueItemViews.swift delete mode 100644 Shared/ViewModels/ItemViewModel/ItemViewModel+Shuffle.swift diff --git a/Shared/Objects/MediaPlayerManager/Supplements/EpisodeMediaPlayerQueue.swift b/Shared/Objects/MediaPlayerManager/Supplements/EpisodeMediaPlayerQueue.swift index e4e7d3dcdf..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 { } } } - - 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) - } - } - - 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) - } - } - - 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) - } - } - - 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 index ae0f74976e..a1e5064a2a 100644 --- a/Shared/Objects/MediaPlayerManager/Supplements/ShuffleMediaPlayerQueue.swift +++ b/Shared/Objects/MediaPlayerManager/Supplements/ShuffleMediaPlayerQueue.swift @@ -23,8 +23,11 @@ class ShuffleMediaPlayerQueue: ViewModel, MediaPlayerQueue { cancellables = [] guard let manager else { return } manager.$playbackItem + .receive(on: DispatchQueue.main) .sink { [weak self] newItem in - self?.didReceive(newItem: newItem) + Task { @MainActor [weak self] in + self?.didReceive(newItem: newItem) + } } .store(in: &cancellables) } @@ -48,11 +51,16 @@ class ShuffleMediaPlayerQueue: ViewModel, MediaPlayerQueue { lazy var nextItemPublisher: Published.Publisher = $nextItem lazy var previousItemPublisher: Published.Publisher = $previousItem - private var shuffledItems: [BaseItemDto] + 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]) { + init(items: [BaseItemDto], fetchMoreItems: (() async throws -> [BaseItemDto])? = nil) { self.shuffledItems = items + self.fetchMoreItems = fetchMoreItems + self.excludeItemIDs = Set(items.compactMap(\.id)) super.init() updateAdjacentItems() } @@ -76,9 +84,19 @@ class ShuffleMediaPlayerQueue: ViewModel, MediaPlayerQueue { private func updateAdjacentItems() { let hasPrevious = currentIndex > 0 - let hasNext = currentIndex < shuffledItems.count - 1 + let remainingItems = shuffledItems.count - currentIndex - 1 + let hasNext = remainingItems > 0 - logger.info("Updating adjacent items: current index = \(currentIndex), hasNext = \(hasNext), hasPrevious = \(hasPrevious)") + 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? @@ -115,6 +133,39 @@ class ShuffleMediaPlayerQueue: ViewModel, MediaPlayerQueue { "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 { @@ -180,7 +231,7 @@ extension ShuffleMediaPlayerQueue { insets: .init(top: 0, leading: 0, bottom: EdgeInsets.edgePadding, trailing: 0) ) ) { item in - EpisodeMediaPlayerQueue.EpisodeRow(episode: item) { + MediaPlayerQueueItemViews.ItemRow(item: item) { selectItem(item) } .edgePadding(.horizontal) @@ -190,9 +241,6 @@ extension ShuffleMediaPlayerQueue { private struct RegularShuffleView: View { - @Environment(\.safeAreaInsets) - private var safeAreaInsets: EdgeInsets - @EnvironmentObject private var containerState: VideoPlayerContainerState @EnvironmentObject @@ -213,16 +261,26 @@ extension ShuffleMediaPlayerQueue { } var body: some View { - CollectionHStack( - uniqueElements: items, - id: \.unwrappedIDHashOrZero - ) { item in - EpisodeMediaPlayerQueue.EpisodeButton(episode: item) { - selectItem(item) - } - .frame(height: 150) - } - .insets(horizontal: max(safeAreaInsets.leading, safeAreaInsets.trailing) + EdgeInsets.edgePadding) + 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 e0f3dfa33c..44c55f7c0b 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -1432,6 +1432,8 @@ internal enum L10n { 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 f1aad3713b..dddcd10fcb 100644 --- a/Shared/ViewModels/ItemViewModel/CollectionItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/CollectionItemViewModel.swift @@ -66,22 +66,14 @@ final class CollectionItemViewModel: ItemViewModel { // MARK: - Get Shuffled Items - func getShuffledItems() async throws -> [BaseItemDto] { - var parameters = Paths.GetItemsByUserIDParameters() - parameters.fields = .MinimumFields - parameters.isRecursive = true - parameters.parentID = item.id - parameters.sortBy = [ItemSortBy.sortName.rawValue] - parameters.sortOrder = [.ascending] - - let request = Paths.getItemsByUserID( - userID: userSession.user.id, - parameters: parameters + @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 ) - let response = try await userSession.client.send(request) - - return (response.value.items ?? []) - .filter(\.isPlayable) - .shuffled() } } diff --git a/Shared/ViewModels/ItemViewModel/ItemViewModel+Shuffle.swift b/Shared/ViewModels/ItemViewModel/ItemViewModel+Shuffle.swift deleted file mode 100644 index 59ea19f926..0000000000 --- a/Shared/ViewModels/ItemViewModel/ItemViewModel+Shuffle.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// 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 JellyfinAPI -import Logging - -extension ItemViewModel { - - func playShuffle(router: NavigationCoordinator.Router) { - guard item.canShuffle else { - logger.error("Shuffle not supported for item type: \(String(describing: item.type))") - return - } - - Task { @MainActor in - do { - #if os(tvOS) - let autoSelectMediaSource = false - #else - let autoSelectMediaSource = true - #endif - - try await ShuffleActionHelper().shuffleAndPlay( - item, - viewModel: self, - router: router, - autoSelectMediaSource: autoSelectMediaSource - ) - } catch { - logger.error("Error shuffling items: \(error)") - } - } - } -} diff --git a/Shared/ViewModels/ItemViewModel/ItemViewModel.swift b/Shared/ViewModels/ItemViewModel/ItemViewModel.swift index a3c3ef56cf..4e8281d5e5 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 48b6738d9f..53ea4dddab 100644 --- a/Shared/ViewModels/ItemViewModel/SeriesItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/SeriesItemViewModel.swift @@ -149,23 +149,15 @@ final class SeriesItemViewModel: ItemViewModel { // MARK: - Get Shuffled Items - func getShuffledItems() async throws -> [BaseItemDto] { - var parameters = Paths.GetItemsByUserIDParameters() - parameters.enableUserData = true - parameters.fields = .MinimumFields - parameters.includeItemTypes = [.episode] - parameters.isRecursive = true - parameters.isMissing = Defaults[.Customization.shouldShowMissingEpisodes] ? nil : false - parameters.parentID = item.id - parameters.sortBy = [ItemSortBy.sortName.rawValue] - parameters.sortOrder = [.ascending] - - let request = Paths.getItemsByUserID( - userID: userSession.user.id, - parameters: parameters + @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 ) - let response = try await userSession.client.send(request) - - return (response.value.items ?? []).shuffled() } } diff --git a/Shared/ViewModels/ItemViewModel/ShuffleActionHelper.swift b/Shared/ViewModels/ItemViewModel/ShuffleActionHelper.swift index 5080acf25b..a9c85a986c 100644 --- a/Shared/ViewModels/ItemViewModel/ShuffleActionHelper.swift +++ b/Shared/ViewModels/ItemViewModel/ShuffleActionHelper.swift @@ -10,12 +10,23 @@ 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, @@ -26,16 +37,7 @@ struct ShuffleActionHelper { return nil } - let shuffledItems: [BaseItemDto] - - if let seriesViewModel = viewModel as? SeriesItemViewModel { - shuffledItems = try await seriesViewModel.getShuffledItems() - } else if let collectionViewModel = viewModel as? CollectionItemViewModel { - shuffledItems = try await collectionViewModel.getShuffledItems() - } else { - logger.error("No shuffle implementation for this ItemViewModel type") - return nil - } + let shuffledItems = try await viewModel.getShuffledItems() guard shuffledItems.isNotEmpty else { logger.error("No items to shuffle") @@ -49,7 +51,15 @@ struct ShuffleActionHelper { firstItem.userData?.playbackPositionTicks = 0 - let queue = ShuffleMediaPlayerQueue(items: shuffledItems) + 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) } @@ -124,11 +134,6 @@ struct ShuffleActionHelper { } } - /// Collects playable items from a mixed collection, expanding containers into their playable content. - /// - /// Containers (series, boxSets) cannot be played directly and are expanded into their playable content. - /// BoxSets may contain series, which are recursively expanded into episodes. - /// Other items (movies, episodes, etc.) are included as-is if playable and have media sources. static func collectPlayableItems(from items: [BaseItemDto]) async throws -> [BaseItemDto] { guard let userSession = Container.shared.currentUserSession() else { throw JellyfinAPIError("No user session") @@ -137,57 +142,161 @@ struct ShuffleActionHelper { var playableItems: [BaseItemDto] = [] for item in items { - switch item.type { - case .series: - let episodes = try await fetchEpisodes(for: item, userSession: userSession) - playableItems.append(contentsOf: episodes) - case .boxSet: - let contents = try await fetchBoxSetContents(for: item, userSession: userSession) - let expandedContents = try await collectPlayableItems(from: contents) - playableItems.append(contentsOf: expandedContents) - default: - if item.isPlayable && item.mediaSources?.isNotEmpty == true { - playableItems.append(item) - } + 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) } } - return playableItems + // .shuffled() needed to break up grouping from sequential container expansion + return playableItems.shuffled() } - private static func fetchEpisodes(for series: BaseItemDto, userSession: UserSession) async throws -> [BaseItemDto] { - var parameters = Paths.GetItemsByUserIDParameters() - parameters.fields = .MinimumFields - parameters.includeItemTypes = [.episode] - parameters.isRecursive = true - parameters.parentID = series.id - parameters.sortBy = [ItemSortBy.sortName.rawValue] - parameters.sortOrder = [.ascending] - - let request = Paths.getItemsByUserID( - userID: userSession.user.id, - parameters: parameters - ) - let response = try await userSession.client.send(request) - let episodes = response.value.items ?? [] - - return episodes.filter { $0.mediaSources?.isNotEmpty ?? false } + 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 } - private static func fetchBoxSetContents(for boxSet: BaseItemDto, userSession: UserSession) async throws -> [BaseItemDto] { - var parameters = Paths.GetItemsByUserIDParameters() - parameters.fields = .MinimumFields - parameters.isRecursive = true - parameters.parentID = boxSet.id - parameters.sortBy = [ItemSortBy.sortName.rawValue] - parameters.sortOrder = [.ascending] - - let request = Paths.getItemsByUserID( - userID: userSession.user.id, - parameters: parameters - ) - let response = try await userSession.client.send(request) - - return response.value.items ?? [] + 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 c7f15b8ecd..53ff45d0c9 100644 --- a/Shared/ViewModels/LibraryViewModel/ItemLibraryViewModel.swift +++ b/Shared/ViewModels/LibraryViewModel/ItemLibraryViewModel.swift @@ -128,27 +128,15 @@ final class ItemLibraryViewModel: PagingLibraryViewModel { // MARK: getShuffledItems - override func getShuffledItems() async throws -> [BaseItemDto] { - var parameters = Paths.GetItemsByUserIDParameters() - parameters.enableUserData = true - parameters.fields = .MinimumFields - parameters.isRecursive = true - parameters.sortBy = [ItemSortBy.sortName.rawValue] - parameters.sortOrder = [.ascending] - - // Set the parent if we're in a specific library/folder - if let parent { - parameters = parent.setParentParameters(parameters) - } - - let request = Paths.getItemsByUserID(userID: userSession.user.id, parameters: parameters) - let response = try await userSession.client.send(request) - - let allItems = response.value.items ?? [] - - // Use shared helper to expand containers (series, boxSets) into playable content - let playableItems = try await ShuffleActionHelper.collectPlayableItems(from: allItems) - - return playableItems.shuffled() + @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 6ddd6cab70..9d7411a4ba 100644 --- a/Shared/ViewModels/LibraryViewModel/PagingLibraryViewModel.swift +++ b/Shared/ViewModels/LibraryViewModel/PagingLibraryViewModel.swift @@ -83,6 +83,7 @@ class PagingLibraryViewModel: ViewModel, Eventful, Stateful { enum BackgroundState: Hashable { case gettingNextPage + case shuffling } // MARK: State @@ -326,9 +327,12 @@ class PagingLibraryViewModel: ViewModel, Eventful, Stateful { 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 } @@ -398,7 +402,8 @@ class PagingLibraryViewModel: ViewModel, Eventful, Stateful { /// Gets all items shuffled. Override if items should /// come from another source instead. - func getShuffledItems() async throws -> [Element] { + @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 9c931a9301..b1371f6d69 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift @@ -117,6 +117,7 @@ extension ItemView { Button(L10n.shuffle, systemImage: "shuffle") { viewModel.playShuffle(router: router.router) } + .disabled(viewModel.backgroundStates.contains(.shuffling)) } } diff --git a/Swiftfin tvOS/Views/PagingLibraryView/PagingLibraryView.swift b/Swiftfin tvOS/Views/PagingLibraryView/PagingLibraryView.swift index e2070f2064..8150b13e55 100644 --- a/Swiftfin tvOS/Views/PagingLibraryView/PagingLibraryView.swift +++ b/Swiftfin tvOS/Views/PagingLibraryView/PagingLibraryView.swift @@ -123,40 +123,11 @@ struct PagingLibraryView: View { let baseItems = items.compactMap { $0 as? BaseItemDto } Task { - await playShuffledItems(baseItems) - } - } - - private func playShuffledItems(_ items: [BaseItemDto]) async { - do { - let playableItems = try await ShuffleActionHelper.collectPlayableItems(from: items) - let shuffledItems = playableItems.shuffled() - - guard let firstItem = shuffledItems.first else { return } - - await routeToVideoPlayer(withFirst: firstItem, queue: shuffledItems) - } catch { - // TODO: Handle error properly with user-visible error message - } - } - - private func routeToVideoPlayer( - withFirst firstItem: BaseItemDto, - queue items: [BaseItemDto] - ) async { - var mutableFirstItem = firstItem - mutableFirstItem.userData?.playbackPositionTicks = 0 - - let queue = ShuffleMediaPlayerQueue(items: items) - let manager = MediaPlayerManager( - item: mutableFirstItem, - queue: queue - ) { item in - try await MediaPlayerItem.build(for: item) - } - - await MainActor.run { - router.route(to: .videoPlayer(manager: manager)) + await ShuffleActionHelper().playLibraryShuffle( + items: baseItems, + viewModel: viewModel, + router: router.router + ) } } @@ -315,7 +286,7 @@ struct PagingLibraryView: View { switch viewModel.state { case .content: if viewModel.elements.isEmpty { - L10n.noResults.text + Text(L10n.noResults) } else { gridView } diff --git a/Swiftfin/Views/ItemView/Components/PlayButton.swift b/Swiftfin/Views/ItemView/Components/PlayButton.swift index 96399a3372..9499448e2b 100644 --- a/Swiftfin/Views/ItemView/Components/PlayButton.swift +++ b/Swiftfin/Views/ItemView/Components/PlayButton.swift @@ -85,9 +85,19 @@ extension ItemView { } if viewModel.item.canShuffle { - Button(L10n.shuffle, systemImage: "shuffle") { + 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) diff --git a/Swiftfin/Views/ItemView/ItemView.swift b/Swiftfin/Views/ItemView/ItemView.swift index 04e3820aed..870c40ec0c 100644 --- a/Swiftfin/Views/ItemView/ItemView.swift +++ b/Swiftfin/Views/ItemView/ItemView.swift @@ -143,6 +143,21 @@ struct ItemView: View { case .initial, .refreshing: DelayedProgressView() } + + // 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 bb44d3f1a3..2c34826aa7 100644 --- a/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift +++ b/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift @@ -244,17 +244,34 @@ struct PagingLibraryView: View { @ViewBuilder private var innerContent: some View { - switch viewModel.state { - case .content: - if viewModel.elements.isEmpty { - L10n.noResults.text - } else { - elementsView + ZStack { + switch viewModel.state { + case .content: + if viewModel.elements.isEmpty { + Text(L10n.noResults) + } else { + elementsView + } + case .initial, .refreshing: + DelayedProgressView() + 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: - DelayedProgressView() - default: - AssertionFailureView("Expected view for unexpected state") } } @@ -476,7 +493,7 @@ struct PagingLibraryView: View { Button(L10n.shuffle, systemImage: "shuffle") { viewModel.send(.getShuffledItems) } - .disabled(viewModel.elements.isEmpty) + .disabled(viewModel.elements.isEmpty || viewModel.backgroundStates.contains(.gettingNextPage)) } } } @@ -488,42 +505,11 @@ private extension PagingLibraryView { let baseItems = items.compactMap { $0 as? BaseItemDto } Task { - await playShuffledItems(baseItems, in: namespace) - } - } - - func playShuffledItems(_ items: [BaseItemDto], in namespace: Namespace.ID) async { - do { - let playableItems = try await ShuffleActionHelper.collectPlayableItems(from: items) - let shuffledItems = playableItems.shuffled() - - guard let firstItem = shuffledItems.first else { return } - - await routeToVideoPlayer(withFirst: firstItem, queue: shuffledItems, in: namespace) - } catch { - // TODO: Handle error properly with user-visible error message - } - } - - func routeToVideoPlayer( - withFirst firstItem: BaseItemDto, - queue items: [BaseItemDto], - in namespace: Namespace.ID - ) async { - let queue = ShuffleMediaPlayerQueue(items: items) - let provider = MediaPlayerItemProvider(item: firstItem) { item in - try await MediaPlayerItem.build(for: item) { - $0.userData?.playbackPositionTicks = 0 - } - } - - await MainActor.run { - router.route( - to: .videoPlayer( - provider: provider, - queue: queue - ), - in: namespace + await ShuffleActionHelper().playLibraryShuffle( + items: baseItems, + viewModel: viewModel, + router: router.router, + namespace: namespace ) } } diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index 46f788214e6213f3d85a09f4a12aa883a3d04134..690b58effd5b223545c242f440c6d8a218456c48 100644 GIT binary patch delta 67 zcmdmUkZsN(wuUW?U4imx3~3BG44Djh4CxGdV93Rw#8AwT!B7g6Nt@j0EX)}U=goJP L-`*0)XyFe4XrU2y delta 17 ZcmbPph;7e7wuUW?U4h$Q1u`1>0{}@f2W$WU