/*  Blackmagic Camera Control WebUI
    (c) Dylan Speiser 2024 — UI redesign 2025
*/

var cameras = [];
var ci = 0;
var WBMode = 0; // 0: balance, 1: tint
var unsavedChanges = [];

function bodyOnLoad() {
    document.getElementById('hostnameInput').value = localStorage.getItem('camerahostname_' + ci) || '';
    if (localStorage.getItem('camerasecurity_' + ci) === 'true') {
        document.getElementById('secureCheckbox').checked = true;
    }
}

// =====================================================================
// Tab switching
// =====================================================================

function switchTab(name) {
    document.querySelectorAll('.tabPanel').forEach(p => p.classList.remove('active'));
    document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
    document.getElementById('tab-' + name).classList.add('active');
    document.getElementById('tab-btn-' + name).classList.add('active');
}

// =====================================================================
// Camera init
// =====================================================================

function initCamera() {
    const hostname = document.getElementById('hostnameInput').value;
    const security = document.getElementById('secureCheckbox').checked;
    const errorSpan = document.getElementById('connectionErrorSpan');

    try {
        const response = sendRequest('GET', (security ? 'https://' : 'http://') + hostname + '/control/api/v1/system', '');
        if (response.status < 300) {
            cameras[ci] = new BMCamera(hostname, security);
            localStorage.setItem('camerahostname_' + ci, hostname);
            localStorage.setItem('camerasecurity_' + ci, security);
            cameras[ci].updateUI = updateUIAll;
            cameras[ci].active = true;
            errorSpan.textContent = 'Connected.';
            errorSpan.style.color = 'var(--accent)';
        } else {
            errorSpan.textContent = response.statusText;
            errorSpan.style.color = 'var(--rec)';
        }
    } catch (error) {
        errorSpan.title = error;
        errorSpan.textContent = 'Error ' + error.code + ': ' + error.name;
        errorSpan.style.color = 'var(--rec)';
    }
    unsavedChanges = unsavedChanges.filter(e => e !== 'Hostname');
}

// =====================================================================
// Main UI updater (called by WebSocket)
// =====================================================================

