feat(core,meta,app,android,ios): title logos and runtimes

ani.zip sent each series' TVDB clear logo and every episode's running
time from the start, and neither was kept. Enrichment now stores the logo
in title_metadata.logo_url (kept across a refresh of the same entry, like
the fanart) and episode runtimes in episode_metadata.runtime, TVDB's
first and AniDB's length after it. AniList's duration lands in
TitleDetails::runtime; TMDB episode runtimes are kept too.

Schema v13 adds the two columns and marks AniList titles un-enriched
without deleting anything. TitleDetails::revision lets details written
before runtime existed refetch by id while the old ones stay on screen,
instead of blanking them in the migration as v10 did.

Clear logos are padded to a square, so each client crops them to their
visible pixels before drawing. The logo replaces the name on the
library's banner and the title page (not on a light theme's title page,
where lettering made for dark art can vanish). Episode rows show how long
they run, in place of the file size on Android and iOS; a film's runtime
joins its line of facts.
This commit is contained in:
nvrl committed 2026-10-29 16:11:24 +01:00
1 parent b5ba59b41d
commit bb5a6638c4
29 files changed
+845 -69

No files matched your search

+8
View File
@@ -316,6 +316,14 @@ extension View {
}
}
/// `24 min`, `2 h 4 min`, `2 h`: a provider's running time in minutes.
func formatRuntime(_ minutes: UInt32) -> String {
let hours = minutes / 60, rest = minutes % 60
if hours == 0 { return "\(rest) min" }
if rest == 0 { return "\(hours) h" }
return "\(hours) h \(rest) min"
}
func formatBytes(_ bytes: UInt64) -> String {
let value = Double(bytes)
let gib = 1024.0 * 1024 * 1024
+6 -1
View File
@@ -505,7 +505,12 @@ private struct FeaturedBanner: View {
}
.overlay(alignment: .bottomLeading) {
VStack(alignment: .leading, spacing: 0) {
Text(title.displayName).textStyle(.headlineMedium).foregroundStyle(.white).lineLimit(2)
// Always over the dark scrim above, so a logo made for dark
// art reads here whatever the theme.
RemoteLogo(title.logoUrl, title.displayName, maxWidth: 240, maxHeight: 72) {
Text(title.displayName).textStyle(.headlineMedium).foregroundStyle(.white).lineLimit(2)
}
.padding(.bottom, 6)
Text(factLine).textStyle(.bodySmall).foregroundStyle(.white.opacity(0.8))
if let overview = title.overview {
Text(overview.trimmingCharacters(in: .whitespacesAndNewlines))
+126
View File
@@ -81,6 +81,117 @@ struct RemoteArtwork: View {
}
}
/// A title's logo — its name as lettering — in place of `fallback`, once it
/// has loaded. `fallback` stands until then, and for good when there is none,
/// so a title without a logo looks as it always did and one with a logo only
/// gains.
///
/// Trimmed to what is not transparent before it is drawn: TVDB pads every
/// clear logo to a square, and the padding would otherwise decide its size
/// and place.
///
/// `maxWidth` and `maxHeight` bound the logo only; the fallback takes the room
/// it always did.
struct RemoteLogo<Fallback: View>: View {
let url: String?
let description: String
let maxWidth: CGFloat
let maxHeight: CGFloat
let fallback: () -> Fallback
@State private var logo: UIImage?
init(
_ url: String?,
_ description: String,
maxWidth: CGFloat,
maxHeight: CGFloat,
@ViewBuilder fallback: @escaping () -> Fallback
) {
self.url = url
self.description = description
self.maxWidth = maxWidth
self.maxHeight = maxHeight
self.fallback = fallback
_logo = State(initialValue: ArtworkLoader.shared.cachedLogo(url))
}
var body: some View {
Group {
if let logo {
Image(uiImage: logo)
.resizable()
.scaledToFit()
.frame(maxWidth: maxWidth, maxHeight: maxHeight, alignment: .bottomLeading)
.accessibilityLabel(description)
} else {
fallback()
}
}
.task(id: url) {
if logo != nil { return }
let loaded = await ArtworkLoader.shared.logo(url)
if Task.isCancelled { return }
logo = loaded
}
}
}
/// Inclusive pixel bounds, top-left origin.
struct PixelBounds: Equatable {
let left: Int
let top: Int
let right: Int
let bottom: Int
}
/// The bounds of the pixels in a row-major `alpha` plane of `width` that are
/// not (nearly) transparent; `nil` when none are. Below the threshold is
/// anti-aliasing haze, not lettering.
func opaqueBounds(alpha: [UInt8], width: Int) -> PixelBounds? {
var left = Int.max, top = Int.max, right = -1, bottom = -1
for (index, value) in alpha.enumerated() where value > 8 {
let x = index % width, y = index / width
left = min(left, x)
right = max(right, x)
top = min(top, y)
bottom = max(bottom, y)
}
return right < 0 ? nil : PixelBounds(left: left, top: top, right: right, bottom: bottom)
}
/// `image` cropped to `opaqueBounds`, or itself when there is nothing to crop.
func trimTransparent(_ image: UIImage) -> UIImage {
guard let cgImage = image.cgImage else { return image }
let width = cgImage.width, height = cgImage.height
var pixels = [UInt8](repeating: 0, count: width * height * 4)
let drawn = pixels.withUnsafeMutableBytes { buffer -> Bool in
guard let context = CGContext(
data: buffer.baseAddress,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: width * 4,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return false }
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
return true
}
guard drawn else { return image }
let alpha = stride(from: 3, to: pixels.count, by: 4).map { pixels[$0] }
guard let bounds = opaqueBounds(alpha: alpha, width: width),
bounds != PixelBounds(left: 0, top: 0, right: width - 1, bottom: height - 1)
else { return image }
let rect = CGRect(
x: bounds.left,
y: bounds.top,
width: bounds.right - bounds.left + 1,
height: bounds.bottom - bounds.top + 1
)
guard let cropped = cgImage.cropping(to: rect) else { return image }
return UIImage(cgImage: cropped, scale: image.scale, orientation: image.imageOrientation)
}
private enum ArtworkLoad {
case pending
case missing
@@ -196,6 +307,21 @@ final class ArtworkLoader: @unchecked Sendable {
return await thumbnail(fallback)
}
func cachedLogo(_ url: String?) -> UIImage? {
guard let url else { return nil }
return decoded.object(forKey: "logo:\(url)" as NSString)
}
/// A title's logo, trimmed to its lettering — see `trimTransparent`.
func logo(_ url: String?) async -> UIImage? {
guard let url else { return nil }
if let image = cachedLogo(url) { return image }
guard let image = await remote(url) else { return nil }
let trimmed = trimTransparent(image)
decoded.setObject(trimmed, forKey: "logo:\(url)" as NSString, cost: trimmed.cost)
return trimmed
}
/// Provider artwork, from the disk cache or the network.
func remote(_ requested: String?) async -> UIImage? {
guard let requested else { return nil }
+17 -4
View File
@@ -147,7 +147,16 @@ struct TitleView: View {
@ViewBuilder private func heading(playlist: [EpisodeRecord], nextUp: Int, upNext: EpisodeRecord?) -> some View {
VStack(alignment: .leading, spacing: 0) {
Text(title.displayName).textStyle(.headlineMedium).foregroundStyle(scheme.onSurface)
// A light page keeps the name: a clear logo is lettering made for
// dark art, white as often as not.
if scheme.light {
Text(title.displayName).textStyle(.headlineMedium).foregroundStyle(scheme.onSurface)
} else {
RemoteLogo(title.logoUrl, title.displayName, maxWidth: 280, maxHeight: 88) {
Text(title.displayName).textStyle(.headlineMedium).foregroundStyle(scheme.onSurface)
}
.padding(.bottom, 4)
}
// The other names it goes by: the share's when the shown one is the
// provider's, and the original-language one.
let names: [String?] = [
@@ -219,7 +228,9 @@ struct TitleView: View {
let year: String? = (title.metadataYear ?? title.year).map { String($0) }
let rating: String? = title.rating.map { String(format: "★ %.1f", $0) }
let watched: String? = title.episodeCount > 1 ? "\(title.watchedCount) of \(title.episodeCount) watched" : nil
let parts: [String?] = [title.formatLabel, title.seasonLabel ?? year, rating, watched]
// A series' running time is its episodes', which each row says.
let runtime: String? = title.kind == .film ? title.runtime.map(formatRuntime) : nil
let parts: [String?] = [title.formatLabel, title.seasonLabel ?? year, runtime, rating, watched]
return parts.compactMap { $0 }.joined(separator: " · ")
}
@@ -404,8 +415,10 @@ private struct EpisodeRow: View {
@State private var holding = false
private var subline: String {
let size: String? = episode.size.map { formatBytes($0) }
let parts: [String?] = [numbering, size, episode.airDate]
// How long it runs says more than how big it is; the size is what
// there is when no provider said.
let length: String? = episode.runtime.map(formatRuntime) ?? episode.size.map { formatBytes($0) }
let parts: [String?] = [numbering, length, episode.airDate]
return parts.compactMap { $0 }.joined(separator: " · ")
}
+16
View File
@@ -278,4 +278,20 @@ final class ThemeTests: XCTestCase {
[.displayLarge, .displayMedium, .displaySmall, .headlineLarge, .headlineMedium, .headlineSmall, .titleLarge]
)
}
func testAPaddedLogoIsBoundedByItsLetteringAndNotItsHaze() {
let width = 10
var alpha = [UInt8](repeating: 0, count: width * 6)
// Lettering from (2, 1) to (6, 3), and a pixel of haze in the corner.
for y in 1 ... 3 { for x in 2 ... 6 { alpha[y * width + x] = 255 } }
alpha[5 * width + 9] = 3
XCTAssertEqual(opaqueBounds(alpha: alpha, width: width), PixelBounds(left: 2, top: 1, right: 6, bottom: 3))
XCTAssertNil(opaqueBounds(alpha: [UInt8](repeating: 0, count: 16), width: 4))
}
func testARunningTimeReadsInHoursAndMinutes() {
XCTAssertEqual(formatRuntime(24), "24 min")
XCTAssertEqual(formatRuntime(120), "2 h")
XCTAssertEqual(formatRuntime(124), "2 h 4 min")
}
}