Commit inicial: G Radio Remoto — Android
Cliente remoto Android (Kotlin + Jetpack Compose) para G Radio Player, mismo protocolo TCP/relay que el cliente de escritorio: reproductor, buscador, parrilla, pautaje, botonera, comercios y visor remotos.
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.gradio.client"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.gradio.client"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 8
|
||||
versionName = "1.0.7"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("sideload") {
|
||||
storeFile = file(System.getProperty("user.home") + "/.android/debug.keystore")
|
||||
storePassword = "android"
|
||||
keyAlias = "androiddebugkey"
|
||||
keyPassword = "android"
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
signingConfig = signingConfigs.getByName("sideload")
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "11"
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.gson)
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
}
|
||||
|
||||
tasks.register("renameApk") {
|
||||
doLast {
|
||||
val buildType = if (gradle.startParameter.taskNames.any { it.contains("Release") }) "release" else "debug"
|
||||
val originalFile = file("build/outputs/apk/$buildType/app-$buildType.apk")
|
||||
val newFile = file("build/outputs/apk/$buildType/GRadioClient.apk")
|
||||
if (originalFile.exists()) {
|
||||
originalFile.renameTo(newFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
tasks.named("assembleDebug").configure { finalizedBy("renameApk") }
|
||||
tasks.named("assembleRelease").configure { finalizedBy("renameApk") }
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
-keep class com.gradio.client.data.** { *; }
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.GRadioClient">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,142 @@
|
||||
package com.gradio.client
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.gradio.client.network.EstadoCon
|
||||
import com.gradio.client.ui.components.ConnectionBar
|
||||
import com.gradio.client.ui.screens.*
|
||||
import com.gradio.client.ui.theme.GRadioClientTheme
|
||||
import com.gradio.client.ui.viewmodel.MainViewModel
|
||||
|
||||
sealed class Screen(val route: String, val label: String, val icon: ImageVector) {
|
||||
object Player : Screen("player", "Player", Icons.Default.PlayArrow)
|
||||
object Buscador : Screen("buscador", "Búsqueda", Icons.Default.Search)
|
||||
object Parrilla : Screen("parrilla", "Parrilla", Icons.Default.CalendarMonth)
|
||||
object Botonera : Screen("botonera", "Botonera", Icons.Default.GridView)
|
||||
object Pautaje : Screen("pautaje", "Pautaje", Icons.Default.Schedule)
|
||||
object Comercios : Screen("comercios", "Comercios", Icons.Default.Campaign)
|
||||
object Visor : Screen("visor", "Reportes", Icons.Default.BarChart)
|
||||
}
|
||||
|
||||
val SCREENS = listOf(
|
||||
Screen.Player,
|
||||
Screen.Buscador,
|
||||
Screen.Parrilla,
|
||||
Screen.Botonera,
|
||||
Screen.Pautaje,
|
||||
Screen.Comercios,
|
||||
Screen.Visor
|
||||
)
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val vm: MainViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
GRadioClientTheme {
|
||||
GRadioClientApp(vm)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GRadioClientApp(vm: MainViewModel) {
|
||||
val config by vm.config.collectAsState()
|
||||
val estadoCon by vm.estadoCon.collectAsState()
|
||||
val snackbar by vm.snackbar.collectAsState()
|
||||
var pantalla by remember { mutableStateOf<Screen>(Screen.Player) }
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
// Mostrar snackbar cuando llega
|
||||
LaunchedEffect(snackbar) {
|
||||
snackbar?.let {
|
||||
snackbarHostState.showSnackbar(it, duration = SnackbarDuration.Short)
|
||||
vm.limpiarSnackbar()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = {
|
||||
SnackbarHost(snackbarHostState) { data ->
|
||||
Snackbar(
|
||||
snackbarData = data,
|
||||
containerColor = com.gradio.client.ui.theme.GradioCard,
|
||||
contentColor = com.gradio.client.ui.theme.OnBg
|
||||
)
|
||||
}
|
||||
},
|
||||
bottomBar = {
|
||||
BottomNavBar(pantalla) { pantalla = it }
|
||||
},
|
||||
containerColor = com.gradio.client.ui.theme.GradioBg
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
// Barra de conexión siempre visible arriba
|
||||
ConnectionBar(
|
||||
estadoCon = estadoCon,
|
||||
config = config,
|
||||
onConectar = { cfg -> vm.conectar(cfg) },
|
||||
onDesconectar = { vm.desconectar() }
|
||||
)
|
||||
|
||||
// Contenido de la pantalla activa
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
when (pantalla) {
|
||||
Screen.Player -> PlayerScreen(vm)
|
||||
Screen.Buscador -> BuscadorScreen(vm)
|
||||
Screen.Parrilla -> ParrillaScreen(vm)
|
||||
Screen.Botonera -> BotoneraScreen(vm)
|
||||
Screen.Pautaje -> PautajeScreen(vm)
|
||||
Screen.Comercios -> ComerciosScreen(vm)
|
||||
Screen.Visor -> VisorScreen(vm)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomNavBar(actual: Screen, onSelect: (Screen) -> Unit) {
|
||||
NavigationBar(
|
||||
containerColor = com.gradio.client.ui.theme.GradioSurface,
|
||||
contentColor = com.gradio.client.ui.theme.OnBg
|
||||
) {
|
||||
SCREENS.forEach { screen ->
|
||||
NavigationBarItem(
|
||||
selected = actual == screen,
|
||||
onClick = { onSelect(screen) },
|
||||
icon = {
|
||||
Icon(screen.icon, contentDescription = screen.label, modifier = Modifier.size(22.dp))
|
||||
},
|
||||
label = { Text(screen.label, fontSize = 10.sp, maxLines = 1) },
|
||||
colors = NavigationBarItemDefaults.colors(
|
||||
selectedIconColor = com.gradio.client.ui.theme.GradioGreen,
|
||||
selectedTextColor = com.gradio.client.ui.theme.GradioGreen,
|
||||
unselectedIconColor = com.gradio.client.ui.theme.OnSurfaceDim,
|
||||
unselectedTextColor = com.gradio.client.ui.theme.OnSurfaceDim,
|
||||
indicatorColor = com.gradio.client.ui.theme.GradioGreenDk.copy(alpha = 0.2f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package com.gradio.client.data
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
// ── Tipos compartidos ─────────────────────────────────────────────────────────
|
||||
|
||||
data class EntradaRemota(
|
||||
val ruta: String = "",
|
||||
val dias: String = "",
|
||||
val inicio: String = "",
|
||||
val fin: String = ""
|
||||
)
|
||||
|
||||
data class TrackRemoto(
|
||||
val ruta: String = "",
|
||||
val duracion: String = "",
|
||||
val titulo: String = ""
|
||||
)
|
||||
|
||||
data class EstadoRemoto(
|
||||
val reproduciendo: Boolean = false,
|
||||
val pausado: Boolean = false,
|
||||
val track_actual: String = "",
|
||||
val titulo_actual: String = "",
|
||||
val posicion_secs: Double = 0.0,
|
||||
val duracion_secs: Double = 0.0,
|
||||
val upvol: Int = 85,
|
||||
val downvol: Int = 85,
|
||||
val comerciales_activos: Boolean = false,
|
||||
val eventos_activos: Boolean = false,
|
||||
val num_comerciales: Int = 0,
|
||||
val num_eventos: Int = 0,
|
||||
val num_eventos_espera: Int = 0,
|
||||
val indice_actual: Int = 0
|
||||
)
|
||||
|
||||
data class EntradaDir(
|
||||
val nombre: String = "",
|
||||
val ruta: String = "",
|
||||
val es_dir: Boolean = false,
|
||||
val es_audio: Boolean = false,
|
||||
val tiene_hijos: Boolean = false
|
||||
)
|
||||
|
||||
data class ResultadoBusqueda(
|
||||
val ruta: String = "",
|
||||
val nombre: String = "",
|
||||
val duracion: String = ""
|
||||
)
|
||||
|
||||
data class BotonRemoto(
|
||||
val indice: Int = 0,
|
||||
val etiqueta: String = "",
|
||||
val ruta: String = "",
|
||||
val color: String = ""
|
||||
)
|
||||
|
||||
data class ItemTanda(
|
||||
val ruta: String = "",
|
||||
val nombre: String = "",
|
||||
val duracion_secs: Double = 0.0
|
||||
)
|
||||
|
||||
data class TandaRemota(
|
||||
val hora: Int = 0,
|
||||
val minuto: Int = 0,
|
||||
val items: List<ItemTanda> = emptyList(),
|
||||
val duracion_total_secs: Double = 0.0
|
||||
)
|
||||
|
||||
// ── Mensajes Cliente → Servidor ───────────────────────────────────────────────
|
||||
|
||||
sealed class MsgCliente {
|
||||
abstract val tipo: String
|
||||
|
||||
data class Auth(val token: String) : MsgCliente() {
|
||||
override val tipo = "Auth"
|
||||
}
|
||||
object Ping : MsgCliente() {
|
||||
override val tipo = "Ping"
|
||||
}
|
||||
data class ListarDir(val ruta: String) : MsgCliente() {
|
||||
override val tipo = "ListarDir"
|
||||
}
|
||||
data class ObtenerPautaje(val tipo_p: String, val hora: Int, val minuto: Int) : MsgCliente() {
|
||||
override val tipo = "ObtenerPautaje"
|
||||
}
|
||||
data class GuardarPautaje(val tipo_p: String, val hora: Int, val minuto: Int, val entradas: List<EntradaRemota>) : MsgCliente() {
|
||||
override val tipo = "GuardarPautaje"
|
||||
}
|
||||
data class ObtenerParrilla(val dia: Int, val hora: Int) : MsgCliente() {
|
||||
override val tipo = "ObtenerParrilla"
|
||||
}
|
||||
data class GuardarParrilla(val dia: Int, val hora: Int, val items: List<String>) : MsgCliente() {
|
||||
override val tipo = "GuardarParrilla"
|
||||
}
|
||||
object ObtenerBotonera : MsgCliente() {
|
||||
override val tipo = "ObtenerBotonera"
|
||||
}
|
||||
data class GuardarBotonera(val botones: List<BotonRemoto>) : MsgCliente() {
|
||||
override val tipo = "GuardarBotonera"
|
||||
}
|
||||
object ObtenerPlaylist : MsgCliente() {
|
||||
override val tipo = "ObtenerPlaylist"
|
||||
}
|
||||
object ObtenerEstado : MsgCliente() {
|
||||
override val tipo = "ObtenerEstado"
|
||||
}
|
||||
data class ComandoPlayer(val cmd: String) : MsgCliente() {
|
||||
override val tipo = "ComandoPlayer"
|
||||
}
|
||||
data class SetVolumen(val upvol: Int) : MsgCliente() {
|
||||
override val tipo = "SetVolumen"
|
||||
}
|
||||
data class ReproducirAhora(val ruta: String) : MsgCliente() {
|
||||
override val tipo = "ReproducirAhora"
|
||||
}
|
||||
data class AgregarAlPlaylist(val ruta: String, val duracion: String, val alInicio: Boolean = false) : MsgCliente() {
|
||||
override val tipo = "AgregarAlPlaylist"
|
||||
}
|
||||
data class EliminarDePlaylist(val indice: Int) : MsgCliente() {
|
||||
override val tipo = "EliminarDePlaylist"
|
||||
}
|
||||
data class MoverEnPlaylist(val indice: Int, val arriba: Boolean) : MsgCliente() {
|
||||
override val tipo = "MoverEnPlaylist"
|
||||
}
|
||||
data class Buscar(val consulta: String, val ruta: String?) : MsgCliente() {
|
||||
override val tipo = "Buscar"
|
||||
}
|
||||
data class ObtenerReportes(val tipo_r: String, val fecha: String) : MsgCliente() {
|
||||
override val tipo = "ObtenerReportes"
|
||||
}
|
||||
object ActualizarIndice : MsgCliente() {
|
||||
override val tipo = "ActualizarIndice"
|
||||
}
|
||||
data class PlaylistPlayAhora(val ruta: String, val duracion: String) : MsgCliente() {
|
||||
override val tipo = "PlaylistPlayAhora"
|
||||
}
|
||||
object StopGeneral : MsgCliente() {
|
||||
override val tipo = "StopGeneral"
|
||||
}
|
||||
object VaciarComerciales : MsgCliente() {
|
||||
override val tipo = "VaciarComerciales"
|
||||
}
|
||||
object VaciarEventos : MsgCliente() {
|
||||
override val tipo = "VaciarEventos"
|
||||
}
|
||||
object VaciarEventosEspera : MsgCliente() {
|
||||
override val tipo = "VaciarEventosEspera"
|
||||
}
|
||||
data class ObtenerCola(val tipo_cola: String) : MsgCliente() {
|
||||
override val tipo = "ObtenerCola"
|
||||
}
|
||||
data class EliminarDeCola(val tipo_cola: String, val indice: Int) : MsgCliente() {
|
||||
override val tipo = "EliminarDeCola"
|
||||
}
|
||||
object ObtenerProximasTandas : MsgCliente() {
|
||||
override val tipo = "ObtenerProximasTandas"
|
||||
}
|
||||
data class ReproducirTanda(val hora: Int, val minuto: Int, val items: List<String>) : MsgCliente() {
|
||||
override val tipo = "ReproducirTanda"
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mensajes Servidor → Cliente ───────────────────────────────────────────────
|
||||
|
||||
sealed class MsgServidor {
|
||||
data class AuthOk(val version: String) : MsgServidor()
|
||||
object Pong : MsgServidor()
|
||||
data class Error(val mensaje: String) : MsgServidor()
|
||||
data class DirectorioListado(val ruta: String, val entradas: List<EntradaDir>) : MsgServidor()
|
||||
data class PautajeObtenido(val tipo_p: String, val hora: Int, val minuto: Int, val entradas: List<EntradaRemota>) : MsgServidor()
|
||||
object PautajeGuardado : MsgServidor()
|
||||
data class ParrillaObtenida(val dia: Int, val hora: Int, val items: List<String>) : MsgServidor()
|
||||
object ParrillaGuardada : MsgServidor()
|
||||
data class BotoneraObtenida(val botones: List<BotonRemoto>) : MsgServidor()
|
||||
object BotoneraGuardada : MsgServidor()
|
||||
data class PlaylistObtenida(val tracks: List<TrackRemoto>) : MsgServidor()
|
||||
data class EstadoObtenido(val estado: EstadoRemoto) : MsgServidor()
|
||||
data class ResultadosBusqueda(val resultados: List<ResultadoBusqueda>) : MsgServidor()
|
||||
data class ReportesObtenidos(val lineas: List<String>) : MsgServidor()
|
||||
data class PlaylistCambiada(val tracks: List<TrackRemoto>) : MsgServidor()
|
||||
data class EstadoCambiado(val estado: EstadoRemoto) : MsgServidor()
|
||||
data class IndiceActualizado(val ok: Boolean, val mensaje: String) : MsgServidor()
|
||||
data class ColaObtenida(val tipo_cola: String, val items: List<String>) : MsgServidor()
|
||||
data class ProximasTandasObtenidas(val tandas: List<TandaRemota>) : MsgServidor()
|
||||
object Ok : MsgServidor()
|
||||
object Unknown : MsgServidor()
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
package com.gradio.client.network
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonObject
|
||||
import com.google.gson.JsonParser
|
||||
import com.gradio.client.data.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import okhttp3.*
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.io.PrintWriter
|
||||
import java.net.Socket
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
|
||||
private const val RELAY_URL = "wss://relay.gradio.net/ws"
|
||||
|
||||
sealed class EstadoCon {
|
||||
object Desconectado : EstadoCon()
|
||||
object Conectando : EstadoCon()
|
||||
data class Conectado(val version: String) : EstadoCon()
|
||||
data class Error(val mensaje: String) : EstadoCon()
|
||||
}
|
||||
|
||||
class ConnectionManager {
|
||||
|
||||
private val gson = Gson()
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
private val _estadoCon = MutableStateFlow<EstadoCon>(EstadoCon.Desconectado)
|
||||
val estadoCon: StateFlow<EstadoCon> = _estadoCon
|
||||
|
||||
private val _mensajes = MutableSharedFlow<MsgServidor>(extraBufferCapacity = 100)
|
||||
val mensajes: SharedFlow<MsgServidor> = _mensajes
|
||||
|
||||
private val cola = LinkedBlockingQueue<String>()
|
||||
|
||||
@Volatile private var activa = false
|
||||
private var jobConexion: Job? = null
|
||||
|
||||
// Contador de generación — se incrementa en cada desconexión para invalidar
|
||||
// callbacks de OkHttp que llegan tarde (onFailure tras ws.cancel()).
|
||||
private val generacion = java.util.concurrent.atomic.AtomicInteger(0)
|
||||
|
||||
// Socket activo — cerrado explícitamente al desconectar para interrumpir readLine()
|
||||
@Volatile private var socketActivo: Socket? = null
|
||||
// WebSocket activo — cancelado al desconectar
|
||||
@Volatile private var wsActivo: WebSocket? = null
|
||||
@Volatile private var okHttpClient: OkHttpClient? = null
|
||||
|
||||
// ── Conectar LAN (TCP) ────────────────────────────────────────────────────
|
||||
|
||||
fun conectarTCP(servidor: String, puerto: Int, token: String) {
|
||||
desconectar()
|
||||
jobConexion = scope.launch {
|
||||
_estadoCon.value = EstadoCon.Conectando
|
||||
var delaySecs = 5L
|
||||
while (isActive) {
|
||||
var socket: Socket? = null
|
||||
try {
|
||||
socket = Socket(servidor, puerto)
|
||||
socket.soTimeout = 60_000 // 60s read timeout para detectar desconexiones
|
||||
socketActivo = socket
|
||||
activa = true
|
||||
|
||||
val writer = PrintWriter(socket.getOutputStream(), true)
|
||||
val reader = BufferedReader(InputStreamReader(socket.getInputStream()))
|
||||
|
||||
// Auth
|
||||
writer.println(toJson(MsgCliente.Auth(token)))
|
||||
|
||||
// Lanzar escritor en hilo separado
|
||||
val writerJob = launch {
|
||||
while (isActive) {
|
||||
val json = cola.poll(100, java.util.concurrent.TimeUnit.MILLISECONDS) ?: continue
|
||||
writer.println(json)
|
||||
}
|
||||
}
|
||||
|
||||
var desconectado = false
|
||||
try {
|
||||
while (isActive) {
|
||||
val linea = try {
|
||||
reader.readLine()
|
||||
} catch (e: java.net.SocketTimeoutException) {
|
||||
// Timeout de lectura — enviar Ping para mantener viva la conexión
|
||||
if (activa) writer.println(toJson(MsgCliente.Ping))
|
||||
continue
|
||||
}
|
||||
if (linea == null) break
|
||||
if (linea.isBlank()) continue
|
||||
procesarLinea(linea, token)
|
||||
}
|
||||
desconectado = true
|
||||
} catch (e: Exception) {
|
||||
if (isActive) desconectado = true
|
||||
} finally {
|
||||
writerJob.cancel()
|
||||
activa = false
|
||||
socketActivo = null
|
||||
try { socket.close() } catch (_: Exception) {}
|
||||
}
|
||||
|
||||
if (!isActive) break
|
||||
|
||||
if (desconectado) {
|
||||
_estadoCon.value = EstadoCon.Desconectado
|
||||
delay(delaySecs * 1000L)
|
||||
if (!isActive) break
|
||||
delaySecs = (delaySecs * 2).coerceAtMost(60)
|
||||
_estadoCon.value = EstadoCon.Conectando
|
||||
} else break
|
||||
|
||||
} catch (e: CancellationException) {
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
activa = false
|
||||
socketActivo = null
|
||||
try { socket?.close() } catch (_: Exception) {}
|
||||
if (!isActive) break
|
||||
_estadoCon.value = EstadoCon.Error("$servidor:$puerto — ${e.message}")
|
||||
delay(delaySecs * 1000L)
|
||||
if (!isActive) break
|
||||
delaySecs = (delaySecs * 2).coerceAtMost(60)
|
||||
_estadoCon.value = EstadoCon.Conectando
|
||||
}
|
||||
}
|
||||
activa = false
|
||||
socketActivo = null
|
||||
_estadoCon.value = EstadoCon.Desconectado
|
||||
}
|
||||
}
|
||||
|
||||
// ── Conectar Relay (WebSocket) ────────────────────────────────────────────
|
||||
|
||||
fun conectarRelay(relayId: String, token: String) {
|
||||
desconectar()
|
||||
val gen = generacion.get() // generación de esta sesión; callbacks de sesiones anteriores la ignorarán
|
||||
jobConexion = scope.launch {
|
||||
_estadoCon.value = EstadoCon.Conectando
|
||||
var delaySecs = 5L
|
||||
|
||||
while (isActive) {
|
||||
val client = OkHttpClient.Builder()
|
||||
.readTimeout(0, java.util.concurrent.TimeUnit.MILLISECONDS)
|
||||
.pingInterval(30, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
okHttpClient = client
|
||||
|
||||
val continuacion = CompletableDeferred<Boolean>()
|
||||
|
||||
val request = Request.Builder().url(RELAY_URL).build()
|
||||
val ws = client.newWebSocket(request, object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
webSocket.send("""{"tipo":"connect","id":"$relayId"}""")
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
if (generacion.get() != gen) return
|
||||
scope.launch {
|
||||
val trimmed = text.trim()
|
||||
if (trimmed.isBlank()) return@launch
|
||||
val json = try { JsonParser.parseString(trimmed).asJsonObject } catch (_: Exception) { null }
|
||||
val tipoLower = json?.get("tipo")?.asString?.lowercase() ?: ""
|
||||
when (tipoLower) {
|
||||
"connected" -> webSocket.send(toJson(MsgCliente.Auth(token)))
|
||||
"error" -> {
|
||||
val msg = json?.get("msg")?.asString ?: "ID no encontrado"
|
||||
_estadoCon.value = EstadoCon.Error("ID $relayId: $msg")
|
||||
delay(10_000L)
|
||||
if (continuacion.isActive) continuacion.complete(true)
|
||||
}
|
||||
else -> procesarLinea(trimmed, token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
activa = false
|
||||
if (generacion.get() != gen) {
|
||||
if (continuacion.isActive) continuacion.complete(false)
|
||||
return
|
||||
}
|
||||
if (continuacion.isActive) continuacion.complete(isActive)
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
activa = false
|
||||
// Si la generación cambió, esta sesión fue cancelada intencionalmente — ignorar
|
||||
if (!isActive || generacion.get() != gen) {
|
||||
if (continuacion.isActive) continuacion.complete(false)
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
// Re-verificar generación: desconectar() pudo haberse llamado entre
|
||||
// el chequeo de arriba y la ejecución de esta coroutine.
|
||||
if (generacion.get() != gen) return@launch
|
||||
_estadoCon.value = EstadoCon.Error("relay: ${t.message ?: "error de conexión"}")
|
||||
if (continuacion.isActive) continuacion.complete(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
wsActivo = ws
|
||||
|
||||
val writerJob = launch {
|
||||
while (isActive) {
|
||||
val json = cola.poll(100, java.util.concurrent.TimeUnit.MILLISECONDS) ?: continue
|
||||
ws.send(json)
|
||||
}
|
||||
}
|
||||
|
||||
val reconectar = try {
|
||||
continuacion.await()
|
||||
} catch (_: CancellationException) {
|
||||
false
|
||||
} finally {
|
||||
writerJob.cancel()
|
||||
ws.cancel()
|
||||
wsActivo = null
|
||||
try { client.dispatcher.executorService.shutdown() } catch (_: Exception) {}
|
||||
}
|
||||
|
||||
if (!reconectar || !isActive) break
|
||||
delay(delaySecs * 1000L)
|
||||
if (!isActive) break
|
||||
delaySecs = (delaySecs * 2).coerceAtMost(60)
|
||||
_estadoCon.value = EstadoCon.Conectando
|
||||
}
|
||||
|
||||
activa = false
|
||||
wsActivo = null
|
||||
_estadoCon.value = EstadoCon.Desconectado
|
||||
}
|
||||
}
|
||||
|
||||
// ── Desconectar — interrumpe inmediatamente ───────────────────────────────
|
||||
|
||||
fun desconectar() {
|
||||
generacion.incrementAndGet() // invalida callbacks pendientes de OkHttp
|
||||
activa = false
|
||||
// Cerrar socket TCP → interrumpe readLine() bloqueante
|
||||
try { socketActivo?.close() } catch (_: Exception) {}
|
||||
socketActivo = null
|
||||
// Cancelar WebSocket
|
||||
try { wsActivo?.cancel() } catch (_: Exception) {}
|
||||
wsActivo = null
|
||||
// Cancelar coroutina
|
||||
jobConexion?.cancel()
|
||||
jobConexion = null
|
||||
// Apagar OkHttp
|
||||
try { okHttpClient?.dispatcher?.executorService?.shutdown() } catch (_: Exception) {}
|
||||
okHttpClient = null
|
||||
cola.clear()
|
||||
_estadoCon.value = EstadoCon.Desconectado
|
||||
}
|
||||
|
||||
// ── Enviar comando ────────────────────────────────────────────────────────
|
||||
|
||||
fun enviar(msg: MsgCliente) {
|
||||
if (activa) cola.offer(toJson(msg))
|
||||
}
|
||||
|
||||
// ── Parseo JSON del servidor ──────────────────────────────────────────────
|
||||
|
||||
private fun procesarLinea(linea: String, token: String) {
|
||||
val obj = try { JsonParser.parseString(linea).asJsonObject } catch (_: Exception) { return }
|
||||
val tipo = obj.get("tipo")?.asString ?: return
|
||||
val msg = parsearMsgServidor(tipo, obj) ?: return
|
||||
|
||||
if (msg is MsgServidor.AuthOk) {
|
||||
activa = true
|
||||
_estadoCon.value = EstadoCon.Conectado(msg.version)
|
||||
enviar(MsgCliente.ObtenerEstado)
|
||||
enviar(MsgCliente.ObtenerPlaylist)
|
||||
enviar(MsgCliente.ObtenerBotonera)
|
||||
} else {
|
||||
scope.launch { _mensajes.emit(msg) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun parsearMsgServidor(tipo: String, obj: JsonObject): MsgServidor? = when (tipo) {
|
||||
"AuthOk" -> MsgServidor.AuthOk(obj["version"]?.asString ?: "")
|
||||
"Pong" -> MsgServidor.Pong
|
||||
"Ok" -> MsgServidor.Ok
|
||||
"Error" -> MsgServidor.Error(obj["mensaje"]?.asString ?: "")
|
||||
"PlaylistObtenida" -> MsgServidor.PlaylistObtenida(
|
||||
gson.fromJson(obj["tracks"], Array<TrackRemoto>::class.java).toList())
|
||||
"PlaylistCambiada" -> MsgServidor.PlaylistCambiada(
|
||||
gson.fromJson(obj["tracks"], Array<TrackRemoto>::class.java).toList())
|
||||
"EstadoObtenido" -> MsgServidor.EstadoObtenido(
|
||||
gson.fromJson(obj["estado"], EstadoRemoto::class.java))
|
||||
"EstadoCambiado" -> MsgServidor.EstadoCambiado(
|
||||
gson.fromJson(obj["estado"], EstadoRemoto::class.java))
|
||||
"PautajeObtenido" -> MsgServidor.PautajeObtenido(
|
||||
obj["tipo_p"]?.asString ?: "", obj["hora"]?.asInt ?: 0, obj["minuto"]?.asInt ?: 0,
|
||||
gson.fromJson(obj["entradas"], Array<EntradaRemota>::class.java).toList())
|
||||
"PautajeGuardado" -> MsgServidor.PautajeGuardado
|
||||
"ParrillaObtenida" -> MsgServidor.ParrillaObtenida(
|
||||
obj["dia"]?.asInt ?: 0, obj["hora"]?.asInt ?: 0,
|
||||
gson.fromJson(obj["items"], Array<String>::class.java).toList())
|
||||
"ParrillaGuardada" -> MsgServidor.ParrillaGuardada
|
||||
"BotoneraObtenida" -> MsgServidor.BotoneraObtenida(
|
||||
gson.fromJson(obj["botones"], Array<BotonRemoto>::class.java).toList())
|
||||
"BotoneraGuardada" -> MsgServidor.BotoneraGuardada
|
||||
"ResultadosBusqueda" -> MsgServidor.ResultadosBusqueda(
|
||||
gson.fromJson(obj["resultados"], Array<ResultadoBusqueda>::class.java).toList())
|
||||
"ReportesObtenidos" -> MsgServidor.ReportesObtenidos(
|
||||
gson.fromJson(obj["lineas"], Array<String>::class.java).toList())
|
||||
"ColaObtenida" -> MsgServidor.ColaObtenida(
|
||||
obj["tipo_cola"]?.asString ?: "",
|
||||
gson.fromJson(obj["items"], Array<String>::class.java).toList())
|
||||
"ProximasTandasObtenidas" -> MsgServidor.ProximasTandasObtenidas(
|
||||
gson.fromJson(obj["tandas"], Array<TandaRemota>::class.java).toList())
|
||||
"IndiceActualizado" -> MsgServidor.IndiceActualizado(
|
||||
obj["ok"]?.asBoolean ?: false, obj["mensaje"]?.asString ?: "")
|
||||
"DirectorioListado" -> MsgServidor.DirectorioListado(
|
||||
obj["ruta"]?.asString ?: "",
|
||||
gson.fromJson(obj["entradas"], Array<EntradaDir>::class.java).toList())
|
||||
else -> MsgServidor.Unknown
|
||||
}
|
||||
|
||||
// ── Serializar MsgCliente a JSON ──────────────────────────────────────────
|
||||
|
||||
private fun toJson(msg: MsgCliente): String {
|
||||
val obj = JsonObject()
|
||||
obj.addProperty("tipo", msg.tipo)
|
||||
when (msg) {
|
||||
is MsgCliente.Auth -> obj.addProperty("token", msg.token)
|
||||
is MsgCliente.ListarDir -> obj.addProperty("ruta", msg.ruta)
|
||||
is MsgCliente.ObtenerPautaje -> {
|
||||
obj.addProperty("tipo_p", msg.tipo_p)
|
||||
obj.addProperty("hora", msg.hora)
|
||||
obj.addProperty("minuto", msg.minuto)
|
||||
}
|
||||
is MsgCliente.GuardarPautaje -> {
|
||||
obj.addProperty("tipo_p", msg.tipo_p)
|
||||
obj.addProperty("hora", msg.hora)
|
||||
obj.addProperty("minuto", msg.minuto)
|
||||
obj.add("entradas", gson.toJsonTree(msg.entradas))
|
||||
}
|
||||
is MsgCliente.ObtenerParrilla -> {
|
||||
obj.addProperty("dia", msg.dia)
|
||||
obj.addProperty("hora", msg.hora)
|
||||
}
|
||||
is MsgCliente.GuardarParrilla -> {
|
||||
obj.addProperty("dia", msg.dia)
|
||||
obj.addProperty("hora", msg.hora)
|
||||
obj.add("items", gson.toJsonTree(msg.items))
|
||||
}
|
||||
is MsgCliente.GuardarBotonera -> obj.add("botones", gson.toJsonTree(msg.botones))
|
||||
is MsgCliente.ComandoPlayer -> obj.addProperty("cmd", msg.cmd)
|
||||
is MsgCliente.SetVolumen -> obj.addProperty("upvol", msg.upvol)
|
||||
is MsgCliente.ReproducirAhora -> obj.addProperty("ruta", msg.ruta)
|
||||
is MsgCliente.PlaylistPlayAhora -> {
|
||||
obj.addProperty("ruta", msg.ruta)
|
||||
obj.addProperty("duracion", msg.duracion)
|
||||
}
|
||||
is MsgCliente.AgregarAlPlaylist -> {
|
||||
obj.addProperty("ruta", msg.ruta)
|
||||
obj.addProperty("duracion", msg.duracion)
|
||||
obj.addProperty("al_inicio", msg.alInicio)
|
||||
}
|
||||
is MsgCliente.EliminarDePlaylist -> obj.addProperty("indice", msg.indice)
|
||||
is MsgCliente.MoverEnPlaylist -> {
|
||||
obj.addProperty("indice", msg.indice)
|
||||
obj.addProperty("arriba", msg.arriba)
|
||||
}
|
||||
is MsgCliente.Buscar -> {
|
||||
obj.addProperty("consulta", msg.consulta)
|
||||
if (msg.ruta != null) obj.addProperty("ruta", msg.ruta)
|
||||
}
|
||||
is MsgCliente.ObtenerReportes -> {
|
||||
obj.addProperty("tipo_r", msg.tipo_r)
|
||||
obj.addProperty("fecha", msg.fecha)
|
||||
}
|
||||
is MsgCliente.ObtenerCola -> obj.addProperty("tipo_cola", msg.tipo_cola)
|
||||
is MsgCliente.EliminarDeCola -> {
|
||||
obj.addProperty("tipo_cola", msg.tipo_cola)
|
||||
obj.addProperty("indice", msg.indice)
|
||||
}
|
||||
is MsgCliente.ReproducirTanda -> {
|
||||
obj.addProperty("hora", msg.hora)
|
||||
obj.addProperty("minuto", msg.minuto)
|
||||
obj.add("items", gson.toJsonTree(msg.items))
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
return obj.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package com.gradio.client.ui.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.gradio.client.R
|
||||
import com.gradio.client.network.EstadoCon
|
||||
import com.gradio.client.ui.theme.*
|
||||
import com.gradio.client.ui.viewmodel.Config
|
||||
|
||||
@Composable
|
||||
fun ConnectionBar(
|
||||
estadoCon: EstadoCon,
|
||||
config: Config,
|
||||
onConectar: (Config) -> Unit,
|
||||
onDesconectar: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var modoRelay by remember { mutableStateOf(config.modoRelay) }
|
||||
var servidor by remember { mutableStateOf(config.servidor) }
|
||||
var puerto by remember { mutableStateOf(config.puerto.toString()) }
|
||||
var relayId by remember { mutableStateOf(config.relayId) }
|
||||
var token by remember { mutableStateOf(config.token) }
|
||||
var showPass by remember { mutableStateOf(false) }
|
||||
|
||||
val conectado = estadoCon is EstadoCon.Conectado
|
||||
val conectando = estadoCon is EstadoCon.Conectando
|
||||
val enError = estadoCon is EstadoCon.Error
|
||||
val ocupado = conectando || enError // reconectando o con error — mostrar Cancelar
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(GradioCard)
|
||||
) {
|
||||
// ── Barra de estado (siempre visible) ────────────────────────────────
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (!conectado && !ocupado)
|
||||
Modifier.clickable { expanded = !expanded }
|
||||
else Modifier
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Logo G Radio (PNG real)
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.gradio_logo),
|
||||
contentDescription = "G Radio",
|
||||
modifier = Modifier.size(28.dp)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
|
||||
// Indicador de estado
|
||||
val (estadoColor, estadoTexto) = when (estadoCon) {
|
||||
is EstadoCon.Conectado -> Pair(GradioGreen, "● Conectado v${estadoCon.version}")
|
||||
is EstadoCon.Conectando -> Pair(WarningColor, "⟳ Conectando…")
|
||||
is EstadoCon.Error -> Pair(ErrorColor, "✗ ${estadoCon.mensaje}")
|
||||
is EstadoCon.Desconectado -> Pair(OnSurfaceDim, "● Desconectado")
|
||||
}
|
||||
|
||||
Text(
|
||||
text = estadoTexto,
|
||||
color = estadoColor,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
if (conectando) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = WarningColor
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
}
|
||||
|
||||
when {
|
||||
// Conectado → botón Desconectar
|
||||
conectado -> {
|
||||
TextButton(
|
||||
onClick = onDesconectar,
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = ErrorColor)
|
||||
) { Text("Desconectar", fontSize = 12.sp) }
|
||||
}
|
||||
// Reconectando o en error → botón Cancelar
|
||||
ocupado -> {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onDesconectar()
|
||||
expanded = false
|
||||
},
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = ErrorColor)
|
||||
) { Text("Cancelar", fontSize = 12.sp) }
|
||||
}
|
||||
// Desconectado → flecha para expandir config
|
||||
else -> {
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||
contentDescription = "Configuración",
|
||||
tint = OnSurfaceDim,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Panel de configuración expandible (solo cuando desconectado) ──────
|
||||
AnimatedVisibility(visible = expanded && !conectado && !ocupado) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
) {
|
||||
// Toggle LAN / Internet
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("LAN", fontSize = 13.sp, color = if (!modoRelay) GradioGreen else OnSurfaceDim)
|
||||
Switch(
|
||||
checked = modoRelay,
|
||||
onCheckedChange = { modoRelay = it },
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = GradioGreen,
|
||||
checkedTrackColor = GradioGreenDk
|
||||
)
|
||||
)
|
||||
Text("Internet (relay)", fontSize = 13.sp, color = if (modoRelay) GradioGreen else OnSurfaceDim)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(6.dp))
|
||||
|
||||
if (!modoRelay) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = servidor,
|
||||
onValueChange = { servidor = it },
|
||||
label = { Text("Servidor", fontSize = 12.sp) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
colors = textFieldColors()
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = puerto,
|
||||
onValueChange = { puerto = it.filter { c -> c.isDigit() } },
|
||||
label = { Text("Puerto", fontSize = 12.sp) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.width(80.dp),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
colors = textFieldColors()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
OutlinedTextField(
|
||||
value = relayId,
|
||||
onValueChange = { if (it.length <= 8 && it.all { c -> c.isDigit() }) relayId = it },
|
||||
label = { Text("ID del servidor (8 dígitos)", fontSize = 12.sp) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
colors = textFieldColors()
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(6.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = token,
|
||||
onValueChange = { token = it },
|
||||
label = { Text("Token (vacío = sin auth)", fontSize = 12.sp) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
visualTransformation = if (showPass) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showPass = !showPass }) {
|
||||
Icon(
|
||||
imageVector = if (showPass) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||
contentDescription = null,
|
||||
tint = OnSurfaceDim
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = textFieldColors()
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val cfg = Config(
|
||||
servidor = servidor,
|
||||
puerto = puerto.toIntOrNull() ?: 7777,
|
||||
token = token,
|
||||
modoRelay = modoRelay,
|
||||
relayId = relayId
|
||||
)
|
||||
onConectar(cfg)
|
||||
expanded = false
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = GradioGreen,
|
||||
contentColor = MaterialTheme.colorScheme.background
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Default.Link, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Conectar", fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = GradioSurface, thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun textFieldColors() = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = GradioGreen,
|
||||
unfocusedBorderColor = OnSurfaceDim.copy(alpha = 0.5f),
|
||||
focusedLabelColor = GradioGreen,
|
||||
cursorColor = GradioGreen,
|
||||
focusedTextColor = OnBg,
|
||||
unfocusedTextColor = OnBg,
|
||||
unfocusedLabelColor = OnSurfaceDim,
|
||||
)
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.gradio.client.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.GridView
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.gradio.client.data.BotonRemoto
|
||||
import com.gradio.client.network.EstadoCon
|
||||
import com.gradio.client.ui.theme.*
|
||||
import com.gradio.client.ui.viewmodel.MainViewModel
|
||||
|
||||
@Composable
|
||||
fun BotoneraScreen(vm: MainViewModel) {
|
||||
val botones by vm.botones.collectAsState()
|
||||
val estCon by vm.estadoCon.collectAsState()
|
||||
val activo = estCon is EstadoCon.Conectado
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Header
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Default.GridView, null, tint = GradioGreen, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Botonera", fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = OnBg, modifier = Modifier.weight(1f))
|
||||
if (activo) {
|
||||
IconButton(onClick = { vm.recargarBotonera() }) {
|
||||
Icon(Icons.Default.Refresh, "Recargar", tint = OnSurfaceDim, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = GradioCard)
|
||||
|
||||
if (!activo) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Conectate para ver la botonera", color = OnSurfaceDim, fontSize = 14.sp)
|
||||
}
|
||||
return@Column
|
||||
}
|
||||
|
||||
if (botones.isEmpty()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Sin botones configurados", color = OnSurfaceDim, fontSize = 14.sp)
|
||||
}
|
||||
return@Column
|
||||
}
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 120.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(botones, key = { it.indice }) { boton ->
|
||||
BotoneraBtn(boton = boton, onClick = { vm.presionarBoton(boton) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BotoneraBtn(boton: BotonRemoto, onClick: () -> Unit) {
|
||||
val btnColor = parseColor(boton.color)
|
||||
val textColor = if (isColorDark(btnColor)) Color.White else Color.Black
|
||||
|
||||
Button(
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(72.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = btnColor, contentColor = textColor),
|
||||
shape = MaterialTheme.shapes.medium
|
||||
) {
|
||||
Text(
|
||||
text = boton.etiqueta.ifBlank { "Botón ${boton.indice + 1}" },
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
lineHeight = 16.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseColor(hex: String): Color {
|
||||
return try {
|
||||
val clean = hex.trimStart('#')
|
||||
when (clean.length) {
|
||||
6 -> Color(android.graphics.Color.parseColor("#$clean"))
|
||||
8 -> Color(android.graphics.Color.parseColor("#$clean"))
|
||||
else -> GradioGreen
|
||||
}
|
||||
} catch (_: Exception) { GradioGreen }
|
||||
}
|
||||
|
||||
private fun isColorDark(color: Color): Boolean {
|
||||
val r = color.red; val g = color.green; val b = color.blue
|
||||
val luminance = 0.299 * r + 0.587 * g + 0.114 * b
|
||||
return luminance < 0.5
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.gradio.client.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.gradio.client.data.ResultadoBusqueda
|
||||
import com.gradio.client.network.EstadoCon
|
||||
import com.gradio.client.ui.theme.*
|
||||
import com.gradio.client.ui.viewmodel.MainViewModel
|
||||
|
||||
@Composable
|
||||
fun BuscadorScreen(vm: MainViewModel) {
|
||||
val resultados by vm.resultadosBusqueda.collectAsState()
|
||||
val buscando by vm.buscando.collectAsState()
|
||||
val estCon by vm.estadoCon.collectAsState()
|
||||
val activo = estCon is EstadoCon.Conectado
|
||||
var consulta by remember { mutableStateOf("") }
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// ── Barra de búsqueda ─────────────────────────────────────────────────
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = consulta,
|
||||
onValueChange = { consulta = it },
|
||||
label = { Text("Buscar audio…", fontSize = 13.sp) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = activo,
|
||||
leadingIcon = { Icon(Icons.Default.Search, null, tint = OnSurfaceDim, modifier = Modifier.size(20.dp)) },
|
||||
trailingIcon = {
|
||||
if (consulta.isNotEmpty()) {
|
||||
IconButton(onClick = { consulta = "" }) {
|
||||
Icon(Icons.Default.Clear, null, tint = OnSurfaceDim, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(onSearch = {
|
||||
if (consulta.isNotBlank() && activo) vm.buscar(consulta)
|
||||
}),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = GradioGreen,
|
||||
unfocusedBorderColor = OnSurfaceDim.copy(alpha = 0.5f),
|
||||
focusedLabelColor = GradioGreen,
|
||||
cursorColor = GradioGreen,
|
||||
focusedTextColor = OnBg,
|
||||
unfocusedTextColor = OnBg,
|
||||
unfocusedLabelColor = OnSurfaceDim,
|
||||
disabledBorderColor = OnSurfaceDim.copy(alpha = 0.2f),
|
||||
disabledTextColor = OnSurfaceDim.copy(alpha = 0.4f),
|
||||
disabledLabelColor = OnSurfaceDim.copy(alpha = 0.3f),
|
||||
)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = { if (consulta.isNotBlank() && activo) vm.buscar(consulta) },
|
||||
enabled = activo && consulta.isNotBlank() && !buscando,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = GradioGreen, contentColor = MaterialTheme.colorScheme.background)
|
||||
) {
|
||||
if (buscando) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.background)
|
||||
} else {
|
||||
Icon(Icons.Default.Search, null, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = GradioCard)
|
||||
|
||||
// ── Resultados ────────────────────────────────────────────────────────
|
||||
if (buscando) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator(color = GradioGreen)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text("Buscando…", color = OnSurfaceDim, fontSize = 14.sp)
|
||||
}
|
||||
}
|
||||
} else if (resultados.isEmpty() && !buscando) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(Icons.Default.LibraryMusic, null, tint = OnSurfaceDim.copy(alpha = 0.4f), modifier = Modifier.size(48.dp))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = if (!activo) "Conectate para buscar audio" else "Escribe para buscar",
|
||||
color = OnSurfaceDim,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = "${resultados.size} resultado(s)",
|
||||
fontSize = 12.sp,
|
||||
color = OnSurfaceDim,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
)
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(resultados, key = { it.ruta }) { res ->
|
||||
BuscadorRow(
|
||||
res, activo,
|
||||
onAlInicio = { vm.agregarAlPlaylist(res.ruta, alInicio = true) },
|
||||
onAlFinal = { vm.agregarAlPlaylist(res.ruta, alInicio = false) }
|
||||
)
|
||||
HorizontalDivider(color = GradioCard.copy(alpha = 0.6f), thickness = 0.5.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BuscadorRow(
|
||||
res: ResultadoBusqueda,
|
||||
activo: Boolean,
|
||||
onAlInicio: () -> Unit,
|
||||
onAlFinal: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Default.AudioFile, null, tint = OnSurfaceDim.copy(alpha = 0.6f), modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = res.nombre,
|
||||
fontSize = 14.sp,
|
||||
color = OnBg,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (res.duracion.isNotBlank()) {
|
||||
Text(res.duracion, fontSize = 11.sp, color = OnSurfaceDim)
|
||||
}
|
||||
}
|
||||
if (activo) {
|
||||
IconButton(onClick = onAlInicio) {
|
||||
Icon(Icons.Default.VerticalAlignTop, "Agregar al inicio", tint = GradioGreen, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
IconButton(onClick = onAlFinal) {
|
||||
Icon(Icons.Default.VerticalAlignBottom, "Agregar al final", tint = GradioGreen, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
package com.gradio.client.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.gradio.client.data.TandaRemota
|
||||
import com.gradio.client.network.EstadoCon
|
||||
import com.gradio.client.ui.theme.*
|
||||
import com.gradio.client.ui.viewmodel.MainViewModel
|
||||
import java.io.File
|
||||
|
||||
@Composable
|
||||
fun ComerciosScreen(vm: MainViewModel) {
|
||||
val estado by vm.estadoPlayer.collectAsState()
|
||||
val estCon by vm.estadoCon.collectAsState()
|
||||
val activo = estCon is EstadoCon.Conectado
|
||||
|
||||
val colaComerciales by vm.colaComerciales.collectAsState()
|
||||
val colaEventos by vm.colaEventos.collectAsState()
|
||||
val colaEventosEspera by vm.colaEventosEspera.collectAsState()
|
||||
val proximasTandas by vm.proximasTandas.collectAsState()
|
||||
|
||||
// Cargar las tres colas al entrar a la pantalla
|
||||
LaunchedEffect(activo) {
|
||||
if (activo) vm.recargarColas()
|
||||
}
|
||||
|
||||
if (!activo) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Conectate para controlar comerciales y eventos", color = OnSurfaceDim, fontSize = 14.sp)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
"Comerciales y Eventos",
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = OnBg,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconButton(onClick = { vm.recargarColas() }) {
|
||||
Icon(Icons.Default.Refresh, "Recargar", tint = OnSurfaceDim, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
ColaCard(
|
||||
titulo = "Comerciales",
|
||||
icon = Icons.Default.Campaign,
|
||||
activo = estado.comerciales_activos,
|
||||
items = colaComerciales,
|
||||
tipoCola = "comerciales",
|
||||
labelDetener = "Detener",
|
||||
onDetener = { vm.stopGeneral() },
|
||||
onVaciar = { vm.vaciarComerciales() },
|
||||
onEliminar = { i -> vm.eliminarDeCola("comerciales", i) }
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
"Próximas tandas",
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = OnSurfaceDim,
|
||||
modifier = Modifier.padding(top = 2.dp, start = 2.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (proximasTandas.isEmpty()) {
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = GradioCard)
|
||||
) {
|
||||
Text(
|
||||
"No hay tandas próximas en el horario",
|
||||
fontSize = 12.sp,
|
||||
color = OnSurfaceDim.copy(alpha = 0.7f),
|
||||
modifier = Modifier.padding(12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
itemsIndexed(proximasTandas, key = { _, t -> "${t.hora}:${t.minuto}" }) { idx, tanda ->
|
||||
TandaCard(
|
||||
tanda = tanda,
|
||||
colorIdx = idx,
|
||||
onReproducir = { vm.reproducirTanda(tanda) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
ColaCard(
|
||||
titulo = "Eventos",
|
||||
icon = Icons.Default.Event,
|
||||
activo = estado.eventos_activos,
|
||||
items = colaEventos,
|
||||
tipoCola = "eventos",
|
||||
labelDetener = "Detener",
|
||||
onDetener = { vm.stopGeneral() },
|
||||
onVaciar = { vm.vaciarEventos() },
|
||||
onEliminar = { i -> vm.eliminarDeCola("eventos", i) }
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
ColaCard(
|
||||
titulo = "Eventos en espera",
|
||||
icon = Icons.Default.HourglassBottom,
|
||||
activo = false,
|
||||
items = colaEventosEspera,
|
||||
tipoCola = "eventos_espera",
|
||||
labelDetener = null,
|
||||
onDetener = {},
|
||||
onVaciar = { vm.vaciarEventosEspera() },
|
||||
onEliminar = { i -> vm.eliminarDeCola("eventos_espera", i) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColaCard(
|
||||
titulo: String,
|
||||
icon: ImageVector,
|
||||
activo: Boolean,
|
||||
items: List<String>,
|
||||
tipoCola: String,
|
||||
labelDetener: String?,
|
||||
onDetener: () -> Unit,
|
||||
onVaciar: () -> Unit,
|
||||
onEliminar: (Int) -> Unit,
|
||||
) {
|
||||
var confirmarVaciar by remember { mutableStateOf(false) }
|
||||
var confirmarDetener by remember { mutableStateOf(false) }
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = GradioCard)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) {
|
||||
|
||||
// ── Header ────────────────────────────────────────────────────────
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
icon, null,
|
||||
tint = if (activo) GradioGreen else OnSurfaceDim,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
titulo,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = OnBg,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
EstadoBadge(activo, items.size)
|
||||
}
|
||||
|
||||
// ── Botones de acción ─────────────────────────────────────────────
|
||||
if (labelDetener != null || items.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
if (labelDetener != null) {
|
||||
OutlinedButton(
|
||||
onClick = { confirmarDetener = true },
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = activo,
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = WarningColor),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, WarningColor.copy(alpha = 0.5f)),
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 6.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Stop, null, modifier = Modifier.size(14.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(labelDetener, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
if (items.isNotEmpty()) {
|
||||
Button(
|
||||
onClick = { confirmarVaciar = true },
|
||||
modifier = if (labelDetener != null) Modifier.weight(1f) else Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = ErrorColor.copy(alpha = 0.85f)),
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 6.dp)
|
||||
) {
|
||||
Icon(Icons.Default.ClearAll, null, modifier = Modifier.size(14.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("Vaciar todo", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lista de ítems ────────────────────────────────────────────────
|
||||
if (items.isEmpty()) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
"Cola vacía",
|
||||
fontSize = 12.sp,
|
||||
color = OnSurfaceDim.copy(alpha = 0.6f),
|
||||
modifier = Modifier.padding(start = 4.dp)
|
||||
)
|
||||
} else {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
HorizontalDivider(color = GradioSurface, thickness = 0.5.dp)
|
||||
items.forEachIndexed { idx, ruta ->
|
||||
ColaItem(
|
||||
indice = idx,
|
||||
ruta = ruta,
|
||||
esActivo = activo && idx == 0,
|
||||
onEliminar = { onEliminar(idx) }
|
||||
)
|
||||
if (idx < items.lastIndex) {
|
||||
HorizontalDivider(
|
||||
color = GradioSurface.copy(alpha = 0.6f),
|
||||
thickness = 0.5.dp,
|
||||
modifier = Modifier.padding(start = 32.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Diálogos de confirmación ──────────────────────────────────────────────
|
||||
if (confirmarDetener) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmarDetener = false },
|
||||
title = { Text("Detener $titulo", color = WarningColor) },
|
||||
text = { Text("Detiene el $titulo actualmente en reproducción.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onDetener(); confirmarDetener = false }) {
|
||||
Text("Confirmar", color = WarningColor)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { confirmarDetener = false }) { Text("Cancelar") }
|
||||
},
|
||||
containerColor = GradioSurface
|
||||
)
|
||||
}
|
||||
|
||||
if (confirmarVaciar) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmarVaciar = false },
|
||||
title = { Text("Vaciar $titulo", color = ErrorColor) },
|
||||
text = { Text("Elimina todos los ítems de la cola de $titulo. Esta acción no se puede deshacer.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onVaciar(); confirmarVaciar = false }) {
|
||||
Text("Vaciar", color = ErrorColor)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { confirmarVaciar = false }) { Text("Cancelar") }
|
||||
},
|
||||
containerColor = GradioSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Colores de fondo alternados para las tarjetas de tanda — igual criterio que
|
||||
// las 4 clases tanda-bg-0..3 del panel "Próximas tandas" del vivo (azul, verde, ámbar, rosa).
|
||||
private val TandaColores = listOf(
|
||||
Color(0xFF1E3A5F), // azul
|
||||
Color(0xFF1F4D33), // verde
|
||||
Color(0xFF5C4A1E), // ámbar
|
||||
Color(0xFF5C2140), // rosa
|
||||
)
|
||||
|
||||
private fun formatDuracionTanda(totalSecs: Double): String {
|
||||
val total = totalSecs.toInt().coerceAtLeast(0)
|
||||
val h = total / 3600
|
||||
val m = (total % 3600) / 60
|
||||
val s = total % 60
|
||||
return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TandaCard(
|
||||
tanda: TandaRemota,
|
||||
colorIdx: Int,
|
||||
onReproducir: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = TandaColores[colorIdx % TandaColores.size])
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"%02d:%02d".format(tanda.hora, tanda.minuto),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = OnBg
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(
|
||||
formatDuracionTanda(tanda.duracion_total_secs),
|
||||
fontSize = 12.sp,
|
||||
color = OnBg.copy(alpha = 0.7f)
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
IconButton(onClick = onReproducir, modifier = Modifier.size(30.dp)) {
|
||||
Icon(Icons.Default.PlayArrow, "Reproducir tanda", tint = OnBg, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = OnBg.copy(alpha = 0.15f), thickness = 0.5.dp)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
tanda.items.forEachIndexed { i, item ->
|
||||
val nombre = item.nombre.ifBlank { File(item.ruta).nameWithoutExtension.ifBlank { item.ruta } }
|
||||
Text(
|
||||
"${i + 1}. $nombre",
|
||||
fontSize = 12.sp,
|
||||
color = OnBg.copy(alpha = 0.9f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(vertical = 1.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColaItem(
|
||||
indice: Int,
|
||||
ruta: String,
|
||||
esActivo: Boolean,
|
||||
onEliminar: () -> Unit,
|
||||
) {
|
||||
val nombre = File(ruta).nameWithoutExtension.ifBlank { ruta }
|
||||
val bgColor = if (esActivo) GradioGreen.copy(alpha = 0.08f) else androidx.compose.ui.graphics.Color.Transparent
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(bgColor)
|
||||
.padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = if (esActivo) "▶" else "${indice + 1}",
|
||||
fontSize = 11.sp,
|
||||
color = if (esActivo) GradioGreen else OnSurfaceDim,
|
||||
modifier = Modifier.width(28.dp),
|
||||
fontWeight = if (esActivo) FontWeight.Bold else FontWeight.Normal
|
||||
)
|
||||
Text(
|
||||
text = nombre,
|
||||
fontSize = 13.sp,
|
||||
color = if (esActivo) GradioGreen else OnBg,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
fontWeight = if (esActivo) FontWeight.SemiBold else FontWeight.Normal
|
||||
)
|
||||
IconButton(
|
||||
onClick = onEliminar,
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close, "Eliminar",
|
||||
tint = ErrorColor.copy(alpha = 0.8f),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EstadoBadge(activo: Boolean, cantidad: Int) {
|
||||
val bgColor = when {
|
||||
activo -> GradioGreen.copy(alpha = 0.15f)
|
||||
cantidad > 0 -> WarningColor.copy(alpha = 0.12f)
|
||||
else -> GradioCard
|
||||
}
|
||||
val textColor = when {
|
||||
activo -> GradioGreen
|
||||
cantidad > 0 -> WarningColor
|
||||
else -> OnSurfaceDim
|
||||
}
|
||||
val label = when {
|
||||
activo -> "● Activo"
|
||||
cantidad > 0 -> "$cantidad en cola"
|
||||
else -> "Vacía"
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(bgColor, shape = MaterialTheme.shapes.small)
|
||||
.padding(horizontal = 8.dp, vertical = 3.dp)
|
||||
) {
|
||||
Text(label, fontSize = 11.sp, color = textColor, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package com.gradio.client.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.gradio.client.network.EstadoCon
|
||||
import com.gradio.client.ui.theme.*
|
||||
import com.gradio.client.ui.viewmodel.MainViewModel
|
||||
|
||||
private val DIAS = listOf("Lun", "Mar", "Mié", "Jue", "Vie", "Sáb", "Dom")
|
||||
|
||||
@Composable
|
||||
fun ParrillaScreen(vm: MainViewModel) {
|
||||
val items by vm.parrillaItems.collectAsState()
|
||||
val dia by vm.parrillaDia.collectAsState()
|
||||
val hora by vm.parrillaHora.collectAsState()
|
||||
val estCon by vm.estadoCon.collectAsState()
|
||||
val activo = estCon is EstadoCon.Conectado
|
||||
|
||||
var editItems by remember(items) { mutableStateOf(items.toMutableList()) }
|
||||
var modoEdicion by remember { mutableStateOf(false) }
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// ── Selector dia / hora ───────────────────────────────────────────────
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
) {
|
||||
Text("Día", fontSize = 12.sp, color = OnSurfaceDim)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
ScrollableTabRow(
|
||||
selectedTabIndex = dia - 1,
|
||||
containerColor = GradioCard,
|
||||
contentColor = GradioGreen,
|
||||
edgePadding = 0.dp
|
||||
) {
|
||||
DIAS.forEachIndexed { i, nombre ->
|
||||
Tab(
|
||||
selected = dia - 1 == i,
|
||||
onClick = {
|
||||
val nuevoDia = i + 1
|
||||
vm.cargarParrilla(nuevoDia, hora)
|
||||
},
|
||||
text = { Text(nombre, fontSize = 13.sp) },
|
||||
selectedContentColor = GradioGreen,
|
||||
unselectedContentColor = OnSurfaceDim
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// Selector hora
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Hora: ", fontSize = 13.sp, color = OnSurfaceDim)
|
||||
IconButton(
|
||||
onClick = { vm.cargarParrilla(dia, (hora - 1 + 24) % 24) },
|
||||
enabled = activo, modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Icon(Icons.Default.ChevronLeft, null, tint = OnBg, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
Text(
|
||||
text = "%02d:00".format(hora),
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = GradioGreen,
|
||||
modifier = Modifier.width(56.dp),
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||
)
|
||||
IconButton(
|
||||
onClick = { vm.cargarParrilla(dia, (hora + 1) % 24) },
|
||||
enabled = activo, modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Icon(Icons.Default.ChevronRight, null, tint = OnBg, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (activo && items.isNotEmpty()) {
|
||||
TextButton(onClick = {
|
||||
if (modoEdicion) {
|
||||
vm.guardarParrilla(editItems)
|
||||
modoEdicion = false
|
||||
} else {
|
||||
editItems = items.toMutableList()
|
||||
modoEdicion = true
|
||||
}
|
||||
}) {
|
||||
Text(
|
||||
if (modoEdicion) "Guardar" else "Editar",
|
||||
color = if (modoEdicion) GradioGreen else OnSurfaceDim,
|
||||
fontSize = 13.sp
|
||||
)
|
||||
}
|
||||
if (modoEdicion) {
|
||||
TextButton(onClick = { modoEdicion = false; editItems = items.toMutableList() }) {
|
||||
Text("Cancelar", color = ErrorColor, fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = GradioCard)
|
||||
|
||||
if (!activo) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Conectate para ver la parrilla", color = OnSurfaceDim, fontSize = 14.sp)
|
||||
}
|
||||
return@Column
|
||||
}
|
||||
|
||||
if (items.isEmpty()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(Icons.Default.MusicNote, null, tint = OnSurfaceDim.copy(alpha = 0.4f), modifier = Modifier.size(40.dp))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Sin elementos en esta hora", color = OnSurfaceDim, fontSize = 14.sp)
|
||||
}
|
||||
}
|
||||
return@Column
|
||||
}
|
||||
|
||||
val listaActual = if (modoEdicion) editItems else items
|
||||
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
itemsIndexed(listaActual) { index, ruta ->
|
||||
val esCarpeta = ruta.endsWith("/*")
|
||||
val etiqueta = if (esCarpeta)
|
||||
ruta.dropLast(2) // mostrar la ruta sin el "/*" final
|
||||
else
|
||||
ruta.substringAfterLast('/').substringBeforeLast('.')
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("${index + 1}", fontSize = 13.sp, color = OnSurfaceDim, modifier = Modifier.width(28.dp))
|
||||
Icon(
|
||||
if (esCarpeta) Icons.Default.Folder else Icons.Default.AudioFile,
|
||||
null,
|
||||
tint = if (esCarpeta) GradioGreen.copy(alpha = 0.7f) else OnSurfaceDim.copy(alpha = 0.6f),
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
etiqueta,
|
||||
fontSize = 14.sp,
|
||||
color = OnBg,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
|
||||
)
|
||||
if (modoEdicion) {
|
||||
IconButton(onClick = {
|
||||
editItems = editItems.toMutableList().also {
|
||||
if (index > 0) { val tmp = it[index]; it[index] = it[index-1]; it[index-1] = tmp }
|
||||
}
|
||||
}, modifier = Modifier.size(32.dp), enabled = index > 0) {
|
||||
Icon(Icons.Default.KeyboardArrowUp, null, tint = OnSurfaceDim, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
IconButton(onClick = {
|
||||
editItems = editItems.toMutableList().also {
|
||||
if (index < it.size - 1) { val tmp = it[index]; it[index] = it[index+1]; it[index+1] = tmp }
|
||||
}
|
||||
}, modifier = Modifier.size(32.dp), enabled = index < editItems.size - 1) {
|
||||
Icon(Icons.Default.KeyboardArrowDown, null, tint = OnSurfaceDim, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
IconButton(onClick = {
|
||||
editItems = editItems.toMutableList().also { it.removeAt(index) }
|
||||
}, modifier = Modifier.size(32.dp)) {
|
||||
Icon(Icons.Default.Close, null, tint = ErrorColor, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = GradioCard.copy(alpha = 0.6f), thickness = 0.5.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package com.gradio.client.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.gradio.client.data.EntradaRemota
|
||||
import com.gradio.client.network.EstadoCon
|
||||
import com.gradio.client.ui.theme.*
|
||||
import com.gradio.client.ui.viewmodel.MainViewModel
|
||||
|
||||
@Composable
|
||||
fun PautajeScreen(vm: MainViewModel) {
|
||||
val entradas by vm.pautajeEntradas.collectAsState()
|
||||
val tipo by vm.pautajeTipo.collectAsState()
|
||||
val hora by vm.pautajeHora.collectAsState()
|
||||
val minuto by vm.pautajeMinuto.collectAsState()
|
||||
val estCon by vm.estadoCon.collectAsState()
|
||||
val activo = estCon is EstadoCon.Conectado
|
||||
|
||||
var tipoSel by remember { mutableStateOf(tipo) }
|
||||
var horaSel by remember { mutableStateOf(hora) }
|
||||
var minutoSel by remember { mutableStateOf(minuto) }
|
||||
var editEntradas by remember(entradas) { mutableStateOf(entradas.toMutableList()) }
|
||||
var modoEdicion by remember { mutableStateOf(false) }
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// ── Controles de selección ────────────────────────────────────────────
|
||||
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||
// Tipo
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Tipo: ", fontSize = 13.sp, color = OnSurfaceDim)
|
||||
FilterChip(
|
||||
selected = tipoSel == "comerciales",
|
||||
onClick = { tipoSel = "comerciales" },
|
||||
label = { Text("Comerciales", fontSize = 12.sp) },
|
||||
modifier = Modifier.padding(end = 6.dp),
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = GradioGreen,
|
||||
selectedLabelColor = MaterialTheme.colorScheme.background
|
||||
)
|
||||
)
|
||||
FilterChip(
|
||||
selected = tipoSel == "eventos",
|
||||
onClick = { tipoSel = "eventos" },
|
||||
label = { Text("Eventos", fontSize = 12.sp) },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = GradioGreen,
|
||||
selectedLabelColor = MaterialTheme.colorScheme.background
|
||||
)
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
|
||||
// Hora y minuto
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Hora:", fontSize = 13.sp, color = OnSurfaceDim)
|
||||
IconButton(onClick = { horaSel = (horaSel - 1 + 24) % 24 }, modifier = Modifier.size(32.dp)) {
|
||||
Icon(Icons.Default.ChevronLeft, null, tint = OnBg, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
Text("%02d".format(horaSel), fontSize = 16.sp, fontWeight = FontWeight.Bold, color = GradioGreen, modifier = Modifier.width(36.dp),
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center)
|
||||
IconButton(onClick = { horaSel = (horaSel + 1) % 24 }, modifier = Modifier.size(32.dp)) {
|
||||
Icon(Icons.Default.ChevronRight, null, tint = OnBg, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text("Min:", fontSize = 13.sp, color = OnSurfaceDim)
|
||||
IconButton(onClick = { minutoSel = (minutoSel - 1 + 60) % 60 }, modifier = Modifier.size(32.dp)) {
|
||||
Icon(Icons.Default.ChevronLeft, null, tint = OnBg, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
Text("%02d".format(minutoSel), fontSize = 16.sp, fontWeight = FontWeight.Bold, color = GradioGreen, modifier = Modifier.width(36.dp),
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center)
|
||||
IconButton(onClick = { minutoSel = (minutoSel + 1) % 60 }, modifier = Modifier.size(32.dp)) {
|
||||
Icon(Icons.Default.ChevronRight, null, tint = OnBg, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (activo) {
|
||||
Button(
|
||||
onClick = { vm.cargarPautaje(tipoSel, horaSel, minutoSel); modoEdicion = false },
|
||||
modifier = Modifier.height(36.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = GradioGreenDk)
|
||||
) { Text("Ver", fontSize = 12.sp) }
|
||||
}
|
||||
}
|
||||
|
||||
if (activo && entradas.isNotEmpty()) {
|
||||
Row {
|
||||
TextButton(onClick = {
|
||||
if (modoEdicion) { vm.guardarPautaje(editEntradas); modoEdicion = false }
|
||||
else { editEntradas = entradas.toMutableList(); modoEdicion = true }
|
||||
}) {
|
||||
Text(if (modoEdicion) "Guardar" else "Editar", color = if (modoEdicion) GradioGreen else OnSurfaceDim, fontSize = 13.sp)
|
||||
}
|
||||
if (modoEdicion) {
|
||||
TextButton(onClick = { modoEdicion = false }) {
|
||||
Text("Cancelar", color = ErrorColor, fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = GradioCard)
|
||||
|
||||
if (!activo) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Conectate para ver pautaje", color = OnSurfaceDim, fontSize = 14.sp)
|
||||
}
|
||||
return@Column
|
||||
}
|
||||
|
||||
val listaActual = if (modoEdicion) editEntradas else entradas
|
||||
|
||||
if (listaActual.isEmpty()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Sin entradas para esta hora", color = OnSurfaceDim, fontSize = 14.sp)
|
||||
}
|
||||
return@Column
|
||||
}
|
||||
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
itemsIndexed(listaActual) { index, entrada ->
|
||||
PautajeRow(entrada, index, modoEdicion,
|
||||
onEliminar = { editEntradas = editEntradas.toMutableList().also { it.removeAt(index) } }
|
||||
)
|
||||
HorizontalDivider(color = GradioCard.copy(alpha = 0.6f), thickness = 0.5.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PautajeRow(
|
||||
entrada: EntradaRemota,
|
||||
index: Int,
|
||||
modoEdicion: Boolean,
|
||||
onEliminar: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("${index + 1}", fontSize = 12.sp, color = OnSurfaceDim, modifier = Modifier.width(24.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
entrada.ruta.substringAfterLast('/').substringBeforeLast('.'),
|
||||
fontSize = 13.sp, color = OnBg, maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
"Días: ${formatDias(entrada.dias)} | ${entrada.inicio} → ${entrada.fin}",
|
||||
fontSize = 11.sp, color = OnSurfaceDim
|
||||
)
|
||||
}
|
||||
if (modoEdicion) {
|
||||
IconButton(onClick = onEliminar, modifier = Modifier.size(32.dp)) {
|
||||
Icon(Icons.Default.Close, null, tint = ErrorColor, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDias(dias: String): String {
|
||||
val nombres = listOf("L", "M", "X", "J", "V", "S", "D")
|
||||
return dias.mapIndexed { i, c -> if (c != '0' && i < nombres.size) nombres[i] else "" }.filter { it.isNotEmpty() }.joinToString("")
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package com.gradio.client.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.gradio.client.data.EstadoRemoto
|
||||
import com.gradio.client.data.TrackRemoto
|
||||
import com.gradio.client.network.EstadoCon
|
||||
import com.gradio.client.ui.theme.*
|
||||
import com.gradio.client.ui.viewmodel.MainViewModel
|
||||
import java.io.File
|
||||
|
||||
@Composable
|
||||
fun PlayerScreen(vm: MainViewModel) {
|
||||
val estado by vm.estadoPlayer.collectAsState()
|
||||
val playlist by vm.playlist.collectAsState()
|
||||
val estCon by vm.estadoCon.collectAsState()
|
||||
val activo = estCon is EstadoCon.Conectado
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// ── Info del track actual ─────────────────────────────────────────────
|
||||
TrackHeader(estado)
|
||||
|
||||
// ── Barra de progreso ─────────────────────────────────────────────────
|
||||
val progress = if (estado.duracion_secs > 0) (estado.posicion_secs / estado.duracion_secs).toFloat() else 0f
|
||||
LinearProgressIndicator(
|
||||
progress = { progress },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(4.dp),
|
||||
color = GradioGreen,
|
||||
trackColor = GradioCard
|
||||
)
|
||||
|
||||
// ── Controles de transporte ───────────────────────────────────────────
|
||||
TransportControls(vm, activo)
|
||||
|
||||
// ── Control de volumen ────────────────────────────────────────────────
|
||||
VolumeControl(vm, estado, activo)
|
||||
|
||||
HorizontalDivider(color = GradioCard)
|
||||
|
||||
// ── Stop general ──────────────────────────────────────────────────────
|
||||
StopGeneralBar(vm, activo)
|
||||
|
||||
HorizontalDivider(color = GradioCard)
|
||||
|
||||
// ── Playlist ──────────────────────────────────────────────────────────
|
||||
Text(
|
||||
text = "Playlist (${playlist.size})",
|
||||
fontSize = 12.sp,
|
||||
color = OnSurfaceDim,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
)
|
||||
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
itemsIndexed(playlist, key = { i, t -> "$i-${t.ruta}" }) { index, track ->
|
||||
PlaylistRow(
|
||||
index = index,
|
||||
track = track,
|
||||
esActual = index == estado.indice_actual && activo,
|
||||
activo = activo,
|
||||
onPlay = { vm.playlistPlayAhora(track.ruta, track.duracion) },
|
||||
onSubir = { vm.moverEnPlaylist(index, true) },
|
||||
onBajar = { vm.moverEnPlaylist(index, false) },
|
||||
onEliminar = { vm.eliminarDePlaylist(index) }
|
||||
)
|
||||
HorizontalDivider(color = GradioCard.copy(alpha = 0.6f), thickness = 0.5.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StopGeneralBar(vm: MainViewModel, activo: Boolean) {
|
||||
var confirmar by remember { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Default.Warning, null, tint = ErrorColor, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Stop General", fontSize = 12.sp, color = ErrorColor, modifier = Modifier.weight(1f))
|
||||
Button(
|
||||
onClick = { confirmar = true },
|
||||
enabled = activo,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = ErrorColor),
|
||||
contentPadding = PaddingValues(horizontal = 14.dp, vertical = 6.dp),
|
||||
modifier = Modifier.height(34.dp)
|
||||
) {
|
||||
Icon(Icons.Default.StopCircle, null, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("Detener todo", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmar) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmar = false },
|
||||
title = { Text("Stop General", color = ErrorColor) },
|
||||
text = { Text("Detiene toda reproducción activa: parrilla, comerciales y eventos.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { vm.stopGeneral(); confirmar = false }) {
|
||||
Text("Confirmar", color = ErrorColor)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { confirmar = false }) { Text("Cancelar") }
|
||||
},
|
||||
containerColor = GradioSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrackHeader(estado: EstadoRemoto) {
|
||||
val titulo = estado.titulo_actual.ifBlank {
|
||||
estado.track_actual.let { File(it).nameWithoutExtension }.ifBlank { "— Sin reproducción —" }
|
||||
}
|
||||
val posStr = fmtTiempo(estado.posicion_secs)
|
||||
val durStr = fmtTiempo(estado.duracion_secs)
|
||||
|
||||
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||
Text(
|
||||
text = titulo,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = OnBg,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Row {
|
||||
val estadoStr = when {
|
||||
estado.reproduciendo -> "▶ Reproduciendo"
|
||||
estado.pausado -> "⏸ Pausado"
|
||||
else -> "⏹ Detenido"
|
||||
}
|
||||
Text(estadoStr, fontSize = 12.sp, color = if (estado.reproduciendo) GradioGreen else OnSurfaceDim)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text("$posStr / $durStr", fontSize = 12.sp, color = OnSurfaceDim)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TransportControls(vm: MainViewModel, activo: Boolean) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TransportBtn(Icons.Default.PlayArrow, "Play", activo, GradioGreen) { vm.play() }
|
||||
TransportBtn(Icons.Default.Pause, "Pausa", activo) { vm.pause() }
|
||||
TransportBtn(Icons.Default.Stop, "Stop", activo) { vm.stop() }
|
||||
TransportBtn(Icons.Default.SkipNext, "Skip", activo) { vm.siguiente() }
|
||||
TransportBtn(Icons.Default.StopCircle, "Stop-After", activo) { vm.stopAfter() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TransportBtn(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
label: String,
|
||||
activo: Boolean,
|
||||
color: androidx.compose.ui.graphics.Color = OnBg,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
IconButton(onClick = onClick, enabled = activo) {
|
||||
Icon(icon, contentDescription = label, tint = if (activo) color else OnSurfaceDim, modifier = Modifier.size(28.dp))
|
||||
}
|
||||
Text(label, fontSize = 10.sp, color = if (activo) OnSurfaceDim else OnSurfaceDim.copy(alpha = 0.4f))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VolumeControl(vm: MainViewModel, estado: EstadoRemoto, activo: Boolean) {
|
||||
var vol by remember(estado.upvol) { mutableFloatStateOf(estado.upvol.toFloat()) }
|
||||
var isDragging by remember { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Default.VolumeDown, null, tint = OnSurfaceDim, modifier = Modifier.size(18.dp))
|
||||
Slider(
|
||||
value = if (isDragging) vol else estado.upvol.toFloat(),
|
||||
onValueChange = { vol = it; isDragging = true },
|
||||
onValueChangeFinished = {
|
||||
isDragging = false
|
||||
vm.setVol(vol.toInt())
|
||||
},
|
||||
valueRange = 0f..100f,
|
||||
enabled = activo,
|
||||
modifier = Modifier.weight(1f).padding(horizontal = 8.dp),
|
||||
colors = SliderDefaults.colors(thumbColor = GradioGreen, activeTrackColor = GradioGreen)
|
||||
)
|
||||
Icon(Icons.Default.VolumeUp, null, tint = OnSurfaceDim, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("${if (isDragging) vol.toInt() else estado.upvol}%", fontSize = 13.sp, color = OnSurfaceDim, modifier = Modifier.width(36.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaylistRow(
|
||||
index: Int,
|
||||
track: TrackRemoto,
|
||||
esActual: Boolean,
|
||||
activo: Boolean,
|
||||
onPlay: () -> Unit,
|
||||
onSubir: () -> Unit,
|
||||
onBajar: () -> Unit,
|
||||
onEliminar: () -> Unit
|
||||
) {
|
||||
val titulo = track.titulo.ifBlank { File(track.ruta).nameWithoutExtension }
|
||||
val bgColor = if (esActual) GradioGreen.copy(alpha = 0.12f) else androidx.compose.ui.graphics.Color.Transparent
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(bgColor)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Número e indicador
|
||||
Text(
|
||||
text = if (esActual) "▶" else "${index + 1}",
|
||||
fontSize = 12.sp,
|
||||
color = if (esActual) GradioGreen else OnSurfaceDim,
|
||||
modifier = Modifier.width(28.dp),
|
||||
fontWeight = if (esActual) FontWeight.Bold else FontWeight.Normal
|
||||
)
|
||||
|
||||
// Título
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = titulo,
|
||||
fontSize = 14.sp,
|
||||
color = if (esActual) GradioGreen else OnBg,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontWeight = if (esActual) FontWeight.SemiBold else FontWeight.Normal
|
||||
)
|
||||
if (track.duracion.isNotBlank()) {
|
||||
Text(track.duracion, fontSize = 11.sp, color = OnSurfaceDim)
|
||||
}
|
||||
}
|
||||
|
||||
// Botones de acción compactos
|
||||
if (activo) {
|
||||
SmallActionBtn(Icons.Default.PlayArrow, "Reproducir") { onPlay() }
|
||||
SmallActionBtn(Icons.Default.KeyboardArrowUp, "Subir") { onSubir() }
|
||||
SmallActionBtn(Icons.Default.KeyboardArrowDown, "Bajar") { onBajar() }
|
||||
SmallActionBtn(Icons.Default.Close, "Eliminar", tint = ErrorColor) { onEliminar() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SmallActionBtn(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
label: String,
|
||||
tint: androidx.compose.ui.graphics.Color = OnSurfaceDim,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
IconButton(onClick = onClick, modifier = Modifier.size(32.dp)) {
|
||||
Icon(icon, contentDescription = label, tint = tint, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
|
||||
private fun fmtTiempo(secs: Double): String {
|
||||
val s = secs.toLong()
|
||||
return "${s / 60}:${(s % 60).toString().padStart(2, '0')}"
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package com.gradio.client.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.gradio.client.network.EstadoCon
|
||||
import com.gradio.client.ui.theme.*
|
||||
import com.gradio.client.ui.viewmodel.MainViewModel
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@Composable
|
||||
fun VisorScreen(vm: MainViewModel) {
|
||||
val lineas by vm.reporteLineas.collectAsState()
|
||||
val estCon by vm.estadoCon.collectAsState()
|
||||
val activo = estCon is EstadoCon.Conectado
|
||||
|
||||
var tipoReporte by remember { mutableStateOf("parrilla") }
|
||||
var fecha by remember { mutableStateOf(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"))) }
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// ── Controles ─────────────────────────────────────────────────────────
|
||||
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.BarChart, null, tint = GradioGreen, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Reportes", fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = OnBg)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
listOf("parrilla" to "Parrilla", "comerciales" to "Comerciales", "eventos" to "Eventos").forEach { (key, label) ->
|
||||
FilterChip(
|
||||
selected = tipoReporte == key,
|
||||
onClick = { tipoReporte = key },
|
||||
label = { Text(label, fontSize = 12.sp) },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = GradioGreen,
|
||||
selectedLabelColor = MaterialTheme.colorScheme.background
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = fecha,
|
||||
onValueChange = { if (it.length <= 8 && it.all { c -> c.isDigit() }) fecha = it },
|
||||
label = { Text("Fecha (YYYYMMDD)", fontSize = 12.sp) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = activo,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = GradioGreen,
|
||||
unfocusedBorderColor = OnSurfaceDim.copy(alpha = 0.5f),
|
||||
focusedLabelColor = GradioGreen,
|
||||
cursorColor = GradioGreen,
|
||||
focusedTextColor = OnBg,
|
||||
unfocusedTextColor = OnBg,
|
||||
unfocusedLabelColor = OnSurfaceDim,
|
||||
)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
IconButton(
|
||||
onClick = {
|
||||
val d = LocalDate.parse(fecha, DateTimeFormatter.ofPattern("yyyyMMdd"))
|
||||
fecha = d.minusDays(1).format(DateTimeFormatter.ofPattern("yyyyMMdd"))
|
||||
},
|
||||
enabled = activo
|
||||
) {
|
||||
Icon(Icons.Default.ChevronLeft, null, tint = OnBg)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
val d = LocalDate.parse(fecha, DateTimeFormatter.ofPattern("yyyyMMdd"))
|
||||
fecha = d.plusDays(1).format(DateTimeFormatter.ofPattern("yyyyMMdd"))
|
||||
},
|
||||
enabled = activo
|
||||
) {
|
||||
Icon(Icons.Default.ChevronRight, null, tint = OnBg)
|
||||
}
|
||||
Button(
|
||||
onClick = { vm.cargarReportes(tipoReporte, fecha) },
|
||||
enabled = activo,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = GradioGreenDk),
|
||||
modifier = Modifier.height(48.dp)
|
||||
) { Text("Ver", fontSize = 13.sp) }
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = GradioCard)
|
||||
|
||||
if (!activo) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Conectate para ver reportes", color = OnSurfaceDim, fontSize = 14.sp)
|
||||
}
|
||||
return@Column
|
||||
}
|
||||
|
||||
if (lineas.isEmpty()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(Icons.Default.BarChart, null, tint = OnSurfaceDim.copy(alpha = 0.4f), modifier = Modifier.size(48.dp))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Selecciona tipo y fecha", color = OnSurfaceDim, fontSize = 14.sp)
|
||||
}
|
||||
}
|
||||
return@Column
|
||||
}
|
||||
|
||||
Text("${lineas.size} línea(s)", fontSize = 12.sp, color = OnSurfaceDim,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp))
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 8.dp)
|
||||
) {
|
||||
items(lineas) { linea ->
|
||||
Text(
|
||||
text = linea,
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = if (linea.contains("ERROR", ignoreCase = true)) ErrorColor
|
||||
else if (linea.contains("WARN", ignoreCase = true)) WarningColor
|
||||
else OnBg.copy(alpha = 0.9f),
|
||||
modifier = Modifier.padding(vertical = 1.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.gradio.client.ui.theme
|
||||
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val GradioGreen = Color(0xFF1DB954)
|
||||
val GradioGreenDk = Color(0xFF148A3C)
|
||||
val GradioBg = Color(0xFF0F1117)
|
||||
val GradioSurface = Color(0xFF1A1D27)
|
||||
val GradioCard = Color(0xFF242838)
|
||||
val OnBg = Color(0xFFE8EAF0)
|
||||
val OnSurfaceDim = Color(0xFF9E9EB0)
|
||||
val ErrorColor = Color(0xFFCF6679)
|
||||
val WarningColor = Color(0xFFFFB74D)
|
||||
|
||||
private val DarkColors = darkColorScheme(
|
||||
primary = GradioGreen,
|
||||
onPrimary = Color.Black,
|
||||
primaryContainer = GradioGreenDk,
|
||||
secondary = Color(0xFF5C6BC0),
|
||||
background = GradioBg,
|
||||
surface = GradioSurface,
|
||||
surfaceVariant = GradioCard,
|
||||
onBackground = OnBg,
|
||||
onSurface = OnBg,
|
||||
onSurfaceVariant = OnSurfaceDim,
|
||||
error = ErrorColor,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun GRadioClientTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(
|
||||
colorScheme = DarkColors,
|
||||
typography = Typography(),
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package com.gradio.client.ui.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.gradio.client.data.*
|
||||
import com.gradio.client.network.ConnectionManager
|
||||
import com.gradio.client.network.EstadoCon
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class Config(
|
||||
val servidor: String = "192.168.1.100",
|
||||
val puerto: Int = 7777,
|
||||
val token: String = "",
|
||||
val modoRelay: Boolean = false,
|
||||
val relayId: String = ""
|
||||
)
|
||||
|
||||
class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
val conexion = ConnectionManager()
|
||||
|
||||
// ── Config persistida ─────────────────────────────────────────────────────
|
||||
private val prefs = app.getSharedPreferences("gr_client", Context.MODE_PRIVATE)
|
||||
|
||||
// ── Reconexión automática al recuperar señal (modo avión → activo) ────────
|
||||
private val cm = app.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
private var reconectarJob: Job? = null
|
||||
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
// Cancelar reconexión pendiente para evitar disparos dobles
|
||||
reconectarJob?.cancel()
|
||||
reconectarJob = viewModelScope.launch {
|
||||
delay(500L) // esperar que la red se estabilice
|
||||
if (estadoCon.value is EstadoCon.Error) {
|
||||
conectar(_config.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val _config = MutableStateFlow(cargarConfig())
|
||||
val config: StateFlow<Config> = _config
|
||||
|
||||
// ── Estado de conexión ────────────────────────────────────────────────────
|
||||
val estadoCon: StateFlow<EstadoCon> = conexion.estadoCon
|
||||
|
||||
// ── Estado del player ─────────────────────────────────────────────────────
|
||||
private val _estadoPlayer = MutableStateFlow(EstadoRemoto())
|
||||
val estadoPlayer: StateFlow<EstadoRemoto> = _estadoPlayer
|
||||
|
||||
private val _playlist = MutableStateFlow<List<TrackRemoto>>(emptyList())
|
||||
val playlist: StateFlow<List<TrackRemoto>> = _playlist
|
||||
|
||||
// ── Búsqueda ──────────────────────────────────────────────────────────────
|
||||
private val _resultadosBusqueda = MutableStateFlow<List<ResultadoBusqueda>>(emptyList())
|
||||
val resultadosBusqueda: StateFlow<List<ResultadoBusqueda>> = _resultadosBusqueda
|
||||
|
||||
private val _buscando = MutableStateFlow(false)
|
||||
val buscando: StateFlow<Boolean> = _buscando
|
||||
|
||||
// ── Botonera ──────────────────────────────────────────────────────────────
|
||||
private val _botones = MutableStateFlow<List<BotonRemoto>>(emptyList())
|
||||
val botones: StateFlow<List<BotonRemoto>> = _botones
|
||||
|
||||
// ── Parrilla ──────────────────────────────────────────────────────────────
|
||||
private val _parrillaItems = MutableStateFlow<List<String>>(emptyList())
|
||||
val parrillaItems: StateFlow<List<String>> = _parrillaItems
|
||||
|
||||
private val _parrillaDia = MutableStateFlow(1)
|
||||
val parrillaDia: StateFlow<Int> = _parrillaDia
|
||||
|
||||
private val _parrillaHora = MutableStateFlow(0)
|
||||
val parrillaHora: StateFlow<Int> = _parrillaHora
|
||||
|
||||
// ── Pautaje ───────────────────────────────────────────────────────────────
|
||||
private val _pautajeEntradas = MutableStateFlow<List<EntradaRemota>>(emptyList())
|
||||
val pautajeEntradas: StateFlow<List<EntradaRemota>> = _pautajeEntradas
|
||||
|
||||
private val _pautajeTipo = MutableStateFlow("comerciales")
|
||||
val pautajeTipo: StateFlow<String> = _pautajeTipo
|
||||
|
||||
private val _pautajeHora = MutableStateFlow(0)
|
||||
val pautajeHora: StateFlow<Int> = _pautajeHora
|
||||
|
||||
private val _pautajeMinuto = MutableStateFlow(0)
|
||||
val pautajeMinuto: StateFlow<Int> = _pautajeMinuto
|
||||
|
||||
// ── Colas activas (comerciales / eventos / eventos en espera) ─────────────
|
||||
private val _colaComerciales = MutableStateFlow<List<String>>(emptyList())
|
||||
val colaComerciales: StateFlow<List<String>> = _colaComerciales
|
||||
|
||||
private val _colaEventos = MutableStateFlow<List<String>>(emptyList())
|
||||
val colaEventos: StateFlow<List<String>> = _colaEventos
|
||||
|
||||
private val _colaEventosEspera = MutableStateFlow<List<String>>(emptyList())
|
||||
val colaEventosEspera: StateFlow<List<String>> = _colaEventosEspera
|
||||
|
||||
private val _proximasTandas = MutableStateFlow<List<TandaRemota>>(emptyList())
|
||||
val proximasTandas: StateFlow<List<TandaRemota>> = _proximasTandas
|
||||
|
||||
// ── Visor ─────────────────────────────────────────────────────────────────
|
||||
private val _reporteLineas = MutableStateFlow<List<String>>(emptyList())
|
||||
val reporteLineas: StateFlow<List<String>> = _reporteLineas
|
||||
|
||||
// ── Toast / snackbar ──────────────────────────────────────────────────────
|
||||
private val _snackbar = MutableStateFlow<String?>(null)
|
||||
val snackbar: StateFlow<String?> = _snackbar
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
conexion.mensajes.collect { msg -> procesarMensaje(msg) }
|
||||
}
|
||||
cm.registerDefaultNetworkCallback(networkCallback)
|
||||
}
|
||||
|
||||
// ── Conexión ──────────────────────────────────────────────────────────────
|
||||
|
||||
fun conectar(cfg: Config) {
|
||||
_config.value = cfg
|
||||
guardarConfig(cfg)
|
||||
if (cfg.modoRelay) {
|
||||
conexion.conectarRelay(cfg.relayId, cfg.token)
|
||||
} else {
|
||||
conexion.conectarTCP(cfg.servidor, cfg.puerto, cfg.token)
|
||||
}
|
||||
}
|
||||
|
||||
fun desconectar() {
|
||||
conexion.desconectar()
|
||||
}
|
||||
|
||||
// ── Comandos player ───────────────────────────────────────────────────────
|
||||
|
||||
fun play() = conexion.enviar(MsgCliente.ComandoPlayer("play"))
|
||||
fun pause() = conexion.enviar(MsgCliente.ComandoPlayer("pause"))
|
||||
fun stop() = conexion.enviar(MsgCliente.ComandoPlayer("stop"))
|
||||
fun stopAfter() = conexion.enviar(MsgCliente.ComandoPlayer("stop_after"))
|
||||
fun siguiente() = conexion.enviar(MsgCliente.ComandoPlayer("siguiente"))
|
||||
fun setVol(v: Int) = conexion.enviar(MsgCliente.SetVolumen(v))
|
||||
fun stopGeneral() = conexion.enviar(MsgCliente.StopGeneral)
|
||||
fun vaciarComerciales() { conexion.enviar(MsgCliente.VaciarComerciales); conexion.enviar(MsgCliente.ObtenerCola("comerciales")) }
|
||||
fun vaciarEventos() { conexion.enviar(MsgCliente.VaciarEventos); conexion.enviar(MsgCliente.ObtenerCola("eventos")) }
|
||||
fun vaciarEventosEspera() { conexion.enviar(MsgCliente.VaciarEventosEspera); conexion.enviar(MsgCliente.ObtenerCola("eventos_espera")) }
|
||||
fun obtenerCola(tipo: String) = conexion.enviar(MsgCliente.ObtenerCola(tipo))
|
||||
fun eliminarDeCola(tipo: String, indice: Int) = conexion.enviar(MsgCliente.EliminarDeCola(tipo, indice))
|
||||
fun recargarColas() {
|
||||
conexion.enviar(MsgCliente.ObtenerCola("comerciales"))
|
||||
conexion.enviar(MsgCliente.ObtenerCola("eventos"))
|
||||
conexion.enviar(MsgCliente.ObtenerCola("eventos_espera"))
|
||||
conexion.enviar(MsgCliente.ObtenerProximasTandas)
|
||||
}
|
||||
fun reproducirTanda(tanda: TandaRemota) {
|
||||
conexion.enviar(MsgCliente.ReproducirTanda(tanda.hora, tanda.minuto, tanda.items.map { it.ruta }))
|
||||
conexion.enviar(MsgCliente.ObtenerProximasTandas)
|
||||
mostrarSnackbar("Reproduciendo tanda %02d:%02d".format(tanda.hora, tanda.minuto))
|
||||
}
|
||||
fun reproducirAhora(ruta: String) = conexion.enviar(MsgCliente.ReproducirAhora(ruta))
|
||||
fun playlistPlayAhora(ruta: String, duracion: String) = conexion.enviar(MsgCliente.PlaylistPlayAhora(ruta, duracion))
|
||||
fun eliminarDePlaylist(indice: Int) {
|
||||
conexion.enviar(MsgCliente.EliminarDePlaylist(indice))
|
||||
conexion.enviar(MsgCliente.ObtenerPlaylist)
|
||||
}
|
||||
fun moverEnPlaylist(indice: Int, arriba: Boolean) {
|
||||
conexion.enviar(MsgCliente.MoverEnPlaylist(indice, arriba))
|
||||
conexion.enviar(MsgCliente.ObtenerPlaylist)
|
||||
}
|
||||
|
||||
// ── Búsqueda ──────────────────────────────────────────────────────────────
|
||||
|
||||
fun buscar(consulta: String) {
|
||||
_buscando.value = true
|
||||
_resultadosBusqueda.value = emptyList()
|
||||
conexion.enviar(MsgCliente.Buscar(consulta, null))
|
||||
}
|
||||
|
||||
fun agregarAlPlaylist(ruta: String, alInicio: Boolean = false) {
|
||||
conexion.enviar(MsgCliente.AgregarAlPlaylist(ruta, "", alInicio))
|
||||
conexion.enviar(MsgCliente.ObtenerPlaylist)
|
||||
mostrarSnackbar(if (alInicio) "Añadido al inicio de la lista" else "Añadido al final de la lista")
|
||||
}
|
||||
|
||||
// ── Botonera ──────────────────────────────────────────────────────────────
|
||||
|
||||
fun presionarBoton(boton: BotonRemoto) {
|
||||
conexion.enviar(MsgCliente.ReproducirAhora(boton.ruta))
|
||||
}
|
||||
|
||||
fun recargarBotonera() = conexion.enviar(MsgCliente.ObtenerBotonera)
|
||||
|
||||
// ── Parrilla ──────────────────────────────────────────────────────────────
|
||||
|
||||
fun cargarParrilla(dia: Int, hora: Int) {
|
||||
_parrillaDia.value = dia
|
||||
_parrillaHora.value = hora
|
||||
conexion.enviar(MsgCliente.ObtenerParrilla(dia, hora))
|
||||
}
|
||||
|
||||
fun guardarParrilla(items: List<String>) {
|
||||
conexion.enviar(MsgCliente.GuardarParrilla(_parrillaDia.value, _parrillaHora.value, items))
|
||||
}
|
||||
|
||||
// ── Pautaje ───────────────────────────────────────────────────────────────
|
||||
|
||||
fun cargarPautaje(tipo: String, hora: Int, minuto: Int) {
|
||||
_pautajeTipo.value = tipo
|
||||
_pautajeHora.value = hora
|
||||
_pautajeMinuto.value = minuto
|
||||
conexion.enviar(MsgCliente.ObtenerPautaje(tipo, hora, minuto))
|
||||
}
|
||||
|
||||
fun guardarPautaje(entradas: List<EntradaRemota>) {
|
||||
conexion.enviar(MsgCliente.GuardarPautaje(_pautajeTipo.value, _pautajeHora.value, _pautajeMinuto.value, entradas))
|
||||
}
|
||||
|
||||
// ── Visor ─────────────────────────────────────────────────────────────────
|
||||
|
||||
fun cargarReportes(tipo: String, fecha: String) {
|
||||
conexion.enviar(MsgCliente.ObtenerReportes(tipo, fecha))
|
||||
}
|
||||
|
||||
// ── Snackbar ──────────────────────────────────────────────────────────────
|
||||
|
||||
fun mostrarSnackbar(msg: String) { _snackbar.value = msg }
|
||||
fun limpiarSnackbar() { _snackbar.value = null }
|
||||
|
||||
// ── Procesador de mensajes del servidor ───────────────────────────────────
|
||||
|
||||
private fun procesarMensaje(msg: MsgServidor) {
|
||||
when (msg) {
|
||||
is MsgServidor.PlaylistObtenida,
|
||||
is MsgServidor.PlaylistCambiada -> {
|
||||
val tracks = if (msg is MsgServidor.PlaylistObtenida) msg.tracks else (msg as MsgServidor.PlaylistCambiada).tracks
|
||||
_playlist.value = tracks
|
||||
}
|
||||
is MsgServidor.EstadoObtenido,
|
||||
is MsgServidor.EstadoCambiado -> {
|
||||
val est = if (msg is MsgServidor.EstadoObtenido) msg.estado else (msg as MsgServidor.EstadoCambiado).estado
|
||||
_estadoPlayer.value = est
|
||||
}
|
||||
is MsgServidor.ResultadosBusqueda -> {
|
||||
_buscando.value = false
|
||||
_resultadosBusqueda.value = msg.resultados
|
||||
}
|
||||
is MsgServidor.BotoneraObtenida -> _botones.value = msg.botones
|
||||
is MsgServidor.ParrillaObtenida -> _parrillaItems.value = msg.items
|
||||
is MsgServidor.PautajeObtenido -> _pautajeEntradas.value = msg.entradas
|
||||
is MsgServidor.ReportesObtenidos -> _reporteLineas.value = msg.lineas
|
||||
is MsgServidor.ColaObtenida -> when (msg.tipo_cola) {
|
||||
"comerciales" -> _colaComerciales.value = msg.items
|
||||
"eventos" -> _colaEventos.value = msg.items
|
||||
"eventos_espera" -> _colaEventosEspera.value = msg.items
|
||||
}
|
||||
is MsgServidor.ProximasTandasObtenidas -> _proximasTandas.value = msg.tandas
|
||||
is MsgServidor.IndiceActualizado -> mostrarSnackbar(if (msg.ok) "Índice actualizado" else "Error: ${msg.mensaje}")
|
||||
is MsgServidor.Error -> mostrarSnackbar("Error: ${msg.mensaje}")
|
||||
is MsgServidor.PautajeGuardado -> mostrarSnackbar("Pautaje guardado")
|
||||
is MsgServidor.ParrillaGuardada -> mostrarSnackbar("Parrilla guardada")
|
||||
is MsgServidor.BotoneraGuardada -> mostrarSnackbar("Botonera guardada")
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Configuración persistida ──────────────────────────────────────────────
|
||||
|
||||
private fun cargarConfig() = Config(
|
||||
servidor = prefs.getString("servidor", "192.168.1.100") ?: "192.168.1.100",
|
||||
puerto = prefs.getInt("puerto", 7777),
|
||||
token = prefs.getString("token", "") ?: "",
|
||||
modoRelay = prefs.getBoolean("modo_relay", false),
|
||||
relayId = prefs.getString("relay_id", "") ?: ""
|
||||
)
|
||||
|
||||
private fun guardarConfig(c: Config) {
|
||||
prefs.edit()
|
||||
.putString("servidor", c.servidor)
|
||||
.putInt("puerto", c.puerto)
|
||||
.putString("token", c.token)
|
||||
.putBoolean("modo_relay", c.modoRelay)
|
||||
.putString("relay_id", c.relayId)
|
||||
.apply()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
try { cm.unregisterNetworkCallback(networkCallback) } catch (_: Exception) {}
|
||||
conexion.desconectar()
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#FFFFFF"/>
|
||||
</shape>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item
|
||||
android:left="18dp"
|
||||
android:top="18dp"
|
||||
android:right="18dp"
|
||||
android:bottom="18dp">
|
||||
<bitmap
|
||||
android:src="@drawable/gradio_logo"
|
||||
android:gravity="fill"/>
|
||||
</item>
|
||||
</layer-list>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">G Radio Client</string>
|
||||
|
||||
<string name="tab_player">Player</string>
|
||||
<string name="tab_search">Búsqueda</string>
|
||||
<string name="tab_schedule">Parrilla</string>
|
||||
<string name="tab_buttons">Botonera</string>
|
||||
<string name="tab_ad_schedule">Pautaje</string>
|
||||
<string name="tab_ads">Comercios</string>
|
||||
<string name="tab_reports">Reportes</string>
|
||||
|
||||
<string name="btn_ok">OK</string>
|
||||
<string name="btn_cancel">Cancelar</string>
|
||||
<string name="btn_save">Guardar</string>
|
||||
<string name="btn_close">Cerrar</string>
|
||||
<string name="btn_connect">Conectar</string>
|
||||
<string name="btn_disconnect">Desconectar</string>
|
||||
<string name="btn_add">Agregar</string>
|
||||
<string name="btn_delete">Eliminar</string>
|
||||
<string name="btn_edit">Editar</string>
|
||||
<string name="btn_confirm">Confirmar</string>
|
||||
<string name="btn_stop">Detener</string>
|
||||
<string name="btn_stop_all">Detener todo</string>
|
||||
<string name="btn_up">Subir</string>
|
||||
<string name="btn_down">Bajar</string>
|
||||
|
||||
<string name="conn_server">Servidor:</string>
|
||||
<string name="conn_port">Puerto:</string>
|
||||
<string name="conn_token">Token:</string>
|
||||
<string name="conn_status_off">Desconectado</string>
|
||||
<string name="conn_status_on">Conectado</string>
|
||||
|
||||
<string name="empty_queue">Cola vacía</string>
|
||||
<string name="search_hint">Escribe para buscar</string>
|
||||
<string name="connect_to_search">Conectate para buscar audio</string>
|
||||
<string name="connect_to_buttons">Conectate para ver la botonera</string>
|
||||
<string name="connect_to_schedule">Conectate para ver la parrilla</string>
|
||||
<string name="connect_to_ad_schedule">Conectate para ver pautaje</string>
|
||||
<string name="connect_to_reports">Conectate para ver reportes</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">G Radio Client</string>
|
||||
|
||||
<string name="tab_player">Player</string>
|
||||
<string name="tab_search">Busca</string>
|
||||
<string name="tab_schedule">Grade</string>
|
||||
<string name="tab_buttons">Botoeira</string>
|
||||
<string name="tab_ad_schedule">Pauta</string>
|
||||
<string name="tab_ads">Comerciais</string>
|
||||
<string name="tab_reports">Relatórios</string>
|
||||
|
||||
<string name="btn_ok">OK</string>
|
||||
<string name="btn_cancel">Cancelar</string>
|
||||
<string name="btn_save">Salvar</string>
|
||||
<string name="btn_close">Fechar</string>
|
||||
<string name="btn_connect">Conectar</string>
|
||||
<string name="btn_disconnect">Desconectar</string>
|
||||
<string name="btn_add">Adicionar</string>
|
||||
<string name="btn_delete">Excluir</string>
|
||||
<string name="btn_edit">Editar</string>
|
||||
<string name="btn_confirm">Confirmar</string>
|
||||
<string name="btn_stop">Parar</string>
|
||||
<string name="btn_stop_all">Parar tudo</string>
|
||||
<string name="btn_up">Subir</string>
|
||||
<string name="btn_down">Descer</string>
|
||||
|
||||
<string name="conn_server">Servidor:</string>
|
||||
<string name="conn_port">Porta:</string>
|
||||
<string name="conn_token">Token:</string>
|
||||
<string name="conn_status_off">Desconectado</string>
|
||||
<string name="conn_status_on">Conectado</string>
|
||||
|
||||
<string name="empty_queue">Fila vazia</string>
|
||||
<string name="search_hint">Digite para buscar</string>
|
||||
<string name="connect_to_search">Conecte-se para buscar áudio</string>
|
||||
<string name="connect_to_buttons">Conecte-se para ver a botoeira</string>
|
||||
<string name="connect_to_schedule">Conecte-se para ver a grade</string>
|
||||
<string name="connect_to_ad_schedule">Conecte-se para ver a pauta</string>
|
||||
<string name="connect_to_reports">Conecte-se para ver relatórios</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Default (English) strings — used when device locale is not es/pt.
|
||||
Note: Most user-facing strings in the Kotlin code are still hardcoded in
|
||||
Spanish. Phase 1 of i18n created this scaffolding; a follow-up pass needs
|
||||
to migrate hardcoded literals to stringResource(R.string.*) calls.
|
||||
-->
|
||||
<resources>
|
||||
<string name="app_name">G Radio Client</string>
|
||||
|
||||
<!-- Tabs / sections -->
|
||||
<string name="tab_player">Player</string>
|
||||
<string name="tab_search">Search</string>
|
||||
<string name="tab_schedule">Schedule</string>
|
||||
<string name="tab_buttons">Buttons</string>
|
||||
<string name="tab_ad_schedule">Ad schedule</string>
|
||||
<string name="tab_ads">Ads</string>
|
||||
<string name="tab_reports">Reports</string>
|
||||
|
||||
<!-- Common actions -->
|
||||
<string name="btn_ok">OK</string>
|
||||
<string name="btn_cancel">Cancel</string>
|
||||
<string name="btn_save">Save</string>
|
||||
<string name="btn_close">Close</string>
|
||||
<string name="btn_connect">Connect</string>
|
||||
<string name="btn_disconnect">Disconnect</string>
|
||||
<string name="btn_add">Add</string>
|
||||
<string name="btn_delete">Delete</string>
|
||||
<string name="btn_edit">Edit</string>
|
||||
<string name="btn_confirm">Confirm</string>
|
||||
<string name="btn_stop">Stop</string>
|
||||
<string name="btn_stop_all">Stop all</string>
|
||||
<string name="btn_up">Up</string>
|
||||
<string name="btn_down">Down</string>
|
||||
|
||||
<!-- Connection -->
|
||||
<string name="conn_server">Server:</string>
|
||||
<string name="conn_port">Port:</string>
|
||||
<string name="conn_token">Token:</string>
|
||||
<string name="conn_status_off">Disconnected</string>
|
||||
<string name="conn_status_on">Connected</string>
|
||||
|
||||
<!-- Empty states -->
|
||||
<string name="empty_queue">Empty queue</string>
|
||||
<string name="search_hint">Type to search</string>
|
||||
<string name="connect_to_search">Connect to search audio</string>
|
||||
<string name="connect_to_buttons">Connect to see the buttons board</string>
|
||||
<string name="connect_to_schedule">Connect to see the schedule</string>
|
||||
<string name="connect_to_ad_schedule">Connect to see the ad schedule</string>
|
||||
<string name="connect_to_reports">Connect to see reports</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.GRadioClient" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
Reference in New Issue
Block a user