function updateUIAll() {
    const cam = cameras[ci];
    if (!cam) return;

    // Camera name
    document.getElementById('cameraName').textContent = cam.name;

    // Hostname
    if (!unsavedChanges.includes('Hostname')) {
        document.getElementById('hostnameInput').value = cam.hostname;
    }

    // Format display
    const fmt = cam.propertyData['/system/format'];
    document.getElementById('formatCodec').textContent =
        fmt?.codec?.toUpperCase().replace(':', ' ').replace('_', ':') || '—';
    const res = fmt?.recordResolution;
    document.getElementById('formatResolution').textContent =
        res ? res.width + 'x' + res.height : '—';
    document.getElementById('formatFPS').textContent =
        fmt?.frameRate ? fmt.frameRate + ' fps' : '—';

    // Recording state
    const isRecording = cam.propertyData['/transports/0/record']?.recording;
    document.getElementById('topBar').classList.toggle('recording', !!isRecording);
    document.getElementById('recordButton').classList.toggle('recording', !!isRecording);
    document.getElementById('recLabel').textContent = isRecording ? 'RECORDING' : 'REC';

    // Loop / single clip buttons
    const loopState = cam.propertyData['/transports/0/playback']?.loop;
    const singleClipState = cam.propertyData['/transports/0/playback']?.singleClip;
    document.getElementById('loopButton').classList.toggle('activated', !!loopState);
    document.getElementById('singleClipButton').classList.toggle('activated', !!singleClipState);

    // Timecode
    document.getElementById('timecodeLabel').textContent =
        parseTimecode(cam.propertyData['/transports/0/timecode']?.timecode);

    // Presets dropdown
    if (!unsavedChanges.includes('presets')) {
        const dd = document.getElementById('presetsDropDown');
        dd.innerHTML = '';
        cam.propertyData['/presets']?.presets?.forEach(item => {
            const name = item.split('.', 1)[0];
            const opt = document.createElement('option');
            opt.textContent = name;
            dd.appendChild(opt);
        });
        dd.childNodes.forEach(child => {
            if (child.nodeName === 'OPTION') {
                child.selected = (child.value + '.cset') === cam.propertyData['/presets/active']?.preset;
            }
        });
    }

    // Lens
    document.getElementById('irisRange').value = cam.propertyData['/lens/iris']?.normalised ?? 0;
    document.getElementById('apertureStopsLabel').textContent =
        cam.propertyData['/lens/iris']?.apertureStop != null
            ? 'f/' + cam.propertyData['/lens/iris'].apertureStop.toFixed(1)
            : '—';
    document.getElementById('zoomRange').value = cam.propertyData['/lens/zoom']?.normalised ?? 0;
    document.getElementById('zoomMMLabel').textContent =
        cam.propertyData['/lens/zoom']?.focalLength != null
            ? cam.propertyData['/lens/zoom'].focalLength + 'mm'
            : '—mm';
    document.getElementById('focusRange').value = cam.propertyData['/lens/focus']?.normalised ?? 0;

    // ISO
    if (!unsavedChanges.includes('ISO')) {
        const iso = cam.propertyData['/video/iso']?.iso;
        if (iso != null) {
            document.getElementById('ISODisplay').textContent = iso;
            document.getElementById('ISOInput').value = iso;
        }
    }

    // Gain
    if (!unsavedChanges.includes('Gain')) {
        const gain = cam.propertyData['/video/gain']?.gain;
        if (gain != null) {
            document.getElementById('gainDisplay').textContent = (gain >= 0 ? '+' : '') + gain + 'dB';
        }
    }

    // White balance
    if (!unsavedChanges.includes('WB')) {
        const wb = cam.propertyData['/video/whiteBalance']?.whiteBalance;
        if (wb != null) document.getElementById('whiteBalanceDisplay').textContent = wb + 'K';
    }
    if (!unsavedChanges.includes('WBT')) {
        const wbt = cam.propertyData['/video/whiteBalanceTint']?.whiteBalanceTint;
        if (wbt != null) document.getElementById('whiteBalanceTintDisplay').textContent = wbt;
    }

    // ND filter
    if (!unsavedChanges.includes('ND')) {
        const nd = cam.propertyData['/video/ndFilter'];
        document.getElementById('ndFilterDisplay').textContent = nd ? nd.stop : '0';
    }

    // Shutter
    if (!unsavedChanges.includes('Shutter')) {
        const shutter = cam.propertyData['/video/shutter'];
        let shutterStr = '—';
        if (shutter?.shutterSpeed) {
            shutterStr = '1/' + shutter.shutterSpeed;
        } else if (shutter?.shutterAngle) {
            const angle = (shutter.shutterAngle / 100).toFixed(1);
            shutterStr = (angle.endsWith('.0') ? parseFloat(angle).toFixed(0) : angle) + '°';
        }
        document.getElementById('shutterDisplay').textContent = shutterStr;
    }

    // AE mode/type
    if (!unsavedChanges.includes('AutoExposure')) {
        document.getElementById('AEmodeDropDown').value = cam.propertyData['/video/autoExposure']?.mode || 'Off';
        document.getElementById('AEtypeDropDown').value = cam.propertyData['/video/autoExposure']?.type || '';
    }

    // Color correction — Lift
    if (!unsavedChanges.includes('CC0')) {
        const lift = cam.propertyData['/colorCorrection/lift'];
        if (lift) {
            document.getElementsByClassName('CClumaLabel')[0].textContent = lift.luma?.toFixed(2);
            document.getElementsByClassName('CCredLabel')[0].textContent = lift.red?.toFixed(2);
            document.getElementsByClassName('CCgreenLabel')[0].textContent = lift.green?.toFixed(2);
            document.getElementsByClassName('CCblueLabel')[0].textContent = lift.blue?.toFixed(2);
        }
    }

    // Gamma
    if (!unsavedChanges.includes('CC1')) {
        const gamma = cam.propertyData['/colorCorrection/gamma'];
        if (gamma) {
            document.getElementsByClassName('CClumaLabel')[1].textContent = gamma.luma?.toFixed(2);
            document.getElementsByClassName('CCredLabel')[1].textContent = gamma.red?.toFixed(2);
            document.getElementsByClassName('CCgreenLabel')[1].textContent = gamma.green?.toFixed(2);
            document.getElementsByClassName('CCblueLabel')[1].textContent = gamma.blue?.toFixed(2);
        }
    }

    // Gain CC
    if (!unsavedChanges.includes('CC2')) {
        const gainCC = cam.propertyData['/colorCorrection/gain'];
        if (gainCC) {
            document.getElementsByClassName('CClumaLabel')[2].textContent = gainCC.luma?.toFixed(2);
            document.getElementsByClassName('CCredLabel')[2].textContent = gainCC.red?.toFixed(2);
            document.getElementsByClassName('CCgreenLabel')[2].textContent = gainCC.green?.toFixed(2);
            document.getElementsByClassName('CCblueLabel')[2].textContent = gainCC.blue?.toFixed(2);
        }
    }

    // Offset
    if (!unsavedChanges.includes('CC3')) {
        const offset = cam.propertyData['/colorCorrection/offset'];
        if (offset) {
            document.getElementsByClassName('CClumaLabel')[3].textContent = offset.luma?.toFixed(2);
            document.getElementsByClassName('CCredLabel')[3].textContent = offset.red?.toFixed(2);
            document.getElementsByClassName('CCgreenLabel')[3].textContent = offset.green?.toFixed(2);
            document.getElementsByClassName('CCblueLabel')[3].textContent = offset.blue?.toFixed(2);
        }
    }

    // Contrast
    if (!unsavedChanges.includes('CC4')) {
        const contrast = cam.propertyData['/colorCorrection/contrast'];
        if (contrast) {
            document.getElementById('CCcontrastPivotRange').value = contrast.pivot;
            document.getElementById('CCcontrastPivotLabel').textContent = contrast.pivot?.toFixed(2);
            document.getElementById('CCcontrastAdjustRange').value = contrast.adjust;
            document.getElementById('CCcontrastAdjustLabel').textContent = parseInt(contrast.adjust * 50) + '%';
        }
    }

    // Color / hue / sat
    if (!unsavedChanges.includes('CC5')) {
        const color = cam.propertyData['/colorCorrection/color'];
        if (color) {
            document.getElementById('CChueRange').value = color.hue;
            document.getElementById('CCcolorHueLabel').textContent = parseInt((color.hue + 1) * 180) + '°';
            document.getElementById('CCsaturationRange').value = color.saturation;
            document.getElementById('CCcolorSatLabel').textContent = parseInt(color.saturation * 50) + '%';
        }
        const lc = cam.propertyData['/colorCorrection/lumaContribution'];
        if (lc) {
            document.getElementById('CClumaContributionRange').value = lc.lumaContribution;
            document.getElementById('CCcolorLCLabel').textContent = parseInt(lc.lumaContribution * 100) + '%';
        }
    }

    // Color science (new)
    updateColorScienceUI();

    // Footer links
    document.getElementById('documentationLink').href =
        (cam.useHTTPS ? 'https://' : 'http://') + cam.hostname + '/control/documentation.html';
    document.getElementById('mediaManagerLink').href =
        (cam.useHTTPS ? 'https://' : 'http://') + cam.hostname;
}

