Aggiunge app Android nativa da zero su branch dedicato.

Nuovo progetto Compose in native/android con API, auth, streaming RTMP e schermate base; nessun codice copiato dalla app Flutter.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-06 15:36:36 +02:00
parent 854738b46d
commit 499aee8988
41 changed files with 2168 additions and 0 deletions

8
native/android/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
/.gradle/
/build/
/local.properties
/.idea/
*.iml
/captures/
/app/build/
.DS_Store

42
native/android/README.md Normal file
View File

@@ -0,0 +1,42 @@
# Match Live TV — app Android nativa
Implementazione **da zero** in Kotlin + Jetpack Compose. Non riusa codice dalla app Flutter (`mobile/`).
## Stack
- UI: Jetpack Compose, Navigation
- Rete: Retrofit + Moshi + OkHttp
- Auth: DataStore
- Streaming: RootEncoder (RTMP) con motore dedicato `LiveBroadcastEngine`
## Struttura
```
app/src/main/kotlin/com/matchlivetv/match_live_tv/
core/ — config, token store
data/ — API, repository, DI container
domain/ — modelli
streaming/ — preview GL, motore RTMP, foreground service
ui/ — schermate Compose
```
## Build APK produzione
```bash
./scripts/build_native_android_apk_prod.sh
adb install -r native/android/app/build/outputs/apk/release/app-release.apk
```
API default: `https://www.matchlivetv.it` (override con `-PAPI_BASE_URL=...`).
## Roadmap
- [x] Login, lista partite, avvio diretta, preview + RTMP
- [ ] Wizard completo (rete, piattaforma YouTube, tabellone)
- [ ] WebSocket tabellone / regia
- [ ] Archivio registrazioni
- [ ] **iOS nativo** — da sviluppare su Mac in `native/ios/` (SwiftUI)
## iOS (prossimo passo su Mac)
Il backend REST/WebSocket è condiviso. Su Mac creeremo `native/ios/` con SwiftUI e AVFoundation/RTMP equivalente, riusando gli stessi contratti API documentati in `mobile/lib/shared/api_client.dart` come riferimento funzionale (non codice).

View File

@@ -0,0 +1,75 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
}
android {
namespace = "com.matchlivetv.match_live_tv"
compileSdk = 35
defaultConfig {
applicationId = "com.matchlivetv.match_live_tv"
minSdk = 24
targetSdk = 35
versionCode = 14
versionName = "2.0.0-native"
val apiBaseUrl = project.findProperty("API_BASE_URL") as String?
?: "https://www.matchlivetv.it"
buildConfigField("String", "API_BASE_URL", "\"$apiBaseUrl\"")
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
signingConfig = signingConfigs.getByName("debug")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
buildConfig = true
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
implementation(composeBom)
androidTestImplementation(composeBom)
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.activity:activity-compose:1.9.3")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.navigation:navigation-compose:2.8.5")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
debugImplementation("androidx.compose.ui:ui-tooling")
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-moshi:2.11.0")
implementation("com.squareup.moshi:moshi-kotlin:1.15.1")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
implementation("androidx.datastore:datastore-preferences:1.1.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation("com.github.pedroSG94.RootEncoder:library:2.5.5")
}

4
native/android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,4 @@
# RootEncoder / Pedro RTMP
-keep class com.pedro.** { *; }
-dontwarn com.pedro.**
-dontwarn org.slf4j.**

View File

@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<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:name=".MatchLiveTvApplication"
android:allowBackup="true"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.MatchLiveTv"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="www.matchlivetv.it"
android:pathPrefix="/join" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="matchlivetv" />
</intent-filter>
</activity>
<service
android:name=".streaming.LiveBroadcastService"
android:exported="false"
android:foregroundServiceType="camera|microphone" />
</application>
</manifest>

View File

@@ -0,0 +1,29 @@
package com.matchlivetv.match_live_tv
import android.content.res.Configuration
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.matchlivetv.match_live_tv.ui.navigation.AppNavHost
import com.matchlivetv.match_live_tv.ui.theme.MatchLiveTheme
class MainActivity : ComponentActivity() {
private val container by lazy { (application as MatchLiveTvApplication).container }
override fun onConfigurationChanged(newConfig: Configuration) {
container.broadcastCoordinator.pauseForConfigurationChange()
super.onConfigurationChanged(newConfig)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MatchLiveTheme {
AppNavHost(container)
}
}
}
}

View File

@@ -0,0 +1,14 @@
package com.matchlivetv.match_live_tv
import android.app.Application
import com.matchlivetv.match_live_tv.data.AppContainer
class MatchLiveTvApplication : Application() {
lateinit var container: AppContainer
private set
override fun onCreate() {
super.onCreate()
container = AppContainer(this)
}
}

View File

@@ -0,0 +1,22 @@
package com.matchlivetv.match_live_tv.core
import com.matchlivetv.match_live_tv.BuildConfig
object AppConfig {
val apiBaseUrl: String = BuildConfig.API_BASE_URL.trimEnd('/')
val apiV1: String get() = "$apiBaseUrl/api/v1"
val cableUrl: String
get() {
val uri = java.net.URI(apiBaseUrl)
val wsScheme = if (uri.scheme == "https") "wss" else "ws"
val defaultPort = if (uri.scheme == "https") 443 else 80
val port = if (uri.port > 0) uri.port else defaultPort
return if (port == defaultPort) {
"$wsScheme://${uri.host}/cable"
} else {
"$wsScheme://${uri.host}:$port/cable"
}
}
}

View File

