mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
ScreenTinker - open source digital signage management software. MIT License, all features included, no license gates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
554 lines
22 KiB
Kotlin
554 lines
22 KiB
Kotlin
package com.remotedisplay.player
|
|
|
|
import android.accessibilityservice.AccessibilityServiceInfo
|
|
import android.content.ComponentName
|
|
import android.content.Context
|
|
import android.content.Intent
|
|
import android.content.ServiceConnection
|
|
import android.os.Build
|
|
import android.os.Bundle
|
|
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.WindowManager
|
|
import android.view.accessibility.AccessibilityManager
|
|
import android.widget.ImageView
|
|
import android.widget.TextView
|
|
import androidx.appcompat.app.AppCompatActivity
|
|
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.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 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 playerView: PlayerView
|
|
private lateinit var imageView: ImageView
|
|
private lateinit var statusOverlay: View
|
|
private lateinit var statusText: TextView
|
|
private lateinit var rootView: View
|
|
|
|
private val handler = Handler(Looper.getMainLooper())
|
|
private var remoteStreaming = false
|
|
private var screenshotStreamRunnable: Runnable? = null
|
|
private var playbackStarted = 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()
|
|
}
|
|
|
|
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)
|
|
|
|
// 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)
|
|
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
|
|
|
|
// 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) } },
|
|
onPlaylistEmpty = { showStatus("Waiting for content...") },
|
|
onRequestRefresh = { wsService?.requestPlaylistRefresh() }
|
|
)
|
|
|
|
// Setup media player
|
|
val youtubeWebView = findViewById<android.webkit.WebView>(R.id.youtubeWebView)
|
|
mediaPlayer = MediaPlayerManager(
|
|
context = this,
|
|
playerView = playerView,
|
|
imageView = imageView,
|
|
youtubeWebView = youtubeWebView,
|
|
onVideoComplete = { playlistController.onVideoComplete() }
|
|
)
|
|
|
|
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)
|
|
updateChecker.startPeriodicCheck()
|
|
|
|
}
|
|
|
|
private fun setupServiceCallbacks() {
|
|
wsService?.onPlaylistUpdate = { data ->
|
|
try {
|
|
// 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")
|
|
|
|
// 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
|
|
|
|
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)
|
|
if (zoneManager?.hasZones() == true) handler.post { zoneManager?.cleanup() }
|
|
playlistController.updatePlaylist(assignments)
|
|
}
|
|
|
|
// Download any missing local content (skip remote URLs)
|
|
thread {
|
|
for (i in 0 until assignments.length()) {
|
|
val item = assignments.getJSONObject(i)
|
|
val contentId = item.getString("content_id")
|
|
val filename = item.optString("filename", "content")
|
|
val remoteUrl = item.optString("remote_url", null)
|
|
|
|
// Skip remote URL content - it streams directly
|
|
if (!remoteUrl.isNullOrEmpty()) {
|
|
wsService?.sendContentAck(contentId, "ready")
|
|
continue
|
|
}
|
|
|
|
if (!contentCache.isContentCached(contentId)) {
|
|
Log.i("MainActivity", "Downloading content: $filename")
|
|
var downloaded = false
|
|
for (attempt in 1..3) {
|
|
val file = contentCache.downloadContent(config.serverUrl, contentId, filename)
|
|
if (file != null) {
|
|
wsService?.sendContentAck(contentId, "ready")
|
|
downloaded = true
|
|
break
|
|
}
|
|
Log.w("MainActivity", "Download attempt $attempt failed for $filename")
|
|
if (attempt < 3) Thread.sleep(2000L * attempt)
|
|
}
|
|
if (!downloaded) wsService?.sendContentAck(contentId, "failed")
|
|
}
|
|
}
|
|
|
|
// Start or resume playback after downloads complete
|
|
handler.post {
|
|
playlistController.startIfNeeded()
|
|
}
|
|
}
|
|
} // end else (not suspended)
|
|
} catch (e: Exception) {
|
|
Log.e("MainActivity", "Playlist update error: ${e.message}")
|
|
}
|
|
}
|
|
|
|
wsService?.onContentDelete = { contentId ->
|
|
contentCache.deleteContent(contentId)
|
|
playlistController.removeContent(contentId)
|
|
}
|
|
|
|
wsService?.onScreenshotRequest = {
|
|
// Handled by service now
|
|
}
|
|
|
|
wsService?.onRemoteStart = {
|
|
// Handled by service now
|
|
}
|
|
|
|
// Provide screenshot callback to service (composite capture on main thread)
|
|
wsService?.onCaptureScreenshot = {
|
|
screenshotCapture.captureView(rootView, 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()
|
|
}
|
|
}
|
|
}
|
|
|
|
wsService?.onRegistered = { _ ->
|
|
hideStatus()
|
|
}
|
|
|
|
wsService?.onUnpaired = {
|
|
Log.w("MainActivity", "Device removed from server, going to provisioning")
|
|
handler.post {
|
|
startActivity(Intent(this, ProvisioningActivity::class.java).apply {
|
|
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
})
|
|
finish()
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun playItem(item: PlaylistItem) {
|
|
hideStatus()
|
|
|
|
// 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)
|
|
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 - download if not cached
|
|
val file = contentCache.getCachedFile(item.contentId)
|
|
if (file == null) {
|
|
Log.w("MainActivity", "Content not cached: ${item.contentId}, downloading...")
|
|
showStatus("Downloading ${item.filename}...")
|
|
thread {
|
|
val downloaded = contentCache.downloadContent(config.serverUrl, item.contentId, item.filename)
|
|
handler.post {
|
|
if (downloaded != null) {
|
|
playFile(item, downloaded)
|
|
} else {
|
|
showStatus("Download failed: ${item.filename}")
|
|
handler.postDelayed({ playlistController.next() }, 3000)
|
|
}
|
|
}
|
|
}
|
|
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(rootView, 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
|
|
Log.i("MainActivity", "Back press intercepted (kiosk mode)")
|
|
}
|
|
|
|
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
|
|
zoneManager?.cleanup()
|
|
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
|
|
)
|
|
}
|
|
}
|
|
}
|