// =====================================================================
// Color Science (new)
// =====================================================================

function updateColorScienceUI() {
    const cam = cameras[ci];
    if (!cam) return;
    const cs = cam.propertyData['/video/colorScience'];
    if (!cs) return;

    const gamma = cs.gamma || '';
    const gamut = cs.gamut || '';

    document.getElementById('currentGamma').textContent = gamma || '—';
    document.getElementById('currentGamut').textContent = gamut || '—';

    // Highlight selected gamma button
    document.querySelectorAll('#gammaOptions .csOptionBtn').forEach(btn => {
        btn.classList.toggle('selected', btn.dataset.gamma === gamma);
    });

    // Highlight selected gamut button
    document.querySelectorAll('#gamutOptions .csOptionBtn').forEach(btn => {
        btn.classList.toggle('selected', btn.dataset.gamut === gamut);
    });
}

function setGamma(btn) {
    if (!cameras[ci]) return;
    const gamma = btn.dataset.gamma;
    cameras[ci].setColorScience(null, gamma);
    // Optimistic UI update
    if (!cameras[ci].propertyData['/video/colorScience']) {
        cameras[ci].propertyData['/video/colorScience'] = {};
    }
    cameras[ci].propertyData['/video/colorScience'].gamma = gamma;
    updateColorScienceUI();
}

