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:
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>
|
||||
Reference in New Issue
Block a user