166 lines
4.4 KiB
Swift
166 lines
4.4 KiB
Swift
//
|
|
// HomePosterCell.swift
|
|
//
|
|
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
|
//
|
|
// 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 <https://www.gnu.org/licenses/>.
|
|
//
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
//
|
|
|
|
import Adwaita
|
|
import Foundation
|
|
import LuminateCore
|
|
import LuminateDI
|
|
|
|
struct HomePosterCell: View {
|
|
|
|
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: showTextSection ? 6 : 0) {
|
|
imageSection
|
|
if showTextSection {
|
|
textSection
|
|
}
|
|
}
|
|
.padding(padding)
|
|
}
|
|
.onClick(handler: onClick ?? {})
|
|
.onAppear {
|
|
Idle {
|
|
loadImage()
|
|
}
|
|
}
|
|
.frame(minWidth: width)
|
|
.halign(.fill)
|
|
.valign(.start)
|
|
.hexpand(false)
|
|
.style("activatable")
|
|
.card()
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var imageSection: Body {
|
|
AspectContainer(aspectRatio: imageAspectRatio)
|
|
.child {
|
|
image
|
|
.halign(.fill)
|
|
.hexpand()
|
|
.overflow(.hidden)
|
|
.card()
|
|
}
|
|
.frame(minWidth: width - (padding * 2))
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var image: Body {
|
|
if let imageData {
|
|
Picture()
|
|
.contentFit(.cover)
|
|
.data(imageData)
|
|
.valign(.fill)
|
|
.halign(.fill)
|
|
.vexpand()
|
|
.hexpand()
|
|
.transition(.crossfade)
|
|
} else {
|
|
Spinner()
|
|
.frame(minWidth: 64, minHeight: 64)
|
|
.transition(.crossfade)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var textSection: Body {
|
|
Bin {
|
|
VStack(spacing: 2) {
|
|
if let title, !title.isEmpty {
|
|
Text(title)
|
|
.maxWidthChars(0)
|
|
.ellipsize()
|
|
.heading()
|
|
}
|
|
if let subtitle, !subtitle.isEmpty {
|
|
Text(subtitle)
|
|
.maxWidthChars(0)
|
|
.ellipsize()
|
|
.caption()
|
|
.dimLabel()
|
|
}
|
|
}
|
|
.valign(.center)
|
|
}
|
|
.frame(minHeight: 40)
|
|
.hexpand()
|
|
}
|
|
|
|
private var showTextSection: Bool {
|
|
(title?.isEmpty == false) && (subtitle?.isEmpty == false)
|
|
}
|
|
|
|
private var padding: Int {
|
|
showTextSection ? Constants.padding : 0
|
|
}
|
|
|
|
private func loadImage() {
|
|
guard let itemId else { return }
|
|
guard
|
|
let url = client.imageURL(
|
|
itemId: itemId,
|
|
imageType: .primary,
|
|
maxWidth: width.cInt * 2
|
|
)
|
|
else { return }
|
|
|
|
Task {
|
|
let data = try? await imageService.loadImage(url: url)
|
|
|
|
_imageData.rawValue = data
|
|
viewUpdateScheduler.scheduleFlush()
|
|
}
|
|
}
|
|
}
|