mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
b5ba59b41d
commit
bb5a6638c4
29 files changed
+845
-69
No files matched your search
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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: " · ")
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user