Initial commit: monorepo Match Live TV.

Rails API, app Flutter, infrastruttura Docker/MediaMTX, sito marketing e documentazione di deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-26 17:45:37 +02:00
commit bba6df52c0
381 changed files with 20599 additions and 0 deletions

14
mobile/android/.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View File

@@ -0,0 +1,59 @@
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.matchlivetv.match_live_tv"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.matchlivetv.match_live_tv"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = maxOf(flutter.minSdkVersion, 21)
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter {
source = "../.."
}
dependencies {
val cameraxVersion = "1.4.1"
implementation("androidx.camera:camera-core:$cameraxVersion")
implementation("androidx.camera:camera-camera2:$cameraxVersion")
implementation("androidx.camera:camera-lifecycle:$cameraxVersion")
implementation("androidx.camera:camera-view:$cameraxVersion")
implementation("com.github.pedroSG94.RootEncoder:library:2.5.5")
}

2
mobile/android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,2 @@
# RootEncoder / SLF4J (R8 release)
-dontwarn org.slf4j.impl.StaticLoggerBinder

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,54 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-feature android:name="android.hardware.camera" android:required="true" />
<uses-feature android:name="android.hardware.microphone" android:required="true" />
<application
android:label="match_live_tv"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<service
android:name=".StreamingForegroundService"
android:exported="false"
android:foregroundServiceType="camera|microphone" />
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@@ -0,0 +1,21 @@
package com.matchlivetv.match_live_tv
import android.os.Bundle
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterFragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Termina eventuale diretta rimasta attiva da sessione precedente
StreamingEngineHolder.engine?.stopStream()
StreamingEngineHolder.release()
startService(StreamingForegroundService.stopIntent(this))
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(StreamingPlugin())
}
}

View File

@@ -0,0 +1,9 @@
package com.matchlivetv.match_live_tv
import com.pedro.library.view.OpenGlView
/** Riferimento condiviso alla surface di preview tra PlatformView e StreamingEngine. */
object StreamPreviewHolder {
@Volatile
var openGlView: OpenGlView? = null
}

View File

