Ephes Blog

Miscellaneous things. Mostly Weeknotes and links I stumbled upon.

Date -

Podlove Web Player Performance Improvements

, Jochen

I recently attended the SUBSCRIBE 11 podcasting conference, where I had the opportunity to chat with the author of Pods-Blitz about the Podlove Web Player. After we both listened to a talk about it, he asked me how I liked the Web Player. I mentioned that I really enjoyed it but suspected it was slowing down my website. He immediately agreed, noting that he had observed the same behavior.

Here's a screenshot illustrating the issue:

python-podcast-performance_original_scores

The poor performance seems to stem from the player loading a substantial amount of JavaScript and CSS assets.

podlove-player_slow_assets

Today, I decided to ask on Discord what might be causing this problem. Below is my original JavaScript code used to initialize the five audio players on the overview page of my podcast:

<script defer src="/static/cast/js/web-player/embed.5.28903a742256.js"></script>
<script>
  function initializePodlovePlayers() {
    document.querySelectorAll('section.block-audio div[id^="audio_"]').forEach(div => {
      const audioId = div.id;
      const url = div.getAttribute('data-url');
      podlovePlayer(`#${audioId}`, url, "/api/audios/player_config/");
    });
  }

  document.body.addEventListener('htmx:afterSwap', (event) => {
    if (event.detail.target.id === 'paging-area') {
      initializePodlovePlayers();
    }
  });

  // Call the function on initial load
  document.addEventListener('DOMContentLoaded', (event) => {
    initializePodlovePlayers();
  });
</script>

Alexander Heimbuch suggested that I use the load event instead of DOMContentLoaded to avoid blocking the main thread. He also advised using the IntersectionObserver API to defer the initialization until the player becomes visible in the viewport. I'm really grateful for Alex's help—it made a significant difference in optimizing my website. Based on his suggestions, I updated my code:

<script defer src="{% static 'cast/js/web-player/embed.5.js' %}"></script>
<script>
  function initializePodlovePlayerWhenVisible(div) {
    const observer = new IntersectionObserver((entries, observer) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          const audioId = div.id;
          const url = div.getAttribute('data-url');
          podlovePlayer(`#${audioId}`, url, "/api/audios/player_config/");
          observer.unobserve(div); // Stop observing since we don't need to initialize again
        }
      });
    });
    observer.observe(div);
  }

  function initializePodlovePlayers() {
    document.querySelectorAll('.podlove-player-container').forEach(div => {
      initializePodlovePlayerWhenVisible(div);
    });
  }

  document.body.addEventListener('htmx:afterSettle', (event) => {
    if (event.detail.target.id === 'paging-area') {
      setTimeout(() => {
        initializePodlovePlayers();
      }, 1500); // Adjust the delay as needed -> wait to finish scrolling up
    }
   });

  // Attach to the load event to ensure the page has fully loaded
  window.addEventListener('load', function() {
    initializePodlovePlayers();
  });
</script>

These changes made a significant difference—the performance score for mobile increased from 60 to 83. Realizing that it wouldn't be easy to reduce the connections to cdn.podlove.org—since each player is isolated in its own iframe and the URL is hardcoded in the pre-built player bundle—I added an additional optimization by including a preconnect link in the header of my website:

<link rel="preconnect" href="https://cdn.podlove.org" />

This also helped a lot to improve performance. Here are the final results:

python-podcast_optimized_scores

After grappling with htmx and pagination issues, I decided to encapsulate all the JavaScript into a web component. Now, the player script is dynamically imported when the web component becomes visible.

{% load static %}
{% if page.pk %}
  <podlove-player
    id="audio_{{ value.pk }}"
    data-variant="xl"
    data-url="{% url 'cast:api:audio_podlove_detail' pk=value.pk post_id=page.pk %}"
    data-embed="{% static 'cast/js/web-player/embed.5.js' %}"
    data-config="{% url 'cast:api:player_config' %}"
  >
  </podlove-player>
{% endif %}
// podlove-player.ts
class PodlovePlayerElement extends HTMLElement {
  constructor() {
    super();
    this.observer = null;
    this.shadow = this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    this.renderPlaceholder();

    if (document.readyState === 'complete') {
      // The page is already fully loaded
      this.observeElement();
    } else {
      // Wait for the 'load' event before initializing
      window.addEventListener('load', () => {
        this.observeElement();
      }, { once: true });
    }
  }

  disconnectedCallback() {
    if (this.observer) {
      this.observer.disconnect();
    }
  }

  renderPlaceholder() {
    // Reserve space to prevent layout shifts
    const container = document.createElement('div');
    container.classList.add('podlove-player-container');

    // Apply styles
    const style = document.createElement('style');
    style.textContent = `
      .podlove-player-container {
        width: 100%;
        max-width: 936px;
        height: 300px;
        margin: 0 auto;
      }
      @media (max-width: 768px) {
        .podlove-player-container {
          max-width: 366px;
          height: 500px;
        }
      }
      .podlove-player-container iframe {
        width: 100%;
        height: 100%;
        border: none;
      }
    `;

    this.shadow.appendChild(style);
    this.shadow.appendChild(container);
  }

  observeElement() {
    this.observer = new IntersectionObserver((entries, observer) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          this.initializePlayer();
          observer.unobserve(this);
        }
      });
    });
    this.observer.observe(this);
  }

  initializePlayer() {
    const container = this.shadow.querySelector('.podlove-player-container');
    const audioId = this.getAttribute('id') || `podlove-player-${Date.now()}`;
    const url = this.getAttribute('data-url');
    const configUrl = this.getAttribute('data-config') || '/api/audios/player_config/';
    const podloveTemplate = this.getAttribute('data-template');
    let embedUrl = this.getAttribute('data-embed') || 'https://cdn.podlove.org/web-player/5.x/embed.js';

    // If host ist localhost use local embed url
    const { protocol, hostname, port } = window.location;


    console.log("data template: ", podloveTemplate);
    const playerDiv = document.createElement('div');
    playerDiv.id = audioId;

    // set the template attribute if it is set (needed for pp theme)
    if (podloveTemplate !== null) {
        playerDiv.setAttribute('data-template', podloveTemplate);
    }
    container.appendChild(playerDiv);

    if (typeof podlovePlayer === 'function') {
      podlovePlayer(playerDiv, url, configUrl);
    } else {
      // If in dev mode on localhost and embedUrl starts with a slash, use the local embedUrl
      // otherwise the vite url 5173 will be used -> which will not work
      if (hostname === 'localhost' && embedUrl.startsWith("/")) {
        embedUrl = `http://localhost:${port}${embedUrl}`;
      }
      // Dynamically load the Podlove player script
      import(embedUrl).then(() => {
        podlovePlayer(playerDiv, url, configUrl);
      });
    }
  }
}

console.log("Registering podlove-player!");
customElements.define('podlove-player', PodlovePlayerElement);