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:
14
mobile/android/.gitignore
vendored
Normal file
14
mobile/android/.gitignore
vendored
Normal 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
|
||||
59
mobile/android/app/build.gradle.kts
Normal file
59
mobile/android/app/build.gradle.kts
Normal 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
2
mobile/android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# RootEncoder / SLF4J (R8 release)
|
||||
-dontwarn org.slf4j.impl.StaticLoggerBinder
|
||||
7
mobile/android/app/src/debug/AndroidManifest.xml
Normal file
7
mobile/android/app/src/debug/AndroidManifest.xml
Normal 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>
|
||||
54
mobile/android/app/src/main/AndroidManifest.xml
Normal file
54
mobile/android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
BIN
mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 544 B |
BIN
mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
BIN
mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 442 B |
BIN
mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
BIN
mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 721 B |
BIN
mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
BIN
mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
BIN
mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
BIN
mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
18
mobile/android/app/src/main/res/values-night/styles.xml
Normal file
18
mobile/android/app/src/main/res/values-night/styles.xml
Normal 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>
|
||||
12
mobile/android/app/src/main/res/values/strings.xml
Normal file
12
mobile/android/app/src/main/res/values/strings.xml
Normal 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>
|
||||
18
mobile/android/app/src/main/res/values/styles.xml
Normal file
18
mobile/android/app/src/main/res/values/styles.xml
Normal 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>
|
||||
@@ -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>
|
||||
7
mobile/android/app/src/profile/AndroidManifest.xml
Normal file
7
mobile/android/app/src/profile/AndroidManifest.xml
Normal 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>
|
||||
25
mobile/android/build.gradle.kts
Normal file
25
mobile/android/build.gradle.kts
Normal 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)
|
||||
}
|
||||
6
mobile/android/gradle.properties
Normal file
6
mobile/android/gradle.properties
Normal 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
|
||||
5
mobile/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
5
mobile/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
|
||||
35
mobile/android/settings.gradle.kts
Normal file
35
mobile/android/settings.gradle.kts
Normal 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")
|
||||
Reference in New Issue
Block a user