diff --git a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt index f06e843..a76d3af 100644 --- a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt +++ b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt @@ -147,7 +147,25 @@ class MainActivity : AppCompatActivity() { 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 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) diff --git a/android/app/src/main/java/com/remotedisplay/player/data/ServerConfig.kt b/android/app/src/main/java/com/remotedisplay/player/data/ServerConfig.kt index 28a3d49..3fc34a5 100644 --- a/android/app/src/main/java/com/remotedisplay/player/data/ServerConfig.kt +++ b/android/app/src/main/java/com/remotedisplay/player/data/ServerConfig.kt @@ -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() + } } diff --git a/server/player/index.html b/server/player/index.html index c8564f6..27065f6 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -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(); } } diff --git a/server/ws/deviceSocket.js b/server/ws/deviceSocket.js index b47ebbd..e8f3efc 100644 --- a/server/ws/deviceSocket.js +++ b/server/ws/deviceSocket.js @@ -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) {