@@ -0,0 +1,67 @@
package com.matchlivetv.match_live_tv.core
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.matchlivetv.match_live_tv.domain.User
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.tokenDataStore: DataStore<Preferences> by preferencesDataStore("auth")
data class StoredSession(
val user: User,
val accessToken: String,
val refreshToken: String,
)
class TokenStore(private val context: Context) {
private val keyAccess = stringPreferencesKey("access_token")
private val keyRefresh = stringPreferencesKey("refresh_token")
private val keyUserId = stringPreferencesKey("user_id")
private val keyUserEmail = stringPreferencesKey("user_email")
private val keyUserName = stringPreferencesKey("user_name")
private val keyUserRole = stringPreferencesKey("user_role")
private val keyTeamId = stringPreferencesKey("active_team_id")
val sessionFlow: Flow<StoredSession?> = context.tokenDataStore.data.map { prefs ->
val access = prefs[keyAccess] ?: return@map null
val refresh = prefs[keyRefresh] ?: return@map null
val id = prefs[keyUserId] ?: return@map null
StoredSession(
user = User(
id = id,
email = prefs[keyUserEmail].orEmpty(),
name = prefs[keyUserName].orEmpty(),
role = prefs[keyUserRole].orEmpty(),
),
accessToken = access,
refreshToken = refresh,
)
}
val activeTeamIdFlow: Flow<String?> =
context.tokenDataStore.data.map { it[keyTeamId] }
suspend fun saveSession(session: StoredSession) {
context.tokenDataStore.edit { prefs ->
prefs[keyAccess] = session.accessToken
prefs[keyRefresh] = session.refreshToken
prefs[keyUserId] = session.user.id
prefs[keyUserEmail] = session.user.email
prefs[keyUserName] = session.user.name
prefs[keyUserRole] = session.user.role
}
}
suspend fun saveActiveTeamId(teamId: String) {
context.tokenDataStore.edit { it[keyTeamId] = teamId }
}
suspend fun clear() {
context.tokenDataStore.edit { it.clear() }
}
}

View File

@@ -0,0 +1,77 @@
package com.matchlivetv.match_live_tv.data
import android.content.Context
import com.matchlivetv.match_live_tv.core.AppConfig
import com.matchlivetv.match_live_tv.core.TokenStore
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
import com.matchlivetv.match_live_tv.data.repository.AuthRepository
import com.matchlivetv.match_live_tv.data.repository.MatchRepository
import com.matchlivetv.match_live_tv.data.repository.SessionRepository
import com.matchlivetv.match_live_tv.BuildConfig
import com.matchlivetv.match_live_tv.streaming.LiveBroadcastCoordinator
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import java.util.concurrent.TimeUnit
class AppContainer(context: Context) {
private val appContext = context.applicationContext
val tokenStore = TokenStore(appContext)
@Volatile
private var accessToken: String? = null
private val authInterceptor = Interceptor { chain ->
val request = chain.request().newBuilder().apply {
accessToken?.let { header("Authorization", "Bearer $it") }
header("Accept", "application/json")
header("Content-Type", "application/json")
}.build()
chain.proceed(request)
}
private val okHttp = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.addInterceptor(authInterceptor)
.apply {
if (BuildConfig.DEBUG) {
addInterceptor(
HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BASIC
},
)
}
}
.build()
private val moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
val api: MatchLiveApi = Retrofit.Builder()
.baseUrl("${AppConfig.apiV1}/")
.client(okHttp)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
.create(MatchLiveApi::class.java)
val authRepository = AuthRepository(api, tokenStore) { token ->
accessToken = token
}
val matchRepository = MatchRepository(api, tokenStore)
val sessionRepository = SessionRepository(api)
val broadcastCoordinator = LiveBroadcastCoordinator(appContext)
suspend fun bootstrapAuth() {
authRepository.currentSession()?.let { accessToken = it.accessToken }
}
}

View File

@@ -0,0 +1,117 @@
package com.matchlivetv.match_live_tv.data.api
import com.matchlivetv.match_live_tv.domain.AuthTokens
import com.matchlivetv.match_live_tv.domain.Match
import com.matchlivetv.match_live_tv.domain.StreamSession
import com.matchlivetv.match_live_tv.domain.Team
import com.matchlivetv.match_live_tv.domain.User
import com.squareup.moshi.Json
data class LoginRequest(val email: String, val password: String)
data class RefreshRequest(@Json(name = "refresh_token") val refreshToken: String)
data class LoginResponse(
val user: UserDto,
@Json(name = "access_token") val accessToken: String,
@Json(name = "refresh_token") val refreshToken: String,
) {
fun toDomain(): Pair<User, AuthTokens> =
user.toDomain() to AuthTokens(accessToken, refreshToken)
}
data class UserDto(
val id: String,
val email: String,
val name: String,
val role: String? = null,
) {
fun toDomain() = User(id, email, name, role ?: "coach")
}
data class TeamDto(
val id: String,
val name: String,
val sport: String? = null,
@Json(name = "can_stream") val canStream: Boolean? = true,
@Json(name = "youtube_enabled") val youtubeEnabled: Boolean? = false,
@Json(name = "billing_url") val billingUrl: String? = null,
@Json(name = "staff_manage_url") val staffManageUrl: String? = null,
) {
fun toDomain() = Team(
id = id,
name = name,
sport = sport ?: "volleyball",
canStream = canStream ?: true,
youtubeEnabled = youtubeEnabled ?: false,
billingUrl = billingUrl,
staffManageUrl = staffManageUrl,
)
}
data class MatchDto(
val id: String,
@Json(name = "team_id") val teamId: String,
@Json(name = "team_name") val teamName: String? = null,
@Json(name = "opponent_name") val opponentName: String,
val location: String? = null,
@Json(name = "scheduled_at") val scheduledAt: String? = null,
@Json(name = "active_session_id") val activeSessionId: String? = null,
@Json(name = "active_session_status") val activeSessionStatus: String? = null,
) {
fun toDomain() = Match(
id = id,
teamId = teamId,
teamName = teamName.orEmpty(),
opponentName = opponentName,
location = location,
scheduledAt = scheduledAt,
activeSessionId = activeSessionId,
activeSessionStatus = activeSessionStatus,
)
}
data class StreamSessionDto(
val id: String,
@Json(name = "match_id") val matchId: String,
val status: String? = null,
val platform: String? = null,
@Json(name = "rtmp_ingest_url") val rtmpIngestUrl: String? = null,
@Json(name = "hls_playback_url") val hlsPlaybackUrl: String? = null,
@Json(name = "watch_page_url") val watchPageUrl: String? = null,
@Json(name = "share_url") val shareUrl: String? = null,
@Json(name = "target_bitrate") val targetBitrate: Int? = null,
@Json(name = "target_fps") val targetFps: Int? = null,
@Json(name = "started_at") val startedAt: String? = null,
) {
fun toDomain() = StreamSession(
id = id,
matchId = matchId,
status = status ?: "idle",
platform = platform ?: "matchlivetv",
rtmpIngestUrl = rtmpIngestUrl,
hlsPlaybackUrl = hlsPlaybackUrl,
watchPageUrl = watchPageUrl,
shareUrl = shareUrl,
targetBitrate = targetBitrate ?: 2_500_000,
targetFps = targetFps ?: 30,
startedAt = startedAt,
)
}
data class CreateSessionRequest(
val platform: String = "matchlivetv",
@Json(name = "privacy_status") val privacyStatus: String = "public",
@Json(name = "quality_preset") val qualityPreset: String = "720p_30_2.5mbps",
@Json(name = "target_bitrate") val targetBitrate: Int = 2_500_000,
@Json(name = "target_fps") val targetFps: Int = 30,
)
data class CreateMatchRequest(val match: Map<String, String>)
data class NetworkTestRequest(
@Json(name = "download_mbps") val downloadMbps: Double,
@Json(name = "upload_mbps") val uploadMbps: Double,
@Json(name = "latency_ms") val latencyMs: Int,
@Json(name = "network_type") val networkType: String,
)

View File

@@ -0,0 +1,57 @@
package com.matchlivetv.match_live_tv.data.api
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.Path
interface MatchLiveApi {
@POST("auth/login")
suspend fun login(@Body body: LoginRequest): LoginResponse
@POST("auth/refresh")
suspend fun refresh(@Body body: RefreshRequest): LoginResponse
@GET("auth/me")
suspend fun me(): UserDto
@POST("auth/logout")
suspend fun logout()
@GET("teams")
suspend fun teams(): List<TeamDto>
@GET("teams/{teamId}/matches")
suspend fun matches(@Path("teamId") teamId: String): List<MatchDto>
@GET("matches/{matchId}")
suspend fun match(@Path("matchId") matchId: String): MatchDto
@POST("matches/{matchId}/sessions")
suspend fun createSession(
@Path("matchId") matchId: String,
@Body body: CreateSessionRequest,
): StreamSessionDto
@GET("sessions/{sessionId}")
suspend fun session(@Path("sessionId") sessionId: String): StreamSessionDto
@PATCH("sessions/{sessionId}/start")
suspend fun startSession(@Path("sessionId") sessionId: String): StreamSessionDto
@PATCH("sessions/{sessionId}/stop")
suspend fun stopSession(@Path("sessionId") sessionId: String): StreamSessionDto
@PATCH("sessions/{sessionId}/pause")
suspend fun pauseSession(@Path("sessionId") sessionId: String): StreamSessionDto
@PATCH("sessions/{sessionId}/resume")
suspend fun resumeSession(@Path("sessionId") sessionId: String): StreamSessionDto
@POST("sessions/{sessionId}/network_test")
suspend fun networkTest(
@Path("sessionId") sessionId: String,
@Body body: NetworkTestRequest,
)
}

View File

@@ -0,0 +1,54 @@
package com.matchlivetv.match_live_tv.data.repository
import com.matchlivetv.match_live_tv.core.StoredSession
import com.matchlivetv.match_live_tv.core.TokenStore
import com.matchlivetv.match_live_tv.data.api.LoginRequest
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
import com.matchlivetv.match_live_tv.data.api.RefreshRequest
import kotlinx.coroutines.flow.first
class AuthRepository(
private val api: MatchLiveApi,
private val tokenStore: TokenStore,
private val onTokenChanged: (String?) -> Unit,
) {
val sessionFlow = tokenStore.sessionFlow
suspend fun login(email: String, password: String): StoredSession {
val response = api.login(LoginRequest(email, password))
val (user, tokens) = response.toDomain()
val session = StoredSession(user, tokens.accessToken, tokens.refreshToken)
tokenStore.saveSession(session)
onTokenChanged(tokens.accessToken)
return session
}
suspend fun currentSession(): StoredSession? = sessionFlow.first()
suspend fun validateOrRefresh(): StoredSession? {
val stored = currentSession() ?: return null
return runCatching {
api.me()
stored
}.getOrElse {
runCatching {
refresh(stored.refreshToken)
}.getOrNull()
}
}
suspend fun refresh(refreshToken: String): StoredSession {
val response = api.refresh(RefreshRequest(refreshToken))
val (user, tokens) = response.toDomain()
val session = StoredSession(user, tokens.accessToken, tokens.refreshToken)
tokenStore.saveSession(session)
onTokenChanged(tokens.accessToken)
return session
}
suspend fun logout() {
runCatching { api.logout() }
tokenStore.clear()
onTokenChanged(null)
}
}

View File

@@ -0,0 +1,27 @@
package com.matchlivetv.match_live_tv.data.repository
import com.matchlivetv.match_live_tv.core.TokenStore
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
import com.matchlivetv.match_live_tv.domain.Match
import com.matchlivetv.match_live_tv.domain.Team
import kotlinx.coroutines.flow.first
class MatchRepository(
private val api: MatchLiveApi,
private val tokenStore: TokenStore,
) {
suspend fun fetchTeams(): List<Team> =
api.teams().map { it.toDomain() }
suspend fun fetchMatches(): List<Match> {
val teamId = tokenStore.activeTeamIdFlow.first()
?: fetchTeams().firstOrNull()?.id
?: return emptyList()
tokenStore.saveActiveTeamId(teamId)
return api.matches(teamId).map { it.toDomain() }
}
suspend fun selectTeam(teamId: String) {
tokenStore.saveActiveTeamId(teamId)
}
}

View File

@@ -0,0 +1,41 @@
package com.matchlivetv.match_live_tv.data.repository
import com.matchlivetv.match_live_tv.data.api.CreateSessionRequest
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
import com.matchlivetv.match_live_tv.data.api.NetworkTestRequest
import com.matchlivetv.match_live_tv.domain.StreamSession
class SessionRepository(
private val api: MatchLiveApi,
) {
suspend fun createSession(matchId: String): StreamSession =
api.createSession(matchId, CreateSessionRequest()).toDomain()
suspend fun fetchSession(sessionId: String): StreamSession =
api.session(sessionId).toDomain()
suspend fun startSession(sessionId: String): StreamSession =
api.startSession(sessionId).toDomain()
suspend fun stopSession(sessionId: String): StreamSession =
api.stopSession(sessionId).toDomain()
suspend fun pauseSession(sessionId: String): StreamSession =
api.pauseSession(sessionId).toDomain()
suspend fun resumeSession(sessionId: String): StreamSession =
api.resumeSession(sessionId).toDomain()
suspend fun submitNetworkTest(
sessionId: String,
downloadMbps: Double,
uploadMbps: Double,
latencyMs: Int,
networkType: String,
) {
api.networkTest(
sessionId,
NetworkTestRequest(downloadMbps, uploadMbps, latencyMs, networkType),
)
}
}

View File

@@ -0,0 +1,57 @@
package com.matchlivetv.match_live_tv.domain
data class User(
val id: String,
val email: String,
val name: String,
val role: String,
)
data class AuthTokens(
val accessToken: String,
val refreshToken: String,
)
data class Team(
val id: String,
val name: String,
val sport: String,
val canStream: Boolean = true,
val youtubeEnabled: Boolean = false,
val billingUrl: String? = null,
val staffManageUrl: String? = null,
)
data class Match(
val id: String,
val teamId: String,
val teamName: String,
val opponentName: String,
val location: String? = null,
val scheduledAt: String? = null,
val activeSessionId: String? = null,
val activeSessionStatus: String? = null,
) {
val canResumeCamera: Boolean
get() {
val status = activeSessionStatus ?: return false
return activeSessionId != null &&
status in setOf("connecting", "live", "reconnecting", "paused")
}
}
data class StreamSession(
val id: String,
val matchId: String,
val status: String,
val platform: String = "matchlivetv",
val rtmpIngestUrl: String? = null,
val hlsPlaybackUrl: String? = null,
val watchPageUrl: String? = null,
val shareUrl: String? = null,
val targetBitrate: Int = 2_500_000,
val targetFps: Int = 30,
val startedAt: String? = null,
) {
val isLive: Boolean get() = status == "live" || status == "reconnecting"
}

View File

@@ -0,0 +1,28 @@
package com.matchlivetv.match_live_tv.streaming
data class BroadcastConfig(
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 portrait: Boolean = false,
)
enum class BroadcastPhase {
IDLE,
PREVIEW,
CONNECTING,
LIVE,
RECONNECTING,
ERROR,
}
data class BroadcastMetrics(
val phase: BroadcastPhase,
val bitrateKbps: Long = 0,
val fps: Int = 0,
val connected: Boolean = false,
val lastError: String? = null,
)

View File

@@ -0,0 +1,44 @@
package com.matchlivetv.match_live_tv.streaming
import android.content.Context
import android.os.Build
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class LiveBroadcastCoordinator(context: Context) {
private val appContext = context.applicationContext
val engine = LiveBroadcastEngine(appContext)
private val _metrics = MutableStateFlow(BroadcastMetrics(BroadcastPhase.IDLE))
val metrics: StateFlow<BroadcastMetrics> = _metrics.asStateFlow()
init {
engine.setMetricsListener { value ->
_metrics.value = value
}
}
fun pauseForConfigurationChange() {
engine.pauseForConfigurationChange()
}
fun startService() {
val intent = LiveBroadcastService.startIntent(appContext)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
appContext.startForegroundService(intent)
} else {
appContext.startService(intent)
}
}
fun stopService() {
appContext.startService(LiveBroadcastService.stopIntent(appContext))
}
fun release() {
stopService()
engine.release()
}
}

View File