@@ -0,0 +1,476 @@
package com.matchlivetv.match_live_tv
import android.content.Context
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import androidx.annotation.RequiresApi
import com.pedro.common.ConnectChecker
import com.pedro.library.generic.GenericStream
import com.pedro.library.util.BitrateAdapter
import com.pedro.library.view.OpenGlView
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
data class StreamingConfig(
val rtmpUrl: String,
val width: Int = 1280,
val height: Int = 720,
val videoBitrate: Int = 2_500_000,
val audioBitrate: Int = 128_000,
val fps: Int = 30,
val rotation: Int = 0,
val sampleRate: Int = 44_100,
val stereo: Boolean = true,
val maxReconnectAttempts: Int = 10,
val reconnectDelayMs: Long = 5_000L,
)
data class StreamingMetrics(
val isStreaming: Boolean,
val isPreviewActive: Boolean,
val isConnected: Boolean,
val state: String,
val bitrateKbps: Long,
val fps: Int,
val durationMs: Long,
val reconnectAttempts: Int,
val lastError: String?,
) {
fun toMap(): Map<String, Any?> = mapOf(
"isStreaming" to isStreaming,
"isPreviewActive" to isPreviewActive,
"isConnected" to isConnected,
"state" to state,
"bitrateKbps" to bitrateKbps,
"fps" to fps,
"durationMs" to durationMs,
"reconnectAttempts" to reconnectAttempts,
"lastError" to lastError,
)
}
enum class StreamingState {
IDLE,
PREVIEWING,
CONNECTING,
STREAMING,
RECONNECTING,
STOPPING,
ERROR,
}
interface StreamingEngineListener {
fun onStateChanged(state: StreamingState, message: String? = null)
fun onMetricsUpdated(metrics: StreamingMetrics)
fun onError(message: String)
}
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
class StreamingEngine(
private val appContext: Context,
) : ConnectChecker {
private val mainHandler = Handler(Looper.getMainLooper())
private val listeners = mutableSetOf<StreamingEngineListener>()
private var genericStream: GenericStream? = null
private var previewSurface: OpenGlView? = null
private var config: StreamingConfig? = null
private var state: StreamingState = StreamingState.IDLE
private var streamStartedAtMs: Long = 0L
private var currentBitrateKbps: Long = 0L
private var currentFps: Int = 0
private var lastError: String? = null
private var lastVideoFrameCount: Long = 0L
private var lastFpsSampleAtMs: Long = 0L
private val reconnectAttempts = AtomicInteger(0)
private val isReleased = AtomicBoolean(false)
private val bitrateAdapter = BitrateAdapter { adaptedBitrate ->
genericStream?.setVideoBitrateOnFly(adaptedBitrate)
}
private val metricsRunnable = object : Runnable {
override fun run() {
emitMetrics()
if (state == StreamingState.STREAMING ||
state == StreamingState.CONNECTING ||
state == StreamingState.RECONNECTING ||
state == StreamingState.PREVIEWING
) {
mainHandler.postDelayed(this, METRICS_INTERVAL_MS)
}
}
}
fun addListener(listener: StreamingEngineListener) {
listeners.add(listener)
}
fun removeListener(listener: StreamingEngineListener) {
listeners.remove(listener)
}
fun bindStreamPreview(glView: OpenGlView) {
ensureMainThread()
val surfaceChanged = previewSurface != null && previewSurface !== glView
previewSurface = glView
if (surfaceChanged) {
genericStream?.let { stream ->
if (stream.isOnPreview) {
stream.stopPreview()
}
}
}
if (state == StreamingState.STREAMING ||
state == StreamingState.CONNECTING ||
state == StreamingState.RECONNECTING
) {
attachPreviewToStream(forceRebind = surfaceChanged)
return
}
val cfg = config ?: previewConfig()
val stream = genericStream ?: obtainGenericStream(cfg)
genericStream = stream
if (!stream.isOnPreview) {
stopPreviewAndStreamForPrepare(stream)
val prepared = try {
stream.prepareVideo(
cfg.width,
cfg.height,
cfg.videoBitrate,
cfg.fps,
rotation = cfg.rotation,
) && stream.prepareAudio(
cfg.sampleRate,
cfg.stereo,
cfg.audioBitrate,
)
} catch (_: Exception) {
false
}
if (!prepared) {
notifyError("Impossibile preparare la preview camera")
return
}
}
attachPreviewToStream()
updateState(StreamingState.PREVIEWING)
startMetricsLoop()
}
fun unbindStreamPreview() {
ensureMainThread()
previewSurface = null
genericStream?.let { stream ->
if (stream.isOnPreview) {
stream.stopPreview()
}
}
if (state == StreamingState.PREVIEWING) {
updateState(StreamingState.IDLE)
}
}
fun startStream(streamConfig: StreamingConfig) {
ensureMainThread()
if (isReleased.get()) {
notifyError("StreamingEngine rilasciato")
return
}
if (state == StreamingState.STREAMING ||
state == StreamingState.CONNECTING ||
state == StreamingState.RECONNECTING
) {
return
}
config = streamConfig
lastError = null
reconnectAttempts.set(0)
val stream = genericStream ?: obtainGenericStream(streamConfig)
genericStream = stream
stopPreviewAndStreamForPrepare(stream)
val prepared = try {
stream.prepareVideo(
streamConfig.width,
streamConfig.height,
streamConfig.videoBitrate,
streamConfig.fps,
rotation = streamConfig.rotation,
) && stream.prepareAudio(
streamConfig.sampleRate,
streamConfig.stereo,
streamConfig.audioBitrate,
)
} catch (exception: IllegalArgumentException) {
false
} catch (exception: IllegalStateException) {
false
}
if (!prepared) {
notifyError("Configurazione audio/video non valida")
updateState(StreamingState.ERROR, "prepare failed")
return
}
attachPreviewToStream()
stream.getStreamClient().setReTries(streamConfig.maxReconnectAttempts)
bitrateAdapter.setMaxBitrate(streamConfig.videoBitrate + streamConfig.audioBitrate)
streamStartedAtMs = SystemClock.elapsedRealtime()
updateState(StreamingState.CONNECTING)
stream.startStream(streamConfig.rtmpUrl)
startMetricsLoop()
}
fun stopStream() {
ensureMainThread()
if (state == StreamingState.IDLE || state == StreamingState.STOPPING) {
return
}
updateState(StreamingState.STOPPING)
stopMetricsLoop()
genericStream?.let { stream ->
if (stream.isStreaming) {
stream.stopStream()
}
if (stream.isOnPreview) {
stream.stopPreview()
}
stream.release()
}
genericStream = null
streamStartedAtMs = 0L
currentBitrateKbps = 0L
currentFps = 0
reconnectAttempts.set(0)
updateState(StreamingState.IDLE)
emitMetrics()
}
fun release() {
ensureMainThread()
if (!isReleased.compareAndSet(false, true)) {
return
}
stopStream()
unbindStreamPreview()
listeners.clear()
}
fun getMetrics(): StreamingMetrics = buildMetrics()
override fun onConnectionStarted(url: String) {
postMain {
updateState(StreamingState.CONNECTING, url)
}
}
override fun onConnectionSuccess() {
postMain {
reconnectAttempts.set(0)
lastError = null
updateState(StreamingState.STREAMING)
}
}
override fun onConnectionFailed(reason: String) {
postMain {
lastError = reason
val stream = genericStream ?: return@postMain
val currentConfig = config ?: return@postMain
updateState(StreamingState.RECONNECTING, reason)
val attempt = reconnectAttempts.incrementAndGet()
val retryScheduled = stream.getStreamClient().reTry(
currentConfig.reconnectDelayMs,
reason,
null,
)
if (retryScheduled) {
emitMetrics()
} else {
updateState(StreamingState.ERROR, reason)
notifyError("Connessione RTMP fallita: $reason")
stream.stopStream()
}
}
}
override fun onNewBitrate(bitrate: Long) {
postMain {
currentBitrateKbps = bitrate / 1000
genericStream?.let { stream ->
bitrateAdapter.adaptBitrate(
bitrate,
stream.getStreamClient().hasCongestion(),
)
}
emitMetrics()
}
}
override fun onDisconnect() {
postMain {
if (state == StreamingState.STREAMING || state == StreamingState.RECONNECTING) {
updateState(StreamingState.RECONNECTING, "disconnected")
}
}
}
override fun onAuthError() {
postMain {
lastError = "auth_error"
updateState(StreamingState.ERROR, "auth_error")
genericStream?.stopStream()
notifyError("Autenticazione RTMP fallita")
}
}
override fun onAuthSuccess() {
postMain {
updateState(StreamingState.STREAMING)
}
}
private fun stopPreviewAndStreamForPrepare(stream: GenericStream) {
if (stream.isStreaming) {
stream.stopStream()
}
if (stream.isOnPreview) {
stream.stopPreview()
}
}
private fun attachPreviewToStream(forceRebind: Boolean = false) {
val glView = previewSurface ?: StreamPreviewHolder.openGlView ?: return
previewSurface = glView
val stream = genericStream ?: return
if (forceRebind && stream.isOnPreview) {
stream.stopPreview()
}
if (!stream.isOnPreview) {
stream.startPreview(glView, autoHandle = true)
}
}
private fun previewConfig(): StreamingConfig {
return config ?: StreamingConfig(
rtmpUrl = "rtmp://127.0.0.1/preview",
width = 1280,
height = 720,
)
}
private fun obtainGenericStream(streamConfig: StreamingConfig): GenericStream {
return GenericStream(
appContext,
this,
).apply {
getGlInterface().autoHandleOrientation = true
getStreamClient().setBitrateExponentialFactor(0.5f)
getStreamClient().setReTries(streamConfig.maxReconnectAttempts)
}
}
private fun buildMetrics(): StreamingMetrics {
val durationMs = if (streamStartedAtMs > 0L) {
SystemClock.elapsedRealtime() - streamStartedAtMs
} else {
0L
}
return StreamingMetrics(
isStreaming = state == StreamingState.STREAMING ||
state == StreamingState.RECONNECTING ||
state == StreamingState.CONNECTING,
isPreviewActive = state == StreamingState.PREVIEWING ||
(genericStream?.isOnPreview == true),
isConnected = state == StreamingState.STREAMING,
state = state.name.lowercase(),
bitrateKbps = currentBitrateKbps,
fps = currentFps,
durationMs = durationMs,
reconnectAttempts = reconnectAttempts.get(),
lastError = lastError,
)
}
private fun updateState(newState: StreamingState, message: String? = null) {
state = newState
listeners.forEach { it.onStateChanged(newState, message) }
emitMetrics()
}
private fun emitMetrics() {
updateFpsEstimate()
val metrics = buildMetrics()
listeners.forEach { it.onMetricsUpdated(metrics) }
}
private fun updateFpsEstimate() {
val stream = genericStream ?: return
val now = SystemClock.elapsedRealtime()
val sentFrames = stream.getStreamClient().getSentVideoFrames()
if (lastFpsSampleAtMs == 0L) {
lastFpsSampleAtMs = now
lastVideoFrameCount = sentFrames
return
}
val elapsedMs = now - lastFpsSampleAtMs
if (elapsedMs >= 1_000L) {
val frameDelta = sentFrames - lastVideoFrameCount
currentFps = ((frameDelta * 1000L) / elapsedMs).toInt()
lastVideoFrameCount = sentFrames
lastFpsSampleAtMs = now
}
}
private fun notifyError(message: String) {
listeners.forEach { it.onError(message) }
}
private fun startMetricsLoop() {
mainHandler.removeCallbacks(metricsRunnable)
mainHandler.post(metricsRunnable)
}
private fun stopMetricsLoop() {
mainHandler.removeCallbacks(metricsRunnable)
}
private fun postMain(block: () -> Unit) {
if (Looper.myLooper() == Looper.getMainLooper()) {
block()
} else {
mainHandler.post(block)
}
}
private fun ensureMainThread() {
check(Looper.myLooper() == Looper.getMainLooper()) {
"StreamingEngine must be used on the main thread"
}
}
companion object {
private const val METRICS_INTERVAL_MS = 1_000L
}
}

View File

@@ -0,0 +1,22 @@
package com.matchlivetv.match_live_tv
import android.content.Context
/** Motore streaming condiviso tra preview UI e RTMP (deve restare legato all'Activity). */
object StreamingEngineHolder {
@Volatile
var engine: StreamingEngine? = null
fun getOrCreate(context: Context): StreamingEngine {
val existing = engine
if (existing != null) {
return existing
}
return StreamingEngine(context.applicationContext).also { engine = it }
}
fun release() {
engine?.release()
engine = null
}
}

View File

@@ -0,0 +1,243 @@
package com.matchlivetv.match_live_tv
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Binder
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.PowerManager
import android.os.SystemClock
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
/** Mantiene in foreground la diretta (notifica + wake lock). Il motore RTMP vive nel plugin. */
class StreamingForegroundService : Service() {
private val binder = LocalBinder()
private val mainHandler = Handler(Looper.getMainLooper())
private var wakeLock: PowerManager.WakeLock? = null
private var sessionStartedAtMs: Long = 0L
private var autoStopTriggered = false
private val sessionTimeoutRunnable = Runnable {
if (!autoStopTriggered) {
autoStopTriggered = true
ServiceEventBus.emit(
mapOf(
"type" to "stopped",
"reason" to "session_timeout",
"durationMs" to elapsedMs(),
),
)
stopForegroundInternal("session_timeout")
}
}
inner class LocalBinder : Binder() {
fun getService(): StreamingForegroundService = this@StreamingForegroundService
}
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> startForegroundSession()
ACTION_STOP -> stopForegroundInternal("user_stop")
}
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder = binder
override fun onDestroy() {
stopForegroundInternal("service_destroy")
super.onDestroy()
}
fun getMetrics(): Map<String, Any?> =
StreamingEngineHolder.engine?.getMetrics()?.toMap() ?: emptyMap()
private fun startForegroundSession() {
autoStopTriggered = false
sessionStartedAtMs = SystemClock.elapsedRealtime()
acquireWakeLock()
promoteToForeground(getString(R.string.streaming_notification_active))
mainHandler.removeCallbacks(sessionTimeoutRunnable)
mainHandler.postDelayed(sessionTimeoutRunnable, SESSION_MAX_MS)
}
private fun stopForegroundInternal(reason: String) {
mainHandler.removeCallbacks(sessionTimeoutRunnable)
releaseWakeLock()
if (sessionStartedAtMs > 0L) {
ServiceEventBus.emit(
mapOf(
"type" to "stopped",
"reason" to reason,
"durationMs" to elapsedMs(),
),
)
}
sessionStartedAtMs = 0L
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
private fun elapsedMs(): Long {
return if (sessionStartedAtMs > 0L) {
SystemClock.elapsedRealtime() - sessionStartedAtMs
} else {
0L
}
}
private fun promoteToForeground(content: String) {
val notification = buildNotification(content)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ServiceCompat.startForeground(
this,
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA or
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE,
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
fun updateNotificationForState(state: StreamingState) {
val content = when (state) {
StreamingState.CONNECTING -> getString(R.string.streaming_notification_connecting)
StreamingState.STREAMING -> getString(R.string.streaming_notification_streaming)
StreamingState.RECONNECTING -> getString(R.string.streaming_notification_reconnecting)
StreamingState.ERROR -> getString(R.string.streaming_notification_error)
else -> getString(R.string.streaming_notification_active)
}
val manager = getSystemService(NotificationManager::class.java)
manager.notify(NOTIFICATION_ID, buildNotification(content))
}
private fun buildNotification(content: String): Notification {
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
val contentIntent = PendingIntent.getActivity(
this,
0,
launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val stopIntent = PendingIntent.getService(
this,
1,
Companion.stopIntent(this),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.streaming_notification_title))
.setContentText(content)
.setSmallIcon(R.mipmap.ic_launcher)
.setOngoing(true)
.setContentIntent(contentIntent)
.addAction(
android.R.drawable.ic_media_pause,
getString(R.string.streaming_notification_stop),
stopIntent,
)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
val manager = getSystemService(NotificationManager::class.java)
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.streaming_notification_channel),
NotificationManager.IMPORTANCE_LOW,
).apply {
description = getString(R.string.streaming_notification_channel_desc)
}
manager.createNotificationChannel(channel)
}
private fun acquireWakeLock() {
if (wakeLock?.isHeld == true) {
return
}
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"$packageName:StreamingWakeLock",
).apply {
setReferenceCounted(false)
acquire(SESSION_MAX_MS + 60_000L)
}
}
private fun releaseWakeLock() {
wakeLock?.let { lock ->
if (lock.isHeld) {
lock.release()
}
}
wakeLock = null
}
companion object {
const val ACTION_START = "com.matchlivetv.match_live_tv.action.START_STREAM"
const val ACTION_STOP = "com.matchlivetv.match_live_tv.action.STOP_STREAM"
private const val CHANNEL_ID = "match_live_tv_streaming"
private const val NOTIFICATION_ID = 1001
private val SESSION_MAX_MS = 90L * 60L * 1000L
fun startIntent(context: Context): Intent {
return Intent(context, StreamingForegroundService::class.java).apply {
action = ACTION_START
}
}
fun stopIntent(context: Context): Intent {
return Intent(context, StreamingForegroundService::class.java).apply {
action = ACTION_STOP
}
}
}
}
object ServiceEventBus {
private val listeners = mutableSetOf<(Map<String, Any?>) -> Unit>()
fun addListener(listener: (Map<String, Any?>) -> Unit) {
synchronized(listeners) {
listeners.add(listener)
}
}
fun removeListener(listener: (Map<String, Any?>) -> Unit) {
synchronized(listeners) {
listeners.remove(listener)
}
}
fun emit(event: Map<String, Any?>) {
val snapshot = synchronized(listeners) { listeners.toList() }
snapshot.forEach { it(event) }
}
}