function setGamut(btn) {
    if (!cameras[ci]) return;
    const gamut = btn.dataset.gamut;
    cameras[ci].setColorScience(gamut, null);
    if (!cameras[ci].propertyData['/video/colorScience']) {
        cameras[ci].propertyData['/video/colorScience'] = {};
    }
    cameras[ci].propertyData['/video/colorScience'].gamut = gamut;
    updateColorScienceUI();
}

const COLOR_SCIENCE_PRESETS = {
    'braw-film': { gamut: 'Blackmagic Wide Gamut', gamma: 'Blackmagic Design Film' },
    'braw-video': { gamut: 'Blackmagic Design', gamma: 'Blackmagic Design Video' },
    'braw-ext': { gamut: 'Blackmagic Design', gamma: 'Blackmagic Design Extended Video' },
    'rec709': { gamut: 'Rec.709', gamma: 'Rec709' },
};

function applyColorSciencePreset(presetKey) {
    if (!cameras[ci]) return;
    const preset = COLOR_SCIENCE_PRESETS[presetKey];
    if (!preset) return;
    cameras[ci].setColorScience(preset.gamut, preset.gamma);
    if (!cameras[ci].propertyData['/video/colorScience']) {
        cameras[ci].propertyData['/video/colorScience'] = {};
    }
    Object.assign(cameras[ci].propertyData['/video/colorScience'], preset);
    updateColorScienceUI();
}

// =====================================================================
// Video format (new)
// =====================================================================

function applyVideoFormat() {
    if (!cameras[ci]) return;
    const codec = document.getElementById('codecSelect').value;
    const frameRate = document.getElementById('frameRateSelect').value;
    const statusEl = document.getElementById('formatSetStatus');

    const data = {};
    if (codec) data.codec = codec;
    if (frameRate) data.frameRate = frameRate;

    if (!Object.keys(data).length) {
        statusEl.textContent = 'Select a codec or frame rate first.';
        return;
    }

    cameras[ci].PUTdata('/system/format', data);
    statusEl.textContent = 'Sent.';
    setTimeout(() => { statusEl.textContent = ''; }, 2000);
}

// =====================================================================
// Exposure controls
// =====================================================================

function adjustISO(delta) {
    if (!cameras[ci]) return;
    const current = cameras[ci].propertyData['/video/iso']?.iso ?? 800;
    cameras[ci].PUTdata('/video/iso', { iso: current + delta });
}

function ISOKeyHandler(event) {
    if (event.key === 'Enter') {
        event.preventDefault();
        const val = parseInt(document.getElementById('ISODisplay').textContent);
        if (!isNaN(val)) cameras[ci].PUTdata('/video/iso', { iso: val });
        unsavedChanges = unsavedChanges.filter(e => e !== 'ISO');
    } else {
        unsavedChanges.push('ISO');
    }
}