@@ -0,0 +1,305 @@
package com.matchlivetv.match_live_tv.streaming
import android.content.Context
import android.media.MediaCodecInfo
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.util.Log
import com.pedro.common.ConnectChecker
import com.pedro.library.generic.GenericStream
import com.pedro.library.util.SensorRotationManager
/**
* Motore RTMP nativo Match Live TV — implementazione dedicata app Android (non Flutter).
*/
class LiveBroadcastEngine(
private val appContext: Context,
) : ConnectChecker {
private val mainHandler = Handler(Looper.getMainLooper())
private var genericStream: GenericStream? = null
private var previewView: LivePreviewView? = null
private var config: BroadcastConfig? = null
private var phase = BroadcastPhase.IDLE
private var surfaceSuspended = false
private var rebindPending = false
private var lastCameraRotation = -1
private var sensorManager: SensorRotationManager? = null
private var listener: ((BroadcastMetrics) -> Unit)? = null
private val rebindRunnable = Runnable { reattachPreviewIfNeeded() }
fun setMetricsListener(block: ((BroadcastMetrics) -> Unit)?) {
listener = block
emitMetrics()
}
fun bindPreview(view: LivePreviewView) {
mainHandler.post {
previewView = view
view.onSurfaceLost = { onPreviewSurfaceLost() }
updatePipelinePreservationFlag()
if (surfaceSuspended || rebindPending) {
scheduleRebind()
} else if (phase == BroadcastPhase.PREVIEW || phase == BroadcastPhase.LIVE) {
attachPreviewInternal()
} else {
ensurePreviewPrepared(view)
}
}
}
/** Chiamato da Activity.onConfigurationChanged prima di super. */
fun pauseForConfigurationChange() {
mainHandler.post {
if (phase == BroadcastPhase.IDLE) return@post
Log.i(TAG, "pauseForConfigurationChange phase=$phase")
surfaceSuspended = true
rebindPending = true
mainHandler.removeCallbacks(rebindRunnable)
previewView?.setForceRender(false)
runCatching {
genericStream?.getGlInterface()?.setForceRender(false)
genericStream?.getGlInterface()?.deAttachPreview()
}
}
}
fun onPreviewSurfaceReady(view: LivePreviewView) {
mainHandler.post {
previewView = view
view.onSurfaceLost = { onPreviewSurfaceLost() }
updatePipelinePreservationFlag()
scheduleRebind()
}
}
private fun onPreviewSurfaceLost() {
mainHandler.post {
surfaceSuspended = true
rebindPending = true
mainHandler.removeCallbacks(rebindRunnable)
previewView?.setForceRender(false)
runCatching {
genericStream?.getGlInterface()?.setForceRender(false)
genericStream?.getGlInterface()?.deAttachPreview()
}
}
}
fun startBroadcast(broadcastConfig: BroadcastConfig) {
mainHandler.post {
config = broadcastConfig
val stream = obtainStream()
resetEncoder(stream, broadcastConfig)
attachPreviewInternal()
applyOrientation(broadcastConfig.portrait, broadcastConfig.portrait)
startSensor()
setPhase(BroadcastPhase.CONNECTING)
Log.i(TAG, "publish ${broadcastConfig.rtmpUrl}")
stream.startStream(broadcastConfig.rtmpUrl)
}
}
fun stopBroadcast() {
mainHandler.post {
stopSensor()
genericStream?.let { stream ->
if (stream.isStreaming) stream.stopStream()
if (stream.isOnPreview) stream.stopPreview()
}
genericStream = null
surfaceSuspended = false
rebindPending = false
setPhase(BroadcastPhase.IDLE)
}
}
fun release() {
mainHandler.post {
stopBroadcast()
previewView = null
}
}
private fun obtainStream(): GenericStream {
val existing = genericStream
if (existing != null) return existing
return GenericStream(appContext, this).also { stream ->
stream.getGlInterface().autoHandleOrientation = false
stream.getStreamClient().setReTries(10)
genericStream = stream
}
}
private fun ensurePreviewPrepared(view: LivePreviewView) {
val stream = obtainStream()
if (stream.isOnPreview) return
val cfg = config ?: BroadcastConfig(rtmpUrl = "")
resetEncoder(stream, cfg.copy(rtmpUrl = cfg.rtmpUrl.ifBlank { "rtmp://127.0.0.1/preview" }))
attachPreviewInternal()
setPhase(BroadcastPhase.PREVIEW)
}
private fun resetEncoder(stream: GenericStream, cfg: BroadcastConfig) {
if (stream.isStreaming) stream.stopStream()
if (stream.isOnPreview) stream.stopPreview()
val rotation = if (cfg.portrait) 90 else 0
val ok = stream.prepareVideo(
width = cfg.width,
height = cfg.height,
bitrate = cfg.videoBitrate,
fps = cfg.fps,
iFrameInterval = 1,
rotation = rotation,
profile = MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline,
level = MediaCodecInfo.CodecProfileLevel.AVCLevel31,
) && stream.prepareAudio(48_000, false, cfg.audioBitrate)
if (!ok) {
setPhase(BroadcastPhase.ERROR, "prepare_failed")
}
}
private fun attachPreviewInternal() {
val view = previewView ?: return
val stream = genericStream ?: return
if (!isSurfaceUsable(view)) {
scheduleRebind()
return
}
val surface = view.holder.surface ?: return
if (!surface.isValid) {
scheduleRebind()
return
}
runCatching {
val gl = stream.getGlInterface()
gl.setForceRender(false)
if (stream.isOnPreview) gl.deAttachPreview()
gl.attachPreview(surface)
gl.setPreviewResolution(view.width.coerceAtLeast(2), view.height.coerceAtLeast(2))
if (!stream.isOnPreview) stream.startPreview(view, false)
gl.setForceRender(true, 30)
surfaceSuspended = false
rebindPending = false
}.onFailure {
Log.w(TAG, "attachPreviewInternal: ${it.message}")
scheduleRebind()
}
}
private fun scheduleRebind() {
mainHandler.removeCallbacks(rebindRunnable)
mainHandler.postDelayed(rebindRunnable, SURFACE_DEBOUNCE_MS)
}
private fun reattachPreviewIfNeeded() {
if (!rebindPending && !surfaceSuspended) return
if (!isSurfaceUsable(previewView)) {
scheduleRebind()
return
}
attachPreviewInternal()
config?.let { applyOrientation(it.portrait, it.portrait) }
}
private fun isSurfaceUsable(view: LivePreviewView?): Boolean {
view ?: return false
if (!view.isAttachedToWindow || view.width <= 0 || view.height <= 0) return false
val surface = view.holder?.surface ?: return false
return surface.isValid
}
private fun updatePipelinePreservationFlag() {
val keep = phase == BroadcastPhase.LIVE ||
phase == BroadcastPhase.CONNECTING ||
phase == BroadcastPhase.RECONNECTING
previewView?.keepPipelineOnSurfaceLoss = keep
}
private fun applyOrientation(isPortrait: Boolean, forcePortrait: Boolean) {
val stream = genericStream ?: return
if (surfaceSuspended) return
runCatching {
val gl = stream.getGlInterface()
gl.setForceRender(false)
gl.autoHandleOrientation = false
gl.setIsPortrait(isPortrait)
gl.setCameraOrientation(if (forcePortrait) 90 else 0)
gl.setPreviewRotation(0)
gl.setStreamRotation(0)
gl.setForceRender(true, 30)
}
}
private fun startSensor() {
if (sensorManager != null) return
sensorManager = SensorRotationManager(appContext, true, true) { rotation, portrait ->
mainHandler.post {
if (surfaceSuspended || phase == BroadcastPhase.IDLE) return@post
if (rotation == lastCameraRotation) return@post
lastCameraRotation = rotation
applyOrientation(portrait, portrait)
}
}.also { it.start() }
}
private fun stopSensor() {
sensorManager?.stop()
sensorManager = null
lastCameraRotation = -1
}
private fun setPhase(newPhase: BroadcastPhase, error: String? = null) {
phase = newPhase
updatePipelinePreservationFlag()
emitMetrics(error)
}
private fun emitMetrics(error: String? = null) {
val stream = genericStream
listener?.invoke(
BroadcastMetrics(
phase = phase,
bitrateKbps = 0,
fps = 0,
connected = phase == BroadcastPhase.LIVE,
lastError = error,
),
)
}
override fun onConnectionStarted(url: String) {
mainHandler.post { setPhase(BroadcastPhase.CONNECTING) }
}
override fun onConnectionSuccess() {
mainHandler.post { setPhase(BroadcastPhase.LIVE) }
}
override fun onConnectionFailed(reason: String) {
mainHandler.post { setPhase(BroadcastPhase.ERROR, reason) }
}
override fun onNewBitrate(bitrate: Long) = Unit
override fun onDisconnect() {
mainHandler.post {
if (phase == BroadcastPhase.LIVE) setPhase(BroadcastPhase.RECONNECTING)
}
}
override fun onAuthError() {
mainHandler.post { setPhase(BroadcastPhase.ERROR, "auth_error") }
}
override fun onAuthSuccess() {
mainHandler.post { setPhase(BroadcastPhase.LIVE) }
}
companion object {
private const val TAG = "LiveBroadcastEngine"
private const val SURFACE_DEBOUNCE_MS = 700L
}
}

