Add player controls and configurable profiles
All checks were successful
Release Dry Run / release-dry-run (push) Successful in 1m34s
Build / build (push) Successful in 11m47s
Template Compliance / compliance (push) Successful in 5s

This commit is contained in:
MrSphay
2026-05-15 21:36:22 +02:00
parent 9fbd79c7ef
commit 7a5cc2f64b
27 changed files with 592 additions and 56 deletions

View File

@@ -5,3 +5,4 @@ POSTGRES_DB="watchlink"
POSTGRES_USER="watchlink"
POSTGRES_PASSWORD="watchlink"
HOST_PORT="3000"
# Uploaded avatars are stored in the Docker volume `avatar-uploads`.

1
.gitignore vendored
View File

@@ -12,3 +12,4 @@ docker-compose.override.yml
Thumbs.db
.kit/
package-registry/
public/uploads/

View File

@@ -26,6 +26,7 @@ COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/node_modules ./node_modules
RUN mkdir -p /app/public/uploads/avatars && chown -R nextjs:nodejs /app/public/uploads
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

View File

@@ -11,6 +11,8 @@ WatchLink is a self-hosted shared-watch web app with persistent user rooms, loca
- Every user gets a stable personal room like `/rooms/@username`.
- Friend requests and room access are modeled for friends-only, public, role-restricted, and explicit access.
- Admin surface for rooms, users, roles, and permissions.
- Site-wide instance settings for registration mode, default room visibility, media providers, and avatar upload limits.
- Profile settings with display name and profile picture upload.
- Shared playback state via Socket.IO.
- Media URL normalization for YouTube, Twitch, and direct video URLs.
- Uptime Kuma/Dockge-inspired app shell with system light/dark theme.
@@ -94,10 +96,13 @@ services:
PORT: 3000
ports:
- "${HOST_PORT:-3000}:3000"
volumes:
- avatar-uploads:/app/public/uploads
command: sh -c "./node_modules/.bin/prisma migrate deploy && node server.js"
volumes:
postgres-data:
avatar-uploads:
```
Start the stack:
@@ -106,7 +111,7 @@ Start the stack:
docker compose up --build
```
The Compose stack exposes the web app on `http://localhost:${HOST_PORT:-3000}` and uses a Postgres volume named `watchlink_postgres-data`. Keep the container `PORT` at `3000`; change `HOST_PORT` when another container already uses port 3000 on the host.
The Compose stack exposes the web app on `http://localhost:${HOST_PORT:-3000}` and uses named volumes for Postgres data and uploaded avatars. Keep the container `PORT` at `3000`; change `HOST_PORT` when another container already uses port 3000 on the host.
On first start, the web container runs `prisma migrate deploy` before starting Next.js. This creates the required tables in a clean Postgres volume.

View File

@@ -26,7 +26,10 @@ services:
PORT: 3000
ports:
- "${HOST_PORT:-3000}:3000"
volumes:
- avatar-uploads:/app/public/uploads
command: sh -c "./node_modules/.bin/prisma migrate deploy && node server.js"
volumes:
postgres-data:
avatar-uploads:

View File

@@ -0,0 +1,9 @@
ALTER TABLE "User" ADD COLUMN "avatarUrl" TEXT;
CREATE TABLE "AppSetting" (
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AppSetting_pkey" PRIMARY KEY ("key")
);

View File