function ISOBlurHandler() {
    unsavedChanges = unsavedChanges.filter(e => e !== 'ISO');
}

function ISOInputHandler() {
    if (event.key === 'Enter') {
        event.preventDefault();
        cameras[ci].PUTdata('/video/iso', { iso: parseInt(document.getElementById('ISOInput').value) });
        unsavedChanges = unsavedChanges.filter(e => e !== 'ISO');
    } else {
        unsavedChanges.push('ISO');
    }
}

function decreaseND() {
    if (!cameras[ci]) return;
    cameras[ci].PUTdata('/video/ndFilter', { stop: (cameras[ci].propertyData['/video/ndFilter']?.stop ?? 0) - 2 });
}

function increaseND() {
    if (!cameras[ci]) return;
    cameras[ci].PUTdata('/video/ndFilter', { stop: (cameras[ci].propertyData['/video/ndFilter']?.stop ?? 0) + 2 });
}

function NDFilterInputHandler() {
    if (event.key === 'Enter') {
        event.preventDefault();
        cameras[ci].PUTdata('/video/ndFilter', { stop: parseInt(document.getElementById('ndFilterDisplay').textContent) });
        unsavedChanges = unsavedChanges.filter(e => e !== 'ND');
    } else {
        unsavedChanges.push('ND');
    }
}

function decreaseGain() {
    if (!cameras[ci]) return;
    cameras[ci].PUTdata('/video/gain', { gain: (cameras[ci].propertyData['/video/gain']?.gain ?? 0) - 2 });
}

function increaseGain() {
    if (!cameras[ci]) return;
    cameras[ci].PUTdata('/video/gain', { gain: (cameras[ci].propertyData['/video/gain']?.gain ?? 0) + 2 });
}

function GainInputHandler() {
    if (event.key === 'Enter') {
        event.preventDefault();
        cameras[ci].PUTdata('/video/gain', { gain: parseInt(document.getElementById('gainDisplay').textContent) });
        unsavedChanges = unsavedChanges.filter(e => e !== 'Gain');
    } else {
        unsavedChanges.push('Gain');
    }
}

function decreaseShutter() {
    if (!cameras[ci]) return;
    const cam = cameras[ci];
    if ('shutterSpeed' in (cam.propertyData['/video/shutter'] ?? {})) {
        cam.PUTdata('/video/shutter', { shutterSpeed: cam.propertyData['/video/shutter'].shutterSpeed + 10 });
    } else {
        cam.PUTdata('/video/shutter', { shutterAngle: cam.propertyData['/video/shutter'].shutterAngle - 1000 });
    }
}

function increaseShutter() {
    if (!cameras[ci]) return;
    const cam = cameras[ci];
    if ('shutterSpeed' in (cam.propertyData['/video/shutter'] ?? {})) {
        cam.PUTdata('/video/shutter', { shutterSpeed: cam.propertyData['/video/shutter'].shutterSpeed - 10 });
    } else {
        cam.PUTdata('/video/shutter', { shutterAngle: cam.propertyData['/video/shutter'].shutterAngle + 1000 });
    }
}

function handleShutterInput() {
    const input = document.getElementById('shutterDisplay').textContent;
    if (event.key === 'Enter') {
        event.preventDefault();
        const cam = cameras[ci];
        if ('shutterSpeed' in (cam.propertyData['/video/shutter'] ?? {})) {
            const val = input.includes('1/') ? parseInt(input.substring(2)) : parseInt(input);
            cam.PUTdata('/video/shutter', { shutterSpeed: val });
        } else {
            cam.PUTdata('/video/shutter', { shutterAngle: parseInt(parseFloat(input) * 100) });
        }
        unsavedChanges = unsavedChanges.filter(e => e !== 'Shutter');
    } else {
        unsavedChanges.push('Shutter');
    }
}

