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:
parent
7c808e9167
commit
159cd48692
11 changed files with 93 additions and 19 deletions
|
|
@ -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 "Homepage naam" 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) => (
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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() &&
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue