Documentation/Source example
Example source

samples/worker/02. Playlist Statistics.js

Referenced by:Worker
'use strict';

// Worker sample 2: Playlist Statistics
// Demonstrates: cloning FbMetadbHandleList, bulk metadata/file-info aggregation in Main/Worker,
// and presenting the result in an interactive panel UI.
// Media Library is exposed alongside playlists so larger real-world batches are easy to test.
// Why a Worker: one metadata batch crosses the realm boundary instead of keeping the panel
// thread busy while every handle and FbFileInfo object is inspected. Engine switches between
// Main and Worker for a direct A/B comparison of the same analysis.

window.DrawMode = 0;
window.DlgCode = 0x0004;

const DT_LEFT = 0x0000;
const DT_CENTER = 0x0001;
const DT_RIGHT = 0x0002;
const DT_VCENTER = 0x0004;
const DT_SINGLELINE = 0x0020;
const DT_NOPREFIX = 0x0800;
const DT_END_ELLIPSIS = 0x8000;

const VK_PRIOR = 0x21;
const VK_NEXT = 0x22;
const VK_END = 0x23;
const VK_HOME = 0x24;
const VK_UP = 0x26;
const VK_DOWN = 0x28;

const FONT = gdi.Font('Segoe UI', 13);
const FONT_BOLD = gdi.Font('Segoe UI', 13, 1);
const FONT_TITLE = gdi.Font('Segoe UI', 18, 1);
const ROW_HEIGHT = 30;
const PADDING = 14;

const COLOUR_BG = 0xFF101318;
const COLOUR_SIDEBAR = 0xFF171B22;
const COLOUR_SELECTED = 0xFF2B3440;
const COLOUR_HOVER = 0xFF222A34;
const COLOUR_TEXT = 0xFFE8EDF2;
const COLOUR_DIM = 0xFF9AA6B2;
const COLOUR_ACCENT = 0xFF4FA3F7;
const COLOUR_BAR = 0xFF25394C;
const COLOUR_DIVIDER = 0xFF2A313A;

let engine = 'worker';
let sources = [];
let selectedSource = -1;
let hoverSource = -1;
let scrollOffset = 0;
let requestId = 0;
let pendingRequestId = 0;
let pendingStarted = 0;
let stats = null;
let loading = false;

function cleanValue(value) {
    const text = String(value == null ? '' : value).trim();
    return text || '(unknown)';
}

function normaliseFieldName(name) {
    return String(name || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
}

function addCount(counts, value) {
    const key = cleanValue(value);
    counts.set(key, (counts.get(key) || 0) + 1);
}

function addNamedCount(counts, key, name) {
    const old = counts.get(key);
    if (old) {
        old.count++;
    } else {
        counts.set(key, { name, count: 1 });
    }
}

function topEntries(counts, limit) {
    return Array.from(counts.entries())
        .map(entry => ({ name: entry[0], count: entry[1] }))
        .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name))
        .slice(0, limit);
}

function topNamedEntries(counts, limit) {
    return Array.from(counts.values())
        .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name))
        .slice(0, limit);
}

function knownCount(counts) {
    return counts.size - (counts.has('(unknown)') ? 1 : 0);
}

function extractYear(value) {
    const match = String(value || '').match(/\d{4}/);
    return match ? match[0] : '(unknown)';
}

function normaliseSampleRate(value) {
    const text = cleanValue(value);
    if (text === '(unknown)') return text;

    let rate = parseFloat(text.replace(/[^0-9.]/g, ''));
    if (!Number.isFinite(rate) || rate <= 0) return text;
    if (/khz/i.test(text)) rate *= 1000;

    if (rate >= 1000) {
        const khz = rate / 1000;
        return `${Number.isInteger(khz) ? khz.toFixed(0) : khz.toFixed(1)} kHz`;
    }
    return `${Math.round(rate)} Hz`;
}

function analyseHandles(handles) {
    const artistCounts = new Map();
    const albumCounts = new Map();
    const genreCounts = new Map();
    const yearCounts = new Map();
    const codecCounts = new Map();
    const sampleRateCounts = new Map();

    let totalSeconds = 0;
    let totalBytes = 0;
    let bitrateTotal = 0;
    let bitrateCount = 0;

    const items = handles.Convert();

    for (const handle of items) {
        const length = Number(handle.Length);
        if (Number.isFinite(length) && length > 0) totalSeconds += length;

        const fileSize = Number(handle.FileSize);
        if (Number.isFinite(fileSize) && fileSize > 0) totalBytes += fileSize;

        const trackArtists = [];
        const albumArtists = [];
        const genres = [];
        let album = '';
        let date = '';
        let codec = '';
        let sampleRate = '';
        let bitrate = NaN;

        const info = handle.GetFileInfo();
        if (info) {
            for (let i = 0; i < info.MetaCount; ++i) {
                const name = normaliseFieldName(info.MetaName(i));
                const valueCount = info.MetaValueCount(i);

                for (let j = 0; j < valueCount; ++j) {
                    const value = cleanValue(info.MetaValue(i, j));
                    if (value === '(unknown)') continue;

                    if (name === 'ARTIST') trackArtists.push(value);
                    else if (name === 'ALBUMARTIST') albumArtists.push(value);
                    else if (name === 'ALBUM' && !album) album = value;
                    else if (name === 'GENRE') genres.push(value);
                    else if ((name === 'DATE' || name === 'YEAR') && !date) date = value;
                }
            }

            for (let i = 0; i < info.InfoCount; ++i) {
                const name = normaliseFieldName(info.InfoName(i));
                const value = cleanValue(info.InfoValue(i));

                if (name === 'CODEC' && !codec) codec = value;
                else if (name === 'SAMPLERATE' && !sampleRate) sampleRate = value;
                else if (name === 'BITRATE' && !Number.isFinite(bitrate)) {
                    const parsed = parseFloat(value);
                    if (Number.isFinite(parsed) && parsed > 0) bitrate = parsed;
                }
            }
        }

        if (trackArtists.length) {
            for (const artist of new Set(trackArtists)) addCount(artistCounts, artist);
        } else {
            addCount(artistCounts, '(unknown)');
        }

        const albumOwner = albumArtists[0] || trackArtists[0] || '(unknown)';
        if (album) {
            const albumKey = `${albumOwner}\u0000${album}`;
            const albumLabel = albumOwner === '(unknown)' ? album : `${album} — ${albumOwner}`;
            addNamedCount(albumCounts, albumKey, albumLabel);
        } else {
            addNamedCount(albumCounts, '(unknown)', '(unknown)');
        }

        if (genres.length) {
            for (const genre of new Set(genres)) addCount(genreCounts, genre);
        } else {
            addCount(genreCounts, '(unknown)');
        }

        addCount(yearCounts, extractYear(date));
        addCount(codecCounts, codec || '(unknown)');
        addCount(sampleRateCounts, normaliseSampleRate(sampleRate));

        if (Number.isFinite(bitrate)) {
            bitrateTotal += bitrate;
            bitrateCount++;
        }
    }

    let averageBitrate = bitrateCount ? bitrateTotal / bitrateCount : 0;
    if (!averageBitrate && totalSeconds > 0 && totalBytes > 0) {
        averageBitrate = totalBytes * 8 / totalSeconds / 1000;
    }

    return {
        tracks: handles.Count,
        totalSeconds,
        totalBytes,
        uniqueArtists: knownCount(artistCounts),
        uniqueAlbums: albumCounts.size - (albumCounts.has('(unknown)') ? 1 : 0),
        uniqueGenres: knownCount(genreCounts),
        averageBitrate,
        topArtists: topEntries(artistCounts, 8),
        topAlbums: topNamedEntries(albumCounts, 8),
        topGenres: topEntries(genreCounts, 8),
        topYears: topEntries(yearCounts, 8),
        codecs: topEntries(codecCounts, 8),
        sampleRates: topEntries(sampleRateCounts, 8)
    };
}

const workerSource = `'use strict';\n\n${cleanValue.toString()}\n\n${normaliseFieldName.toString()}\n\n${addCount.toString()}\n\n${addNamedCount.toString()}\n\n${topEntries.toString()}\n\n${topNamedEntries.toString()}\n\n${knownCount.toString()}\n\n${extractYear.toString()}\n\n${normaliseSampleRate.toString()}\n\n${analyseHandles.toString()}\n\nonmessage = function (event) {\n    const started = performance.now();\n    const result = analyseHandles(event.data.handles);\n    result.requestId = event.data.requestId;\n    result.calcElapsed = performance.now() - started;\n    postMessage(result);\n};`;

const worker = new Worker(workerSource, 'playlist-statistics');

worker.onmessage = function (event) {
    if (event.data.requestId !== pendingRequestId || engine !== 'worker') return;
    stats = event.data;
    stats.totalElapsed = performance.now() - pendingStarted;
    loading = false;
    window.Repaint();
};

worker.onerror = function (event) {
    loading = false;
    console.log(`Worker error: ${event.message}`);
    window.Repaint();
};

function sidebarWidth() {
    return Math.max(180, Math.min(320, Math.round(window.Width * 0.32)));
}

function visibleRows() {
    return Math.max(1, Math.floor(window.Height / ROW_HEIGHT));
}

function clampScroll() {
    const maxOffset = Math.max(0, sources.length - visibleRows());
    scrollOffset = Math.max(0, Math.min(maxOffset, scrollOffset));
}

function ensureSelectedVisible() {
    if (selectedSource < scrollOffset) {
        scrollOffset = selectedSource;
    } else {
        const rows = visibleRows();
        if (selectedSource >= scrollOffset + rows) {
            scrollOffset = selectedSource - rows + 1;
        }
    }
    clampScroll();
}

function selectSource(index) {
    if (!sources.length) return;
    index = Math.max(0, Math.min(sources.length - 1, index));
    if (index === selectedSource) return;

    selectedSource = index;
    ensureSelectedVisible();
    analyseSelectedSource();
}

function sourceKey(source) {
    return source && source.library ? 'library' : source ? 'playlist:' + source.name : '';
}

function refreshSources() {
    const previousKey = selectedSource >= 0 && selectedSource < sources.length
        ? sourceKey(sources[selectedSource])
        : '';

    sources = [{ library: true, index: -1, name: 'Media Library' }];
    for (let i = 0; i < plman.PlaylistCount; ++i) {
        sources.push({ library: false, index: i, name: plman.GetPlaylistName(i) });
    }

    let next = -1;
    if (previousKey) next = sources.findIndex(item => sourceKey(item) === previousKey);
    if (next < 0 && plman.ActivePlaylist >= 0 && plman.ActivePlaylist < plman.PlaylistCount) {
        next = plman.ActivePlaylist + 1;
    }
    if (next < 0) next = 0;

    selectedSource = next;
    clampScroll();
    analyseSelectedSource();
}

function getSelectedHandles() {
    if (selectedSource < 0 || selectedSource >= sources.length) return null;
    const source = sources[selectedSource];
    return source.library ? fb.GetLibraryItems() : plman.GetPlaylistItems(source.index);
}

function analyseSelectedSource() {
    const handles = getSelectedHandles();
    if (!handles) return;

    const id = ++requestId;
    pendingRequestId = id;
    pendingStarted = performance.now();
    stats = null;
    loading = true;
    window.Repaint();

    if (engine === 'worker') {
        // FbMetadbHandleList is CLONEABLE. The panel keeps its own usable list.
        worker.postMessage({ requestId: id, handles });
        return;
    }

    // Defer one task so the panel can present the loading state before the synchronous baseline.
    const started = pendingStarted;
    setTimeout(() => {
        if (id !== pendingRequestId || engine !== 'main') return;
        const calcStarted = performance.now();
        const result = analyseHandles(handles);
        result.requestId = id;
        result.calcElapsed = performance.now() - calcStarted;
        result.totalElapsed = performance.now() - started;
        stats = result;
        loading = false;
        window.Repaint();
    }, 0);
}

function formatDuration(totalSeconds) {
    let seconds = Math.max(0, Math.round(totalSeconds || 0));
    const hours = Math.floor(seconds / 3600);
    seconds -= hours * 3600;
    const minutes = Math.floor(seconds / 60);
    seconds -= minutes * 60;
    return hours > 0
        ? `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
        : `${minutes}:${String(seconds).padStart(2, '0')}`;
}

function formatBytes(bytes) {
    let value = Math.max(0, Number(bytes) || 0);
    const units = ['B', 'KB', 'MB', 'GB', 'TB'];
    let unit = 0;
    while (value >= 1024 && unit < units.length - 1) {
        value /= 1024;
        unit++;
    }
    const digits = value >= 100 || unit === 0 ? 0 : value >= 10 ? 1 : 2;
    return `${value.toFixed(digits)} ${units[unit]}`;
}

function formatCount(value) {
    return Math.max(0, Number(value) || 0).toLocaleString();
}

function engineRects() {
    const y = 10;
    const x = Math.max(sidebarWidth() + PADDING, window.Width - 174);
    return {
        label: { x, y, w: 46, h: 26 },
        main: { x: x + 48, y, w: 48, h: 26 },
        worker: { x: x + 100, y, w: 60, h: 26 }
    };
}

function contains(rect, x, y) {
    return x >= rect.x && x < rect.x + rect.w && y >= rect.y && y < rect.y + rect.h;
}

function drawChoice(gr, rect, text, selected) {
    gr.FillSolidRect(rect.x, rect.y, rect.w, rect.h, selected ? 0xFF355D7A : 0xFF20262E);
    gr.GdiDrawText(text, FONT, selected ? 0xFFFFFFFF : 0xFFB7C1CB,
        rect.x, rect.y, rect.w, rect.h, DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX);
}

function drawEntryList(gr, title, entries, x, y, w, maxRows) {
    gr.GdiDrawText(title, FONT_BOLD, COLOUR_TEXT, x, y, w, 24, DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX);
    y += 26;

    const shown = entries.slice(0, maxRows);
    const maxCount = shown.length ? Math.max(...shown.map(entry => entry.count)) : 1;

    for (const entry of shown) {
        const barWidth = Math.max(2, (w - 54) * entry.count / maxCount);
        gr.FillSolidRect(x, y + 4, barWidth, 18, COLOUR_BAR);
        gr.GdiDrawText(entry.name, FONT, COLOUR_TEXT, x + 6, y, Math.max(1, w - 58), 26,
            DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS | DT_NOPREFIX);
        gr.GdiDrawText(String(entry.count), FONT, COLOUR_DIM, x + w - 48, y, 48, 26,
            DT_RIGHT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX);
        y += 27;
    }
}

function on_paint(gr) {
    const sw = sidebarWidth();
    gr.FillSolidRect(0, 0, window.Width, window.Height, COLOUR_BG);
    gr.FillSolidRect(0, 0, sw, window.Height, COLOUR_SIDEBAR);
    gr.FillSolidRect(sw - 1, 0, 1, window.Height, COLOUR_DIVIDER);

    for (let row = 0; row < visibleRows(); ++row) {
        const listIndex = scrollOffset + row;
        if (listIndex >= sources.length) break;
        const y = row * ROW_HEIGHT;

        if (listIndex === selectedSource) {
            gr.FillSolidRect(0, y, sw - 1, ROW_HEIGHT, COLOUR_SELECTED);
            gr.FillSolidRect(0, y, 3, ROW_HEIGHT, COLOUR_ACCENT);
        } else if (listIndex === hoverSource) {
            gr.FillSolidRect(0, y, sw - 1, ROW_HEIGHT, COLOUR_HOVER);
        }

        gr.GdiDrawText(sources[listIndex].name, sources[listIndex].library ? FONT_BOLD : FONT, COLOUR_TEXT,
            10, y, sw - 20, ROW_HEIGHT,
            DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS | DT_NOPREFIX);

        if (listIndex === 0 && scrollOffset === 0) {
            gr.FillSolidRect(10, ROW_HEIGHT - 1, Math.max(1, sw - 20), 1, COLOUR_DIVIDER);
        }
    }

    const x = sw + PADDING;
    const w = Math.max(1, window.Width - x - PADDING);
    let y = PADDING;

    if (selectedSource < 0 || selectedSource >= sources.length) return;

    const engineUi = engineRects();
    const titleRight = Math.max(x + 1, engineUi.label.x - 10);
    gr.GdiDrawText('Statistics', FONT_TITLE, COLOUR_TEXT, x, y,
        Math.max(1, titleRight - x), 34,
        DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS | DT_NOPREFIX);

    gr.GdiDrawText('Engine:', FONT, COLOUR_DIM,
        engineUi.label.x, engineUi.label.y, engineUi.label.w, engineUi.label.h,
        DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX);
    drawChoice(gr, engineUi.main, 'Main', engine === 'main');
    drawChoice(gr, engineUi.worker, 'Worker', engine === 'worker');
    y += 42;

    if (loading) {
        gr.GdiDrawText(`Calculating in ${engine === 'worker' ? 'Worker' : 'Main'}…`, FONT, COLOUR_DIM, x, y, w, 28,
            DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX);
        return;
    }

    if (!stats) return;

    gr.GdiDrawText(`${engine === 'worker' ? 'Worker' : 'Main'}  •  calc ${stats.calcElapsed.toFixed(1)} ms  •  total ${stats.totalElapsed.toFixed(1)} ms`,
        FONT, COLOUR_DIM, x, y, w, 24,
        DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX);
    y += 28;

    gr.GdiDrawText(`Tracks  ${formatCount(stats.tracks)}`, FONT_BOLD, COLOUR_TEXT, x, y, w / 2, 26,
        DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX);
    gr.GdiDrawText(`Duration  ${formatDuration(stats.totalSeconds)}`, FONT_BOLD, COLOUR_TEXT, x + w / 2, y, w / 2, 26,
        DT_RIGHT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX);
    y += 26;

    gr.GdiDrawText(`Size  ${formatBytes(stats.totalBytes)}`, FONT_BOLD, COLOUR_TEXT, x, y, w / 2, 26,
        DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX);
    gr.GdiDrawText(`Avg bitrate  ${Math.round(stats.averageBitrate || 0)} kbps`, FONT_BOLD, COLOUR_TEXT, x + w / 2, y, w / 2, 26,
        DT_RIGHT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX);
    y += 30;

    gr.GdiDrawText(`Artists  ${formatCount(stats.uniqueArtists)}  •  Albums  ${formatCount(stats.uniqueAlbums)}  •  Genres  ${formatCount(stats.uniqueGenres)}`,
        FONT, COLOUR_DIM, x, y, w, 24,
        DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS | DT_NOPREFIX);
    y += 34;

    const sections = [
        { title: 'Top artists', entries: stats.topArtists },
        { title: 'Top albums', entries: stats.topAlbums },
        { title: 'Genres', entries: stats.topGenres },
        { title: 'Years', entries: stats.topYears },
        { title: 'Codecs', entries: stats.codecs },
        { title: 'Sample rates', entries: stats.sampleRates }
    ];

    const columns = w >= 560 ? 2 : 1;
    const sectionRows = Math.ceil(sections.length / columns);
    const gapX = 18;
    const gapY = 10;
    const columnWidth = columns === 2 ? (w - gapX) / 2 : w;
    const available = Math.max(60, window.Height - y - PADDING);
    const rowsPerSection = Math.max(1, Math.min(5,
        Math.floor((available / sectionRows - 26 - gapY) / 27)));
    const sectionHeight = 26 + rowsPerSection * 27 + gapY;

    for (let i = 0; i < sections.length; ++i) {
        const column = i % columns;
        const row = Math.floor(i / columns);
        drawEntryList(gr, sections[i].title, sections[i].entries,
            x + column * (columnWidth + gapX), y + row * sectionHeight,
            columnWidth, rowsPerSection);
    }
}

function on_mouse_move(x, y) {
    const sw = sidebarWidth();
    const oldHover = hoverSource;
    hoverSource = -1;

    if (x >= 0 && x < sw && y >= 0 && y < window.Height) {
        const row = Math.floor(y / ROW_HEIGHT);
        const index = scrollOffset + row;
        if (index >= 0 && index < sources.length) hoverSource = index;
    }

    if (hoverSource !== oldHover) window.Repaint();
}

function on_mouse_leave() {
    if (hoverSource !== -1) {
        hoverSource = -1;
        window.Repaint();
    }
}

function on_mouse_lbtn_up(x, y) {
    const engineUi = engineRects();
    let nextEngine = engine;
    if (contains(engineUi.main, x, y)) nextEngine = 'main';
    else if (contains(engineUi.worker, x, y)) nextEngine = 'worker';

    if (nextEngine !== engine) {
        engine = nextEngine;
        analyseSelectedSource();
        return;
    }

    const sw = sidebarWidth();
    if (x < 0 || x >= sw || y < 0 || y >= window.Height) return;

    const index = scrollOffset + Math.floor(y / ROW_HEIGHT);
    if (index < 0 || index >= sources.length) return;

    selectSource(index);
}

function on_key_down(vkey) {
    if (!sources.length) return;

    let next = selectedSource < 0 ? 0 : selectedSource;
    const page = Math.max(1, visibleRows() - 1);

    switch (vkey) {
        case VK_UP:
            next--;
            break;
        case VK_DOWN:
            next++;
            break;
        case VK_HOME:
            next = 0;
            break;
        case VK_END:
            next = sources.length - 1;
            break;
        case VK_PRIOR:
            next -= page;
            break;
        case VK_NEXT:
            next += page;
            break;
        default:
            return;
    }

    selectSource(next);
}

function on_mouse_wheel(step) {
    if (!sources.length) return;
    const oldOffset = scrollOffset;
    scrollOffset -= step * 3;
    clampScroll();
    if (scrollOffset !== oldOffset) window.Repaint();
}

function on_size() {
    clampScroll();
    window.Repaint();
}

function on_playlists_changed() {
    refreshSources();
}

function on_playlist_items_added(playlistIndex) {
    const source = sources[selectedSource];
    if (source && !source.library && source.index === playlistIndex) analyseSelectedSource();
}

function on_playlist_items_removed(playlistIndex) {
    const source = sources[selectedSource];
    if (source && !source.library && source.index === playlistIndex) analyseSelectedSource();
}

function on_playlist_items_reordered(playlistIndex) {
    const source = sources[selectedSource];
    if (source && !source.library && source.index === playlistIndex) analyseSelectedSource();
}

function on_library_items_added() {
    const source = sources[selectedSource];
    if (source && source.library) analyseSelectedSource();
}

function on_library_items_removed() {
    const source = sources[selectedSource];
    if (source && source.library) analyseSelectedSource();
}

function on_library_items_changed() {
    const source = sources[selectedSource];
    if (source && source.library) analyseSelectedSource();
}

refreshSources();