screentinker/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt
screentinker 938a43a466
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
Group sync: clock/schedule synchronized playback (offline-native) + double-buffer (#167)
* feat(group-sync): synchronized playback per group (server + Android) [stage 1]

Play a group's shared playlist in lockstep across its displays — start/end items
together — by reusing the video-wall sync primitive with the spatial transform
removed and keyed on group_id instead of wall_id.

Server:
- device_groups += sync_enabled + leader_device_id (optional pin).
- buildPlaylistPayload emits a group_sync:{group_id, is_leader} block ONLY for a
  member whose playlist matches the group's shared playlist (playlist-match guard —
  a mismatched member is ignored, never synced). Membership via device_group_members.
- Leader auto-election: pinned-if-online -> first online matching member -> stable
  fallback; computed (never persisted, so the operator's pin is preserved).
- group:sync / group:sync-request relay among eligible members (mirrors wall:sync,
  guarded on membership + playlist match).
- Self-heal: re-push payloads to group members on (re)connect so is_leader refreshes.
- PUT /api/device-groups/:id accepts sync_enabled + leader_device_id and re-pushes.

Android:
- WallController is now mode-aware. WALL = transform + object-fit:fill + forced
  follower-mute (UNCHANGED — every wall branch is byte-equivalent when isGroup=false).
  GROUP = same leader/follower timing incl. the full video drift controller, but
  full-screen (no transform), normal fit, and per-item mute honored (no forced mute).
- WebSocketService: emitGroupSync/emitGroupSyncRequest + onGroupSync/onGroupSyncRequest.
- MainActivity: dispatch emit by mode, parseGroupConfig, onPlaylistUpdate group hook.

Wall path is provably unchanged (additive mode, defaults to WALL). Kotlin compiles;
server suite 407/407.

Stage 2 (follow-up): web + Tizen parity, dashboard sync toggle + leader picker,
offline-sweep leader promotion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(group-sync): web + Tizen player parity [stage 2]

Mirror the Android group-sync generalization in the two web-based players so a group
with a shared playlist syncs across mixed player types, not just Android.

Web player (server/player/index.html):
- group:sync / group:sync-request handlers (index jump + latency-compensated video
  drift, same maths as wall:sync) — no audio policy here, per-item mute honored.
- emitGroupSync() + applyGroupSync() (4Hz leader broadcast; follower sync-request).
  NO CSS transform, NO forced mute (unlike applyWallMode).
- isFollower now also true for a group follower (suppresses self-advance).
- handlePlaylistUpdate handles data.group_sync (mutually exclusive with wall).

Tizen (tizen/js/player.js WallController + app.js):
- WallController is mode-aware: WALL styles the slice + forced follower semantics
  (UNCHANGED when mode !== 'group'); GROUP clears any wall styling (full-screen) and
  drives leader/follower timing only. syncId()/clearStageStyle() helpers; emitSync/
  onSync/onSyncRequest key the event + id off the mode.
- app.js: group:sync/request socket handlers; onPlaylist enters group sync on a
  group_sync block, else exits — content renders through the normal single-zone path.

Realigned the v4-exit-signal-phase3 TIZEN slice (682->696) shifted by the app.js edits.
Wall path is provably unchanged in both (additive group mode). Server suite 407/407;
both players' JS parse.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(group-sync): dashboard UI — sync toggle + leader picker [stage 3]

On each group's header row:
- "Sync" checkbox -> PUT /api/groups/:id { sync_enabled } enables synchronized
  playback; server re-pushes to members so they enter/exit sync mode. A hint notes
  it needs a group playlist and that a display on a different playlist is ignored.
- When on, a "Leader: Auto / <display>" picker -> { leader_device_id } (null = auto-
  elect, which self-heals; or pin a specific member to always lead when online).

Adds api.updateGroup(id, data) and the en i18n strings (en-only, mirroring the
existing per-group UI keys; the i18n parity test is apitoken-scoped).

Frontend parses (ESM); server suite 407/407.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(group-sync): rework to clock/schedule sync + double-buffer + polish

Replace the leader/follower relay model with clock/schedule sync. Every
same-playlist member derives the identical (index, position) locally from a
server-disciplined clock + the deterministic playlist schedule, so sync:
  - needs no server at play-time (offline-native), and
  - has no leader role to double-elect (kills the split-brain class the leaked
    WallController tick produced).

Server
  - heartbeat-ack now carries server_ms + echoes client_ms for NTP-style clock
    discipline; the client caches the offset (survives an outage).
  - POST /groups/:id/resync -> group:resync (manual "Resync now").
  - (kept: group_sync payload; leader machinery is now vestigial/ignored.)

Clients (web / Tizen / Android)
  - Clock offset disciplined over the heartbeat, cached (localStorage / prefs).
  - Schedule engine: pos = (syncedNow mod Sum(duration_sec)) with a CANONICAL
    slot formula identical across platforms so mixed-platform groups can't drift.
  - Snap-on-load: a fresh clip hard-seeks ONCE to the exact position (was ~5s of
    gentle nudge to eat a ~0.3s load offset); steady-state keeps the gentle nudge.
  - Double buffer: warm the next clip a few s before the boundary -> instant
    switch, no black hold. Android pre-decodes on a throwaway surface so the swap
    doesn't flash one wrong-aspect (landscape-stretched) frame.
  - In-place duration edits: duration_sec dropped from the change signature and
    applied in place, so a duration edit re-anchors the schedule WITHOUT a restart.
  - Live-log shows discrete corrections (jump/align/seek) immediately; only the
    steady-state line is throttled.

Android
  - Fix leaked WallController leader tick: onDestroy() now stops it (Handler on the
    main looper outlived the Activity -> zombie broadcaster / split-brain).

Dashboard
  - Group leader picker -> "Resync now" button.

Tests: heartbeat-ack clock fields + resync route; exit-signal .wgt slice realigned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 14:24:31 -05:00

1150 lines
55 KiB
Kotlin

package com.remotedisplay.player
import android.accessibilityservice.AccessibilityServiceInfo
import android.Manifest
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.widget.EditText
import android.widget.FrameLayout
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.util.Log
import android.view.KeyEvent
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.view.accessibility.AccessibilityManager
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.media3.ui.PlayerView
import com.remotedisplay.player.data.ContentCache
import com.remotedisplay.player.data.ServerConfig
import com.remotedisplay.player.player.MediaPlayerManager
import com.remotedisplay.player.player.PlaylistController
import com.remotedisplay.player.player.PlaylistItem
import com.remotedisplay.player.player.PipOverlay
import com.remotedisplay.player.player.WallController
import com.remotedisplay.player.player.GroupScheduleController
import com.remotedisplay.player.player.ZoneManager
import com.remotedisplay.player.remote.ScreenshotCapture
import com.remotedisplay.player.remote.TouchInjector
import com.remotedisplay.player.service.UpdateChecker
import com.remotedisplay.player.service.WebSocketService
import org.json.JSONObject
import kotlin.concurrent.thread
class MainActivity : AppCompatActivity() {
private lateinit var config: ServerConfig
private lateinit var contentCache: ContentCache
private lateinit var downloadCoordinator: com.remotedisplay.player.data.DownloadCoordinator
private lateinit var screenshotCapture: ScreenshotCapture
private lateinit var touchInjector: TouchInjector
private var wsService: WebSocketService? = null
private var bound = false
private lateinit var mediaPlayer: MediaPlayerManager
private lateinit var playlistController: PlaylistController
private lateinit var updateChecker: UpdateChecker
private var zoneManager: ZoneManager? = null
private lateinit var wallController: WallController
private lateinit var groupSchedule: GroupScheduleController
private lateinit var pipOverlay: PipOverlay // #109: PiP overlay layer
private lateinit var playerView: PlayerView
private lateinit var imageView: ImageView
private lateinit var statusOverlay: View
private lateinit var statusText: TextView
private lateinit var rootView: View
private lateinit var pipLayout: FrameLayout // #109: reparented above rootView (see onCreate)
private lateinit var captureRoot: View // window content; capture source (includes pipLayout)
private var currentOrientation: String? = null
private val handler = Handler(Looper.getMainLooper())
private var remoteStreaming = false
private var screenshotStreamRunnable: Runnable? = null
private var playbackStarted = false
// Multi-tap BACK/ESC for hidden settings menu.
// Collect taps in a 2-second window; on expiry: 2 taps → PIN → settings, 3+ taps → exit.
private val backTapTimes = mutableListOf<Long>()
private var backTapRunnable: Runnable? = null
private val TAP_WINDOW_MS = 1800L
// Connection-failure auto-prompt threshold.
private var failureBannerShown = false
private val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val binder = service as WebSocketService.LocalBinder
wsService = binder.getService()
bound = true
setupServiceCallbacks()
wsService?.connect()
// If the service is ALREADY connected+registered when we bind (MainActivity relaunched
// via CLEAR_TASK right after a re-pair/reclaim, so the onRegistered that clears the boot
// "Connecting to server…" status fired before this Activity existed), catch the UI up by
// pulling a fresh playlist — its update drives the real status (playing / waiting-for-
// content / nothing-scheduled), replacing the stale "Connecting to server…". Without this
// a fully-online device could sit on "Connecting to server…" indefinitely. We keep the
// boot status until the playlist arrives (no blank screen) rather than blindly hiding it.
if (wsService?.isConnected() == true && !playlistController.isPlaying) {
ackedContent.clear()
wsService?.requestPlaylistRefresh()
}
}
override fun onServiceDisconnected(name: ComponentName?) {
wsService = null
bound = false
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
config = ServerConfig(this)
val prefs = getSharedPreferences("remote_display", MODE_PRIVATE)
// Show setup wizard if not completed yet
if (!prefs.getBoolean("setup_complete", false)) {
// Auto-mark complete if accessibility is already enabled (existing install)
if (isAccessibilityEnabled()) {
prefs.edit().putBoolean("setup_complete", true).apply()
} else {
startActivity(Intent(this, SetupActivity::class.java))
finish()
return
}
}
// Check provisioning BEFORE inflating the heavy media layout
if (!config.isProvisioned || !config.isPaired) {
startActivity(Intent(this, ProvisioningActivity::class.java))
finish()
return
}
setContentView(R.layout.activity_main)
// The display is up now — clear the boot "Starting display…" notification.
(getSystemService(Context.NOTIFICATION_SERVICE) as? android.app.NotificationManager)?.cancel(999)
// Fullscreen immersive
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
View.SYSTEM_UI_FLAG_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
contentCache = ContentCache(this)
// Coordinated background downloads (single-flight + bounded pool + backoff) — reconnect-safe.
downloadCoordinator = com.remotedisplay.player.data.DownloadCoordinator(
cache = contentCache,
serverUrl = { config.serverUrl },
socketAlive = { wsService?.isConnected() == true },
onAck = { cid, status -> ackContentOnce(cid, status) }
)
screenshotCapture = ScreenshotCapture()
touchInjector = TouchInjector()
playerView = findViewById(R.id.playerView)
imageView = findViewById(R.id.imageView)
statusOverlay = findViewById(R.id.statusOverlay)
statusText = findViewById(R.id.statusText)
rootView = findViewById(R.id.rootLayout)
// Hide player controls
playerView.useController = false
val youtubeWebView = findViewById<android.webkit.WebView>(R.id.youtubeWebView)
// #109 fix (1): the PiP layer must render ABOVE the YouTube WebView's video plane.
// As the last child of rootLayout it sat in the SAME compositing band as the WebView
// and was occluded by the playing video surface. Reparent it OUT of rootLayout to the
// window content (android.R.id.content), as a top-level sibling drawn AFTER rootLayout
// — so it composites above the WebView. applyOrientation()/applyWallTransform() mirror
// rootView's transform onto it so corner positions still track the rotated content,
// and the remote-view screenshot captures `captureRoot` (content) so the PiP is still
// included. See docs/109-android-pip-visibility.md.
captureRoot = findViewById(android.R.id.content)
pipLayout = findViewById(R.id.pipLayout)
(pipLayout.parent as? ViewGroup)?.removeView(pipLayout)
(captureRoot as ViewGroup).addView(
pipLayout,
FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)
)
// #109: PiP overlay layer. Reports show/clear over device:log (tag "pip"); rootView +
// youtubeWebView are passed for pipDebug geometry logging only.
pipOverlay = PipOverlay(this, pipLayout, rootView, youtubeWebView) { level, message ->
wsService?.sendLog("pip", level, message)
}
// Setup zone manager for multi-zone layouts
zoneManager = ZoneManager(this, rootView as FrameLayout) {
playlistController.onVideoComplete()
}
// Setup playlist controller
playlistController = PlaylistController(
onItemChanged = { item -> item?.let { playItem(it) } },
// #74/#75: clear the last frame when going idle (else a now-filtered item lingers on screen)
onPlaylistEmpty = { if (::mediaPlayer.isInitialized) mediaPlayer.stop(); showStatus(getString(R.string.waiting_for_content)) },
onRequestRefresh = { wsService?.requestPlaylistRefresh() },
onNothingScheduled = { if (::mediaPlayer.isInitialized) mediaPlayer.stop(); showStatus(getString(R.string.nothing_scheduled)) },
// Screen-resilience: the defined "waiting for content" state — ONLY on a fresh device
// with nothing to show yet (never while content is on screen; that path keeps current).
onWaitingForContent = { if (::mediaPlayer.isInitialized) mediaPlayer.stop(); showStatus(getString(R.string.waiting_for_content)) }
)
// Screen-resilience: an item is playable only when its content is actually available —
// a widget, a remote stream, or a fully-downloaded local file. A not-yet/failed download is
// skipped (kept in the background) instead of blanking the screen on a loading state.
playlistController.setContentReadyCheck { item ->
item.isWidget || item.isRemote || contentCache.isContentCached(item.contentId)
}
// Setup media player
mediaPlayer = MediaPlayerManager(
context = this,
playerView = playerView,
imageView = imageView,
youtubeWebView = youtubeWebView,
onVideoComplete = { playlistController.onVideoComplete() },
onImageError = {
Log.w("MainActivity", "Image failed to load, skipping to next item")
handler.postDelayed({ playlistController.next() }, 500)
}
)
// Video-wall controller. The emit lambdas read wsService lazily (it's bound after
// onCreate), and they no-op until the socket is connected (guarded in the service).
wallController = WallController(
media = mediaPlayer,
playlist = playlistController,
deviceId = { config.deviceId },
emitSync = { isGroup, id, idx, contentId, posSec ->
if (isGroup) wsService?.emitGroupSync(id, idx, contentId, posSec)
else wsService?.emitWallSync(id, idx, contentId, posSec)
},
emitSyncRequest = { isGroup, id ->
if (isGroup) wsService?.emitGroupSyncRequest(id) else wsService?.emitWallSyncRequest(id)
},
applyTransform = { cfg -> applyWallTransform(cfg) }
)
// #group-sync: clock/schedule group sync (no leader, offline-native). Reads the disciplined
// clock from the bound service; streams diagnostics to the dashboard live-log (tag 'sync').
groupSchedule = GroupScheduleController(
playlist = playlistController,
media = mediaPlayer,
syncedNow = { wsService?.syncedNowMs() ?: System.currentTimeMillis() },
report = { msg -> wsService?.sendLog("sync", "info", msg) },
// Double buffer: warm the NEXT clip's second player if it's a locally-cached video, so the
// boundary switch is a warm swap (no black hold). Non-video / uncached items just skip it.
onPreloadNext = { idx ->
val next = playlistController.itemAt(idx)
if (next != null && !next.isRemote && next.mimeType.startsWith("video/")) {
contentCache.getCachedFile(next.contentId)?.let { mediaPlayer.preloadVideo(it) }
}
}
)
// Restore cached playlist for offline cold-start (play immediately from disk cache).
// Catch Throwable (not just Exception) so an OOM or corrupt entry can't kill the app
// before the WebSocket connects — that's the crash-loop scenario. If the cache is
// unusable for any reason, drop it and continue; the server will resend on connect.
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")
// #74/#75: restore the cached effective timezone too (offline schedules)
playlistController.setTimezone(if (cached.isNull("timezone")) null else cached.optString("timezone", "").ifEmpty { null })
playlistController.updatePlaylist(assignments)
playlistController.startIfNeeded()
// #group-sync: if this device was in a sync group, resume the schedule immediately
// from the cached clock offset — a reboot mid-outage comes back aligned, no server.
val cg = if (cached.isNull("group_sync")) null else cached.optJSONObject("group_sync")
if (cg != null) groupSchedule.apply(cg.optString("group_id"))
}
} catch (e: Throwable) {
Log.w("MainActivity", "Failed to restore cached playlist, clearing cache: ${e.message}")
try { config.clearPlaylistCache() } catch (_: Throwable) {}
}
}
if (!playlistController.isPlaying) {
showStatus("Connecting to server...")
}
// Start and bind to WebSocket service
try {
val serviceIntent = Intent(this, WebSocketService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent)
} else {
startService(serviceIntent)
}
bindService(serviceIntent, connection, Context.BIND_AUTO_CREATE)
} catch (e: Exception) {
Log.e("MainActivity", "Failed to start service: ${e.message}")
showStatus("Service error: ${e.message}")
}
// Start auto-update checker
updateChecker = UpdateChecker(this)
// #139: surface OTA status (applying / backing off / manual-update-required) to the
// dashboard. wsService is read lazily — it binds after this runs.
updateChecker.otaLogReporter = { level, msg -> wsService?.sendLog("ota", level, msg) }
// #139 Phase 2 (Option B): announce OTA status transitions (clear / enter-backoff) so the
// dashboard badge clears/lights up promptly without waiting for a reconnect.
updateChecker.otaStatusReporter = { wsService?.sendOtaStatus() }
updateChecker.startPeriodicCheck()
// Periodic connection-failure check so the "Stuck connecting?" banner appears
// without waiting for the next playlist update
val failureCheck = object : Runnable {
override fun run() {
checkConnectionFailureBanner()
handler.postDelayed(this, 30_000L)
}
}
handler.postDelayed(failureCheck, 15_000L)
}
// Rotate the whole stage in software so portrait / flipped signage works even on
// fixed-landscape hardware (Fire TV, Android TV and most signage sticks ignore
// setRequestedOrientation - they can't physically rotate the panel). Resizes
// rootView to the rotated dimensions, recenters, and rotates. Covers single-zone
// (playerView/imageView/youtubeWebView) and multi-zone (ZoneManager renders into
// the same rootView). Values mirror the dashboard: landscape / portrait /
// landscape-flipped / portrait-flipped.
private fun applyOrientation(orientation: String) {
if (orientation == currentOrientation) return
currentOrientation = orientation
val m = resources.displayMetrics
val w = m.widthPixels.toFloat()
val h = m.heightPixels.toFloat()
val (rot, swap) = when (orientation) {
"portrait" -> 90f to true
"portrait-flipped" -> 270f to true
"landscape-flipped" -> 180f to false
else -> 0f to false // landscape
}
val lp = rootView.layoutParams
lp.width = (if (swap) h else w).toInt()
lp.height = (if (swap) w else h).toInt()
rootView.layoutParams = lp
rootView.translationX = if (swap) (w - h) / 2f else 0f
rootView.translationY = if (swap) (h - w) / 2f else 0f
rootView.rotation = rot
rootView.requestLayout()
mirrorTransformToPip()
Log.i("MainActivity", "Applied orientation: $orientation (rotation=$rot, swap=$swap)")
}
// #109: pipLayout was reparented out of rootView (to draw above the WebView), so it no
// longer inherits the orientation/wall transform. Copy rootView's current size + transform
// onto it verbatim so the PiP box still lands in the same rotated coordinate space as the
// visible content (mirrors the web/Tizen players, which apply the same transform to #pip
// as to #stage). Called after every rootView transform change.
private fun mirrorTransformToPip() {
if (!::pipLayout.isInitialized) return
val rl = rootView.layoutParams
val pl = pipLayout.layoutParams
pl.width = rl.width
pl.height = rl.height
pipLayout.layoutParams = pl
pipLayout.translationX = rootView.translationX
pipLayout.translationY = rootView.translationY
pipLayout.rotation = rootView.rotation
pipLayout.scaleX = rootView.scaleX
pipLayout.scaleY = rootView.scaleY
pipLayout.requestLayout()
}
private fun parseWallConfig(wc: JSONObject): WallController.WallConfig {
fun rect(key: String): WallController.Rect {
val o = wc.optJSONObject(key)
return WallController.Rect(
x = o?.optDouble("x", 0.0)?.toFloat() ?: 0f,
y = o?.optDouble("y", 0.0)?.toFloat() ?: 0f,
w = o?.optDouble("w", 0.0)?.toFloat() ?: 0f,
h = o?.optDouble("h", 0.0)?.toFloat() ?: 0f
)
}
return WallController.WallConfig(
wallId = wc.optString("wall_id", ""),
screen = rect("screen_rect"),
player = rect("player_rect"),
isLeader = wc.optBoolean("is_leader", false),
rotation = wc.optInt("rotation", 0)
)
}
// #group-sync: fullscreen synchronized playback — no tile geometry, just id + leader role.
private fun parseGroupConfig(gs: JSONObject): WallController.WallConfig {
val zero = WallController.Rect(0f, 0f, 0f, 0f)
return WallController.WallConfig(
wallId = gs.optString("group_id", ""),
screen = zero, player = zero, rotation = 0,
isLeader = gs.optBoolean("is_leader", false),
mode = WallController.Mode.GROUP
)
}
// Video-wall slice transform. The content view represents the whole wall (player_rect);
// size + offset rootView so this screen's screen_rect fills the device viewport, content
// stretched to fill (object-fit:fill parity, set on the views via MediaPlayerManager).
// Mirrors the web player's vw/vh stage math. Per-tile rotation is intentionally not
// applied (web/Tizen parity). cfg == null restores full screen.
private fun applyWallTransform(cfg: WallController.WallConfig?) {
val lp = rootView.layoutParams
if (cfg == null) {
lp.width = ViewGroup.LayoutParams.MATCH_PARENT
lp.height = ViewGroup.LayoutParams.MATCH_PARENT
rootView.layoutParams = lp
rootView.translationX = 0f
rootView.translationY = 0f
rootView.rotation = 0f
rootView.scaleX = 1f
rootView.scaleY = 1f
rootView.requestLayout()
mirrorTransformToPip()
// Force the next playlist update to re-apply orientation (applyOrientation
// early-returns when the value is unchanged).
currentOrientation = null
Log.i("MainActivity", "Wall transform cleared (restored full screen)")
return
}
val s = cfg.screen
val p = cfg.player
if (s.w == 0f || s.h == 0f) {
Log.w("MainActivity", "Wall screen_rect has zero size; skipping transform")
return
}
val dw = resources.displayMetrics.widthPixels.toFloat()
val dh = resources.displayMetrics.heightPixels.toFloat()
lp.width = ((p.w / s.w) * dw).toInt()
lp.height = ((p.h / s.h) * dh).toInt()
rootView.layoutParams = lp
rootView.translationX = ((p.x - s.x) / s.w) * dw // negative for right/lower tiles
rootView.translationY = ((p.y - s.y) / s.h) * dh
rootView.rotation = 0f // per-tile rotation: TODO (parity = none)
rootView.scaleX = 1f
rootView.scaleY = 1f
rootView.requestLayout()
mirrorTransformToPip()
// Orientation no longer reflects reality; ensure it re-applies after wall exit.
currentOrientation = null
Log.i("MainActivity", "Wall transform: size=${lp.width}x${lp.height} tx=${rootView.translationX} ty=${rootView.translationY}")
}
private fun setupServiceCallbacks() {
wsService?.onPlaylistUpdate = { data ->
try {
// Orientation is applied in the non-wall branch below; wall mode owns the
// root-view transform itself and must not be rotated.
// Check if device is suspended (trial expired / over limit)
if (data.optBoolean("suspended", false)) {
val message = data.optString("message", "Account Suspended")
val detail = data.optString("detail", "Please upgrade your plan.")
handler.post {
showStatus("$message\n$detail")
if (::mediaPlayer.isInitialized) mediaPlayer.stop()
}
} else {
val assignments = data.getJSONArray("assignments")
// #74/#75: device-effective IANA timezone for per-item schedule evaluation
val effectiveTz = if (data.isNull("timezone")) null else data.optString("timezone", "").ifEmpty { null }
playlistController.setTimezone(effectiveTz)
zoneManager?.setTimezone(effectiveTz)
// Cache playlist JSON for offline cold-start
config.cachedPlaylist = data.toString()
// Video-wall mode takes precedence over orientation + multi-zone: the wall is
// fullscreen, and WallController owns the root-view slice transform and the
// leader/follower role. (We're on the main thread here — onPlaylistUpdate is
// posted to the main looper by WebSocketService.)
val wallObj = if (data.isNull("wall_config")) null else data.optJSONObject("wall_config")
if (wallObj != null) {
com.remotedisplay.player.util.DebugLog.i("Player", "Layout: VIDEO-WALL (${assignments.length()} assignments)")
if (zoneManager?.hasZones() == true) zoneManager?.cleanup()
groupSchedule.exit() // wall and group are mutually exclusive
wallController.apply(parseWallConfig(wallObj))
playlistController.updatePlaylist(assignments)
} else {
// #group-sync: not a wall — enter clock/schedule group sync if the payload carries a
// group_sync block, else leave it. No leader/relay: the schedule tick drives index +
// position locally (offline-native). Content renders through the normal path below.
wallController.exit() // never in wall mode here
val groupObj = if (data.isNull("group_sync")) null else data.optJSONObject("group_sync")
if (groupObj != null) groupSchedule.apply(groupObj.optString("group_id")) else groupSchedule.exit()
applyOrientation(data.optString("orientation", "landscape"))
// Check for multi-zone layout
val layoutObj = if (data.isNull("layout")) null else data.optJSONObject("layout")
val layoutZones = layoutObj?.optJSONArray("zones")
if (layoutZones != null && layoutZones.length() > 1) {
// Multi-zone mode - use ZoneManager
val layoutId = layoutObj?.optString("id", "") ?: ""
val currentLayoutId = zoneManager?.currentLayoutId
// Build a signature of current assignments to detect content changes
val assignmentSig = (0 until assignments.length()).map { i ->
val a = assignments.getJSONObject(i)
"${a.optString("content_id")}:${a.optString("zone_id")}:${a.optString("widget_id")}"
}.sorted().joinToString("|")
val changed = assignmentSig != zoneManager?.lastAssignmentSig
com.remotedisplay.player.util.DebugLog.i("Player", "Layout: MULTI-ZONE (${layoutZones.length()} zones, layout=$layoutId), ${assignments.length()} assignments")
if (zoneManager?.hasZones() != true || layoutId != currentLayoutId) {
Log.i("MainActivity", "Multi-zone layout with ${layoutZones.length()} zones (layout=$layoutId, was=$currentLayoutId)")
handler.post {
hideStatus()
if (::mediaPlayer.isInitialized) mediaPlayer.stop()
playlistController.stop()
playerView.visibility = View.GONE
imageView.visibility = View.GONE
zoneManager?.setupZones(layoutZones, layoutId)
zoneManager?.renderAssignments(assignments, config.serverUrl, contentCache)
zoneManager?.lastAssignmentSig = assignmentSig
}
} else if (changed) {
Log.i("MainActivity", "Multi-zone assignments changed, re-rendering")
handler.post {
zoneManager?.renderAssignments(assignments, config.serverUrl, contentCache)
zoneManager?.lastAssignmentSig = assignmentSig
}
} else {
Log.i("MainActivity", "Multi-zone unchanged, skipping")
}
} else {
// Single-zone mode - use PlaylistController (existing behavior)
com.remotedisplay.player.util.DebugLog.i("Player", "Layout: SINGLE/FULLSCREEN (${layoutZones?.length() ?: 0} zones), ${assignments.length()} assignments")
if (zoneManager?.hasZones() == true) handler.post { zoneManager?.cleanup() }
playlistController.updatePlaylist(assignments)
}
} // end else (not a video wall)
// Download any missing local content (skip remote URLs).
// Runs for wall + single-zone; multi-zone drives its own rendering via ZoneManager
// (the startIfNeeded below is guarded so it won't run behind zones).
thread {
for (i in 0 until assignments.length()) {
val item = assignments.getJSONObject(i)
// Widget assignments have no downloadable content file - skip
// (also avoids getString throwing on a null content_id).
val widgetId = if (item.isNull("widget_id")) "" else item.optString("widget_id", "")
if (widgetId.isNotEmpty()) continue
val contentId = if (item.isNull("content_id")) "" else item.optString("content_id", "")
if (contentId.isEmpty()) continue
val filename = item.optString("filename", "content")
// org.json's optString(key, null) returns the STRING "null" when the value is JSON
// null (not the fallback) — so a local item with "remote_url": null was being
// misclassified as a remote stream, ack'd "ready", and NEVER downloaded, stranding
// the screen on "waiting for content". Guard with isNull() like widget_id/content_id above.
val remoteUrl = if (item.isNull("remote_url")) null else item.optString("remote_url", null)
// Skip remote URL content - it streams directly
if (!remoteUrl.isNullOrEmpty()) {
ackContentOnce(contentId, "ready")
continue
}
// Background download is now COORDINATED: single-flight per contentId + a bounded
// pool + failure backoff. So a watchdog/ConnectionGuard reconnect mid-fetch can't
// spawn a duplicate racing the .part, orphan/pile up threads, or storm a failing
// URL. ensure() is non-blocking and idempotent: it re-acks cached content (SEED-A),
// defers when the socket is down (watchdog owns recovery), respects backoff, and
// downloads at most once. It acks ready/failed itself (deduped via onAck).
downloadCoordinator.ensure(contentId, filename)
}
// Start/resume playback immediately — do NOT wait on downloads (they're async now).
// Screen-resilience plays whatever is cached and skips not-yet-ready items; the 3s
// recheck swaps new content in once its download completes. Single-zone only; in
// multi-zone, ZoneManager drives each zone.
handler.post {
if (zoneManager?.hasZones() != true) playlistController.startIfNeeded()
}
}
} // end else (not suspended)
} catch (e: Exception) {
Log.e("MainActivity", "Playlist update error: ${e.message}")
}
}
wsService?.onContentDelete = { contentId ->
downloadCoordinator.forget(contentId) // drop in-flight/backoff state so a re-add re-downloads
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 = {
// Handled by service now
}
wsService?.onRemoteStart = {
// Handled by service now
}
// Provide screenshot callback to service (composite capture on main thread).
// Capture the window content (not just rootView) so the reparented #109 PiP layer
// is included in remote-view screenshots.
wsService?.onCaptureScreenshot = {
screenshotCapture.captureView(captureRoot, 40)
}
wsService?.onRemoteStop = {
remoteStreaming = false
stopScreenshotStreaming()
}
wsService?.onRemoteTouch = { x, y, action ->
when (action) {
"tap" -> touchInjector.injectTap(rootView, x, y)
"down" -> touchInjector.injectDown(rootView, x, y)
"move" -> touchInjector.injectMove(rootView, x, y)
"up" -> touchInjector.injectUp(rootView, x, y)
}
}
wsService?.onRemoteKey = { _ ->
// Key injection handled in WebSocketService directly
}
wsService?.onCommand = { type, payload ->
Log.i("MainActivity", "Command received: $type")
when (type) {
"reboot", "shutdown", "power_menu" -> {
val svc = com.remotedisplay.player.service.PowerAccessibilityService.instance
if (svc != null) {
svc.showPowerDialog()
Log.i("MainActivity", "Power dialog shown via accessibility")
} else {
Log.w("MainActivity", "Accessibility service not enabled - trying fallback")
thread {
try { Runtime.getRuntime().exec(arrayOf("input", "keyevent", "--longpress", "26")).waitFor() } catch (_: Exception) {}
}
}
}
"screen_off" -> {
thread {
try {
Runtime.getRuntime().exec(arrayOf("input", "keyevent", "26")).waitFor() // POWER key
} catch (e: Exception) {
Log.e("MainActivity", "Screen off failed: ${e.message}")
}
}
}
"screen_on" -> {
thread {
try {
Runtime.getRuntime().exec(arrayOf("input", "keyevent", "224")).waitFor() // WAKEUP key
} catch (e: Exception) {
Log.e("MainActivity", "Screen on failed: ${e.message}")
}
}
}
"launch" -> {
val intent = android.content.Intent(this@MainActivity, MainActivity::class.java).apply {
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
startActivity(intent)
}
"update" -> {
Log.i("MainActivity", "Force update check triggered")
if (::updateChecker.isInitialized) updateChecker.checkForUpdate()
}
"refresh" -> {
wsService?.connect()
}
// #109 debug: toggle the PiP magenta-box + geometry logging (default off).
// device:command {type:"pip_debug", payload:{enabled:true}}.
"pip_debug" -> {
val on = payload?.optBoolean("enabled", false) ?: false
if (::pipOverlay.isInitialized) pipOverlay.pipDebug = on
Log.i("MainActivity", "PiP debug ${if (on) "ENABLED" else "disabled"}")
}
}
}
wsService?.onWallSync = { data -> if (::wallController.isInitialized) wallController.onSync(data) }
wsService?.onWallSyncRequest = { data -> if (::wallController.isInitialized) wallController.onSyncRequest(data) }
// #group-sync is clock/schedule now (no leader relay). The server only nudges an immediate
// re-align (dashboard "Resync now"); the schedule tick otherwise runs entirely locally.
wsService?.onGroupResync = { if (::groupSchedule.isInitialized) groupSchedule.resync() }
// #109: PiP overlay show/clear (posted to the main thread by the service).
wsService?.onPipShow = { data -> if (::pipOverlay.isInitialized) pipOverlay.show(data) }
wsService?.onPipClear = { data -> if (::pipOverlay.isInitialized) pipOverlay.clearFrom(data) }
// #129: real-time mute. Apply immediately if the toggled item is the one playing now;
// otherwise it's already persisted server-side and lands via the next playlist update.
wsService?.onMuteChanged = { data ->
val contentId = if (data.isNull("content_id")) "" else data.optString("content_id", "")
val current = playlistController.currentContentId ?: ""
if (contentId.isNotEmpty() && contentId == current && ::mediaPlayer.isInitialized) {
mediaPlayer.setVideoMuted(data.optBoolean("muted", false))
}
}
wsService?.onRegistered = { _ ->
hideStatus()
// Root-2 (SEED-B): a disconnect may have dropped in-flight content-acks. Clear the
// de-dup set so the next playlist-update re-acks all content and the CMS re-syncs.
ackedContent.clear()
}
wsService?.onUnpaired = {
Log.w("MainActivity", "Device removed from server, going to provisioning for re-pair")
config.clearPlaylistCache()
handler.post {
startActivity(Intent(this, ProvisioningActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
// Tell provisioning this is a server-initiated re-pair (known-good URL) so it
// shows a "waiting for re-pair" status + the code instead of the URL entry.
putExtra("EXTRA_REPAIR", true)
})
finish()
}
}
}
// Root-2 content-ack de-dup. Re-acking content state (SEED-A) fixes the CMS "stuck downloading"
// label, but we must not re-ack the same (content,status) every 60s playlist refresh. This set
// is cleared on each (re)registration (see onRegistered) so a reconnect re-acks everything the
// server may have missed while we were disconnected (SEED-B), but is quiet within a session.
private val ackedContent = java.util.Collections.synchronizedSet(HashSet<String>())
private fun ackContentOnce(contentId: String, status: String) {
if (ackedContent.add("$contentId:$status")) {
// a status change for this content supersedes the opposite one
ackedContent.remove("$contentId:${if (status == "ready") "failed" else "ready"}")
wsService?.sendContentAck(contentId, status)
}
}
private fun playItem(item: PlaylistItem) {
hideStatus()
com.remotedisplay.player.util.DebugLog.i("Player", "playItem: ${item.filename} mime=${item.mimeType} widget=${item.widgetId ?: "-"} zone=fullscreen")
// Widget content - render fullscreen in a WebView (single-zone / fullscreen
// layouts; multi-zone widgets go through ZoneManager). Previously unhandled,
// so widgets were blank/broken in default-fullscreen and the fullscreen template.
if (item.isWidget) {
val url = "${config.serverUrl}/api/widgets/${item.widgetId}/render"
Log.i("MainActivity", "Playing widget fullscreen: $url")
mediaPlayer.showWidget(url)
wsService?.sendPlaybackState(item.contentId.ifEmpty { item.widgetId ?: "" }, 0f)
return
}
// YouTube content - play in WebView
if (item.mimeType == "video/youtube" && !item.remoteUrl.isNullOrEmpty()) {
Log.i("MainActivity", "Playing YouTube: ${item.remoteUrl}")
mediaPlayer.playYoutube(item.remoteUrl!!, item.durationSec, item.muted)
wsService?.sendPlaybackState(item.contentId, 0f)
return
}
// Remote URL content - stream directly, no download
if (item.isRemote) {
Log.i("MainActivity", "Playing remote content: ${item.remoteUrl}")
if (item.mimeType.startsWith("video/")) {
mediaPlayer.playVideoFromUrl(item.remoteUrl!!, item.muted)
} else if (item.mimeType.startsWith("image/")) {
mediaPlayer.showImageFromUrl(item.remoteUrl!!)
}
wsService?.sendPlaybackState(item.contentId, 0f)
return
}
// Local content - play from cache. Screen-resilience: the controller only advances to
// items whose content is READY, so reaching here uncached is a rare race (e.g. the file
// was evicted between selection and play). NEVER blank or show a "Downloading…" screen —
// keep whatever is on screen and move on; the background download loop (onPlaylistUpdate)
// fetches it and it plays once fully + validly downloaded. Content update is a BACKGROUND
// operation; we only ever SWAP to fully-downloaded content.
val file = contentCache.getCachedFile(item.contentId)
if (file == null) {
Log.i("MainActivity", "Content not ready at play time (${item.filename}) — keeping screen, advancing (bg download continues)")
downloadCoordinator.ensure(item.contentId, item.filename) // ensure it's being fetched (single-flight)
handler.post { playlistController.next() }
return
}
playFile(item, file)
}
private fun playFile(item: PlaylistItem, file: java.io.File) {
if (item.mimeType.startsWith("video/")) {
mediaPlayer.playVideo(file, item.muted)
} else if (item.mimeType.startsWith("image/")) {
mediaPlayer.showImage(file)
}
// Report playback state
wsService?.sendPlaybackState(item.contentId, 0f)
}
private fun showStatus(message: String) {
statusOverlay.visibility = View.VISIBLE
statusText.text = message
}
private fun hideStatus() {
statusOverlay.visibility = View.GONE
}
private fun captureAndSendScreenshot() {
Log.i("MainActivity", "Capturing screenshot")
val base64 = screenshotCapture.captureView(captureRoot, 40)
if (base64 != null) {
Log.i("MainActivity", "Screenshot captured, size=${base64.length} chars, sending...")
wsService?.sendScreenshot(base64)
} else {
Log.e("MainActivity", "Screenshot capture returned null!")
}
}
private fun startScreenshotStreaming() {
stopScreenshotStreaming()
screenshotStreamRunnable = object : Runnable {
override fun run() {
if (remoteStreaming) {
captureAndSendScreenshot()
handler.postDelayed(this, 1000) // ~1 FPS
}
}
}
handler.post(screenshotStreamRunnable!!)
}
private fun stopScreenshotStreaming() {
screenshotStreamRunnable?.let { handler.removeCallbacks(it) }
screenshotStreamRunnable = null
}
private fun handleRemoteKey(keycode: String) {
// Use shell `input keyevent` for system keys (HOME, BACK, etc.)
// This works from the app process on most Android TV devices
thread {
try {
val code = when (keycode) {
"KEYCODE_HOME" -> "3"
"KEYCODE_BACK" -> "4"
"KEYCODE_MENU" -> "82"
"KEYCODE_VOLUME_UP" -> "24"
"KEYCODE_VOLUME_DOWN" -> "25"
"KEYCODE_DPAD_UP" -> "19"
"KEYCODE_DPAD_DOWN" -> "20"
"KEYCODE_DPAD_LEFT" -> "21"
"KEYCODE_DPAD_RIGHT" -> "22"
"KEYCODE_DPAD_CENTER" -> "23"
"KEYCODE_ENTER" -> "66"
"KEYCODE_POWER" -> "26"
else -> return@thread
}
Log.i("MainActivity", "Injecting key: $keycode ($code)")
val process = Runtime.getRuntime().exec(arrayOf("input", "keyevent", code))
process.waitFor()
Log.i("MainActivity", "Key injection result: ${process.exitValue()}")
} catch (e: Exception) {
Log.e("MainActivity", "Key injection failed: ${e.message}")
}
}
}
@Suppress("DEPRECATION")
override fun onBackPressed() {
// Don't exit the app on back press - this is a kiosk/signage app.
// Multi-tap detection is handled in dispatchKeyEvent.
Log.i("MainActivity", "Back press intercepted (kiosk mode)")
}
// Multi-tap BACK/ESC detection — 2 taps → settings, 3+ taps → exit dialog.
// Catches hardware BACK, D-pad BACK (KEYCODE_BACK=4), and ESC (111).
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (event.action == KeyEvent.ACTION_DOWN) {
when (event.keyCode) {
KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_ESCAPE -> {
handleBackTap()
return true // consume — never let the system handle it
}
}
}
return super.dispatchKeyEvent(event)
}
private fun handleBackTap() {
val now = System.currentTimeMillis()
backTapTimes.add(now)
// Trim taps older than the window
while (backTapTimes.isNotEmpty() && now - backTapTimes.first() > TAP_WINDOW_MS) {
backTapTimes.removeAt(0)
}
// Cancel any pending evaluation and re-schedule
backTapRunnable?.let { handler.removeCallbacks(it) }
backTapRunnable = Runnable {
val count = backTapTimes.size
backTapTimes.clear()
when {
count >= 3 -> showExitDialog()
count == 2 -> showPinDialog()
// count == 1 → ignored (kiosk)
}
}
handler.postDelayed(backTapRunnable!!, TAP_WINDOW_MS)
}
private fun showPinDialog() {
val input = EditText(this).apply {
inputType = android.text.InputType.TYPE_CLASS_NUMBER or android.text.InputType.TYPE_NUMBER_VARIATION_PASSWORD
hint = getString(R.string.settings_pin_hint)
setSingleLine()
}
val container = FrameLayout(this).apply {
val pad = (16 * resources.displayMetrics.density).toInt()
setPadding(pad, pad, pad, 0)
addView(input)
}
AlertDialog.Builder(this)
.setTitle(getString(R.string.settings_pin_title))
.setView(container)
.setPositiveButton(android.R.string.ok) { _, _ ->
if (input.text.toString() == config.settingsPin) {
showSettingsDialog()
} else {
Toast.makeText(this, getString(R.string.settings_pin_wrong), Toast.LENGTH_SHORT).show()
}
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun showSettingsDialog() {
val serverUrl = config.serverUrl
val connected = wsService?.isConnected() == true
val version = try {
packageManager.getPackageInfo(packageName, 0).versionName ?: "?"
} catch (_: Exception) { "?" }
val items = arrayOf(
"${getString(R.string.settings_change_server)}\n ${if (serverUrl.isEmpty()) "—" else serverUrl}",
getString(R.string.settings_reconfigure),
getString(R.string.settings_permissions),
"${getString(R.string.settings_device_info)}\n ${getString(R.string.settings_info_device)}: ${config.deviceId.take(8)}… | v$version | ${if (connected) "●" else "○"}",
getString(R.string.settings_exit)
)
AlertDialog.Builder(this)
.setTitle(getString(R.string.settings_title))
.setItems(items) { _, which ->
when (which) {
0 -> showChangeServerDialog(serverUrl)
1 -> {
config.clearDeviceCredentials()
navigateToProvisioning(serverUrl)
}
2 -> showPermissionsDialog()
4 -> showExitDialog()
// 3 = info (read-only, dismiss)
}
}
.setOnCancelListener { /* dismissed, back to kiosk */ }
.show()
}
private fun showChangeServerDialog(currentUrl: String) {
val input = EditText(this).apply {
setText(currentUrl)
inputType = android.text.InputType.TYPE_TEXT_VARIATION_URI
hint = "https://screentinker.com"
setSingleLine()
}
val container = FrameLayout(this).apply {
val pad = (16 * resources.displayMetrics.density).toInt()
setPadding(pad, pad, pad, 0)
addView(input)
}
AlertDialog.Builder(this)
.setTitle(getString(R.string.settings_change_server))
.setView(container)
.setPositiveButton(getString(R.string.settings_save)) { _, _ ->
val url = input.text.toString().trim().trimEnd('/')
if (url.isNotEmpty() && url != currentUrl) {
config.clearDeviceCredentials()
navigateToProvisioning(url)
}
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun showPermissionsDialog() {
val accEnabled = isAccessibilityEnabled()
val notifyGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
} else true
val lines = buildString {
appendLine("${getString(R.string.settings_perm_accessibility)}: ${if (accEnabled) "✓" else "✗"}")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
appendLine("${getString(R.string.settings_perm_notifications)}: ${if (notifyGranted) "✓" else "✗"}")
}
appendLine("")
appendLine(getString(R.string.settings_perm_hint))
}
AlertDialog.Builder(this)
.setTitle(getString(R.string.settings_permissions))
.setMessage(lines)
.setPositiveButton(getString(R.string.settings_perm_open)) { _, _ ->
val intent = Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = android.net.Uri.parse("package:$packageName")
}
startActivity(intent)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun showExitDialog() {
AlertDialog.Builder(this)
.setTitle(getString(R.string.settings_exit_title))
.setMessage(getString(R.string.settings_exit_confirm))
.setPositiveButton(getString(R.string.settings_exit_yes)) { _, _ ->
try {
wsService?.disconnect()
if (bound) { unbindService(connection); bound = false }
} catch (_: Exception) {}
finishAffinity()
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun navigateToProvisioning(url: String? = null) {
try { wsService?.disconnect() } catch (_: Exception) {}
if (bound) { try { unbindService(connection) } catch (_: Exception) {}; bound = false }
val intent = Intent(this, ProvisioningActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
url?.let { putExtra("EXTRA_SERVER_URL", it) }
}
startActivity(intent)
finish()
}
private fun checkConnectionFailureBanner() {
val failures = wsService?.consecutiveFailures ?: 0
if (failures > 10 && !failureBannerShown && wsService?.isConnected() != true) {
failureBannerShown = true
showStatus("${getString(R.string.settings_connection_failed)}\n${getString(R.string.settings_connection_hint)}")
}
if (failures == 0) {
failureBannerShown = false
}
}
private fun isAccessibilityEnabled(): Boolean {
val am = getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
val myComponent = ComponentName(this, com.remotedisplay.player.service.PowerAccessibilityService::class.java)
return am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK).any {
it.resolveInfo.serviceInfo.let { si -> ComponentName(si.packageName, si.name) == myComponent }
}
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
// Home press brings us back - just re-apply immersive mode
Log.i("MainActivity", "onNewIntent - returning to foreground")
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
View.SYSTEM_UI_FLAG_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
)
}
override fun onDestroy() {
remoteStreaming = false
// Kill the wall/group leader tick BEFORE releasing media. The Handler is on the main looper
// (outlives this Activity), so a surviving tick would keep broadcasting sync frames against
// the released player forever — the zombie-leader / split-brain / garbage-position leak.
if (::wallController.isInitialized) wallController.shutdown()
if (::groupSchedule.isInitialized) groupSchedule.shutdown()
if (::downloadCoordinator.isInitialized) downloadCoordinator.shutdown() // cancel in-flight downloads (no orphan/leak)
zoneManager?.cleanup()
if (::pipOverlay.isInitialized) pipOverlay.clear(null) // #109: tear down overlay WebView
if (::mediaPlayer.isInitialized) {
stopScreenshotStreaming()
mediaPlayer.release()
}
if (bound) {
try { unbindService(connection) } catch (_: Exception) {}
bound = false
}
super.onDestroy()
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) {
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
View.SYSTEM_UI_FLAG_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
)
}
}
}