From 3ee94fb0f9383b1cd67d13940cecd29f61b2441a Mon Sep 17 00:00:00 2001 From: Brendan Szymanski Date: Sun, 21 Jun 2026 12:08:42 -0400 Subject: [PATCH] Custom display logic for media types --- Sources/Luminate/Luminate.swift | 18 ++-- Sources/Luminate/Pages/HomeView.swift | 32 ++++--- Sources/Luminate/Pages/ItemPage.swift | 30 ++++--- .../LuminateCore/BaseItemDto+Display.swift | 59 +++++++++++-- Sources/LuminateCore/DisplayType.swift | 75 ++++++++++++++++ Sources/LuminateCore/JellyfinClient.swift | 68 +++++++-------- Sources/LuminateCore/LuminateCore.swift | 18 ++++ Sources/LuminateCore/Page.swift | 14 +-- Sources/LuminatePlayer/PlayerView.swift | 4 +- .../LuminateUI/Components/EpisodeList.swift | 4 +- .../Components/HomePosterCell.swift | 85 ++++++++++++------- Sources/LuminateUI/Components/ItemGrid.swift | 27 ++++-- Sources/LuminateUI/Components/MediaRow.swift | 41 ++++++--- .../Components/MovieDetailView.swift | 24 +++--- .../LuminateUI/Components/PersonCell.swift | 2 +- .../LuminateUI/Components/PosterCell.swift | 2 +- .../LuminateUI/Components/SearchView.swift | 6 +- .../LuminateUI/Components/TVShowView.swift | 4 +- 18 files changed, 365 insertions(+), 148 deletions(-) create mode 100644 Sources/LuminateCore/DisplayType.swift diff --git a/Sources/Luminate/Luminate.swift b/Sources/Luminate/Luminate.swift index 945e385..2b5af0f 100644 --- a/Sources/Luminate/Luminate.swift +++ b/Sources/Luminate/Luminate.swift @@ -124,18 +124,24 @@ struct ContentView: View { var view: Body { NavigationView($stack, "Luminate") { page in switch page { - case .itemPage(let title, let items): - ItemPage(title: title, items: items, navigation: $stack) + case .items(let title, let items, let type): + ItemPage(title: title, items: items, type: type, navigation: $stack) .topToolbar { ToolbarView() } - .navigationTitle(title) - case .collectionPage(let item): - ItemPage(item: item, navigation: $stack) + .navigationTitle(page.description) + case .item(let item, let type): + ItemPage(item: item, type: type, navigation: $stack) .topToolbar { ToolbarView() } - .navigationTitle(item.name ?? "Library") + .navigationTitle(page.description) + case .movieDetail(let item): + MovieDetailView(for: item) + .topToolbar { + ToolbarView() + } + .navigationTitle(page.description) } } initialView: { HomeView(navigation: $stack) diff --git a/Sources/Luminate/Pages/HomeView.swift b/Sources/Luminate/Pages/HomeView.swift index b793728..c8b10f7 100644 --- a/Sources/Luminate/Pages/HomeView.swift +++ b/Sources/Luminate/Pages/HomeView.swift @@ -33,10 +33,10 @@ public struct HomeView: View { @Injected(\.client) var client @Injected(\.userId) var userId @Binding var navigation: NavigationStack - @State private var resumeItems: [Components.Schemas.BaseItemDto] = [] - @State private var nextUpItems: [Components.Schemas.BaseItemDto] = [] - @State private var latestItems: [Components.Schemas.BaseItemDto] = [] - @State private var libraries: [Components.Schemas.BaseItemDto] = [] + @State private var resumeItems: [BaseItemDto] = [] + @State private var nextUpItems: [BaseItemDto] = [] + @State private var latestItems: [BaseItemDto] = [] + @State private var libraries: [BaseItemDto] = [] @State private var isLoading = true @State private var isLoadingData = false @@ -55,6 +55,12 @@ public struct HomeView: View { .frame(minWidth: 64) .frame(maxWidth: 64) } else { + ItemGrid( + items: libraries, + type: .library, + navigation: $navigation + ) + .padding(32, .bottom) if !resumeItems.isEmpty { let title = "Continue Watching" MediaRow( @@ -62,7 +68,7 @@ public struct HomeView: View { items: resumeItems, navigation: $navigation, onSeeAll: { - navigation.push(.itemPage(title: title, items: resumeItems)) + navigation.push(.items(title: title, items: resumeItems)) } ) .padding(32, .bottom) @@ -74,7 +80,7 @@ public struct HomeView: View { items: nextUpItems, navigation: $navigation, onSeeAll: { - navigation.push(.itemPage(title: title, items: nextUpItems)) + navigation.push(.items(title: title, items: nextUpItems)) } ) .padding(32, .bottom) @@ -86,16 +92,11 @@ public struct HomeView: View { items: latestItems, navigation: $navigation, onSeeAll: { - navigation.push(.itemPage(title: title, items: latestItems)) + navigation.push(.items(title: title, items: latestItems)) } ) .padding(32, .bottom) } - ItemGrid( - items: libraries, - navigation: $navigation - ) - .padding(32, .bottom) } } .padding(8, .horizontal) @@ -113,14 +114,17 @@ public struct HomeView: View { Task { async let resume = client.getItems( userId: userId, + fields: [.primaryImageAspectRatio], filters: [.isResumable], sortBy: [.datePlayed], sortOrder: [.descending], limit: 20, recursive: true ) - async let nextUp = client.getNextUp(userId: userId, limit: 20) - async let latest = client.getLatestMedia(userId: userId, limit: 20) + async let nextUp = client.getNextUp( + userId: userId, limit: 20, fields: [.primaryImageAspectRatio]) + async let latest = client.getLatestMedia( + userId: userId, fields: [.primaryImageAspectRatio], limit: 20) async let views = client.getUserViews(userId: userId) do { let (resume, nextUp, latest, views) = try await (resume, nextUp, latest, views) diff --git a/Sources/Luminate/Pages/ItemPage.swift b/Sources/Luminate/Pages/ItemPage.swift index 47df566..1b2e975 100644 --- a/Sources/Luminate/Pages/ItemPage.swift +++ b/Sources/Luminate/Pages/ItemPage.swift @@ -29,34 +29,42 @@ import LuminateUI public struct ItemPage: View { + private var title: String? + private var parentId: String? + private var type: DisplayType + + @Binding var navigation: NavigationStack + + @State private var items: [BaseItemDto] = [] + @State private var isLoading = false + + @Injected(\.client) var client + @Injected(\.userId) var userId + nonisolated public init( - title: String, items: [Components.Schemas.BaseItemDto], + title: String, items: [BaseItemDto], + type: DisplayType = .mixed, navigation: Binding> ) { self.title = title self._items = .init(wrappedValue: items) self.parentId = nil + self.type = type _navigation = navigation } nonisolated public init( - item: Components.Schemas.BaseItemDto, + item: BaseItemDto, + type: DisplayType = .mixed, navigation: Binding> ) { self.title = item.name self.parentId = item.id + self.type = type _isLoading = .init(wrappedValue: item.id != nil) _navigation = navigation } - private var title: String? - private var parentId: String? - @Binding var navigation: NavigationStack - @State private var items: [Components.Schemas.BaseItemDto] = [] - @State private var isLoading = false - @Injected(\.client) var client - @Injected(\.userId) var userId - public var view: Body { ScrollView { Clamp() @@ -68,6 +76,7 @@ public struct ItemPage: View { } else { ItemGrid( items: items, + type: type, navigation: $navigation, title: title ) @@ -88,6 +97,7 @@ public struct ItemPage: View { let result = try? await client.getItems( userId: userId, parentId: parentId, + fields: [.primaryImageAspectRatio], sortBy: [.sortName], sortOrder: [.ascending] ) diff --git a/Sources/LuminateCore/BaseItemDto+Display.swift b/Sources/LuminateCore/BaseItemDto+Display.swift index 401a343..f1d7ee3 100644 --- a/Sources/LuminateCore/BaseItemDto+Display.swift +++ b/Sources/LuminateCore/BaseItemDto+Display.swift @@ -21,11 +21,11 @@ import Foundation -extension Components.Schemas.BaseItemPerson: Identifiable {} +extension BaseItemPerson: Identifiable {} -extension Components.Schemas.BaseItemDto: Identifiable {} +extension BaseItemDto: Identifiable {} -extension Components.Schemas.SearchHint { +extension SearchHint { public var runtimeString: String { guard let ticks = runTimeTicks else { return "" } let totalSeconds = Int(ticks / 10_000_000) @@ -36,7 +36,7 @@ extension Components.Schemas.SearchHint { } } -extension Components.Schemas.BaseItemDto { +extension BaseItemDto { public var runtimeString: String { guard let ticks = runTimeTicks else { return "" } let totalSeconds = Int(ticks / 10_000_000) @@ -46,16 +46,61 @@ extension Components.Schemas.BaseItemDto { return "\(minutes)m" } - public var yearString: String { - guard let year = productionYear else { return "" } - return "\(year)" + public var yearString: String? { + productionYear.map(String.init) + } + + public var episodePlacementString: String? { + guard let seasonNumber = parentIndexNumber else { return nil } + guard let episodeNumber = indexNumber else { return nil } + guard let episodeName = name else { return nil } + return "S\(seasonNumber):E\(episodeNumber) - \(episodeName)" } public var primaryImageTag: String? { imageTags?.additionalProperties["Primary"] } + public var seriesRunYears: String? { + guard isShow else { return nil } + guard let startYear = yearString else { return nil } + guard status == "Ended" else { return "\(startYear) - Present" } + guard let endDate else { return nil } + let endYear = String(Calendar.current.component(.year, from: endDate)) + return startYear == endYear ? startYear : "\(startYear) - \(endYear)" + } + public var backdropImageTag: String? { backdropImageTags?.first } + + public var type: BaseItemKind? { + _type?.value1 + } + + public var isShow: Bool { + _type?.value1 == .series + } + + public var displayType: DisplayType { + return switch type { + case .season: .season + case .episode: .episode + case .movie: .movie + case .person: .person + case .series: .series + default: .mixed + } + } + + public var childDisplayType: DisplayType? { + return switch type { + case .season: .episode + case .episode: .none + case .movie: .none + case .person: .mixed + case .series: .season + default: .mixed + } + } } diff --git a/Sources/LuminateCore/DisplayType.swift b/Sources/LuminateCore/DisplayType.swift new file mode 100644 index 0000000..3b8e60c --- /dev/null +++ b/Sources/LuminateCore/DisplayType.swift @@ -0,0 +1,75 @@ +// +// MediaRow.swift +// +// Copyright 2026 Brendan Szymanski +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Foundation + +public enum DisplayType { + case mixed + case season + case episode + case movie + case person + case library + case series + + public var aspectRatio: Float { + return switch self { + case .mixed: 1.5 + case .season: 1.5 + case .episode: 0.5625 + case .movie: 1.5 + case .person: 1 + case .library: 0.5625 + case .series: 1.5 + } + } + + public func itemId(for item: BaseItemDto) -> String? { + return switch self { + case .mixed: item.seriesId ?? item.id + default: item.id + } + } + + public func title(for item: BaseItemDto) -> String? { + return switch self { + case .mixed: item.seriesName ?? item.name + case .library: .none + default: item.name + } + } + + public func subtitle(for item: BaseItemDto) -> String? { + return switch self { + case .library: .none + case .mixed: item.episodePlacementString ?? item.seriesRunYears ?? item.yearString + default: item.yearString + } + } + + public func itemWidth(isMobile: Bool = false) -> Int { + switch self { + case .episode: return isMobile ? 150 : 300 + case .library: return isMobile ? 125 : 250 + default: return isMobile ? 100 : 200 + } + } +} diff --git a/Sources/LuminateCore/JellyfinClient.swift b/Sources/LuminateCore/JellyfinClient.swift index df02956..97e8f55 100644 --- a/Sources/LuminateCore/JellyfinClient.swift +++ b/Sources/LuminateCore/JellyfinClient.swift @@ -143,7 +143,7 @@ public actor JellyfinClient { } public func authenticate(username: String, password: String) async throws - -> Components.Schemas.AuthenticationResult + -> AuthenticationResult { let response = try await client.authenticateUserByName( Operations.AuthenticateUserByName.Input( @@ -187,17 +187,17 @@ public actor JellyfinClient { public func getItems( userId: String, parentId: String? = nil, - includeItemTypes: [Components.Schemas.BaseItemKind]? = nil, - fields: [Components.Schemas.ItemFields]? = nil, - filters: [Components.Schemas.ItemFilter]? = nil, - sortBy: [Components.Schemas.ItemSortBy]? = nil, - sortOrder: [Components.Schemas.SortOrder]? = nil, + includeItemTypes: [BaseItemKind]? = nil, + fields: [ItemFields]? = nil, + filters: [ItemFilter]? = nil, + sortBy: [ItemSortBy]? = nil, + sortOrder: [SortOrder]? = nil, searchTerm: String? = nil, startIndex: Int32? = nil, limit: Int32? = nil, recursive: Bool? = nil, isFavorite: Bool? = nil - ) async throws -> Components.Schemas.BaseItemDtoQueryResult { + ) async throws -> BaseItemDtoQueryResult { guard token != nil else { throw JellyfinError.notAuthenticated } var query = Operations.GetItems.Input.Query(userId: userId) query.parentId = parentId @@ -234,7 +234,7 @@ public actor JellyfinClient { } public func getItem(itemId: String, userId: String? = nil) async throws - -> Components.Schemas.BaseItemDto + -> BaseItemDto { guard token != nil else { throw JellyfinError.notAuthenticated } let response = try await client.getItem( @@ -264,7 +264,7 @@ public actor JellyfinClient { } public func getUserViews(userId: String) async throws - -> Components.Schemas.BaseItemDtoQueryResult + -> BaseItemDtoQueryResult { guard token != nil else { throw JellyfinError.notAuthenticated } let response = try await client.getUserViews( @@ -296,12 +296,12 @@ public actor JellyfinClient { userId: String, startIndex: Int32? = nil, limit: Int32? = nil, - fields: [Components.Schemas.ItemFields]? = nil, + fields: [ItemFields]? = nil, seriesId: String? = nil, parentId: String? = nil, enableResumable: Bool? = nil, enableRewatching: Bool? = nil - ) async throws -> Components.Schemas.BaseItemDtoQueryResult { + ) async throws -> BaseItemDtoQueryResult { guard token != nil else { throw JellyfinError.notAuthenticated } var query = Operations.GetNextUp.Input.Query(userId: userId) query.startIndex = startIndex @@ -336,13 +336,13 @@ public actor JellyfinClient { public func getSeasons( seriesId: String, userId: String, - fields: [Components.Schemas.ItemFields]? = nil, + fields: [ItemFields]? = nil, isSpecialSeason: Bool? = nil, enableImages: Bool? = nil, imageTypeLimit: Int32? = nil, - enableImageTypes: [Components.Schemas.ImageType]? = nil, + enableImageTypes: [ImageType]? = nil, enableUserData: Bool? = nil - ) async throws -> Components.Schemas.BaseItemDtoQueryResult { + ) async throws -> BaseItemDtoQueryResult { guard token != nil else { throw JellyfinError.notAuthenticated } var query = Operations.GetSeasons.Input.Query(userId: userId) query.fields = fields @@ -384,14 +384,14 @@ public actor JellyfinClient { userId: String, seasonId: String? = nil, season: Int32? = nil, - fields: [Components.Schemas.ItemFields]? = nil, + fields: [ItemFields]? = nil, startIndex: Int32? = nil, limit: Int32? = nil, enableImages: Bool? = nil, imageTypeLimit: Int32? = nil, - enableImageTypes: [Components.Schemas.ImageType]? = nil, + enableImageTypes: [ImageType]? = nil, enableUserData: Bool? = nil - ) async throws -> Components.Schemas.BaseItemDtoQueryResult { + ) async throws -> BaseItemDtoQueryResult { guard token != nil else { throw JellyfinError.notAuthenticated } var query = Operations.GetEpisodes.Input.Query(userId: userId) query.seasonId = seasonId @@ -436,9 +436,9 @@ public actor JellyfinClient { userId: String? = nil, startIndex: Int32? = nil, limit: Int32? = nil, - includeItemTypes: [Components.Schemas.BaseItemKind]? = nil, + includeItemTypes: [BaseItemKind]? = nil, parentId: String? = nil - ) async throws -> Components.Schemas.SearchHintResult { + ) async throws -> SearchHintResult { guard token != nil else { throw JellyfinError.notAuthenticated } var query = Operations.GetSearchHints.Input.Query(searchTerm: searchTerm) query.userId = userId @@ -470,7 +470,7 @@ public actor JellyfinClient { } public func markPlayedItem(itemId: String, userId: String, datePlayed: Date? = nil) async throws - -> Components.Schemas.UserItemDataDto + -> UserItemDataDto { guard token != nil else { throw JellyfinError.notAuthenticated } let response = try await client.markPlayedItem( @@ -502,7 +502,7 @@ public actor JellyfinClient { } public func markUnplayedItem(itemId: String, userId: String) async throws - -> Components.Schemas.UserItemDataDto + -> UserItemDataDto { guard token != nil else { throw JellyfinError.notAuthenticated } let response = try await client.markUnplayedItem( @@ -534,7 +534,7 @@ public actor JellyfinClient { } public func markFavoriteItem(itemId: String, userId: String) async throws - -> Components.Schemas.UserItemDataDto + -> UserItemDataDto { guard token != nil else { throw JellyfinError.notAuthenticated } let response = try await client.markFavoriteItem( @@ -564,7 +564,7 @@ public actor JellyfinClient { } public func unmarkFavoriteItem(itemId: String, userId: String) async throws - -> Components.Schemas.UserItemDataDto + -> UserItemDataDto { guard token != nil else { throw JellyfinError.notAuthenticated } let response = try await client.unmarkFavoriteItem( @@ -594,7 +594,7 @@ public actor JellyfinClient { } public func getPlaybackInfo(itemId: String, userId: String) async throws - -> Components.Schemas.PlaybackInfoResponse + -> PlaybackInfoResponse { guard token != nil else { throw JellyfinError.notAuthenticated } let response = try await client.getPlaybackInfo( @@ -625,7 +625,7 @@ public actor JellyfinClient { } } - public func reportPlaybackStart(info: Components.Schemas.PlaybackStartInfo) async throws { + public func reportPlaybackStart(info: PlaybackStartInfo) async throws { guard token != nil else { throw JellyfinError.notAuthenticated } let response = try await client.reportPlaybackStart( Operations.ReportPlaybackStart.Input( @@ -645,7 +645,7 @@ public actor JellyfinClient { } } - public func reportPlaybackProgress(info: Components.Schemas.PlaybackProgressInfo) async throws { + public func reportPlaybackProgress(info: PlaybackProgressInfo) async throws { guard token != nil else { throw JellyfinError.notAuthenticated } let response = try await client.reportPlaybackProgress( Operations.ReportPlaybackProgress.Input( @@ -665,7 +665,7 @@ public actor JellyfinClient { } } - public func reportPlaybackStopped(info: Components.Schemas.PlaybackStopInfo) async throws { + public func reportPlaybackStopped(info: PlaybackStopInfo) async throws { guard token != nil else { throw JellyfinError.notAuthenticated } let response = try await client.reportPlaybackStopped( Operations.ReportPlaybackStopped.Input( @@ -688,15 +688,15 @@ public actor JellyfinClient { public func getLatestMedia( userId: String, parentId: String? = nil, - fields: [Components.Schemas.ItemFields]? = nil, - includeItemTypes: [Components.Schemas.BaseItemKind]? = nil, + fields: [ItemFields]? = nil, + includeItemTypes: [BaseItemKind]? = nil, limit: Int32? = nil, enableImages: Bool? = nil, imageTypeLimit: Int32? = nil, - enableImageTypes: [Components.Schemas.ImageType]? = nil, + enableImageTypes: [ImageType]? = nil, enableUserData: Bool? = nil, groupItems: Bool? = nil - ) async throws -> [Components.Schemas.BaseItemDto] { + ) async throws -> [BaseItemDto] { guard token != nil else { throw JellyfinError.notAuthenticated } var query = Operations.GetLatestMedia.Input.Query(userId: userId) query.parentId = parentId @@ -731,8 +731,8 @@ public actor JellyfinClient { } } - public func imageURL( - itemId: String, imageType: Components.Schemas.ImageType, tag: String? = nil, + nonisolated public func imageURL( + itemId: String, imageType: ImageType, tag: String? = nil, maxWidth: Int32? = nil, quality: Int32? = 90 ) -> URL? { guard var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false) else { @@ -747,7 +747,7 @@ public actor JellyfinClient { return components.url } - public func userImageURL(userId: String, tag: String? = nil) -> URL? { + nonisolated public func userImageURL(userId: String, tag: String? = nil) -> URL? { guard var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false) else { return nil } diff --git a/Sources/LuminateCore/LuminateCore.swift b/Sources/LuminateCore/LuminateCore.swift index 2e0e4c4..d1f7e71 100644 --- a/Sources/LuminateCore/LuminateCore.swift +++ b/Sources/LuminateCore/LuminateCore.swift @@ -23,6 +23,24 @@ @_exported import OpenAPIRuntime @_exported import OpenAPIURLSession +public typealias AuthenticationResult = Components.Schemas.AuthenticationResult +public typealias BaseItemDto = Components.Schemas.BaseItemDto +public typealias BaseItemDtoQueryResult = Components.Schemas.BaseItemDtoQueryResult +public typealias BaseItemKind = Components.Schemas.BaseItemKind +public typealias BaseItemPerson = Components.Schemas.BaseItemPerson +public typealias ImageType = Components.Schemas.ImageType +public typealias ItemFields = Components.Schemas.ItemFields +public typealias ItemFilter = Components.Schemas.ItemFilter +public typealias ItemSortBy = Components.Schemas.ItemSortBy +public typealias PlaybackInfoResponse = Components.Schemas.PlaybackInfoResponse +public typealias PlaybackProgressInfo = Components.Schemas.PlaybackProgressInfo +public typealias PlaybackStartInfo = Components.Schemas.PlaybackStartInfo +public typealias PlaybackStopInfo = Components.Schemas.PlaybackStopInfo +public typealias SearchHint = Components.Schemas.SearchHint +public typealias SearchHintResult = Components.Schemas.SearchHintResult +public typealias SortOrder = Components.Schemas.SortOrder +public typealias UserItemDataDto = Components.Schemas.UserItemDataDto + public enum LuminateCore { public static let version = "0.1.0" } diff --git a/Sources/LuminateCore/Page.swift b/Sources/LuminateCore/Page.swift index 0bc1155..c696422 100644 --- a/Sources/LuminateCore/Page.swift +++ b/Sources/LuminateCore/Page.swift @@ -20,15 +20,15 @@ // public enum Page: CustomStringConvertible { - case collectionPage(item: Components.Schemas.BaseItemDto) - case itemPage(title: String, items: [Components.Schemas.BaseItemDto]) + case item(item: BaseItemDto, type: DisplayType = .mixed) + case items(title: String, items: [BaseItemDto], type: DisplayType = .mixed) + case movieDetail(item: BaseItemDto) public var description: String { - switch self { - case .collectionPage(let item): - return item.name ?? "Library" - case .itemPage(let title, _): - return title + return switch self { + case .item(let item, _): item.name ?? "Library" + case .items(let title, _, _): title + case .movieDetail(let item): item.name ?? "Luminate" } } } diff --git a/Sources/LuminatePlayer/PlayerView.swift b/Sources/LuminatePlayer/PlayerView.swift index 78351ff..5213fa1 100644 --- a/Sources/LuminatePlayer/PlayerView.swift +++ b/Sources/LuminatePlayer/PlayerView.swift @@ -25,7 +25,7 @@ import LuminateCore public struct PlayerView: View { - public var item: Components.Schemas.BaseItemDto + public var item: BaseItemDto public var client: JellyfinClient public var userId: String public var mediaSourceId: String @@ -38,7 +38,7 @@ public struct PlayerView: View { public var onClose: () -> Void public init( - item: Components.Schemas.BaseItemDto, + item: BaseItemDto, client: JellyfinClient, userId: String, mediaSourceId: String, diff --git a/Sources/LuminateUI/Components/EpisodeList.swift b/Sources/LuminateUI/Components/EpisodeList.swift index 0aa3a19..554c388 100644 --- a/Sources/LuminateUI/Components/EpisodeList.swift +++ b/Sources/LuminateUI/Components/EpisodeList.swift @@ -30,7 +30,7 @@ struct EpisodeList: View { var seasonId: String var client: JellyfinClient var userId: String - @State private var episodes: [Components.Schemas.BaseItemDto] = [] + @State private var episodes: [BaseItemDto] = [] var view: Body { VStack { @@ -60,7 +60,7 @@ struct EpisodeList: View { struct EpisodeRow: View { - var episode: Components.Schemas.BaseItemDto + var episode: BaseItemDto var client: JellyfinClient @Injected(\.imageService) var imageService @Injected(\.viewUpdateScheduler) var viewUpdateScheduler diff --git a/Sources/LuminateUI/Components/HomePosterCell.swift b/Sources/LuminateUI/Components/HomePosterCell.swift index 17c5933..d19e5ac 100644 --- a/Sources/LuminateUI/Components/HomePosterCell.swift +++ b/Sources/LuminateUI/Components/HomePosterCell.swift @@ -26,36 +26,56 @@ import LuminateDI struct HomePosterCell: View { - let item: Components.Schemas.BaseItemDto - let minWidth: Int = 200 - @Binding var navigation: NavigationStack - @Injected(\.client) var client - @Injected(\.imageService) var imageService - @Injected(\.viewUpdateScheduler) var viewUpdateScheduler + let itemId: String? + let title: String? + let subtitle: String? + let width: Int + let imageAspectRatio: Float + let onClick: (() -> Void)? + + @Injected(\.client) private var client + @Injected(\.imageService) private var imageService + @Injected(\.viewUpdateScheduler) private var viewUpdateScheduler + @State private var imageData: Data? private struct Constants { static let padding = 8 } + init( + itemId: String? = nil, + title: String? = nil, + subtitle: String? = nil, + width: Int = 200, + imageAspectRatio: Float = 1.5, + onClick: @escaping () -> Void + ) { + self.itemId = itemId + self.title = title + self.subtitle = subtitle + self.width = width + self.imageAspectRatio = imageAspectRatio + self.onClick = onClick + } + var view: Body { Bin { - VStack(spacing: 6) { + VStack(spacing: showTextSection ? 6 : 0) { imageSection - textSection - } - .padding(Constants.padding) - }.onClick { - if item.isFolder ?? false { - navigation.push(.collectionPage(item: item)) + if showTextSection { + textSection + } } + .padding(padding) } + .onClick(handler: onClick ?? {}) .onAppear { Idle { loadImage() } } - .frame(minWidth: minWidth + (Constants.padding * 2)) + .frame(minWidth: width) .halign(.fill) .valign(.start) .hexpand(false) @@ -65,7 +85,7 @@ struct HomePosterCell: View { @ViewBuilder private var imageSection: Body { - AspectContainer(aspectRatio: 1.5) + AspectContainer(aspectRatio: imageAspectRatio) .child { image .halign(.fill) @@ -73,7 +93,7 @@ struct HomePosterCell: View { .overflow(.hidden) .card() } - .frame(minWidth: minWidth) + .frame(minWidth: width - (padding * 2)) } @ViewBuilder @@ -98,14 +118,13 @@ struct HomePosterCell: View { private var textSection: Body { Bin { VStack(spacing: 2) { - if let title = item.name, !title.isEmpty { - Text(item.name ?? "") + if let title, !title.isEmpty { + Text(title) .maxWidthChars(0) .ellipsize() .heading() } - let subtitle = item.yearString - if !subtitle.isEmpty { + if let subtitle, !subtitle.isEmpty { Text(subtitle) .maxWidthChars(0) .ellipsize() @@ -119,19 +138,25 @@ struct HomePosterCell: View { .hexpand() } + private var showTextSection: Bool { + (title?.isEmpty == false) && (subtitle?.isEmpty == false) + } + + private var padding: Int { + showTextSection ? Constants.padding : 0 + } + private func loadImage() { - guard let tag = item.primaryImageTag, - let itemId = item.seriesId ?? item.id + guard let itemId else { return } + guard + let url = client.imageURL( + itemId: itemId, + imageType: .primary, + maxWidth: width.cInt * 2 + ) else { return } + Task { - guard - let url = await client.imageURL( - itemId: itemId, - imageType: .primary, - tag: tag, - maxWidth: 400 - ) - else { return } let data = try? await imageService.loadImage(url: url) _imageData.rawValue = data diff --git a/Sources/LuminateUI/Components/ItemGrid.swift b/Sources/LuminateUI/Components/ItemGrid.swift index 8c6b62d..a05d28d 100644 --- a/Sources/LuminateUI/Components/ItemGrid.swift +++ b/Sources/LuminateUI/Components/ItemGrid.swift @@ -25,16 +25,20 @@ import LuminateCore public struct ItemGrid: View { - public var items: [Components.Schemas.BaseItemDto] - @Binding public var navigation: NavigationStack + public var items: [BaseItemDto] + public var type: DisplayType public var title: String? + @Binding public var navigation: NavigationStack + public init( - items: [Components.Schemas.BaseItemDto], + items: [BaseItemDto], + type: DisplayType = .mixed, navigation: Binding>, title: String? = nil ) { self.items = items + self.type = type _navigation = navigation self.title = title } @@ -46,11 +50,24 @@ public struct ItemGrid: View { .halign(.start) .padding(10, .horizontal) FlowGrid(items, id: \.id) { item in - HomePosterCell(item: item, navigation: $navigation) + HomePosterCell( + itemId: type.itemId(for: item), + title: type.title(for: item), + subtitle: type.subtitle(for: item), + width: type.itemWidth(), + imageAspectRatio: type.aspectRatio + ) { + if let childDisplayType = item.childDisplayType { + navigation.push(.item(item: item, type: childDisplayType)) + } + if item.type == .movie { + navigation.push(.movieDetail(item: item)) + } + } } .columnSpacing(16) .rowSpacing(16) - .minimumSize(216) + .minimumSize(type.itemWidth()) .halign(.fill) } } diff --git a/Sources/LuminateUI/Components/MediaRow.swift b/Sources/LuminateUI/Components/MediaRow.swift index 8062fee..e6b319d 100644 --- a/Sources/LuminateUI/Components/MediaRow.swift +++ b/Sources/LuminateUI/Components/MediaRow.swift @@ -22,22 +22,29 @@ import Adwaita import Foundation import LuminateCore +import LuminateDI public struct MediaRow: View { public var title: String - public var items: [Components.Schemas.BaseItemDto] - @Binding public var navigation: NavigationStack + public var items: [BaseItemDto] + public var type: DisplayType public var onSeeAll: (() -> Void)? + @Binding public var navigation: NavigationStack + + @Injected(\.client) private var client + public init( title: String, - items: [Components.Schemas.BaseItemDto], + items: [BaseItemDto], + type: DisplayType = .mixed, navigation: Binding>, - onSeeAll: (() -> Void)? = nil + onSeeAll: @escaping () -> Void ) { self.title = title self.items = items + self.type = type _navigation = navigation self.onSeeAll = onSeeAll } @@ -47,25 +54,37 @@ public struct MediaRow: View { HStack { Text(title) .title3() - - /// Poor man's `Spacer()` - Bin() - .hexpand() + .halign(.start) if let onSeeAll { Button("See All") { onSeeAll() } + .halign(.end) + .hexpand() } } + .halign(.fill) ScrollView { ForEach(items, horizontal: true) { item in - HomePosterCell(item: item, navigation: $navigation) - .padding(16, .trailing) + HomePosterCell( + itemId: type.itemId(for: item), + title: type.title(for: item), + subtitle: type.subtitle(for: item), + width: type.itemWidth(), + imageAspectRatio: type.aspectRatio + ) { + if let childDisplayType = item.childDisplayType { + navigation.push(.item(item: item, type: childDisplayType)) + } + if item.type == .movie { + navigation.push(.movieDetail(item: item)) + } + } + .padding(16, .trailing) } } - // .propagateNaturalHeight() .vscrollbarPolicy(.never) .hscrollbarPolicy(.external) .style("undershoot-start") diff --git a/Sources/LuminateUI/Components/MovieDetailView.swift b/Sources/LuminateUI/Components/MovieDetailView.swift index 4b2c028..ceea73b 100644 --- a/Sources/LuminateUI/Components/MovieDetailView.swift +++ b/Sources/LuminateUI/Components/MovieDetailView.swift @@ -24,26 +24,24 @@ import Foundation import LuminateCore import LuminateDI -struct MovieDetailView: View { +public struct MovieDetailView: View { - var item: Components.Schemas.BaseItemDto - var client: JellyfinClient - var userId: String - @Injected(\.imageService) var imageService + var item: BaseItemDto + @Injected(\.client) private var client + @Injected(\.userId) private var userId + @Injected(\.imageService) private var imageService @State private var isFavorite: Bool @State private var isPlayed: Bool - @State private var similarItems: [Components.Schemas.BaseItemDto] = [] + @State private var similarItems: [BaseItemDto] = [] @State private var backdropData: Data? - init(item: Components.Schemas.BaseItemDto, client: JellyfinClient, userId: String) { + public init(for item: BaseItemDto) { self.item = item - self.client = client - self.userId = userId _isFavorite = .init(wrappedValue: item.userData?.value1.isFavorite ?? false) _isPlayed = .init(wrappedValue: item.userData?.value1.played ?? false) } - var view: Body { + public var view: Body { ScrollView { VStack { if let data = backdropData { @@ -151,7 +149,7 @@ struct MovieDetailView: View { limit: 10, recursive: true ) - await MainActor.run { similarItems = result?.items ?? [] } + similarItems = result?.items ?? [] } } @@ -162,7 +160,7 @@ struct MovieDetailView: View { } else { try? await client.markFavoriteItem(itemId: item.id ?? "", userId: userId) } - await MainActor.run { isFavorite.toggle() } + isFavorite.toggle() } } @@ -173,7 +171,7 @@ struct MovieDetailView: View { } else { try? await client.markPlayedItem(itemId: item.id ?? "", userId: userId) } - await MainActor.run { isPlayed.toggle() } + isPlayed.toggle() } } } diff --git a/Sources/LuminateUI/Components/PersonCell.swift b/Sources/LuminateUI/Components/PersonCell.swift index 4270dfb..d92c7ae 100644 --- a/Sources/LuminateUI/Components/PersonCell.swift +++ b/Sources/LuminateUI/Components/PersonCell.swift @@ -23,7 +23,7 @@ import Adwaita import LuminateCore struct PersonCell: View { - var person: Components.Schemas.BaseItemPerson + var person: BaseItemPerson var view: Body { VStack { Avatar(showInitials: false, size: 60) diff --git a/Sources/LuminateUI/Components/PosterCell.swift b/Sources/LuminateUI/Components/PosterCell.swift index 8f65c35..bb5c331 100644 --- a/Sources/LuminateUI/Components/PosterCell.swift +++ b/Sources/LuminateUI/Components/PosterCell.swift @@ -26,7 +26,7 @@ import LuminateDI struct PosterCell: View { - var item: Components.Schemas.BaseItemDto + var item: BaseItemDto @Injected(\.client) private var client: JellyfinClient @Injected(\.imageService) private var imageService @Injected(\.viewUpdateScheduler) private var viewUpdateScheduler diff --git a/Sources/LuminateUI/Components/SearchView.swift b/Sources/LuminateUI/Components/SearchView.swift index 93daf5c..b06e1e6 100644 --- a/Sources/LuminateUI/Components/SearchView.swift +++ b/Sources/LuminateUI/Components/SearchView.swift @@ -29,7 +29,7 @@ struct SearchView: View { var client: JellyfinClient var userId: String @State private var searchText = "" - @State private var results: [Components.Schemas.SearchHint] = [] + @State private var results: [SearchHint] = [] @State private var isSearching = false var view: Body { @@ -73,13 +73,13 @@ struct SearchView: View { } } -extension Components.Schemas.SearchHint: Identifiable { +extension SearchHint: Identifiable { public var id: String { id ?? itemId ?? String(describing: self) } } struct SearchResultRow: View { - var hint: Components.Schemas.SearchHint + var hint: SearchHint var client: JellyfinClient @Injected(\.viewUpdateScheduler) var viewUpdateScheduler @Injected(\.imageService) var imageService diff --git a/Sources/LuminateUI/Components/TVShowView.swift b/Sources/LuminateUI/Components/TVShowView.swift index 3a16302..6b37a5b 100644 --- a/Sources/LuminateUI/Components/TVShowView.swift +++ b/Sources/LuminateUI/Components/TVShowView.swift @@ -26,11 +26,11 @@ import LuminateDI struct TVShowView: View { - var item: Components.Schemas.BaseItemDto + var item: BaseItemDto var client: JellyfinClient var userId: String @Injected(\.imageService) var imageService - @State private var seasons: [Components.Schemas.BaseItemDto] = [] + @State private var seasons: [BaseItemDto] = [] @State private var selectedSeasonId: String? @State private var backdropData: Data?