function swapWBMode() {
    if (WBMode === 0) {
        document.getElementById('WBLabel').textContent = 'TINT ↕';
        document.getElementById('WBValueContainer').classList.add('hidden');
        document.getElementById('WBTintValueContainer').classList.remove('hidden');
        WBMode = 1;
    } else {
        document.getElementById('WBLabel').textContent = 'WB ↕';
        document.getElementById('WBValueContainer').classList.remove('hidden');
        document.getElementById('WBTintValueContainer').classList.add('hidden');
        WBMode = 0;
    }
}

function decreaseWhiteBalance() {
    if (!cameras[ci]) return;
    cameras[ci].PUTdata('/video/whiteBalance', { whiteBalance: (cameras[ci].propertyData['/video/whiteBalance']?.whiteBalance ?? 5600) - 50 });
}

function increaseWhiteBalance() {
    if (!cameras[ci]) return;
    cameras[ci].PUTdata('/video/whiteBalance', { whiteBalance: (cameras[ci].propertyData['/video/whiteBalance']?.whiteBalance ?? 5600) + 50 });
}

function WBInputHandler() {
    if (event.key === 'Enter') {
        event.preventDefault();
        cameras[ci].PUTdata('/video/whiteBalance', { whiteBalance: parseInt(document.getElementById('whiteBalanceDisplay').textContent) });
        unsavedChanges = unsavedChanges.filter(e => e !== 'WB');
    } else {
        unsavedChanges.push('WB');
    }
}

function decreaseWhiteBalanceTint() {
    if (!cameras[ci]) return;
    cameras[ci].PUTdata('/video/whiteBalanceTint', { whiteBalanceTint: (cameras[ci].propertyData['/video/whiteBalanceTint']?.whiteBalanceTint ?? 0) - 1 });
}

function increaseWhiteBalanceTint() {
    if (!cameras[ci]) return;
    cameras[ci].PUTdata('/video/whiteBalanceTint', { whiteBalanceTint: (cameras[ci].propertyData['/video/whiteBalanceTint']?.whiteBalanceTint ?? 0) + 1 });
}

function WBTInputHandler() {
    if (event.key === 'Enter') {
        event.preventDefault();
        cameras[ci].PUTdata('/video/whiteBalanceTint', { whiteBalanceTint: parseInt(document.getElementById('whiteBalanceTintDisplay').textContent) });
        unsavedChanges = unsavedChanges.filter(e => e !== 'WBT');
    } else {
        unsavedChanges.push('WBT');
    }
}

// =====================================================================
// Color correction
// =====================================================================

function CCInputHandler(which) {
    if (event.key === 'Enter') {
        event.preventDefault();
        setCCFromUI(which);
    } else {
        unsavedChanges.push('CC' + which);
    }
}

function setCCFromUI(which) {
    if (which < 4) {
        const luma  = parseFloat(document.getElementsByClassName('CClumaLabel')[which].textContent);
        const red   = parseFloat(document.getElementsByClassName('CCredLabel')[which].textContent);
        const green = parseFloat(document.getElementsByClassName('CCgreenLabel')[which].textContent);
        const blue  = parseFloat(document.getElementsByClassName('CCblueLabel')[which].textContent);
        const obj   = { red, green, blue, luma };

        const endpoints = ['/colorCorrection/lift', '/colorCorrection/gamma', '/colorCorrection/gain', '/colorCorrection/offset'];
        cameras[ci].PUTdata(endpoints[which], obj);
    } else if (which === 4) {
        const pivot  = parseFloat(document.getElementById('CCcontrastPivotLabel').textContent);
        const adjust = parseInt(document.getElementById('CCcontrastAdjustLabel').textContent) / 50.0;
        cameras[ci].PUTdata('/colorCorrection/contrast', { pivot, adjust });
    } else {
        const hue    = (parseInt(document.getElementById('CCcolorHueLabel').textContent) / 180.0) - 1.0;
        const sat    = parseInt(document.getElementById('CCcolorSatLabel').textContent) / 50.0;
        const lc     = parseInt(document.getElementById('CCcolorLCLabel').textContent) / 100.0;
        cameras[ci].PUTdata('/colorCorrection/color', { hue, saturation: sat });
        cameras[ci].PUTdata('/colorCorrection/lumaContribution', { lumaContribution: lc });
    }
    unsavedChanges = unsavedChanges.filter(e => !e.includes('CC' + which));
}

