From 4f332554177b4215f247fd7726d94e2e58672d2e Mon Sep 17 00:00:00 2001 From: Spirit Date: Tue, 3 Feb 2026 19:53:54 +0000 Subject: [PATCH] feat: Add digital signage mode (URL rotation) - SignageManager.kt: Handles URL rotation with configurable interval - Admin UI: Toggle, URL list (one per line), interval setting - SignageManagerTest.kt: 9 unit tests for rotation logic Features: - Enable/disable signage mode - Configure list of URLs to rotate - Set rotation interval (min 5 seconds) - Pauses on user interaction (resumes after 1 min) - Resets to first URL on idle timeout Note: MainActivity integration pending --- .../farm/hobbs/kioskbrowser/SignageManager.kt | 165 ++++++++++++++++++ app/src/main/res/layout/activity_admin.xml | 46 +++++ .../hobbs/kioskbrowser/SignageManagerTest.kt | 81 +++++++++ 3 files changed, 292 insertions(+) create mode 100644 app/src/main/kotlin/farm/hobbs/kioskbrowser/SignageManager.kt create mode 100644 app/src/test/kotlin/farm/hobbs/kioskbrowser/SignageManagerTest.kt diff --git a/app/src/main/kotlin/farm/hobbs/kioskbrowser/SignageManager.kt b/app/src/main/kotlin/farm/hobbs/kioskbrowser/SignageManager.kt new file mode 100644 index 0000000..4a99477 --- /dev/null +++ b/app/src/main/kotlin/farm/hobbs/kioskbrowser/SignageManager.kt @@ -0,0 +1,165 @@ +package farm.hobbs.kioskbrowser + +import android.os.Handler +import android.os.Looper +import android.util.Log + +/** + * Manages digital signage URL rotation. + * + * Cycles through a list of URLs at a configurable interval. + * User interaction pauses rotation temporarily. + */ +class SignageManager( + private val onUrlChange: (String) -> Unit +) { + companion object { + private const val TAG = "SignageManager" + private const val DEFAULT_INTERVAL_MS = 30_000L // 30 seconds + private const val PAUSE_DURATION_MS = 60_000L // 1 minute pause on interaction + } + + private val handler = Handler(Looper.getMainLooper()) + private var urls: List = emptyList() + private var currentIndex = 0 + private var intervalMs = DEFAULT_INTERVAL_MS + private var isEnabled = false + private var isPaused = false + + private val rotationRunnable = object : Runnable { + override fun run() { + if (isEnabled && !isPaused && urls.isNotEmpty()) { + advanceToNext() + handler.postDelayed(this, intervalMs) + } + } + } + + private val resumeRunnable = Runnable { + isPaused = false + if (isEnabled) { + scheduleNextRotation() + } + } + + /** + * Configure signage mode settings. + * @param enabled Whether signage mode is active + * @param urlList List of URLs to rotate through + * @param intervalSeconds Seconds between rotations + */ + fun configure(enabled: Boolean, urlList: List, intervalSeconds: Int) { + Log.d(TAG, "Configuring: enabled=$enabled, urls=${urlList.size}, interval=${intervalSeconds}s") + + isEnabled = enabled + urls = urlList.filter { it.isNotBlank() } + intervalMs = (intervalSeconds * 1000L).coerceAtLeast(5000L) // Min 5 seconds + + if (enabled && urls.isNotEmpty()) { + start() + } else { + stop() + } + } + + /** + * Start signage rotation from the first URL. + */ + fun start() { + if (urls.isEmpty()) { + Log.w(TAG, "Cannot start: no URLs configured") + return + } + + Log.d(TAG, "Starting signage rotation") + currentIndex = 0 + isPaused = false + isEnabled = true + + // Load first URL immediately + loadCurrentUrl() + + // Schedule next rotation + scheduleNextRotation() + } + + /** + * Stop signage rotation. + */ + fun stop() { + Log.d(TAG, "Stopping signage rotation") + isEnabled = false + handler.removeCallbacks(rotationRunnable) + handler.removeCallbacks(resumeRunnable) + } + + /** + * Pause rotation temporarily (e.g., on user interaction). + * Auto-resumes after PAUSE_DURATION_MS. + */ + fun pauseTemporarily() { + if (!isEnabled) return + + Log.d(TAG, "Pausing rotation temporarily") + isPaused = true + handler.removeCallbacks(rotationRunnable) + handler.removeCallbacks(resumeRunnable) + handler.postDelayed(resumeRunnable, PAUSE_DURATION_MS) + } + + /** + * Reset to first URL (e.g., on idle timeout). + */ + fun reset() { + Log.d(TAG, "Resetting to first URL") + currentIndex = 0 + isPaused = false + + if (isEnabled && urls.isNotEmpty()) { + loadCurrentUrl() + scheduleNextRotation() + } + } + + /** + * Get current URL being displayed. + */ + fun getCurrentUrl(): String? = urls.getOrNull(currentIndex) + + /** + * Get total number of URLs in rotation. + */ + fun getUrlCount(): Int = urls.size + + /** + * Check if signage mode is currently active. + */ + fun isActive(): Boolean = isEnabled && urls.isNotEmpty() + + private fun advanceToNext() { + if (urls.isEmpty()) return + + currentIndex = (currentIndex + 1) % urls.size + Log.d(TAG, "Advancing to URL ${currentIndex + 1}/${urls.size}") + loadCurrentUrl() + } + + private fun loadCurrentUrl() { + urls.getOrNull(currentIndex)?.let { url -> + Log.d(TAG, "Loading URL: $url") + onUrlChange(url) + } + } + + private fun scheduleNextRotation() { + handler.removeCallbacks(rotationRunnable) + handler.postDelayed(rotationRunnable, intervalMs) + } + + /** + * Clean up resources. + */ + fun destroy() { + stop() + } +} diff --git a/app/src/main/res/layout/activity_admin.xml b/app/src/main/res/layout/activity_admin.xml index 7bf009c..3a195b9 100644 --- a/app/src/main/res/layout/activity_admin.xml +++ b/app/src/main/res/layout/activity_admin.xml @@ -69,6 +69,52 @@ android:inputType="numberPassword" /> + + + + + + + + + + + + + + + () + assertEquals(0, urls.size) + } + + @Test + fun `URL list filters blank entries`() { + val urls = listOf("https://a.com", "", "https://b.com", " ", "https://c.com") + .filter { it.isNotBlank() } + assertEquals(3, urls.size) + } + + @Test + fun `index wraps around at end of list`() { + val urls = listOf("a", "b", "c") + var index = 0 + + // Advance through all URLs + index = (index + 1) % urls.size // 1 + index = (index + 1) % urls.size // 2 + index = (index + 1) % urls.size // 0 (wrapped) + + assertEquals(0, index) + assertEquals("a", urls[index]) + } + + @Test + fun `interval coerced to minimum 5 seconds`() { + val inputSeconds = 2 + val intervalMs = (inputSeconds * 1000L).coerceAtLeast(5000L) + assertEquals(5000L, intervalMs) + } + + @Test + fun `interval accepts values above minimum`() { + val inputSeconds = 30 + val intervalMs = (inputSeconds * 1000L).coerceAtLeast(5000L) + assertEquals(30000L, intervalMs) + } + + @Test + fun `single URL list stays on same URL`() { + val urls = listOf("https://only.com") + var index = 0 + + index = (index + 1) % urls.size + assertEquals(0, index) // Still at 0 + } + + @Test + fun `getCurrentUrl returns null for empty list`() { + val urls = emptyList() + val index = 0 + assertNull(urls.getOrNull(index)) + } + + @Test + fun `getCurrentUrl returns correct URL`() { + val urls = listOf("https://first.com", "https://second.com") + assertEquals("https://first.com", urls.getOrNull(0)) + assertEquals("https://second.com", urls.getOrNull(1)) + } +}