@@ -38,6 +38,7 @@ model User {
username String @unique
passwordHash String
displayName String?
avatarUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
roles UserRole[]
@@ -48,6 +49,12 @@ model User {
submitted MediaSource[]
}
model AppSetting {
key String @id
value String
updatedAt DateTime @updatedAt
}
model Role {
id String @id @default(cuid())
name String @unique

View File

@@ -41,7 +41,7 @@ export default async function ProfilePage() {
<section className="split-grid">
<Panel title="Account" eyebrow="Identity">
<div className="profile-block">
<Avatar name={user.displayName || user.username} size={64} />
<Avatar name={user.displayName || user.username} src={user.avatarUrl} size={64} />
<div className="row-title">
<strong>{user.displayName || user.username}</strong>
<span>@{user.username}</span>

View File

@@ -1,40 +1,59 @@
import { AppShell } from "@/components/app-shell";
import { Avatar } from "@/components/avatar";
import { EmptyState, PageHeader, Panel, StatusDot } from "@/components/ui";
import { updateProfile } from "@/lib/profile-actions";
import { getShellContext } from "@/lib/shell";
import { requireCurrentUser } from "@/lib/session";
import { getAppSettings } from "@/lib/settings";
import { requireInitialSetup } from "@/lib/setup";
export const dynamic = "force-dynamic";
export default async function AccountSettingsPage() {
export default async function AccountSettingsPage({ searchParams }: { searchParams: Promise<{ error?: string; saved?: string }> }) {
await requireInitialSetup();
const user = await requireCurrentUser();
const shell = await getShellContext(user);
const settings = await getAppSettings();
const { error, saved } = await searchParams;
return (
<AppShell active="Overview" {...shell}>
<PageHeader
title="Settings"
description="Account preferences and session options. Controls stay disabled until matching server actions exist."
meta={<StatusDot tone="good" label="account loaded" />}
description="Account preferences, display name, avatar upload, and session options."
meta={
<>
<StatusDot tone="good" label="account loaded" />
<StatusDot tone="info" label={`${settings.maxAvatarUploadMb} MB avatar limit`} />
</>
}
/>
<section className="split-grid">
<Panel title="Profile Settings" eyebrow="Account">
<form className="form compact-form">
{saved ? <p className="form-success">Profile updated.</p> : null}
{error === "avatar" ? <p className="form-error">Upload a PNG, JPG, WEBP, or GIF below {settings.maxAvatarUploadMb} MB.</p> : null}
<form className="form compact-form" action={updateProfile} encType="multipart/form-data">
<div className="profile-block">
<Avatar name={user.displayName || user.username} src={user.avatarUrl} size={72} />
<div className="row-title">
<strong>{user.displayName || user.username}</strong>
<span>@{user.username}</span>
</div>
</div>
<label>
Display name
<input className="input" defaultValue={user.displayName || user.username} disabled />
<input className="input" name="displayName" defaultValue={user.displayName || user.username} maxLength={80} />
</label>
<label>
Avatar
<input className="input" placeholder="Avatar upload is not wired yet" disabled />
Profile picture
<input className="input" name="avatar" type="file" accept="image/png,image/jpeg,image/webp,image/gif" />
</label>
<button className="button primary" type="button" disabled>Save profile</button>
<button className="button primary" type="submit">Save profile</button>
</form>
</Panel>
<Panel title="Sessions" eyebrow="Security">
<EmptyState title="Session list is not persisted yet" description="The active session is managed by a signed HTTP-only cookie." />
<EmptyState title="Only the current session is tracked" description="The active session is managed by a signed HTTP-only cookie; device-level session history still needs a session table." />
</Panel>
</section>
</AppShell>

View File

@@ -112,7 +112,7 @@ export default async function ActivityPage() {
recentMedia.slice(0, 5).map((item) => (
<div className="row" key={item.id}>
<div className="row-main">
<Avatar name={item.submitter?.displayName || item.submitter?.username || "WL"} size={34} />
<Avatar name={item.submitter?.displayName || item.submitter?.username || "WL"} src={item.submitter?.avatarUrl} size={34} />
<div className="row-title">
<strong>{item.title || item.originalUrl}</strong>
<span>{item.room.name}</span>

View File

@@ -10,12 +10,14 @@ import { getShellContext } from "@/lib/shell";
import { requireCurrentUser, userIsAdmin } from "@/lib/session";
import { requireInitialSetup } from "@/lib/setup";
import { DataTable, EmptyState, MetricTile, PageHeader, Panel, StatusDot, Tabs } from "@/components/ui";
import { getAppSettings, type AppSettings } from "@/lib/settings";
import { updateInstanceSettings, updateSecuritySettings } from "@/lib/settings-actions";
export const dynamic = "force-dynamic";
const adminTabs = ["Users", "Rooms", "Roles", "Invites", "Instance", "Security"];
export default async function AdminPage({ searchParams }: { searchParams: Promise<{ tab?: string; q?: string }> }) {
export default async function AdminPage({ searchParams }: { searchParams: Promise<{ tab?: string; q?: string; saved?: string }> }) {
await requireInitialSetup();
const user = await requireCurrentUser();
@@ -23,12 +25,12 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
redirect("/dashboard");
}
const { tab = "Users", q = "" } = await searchParams;
const { tab = "Users", q = "", saved } = await searchParams;
const activeTab = adminTabs.includes(tab) ? tab : "Users";
const query = q.trim().toLowerCase();
const shell = await getShellContext(user);
const [users, rooms, roles, pendingRequests] = await Promise.all([
const [users, rooms, roles, pendingRequests, appSettings] = await Promise.all([
prisma.user.findMany({
include: { roles: { include: { role: true } }, _count: { select: { ownedRooms: true, roomMembers: true } } },
orderBy: { createdAt: "asc" }
@@ -41,7 +43,8 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
include: { _count: { select: { users: true, permissions: true } } },
orderBy: { name: "asc" }
}),
prisma.friendship.count({ where: { status: "PENDING" } })
prisma.friendship.count({ where: { status: "PENDING" } }),
getAppSettings()
]);
const filteredUsers = query
? users.filter((account) =>
@@ -103,8 +106,8 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
{activeTab === "Rooms" ? <RoomsTable rooms={filteredRooms} /> : null}
{activeTab === "Roles" ? <RolesTable roles={filteredRoles} /> : null}
{activeTab === "Invites" ? <InvitesPanel pendingRequests={pendingRequests} /> : null}
{activeTab === "Instance" ? <InstancePanel userCount={users.length} roomCount={rooms.length} /> : null}
{activeTab === "Security" ? <SecurityPanel /> : null}
{activeTab === "Instance" ? <InstancePanel userCount={users.length} roomCount={rooms.length} settings={appSettings} saved={saved} /> : null}
{activeTab === "Security" ? <SecurityPanel settings={appSettings} saved={saved} /> : null}
</Panel>
</AppShell>
);
@@ -117,6 +120,7 @@ function UsersTable({
id: string;
username: string;
displayName: string | null;
avatarUrl: string | null;
createdAt: Date;
roles: Array<{ roleId: string; role: { name: string } }>;
_count: { ownedRooms: number; roomMembers: number };
@@ -139,7 +143,7 @@ function UsersTable({
<tr key={account.id}>
<td>
<div className="row-main">
<Avatar name={account.displayName || account.username} size={30} />
<Avatar name={account.displayName || account.username} src={account.avatarUrl} size={30} />
<div className="row-title">
<strong>{account.displayName || account.username}</strong>
<span>@{account.username}</span>
@@ -275,9 +279,45 @@ function InvitesPanel({ pendingRequests }: { pendingRequests: number }) {
);
}
function InstancePanel({ userCount, roomCount }: { userCount: number; roomCount: number }) {
function InstancePanel({ userCount, roomCount, settings, saved }: { userCount: number; roomCount: number; settings: AppSettings; saved?: string }) {
return (
<div className="split-grid">
<Panel title="Instance Settings" eyebrow="Site-wide">
{saved ? <p className="form-success">Instance settings saved.</p> : null}
<form className="form compact-form" action={updateInstanceSettings}>
<label>
Instance name
<input className="input" name="instanceName" defaultValue={settings.instanceName} maxLength={80} />
</label>
<label>
Description
<textarea className="input textarea" name="instanceDescription" defaultValue={settings.instanceDescription} maxLength={240} />
</label>
<label>
Default room visibility
<select className="input" name="defaultRoomVisibility" defaultValue={settings.defaultRoomVisibility}>
<option value="FRIENDS">Friends-only</option>
<option value="PUBLIC">Public</option>
<option value="EXPLICIT">Explicit members</option>
<option value="ROLE_RESTRICTED">Role restricted</option>
</select>
</label>
<label>
Avatar upload limit in MB
<input className="input" name="maxAvatarUploadMb" type="number" min="1" max="10" defaultValue={settings.maxAvatarUploadMb} />
</label>
<fieldset className="checkbox-grid">
<legend>Allowed media providers</legend>
{["YOUTUBE", "TWITCH", "DIRECT"].map((provider) => (
<label key={provider}>
<input name="allowedProviders" type="checkbox" value={provider} defaultChecked={settings.allowedProviders.includes(provider)} />
{provider}
</label>
))}
</fieldset>
<button className="button primary" type="submit">Save instance settings</button>
</form>
</Panel>
<Panel title="Runtime" eyebrow="Container">
<div className="settings-list">
<Setting icon={<Database size={16} />} label="Database" value="Postgres via DATABASE_URL" />
@@ -297,15 +337,27 @@ function InstancePanel({ userCount, roomCount }: { userCount: number; roomCount:
);
}
function SecurityPanel() {
function SecurityPanel({ settings, saved }: { settings: AppSettings; saved?: string }) {
return (
<div className="split-grid">
<Panel title="Security Controls" eyebrow="Policy">
<div className="settings-list">
<Setting icon={<LockKeyhole size={16} />} label="Password storage" value="bcrypt hash" />
<Setting icon={<Shield size={16} />} label="Admin check" value="Server-side role lookup" />
<Setting icon={<AlertTriangle size={16} />} label="Registration mode" value="Open registration" />
</div>
{saved ? <p className="form-success">Security settings saved.</p> : null}
<form className="form compact-form" action={updateSecuritySettings}>
<label>
Registration mode
<select className="input" name="registrationMode" defaultValue={settings.registrationMode}>
<option value="OPEN">Open registration</option>
<option value="INVITE_ONLY">Invite only</option>
<option value="DISABLED">Disabled</option>
</select>
</label>
<div className="settings-list">
<Setting icon={<LockKeyhole size={16} />} label="Password storage" value="bcrypt hash" />
<Setting icon={<Shield size={16} />} label="Admin check" value="Server-side role lookup" />
<Setting icon={<AlertTriangle size={16} />} label="Registration mode" value={settings.registrationMode.toLowerCase().replace("_", " ")} />
</div>
<button className="button primary" type="submit">Save security settings</button>
</form>
</Panel>
<Panel title="Danger Zone" eyebrow="Destructive">
<EmptyState title="Destructive actions are disabled" description="User banning, registration mode changes, and deletion need server actions before controls become active." />

View File

@@ -156,7 +156,7 @@ export default async function DashboardPage() {
recentMedia.map((item) => (
<div className="row" key={item.id}>
<div className="row-main">
<Avatar name={item.submitter?.displayName || item.submitter?.username || "WL"} size={34} />
<Avatar name={item.submitter?.displayName || item.submitter?.username || "WL"} src={item.submitter?.avatarUrl} size={34} />
<div className="row-title">
<strong>{item.title || item.originalUrl}</strong>
<span>{item.room.name} by {item.submitter?.displayName || item.submitter?.username || "Unknown"}</span>

View File

@@ -49,7 +49,8 @@ a {
button,
input,
select {
select,
textarea {
font: inherit;
}
@@ -267,6 +268,12 @@ select {
background: #05070a;
}
.twitch-embed iframe {
width: 100%;
height: 100%;
border: 0;
}
.video-state h2 {
margin: 0 0 10px;
font-size: 24px;
@@ -317,6 +324,34 @@ select {
padding: 8px 10px;
}
.textarea {
min-height: 92px;
resize: vertical;
}
.checkbox-grid {
display: grid;
gap: 8px;
border: 1px solid var(--border);
border-radius: 8px;
margin: 0;
padding: 10px;
}
.checkbox-grid legend {
color: var(--muted);
font-size: 12px;
font-weight: 800;
padding: 0 4px;
}
.checkbox-grid label {
display: flex;
align-items: center;
gap: 8px;
color: var(--text);
}
.stack {
display: grid;
gap: 12px;
@@ -365,6 +400,13 @@ select {
background: color-mix(in srgb, var(--avatar-color) 18%, var(--panel));
color: var(--avatar-color);
font-weight: 800;
overflow: hidden;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.empty-state {
@@ -432,6 +474,17 @@ select {
font-weight: 700;
}
.form-success {
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border));
border-radius: 8px;
background: color-mix(in srgb, var(--accent) 10%, var(--panel));
color: var(--accent);
margin: 0 0 14px;
padding: 10px 12px;
font-size: 13px;
font-weight: 700;
}
@media (max-width: 1100px) {
.room-layout {
grid-template-columns: 1fr;

View File

@@ -1,6 +1,7 @@
import Link from "next/link";
import { loginUser } from "@/lib/user-actions";
import { hasAdminUser } from "@/lib/setup";
import { getAppSettings } from "@/lib/settings";
import { redirect } from "next/navigation";
export const dynamic = "force-dynamic";
@@ -9,6 +10,7 @@ export default async function LoginPage({ searchParams }: { searchParams: Promis
if (!(await hasAdminUser())) {
redirect("/setup");
}
const settings = await getAppSettings();
const { error } = await searchParams;
return (
@@ -26,7 +28,7 @@ export default async function LoginPage({ searchParams }: { searchParams: Promis
<h1>Login</h1>
<p>Enter your account to open rooms and manage playback.</p>
</div>
{error ? <p className="form-error">Invalid username or password.</p> : null}
{error ? <p className="form-error">{error === "registration-closed" ? "Registration is currently disabled by the administrator." : "Invalid username or password."}</p> : null}
<form className="form" action={loginUser}>
<label>
Username
@@ -39,9 +41,13 @@ export default async function LoginPage({ searchParams }: { searchParams: Promis
<button className="button primary" type="submit">
Login
</button>
<Link className="button" href="/register">
Create account
</Link>
{settings.registrationMode === "OPEN" ? (
<Link className="button" href="/register">
Create account
</Link>
) : (
<p className="disabled-note">Registration is {settings.registrationMode.toLowerCase().replace("_", " ")}.</p>
)}
</form>
</div>
</section>

View File

@@ -18,6 +18,7 @@ type PersonSummary = {
id: string;
username: string;
displayName: string | null;
avatarUrl: string | null;
};
type FriendshipWithUsers = {
@@ -154,7 +155,7 @@ function FriendshipList({
return (
<div className="row" key={friendship.id}>
<div className="row-main">
<Avatar name={person.displayName || person.username} />
<Avatar name={person.displayName || person.username} src={person.avatarUrl} />
<div className="row-title">
<strong>{person.displayName || person.username}</strong>
<span>@{person.username}</span>
@@ -183,7 +184,7 @@ function IncomingList({ requests }: { requests: FriendshipWithUsers[] }) {
{requests.map((request) => (
<div className="row" key={request.id}>
<div className="row-main">
<Avatar name={request.requester.displayName || request.requester.username} />
<Avatar name={request.requester.displayName || request.requester.username} src={request.requester.avatarUrl} />
<div className="row-title">
<strong>{request.requester.displayName || request.requester.username}</strong>
<span>@{request.requester.username}</span>
@@ -236,7 +237,7 @@ function Directory({
<tr key={user.id}>
<td>
<div className="row-main">
<Avatar name={user.displayName || user.username} size={30} />
<Avatar name={user.displayName || user.username} src={user.avatarUrl} size={30} />
<div className="row-title">
<strong>{user.displayName || user.username}</strong>
<span>@{user.username}</span>

View File

@@ -1,6 +1,7 @@
import Link from "next/link";
import { registerUser } from "@/lib/user-actions";
import { hasAdminUser } from "@/lib/setup";
import { getAppSettings } from "@/lib/settings";
import { redirect } from "next/navigation";
export const dynamic = "force-dynamic";
@@ -9,6 +10,10 @@ export default async function RegisterPage({ searchParams }: { searchParams: Pro
if (!(await hasAdminUser())) {
redirect("/setup");
}
const settings = await getAppSettings();
if (settings.registrationMode !== "OPEN") {
redirect("/login?error=registration-closed");
}
const { error } = await searchParams;
return (
@@ -51,5 +56,6 @@ export default async function RegisterPage({ searchParams }: { searchParams: Pro
function registerError(error: string) {
if (error === "username") return "This username is already taken.";
if (error === "closed") return "Registration is currently disabled by the administrator.";
return "Use a username and a password with at least 10 characters.";
}

View File

@@ -103,6 +103,7 @@ export default async function RoomPage({ params }: { params: Promise<{ slug: str
{
id: room.owner.id,
name: room.owner.displayName || room.owner.username,
avatarUrl: room.owner.avatarUrl,
role: "Owner",
status: room.owner.id === user.id ? "Online" : "Available"
}
@@ -111,6 +112,7 @@ export default async function RoomPage({ params }: { params: Promise<{ slug: str
...room.members.map((member) => ({
id: member.userId,
name: member.user.displayName || member.user.username,
avatarUrl: member.user.avatarUrl,
role: member.canManage ? "Manager" : "Member",
status: member.userId === user.id ? "Online" : "Allowed"
}))

View File

@@ -33,6 +33,8 @@ export function AppShell({
active = "Overview",
isAdmin = false,
userName,
userAvatarUrl,
instanceName = "WatchLink",
rooms = [],
pendingRequests = 0,
activeRoomCount = 0
@@ -41,6 +43,8 @@ export function AppShell({
active?: string;
isAdmin?: boolean;
userName?: string;
userAvatarUrl?: string | null;
instanceName?: string;
rooms?: ShellRoom[];
pendingRequests?: number;
activeRoomCount?: number;
@@ -58,7 +62,7 @@ export function AppShell({
<aside className="sidebar">
<Link className="brand" href="/dashboard">
<span className="brand-mark" aria-hidden="true" />
<span>WatchLink</span>
<span>{instanceName}</span>
</Link>
<nav className="nav-list" aria-label="Primary">
{visibleNav.map((item) => {
@@ -101,7 +105,7 @@ export function AppShell({
{userName ? (
<div className="sidebar-user">
<Avatar name={userName} />
<Avatar name={userName} src={userAvatarUrl} />
<div className="row-title">
<strong>{userName}</strong>
<span>{isAdmin ? "Administrator" : "Member"}</span>

View File

@@ -1,6 +1,6 @@
const COLORS = ["#22c55e", "#06b6d4", "#f59e0b", "#f97316", "#84cc16", "#14b8a6", "#64748b"];
export function Avatar({ name, size = 32 }: { name: string; size?: number }) {
export function Avatar({ name, size = 32, src }: { name: string; size?: number; src?: string | null }) {
const normalized = name.trim() || "?";
const color = COLORS[hashString(normalized) % COLORS.length];
const initials = normalized
@@ -15,7 +15,7 @@ export function Avatar({ name, size = 32 }: { name: string; size?: number }) {
style={{ "--avatar-color": color, width: size, height: size, fontSize: Math.max(11, size * 0.36) } as React.CSSProperties}
aria-hidden="true"
>
{initials || "?"}
{src ? <img src={src} alt="" /> : initials || "?"}
</span>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { ChevronDown, ChevronUp, MessageSquareText, Pause, Play, Radio, Settings2, ShieldCheck, SkipForward, Trash2 } from "lucide-react";
import { io } from "socket.io-client";
import { normalizeMediaUrl } from "@/lib/media";
@@ -14,6 +14,31 @@ const socket = io({
autoConnect: false
});
declare global {
interface Window {
Twitch?: {
Player: new (
element: HTMLElement,
options: {
width: string;
height: string;
parent: string[];
autoplay?: boolean;
video?: string;
channel?: string;
}
) => TwitchPlayer;
};
}
}
type TwitchPlayer = {
play: () => void;
pause: () => void;
seek?: (seconds: number) => void;
destroy?: () => void;
};
type QueueItem = {
id: string;
title: string;
@@ -24,9 +49,12 @@ type QueueItem = {
createdAt: string;
};
type MediaCommand = Pick<QueueItem, "provider" | "originalUrl" | "playbackUrl"> | null;
type Participant = {
id: string;
name: string;
avatarUrl?: string | null;
role: string;
status: string;
};
@@ -53,8 +81,12 @@ export function RoomConsole({
const [connected, setConnected] = useState(false);
const [source, setSource] = useState("");
const [rail, setRail] = useState("Activity");
const [activeQueueItem, setActiveQueueItem] = useState<QueueItem | null>(null);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
const twitchPlayerRef = useRef<TwitchPlayer | null>(null);
const previewMedia = useMemo(() => (source ? normalizeMediaUrl(source) : null), [source]);
const currentMedia = previewMedia || queue[0] || null;
const currentMedia = previewMedia || activeQueueItem || queue[0] || null;
function connect() {
if (!socket.connected) {
@@ -64,9 +96,9 @@ export function RoomConsole({
setConnected(true);
}
function emit(event: "media:set" | "playback:play" | "playback:pause" | "playback:seek") {
function emit(event: "media:set" | "playback:play" | "playback:pause" | "playback:seek", mediaOverride?: MediaCommand) {
connect();
const media = previewMedia || queue[0];
const media = mediaOverride || currentMedia;
socket.emit(event, {
provider: media?.provider,
originalUrl: media?.originalUrl,
@@ -75,6 +107,49 @@ export function RoomConsole({
});
}
function postYoutubeCommand(func: string, args: unknown[] = []) {
iframeRef.current?.contentWindow?.postMessage(JSON.stringify({ event: "command", func, args }), "https://www.youtube.com");
}
function controlPlayer(action: "play" | "pause" | "seek") {
const provider = currentMedia?.provider;
if (provider === "YOUTUBE") {
if (action === "play") postYoutubeCommand("playVideo");
if (action === "pause") postYoutubeCommand("pauseVideo");
if (action === "seek") postYoutubeCommand("seekTo", [82, true]);
}
if (provider === "DIRECT" && videoRef.current) {
if (action === "play") void videoRef.current.play();
if (action === "pause") videoRef.current.pause();
if (action === "seek") videoRef.current.currentTime = Math.max(0, videoRef.current.currentTime + 30);
}
if (provider === "TWITCH" && twitchPlayerRef.current) {
if (action === "play") twitchPlayerRef.current.play();
if (action === "pause") twitchPlayerRef.current.pause();
if (action === "seek" && twitchPlayerRef.current.seek) twitchPlayerRef.current.seek(82);
}
}
function handleTransport(event: "playback:play" | "playback:pause" | "playback:seek") {
emit(event);
if (event === "playback:play") controlPlayer("play");
if (event === "playback:pause") controlPlayer("pause");
if (event === "playback:seek") controlPlayer("seek");
}
function playQueueItem(item: QueueItem) {
setActiveQueueItem(item);
setSource("");
emit("media:set", item);
window.setTimeout(() => {
postYoutubeCommand("playVideo");
twitchPlayerRef.current?.play();
void videoRef.current?.play();
}, 800);
}
return (
<section className="watch-console">
<div className="player-column">
@@ -95,6 +170,9 @@ export function RoomConsole({
provider={currentMedia.provider}
playbackUrl={currentMedia.playbackUrl}
title={currentMedia.title || currentMedia.originalUrl}
iframeRef={iframeRef}
videoRef={videoRef}
twitchPlayerRef={twitchPlayerRef}
/>
) : (
<div className="video-state">
@@ -107,13 +185,13 @@ export function RoomConsole({
<form className="transport-bar" action={addMediaToRoom}>
<input type="hidden" name="roomId" value={roomId} />
<button className="icon-button" type="button" onClick={() => emit("playback:play")} title="Play">
<button className="icon-button" type="button" onClick={() => handleTransport("playback:play")} title="Play">
<Play size={16} />
</button>
<button className="icon-button" type="button" onClick={() => emit("playback:pause")} title="Pause">
<button className="icon-button" type="button" onClick={() => handleTransport("playback:pause")} title="Pause">
<Pause size={16} />
</button>
<button className="icon-button" type="button" onClick={() => emit("playback:seek")} title="Seek">
<button className="icon-button" type="button" onClick={() => handleTransport("playback:seek")} title="Seek">
<SkipForward size={16} />
</button>
<input
@@ -124,7 +202,7 @@ export function RoomConsole({
onChange={(event) => setSource(event.target.value)}
placeholder="Paste YouTube, Twitch, or direct video URL"
/>
<button className="button primary" type="submit" onClick={() => emit("media:set")} disabled={!source}>
<button className="button primary" type="submit" onClick={() => emit("media:set", previewMedia)} disabled={!source}>
<Radio size={16} /> Add source
</button>
</form>
@@ -144,7 +222,7 @@ export function RoomConsole({
<span>{item.provider} by {item.by} - {item.createdAt}</span>
</div>
<div className="row-actions">
<button className="icon-button compact" type="button" title="Play now" onClick={() => emit("media:set")}>
<button className="icon-button compact" type="button" title="Play now" onClick={() => playQueueItem(item)}>
<Play size={14} />
</button>
<button className="icon-button compact" type="button" title="Queue reorder persistence is not implemented yet." disabled>
@@ -170,7 +248,7 @@ export function RoomConsole({
participants.map((item) => (
<div className="row" key={item.id}>
<div className="row-main">
<Avatar name={item.name} />
<Avatar name={item.name} src={item.avatarUrl} />
<div className="row-title">
<strong>{item.name}</strong>
<span>{item.role}</span>
@@ -249,12 +327,27 @@ function SettingRow({ icon, label, value }: { icon: React.ReactNode; label: stri
);
}
function MediaPreview({ provider, playbackUrl, title }: { provider: string; playbackUrl: string; title: string }) {
if (provider === "YOUTUBE" || provider === "TWITCH") {
function MediaPreview({
provider,
playbackUrl,
title,
iframeRef,
videoRef,
twitchPlayerRef
}: {
provider: string;
playbackUrl: string;
title: string;
iframeRef: React.RefObject<HTMLIFrameElement | null>;
videoRef: React.RefObject<HTMLVideoElement | null>;
twitchPlayerRef: React.MutableRefObject<TwitchPlayer | null>;
}) {
if (provider === "YOUTUBE") {
return (
<iframe
ref={iframeRef}
className="media-embed"
src={playbackUrl}
src={withBrowserOrigin(playbackUrl)}
title={title}
allow="autoplay; encrypted-media; fullscreen; picture-in-picture"
allowFullScreen
@@ -262,8 +355,12 @@ function MediaPreview({ provider, playbackUrl, title }: { provider: string; play
);
}
if (provider === "TWITCH") {
return <TwitchPreview playbackUrl={playbackUrl} title={title} twitchPlayerRef={twitchPlayerRef} />;
}
if (provider === "DIRECT") {
return <video className="media-embed" src={playbackUrl} controls playsInline />;
return <video ref={videoRef} className="media-embed" src={playbackUrl} controls playsInline />;
}
return (
@@ -276,6 +373,94 @@ function MediaPreview({ provider, playbackUrl, title }: { provider: string; play
);
}
function TwitchPreview({
playbackUrl,
title,
twitchPlayerRef
}: {
playbackUrl: string;
title: string;
twitchPlayerRef: React.MutableRefObject<TwitchPlayer | null>;
}) {
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
let disposed = false;
const container = containerRef.current;
if (!container) return;
async function mount() {
await loadTwitchApi();
if (disposed || !container || !window.Twitch) return;
container.replaceChildren();
twitchPlayerRef.current?.destroy?.();
const options = getTwitchOptions(playbackUrl);
twitchPlayerRef.current = new window.Twitch.Player(container, {
width: "100%",
height: "100%",
parent: [window.location.hostname || "localhost"],
autoplay: false,
...options
});
}
void mount();
return () => {
disposed = true;
twitchPlayerRef.current?.destroy?.();
twitchPlayerRef.current = null;
};
}, [playbackUrl, twitchPlayerRef]);
return <div ref={containerRef} className="media-embed twitch-embed" role="region" aria-label={title} />;
}
function loadTwitchApi() {
if (window.Twitch) return Promise.resolve();
const existing = document.querySelector<HTMLScriptElement>('script[src="https://player.twitch.tv/js/embed/v1.js"]');
if (existing) {
return new Promise<void>((resolve, reject) => {
existing.addEventListener("load", () => resolve(), { once: true });
existing.addEventListener("error", () => reject(new Error("Failed to load Twitch player API.")), { once: true });
});
}
return new Promise<void>((resolve, reject) => {
const script = document.createElement("script");
script.src = "https://player.twitch.tv/js/embed/v1.js";
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error("Failed to load Twitch player API."));
document.head.appendChild(script);
});
}
function getTwitchOptions(playbackUrl: string) {
try {
const url = new URL(playbackUrl);
const video = url.searchParams.get("video");
const channel = url.searchParams.get("channel");
return video ? { video } : { channel: channel || "" };
} catch {
return { channel: "" };
}
}
function withBrowserOrigin(playbackUrl: string) {
if (typeof window === "undefined") return playbackUrl;
try {
const url = new URL(playbackUrl);
url.searchParams.set("enablejsapi", "1");
url.searchParams.set("playsinline", "1");
url.searchParams.set("origin", window.location.origin);
return url.toString();
} catch {
return playbackUrl;
}
}
function formatVisibility(value: string) {
return value.toLowerCase().replace("_", " ");
}

View File

@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
import { normalizeMediaUrl } from "./media";
import { prisma } from "./prisma";
import { requireCurrentUser } from "./session";
import { getAppSettings } from "./settings";
export async function addMediaToRoom(formData: FormData) {
const user = await requireCurrentUser();
@@ -22,7 +23,10 @@ export async function addMediaToRoom(formData: FormData) {
if (!room) return;
const settings = await getAppSettings();
const media = normalizeMediaUrl(sourceUrl);
if (!settings.allowedProviders.includes(media.provider)) return;
await prisma.mediaSource.create({
data: {
roomId: room.id,

View File

@@ -0,0 +1,49 @@
"use server";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { prisma } from "./prisma";
import { requireCurrentUser } from "./session";
import { getAppSettings } from "./settings";
const avatarTypes = new Map([
["image/png", "png"],
["image/jpeg", "jpg"],
["image/webp", "webp"],
["image/gif", "gif"]
]);
export async function updateProfile(formData: FormData) {
const user = await requireCurrentUser();
const displayName = String(formData.get("displayName") || user.username).trim().slice(0, 80) || user.username;
const avatar = formData.get("avatar");
const data: { displayName: string; avatarUrl?: string } = { displayName };
if (avatar instanceof File && avatar.size > 0) {
const settings = await getAppSettings();
const extension = avatarTypes.get(avatar.type);
const maxBytes = settings.maxAvatarUploadMb * 1024 * 1024;
if (!extension || avatar.size > maxBytes) {
redirect("/account/settings?error=avatar");
}
const uploadsDir = path.join(process.cwd(), "public", "uploads", "avatars");
await mkdir(uploadsDir, { recursive: true });
const fileName = `${user.id}-${Date.now()}.${extension}`;
await writeFile(path.join(uploadsDir, fileName), Buffer.from(await avatar.arrayBuffer()));
data.avatarUrl = `/uploads/avatars/${fileName}`;
}
await prisma.user.update({
where: { id: user.id },
data
});
revalidatePath("/account/profile");
revalidatePath("/account/settings");
revalidatePath("/dashboard");
redirect("/account/settings?saved=1");
}

View File

@@ -5,6 +5,7 @@ import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { prisma } from "./prisma";
import { requireCurrentUser } from "./session";
import { getAppSettings } from "./settings";
function normalizeSlug(value: string) {
return value
@@ -17,8 +18,9 @@ function normalizeSlug(value: string) {
export async function createRoom(formData: FormData) {
const user = await requireCurrentUser();
const settings = await getAppSettings();
const name = String(formData.get("name") || "").trim();
const visibility = String(formData.get("visibility") || "FRIENDS");
const visibility = String(formData.get("visibility") || settings.defaultRoomVisibility);
const baseSlug = normalizeSlug(name);
if (!name || !baseSlug) {

View File

@@ -0,0 +1,69 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { prisma } from "./prisma";
import { requireCurrentUser, userIsAdmin } from "./session";
const allowedRegistrationModes = ["OPEN", "INVITE_ONLY", "DISABLED"];
const allowedVisibility = ["PUBLIC", "FRIENDS", "EXPLICIT", "ROLE_RESTRICTED"];
const allowedProviders = ["YOUTUBE", "TWITCH", "DIRECT"];
async function requireAdmin() {
const user = await requireCurrentUser();
if (!userIsAdmin(user)) {
redirect("/dashboard");
}
return user;
}
export async function updateInstanceSettings(formData: FormData) {
await requireAdmin();
const instanceName = String(formData.get("instanceName") || "WatchLink").trim().slice(0, 80) || "WatchLink";
const instanceDescription = String(formData.get("instanceDescription") || "").trim().slice(0, 240);
const defaultRoomVisibility = normalizeChoice(String(formData.get("defaultRoomVisibility") || "FRIENDS"), allowedVisibility, "FRIENDS");
const selectedProviders = allowedProviders.filter((provider) => formData.getAll("allowedProviders").includes(provider));
const maxAvatarUploadMb = Math.min(10, Math.max(1, Number(formData.get("maxAvatarUploadMb") || 2) || 2));
await writeSettings({
instanceName,
instanceDescription,
defaultRoomVisibility,
allowedProviders: selectedProviders.length > 0 ? selectedProviders.join(",") : "YOUTUBE,TWITCH,DIRECT",
maxAvatarUploadMb: String(maxAvatarUploadMb)
});
revalidateSettingsPaths();
redirect("/admin?tab=Instance&saved=1");
}
export async function updateSecuritySettings(formData: FormData) {
await requireAdmin();
const registrationMode = normalizeChoice(String(formData.get("registrationMode") || "OPEN"), allowedRegistrationModes, "OPEN");
await writeSettings({ registrationMode });
revalidateSettingsPaths();
redirect("/admin?tab=Security&saved=1");
}
async function writeSettings(values: Record<string, string>) {
await prisma.$transaction(
Object.entries(values).map(([key, value]) =>
prisma.appSetting.upsert({
where: { key },
update: { value },
create: { key, value }
})
)
);
}
function normalizeChoice<T extends string>(value: string, allowed: T[], fallback: T) {
return allowed.includes(value as T) ? (value as T) : fallback;
}
function revalidateSettingsPaths() {
revalidatePath("/admin");
revalidatePath("/dashboard");
revalidatePath("/rooms");
revalidatePath("/register");
}

47
src/lib/settings.ts Normal file
View File

@@ -0,0 +1,47 @@
import { prisma } from "./prisma";
export type AppSettings = {
instanceName: string;
instanceDescription: string;
registrationMode: "OPEN" | "INVITE_ONLY" | "DISABLED";
defaultRoomVisibility: "PUBLIC" | "FRIENDS" | "EXPLICIT" | "ROLE_RESTRICTED";
allowedProviders: string[];
maxAvatarUploadMb: number;
};
const defaults: AppSettings = {
instanceName: "WatchLink",
instanceDescription: "Persistent shared watch rooms for friends and teams.",
registrationMode: "OPEN",
defaultRoomVisibility: "FRIENDS",
allowedProviders: ["YOUTUBE", "TWITCH", "DIRECT"],
maxAvatarUploadMb: 2
};
export async function getAppSettings(): Promise<AppSettings> {
const rows = await prisma.appSetting.findMany();
const values = new Map(rows.map((row) => [row.key, row.value]));
const registrationMode = parseChoice(values.get("registrationMode"), ["OPEN", "INVITE_ONLY", "DISABLED"], defaults.registrationMode);
const defaultRoomVisibility = parseChoice(
values.get("defaultRoomVisibility"),
["PUBLIC", "FRIENDS", "EXPLICIT", "ROLE_RESTRICTED"],
defaults.defaultRoomVisibility
);
const maxAvatarUploadMb = Number(values.get("maxAvatarUploadMb") || defaults.maxAvatarUploadMb);
return {
instanceName: values.get("instanceName") || defaults.instanceName,
instanceDescription: values.get("instanceDescription") || defaults.instanceDescription,
registrationMode,
defaultRoomVisibility,
allowedProviders: (values.get("allowedProviders") || defaults.allowedProviders.join(","))
.split(",")
.map((item) => item.trim())
.filter(Boolean),
maxAvatarUploadMb: Number.isFinite(maxAvatarUploadMb) && maxAvatarUploadMb > 0 ? maxAvatarUploadMb : defaults.maxAvatarUploadMb
};
}
function parseChoice<T extends string>(value: string | undefined, allowed: T[], fallback: T): T {
return allowed.includes(value as T) ? (value as T) : fallback;
}

View File

@@ -1,10 +1,11 @@
import { prisma } from "./prisma";
import { getAppSettings } from "./settings";
import { getCurrentUser, userIsAdmin } from "./session";
type CurrentUser = NonNullable<Awaited<ReturnType<typeof getCurrentUser>>>;
export async function getShellContext(user: CurrentUser) {
const [rooms, pendingRequests, activeRoomCount] = await Promise.all([
const [rooms, pendingRequests, activeRoomCount, settings] = await Promise.all([
prisma.room.findMany({
where: {
OR: [{ ownerId: user.id }, { members: { some: { userId: user.id } } }, { visibility: "PUBLIC" }]
@@ -14,12 +15,15 @@ export async function getShellContext(user: CurrentUser) {
take: 8
}),
prisma.friendship.count({ where: { receiverId: user.id, status: "PENDING" } }),
prisma.room.count({ where: { mediaSources: { some: {} } } })
prisma.room.count({ where: { mediaSources: { some: {} } } }),
getAppSettings()
]);
return {
isAdmin: userIsAdmin(user),
userName: user.displayName || user.username,
userAvatarUrl: user.avatarUrl,
instanceName: settings.instanceName,
pendingRequests,
activeRoomCount,
rooms: rooms.map((room) => ({

View File

@@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
import { Prisma, type User } from "@prisma/client";
import { prisma } from "./prisma";
import { clearSession, setSession } from "./session";
import { getAppSettings } from "./settings";
function normalizeUsername(value: FormDataEntryValue | null) {
return String(value || "")
@@ -14,6 +15,11 @@ function normalizeUsername(value: FormDataEntryValue | null) {
}
export async function registerUser(formData: FormData) {
const settings = await getAppSettings();
if (settings.registrationMode !== "OPEN") {
redirect("/register?error=closed");
}
const username = normalizeUsername(formData.get("username"));
const password = String(formData.get("password") || "");