From eb99dfe30b6bb55771a0fdeffa661f6a8527c974 Mon Sep 17 00:00:00 2001 From: William Martineau <46766097+william1509@users.noreply.github.com> Date: Fri, 29 Sep 2023 00:37:17 -0400 Subject: [PATCH] Shuffle play (#816) --- Shared/Strings/Strings.swift | 14 ++- .../ViewModels/ItemTypeLibraryViewModel.swift | 44 ++++--- Shared/ViewModels/LibraryViewModel.swift | 108 +++++++++--------- .../ViewModels/PagingLibraryViewModel.swift | 25 ++++ Swiftfin.xcodeproj/project.pbxproj | 4 + .../Components/LibraryViewTypeToggle.swift | 4 +- Swiftfin/Components/RandomItemButton.swift | 42 +++++++ Swiftfin/Views/BasicLibraryView.swift | 13 ++- Swiftfin/Views/LibraryView.swift | 14 ++- Translations/en.lproj/Localizable.strings | Bin 16948 -> 8532 bytes 10 files changed, 187 insertions(+), 81 deletions(-) create mode 100644 Swiftfin/Components/RandomItemButton.swift diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index 9dc02810..394ddd7a 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -28,7 +28,7 @@ internal enum L10n { internal static let allGenres = L10n.tr("Localizable", "allGenres", fallback: "All Genres") /// All Media internal static let allMedia = L10n.tr("Localizable", "allMedia", fallback: "All Media") - /// Appearance + /// Represents the Appearance setting label internal static let appearance = L10n.tr("Localizable", "appearance", fallback: "Appearance") /// App Icon internal static let appIcon = L10n.tr("Localizable", "appIcon", fallback: "App Icon") @@ -106,7 +106,7 @@ internal enum L10n { internal static let currentPosition = L10n.tr("Localizable", "currentPosition", fallback: "Current Position") /// Customize internal static let customize = L10n.tr("Localizable", "customize", fallback: "Customize") - /// Dark + /// Represents the dark theme setting internal static let dark = L10n.tr("Localizable", "dark", fallback: "Dark") /// Default Scheme internal static let defaultScheme = L10n.tr("Localizable", "defaultScheme", fallback: "Default Scheme") @@ -156,6 +156,8 @@ internal enum L10n { internal static let genres = L10n.tr("Localizable", "genres", fallback: "Genres") /// Green internal static let green = L10n.tr("Localizable", "green", fallback: "Green") + /// Grid + internal static let grid = L10n.tr("Localizable", "grid", fallback: "Grid") /// Haptic Feedback internal static let hapticFeedback = L10n.tr("Localizable", "hapticFeedback", fallback: "Haptic Feedback") /// Home @@ -194,8 +196,10 @@ internal enum L10n { } /// Library internal static let library = L10n.tr("Localizable", "library", fallback: "Library") - /// Light + /// Represents the light theme setting internal static let light = L10n.tr("Localizable", "light", fallback: "Light") + /// List + internal static let list = L10n.tr("Localizable", "list", fallback: "List") /// Live TV internal static let liveTV = L10n.tr("Localizable", "liveTV", fallback: "Live TV") /// Loading @@ -340,6 +344,8 @@ internal enum L10n { internal static let quickConnectStep3 = L10n.tr("Localizable", "quickConnectStep3", fallback: "3. Enter the following code:") /// Authorizing Quick Connect successful. Please continue on your other device. internal static let quickConnectSuccessMessage = L10n.tr("Localizable", "quickConnectSuccessMessage", fallback: "Authorizing Quick Connect successful. Please continue on your other device.") + /// Random + internal static let random = L10n.tr("Localizable", "random", fallback: "Random") /// Random Image internal static let randomImage = L10n.tr("Localizable", "randomImage", fallback: "Random Image") /// Rated @@ -480,7 +486,7 @@ internal enum L10n { internal static let suggestions = L10n.tr("Localizable", "suggestions", fallback: "Suggestions") /// Switch User internal static let switchUser = L10n.tr("Localizable", "switchUser", fallback: "Switch User") - /// System + /// Represents the system theme setting internal static let system = L10n.tr("Localizable", "system", fallback: "System") /// System Control Gestures Enabled internal static let systemControlGesturesEnabled = L10n.tr("Localizable", "systemControlGesturesEnabled", fallback: "System Control Gestures Enabled") diff --git a/Shared/ViewModels/ItemTypeLibraryViewModel.swift b/Shared/ViewModels/ItemTypeLibraryViewModel.swift index d388dfcf..d602b4b5 100644 --- a/Shared/ViewModels/ItemTypeLibraryViewModel.swift +++ b/Shared/ViewModels/ItemTypeLibraryViewModel.swift @@ -8,6 +8,7 @@ import Combine import Foundation +import Get import JellyfinAPI final class ItemTypeLibraryViewModel: PagingLibraryViewModel { @@ -35,25 +36,8 @@ final class ItemTypeLibraryViewModel: PagingLibraryViewModel { hasNextPage = true } - let genreIDs = filters.genres.compactMap(\.id) - let sortBy: [String] = filters.sortBy.map(\.filterName).appending("IsFolder") - let sortOrder = filters.sortOrder.map { SortOrder(rawValue: $0.filterName) ?? .ascending } - let itemFilters: [ItemFilter] = filters.filters.compactMap { .init(rawValue: $0.filterName) } - Task { - let parameters = Paths.GetItemsParameters( - userID: userSession.user.id, - startIndex: currentPage * pageItemSize, - limit: pageItemSize, - isRecursive: true, - sortOrder: sortOrder, - fields: ItemFields.allCases, - includeItemTypes: itemTypes, - filters: itemFilters, - sortBy: sortBy, - enableUserData: true, - genreIDs: genreIDs - ) + var parameters = self._getDefaultParams() let request = Paths.getItems(parameters: parameters) let response = try await userSession.client.send(request) @@ -68,6 +52,30 @@ final class ItemTypeLibraryViewModel: PagingLibraryViewModel { } } + override func _getDefaultParams() -> Paths.GetItemsParameters? { + let filters = filterViewModel.currentFilters + let genreIDs = filters.genres.compactMap(\.id) + let sortBy: [String] = filters.sortBy.map(\.filterName).appending("IsFolder") + let sortOrder = filters.sortOrder.map { SortOrder(rawValue: $0.filterName) ?? .ascending } + let itemFilters: [ItemFilter] = filters.filters.compactMap { .init(rawValue: $0.filterName) } + + let parameters = Paths.GetItemsParameters( + userID: userSession.user.id, + startIndex: currentPage * pageItemSize, + limit: pageItemSize, + isRecursive: true, + sortOrder: sortOrder, + fields: ItemFields.allCases, + includeItemTypes: itemTypes, + filters: itemFilters, + sortBy: sortBy, + enableUserData: true, + genreIDs: genreIDs + ) + + return parameters + } + override func _requestNextPage() { requestItems(with: filterViewModel.currentFilters) } diff --git a/Shared/ViewModels/LibraryViewModel.swift b/Shared/ViewModels/LibraryViewModel.swift index 5fbab98f..1bf62fd1 100644 --- a/Shared/ViewModels/LibraryViewModel.swift +++ b/Shared/ViewModels/LibraryViewModel.swift @@ -9,6 +9,7 @@ import Combine import Defaults import Factory +import Get import JellyfinAPI import SwiftUI import UIKit @@ -65,67 +66,21 @@ final class LibraryViewModel: PagingLibraryViewModel { self.hasNextPage = true } - var libraryID: String? - var personIDs: [String]? - var studioIDs: [String]? - - if let parent = parent { - switch type { - case .library, .folders: - libraryID = parent.id - case .person: - personIDs = [parent].compactMap(\.id) - case .studio: - studioIDs = [parent].compactMap(\.id) - } - } - - var recursive = true - let includeItemTypes: [BaseItemKind] - - if filters.filters.contains(ItemFilter.isFavorite.filter) { - includeItemTypes = [.movie, .boxSet, .series, .season, .episode] - } else if type == .folders { - recursive = false - includeItemTypes = [.movie, .boxSet, .series, .folder, .collectionFolder] - } else { - includeItemTypes = [.movie, .boxSet, .series] - } - - var excludedIDs: [String]? + var parameters = _getDefaultParams() + parameters?.limit = pageItemSize + parameters?.startIndex = currentPage * pageItemSize + parameters?.sortOrder = filters.sortOrder.map { SortOrder(rawValue: $0.filterName) ?? .ascending } + parameters?.sortBy = filters.sortBy.map(\.filterName).appending("IsFolder") if filters.sortBy.first == SortBy.random.filter { - excludedIDs = items.compactMap(\.id) + parameters?.excludeItemIDs = items.compactMap(\.id) } - let genreIDs = filters.genres.compactMap(\.id) - let sortBy: [String] = filters.sortBy.map(\.filterName).appending("IsFolder") - let sortOrder = filters.sortOrder.map { SortOrder(rawValue: $0.filterName) ?? .ascending } - let itemFilters: [ItemFilter] = filters.filters.compactMap { .init(rawValue: $0.filterName) } - Task { await MainActor.run { self.isLoading = true } - let parameters = Paths.GetItemsParameters( - userID: userSession.user.id, - excludeItemIDs: excludedIDs, - startIndex: currentPage * pageItemSize, - limit: pageItemSize, - isRecursive: recursive, - sortOrder: sortOrder, - parentID: libraryID, - fields: ItemFields.allCases, - includeItemTypes: includeItemTypes, - filters: itemFilters, - sortBy: sortBy, - enableUserData: true, - personIDs: personIDs, - studioIDs: studioIDs, - genreIDs: genreIDs, - enableImages: true - ) let request = Paths.getItems(parameters: parameters) let response = try await userSession.client.send(request) @@ -144,4 +99,53 @@ final class LibraryViewModel: PagingLibraryViewModel { override func _requestNextPage() { requestItems(with: filterViewModel.currentFilters) } + + override func _getDefaultParams() -> Paths.GetItemsParameters? { + + let filters = filterViewModel.currentFilters + var libraryID: String? + var personIDs: [String]? + var studioIDs: [String]? + let includeItemTypes: [BaseItemKind] + var recursive = true + + if let parent = parent { + switch type { + case .library, .folders: + libraryID = parent.id + case .person: + personIDs = [parent].compactMap(\.id) + case .studio: + studioIDs = [parent].compactMap(\.id) + } + } + + if filters.filters.contains(ItemFilter.isFavorite.filter) { + includeItemTypes = [.movie, .boxSet, .series, .season, .episode] + } else if type == .folders { + recursive = false + includeItemTypes = [.movie, .boxSet, .series, .folder, .collectionFolder] + } else { + includeItemTypes = [.movie, .boxSet, .series] + } + + let genreIDs = filters.genres.compactMap(\.id) + let itemFilters: [ItemFilter] = filters.filters.compactMap { .init(rawValue: $0.filterName) } + + let parameters = Paths.GetItemsParameters( + userID: userSession.user.id, + isRecursive: recursive, + parentID: libraryID, + fields: ItemFields.allCases, + includeItemTypes: includeItemTypes, + filters: itemFilters, + enableUserData: true, + personIDs: personIDs, + studioIDs: studioIDs, + genreIDs: genreIDs, + enableImages: true + ) + + return parameters + } } diff --git a/Shared/ViewModels/PagingLibraryViewModel.swift b/Shared/ViewModels/PagingLibraryViewModel.swift index 042c93a7..42d7408e 100644 --- a/Shared/ViewModels/PagingLibraryViewModel.swift +++ b/Shared/ViewModels/PagingLibraryViewModel.swift @@ -8,6 +8,7 @@ import Defaults import Foundation +import Get import JellyfinAPI import OrderedCollections import UIKit @@ -28,6 +29,30 @@ class PagingLibraryViewModel: ViewModel { return UIScreen.main.maxChildren(width: libraryGridPosterType.width, height: height) } + public func getRandomItemFromLibrary() async throws -> BaseItemDtoQueryResult { + + var parameters = _getDefaultParams() + parameters?.limit = 1 + parameters?.sortBy = [SortBy.random.rawValue] + + await MainActor.run { + self.isLoading = true + } + + let request = Paths.getItems(parameters: parameters) + let response = try await userSession.client.send(request) + + await MainActor.run { + self.isLoading = false + } + + return response.value + } + + func _getDefaultParams() -> Paths.GetItemsParameters? { + Paths.GetItemsParameters() + } + func refresh() { currentPage = 0 hasNextPage = true diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 39a51896..e7f55515 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 4E8B34EA2AB91B6E0018F305 /* FilterDrawerSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8B34E92AB91B6E0018F305 /* FilterDrawerSelection.swift */; }; 4E8B34EB2AB91B6E0018F305 /* FilterDrawerSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8B34E92AB91B6E0018F305 /* FilterDrawerSelection.swift */; }; 4EAA35BB2AB9699B00D840DD /* FilterDrawerButtonSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EAA35BA2AB9699B00D840DD /* FilterDrawerButtonSelectorView.swift */; }; + 4F1282B12A7F3E8F005BCA29 /* RandomItemButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F1282B02A7F3E8F005BCA29 /* RandomItemButton.swift */; }; 531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690E6267ABD79005D8AB9 /* HomeView.swift */; }; 53192D5D265AA78A008A4215 /* DeviceProfileBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */; }; 531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531AC8BE26750DE20091C7EB /* ImageView.swift */; }; @@ -777,6 +778,7 @@ 4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomizeViewsSettings.swift; sourceTree = ""; }; 4E8B34E92AB91B6E0018F305 /* FilterDrawerSelection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FilterDrawerSelection.swift; sourceTree = ""; }; 4EAA35BA2AB9699B00D840DD /* FilterDrawerButtonSelectorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FilterDrawerButtonSelectorView.swift; sourceTree = ""; }; + 4F1282B02A7F3E8F005BCA29 /* RandomItemButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RandomItemButton.swift; sourceTree = ""; }; 531690E6267ABD79005D8AB9 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; 531690F9267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlainNavigationLinkButton.swift; sourceTree = ""; }; 53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceProfileBuilder.swift; sourceTree = ""; }; @@ -1817,6 +1819,7 @@ E1581E26291EF59800D6C640 /* SplitContentView.swift */, E157562F29355B7900976E1F /* UpdateView.swift */, E192607F28D28AAD002314B4 /* UserProfileButton.swift */, + 4F1282B02A7F3E8F005BCA29 /* RandomItemButton.swift */, ); path = Components; sourceTree = ""; @@ -3374,6 +3377,7 @@ 535BAE9F2649E569005FA86D /* ItemView.swift in Sources */, E17AC9712954F636003D2BC2 /* DownloadListCoordinator.swift in Sources */, E10EAA4F277BBCC4000269ED /* CGSize.swift in Sources */, + 4F1282B12A7F3E8F005BCA29 /* RandomItemButton.swift in Sources */, E18E01EB288747230022598C /* MovieItemContentView.swift in Sources */, E17FB55B28C1266400311DFE /* GenresHStack.swift in Sources */, E18E01FA288747580022598C /* AboutAppView.swift in Sources */, diff --git a/Swiftfin/Components/LibraryViewTypeToggle.swift b/Swiftfin/Components/LibraryViewTypeToggle.swift index efd1ff13..3b3564dd 100644 --- a/Swiftfin/Components/LibraryViewTypeToggle.swift +++ b/Swiftfin/Components/LibraryViewTypeToggle.swift @@ -25,9 +25,9 @@ struct LibraryViewTypeToggle: View { } label: { switch libraryViewType { case .grid: - Image(systemName: "list.dash") + Label(L10n.list, systemImage: "list.dash") case .list: - Image(systemName: "square.grid.2x2") + Label(L10n.grid, systemImage: "square.grid.2x2") } } } diff --git a/Swiftfin/Components/RandomItemButton.swift b/Swiftfin/Components/RandomItemButton.swift new file mode 100644 index 00000000..8ea41889 --- /dev/null +++ b/Swiftfin/Components/RandomItemButton.swift @@ -0,0 +1,42 @@ +// +// 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) 2023 Jellyfin & Jellyfin Contributors +// + +import Defaults +import JellyfinAPI +import SwiftUI + +struct RandomItemButton: View { + + @ObservedObject + private var viewModel: PagingLibraryViewModel + private var onSelect: (BaseItemDtoQueryResult) -> Void + + var body: some View { + Button { + Task { + let response = try await viewModel.getRandomItemFromLibrary() + onSelect(response) + } + } label: { + Label(L10n.random, systemImage: "dice.fill") + } + } +} + +extension RandomItemButton { + init(viewModel: PagingLibraryViewModel) { + self.init( + viewModel: viewModel, + onSelect: { _ in } + ) + } + + func onSelect(_ action: @escaping (BaseItemDtoQueryResult) -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } +} diff --git a/Swiftfin/Views/BasicLibraryView.swift b/Swiftfin/Views/BasicLibraryView.swift index ee6dd06a..f69d19ed 100644 --- a/Swiftfin/Views/BasicLibraryView.swift +++ b/Swiftfin/Views/BasicLibraryView.swift @@ -58,8 +58,17 @@ struct BasicLibraryView: View { if viewModel.isLoading && !viewModel.items.isEmpty { ProgressView() } - - LibraryViewTypeToggle(libraryViewType: $libraryViewType) + Menu { + LibraryViewTypeToggle(libraryViewType: $libraryViewType) + RandomItemButton(viewModel: viewModel) + .onSelect { response in + if let item = response.items?.first { + router.route(to: \.item, item) + } + } + } label: { + Image(systemName: "ellipsis.circle") + } } } } diff --git a/Swiftfin/Views/LibraryView.swift b/Swiftfin/Views/LibraryView.swift index ce378034..ee16e74a 100644 --- a/Swiftfin/Views/LibraryView.swift +++ b/Swiftfin/Views/LibraryView.swift @@ -84,12 +84,20 @@ struct LibraryView: View { } .toolbar { ToolbarItemGroup(placement: .navigationBarTrailing) { - if viewModel.isLoading && !viewModel.items.isEmpty { ProgressView() } - - LibraryViewTypeToggle(libraryViewType: $libraryViewType) + Menu { + LibraryViewTypeToggle(libraryViewType: $libraryViewType) + RandomItemButton(viewModel: viewModel) + .onSelect { response in + if let item = response.items?.first { + router.route(to: \.item, item) + } + } + } label: { + Image(systemName: "ellipsis.circle") + } } } } diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index 013b272168bf2b3babd874798281ab995753a59e..ac6a47f37a1f0c7394e3d1e34e030781b68a4bda 100644 GIT binary patch literal 8532 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 Het7j?$_mSZ literal 16948 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 m*0H*pAIR%5X8P1n?Hj8^dyg35XJ1%U`@JG0