function resetCC(which) {
    const resets = [
        ['/colorCorrection/lift',    { red: 0.0, green: 0.0, blue: 0.0, luma: 0.0 }],
        ['/colorCorrection/gamma',   { red: 0.0, green: 0.0, blue: 0.0, luma: 0.0 }],
        ['/colorCorrection/gain',    { red: 1.0, green: 1.0, blue: 1.0, luma: 1.0 }],
        ['/colorCorrection/offset',  { red: 0.0, green: 0.0, blue: 0.0, luma: 0.0 }],
        ['/colorCorrection/contrast', { pivot: 0.5, adjust: 1.0 }],
    ];
    if (which < 5) {
        cameras[ci].PUTdata(resets[which][0], resets[which][1]);
    } else {
        cameras[ci].PUTdata('/colorCorrection/color', { hue: 0.0, saturation: 1.0 });
        cameras[ci].PUTdata('/colorCorrection/lumaContribution', { lumaContribution: 1.0 });
    }
    unsavedChanges = unsavedChanges.filter(e => !e.includes('CC' + which));
}

// =====================================================================
// Presets / AE / Manual API
// =====================================================================

function presetInputHandler() {
    cameras[ci].PUTdata('/presets/active', { preset: document.getElementById('presetsDropDown').value + '.cset' });
    unsavedChanges = unsavedChanges.filter(e => e !== 'presets');
}

function AEmodeInputHandler() {
    cameras[ci].PUTdata('/video/autoExposure', {
        mode: document.getElementById('AEmodeDropDown').value,
        type: document.getElementById('AEtypeDropDown').value,
    });
    unsavedChanges = unsavedChanges.filter(e => e !== 'AutoExposure');
}

function manualAPICall() {
    const isGET = document.getElementById('requestTypeGET').checked;
    const endpoint = document.getElementById('manualRequestEndpointLabel').value;
    let body = '';
    try { body = JSON.parse(document.getElementById('manualRequestBodyLabel').value); } catch (_) {}
    const response = sendRequest(isGET ? 'GET' : 'PUT', cameras[ci].APIAddress + endpoint, body);
    document.getElementById('manualRequestResponseP').textContent = JSON.stringify(response, null, 2);
}

function hostnameInputHandler() {
    if (event.key === 'Enter') {
        event.preventDefault();
        unsavedChanges = unsavedChanges.filter(e => e !== 'Hostname');
        initCamera();
    } else {
        unsavedChanges.push('Hostname');
    }
}

function loopHandler(callerString) {
    const playbackState = cameras[ci].propertyData['/transports/0/playback'];
    if (callerString === 'Loop') {
        playbackState.loop = !playbackState.loop;
    } else if (callerString === 'Single Clip') {
        playbackState.singleClip = !playbackState.singleClip;
    }
    cameras[ci].PUTdata('/transports/0/playback', playbackState);
}

// =====================================================================
// Helpers
// =====================================================================

function parseTimecode(timecodeBCD) {
    if (timecodeBCD == null) return '--:--:--:--';
    const noDF = timecodeBCD & 0x7fffffff;
    const str = parseInt(noDF.toString(16), 10).toString().padStart(8, '0');
    return str.match(/.{1,2}/g).join(':');
}
