diff --git a/frontend/js/api.js b/frontend/js/api.js
index 990a616..e275d30 100644
--- a/frontend/js/api.js
+++ b/frontend/js/api.js
@@ -36,6 +36,10 @@ export const api = {
// 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).
diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js
index 91eeff6..ad0be0a 100644
--- a/frontend/js/i18n/en.js
+++ b/frontend/js/i18n/en.js
@@ -301,6 +301,26 @@ export default {
'device.debug.toggle': 'Debug logging (live)',
'device.debug.hint': 'Streams player/zone logs from this device in real time. Turns off on its own when the device reconnects.',
'device.form.save_settings': 'Save Settings',
+ // #150 re-adopt: restore a removed device's saved settings onto this one
+ 'device.readopt.button': 'Restore from removed device…',
+ 'device.readopt.button_hint': "Apply a previously-removed device's saved settings onto this one (for a re-paired screen whose fingerprint changed)",
+ 'device.readopt.title': 'Restore settings from a removed device',
+ 'device.readopt.help': 'Pick a previously-removed device to copy its saved settings onto “{name}”. This overwrites the current settings.',
+ 'device.readopt.empty': 'No previously-removed devices in this workspace.',
+ 'device.readopt.unnamed': 'Unnamed device',
+ 'device.readopt.blocked': 'Blocked',
+ 'device.readopt.summary_orientation': 'Orientation',
+ 'device.readopt.summary_timezone': 'Timezone',
+ 'device.readopt.summary_playlist': 'Playlist',
+ 'device.readopt.playlist_none': 'none',
+ 'device.readopt.playlist_removed': '(playlist since deleted)',
+ 'device.readopt.last_seen': 'Last seen',
+ 'device.readopt.removed': 'Removed',
+ 'device.readopt.apply': 'Apply',
+ 'device.readopt.confirm': 'Apply saved settings from “{source}” onto “{target}”? This overwrites the current settings.',
+ 'device.readopt.confirm_blocked': 'This device was BLOCKED. Applying it will re-block the target device — it will immediately refuse to connect and the screen will go dark. Continue?',
+ 'device.readopt.success': 'Settings restored ({orientation})',
+ 'device.readopt.error': 'Could not restore settings',
// Control buttons
'device.ctl.reboot_device': 'Reboot Device',
'device.ctl.screen_off': 'Screen Off',
diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js
index cc5ef1c..4e2088a 100644
--- a/frontend/js/views/device-detail.js
+++ b/frontend/js/views/device-detail.js
@@ -370,6 +370,7 @@ async function loadDevice(deviceId, activeTab = null) {
+
@@ -644,7 +645,100 @@ function showDevicePreview(device) {
});
}
-async function setupActions(device) {
+async // #150 re-adopt fallback: browse the workspace's previously-removed device snapshots and
+// apply one onto THIS (usually blank, just-re-paired) device. Primary restore is the silent
+// fingerprint-match on re-pair; this is for factory-reset / new-hardware / changed-fingerprint.
+const ORIENT_LABELS = {
+ 'landscape': 'device.form.orientation.landscape',
+ 'portrait': 'device.form.orientation.portrait',
+ 'landscape-flipped': 'device.form.orientation.landscape_flipped',
+ 'portrait-flipped': 'device.form.orientation.portrait_flipped',
+};
+const orientLabel = (o) => t(ORIENT_LABELS[o] || ORIENT_LABELS.landscape);
+const fmtTs = (ts) => (ts ? new Date(ts * 1000).toLocaleString() : '—');
+
+async function showReAdoptModal(device) {
+ let snapshots, playlists;
+ try {
+ [snapshots, playlists] = await Promise.all([
+ api.getRemovedDevices(),
+ api.getPlaylists().catch(() => []), // best-effort: only used to label the restored playlist
+ ]);
+ } catch (err) { showToast(err.message || t('device.readopt.error'), 'error'); return; }
+
+ const plById = new Map((playlists || []).map(p => [p.id, p.name]));
+ const playlistLabel = (s) => !s.playlist_id
+ ? t('device.readopt.playlist_none')
+ : (plById.get(s.playlist_id) || t('device.readopt.playlist_removed'));
+
+ const rowsHtml = (snapshots || []).map((s, i) => {
+ const blockedBadge = s.blocked
+ ? `${t('device.readopt.blocked')}`
+ : '';
+ // Fingerprint is the key but not an operator-facing identifier — truncated + on-hover only.
+ const fpShort = (s.fingerprint || '').slice(0, 8);
+ return `
+