View File

@@ -0,0 +1,138 @@
package com.matchlivetv.match_live_tv.streaming
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.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import com.matchlivetv.match_live_tv.MainActivity
import com.matchlivetv.match_live_tv.R
class LiveBroadcastService : Service() {
private val binder = LocalBinder()
private var wakeLock: PowerManager.WakeLock? = null
inner class LocalBinder : Binder() {
fun service(): LiveBroadcastService = this@LiveBroadcastService
}
override fun onBind(intent: Intent?): IBinder = binder
override fun onCreate() {
super.onCreate()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.streaming_notification_channel),
NotificationManager.IMPORTANCE_LOW,
)
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> promoteForeground(getString(R.string.streaming_notification_active))
ACTION_STOP -> stopForegroundAndSelf()
}
return START_STICKY
}
override fun onDestroy() {
releaseWakeLock()
super.onDestroy()
}
fun updatePhase(phase: BroadcastPhase) {
val text = when (phase) {
BroadcastPhase.CONNECTING -> getString(R.string.streaming_notification_connecting)
BroadcastPhase.LIVE -> getString(R.string.streaming_notification_streaming)
BroadcastPhase.RECONNECTING -> getString(R.string.streaming_notification_reconnecting)
BroadcastPhase.ERROR -> getString(R.string.streaming_notification_error)
else -> getString(R.string.streaming_notification_active)
}
getSystemService(NotificationManager::class.java)
.notify(NOTIFICATION_ID, buildNotification(text))
}
private fun promoteForeground(content: String) {
acquireWakeLock()
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)
}
}
private fun stopForegroundAndSelf() {
releaseWakeLock()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
private fun buildNotification(content: String): Notification {
val launch = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val stop = PendingIntent.getService(
this,
1,
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.drawable.ic_notification)
.setOngoing(true)
.setContentIntent(launch)
.addAction(android.R.drawable.ic_media_pause, getString(R.string.streaming_notification_stop), stop)
.build()
}
private fun acquireWakeLock() {
if (wakeLock?.isHeld == true) return
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$packageName:LiveBroadcast").apply {
acquire(90L * 60L * 1000L)
}
}
private fun releaseWakeLock() {
wakeLock?.takeIf { it.isHeld }?.release()
wakeLock = null
}
companion object {
private const val CHANNEL_ID = "match_live_tv_live"
private const val NOTIFICATION_ID = 2001
private const val ACTION_START = "com.matchlivetv.action.START_LIVE"
private const val ACTION_STOP = "com.matchlivetv.action.STOP_LIVE"
fun startIntent(context: Context) =
Intent(context, LiveBroadcastService::class.java).setAction(ACTION_START)
fun stopIntent(context: Context) =
Intent(context, LiveBroadcastService::class.java).setAction(ACTION_STOP)
}
}

View File

@@ -0,0 +1,28 @@
package com.matchlivetv.match_live_tv.streaming
import android.content.Context
import android.util.AttributeSet
import android.view.SurfaceHolder
import com.pedro.library.view.OpenGlView
/**
* Surface camera per la preview in-app. Durante una diretta attiva non invoca stop()
* del pipeline quando Android ricrea la surface (rotazione).
*/
class LivePreviewView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : OpenGlView(context, attrs) {
var keepPipelineOnSurfaceLoss: Boolean = false
var onSurfaceLost: (() -> Unit)? = null
override fun surfaceDestroyed(holder: SurfaceHolder) {
if (keepPipelineOnSurfaceLoss && isRunning) {
setForceRender(false)
onSurfaceLost?.invoke()
return
}
super.surfaceDestroyed(holder)
}
}

View File

@@ -0,0 +1,145 @@
package com.matchlivetv.match_live_tv.ui.broadcast
import android.view.ViewGroup
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.matchlivetv.match_live_tv.data.AppContainer
import com.matchlivetv.match_live_tv.domain.StreamSession
import com.matchlivetv.match_live_tv.streaming.BroadcastConfig
import com.matchlivetv.match_live_tv.streaming.BroadcastPhase
import com.matchlivetv.match_live_tv.streaming.LivePreviewView
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
import kotlinx.coroutines.launch
@Composable
fun BroadcastScreen(
container: AppContainer,
sessionId: String,
onFinished: () -> Unit,
) {
val scope = rememberCoroutineScope()
var session by remember { mutableStateOf<StreamSession?>(null) }
var error by remember { mutableStateOf<String?>(null) }
val metrics by container.broadcastCoordinator.metrics.collectAsState()
val configuration = LocalConfiguration.current
val portrait = configuration.screenHeightDp > configuration.screenWidthDp
LaunchedEffect(sessionId) {
runCatching { container.sessionRepository.fetchSession(sessionId) }
.onSuccess { loaded ->
session = loaded
val url = loaded.rtmpIngestUrl
if (url.isNullOrBlank()) {
error = "URL RTMP mancante"
return@onSuccess
}
container.broadcastCoordinator.startService()
container.broadcastCoordinator.engine.startBroadcast(
BroadcastConfig(
rtmpUrl = url,
width = if (portrait) 720 else 1280,
height = if (portrait) 1280 else 720,
portrait = portrait,
videoBitrate = loaded.targetBitrate,
fps = loaded.targetFps,
),
)
}
.onFailure { error = it.message ?: "Sessione non disponibile" }
}
DisposableEffect(Unit) {
onDispose {
container.broadcastCoordinator.stopService()
container.broadcastCoordinator.engine.stopBroadcast()
}
}
Column(Modifier.fillMaxSize().background(MatchColors.Background)) {
Box(Modifier.weight(1f).fillMaxWidth()) {
CameraPreviewPanel(container)
Text(
text = when (metrics.phase) {
BroadcastPhase.LIVE -> "IN DIRETTA"
BroadcastPhase.CONNECTING -> "CONNESSIONE…"
BroadcastPhase.RECONNECTING -> "RICONNESSIONE…"
BroadcastPhase.ERROR -> metrics.lastError ?: "ERRORE"
else -> "PREVIEW"
},
color = if (metrics.phase == BroadcastPhase.LIVE) MatchColors.SuccessGreen else MatchColors.AccentYellow,
modifier = Modifier.padding(16.dp),
)
}
error?.let {
Text(it, color = MatchColors.PrimaryRed, modifier = Modifier.padding(16.dp))
}
Button(
onClick = {
scope.launch {
runCatching { container.sessionRepository.stopSession(sessionId) }
container.broadcastCoordinator.engine.stopBroadcast()
container.broadcastCoordinator.stopService()
onFinished()
}
},
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Text("TERMINA DIRETTA")
}
}
}
@Composable
private fun CameraPreviewPanel(container: AppContainer) {
val context = LocalContext.current
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = {
LivePreviewView(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
holder.addCallback(object : android.view.SurfaceHolder.Callback {
override fun surfaceCreated(holder: android.view.SurfaceHolder) {
container.broadcastCoordinator.engine.onPreviewSurfaceReady(this@apply)
}
override fun surfaceChanged(
holder: android.view.SurfaceHolder,
format: Int,
width: Int,
height: Int,
) {
container.broadcastCoordinator.engine.onPreviewSurfaceReady(this@apply)
}
override fun surfaceDestroyed(holder: android.view.SurfaceHolder) = Unit
})
container.broadcastCoordinator.engine.bindPreview(this)
}
},
)
}

View File

@@ -0,0 +1,87 @@
package com.matchlivetv.match_live_tv.ui.login
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import com.matchlivetv.match_live_tv.data.AppContainer
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
import kotlinx.coroutines.launch
@Composable
fun LoginScreen(
container: AppContainer,
onLoggedIn: () -> Unit,
) {
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
var loading by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
) {
Text("MATCH LIVE TV", color = MatchColors.PrimaryRed)
Spacer(Modifier.height(24.dp))
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("Email") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
)
error?.let {
Spacer(Modifier.height(8.dp))
Text(it, color = MatchColors.PrimaryRed)
}
Spacer(Modifier.height(20.dp))
Button(
onClick = {
loading = true
error = null
scope.launch {
runCatching {
container.authRepository.login(email.trim(), password)
}.onSuccess {
onLoggedIn()
}.onFailure {
error = it.message ?: "Login fallito"
}
loading = false
}
},
enabled = !loading && email.isNotBlank() && password.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
Text(if (loading) "Accesso…" else "ACCEDI")
}
}
}

View File

@@ -0,0 +1,132 @@
package com.matchlivetv.match_live_tv.ui.matches
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.matchlivetv.match_live_tv.data.AppContainer
import com.matchlivetv.match_live_tv.domain.Match
import com.matchlivetv.match_live_tv.domain.StreamSession
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MatchesScreen(
container: AppContainer,
onOpenBroadcast: (sessionId: String) -> Unit,
onLogout: () -> Unit,
) {
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var matches by remember { mutableStateOf<List<Match>>(emptyList()) }
val scope = rememberCoroutineScope()
fun reload() {
loading = true
error = null
scope.launch {
runCatching { container.matchRepository.fetchMatches() }
.onSuccess { matches = it }
.onFailure { error = it.message ?: "Errore caricamento" }
loading = false
}
}
LaunchedEffect(Unit) { reload() }
fun startMatch(match: Match) {
scope.launch {
runCatching {
val session = if (match.canResumeCamera && match.activeSessionId != null) {
container.sessionRepository.fetchSession(match.activeSessionId)
} else {
val created = container.sessionRepository.createSession(match.id)
container.sessionRepository.submitNetworkTest(
sessionId = created.id,
downloadMbps = 20.0,
uploadMbps = 5.0,
latencyMs = 50,
networkType = "WiFi",
)
container.sessionRepository.startSession(created.id)
}
session
}.onSuccess { session: StreamSession ->
onOpenBroadcast(session.id)
}.onFailure {
error = it.message ?: "Impossibile avviare la diretta"
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("MATCH LIVE TV") },
actions = {
IconButton(onClick = {
scope.launch {
container.authRepository.logout()
onLogout()
}
}) {
Text("Esci")
}
},
)
},
) { padding ->
Column(
Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
) {
when {
loading -> CircularProgressIndicator(color = MatchColors.PrimaryRed)
error != null -> {
Text(error!!, color = MatchColors.PrimaryRed)
Button(onClick = { reload() }) { Text("Riprova") }
}
matches.isEmpty() -> Text("Nessuna partita in programma")
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(matches, key = { it.id }) { match ->
Column(
Modifier
.fillMaxWidth()
.clickable { startMatch(match) }
.padding(12.dp),
) {
Text("${match.teamName} vs ${match.opponentName}")
Text(
if (match.canResumeCamera) "Riprendi diretta" else "Vai in diretta",
color = MatchColors.AccentYellow,
)
}
}
}
}
}
}
}