View File

@@ -0,0 +1,286 @@
package com.matchlivetv.match_live_tv
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Build
import android.os.IBinder
import com.pedro.library.view.OpenGlView
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result
class StreamingPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, EventChannel.StreamHandler {
private lateinit var appContext: Context
private lateinit var methodChannel: MethodChannel
private lateinit var eventChannel: EventChannel
private var activityBinding: ActivityPluginBinding? = null
private var eventSink: EventChannel.EventSink? = null
private var boundService: StreamingForegroundService? = null
private var serviceBound = false
private val engineListener = object : StreamingEngineListener {
override fun onStateChanged(state: StreamingState, message: String?) {
ServiceEventBus.emit(
mapOf(
"type" to "state",
"state" to state.name.lowercase(),
"message" to message,
),
)
boundService?.updateNotificationForState(state)
}
override fun onMetricsUpdated(metrics: StreamingMetrics) {
ServiceEventBus.emit(
mapOf(
"type" to "metrics",
"metrics" to metrics.toMap(),
),
)
}
override fun onError(message: String) {
ServiceEventBus.emit(
mapOf(
"type" to "error",
"message" to message,
),
)
}
}
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val binder = service as? StreamingForegroundService.LocalBinder ?: return
boundService = binder.getService()
serviceBound = true
}
override fun onServiceDisconnected(name: ComponentName?) {
boundService = null
serviceBound = false
}
}
private val busListener: (Map<String, Any?>) -> Unit = { event ->
eventSink?.success(event)
}
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
appContext = binding.applicationContext
methodChannel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL)
methodChannel.setMethodCallHandler(this)
eventChannel = EventChannel(binding.binaryMessenger, EVENT_CHANNEL)
eventChannel.setStreamHandler(this)
binding.platformViewRegistry.registerViewFactory(
PREVIEW_VIEW_TYPE,
StreamingPreviewFactory { mainHandlerRebindPreview() },
)
ServiceEventBus.addListener(busListener)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel.setMethodCallHandler(null)
eventChannel.setStreamHandler(null)
ServiceEventBus.removeListener(busListener)
releaseEngine()
unbindServiceIfNeeded()
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activityBinding = binding
mainHandlerRebindPreview()
}
override fun onDetachedFromActivityForConfigChanges() {
activityBinding = null
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activityBinding = binding
mainHandlerRebindPreview()
}
private fun mainHandlerRebindPreview() {
android.os.Handler(android.os.Looper.getMainLooper()).post {
attachPreviewIfPossible()
}
}
override fun onDetachedFromActivity() {
activityBinding = null
unbindServiceIfNeeded()
}
override fun onMethodCall(call: MethodCall, result: Result) {
when (call.method) {
"startStream" -> startStream(call, result)
"stopStream" -> stopStream(result)
"getMetrics" -> getMetrics(result)
"startPreview" -> startPreview(result)
"stopPreview" -> stopPreview(result)
"bindPreview" -> bindPreview(result)
"unbindPreview" -> unbindPreview(result)
else -> result.notImplemented()
}
}
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
eventSink = events
}
override fun onCancel(arguments: Any?) {
eventSink = null
}
private fun startStream(call: MethodCall, result: Result) {
if (activityBinding?.activity == null) {
result.error("no_activity", "Activity non disponibile per lo streaming", null)
return
}
val args = call.arguments as? Map<*, *>
val rtmpUrl = args?.get("rtmpUrl") as? String
if (rtmpUrl.isNullOrBlank()) {
result.error("invalid_args", "rtmpUrl is required", null)
return
}
val config = StreamingConfig(
rtmpUrl = rtmpUrl,
width = (args["width"] as? Number)?.toInt() ?: 1280,
height = (args["height"] as? Number)?.toInt() ?: 720,
videoBitrate = (args["videoBitrate"] as? Number)?.toInt() ?: 2_500_000,
audioBitrate = (args["audioBitrate"] as? Number)?.toInt() ?: 128_000,
fps = (args["fps"] as? Number)?.toInt() ?: 30,
rotation = (args["rotation"] as? Number)?.toInt() ?: 0,
maxReconnectAttempts = (args["maxReconnectAttempts"] as? Number)?.toInt() ?: 10,
reconnectDelayMs = (args["reconnectDelayMs"] as? Number)?.toLong() ?: 5_000L,
)
val engine = getOrCreateEngine()
engine.removeListener(engineListener)
engine.addListener(engineListener)
attachPreviewIfPossible()
val intent = StreamingForegroundService.startIntent(appContext)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
appContext.startForegroundService(intent)
} else {
appContext.startService(intent)
}
bindServiceIfNeeded()
try {
engine.startStream(config)
result.success(true)
} catch (exception: Exception) {
appContext.startService(StreamingForegroundService.stopIntent(appContext))
result.error("start_failed", exception.message, null)
}
}
private fun stopStream(result: Result) {
appContext.startService(StreamingForegroundService.stopIntent(appContext))
getEngine()?.let { engine ->
engine.removeListener(engineListener)
engine.stopStream()
}
unbindServiceIfNeeded()
result.success(true)
}
private fun getMetrics(result: Result) {
val metrics = boundService?.getMetrics()
?: getEngine()?.getMetrics()?.toMap()
?: emptyMap()
result.success(metrics)
}
private fun bindPreview(result: Result) {
attachPreviewIfPossible()
result.success(StreamPreviewHolder.openGlView != null)
}
private fun unbindPreview(result: Result) {
getEngine()?.unbindStreamPreview()
result.success(true)
}
private fun startPreview(result: Result) {
attachPreviewIfPossible()
result.success(StreamPreviewHolder.openGlView != null)
}
private fun stopPreview(result: Result) {
getEngine()?.unbindStreamPreview()
result.success(true)
}
private fun getOrCreateEngine(): StreamingEngine {
val context = activityBinding?.activity ?: appContext
return StreamingEngineHolder.getOrCreate(context).also { engine ->
engine.removeListener(engineListener)
engine.addListener(engineListener)
}
}
private fun getEngine(): StreamingEngine? = StreamingEngineHolder.engine
private fun releaseEngine() {
getEngine()?.let { engine ->
engine.removeListener(engineListener)
engine.stopStream()
}
StreamingEngineHolder.release()
}
private fun attachPreviewIfPossible() {
val glView = StreamPreviewHolder.openGlView ?: return
val engine = getOrCreateEngine()
engine.bindStreamPreview(glView)
}
private fun bindServiceIfNeeded() {
if (serviceBound) {
return
}
val intent = Intent(appContext, StreamingForegroundService::class.java)
appContext.bindService(
intent,
serviceConnection,
Context.BIND_AUTO_CREATE,
)
}
private fun unbindServiceIfNeeded() {
if (!serviceBound) {
return
}
try {
appContext.unbindService(serviceConnection)
} catch (_: IllegalArgumentException) {
}
serviceBound = false
boundService = null
}
companion object {
private const val METHOD_CHANNEL = "com.matchlivetv.match_live_tv/streaming"
private const val EVENT_CHANNEL = "com.matchlivetv.match_live_tv/streaming_events"
const val PREVIEW_VIEW_TYPE = "match_live_tv/streaming_preview"
}
}

