mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
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:
parent
470197d203
commit
dc7450b6a7
|
|
@ -147,7 +147,25 @@ class MainActivity : AppCompatActivity() {
|
||||||
onVideoComplete = { playlistController.onVideoComplete() }
|
onVideoComplete = { playlistController.onVideoComplete() }
|
||||||
)
|
)
|
||||||
|
|
||||||
showStatus("Connecting to server...")
|
// 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
|
// Start and bind to WebSocket service
|
||||||
try {
|
try {
|
||||||
|
|
@ -184,6 +202,9 @@ class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
val assignments = data.getJSONArray("assignments")
|
val assignments = data.getJSONArray("assignments")
|
||||||
|
|
||||||
|
// Cache playlist JSON for offline cold-start
|
||||||
|
config.cachedPlaylist = data.toString()
|
||||||
|
|
||||||
// Check for multi-zone layout
|
// Check for multi-zone layout
|
||||||
val layoutObj = if (data.isNull("layout")) null else data.optJSONObject("layout")
|
val layoutObj = if (data.isNull("layout")) null else data.optJSONObject("layout")
|
||||||
val layoutZones = layoutObj?.optJSONArray("zones")
|
val layoutZones = layoutObj?.optJSONArray("zones")
|
||||||
|
|
@ -272,6 +293,20 @@ class MainActivity : AppCompatActivity() {
|
||||||
wsService?.onContentDelete = { contentId ->
|
wsService?.onContentDelete = { contentId ->
|
||||||
contentCache.deleteContent(contentId)
|
contentCache.deleteContent(contentId)
|
||||||
playlistController.removeContent(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 = {
|
wsService?.onScreenshotRequest = {
|
||||||
|
|
@ -360,6 +395,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
wsService?.onUnpaired = {
|
wsService?.onUnpaired = {
|
||||||
Log.w("MainActivity", "Device removed from server, going to provisioning")
|
Log.w("MainActivity", "Device removed from server, going to provisioning")
|
||||||
|
config.clearPlaylistCache()
|
||||||
handler.post {
|
handler.post {
|
||||||
startActivity(Intent(this, ProvisioningActivity::class.java).apply {
|
startActivity(Intent(this, ProvisioningActivity::class.java).apply {
|
||||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
|
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
|
|
||||||
|
|
@ -62,4 +62,13 @@ class ServerConfig(context: Context) {
|
||||||
fun clear() {
|
fun clear() {
|
||||||
prefs.edit().clear().apply()
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,13 @@
|
||||||
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); } catch { return {}; }
|
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); } catch { return {}; }
|
||||||
}
|
}
|
||||||
function saveConfig(cfg) { localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg)); }
|
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 ====================
|
// ==================== State ====================
|
||||||
let socket = null;
|
let socket = null;
|
||||||
|
|
@ -162,6 +169,17 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.serverUrl && config.deviceId && config.paired) {
|
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
|
// Show tap-to-start overlay to unlock audio on auto-reconnect
|
||||||
const tapOverlay = document.createElement('div');
|
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';
|
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;
|
delete config.deviceToken;
|
||||||
config.paired = false;
|
config.paired = false;
|
||||||
saveConfig(config);
|
saveConfig(config);
|
||||||
|
savePlaylistCache([]);
|
||||||
document.getElementById('setupScreen').style.display = 'flex';
|
document.getElementById('setupScreen').style.display = 'flex';
|
||||||
document.getElementById('urlForm').style.display = 'block';
|
document.getElementById('urlForm').style.display = 'block';
|
||||||
document.getElementById('pairingSection').style.display = 'none';
|
document.getElementById('pairingSection').style.display = 'none';
|
||||||
|
|
@ -310,6 +329,7 @@
|
||||||
|
|
||||||
socket.on('device:content-delete', (data) => {
|
socket.on('device:content-delete', (data) => {
|
||||||
playlist = playlist.filter(p => p.content_id !== data.content_id);
|
playlist = playlist.filter(p => p.content_id !== data.content_id);
|
||||||
|
savePlaylistCache(playlist);
|
||||||
if (playlist.length === 0) showStatus('Waiting for content...');
|
if (playlist.length === 0) showStatus('Waiting for content...');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -486,6 +506,7 @@
|
||||||
|
|
||||||
console.log('Playlist changed, updating');
|
console.log('Playlist changed, updating');
|
||||||
playlist = newItems;
|
playlist = newItems;
|
||||||
|
savePlaylistCache(playlist);
|
||||||
|
|
||||||
if (playlist.length === 0) {
|
if (playlist.length === 0) {
|
||||||
showStatus('Waiting for content...');
|
showStatus('Waiting for content...');
|
||||||
|
|
@ -939,6 +960,7 @@
|
||||||
// Reset config and go back to setup
|
// Reset config and go back to setup
|
||||||
if (confirm('Reset player and return to setup?')) {
|
if (confirm('Reset player and return to setup?')) {
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
localStorage.removeItem(PLAYLIST_CACHE_KEY);
|
||||||
location.reload();
|
location.reload();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -139,19 +139,24 @@ module.exports = function setupDeviceSocket(io) {
|
||||||
// Someone reinstalled - link them back to existing device
|
// Someone reinstalled - link them back to existing device
|
||||||
const oldDevice = db.prepare('SELECT * FROM devices WHERE id = ?').get(existing.device_id);
|
const oldDevice = db.prepare('SELECT * FROM devices WHERE id = ?').get(existing.device_id);
|
||||||
if (oldDevice) {
|
if (oldDevice) {
|
||||||
// Validate token if the old device has one
|
// Fingerprint matched — this is a reinstalled app reconnecting to its old device.
|
||||||
if (oldDevice.device_token && !validateDeviceToken(existing.device_id, device_token)) {
|
// Issue a fresh token so the app can authenticate going forward.
|
||||||
console.warn(`Fingerprint match but invalid token for device ${existing.device_id}`);
|
const newToken = generateDeviceToken();
|
||||||
// Generate a new token — the reinstalled app needs a fresh one via re-pairing
|
db.prepare('UPDATE devices SET device_token = ? WHERE id = ?').run(newToken, existing.device_id);
|
||||||
socket.emit('device:unpaired', { reason: 'invalid_token' });
|
console.log(`Fingerprint match: linking reinstalled app to existing device ${existing.device_id} (new token issued)`);
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log(`Fingerprint match: linking to existing device ${existing.device_id}`);
|
|
||||||
authenticated = true;
|
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;
|
currentDeviceId = existing.device_id;
|
||||||
heartbeat.registerConnection(existing.device_id, socket.id);
|
heartbeat.registerConnection(existing.device_id, socket.id);
|
||||||
socket.join(existing.device_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
|
// Send playlist
|
||||||
const access = checkDeviceAccess(existing.device_id);
|
const access = checkDeviceAccess(existing.device_id);
|
||||||
if (!access.allowed) {
|
if (!access.allowed) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue