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 import SwiftUI
struct AttributeBadge<Content: View>: View { struct AttributeBadge: View {
@Environment(\.font) @Environment(\.font)
private var font private var font
@ -19,7 +19,7 @@ struct AttributeBadge<Content: View>: View {
} }
private let style: AttributeStyle private let style: AttributeStyle
private let content: () -> Content private let content: () -> any View
private var usedFont: Font { private var usedFont: Font {
font ?? .caption.weight(.semibold) font ?? .caption.weight(.semibold)
@ -29,6 +29,7 @@ struct AttributeBadge<Content: View>: View {
private var innerBody: some View { private var innerBody: some View {
if style == .fill { if style == .fill {
content() content()
.eraseToAnyView()
.padding(.init(vertical: 1, horizontal: 4)) .padding(.init(vertical: 1, horizontal: 4))
.hidden() .hidden()
.background { .background {
@ -36,11 +37,13 @@ struct AttributeBadge<Content: View>: View {
.cornerRadius(2) .cornerRadius(2)
.inverseMask { .inverseMask {
content() content()
.eraseToAnyView()
.padding(.init(vertical: 1, horizontal: 4)) .padding(.init(vertical: 1, horizontal: 4))
} }
} }
} else { } else {
content() content()
.eraseToAnyView()
.foregroundStyle(Color(UIColor.lightGray)) .foregroundStyle(Color(UIColor.lightGray))
.padding(.init(vertical: 1, horizontal: 4)) .padding(.init(vertical: 1, horizontal: 4))
.overlay( .overlay(
@ -57,7 +60,7 @@ struct AttributeBadge<Content: View>: View {
} }
} }
extension AttributeBadge where Content == Text { extension AttributeBadge {
init( init(
style: AttributeStyle, style: AttributeStyle,
@ -76,9 +79,6 @@ extension AttributeBadge where Content == Text {
Text(title) Text(title)
} }
} }
}
extension AttributeBadge where Content == Label<Text, Image> {
init( init(
style: AttributeStyle, style: AttributeStyle,

View File

@ -24,14 +24,171 @@ extension BaseItemKind: SupportedCaseIterable {
extension BaseItemKind: ItemFilter { extension BaseItemKind: ItemFilter {
// TODO: localize
var displayTitle: String { 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 { 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] { static var itemIdentifiableCases: [BaseItemKind] {
[.boxSet, .movie, .person, .series] [.boxSet, .movie, .person, .series]
} }

View File

@ -13,4 +13,21 @@ extension OrderedDictionary {
var isNotEmpty: Bool { var isNotEmpty: Bool {
!isEmpty !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 { internal static func agesGroup(_ p1: Any) -> String {
return L10n.tr("Localizable", "agesGroup", String(describing: p1), fallback: "Age %@") 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 /// Aired
internal static let aired = L10n.tr("Localizable", "aired", fallback: "Aired") internal static let aired = L10n.tr("Localizable", "aired", fallback: "Aired")
/// Aired episode order /// Aired episode order
@ -84,6 +88,10 @@ internal enum L10n {
internal static let album = L10n.tr("Localizable", "album", fallback: "Album") internal static let album = L10n.tr("Localizable", "album", fallback: "Album")
/// Album Artist /// Album Artist
internal static let albumArtist = L10n.tr("Localizable", "albumArtist", fallback: "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 /// All
internal static let all = L10n.tr("Localizable", "all", fallback: "All") internal static let all = L10n.tr("Localizable", "all", fallback: "All")
/// All Audiences /// All Audiences
@ -128,6 +136,8 @@ internal enum L10n {
internal static let art = L10n.tr("Localizable", "art", fallback: "Art") internal static let art = L10n.tr("Localizable", "art", fallback: "Art")
/// Artist /// Artist
internal static let artist = L10n.tr("Localizable", "artist", fallback: "Artist") internal static let artist = L10n.tr("Localizable", "artist", fallback: "Artist")
/// Artists
internal static let artists = L10n.tr("Localizable", "artists", fallback: "Artists")
/// Ascending /// Ascending
internal static let ascending = L10n.tr("Localizable", "ascending", fallback: "Ascending") internal static let ascending = L10n.tr("Localizable", "ascending", fallback: "Ascending")
/// Aspect Fill /// 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") internal static let audioBitDepthNotSupported = L10n.tr("Localizable", "audioBitDepthNotSupported", fallback: "The audio bit depth is not supported")
/// The audio bitrate is not supported /// The audio bitrate is not supported
internal static let audioBitrateNotSupported = L10n.tr("Localizable", "audioBitrateNotSupported", fallback: "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 /// 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") internal static let audioChannelsNotSupported = L10n.tr("Localizable", "audioChannelsNotSupported", fallback: "The number of audio channels is not supported")
/// The audio codec 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") internal static let banner = L10n.tr("Localizable", "banner", fallback: "Banner")
/// Bar Buttons /// Bar Buttons
internal static let barButtons = L10n.tr("Localizable", "barButtons", fallback: "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 /// Behavior
internal static let behavior = L10n.tr("Localizable", "behavior", fallback: "Behavior") internal static let behavior = L10n.tr("Localizable", "behavior", fallback: "Behavior")
/// Behind the Scenes /// 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.") internal static let blockUnratedItemsDescription = L10n.tr("Localizable", "blockUnratedItemsDescription", fallback: "Block items from this user with no or unrecognized rating information.")
/// Blue /// Blue
internal static let blue = L10n.tr("Localizable", "blue", fallback: "Blue") internal static let blue = L10n.tr("Localizable", "blue", fallback: "Blue")
/// Book
internal static let book = L10n.tr("Localizable", "book", fallback: "Book")
/// Books /// Books
internal static let books = L10n.tr("Localizable", "books", fallback: "Books") internal static let books = L10n.tr("Localizable", "books", fallback: "Books")
/// Box /// Box
@ -262,8 +282,16 @@ internal enum L10n {
internal static let category = L10n.tr("Localizable", "category", fallback: "Category") internal static let category = L10n.tr("Localizable", "category", fallback: "Category")
/// Change Pin /// Change Pin
internal static let changePin = L10n.tr("Localizable", "changePin", fallback: "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 /// Channel display
internal static let channelDisplay = L10n.tr("Localizable", "channelDisplay", fallback: "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 /// Channels
internal static let channels = L10n.tr("Localizable", "channels", fallback: "Channels") internal static let channels = L10n.tr("Localizable", "channels", fallback: "Channels")
/// Chapter /// Chapter
@ -282,6 +310,12 @@ internal enum L10n {
internal static let clip = L10n.tr("Localizable", "clip", fallback: "Clip") internal static let clip = L10n.tr("Localizable", "clip", fallback: "Clip")
/// Close /// Close
internal static let close = L10n.tr("Localizable", "close", fallback: "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 /// Collections
internal static let collections = L10n.tr("Localizable", "collections", fallback: "Collections") internal static let collections = L10n.tr("Localizable", "collections", fallback: "Collections")
/// Color /// Color
@ -658,6 +692,8 @@ internal enum L10n {
internal static let findMissingDescription = L10n.tr("Localizable", "findMissingDescription", fallback: "Find missing metadata and images.") internal static let findMissingDescription = L10n.tr("Localizable", "findMissingDescription", fallback: "Find missing metadata and images.")
/// Folder /// Folder
internal static let folder = L10n.tr("Localizable", "folder", fallback: "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 /// Force remote media transcoding
internal static let forceRemoteTranscoding = L10n.tr("Localizable", "forceRemoteTranscoding", fallback: "Force remote media transcoding") internal static let forceRemoteTranscoding = L10n.tr("Localizable", "forceRemoteTranscoding", fallback: "Force remote media transcoding")
/// Format /// Format
@ -668,6 +704,8 @@ internal enum L10n {
internal static let fullSideBySide = L10n.tr("Localizable", "fullSideBySide", fallback: "Full Side-by-Side") internal static let fullSideBySide = L10n.tr("Localizable", "fullSideBySide", fallback: "Full Side-by-Side")
/// Full Top and Bottom /// Full Top and Bottom
internal static let fullTopAndBottom = L10n.tr("Localizable", "fullTopAndBottom", fallback: "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 /// Genres
internal static let genres = L10n.tr("Localizable", "genres", fallback: "Genres") internal static let genres = L10n.tr("Localizable", "genres", fallback: "Genres")
/// Categories that describe the themes or styles of media. /// 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") internal static let layout = L10n.tr("Localizable", "layout", fallback: "Layout")
/// Learn more /// Learn more
internal static let learnMore = L10n.tr("Localizable", "learnMore", fallback: "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 /// Left
internal static let `left` = L10n.tr("Localizable", "left", fallback: "Left") internal static let `left` = L10n.tr("Localizable", "left", fallback: "Left")
/// Left vertical pan /// Left vertical pan
@ -821,11 +861,17 @@ internal enum L10n {
/// Live TV /// Live TV
internal static let liveTV = L10n.tr("Localizable", "liveTV", fallback: "Live TV") internal static let liveTV = L10n.tr("Localizable", "liveTV", fallback: "Live TV")
/// Live TV access /// Live TV access
internal static let liveTvAccess = L10n.tr("Localizable", "liveTvAccess", fallback: "Live TV access") internal static let liveTVAccess = L10n.tr("Localizable", "liveTVAccess", fallback: "Live TV access")
/// Live TV Channels /// Live TV channel
internal static let liveTVChannels = L10n.tr("Localizable", "liveTVChannels", fallback: "Live TV Channels") internal static let liveTVChannel = L10n.tr("Localizable", "liveTVChannel", fallback: "Live TV channel")
/// Live TV Programs /// Live TV channels
internal static let liveTVPrograms = L10n.tr("Localizable", "liveTVPrograms", fallback: "Live TV Programs") 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 /// Live TV recording management
internal static let liveTvRecordingManagement = L10n.tr("Localizable", "liveTvRecordingManagement", fallback: "Live TV recording management") internal static let liveTvRecordingManagement = L10n.tr("Localizable", "liveTvRecordingManagement", fallback: "Live TV recording management")
/// Loading user failed /// Loading user failed
@ -854,6 +900,10 @@ internal enum L10n {
internal static let lyrics = L10n.tr("Localizable", "lyrics", fallback: "Lyrics") internal static let lyrics = L10n.tr("Localizable", "lyrics", fallback: "Lyrics")
/// Management /// Management
internal static let management = L10n.tr("Localizable", "management", fallback: "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 /// Maximum Bitrate
internal static let maximumBitrate = L10n.tr("Localizable", "maximumBitrate", fallback: "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. /// 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") internal static let missingItems = L10n.tr("Localizable", "missingItems", fallback: "Missing Items")
/// Mixer /// Mixer
internal static let mixer = L10n.tr("Localizable", "mixer", fallback: "Mixer") internal static let mixer = L10n.tr("Localizable", "mixer", fallback: "Mixer")
/// Movie
internal static let movie = L10n.tr("Localizable", "movie", fallback: "Movie")
/// Movies /// Movies
internal static let movies = L10n.tr("Localizable", "movies", fallback: "Movies") internal static let movies = L10n.tr("Localizable", "movies", fallback: "Movies")
/// Multi tap /// Multi tap
internal static let multiTap = L10n.tr("Localizable", "multiTap", fallback: "Multi tap") internal static let multiTap = L10n.tr("Localizable", "multiTap", fallback: "Multi tap")
/// Music /// Music
internal static let music = L10n.tr("Localizable", "music", fallback: "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 /// MVC
internal static let mvc = L10n.tr("Localizable", "mvc", fallback: "MVC") internal static let mvc = L10n.tr("Localizable", "mvc", fallback: "MVC")
/// Name /// 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.") internal static let peopleDescription = L10n.tr("Localizable", "peopleDescription", fallback: "People who helped create or perform specific media.")
/// Permissions /// Permissions
internal static let permissions = L10n.tr("Localizable", "permissions", fallback: "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 /// Pin
internal static let pin = L10n.tr("Localizable", "pin", fallback: "Pin") internal static let pin = L10n.tr("Localizable", "pin", fallback: "Pin")
/// Pinch /// Pinch
@ -1048,6 +1114,14 @@ internal enum L10n {
internal static let played = L10n.tr("Localizable", "played", fallback: "Played") internal static let played = L10n.tr("Localizable", "played", fallback: "Played")
/// Play From Beginning /// Play From Beginning
internal static let playFromBeginning = L10n.tr("Localizable", "playFromBeginning", fallback: "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 /// Play Next Item
internal static let playNextItem = L10n.tr("Localizable", "playNextItem", fallback: "Play Next Item") internal static let playNextItem = L10n.tr("Localizable", "playNextItem", fallback: "Play Next Item")
/// Play on active /// Play on active
@ -1082,6 +1156,8 @@ internal enum L10n {
internal static let profileNotSaved = L10n.tr("Localizable", "profileNotSaved", fallback: "Profile not saved") internal static let profileNotSaved = L10n.tr("Localizable", "profileNotSaved", fallback: "Profile not saved")
/// Profiles /// Profiles
internal static let profiles = L10n.tr("Localizable", "profiles", fallback: "Profiles") internal static let profiles = L10n.tr("Localizable", "profiles", fallback: "Profiles")
/// Program
internal static let program = L10n.tr("Localizable", "program", fallback: "Program")
/// Programs /// Programs
internal static let programs = L10n.tr("Localizable", "programs", fallback: "Programs") internal static let programs = L10n.tr("Localizable", "programs", fallback: "Programs")
/// Progress /// Progress
@ -1122,6 +1198,10 @@ internal enum L10n {
internal static let recentlyAdded = L10n.tr("Localizable", "recentlyAdded", fallback: "Recently Added") internal static let recentlyAdded = L10n.tr("Localizable", "recentlyAdded", fallback: "Recently Added")
/// Recommended /// Recommended
internal static let recommended = L10n.tr("Localizable", "recommended", fallback: "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 /// Red
internal static let red = L10n.tr("Localizable", "red", fallback: "Red") internal static let red = L10n.tr("Localizable", "red", fallback: "Red")
/// The number of reference frames is not supported /// The number of reference frames is not supported
@ -1260,6 +1340,8 @@ internal enum L10n {
internal static func seasonAndEpisode(_ p1: Any, _ p2: Any) -> String { 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$@") 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 /// Secondary audio is not supported
internal static let secondaryAudioNotSupported = L10n.tr("Localizable", "secondaryAudioNotSupported", fallback: "Secondary audio is not supported") internal static let secondaryAudioNotSupported = L10n.tr("Localizable", "secondaryAudioNotSupported", fallback: "Secondary audio is not supported")
/// Security /// Security
@ -1500,14 +1582,22 @@ internal enum L10n {
internal static let transition = L10n.tr("Localizable", "transition", fallback: "Transition") internal static let transition = L10n.tr("Localizable", "transition", fallback: "Transition")
/// Translator /// Translator
internal static let translator = L10n.tr("Localizable", "translator", fallback: "Translator") internal static let translator = L10n.tr("Localizable", "translator", fallback: "Translator")
/// Trigger already exists /// Trigger already exits
internal static let triggerAlreadyExists = L10n.tr("Localizable", "triggerAlreadyExists", fallback: "Trigger already exists") internal static let triggerAlreadyExists = L10n.tr("Localizable", "triggerAlreadyExists", fallback: "Trigger already exits")
/// Triggers /// Triggers
internal static let triggers = L10n.tr("Localizable", "triggers", fallback: "Triggers") internal static let triggers = L10n.tr("Localizable", "triggers", fallback: "Triggers")
/// TV /// TV
internal static let tv = L10n.tr("Localizable", "tv", fallback: "TV") internal static let tv = L10n.tr("Localizable", "tv", fallback: "TV")
/// TV Access /// TV Access
internal static let tvAccess = L10n.tr("Localizable", "tvAccess", fallback: "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 /// TV Shows
internal static let tvShows = L10n.tr("Localizable", "tvShows", fallback: "TV Shows") internal static let tvShows = L10n.tr("Localizable", "tvShows", fallback: "TV Shows")
/// Type /// Type
@ -1574,8 +1664,16 @@ internal enum L10n {
internal static func userRequiresDeviceAuthentication(_ p1: Any) -> String { internal static func userRequiresDeviceAuthentication(_ p1: Any) -> String {
return L10n.tr("Localizable", "userRequiresDeviceAuthentication", String(describing: p1), fallback: "User %@ requires device authentication") 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 /// Users
internal static let users = L10n.tr("Localizable", "users", fallback: "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 /// Use splashscreen
internal static let useSplashscreen = L10n.tr("Localizable", "useSplashscreen", fallback: "Use splashscreen") internal static let useSplashscreen = L10n.tr("Localizable", "useSplashscreen", fallback: "Use splashscreen")
/// Version /// Version
@ -1608,6 +1706,8 @@ internal enum L10n {
internal static let videoRemuxing = L10n.tr("Localizable", "videoRemuxing", fallback: "Video remuxing") internal static let videoRemuxing = L10n.tr("Localizable", "videoRemuxing", fallback: "Video remuxing")
/// The video resolution is not supported /// The video resolution is not supported
internal static let videoResolutionNotSupported = L10n.tr("Localizable", "videoResolutionNotSupported", fallback: "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 /// Video transcoding
internal static let videoTranscoding = L10n.tr("Localizable", "videoTranscoding", fallback: "Video transcoding") internal static let videoTranscoding = L10n.tr("Localizable", "videoTranscoding", fallback: "Video transcoding")
/// Some views may need an app restart to update. /// Some views may need an app restart to update.

View File

@ -9,11 +9,20 @@
import Combine import Combine
import Foundation import Foundation
import JellyfinAPI import JellyfinAPI
import OrderedCollections
final class CollectionItemViewModel: ItemViewModel { final class CollectionItemViewModel: ItemViewModel {
// MARK: - Published Collection Items
@Published @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 { override func onRefresh() async throws {
let collectionItems = try await self.getCollectionItems() 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() var parameters = Paths.GetItemsByUserIDParameters()
parameters.fields = .MinimumFields parameters.fields = .MinimumFields
parameters.includeItemTypes = BaseItemKind.supportedCases
.appending(.episode)
parameters.parentID = item.id parameters.parentID = item.id
let request = Paths.getItemsByUserID( let request = Paths.getItemsByUserID(
@ -35,6 +47,15 @@ final class CollectionItemViewModel: ItemViewModel {
) )
let response = try await userSession.client.send(request) 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 @Published
var state: State = .initial var state: State = .initial
var presentPlayButton: Bool { true }
// tasks // tasks
private var toggleIsFavoriteTask: AnyCancellable? 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 {} func onRefresh() async throws {}
private func getFullItem() async throws -> BaseItemDto { private func getFullItem() async throws -> BaseItemDto {

View File

@ -6,6 +6,7 @@
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors // Copyright (c) 2025 Jellyfin & Jellyfin Contributors
// //
import JellyfinAPI
import SwiftUI import SwiftUI
extension CollectionItemView { extension CollectionItemView {
@ -25,17 +26,23 @@ extension CollectionItemView {
.frame(height: UIScreen.main.bounds.height - 150) .frame(height: UIScreen.main.bounds.height - 150)
.padding(.bottom, 50) .padding(.bottom, 50)
if viewModel.collectionItems.isNotEmpty { ForEach(viewModel.collectionItems.elements, id: \.key) { element in
PosterHStack( if element.value.isNotEmpty {
title: L10n.items, PosterHStack(
type: .portrait, title: element.key.pluralDisplayTitle,
items: viewModel.collectionItems type: .portrait,
) items: element.value
.onSelect { item in )
router.route(to: \.item, item) .onSelect { item in
router.route(to: \.item, item)
}
} }
} }
if viewModel.similarItems.isNotEmpty {
ItemView.SimilarItemsHStack(items: viewModel.similarItems)
}
ItemView.AboutView(viewModel: viewModel) ItemView.AboutView(viewModel: viewModel)
} }
.background { .background {

View File

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

View File

@ -119,8 +119,10 @@ extension ItemView {
Spacer() Spacer()
VStack { VStack {
ItemView.PlayButton(viewModel: viewModel) if viewModel.presentPlayButton {
.focused($focusedLayer, equals: .playButton) ItemView.PlayButton(viewModel: viewModel)
.focused($focusedLayer, equals: .playButton)
}
ItemView.ActionButtonHStack(viewModel: viewModel) ItemView.ActionButtonHStack(viewModel: viewModel)
.frame(width: 440) .frame(width: 440)

View File

@ -88,11 +88,11 @@ struct ServerUserLiveTVAccessView: View {
List { List {
Section(L10n.access) { Section(L10n.access) {
Toggle( Toggle(
L10n.liveTvAccess, L10n.liveTVAccess,
isOn: $tempPolicy.enableLiveTvAccess.coalesce(false) isOn: $tempPolicy.enableLiveTvAccess.coalesce(false)
) )
Toggle( Toggle(
L10n.liveTvRecordingManagement, L10n.liveTVRecordingManagement,
isOn: $tempPolicy.enableLiveTvManagement.coalesce(false) isOn: $tempPolicy.enableLiveTvManagement.coalesce(false)
) )
} }

View File

@ -10,152 +10,141 @@ import SwiftUI
import WrappingHStack import WrappingHStack
extension ItemView { extension ItemView {
struct AttributesHStack: View { struct AttributesHStack: View {
@ObservedObject
var viewModel: ItemViewModel
@StoredValue(.User.itemViewAttributes) @StoredValue(.User.itemViewAttributes)
private var 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 { var body: some View {
let badges = computeBadges() 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 badgeItem
.fixedSize(horizontal: true, vertical: false) .fixedSize(horizontal: true, vertical: false)
} }
.foregroundStyle(Color(UIColor.darkGray)) .foregroundStyle(Color(UIColor.darkGray))
.lineLimit(1) .lineLimit(1)
.frame(maxWidth: 300)
} }
} }
// MARK: - Compute Badges // MARK: - Compute Badges
private func computeBadges() -> [AnyView] { private func computeBadges() -> [AttributeBadge] {
var badges: [AnyView] = [] var badges: [AttributeBadge] = []
var processedGroups = Set<ItemViewAttribute>()
for attribute in itemViewAttributes { for attribute in itemViewAttributes {
if processedGroups.contains(attribute) { continue } var badge: AttributeBadge? = nil
processedGroups.insert(attribute)
switch attribute { switch attribute {
case .ratingCritics: case .ratingCritics:
if let criticRating = viewModel.item.criticRating { if let criticRating = viewModel.item.criticRating {
let badge = AnyView( badge = AttributeBadge(
AttributeBadge( style: .outline,
style: .outline, title: Text("\(criticRating, specifier: "%.0f")")
title: Text("\(criticRating, specifier: "%.0f")") ) {
) { if criticRating >= 60 {
if criticRating >= 60 { Image(.tomatoFresh)
Image(.tomatoFresh) .symbolRenderingMode(.hierarchical)
.symbolRenderingMode(.hierarchical) } else {
} else { Image(.tomatoRotten)
Image(.tomatoRotten)
}
} }
) }
badges.append(badge)
} }
case .ratingCommunity: case .ratingCommunity:
if let communityRating = viewModel.item.communityRating { if let communityRating = viewModel.item.communityRating {
let badge = AnyView( badge = AttributeBadge(
AttributeBadge( style: .outline,
style: .outline, title: Text("\(communityRating, specifier: "%.01f")"),
title: Text("\(communityRating, specifier: "%.01f")"), systemName: "star.fill"
systemName: "star.fill"
)
) )
badges.append(badge)
} }
case .ratingOfficial: case .ratingOfficial:
if let officialRating = viewModel.item.officialRating { if let officialRating = viewModel.item.officialRating {
let badge = AnyView( badge = AttributeBadge(
AttributeBadge( style: .outline,
style: .outline, title: officialRating
title: officialRating
)
) )
badges.append(badge)
} }
case .videoQuality: case .videoQuality:
if let mediaStreams = viewModel.selectedMediaSource?.mediaStreams { if let mediaStreams = viewModel.selectedMediaSource?.mediaStreams {
// Resolution badge (if available). Only one of 4K or HD is shown. // Resolution badge (if available). Only one of 4K or HD is shown.
if mediaStreams.has4KVideo { if mediaStreams.has4KVideo {
let badge = AnyView( badge = AttributeBadge(
AttributeBadge( style: .fill,
style: .fill, title: "4K"
title: "4K"
)
) )
badges.append(badge)
} else if mediaStreams.hasHDVideo { } else if mediaStreams.hasHDVideo {
let badge = AnyView( badge = AttributeBadge(
AttributeBadge( style: .fill,
style: .fill, title: "HD"
title: "HD"
)
) )
badges.append(badge)
} }
if mediaStreams.hasDolbyVision { if mediaStreams.hasDolbyVision {
let badge = AnyView( badge = AttributeBadge(
AttributeBadge( style: .fill,
style: .fill, title: "DV"
title: "DV"
)
) )
badges.append(badge)
} }
if mediaStreams.hasHDRVideo { if mediaStreams.hasHDRVideo {
let badge = AnyView( badge = AttributeBadge(
AttributeBadge( style: .fill,
style: .fill, title: "HDR"
title: "HDR"
)
) )
badges.append(badge)
} }
} }
case .audioChannels: case .audioChannels:
if let mediaStreams = viewModel.selectedMediaSource?.mediaStreams { if let mediaStreams = viewModel.selectedMediaSource?.mediaStreams {
if mediaStreams.has51AudioChannelLayout { if mediaStreams.has51AudioChannelLayout {
let badge = AnyView( badge = AttributeBadge(
AttributeBadge( style: .fill,
style: .fill, title: "5.1"
title: "5.1"
)
) )
badges.append(badge)
} }
if mediaStreams.has71AudioChannelLayout { if mediaStreams.has71AudioChannelLayout {
let badge = AnyView( badge = AttributeBadge(
AttributeBadge( style: .fill,
style: .fill, title: "7.1"
title: "7.1"
)
) )
badges.append(badge)
} }
} }
case .subtitles: case .subtitles:
if let mediaStreams = viewModel.selectedMediaSource?.mediaStreams, if let mediaStreams = viewModel.selectedMediaSource?.mediaStreams,
mediaStreams.hasSubtitles mediaStreams.hasSubtitles
{ {
let badge = AnyView( badge = AttributeBadge(
AttributeBadge( style: .outline,
style: .outline, title: "CC"
title: "CC"
)
) )
badges.append(badge)
} }
} }
if let badge {
badges.append(badge)
}
} }
return badges return badges
} }
} }

View File

@ -23,46 +23,34 @@ extension CollectionItemView {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 20) {
VStack(alignment: .center) { // MARK: Items
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)
Text(viewModel.item.displayTitle) ForEach(viewModel.collectionItems.elements, id: \.key) { element in
.font(.title2) if element.value.isNotEmpty {
.fontWeight(.bold) PosterHStack(
.multilineTextAlignment(.center) title: element.key.pluralDisplayTitle,
.lineLimit(2) type: .portrait,
.padding(.horizontal) 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) RowDivider()
.font(.title) }
.frame(maxWidth: 300)
.foregroundStyle(.primary)
} }
// MARK: Overview
ItemView.OverviewView(item: viewModel.item)
.overviewLineLimit(4)
.taglineLineLimit(2)
.padding(.horizontal)
RowDivider()
// MARK: Genres // MARK: Genres
if let genres = viewModel.item.itemGenres, genres.isNotEmpty { if let genres = viewModel.item.itemGenres, genres.isNotEmpty {
@ -79,29 +67,15 @@ extension CollectionItemView {
RowDivider() RowDivider()
} }
// MARK: Items // MARK: Similar
if viewModel.collectionItems.isNotEmpty { if viewModel.similarItems.isNotEmpty {
PosterHStack( ItemView.SimilarItemsHStack(items: viewModel.similarItems)
title: L10n.items,
type: .portrait, RowDivider()
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)
}
} }
ItemView.AboutView(viewModel: viewModel)
} }
} }
} }

View File

@ -12,13 +12,26 @@ import SwiftUI
struct CollectionItemView: View { struct CollectionItemView: View {
@Default(.Customization.itemViewType)
private var itemViewType
@ObservedObject @ObservedObject
var viewModel: CollectionItemViewModel var viewModel: CollectionItemViewModel
var body: some View { var body: some View {
ScrollView(showsIndicators: false) { switch itemViewType {
ContentView(viewModel: viewModel) case .compactPoster:
.edgePadding(.bottom) 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) .foregroundColor(.secondary)
.padding(.horizontal) .padding(.horizontal)
ItemView.AttributesHStack(viewModel: viewModel) ItemView.AttributesHStack(viewModel: viewModel, alignment: .center)
ItemView.PlayButton(viewModel: viewModel) ItemView.PlayButton(viewModel: viewModel)
.frame(maxWidth: 300) .frame(maxWidth: 300)

View File

@ -23,8 +23,6 @@ extension ItemView {
@ObservedObject @ObservedObject
var viewModel: ItemViewModel var viewModel: ItemViewModel
@State
private var scrollViewOffset: CGFloat = 0
@State @State
private var blurHashBottomEdgeColor: Color = .secondarySystemFill private var blurHashBottomEdgeColor: Color = .secondarySystemFill
@ -132,9 +130,11 @@ extension ItemView.CinematicScrollView {
.foregroundColor(Color(UIColor.lightGray)) .foregroundColor(Color(UIColor.lightGray))
.padding(.horizontal) .padding(.horizontal)
ItemView.PlayButton(viewModel: viewModel) if viewModel.presentPlayButton {
.frame(maxWidth: 300) ItemView.PlayButton(viewModel: viewModel)
.frame(height: 50) .frame(maxWidth: 300)
.frame(height: 50)
}
ItemView.ActionButtonHStack(viewModel: viewModel) ItemView.ActionButtonHStack(viewModel: viewModel)
.font(.title) .font(.title)
@ -148,7 +148,7 @@ extension ItemView.CinematicScrollView {
.taglineLineLimit(2) .taglineLineLimit(2)
.foregroundColor(.white) .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.AttributesHStack(viewModel: viewModel)
ItemView.PlayButton(viewModel: viewModel) if viewModel.presentPlayButton {
.frame(maxWidth: 300) ItemView.PlayButton(viewModel: viewModel)
.frame(height: 50) .frame(height: 50)
}
ItemView.ActionButtonHStack(viewModel: viewModel) ItemView.ActionButtonHStack(viewModel: viewModel)
.font(.title) .font(.title)
.frame(maxWidth: 300)
.foregroundColor(.white) .foregroundColor(.white)
} }
.frame(maxWidth: .infinity, alignment: .bottom) .frame(maxWidth: .infinity, alignment: .bottom)

View File

@ -133,7 +133,7 @@ extension ItemView.CompactPosterScrollView {
.font(.subheadline.weight(.medium)) .font(.subheadline.weight(.medium))
.foregroundColor(Color(UIColor.lightGray)) .foregroundColor(Color(UIColor.lightGray))
ItemView.AttributesHStack(viewModel: viewModel) ItemView.AttributesHStack(viewModel: viewModel, alignment: .leading)
} }
} }
@ -155,8 +155,10 @@ extension ItemView.CompactPosterScrollView {
HStack(alignment: .center) { HStack(alignment: .center) {
ItemView.PlayButton(viewModel: viewModel) if viewModel.presentPlayButton {
.frame(width: 130, height: 40) ItemView.PlayButton(viewModel: viewModel)
.frame(width: 130, height: 40)
}
Spacer() Spacer()

View File

@ -22,6 +22,34 @@ extension iPadOSCollectionItemView {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 20) { 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 // MARK: Genres
if let genres = viewModel.item.itemGenres, genres.isNotEmpty { if let genres = viewModel.item.itemGenres, genres.isNotEmpty {
@ -38,17 +66,12 @@ extension iPadOSCollectionItemView {
RowDivider() RowDivider()
} }
// MARK: Items // MARK: Similar
if viewModel.collectionItems.isNotEmpty { if viewModel.similarItems.isNotEmpty {
PosterHStack( ItemView.SimilarItemsHStack(items: viewModel.similarItems)
title: L10n.items,
type: .portrait, RowDivider()
items: viewModel.collectionItems
)
.onSelect { item in
router.route(to: \.item, item)
}
} }
ItemView.AboutView(viewModel: viewModel) ItemView.AboutView(viewModel: viewModel)

View File

@ -110,8 +110,6 @@ extension ItemView.iPadOSCinematicScrollView {
.foregroundColor(.white) .foregroundColor(.white)
HStack(spacing: 30) { HStack(spacing: 30) {
ItemView.AttributesHStack(viewModel: viewModel)
DotHStack { DotHStack {
if let firstGenre = viewModel.item.genres?.first { if let firstGenre = viewModel.item.genres?.first {
Text(firstGenre) Text(firstGenre)
@ -127,28 +125,25 @@ extension ItemView.iPadOSCinematicScrollView {
} }
.font(.footnote) .font(.footnote)
.foregroundColor(Color(UIColor.lightGray)) .foregroundColor(Color(UIColor.lightGray))
ItemView.AttributesHStack(viewModel: viewModel, alignment: .leading)
} }
} }
.padding(.trailing, 200) .padding(.trailing, 200)
Spacer() Spacer()
// TODO: remove when/if collections have a different view VStack(spacing: 10) {
if viewModel.presentPlayButton {
if !(viewModel is CollectionItemViewModel) {
VStack(spacing: 10) {
ItemView.PlayButton(viewModel: viewModel) ItemView.PlayButton(viewModel: viewModel)
.frame(height: 50) .frame(height: 50)
ItemView.ActionButtonHStack(viewModel: viewModel)
.font(.title)
.foregroundColor(.white)
} }
.frame(width: 250)
} else { ItemView.ActionButtonHStack(viewModel: viewModel)
Color.clear .font(.title)
.frame(width: 250) .foregroundColor(.white)
} }
.frame(width: 250)
} }
} }
} }