Add master display UI with audio output management and styling
- Implement main.js for the master display functionality, including WebSocket connection, audio output management, and state handling. - Create style.css for the master display's visual design, ensuring a cohesive look and feel with a dark theme and responsive layout. - Integrate device management with a fallback for non-Electron environments, allowing users to select audio outputs. - Add features for managing favorites, including toggling favorites and filtering by genre. - Enhance user experience with a responsive favorites grid and drag-to-scroll functionality.
This commit is contained in:
142
server/rooms.js
Normal file
142
server/rooms.js
Normal file
@@ -0,0 +1,142 @@
|
||||
// Room model: named multi-client listening sessions.
|
||||
//
|
||||
// Each user has an auto-provisioned "personal" room (slug = `u-<id>`) so the
|
||||
// kiosk Just Works on first login. Shared rooms are explicit creations and
|
||||
// have a member list.
|
||||
|
||||
import { getDb } from './db/index.js';
|
||||
import { slugify } from './stations.js';
|
||||
|
||||
function rowToRoom(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
slug: row.slug,
|
||||
name: row.name,
|
||||
created_by: row.created_by,
|
||||
created_at: row.created_at
|
||||
};
|
||||
}
|
||||
|
||||
export function getRoomBySlug(slug) {
|
||||
return rowToRoom(getDb().prepare('SELECT * FROM rooms WHERE slug = ?').get(slug));
|
||||
}
|
||||
|
||||
export function getRoomById(id) {
|
||||
return rowToRoom(getDb().prepare('SELECT * FROM rooms WHERE id = ?').get(id));
|
||||
}
|
||||
|
||||
export function isMember(roomId, userId) {
|
||||
if (!roomId || !userId) return false;
|
||||
const row = getDb().prepare(
|
||||
'SELECT 1 FROM room_members WHERE room_id = ? AND user_id = ?'
|
||||
).get(roomId, userId);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
export function listRoomsForUser(userId) {
|
||||
return getDb().prepare(`
|
||||
SELECT r.*, rm.role
|
||||
FROM rooms r
|
||||
JOIN room_members rm ON rm.room_id = r.id
|
||||
WHERE rm.user_id = ?
|
||||
ORDER BY r.name COLLATE NOCASE
|
||||
`).all(userId).map((row) => ({ ...rowToRoom(row), role: row.role }));
|
||||
}
|
||||
|
||||
export function listMembers(roomId) {
|
||||
return getDb().prepare(`
|
||||
SELECT u.id, u.username, rm.role, rm.created_at
|
||||
FROM room_members rm JOIN users u ON u.id = rm.user_id
|
||||
WHERE rm.room_id = ?
|
||||
ORDER BY u.username COLLATE NOCASE
|
||||
`).all(roomId);
|
||||
}
|
||||
|
||||
function uniqueRoomSlug(base) {
|
||||
const db = getDb();
|
||||
let slug = base, n = 1;
|
||||
while (db.prepare('SELECT 1 FROM rooms WHERE slug = ?').get(slug)) {
|
||||
n += 1;
|
||||
slug = `${base}-${n}`;
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
export function createRoom({ name, slug, ownerId }) {
|
||||
if (!name) throw new Error('name required');
|
||||
const db = getDb();
|
||||
const baseSlug = slug || slugify(name);
|
||||
const finalSlug = uniqueRoomSlug(baseSlug);
|
||||
const info = db.prepare(
|
||||
'INSERT INTO rooms (slug, name, created_by) VALUES (?, ?, ?)'
|
||||
).run(finalSlug, name, ownerId ?? null);
|
||||
const id = info.lastInsertRowid;
|
||||
if (ownerId) {
|
||||
db.prepare(
|
||||
"INSERT OR IGNORE INTO room_members (room_id, user_id, role) VALUES (?, ?, 'owner')"
|
||||
).run(id, ownerId);
|
||||
}
|
||||
db.prepare(
|
||||
'INSERT OR IGNORE INTO room_state (room_id, playing, volume) VALUES (?, 0, 0.7)'
|
||||
).run(id);
|
||||
return getRoomById(id);
|
||||
}
|
||||
|
||||
export function addMember(roomId, userId, role = 'member') {
|
||||
getDb().prepare(
|
||||
'INSERT OR IGNORE INTO room_members (room_id, user_id, role) VALUES (?, ?, ?)'
|
||||
).run(roomId, userId, role);
|
||||
}
|
||||
|
||||
export function removeMember(roomId, userId) {
|
||||
return getDb().prepare(
|
||||
'DELETE FROM room_members WHERE room_id = ? AND user_id = ?'
|
||||
).run(roomId, userId).changes > 0;
|
||||
}
|
||||
|
||||
/** Idempotently ensure a personal room exists for the user, returning it. */
|
||||
export function ensurePersonalRoom(user) {
|
||||
if (!user) return null;
|
||||
const slug = `u-${user.id}`;
|
||||
const existing = getRoomBySlug(slug);
|
||||
if (existing) return existing;
|
||||
const db = getDb();
|
||||
db.prepare(
|
||||
'INSERT INTO rooms (slug, name, created_by) VALUES (?, ?, ?)'
|
||||
).run(slug, `${user.username}'s room`, user.id);
|
||||
const room = getRoomBySlug(slug);
|
||||
db.prepare(
|
||||
"INSERT OR IGNORE INTO room_members (room_id, user_id, role) VALUES (?, ?, 'owner')"
|
||||
).run(room.id, user.id);
|
||||
db.prepare(
|
||||
'INSERT OR IGNORE INTO room_state (room_id, playing, volume) VALUES (?, 0, 0.7)'
|
||||
).run(room.id);
|
||||
return room;
|
||||
}
|
||||
|
||||
export function getRoomState(roomId) {
|
||||
const row = getDb().prepare('SELECT * FROM room_state WHERE room_id = ?').get(roomId);
|
||||
if (!row) return { station_id: null, playing: false, volume: 0.7, updated_at: null };
|
||||
return {
|
||||
station_id: row.station_id,
|
||||
playing: !!row.playing,
|
||||
volume: row.volume,
|
||||
updated_at: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export function setRoomState(roomId, patch) {
|
||||
const cur = getRoomState(roomId);
|
||||
const next = { ...cur, ...patch };
|
||||
getDb().prepare(`
|
||||
INSERT INTO room_state (room_id, station_id, playing, volume, updated_at)
|
||||
VALUES (?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(room_id) DO UPDATE SET
|
||||
station_id = excluded.station_id,
|
||||
playing = excluded.playing,
|
||||
volume = excluded.volume,
|
||||
updated_at = excluded.updated_at
|
||||
`).run(roomId, next.station_id ?? null, next.playing ? 1 : 0, next.volume ?? 0.7);
|
||||
return getRoomState(roomId);
|
||||
}
|
||||
Reference in New Issue
Block a user