Enable room queue controls
All checks were successful
Template Compliance / compliance (push) Successful in 5s
Release Dry Run / release-dry-run (push) Successful in 1m35s
Build / build (push) Successful in 12m32s

This commit is contained in:
MrSphay
2026-05-15 22:06:33 +02:00
parent 7a5cc2f64b
commit 04d75c386f
8 changed files with 406 additions and 34 deletions

View File

@@ -0,0 +1,11 @@
ALTER TABLE "MediaSource" ADD COLUMN "thumbnailUrl" TEXT;
ALTER TABLE "MediaSource" ADD COLUMN "queuePosition" INTEGER NOT NULL DEFAULT 0;
WITH ordered AS (
SELECT "id", ROW_NUMBER() OVER (PARTITION BY "roomId" ORDER BY "createdAt" ASC, "id" ASC) AS position
FROM "MediaSource"
)
UPDATE "MediaSource"
SET "queuePosition" = ordered.position
FROM ordered
WHERE "MediaSource"."id" = ordered."id";

View File

@@ -134,7 +134,9 @@ model MediaSource {
provider MediaProvider
originalUrl String
playbackUrl String
thumbnailUrl String?
title String?
queuePosition Int @default(0)
createdAt DateTime @default(now())
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
submitter User? @relation(fields: [submitterId], references: [id], onDelete: SetNull)

View File

@@ -302,6 +302,28 @@ textarea {
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition:
transform 140ms ease,
border-color 140ms ease,
background 140ms ease,
color 140ms ease,
box-shadow 140ms ease;
}
.button:hover:not(:disabled),
.icon-button:hover:not(:disabled),
.rail-tabs button:hover:not(:disabled),
.tab:hover {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--accent-2) 45%, var(--border));
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.10);
}
.button:active:not(:disabled),
.icon-button:active:not(:disabled),
.rail-tabs button:active:not(:disabled),
.tab:active {
transform: translateY(0) scale(0.98);
}
.button.primary {
@@ -352,6 +374,13 @@ textarea {
color: var(--text);
}
.inline-check {
display: flex !important;
align-items: center;
gap: 8px !important;
color: var(--text) !important;
}
.stack {
display: grid;
gap: 12px;
@@ -688,6 +717,27 @@ textarea {
font-weight: 800;
}
.queue-thumbnail {
display: grid;
width: 72px;
aspect-ratio: 16 / 9;
flex: 0 0 auto;
place-items: center;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 7px;
background: #05070a;
color: var(--accent-2);
font-size: 12px;
font-weight: 900;
}
.queue-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.queue-row .row-title,
.timeline-item .row-title {
min-width: 0;

View File

@@ -3,26 +3,35 @@ import { RoomConsole } from "@/components/room-console";
import { StatusBadge } from "@/components/status-badge";
import { PageHeader, StatusDot } from "@/components/ui";
import { canEnterRoom } from "@/lib/access";
import { normalizeMediaUrl } from "@/lib/media";
import { prisma } from "@/lib/prisma";
import { getShellContext } from "@/lib/shell";
import { requireCurrentUser, userIsAdmin } from "@/lib/session";
import { requireInitialSetup } from "@/lib/setup";
import { redirect } from "next/navigation";
import Link from "next/link";
export const dynamic = "force-dynamic";
export default async function RoomPage({ params }: { params: Promise<{ slug: string }> }) {
export default async function RoomPage({
params,
searchParams
}: {
params: Promise<{ slug: string }>;
searchParams: Promise<{ rail?: string; saved?: string; error?: string }>;
}) {
await requireInitialSetup();
const user = await requireCurrentUser();
const isAdmin = userIsAdmin(user);
const { slug } = await params;
const { rail = "Activity", saved, error } = await searchParams;
const roomSlug = decodeURIComponent(slug);
const room = await prisma.room.findUnique({
where: { slug: roomSlug },
include: {
owner: true,
members: { include: { user: true } },
mediaSources: { include: { submitter: true }, orderBy: { createdAt: "desc" }, take: 20 }
mediaSources: { include: { submitter: true }, orderBy: [{ queuePosition: "asc" }, { createdAt: "asc" }], take: 40 }
}
});
@@ -76,8 +85,8 @@ export default async function RoomPage({ params }: { params: Promise<{ slug: str
}
actions={
<>
<button className="button" type="button" disabled title="Invite persistence is not implemented yet.">Invite</button>
<button className="button" type="button" disabled title="Room settings require dedicated server actions before editing.">Settings</button>
<Link className="button animated-button" href={`/rooms/${encodeURIComponent(room.slug)}?rail=Invite`}>Invite</Link>
<Link className="button animated-button" href={`/rooms/${encodeURIComponent(room.slug)}?rail=Settings`}>Settings</Link>
</>
}
/>
@@ -87,16 +96,23 @@ export default async function RoomPage({ params }: { params: Promise<{ slug: str
roomName={room.name}
roomVisibility={room.visibility}
ownerName={room.owner?.displayName || room.owner?.username || "Unassigned"}
initialRail={rail}
savedState={saved}
errorState={error}
currentUser={user.displayName || user.username}
queue={room.mediaSources.map((item) => ({
id: item.id,
title: item.title || item.originalUrl,
provider: item.provider,
originalUrl: item.originalUrl,
playbackUrl: item.playbackUrl,
by: item.submitter?.displayName || item.submitter?.username || "Unknown",
createdAt: formatDate(item.createdAt)
}))}
queue={room.mediaSources.map((item) => {
const normalized = normalizeMediaUrl(item.originalUrl);
return {
id: item.id,
title: item.title || item.originalUrl,
provider: item.provider,
originalUrl: item.originalUrl,
playbackUrl: item.playbackUrl,
thumbnailUrl: item.thumbnailUrl || normalized.thumbnailUrl,
by: item.submitter?.displayName || item.submitter?.username || "Unknown",
createdAt: formatDate(item.createdAt)
};
})}
participants={[
...(room.owner
? [

View File

@@ -1,10 +1,11 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { ChevronDown, ChevronUp, MessageSquareText, Pause, Play, Radio, Settings2, ShieldCheck, SkipForward, Trash2 } from "lucide-react";
import { ChevronDown, ChevronUp, MessageSquareText, Pause, Play, Radio, Settings2, ShieldCheck, SkipForward, Trash2, UserPlus } from "lucide-react";
import { io } from "socket.io-client";
import { normalizeMediaUrl } from "@/lib/media";
import { addMediaToRoom } from "@/lib/media-actions";
import { addMediaToRoom, moveMediaDown, moveMediaUp, removeMediaFromRoom, setCurrentMedia } from "@/lib/media-actions";
import { addRoomMember, updateRoomSettings } from "@/lib/room-actions";
import { Avatar } from "./avatar";
import { StatusBadge } from "./status-badge";
import { EmptyState, Panel, StatusDot } from "./ui";
@@ -45,6 +46,7 @@ type QueueItem = {
provider: string;
originalUrl: string;
playbackUrl: string;
thumbnailUrl?: string | null;
by: string;
createdAt: string;
};
@@ -65,6 +67,9 @@ export function RoomConsole({
roomName,
roomVisibility,
ownerName,
initialRail = "Activity",
savedState,
errorState,
currentUser,
queue = [],
participants = []
@@ -74,13 +79,16 @@ export function RoomConsole({
roomName: string;
roomVisibility: string;
ownerName: string;
initialRail?: string;
savedState?: string;
errorState?: string;
currentUser: string;
queue?: QueueItem[];
participants?: Participant[];
}) {
const [connected, setConnected] = useState(false);
const [source, setSource] = useState("");
const [rail, setRail] = useState("Activity");
const [rail, setRail] = useState(["Activity", "Chat", "Invite", "Settings"].includes(initialRail) ? initialRail : "Activity");
const [activeQueueItem, setActiveQueueItem] = useState<QueueItem | null>(null);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
@@ -150,6 +158,10 @@ export function RoomConsole({
}, 800);
}
function submitAndPlay(item: QueueItem) {
playQueueItem(item);
}
return (
<section className="watch-console">
<div className="player-column">
@@ -217,23 +229,38 @@ export function RoomConsole({
{queue.map((item, index) => (
<div className="queue-row" key={item.id}>
<span className="queue-index">{index + 1}</span>
<div className="queue-thumbnail">
{item.thumbnailUrl ? <img src={item.thumbnailUrl} alt="" /> : <span>{item.provider.slice(0, 2)}</span>}
</div>
<div className="row-title">
<strong>{item.title}</strong>
<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={() => playQueueItem(item)}>
<Play size={14} />
</button>
<button className="icon-button compact" type="button" title="Queue reorder persistence is not implemented yet." disabled>
<ChevronUp size={14} />
</button>
<button className="icon-button compact" type="button" title="Queue reorder persistence is not implemented yet." disabled>
<ChevronDown size={14} />
</button>
<button className="icon-button compact danger" type="button" title="Queue removal needs a server action." disabled>
<Trash2 size={14} />
</button>
<form action={setCurrentMedia} onSubmit={() => submitAndPlay(item)}>
<input type="hidden" name="mediaId" value={item.id} />
<button className="icon-button compact animated-button" type="submit" title="Play now">
<Play size={14} />
</button>
</form>
<form action={moveMediaUp}>
<input type="hidden" name="mediaId" value={item.id} />
<button className="icon-button compact animated-button" type="submit" title="Move up" disabled={index === 0}>
<ChevronUp size={14} />
</button>
</form>
<form action={moveMediaDown}>
<input type="hidden" name="mediaId" value={item.id} />
<button className="icon-button compact animated-button" type="submit" title="Move down" disabled={index === queue.length - 1}>
<ChevronDown size={14} />
</button>
</form>
<form action={removeMediaFromRoom}>
<input type="hidden" name="mediaId" value={item.id} />
<button className="icon-button compact danger animated-button" type="submit" title="Remove">
<Trash2 size={14} />
</button>
</form>
</div>
</div>
))}
@@ -265,7 +292,7 @@ export function RoomConsole({
<aside className="right-rail">
<Panel title="Room Rail" eyebrow="Activity and settings">
<div className="rail-tabs" role="tablist" aria-label="Room rail">
{["Activity", "Chat", "Settings"].map((item) => (
{["Activity", "Chat", "Invite", "Settings"].map((item) => (
<button key={item} className={rail === item ? "active" : ""} type="button" onClick={() => setRail(item)}>
{item}
</button>
@@ -300,13 +327,53 @@ export function RoomConsole({
</div>
) : null}
{rail === "Invite" ? (
<div className="settings-list" id="room-invite">
{savedState ? <p className="form-success">Room member saved.</p> : null}
{errorState === "user" ? <p className="form-error">No matching user was found, or the user already owns the room.</p> : null}
<form className="form compact-form" action={addRoomMember}>
<input type="hidden" name="roomId" value={roomId} />
<label>
Username
<input className="input" name="username" placeholder="username" autoComplete="off" required />
</label>
<label className="inline-check">
<input name="canManage" type="checkbox" />
Can manage room
</label>
<button className="button primary animated-button" type="submit">
<UserPlus size={16} /> Add member
</button>
</form>
</div>
) : null}
{rail === "Settings" ? (
<div className="settings-list">
<SettingRow icon={<ShieldCheck size={16} />} label="Visibility" value={formatVisibility(roomVisibility)} />
<div className="settings-list" id="room-settings">
{savedState ? <p className="form-success">Room settings saved.</p> : null}
<form className="form compact-form" action={updateRoomSettings}>
<input type="hidden" name="roomId" value={roomId} />
<label>
Room name
<input className="input" name="name" defaultValue={roomName} maxLength={80} required />
</label>
<label>
Visibility
<select className="input" name="visibility" defaultValue={roomVisibility}>
<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>
<button className="button primary animated-button" type="submit">
<Settings2 size={16} /> Save room settings
</button>
</form>
<SettingRow icon={<ShieldCheck size={16} />} label="Current visibility" value={formatVisibility(roomVisibility)} />
<SettingRow icon={<Play size={16} />} label="Playback control" value="All authorized participants" />
<SettingRow icon={<Settings2 size={16} />} label="Owner" value={ownerName} />
<SettingRow icon={<Radio size={16} />} label="Sync mode" value="Socket.IO room channel" />
<p className="disabled-note">Editable settings require dedicated server actions; the current values are read-only.</p>
</div>
) : null}
</Panel>

View File

@@ -3,7 +3,7 @@
import { revalidatePath } from "next/cache";
import { normalizeMediaUrl } from "./media";
import { prisma } from "./prisma";
import { requireCurrentUser } from "./session";
import { requireCurrentUser, userIsAdmin } from "./session";
import { getAppSettings } from "./settings";
export async function addMediaToRoom(formData: FormData) {
@@ -27,6 +27,8 @@ export async function addMediaToRoom(formData: FormData) {
const media = normalizeMediaUrl(sourceUrl);
if (!settings.allowedProviders.includes(media.provider)) return;
const nextPosition = await prisma.mediaSource.count({ where: { roomId: room.id } });
await prisma.mediaSource.create({
data: {
roomId: room.id,
@@ -34,6 +36,8 @@ export async function addMediaToRoom(formData: FormData) {
provider: media.provider,
originalUrl: media.originalUrl,
playbackUrl: media.playbackUrl,
thumbnailUrl: media.thumbnailUrl,
queuePosition: nextPosition + 1,
title: media.originalUrl
}
});
@@ -54,3 +58,149 @@ export async function addMediaToRoom(formData: FormData) {
revalidatePath(`/rooms/${encodeURIComponent(room.slug)}`);
revalidatePath("/dashboard");
}
export async function removeMediaFromRoom(formData: FormData) {
const { room, media } = await requireMediaManager(formData);
if (!room || !media) return;
await prisma.mediaSource.delete({ where: { id: media.id } });
await normalizeQueue(room.id);
revalidateRoom(room.slug);
}
export async function moveMediaUp(formData: FormData) {
await moveMedia(formData, -1);
}
export async function moveMediaDown(formData: FormData) {
await moveMedia(formData, 1);
}
export async function setCurrentMedia(formData: FormData) {
const user = await requireCurrentUser();
const mediaId = String(formData.get("mediaId") || "");
if (!mediaId) return;
const media = await prisma.mediaSource.findUnique({
where: { id: mediaId },
include: {
room: {
select: {
id: true,
slug: true,
ownerId: true,
visibility: true,
members: { where: { userId: user.id }, select: { canManage: true } }
}
}
}
});
if (!media || !canUseRoom(user.id, media.room, user)) return;
await prisma.room.update({
where: { id: media.roomId },
data: {
currentState: {
provider: media.provider,
originalUrl: media.originalUrl,
playbackUrl: media.playbackUrl,
mediaSourceId: media.id,
updatedBy: user.username,
updatedAt: Date.now()
}
}
});
revalidateRoom(media.room.slug);
}
async function moveMedia(formData: FormData, direction: -1 | 1) {
const { room, media } = await requireMediaManager(formData);
if (!room || !media) return;
const queue = await prisma.mediaSource.findMany({
where: { roomId: room.id },
orderBy: [{ queuePosition: "asc" }, { createdAt: "asc" }, { id: "asc" }],
select: { id: true }
});
const index = queue.findIndex((item) => item.id === media.id);
const target = index + direction;
if (index < 0 || target < 0 || target >= queue.length) return;
const reordered = [...queue];
[reordered[index], reordered[target]] = [reordered[target], reordered[index]];
await prisma.$transaction(
reordered.map((item, itemIndex) =>
prisma.mediaSource.update({
where: { id: item.id },
data: { queuePosition: itemIndex + 1 }
})
)
);
revalidateRoom(room.slug);
}
async function requireMediaManager(formData: FormData) {
const user = await requireCurrentUser();
const mediaId = String(formData.get("mediaId") || "");
if (!mediaId) return { room: null, media: null };
const media = await prisma.mediaSource.findUnique({
where: { id: mediaId },
include: {
room: {
select: {
id: true,
slug: true,
ownerId: true,
visibility: true,
members: { where: { userId: user.id }, select: { canManage: true } }
}
}
}
});
if (!media || !canManageRoom(user.id, media.room, user)) return { room: null, media: null };
return { room: media.room, media };
}
function canUseRoom(
userId: string,
room: { ownerId: string | null; visibility: string; members: Array<{ canManage: boolean }> },
user?: Awaited<ReturnType<typeof requireCurrentUser>>
) {
return room.ownerId === userId || Boolean(user && userIsAdmin(user)) || room.visibility === "PUBLIC" || room.members.length > 0;
}
function canManageRoom(
userId: string,
room: { ownerId: string | null; members: Array<{ canManage: boolean }> },
user?: Awaited<ReturnType<typeof requireCurrentUser>>
) {
return room.ownerId === userId || Boolean(user && userIsAdmin(user)) || room.members.some((member) => member.canManage);
}
async function normalizeQueue(roomId: string) {
const queue = await prisma.mediaSource.findMany({
where: { roomId },
orderBy: [{ queuePosition: "asc" }, { createdAt: "asc" }, { id: "asc" }],
select: { id: true }
});
if (queue.length === 0) return;
await prisma.$transaction(
queue.map((item, index) =>
prisma.mediaSource.update({
where: { id: item.id },
data: { queuePosition: index + 1 }
})
)
);
}
function revalidateRoom(slug: string) {
revalidatePath(`/rooms/${encodeURIComponent(slug)}`);
revalidatePath("/dashboard");
revalidatePath("/rooms");
}

View File

@@ -4,6 +4,7 @@ export type NormalizedMedia = {
provider: "YOUTUBE" | "TWITCH" | "DIRECT" | "UNKNOWN";
originalUrl: string;
playbackUrl: string;
thumbnailUrl?: string;
title?: string;
};
@@ -34,6 +35,7 @@ export function normalizeMediaUrl(input: string): NormalizedMedia {
return {
provider: "YOUTUBE",
originalUrl,
thumbnailUrl: `https://img.youtube.com/vi/${id}/hqdefault.jpg`,
playbackUrl: `https://www.youtube.com/embed/${id}?enablejsapi=1&origin=${encodeURIComponent(
process.env.NEXTAUTH_URL || "http://localhost:3000"
)}`
@@ -48,6 +50,7 @@ export function normalizeMediaUrl(input: string): NormalizedMedia {
return {
provider: "TWITCH",
originalUrl,
thumbnailUrl: "/watchlink-icon-64.png",
playbackUrl: `https://player.twitch.tv/?video=${parts[1]}&parent=${parent}`
};
}
@@ -55,6 +58,7 @@ export function normalizeMediaUrl(input: string): NormalizedMedia {
return {
provider: "TWITCH",
originalUrl,
thumbnailUrl: "/watchlink-icon-64.png",
playbackUrl: `https://player.twitch.tv/?channel=${parts[0]}&parent=${parent}`
};
}
@@ -64,6 +68,7 @@ export function normalizeMediaUrl(input: string): NormalizedMedia {
return {
provider: "DIRECT",
originalUrl,
thumbnailUrl: "/watchlink-icon-64.png",
playbackUrl: originalUrl
};
}

View File

@@ -4,7 +4,7 @@ import { RoomVisibility } from "@prisma/client";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { prisma } from "./prisma";
import { requireCurrentUser } from "./session";
import { requireCurrentUser, userIsAdmin } from "./session";
import { getAppSettings } from "./settings";
function normalizeSlug(value: string) {
@@ -53,3 +53,74 @@ export async function createRoom(formData: FormData) {
revalidatePath("/dashboard");
redirect(`/rooms/${encodeURIComponent(room.slug)}`);
}
export async function updateRoomSettings(formData: FormData) {
const user = await requireCurrentUser();
const roomId = String(formData.get("roomId") || "");
const name = String(formData.get("name") || "").trim().slice(0, 80);
const visibility = String(formData.get("visibility") || "FRIENDS");
const room = await prisma.room.findUnique({
where: { id: roomId },
include: { members: { where: { userId: user.id }, select: { canManage: true } } }
});
if (!room || !canManageRoom(user, room) || !name) return;
const allowedVisibility: RoomVisibility[] = ["PUBLIC", "FRIENDS", "EXPLICIT", "ROLE_RESTRICTED"];
const roomVisibility = allowedVisibility.includes(visibility as RoomVisibility)
? (visibility as RoomVisibility)
: room.visibility;
await prisma.room.update({
where: { id: room.id },
data: {
name,
visibility: roomVisibility
}
});
revalidateRoom(room.slug);
redirect(`/rooms/${encodeURIComponent(room.slug)}?rail=Settings&saved=1`);
}
export async function addRoomMember(formData: FormData) {
const user = await requireCurrentUser();
const roomId = String(formData.get("roomId") || "");
const username = String(formData.get("username") || "").trim().toLowerCase();
const canManage = formData.get("canManage") === "on";
const room = await prisma.room.findUnique({
where: { id: roomId },
include: { members: { where: { userId: user.id }, select: { canManage: true } } }
});
if (!room || !canManageRoom(user, room) || !username) return;
const invited = await prisma.user.findUnique({ where: { username }, select: { id: true } });
if (!invited || invited.id === room.ownerId) {
redirect(`/rooms/${encodeURIComponent(room.slug)}?rail=Invite&error=user`);
}
await prisma.roomMember.upsert({
where: { roomId_userId: { roomId: room.id, userId: invited.id } },
update: { canManage },
create: { roomId: room.id, userId: invited.id, canManage }
});
revalidateRoom(room.slug);
redirect(`/rooms/${encodeURIComponent(room.slug)}?rail=Invite&saved=1`);
}
function canManageRoom(
user: Awaited<ReturnType<typeof requireCurrentUser>>,
room: { ownerId: string | null; members: Array<{ canManage: boolean }> }
) {
return room.ownerId === user.id || userIsAdmin(user) || room.members.some((member) => member.canManage);
}
function revalidateRoom(slug: string) {
revalidatePath(`/rooms/${encodeURIComponent(slug)}`);
revalidatePath("/rooms");
revalidatePath("/dashboard");
}