- {["Activity", "Chat", "Settings"].map((item) => (
+ {["Activity", "Chat", "Invite", "Settings"].map((item) => (
@@ -300,13 +327,53 @@ export function RoomConsole({
) : null}
+ {rail === "Invite" ? (
+
-
} label="Visibility" value={formatVisibility(roomVisibility)} />
+
+ {savedState ?
Room settings saved.
: null}
+
+
} label="Current visibility" value={formatVisibility(roomVisibility)} />
} label="Playback control" value="All authorized participants" />
} label="Owner" value={ownerName} />
} label="Sync mode" value="Socket.IO room channel" />
-
Editable settings require dedicated server actions; the current values are read-only.
) : null}
diff --git a/src/lib/media-actions.ts b/src/lib/media-actions.ts
index e344243..ffcef03 100644
--- a/src/lib/media-actions.ts
+++ b/src/lib/media-actions.ts
@@ -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
>
+) {
+ 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>
+) {
+ 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");
+}
diff --git a/src/lib/media.ts b/src/lib/media.ts
index 196ead7..543b611 100644
--- a/src/lib/media.ts
+++ b/src/lib/media.ts
@@ -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
};
}
diff --git a/src/lib/room-actions.ts b/src/lib/room-actions.ts
index 562b625..c9b99a3 100644
--- a/src/lib/room-actions.ts
+++ b/src/lib/room-actions.ts
@@ -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>,
+ 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");
+}