diff --git a/Shared/Components/AttributeBadge.swift b/Shared/Components/AttributeBadge.swift index 9b721b3a..631c1dc6 100644 --- a/Shared/Components/AttributeBadge.swift +++ b/Shared/Components/AttributeBadge.swift @@ -8,7 +8,7 @@ import SwiftUI -struct AttributeBadge: View { +struct AttributeBadge: View { @Environment(\.font) private var font @@ -19,7 +19,7 @@ struct AttributeBadge: View { } private let style: AttributeStyle - private let content: () -> Content + private let content: () -> any View private var usedFont: Font { font ?? .caption.weight(.semibold) @@ -29,6 +29,7 @@ struct AttributeBadge: View { private var innerBody: some View { if style == .fill { content() + .eraseToAnyView() .padding(.init(vertical: 1, horizontal: 4)) .hidden() .background { @@ -36,11 +37,13 @@ struct AttributeBadge: View { .cornerRadius(2) .inverseMask { content() + .eraseToAnyView() .padding(.init(vertical: 1, horizontal: 4)) } } } else { content() + .eraseToAnyView() .foregroundStyle(Color(UIColor.lightGray)) .padding(.init(vertical: 1, horizontal: 4)) .overlay( @@ -57,7 +60,7 @@ struct AttributeBadge: View { } } -extension AttributeBadge where Content == Text { +extension AttributeBadge { init( style: AttributeStyle, @@ -76,9 +79,6 @@ extension AttributeBadge where Content == Text { Text(title) } } -} - -extension AttributeBadge where Content == Label { init( style: AttributeStyle, diff --git a/Shared/Extensions/JellyfinAPI/BaseItemKind.swift b/Shared/Extensions/JellyfinAPI/BaseItemKind.swift index 9509063b..fe36d2a3 100644 --- a/Shared/Extensions/JellyfinAPI/BaseItemKind.swift +++ b/Shared/Extensions/JellyfinAPI/BaseItemKind.swift @@ -24,14 +24,171 @@ extension BaseItemKind: SupportedCaseIterable { extension BaseItemKind: ItemFilter { - // TODO: localize var displayTitle: String { - rawValue + switch self { + case .aggregateFolder: + return L10n.aggregateFolder + case .audio: + return L10n.audio + case .audioBook: + return L10n.audioBook + case .basePluginFolder: + return L10n.basePluginFolder + case .book: + return L10n.book + case .boxSet: + return L10n.collection + case .channel: + return L10n.channel + case .channelFolderItem: + return L10n.channelFolderItem + case .collectionFolder: + return L10n.collectionFolder + case .episode: + return L10n.episode + case .folder: + return L10n.folder + case .genre: + return L10n.genre + case .manualPlaylistsFolder: + return L10n.manualPlaylistsFolder + case .movie: + return L10n.movie + case .liveTvChannel: + return L10n.liveTVChannel + case .liveTvProgram: + return L10n.liveTVProgram + case .musicAlbum: + return L10n.album + case .musicArtist: + return L10n.artist + case .musicGenre: + return L10n.genre + case .musicVideo: + return L10n.musicVideo + case .person: + return L10n.person + case .photo: + return L10n.photo + case .photoAlbum: + return L10n.photoAlbum + case .playlist: + return L10n.playlist + case .playlistsFolder: + return L10n.playlistsFolder + case .program: + return L10n.program + case .recording: + return L10n.recording + case .season: + return L10n.season + case .series: + return L10n.series + case .studio: + return L10n.studio + case .trailer: + return L10n.trailer + case .tvChannel: + return L10n.tvChannel + case .tvProgram: + return L10n.tvProgram + case .userRootFolder: + return L10n.userRootFolder + case .userView: + return L10n.userView + case .video: + return L10n.video + case .year: + return L10n.year + } } } extension BaseItemKind { + var pluralDisplayTitle: String { + switch self { + case .aggregateFolder: + return L10n.aggregateFolders + case .audio: + return L10n.audio + case .audioBook: + return L10n.audioBooks + case .basePluginFolder: + return L10n.basePluginFolders + case .book: + return L10n.books + case .boxSet: + return L10n.collections + case .channel: + return L10n.channels + case .channelFolderItem: + return L10n.channelFolderItems + case .collectionFolder: + return L10n.collectionFolders + case .episode: + return L10n.episodes + case .folder: + return L10n.folders + case .genre: + return L10n.genres + case .manualPlaylistsFolder: + return L10n.manualPlaylistsFolders + case .movie: + return L10n.movies + case .liveTvChannel: + return L10n.liveTVChannels + case .liveTvProgram: + return L10n.liveTVPrograms + case .musicAlbum: + return L10n.albums + case .musicArtist: + return L10n.artists + case .musicGenre: + return L10n.genres + case .musicVideo: + return L10n.musicVideos + case .person: + return L10n.people + case .photo: + return L10n.photos + case .photoAlbum: + return L10n.photoAlbums + case .playlist: + return L10n.playlists + case .playlistsFolder: + return L10n.playlistsFolders + case .program: + return L10n.programs + case .recording: + return L10n.recordings + case .season: + return L10n.seasons + case .series: + return L10n.series + case .studio: + return L10n.studios + case .trailer: + return L10n.trailers + case .tvChannel: + return L10n.tvChannels + case .tvProgram: + return L10n.tvPrograms + case .userRootFolder: + return L10n.userRootFolders + case .userView: + return L10n.userViews + case .video: + return L10n.videos + case .year: + return L10n.years + } + } +} + +extension BaseItemKind { + + /// Item types that can be identified on the server. static var itemIdentifiableCases: [BaseItemKind] { [.boxSet, .movie, .person, .series] } diff --git a/Shared/Extensions/OrderedDictionary.swift b/Shared/Extensions/OrderedDictionary.swift index 13394084..4f045bfb 100644 --- a/Shared/Extensions/OrderedDictionary.swift +++ b/Shared/Extensions/OrderedDictionary.swift @@ -13,4 +13,21 @@ extension OrderedDictionary { var isNotEmpty: Bool { !isEmpty } + + func compactKeys() -> OrderedDictionary where Key == WrappedKey? { + reduce(into: OrderedDictionary()) { result, pair in + if let unwrappedKey = pair.key { + result[unwrappedKey] = pair.value + } + } + } + + func sortedKeys(by areInIncreasingOrder: (Key, Key) -> Bool) -> OrderedDictionary { + let sortedKeys = keys.sorted(by: areInIncreasingOrder) + + return OrderedDictionary(uniqueKeysWithValues: sortedKeys.compactMap { key in + guard let value = self[key] else { return nil } + return (key, value) + }) + } } diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index 5f902658..fd842291 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -70,6 +70,10 @@ internal enum L10n { internal static func agesGroup(_ p1: Any) -> String { return L10n.tr("Localizable", "agesGroup", String(describing: p1), fallback: "Age %@") } + /// Aggregate folder + internal static let aggregateFolder = L10n.tr("Localizable", "aggregateFolder", fallback: "Aggregate folder") + /// Aggregate folders + internal static let aggregateFolders = L10n.tr("Localizable", "aggregateFolders", fallback: "Aggregate folders") /// Aired internal static let aired = L10n.tr("Localizable", "aired", fallback: "Aired") /// Aired episode order @@ -84,6 +88,10 @@ internal enum L10n { internal static let album = L10n.tr("Localizable", "album", fallback: "Album") /// Album Artist internal static let albumArtist = L10n.tr("Localizable", "albumArtist", fallback: "Album Artist") + /// Album Artists + internal static let albumArtists = L10n.tr("Localizable", "albumArtists", fallback: "Album Artists") + /// Albums + internal static let albums = L10n.tr("Localizable", "albums", fallback: "Albums") /// All internal static let all = L10n.tr("Localizable", "all", fallback: "All") /// All Audiences @@ -128,6 +136,8 @@ internal enum L10n { internal static let art = L10n.tr("Localizable", "art", fallback: "Art") /// Artist internal static let artist = L10n.tr("Localizable", "artist", fallback: "Artist") + /// Artists + internal static let artists = L10n.tr("Localizable", "artists", fallback: "Artists") /// Ascending internal static let ascending = L10n.tr("Localizable", "ascending", fallback: "Ascending") /// Aspect Fill @@ -138,6 +148,10 @@ internal enum L10n { internal static let audioBitDepthNotSupported = L10n.tr("Localizable", "audioBitDepthNotSupported", fallback: "The audio bit depth is not supported") /// The audio bitrate is not supported internal static let audioBitrateNotSupported = L10n.tr("Localizable", "audioBitrateNotSupported", fallback: "The audio bitrate is not supported") + /// Audio book + internal static let audioBook = L10n.tr("Localizable", "audioBook", fallback: "Audio book") + /// Audio books + internal static let audioBooks = L10n.tr("Localizable", "audioBooks", fallback: "Audio books") /// The number of audio channels is not supported internal static let audioChannelsNotSupported = L10n.tr("Localizable", "audioChannelsNotSupported", fallback: "The number of audio channels is not supported") /// The audio codec is not supported @@ -170,6 +184,10 @@ internal enum L10n { internal static let banner = L10n.tr("Localizable", "banner", fallback: "Banner") /// Bar Buttons internal static let barButtons = L10n.tr("Localizable", "barButtons", fallback: "Bar Buttons") + /// Plugin folder + internal static let basePluginFolder = L10n.tr("Localizable", "basePluginFolder", fallback: "Plugin folder") + /// Plugin folders + internal static let basePluginFolders = L10n.tr("Localizable", "basePluginFolders", fallback: "Plugin folders") /// Behavior internal static let behavior = L10n.tr("Localizable", "behavior", fallback: "Behavior") /// Behind the Scenes @@ -234,6 +252,8 @@ internal enum L10n { internal static let blockUnratedItemsDescription = L10n.tr("Localizable", "blockUnratedItemsDescription", fallback: "Block items from this user with no or unrecognized rating information.") /// Blue internal static let blue = L10n.tr("Localizable", "blue", fallback: "Blue") + /// Book + internal static let book = L10n.tr("Localizable", "book", fallback: "Book") /// Books internal static let books = L10n.tr("Localizable", "books", fallback: "Books") /// Box @@ -262,8 +282,16 @@ internal enum L10n { internal static let category = L10n.tr("Localizable", "category", fallback: "Category") /// Change Pin internal static let changePin = L10n.tr("Localizable", "changePin", fallback: "Change Pin") + /// Channel + internal static let channel = L10n.tr("Localizable", "channel", fallback: "Channel") /// Channel display internal static let channelDisplay = L10n.tr("Localizable", "channelDisplay", fallback: "Channel display") + /// Channel folder + internal static let channelFolder = L10n.tr("Localizable", "channelFolder", fallback: "Channel folder") + /// Channel folder item + internal static let channelFolderItem = L10n.tr("Localizable", "channelFolderItem", fallback: "Channel folder item") + /// Channel folder items + internal static let channelFolderItems = L10n.tr("Localizable", "channelFolderItems", fallback: "Channel folder items") /// Channels internal static let channels = L10n.tr("Localizable", "channels", fallback: "Channels") /// Chapter @@ -282,6 +310,12 @@ internal enum L10n { internal static let clip = L10n.tr("Localizable", "clip", fallback: "Clip") /// Close internal static let close = L10n.tr("Localizable", "close", fallback: "Close") + /// Collection + internal static let collection = L10n.tr("Localizable", "collection", fallback: "Collection") + /// Collection folder + internal static let collectionFolder = L10n.tr("Localizable", "collectionFolder", fallback: "Collection folder") + /// Collection folders + internal static let collectionFolders = L10n.tr("Localizable", "collectionFolders", fallback: "Collection folders") /// Collections internal static let collections = L10n.tr("Localizable", "collections", fallback: "Collections") /// Color @@ -658,6 +692,8 @@ internal enum L10n { internal static let findMissingDescription = L10n.tr("Localizable", "findMissingDescription", fallback: "Find missing metadata and images.") /// Folder internal static let folder = L10n.tr("Localizable", "folder", fallback: "Folder") + /// Folders + internal static let folders = L10n.tr("Localizable", "folders", fallback: "Folders") /// Force remote media transcoding internal static let forceRemoteTranscoding = L10n.tr("Localizable", "forceRemoteTranscoding", fallback: "Force remote media transcoding") /// Format @@ -668,6 +704,8 @@ internal enum L10n { internal static let fullSideBySide = L10n.tr("Localizable", "fullSideBySide", fallback: "Full Side-by-Side") /// Full Top and Bottom internal static let fullTopAndBottom = L10n.tr("Localizable", "fullTopAndBottom", fallback: "Full Top and Bottom") + /// Genre + internal static let genre = L10n.tr("Localizable", "genre", fallback: "Genre") /// Genres internal static let genres = L10n.tr("Localizable", "genres", fallback: "Genres") /// Categories that describe the themes or styles of media. @@ -794,6 +832,8 @@ internal enum L10n { internal static let layout = L10n.tr("Localizable", "layout", fallback: "Layout") /// Learn more internal static let learnMore = L10n.tr("Localizable", "learnMore", fallback: "Learn more") + /// Learn more... + internal static let learnMoreEllipsis = L10n.tr("Localizable", "learnMoreEllipsis", fallback: "Learn more...") /// Left internal static let `left` = L10n.tr("Localizable", "left", fallback: "Left") /// Left vertical pan @@ -821,11 +861,17 @@ internal enum L10n { /// Live TV internal static let liveTV = L10n.tr("Localizable", "liveTV", fallback: "Live TV") /// Live TV access - internal static let liveTvAccess = L10n.tr("Localizable", "liveTvAccess", fallback: "Live TV access") - /// Live TV Channels - internal static let liveTVChannels = L10n.tr("Localizable", "liveTVChannels", fallback: "Live TV Channels") - /// Live TV Programs - internal static let liveTVPrograms = L10n.tr("Localizable", "liveTVPrograms", fallback: "Live TV Programs") + internal static let liveTVAccess = L10n.tr("Localizable", "liveTVAccess", fallback: "Live TV access") + /// Live TV channel + internal static let liveTVChannel = L10n.tr("Localizable", "liveTVChannel", fallback: "Live TV channel") + /// Live TV channels + internal static let liveTVChannels = L10n.tr("Localizable", "liveTVChannels", fallback: "Live TV channels") + /// Live TV program + internal static let liveTVProgram = L10n.tr("Localizable", "liveTVProgram", fallback: "Live TV program") + /// Live TV programs + internal static let liveTVPrograms = L10n.tr("Localizable", "liveTVPrograms", fallback: "Live TV programs") + /// Live TV recording management + internal static let liveTVRecordingManagement = L10n.tr("Localizable", "liveTVRecordingManagement", fallback: "Live TV recording management") /// Live TV recording management internal static let liveTvRecordingManagement = L10n.tr("Localizable", "liveTvRecordingManagement", fallback: "Live TV recording management") /// Loading user failed @@ -854,6 +900,10 @@ internal enum L10n { internal static let lyrics = L10n.tr("Localizable", "lyrics", fallback: "Lyrics") /// Management internal static let management = L10n.tr("Localizable", "management", fallback: "Management") + /// Manual playlists folder + internal static let manualPlaylistsFolder = L10n.tr("Localizable", "manualPlaylistsFolder", fallback: "Manual playlists folder") + /// Manual playlists folders + internal static let manualPlaylistsFolders = L10n.tr("Localizable", "manualPlaylistsFolders", fallback: "Manual playlists folders") /// Maximum Bitrate internal static let maximumBitrate = L10n.tr("Localizable", "maximumBitrate", fallback: "Maximum Bitrate") /// Limits the total number of connections a user can have to the server. @@ -906,12 +956,18 @@ internal enum L10n { internal static let missingItems = L10n.tr("Localizable", "missingItems", fallback: "Missing Items") /// Mixer internal static let mixer = L10n.tr("Localizable", "mixer", fallback: "Mixer") + /// Movie + internal static let movie = L10n.tr("Localizable", "movie", fallback: "Movie") /// Movies internal static let movies = L10n.tr("Localizable", "movies", fallback: "Movies") /// Multi tap internal static let multiTap = L10n.tr("Localizable", "multiTap", fallback: "Multi tap") /// Music internal static let music = L10n.tr("Localizable", "music", fallback: "Music") + /// Music video + internal static let musicVideo = L10n.tr("Localizable", "musicVideo", fallback: "Music video") + /// Music videos + internal static let musicVideos = L10n.tr("Localizable", "musicVideos", fallback: "Music videos") /// MVC internal static let mvc = L10n.tr("Localizable", "mvc", fallback: "MVC") /// Name @@ -1026,6 +1082,16 @@ internal enum L10n { internal static let peopleDescription = L10n.tr("Localizable", "peopleDescription", fallback: "People who helped create or perform specific media.") /// Permissions internal static let permissions = L10n.tr("Localizable", "permissions", fallback: "Permissions") + /// Person + internal static let person = L10n.tr("Localizable", "person", fallback: "Person") + /// Photo + internal static let photo = L10n.tr("Localizable", "photo", fallback: "Photo") + /// Photo album + internal static let photoAlbum = L10n.tr("Localizable", "photoAlbum", fallback: "Photo album") + /// Photo albums + internal static let photoAlbums = L10n.tr("Localizable", "photoAlbums", fallback: "Photo albums") + /// Photos + internal static let photos = L10n.tr("Localizable", "photos", fallback: "Photos") /// Pin internal static let pin = L10n.tr("Localizable", "pin", fallback: "Pin") /// Pinch @@ -1048,6 +1114,14 @@ internal enum L10n { internal static let played = L10n.tr("Localizable", "played", fallback: "Played") /// Play From Beginning internal static let playFromBeginning = L10n.tr("Localizable", "playFromBeginning", fallback: "Play From Beginning") + /// Playlist + internal static let playlist = L10n.tr("Localizable", "playlist", fallback: "Playlist") + /// Playlists + internal static let playlists = L10n.tr("Localizable", "playlists", fallback: "Playlists") + /// Playlists folder + internal static let playlistsFolder = L10n.tr("Localizable", "playlistsFolder", fallback: "Playlists folder") + /// Playlists folders + internal static let playlistsFolders = L10n.tr("Localizable", "playlistsFolders", fallback: "Playlists folders") /// Play Next Item internal static let playNextItem = L10n.tr("Localizable", "playNextItem", fallback: "Play Next Item") /// Play on active @@ -1082,6 +1156,8 @@ internal enum L10n { internal static let profileNotSaved = L10n.tr("Localizable", "profileNotSaved", fallback: "Profile not saved") /// Profiles internal static let profiles = L10n.tr("Localizable", "profiles", fallback: "Profiles") + /// Program + internal static let program = L10n.tr("Localizable", "program", fallback: "Program") /// Programs internal static let programs = L10n.tr("Localizable", "programs", fallback: "Programs") /// Progress @@ -1122,6 +1198,10 @@ internal enum L10n { internal static let recentlyAdded = L10n.tr("Localizable", "recentlyAdded", fallback: "Recently Added") /// Recommended internal static let recommended = L10n.tr("Localizable", "recommended", fallback: "Recommended") + /// Recording + internal static let recording = L10n.tr("Localizable", "recording", fallback: "Recording") + /// Recordings + internal static let recordings = L10n.tr("Localizable", "recordings", fallback: "Recordings") /// Red internal static let red = L10n.tr("Localizable", "red", fallback: "Red") /// The number of reference frames is not supported @@ -1260,6 +1340,8 @@ internal enum L10n { internal static func seasonAndEpisode(_ p1: Any, _ p2: Any) -> String { return L10n.tr("Localizable", "seasonAndEpisode", String(describing: p1), String(describing: p2), fallback: "S%1$@:E%2$@") } + /// Seasons + internal static let seasons = L10n.tr("Localizable", "seasons", fallback: "Seasons") /// Secondary audio is not supported internal static let secondaryAudioNotSupported = L10n.tr("Localizable", "secondaryAudioNotSupported", fallback: "Secondary audio is not supported") /// Security @@ -1500,14 +1582,22 @@ internal enum L10n { internal static let transition = L10n.tr("Localizable", "transition", fallback: "Transition") /// Translator internal static let translator = L10n.tr("Localizable", "translator", fallback: "Translator") - /// Trigger already exists - internal static let triggerAlreadyExists = L10n.tr("Localizable", "triggerAlreadyExists", fallback: "Trigger already exists") + /// Trigger already exits + internal static let triggerAlreadyExists = L10n.tr("Localizable", "triggerAlreadyExists", fallback: "Trigger already exits") /// Triggers internal static let triggers = L10n.tr("Localizable", "triggers", fallback: "Triggers") /// TV internal static let tv = L10n.tr("Localizable", "tv", fallback: "TV") /// TV Access internal static let tvAccess = L10n.tr("Localizable", "tvAccess", fallback: "TV Access") + /// TV channel + internal static let tvChannel = L10n.tr("Localizable", "tvChannel", fallback: "TV channel") + /// TV channels + internal static let tvChannels = L10n.tr("Localizable", "tvChannels", fallback: "TV channels") + /// TV program + internal static let tvProgram = L10n.tr("Localizable", "tvProgram", fallback: "TV program") + /// TV programs + internal static let tvPrograms = L10n.tr("Localizable", "tvPrograms", fallback: "TV programs") /// TV Shows internal static let tvShows = L10n.tr("Localizable", "tvShows", fallback: "TV Shows") /// Type @@ -1574,8 +1664,16 @@ internal enum L10n { internal static func userRequiresDeviceAuthentication(_ p1: Any) -> String { return L10n.tr("Localizable", "userRequiresDeviceAuthentication", String(describing: p1), fallback: "User %@ requires device authentication") } + /// User root folder + internal static let userRootFolder = L10n.tr("Localizable", "userRootFolder", fallback: "User root folder") + /// User root folders + internal static let userRootFolders = L10n.tr("Localizable", "userRootFolders", fallback: "User root folders") /// Users internal static let users = L10n.tr("Localizable", "users", fallback: "Users") + /// User view + internal static let userView = L10n.tr("Localizable", "userView", fallback: "User view") + /// User views + internal static let userViews = L10n.tr("Localizable", "userViews", fallback: "User views") /// Use splashscreen internal static let useSplashscreen = L10n.tr("Localizable", "useSplashscreen", fallback: "Use splashscreen") /// Version @@ -1608,6 +1706,8 @@ internal enum L10n { internal static let videoRemuxing = L10n.tr("Localizable", "videoRemuxing", fallback: "Video remuxing") /// The video resolution is not supported internal static let videoResolutionNotSupported = L10n.tr("Localizable", "videoResolutionNotSupported", fallback: "The video resolution is not supported") + /// Videos + internal static let videos = L10n.tr("Localizable", "videos", fallback: "Videos") /// Video transcoding internal static let videoTranscoding = L10n.tr("Localizable", "videoTranscoding", fallback: "Video transcoding") /// Some views may need an app restart to update. diff --git a/Shared/ViewModels/ItemViewModel/CollectionItemViewModel.swift b/Shared/ViewModels/ItemViewModel/CollectionItemViewModel.swift index d82c539b..9bf81f0b 100644 --- a/Shared/ViewModels/ItemViewModel/CollectionItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/CollectionItemViewModel.swift @@ -9,11 +9,20 @@ import Combine import Foundation import JellyfinAPI +import OrderedCollections final class CollectionItemViewModel: ItemViewModel { + // MARK: - Published Collection Items + @Published - private(set) var collectionItems: [BaseItemDto] = [] + private(set) var collectionItems: OrderedDictionary = [:] + + override var presentPlayButton: Bool { + false + } + + // MARK: - On Refresh override func onRefresh() async throws { let collectionItems = try await self.getCollectionItems() @@ -23,10 +32,13 @@ final class CollectionItemViewModel: ItemViewModel { } } - private func getCollectionItems() async throws -> [BaseItemDto] { + // MARK: - Get Collection Items + private func getCollectionItems() async throws -> OrderedDictionary { var parameters = Paths.GetItemsByUserIDParameters() parameters.fields = .MinimumFields + parameters.includeItemTypes = BaseItemKind.supportedCases + .appending(.episode) parameters.parentID = item.id let request = Paths.getItemsByUserID( @@ -35,6 +47,15 @@ final class CollectionItemViewModel: ItemViewModel { ) let response = try await userSession.client.send(request) - return response.value.items ?? [] + let items = response.value.items ?? [] + + let result = OrderedDictionary( + grouping: items, + by: \.type + ) + .compactKeys() + .sortedKeys { $0.rawValue < $1.rawValue } + + return result } } diff --git a/Shared/ViewModels/ItemViewModel/ItemViewModel.swift b/Shared/ViewModels/ItemViewModel/ItemViewModel.swift index f629b08e..b940a8f1 100644 --- a/Shared/ViewModels/ItemViewModel/ItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/ItemViewModel.swift @@ -80,6 +80,8 @@ class ItemViewModel: ViewModel, Stateful { @Published var state: State = .initial + var presentPlayButton: Bool { true } + // tasks private var toggleIsFavoriteTask: AnyCancellable? @@ -288,6 +290,7 @@ class ItemViewModel: ViewModel, Stateful { } } + @available(*, deprecated, message: "Override the `respond` method instead and return `super.respond(to:)`") func onRefresh() async throws {} private func getFullItem() async throws -> BaseItemDto { diff --git a/Swiftfin tvOS/Views/ItemView/CollectionItemView/CollectionItemContentView.swift b/Swiftfin tvOS/Views/ItemView/CollectionItemView/CollectionItemContentView.swift index 2652597a..345e0e93 100644 --- a/Swiftfin tvOS/Views/ItemView/CollectionItemView/CollectionItemContentView.swift +++ b/Swiftfin tvOS/Views/ItemView/CollectionItemView/CollectionItemContentView.swift @@ -6,6 +6,7 @@ // Copyright (c) 2025 Jellyfin & Jellyfin Contributors // +import JellyfinAPI import SwiftUI extension CollectionItemView { @@ -25,17 +26,23 @@ extension CollectionItemView { .frame(height: UIScreen.main.bounds.height - 150) .padding(.bottom, 50) - if viewModel.collectionItems.isNotEmpty { - PosterHStack( - title: L10n.items, - type: .portrait, - items: viewModel.collectionItems - ) - .onSelect { item in - router.route(to: \.item, item) + ForEach(viewModel.collectionItems.elements, id: \.key) { element in + if element.value.isNotEmpty { + PosterHStack( + title: element.key.pluralDisplayTitle, + type: .portrait, + items: element.value + ) + .onSelect { item in + router.route(to: \.item, item) + } } } + if viewModel.similarItems.isNotEmpty { + ItemView.SimilarItemsHStack(items: viewModel.similarItems) + } + ItemView.AboutView(viewModel: viewModel) } .background { diff --git a/Swiftfin tvOS/Views/ItemView/Components/AttributeHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/AttributeHStack.swift index 96c7d393..87b04916 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/AttributeHStack.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/AttributeHStack.swift @@ -24,6 +24,7 @@ extension ItemView { .fixedSize(horizontal: true, vertical: false) } } + .frame(alignment: .leading) .lineLimit(1) .foregroundStyle(Color(UIColor.darkGray)) } diff --git a/Swiftfin tvOS/Views/ItemView/ScrollViews/CinematicScrollView.swift b/Swiftfin tvOS/Views/ItemView/ScrollViews/CinematicScrollView.swift index 914b4291..c1ddaabb 100644 --- a/Swiftfin tvOS/Views/ItemView/ScrollViews/CinematicScrollView.swift +++ b/Swiftfin tvOS/Views/ItemView/ScrollViews/CinematicScrollView.swift @@ -119,8 +119,10 @@ extension ItemView { Spacer() VStack { - ItemView.PlayButton(viewModel: viewModel) - .focused($focusedLayer, equals: .playButton) + if viewModel.presentPlayButton { + ItemView.PlayButton(viewModel: viewModel) + .focused($focusedLayer, equals: .playButton) + } ItemView.ActionButtonHStack(viewModel: viewModel) .frame(width: 440) diff --git a/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserLiveTVAccessView/ServerUserLiveTVAccessView.swift b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserLiveTVAccessView/ServerUserLiveTVAccessView.swift index 3aed581e..30aee9cb 100644 --- a/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserLiveTVAccessView/ServerUserLiveTVAccessView.swift +++ b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserLiveTVAccessView/ServerUserLiveTVAccessView.swift @@ -88,11 +88,11 @@ struct ServerUserLiveTVAccessView: View { List { Section(L10n.access) { Toggle( - L10n.liveTvAccess, + L10n.liveTVAccess, isOn: $tempPolicy.enableLiveTvAccess.coalesce(false) ) Toggle( - L10n.liveTvRecordingManagement, + L10n.liveTVRecordingManagement, isOn: $tempPolicy.enableLiveTvManagement.coalesce(false) ) } diff --git a/Swiftfin/Views/ItemView/Components/AttributeHStack.swift b/Swiftfin/Views/ItemView/Components/AttributeHStack.swift index a6add7c2..6e3c483a 100644 --- a/Swiftfin/Views/ItemView/Components/AttributeHStack.swift +++ b/Swiftfin/Views/ItemView/Components/AttributeHStack.swift @@ -10,152 +10,141 @@ import SwiftUI import WrappingHStack extension ItemView { + struct AttributesHStack: View { - @ObservedObject - var viewModel: ItemViewModel @StoredValue(.User.itemViewAttributes) private var itemViewAttributes - // MARK: - Body + @ObservedObject + private var viewModel: ItemViewModel + + private let alignment: HorizontalAlignment + + init( + viewModel: ItemViewModel, + alignment: HorizontalAlignment = .center + ) { + self.viewModel = viewModel + self.alignment = alignment + } var body: some View { let badges = computeBadges() - if !badges.isEmpty { - WrappingHStack(badges, id: \.self, alignment: .center, spacing: .constant(8), lineSpacing: 8) { badgeItem in + + if badges.isNotEmpty { + WrappingHStack( + badges, + id: \.self, + alignment: alignment, + spacing: .constant(8), + lineSpacing: 8 + ) { badgeItem in badgeItem .fixedSize(horizontal: true, vertical: false) } .foregroundStyle(Color(UIColor.darkGray)) .lineLimit(1) - .frame(maxWidth: 300) } } // MARK: - Compute Badges - private func computeBadges() -> [AnyView] { - var badges: [AnyView] = [] - var processedGroups = Set() + private func computeBadges() -> [AttributeBadge] { + var badges: [AttributeBadge] = [] for attribute in itemViewAttributes { - if processedGroups.contains(attribute) { continue } - processedGroups.insert(attribute) + var badge: AttributeBadge? = nil switch attribute { case .ratingCritics: if let criticRating = viewModel.item.criticRating { - let badge = AnyView( - AttributeBadge( - style: .outline, - title: Text("\(criticRating, specifier: "%.0f")") - ) { - if criticRating >= 60 { - Image(.tomatoFresh) - .symbolRenderingMode(.hierarchical) - } else { - Image(.tomatoRotten) - } + badge = AttributeBadge( + style: .outline, + title: Text("\(criticRating, specifier: "%.0f")") + ) { + if criticRating >= 60 { + Image(.tomatoFresh) + .symbolRenderingMode(.hierarchical) + } else { + Image(.tomatoRotten) } - ) - badges.append(badge) + } } case .ratingCommunity: if let communityRating = viewModel.item.communityRating { - let badge = AnyView( - AttributeBadge( - style: .outline, - title: Text("\(communityRating, specifier: "%.01f")"), - systemName: "star.fill" - ) + badge = AttributeBadge( + style: .outline, + title: Text("\(communityRating, specifier: "%.01f")"), + systemName: "star.fill" ) - badges.append(badge) } case .ratingOfficial: if let officialRating = viewModel.item.officialRating { - let badge = AnyView( - AttributeBadge( - style: .outline, - title: officialRating - ) + badge = AttributeBadge( + style: .outline, + title: officialRating ) - badges.append(badge) } case .videoQuality: if let mediaStreams = viewModel.selectedMediaSource?.mediaStreams { // Resolution badge (if available). Only one of 4K or HD is shown. if mediaStreams.has4KVideo { - let badge = AnyView( - AttributeBadge( - style: .fill, - title: "4K" - ) + badge = AttributeBadge( + style: .fill, + title: "4K" ) - badges.append(badge) } else if mediaStreams.hasHDVideo { - let badge = AnyView( - AttributeBadge( - style: .fill, - title: "HD" - ) + badge = AttributeBadge( + style: .fill, + title: "HD" ) - badges.append(badge) } if mediaStreams.hasDolbyVision { - let badge = AnyView( - AttributeBadge( - style: .fill, - title: "DV" - ) + badge = AttributeBadge( + style: .fill, + title: "DV" ) - badges.append(badge) } if mediaStreams.hasHDRVideo { - let badge = AnyView( - AttributeBadge( - style: .fill, - title: "HDR" - ) + badge = AttributeBadge( + style: .fill, + title: "HDR" ) - badges.append(badge) } } case .audioChannels: if let mediaStreams = viewModel.selectedMediaSource?.mediaStreams { if mediaStreams.has51AudioChannelLayout { - let badge = AnyView( - AttributeBadge( - style: .fill, - title: "5.1" - ) + badge = AttributeBadge( + style: .fill, + title: "5.1" ) - badges.append(badge) } if mediaStreams.has71AudioChannelLayout { - let badge = AnyView( - AttributeBadge( - style: .fill, - title: "7.1" - ) + badge = AttributeBadge( + style: .fill, + title: "7.1" ) - badges.append(badge) } } case .subtitles: if let mediaStreams = viewModel.selectedMediaSource?.mediaStreams, mediaStreams.hasSubtitles { - let badge = AnyView( - AttributeBadge( - style: .outline, - title: "CC" - ) + badge = AttributeBadge( + style: .outline, + title: "CC" ) - badges.append(badge) } } + + if let badge { + badges.append(badge) + } } + return badges } } diff --git a/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemContentView.swift b/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemContentView.swift index 78e4909f..fd18f550 100644 --- a/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemContentView.swift +++ b/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemContentView.swift @@ -23,46 +23,34 @@ extension CollectionItemView { var body: some View { VStack(alignment: .leading, spacing: 20) { - VStack(alignment: .center) { - ImageView(viewModel.item.imageSource(.backdrop, maxWidth: 600)) - .placeholder { source in - if let blurHash = source.blurHash { - BlurHashView(blurHash: blurHash, size: .Square(length: 8)) - } else { - Color.secondarySystemFill - .opacity(0.75) - } - } - .failure { - SystemImageContentView(systemName: viewModel.item.systemImage) - } - .posterStyle(.landscape, contentMode: .fill) - .frame(maxHeight: 300) - .posterShadow() - .edgePadding(.horizontal) + // MARK: Items - Text(viewModel.item.displayTitle) - .font(.title2) - .fontWeight(.bold) - .multilineTextAlignment(.center) - .lineLimit(2) - .padding(.horizontal) + ForEach(viewModel.collectionItems.elements, id: \.key) { element in + if element.value.isNotEmpty { + PosterHStack( + title: element.key.pluralDisplayTitle, + type: .portrait, + items: element.value + ) + .trailing { + SeeAllButton() + .onSelect { + let viewModel = ItemLibraryViewModel( + title: viewModel.item.displayTitle, + id: viewModel.item.id, + element.value + ) + router.route(to: \.library, viewModel) + } + } + .onSelect { item in + router.route(to: \.item, item) + } - ItemView.ActionButtonHStack(viewModel: viewModel) - .font(.title) - .frame(maxWidth: 300) - .foregroundStyle(.primary) + RowDivider() + } } - // MARK: Overview - - ItemView.OverviewView(item: viewModel.item) - .overviewLineLimit(4) - .taglineLineLimit(2) - .padding(.horizontal) - - RowDivider() - // MARK: Genres if let genres = viewModel.item.itemGenres, genres.isNotEmpty { @@ -79,29 +67,15 @@ extension CollectionItemView { RowDivider() } - // MARK: Items + // MARK: Similar - if viewModel.collectionItems.isNotEmpty { - PosterHStack( - title: L10n.items, - type: .portrait, - items: viewModel.collectionItems - ) - .trailing { - SeeAllButton() - .onSelect { - let viewModel = ItemLibraryViewModel( - title: viewModel.item.displayTitle, - id: viewModel.item.id, - viewModel.collectionItems - ) - router.route(to: \.library, viewModel) - } - } - .onSelect { item in - router.route(to: \.item, item) - } + if viewModel.similarItems.isNotEmpty { + ItemView.SimilarItemsHStack(items: viewModel.similarItems) + + RowDivider() } + + ItemView.AboutView(viewModel: viewModel) } } } diff --git a/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemView.swift b/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemView.swift index ea873520..f736b03b 100644 --- a/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemView.swift +++ b/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemView.swift @@ -12,13 +12,26 @@ import SwiftUI struct CollectionItemView: View { + @Default(.Customization.itemViewType) + private var itemViewType + @ObservedObject var viewModel: CollectionItemViewModel var body: some View { - ScrollView(showsIndicators: false) { - ContentView(viewModel: viewModel) - .edgePadding(.bottom) + switch itemViewType { + case .compactPoster: + ItemView.CompactPosterScrollView(viewModel: viewModel) { + ContentView(viewModel: viewModel) + } + case .compactLogo: + ItemView.CompactLogoScrollView(viewModel: viewModel) { + ContentView(viewModel: viewModel) + } + case .cinematic: + ItemView.CinematicScrollView(viewModel: viewModel) { + ContentView(viewModel: viewModel) + } } } } diff --git a/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemContentView.swift b/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemContentView.swift index 95084439..b8371ce9 100644 --- a/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemContentView.swift +++ b/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemContentView.swift @@ -128,7 +128,7 @@ extension EpisodeItemView.ContentView { .foregroundColor(.secondary) .padding(.horizontal) - ItemView.AttributesHStack(viewModel: viewModel) + ItemView.AttributesHStack(viewModel: viewModel, alignment: .center) ItemView.PlayButton(viewModel: viewModel) .frame(maxWidth: 300) diff --git a/Swiftfin/Views/ItemView/iOS/ScrollViews/CinematicScrollView.swift b/Swiftfin/Views/ItemView/iOS/ScrollViews/CinematicScrollView.swift index c4ae16e3..8cc9097a 100644 --- a/Swiftfin/Views/ItemView/iOS/ScrollViews/CinematicScrollView.swift +++ b/Swiftfin/Views/ItemView/iOS/ScrollViews/CinematicScrollView.swift @@ -23,8 +23,6 @@ extension ItemView { @ObservedObject var viewModel: ItemViewModel - @State - private var scrollViewOffset: CGFloat = 0 @State private var blurHashBottomEdgeColor: Color = .secondarySystemFill @@ -132,9 +130,11 @@ extension ItemView.CinematicScrollView { .foregroundColor(Color(UIColor.lightGray)) .padding(.horizontal) - ItemView.PlayButton(viewModel: viewModel) - .frame(maxWidth: 300) - .frame(height: 50) + if viewModel.presentPlayButton { + ItemView.PlayButton(viewModel: viewModel) + .frame(maxWidth: 300) + .frame(height: 50) + } ItemView.ActionButtonHStack(viewModel: viewModel) .font(.title) @@ -148,7 +148,7 @@ extension ItemView.CinematicScrollView { .taglineLineLimit(2) .foregroundColor(.white) - ItemView.AttributesHStack(viewModel: viewModel) + ItemView.AttributesHStack(viewModel: viewModel, alignment: .leading) } } } diff --git a/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactLogoScrollView.swift b/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactLogoScrollView.swift index 14bfaedc..942be65d 100644 --- a/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactLogoScrollView.swift +++ b/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactLogoScrollView.swift @@ -131,13 +131,13 @@ extension ItemView.CompactLogoScrollView { ItemView.AttributesHStack(viewModel: viewModel) - ItemView.PlayButton(viewModel: viewModel) - .frame(maxWidth: 300) - .frame(height: 50) + if viewModel.presentPlayButton { + ItemView.PlayButton(viewModel: viewModel) + .frame(height: 50) + } ItemView.ActionButtonHStack(viewModel: viewModel) .font(.title) - .frame(maxWidth: 300) .foregroundColor(.white) } .frame(maxWidth: .infinity, alignment: .bottom) diff --git a/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactPortraitScrollView.swift b/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactPortraitScrollView.swift index 93fb7c74..668b4212 100644 --- a/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactPortraitScrollView.swift +++ b/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactPortraitScrollView.swift @@ -133,7 +133,7 @@ extension ItemView.CompactPosterScrollView { .font(.subheadline.weight(.medium)) .foregroundColor(Color(UIColor.lightGray)) - ItemView.AttributesHStack(viewModel: viewModel) + ItemView.AttributesHStack(viewModel: viewModel, alignment: .leading) } } @@ -155,8 +155,10 @@ extension ItemView.CompactPosterScrollView { HStack(alignment: .center) { - ItemView.PlayButton(viewModel: viewModel) - .frame(width: 130, height: 40) + if viewModel.presentPlayButton { + ItemView.PlayButton(viewModel: viewModel) + .frame(width: 130, height: 40) + } Spacer() diff --git a/Swiftfin/Views/ItemView/iPadOS/CollectionItemView/iPadOSCollectionItemContentView.swift b/Swiftfin/Views/ItemView/iPadOS/CollectionItemView/iPadOSCollectionItemContentView.swift index 0ac38399..4c701ea7 100644 --- a/Swiftfin/Views/ItemView/iPadOS/CollectionItemView/iPadOSCollectionItemContentView.swift +++ b/Swiftfin/Views/ItemView/iPadOS/CollectionItemView/iPadOSCollectionItemContentView.swift @@ -22,6 +22,34 @@ extension iPadOSCollectionItemView { var body: some View { VStack(alignment: .leading, spacing: 20) { + // MARK: Items + + ForEach(viewModel.collectionItems.elements, id: \.key) { element in + if element.value.isNotEmpty { + PosterHStack( + title: element.key.pluralDisplayTitle, + type: .portrait, + items: element.value + ) + .trailing { + SeeAllButton() + .onSelect { + let viewModel = ItemLibraryViewModel( + title: viewModel.item.displayTitle, + id: viewModel.item.id, + element.value + ) + router.route(to: \.library, viewModel) + } + } + .onSelect { item in + router.route(to: \.item, item) + } + + RowDivider() + } + } + // MARK: Genres if let genres = viewModel.item.itemGenres, genres.isNotEmpty { @@ -38,17 +66,12 @@ extension iPadOSCollectionItemView { RowDivider() } - // MARK: Items + // MARK: Similar - if viewModel.collectionItems.isNotEmpty { - PosterHStack( - title: L10n.items, - type: .portrait, - items: viewModel.collectionItems - ) - .onSelect { item in - router.route(to: \.item, item) - } + if viewModel.similarItems.isNotEmpty { + ItemView.SimilarItemsHStack(items: viewModel.similarItems) + + RowDivider() } ItemView.AboutView(viewModel: viewModel) diff --git a/Swiftfin/Views/ItemView/iPadOS/ScrollViews/iPadOSCinematicScrollView.swift b/Swiftfin/Views/ItemView/iPadOS/ScrollViews/iPadOSCinematicScrollView.swift index fe188ebb..266f8ee0 100644 --- a/Swiftfin/Views/ItemView/iPadOS/ScrollViews/iPadOSCinematicScrollView.swift +++ b/Swiftfin/Views/ItemView/iPadOS/ScrollViews/iPadOSCinematicScrollView.swift @@ -110,8 +110,6 @@ extension ItemView.iPadOSCinematicScrollView { .foregroundColor(.white) HStack(spacing: 30) { - ItemView.AttributesHStack(viewModel: viewModel) - DotHStack { if let firstGenre = viewModel.item.genres?.first { Text(firstGenre) @@ -127,28 +125,25 @@ extension ItemView.iPadOSCinematicScrollView { } .font(.footnote) .foregroundColor(Color(UIColor.lightGray)) + + ItemView.AttributesHStack(viewModel: viewModel, alignment: .leading) } } .padding(.trailing, 200) Spacer() - // TODO: remove when/if collections have a different view - - if !(viewModel is CollectionItemViewModel) { - VStack(spacing: 10) { + VStack(spacing: 10) { + if viewModel.presentPlayButton { ItemView.PlayButton(viewModel: viewModel) .frame(height: 50) - - ItemView.ActionButtonHStack(viewModel: viewModel) - .font(.title) - .foregroundColor(.white) } - .frame(width: 250) - } else { - Color.clear - .frame(width: 250) + + ItemView.ActionButtonHStack(viewModel: viewModel) + .font(.title) + .foregroundColor(.white) } + .frame(width: 250) } } } diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index 20a358eb..616b6ed9 100644 Binary files a/Translations/en.lproj/Localizable.strings and b/Translations/en.lproj/Localizable.strings differ