From 499aee89885208c4ae5266d7d0bb2f82b49988a4 Mon Sep 17 00:00:00 2001 From: Emiliano Frascaro Date: Sat, 6 Jun 2026 15:36:36 +0200 Subject: [PATCH] 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 --- native/android/.gitignore | 8 + native/android/README.md | 42 +++ native/android/app/build.gradle.kts | 75 +++++ native/android/app/proguard-rules.pro | 4 + .../android/app/src/main/AndroidManifest.xml | 59 ++++ .../matchlivetv/match_live_tv/MainActivity.kt | 29 ++ .../match_live_tv/MatchLiveTvApplication.kt | 14 + .../match_live_tv/core/AppConfig.kt | 22 ++ .../match_live_tv/core/TokenStore.kt | 67 ++++ .../match_live_tv/data/AppContainer.kt | 77 +++++ .../match_live_tv/data/api/ApiDtos.kt | 117 +++++++ .../match_live_tv/data/api/MatchLiveApi.kt | 57 ++++ .../data/repository/AuthRepository.kt | 54 ++++ .../data/repository/MatchRepository.kt | 27 ++ .../data/repository/SessionRepository.kt | 41 +++ .../match_live_tv/domain/Models.kt | 57 ++++ .../streaming/BroadcastModels.kt | 28 ++ .../streaming/LiveBroadcastCoordinator.kt | 44 +++ .../streaming/LiveBroadcastEngine.kt | 305 ++++++++++++++++++ .../streaming/LiveBroadcastService.kt | 138 ++++++++ .../streaming/LivePreviewView.kt | 28 ++ .../ui/broadcast/BroadcastScreen.kt | 145 +++++++++ .../match_live_tv/ui/login/LoginScreen.kt | 87 +++++ .../match_live_tv/ui/matches/MatchesScreen.kt | 132 ++++++++ .../match_live_tv/ui/navigation/AppNavHost.kt | 69 ++++ .../match_live_tv/ui/navigation/Routes.kt | 10 + .../match_live_tv/ui/splash/SplashScreen.kt | 30 ++ .../match_live_tv/ui/theme/AppTheme.kt | 58 ++++ .../app/src/main/res/drawable/ic_launcher.xml | 13 + .../src/main/res/drawable/ic_notification.xml | 10 + .../app/src/main/res/values/strings.xml | 13 + .../app/src/main/res/values/themes.xml | 4 + .../main/res/xml/network_security_config.xml | 8 + native/android/build.gradle.kts | 5 + native/android/gradle.properties | 4 + .../android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 53636 bytes .../gradle/wrapper/gradle-wrapper.properties | 5 + native/android/gradlew | 160 +++++++++ native/android/gradlew.bat | 90 ++++++ native/android/settings.gradle.kts | 19 ++ scripts/build_native_android_apk_prod.sh | 13 + 41 files changed, 2168 insertions(+) create mode 100644 native/android/.gitignore create mode 100644 native/android/README.md create mode 100644 native/android/app/build.gradle.kts create mode 100644 native/android/app/proguard-rules.pro create mode 100644 native/android/app/src/main/AndroidManifest.xml create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/MainActivity.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/MatchLiveTvApplication.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/AppConfig.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/TokenStore.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/AppContainer.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/AuthRepository.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/MatchRepository.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/SessionRepository.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Models.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/BroadcastModels.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastCoordinator.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastEngine.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastService.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LivePreviewView.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/login/LoginScreen.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/matches/MatchesScreen.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/navigation/AppNavHost.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/navigation/Routes.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/splash/SplashScreen.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/theme/AppTheme.kt create mode 100644 native/android/app/src/main/res/drawable/ic_launcher.xml create mode 100644 native/android/app/src/main/res/drawable/ic_notification.xml create mode 100644 native/android/app/src/main/res/values/strings.xml create mode 100644 native/android/app/src/main/res/values/themes.xml create mode 100644 native/android/app/src/main/res/xml/network_security_config.xml create mode 100644 native/android/build.gradle.kts create mode 100644 native/android/gradle.properties create mode 100644 native/android/gradle/wrapper/gradle-wrapper.jar create mode 100644 native/android/gradle/wrapper/gradle-wrapper.properties create mode 100755 native/android/gradlew create mode 100644 native/android/gradlew.bat create mode 100644 native/android/settings.gradle.kts create mode 100755 scripts/build_native_android_apk_prod.sh diff --git a/native/android/.gitignore b/native/android/.gitignore new file mode 100644 index 0000000..f975f94 --- /dev/null +++ b/native/android/.gitignore @@ -0,0 +1,8 @@ +/.gradle/ +/build/ +/local.properties +/.idea/ +*.iml +/captures/ +/app/build/ +.DS_Store diff --git a/native/android/README.md b/native/android/README.md new file mode 100644 index 0000000..9ef3060 --- /dev/null +++ b/native/android/README.md @@ -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). diff --git a/native/android/app/build.gradle.kts b/native/android/app/build.gradle.kts new file mode 100644 index 0000000..80e51c3 --- /dev/null +++ b/native/android/app/build.gradle.kts @@ -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") +} diff --git a/native/android/app/proguard-rules.pro b/native/android/app/proguard-rules.pro new file mode 100644 index 0000000..5ca49b7 --- /dev/null +++ b/native/android/app/proguard-rules.pro @@ -0,0 +1,4 @@ +# RootEncoder / Pedro RTMP +-keep class com.pedro.** { *; } +-dontwarn com.pedro.** +-dontwarn org.slf4j.** diff --git a/native/android/app/src/main/AndroidManifest.xml b/native/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..bedeaaf --- /dev/null +++ b/native/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/MainActivity.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/MainActivity.kt new file mode 100644 index 0000000..5562355 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/MainActivity.kt @@ -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) + } + } + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/MatchLiveTvApplication.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/MatchLiveTvApplication.kt new file mode 100644 index 0000000..79370b6 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/MatchLiveTvApplication.kt @@ -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) + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/AppConfig.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/AppConfig.kt new file mode 100644 index 0000000..8e1a735 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/AppConfig.kt @@ -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" + } + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/TokenStore.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/TokenStore.kt new file mode 100644 index 0000000..85521bb --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/TokenStore.kt @@ -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 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 = 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 = + 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() } + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/AppContainer.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/AppContainer.kt new file mode 100644 index 0000000..99744ce --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/AppContainer.kt @@ -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 } + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt new file mode 100644 index 0000000..6f7ec01 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt @@ -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.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) + +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, +) diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt new file mode 100644 index 0000000..44a6849 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt @@ -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 + + @GET("teams/{teamId}/matches") + suspend fun matches(@Path("teamId") teamId: String): List + + @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, + ) +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/AuthRepository.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/AuthRepository.kt new file mode 100644 index 0000000..50dc413 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/AuthRepository.kt @@ -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) + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/MatchRepository.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/MatchRepository.kt new file mode 100644 index 0000000..b86f680 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/MatchRepository.kt @@ -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 = + api.teams().map { it.toDomain() } + + suspend fun fetchMatches(): List { + 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) + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/SessionRepository.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/SessionRepository.kt new file mode 100644 index 0000000..4d2ac82 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/SessionRepository.kt @@ -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), + ) + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Models.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Models.kt new file mode 100644 index 0000000..3808b82 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Models.kt @@ -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" +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/BroadcastModels.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/BroadcastModels.kt new file mode 100644 index 0000000..be47fb9 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/BroadcastModels.kt @@ -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, +) diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastCoordinator.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastCoordinator.kt new file mode 100644 index 0000000..96b1c5a --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastCoordinator.kt @@ -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 = _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() + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastEngine.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastEngine.kt new file mode 100644 index 0000000..d842fd5 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastEngine.kt @@ -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 + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastService.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastService.kt new file mode 100644 index 0000000..b7c14ec --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastService.kt @@ -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) + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LivePreviewView.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LivePreviewView.kt new file mode 100644 index 0000000..7dcbfc9 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LivePreviewView.kt @@ -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) + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt new file mode 100644 index 0000000..d253a1e --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt @@ -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(null) } + var error by remember { mutableStateOf(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) + } + }, + ) +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/login/LoginScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/login/LoginScreen.kt new file mode 100644 index 0000000..f45bb4d --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/login/LoginScreen.kt @@ -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(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") + } + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/matches/MatchesScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/matches/MatchesScreen.kt new file mode 100644 index 0000000..8024af9 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/matches/MatchesScreen.kt @@ -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(null) } + var matches by remember { mutableStateOf>(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, + ) + } + } + } + } + } + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/navigation/AppNavHost.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/navigation/AppNavHost.kt new file mode 100644 index 0000000..ec9b87a --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/navigation/AppNavHost.kt @@ -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() + }, + ) + } + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/navigation/Routes.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/navigation/Routes.kt new file mode 100644 index 0000000..6c03074 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/navigation/Routes.kt @@ -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" +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/splash/SplashScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/splash/SplashScreen.kt new file mode 100644 index 0000000..cf245c9 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/splash/SplashScreen.kt @@ -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)) + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/theme/AppTheme.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/theme/AppTheme.kt new file mode 100644 index 0000000..c6c7373 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/theme/AppTheme.kt @@ -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, + ) +} diff --git a/native/android/app/src/main/res/drawable/ic_launcher.xml b/native/android/app/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..515461b --- /dev/null +++ b/native/android/app/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/native/android/app/src/main/res/drawable/ic_notification.xml b/native/android/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 0000000..331e4c0 --- /dev/null +++ b/native/android/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,10 @@ + + + + diff --git a/native/android/app/src/main/res/values/strings.xml b/native/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..cd869a6 --- /dev/null +++ b/native/android/app/src/main/res/values/strings.xml @@ -0,0 +1,13 @@ + + + Match Live TV + Diretta in corso + Notifica durante lo streaming live + Match Live TV + Preparazione diretta… + Connessione RTMP… + In diretta + Riconnessione… + Errore streaming + Termina + diff --git a/native/android/app/src/main/res/values/themes.xml b/native/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..2e23a26 --- /dev/null +++ b/native/android/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +