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 { interface ShelfRow {
id: string; id: string;
name: string; name: string;
homepageName?: string | null;
slug: string; slug: string;
kind: "MOVIE" | "SERIES"; kind: "MOVIE" | "SERIES";
sortOrder: number; sortOrder: number;
@ -230,6 +231,21 @@ export default function SettingsPage() {
loadShelves(); 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() { async function saveConfig() {
setCfgBusy(true); setCfgBusy(true);
setCfgMsg(null); setCfgMsg(null);
@ -365,6 +381,7 @@ export default function SettingsPage() {
<h2 className="section-title">Bibliotheek-planken</h2> <h2 className="section-title">Bibliotheek-planken</h2>
<p className="muted"> <p className="muted">
Namen zoals Kids Films of NL Series. Op Nodes koppel je scanpaden aan een plank. 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> </p>
</div> </div>
@ -396,6 +413,7 @@ export default function SettingsPage() {
<thead> <thead>
<tr> <tr>
<th>Naam</th> <th>Naam</th>
<th>Homepage naam</th>
<th>Type</th> <th>Type</th>
<th>Pad(en)</th> <th>Pad(en)</th>
<th>Bestanden</th> <th>Bestanden</th>
@ -406,6 +424,24 @@ export default function SettingsPage() {
{shelves.map((s) => ( {shelves.map((s) => (
<tr key={s.id}> <tr key={s.id}>
<td>{s.name}</td> <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>{s.kind === "MOVIE" ? "Films" : "Series"}</td>
<td className="path-cell"> <td className="path-cell">
{s.paths?.length ? ( {s.paths?.length ? (
@ -439,7 +475,7 @@ export default function SettingsPage() {
))} ))}
{shelves.length === 0 && ( {shelves.length === 0 && (
<tr> <tr>
<td colSpan={5} className="muted" style={{ textAlign: "center" }}> <td colSpan={6} className="muted" style={{ textAlign: "center" }}>
Laden… Laden…
</td> </td>
</tr> </tr>
@ -461,6 +497,20 @@ export default function SettingsPage() {
<span>{s.mediaFileCount} bestanden</span> <span>{s.mediaFileCount} bestanden</span>
<span>{s.scanRootCount} pad(en)</span> <span>{s.scanRootCount} pad(en)</span>
</div> </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 ? ( {s.paths?.length ? (
<div className="path-block"> <div className="path-block">
{s.paths.map((p) => ( {s.paths.map((p) => (

View file

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

View file

@ -312,10 +312,14 @@ data class HomePayload(
data class ShelfRow( data class ShelfRow(
val id: String, val id: String,
val title: String, val title: String,
val name: String = title,
val kind: String, val kind: String,
val slug: String = "", val slug: String = "",
val items: List<MediaItem>, 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( data class MediaItem(
val type: String, val type: String,
@ -409,6 +413,7 @@ private fun JSONArray?.toShelves(): List<ShelfRow> {
ShelfRow( ShelfRow(
id = o.optString("id"), id = o.optString("id"),
title = o.optString("title"), title = o.optString("title"),
name = o.optString("name").ifBlank { o.optString("title") },
kind = o.optString("kind"), kind = o.optString("kind"),
slug = o.optString("slug").ifBlank { o.optString("id") }, slug = o.optString("slug").ifBlank { o.optString("id") },
items = o.optJSONArray("items").toItems(), items = o.optJSONArray("items").toItems(),

View file

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

View file

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

View file

@ -260,9 +260,9 @@ fun SearchScreen(
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 110.dp), columns = GridCells.Adaptive(minSize = 110.dp),
state = gridState, state = gridState,
contentPadding = PaddingValues(bottom = 24.dp, end = 8.dp), contentPadding = PaddingValues(start = 8.dp, end = 8.dp, top = 12.dp, bottom = 24.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(14.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { ) {
items(results, key = { "${it.type}:${it.id}" }) { item -> items(results, key = { "${it.type}:${it.id}" }) { item ->
@ -270,6 +270,7 @@ fun SearchScreen(
item = item, item = item,
width = 110.dp, width = 110.dp,
height = 165.dp, height = 165.dp,
modifier = Modifier.padding(6.dp),
onClick = { onClick = {
if (item.type == "movie") onOpenMovie(item.id) else onOpenSeries(item.id) 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)), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.14f), scale = ClickableSurfaceDefaults.scale(focusedScale = 1.10f),
border = ClickableSurfaceDefaults.border( border = ClickableSurfaceDefaults.border(
focusedBorder = Border( focusedBorder = Border(
border = androidx.compose.foundation.BorderStroke(3.dp, Color.White), 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

@ -278,9 +278,10 @@ model Node {
/** Named Stremio catalog / shelf shared across all nodes (e.g. Kids Films). */ /** Named Stremio catalog / shelf shared across all nodes (e.g. Kids Films). */
model LibraryShelf { model LibraryShelf {
id String @id @default(uuid()) id String @id @default(uuid())
name String name String
slug String @unique homepageName String? @map("homepage_name")
slug String @unique
kind LibraryShelfKind kind LibraryShelfKind
sortOrder Int @default(0) @map("sort_order") sortOrder Int @default(0) @map("sort_order")
enabled Boolean @default(true) enabled Boolean @default(true)

View file

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

View file

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