View File

@@ -0,0 +1,69 @@
package com.matchlivetv.match_live_tv.ui.navigation
import androidx.compose.runtime.Composable
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.matchlivetv.match_live_tv.data.AppContainer
import com.matchlivetv.match_live_tv.ui.broadcast.BroadcastScreen
import com.matchlivetv.match_live_tv.ui.login.LoginScreen
import com.matchlivetv.match_live_tv.ui.matches.MatchesScreen
import com.matchlivetv.match_live_tv.ui.splash.SplashScreen
@Composable
fun AppNavHost(container: AppContainer) {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = Routes.Splash) {
composable(Routes.Splash) {
SplashScreen(
container = container,
onAuthenticated = {
navController.navigate(Routes.Matches) {
popUpTo(Routes.Splash) { inclusive = true }
}
},
onUnauthenticated = {
navController.navigate(Routes.Login) {
popUpTo(Routes.Splash) { inclusive = true }
}
},
)
}
composable(Routes.Login) {
LoginScreen(container) {
navController.navigate(Routes.Matches) {
popUpTo(Routes.Login) { inclusive = true }
}
}
}
composable(Routes.Matches) {
MatchesScreen(
container = container,
onOpenBroadcast = { sessionId ->
navController.navigate(Routes.broadcast(sessionId))
},
onLogout = {
navController.navigate(Routes.Login) {
popUpTo(Routes.Matches) { inclusive = true }
}
},
)
}
composable(
route = Routes.Broadcast,
arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
) { entry ->
val sessionId = entry.arguments?.getString("sessionId") ?: return@composable
BroadcastScreen(
container = container,
sessionId = sessionId,
onFinished = {
navController.popBackStack()
},
)
}
}
}

View File

@@ -0,0 +1,10 @@
package com.matchlivetv.match_live_tv.ui.navigation
object Routes {
const val Splash = "splash"
const val Login = "login"
const val Matches = "matches"
const val Broadcast = "broadcast/{sessionId}"
fun broadcast(sessionId: String) = "broadcast/$sessionId"
}

View File

@@ -0,0 +1,30 @@
package com.matchlivetv.match_live_tv.ui.splash
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.matchlivetv.match_live_tv.data.AppContainer
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
@Composable
fun SplashScreen(
container: AppContainer,
onAuthenticated: () -> Unit,
onUnauthenticated: () -> Unit,
) {
LaunchedEffect(Unit) {
container.bootstrapAuth()
val session = container.authRepository.validateOrRefresh()
if (session != null) onAuthenticated() else onUnauthenticated()
}
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MatchColors.PrimaryRed)
Text("MATCH LIVE TV", modifier = Modifier.align(Alignment.BottomCenter))
}
}

View File

@@ -0,0 +1,58 @@
package com.matchlivetv.match_live_tv.ui.theme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.MaterialTheme
object MatchColors {
val PrimaryRed = Color(0xFFFF2D2D)
val Background = Color(0xFF0A0A0A)
val Surface = Color(0xFF1E1E1E)
val SurfaceElevated = Color(0xFF2A2A2A)
val AccentYellow = Color(0xFFF5C518)
val SuccessGreen = Color(0xFF22C55E)
val TextSecondary = Color(0xFF9CA3AF)
}
private val DarkScheme = darkColorScheme(
primary = MatchColors.PrimaryRed,
onPrimary = Color.White,
background = MatchColors.Background,
onBackground = Color.White,
surface = MatchColors.Surface,
onSurface = Color.White,
secondary = MatchColors.AccentYellow,
)
private val Typography = Typography(
headlineMedium = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
),
titleMedium = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.SemiBold,
fontSize = 18.sp,
),
bodyMedium = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
color = MatchColors.TextSecondary,
),
)
@androidx.compose.runtime.Composable
fun MatchLiveTheme(content: @androidx.compose.runtime.Composable () -> Unit) {
MaterialTheme(
colorScheme = DarkScheme,
typography = Typography,
content = content,
)
}

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FF2D2D"
android:pathData="M54,8 L100,100 H8 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M54,28 L78,88 H30 Z" />
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF2D2D"
android:pathData="M4,4h16v16H4z" />
</vector>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Match Live TV</string>
<string name="streaming_notification_channel">Diretta in corso</string>
<string name="streaming_notification_channel_desc">Notifica durante lo streaming live</string>
<string name="streaming_notification_title">Match Live TV</string>
<string name="streaming_notification_active">Preparazione diretta…</string>
<string name="streaming_notification_connecting">Connessione RTMP…</string>
<string name="streaming_notification_streaming">In diretta</string>
<string name="streaming_notification_reconnecting">Riconnessione…</string>
<string name="streaming_notification_error">Errore streaming</string>
<string name="streaming_notification_stop">Termina</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.MatchLiveTv" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>

View File

@@ -0,0 +1,5 @@
plugins {
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.1.0" apply false
}

View File

@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

Binary file not shown.

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

160
native/android/gradlew vendored Executable file
View File

@@ -0,0 +1,160 @@
#!/usr/bin/env bash
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS=""
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn ( ) {
echo "$*"
}
die ( ) {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
esac
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
function splitJvmOpts() {
JVM_OPTS=("$@")
}
eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"

90
native/android/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,90 @@
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windowz variants
if not "%OS%" == "Windows_NT" goto win9xME_args
if "%@eval[2+2]" == "4" goto 4NT_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
goto execute
:4NT_args
@rem Get arguments from the 4NT Shell from JP Software
set CMD_LINE_ARGS=%$
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -0,0 +1,19 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
}
rootProject.name = "MatchLiveTvNative"
include(":app")