Add viewer accounts, device linking, and Android TV client foundation.
This commit is contained in:
parent
d233878e6d
commit
0b107515e4
33 changed files with 3173 additions and 0 deletions
142
apps/admin-ui/src/app/link/page.tsx
Normal file
142
apps/admin-ui/src/app/link/page.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export default function LinkDevicePage() {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [ok, setOk] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const q = new URLSearchParams(window.location.search);
|
||||||
|
const c = q.get("code");
|
||||||
|
if (c) setCode(c.toUpperCase());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function onSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setErr(null);
|
||||||
|
setOk(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/v1/link/claim", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password, code }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
setErr(data.error?.message ?? "Koppelen mislukt");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOk(`Apparaat “${data.deviceName ?? "TV"}” is gekoppeld. Je kunt dit venster sluiten.`);
|
||||||
|
setPassword("");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="link-page">
|
||||||
|
<div className="link-card">
|
||||||
|
<p className="page-kicker">Media Cluster</p>
|
||||||
|
<h1>Apparaat koppelen</h1>
|
||||||
|
<p className="muted">
|
||||||
|
Log in met je kijker-account en voer de code van je TV of telefoon in.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={onSubmit} className="link-form">
|
||||||
|
<label>
|
||||||
|
E-mail
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
autoComplete="username"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Wachtwoord
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Code
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="text"
|
||||||
|
autoCapitalize="characters"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
placeholder="ABCD-EFGH"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{err && <p className="form-error">{err}</p>}
|
||||||
|
{ok && <p className="form-ok">{ok}</p>}
|
||||||
|
<button type="submit" className="btn-primary" disabled={busy}>
|
||||||
|
{busy ? "Bezig…" : "Koppelen"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<style jsx>{`
|
||||||
|
.link-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(56, 120, 255, 0.25), transparent),
|
||||||
|
#04060a;
|
||||||
|
}
|
||||||
|
.link-card {
|
||||||
|
width: min(420px, 100%);
|
||||||
|
background: rgba(12, 16, 24, 0.92);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1.75rem;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0.25rem 0 0.5rem;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
.link-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.9rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
padding: 0.7rem 0.8rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background: #0a0e16;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.form-error {
|
||||||
|
color: #ff8b8b;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.form-ok {
|
||||||
|
color: #8dffb0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
225
apps/admin-ui/src/app/viewers/page.tsx
Normal file
225
apps/admin-ui/src/app/viewers/page.tsx
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FormEvent, useCallback, useEffect, useState } from "react";
|
||||||
|
import { Nav, useAuth } from "@/components/Nav";
|
||||||
|
|
||||||
|
type Device = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
platform: string;
|
||||||
|
lastSeenAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Viewer = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
appAccess: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
deviceCount: number;
|
||||||
|
devices: Device[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ViewersPage() {
|
||||||
|
useAuth();
|
||||||
|
const [viewers, setViewers] = useState<Viewer[]>([]);
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
fetch("/api/v1/admin/viewers", { credentials: "include" })
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => setViewers(d.viewers ?? []))
|
||||||
|
.catch(() => setErr("Kon kijkers niet laden"));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
async function createViewer(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setMsg(null);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/v1/admin/viewers", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password, name: name || undefined, appAccess: true }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
setErr(data.error?.message ?? "Aanmaken mislukt");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMsg(`Kijker ${data.viewer.email} aangemaakt`);
|
||||||
|
setEmail("");
|
||||||
|
setPassword("");
|
||||||
|
setName("");
|
||||||
|
load();
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggle(v: Viewer, field: "enabled" | "appAccess") {
|
||||||
|
await fetch(`/api/v1/admin/viewers/${v.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ [field]: !v[field] }),
|
||||||
|
});
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeDevice(deviceId: string) {
|
||||||
|
if (!confirm("Apparaat intrekken?")) return;
|
||||||
|
await fetch(`/api/v1/admin/viewers/devices/${deviceId}/revoke`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Nav />
|
||||||
|
<main className="page">
|
||||||
|
<header className="page-header">
|
||||||
|
<div>
|
||||||
|
<p className="page-kicker">Mensen</p>
|
||||||
|
<h1>Kijkers</h1>
|
||||||
|
<p className="muted">
|
||||||
|
Accounts voor de Android TV / phone-app. Gebruikers koppelen zelf via{" "}
|
||||||
|
<a href="/link">/link</a> met een code.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="card-block">
|
||||||
|
<h2>Nieuwe kijker</h2>
|
||||||
|
<form className="form-grid" onSubmit={createViewer}>
|
||||||
|
<label>
|
||||||
|
Naam
|
||||||
|
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Jos" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
E-mail
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Wachtwoord
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="btn-primary" disabled={busy}>
|
||||||
|
Aanmaken
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{msg && <p className="form-ok">{msg}</p>}
|
||||||
|
{err && <p className="form-error">{err}</p>}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card-block">
|
||||||
|
<h2>Overzicht</h2>
|
||||||
|
{viewers.length === 0 ? (
|
||||||
|
<p className="muted">Nog geen kijkers.</p>
|
||||||
|
) : (
|
||||||
|
<div className="viewer-list">
|
||||||
|
{viewers.map((v) => (
|
||||||
|
<article key={v.id} className="viewer-row">
|
||||||
|
<div>
|
||||||
|
<strong>{v.name || v.email}</strong>
|
||||||
|
<div className="muted">{v.email}</div>
|
||||||
|
<div className="muted">
|
||||||
|
{v.enabled ? "Actief" : "Uit"} · app {v.appAccess ? "aan" : "uit"} ·{" "}
|
||||||
|
{v.deviceCount} apparaat(en)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="viewer-actions">
|
||||||
|
<button type="button" onClick={() => toggle(v, "enabled")}>
|
||||||
|
{v.enabled ? "Uitschakelen" : "Activeren"}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => toggle(v, "appAccess")}>
|
||||||
|
{v.appAccess ? "App uit" : "App aan"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{v.devices.length > 0 && (
|
||||||
|
<ul className="device-list">
|
||||||
|
{v.devices.map((d) => (
|
||||||
|
<li key={d.id}>
|
||||||
|
<span>
|
||||||
|
{d.name} <span className="muted">({d.platform})</span>
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={() => revokeDevice(d.id)}>
|
||||||
|
Intrekken
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<style jsx>{`
|
||||||
|
.viewer-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.viewer-row {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.viewer-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.device-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.device-list li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.form-ok {
|
||||||
|
color: #8dffb0;
|
||||||
|
}
|
||||||
|
.form-error {
|
||||||
|
color: #ff8b8b;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,7 @@ const desktopLinks = [
|
||||||
{ href: "/downloads", label: "Downloads" },
|
{ href: "/downloads", label: "Downloads" },
|
||||||
{ href: "/streams", label: "Streams" },
|
{ href: "/streams", label: "Streams" },
|
||||||
{ href: "/accounts", label: "Accounts" },
|
{ href: "/accounts", label: "Accounts" },
|
||||||
|
{ href: "/viewers", label: "Kijkers" },
|
||||||
{ href: "/settings", label: "Instellingen" },
|
{ href: "/settings", label: "Instellingen" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
12
apps/android-tv/.gitignore
vendored
Normal file
12
apps/android-tv/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
/.gradle/
|
||||||
|
/.idea/
|
||||||
|
/.kotlin/
|
||||||
|
**/build/
|
||||||
|
local.properties
|
||||||
|
*.iml
|
||||||
|
.DS_Store
|
||||||
|
captures/
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
|
hs_err_pid*
|
||||||
|
replay_pid*
|
||||||
30
apps/android-tv/README.md
Normal file
30
apps/android-tv/README.md
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
# Media Cluster — Android TV / phone client
|
||||||
|
|
||||||
|
Native client for the private Media Cluster (alternative to Stremio).
|
||||||
|
|
||||||
|
## Features (v0.1)
|
||||||
|
|
||||||
|
- Device linking via short code + `/link` page
|
||||||
|
- Home with **Verder kijken** + configurable shelves
|
||||||
|
- Detail → ExoPlayer direct play (no transcoding)
|
||||||
|
- Progress sync to master
|
||||||
|
- Shelf visibility / order per viewer
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Open `apps/android-tv` in Android Studio.
|
||||||
|
2. Set `DEFAULT_API_BASE` in `app/build.gradle.kts` if needed (default `https://master.vonas.nl`).
|
||||||
|
3. On master, set `CLIENT_LINK_BASE_URL` to your admin UI origin (e.g. `https://admin.vonas.nl`) so the TV shows the right link URL.
|
||||||
|
4. Run migration `20260831010000_viewer_client`.
|
||||||
|
5. Admin → **Kijkers** → create a viewer account.
|
||||||
|
6. Install APK on TV → enter code at `/link`.
|
||||||
|
|
||||||
|
## API used
|
||||||
|
|
||||||
|
- `POST /api/v1/client/device/code`
|
||||||
|
- `GET /api/v1/client/device/poll`
|
||||||
|
- `POST /api/v1/link/claim`
|
||||||
|
- `GET /api/v1/client/home`
|
||||||
|
- `POST /api/v1/client/play`
|
||||||
|
- `POST /api/v1/client/progress`
|
||||||
|
- `GET|PUT /api/v1/client/shelves/prefs`
|
||||||
66
apps/android-tv/app/build.gradle.kts
Normal file
66
apps/android-tv/app/build.gradle.kts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
id("org.jetbrains.kotlin.plugin.compose")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "nl.vonas.mediacluster.tv"
|
||||||
|
compileSdk = 35
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "nl.vonas.mediacluster.tv"
|
||||||
|
minSdk = 24
|
||||||
|
targetSdk = 35
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "0.1.0"
|
||||||
|
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
|
||||||
|
implementation(composeBom)
|
||||||
|
implementation("androidx.compose.ui:ui")
|
||||||
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
|
implementation("androidx.compose.material3:material3")
|
||||||
|
implementation("androidx.activity:activity-compose:1.9.3")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
||||||
|
implementation("androidx.navigation:navigation-compose:2.8.5")
|
||||||
|
implementation("androidx.tv:tv-foundation:1.0.0-alpha11")
|
||||||
|
implementation("androidx.tv:tv-material:1.0.0")
|
||||||
|
|
||||||
|
implementation("androidx.media3:media3-exoplayer:1.5.1")
|
||||||
|
implementation("androidx.media3:media3-ui:1.5.1")
|
||||||
|
implementation("androidx.media3:media3-datasource-okhttp:1.5.1")
|
||||||
|
|
||||||
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||||
|
implementation("androidx.datastore:datastore-preferences:1.1.1")
|
||||||
|
implementation("io.coil-kt:coil-compose:2.7.0")
|
||||||
|
|
||||||
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
|
}
|
||||||
1
apps/android-tv/app/proguard-rules.pro
vendored
Normal file
1
apps/android-tv/app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# Add project specific ProGuard rules here.
|
||||||
35
apps/android-tv/app/src/main/AndroidManifest.xml
Normal file
35
apps/android-tv/app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.software.leanback"
|
||||||
|
android:required="false" />
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.hardware.touchscreen"
|
||||||
|
android:required="false" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".McApp"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:banner="@drawable/app_banner"
|
||||||
|
android:icon="@drawable/ic_launcher_fg"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.MediaClusterTv"
|
||||||
|
android:usesCleartextTraffic="false">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:banner="@drawable/app_banner"
|
||||||
|
android:configChanges="keyboard|keyboardHidden|navigation"
|
||||||
|
android:screenOrientation="landscape">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
package nl.vonas.mediacluster.tv
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import nl.vonas.mediacluster.tv.ui.McNav
|
||||||
|
import nl.vonas.mediacluster.tv.ui.theme.MediaClusterTheme
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
setContent {
|
||||||
|
MediaClusterTheme {
|
||||||
|
McNav()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
package nl.vonas.mediacluster.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
|
||||||
|
class McApp : Application()
|
||||||
|
|
@ -0,0 +1,278 @@
|
||||||
|
package nl.vonas.mediacluster.tv.data
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class ApiClient(
|
||||||
|
private var baseUrl: String,
|
||||||
|
private var token: String? = null,
|
||||||
|
) {
|
||||||
|
private val http = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(20, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val jsonMedia = "application/json; charset=utf-8".toMediaType()
|
||||||
|
|
||||||
|
fun configure(baseUrl: String, token: String?) {
|
||||||
|
this.baseUrl = baseUrl.trimEnd('/')
|
||||||
|
this.token = token
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createDeviceCode(deviceName: String): DeviceCodeResponse = withContext(Dispatchers.IO) {
|
||||||
|
val body = JSONObject()
|
||||||
|
.put("deviceName", deviceName)
|
||||||
|
.put("platform", "android_tv")
|
||||||
|
.toString()
|
||||||
|
.toRequestBody(jsonMedia)
|
||||||
|
val req = Request.Builder()
|
||||||
|
.url("$baseUrl/api/v1/client/device/code")
|
||||||
|
.post(body)
|
||||||
|
.build()
|
||||||
|
parseObject(execute(req)) { o ->
|
||||||
|
DeviceCodeResponse(
|
||||||
|
code = o.getString("code"),
|
||||||
|
expiresAt = o.optString("expiresAt"),
|
||||||
|
pollIntervalSeconds = o.optInt("pollIntervalSeconds", 2),
|
||||||
|
linkUrl = o.optString("linkUrl"),
|
||||||
|
qrPayload = o.optString("qrPayload"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun pollDeviceCode(code: String): PollResult = withContext(Dispatchers.IO) {
|
||||||
|
val req = Request.Builder()
|
||||||
|
.url("$baseUrl/api/v1/client/device/poll?code=${java.net.URLEncoder.encode(code, "UTF-8")}")
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
parseObject(execute(req)) { o ->
|
||||||
|
when (o.optString("status")) {
|
||||||
|
"linked" -> PollResult.Linked(
|
||||||
|
refreshToken = o.getString("refreshToken"),
|
||||||
|
viewerName = o.optJSONObject("viewer")?.optString("name")
|
||||||
|
?: o.optJSONObject("viewer")?.optString("email")
|
||||||
|
?: "Kijker",
|
||||||
|
)
|
||||||
|
"expired" -> PollResult.Expired
|
||||||
|
"already_linked" -> PollResult.AlreadyLinked
|
||||||
|
else -> PollResult.Pending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun home(): HomePayload = withContext(Dispatchers.IO) {
|
||||||
|
val req = authed("$baseUrl/api/v1/client/home").get().build()
|
||||||
|
parseObject(execute(req)) { o ->
|
||||||
|
HomePayload(
|
||||||
|
continueWatching = o.optJSONArray("continueWatching").toItems(),
|
||||||
|
shelves = o.optJSONArray("shelves").toShelves(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun movie(id: String): JSONObject = withContext(Dispatchers.IO) {
|
||||||
|
val req = authed("$baseUrl/api/v1/client/movies/$id").get().build()
|
||||||
|
JSONObject(execute(req))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun series(id: String): JSONObject = withContext(Dispatchers.IO) {
|
||||||
|
val req = authed("$baseUrl/api/v1/client/series/$id").get().build()
|
||||||
|
JSONObject(execute(req))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun play(mediaFileId: String): PlayResult = withContext(Dispatchers.IO) {
|
||||||
|
val body = JSONObject().put("mediaFileId", mediaFileId).toString().toRequestBody(jsonMedia)
|
||||||
|
val req = authed("$baseUrl/api/v1/client/play").post(body).build()
|
||||||
|
parseObject(execute(req)) { o ->
|
||||||
|
PlayResult(
|
||||||
|
streamUrl = o.getString("streamUrl"),
|
||||||
|
sessionId = o.optString("sessionId"),
|
||||||
|
mediaFileId = o.optString("mediaFileId"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun postProgress(
|
||||||
|
mediaType: String,
|
||||||
|
movieId: String? = null,
|
||||||
|
episodeId: String? = null,
|
||||||
|
seriesId: String? = null,
|
||||||
|
positionMs: Long,
|
||||||
|
durationMs: Long,
|
||||||
|
) = withContext(Dispatchers.IO) {
|
||||||
|
val o = JSONObject()
|
||||||
|
.put("mediaType", mediaType)
|
||||||
|
.put("positionMs", positionMs)
|
||||||
|
.put("durationMs", durationMs)
|
||||||
|
if (movieId != null) o.put("movieId", movieId)
|
||||||
|
if (episodeId != null) o.put("episodeId", episodeId)
|
||||||
|
if (seriesId != null) o.put("seriesId", seriesId)
|
||||||
|
val req = authed("$baseUrl/api/v1/client/progress")
|
||||||
|
.post(o.toString().toRequestBody(jsonMedia))
|
||||||
|
.build()
|
||||||
|
execute(req)
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getShelfPrefs(): List<ShelfPref> = withContext(Dispatchers.IO) {
|
||||||
|
val req = authed("$baseUrl/api/v1/client/shelves/prefs").get().build()
|
||||||
|
parseObject(execute(req)) { o ->
|
||||||
|
val arr = o.optJSONArray("shelves") ?: JSONArray()
|
||||||
|
buildList {
|
||||||
|
for (i in 0 until arr.length()) {
|
||||||
|
val s = arr.getJSONObject(i)
|
||||||
|
add(
|
||||||
|
ShelfPref(
|
||||||
|
shelfId = s.getString("shelfId"),
|
||||||
|
name = s.getString("name"),
|
||||||
|
visible = s.optBoolean("visible", true),
|
||||||
|
sortOrder = s.optInt("sortOrder", i),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveShelfPrefs(prefs: List<ShelfPref>) = withContext(Dispatchers.IO) {
|
||||||
|
val arr = JSONArray()
|
||||||
|
prefs.forEachIndexed { index, p ->
|
||||||
|
arr.put(
|
||||||
|
JSONObject()
|
||||||
|
.put("shelfId", p.shelfId)
|
||||||
|
.put("visible", p.visible)
|
||||||
|
.put("sortOrder", p.sortOrder.takeIf { it >= 0 } ?: index)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val body = JSONObject().put("shelves", arr).toString().toRequestBody(jsonMedia)
|
||||||
|
val req = authed("$baseUrl/api/v1/client/shelves/prefs").put(body).build()
|
||||||
|
execute(req)
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun authed(url: String): Request.Builder {
|
||||||
|
val b = Request.Builder().url(url)
|
||||||
|
val t = token
|
||||||
|
if (!t.isNullOrBlank()) b.header("Authorization", "Bearer $t")
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun execute(req: Request): String {
|
||||||
|
http.newCall(req).execute().use { res ->
|
||||||
|
val text = res.body?.string().orEmpty()
|
||||||
|
if (!res.isSuccessful) {
|
||||||
|
val msg = runCatching {
|
||||||
|
JSONObject(text).optJSONObject("error")?.optString("message")
|
||||||
|
}.getOrNull() ?: "HTTP ${res.code}"
|
||||||
|
throw ApiException(msg)
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> parseObject(text: String, block: (JSONObject) -> T): T = block(JSONObject(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
class ApiException(message: String) : Exception(message)
|
||||||
|
|
||||||
|
data class DeviceCodeResponse(
|
||||||
|
val code: String,
|
||||||
|
val expiresAt: String,
|
||||||
|
val pollIntervalSeconds: Int,
|
||||||
|
val linkUrl: String,
|
||||||
|
val qrPayload: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed class PollResult {
|
||||||
|
data object Pending : PollResult()
|
||||||
|
data object Expired : PollResult()
|
||||||
|
data object AlreadyLinked : PollResult()
|
||||||
|
data class Linked(val refreshToken: String, val viewerName: String) : PollResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
data class HomePayload(
|
||||||
|
val continueWatching: List<MediaItem>,
|
||||||
|
val shelves: List<ShelfRow>,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ShelfRow(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val kind: String,
|
||||||
|
val items: List<MediaItem>,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MediaItem(
|
||||||
|
val type: String,
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val subtitle: String? = null,
|
||||||
|
val year: Int? = null,
|
||||||
|
val posterUrl: String? = null,
|
||||||
|
val backdropUrl: String? = null,
|
||||||
|
val mediaFileId: String? = null,
|
||||||
|
val positionMs: Long = 0,
|
||||||
|
val durationMs: Long = 0,
|
||||||
|
val progressId: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PlayResult(
|
||||||
|
val streamUrl: String,
|
||||||
|
val sessionId: String,
|
||||||
|
val mediaFileId: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ShelfPref(
|
||||||
|
val shelfId: String,
|
||||||
|
val name: String,
|
||||||
|
val visible: Boolean,
|
||||||
|
val sortOrder: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun JSONArray?.toItems(): List<MediaItem> {
|
||||||
|
if (this == null) return emptyList()
|
||||||
|
return buildList {
|
||||||
|
for (i in 0 until length()) {
|
||||||
|
val o = getJSONObject(i)
|
||||||
|
add(
|
||||||
|
MediaItem(
|
||||||
|
type = o.optString("type"),
|
||||||
|
id = o.optString("id"),
|
||||||
|
title = o.optString("title"),
|
||||||
|
subtitle = o.optString("subtitle").ifBlank { null },
|
||||||
|
year = o.optInt("year").takeIf { o.has("year") && !o.isNull("year") },
|
||||||
|
posterUrl = o.optString("posterUrl").ifBlank { null },
|
||||||
|
backdropUrl = o.optString("backdropUrl").ifBlank { null },
|
||||||
|
mediaFileId = o.optString("mediaFileId").ifBlank { null },
|
||||||
|
positionMs = o.optLong("positionMs"),
|
||||||
|
durationMs = o.optLong("durationMs"),
|
||||||
|
progressId = o.optString("progressId").ifBlank { null },
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JSONArray?.toShelves(): List<ShelfRow> {
|
||||||
|
if (this == null) return emptyList()
|
||||||
|
return buildList {
|
||||||
|
for (i in 0 until length()) {
|
||||||
|
val o = getJSONObject(i)
|
||||||
|
add(
|
||||||
|
ShelfRow(
|
||||||
|
id = o.optString("id"),
|
||||||
|
title = o.optString("title"),
|
||||||
|
kind = o.optString("kind"),
|
||||||
|
items = o.optJSONArray("items").toItems(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
package nl.vonas.mediacluster.tv.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import nl.vonas.mediacluster.tv.BuildConfig
|
||||||
|
|
||||||
|
private val Context.dataStore by preferencesDataStore("mc_tv")
|
||||||
|
|
||||||
|
class SessionStore(private val context: Context) {
|
||||||
|
private val tokenKey = stringPreferencesKey("refresh_token")
|
||||||
|
private val apiKey = stringPreferencesKey("api_base")
|
||||||
|
|
||||||
|
val refreshToken: Flow<String?> = context.dataStore.data.map { it[tokenKey] }
|
||||||
|
val apiBase: Flow<String> = context.dataStore.data.map {
|
||||||
|
it[apiKey] ?: BuildConfig.DEFAULT_API_BASE
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setToken(token: String?) {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
if (token.isNullOrBlank()) prefs.remove(tokenKey) else prefs[tokenKey] = token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setApiBase(url: String) {
|
||||||
|
context.dataStore.edit { it[apiKey] = url.trim().trimEnd('/') }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,692 @@
|
||||||
|
package nl.vonas.mediacluster.tv.ui
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.media3.common.MediaItem as ExoMediaItem
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.ui.PlayerView
|
||||||
|
import androidx.navigation.NavType
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import androidx.navigation.navArgument
|
||||||
|
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||||
|
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Surface
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import androidx.tv.material3.Switch
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import nl.vonas.mediacluster.tv.data.ApiClient
|
||||||
|
import nl.vonas.mediacluster.tv.data.ApiException
|
||||||
|
import nl.vonas.mediacluster.tv.data.HomePayload
|
||||||
|
import nl.vonas.mediacluster.tv.data.MediaItem
|
||||||
|
import nl.vonas.mediacluster.tv.data.PollResult
|
||||||
|
import nl.vonas.mediacluster.tv.data.SessionStore
|
||||||
|
import nl.vonas.mediacluster.tv.data.ShelfPref
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun McNav() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val store = remember { SessionStore(context) }
|
||||||
|
val api = remember { ApiClient(baseUrl = "") }
|
||||||
|
val nav = rememberNavController()
|
||||||
|
var ready by remember { mutableStateOf(false) }
|
||||||
|
var authed by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
val base = store.apiBase.first()
|
||||||
|
val token = store.refreshToken.first()
|
||||||
|
api.configure(base, token)
|
||||||
|
authed = !token.isNullOrBlank()
|
||||||
|
ready = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ready) {
|
||||||
|
Box(Modifier.fillMaxSize().background(Color(0xFF04060A)), contentAlignment = Alignment.Center) {
|
||||||
|
Text("Media Cluster", color = Color.White, fontSize = 28.sp)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
NavHost(navController = nav, startDestination = if (authed) "home" else "link") {
|
||||||
|
composable("link") {
|
||||||
|
LinkScreen(
|
||||||
|
api = api,
|
||||||
|
store = store,
|
||||||
|
onLinked = {
|
||||||
|
authed = true
|
||||||
|
nav.navigate("home") {
|
||||||
|
popUpTo("link") { inclusive = true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable("home") {
|
||||||
|
HomeScreen(
|
||||||
|
api = api,
|
||||||
|
onPlayMovie = { id -> nav.navigate("movie/$id") },
|
||||||
|
onPlaySeries = { id -> nav.navigate("series/$id") },
|
||||||
|
onPlayDirect = { mediaFileId, movieId, episodeId, seriesId, type, startMs ->
|
||||||
|
val m = movieId?.ifBlank { null } ?: "-"
|
||||||
|
val e = episodeId?.ifBlank { null } ?: "-"
|
||||||
|
val s = seriesId?.ifBlank { null } ?: "-"
|
||||||
|
nav.navigate("player/$mediaFileId/$type/$m/$e/$s/$startMs")
|
||||||
|
},
|
||||||
|
onSettings = { nav.navigate("settings") },
|
||||||
|
onLogout = {
|
||||||
|
kotlinx.coroutines.MainScope().launch {
|
||||||
|
store.setToken(null)
|
||||||
|
api.configure(store.apiBase.first(), null)
|
||||||
|
authed = false
|
||||||
|
nav.navigate("link") {
|
||||||
|
popUpTo(0) { inclusive = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
"movie/{id}",
|
||||||
|
arguments = listOf(navArgument("id") { type = NavType.StringType }),
|
||||||
|
) { entry ->
|
||||||
|
val id = entry.arguments?.getString("id") ?: return@composable
|
||||||
|
DetailScreen(
|
||||||
|
load = { api.movie(id) },
|
||||||
|
onPlay = { mediaFileId ->
|
||||||
|
nav.navigate("player/$mediaFileId/MOVIE/$id/-/-/0")
|
||||||
|
},
|
||||||
|
onBack = { nav.popBackStack() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
"series/{id}",
|
||||||
|
arguments = listOf(navArgument("id") { type = NavType.StringType }),
|
||||||
|
) { entry ->
|
||||||
|
val id = entry.arguments?.getString("id") ?: return@composable
|
||||||
|
SeriesScreen(
|
||||||
|
load = { api.series(id) },
|
||||||
|
onPlay = { mediaFileId, episodeId ->
|
||||||
|
nav.navigate("player/$mediaFileId/EPISODE/-/$episodeId/$id/0")
|
||||||
|
},
|
||||||
|
onBack = { nav.popBackStack() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
"player/{mediaFileId}/{type}/{movieId}/{episodeId}/{seriesId}/{startMs}",
|
||||||
|
arguments = listOf(
|
||||||
|
navArgument("mediaFileId") { type = NavType.StringType },
|
||||||
|
navArgument("type") { type = NavType.StringType },
|
||||||
|
navArgument("movieId") { type = NavType.StringType },
|
||||||
|
navArgument("episodeId") { type = NavType.StringType },
|
||||||
|
navArgument("seriesId") { type = NavType.StringType },
|
||||||
|
navArgument("startMs") { type = NavType.LongType },
|
||||||
|
),
|
||||||
|
) { entry ->
|
||||||
|
fun dash(v: String?) = v?.takeIf { it.isNotBlank() && it != "-" }
|
||||||
|
PlayerScreen(
|
||||||
|
api = api,
|
||||||
|
mediaFileId = entry.arguments?.getString("mediaFileId").orEmpty(),
|
||||||
|
mediaType = entry.arguments?.getString("type") ?: "MOVIE",
|
||||||
|
movieId = dash(entry.arguments?.getString("movieId")),
|
||||||
|
episodeId = dash(entry.arguments?.getString("episodeId")),
|
||||||
|
seriesId = dash(entry.arguments?.getString("seriesId")),
|
||||||
|
startMs = entry.arguments?.getLong("startMs") ?: 0L,
|
||||||
|
onBack = { nav.popBackStack() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable("settings") {
|
||||||
|
SettingsScreen(
|
||||||
|
api = api,
|
||||||
|
store = store,
|
||||||
|
onBack = { nav.popBackStack() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun LinkScreen(
|
||||||
|
api: ApiClient,
|
||||||
|
store: SessionStore,
|
||||||
|
onLinked: () -> Unit,
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var code by remember { mutableStateOf<String?>(null) }
|
||||||
|
var linkUrl by remember { mutableStateOf("") }
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
var status by remember { mutableStateOf("Code ophalen…") }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
val base = store.apiBase.first()
|
||||||
|
api.configure(base, null)
|
||||||
|
try {
|
||||||
|
val deviceName = "${Build.MANUFACTURER} ${Build.MODEL}".trim()
|
||||||
|
val created = api.createDeviceCode(deviceName.ifBlank { "Android TV" })
|
||||||
|
code = created.code
|
||||||
|
linkUrl = created.linkUrl
|
||||||
|
status = "Wachten op koppeling…"
|
||||||
|
while (true) {
|
||||||
|
delay((created.pollIntervalSeconds.coerceAtLeast(2)) * 1000L)
|
||||||
|
when (val poll = api.pollDeviceCode(created.code)) {
|
||||||
|
is PollResult.Linked -> {
|
||||||
|
store.setToken(poll.refreshToken)
|
||||||
|
api.configure(base, poll.refreshToken)
|
||||||
|
status = "Welkom ${poll.viewerName}"
|
||||||
|
delay(600)
|
||||||
|
onLinked()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
PollResult.Expired -> {
|
||||||
|
status = "Code verlopen — herstart de app"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
PollResult.AlreadyLinked -> {
|
||||||
|
status = "Al gekoppeld — herstart de app"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
PollResult.Pending -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
error = e.message ?: "Kon niet verbinden met master"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color(0xFF04060A))
|
||||||
|
.padding(48.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
Text("Media Cluster", fontSize = 40.sp, fontWeight = FontWeight.Bold, color = Color.White)
|
||||||
|
Text("Koppel dit apparaat", fontSize = 22.sp, color = Color(0xFFB8C0D0))
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(code ?: "····-····", fontSize = 56.sp, fontWeight = FontWeight.Bold, color = Color(0xFF6EA8FF))
|
||||||
|
if (linkUrl.isNotBlank()) {
|
||||||
|
Text("Ga naar $linkUrl", fontSize = 18.sp, color = Color.White)
|
||||||
|
}
|
||||||
|
Text(status, fontSize = 16.sp, color = Color(0xFF9AA3B5))
|
||||||
|
if (error != null) {
|
||||||
|
Text(error!!, color = Color(0xFFFF8B8B), fontSize = 16.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun HomeScreen(
|
||||||
|
api: ApiClient,
|
||||||
|
onPlayMovie: (String) -> Unit,
|
||||||
|
onPlaySeries: (String) -> Unit,
|
||||||
|
onPlayDirect: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long) -> Unit,
|
||||||
|
onSettings: () -> Unit,
|
||||||
|
onLogout: () -> Unit,
|
||||||
|
) {
|
||||||
|
var home by remember { mutableStateOf<HomePayload?>(null) }
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
try {
|
||||||
|
home = api.home()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
error = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color(0xFF04060A)),
|
||||||
|
contentPadding = PaddingValues(32.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(28.dp),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text("Media Cluster", fontSize = 32.sp, fontWeight = FontWeight.Bold, color = Color.White)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
TvButton("Planken", onSettings)
|
||||||
|
TvButton("Uitloggen", onLogout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (error != null) {
|
||||||
|
item { Text(error!!, color = Color(0xFFFF8B8B)) }
|
||||||
|
}
|
||||||
|
val continueItems = home?.continueWatching.orEmpty()
|
||||||
|
if (continueItems.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
ShelfSection("Verder kijken", continueItems) { item ->
|
||||||
|
val fileId = item.mediaFileId
|
||||||
|
if (!fileId.isNullOrBlank()) {
|
||||||
|
onPlayDirect(
|
||||||
|
fileId,
|
||||||
|
item.id.takeIf { item.type == "movie" },
|
||||||
|
item.id.takeIf { item.type == "episode" },
|
||||||
|
null,
|
||||||
|
if (item.type == "episode") "EPISODE" else "MOVIE",
|
||||||
|
item.positionMs,
|
||||||
|
)
|
||||||
|
} else if (item.type == "series" || item.type == "episode") {
|
||||||
|
// fall through
|
||||||
|
} else {
|
||||||
|
onPlayMovie(item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(home?.shelves.orEmpty()) { shelf ->
|
||||||
|
ShelfSection(shelf.title, shelf.items) { item ->
|
||||||
|
when (item.type) {
|
||||||
|
"movie" -> onPlayMovie(item.id)
|
||||||
|
else -> onPlaySeries(item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun ShelfSection(title: String, items: List<MediaItem>, onClick: (MediaItem) -> Unit) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text(title, fontSize = 22.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||||
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
|
items(items, key = { "${it.type}:${it.id}" }) { item ->
|
||||||
|
PosterCard(item, onClick = { onClick(item) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun PosterCard(item: MediaItem, onClick: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = Modifier.width(140.dp),
|
||||||
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
|
||||||
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor = Color(0xFF121826),
|
||||||
|
focusedContainerColor = Color(0xFF1A2438),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
AsyncImage(
|
||||||
|
model = item.posterUrl,
|
||||||
|
contentDescription = item.title,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(200.dp)
|
||||||
|
.background(Color(0xFF1A2233)),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
item.title,
|
||||||
|
modifier = Modifier.padding(8.dp),
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
maxLines = 2,
|
||||||
|
)
|
||||||
|
if (!item.subtitle.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
item.subtitle,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 0.dp),
|
||||||
|
color = Color(0xFF9AA3B5),
|
||||||
|
fontSize = 11.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun DetailScreen(
|
||||||
|
load: suspend () -> JSONObject,
|
||||||
|
onPlay: (String) -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
var title by remember { mutableStateOf("…") }
|
||||||
|
var overview by remember { mutableStateOf("") }
|
||||||
|
var backdrop by remember { mutableStateOf<String?>(null) }
|
||||||
|
var files by remember { mutableStateOf<List<Pair<String, String>>>(emptyList()) }
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
try {
|
||||||
|
val o = load()
|
||||||
|
title = o.optString("title")
|
||||||
|
overview = o.optString("overview")
|
||||||
|
backdrop = o.optString("backdropUrl").ifBlank { null }
|
||||||
|
val arr = o.optJSONArray("mediaFiles")
|
||||||
|
val list = mutableListOf<Pair<String, String>>()
|
||||||
|
if (arr != null) {
|
||||||
|
for (i in 0 until arr.length()) {
|
||||||
|
val f = arr.getJSONObject(i)
|
||||||
|
val label = listOfNotNull(
|
||||||
|
f.optString("resolution").ifBlank { null },
|
||||||
|
f.optString("hdrType").ifBlank { null },
|
||||||
|
f.optString("releaseName").ifBlank { null },
|
||||||
|
).joinToString(" · ")
|
||||||
|
list += f.getString("id") to label.ifBlank { "Afspelen" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
files = list
|
||||||
|
} catch (e: Exception) {
|
||||||
|
error = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize().background(Color(0xFF04060A))) {
|
||||||
|
if (backdrop != null) {
|
||||||
|
AsyncImage(
|
||||||
|
model = backdrop,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
alpha = 0.35f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(Modifier.padding(40.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
TvButton("Terug", onBack)
|
||||||
|
Text(title, fontSize = 36.sp, fontWeight = FontWeight.Bold, color = Color.White)
|
||||||
|
Text(overview, color = Color(0xFFD0D6E2), fontSize = 16.sp, maxLines = 6)
|
||||||
|
if (error != null) Text(error!!, color = Color(0xFFFF8B8B))
|
||||||
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
items(files) { (id, label) ->
|
||||||
|
TvButton(label.take(48), onClick = { onPlay(id) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun SeriesScreen(
|
||||||
|
load: suspend () -> JSONObject,
|
||||||
|
onPlay: (mediaFileId: String, episodeId: String) -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
var title by remember { mutableStateOf("…") }
|
||||||
|
var episodes by remember { mutableStateOf<List<Triple<String, String, String?>>>(emptyList()) }
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
try {
|
||||||
|
val o = load()
|
||||||
|
title = o.optString("title")
|
||||||
|
val seasons = o.optJSONArray("seasons")
|
||||||
|
val list = mutableListOf<Triple<String, String, String?>>()
|
||||||
|
if (seasons != null) {
|
||||||
|
for (si in 0 until seasons.length()) {
|
||||||
|
val season = seasons.getJSONObject(si)
|
||||||
|
val eps = season.optJSONArray("episodes") ?: continue
|
||||||
|
for (ei in 0 until eps.length()) {
|
||||||
|
val e = eps.getJSONObject(ei)
|
||||||
|
if (!e.optBoolean("available", false)) continue
|
||||||
|
val label = "S${e.optInt("seasonNumber")}E${e.optInt("episodeNumber")} · ${e.optString("title")}"
|
||||||
|
list += Triple(e.getString("id"), label, e.optString("mediaFileId").ifBlank { null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
episodes = list
|
||||||
|
} catch (e: Exception) {
|
||||||
|
error = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
Modifier.fillMaxSize().background(Color(0xFF04060A)).padding(32.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
TvButton("Terug", onBack)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(title, fontSize = 32.sp, fontWeight = FontWeight.Bold, color = Color.White)
|
||||||
|
if (error != null) Text(error!!, color = Color(0xFFFF8B8B))
|
||||||
|
}
|
||||||
|
items(episodes) { (id, label, fileId) ->
|
||||||
|
if (fileId != null) {
|
||||||
|
TvButton(label, onClick = { onPlay(fileId, id) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PlayerScreen(
|
||||||
|
api: ApiClient,
|
||||||
|
mediaFileId: String,
|
||||||
|
mediaType: String,
|
||||||
|
movieId: String?,
|
||||||
|
episodeId: String?,
|
||||||
|
seriesId: String?,
|
||||||
|
startMs: Long,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
val player = remember {
|
||||||
|
ExoPlayer.Builder(context).build().apply {
|
||||||
|
playWhenReady = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(mediaFileId) {
|
||||||
|
try {
|
||||||
|
val play = api.play(mediaFileId)
|
||||||
|
player.setMediaItem(ExoMediaItem.fromUri(play.streamUrl))
|
||||||
|
player.prepare()
|
||||||
|
if (startMs > 0) player.seekTo(startMs)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
error = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(player) {
|
||||||
|
while (true) {
|
||||||
|
delay(15_000)
|
||||||
|
if (player.playbackState == Player.STATE_READY || player.isPlaying) {
|
||||||
|
try {
|
||||||
|
api.postProgress(
|
||||||
|
mediaType = mediaType,
|
||||||
|
movieId = movieId,
|
||||||
|
episodeId = episodeId,
|
||||||
|
seriesId = seriesId,
|
||||||
|
positionMs = player.currentPosition,
|
||||||
|
durationMs = player.duration.coerceAtLeast(0),
|
||||||
|
)
|
||||||
|
} catch (_: ApiException) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
androidx.compose.runtime.DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
try {
|
||||||
|
kotlinx.coroutines.runBlocking {
|
||||||
|
api.postProgress(
|
||||||
|
mediaType = mediaType,
|
||||||
|
movieId = movieId,
|
||||||
|
episodeId = episodeId,
|
||||||
|
seriesId = seriesId,
|
||||||
|
positionMs = player.currentPosition,
|
||||||
|
durationMs = player.duration.coerceAtLeast(0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
player.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize().background(Color.Black)) {
|
||||||
|
AndroidView(
|
||||||
|
factory = { ctx ->
|
||||||
|
PlayerView(ctx).apply {
|
||||||
|
this.player = player
|
||||||
|
useController = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
if (error != null) {
|
||||||
|
Text(error!!, color = Color(0xFFFF8B8B), modifier = Modifier.align(Alignment.Center))
|
||||||
|
}
|
||||||
|
Box(Modifier.padding(24.dp)) {
|
||||||
|
TvButton("Terug", onBack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun SettingsScreen(
|
||||||
|
api: ApiClient,
|
||||||
|
store: SessionStore,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
var prefs by remember { mutableStateOf<List<ShelfPref>>(emptyList()) }
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
try {
|
||||||
|
prefs = api.getShelfPrefs().sortedBy { it.sortOrder }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
error = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun persist(next: List<ShelfPref>) {
|
||||||
|
prefs = next
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
api.saveShelfPrefs(next.mapIndexed { i, p -> p.copy(sortOrder = i) })
|
||||||
|
} catch (e: Exception) {
|
||||||
|
error = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
Modifier.fillMaxSize().background(Color(0xFF04060A)).padding(32.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
TvButton("Terug", onBack)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text("Planken", fontSize = 28.sp, fontWeight = FontWeight.Bold, color = Color.White)
|
||||||
|
Text("Zet planken aan/uit en verander de volgorde.", color = Color(0xFF9AA3B5))
|
||||||
|
if (error != null) Text(error!!, color = Color(0xFFFF8B8B))
|
||||||
|
}
|
||||||
|
items(prefs.size) { index ->
|
||||||
|
val p = prefs[index]
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(p.name, color = Color.White, fontSize = 18.sp, modifier = Modifier.weight(1f))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
TvButton("Omhoog") {
|
||||||
|
if (index > 0) {
|
||||||
|
val next = prefs.toMutableList()
|
||||||
|
val tmp = next[index - 1]
|
||||||
|
next[index - 1] = next[index]
|
||||||
|
next[index] = tmp
|
||||||
|
persist(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TvButton("Omlaag") {
|
||||||
|
if (index < prefs.lastIndex) {
|
||||||
|
val next = prefs.toMutableList()
|
||||||
|
val tmp = next[index + 1]
|
||||||
|
next[index + 1] = next[index]
|
||||||
|
next[index] = tmp
|
||||||
|
persist(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = p.visible,
|
||||||
|
onCheckedChange = { checked ->
|
||||||
|
val next = prefs.toMutableList()
|
||||||
|
next[index] = p.copy(visible = checked)
|
||||||
|
persist(next)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun TvButton(label: String, onClick: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f),
|
||||||
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor = Color(0xFF1A2438),
|
||||||
|
focusedContainerColor = Color(0xFF2A3A5A),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
package nl.vonas.mediacluster.tv.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.darkColorScheme
|
||||||
|
|
||||||
|
private val Scheme = darkColorScheme(
|
||||||
|
primary = Color(0xFF6EA8FF),
|
||||||
|
onPrimary = Color(0xFF001B3D),
|
||||||
|
surface = Color(0xFF0A0E16),
|
||||||
|
onSurface = Color(0xFFF2F5FA),
|
||||||
|
border = Color(0x33FFFFFF),
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun MediaClusterTheme(content: @Composable () -> Unit) {
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = Scheme,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
5
apps/android-tv/app/src/main/res/drawable/app_banner.xml
Normal file
5
apps/android-tv/app/src/main/res/drawable/app_banner.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||||
|
<solid android:color="#0A0E16" />
|
||||||
|
<size android:width="320dp" android:height="180dp" />
|
||||||
|
</shape>
|
||||||
10
apps/android-tv/app/src/main/res/drawable/ic_launcher_fg.xml
Normal file
10
apps/android-tv/app/src/main/res/drawable/ic_launcher_fg.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#6EA8FF"
|
||||||
|
android:pathData="M30,34h48v8H30zM30,50h48v8H30zM30,66h32v8H30z" />
|
||||||
|
</vector>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_bg" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_fg" />
|
||||||
|
</adaptive-icon>
|
||||||
4
apps/android-tv/app/src/main/res/values/colors.xml
Normal file
4
apps/android-tv/app/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_bg">#0A0E16</color>
|
||||||
|
</resources>
|
||||||
4
apps/android-tv/app/src/main/res/values/strings.xml
Normal file
4
apps/android-tv/app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Media Cluster</string>
|
||||||
|
</resources>
|
||||||
7
apps/android-tv/app/src/main/res/values/themes.xml
Normal file
7
apps/android-tv/app/src/main/res/values/themes.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.MediaClusterTv" parent="android:Theme.Material.NoActionBar">
|
||||||
|
<item name="android:windowBackground">#04060A</item>
|
||||||
|
<item name="android:colorPrimary">#6EA8FF</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
5
apps/android-tv/build.gradle.kts
Normal file
5
apps/android-tv/build.gradle.kts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
plugins {
|
||||||
|
id("com.android.application") version "8.13.2" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
|
||||||
|
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
|
||||||
|
}
|
||||||
9
apps/android-tv/gradle.properties
Normal file
9
apps/android-tv/gradle.properties
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
# Lichter voor ~16GB RAM + Android Studio open
|
||||||
|
org.gradle.jvmargs=-Xmx1536m -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.parallel=false
|
||||||
|
org.gradle.workers.max=2
|
||||||
|
org.gradle.caching=true
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
|
kotlin.daemon.jvmargs=-Xmx1024m
|
||||||
BIN
apps/android-tv/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
apps/android-tv/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
apps/android-tv/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
apps/android-tv/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
248
apps/android-tv/gradlew
vendored
Normal file
248
apps/android-tv/gradlew
vendored
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
93
apps/android-tv/gradlew.bat
vendored
Normal file
93
apps/android-tv/gradlew.bat
vendored
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
16
apps/android-tv/settings.gradle.kts
Normal file
16
apps/android-tv/settings.gradle.kts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rootProject.name = "MediaClusterTv"
|
||||||
|
include(":app")
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
-- Viewer accounts, device linking, watch progress, shelf prefs
|
||||||
|
CREATE TYPE "WatchMediaType" AS ENUM ('MOVIE', 'EPISODE');
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "viewer_users" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"password_hash" TEXT NOT NULL,
|
||||||
|
"name" TEXT,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"app_access" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "viewer_users_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "viewer_users_email_key" ON "viewer_users"("email");
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "viewer_devices" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"viewer_user_id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"platform" TEXT NOT NULL DEFAULT 'android_tv',
|
||||||
|
"refresh_token_hash" TEXT NOT NULL,
|
||||||
|
"last_seen_at" TIMESTAMP(3),
|
||||||
|
"revoked" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "viewer_devices_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "viewer_devices_refresh_token_hash_key" ON "viewer_devices"("refresh_token_hash");
|
||||||
|
CREATE INDEX IF NOT EXISTS "viewer_devices_viewer_user_id_idx" ON "viewer_devices"("viewer_user_id");
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "device_link_codes" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"code_hash" TEXT NOT NULL,
|
||||||
|
"code_display" TEXT,
|
||||||
|
"device_name" TEXT NOT NULL,
|
||||||
|
"platform" TEXT NOT NULL DEFAULT 'android_tv',
|
||||||
|
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
"consumed_at" TIMESTAMP(3),
|
||||||
|
"viewer_user_id" TEXT,
|
||||||
|
"claim_token_hash" TEXT,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "device_link_codes_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "device_link_codes_code_hash_key" ON "device_link_codes"("code_hash");
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "device_link_codes_claim_token_hash_key" ON "device_link_codes"("claim_token_hash");
|
||||||
|
CREATE INDEX IF NOT EXISTS "device_link_codes_expires_at_idx" ON "device_link_codes"("expires_at");
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "watch_progress" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"viewer_user_id" TEXT NOT NULL,
|
||||||
|
"media_type" "WatchMediaType" NOT NULL,
|
||||||
|
"movie_id" TEXT,
|
||||||
|
"episode_id" TEXT,
|
||||||
|
"series_id" TEXT,
|
||||||
|
"position_ms" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"duration_ms" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"completed" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"hidden_from_continue" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "watch_progress_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "watch_progress_viewer_user_id_movie_id_key" ON "watch_progress"("viewer_user_id", "movie_id");
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "watch_progress_viewer_user_id_episode_id_key" ON "watch_progress"("viewer_user_id", "episode_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "watch_progress_viewer_user_id_updated_at_idx" ON "watch_progress"("viewer_user_id", "updated_at");
|
||||||
|
CREATE INDEX IF NOT EXISTS "watch_progress_viewer_user_id_hidden_from_continue_completed_idx" ON "watch_progress"("viewer_user_id", "hidden_from_continue", "completed");
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "viewer_shelf_preferences" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"viewer_user_id" TEXT NOT NULL,
|
||||||
|
"shelf_id" TEXT NOT NULL,
|
||||||
|
"visible" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
CONSTRAINT "viewer_shelf_preferences_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "viewer_shelf_preferences_viewer_user_id_shelf_id_key" ON "viewer_shelf_preferences"("viewer_user_id", "shelf_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "viewer_shelf_preferences_viewer_user_id_sort_order_idx" ON "viewer_shelf_preferences"("viewer_user_id", "sort_order");
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "viewer_devices" ADD CONSTRAINT "viewer_devices_viewer_user_id_fkey"
|
||||||
|
FOREIGN KEY ("viewer_user_id") REFERENCES "viewer_users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN null; END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "device_link_codes" ADD CONSTRAINT "device_link_codes_viewer_user_id_fkey"
|
||||||
|
FOREIGN KEY ("viewer_user_id") REFERENCES "viewer_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN null; END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "watch_progress" ADD CONSTRAINT "watch_progress_viewer_user_id_fkey"
|
||||||
|
FOREIGN KEY ("viewer_user_id") REFERENCES "viewer_users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN null; END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "viewer_shelf_preferences" ADD CONSTRAINT "viewer_shelf_preferences_viewer_user_id_fkey"
|
||||||
|
FOREIGN KEY ("viewer_user_id") REFERENCES "viewer_users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN null; END $$;
|
||||||
|
|
@ -57,6 +57,105 @@ model User {
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** App-kijker (Android TV / phone) — apart van admin User. */
|
||||||
|
model ViewerUser {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
email String @unique
|
||||||
|
passwordHash String @map("password_hash")
|
||||||
|
name String?
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
appAccess Boolean @default(true) @map("app_access")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
devices ViewerDevice[]
|
||||||
|
progress WatchProgress[]
|
||||||
|
shelfPrefs ViewerShelfPreference[]
|
||||||
|
linkCodes DeviceLinkCode[]
|
||||||
|
|
||||||
|
@@map("viewer_users")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ViewerDevice {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
viewerUserId String @map("viewer_user_id")
|
||||||
|
name String
|
||||||
|
platform String @default("android_tv")
|
||||||
|
refreshTokenHash String @unique @map("refresh_token_hash")
|
||||||
|
lastSeenAt DateTime? @map("last_seen_at")
|
||||||
|
revoked Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
viewer ViewerUser @relation(fields: [viewerUserId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([viewerUserId])
|
||||||
|
@@map("viewer_devices")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kortstondige koppelcode (TV toont code → gebruiker claimt op /link). */
|
||||||
|
model DeviceLinkCode {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
codeHash String @unique @map("code_hash")
|
||||||
|
/** Display-code (alleen tot claim; daarna null) */
|
||||||
|
codeDisplay String? @map("code_display")
|
||||||
|
deviceName String @map("device_name")
|
||||||
|
platform String @default("android_tv")
|
||||||
|
expiresAt DateTime @map("expires_at")
|
||||||
|
consumedAt DateTime? @map("consumed_at")
|
||||||
|
viewerUserId String? @map("viewer_user_id")
|
||||||
|
/** Eenmalig token voor device poll na claim */
|
||||||
|
claimTokenHash String? @unique @map("claim_token_hash")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
viewer ViewerUser? @relation(fields: [viewerUserId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([expiresAt])
|
||||||
|
@@map("device_link_codes")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WatchMediaType {
|
||||||
|
MOVIE
|
||||||
|
EPISODE
|
||||||
|
}
|
||||||
|
|
||||||
|
model WatchProgress {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
viewerUserId String @map("viewer_user_id")
|
||||||
|
mediaType WatchMediaType @map("media_type")
|
||||||
|
movieId String? @map("movie_id")
|
||||||
|
episodeId String? @map("episode_id")
|
||||||
|
seriesId String? @map("series_id")
|
||||||
|
positionMs Int @default(0) @map("position_ms")
|
||||||
|
durationMs Int @default(0) @map("duration_ms")
|
||||||
|
completed Boolean @default(false)
|
||||||
|
hiddenFromContinue Boolean @default(false) @map("hidden_from_continue")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
viewer ViewerUser @relation(fields: [viewerUserId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([viewerUserId, movieId])
|
||||||
|
@@unique([viewerUserId, episodeId])
|
||||||
|
@@index([viewerUserId, updatedAt])
|
||||||
|
@@index([viewerUserId, hiddenFromContinue, completed])
|
||||||
|
@@map("watch_progress")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ViewerShelfPreference {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
viewerUserId String @map("viewer_user_id")
|
||||||
|
shelfId String @map("shelf_id")
|
||||||
|
visible Boolean @default(true)
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
|
||||||
|
viewer ViewerUser @relation(fields: [viewerUserId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([viewerUserId, shelfId])
|
||||||
|
@@index([viewerUserId, sortOrder])
|
||||||
|
@@map("viewer_shelf_preferences")
|
||||||
|
}
|
||||||
|
|
||||||
model AdminSession {
|
model AdminSession {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String @map("user_id")
|
userId String @map("user_id")
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { registerAdminRoutes } from "./admin/routes";
|
||||||
import { registerInstallRoutes } from "./install/routes";
|
import { registerInstallRoutes } from "./install/routes";
|
||||||
import { registerDownloadRoutes } from "./downloads/routes";
|
import { registerDownloadRoutes } from "./downloads/routes";
|
||||||
import { registerSettingsRoutes } from "./settings/routes";
|
import { registerSettingsRoutes } from "./settings/routes";
|
||||||
|
import { registerViewerRoutes } from "./viewer/routes";
|
||||||
import { nodeConnectionManager } from "./websocket/manager";
|
import { nodeConnectionManager } from "./websocket/manager";
|
||||||
import { MetadataService } from "./metadata/service";
|
import { MetadataService } from "./metadata/service";
|
||||||
import { toErrorResponse } from "./security/errors";
|
import { toErrorResponse } from "./security/errors";
|
||||||
|
|
@ -75,6 +76,7 @@ async function main() {
|
||||||
await registerInstallRoutes(app, config);
|
await registerInstallRoutes(app, config);
|
||||||
registerDownloadRoutes(app, config);
|
registerDownloadRoutes(app, config);
|
||||||
registerSettingsRoutes(app, config);
|
registerSettingsRoutes(app, config);
|
||||||
|
registerViewerRoutes(app, config);
|
||||||
|
|
||||||
app.get("/api/v1/node/connect", { websocket: true }, (socket) => {
|
app.get("/api/v1/node/connect", { websocket: true }, (socket) => {
|
||||||
void nodeConnectionManager.handleConnection(socket);
|
void nodeConnectionManager.handleConnection(socket);
|
||||||
|
|
@ -295,6 +297,94 @@ async function main() {
|
||||||
console.warn("Could not ensure download tables:", err);
|
console.warn("Could not ensure download tables:", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.$executeRawUnsafe(`
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE "WatchMediaType" AS ENUM ('MOVIE', 'EPISODE');
|
||||||
|
EXCEPTION WHEN duplicate_object THEN null; END $$;
|
||||||
|
`);
|
||||||
|
await prisma.$executeRawUnsafe(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "viewer_users" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"password_hash" TEXT NOT NULL,
|
||||||
|
"name" TEXT,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"app_access" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "viewer_users_pkey" PRIMARY KEY ("id")
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await prisma.$executeRawUnsafe(
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS "viewer_users_email_key" ON "viewer_users"("email")`
|
||||||
|
);
|
||||||
|
await prisma.$executeRawUnsafe(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "viewer_devices" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"viewer_user_id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"platform" TEXT NOT NULL DEFAULT 'android_tv',
|
||||||
|
"refresh_token_hash" TEXT NOT NULL,
|
||||||
|
"last_seen_at" TIMESTAMP(3),
|
||||||
|
"revoked" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "viewer_devices_pkey" PRIMARY KEY ("id")
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await prisma.$executeRawUnsafe(
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS "viewer_devices_refresh_token_hash_key" ON "viewer_devices"("refresh_token_hash")`
|
||||||
|
);
|
||||||
|
await prisma.$executeRawUnsafe(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "device_link_codes" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"code_hash" TEXT NOT NULL,
|
||||||
|
"code_display" TEXT,
|
||||||
|
"device_name" TEXT NOT NULL,
|
||||||
|
"platform" TEXT NOT NULL DEFAULT 'android_tv',
|
||||||
|
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
"consumed_at" TIMESTAMP(3),
|
||||||
|
"viewer_user_id" TEXT,
|
||||||
|
"claim_token_hash" TEXT,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "device_link_codes_pkey" PRIMARY KEY ("id")
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await prisma.$executeRawUnsafe(
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS "device_link_codes_code_hash_key" ON "device_link_codes"("code_hash")`
|
||||||
|
);
|
||||||
|
await prisma.$executeRawUnsafe(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "watch_progress" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"viewer_user_id" TEXT NOT NULL,
|
||||||
|
"media_type" "WatchMediaType" NOT NULL,
|
||||||
|
"movie_id" TEXT,
|
||||||
|
"episode_id" TEXT,
|
||||||
|
"series_id" TEXT,
|
||||||
|
"position_ms" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"duration_ms" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"completed" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"hidden_from_continue" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "watch_progress_pkey" PRIMARY KEY ("id")
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await prisma.$executeRawUnsafe(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "viewer_shelf_preferences" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"viewer_user_id" TEXT NOT NULL,
|
||||||
|
"shelf_id" TEXT NOT NULL,
|
||||||
|
"visible" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
CONSTRAINT "viewer_shelf_preferences_pkey" PRIMARY KEY ("id")
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Could not ensure viewer client tables:", err);
|
||||||
|
}
|
||||||
|
|
||||||
// Repair drifted episode.series_id so series.episodes and seasons.episodes stay aligned.
|
// Repair drifted episode.series_id so series.episodes and seasons.episodes stay aligned.
|
||||||
try {
|
try {
|
||||||
await prisma.$executeRawUnsafe(`
|
await prisma.$executeRawUnsafe(`
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ const configSchema = z.object({
|
||||||
ADMIN_PASSWORD: z.string().min(8).optional(),
|
ADMIN_PASSWORD: z.string().min(8).optional(),
|
||||||
TMDB_API_KEY: z.string().optional(),
|
TMDB_API_KEY: z.string().optional(),
|
||||||
PUBLIC_URL: z.string().url().default("http://localhost:3000"),
|
PUBLIC_URL: z.string().url().default("http://localhost:3000"),
|
||||||
|
/** Base URL shown on TV for /link (often admin UI). Falls back to PUBLIC_URL. */
|
||||||
|
CLIENT_LINK_BASE_URL: z.string().url().optional(),
|
||||||
PLAYBACK_IDLE_TIMEOUT_SECONDS: z.coerce.number().default(1200),
|
PLAYBACK_IDLE_TIMEOUT_SECONDS: z.coerce.number().default(1200),
|
||||||
PLAYBACK_ABSOLUTE_LIFETIME_SECONDS: z.coerce.number().default(43200),
|
PLAYBACK_ABSOLUTE_LIFETIME_SECONDS: z.coerce.number().default(43200),
|
||||||
NODE_HEARTBEAT_INTERVAL_SECONDS: z.coerce.number().default(30),
|
NODE_HEARTBEAT_INTERVAL_SECONDS: z.coerce.number().default(30),
|
||||||
|
|
|
||||||
187
apps/master-api/src/viewer/routes.ts
Normal file
187
apps/master-api/src/viewer/routes.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { AppError } from "../security/errors";
|
||||||
|
import { requireAdmin } from "../auth/routes";
|
||||||
|
import { PlaybackService } from "../playback/service";
|
||||||
|
import type { Config } from "../config";
|
||||||
|
import { ViewerService } from "./service";
|
||||||
|
|
||||||
|
export function registerViewerRoutes(app: FastifyInstance, config: Config) {
|
||||||
|
const playback = new PlaybackService(config);
|
||||||
|
const viewers = new ViewerService(config, playback);
|
||||||
|
|
||||||
|
// ——— Admin: kijkers ———
|
||||||
|
app.get("/api/v1/admin/viewers", { preHandler: requireAdmin }, async () => {
|
||||||
|
return { viewers: await viewers.listViewers() };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/v1/admin/viewers", { preHandler: requireAdmin }, async (request) => {
|
||||||
|
const body = request.body as {
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
name?: string;
|
||||||
|
appAccess?: boolean;
|
||||||
|
};
|
||||||
|
const viewer = await viewers.createViewer({
|
||||||
|
email: body.email || "",
|
||||||
|
password: body.password || "",
|
||||||
|
name: body.name,
|
||||||
|
appAccess: body.appAccess,
|
||||||
|
});
|
||||||
|
return { viewer };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch("/api/v1/admin/viewers/:id", { preHandler: requireAdmin }, async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const body = request.body as {
|
||||||
|
name?: string | null;
|
||||||
|
enabled?: boolean;
|
||||||
|
appAccess?: boolean;
|
||||||
|
password?: string;
|
||||||
|
};
|
||||||
|
const viewer = await viewers.updateViewer(id, body);
|
||||||
|
return { viewer };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
"/api/v1/admin/viewers/devices/:deviceId/revoke",
|
||||||
|
{ preHandler: requireAdmin },
|
||||||
|
async (request) => {
|
||||||
|
const { deviceId } = request.params as { deviceId: string };
|
||||||
|
return viewers.revokeDevice(deviceId);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ——— Device link (TV) ———
|
||||||
|
app.post(
|
||||||
|
"/api/v1/client/device/code",
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
rateLimit: { max: 20, timeWindow: "1 minute" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request) => {
|
||||||
|
const body = (request.body || {}) as { deviceName?: string; platform?: string };
|
||||||
|
return viewers.createDeviceCode(body.deviceName || "Android TV", body.platform || "android_tv");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
"/api/v1/client/device/poll",
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
rateLimit: { max: 120, timeWindow: "1 minute" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request) => {
|
||||||
|
const q = request.query as { code?: string };
|
||||||
|
if (!q.code?.trim()) throw new AppError("INVALID_REQUEST", "code verplicht", 400);
|
||||||
|
return viewers.pollDeviceCode(q.code);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ——— Link page (browser) ———
|
||||||
|
app.post(
|
||||||
|
"/api/v1/link/claim",
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
rateLimit: { max: 15, timeWindow: "1 minute" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request) => {
|
||||||
|
const body = request.body as { email?: string; password?: string; code?: string };
|
||||||
|
if (!body.email || !body.password || !body.code) {
|
||||||
|
throw new AppError("INVALID_REQUEST", "E-mail, wachtwoord en code verplicht", 400);
|
||||||
|
}
|
||||||
|
return viewers.claimCode({
|
||||||
|
email: body.email,
|
||||||
|
password: body.password,
|
||||||
|
code: body.code,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ——— Authenticated client ———
|
||||||
|
app.get("/api/v1/client/me", async (request) => {
|
||||||
|
const auth = await viewers.authFromBearer(request.headers.authorization);
|
||||||
|
return { viewer: { id: auth.viewerId, email: auth.email, name: auth.name }, deviceId: auth.deviceId };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/v1/client/home", async (request) => {
|
||||||
|
const auth = await viewers.authFromBearer(request.headers.authorization);
|
||||||
|
return viewers.home(auth.viewerId);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/v1/client/movies/:id", async (request) => {
|
||||||
|
await viewers.authFromBearer(request.headers.authorization);
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
return viewers.movieDetail(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/v1/client/series/:id", async (request) => {
|
||||||
|
await viewers.authFromBearer(request.headers.authorization);
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
return viewers.seriesDetail(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/v1/client/play", async (request) => {
|
||||||
|
const auth = await viewers.authFromBearer(request.headers.authorization);
|
||||||
|
const body = request.body as { mediaFileId?: string };
|
||||||
|
if (!body.mediaFileId) throw new AppError("INVALID_REQUEST", "mediaFileId verplicht", 400);
|
||||||
|
return viewers.play(body.mediaFileId, auth.viewerId);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/v1/client/progress", async (request) => {
|
||||||
|
const auth = await viewers.authFromBearer(request.headers.authorization);
|
||||||
|
const body = request.body as {
|
||||||
|
mediaType?: "MOVIE" | "EPISODE";
|
||||||
|
movieId?: string;
|
||||||
|
episodeId?: string;
|
||||||
|
seriesId?: string;
|
||||||
|
positionMs?: number;
|
||||||
|
durationMs?: number;
|
||||||
|
completed?: boolean;
|
||||||
|
hiddenFromContinue?: boolean;
|
||||||
|
};
|
||||||
|
if (!body.mediaType) throw new AppError("INVALID_REQUEST", "mediaType verplicht", 400);
|
||||||
|
const row = await viewers.upsertProgress(auth.viewerId, {
|
||||||
|
mediaType: body.mediaType,
|
||||||
|
movieId: body.movieId,
|
||||||
|
episodeId: body.episodeId,
|
||||||
|
seriesId: body.seriesId,
|
||||||
|
positionMs: body.positionMs ?? 0,
|
||||||
|
durationMs: body.durationMs ?? 0,
|
||||||
|
completed: body.completed,
|
||||||
|
hiddenFromContinue: body.hiddenFromContinue,
|
||||||
|
});
|
||||||
|
return { progress: row };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/v1/client/progress/mark-watched", async (request) => {
|
||||||
|
const auth = await viewers.authFromBearer(request.headers.authorization);
|
||||||
|
const body = request.body as { movieId?: string; episodeId?: string };
|
||||||
|
const row = await viewers.markWatched(auth.viewerId, body);
|
||||||
|
return { progress: row };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/v1/client/progress/:id/dismiss", async (request) => {
|
||||||
|
const auth = await viewers.authFromBearer(request.headers.authorization);
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
return viewers.dismissContinue(auth.viewerId, id);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/v1/client/shelves/prefs", async (request) => {
|
||||||
|
const auth = await viewers.authFromBearer(request.headers.authorization);
|
||||||
|
return { shelves: await viewers.getShelfPrefs(auth.viewerId) };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put("/api/v1/client/shelves/prefs", async (request) => {
|
||||||
|
const auth = await viewers.authFromBearer(request.headers.authorization);
|
||||||
|
const body = request.body as {
|
||||||
|
shelves?: Array<{ shelfId: string; visible: boolean; sortOrder: number }>;
|
||||||
|
};
|
||||||
|
if (!Array.isArray(body.shelves)) {
|
||||||
|
throw new AppError("INVALID_REQUEST", "shelves array verplicht", 400);
|
||||||
|
}
|
||||||
|
return { shelves: await viewers.setShelfPrefs(auth.viewerId, body.shelves) };
|
||||||
|
});
|
||||||
|
}
|
||||||
717
apps/master-api/src/viewer/service.ts
Normal file
717
apps/master-api/src/viewer/service.ts
Normal file
|
|
@ -0,0 +1,717 @@
|
||||||
|
import { randomBytes } from "crypto";
|
||||||
|
import { prisma } from "../database/client";
|
||||||
|
import {
|
||||||
|
generateSecureToken,
|
||||||
|
hashPassword,
|
||||||
|
hashToken,
|
||||||
|
verifyPassword,
|
||||||
|
} from "../security/crypto";
|
||||||
|
import { AppError } from "../security/errors";
|
||||||
|
import { PlaybackService } from "../playback/service";
|
||||||
|
import type { Config } from "../config";
|
||||||
|
import type { FastifyRequest } from "fastify";
|
||||||
|
|
||||||
|
const CODE_TTL_MS = 10 * 60 * 1000;
|
||||||
|
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
|
const POLL_INTERVAL_SEC = 2;
|
||||||
|
|
||||||
|
export type AuthedViewer = {
|
||||||
|
viewerId: string;
|
||||||
|
deviceId: string;
|
||||||
|
email: string;
|
||||||
|
name: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function generateLinkCode(length = 8): string {
|
||||||
|
const bytes = randomBytes(length);
|
||||||
|
let out = "";
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
out += CODE_ALPHABET[bytes[i]! % CODE_ALPHABET.length];
|
||||||
|
}
|
||||||
|
return `${out.slice(0, 4)}-${out.slice(4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize to ABCD-EFGH then hash — must match createDeviceCode. */
|
||||||
|
export function hashLinkCode(raw: string): string {
|
||||||
|
const n = raw.trim().toUpperCase().replace(/[^A-Z0-9]/g, "");
|
||||||
|
const display = n.length === 8 ? `${n.slice(0, 4)}-${n.slice(4)}` : raw.trim().toUpperCase();
|
||||||
|
return hashToken(display);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ViewerService {
|
||||||
|
constructor(
|
||||||
|
private readonly config: Config,
|
||||||
|
private readonly playback: PlaybackService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createViewer(input: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
name?: string | null;
|
||||||
|
appAccess?: boolean;
|
||||||
|
}) {
|
||||||
|
const email = input.email.trim().toLowerCase();
|
||||||
|
if (!email || !input.password || input.password.length < 8) {
|
||||||
|
throw new AppError("INVALID_REQUEST", "E-mail en wachtwoord (min. 8) verplicht", 400);
|
||||||
|
}
|
||||||
|
const existing = await prisma.viewerUser.findUnique({ where: { email } });
|
||||||
|
if (existing) throw new AppError("CONFLICT", "E-mail bestaat al", 409);
|
||||||
|
|
||||||
|
return prisma.viewerUser.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
passwordHash: await hashPassword(input.password),
|
||||||
|
name: input.name?.trim() || null,
|
||||||
|
appAccess: input.appAccess !== false,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
enabled: true,
|
||||||
|
appAccess: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listViewers() {
|
||||||
|
const rows = await prisma.viewerUser.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: {
|
||||||
|
_count: { select: { devices: true } },
|
||||||
|
devices: {
|
||||||
|
where: { revoked: false },
|
||||||
|
select: { id: true, name: true, platform: true, lastSeenAt: true, createdAt: true },
|
||||||
|
orderBy: { lastSeenAt: "desc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return rows.map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
email: v.email,
|
||||||
|
name: v.name,
|
||||||
|
enabled: v.enabled,
|
||||||
|
appAccess: v.appAccess,
|
||||||
|
createdAt: v.createdAt,
|
||||||
|
deviceCount: v._count.devices,
|
||||||
|
devices: v.devices,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateViewer(
|
||||||
|
id: string,
|
||||||
|
data: {
|
||||||
|
name?: string | null;
|
||||||
|
enabled?: boolean;
|
||||||
|
appAccess?: boolean;
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const patch: {
|
||||||
|
name?: string | null;
|
||||||
|
enabled?: boolean;
|
||||||
|
appAccess?: boolean;
|
||||||
|
passwordHash?: string;
|
||||||
|
} = {};
|
||||||
|
if (data.name !== undefined) patch.name = data.name?.trim() || null;
|
||||||
|
if (data.enabled !== undefined) patch.enabled = data.enabled;
|
||||||
|
if (data.appAccess !== undefined) patch.appAccess = data.appAccess;
|
||||||
|
if (data.password?.trim()) {
|
||||||
|
if (data.password.length < 8) {
|
||||||
|
throw new AppError("INVALID_REQUEST", "Wachtwoord minstens 8 tekens", 400);
|
||||||
|
}
|
||||||
|
patch.passwordHash = await hashPassword(data.password);
|
||||||
|
}
|
||||||
|
return prisma.viewerUser.update({
|
||||||
|
where: { id },
|
||||||
|
data: patch,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
enabled: true,
|
||||||
|
appAccess: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeDevice(deviceId: string) {
|
||||||
|
await prisma.viewerDevice.update({
|
||||||
|
where: { id: deviceId },
|
||||||
|
data: { revoked: true, refreshTokenHash: hashToken(`revoked:${deviceId}:${Date.now()}`) },
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async createDeviceCode(deviceName: string, platform = "android_tv") {
|
||||||
|
const name = deviceName.trim().slice(0, 80) || "Android TV";
|
||||||
|
const code = generateLinkCode(8);
|
||||||
|
const expiresAt = new Date(Date.now() + CODE_TTL_MS);
|
||||||
|
await prisma.deviceLinkCode.create({
|
||||||
|
data: {
|
||||||
|
codeHash: hashLinkCode(code),
|
||||||
|
codeDisplay: code,
|
||||||
|
deviceName: name,
|
||||||
|
platform,
|
||||||
|
expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const linkUrl = `${this.publicBase()}/link`;
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
expiresAt: expiresAt.toISOString(),
|
||||||
|
expiresInSeconds: Math.floor(CODE_TTL_MS / 1000),
|
||||||
|
pollIntervalSeconds: POLL_INTERVAL_SEC,
|
||||||
|
linkUrl,
|
||||||
|
qrPayload: `${linkUrl}?code=${encodeURIComponent(code)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async pollDeviceCode(codeRaw: string) {
|
||||||
|
const row = await prisma.deviceLinkCode.findUnique({
|
||||||
|
where: { codeHash: hashLinkCode(codeRaw) },
|
||||||
|
});
|
||||||
|
if (!row) throw new AppError("NOT_FOUND", "Onbekende code", 404);
|
||||||
|
if (row.expiresAt.getTime() < Date.now() && !row.consumedAt) {
|
||||||
|
return { status: "expired" as const };
|
||||||
|
}
|
||||||
|
if (!row.consumedAt || !row.viewerUserId) {
|
||||||
|
return { status: "pending" as const };
|
||||||
|
}
|
||||||
|
if (!row.claimTokenHash) {
|
||||||
|
return { status: "already_linked" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshToken = generateSecureToken(32);
|
||||||
|
const viewer = await prisma.viewerUser.findUnique({ where: { id: row.viewerUserId } });
|
||||||
|
if (!viewer || !viewer.enabled || !viewer.appAccess) {
|
||||||
|
throw new AppError("FORBIDDEN", "Account heeft geen app-toegang", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const device = await prisma.$transaction(async (tx) => {
|
||||||
|
const claimed = await tx.deviceLinkCode.updateMany({
|
||||||
|
where: { id: row.id, claimTokenHash: row.claimTokenHash },
|
||||||
|
data: { claimTokenHash: null, codeDisplay: null },
|
||||||
|
});
|
||||||
|
if (claimed.count === 0) return null;
|
||||||
|
return tx.viewerDevice.create({
|
||||||
|
data: {
|
||||||
|
viewerUserId: viewer.id,
|
||||||
|
name: row.deviceName,
|
||||||
|
platform: row.platform,
|
||||||
|
refreshTokenHash: hashToken(refreshToken),
|
||||||
|
lastSeenAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!device) return { status: "already_linked" as const };
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "linked" as const,
|
||||||
|
refreshToken,
|
||||||
|
device: { id: device.id, name: device.name, platform: device.platform },
|
||||||
|
viewer: { id: viewer.id, email: viewer.email, name: viewer.name },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async claimCode(opts: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
code: string;
|
||||||
|
}) {
|
||||||
|
const email = opts.email.trim().toLowerCase();
|
||||||
|
const viewer = await prisma.viewerUser.findUnique({ where: { email } });
|
||||||
|
if (!viewer || !(await verifyPassword(viewer.passwordHash, opts.password))) {
|
||||||
|
throw new AppError("INVALID_CREDENTIALS", "Ongeldige login", 401);
|
||||||
|
}
|
||||||
|
if (!viewer.enabled || !viewer.appAccess) {
|
||||||
|
throw new AppError("FORBIDDEN", "Geen app-toegang voor dit account", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = await prisma.deviceLinkCode.findUnique({
|
||||||
|
where: { codeHash: hashLinkCode(opts.code) },
|
||||||
|
});
|
||||||
|
if (!row) throw new AppError("NOT_FOUND", "Onbekende code", 404);
|
||||||
|
if (row.consumedAt) throw new AppError("CONFLICT", "Code al gebruikt", 409);
|
||||||
|
if (row.expiresAt.getTime() < Date.now()) {
|
||||||
|
throw new AppError("EXPIRED", "Code verlopen — start opnieuw op de TV", 410);
|
||||||
|
}
|
||||||
|
|
||||||
|
const claimToken = generateSecureToken(24);
|
||||||
|
await prisma.deviceLinkCode.update({
|
||||||
|
where: { id: row.id },
|
||||||
|
data: {
|
||||||
|
consumedAt: new Date(),
|
||||||
|
viewerUserId: viewer.id,
|
||||||
|
claimTokenHash: hashToken(claimToken),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
deviceName: row.deviceName,
|
||||||
|
viewer: { id: viewer.id, email: viewer.email, name: viewer.name },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async authFromBearer(authHeader?: string): Promise<AuthedViewer> {
|
||||||
|
if (!authHeader?.startsWith("Bearer ")) {
|
||||||
|
throw new AppError("UNAUTHORIZED", "Bearer token verplicht", 401);
|
||||||
|
}
|
||||||
|
const token = authHeader.slice("Bearer ".length).trim();
|
||||||
|
if (!token) throw new AppError("UNAUTHORIZED", "Bearer token verplicht", 401);
|
||||||
|
|
||||||
|
const device = await prisma.viewerDevice.findUnique({
|
||||||
|
where: { refreshTokenHash: hashToken(token) },
|
||||||
|
include: { viewer: true },
|
||||||
|
});
|
||||||
|
if (!device || device.revoked) {
|
||||||
|
throw new AppError("UNAUTHORIZED", "Ongeldig of ingetrokken apparaat", 401);
|
||||||
|
}
|
||||||
|
if (!device.viewer.enabled || !device.viewer.appAccess) {
|
||||||
|
throw new AppError("FORBIDDEN", "Account uitgeschakeld", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.viewerDevice.update({
|
||||||
|
where: { id: device.id },
|
||||||
|
data: { lastSeenAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
viewerId: device.viewer.id,
|
||||||
|
deviceId: device.id,
|
||||||
|
email: device.viewer.email,
|
||||||
|
name: device.viewer.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async home(viewerId: string) {
|
||||||
|
const [shelves, prefs, continueItems] = await Promise.all([
|
||||||
|
prisma.libraryShelf.findMany({
|
||||||
|
where: { enabled: true },
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
}),
|
||||||
|
prisma.viewerShelfPreference.findMany({ where: { viewerUserId: viewerId } }),
|
||||||
|
this.continueWatching(viewerId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const prefMap = new Map(prefs.map((p) => [p.shelfId, p]));
|
||||||
|
const ordered = shelves
|
||||||
|
.map((s, idx) => {
|
||||||
|
const pref = prefMap.get(s.id);
|
||||||
|
return {
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
slug: s.slug,
|
||||||
|
kind: s.kind,
|
||||||
|
visible: pref?.visible ?? true,
|
||||||
|
sortOrder: pref?.sortOrder ?? s.sortOrder * 10 + idx,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((s) => s.visible)
|
||||||
|
.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||||
|
|
||||||
|
const rows = [];
|
||||||
|
for (const shelf of ordered) {
|
||||||
|
const items = await this.shelfItems(shelf.id, shelf.kind, 24);
|
||||||
|
if (items.length) {
|
||||||
|
rows.push({
|
||||||
|
id: shelf.id,
|
||||||
|
title: shelf.name,
|
||||||
|
kind: shelf.kind,
|
||||||
|
slug: shelf.slug,
|
||||||
|
items,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
continueWatching: continueItems,
|
||||||
|
shelves: rows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async shelfItems(shelfId: string, kind: "MOVIE" | "SERIES", take: number) {
|
||||||
|
if (kind === "MOVIE") {
|
||||||
|
const files = await prisma.mediaFile.findMany({
|
||||||
|
where: { shelfId, available: true, movieId: { not: null } },
|
||||||
|
include: { movie: true },
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
take: take * 3,
|
||||||
|
});
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out = [];
|
||||||
|
for (const f of files) {
|
||||||
|
if (!f.movie || seen.has(f.movie.id)) continue;
|
||||||
|
seen.add(f.movie.id);
|
||||||
|
out.push({
|
||||||
|
type: "movie" as const,
|
||||||
|
id: f.movie.id,
|
||||||
|
title: f.movie.title,
|
||||||
|
year: f.movie.year,
|
||||||
|
posterUrl: f.movie.posterUrl,
|
||||||
|
backdropUrl: f.movie.backdropUrl,
|
||||||
|
mediaFileId: f.id,
|
||||||
|
});
|
||||||
|
if (out.length >= take) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = await prisma.mediaFile.findMany({
|
||||||
|
where: { shelfId, available: true, episodeId: { not: null } },
|
||||||
|
include: { episode: { include: { series: true } } },
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
take: take * 5,
|
||||||
|
});
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out = [];
|
||||||
|
for (const f of files) {
|
||||||
|
const series = f.episode?.series;
|
||||||
|
if (!series || seen.has(series.id)) continue;
|
||||||
|
seen.add(series.id);
|
||||||
|
out.push({
|
||||||
|
type: "series" as const,
|
||||||
|
id: series.id,
|
||||||
|
title: series.title,
|
||||||
|
year: series.year,
|
||||||
|
posterUrl: series.posterUrl,
|
||||||
|
backdropUrl: series.backdropUrl,
|
||||||
|
});
|
||||||
|
if (out.length >= take) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async continueWatching(viewerId: string) {
|
||||||
|
const rows = await prisma.watchProgress.findMany({
|
||||||
|
where: {
|
||||||
|
viewerUserId: viewerId,
|
||||||
|
hiddenFromContinue: false,
|
||||||
|
completed: false,
|
||||||
|
positionMs: { gt: 30_000 },
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
take: 24,
|
||||||
|
});
|
||||||
|
|
||||||
|
const out = [];
|
||||||
|
for (const p of rows) {
|
||||||
|
if (p.mediaType === "MOVIE" && p.movieId) {
|
||||||
|
const movie = await prisma.movie.findUnique({ where: { id: p.movieId } });
|
||||||
|
if (!movie) continue;
|
||||||
|
const file = await prisma.mediaFile.findFirst({
|
||||||
|
where: { movieId: movie.id, available: true },
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
});
|
||||||
|
out.push({
|
||||||
|
type: "movie" as const,
|
||||||
|
id: movie.id,
|
||||||
|
title: movie.title,
|
||||||
|
year: movie.year,
|
||||||
|
posterUrl: movie.posterUrl,
|
||||||
|
backdropUrl: movie.backdropUrl,
|
||||||
|
mediaFileId: file?.id ?? null,
|
||||||
|
positionMs: p.positionMs,
|
||||||
|
durationMs: p.durationMs,
|
||||||
|
progressId: p.id,
|
||||||
|
});
|
||||||
|
} else if (p.mediaType === "EPISODE" && p.episodeId) {
|
||||||
|
const episode = await prisma.episode.findUnique({
|
||||||
|
where: { id: p.episodeId },
|
||||||
|
include: { series: true },
|
||||||
|
});
|
||||||
|
if (!episode) continue;
|
||||||
|
const file = await prisma.mediaFile.findFirst({
|
||||||
|
where: { episodeId: episode.id, available: true },
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
});
|
||||||
|
out.push({
|
||||||
|
type: "episode" as const,
|
||||||
|
id: episode.id,
|
||||||
|
seriesId: episode.seriesId,
|
||||||
|
title: episode.series.title,
|
||||||
|
subtitle: `S${episode.seasonNumber}E${episode.episodeNumber}${
|
||||||
|
episode.title ? ` · ${episode.title}` : ""
|
||||||
|
}`,
|
||||||
|
year: episode.series.year,
|
||||||
|
posterUrl: episode.stillUrl || episode.series.posterUrl,
|
||||||
|
backdropUrl: episode.series.backdropUrl,
|
||||||
|
mediaFileId: file?.id ?? null,
|
||||||
|
positionMs: p.positionMs,
|
||||||
|
durationMs: p.durationMs,
|
||||||
|
progressId: p.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertProgress(
|
||||||
|
viewerId: string,
|
||||||
|
body: {
|
||||||
|
mediaType: "MOVIE" | "EPISODE";
|
||||||
|
movieId?: string;
|
||||||
|
episodeId?: string;
|
||||||
|
seriesId?: string;
|
||||||
|
positionMs: number;
|
||||||
|
durationMs: number;
|
||||||
|
completed?: boolean;
|
||||||
|
hiddenFromContinue?: boolean;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const positionMs = Math.max(0, Math.floor(body.positionMs || 0));
|
||||||
|
const durationMs = Math.max(0, Math.floor(body.durationMs || 0));
|
||||||
|
const autoComplete =
|
||||||
|
body.completed === true ||
|
||||||
|
(durationMs > 0 && positionMs / durationMs >= 0.9);
|
||||||
|
|
||||||
|
if (body.mediaType === "MOVIE") {
|
||||||
|
if (!body.movieId) throw new AppError("INVALID_REQUEST", "movieId verplicht", 400);
|
||||||
|
return prisma.watchProgress.upsert({
|
||||||
|
where: {
|
||||||
|
viewerUserId_movieId: { viewerUserId: viewerId, movieId: body.movieId },
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
viewerUserId: viewerId,
|
||||||
|
mediaType: "MOVIE",
|
||||||
|
movieId: body.movieId,
|
||||||
|
positionMs,
|
||||||
|
durationMs,
|
||||||
|
completed: autoComplete,
|
||||||
|
hiddenFromContinue: body.hiddenFromContinue ?? false,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
positionMs,
|
||||||
|
durationMs,
|
||||||
|
completed: body.completed ?? autoComplete,
|
||||||
|
...(body.hiddenFromContinue !== undefined
|
||||||
|
? { hiddenFromContinue: body.hiddenFromContinue }
|
||||||
|
: autoComplete
|
||||||
|
? { hiddenFromContinue: true }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.episodeId) throw new AppError("INVALID_REQUEST", "episodeId verplicht", 400);
|
||||||
|
let seriesId = body.seriesId ?? null;
|
||||||
|
if (!seriesId) {
|
||||||
|
const ep = await prisma.episode.findUnique({ where: { id: body.episodeId } });
|
||||||
|
seriesId = ep?.seriesId ?? null;
|
||||||
|
}
|
||||||
|
return prisma.watchProgress.upsert({
|
||||||
|
where: {
|
||||||
|
viewerUserId_episodeId: { viewerUserId: viewerId, episodeId: body.episodeId },
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
viewerUserId: viewerId,
|
||||||
|
mediaType: "EPISODE",
|
||||||
|
episodeId: body.episodeId,
|
||||||
|
seriesId,
|
||||||
|
positionMs,
|
||||||
|
durationMs,
|
||||||
|
completed: autoComplete,
|
||||||
|
hiddenFromContinue: body.hiddenFromContinue ?? false,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
positionMs,
|
||||||
|
durationMs,
|
||||||
|
seriesId,
|
||||||
|
completed: body.completed ?? autoComplete,
|
||||||
|
...(body.hiddenFromContinue !== undefined
|
||||||
|
? { hiddenFromContinue: body.hiddenFromContinue }
|
||||||
|
: autoComplete
|
||||||
|
? { hiddenFromContinue: true }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async markWatched(viewerId: string, opts: { movieId?: string; episodeId?: string }) {
|
||||||
|
if (opts.movieId) {
|
||||||
|
return this.upsertProgress(viewerId, {
|
||||||
|
mediaType: "MOVIE",
|
||||||
|
movieId: opts.movieId,
|
||||||
|
positionMs: 1,
|
||||||
|
durationMs: 1,
|
||||||
|
completed: true,
|
||||||
|
hiddenFromContinue: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (opts.episodeId) {
|
||||||
|
return this.upsertProgress(viewerId, {
|
||||||
|
mediaType: "EPISODE",
|
||||||
|
episodeId: opts.episodeId,
|
||||||
|
positionMs: 1,
|
||||||
|
durationMs: 1,
|
||||||
|
completed: true,
|
||||||
|
hiddenFromContinue: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new AppError("INVALID_REQUEST", "movieId of episodeId verplicht", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
async dismissContinue(viewerId: string, progressId: string) {
|
||||||
|
const row = await prisma.watchProgress.findFirst({
|
||||||
|
where: { id: progressId, viewerUserId: viewerId },
|
||||||
|
});
|
||||||
|
if (!row) throw new AppError("NOT_FOUND", "Niet gevonden", 404);
|
||||||
|
await prisma.watchProgress.update({
|
||||||
|
where: { id: progressId },
|
||||||
|
data: { hiddenFromContinue: true },
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getShelfPrefs(viewerId: string) {
|
||||||
|
const shelves = await prisma.libraryShelf.findMany({
|
||||||
|
where: { enabled: true },
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
});
|
||||||
|
const prefs = await prisma.viewerShelfPreference.findMany({
|
||||||
|
where: { viewerUserId: viewerId },
|
||||||
|
});
|
||||||
|
const prefMap = new Map(prefs.map((p) => [p.shelfId, p]));
|
||||||
|
return shelves.map((s, idx) => {
|
||||||
|
const pref = prefMap.get(s.id);
|
||||||
|
return {
|
||||||
|
shelfId: s.id,
|
||||||
|
name: s.name,
|
||||||
|
slug: s.slug,
|
||||||
|
kind: s.kind,
|
||||||
|
visible: pref?.visible ?? true,
|
||||||
|
sortOrder: pref?.sortOrder ?? s.sortOrder * 10 + idx,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async setShelfPrefs(
|
||||||
|
viewerId: string,
|
||||||
|
items: Array<{ shelfId: string; visible: boolean; sortOrder: number }>
|
||||||
|
) {
|
||||||
|
for (const item of items) {
|
||||||
|
await prisma.viewerShelfPreference.upsert({
|
||||||
|
where: {
|
||||||
|
viewerUserId_shelfId: { viewerUserId: viewerId, shelfId: item.shelfId },
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
viewerUserId: viewerId,
|
||||||
|
shelfId: item.shelfId,
|
||||||
|
visible: item.visible,
|
||||||
|
sortOrder: item.sortOrder,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
visible: item.visible,
|
||||||
|
sortOrder: item.sortOrder,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.getShelfPrefs(viewerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async movieDetail(movieId: string) {
|
||||||
|
const movie = await prisma.movie.findUnique({
|
||||||
|
where: { id: movieId },
|
||||||
|
include: {
|
||||||
|
mediaFiles: {
|
||||||
|
where: { available: true },
|
||||||
|
include: { node: { select: { id: true, name: true, status: true } } },
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!movie) throw new AppError("NOT_FOUND", "Film niet gevonden", 404);
|
||||||
|
return {
|
||||||
|
type: "movie" as const,
|
||||||
|
id: movie.id,
|
||||||
|
title: movie.title,
|
||||||
|
year: movie.year,
|
||||||
|
overview: movie.overview,
|
||||||
|
posterUrl: movie.posterUrl,
|
||||||
|
backdropUrl: movie.backdropUrl,
|
||||||
|
runtime: movie.runtime,
|
||||||
|
genres: movie.genres,
|
||||||
|
mediaFiles: movie.mediaFiles.map((f) => ({
|
||||||
|
id: f.id,
|
||||||
|
resolution: f.resolution,
|
||||||
|
hdrType: f.hdrType,
|
||||||
|
videoCodec: f.videoCodec,
|
||||||
|
releaseName: f.releaseName,
|
||||||
|
nodeName: f.node.name,
|
||||||
|
nodeOnline: f.node.status === "ONLINE",
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async seriesDetail(seriesId: string) {
|
||||||
|
const series = await prisma.series.findUnique({
|
||||||
|
where: { id: seriesId },
|
||||||
|
include: {
|
||||||
|
seasons: {
|
||||||
|
orderBy: { seasonNumber: "asc" },
|
||||||
|
include: {
|
||||||
|
episodes: {
|
||||||
|
orderBy: { episodeNumber: "asc" },
|
||||||
|
include: {
|
||||||
|
mediaFiles: {
|
||||||
|
where: { available: true },
|
||||||
|
select: { id: true },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!series) throw new AppError("NOT_FOUND", "Serie niet gevonden", 404);
|
||||||
|
return {
|
||||||
|
type: "series" as const,
|
||||||
|
id: series.id,
|
||||||
|
title: series.title,
|
||||||
|
year: series.year,
|
||||||
|
overview: series.overview,
|
||||||
|
posterUrl: series.posterUrl,
|
||||||
|
backdropUrl: series.backdropUrl,
|
||||||
|
seasons: series.seasons.map((s) => ({
|
||||||
|
seasonNumber: s.seasonNumber,
|
||||||
|
episodes: s.episodes.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
seasonNumber: e.seasonNumber,
|
||||||
|
episodeNumber: e.episodeNumber,
|
||||||
|
title: e.title,
|
||||||
|
overview: e.overview,
|
||||||
|
stillUrl: e.stillUrl,
|
||||||
|
runtime: e.runtime,
|
||||||
|
mediaFileId: e.mediaFiles[0]?.id ?? null,
|
||||||
|
available: e.mediaFiles.length > 0,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async play(mediaFileId: string, viewerId: string) {
|
||||||
|
const session = await this.playback.createSession(mediaFileId, viewerId, undefined);
|
||||||
|
return {
|
||||||
|
streamUrl: session.streamUrl,
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
mediaFileId: session.mediaFileId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private publicBase(): string {
|
||||||
|
const base = (this.config.CLIENT_LINK_BASE_URL || this.config.PUBLIC_URL).replace(/\/+$/, "");
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireViewer(
|
||||||
|
request: FastifyRequest,
|
||||||
|
service: ViewerService
|
||||||
|
): Promise<AuthedViewer> {
|
||||||
|
return service.authFromBearer(request.headers.authorization);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue