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:
Marco Mooren
2026-05-11 17:55:09 +02:00
parent 86690c3753
commit b86dcfbb8d
40 changed files with 3943 additions and 274 deletions

142
server/rooms.js Normal file
View 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);
}