Wix Audio Player Displays Incorrect Duration for Externally Hosted Files

We have a Wix Studio site where audio files are hosted on Cloudflare R2. The native Wix AudioPlayer displays an incorrect static duration (“01:04”) for all files before playback starts, which looks broken to visitors.

To fix this we built a custom HTML5 audio player as a Web Component hosted on GitHub Pages and registered it in Wix Studio as a Custom Element. The component itself works fine in isolation.

The problem is getting dynamic data into it reliably when it’s inside a repeater. We’re calling setAttribute from onItemReady via Velo, but the attributes only apply correctly to some items – others render with no data at all. The behavior seems related to the Custom Element lifecycle not playing nicely with how Wix initializes repeater items.

Two questions:

  1. Has anyone successfully passed dynamic per-item data to a Custom Element inside a repeater? If so, what approach worked?

  2. Is there any way to fix or work around the native AudioPlayer’s incorrect duration display when audio is hosted externally?

Thanks in advance.

Hi, @Matt_Woodward !!

It’s likely a timing issue where you’re passing data before the Custom Element is fully ready. How about adding a slight delay to setAttribute to see if that fixes it? :innocent:

Thanks for the suggestion!

I’ve tried timeouts, dispatching events, and a bunch of other approaches, including with Claude’s help, but unfortunately no combination of things I’ve tried works.

The best I can get it working is “hit or miss,” so if for example I have a repeater using data from the CMS, and for each item in the repeater I have an audio file, the first couple of instances of the custom audio player might work, then the next several won’t, then one further down the page will work again.

Honestly I’d be fine using the native Wix audio player if the duration wasn’t incorrect. I did figure out how to hide the time display altogether (meaning the current time and the duration both), but that’s less than ideal.

For audio files hosted on Wix directly, the native audio player works correctly. But we have several hundred GB of audio files (the site is for a community radio station), so unless we bust the budget on a plan with unlimited storage, that won’t really work. Plus since we already have all the files on Cloudflare storage/CDN, migrating the site from WordPress to Wix and moving all our audio files would be a nightmare.

I appreciate the suggestion. I may keep messing with it a bit more and would be interested in any other ideas anyone may have.

I assume it’s not that custom elements aren’t fully supported in repeaters, because if they are, it seems weird this doesn’t “just work.”

Thanks again!

I see,:thinking: that didn’t work either…:melting_face: Well, looking at your question, I remembered I faced a similar struggle before. I was trying to integrate Wavesurfer.js into a Wix repeater as a Custom Element. :kissing_face:

Looking back at my old code, it’s honestly a bit of a headache to read now, but it’s clear I was stuck in the same swamp as you are. :melting_face: I eventually got it working with this polling approach in connectedCallback. It might be exactly what you need to bridge the gap between Wix and your Custom Element. :innocent:

connectedCallback() {

        // Poll every 50ms to check if the attribute has been set
        let intervalId = setInterval(() => {
            this.soundUrl = this.getAttribute("soundUrl");
            
            // If soundUrl exists, convert it, update the player, and clear the interval
            this.soundUrl && (
                this.soundUrlExternal = this.convertWixAudioUrlToExternal(this.soundUrl), 
                this.update(), 
                clearInterval(intervalId)
            );
        }, 50);

        // Safety net: Clear the interval after 3 seconds to prevent infinite polling
        setTimeout(() => clearInterval(intervalId), 3000);

    }

Thanks for the additional info! That was another approach I tried but in my case it didn’t work, or I may just have been doing things slightly differently.

After a ton of trial and error I think I have a reliable approach. Or at least it’s working so far. :slight_smile:

My take on the core issue based on the behavior I was seeing is that Wix seems to pre-create all custom element instances in a repeater before the data is available, and routes setAttribute calls through a worker proxy. By the time Velo’s onItemReady runs and tries to pass data to the elements, most instances aren’t connected (i.e., connectedCallback hasn’t been called), so the attributes land on disconnected proxy instances and never reach the rendered DOM elements. In my situation, no amount of setTimeout tricks in onItemReady reliably fixed this.

One thing I’ll mention before getting into the guts of what’s working for me, is in the course of troubleshooting I wound up using a single JSON attribute instead of multiple attributes on the custom audio player. I don’t know that this has any real impact on the working solution, but I decided to leave it alone for now.

So instead of setting src, track-name, artist-name, and cover-image separately, I packed everything into one JSON attribute:

customPlayer.setAttribute("player-data", JSON.stringify({
    src: "https://...",
    track: "Track Title",
    artist: "Artist",
    cover: "https://..."
}));