View File

@@ -0,0 +1,42 @@
package com.matchlivetv.match_live_tv
import android.content.Context
import android.view.View
import android.widget.FrameLayout
import com.pedro.library.view.OpenGlView
import io.flutter.plugin.common.StandardMessageCodec
import io.flutter.plugin.platform.PlatformView
import io.flutter.plugin.platform.PlatformViewFactory
class StreamingPreviewPlatformView(
context: Context,
private val onPreviewReady: (OpenGlView) -> Unit,
) : PlatformView {
private val openGlView = OpenGlView(context).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
)
}
init {
StreamPreviewHolder.openGlView = openGlView
onPreviewReady(openGlView)
}
override fun getView(): View = openGlView
override fun dispose() {
// Non azzerare il holder: la rotazione ricrea la PlatformView e il plugin riaggancia.
}
}
class StreamingPreviewFactory(
private val onPreviewReady: (OpenGlView) -> Unit,
) : PlatformViewFactory(StandardMessageCodec.INSTANCE) {
override fun create(context: Context, viewId: Int, args: Any?): PlatformView {
return StreamingPreviewPlatformView(context, onPreviewReady)
}
}

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="streaming_notification_channel">Streaming live</string>
<string name="streaming_notification_channel_desc">Notifiche durante lo streaming Match Live TV</string>
<string name="streaming_notification_title">Match Live TV</string>
<string name="streaming_notification_active">Streaming attivo</string>
<string name="streaming_notification_connecting">Connessione al server RTMP…</string>
<string name="streaming_notification_streaming">Trasmissione in corso</string>
<string name="streaming_notification_reconnecting">Riconnessione in corso…</string>
<string name="streaming_notification_error">Errore di streaming</string>
<string name="streaming_notification_stop">Stop</string>
</resources>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Dev: API Rails su host (emulatore → 10.0.2.2) -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">10.0.2.2</domain>
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
</domain-config>
</network-security-config>

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,25 @@
allprojects {
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This newDsl flag was added by the Flutter template
android.newDsl=false
# This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false

View File

@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip

View File

@@ -0,0 +1,35 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.0.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.PREFER_PROJECT)
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
}
include(":app")