-- =========================================================================== -- Radio (CLIENTE) v2 - Mu Plus Radio dentro del juego, diseno con imagenes -- Tecla F6: panel con logo, cancion actual, ecualizador, Reproducir/Detener y volumen. -- -- Como funciona: el audio lo reproduce el reproductor nativo de Windows (WinRT -- MediaPlayer) dentro de un PowerShell oculto que este script lanza la primera -- vez que se pulsa Reproducir. Lua y PowerShell se hablan con archivitos en la -- carpeta del juego: -- Radio_cmd.txt Lua -> PS "seq|play/stop|volumen" -- Radio_state.txt PS -> Lua "epoch|estado|cancion" (lo escribe cada 2 s) -- (el PS tambien vigila el proceso del juego, CFG.GameExe: si se cierra con la X, se apaga al tiro) -- Radio_hb.txt Lua -> PS latido (solo mientras estas dentro del mundo); si pasan -- 20 s sin latido, PS se cierra: la radio se apaga sola al -- cerrar el juego o volver a la seleccion de personaje -- Radio_cfg.txt volumen guardado Radio_pos.txt posicion del panel -- La cancion la consulta PowerShell (no congela el juego como GetUrlData). -- -- Mover: clic sobre la cabecera lo toma, mueve el mouse, clic otra vez lo suelta. -- Clic derecho sobre la cabecera vuelve a la posicion original. -- Volumen: botones - / + o clic directo sobre la barra. -- -- Imagenes: las mismas rk_*.ozt del ranking F4 (Data\Custom\ScriptImages\). Nada nuevo. -- Ubicacion: Data\Custom\Script\Scripts\Radio\Radio.lua -- Carga: requirefolder('Scripts\\Radio') en el ScriptMain del cliente. -- Requiere: io.open e io.popen habilitados. -- Solo ASCII en los textos. -- =========================================================================== local CFG = { Key = Keys.F6 or 117, Scene = 5, -- GetMainScene() == 5: dentro del juego Title = "MU PLUS RADIO", Subtitle = "Musica en vivo 24/7", Stream = "https://radio.muplusone.com/listen/mu_plus_radio/radio.mp3", Api = "https://radio.muplusone.com/api/nowplaying/mu_plus_radio", VolStep = 10, VolStart = 60, StaleSec = 6, -- sin estado nuevo en 6 s = PowerShell no responde StartSec = 12, -- tiempo maximo de arranque de PowerShell GameExe = "main", -- proceso del juego (sin .exe): la radio se apaga apenas se cierra Logo = true, -- logo del servidor (rk_logo.ozt) AllowMove = true, PosFile = "Radio_pos.txt", MarginRight = 240, -- a la izquierda del ranking F4 (que va pegado a la derecha) Y = 70, Bars = 24, -- barras del ecualizador Scale = 0.85, -- tamano del panel: 1.0 = original, menor = mas chico (0.7 a 1.0) } local SC = math.max(0.6, math.min(1.2, CFG.Scale or 1.0)) local OX, OY = 0, 0 -- origen del panel: todo se escala desde su esquina local LOGO_H = 58 local W = 220 local H = 304 - (CFG.Logo and 0 or LOGO_H) local CHARS = math.floor(32 * SC) -- letras por linea en la tarjeta (el texto no se achica) -- Ids de imagen propios (40160..), mismos archivos del ranking. local IMG = { tl = 40160, tm = 40161, tr = 40162, ml = 40163, mm = 40164, mr = 40165, bl = 40166, bm = 40167, br = 40168, head = 40169, line = 40170, tabOn = 40171, tabOff = 40172, barGold = 40173, barGreen = 40174, rowAlt = 40175, strip = 40176, logo = 40177, } local IMG_FILES = { [IMG.tl] = "rk_tl", [IMG.tm] = "rk_tm", [IMG.tr] = "rk_tr", [IMG.ml] = "rk_ml", [IMG.mm] = "rk_mm", [IMG.mr] = "rk_mr", [IMG.bl] = "rk_bl", [IMG.bm] = "rk_bm", [IMG.br] = "rk_br", [IMG.head] = "rk_head", [IMG.line] = "rk_line", [IMG.tabOn] = "rk_tab_on", [IMG.tabOff] = "rk_tab_off", [IMG.barGold] = "rk_bar_gold", [IMG.barGreen] = "rk_bar_green", [IMG.rowAlt] = "rk_row_alt", [IMG.strip] = "rk_strip", [IMG.logo] = "rk_logo", } local State = { open = false, want = false, -- el jugador quiere que suene vol = CFG.VolStart, seq = os.time(), launchAt = 0, alive = false, -- PowerShell responde st = "stopped", title = "", lastPoll = 0, lastHb = 0, synced = false, handle = nil, imgLoaded = false, dx = 0, dy = 0, moving = false, grabX = 0, grabY = 0, t0 = os.clock(), } local Hit = { win = {}, head = {}, close = {}, play = {}, vmin = {}, vmax = {}, vbar = {} } BridgeFunctionAttach('KeyboardEvent', 'Radio_Key') BridgeFunctionAttach('MainInterfaceProcThread', 'Radio_Draw') BridgeFunctionAttach('MainLoader', 'Radio_LoadImages') BridgeFunctionAttach('UpdateMouseEvent', 'Radio_Mouse') BridgeFunctionAttach('UpdateKeyEvent', 'Radio_UpdateKey') -- --------------------------------------------------------------------------- -- Utilidades -- --------------------------------------------------------------------------- -- El mouse viene en pantalla; los rectangulos estan en medidas de diseno (antes de escalar). local function DX(mx) return OX + (mx - OX) / SC end local function DY(my) return OY + (my - OY) / SC end local function Inside(r, mx, my) if r.x == nil then return false end mx, my = DX(mx), DY(my) return mx >= r.x and mx <= (r.x + r.w) and my >= r.y and my <= (r.y + r.h) end local function Set(r, x, y, w, h) r.x, r.y, r.w, r.h = x, y, w, h end local function Clamp(v, lo, hi) if v < lo then return lo end if v > hi then return hi end return v end -- --------------------------------------------------------------------------- -- Reproductor (PowerShell). __URL__ y __API__ se reemplazan al escribirlo. -- --------------------------------------------------------------------------- local PS_SCRIPT = [==[ $ErrorActionPreference = 'SilentlyContinue' $dir = $PSScriptRoot if (-not $dir) { $dir = (Get-Location).Path } $cmdF = Join-Path $dir 'Radio_cmd.txt' $stF = Join-Path $dir 'Radio_state.txt' $hbF = Join-Path $dir 'Radio_hb.txt' $url = '__URL__' $api = '__API__' $exe = '__EXE__' $created = $false $mx = New-Object System.Threading.Mutex($true, 'Local\MuPlusOneRadio', [ref]$created) if (-not $created) { exit } try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch {} $winrt = $true try { [void][Windows.Media.Playback.MediaPlayer, Windows.Media.Playback, ContentType=WindowsRuntime] [void][Windows.Media.Core.MediaSource, Windows.Media.Core, ContentType=WindowsRuntime] } catch { $winrt = $false } $mp = $null; $want = $false; $vol = 60; $seq = ''; $title = '' $seenGame = $false; $tick = 0 $stuck = $null; $lastTitleT = [datetime]::MinValue; $lastOut = ''; $lastEpoch = 0 function Clean([string]$s) { if (-not $s) { return '' } $s = $s.Replace([string][char]0x2013, '-').Replace([string][char]0x2014, '-') $d = $s.Normalize([Text.NormalizationForm]::FormD) $d = [regex]::Replace($d, '\p{Mn}', '') $d = [regex]::Replace($d, '[^\x20-\x7E]', '') return $d.Replace('|', '-').Trim() } function GetTitle { try { $r = [Net.WebRequest]::Create($api) $r.Timeout = 5000 $r.UserAgent = 'MuPlusOneRadio' $resp = $r.GetResponse() $sr = New-Object IO.StreamReader($resp.GetResponseStream(), [Text.Encoding]::UTF8) $txt = $sr.ReadToEnd(); $sr.Close(); $resp.Close() $t = $null try { $o = $txt | ConvertFrom-Json; $t = $o.now_playing.song.text } catch {} if (-not $t -and ($txt -match '"text"\s*:\s*"([^"]*)"')) { $t = $Matches[1] } return (Clean $t) } catch { return $null } } function StopPlay { if ($script:mp) { try { $script:mp.Pause(); $script:mp.Dispose() } catch {} ; $script:mp = $null } } function StartPlay { StopPlay if (-not $script:winrt) { return } try { $p = New-Object Windows.Media.Playback.MediaPlayer $p.Source = [Windows.Media.Core.MediaSource]::CreateFromUri([Uri]$script:url) $p.Volume = $script:vol / 100.0 $p.Play() $script:mp = $p } catch { $script:mp = $null } } function PState { if (-not $script:mp) { return 'none' } try { return ([string]$script:mp.PlaybackSession.PlaybackState).ToLower() } catch { return 'none' } } while ($true) { Start-Sleep -Milliseconds 250 # Proceso del juego: si estaba abierto y ya no existe (se cerro con la X), salir al tiro. $tick++ if (($tick % 4) -eq 0) { $g = @(Get-Process -Name $exe -ErrorAction SilentlyContinue | Where-Object { (-not $_.Path) -or $_.Path.StartsWith($dir, [StringComparison]::OrdinalIgnoreCase) }) if ($g.Count -gt 0) { $seenGame = $true } elseif ($seenGame) { break } } # Latido: si salio del mundo o el proceso no se pudo vigilar (20 s sin latido), salir. $age = ((Get-Date) - [IO.File]::GetLastWriteTime($hbF)).TotalSeconds if ($age -gt 20) { break } # Comandos desde Lua: "seq|play/stop|volumen" $c = $null try { $c = [IO.File]::ReadAllText($cmdF) } catch {} if ($c) { $p = $c.Trim().Split('|') if ($p.Count -ge 3 -and $p[0] -ne $seq) { $seq = $p[0] $v = 0 if ([int]::TryParse($p[2], [ref]$v)) { $vol = [Math]::Max(0, [Math]::Min(100, $v)) } if ($mp) { try { $mp.Volume = $vol / 100.0 } catch {} } $nw = ($p[1] -eq 'play') if ($nw -and -not $want) { $want = $true; $stuck = $null; StartPlay; $lastTitleT = [datetime]::MinValue } elseif ((-not $nw) -and $want) { $want = $false; StopPlay } } } # Vigilante: si el stream se cae o se queda pegado, reconectar. $ps = PState if ($want) { if ($ps -eq 'playing') { $stuck = $null } else { if (-not $stuck) { $stuck = Get-Date } $lim = 20 if ($ps -eq 'paused' -or $ps -eq 'none') { $lim = 6 } if (((Get-Date) - $stuck).TotalSeconds -gt $lim) { StartPlay; $stuck = Get-Date } } } # Cancion actual (cada 15 s mientras suena, o hasta tener alguna) if (((Get-Date) - $lastTitleT).TotalSeconds -gt 15) { $lastTitleT = Get-Date if ($want -or $title -eq '') { $t = GetTitle; if ($t) { $title = $t } } } # Estado para Lua $st = 'stopped' if ($want) { switch ($ps) { 'playing' { $st = 'playing' } 'buffering' { $st = 'buffering' } default { $st = 'connecting' } } } if (-not $winrt) { $st = 'error' } $now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() $out = $st + '|' + $title if (($out -ne $lastOut) -or (($now - $lastEpoch) -ge 2)) { try { [IO.File]::WriteAllText($stF, ([string]$now + '|' + $out), [Text.Encoding]::ASCII) } catch {} $lastOut = $out; $lastEpoch = $now } } StopPlay try { Remove-Item $stF -Force } catch {} try { $mx.ReleaseMutex() } catch {} ]==] -- --------------------------------------------------------------------------- -- Archivos y comunicacion con PowerShell -- --------------------------------------------------------------------------- local function WriteFile(name, text) local ok, f = pcall(io.open, name, "w") if ok and f then f:write(text) f:close() return true end return false end local function ReadFile(name) local ok, f = pcall(io.open, name, "r") if ok and f then local s = f:read("*a") f:close() return s end return nil end local function SaveVol() WriteFile("Radio_cfg.txt", tostring(State.vol)) end local function LoadVol() local s = ReadFile("Radio_cfg.txt") local v = s and tonumber(s:match("%d+")) if v then State.vol = math.max(0, math.min(100, math.floor(v))) end end local function Heartbeat() if GetMainScene() ~= CFG.Scene then return end -- fuera del mundo no hay latido local now = os.time() if now - State.lastHb >= 5 then State.lastHb = now WriteFile("Radio_hb.txt", tostring(now)) end end local function SendCmd() State.seq = State.seq + 1 WriteFile("Radio_cmd.txt", string.format("%d|%s|%d", State.seq, State.want and "play" or "stop", State.vol)) end local function Starting() return State.launchAt ~= 0 and (os.time() - State.launchAt) < CFG.StartSec end local function Launch() local code = PS_SCRIPT code = code:gsub("__URL__", function() return CFG.Stream end) code = code:gsub("__API__", function() return CFG.Api end) code = code:gsub("__EXE__", function() return CFG.GameExe end) if not WriteFile("Radio_player.ps1", code) then return false end State.lastHb = 0 Heartbeat() State.launchAt = os.time() local ok, h = pcall(io.popen, 'cmd /c start "" /min powershell -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File Radio_player.ps1') State.handle = ok and h or nil -- se guarda para que el GC no lo cierre return ok end local function Poll() local now = os.time() if now == State.lastPoll then return end State.lastPoll = now local s = ReadFile("Radio_state.txt") if not s then State.alive = false return end local ep, st, title = s:match("^(%d+)|(%a+)|([^\r\n]*)") if not ep then return end -- lectura a medias, se reintenta el proximo segundo State.alive = (now - tonumber(ep)) <= CFG.StaleSec if State.alive then State.st = st State.title = title -- Si ya sonaba de una sesion anterior del juego, tomar ese estado. if not State.synced then State.synced = true if st ~= "stopped" then State.want = true end end end end local function TogglePlay() if State.want then State.want = false SendCmd() return end State.want = true SendCmd() if not State.alive and not Starting() then if not Launch() then State.want = false end end end local function ChangeVol(delta) State.vol = math.max(0, math.min(100, State.vol + delta)) SaveVol() SendCmd() end -- --------------------------------------------------------------------------- -- Posicion del panel -- --------------------------------------------------------------------------- local function LoadPos() local ok, f = pcall(io.open, CFG.PosFile, "r") if ok and f then local a, b = f:read("*l"), f:read("*l") f:close() State.dx, State.dy = tonumber(a) or 0, tonumber(b) or 0 end end local function SavePos() local ok, f = pcall(io.open, CFG.PosFile, "w") if ok and f then f:write(tostring(State.dx), "\n", tostring(State.dy), "\n"); f:close() end end local function CloseWin() State.open = false if State.moving then State.moving = false; SavePos() end end -- --------------------------------------------------------------------------- -- Entrada -- --------------------------------------------------------------------------- function Radio_Key(key) if CheckWindowOpen(UIChatWindow) == 1 then return end if key == CFG.Key then if State.open then CloseWin() elseif GetMainScene() == CFG.Scene then State.open = true end return end if State.open and key == Keys.Escape then CloseWin() end end function Radio_Mouse() if not State.open then return end -- Panel tomado: cualquier clic lo suelta if State.moving then if CheckReleasedKey(Keys.LButton) == 1 then State.moving = false; SavePos() end return end local mx, my = MousePosX(), MousePosY() if CheckReleasedKey(Keys.RButton) == 1 and CFG.AllowMove and Inside(Hit.head, mx, my) then State.dx, State.dy = 0, 0 SavePos() return end if CheckReleasedKey(Keys.LButton) ~= 1 then return end if Inside(Hit.close, mx, my) then CloseWin() elseif Inside(Hit.play, mx, my) then TogglePlay() elseif Inside(Hit.vmin, mx, my) then ChangeVol(-CFG.VolStep) elseif Inside(Hit.vmax, mx, my) then ChangeVol(CFG.VolStep) elseif Inside(Hit.vbar, mx, my) then local f = Clamp((DX(mx) - Hit.vbar.x) / Hit.vbar.w, 0, 1) local v = math.floor(f * 100 / CFG.VolStep + 0.5) * CFG.VolStep ChangeVol(v - State.vol) elseif CFG.AllowMove and Inside(Hit.head, mx, my) then State.moving = true State.grabX, State.grabY = mx - State.dx, my - State.dy end end function Radio_UpdateKey() if not State.open then return end if CheckWindowOpen(UIChatWindow) == 1 then return end if State.moving or Inside(Hit.win, MousePosX(), MousePosY()) then DisableClickClient() end end -- --------------------------------------------------------------------------- -- Dibujo -- --------------------------------------------------------------------------- local function LoadImagesOnce() if State.imgLoaded then return end State.imgLoaded = true for id, file in pairs(IMG_FILES) do LoadBitmap("Custom\\ScriptImages\\" .. file .. ".tga", id) end end function Radio_LoadImages() LoadImagesOnce() end local function Img(id, x, y, w, h) RenderImage2(id, math.floor(OX + (x - OX) * SC + 0.5), math.floor(OY + (y - OY) * SC + 0.5), math.floor(w * SC + 0.5), math.floor(h * SC + 0.5), 0.0, 0.0, 1.0, 1.0, 1, 1, 1.0) end local function Frame(x, y, w, h) local c = 8 Img(IMG.tl, x, y, c, c); Img(IMG.tm, x + c, y, w - 2 * c, c); Img(IMG.tr, x + w - c, y, c, c) Img(IMG.ml, x, y + c, c, h - 2 * c); Img(IMG.mm, x + c, y + c, w - 2 * c, h - 2 * c); Img(IMG.mr, x + w - c, y + c, c, h - 2 * c) Img(IMG.bl, x, y + h - c, c, c); Img(IMG.bm, x + c, y + h - c, w - 2 * c, c); Img(IMG.br, x + w - c, y + h - c, c, c) end local function Text(x, y, s, w, align, r, g, b, a) SetTextColor(r, g, b, a or 255) RenderText5(math.floor(OX + (x - OX) * SC + 0.5), math.floor(OY + (y - OY) * SC + 0.5), s, math.floor(w * SC + 0.5), align) end local function Cut(s, n) s = tostring(s) if #s > n then return string.sub(s, 1, n - 3) .. "..." end return s end -- Parte el texto en lineas de n letras (maximo maxl); la ultima lleva "..." si no cabe. local function Wrap(s, n, maxl) local lines, cur = {}, "" local cut = false for w in tostring(s):gmatch("%S+") do if #w > n then w = string.sub(w, 1, n) end if cur == "" then cur = w elseif #cur + 1 + #w <= n then cur = cur .. " " .. w else lines[#lines + 1] = cur cur = w if #lines >= maxl then cut = true; break end end end if not cut and cur ~= "" then lines[#lines + 1] = cur end if cut then local last = lines[maxl] if #last > n - 3 then last = string.sub(last, 1, n - 3) end lines[maxl] = last .. "..." end return lines end -- "Artista - Titulo" -> artista, titulo local function SplitSong(s) if s == nil or s == "" then return nil, nil end local a, t = s:match("^(.-)%s+%-%s+(.+)$") if a and a ~= "" then return a, t end return nil, s end -- Devuelve texto, r, g, b, nivel (0 apagado, 1 conectando, 2 sonando) local function StatusInfo() if not State.want then return "Detenida", 150, 150, 160, 0 end if not State.alive then if Starting() then return "Iniciando...", 255, 215, 90, 1 end return "Reproductor sin respuesta", 255, 120, 120, 0 end if State.st == "playing" then return "Sonando", 130, 255, 150, 2 end if State.st == "buffering" then return "Cargando audio...", 255, 215, 90, 1 end if State.st == "connecting" then return "Conectando...", 255, 215, 90, 1 end if State.st == "error" then return "Error del reproductor", 255, 120, 120, 0 end return "Detenida", 150, 150, 160, 0 end -- Ecualizador animado: barras doradas que suben y bajan mientras suena. local function DrawEqualizer(x, baseY, maxH, level) local t = os.clock() - State.t0 local n = CFG.Bars local step = (W - 28) / n local bw = math.floor(step) - 2 for i = 1, n do local h if level == 2 then local a = 0.5 + 0.5 * math.sin(t * (3.1 + (i * 1.7) % 4.3) + i * 1.9) local b = 0.5 + 0.5 * math.sin(t * (1.3 + (i * 0.9) % 2.1) + i * 0.7) h = 3 + (maxH - 3) * (0.25 + 0.75 * a) * (0.45 + 0.55 * b) elseif level == 1 then local a = 0.5 + 0.5 * math.sin(t * 2.2 + i * 0.5) h = 2 + 5 * a else h = 2 end h = math.floor(h) local bx = x + 14 + math.floor((i - 1) * step) if level == 0 then Img(IMG.line, bx, baseY - 2, bw, 2) else Img(IMG.barGold, bx, baseY - h, bw, h) end end end function Radio_Draw() Heartbeat() Poll() -- Si PowerShell murio estando en "quiero que suene", dejar el boton en Reproducir. if State.want and not State.alive and not Starting() and State.launchAt ~= 0 then State.want = false end if not State.open then return end if CheckWindowOpen(UIInventory) == 1 or CheckWindowOpen(UICharacter) == 1 then CloseWin() return end LoadImagesOnce() -- Posicion (movible) if State.moving then State.dx = MousePosX() - State.grabX State.dy = MousePosY() - State.grabY end local baseX = GetWideX() - W * SC - CFG.MarginRight State.dx = Clamp(State.dx, -baseX, GetWideX() - W * SC - baseX) State.dy = Clamp(State.dy, -CFG.Y, 470 - H * SC - CFG.Y) local x, y = baseX + State.dx, CFG.Y + State.dy OX, OY = x, y local mx, my = MousePosX(), MousePosY() SetFontType(1) SetTextBg(0, 0, 0, 0) local stText, sr, sg, sb, level = StatusInfo() -- Marco y cabecera local oy = CFG.Logo and LOGO_H or 0 Frame(x, y, W, H) Img(IMG.head, x + 3, y + 3, W - 6, 32 + oy) Img(IMG.line, x + 3, y + 35 + oy, W - 6, 1) if CFG.Logo then Img(IMG.logo, x + (W - 120) // 2, y + 2, 120, 60) end local cy = y + oy Text(x, cy + 7, CFG.Title, W, ALIGN_CENTER, 255, 215, 90) Text(x, cy + 21, CFG.Subtitle, W, ALIGN_CENTER, 175, 170, 150) Set(Hit.head, x, y, W - 24, 36 + oy) Set(Hit.close, x + W - 22, y + 6, 16, 16) local overClose = Inside(Hit.close, mx, my) Text(x + W - 22, y + 8, "x", 16, ALIGN_CENTER, overClose and 255 or 190, overClose and 120 or 190, overClose and 120 or 190) Set(Hit.win, x, y, W, H) -- Tarjeta "Ahora suena" Img(IMG.strip, x + 8, cy + 40, W - 16, 62) Text(x + 14, cy + 44, "AHORA SUENA", 100, ALIGN_LEFT, 160, 150, 120) if level == 2 then Text(x + W - 70, cy + 44, "EN VIVO", 56, ALIGN_RIGHT, 255, 110, 110) end local artist, title = SplitSong(State.title) local ly = cy + 58 if artist == nil and title == nil then Text(x + 14, ly, "Mu Plus Radio", W - 28, ALIGN_LEFT, 255, 215, 90) Text(x + 14, ly + 14, "Esperando informacion...", W - 28, ALIGN_LEFT, 170, 170, 180) else if artist then Text(x + 14, ly, Cut(artist, CHARS), W - 28, ALIGN_LEFT, 255, 215, 90) ly = ly + 14 end local maxl = artist and 2 or 3 for _, line in ipairs(Wrap(title, CHARS, maxl)) do Text(x + 14, ly, line, W - 28, ALIGN_LEFT, 235, 235, 240) ly = ly + 14 end end -- Ecualizador DrawEqualizer(x, cy + 138, 28, level) Img(IMG.line, x + 8, cy + 143, W - 16, 1) -- Boton Reproducir / Detener Set(Hit.play, x + 8, cy + 150, W - 16, 26) local overPlay = Inside(Hit.play, mx, my) local gold = (not State.want) or overPlay Img(gold and IMG.tabOn or IMG.tabOff, Hit.play.x, Hit.play.y, Hit.play.w, Hit.play.h) Text(Hit.play.x, Hit.play.y + 8, State.want and "DETENER" or "REPRODUCIR", Hit.play.w, ALIGN_CENTER, 255, 245, 210) -- Volumen Text(x + 14, cy + 184, "VOLUMEN", 80, ALIGN_LEFT, 160, 150, 120) Text(x + W - 94, cy + 184, string.format("%d%%", State.vol), 80, ALIGN_RIGHT, 255, 215, 90) Set(Hit.vmin, x + 8, cy + 198, 20, 16) Set(Hit.vmax, x + W - 28, cy + 198, 20, 16) Img(Inside(Hit.vmin, mx, my) and IMG.tabOn or IMG.tabOff, Hit.vmin.x, Hit.vmin.y, Hit.vmin.w, Hit.vmin.h) Img(Inside(Hit.vmax, mx, my) and IMG.tabOn or IMG.tabOff, Hit.vmax.x, Hit.vmax.y, Hit.vmax.w, Hit.vmax.h) Text(Hit.vmin.x, Hit.vmin.y + 3, "-", Hit.vmin.w, ALIGN_CENTER, 235, 230, 215) Text(Hit.vmax.x, Hit.vmax.y + 3, "+", Hit.vmax.w, ALIGN_CENTER, 235, 230, 215) local bx, bw = x + 34, W - 68 Set(Hit.vbar, bx, cy + 196, bw, 20) Img(IMG.rowAlt, bx, cy + 201, bw, 10) Img(IMG.line, bx, cy + 200, bw, 1) Img(IMG.line, bx, cy + 211, bw, 1) local fw = math.floor(bw * State.vol / 100) if fw > 0 then Img(State.vol >= 100 and IMG.barGreen or IMG.barGold, bx, cy + 201, fw, 10) end -- Pie Img(IMG.line, x + 8, y + H - 24, W - 16, 1) Text(x + 14, y + H - 17, stText, 130, ALIGN_LEFT, sr, sg, sb) Text(x + W - 80, y + H - 17, "F6 abrir/cerrar", 68, ALIGN_RIGHT, 120, 120, 130) end LoadVol() LoadPos() Console(1, "[Radio] v2 cargado - F6 abre el panel")