418 lines
22 KiB
HTML
418 lines
22 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="it">
|
|
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>TF5 Web Mixer</title>
|
|
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<style>
|
|
body {
|
|
background-color: #121212;
|
|
color: #e0e0e0;
|
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
user-select: none;
|
|
overflow: hidden; /* Evita lo scroll dell'intera pagina */
|
|
}
|
|
|
|
input[type=range][orient=vertical] {
|
|
appearance: slider-vertical;
|
|
width: 40px;
|
|
height: 250px;
|
|
cursor: grab;
|
|
outline: none;
|
|
}
|
|
|
|
input[type=range][orient=vertical]:active {
|
|
cursor: grabbing;
|
|
}
|
|
|
|
/* Colori Fader */
|
|
.fader-main { accent-color: #3b82f6; }
|
|
.fader-send { accent-color: #f59e0b; }
|
|
.fader-stereo { accent-color: #ef4444; }
|
|
|
|
.fader-strip {
|
|
background: linear-gradient(180deg, #1f2937 0%, #111827 100%);
|
|
border: 1px solid #374151;
|
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.5);
|
|
}
|
|
|
|
.btn-on {
|
|
background-color: #ef4444;
|
|
color: white;
|
|
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.5);
|
|
border-color: #991b1b;
|
|
}
|
|
|
|
.btn-off {
|
|
background-color: #374151;
|
|
color: #9ca3af;
|
|
border-color: #1f2937;
|
|
}
|
|
|
|
.nav-tab.active {
|
|
border-bottom: 2px solid #3b82f6;
|
|
color: #60a5fa;
|
|
}
|
|
|
|
.mix-btn.active {
|
|
background-color: #f59e0b;
|
|
color: #fff;
|
|
font-weight: bold;
|
|
border-color: #d97706;
|
|
}
|
|
|
|
/* Stili per i Meter Audio */
|
|
.meter-track {
|
|
width: 12px;
|
|
height: 250px;
|
|
background-color: #111827;
|
|
border: 1px solid #4b5563;
|
|
border-radius: 4px;
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
.meter-bar {
|
|
position: absolute;
|
|
bottom: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
background: linear-gradient(to top, #4ade80, #facc15 85%, #ef4444 95%);
|
|
transition: height 0.05s linear; /* Transizione fluida */
|
|
}
|
|
|
|
.ha-gain-badge {
|
|
font-family: monospace;
|
|
font-size: 9px;
|
|
font-weight: bold;
|
|
padding: 1px 4px;
|
|
border-radius: 3px;
|
|
width: 100%;
|
|
text-align: center;
|
|
margin-bottom: 2px;
|
|
}
|
|
.ha-gain-low { background: #1e3a5f; color: #60a5fa; }
|
|
.ha-gain-mid { background: #1a3a1a; color: #4ade80; }
|
|
.ha-gain-high { background: #3a1a00; color: #fb923c; }
|
|
.ha-gain-danger { background: #3a0a0a; color: #f87171; }
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
<div id="app" class="h-screen flex flex-col">
|
|
<!-- Header -->
|
|
<header class="bg-gray-900 p-4 shadow-lg border-b border-gray-800 flex justify-between items-center flex-none">
|
|
<h1 class="text-2xl font-bold tracking-wider text-blue-400">YAMAHA TF5 <span class="text-gray-500 text-sm">Web Controller</span></h1>
|
|
<div class="flex items-center gap-2 text-sm">
|
|
<span class="relative flex h-3 w-3">
|
|
<span :class="wsConnected ? 'animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75' : ''"></span>
|
|
<span class="relative inline-flex rounded-full h-3 w-3" :class="wsConnected ? 'bg-green-500' : 'bg-red-500'"></span>
|
|
</span>
|
|
{{ wsConnected ? 'Connesso' : 'Disconnesso' }}
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Navigation Main -->
|
|
<nav class="flex overflow-x-auto bg-gray-800 p-2 gap-4 flex-none">
|
|
<button v-for="tab in tabs" :key="tab.id" @click="activeTab = tab.id"
|
|
class="nav-tab px-4 py-2 font-semibold uppercase text-sm whitespace-nowrap" :class="{'active': activeTab === tab.id}">
|
|
{{ tab.label }}
|
|
</button>
|
|
</nav>
|
|
|
|
<!-- Sub-Navigation per SENDS ON FADERS -->
|
|
<div v-if="activeTab === 'mix_sends'" class="bg-gray-900 p-2 border-b border-gray-700 flex overflow-x-auto gap-2 items-center flex-none">
|
|
<span class="text-gray-400 text-sm font-bold ml-2 mr-2 whitespace-nowrap">SELEZIONA MIX:</span>
|
|
<button v-for="i in 20" :key="'mix'+i" @click="loadMixSends(i)"
|
|
class="mix-btn px-3 py-1 rounded border border-gray-600 text-sm text-gray-300 transition-colors"
|
|
:class="{'active': activeMixId === i}">
|
|
M{{ i }}
|
|
</button>
|
|
<div v-if="isLoadingSends" class="ml-4 text-yellow-500 text-sm animate-pulse">Caricamento fader...</div>
|
|
</div>
|
|
|
|
<!-- Mixer Surface -->
|
|
<main class="flex-1 flex overflow-hidden bg-gray-950">
|
|
|
|
<!-- ZONA CANALI (Scorrevole) -->
|
|
<div class="flex-1 overflow-x-auto p-6 flex gap-4 items-start">
|
|
|
|
<div v-if="currentFaders.length === 0 && !isLoadingSends" class="text-gray-500 m-auto text-center w-full mt-20">
|
|
Nessun fader disponibile in questa vista.
|
|
</div>
|
|
|
|
<!-- Fader strips -->
|
|
<div v-for="fader in currentFaders" :key="fader.id"
|
|
class="fader-strip rounded-lg p-3 flex flex-col items-center min-w-[80px]"
|
|
:class="{'border-yellow-700': activeTab === 'mix_sends'}">
|
|
|
|
<div class="h-10 mb-2 w-full text-center overflow-hidden text-ellipsis px-1 border border-gray-700 bg-gray-800 rounded flex items-center justify-center font-bold text-xs">
|
|
{{ fader.name }}
|
|
</div>
|
|
|
|
<div v-if="activeTab === 'channel' && fader.ha_gain !== null"
|
|
class="ha-gain-badge"
|
|
:class="haGainClass(fader.ha_gain)"
|
|
:title="`HA Gain: +${fader.ha_gain} dB`">
|
|
+{{ fader.ha_gain }} dB
|
|
</div>
|
|
|
|
<button @click="toggleOn(fader.id, fader.on)"
|
|
class="w-full h-10 rounded border-b-4 mb-6 font-bold text-sm transition-all"
|
|
:class="fader.on ? 'btn-on' : 'btn-off'">ON</button>
|
|
|
|
<!-- Contenitore Fader + Meter -->
|
|
<div class="flex items-end gap-2 mb-4">
|
|
<!-- Meter -->
|
|
<div class="meter-track">
|
|
<div class="meter-bar" :style="{ height: getMeterHeight(fader.id) + '%' }"></div>
|
|
</div>
|
|
<!-- Fader -->
|
|
<div class="relative flex justify-center bg-gray-950 p-2 rounded-lg border border-gray-800">
|
|
<div class="absolute left-1 top-0 bottom-0 w-1 flex flex-col justify-between py-2 text-[8px] text-gray-500 font-bold">
|
|
<span>+10</span><span>0</span><span>-20</span><span>-60</span><span>-∞</span>
|
|
</div>
|
|
<input type="range" orient="vertical" min="0" max="100" step="0.1"
|
|
:class="activeTab === 'mix_sends' ? 'fader-send' : 'fader-main'" :value="fader.slider_pos"
|
|
@pointerdown="startDrag(fader.id)" @pointerup="stopDrag(fader.id)"
|
|
@pointerleave="stopDrag(fader.id)" @input="handleSliderMove(fader.id, $event.target.value)"
|
|
@change="sendFinalLevel(fader.id)">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-black font-mono text-[10px] py-1 w-full text-center rounded border border-gray-700"
|
|
:class="activeTab === 'mix_sends' ? 'text-yellow-400' : 'text-green-400'">
|
|
{{ formatDb(fader.level_db) }}
|
|
</div>
|
|
<div class="text-gray-500 text-[10px] mt-2 font-bold uppercase">{{ getFaderLabel(fader.id) }}</div>
|
|
</div>
|
|
|
|
<div class="min-w-[20px] h-full"> </div>
|
|
</div>
|
|
|
|
<!-- ZONA MASTER (Fissa a destra) -->
|
|
<div class="flex-none w-[130px] p-6 bg-gray-900 border-l border-gray-800 shadow-[-10px_0_20px_rgba(0,0,0,0.5)] z-10 flex flex-col items-center">
|
|
|
|
<!-- MASTER DEL MIX SELEZIONATO (Sends on Faders) -->
|
|
<div v-if="activeTab === 'mix_sends' && currentMixMaster"
|
|
class="fader-strip rounded-lg p-3 flex flex-col items-center w-full border-yellow-600 border bg-gray-800">
|
|
<div class="h-10 mb-2 w-full text-center bg-yellow-700 text-black rounded flex flex-col items-center justify-center font-bold text-[10px] leading-tight">
|
|
<span>MIX {{ activeMixId }}</span>
|
|
<span class="uppercase opacity-80 truncate w-full px-1">{{ currentMixMaster.name }}</span>
|
|
</div>
|
|
<button @click="toggleOn(activeMixId, currentMixMaster.on, 'mix')"
|
|
class="w-full h-10 rounded border-b-4 mb-6 font-bold text-sm transition-all"
|
|
:class="currentMixMaster.on ? 'btn-on' : 'btn-off'">ON</button>
|
|
<!-- Contenitore Fader + Meter Master Mix -->
|
|
<div class="flex items-end gap-2 mb-4">
|
|
<div class="meter-track"><div class="meter-bar" :style="{ height: getMeterHeight(activeMixId, 'mix') + '%' }"></div></div>
|
|
<div class="relative flex justify-center bg-gray-950 p-2 rounded-lg border border-gray-800">
|
|
<input type="range" orient="vertical" min="0" max="100" step="0.1" class="fader-send"
|
|
:value="currentMixMaster.slider_pos" @pointerdown="startDrag('mix-master')"
|
|
@pointerup="stopDrag('mix-master')" @pointerleave="stopDrag('mix-master')"
|
|
@input="handleSliderMove(activeMixId, $event.target.value, 'mix')"
|
|
@change="sendFinalLevel(activeMixId, 'mix')">
|
|
</div>
|
|
</div>
|
|
<div class="bg-black text-yellow-400 font-mono text-[10px] py-1 w-full text-center rounded border border-gray-700">{{ formatDb(currentMixMaster.level_db) }}</div>
|
|
<div class="text-yellow-600 text-[10px] mt-2 font-bold uppercase">MASTER</div>
|
|
</div>
|
|
|
|
<!-- MASTER STEREO (Default) -->
|
|
<div v-if="activeTab !== 'mix_sends' && state.stereo !== undefined"
|
|
class="fader-strip rounded-lg p-3 flex flex-col items-center w-full border-red-900 border bg-gray-800">
|
|
<div class="h-10 mb-2 w-full text-center bg-red-900 text-white rounded flex items-center justify-center font-bold text-[10px] uppercase px-1">
|
|
Stereo L/R</div>
|
|
<button @click="toggleOn('stereo', state.stereo_on, 'stereo')"
|
|
class="w-full h-10 rounded border-b-4 mb-6 font-bold text-sm transition-all"
|
|
:class="state.stereo_on ? 'btn-on' : 'btn-off'">ON</button>
|
|
<!-- Contenitore Fader + Meter Master Stereo -->
|
|
<div class="flex items-end gap-2 mb-4">
|
|
<div class="meter-track"><div class="meter-bar" :style="{ height: getMeterHeight(1, 'stereo') + '%' }"></div></div>
|
|
<div class="relative flex justify-center bg-gray-950 p-2 rounded-lg border border-gray-800">
|
|
<input type="range" orient="vertical" min="0" max="100" step="0.1" class="fader-stereo"
|
|
:value="stereoFader.slider_pos" @pointerdown="startDrag('stereo')"
|
|
@pointerup="stopDrag('stereo')" @pointerleave="stopDrag('stereo')"
|
|
@input="handleSliderMove('stereo', $event.target.value, 'stereo')"
|
|
@change="sendFinalLevel('stereo', 'stereo')">
|
|
</div>
|
|
</div>
|
|
<div class="bg-black text-red-400 font-mono text-[10px] py-1 w-full text-center rounded border border-gray-700">{{ formatDb(state.stereo) }}</div>
|
|
<div class="text-red-500 text-[10px] mt-2 font-bold uppercase">MAIN</div>
|
|
</div>
|
|
|
|
</div>
|
|
</main>
|
|
</div>
|
|
|
|
<script>
|
|
const { createApp } = Vue;
|
|
|
|
const mapRange = (val, in_min, in_max, out_min, out_max) => (val - in_min) * (out_max - out_min) / (in_max - in_min) + out_min;
|
|
function sliderToDb(pos) {
|
|
pos = parseFloat(pos);
|
|
if (pos <= 0) return -120;
|
|
if (pos <= 20) return mapRange(pos, 0, 20, -120, -60);
|
|
if (pos <= 50) return mapRange(pos, 20, 50, -60, -20);
|
|
if (pos <= 80) return mapRange(pos, 50, 80, -20, 0);
|
|
return mapRange(pos, 80, 100, 0, 10);
|
|
}
|
|
function dbToSlider(db) {
|
|
db = parseFloat(db);
|
|
if (db <= -120) return 0;
|
|
if (db <= -60) return mapRange(db, -120, -60, 0, 20);
|
|
if (db <= -20) return mapRange(db, -60, -20, 20, 50);
|
|
if (db <= 0) return mapRange(db, -20, 0, 50, 80);
|
|
if (db <= 10) return mapRange(db, 0, 10, 80, 100);
|
|
return 100;
|
|
}
|
|
|
|
createApp({
|
|
data() {
|
|
return {
|
|
ws: null,
|
|
wsConnected: false,
|
|
activeTab: 'channel',
|
|
tabs: [
|
|
{ id: 'channel', label: 'Inputs 1-40', prefix: 'CH' },
|
|
{ id: 'mix_sends', label: 'Sends on Faders (Spie)', prefix: 'CH' },
|
|
{ id: 'steinch', label: 'ST IN 1-2', prefix: 'ST' },
|
|
{ id: 'fxrtn', label: 'FX Returns', prefix: 'FX' },
|
|
{ id: 'mix', label: 'Mixes Masters', prefix: 'MIX' },
|
|
{ id: 'dca', label: 'DCA 1-8', prefix: 'DCA' }
|
|
],
|
|
state: { channels: [], steinch: [], mixes: [], dcas: [], fxrtn: [], stereo: -120, stereo_on: false },
|
|
meterState: {},
|
|
|
|
activeMixId: 1,
|
|
mixSendsData: [],
|
|
isLoadingSends: false,
|
|
|
|
draggingFaders: {}, localLevels: {}, lastSendTime: {}, releaseTimers: {}
|
|
}
|
|
},
|
|
computed: {
|
|
currentFaders() {
|
|
let dataArray = [];
|
|
if (this.activeTab === 'channel') dataArray = this.state.channels;
|
|
else if (this.activeTab === 'steinch') dataArray = this.state.steinch;
|
|
else if (this.activeTab === 'mix') dataArray = this.state.mixes;
|
|
else if (this.activeTab === 'dca') dataArray = this.state.dcas;
|
|
else if (this.activeTab === 'fxrtn') dataArray = this.state.fxrtn;
|
|
else if (this.activeTab === 'mix_sends') dataArray = this.mixSendsData;
|
|
|
|
return dataArray.map(item => {
|
|
const id = item.channel || item.mix || item.dca || item.id;
|
|
const isDragging = this.draggingFaders[id];
|
|
const displayDb = isDragging ? this.localLevels[id] : (item.level_db === -Infinity || item.level_db <= -120 ? -120 : item.level_db);
|
|
return { id: id, name: item.name, on: item.on, level_db: displayDb, slider_pos: dbToSlider(displayDb), ha_gain: item.ha_gain ?? null };
|
|
});
|
|
},
|
|
currentMixMaster() {
|
|
if (!this.state.mixes) return null;
|
|
const mixObj = this.state.mixes.find(m => m.mix == this.activeMixId);
|
|
if (!mixObj) return null;
|
|
const id = 'mix-master';
|
|
const isDragging = this.draggingFaders[id];
|
|
const displayDb = isDragging ? this.localLevels[id] : (mixObj.level_db <= -120 ? -120 : mixObj.level_db);
|
|
return { name: mixObj.name || ('Mix ' + this.activeMixId), on: mixObj.on, level_db: displayDb, slider_pos: dbToSlider(displayDb) };
|
|
},
|
|
stereoFader() {
|
|
const isDragging = this.draggingFaders['stereo'];
|
|
const displayDb = isDragging ? this.localLevels['stereo'] : (this.state.stereo <= -120 ? -120 : this.state.stereo);
|
|
return { level_db: displayDb, slider_pos: dbToSlider(displayDb) };
|
|
},
|
|
},
|
|
mounted() { this.connectWebSocket(); },
|
|
methods: {
|
|
connectWebSocket() {
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
this.ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
|
|
this.ws.onopen = () => { this.wsConnected = true; };
|
|
this.ws.onclose = () => { this.wsConnected = false; setTimeout(this.connectWebSocket, 3000); };
|
|
this.ws.onmessage = (event) => {
|
|
const msg = JSON.parse(event.data);
|
|
if (msg.type === "state_update") { this.state = msg.data; }
|
|
else if (msg.type === "meter_update") { this.meterState = msg.data; }
|
|
else if (msg.type === "mix_sends_data" && msg.mix === this.activeMixId) { this.mixSendsData = msg.data; this.isLoadingSends = false; }
|
|
};
|
|
},
|
|
getMeterHeight(id, overrideType = null) {
|
|
const type = overrideType || (this.activeTab === 'mix_sends' ? 'channel' : this.activeTab);
|
|
if (!this.meterState[type] || !this.meterState[type].readings) return 0;
|
|
const meter = this.meterState[type].readings.find(m => m.channel == id);
|
|
if (!meter || meter.raw === null) return 0;
|
|
return (meter.raw / 127) * 100;
|
|
},
|
|
getFaderLabel(id) {
|
|
const tabInfo = this.tabs.find(t => t.id === this.activeTab || (this.activeTab === 'mix_sends' && t.id === 'channel'));
|
|
return `${tabInfo.prefix || 'CH'} ${id}`;
|
|
},
|
|
loadMixSends(mixNumber) {
|
|
this.activeMixId = mixNumber;
|
|
this.mixSendsData = [];
|
|
this.isLoadingSends = true;
|
|
if (this.wsConnected) {
|
|
this.ws.send(JSON.stringify({ action: 'get_mix_sends', mix: mixNumber }));
|
|
}
|
|
},
|
|
formatDb(val) { return val <= -120 ? "-inf dB" : (val > 0 ? "+" : "") + parseFloat(val).toFixed(1) + " dB"; },
|
|
startDrag(id) {
|
|
if (this.releaseTimers[id]) clearTimeout(this.releaseTimers[id]);
|
|
this.draggingFaders[id] = true;
|
|
},
|
|
stopDrag(id) {
|
|
if (this.draggingFaders[id]) {
|
|
this.sendFinalLevel(id);
|
|
this.releaseTimers[id] = setTimeout(() => { this.draggingFaders[id] = false; }, 600);
|
|
}
|
|
},
|
|
handleSliderMove(id, sliderValue, overrideType = null) {
|
|
const realDb = sliderToDb(sliderValue);
|
|
this.localLevels[id] = realDb;
|
|
const now = Date.now();
|
|
if (!this.lastSendTime[id] || now - this.lastSendTime[id] > 50) {
|
|
this.sendCommand(id, realDb, overrideType);
|
|
this.lastSendTime[id] = now;
|
|
}
|
|
},
|
|
sendFinalLevel(id, overrideType = null) {
|
|
if (this.localLevels[id] !== undefined) this.sendCommand(id, this.localLevels[id], overrideType);
|
|
},
|
|
sendCommand(id, dbValue, overrideType = null) {
|
|
if (!this.wsConnected) return;
|
|
if (this.activeTab === 'mix_sends' && !overrideType) {
|
|
const fader = this.mixSendsData.find(f => f.id === id);
|
|
if (fader) fader.level_db = dbValue;
|
|
this.ws.send(JSON.stringify({ action: 'set_send_level', id: id, mix: this.activeMixId, value: dbValue }));
|
|
} else {
|
|
const type = overrideType || this.activeTab;
|
|
this.ws.send(JSON.stringify({ action: 'set_level', type: type, id: id, value: dbValue }));
|
|
}
|
|
},
|
|
toggleOn(id, currentState, overrideType = null) {
|
|
if (!this.wsConnected) return;
|
|
const type = overrideType || this.activeTab;
|
|
if (type === 'mix_sends') {
|
|
const fader = this.mixSendsData.find(f => f.id === id);
|
|
if (fader) fader.on = !currentState;
|
|
this.ws.send(JSON.stringify({ action: 'set_send_on', id: id, mix: this.activeMixId, value: !currentState }));
|
|
} else {
|
|
this.ws.send(JSON.stringify({ action: 'set_on', type: type, id: id, value: !currentState }));
|
|
}
|
|
},
|
|
haGainClass(gain) {
|
|
if (gain === null || gain === undefined) return 'ha-gain-low';
|
|
if (gain < 20) return 'ha-gain-low';
|
|
if (gain < 40) return 'ha-gain-mid';
|
|
if (gain < 55) return 'ha-gain-high';
|
|
return 'ha-gain-danger';
|
|
}
|
|
}
|
|
}).mount('#app');
|
|
</script>
|
|
</body>
|
|
|
|
</html> |