mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
* feat(group-sync): synchronized playback per group (server + Android) [stage 1]
Play a group's shared playlist in lockstep across its displays — start/end items
together — by reusing the video-wall sync primitive with the spatial transform
removed and keyed on group_id instead of wall_id.
Server:
- device_groups += sync_enabled + leader_device_id (optional pin).
- buildPlaylistPayload emits a group_sync:{group_id, is_leader} block ONLY for a
member whose playlist matches the group's shared playlist (playlist-match guard —
a mismatched member is ignored, never synced). Membership via device_group_members.
- Leader auto-election: pinned-if-online -> first online matching member -> stable
fallback; computed (never persisted, so the operator's pin is preserved).
- group:sync / group:sync-request relay among eligible members (mirrors wall:sync,
guarded on membership + playlist match).
- Self-heal: re-push payloads to group members on (re)connect so is_leader refreshes.
- PUT /api/device-groups/:id accepts sync_enabled + leader_device_id and re-pushes.
Android:
- WallController is now mode-aware. WALL = transform + object-fit:fill + forced
follower-mute (UNCHANGED — every wall branch is byte-equivalent when isGroup=false).
GROUP = same leader/follower timing incl. the full video drift controller, but
full-screen (no transform), normal fit, and per-item mute honored (no forced mute).
- WebSocketService: emitGroupSync/emitGroupSyncRequest + onGroupSync/onGroupSyncRequest.
- MainActivity: dispatch emit by mode, parseGroupConfig, onPlaylistUpdate group hook.
Wall path is provably unchanged (additive mode, defaults to WALL). Kotlin compiles;
server suite 407/407.
Stage 2 (follow-up): web + Tizen parity, dashboard sync toggle + leader picker,
offline-sweep leader promotion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(group-sync): web + Tizen player parity [stage 2]
Mirror the Android group-sync generalization in the two web-based players so a group
with a shared playlist syncs across mixed player types, not just Android.
Web player (server/player/index.html):
- group:sync / group:sync-request handlers (index jump + latency-compensated video
drift, same maths as wall:sync) — no audio policy here, per-item mute honored.
- emitGroupSync() + applyGroupSync() (4Hz leader broadcast; follower sync-request).
NO CSS transform, NO forced mute (unlike applyWallMode).
- isFollower now also true for a group follower (suppresses self-advance).
- handlePlaylistUpdate handles data.group_sync (mutually exclusive with wall).
Tizen (tizen/js/player.js WallController + app.js):
- WallController is mode-aware: WALL styles the slice + forced follower semantics
(UNCHANGED when mode !== 'group'); GROUP clears any wall styling (full-screen) and
drives leader/follower timing only. syncId()/clearStageStyle() helpers; emitSync/
onSync/onSyncRequest key the event + id off the mode.
- app.js: group:sync/request socket handlers; onPlaylist enters group sync on a
group_sync block, else exits — content renders through the normal single-zone path.
Realigned the v4-exit-signal-phase3 TIZEN slice (682->696) shifted by the app.js edits.
Wall path is provably unchanged in both (additive group mode). Server suite 407/407;
both players' JS parse.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(group-sync): dashboard UI — sync toggle + leader picker [stage 3]
On each group's header row:
- "Sync" checkbox -> PUT /api/groups/:id { sync_enabled } enables synchronized
playback; server re-pushes to members so they enter/exit sync mode. A hint notes
it needs a group playlist and that a display on a different playlist is ignored.
- When on, a "Leader: Auto / <display>" picker -> { leader_device_id } (null = auto-
elect, which self-heals; or pin a specific member to always lead when online).
Adds api.updateGroup(id, data) and the en i18n strings (en-only, mirroring the
existing per-group UI keys; the i18n parity test is apitoken-scoped).
Frontend parses (ESM); server suite 407/407.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(group-sync): rework to clock/schedule sync + double-buffer + polish
Replace the leader/follower relay model with clock/schedule sync. Every
same-playlist member derives the identical (index, position) locally from a
server-disciplined clock + the deterministic playlist schedule, so sync:
- needs no server at play-time (offline-native), and
- has no leader role to double-elect (kills the split-brain class the leaked
WallController tick produced).
Server
- heartbeat-ack now carries server_ms + echoes client_ms for NTP-style clock
discipline; the client caches the offset (survives an outage).
- POST /groups/:id/resync -> group:resync (manual "Resync now").
- (kept: group_sync payload; leader machinery is now vestigial/ignored.)
Clients (web / Tizen / Android)
- Clock offset disciplined over the heartbeat, cached (localStorage / prefs).
- Schedule engine: pos = (syncedNow mod Sum(duration_sec)) with a CANONICAL
slot formula identical across platforms so mixed-platform groups can't drift.
- Snap-on-load: a fresh clip hard-seeks ONCE to the exact position (was ~5s of
gentle nudge to eat a ~0.3s load offset); steady-state keeps the gentle nudge.
- Double buffer: warm the next clip a few s before the boundary -> instant
switch, no black hold. Android pre-decodes on a throwaway surface so the swap
doesn't flash one wrong-aspect (landscape-stretched) frame.
- In-place duration edits: duration_sec dropped from the change signature and
applied in place, so a duration edit re-anchors the schedule WITHOUT a restart.
- Live-log shows discrete corrections (jump/align/seek) immediately; only the
steady-state line is throttled.
Android
- Fix leaked WallController leader tick: onDestroy() now stops it (Handler on the
main looper outlived the Activity -> zombie broadcaster / split-brain).
Dashboard
- Group leader picker -> "Resync now" button.
Tests: heartbeat-ack clock fields + resync route; exit-signal .wgt slice realigned.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
240 lines
13 KiB
JavaScript
240 lines
13 KiB
JavaScript
const API_BASE = '/api';
|
|
|
|
function getAuthHeaders() {
|
|
const token = localStorage.getItem('token');
|
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
}
|
|
|
|
async function request(url, options = {}) {
|
|
const res = await fetch(API_BASE + url, {
|
|
headers: { 'Content-Type': 'application/json', ...getAuthHeaders(), ...options.headers },
|
|
...options,
|
|
});
|
|
if (res.status === 401) {
|
|
// Token expired or invalid - redirect to login
|
|
localStorage.removeItem('token');
|
|
localStorage.removeItem('user');
|
|
window.location.hash = '#/login';
|
|
window.location.reload();
|
|
throw new Error('Session expired');
|
|
}
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ error: res.statusText }));
|
|
throw new Error(err.error || 'Request failed');
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
export const api = {
|
|
// Devices
|
|
getDevices: () => request('/devices'),
|
|
reorderDevices: (order) => request('/devices/reorder', { method: 'POST', body: JSON.stringify({ order }) }),
|
|
getDevice: (id) => request(`/devices/${id}`),
|
|
updateDevice: (id, data) => request(`/devices/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
|
deleteDevice: (id) => request(`/devices/${id}`, { method: 'DELETE' }),
|
|
// #146 Item D: operator block/unblock — refuses the device at its next register with
|
|
// no restart. Server enforces via the SNAT-safe identity chain (deviceSocket).
|
|
blockDevice: (id) => request(`/devices/${id}/block`, { method: 'POST' }),
|
|
unblockDevice: (id) => request(`/devices/${id}/unblock`, { method: 'POST' }),
|
|
// #150: fingerprint-keyed settings snapshots of previously-removed devices (this workspace),
|
|
// and the re-adopt action that applies a snapshot onto a newly-paired device.
|
|
getRemovedDevices: () => request('/devices/removed'),
|
|
reAdoptDevice: (id, fingerprint) => request(`/devices/${id}/re-adopt`, { method: 'POST', body: JSON.stringify({ fingerprint }) }),
|
|
|
|
// #109 PiP overlay: push/clear a floating overlay on a device or group. `id` may be a
|
|
// device id OR a group id (the server resolves + expands). Needs full scope (no-op for JWT).
|
|
sendPip: (id, opts) => request('/pip', { method: 'POST', body: JSON.stringify({ device_id: id, ...opts }) }),
|
|
clearPip: (id, pipId) => request('/pip/clear', { method: 'POST', body: JSON.stringify({ device_id: id, pip_id: pipId || undefined }) }),
|
|
|
|
// Provisioning
|
|
pairDevice: (pairing_code, name) => request('/provision/pair', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ pairing_code, name })
|
|
}),
|
|
|
|
// Content
|
|
getContent: (folderId) => {
|
|
if (folderId === undefined) return request('/content');
|
|
const q = folderId === null ? 'root' : encodeURIComponent(folderId);
|
|
return request(`/content?folder_id=${q}`);
|
|
},
|
|
getContentItem: (id) => request(`/content/${id}`),
|
|
deleteContent: (id) => request(`/content/${id}`, { method: 'DELETE' }),
|
|
updateContent: (id, data) => request(`/content/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
|
moveContent: (id, folderId) => request(`/content/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ folder_id: folderId })
|
|
}),
|
|
|
|
// Folders
|
|
getFolders: () => request('/folders'),
|
|
createFolder: (name, parentId) => request('/folders', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ name, parent_id: parentId || null })
|
|
}),
|
|
renameFolder: (id, name) => request(`/folders/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ name })
|
|
}),
|
|
moveFolder: (id, parentId) => request(`/folders/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ parent_id: parentId || null })
|
|
}),
|
|
deleteFolder: (id) => request(`/folders/${id}`, { method: 'DELETE' }),
|
|
uploadContent: async (file, onProgress) => {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const xhr = new XMLHttpRequest();
|
|
xhr.open('POST', `${API_BASE}/content`);
|
|
const token = localStorage.getItem('token');
|
|
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
|
if (onProgress) {
|
|
xhr.upload.onprogress = (e) => {
|
|
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
|
|
};
|
|
}
|
|
xhr.onload = () => {
|
|
if (xhr.status >= 200 && xhr.status < 300) {
|
|
resolve(JSON.parse(xhr.responseText));
|
|
} else {
|
|
reject(new Error('Upload failed'));
|
|
}
|
|
};
|
|
xhr.onerror = () => reject(new Error('Upload failed'));
|
|
xhr.send(formData);
|
|
});
|
|
},
|
|
|
|
addRemoteContent: (url, name, mime_type) => request('/content/remote', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ url, name, mime_type })
|
|
}),
|
|
|
|
addYoutubeContent: (url, name) => request('/content/youtube', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ url, name })
|
|
}),
|
|
|
|
// Assignments
|
|
getAssignments: (deviceId) => request(`/assignments/device/${deviceId}`),
|
|
addAssignment: (deviceId, data) => request(`/assignments/device/${deviceId}`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(data)
|
|
}),
|
|
updateAssignment: (id, data) => request(`/assignments/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
|
deleteAssignment: (id) => request(`/assignments/${id}`, { method: 'DELETE' }),
|
|
reorderAssignments: (deviceId, order) => request(`/assignments/device/${deviceId}/reorder`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ order })
|
|
}),
|
|
|
|
// Widgets
|
|
getWidgets: () => request('/widgets'),
|
|
|
|
// Device Groups
|
|
getGroups: () => request('/groups'),
|
|
createGroup: (name, color) => request('/groups', { method: 'POST', body: JSON.stringify({ name, color }) }),
|
|
updateGroup: (id, data) => request(`/groups/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
|
resyncGroup: (id) => request(`/groups/${id}/resync`, { method: 'POST' }),
|
|
deleteGroup: (id) => request(`/groups/${id}`, { method: 'DELETE' }),
|
|
getGroupDevices: (id) => request(`/groups/${id}/devices`),
|
|
addDeviceToGroup: (groupId, device_id) => request(`/groups/${groupId}/devices`, { method: 'POST', body: JSON.stringify({ device_id }) }),
|
|
removeDeviceFromGroup: (groupId, deviceId) => request(`/groups/${groupId}/devices/${deviceId}`, { method: 'DELETE' }),
|
|
sendGroupCommand: (groupId, type, payload) => request(`/groups/${groupId}/command`, { method: 'POST', body: JSON.stringify({ type, payload }) }),
|
|
|
|
// Video walls
|
|
getWalls: () => request('/walls'),
|
|
createWall: (data) => request('/walls', { method: 'POST', body: JSON.stringify(data) }),
|
|
setWallDevices: (id, devices) => request(`/walls/${id}/devices`, { method: 'PUT', body: JSON.stringify({ devices }) }),
|
|
updateWall: (id, data) => request(`/walls/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
|
deleteWall: (id) => request(`/walls/${id}`, { method: 'DELETE' }),
|
|
|
|
// Playlists
|
|
getPlaylists: () => request('/playlists'),
|
|
createPlaylist: (name, description) => request('/playlists', { method: 'POST', body: JSON.stringify({ name, description }) }),
|
|
getPlaylist: (id) => request(`/playlists/${id}`),
|
|
updatePlaylist: (id, data) => request(`/playlists/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
|
deletePlaylist: (id) => request(`/playlists/${id}`, { method: 'DELETE' }),
|
|
getPlaylistItems: (id) => request(`/playlists/${id}/items`),
|
|
addPlaylistItem: (id, data) => request(`/playlists/${id}/items`, { method: 'POST', body: JSON.stringify(data) }),
|
|
updatePlaylistItem: (id, itemId, data) => request(`/playlists/${id}/items/${itemId}`, { method: 'PUT', body: JSON.stringify(data) }),
|
|
deletePlaylistItem: (id, itemId) => request(`/playlists/${id}/items/${itemId}`, { method: 'DELETE' }),
|
|
duplicatePlaylistItem: (id, itemId) => request(`/playlists/${id}/items/${itemId}/duplicate`, { method: 'POST' }),
|
|
reorderPlaylistItems: (id, order) => request(`/playlists/${id}/items/reorder`, { method: 'POST', body: JSON.stringify({ order }) }),
|
|
// #74/#75 per-item schedule blocks
|
|
getItemSchedules: (id, itemId) => request(`/playlists/${id}/items/${itemId}/schedules`),
|
|
setItemSchedules: (id, itemId, blocks) => request(`/playlists/${id}/items/${itemId}/schedules`, { method: 'PUT', body: JSON.stringify({ blocks }) }),
|
|
assignPlaylistToDevice: (playlistId, device_id) => request(`/playlists/${playlistId}/assign`, { method: 'POST', body: JSON.stringify({ device_id }) }),
|
|
publishPlaylist: (id) => request(`/playlists/${id}/publish`, { method: 'POST' }),
|
|
discardPlaylistDraft: (id) => request(`/playlists/${id}/discard`, { method: 'POST' }),
|
|
|
|
// Device Groups - Playlist
|
|
groupAssignPlaylist: (groupId, playlist_id) => request(`/groups/${groupId}/assign-playlist`, { method: 'POST', body: JSON.stringify({ playlist_id }) }),
|
|
|
|
// API Tokens (personal access tokens, workspace-scoped)
|
|
getTokens: () => request('/tokens'),
|
|
createToken: (data) => request('/tokens', { method: 'POST', body: JSON.stringify(data) }),
|
|
revokeToken: (id) => request('/tokens/' + id, { method: 'DELETE' }),
|
|
setTokenTargets: (id, target_playlist_ids) => request('/tokens/' + id + '/targets', { method: 'PUT', body: JSON.stringify({ target_playlist_ids }) }), // #73: re-designate agency token playlists
|
|
|
|
// Current user
|
|
getMe: () => request('/auth/me'),
|
|
updateMe: (data) => request('/auth/me', { method: 'PUT', body: JSON.stringify(data) }),
|
|
switchWorkspace: (workspaceId) => request('/auth/switch-workspace', { method: 'POST', body: JSON.stringify({ workspace_id: workspaceId }) }),
|
|
renameWorkspace: (id, data) => request(`/workspaces/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
|
|
|
// Workspace members + invites (slice 2A read-only)
|
|
getWorkspaceMembers: (id) => request(`/workspaces/${id}/members`),
|
|
getWorkspaceInvites: (id) => request(`/workspaces/${id}/invites`),
|
|
|
|
// Workspace member/invite mutations (slice 2B). All admin-only server-side
|
|
// (canAdminWorkspace gate). Server returns translated English error messages
|
|
// mapped to i18n keys via mapMutationError() in workspace-members.js.
|
|
inviteWorkspaceMember: (workspaceId, data) => request(`/workspaces/${workspaceId}/invites`, { method: 'POST', body: JSON.stringify(data) }),
|
|
cancelWorkspaceInvite: (workspaceId, inviteId) => request(`/workspaces/${workspaceId}/invites/${inviteId}`, { method: 'DELETE' }),
|
|
updateWorkspaceMemberRole: (workspaceId, userId, role) => request(`/workspaces/${workspaceId}/members/${userId}`, { method: 'PUT', body: JSON.stringify({ role }) }),
|
|
removeWorkspaceMember: (workspaceId, userId) => request(`/workspaces/${workspaceId}/members/${userId}`, { method: 'DELETE' }),
|
|
|
|
// Slice 2C - accept a workspace invite by id (post-auth flow)
|
|
acceptInvite: (inviteId) => request(`/auth/accept-invite/${inviteId}`, { method: 'POST' }),
|
|
|
|
// Admin-provisioned user creation (#10). data: { email, name, password,
|
|
// workspaceId, role, mustChangePassword }
|
|
adminCreateUser: (data) => request('/admin/users', { method: 'POST', body: JSON.stringify(data) }),
|
|
adminCreateOrg: (name) => request('/admin/orgs', { method: 'POST', body: JSON.stringify({ name }) }),
|
|
adminListOrgs: () => request('/admin/orgs'),
|
|
adminDeleteOrg: (id) => request(`/admin/orgs/${id}`, { method: 'DELETE' }),
|
|
adminDeleteWorkspace: (id) => request(`/admin/workspaces/${id}`, { method: 'DELETE' }),
|
|
aiGetSettings: () => request('/ai/settings'),
|
|
aiSaveSettings: (data) => request('/ai/settings', { method: 'PUT', body: JSON.stringify(data) }),
|
|
aiGenerateDesign: (prompt) => request('/ai/generate-design', { method: 'POST', body: JSON.stringify({ prompt }) }),
|
|
aiListModels: (base_url, api_key) => request('/ai/models', { method: 'POST', body: JSON.stringify({ base_url, api_key }) }),
|
|
|
|
// Instance-level default branding (#15, platform admin).
|
|
adminGetBranding: () => request('/admin/branding'),
|
|
adminSetBranding: (data) => request('/admin/branding', { method: 'PUT', body: JSON.stringify(data) }),
|
|
// #146: toggle the /api/status debug block exposure (platform-admin only).
|
|
adminGetStatusDebug: () => request('/admin/status-debug'),
|
|
adminSetStatusDebug: (enabled) => request('/admin/status-debug', { method: 'PUT', body: JSON.stringify({ enabled }) }),
|
|
|
|
// Per-user workspace membership management (platform Users page modal).
|
|
adminGetUserWorkspaces: (id) => request(`/admin/users/${id}/workspaces`),
|
|
adminAddUserWorkspace: (id, workspaceId, role) => request(`/admin/users/${id}/workspaces`, { method: 'POST', body: JSON.stringify({ workspaceId, role }) }),
|
|
adminSetUserWorkspaceRole: (id, workspaceId, role) => request(`/admin/users/${id}/workspaces/${workspaceId}`, { method: 'PUT', body: JSON.stringify({ role }) }),
|
|
adminRemoveUserWorkspace: (id, workspaceId) => request(`/admin/users/${id}/workspaces/${workspaceId}`, { method: 'DELETE' }),
|
|
|
|
// Admin - Users
|
|
getUsers: () => request('/auth/users'),
|
|
deleteUser: (id) => request(`/auth/users/${id}`, { method: 'DELETE' }),
|
|
resetUserPassword: (id, password) => request(`/auth/users/${id}/password`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ password }),
|
|
}),
|
|
assignPlan: (user_id, plan_id) => request('/subscription/assign', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ user_id, plan_id })
|
|
}),
|
|
};
|