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:
8
native/android/.gitignore
vendored
Normal file
8
native/android/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/.gradle/
|
||||||
|
/build/
|
||||||
|
/local.properties
|
||||||
|
/.idea/
|
||||||
|
*.iml
|
||||||
|
/captures/
|
||||||
|
/app/build/
|
||||||
|
.DS_Store
|
||||||
42
native/android/README.md
Normal file
42
native/android/README.md
Normal 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).
|
||||||
75
native/android/app/build.gradle.kts
Normal file
75
native/android/app/build.gradle.kts
Normal 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
4
native/android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# RootEncoder / Pedro RTMP
|
||||||
|
-keep class com.pedro.** { *; }
|
||||||
|
-dontwarn com.pedro.**
|
||||||
|
-dontwarn org.slf4j.**
|
||||||
59
native/android/app/src/main/AndroidManifest.xml
Normal file
59
native/android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
13
native/android/app/src/main/res/drawable/ic_launcher.xml
Normal file
13
native/android/app/src/main/res/drawable/ic_launcher.xml
Normal 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>
|
||||||
10
native/android/app/src/main/res/drawable/ic_notification.xml
Normal file
10
native/android/app/src/main/res/drawable/ic_notification.xml
Normal 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>
|
||||||
13
native/android/app/src/main/res/values/strings.xml
Normal file
13
native/android/app/src/main/res/values/strings.xml
Normal 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>
|
||||||
4
native/android/app/src/main/res/values/themes.xml
Normal file
4
native/android/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.MatchLiveTv" parent="android:Theme.Material.Light.NoActionBar" />
|
||||||
|
</resources>
|
||||||
@@ -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>
|
||||||
5
native/android/build.gradle.kts
Normal file
5
native/android/build.gradle.kts
Normal 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
|
||||||
|
}
|
||||||
4
native/android/gradle.properties
Normal file
4
native/android/gradle.properties
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
BIN
native/android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
native/android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
5
native/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
5
native/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip
|
||||||
160
native/android/gradlew
vendored
Executable file
160
native/android/gradlew
vendored
Executable 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
90
native/android/gradlew.bat
vendored
Normal 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
|
||||||
19
native/android/settings.gradle.kts
Normal file
19
native/android/settings.gradle.kts
Normal 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")
|
||||||
13
scripts/build_native_android_apk_prod.sh
Executable file
13
scripts/build_native_android_apk_prod.sh
Executable file
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
ANDROID_DIR="$ROOT/native/android"
|
||||||
|
API_BASE_URL="${API_BASE_URL:-https://www.matchlivetv.it}"
|
||||||
|
|
||||||
|
cd "$ANDROID_DIR"
|
||||||
|
chmod +x gradlew
|
||||||
|
./gradlew assembleRelease -PAPI_BASE_URL="$API_BASE_URL"
|
||||||
|
|
||||||
|
APK="$ANDROID_DIR/app/build/outputs/apk/release/app-release.apk"
|
||||||
|
echo "APK: $APK"
|
||||||
|
echo "API_BASE_URL=$API_BASE_URL"
|
||||||
Reference in New Issue
Block a user