The discrete tick-slider UI lands Default at the slider's mid-point,
which matches how every other size control on a TV (volume, brightness,
font-size sliders) works. The previous four-level asymmetric layout
(two shrink steps, one growth step) was a discrete-picker decision
that didn't translate well once the UI became a slider: at four slots
Default sat at slot 2 of 4, off-centre.
DisplaySizeLevel proto enum gains EXTRA_LARGE = 4. The Kotlin enum
ordering already iterates UNRECOGNIZED last so existing UNRECOGNIZED
clamps still work. The string-array and individual level string
resources gain the Extra large entry.
Percentages are re-tuned to be symmetric around Default with growth
headroom slightly larger than shrink headroom (matches the demand
signal from the upstream discussion thread):
- Text: XS 82 S 92 Default 100 L 110 XL 120
- Card: XS 78 S 90 Default 100 L 115 XL 130
- Spacing: XS 75 S 88 Default 100 L 118 XL 135
The scaledSp lookup is regenerated for the new percentages and gains
an XL column. The formula fallback (rounded integer math) handles any
base sp not in the table.
Tests assert the new Card LARGE (115%) and Spacing LARGE (118%)
percentages against the dp math.
Settings panel layout flipped: preview now occupies the left two-thirds
of the page (nav rail at the natural app-left position), settings panel
sits on the right at 300dp wide. Mirrors the real home-screen layout
so the preview reads as a true reflection of what the user will see.
Replaces the popup ChoicePreference selector with an inline tick-slider
per control. Each slider is a chunky 24dp rounded track using
surfaceVariant for the inactive background and primary for the thumb;
the thumb is a rounded pill that snaps to one of four slot centers and
fills 88% of the track height. D-pad LEFT/RIGHT cycles slots within the
focused control; UP/DOWN moves between controls. Selection commits on
focus change so the preview updates without an extra Enter press.
Wraps the settings panel in a nested MaterialTheme using the unscaled
AppTypography (both tv.material3 and compose.material3 flavors) so the
panel's own labels stay at base size regardless of the Text size level
the user picks. Without this nesting the panel chrome reflowed under
the user's fingers as they cycled levels.
Preview now uses SlimItemFields when fetching, so the Overview text
populates on the hero. Episodes row is rendered first (matching the
real home-screen ordering) so the hero defaults to an episode item,
which exercises the full hero surface (title + episode name + overview
+ quick details). TimeDisplay is drawn over the preview area when the
user's showClock preference is enabled.
Text-size jumps are tuned wider: ExtraSmall 84% (was 88), Small 92%
(was 94), Large 116% (was 108). Default stays at 100%. The hand-mapped
sp lookup is regenerated for these percentages.
scaledSp falls back to integer-rounded formula math instead of throwing
on unmapped base sp values, so the helper is safe to call from any
fontSize site without first extending the table.
Adds a scaledSp(baseSp: Int) composable helper that returns a TextUnit
scaled by the active text-size level. The lookup is a hand-mapped
DisplaySizeLevel.scaledSp(Int) table for the known base sp values
(integer outputs at every cell, no fractional sp). Unknown base sp
values fall back to integer-rounded percent math instead of throwing,
so the helper is safe to call from anywhere.
scaledSp reads from LocalInterfaceCustomization.current.textSizeLevel
so the same composition local that already carries the user's interface
preferences also drives text scaling.
33 hardcoded fontSize = N.sp call sites across cards, components,
playback overlay, nav, setup, and livetv go through scaledSp. This
covers both plain text and FontAwesome icon-glyph sites: at the Default
level the lookup returns the exact base sp, so today's rendered sizes
are byte-identical. PlaybackController's file-level titleTextSize and
subtitleTextSize constants are inlined as scaledSp(28) and scaledSp(18),
keeping the rendering and PlaybackOverlay's height-offset math in lockstep
at every level.
The v1 scaledDensity helper in InterfaceHelpers.kt and its tests are
deleted - they had no callers after MainContent's density override was
removed. The InterfaceCustomization derived-getter doc comment is
retuned to v2-valid percent values.
WholphinTheme takes a new textSizeLevel parameter (defaults to DEFAULT).
AppTypography stays the TV Material3 default; scaledBy(level) returns a
TvTypography with each role's fontSize and lineHeight multiplied by the
level's textPercent (identity at DEFAULT). toMaterialTypography() copies
each role's TextStyle into a compose.material3.Typography so the inner
compose.material3.MaterialTheme inside WholphinTheme is fed from the same
source. Both wraps now scale uniformly, and any compose.material3
component reading MaterialTheme.typography.X now matches the TV M3 size
for X.
MainActivity and WholphinDreamService pass the level from prefs. Preview
call sites in cards/playback fall through to DEFAULT and render identically
to before.
The three v1 free sliders (UI scale, Card size, Spacing) become two
discrete enum-based controls (Card size, Spacing) and a new Text size
control, each selecting one of four DisplaySizeLevel values
(Extra small / Small / Default / Large). UI scale is dropped entirely;
the density override at the MainContent root goes with it.
Proto: replaces ui_scale_percent / card_size_percent / spacing_percent
with text_size_level / card_size_level / spacing_level on InterfacePreferences.
DisplaySizeLevel enum is ordered DEFAULT = 0 so proto-zero-default lands
new installs and any unset upgrader on DEFAULT for free; no upgrade-reset
block needed, no serializer default-setting needed.
AppPreference UiScale / CardSize / Spacing slider objects become three
AppChoicePreference<AppPreferences, DisplaySizeLevel> entries
(TextSize, CardSize, Spacing). DisplaySizeLevels.kt holds the user-facing
display order and the three per-control percent-mapping extensions
(textPercent / cardPercent / spacingPercent).
InterfaceCustomization stores the three level fields and exposes
cardSizePercent / spacingPercent as derived getters off the levels, so
every consumer of cardHeightDp / episodeCardHeightDp / spacingDp stays
unchanged.
DisplaySizePage keeps the preview infrastructure (sample-item viewmodel,
HomePageContent-based canvas, fake nav rail, two-column layout) and
swaps the slider column for three ChoicePreference selectors bound to
the new AppPreferences. The density-override wrap on the preview pane
is removed.
InterfaceHelpersTest withinBoundsOrDefault now exercises SkipForward
since CardSize / Spacing are no longer AppSliderPreference. The two
InterfaceCustomization scaling tests assert against cardSizeLevel = LARGE
(116%) and spacingLevel = LARGE (125%).
resolveCardHeight now stacks the preset base, the global Card percent,
and the per-row multiplier: effective = base * globalCardSizePercent
/ 100 * cardSizeMultiplier / 100. Before this commit, a row with an
absolute preset heightDp (Favorite-Episode at 148, live-TV at 96, etc.)
was insulated from the global slider - the user had to drag the
per-row Card slider on that specific row to scale it, which made the
global slider feel inconsistent. Now the global reaches every row;
preset rows still get to set their own anchor and the per-row
multiplier still adjusts them independently.
Signature change: globalCardHeightDp (a pre-scaled dp value) is
replaced by globalCardSizePercent (raw percent) so the resolver can
apply the global multiplication itself, and baseCardHeightDp is
renamed to naturalCardHeightDp since it now serves both as the
fallback base and as the no-override sentinel. The composable caller
(resolvedCardHeight) drops its branch to pick between cardHeightDp and
episodeCardHeightDp - it just passes cardSizePercent through.
Tests cover preset compounding (148 * 150% * 100% = 222; 148 * 50% =
74; 96 * 125% = 120) plus the existing natural-base, sentinel, and
per-row override cases retargeted at the new signature.
resolvedCardHeight previously routed through a two-branch if(isWide)
hidden inside the composable extension, so the regression tests for
the global-slider reach bug only exercised the inner resolveCardHeight
(which was never broken). Lift the aspect -> natural base mapping
into a pure naturalCardBaseDp(AspectRatio) function with an exhaustive
when, and test that directly. SQUARE and FOUR_THREE rows piggyback on
the TALL global by design; the override branch still handles their
absolute heightDp values (Music presets at 100dp, etc.).
MainContent and DisplaySizePage's preview now both call a single
scaledDensity(base, percent) helper instead of inlining Density(...)
constructions. The double-fontScale-multiply regression has a real
guard now: tests assert scaledDensity(2.0, 1.0, 150) returns density
3.0 and fontScale 1.0 (not 1.5), and a third test confirms an
accessibility fontScale survives a UI scale change.
Pure refactor + test sharpening; no behaviour change.
resolvedCardHeight now picks the row's natural base (128 for WIDE /
episode aspect, 172 for TALL / poster aspect) via HomeRowViewOptions
.aspectRatio and selects the matching scaled global (cardHeightDp or
episodeCardHeightDp) from InterfaceCustomization. Previously the
function always passed HEIGHT_2X3_DP as the natural base, so every
row storing heightDp = HEIGHT_EPISODE = 128 hit the override branch
and the global Card slider silently skipped episode-aspect home rows
(Favorite-Episode preset, genreDefault rows, etc.).
MainContent and DisplaySizePage's preview Density no longer multiply
fontScale by the UI-scale factor. sp rendering pipes through
density * fontScale, so scaling both produced factor^2 text size -
slider 150% rendered fonts at 2.25x while layouts rendered at 1.5x.
Scaling density alone makes both linear; the user's system fontScale
stays applied on top.
ItemGrid, CollectionFolderGrid, CollectionMixedGrid, and HomeRowGrid
now honor the per-type viewOptions.spacing base (16/24/16 for the
three view-options presets) the same way CollectionFolderList already
did - scaled by the global Spacing percent at the consumer site.
Before this fix only the list-mode consumer honored the per-type base
and grids fell back to a fixed 16dp baseline regardless of preset.
Also wires ViewMoreCard's default size into cardHeightDp and
QueueRowOverlay's queue-thumbnail height into episodeCardHeightDp so
those two surfaces scale too. Adds a Why: comment on the
migrateLegacyRowSpacing clamp (50..150 per-row range, intentionally
narrower than the global 25..175). Drops a couple of restating //
comments in DisplaySizePage and imports java.util.EnumSet at the top
of InterfaceHelpersTest. New unit tests cover resolveCardHeight's
episode-natural-base path, per-row override precedence over the
global, and the linear-density invariant guarding against the
fontScale bug regressing.
The actual EPISODE_ROW result row on SearchPage was missing the
LocalInterfaceCustomization.current.episodeCardHeightDp.dp read that the
adjacent ALBUM/ARTIST/SONG rows already had, so the Card size slider
did not affect Search episode results. Drop the extra
padding(horizontal = 8.dp) at the same call site so the row's
leading-edge alignment matches the other ItemRow consumers after the
horizontalPadding/cardSpacing split in 66b58ada.
Also fix EpisodeCard so the requested image size matches the rendered
card: previously imageWidth = Dp.Unspecified was being roundToPx()'d
into a junk value and passed as fillWidth, and fillHeight was never
plumbed through at all. Mirror the SeasonCard pattern - null when the
input dp is Unspecified, otherwise the pixel value - so the Jellyfin
server scales the image to the real card dimensions. Source images
that are themselves low-resolution (auto-extracted episode PRIMARY)
will still look soft when the slider is at 150%, but well-curated
episode thumbnails now render sharply.
Restores TestHomeRowSamples.kt (sample-data + sanity tests that
survived an earlier soft-reset on disk but never got staged). Updates
the RecentlyAdded sample to use the new cardSizeMultiplier and
spacingMultiplier fields.
Adds InterfaceHelpersTest.kt covering:
- Int.withinBoundsOrDefault (in-range, below-min, above-max, boundary
equality) for both CardSize and Spacing preferences.
- resolveCardHeight (newly extracted pure helper underneath the
composable resolvedCardHeight): sentinel 0, legacy 172, explicit
per-row override, multiplier path.
- InterfaceCustomization.cardHeightDp / spacingDp math.
- migrateLegacyRowSpacing: absent / default / non-default / snapping /
clamping / does-not-overwrite-existing-multiplier.
Adds migrateLegacyRowSpacing in HomeSettingsService and wires it into
decode(): when a row's raw JSON still carries the legacy "spacing"
field (from before this PR deleted the absolute spacing data field),
translate the value into the new spacingMultiplier so users keep the
same visual gap after upgrade.
Extracts resolveCardHeight as a pure helper from the @Composable
resolvedCardHeight so the resolution math is testable without the
Compose runtime.
Per-row card size and spacing return to HomeRowSettings, reframed as
percent multipliers that compound with the global Display Size sliders
instead of the old absolute heightDp / spacing values. Display Presets
continue to set absolute heightDp for special cases (Favorite Episode,
Compact, etc.); the multiplier applies on top of whatever base is in
effect for that row.
HomeRowViewOptions:
- Drops the unused absolute `spacing: Int = 16` field.
- Adds `cardSizeMultiplier: Int = 100` and `spacingMultiplier: Int = 100`.
- `heightDp` stays as the per-row absolute set by Display Presets.
resolvedCardHeight() in InterfaceHelpers now layers the multiplier on
top of the existing per-row vs global resolution.
HomeRowSettings exposes Card size and Spacing sliders for each row
(range 50-150, step 5, default 100) under "General". Genre rows keep
their stripped-down options list and skip both. On HomePage, the
per-row spacingMultiplier compounds with the global Spacing slider for
that row's inter-card gap.
HomeRowViewOptions is serialized to the user's Jellyfin server.
Existing users have rows with heightDp == 172 (the pre-PR default)
persisted upstream. Without this guard, resolvedCardHeight() would
treat 172 as a per-row override and the Card size slider would do
nothing on the home page until the user explicitly reset their rows.
Treat the legacy 172 value as "no override" so users coming from older
builds see the slider take effect immediately on home rows.
HEIGHT_2X3_DP is now the base value the global multiplier scales
from; no preset sets heightDp to exactly 172. HEIGHT_EPISODE (128)
and other preset values remain valid per-row overrides because they
are not equal to 172.
Replaces hardcoded card heights and the per-library Spacing slider
with reads from LocalInterfaceCustomization. Home rows split the
previous single horizontalPadding arg into a fixed leading-edge
contentPadding (BASE_SPACING_DP.dp) and a slider-driven cardSpacing.
CardGrid grows a contentPadding so its leading edge stays flush
regardless of the spacing slider.
The ViewOptionsSpacing slider in ViewOptionsDialog (grid + list) and
CollectionViewOptionsDialog is removed - the global Spacing slider
covers all browse-density tuning. ViewOptions.spacing remains as a
type-driven base (16/4/2 dp for GRID/LIST/DENSE_LIST, set by the type
setter) and is multiplied by the global Spacing percent at the
list-view consumer site. The Increase / Decrease all card-size
shortcuts in HomeSettingsGlobal are also removed (superseded by the
global Card size slider).
Episode-card heights also scale with the global Card size slider via
a new InterfaceCustomization.episodeCardHeightDp helper. Direct
consumer sites (ExtrasRow, MovieDetails similar-items, PersonPage
episode row, SearchPage EPISODE_ROW results) read it. Preset-set
absolute heights (Favorite-Episode preset, etc.) stay as overrides.
Adds HomeRowViewOptions.resolvedCardHeight() to InterfaceHelpers.kt -
the composable resolver bridging per-row heightDp overrides and the
global slider. HomeRowViewOptions.heightDp default flips from
Cards.HEIGHT_2X3_DP (172) to 0, the new sentinel meaning "use the
global value".
Consolidates UI sizing into three percentage sliders surfaced through a
new Settings -> Interface -> Display size page: UI scale (50-150%),
Card size (50-150%), Spacing (25-175%). Each persists immediately via
DataStore; the rest of the app reflects them through
LocalInterfaceCustomization and a root-level LocalDensity override in
MainContent.
The page mirrors HomeSettingsPage's two-column shell: a fixed-width
settings panel on the left, HomePageContent rendering live preview rows
on the right. The page snapshots its own density on entry so the slider
rail stays fixed while UI scale changes the rest of the app.
Includes a one-shot AppUpgradeHandler migration block for installs at or
before 0.6.4-31-g0 that resets the three new percent fields to their
defaults.