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:
2026-08-23 01:18:21 -05:00
commit b5a0f3c671
44 changed files with 4303 additions and 0 deletions
+92
View File
@@ -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") }
}
+1
View File
@@ -0,0 +1 @@
-keep class com.gradio.client.data.** { *; }
+27
View File
@@ -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()
}
}
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>
+41
View File
@@ -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>
+41
View File
@@ -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>
+51
View File
@@ -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>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.GRadioClient" parent="android:Theme.Material.Light.NoActionBar" />
</resources>