Offline resilience: persist playlist cache for cold-start recovery

Web player:
- Cache playlist JSON to localStorage on every update
- Restore and start playing immediately on boot before connecting
- Clear cache on unpair/reset

Android app:
- Cache playlist JSON to EncryptedSharedPreferences on every update
- Restore cached playlist on cold-start, play from disk-cached content
- Update cache on content deletion, clear on unpair

Server (device socket):
- Fingerprint reconnect: issue fresh token instead of rejecting
- Send device:paired on fingerprint recovery for claimed devices
- Add status logging and dashboard notification on fingerprint reconnect

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-04-13 21:49:45 -05:00
parent 470197d203
commit dc7450b6a7
4 changed files with 82 additions and 10 deletions

View file

@ -147,7 +147,25 @@ class MainActivity : AppCompatActivity() {
onVideoComplete = { playlistController.onVideoComplete() }
)
// Restore cached playlist for offline cold-start (play immediately from disk cache)
val cachedJson = config.cachedPlaylist
if (cachedJson.isNotEmpty()) {
try {
val cached = JSONObject(cachedJson)
val assignments = cached.getJSONArray("assignments")
if (assignments.length() > 0) {
Log.i("MainActivity", "Restoring cached playlist: ${assignments.length()} items")
playlistController.updatePlaylist(assignments)
playlistController.startIfNeeded()
}
} catch (e: Exception) {
Log.w("MainActivity", "Failed to restore cached playlist: ${e.message}")
}
}
if (!playlistController.isPlaying) {
showStatus("Connecting to server...")
}
// Start and bind to WebSocket service
try {
@ -184,6 +202,9 @@ class MainActivity : AppCompatActivity() {
val assignments = data.getJSONArray("assignments")
// Cache playlist JSON for offline cold-start
config.cachedPlaylist = data.toString()
// Check for multi-zone layout
val layoutObj = if (data.isNull("layout")) null else data.optJSONObject("layout")
val layoutZones = layoutObj?.optJSONArray("zones")
@ -272,6 +293,20 @@ class MainActivity : AppCompatActivity() {
wsService?.onContentDelete = { contentId ->
contentCache.deleteContent(contentId)
playlistController.removeContent(contentId)
// Update cached playlist to reflect deletion
try {
val cached = JSONObject(config.cachedPlaylist)
val arr = cached.optJSONArray("assignments")
if (arr != null) {
val filtered = org.json.JSONArray()
for (i in 0 until arr.length()) {
val item = arr.getJSONObject(i)
if (item.optString("content_id") != contentId) filtered.put(item)
}
cached.put("assignments", filtered)
config.cachedPlaylist = cached.toString()
}
} catch (_: Exception) {}
}
wsService?.onScreenshotRequest = {
@ -360,6 +395,7 @@ class MainActivity : AppCompatActivity() {
wsService?.onUnpaired = {
Log.w("MainActivity", "Device removed from server, going to provisioning")
config.clearPlaylistCache()
handler.post {
startActivity(Intent(this, ProvisioningActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)

View file

@ -62,4 +62,13 @@ class ServerConfig(context: Context) {
fun clear() {
prefs.edit().clear().apply()
}
// Playlist cache for offline cold-start
var cachedPlaylist: String
get() = prefs.getString("cached_playlist", "") ?: ""
set(value) = prefs.edit().putString("cached_playlist", value).apply()
fun clearPlaylistCache() {
prefs.edit().remove("cached_playlist").apply()
}
}

View file

@ -88,6 +88,13 @@
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); } catch { return {}; }
}
function saveConfig(cfg) { localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg)); }
const PLAYLIST_CACHE_KEY = 'rd_playlist_cache';
function savePlaylistCache(items) {
try { localStorage.setItem(PLAYLIST_CACHE_KEY, JSON.stringify(items)); } catch {}
}
function loadPlaylistCache() {
try { return JSON.parse(localStorage.getItem(PLAYLIST_CACHE_KEY) || '[]'); } catch { return []; }
}
// ==================== State ====================
let socket = null;
@ -162,6 +169,17 @@
}
if (config.serverUrl && config.deviceId && config.paired) {
// Restore cached playlist immediately so content plays even if offline
const cachedPlaylist = loadPlaylistCache();
if (cachedPlaylist.length > 0) {
console.log('Restored cached playlist:', cachedPlaylist.length, 'items');
playlist = cachedPlaylist;
currentIndex = 0;
isPlaying = true;
document.getElementById('setupScreen').style.display = 'none';
playCurrentItem();
}
// Show tap-to-start overlay to unlock audio on auto-reconnect
const tapOverlay = document.createElement('div');
tapOverlay.style.cssText = 'position:fixed;inset:0;background:#111827;z-index:2000;display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:pointer';
@ -285,6 +303,7 @@
delete config.deviceToken;
config.paired = false;
saveConfig(config);
savePlaylistCache([]);
document.getElementById('setupScreen').style.display = 'flex';
document.getElementById('urlForm').style.display = 'block';
document.getElementById('pairingSection').style.display = 'none';
@ -310,6 +329,7 @@
socket.on('device:content-delete', (data) => {
playlist = playlist.filter(p => p.content_id !== data.content_id);
savePlaylistCache(playlist);
if (playlist.length === 0) showStatus('Waiting for content...');
});
@ -486,6 +506,7 @@
console.log('Playlist changed, updating');
playlist = newItems;
savePlaylistCache(playlist);
if (playlist.length === 0) {
showStatus('Waiting for content...');
@ -939,6 +960,7 @@
// Reset config and go back to setup
if (confirm('Reset player and return to setup?')) {
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(PLAYLIST_CACHE_KEY);
location.reload();
}
}

View file

@ -139,19 +139,24 @@ module.exports = function setupDeviceSocket(io) {
// Someone reinstalled - link them back to existing device
const oldDevice = db.prepare('SELECT * FROM devices WHERE id = ?').get(existing.device_id);
if (oldDevice) {
// Validate token if the old device has one
if (oldDevice.device_token && !validateDeviceToken(existing.device_id, device_token)) {
console.warn(`Fingerprint match but invalid token for device ${existing.device_id}`);
// Generate a new token — the reinstalled app needs a fresh one via re-pairing
socket.emit('device:unpaired', { reason: 'invalid_token' });
return;
}
console.log(`Fingerprint match: linking to existing device ${existing.device_id}`);
// Fingerprint matched — this is a reinstalled app reconnecting to its old device.
// Issue a fresh token so the app can authenticate going forward.
const newToken = generateDeviceToken();
db.prepare('UPDATE devices SET device_token = ? WHERE id = ?').run(newToken, existing.device_id);
console.log(`Fingerprint match: linking reinstalled app to existing device ${existing.device_id} (new token issued)`);
authenticated = true;
socket.emit('device:registered', { device_id: existing.device_id, device_token: oldDevice.device_token, status: oldDevice.status });
db.prepare("UPDATE devices SET status = 'online', last_heartbeat = strftime('%s','now'), ip_address = ?, updated_at = strftime('%s','now') WHERE id = ?")
.run(getClientIp(socket), existing.device_id);
socket.emit('device:registered', { device_id: existing.device_id, device_token: newToken, status: 'online' });
// If device was already claimed by a user, tell the player it's paired
if (oldDevice.user_id) {
socket.emit('device:paired', { name: oldDevice.name || 'Display' });
}
currentDeviceId = existing.device_id;
heartbeat.registerConnection(existing.device_id, socket.id);
socket.join(existing.device_id);
logDeviceStatus(existing.device_id, 'online');
dashboardNs.emit('dashboard:device-status', { device_id: existing.device_id, status: 'online' });
// Send playlist
const access = checkDeviceAccess(existing.device_id);
if (!access.allowed) {