Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ internal const val BINDER_COVER_PX = 192
/** The large widget's current book cover (W2). */
internal const val HOME_CURRENT_COVER_PX = 192

/** One of the large widget's next Lives tiles (W2). */
internal const val HOME_LIFE_COVER_PX = 128
/** One of the large widget's next Lives tiles (W2, raised for the v2 grid's bigger tiles). */
internal const val HOME_LIFE_COVER_PX = 176

/** Bitmap bytes one RemoteViews payload may carry, well under the 1 MB transaction. */
internal const val WIDGET_BITMAP_BUDGET_BYTES = 600 * 1024
Expand Down
284 changes: 243 additions & 41 deletions app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayout.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,65 +3,267 @@ package com.ninelivesaudio.app.widget.home
import com.ninelivesaudio.app.widget.HOME_CURRENT_COVER_PX
import com.ninelivesaudio.app.widget.HOME_LIFE_COVER_PX
import com.ninelivesaudio.app.widget.rgb565Bytes
import kotlin.math.ceil
import kotlin.math.floor

/*
* The Nine Lives widget is a small Home screen: the altar card, the Lives
* grid with the current book as LIFE I, and one line with the title and
* chapter where Home has its mini player. The widget runs in SizeMode.Exact,
* so [homePlanFor] gets the launcher's real size and decides everything from
* it. As the widget shrinks it drops, in order: the altar line, the tall
* altar (for the compact one), rows of Lives, the Lives beyond the current
* book, the eye, and the altar itself. The current book with play and pause
* is in every plan.
*/

/** The widget's overall arrangement. */
internal enum class HomeFace {
/** Altar on top, the Lives grid, the title line. */
STACKED,

/** Two cells wide: a mini altar, LIFE I large, the title and chapter under it. */
NARROW,

/** One cell high: LIFE I, the title and chapter beside it, a small rank block if it fits. */
ROW,
}

/** How the altar card is drawn. */
internal enum class AltarStyle {
/** Home's: rank, the eye above the wordmark, total time. */
TALL,

/** The eye beside a stack of rank, wordmark and total time, still centered. */
COMPACT,

/** Rank, wordmark and total time, no eye. */
MINI,

/** No altar at all. */
NONE,
}

/**
* Everything the face needs to know about its size, in dp. [rows] times three
* is how many Lives slots the grid has (NARROW and ROW show LIFE I only).
*/
internal data class HomePlan(
val face: HomeFace,
val altar: AltarStyle,
val rows: Int,
val tileDp: Int,
val eyeDp: Int,
val showPhrase: Boolean,
) {
val slots: Int get() = if (face == HomeFace.STACKED) rows * LIFE_COLUMNS else 1

/** The grid's width, which the altar card and the title line share so all three line up. */
val gridWidthDp: Int get() = if (face == HomeFace.STACKED) tileDp * LIFE_COLUMNS + LIFE_GAP_DP * (LIFE_COLUMNS - 1) else tileDp
}

/** Padding on every edge, enough for a tile corner to clear One UI's round widget corners. */
internal const val HOME_PAD_DP = 14

internal const val LIFE_COLUMNS = 3
internal const val LIFE_GAP_DP = 8
internal const val MAX_LIFE_ROWS = 3

/** Tiles stop growing here, so a very wide widget gets margins, not huge covers. */
internal const val MAX_TILE_DP = 116

/** Below this a tile is too small to read, and the plan gives up a row or the grid. */
internal const val MIN_TILE_DP = 52

/** Gap under the altar card. */
internal const val SECTION_GAP_DP = 10

/** Gap above the title line. */
internal const val LINE_GAP_DP = 6

/** The title line (12sp, one line). */
internal const val TITLE_LINE_DP = 17

/** The two line title block of the NARROW face. */
internal const val TITLE_BLOCK_DP = 32

/**
* The tall altar's eye: [MIN_EYE_DP] when the tiles need the height, growing
* with what they leave (see [tallAltarExtras]).
*/
internal const val MAX_EYE_DP = 56
internal const val MID_EYE_DP = 48
internal const val MIN_EYE_DP = 32
internal const val COMPACT_EYE_DP = 44

/** The altar line costs this much height, and shows when the slack covers it. */
internal const val PHRASE_DP = 17

