Add shelf homepage names and fix TV poster clipping on home rows.

Admin can set a separate homepage label per library shelf; the client home API uses it for row titles while browse keeps the canonical name. Also fixes LazyRow focus clipping on the TV home screen (0.8.4).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-01 16:20:53 +02:00
parent 7c808e9167
commit 159cd48692
11 changed files with 93 additions and 19 deletions

View file

@ -16,6 +16,7 @@ function parseTab(v: string | null): SettingsTab {
interface ShelfRow {
id: string;
name: string;
homepageName?: string | null;
slug: string;
kind: "MOVIE" | "SERIES";
sortOrder: number;
@ -230,6 +231,21 @@ export default function SettingsPage() {
loadShelves();
}
async function saveShelfHomepageName(id: string, homepageName: string) {
const res = await fetch(`/api/v1/admin/library/shelves/${id}`, {
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ homepageName: homepageName.trim() || null }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
alert(data.error?.message ?? "Opslaan mislukt");
return;
}
loadShelves();
}
async function saveConfig() {
setCfgBusy(true);
setCfgMsg(null);
@ -365,6 +381,7 @@ export default function SettingsPage() {
<h2 className="section-title">Bibliotheek-planken</h2>
<p className="muted">
Namen zoals Kids Films of NL Series. Op Nodes koppel je scanpaden aan een plank.
Met &quot;Homepage naam&quot; toon je op de TV-startpagina een kortere of andere titel.
</p>
</div>
@ -396,6 +413,7 @@ export default function SettingsPage() {
<thead>
<tr>
<th>Naam</th>
<th>Homepage naam</th>
<th>Type</th>
<th>Pad(en)</th>
<th>Bestanden</th>
@ -406,6 +424,24 @@ export default function SettingsPage() {
{shelves.map((s) => (
<tr key={s.id}>
<td>{s.name}</td>
<td>
<input
key={`${s.id}-${s.homepageName ?? ""}`}
type="text"
defaultValue={s.homepageName ?? ""}
placeholder={s.name}
aria-label={`Homepage naam voor ${s.name}`}
onBlur={(e) => {
const next = e.target.value.trim();
const prev = (s.homepageName ?? "").trim();
if (next !== prev) saveShelfHomepageName(s.id, next);
}}
onKeyDown={(e) => {
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
}}
style={{ minWidth: "10rem" }}
/>
</td>
<td>{s.kind === "MOVIE" ? "Films" : "Series"}</td>
<td className="path-cell">
{s.paths?.length ? (
@ -439,7 +475,7 @@ export default function SettingsPage() {
))}
{shelves.length === 0 && (
<tr>
<td colSpan={5} className="muted" style={{ textAlign: "center" }}>
<td colSpan={6} className="muted" style={{ textAlign: "center" }}>
Laden…
</td>
</tr>
@ -461,6 +497,20 @@ export default function SettingsPage() {
<span>{s.mediaFileCount} bestanden</span>
<span>{s.scanRootCount} pad(en)</span>
</div>
<label className="field-stack" style={{ marginTop: "0.75rem" }}>
<span className="muted tiny">Homepage naam (TV)</span>
<input
key={`${s.id}-${s.homepageName ?? ""}-mobile`}
type="text"
defaultValue={s.homepageName ?? ""}
placeholder={s.name}
onBlur={(e) => {
const next = e.target.value.trim();
const prev = (s.homepageName ?? "").trim();
if (next !== prev) saveShelfHomepageName(s.id, next);
}}
/>
</label>
{s.paths?.length ? (
<div className="path-block">
{s.paths.map((p) => (

View file

@ -12,8 +12,8 @@ android {
applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24
targetSdk = 35
versionCode = 19
versionName = "0.8.2"
versionCode = 21
versionName = "0.8.4"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a")

View file

@ -312,10 +312,14 @@ data class HomePayload(
data class ShelfRow(
val id: String,
val title: String,
val name: String = title,
val kind: String,
val slug: String = "",
val items: List<MediaItem>,
)
) {
/** Canonical shelf name for browse / sub-nav (not the homepage label). */
val browseTitle: String get() = name.ifBlank { title }
}
data class MediaItem(
val type: String,
@ -409,6 +413,7 @@ private fun JSONArray?.toShelves(): List<ShelfRow> {
ShelfRow(
id = o.optString("id"),
title = o.optString("title"),
name = o.optString("name").ifBlank { o.optString("title") },
kind = o.optString("kind"),
slug = o.optString("slug").ifBlank { o.optString("id") },
items = o.optJSONArray("items").toItems(),

View file

@ -154,9 +154,9 @@ fun BrowseScreen(
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 140.dp),
state = gridState,
contentPadding = PaddingValues(bottom = 40.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(start = 8.dp, top = 12.dp, end = 8.dp, bottom = 40.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize(),
) {
items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item ->
@ -164,6 +164,7 @@ fun BrowseScreen(
item = item,
width = 140.dp,
height = 210.dp,
modifier = Modifier.padding(6.dp),
onClick = {
val canResume = onPlayContinue != null &&
!item.mediaFileId.isNullOrBlank() &&

View file

@ -626,7 +626,7 @@ private fun HomeScreen(
ShelfBlock(
title = shelf.title,
items = shelf.items,
onSeeAll = { onSeeAll(catalogId, shelf.title) },
onSeeAll = { onSeeAll(catalogId, shelf.browseTitle) },
onClick = { item ->
onContentFocused()
if (item.type == "movie") onOpenMovie(item.id) else onOpenSeries(item.id)
@ -836,9 +836,9 @@ private fun ShelfBlock(
onClick: (MediaItem) -> Unit,
onLongClick: ((MediaItem) -> Unit)? = null,
) {
Column(Modifier.padding(start = 48.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
Row(
Modifier.fillMaxWidth().padding(end = 48.dp),
Modifier.fillMaxWidth().padding(horizontal = 48.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
@ -852,9 +852,10 @@ private fun ShelfBlock(
McButton("Alles") { onSeeAll() }
}
}
// contentPadding i.p.v. Column-startpadding: focus-scale mag in de marge tekenen
LazyRow(
horizontalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = PaddingValues(end = 48.dp),
contentPadding = PaddingValues(start = 48.dp, end = 48.dp, top = 18.dp, bottom = 18.dp),
) {
items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item ->
PosterCard(

View file

@ -260,9 +260,9 @@ fun SearchScreen(
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 110.dp),
state = gridState,
contentPadding = PaddingValues(bottom = 24.dp, end = 8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
contentPadding = PaddingValues(start = 8.dp, end = 8.dp, top = 12.dp, bottom = 24.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize(),
) {
items(results, key = { "${it.type}:${it.id}" }) { item ->
@ -270,6 +270,7 @@ fun SearchScreen(
item = item,
width = 110.dp,
height = 165.dp,
modifier = Modifier.padding(6.dp),
onClick = {
if (item.type == "movie") onOpenMovie(item.id) else onOpenSeries(item.id)
},

View file

@ -137,7 +137,7 @@ fun PosterCard(
},
),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.14f),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.10f),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(
border = androidx.compose.foundation.BorderStroke(3.dp, Color.White),

View file

@ -0,0 +1,2 @@
-- Optional display label for Android TV / client home rows (falls back to name).
ALTER TABLE "library_shelves" ADD COLUMN IF NOT EXISTS "homepage_name" TEXT;

View file

@ -280,6 +280,7 @@ model Node {
model LibraryShelf {
id String @id @default(uuid())
name String
homepageName String? @map("homepage_name")
slug String @unique
kind LibraryShelfKind
sortOrder Int @default(0) @map("sort_order")

View file

@ -596,6 +596,7 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
shelves: shelves.map((s) => ({
id: s.id,
name: s.name,
homepageName: s.homepageName,
slug: s.slug,
kind: s.kind,
sortOrder: s.sortOrder,
@ -636,6 +637,7 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
const { id } = request.params as { id: string };
const body = request.body as {
name?: string;
homepageName?: string | null;
enabled?: boolean;
sortOrder?: number;
};
@ -645,6 +647,10 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
if (!name) throw new AppError("INVALID_REQUEST", "name required", 400);
data.name = name;
}
if (body.homepageName !== undefined) {
const homepageName = body.homepageName?.trim();
data.homepageName = homepageName ? homepageName : null;
}
if (body.enabled !== undefined) data.enabled = body.enabled;
if (body.sortOrder !== undefined) data.sortOrder = body.sortOrder;
const shelf = await prisma.libraryShelf.update({ where: { id }, data });

View file

@ -326,6 +326,7 @@ export class ViewerService {
return {
id: s.id,
name: s.name,
homepageName: s.homepageName,
slug: s.slug,
kind: s.kind as "MOVIE" | "SERIES",
visible: pref?.visible ?? true,
@ -338,6 +339,7 @@ export class ViewerService {
const rows: Array<{
id: string;
title: string;
name: string;
kind: "MOVIE" | "SERIES";
slug: string;
items: Array<{
@ -356,6 +358,7 @@ export class ViewerService {
rows.push({
id: "system:recent-movies",
title: "Recent toegevoegd",
name: "Recent toegevoegd",
kind: "MOVIE",
slug: "recent-movies",
items: recentMovies,
@ -365,6 +368,7 @@ export class ViewerService {
rows.push({
id: "system:recent-series",
title: "Recent toegevoegde series",
name: "Recent toegevoegde series",
kind: "SERIES",
slug: "recent-series",
items: recentSeries,
@ -374,6 +378,7 @@ export class ViewerService {
rows.push({
id: "system:4k-movies",
title: "4K films",
name: "4K films",
kind: "MOVIE",
slug: "4k-movies",
items: fourK,
@ -383,9 +388,11 @@ export class ViewerService {
for (const shelf of ordered) {
const items = await this.shelfItems(shelf.id, shelf.kind, 48);
if (items.length) {
const homepageLabel = shelf.homepageName?.trim();
rows.push({
id: shelf.id,
title: shelf.name,
title: homepageLabel || shelf.name,
name: shelf.name,
kind: shelf.kind,
slug: shelf.slug,
items,