Complete Collections' `ItemView` (#1500)

* WIP

* Remove Divider

* self deprecation message

* `OrderedDictionary<BaseItemKind, [BaseItemDto]>`

* Localization fun

* cleanup

* Remove play button items & order by BaseItemKind.

* Fix AttributesHStack on iPad and make sure they align to the correct side. Looks jarring on Collections since Collections are often more limited on AttributesHStack items.

* Localization fix

* cleanup

* cleanup

---------

Co-authored-by: Ethan Pippin <ethanpippin2343@gmail.com>
This commit is contained in:
Joe Kribs 2025-05-17 09:34:04 -06:00 committed by GitHub
parent 346dfde4fc
commit 6700ce969c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 511 additions and 207 deletions

View File

@ -8,7 +8,7 @@
import SwiftUI
struct AttributeBadge<Content: View>: View {
struct AttributeBadge: View {
@Environment(\.font)
private var font
@ -19,7 +19,7 @@ struct AttributeBadge<Content: View>: 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<Content: View>: 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<Content: View>: 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<Content: View>: 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<Text, Image> {
init(
style: AttributeStyle,

View File

@ -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]
}

View File

@ -13,4 +13,21 @@ extension OrderedDictionary {
var isNotEmpty: Bool {
!isEmpty
}
func compactKeys<WrappedKey: Hashable>() -> OrderedDictionary<WrappedKey, Value> where Key == WrappedKey? {
reduce(into: OrderedDictionary<WrappedKey, Value>()) { result, pair in
if let unwrappedKey = pair.key {
result[unwrappedKey] = pair.value
}
}
}
func sortedKeys(by areInIncreasingOrder: (Key, Key) -> Bool) -> OrderedDictionary<Key, Value> {
let sortedKeys = keys.sorted(by: areInIncreasingOrder)
return OrderedDictionary(uniqueKeysWithValues: sortedKeys.compactMap { key in
guard let value = self[key] else { return nil }
return (key, value)
})
}
}

View File

@ -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.

View File

@ -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<BaseItemKind, [BaseItemDto]> = [:]
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<BaseItemKind, [BaseItemDto]> {
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<BaseItemKind?, [BaseItemDto]>(
grouping: items,
by: \.type
)
.compactKeys()
.sortedKeys { $0.rawValue < $1.rawValue }
return result
}
}

View File

@ -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 {

View File

@ -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 {

View File

@ -24,6 +24,7 @@ extension ItemView {
.fixedSize(horizontal: true, vertical: false)
}
}
.frame(alignment: .leading)
.lineLimit(1)
.foregroundStyle(Color(UIColor.darkGray))
}

View File

@ -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)

View File

@ -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)
)
}

View File

@ -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<ItemViewAttribute>()
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
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}
}
}

View File

@ -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)

View File

@ -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)
}
}
}

View File

@ -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)

View File

@ -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()

View File

@ -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)

View File

@ -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)
}
}
}