/**
* What the Nine Lives widget keeps at a size. The design's priority order is
* rank card, then current book, then the next Lives, and smaller sizes drop
* from the bottom of that list: Lives first, then the rank card. The current
* book with its play button is in every layout.
* How much the tall altar's covered area counts for against the compact one's.
* It wins when it costs the Lives less than about a tenth of their size.
*/
internal enum class HomeLayout { CURRENT_ONLY, RANK_AND_CURRENT, FULL }
internal const val TALL_ALTAR_BONUS = 1.2f

/** Width and height in dp, as Glance hands them to the layout. */
internal fun homeLayoutFor(widthDp: Float, heightDp: Float): HomeLayout = when {
widthDp < 250f || heightDp < 180f -> HomeLayout.CURRENT_ONLY
heightDp < 260f -> HomeLayout.RANK_AND_CURRENT
else -> HomeLayout.FULL
/** Heights of the altar styles, in dp, without the altar line. */
internal fun altarHeightDp(style: AltarStyle, eyeDp: Int = MIN_EYE_DP): Int = when (style) {
// padding 10 + rank 13 + 3 + dot 4 + 3 + eye + 4 + wordmark 24 + 2 + total 16 + padding 10
AltarStyle.TALL -> 89 + eyeDp
// padding 8 + max(eye 44, rank 13 + wordmark 22 + total 16) + padding 8
AltarStyle.COMPACT -> 16 + maxOf(COMPACT_EYE_DP, 51)
// padding 8 + rank 12 + wordmark 20 + total 15 + padding 8
AltarStyle.MINI -> 63
AltarStyle.NONE -> 0
}

/** How many next Lives fit (decision J3): three under 300dp wide, four from 300dp. */
internal fun livesSlotsFor(widthDp: Float): Int = if (widthDp < 300f) 3 else 4
/**
* Share of the reported height the plan keeps in reserve. Launchers report a
* widget's size from their cell estimate, not its real bounds: Pixel Launcher
* reports a 4x3 as 359dp high when it is drawn 344dp high. The reserve lands in
* the face's weighted gaps when the report is honest.
*/
internal const val REPORTED_SIZE_SLACK = 0.05f

/** The widest a Lives tile grows, so the full face still fits its height. */
internal const val MAX_LIFE_TILE_DP = 72f
/** The face is one cell high below this inner height. */
internal const val ROW_FACE_MAX_INNER_DP = 132

/** Gap between two Lives tiles. */
internal const val LIFE_GAP_DP = 10f
/** The face is two cells wide below this inner width. */
internal const val NARROW_FACE_MAX_INNER_DP = 200

/**
* One square Lives tile, in dp: [slots] tiles and their gaps share the width
* inside the widget's 12dp padding, capped at [MAX_LIFE_TILE_DP].
* The plan for a widget of [widthDp] by [heightDp] (the launcher's real size)
* showing [tiles] books, the current one included. [tiles] only ever lowers
* the row count, so a short history gets bigger tiles instead of empty rows.
*/
internal fun lifeTileDp(widthDp: Float, slots: Int): Float =
minOf(MAX_LIFE_TILE_DP, (widthDp - 24f - LIFE_GAP_DP * (slots - 1)) / slots)
internal fun homePlanFor(widthDp: Float, heightDp: Float, tiles: Int = MAX_LIFE_ROWS * LIFE_COLUMNS): HomePlan {
val w = widthDp - 2 * HOME_PAD_DP
val h = heightDp * (1f - REPORTED_SIZE_SLACK) - 2 * HOME_PAD_DP

if (h < ROW_FACE_MAX_INNER_DP) return rowPlan(w, h)
if (w < NARROW_FACE_MAX_INNER_DP) {
val withAltar = h - altarHeightDp(AltarStyle.MINI) - SECTION_GAP_DP - LINE_GAP_DP - TITLE_BLOCK_DP
val altar = if (withAltar >= MIN_TILE_DP + 20) AltarStyle.MINI else AltarStyle.NONE
val room = if (altar == AltarStyle.MINI) withAltar else h - LINE_GAP_DP - TITLE_BLOCK_DP
val tile = minOf(w, room, MAX_TILE_DP.toFloat()).toInt().coerceAtLeast(1)
return HomePlan(HomeFace.NARROW, altar, rows = 0, tileDp = tile, eyeDp = 0, showPhrase = false)
}

/** Most Lives any layout shows. */
internal const val MAX_LIVES = 4
val tileByWidth = minOf(MAX_TILE_DP.toFloat(), (w - LIFE_GAP_DP * (LIFE_COLUMNS - 1)) / LIFE_COLUMNS)
val maxRows = ceil(tiles.coerceAtLeast(1) / LIFE_COLUMNS.toFloat()).toInt().coerceIn(1, MAX_LIFE_ROWS)

// Each altar and row count is scored by the cover area it fills, the tall
// altar with a bonus for being Home's own. The tiles take the height first,
// at the smallest eye, and whatever they leave grows the eye.
var best: HomePlan? = null
var bestScore = 0f
for ((altar, rows) in ALTAR_CHOICES) {
if (rows > maxRows) continue
val eye = if (altar == AltarStyle.TALL) MIN_EYE_DP else COMPACT_EYE_DP
val room = gridRoom(h, altarHeightDp(altar, eye))
val tile = tileFor(room, tileByWidth, rows)
if (tile < MIN_TILE_DP) continue
val score = minOf(rows * LIFE_COLUMNS, tiles) * tile * tile * (if (altar == AltarStyle.TALL) TALL_ALTAR_BONUS else 1f)
if (score <= bestScore) continue
bestScore = score
best = if (altar == AltarStyle.TALL) {
val slack = (room - (tile * rows + LIFE_GAP_DP * (rows - 1))).toInt()
val (grownEye, phrase) = tallAltarExtras(slack)
HomePlan(HomeFace.STACKED, altar, rows, tile.toInt(), grownEye, phrase)
} else {
HomePlan(HomeFace.STACKED, altar, rows, tile.toInt(), eye, showPhrase = false)
}
}
return best ?: rowPlan(w, h)
}

/**
* The Glance `SizeMode.Responsive` buckets, width to height in dp. Glance
* lays out each bucket once and the launcher picks the biggest that fits, so
* the layout only ever sees these sizes. One starts at 300dp wide so a widget
* that wide gets its four Lives (J3). The smallest matches the resize floor in
* widget_home_info.xml, since a launcher rounds cells and a 2x2 or a one row
* widget can come out under 180 by 110 (Pixel: about 172 by 99).
* The one row face: LIFE I as tall as the row, but never so wide that the
* title and chapter beside it lose their [ROW_TITLE_MIN_DP].
*/
internal val HOME_WIDGET_SIZES: List<Pair<Float, Float>> =
listOf(160f to 80f, 160f to 180f, 250f to 180f, 250f to 260f, 300f to 260f)
private fun rowPlan(w: Float, h: Float): HomePlan {
val tile = minOf(h, w - ROW_GAP_DP - ROW_TITLE_MIN_DP).toInt().coerceAtLeast(1)
return HomePlan(HomeFace.ROW, AltarStyle.NONE, rows = 0, tileDp = tile, eyeDp = 0, showPhrase = false)
}

/** A current only widget tall enough to stack the cover and play above the title (a 2x2). */
internal fun isTallCurrentOnly(widthDp: Float, heightDp: Float): Boolean =
homeLayoutFor(widthDp, heightDp) == HomeLayout.CURRENT_ONLY && heightDp >= 180f
/** The row face's gap between LIFE I and its title. */
internal const val ROW_GAP_DP = 10

/** Cover bytes one bucket draws: the current book, plus the Lives only where they show. */
internal fun homeBucketBitmapBytes(widthDp: Float, heightDp: Float): Int {
val lives = if (homeLayoutFor(widthDp, heightDp) == HomeLayout.FULL) livesSlotsFor(widthDp) else 0
return rgb565Bytes(HOME_CURRENT_COVER_PX) + lives * rgb565Bytes(HOME_LIFE_COVER_PX)
/** The least width the row face keeps for the title and chapter. */
internal const val ROW_TITLE_MIN_DP = 64

/**
* Spends the tall altar's [slack] (dp the grid does not need): the eye grows
* to [MID_EYE_DP] first, then the altar line comes in, then the eye grows on
* to [MAX_EYE_DP]. Returns the eye size and whether the line shows.
*/
internal fun tallAltarExtras(slack: Int): Pair<Int, Boolean> {
var left = slack.coerceAtLeast(0)
var eye = MIN_EYE_DP + minOf(left, MID_EYE_DP - MIN_EYE_DP)
left -= eye - MIN_EYE_DP
val phrase = left >= PHRASE_DP
if (phrase) left -= PHRASE_DP
eye += minOf(left, MAX_EYE_DP - eye)
return eye to phrase
}

/**
* The most cover bytes one update can carry. API 30 sends one RemoteViews per
* size and does not share a bitmap between them, so every bucket counts.
* The altar and row counts the stacked face chooses between. Three rows only
* ever come with the tall altar, so a widget never trades Home's look for a
* third row of small tiles.
*/
private val ALTAR_CHOICES: List<Pair<AltarStyle, Int>> = listOf(
AltarStyle.TALL to 3,
AltarStyle.TALL to 2,
AltarStyle.COMPACT to 2,
AltarStyle.TALL to 1,
AltarStyle.COMPACT to 1,
)

/** Height left for the grid under an altar of [altarDp], above the title line. */
private fun gridRoom(innerHeight: Float, altarDp: Int): Float =
innerHeight - altarDp - SECTION_GAP_DP - LINE_GAP_DP - TITLE_LINE_DP

/** The square tile for [rows] rows in [room], never wider than [tileByWidth]. */
private fun tileFor(room: Float, tileByWidth: Float, rows: Int): Float =
floor(minOf(tileByWidth, (room - LIFE_GAP_DP * (rows - 1)) / rows))

/** The play or pause disc on LIFE I: about a third of the tile, never too small to hit. */
internal fun badgeDp(tileDp: Int): Int = (tileDp * 0.36f).toInt().coerceIn(MIN_BADGE_DP, MAX_BADGE_DP)

internal const val MIN_BADGE_DP = 26
internal const val MAX_BADGE_DP = 44

/** "LIFE I" to "LIFE IX", as Home labels its tiles. Past nine, plain digits. */
internal fun lifeLabel(index: Int): String = "LIFE " + lifeNumeral(index + 1)

private val NUMERALS = listOf("I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX")

internal fun lifeNumeral(n: Int): String = NUMERALS.getOrNull(n - 1) ?: n.toString()

/**
* Glance cannot set letter spacing, so tracked caps are spelled with a thin
* space between letters. A thin space is about 0.2 em, the tracking Home
* gives its wordmark (4sp at 20sp) and its rank label (2sp at 10sp). A word
* gap keeps its normal space plus the tracking on both sides.
*/
internal fun trackedCaps(text: String): String =
text.uppercase().toList().joinToString(THIN_SPACE)

internal const val THIN_SPACE = "\u2009"

/** A hair space, for Home's lighter 1sp tracking on its 9sp LIFE numerals (about 0.1 em). */
internal const val HAIR_SPACE = "\u200A"

/** A LIFE numeral tracked like Home's, with a hair space between letters. */
internal fun trackedLabel(text: String): String = text.toList().joinToString(HAIR_SPACE)

/** Home's wordmark, tracked. */
internal val WORDMARK: String = trackedCaps("Nine Lives")

/**
* The most cover bytes one update carries: LIFE I and the next Lives, one
* bitmap each. Every size Glance lays out reuses the same bitmaps, and a
* RemoteViews keeps one copy of a bitmap however many views show it.
*/
internal fun homeWorstCaseBitmapBytes(): Int =
HOME_WIDGET_SIZES.sumOf { (w, h) -> homeBucketBitmapBytes(w, h) }
internal fun homeUpdateBitmapBytes(): Int =
rgb565Bytes(HOME_CURRENT_COVER_PX) + MAX_LIVES * rgb565Bytes(HOME_LIFE_COVER_PX)
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,14 @@ import kotlin.time.DurationUnit
/** The current book's button. WAIT is a dimmed play while a book loads, like the app. */
internal enum class CurrentButton { PLAY, PAUSE, WAIT, OPEN }

/** Home's altar card, the same words Home shows. */
internal data class RankCard(val epithet: String, val totalTimeText: String)
/** Home's altar card, the same words Home shows. [phrase] is the altar line under the wordmark. */
internal data class RankCard(val epithet: String, val totalTimeText: String, val phrase: String = "")

/**
* The next Lives after the current book. With the current book as LIFE I
* that is nine tiles at most, Home's full grid.
*/
internal const val MAX_LIVES = 8

internal data class CurrentCard(
val bookId: String,
Expand Down Expand Up @@ -86,7 +92,7 @@ internal fun buildHomeWidgetModel(
if (content != WidgetContent.READY) return HomeWidgetModel(content, isLocalMode, null, null, emptyList())

val total = totalTimeGivenSeconds(recent.map { it.currentTime.toDouble(DurationUnit.SECONDS) })
val rank = RankCard(AltarCopy.epithet(total), "Total time given: " + formatListeningTime(total))
val rank = RankCard(AltarCopy.epithet(total), "Total time given: " + formatListeningTime(total), AltarCopy.phrase(total))

val shown = current ?: recent.first()
val chapter = if (current != null) {
Expand Down
Loading
Loading