And then in the custom element code (which in my case is a JavaScript file that lives on GitHub), parse it in the attributeChangedCallback:

static get observedAttributes() { return ['player-data']; }

attributeChangedCallback(name, _old, val) {
    if (name === 'player-data' && val) {
        try {
            const data = JSON.parse(val);
            this._applyData(data);
        } catch(err) {}
    }
}

Again, not sure if that really matters or not but in case it’s helping this work, I figured I’d mention it.

Now as for what I think actually fixed it, here’s the approach I took.

Deferred hydration via forEachItem with a setTimeout

The idea here is don’t set attributes inside onItemReady at all; instead, after setting the repeater data, call a separate hydration function that uses forEachItem with a setTimeout to let Wix finish connecting all the elements first.

function hydrateCustomPlayers() {
    const repeater = $w("#postsRepeater");
    repeater.forEachItem(($item, itemData, index) => {
        const rawUrl = itemData.audioFileUrl;
        if (!rawUrl) return;

        // Capture values in closure NOW to prevent last-row-wins bleeding
        const capturedSrc = rawUrl;
        const capturedTrack = itemData.title;
        const capturedArtist = itemData._resolvedArtist;
        const capturedCover = itemData._resolvedCover;

        // Use a fixed element selector — Velo TypeScript requires literal strings
        const customPlayer = $item("#kptzAudioPlayer");

        setTimeout(() => {
            if (customPlayer && typeof customPlayer.setAttribute === 'function') {
                customPlayer.setAttribute("player-data", JSON.stringify({
                    src: capturedSrc,
                    track: capturedTrack,
                    artist: capturedArtist,
                    cover: capturedCover
                }));
            }
        }, 1500 + (index * 50));
    });
}

And call it immediately after setting the repeater data.

// After querying the CMS data ...
const results = await wixData.query("Posts")
    .descending("publishDateTime")
    .find();

// Pre-fetch all related data and attach it to each item
const enrichedItems = await enrichPostsWithShowData(results.items);

// Set the repeater data, then immediately kick off hydration
$w("#postsRepeater").data = enrichedItems;
hydrateCustomPlayers();

We’ll see where that enrichPostsWithShowData function comes from next. :slight_smile:

Pre-Fetch All Related Data Before Setting Repeater Data

This is critical if your CMS items have related data (like in my case I have Posts collection and a related Shows collection) that needs to be resolved before hydration. If you fetch that data inside onItemReady asynchronously, it may not be read when hydrateCustomPlayers runs.

So instead, I batch-fetch all related records up-front and attach them directly to each item.

async function enrichPostsWithShowData(items) {
    // Collect all unique related IDs
    const showIds = [...new Set(
        items
            .filter(item => item.show && typeof item.show === 'string')
            .map(item => item.show)
    )];

    // One batch query instead of N individual fetches
    let showMap = {};
    if (showIds.length > 0) {
        const showResults = await wixData.query("Shows")
            .hasSome("_id", showIds)
            .find();
        showResults.items.forEach(show => {
            showMap[show._id] = show;
        });
    }

    // Attach resolved data directly to each item
    return items.map(item => {
        const showObj = showMap[item.show] || null;
        return {
            ...item,
            _showObj: showObj,
            _resolvedCover: wixImageToUrl(showObj?.logo || DEFAULT_LOGO),
            _resolvedArtist: item.airDate ? formatAirDate(item.airDate) : DEFAULT_ARTIST_TEXT
        };
    });
}

(For context, in my custom player I use the artist attribute to show the air date of a show.)

And then in a load() function, I do this:

const enriched = await enrichPostsWithShowData(results.items);
$w("#postsRepeater").data = enriched;
hydrateCustomPlayers();

My take on why this forEachItem + setTimeout approach works is because by the time the timeout fires, Wix has finished connecting all the custom element instances to the DOM. At that point setAttribute lands on the actual connected elements and triggers attributeChangedCallback correctly. The closure captures each item’s data immediately in forEachItem preventing any missing attributes or (and this is something I was seeing in some iterations of my attempts) variable bleeding between items.

The pre-fetch approach eliminates the race condition where async data fetches inside onItemReady might not be complete before the hydration timeout fires.

Currently I’m using a 1500ms base timeout with 50ms stagger per item which, like the collected attributes bit I mentioned above, is a holdover from previous experimentation, so I may be able to reduce this.

Anyway, long way of saying IT FINALLY WORKS! I really appreciate the input on this, and hope this all makes sense if someone else runs into a similar issue.

Thanks!