/**
 * VMPlayer Frontend Player
 *
 * Owns the audio element and every piece of interactivity on the
 * player: playback controls, volume, mute, station cycling, clock,
 * metadata polling, ON AIR badge tracking, artwork updates, popout
 * button. Persists user preferences (volume, mute, active station)
 * to localStorage so listeners resume where they left off.
 *
 * Multiple player instances can coexist on one page; each finds
 * its own container and manages its own state independently.
 *
 * @package VMPlayer
 */

( function () {
    'use strict';

    var config = window.vmplayerConfig || {};

    /* ==================================================================
     * VMPlayer class
     * =============================================================== */

    function VMPlayer( container ) {
        this.container            = container;
        this.audio                = null;
        this.currentStationIndex  = 0;
        this.isPlaying            = false;
        this.isMuted              = false;
        this.volume               = 1.0;
        this.metadataTimer        = null;
        this.clockTimer           = null;
        this.visualizer           = null;
        this.hasStartedPlayback   = false;
        this.currentMetadata      = null;
        this.popoutWindow         = null;
        this.pendingPopoutInit    = null;
        this.expectingPopoutClose = false;
        this.kebabMenuOpen        = false;
        this.dom                  = {};
    }

    /**
     * Bootstrap the player.
     */
    VMPlayer.prototype.init = function () {
        if ( ! Array.isArray( config.stations ) || 0 === config.stations.length ) {
            return;
        }

        this.cacheDom();
        this.loadPersistedState();
        // If loadPersistedState restored a station index different
        // from the initially rendered one, the feature flags cached
        // by cacheDom reflect the wrong station. Refresh them from
        // the currentStationIndex now that the source of truth is
        // settled.
        this.applyStationFeatureFlags();
        this.createAudio();
        this.bindControls();
        this.startClock();
        this.renderCurrentStation();
        this.applyVolumeToSlider();
        this.applyMuteState();
        this.togglePopoutButton();
        this.startVisualizer();

        // Subscribe to admin station changes so this player instance
        // updates in place when an admin adds, removes, or reorders
        // stations in a different tab. Without this the visitor would
        // have to reload the page to see new stations.
        this.subscribeToStationSync();

        // Kick off metadata polling straight away so the display
        // reflects the actual current track without waiting for
        // the user to press play.
        this.fetchMetadata();
        this.startMetadataPolling();

        // Auto-resume playback if the listener was playing before a
        // page reload in the same tab session. Browsers block audio
        // autoplay unless the origin already has user engagement, but
        // a listener who explicitly pressed play on the previous page
        // load qualifies as engaged, so this usually just works. When
        // the browser does block, the play promise rejects and the UI
        // stays paused; nothing worse than the old behavior.
        this.attemptAutoResume();
    };

    /**
     * If the listener had audio playing before a page reload, restart
     * playback automatically so they do not have to click play again
     * every time the page refreshes or crashes. Only fires when the
     * intent flag from sessionStorage is set, so a fresh visit stays
     * paused until the listener presses play once.
     */
    VMPlayer.prototype.attemptAutoResume = function () {
        if ( ! this.loadPlaybackIntent() ) {
            return;
        }
        // Delay slightly so the audio element has finished wiring up
        // sources and event listeners. Also gives the metadata fetch
        // a head start, so the track title is often already up to
        // date by the time audio arrives.
        var self = this;
        setTimeout( function () {
            self.play();
        }, 250 );
    };

    /* ------------------------------------------------------------------
     * DOM cache
     * --------------------------------------------------------------- */

    VMPlayer.prototype.cacheDom = function () {
        var c = this.container;
        this.dom = {
            onair:        c.querySelector( '.vmplayer__onair' ),
            time:         c.querySelector( '[data-role="time"]' ),
            date:         c.querySelector( '[data-role="date"]' ),
            artwork:      c.querySelector( '[data-role="artwork"]' ),
            artworkImg:   c.querySelector( '[data-role="artwork-img"]' ),
            trackTitle:   c.querySelector( '[data-role="track-title"]' ),
            artist:       c.querySelector( '[data-role="artist"]' ),
            genre:        c.querySelector( '[data-role="genre"]' ),
            bitrate:      c.querySelector( '[data-role="bitrate"]' ),
            samplerate:   c.querySelector( '[data-role="samplerate"]' ),
            channels:     c.querySelector( '[data-role="channels"]' ),
            description:  c.querySelector( '[data-role="description"]' ),
            sepBitrate:   c.querySelector( '[data-role="sep-bitrate"]' ),
            sepSampleRate:c.querySelector( '[data-role="sep-samplerate"]' ),
            sepChannels:  c.querySelector( '[data-role="sep-channels"]' ),
            sepListeners: c.querySelector( '[data-role="sep-listeners"]' ),
            brandLogo:    c.querySelector( '[data-role="brand-logo"]' ),
            brandName:    c.querySelector( '[data-role="brand-name"]' ),
            brandTagline: c.querySelector( '[data-role="brand-tagline"]' ),
            stationCounter: c.querySelector( '[data-role="station-counter"]' ),
            social:       c.querySelector( '[data-role="social"]' ),
            visualizer:   c.querySelector( '[data-role="visualizer"]' ),
            muteToggle:   c.querySelector( '[data-role="mute-toggle"]' ),
            volumeSlider: c.querySelector( '[data-role="volume-slider"]' ),
            volUp:        c.querySelector( '[data-role="vol-up"]' ),
            prev:         c.querySelector( '[data-role="prev"]' ),
            playToggle:   c.querySelector( '[data-role="play-toggle"]' ),
            next:         c.querySelector( '[data-role="next"]' ),
            popout:       c.querySelector( '[data-role="popout"]' ),
            kebab:        c.querySelector( '[data-role="kebab"]' ),
            formatBadges: c.querySelectorAll( '[data-format]' ),
            // Compact variant hooks. Null on the main variant where
            // these elements do not exist; every consumer null-guards.
            liveBadge:         c.querySelector( '[data-role="live-badge"]' ),
            listenerCount:     c.querySelector( '[data-role="listener-count"]' ),
            listenerCountValue:c.querySelector( '[data-role="listener-count-value"]' ),
            formatLabel:       c.querySelector( '[data-role="format-label"]' ),
            nowPlayingLabel:   c.querySelector( '[data-role="nowplaying-label"]' ),
            nowPlayingText:    c.querySelector( '[data-role="nowplaying-text"]' )
        };
        // Feature toggles read from container data attributes so PHP
        // controls whether these render at all. JS uses these to
        // decide whether to show LIVE indicator and listener count
        // when the corresponding metadata arrives.
        this.isCompactVariant = c.classList.contains( 'vmplayer--compact' );
        this.showListenerCount = c.getAttribute( 'data-show-listener-count' ) === '1';
        this.detectLive = c.getAttribute( 'data-detect-live' ) === '1';
        this.currentStationIndex = parseInt( c.getAttribute( 'data-station-index' ), 10 ) || 0;
    };

    /* ------------------------------------------------------------------
     * Persistence
     * --------------------------------------------------------------- */

    VMPlayer.prototype.loadPersistedState = function () {
        try {
            var v = localStorage.getItem( 'vmplayer_volume' );
            if ( null !== v ) {
                this.volume = Math.max( 0, Math.min( 1, parseFloat( v ) ) );
            }
            var m = localStorage.getItem( 'vmplayer_muted' );
            if ( 'true' === m ) {
                this.isMuted = true;
            }
            var s = localStorage.getItem( 'vmplayer_station_index' );
            if ( null !== s ) {
                var idx = parseInt( s, 10 );
                if ( ! isNaN( idx ) && idx >= 0 && idx < config.stations.length ) {
                    this.currentStationIndex = idx;
                }
            }
        } catch ( e ) {
            // localStorage may be unavailable in private browsing.
        }
    };

    /**
     * Read the playback intent flag from sessionStorage. Returns true
     * when the user had audio playing before the last page reload, so
     * this session should attempt to resume automatically. Uses
     * sessionStorage so the flag lives only for the current tab
     * session; closing the tab clears it and a fresh visit starts
     * paused. The wrapping try/catch handles the private-browsing
     * case where sessionStorage throws on access.
     */
    VMPlayer.prototype.loadPlaybackIntent = function () {
        try {
            return 'true' === sessionStorage.getItem( 'vmplayer_was_playing' );
        } catch ( e ) {
            return false;
        }
    };

    /**
     * Save the playback intent flag to sessionStorage. Called from
     * play() with true and pause() with false so a page refresh in
     * the middle of a listening session picks up where the user left
     * off. Only records explicit user intent: audio errors, stream
     * drops, and station switches do not touch this flag so a broken
     * stream cannot leave a stale true value that keeps trying to
     * play on every refresh.
     */
    VMPlayer.prototype.persistPlaybackIntent = function ( wasPlaying ) {
        try {
            sessionStorage.setItem( 'vmplayer_was_playing', wasPlaying ? 'true' : 'false' );
        } catch ( e ) {
            // sessionStorage unavailable. Silent failure is fine
            // because the user will just have to click play again.
        }
    };

    VMPlayer.prototype.persistVolume = function () {
        try { localStorage.setItem( 'vmplayer_volume', String( this.volume ) ); } catch ( e ) {}
    };
    VMPlayer.prototype.persistMuted = function () {
        try { localStorage.setItem( 'vmplayer_muted', this.isMuted ? 'true' : 'false' ); } catch ( e ) {}
    };
    VMPlayer.prototype.persistStationIndex = function () {
        try { localStorage.setItem( 'vmplayer_station_index', String( this.currentStationIndex ) ); } catch ( e ) {}
    };

    /* ------------------------------------------------------------------
     * Audio element
     * --------------------------------------------------------------- */

    VMPlayer.prototype.createAudio = function () {
        this.audio = document.createElement( 'audio' );
        this.audio.preload = 'none';
        // crossOrigin is deliberately left unset. When set to 'anonymous',
        // the browser requires the stream server to send CORS response
        // headers, which almost no Shoutcast or Icecast servers do by
        // default. Without those headers the audio can be silenced by
        // the browser. Direct playback works fine without it.
        this.audio.volume = this.volume;
        this.audio.muted = this.isMuted;

        this.audio.addEventListener( 'playing', this.onAudioPlaying.bind( this ) );
        this.audio.addEventListener( 'pause',   this.onAudioPaused.bind( this ) );
        this.audio.addEventListener( 'ended',   this.onAudioPaused.bind( this ) );
        this.audio.addEventListener( 'error',   this.onAudioError.bind( this ) );
        this.audio.addEventListener( 'waiting', this.onAudioWaiting.bind( this ) );

        this.setAudioSource();
    };

    VMPlayer.prototype.setAudioSource = function () {
        var station = this.getCurrentStation();
        if ( ! station ) {
            return;
        }
        this.audio.src = station.stream_url;
    };

    VMPlayer.prototype.onAudioPlaying = function () {
        this.isPlaying = true;
        this.setPlayButtonState( 'playing' );
        this.setOnAir( true );
        if ( this.visualizer ) {
            this.visualizer.start();
        }
    };

    VMPlayer.prototype.onAudioPaused = function () {
        this.isPlaying = false;
        this.setPlayButtonState( 'paused' );
        this.setOnAir( false );
        if ( this.visualizer ) {
            this.visualizer.stop();
        }
    };

    VMPlayer.prototype.onAudioWaiting = function () {
        // Buffering state. Keep the play button showing playing so
        // the user does not think they need to click again.
    };

    VMPlayer.prototype.onAudioError = function () {
        this.isPlaying = false;
        this.setPlayButtonState( 'paused' );
        this.setOnAir( false );
        if ( this.visualizer ) {
            this.visualizer.stop();
        }
    };

    /* ------------------------------------------------------------------
     * Play / pause
     * --------------------------------------------------------------- */

    VMPlayer.prototype.togglePlay = function () {
        if ( this.isPlaying ) {
            this.pause();
        } else {
            this.play();
        }
    };

    VMPlayer.prototype.play = function () {
        // If the popout is open, main takes over by closing it. This
        // stops the popout's audio, the beforeunload handler on the
        // popout sends its final state back, and this flag prevents
        // the incoming close message from double-triggering play.
        if ( this.popoutWindow && ! this.popoutWindow.closed ) {
            this.expectingPopoutClose = true;
            this.postToPopout( 'vmplayer:close', {} );
        }

        if ( ! this.audio.src ) {
            this.setAudioSource();
        }
        // Force a reload of the source to avoid stale buffers on
        // long-paused live streams.
        if ( this.audio.paused && this.hasStartedPlayback ) {
            this.audio.load();
            this.setAudioSource();
        }
        this.hasStartedPlayback = true;

        // Lazy-init visualizer here rather than on page load because
        // browsers block AudioContext creation before user gesture.
        if ( config.enableVisualizer && this.visualizer && 'idle' === this.visualizer.getMode() ) {
            this.visualizer.init();
        }

        var self = this;
        var playPromise = this.audio.play();
        if ( playPromise && 'function' === typeof playPromise.catch ) {
            playPromise.then( function () {
                // Playback started. Record intent so a page refresh
                // in this tab session automatically picks up where
                // the listener left off.
                self.persistPlaybackIntent( true );
            } ).catch( function () {
                // Autoplay blocked or stream failed. onAudioError will
                // reset the UI. Nothing more to do here.
            } );
        }
    };

    VMPlayer.prototype.pause = function () {
        this.audio.pause();
        // Explicit user pause clears the auto-resume intent so a
        // refresh after pausing does not silently start audio again.
        this.persistPlaybackIntent( false );
    };

    VMPlayer.prototype.setPlayButtonState = function ( state ) {
        if ( ! this.dom.playToggle ) {
            return;
        }
        this.dom.playToggle.setAttribute( 'data-state', state );
        this.dom.playToggle.setAttribute(
            'aria-label',
            'playing' === state
                ? ( config.strings.pause || 'Pause' )
                : ( config.strings.play || 'Play' )
        );
    };

    /* ------------------------------------------------------------------
     * Volume and mute
     * --------------------------------------------------------------- */

    VMPlayer.prototype.setVolume = function ( value ) {
        var vol = Math.max( 0, Math.min( 1, parseFloat( value ) ) );
        if ( isNaN( vol ) ) {
            return;
        }
        this.volume = vol;
        this.audio.volume = vol;

        // Unmute automatically when the user drags volume up from zero.
        if ( vol > 0 && this.isMuted ) {
            this.isMuted = false;
            this.audio.muted = false;
            this.applyMuteState();
            this.persistMuted();
        }

        this.applyVolumeToSlider();
        this.persistVolume();
    };

    VMPlayer.prototype.applyVolumeToSlider = function () {
        if ( this.dom.volumeSlider ) {
            var pct = Math.round( this.volume * 100 );
            this.dom.volumeSlider.value = pct;
            this.dom.volumeSlider.style.setProperty( '--vmp-vol', pct + '%' );
        }
    };

    VMPlayer.prototype.toggleMute = function () {
        this.isMuted = ! this.isMuted;
        this.audio.muted = this.isMuted;
        this.applyMuteState();
        this.persistMuted();
    };

    VMPlayer.prototype.applyMuteState = function () {
        if ( this.dom.muteToggle ) {
            this.dom.muteToggle.setAttribute( 'data-muted', this.isMuted ? 'true' : 'false' );
            this.dom.muteToggle.setAttribute(
                'aria-label',
                this.isMuted
                    ? ( config.strings.unmute || 'Unmute' )
                    : ( config.strings.mute || 'Mute' )
            );
        }
    };

    /* ------------------------------------------------------------------
     * Station cycling
     * --------------------------------------------------------------- */

    VMPlayer.prototype.getCurrentStation = function () {
        return config.stations[ this.currentStationIndex ] || null;
    };

    VMPlayer.prototype.nextStation = function () {
        var total = config.stations.length;
        if ( total <= 1 ) {
            return;
        }
        this.loadStation( ( this.currentStationIndex + 1 ) % total );
    };

    VMPlayer.prototype.previousStation = function () {
        var total = config.stations.length;
        if ( total <= 1 ) {
            return;
        }
        var idx = this.currentStationIndex - 1;
        if ( idx < 0 ) {
            idx = total - 1;
        }
        this.loadStation( idx );
    };

    /**
     * Refresh the JS-side per-station feature flags to match the
     * current station in config.stations. Called whenever the
     * currentStationIndex changes: on init after loadPersistedState
     * restores the last-visited station, and on any prev/next click
     * through loadStation. Without this, the flags cached from the
     * DOM at page load would keep reflecting the shortcode's initial
     * station even after the visible station changed, which is why
     * disabling listener count on one station could leave it visible
     * for a listener whose localStorage pointed elsewhere.
     *
     * Also mirrors the values back onto the container data
     * attributes so any code that reads them later sees the current
     * state, not the initial-render state.
     */
    VMPlayer.prototype.applyStationFeatureFlags = function () {
        var station = config.stations[ this.currentStationIndex ] || {};
        this.showListenerCount = !! station.show_listener_count;
        this.detectLive        = !! station.detect_live;
        if ( this.container ) {
            this.container.setAttribute( 'data-show-listener-count', this.showListenerCount ? '1' : '0' );
            this.container.setAttribute( 'data-detect-live',         this.detectLive        ? '1' : '0' );
        }
    };

    VMPlayer.prototype.loadStation = function ( index ) {
        if ( index < 0 || index >= config.stations.length ) {
            return;
        }
        var wasPlaying = this.isPlaying;

        this.currentStationIndex = index;
        this.container.setAttribute( 'data-station-index', index );
        this.persistStationIndex();

        // Refresh per-station feature flags so switching stations
        // updates the LIVE indicator and listener count display for
        // the newly selected station.
        this.applyStationFeatureFlags();

        // Reset display to station defaults immediately, then metadata
        // fetch will fill in the current track.
        this.audio.pause();
        this.setAudioSource();
        this.renderCurrentStation();
        this.currentMetadata = null;

        // Clear any listener count chip from the previous station so
        // it does not linger visible for a beat while the new station's
        // metadata fetch is in flight. The next applyListenerCount call
        // will unhide it if this station has the feature on and the
        // server reports a positive count.
        this.applyListenerCount( null );
        // Same treatment for the LIVE badge: hide it immediately on
        // switch so a stale live indicator from the previous station
        // does not flash before the new metadata arrives.
        this.applyLiveState( false );

        this.fetchMetadata();

        if ( wasPlaying ) {
            this.play();
        }
    };

    /**
     * Refresh the display to reflect the current station's baseline
     * data. Fields that get overwritten by metadata (title, artist,
     * bitrate, artwork) start with the station's placeholder values.
     */
    VMPlayer.prototype.renderCurrentStation = function () {
        var station = this.getCurrentStation();
        if ( ! station ) {
            return;
        }

        if ( this.dom.trackTitle ) {
            this.dom.trackTitle.textContent = station.name || '';
        }
        if ( this.dom.artist ) {
            this.dom.artist.textContent = '\u00A0';
        }
        if ( this.dom.genre ) {
            this.dom.genre.textContent = station.genre || '';
        }
        if ( this.dom.description ) {
            this.dom.description.textContent = station.description || '';
        }
        if ( this.dom.brandName ) {
            this.dom.brandName.textContent = station.name || '';
        }
        if ( this.dom.brandTagline ) {
            this.dom.brandTagline.textContent = station.tagline || '';
        }
        if ( this.dom.brandLogo && station.logo_url ) {
            this.dom.brandLogo.setAttribute( 'src', station.logo_url );
        }
        this.updateStationCounter();
        this.updateArtwork( station.logo_url || '' );
        this.highlightFormatBadge( station.stream_type );
        this.renderSocial( station.social || {} );
        this.updateStreamInfoSeparators();
    };

    /**
     * Refresh the "Station N of M" pill. Uses the current stations
     * array as the source of truth (rather than the initial data-total
     * attribute) so the counter stays accurate when the admin adds or
     * removes stations in another tab and broadcasts an update. Also
     * toggles the is-hidden class so the pill appears and disappears
     * as the total crosses the 1-station threshold.
     */
    VMPlayer.prototype.updateStationCounter = function () {
        if ( ! this.dom.stationCounter ) {
            return;
        }
        var stations = Array.isArray( config.stations ) ? config.stations : [];
        var total    = stations.length;
        var single   = total <= 1;

        if ( single ) {
            this.dom.stationCounter.classList.add( 'is-hidden' );
            return;
        }
        this.dom.stationCounter.classList.remove( 'is-hidden' );

        var current  = this.currentStationIndex + 1;
        var template = ( config.strings && config.strings.stationOf )
            ? config.strings.stationOf
            : 'Station %1$d of %2$d';
        var text = template.replace( '%1$d', current ).replace( '%2$d', total );
        this.dom.stationCounter.textContent = text;
        this.dom.stationCounter.setAttribute( 'aria-label', text );
        this.dom.stationCounter.setAttribute( 'data-total', String( total ) );
    };

    /**
     * Enable or disable the previous/next station buttons based on
     * whether we have more than one station to cycle to. Applies both
     * the disabled DOM attribute (which browsers honor for click
     * events) and aria-disabled (which assistive tech honors) so the
     * behavior stays consistent for every listener.
     */
    VMPlayer.prototype.updatePrevNextEnabled = function () {
        var stations = Array.isArray( config.stations ) ? config.stations : [];
        var single   = stations.length <= 1;
        var setState = function ( btn ) {
            if ( ! btn ) { return; }
            btn.disabled = single;
            btn.setAttribute( 'aria-disabled', single ? 'true' : 'false' );
        };
        setState( this.dom.prev );
        setState( this.dom.next );
    };

    /**
     * Open a BroadcastChannel on the vmplayer_stations channel and
     * listen for messages posted by the admin JS when a station is
     * added, updated, deleted, or reordered. When one arrives, apply
     * the new station list to this player instance without touching
     * the audio element so playback continues uninterrupted. Older
     * browsers without BroadcastChannel silently skip this and the
     * frontend picks up changes on the visitor's next page load.
     */
    VMPlayer.prototype.subscribeToStationSync = function () {
        if ( 'undefined' === typeof window.BroadcastChannel ) {
            return;
        }
        var self = this;
        try {
            this.stationSyncChannel = new window.BroadcastChannel( 'vmplayer_stations' );
        } catch ( e ) {
            this.stationSyncChannel = null;
            return;
        }
        this.stationSyncChannel.addEventListener( 'message', function ( event ) {
            if ( ! event || ! event.data || 'vmplayer:stations_updated' !== event.data.type ) {
                return;
            }
            self.applyStationListUpdate( event.data.stations );
        } );
    };

    /**
     * Apply an updated stations array to this instance. Preserves the
     * currently playing station's identity across the update so
     * listeners never get bumped to a different stream when the
     * admin's change was unrelated to the station currently on air.
     * Fills in the packaged default logo for stations the admin saved
     * without one so downstream rendering stays consistent with the
     * initial page-load path.
     *
     * @param {Array} incomingStations Fresh stations array from admin.
     */
    VMPlayer.prototype.applyStationListUpdate = function ( incomingStations ) {
        if ( ! Array.isArray( incomingStations ) ) {
            return;
        }

        // Fill in the packaged default logo for stations that lack one
        // so the rest of this instance never has to think about empty
        // logo URLs. Same guarantee the initial page-load path gives.
        var defaultLogo = config.defaultLogoUrl || '';
        var stations = incomingStations.map( function ( station ) {
            var out = {};
            for ( var key in station ) {
                if ( Object.prototype.hasOwnProperty.call( station, key ) ) {
                    out[ key ] = station[ key ];
                }
            }
            if ( ! out.logo_url || '' === String( out.logo_url ).trim() ) {
                out.logo_url = defaultLogo;
            }
            return out;
        } );

        // Preserve the currently playing station's identity if we can
        // find it in the new list, so the visitor does not get bumped
        // to a different stream when the admin's change was unrelated.
        var currentStation = config.stations && config.stations[ this.currentStationIndex ];
        var currentId      = currentStation ? currentStation.id : '';
        var newIndex       = -1;
        if ( currentId ) {
            for ( var i = 0; i < stations.length; i++ ) {
                if ( stations[ i ].id === currentId ) {
                    newIndex = i;
                    break;
                }
            }
        }

        config.stations = stations;

        if ( newIndex >= 0 ) {
            // Same station, possibly new position in the list.
            this.currentStationIndex = newIndex;
        } else if ( stations.length > 0 ) {
            // The station we were playing was removed. Fall back to
            // the first available station, which will need a fresh
            // render since we are now on something different.
            this.currentStationIndex = 0;
            this.renderCurrentStation();
        } else {
            // No stations left. Nothing to do about the audio element
            // beyond stopping it; there is nothing to switch to.
            if ( this.audio ) {
                try { this.audio.pause(); } catch ( _ ) {}
            }
        }

        this.updateStationCounter();
        this.updatePrevNextEnabled();
    };

    /* ------------------------------------------------------------------
     * Metadata rendering
     * --------------------------------------------------------------- */

    VMPlayer.prototype.applyMetadata = function ( meta ) {
        if ( ! meta || 'object' !== typeof meta ) {
            return;
        }
        this.currentMetadata = meta;

        // LIVE broadcast detection. The server sets is_live_broadcast
        // true when the current source looks like a live human on air
        // rather than an automation playlist. When on, replace the
        // track title area with the LIVE indicator so listeners do
        // not see the leftover song title from before the live source
        // took over. When off, restore the normal title/artist flow.
        var isLive = this.detectLive && !! meta.is_live_broadcast;
        this.applyLiveState( isLive );

        if ( ! isLive ) {
            if ( this.dom.trackTitle && meta.title ) {
                this.dom.trackTitle.textContent = meta.title;
            }
            if ( this.dom.artist ) {
                this.dom.artist.textContent = meta.artist || '\u00A0';
            }
        }

        if ( this.dom.genre && meta.genre ) {
            this.dom.genre.textContent = meta.genre;
        }
        if ( this.dom.bitrate ) {
            this.dom.bitrate.textContent = meta.bitrate ? meta.bitrate + ' kbps' : '';
        }
        if ( this.dom.samplerate ) {
            var sr = parseInt( meta.sample_rate, 10 );
            this.dom.samplerate.textContent = sr > 0 ? ( sr / 1000 ) + ' kHz' : '';
        }
        if ( this.dom.channels ) {
            var ch = parseInt( meta.channels, 10 );
            this.dom.channels.textContent = 1 === ch ? 'Mono' : ( 2 === ch ? 'Stereo' : '' );
        }

        // Listener count. Only rendered when the station admin enabled
        // it AND the server actually returned a number. Null in meta
        // means the server did not expose the field, which is
        // different from zero listeners. We hide the chip either way
        // when the number is not positive so the display never says
        // "0 listeners" and looks like an empty stream.
        this.applyListenerCount( meta.listener_count );

        if ( meta.artwork_url ) {
            this.updateArtwork( meta.artwork_url );
        }

        // Update the streaming-via badge if metadata reveals a different
        // effective content type than the station is configured for.
        if ( meta.stream_type ) {
            this.highlightFormatBadge( meta.stream_type );
            if ( this.dom.formatLabel ) {
                this.dom.formatLabel.textContent = meta.stream_type.toUpperCase();
            }
        }

        this.updateStreamInfoSeparators();
    };

    /**
     * Toggle the LIVE indicator on or off. Both variants have a
     * dedicated live badge element now, so the logic is uniform:
     * show the badge, blank the artist line, and let the track title
     * fall back to the station name so the display reads sensibly.
     * When the flag flips off, normal metadata updates resume in
     * applyMetadata.
     */
    VMPlayer.prototype.applyLiveState = function ( isLive ) {
        var badge = this.dom.liveBadge;
        if ( badge ) {
            if ( isLive ) {
                badge.removeAttribute( 'hidden' );
            } else {
                badge.setAttribute( 'hidden', '' );
            }
        }
        if ( isLive ) {
            // Blank the artist so listeners do not see a stale song
            // credit alongside the LIVE badge. Track title is set to
            // the station name from the current metadata payload so
            // the display still reads as "This is Q95 FM, LIVE" at a
            // glance rather than an empty box.
            if ( this.dom.artist ) {
                this.dom.artist.textContent = '\u00A0';
            }
            if ( this.dom.trackTitle && this.currentMetadata && this.currentMetadata.station_name ) {
                this.dom.trackTitle.textContent = this.currentMetadata.station_name;
            }
        }
    };

    /**
     * Show or hide the listener count chip. Requires the admin toggle
     * AND a positive count from the server; either missing means the
     * chip stays hidden. Formats numbers over 999 with a comma so
     * "1,234 listeners" reads correctly.
     */
    VMPlayer.prototype.applyListenerCount = function ( count ) {
        var chip = this.dom.listenerCount;
        if ( ! chip ) {
            return;
        }
        var num = parseInt( count, 10 );
        if ( ! this.showListenerCount || isNaN( num ) || num <= 0 ) {
            chip.setAttribute( 'hidden', '' );
            return;
        }
        if ( this.dom.listenerCountValue ) {
            this.dom.listenerCountValue.textContent = num.toLocaleString();
        }
        chip.removeAttribute( 'hidden' );
    };

    VMPlayer.prototype.updateStreamInfoSeparators = function () {
        var hasGenre      = this.dom.genre      && '' !== this.dom.genre.textContent.trim();
        var hasBitrate    = this.dom.bitrate    && '' !== this.dom.bitrate.textContent.trim();
        var hasSampleRate = this.dom.samplerate && '' !== this.dom.samplerate.textContent.trim();
        var hasChannels   = this.dom.channels   && '' !== this.dom.channels.textContent.trim();
        // Listener count is considered present when the chip itself
        // is not hidden. This mirrors what a listener sees rather than
        // whether the underlying data exists, so the leading separator
        // does not appear when the count is present in DOM but hidden
        // by the admin toggle or a zero count from the server.
        var hasListeners  = this.dom.listenerCount && ! this.dom.listenerCount.hasAttribute( 'hidden' );

        this.toggleHidden( this.dom.sepBitrate,    ! ( hasGenre && hasBitrate ) );
        this.toggleHidden( this.dom.sepSampleRate, ! ( ( hasBitrate || hasGenre ) && hasSampleRate ) );
        this.toggleHidden( this.dom.sepChannels,   ! ( ( hasSampleRate || hasBitrate || hasGenre ) && hasChannels ) );
        this.toggleHidden( this.dom.sepListeners,  ! ( ( hasChannels || hasSampleRate || hasBitrate || hasGenre ) && hasListeners ) );
    };

    VMPlayer.prototype.toggleHidden = function ( el, hidden ) {
        if ( ! el ) {
            return;
        }
        if ( hidden ) {
            el.classList.add( 'is-hidden' );
        } else {
            el.classList.remove( 'is-hidden' );
        }
    };

    VMPlayer.prototype.updateArtwork = function ( url ) {
        if ( ! this.dom.artwork ) {
            return;
        }
        var img = this.dom.artworkImg || this.dom.artwork.querySelector( 'img' );

        if ( ! url ) {
            return;
        }

        // Same URL already showing means nothing to do. Avoids
        // flicker and skips the reload of a broken URL that failed once.
        if ( img && img.getAttribute( 'src' ) === url ) {
            return;
        }

        if ( ! img ) {
            img = document.createElement( 'img' );
            img.setAttribute( 'alt', '' );
            img.setAttribute( 'data-role', 'artwork-img' );
            this.dom.artwork.innerHTML = '';
            this.dom.artwork.appendChild( img );
            this.dom.artworkImg = img;
        }

        // Wire a one-shot error handler that falls back to the station
        // logo when the remote artwork fails to load. Covers 404s, CORS
        // errors, hotlink blocks, and any other reason the browser
        // refuses to render the URL. Removing the listener after firing
        // prevents infinite loops if the fallback URL also fails.
        var self = this;
        var onError = function () {
            img.removeEventListener( 'error', onError );
            var station = self.getCurrentStation();
            var fallback = station && station.logo_url ? station.logo_url : '';
            if ( fallback && fallback !== url ) {
                img.setAttribute( 'src', fallback );
            } else {
                // No station logo either. Remove the img to reveal the
                // empty artwork frame styling behind it.
                if ( img.parentNode ) {
                    img.parentNode.removeChild( img );
                }
                self.dom.artworkImg = null;
            }
        };
        img.addEventListener( 'error', onError );

        img.setAttribute( 'src', url );
    };

    VMPlayer.prototype.highlightFormatBadge = function ( streamType ) {
        if ( ! this.dom.formatBadges ) {
            return;
        }
        var st = ( streamType || '' ).toLowerCase();
        for ( var i = 0; i < this.dom.formatBadges.length; i++ ) {
            var badge = this.dom.formatBadges[ i ];
            if ( badge.getAttribute( 'data-format' ) === st ) {
                badge.classList.add( 'is-active' );
            } else {
                badge.classList.remove( 'is-active' );
            }
        }
    };

    VMPlayer.prototype.renderSocial = function ( social ) {
        if ( ! this.dom.social || ! social || 'object' !== typeof social ) {
            return;
        }
        // Update existing anchor hrefs and visibility instead of rebuilding
        // the DOM so any CSS transitions on the icons stay smooth.
        var links = this.dom.social.querySelectorAll( '.vmplayer__social-link' );
        for ( var i = 0; i < links.length; i++ ) {
            var link = links[ i ];
            var platform = link.getAttribute( 'data-platform' );
            var url = social[ platform ] || '';
            if ( url ) {
                link.setAttribute( 'href', url );
                link.classList.remove( 'is-hidden' );
            } else {
                link.classList.add( 'is-hidden' );
            }
        }
    };

    /* ------------------------------------------------------------------
     * ON AIR badge
     * --------------------------------------------------------------- */

    VMPlayer.prototype.setOnAir = function ( on ) {
        if ( ! this.dom.onair ) {
            return;
        }
        this.dom.onair.setAttribute( 'data-state', on ? 'on' : 'off' );
    };

    /* ------------------------------------------------------------------
     * Clock
     * --------------------------------------------------------------- */

    VMPlayer.prototype.startClock = function () {
        this.updateClock();
        this.clockTimer = window.setInterval( this.updateClock.bind( this ), 1000 );
    };

    VMPlayer.prototype.updateClock = function () {
        var now = new Date();
        if ( this.dom.time ) {
            this.dom.time.textContent = this.formatTime( now );
        }
        if ( this.dom.date ) {
            this.dom.date.textContent = this.formatDate( now );
        }
    };

    VMPlayer.prototype.formatTime = function ( d ) {
        var opts = { hour: 'numeric', minute: '2-digit', second: '2-digit', hour12: true };
        try {
            return new Intl.DateTimeFormat( undefined, opts ).format( d );
        } catch ( e ) {
            return d.toLocaleTimeString();
        }
    };

    VMPlayer.prototype.formatDate = function ( d ) {
        var opts = { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' };
        try {
            return new Intl.DateTimeFormat( undefined, opts ).format( d );
        } catch ( e ) {
            return d.toLocaleDateString();
        }
    };

    /* ------------------------------------------------------------------
     * Metadata polling
     * --------------------------------------------------------------- */

    VMPlayer.prototype.startMetadataPolling = function () {
        var self = this;
        var interval = Math.max( 5, parseInt( config.refreshInterval, 10 ) || 15 ) * 1000;
        this.metadataTimer = window.setInterval( function () {
            self.fetchMetadata();
        }, interval );
    };

    VMPlayer.prototype.fetchMetadata = function () {
        var self = this;
        var body = new FormData();
        body.append( 'action', 'vmplayer_get_metadata' );
        body.append( 'nonce', config.nonce );
        body.append( 'station_index', this.currentStationIndex );

        fetch( config.ajaxUrl, {
            method: 'POST',
            credentials: 'same-origin',
            body: body
        } )
            .then( function ( r ) { return r.json(); } )
            .then( function ( response ) {
                if ( response && response.success && response.data ) {
                    self.applyMetadata( response.data );
                }
            } )
            .catch( function () {
                // Silent: stream continues to play whether or not
                // metadata fetch succeeds. Next tick will try again.
            } );
    };

    /* ------------------------------------------------------------------
     * Visualizer
     * --------------------------------------------------------------- */

    VMPlayer.prototype.startVisualizer = function () {
        if ( ! config.enableVisualizer ) {
            return;
        }
        if ( ! this.dom.visualizer || 'undefined' === typeof window.VMPlayerVisualizer ) {
            return;
        }
        this.visualizer = new window.VMPlayerVisualizer( this.audio, this.dom.visualizer );
        // Start in paused state so the CSS animation is visually still.
        this.visualizer.stop();
    };

    /* ------------------------------------------------------------------
     * Popout
     * --------------------------------------------------------------- */

    VMPlayer.prototype.togglePopoutButton = function () {
        if ( ! this.dom.popout ) {
            return;
        }
        if ( ! config.enablePopout ) {
            this.dom.popout.classList.add( 'is-hidden' );
        }
    };

    VMPlayer.prototype.openPopout = function () {
        if ( ! config.enablePopout || ! config.popoutUrl ) {
            return;
        }
        // If a popout is already open, focus it instead of opening a second one.
        if ( this.popoutWindow && ! this.popoutWindow.closed ) {
            this.popoutWindow.focus();
            return;
        }

        var w = 420;
        var h = 780;
        var left = ( window.screen.availWidth - w ) / 2;
        var top = ( window.screen.availHeight - h ) / 2;
        var url = config.popoutUrl
            + ( config.popoutUrl.indexOf( '?' ) >= 0 ? '&' : '?' )
            + 'station=' + this.currentStationIndex;

        this.popoutWindow = window.open(
            url,
            'vmplayer_popout',
            'width=' + w + ',height=' + h + ',left=' + left + ',top=' + top +
                ',resizable=yes,scrollbars=no,toolbar=no,menubar=no,location=no,status=no'
        );

        // Remember the play state so we can hand it off when the popout
        // signals it is ready, then pause locally so both do not play at once.
        var wasPlaying = this.isPlaying;
        this.pendingPopoutInit = {
            stationIndex: this.currentStationIndex,
            volume:       this.volume,
            isMuted:      this.isMuted,
            isPlaying:    wasPlaying
        };
        if ( wasPlaying ) {
            this.pause();
        }
    };

    VMPlayer.prototype.postToPopout = function ( type, payload ) {
        if ( ! this.popoutWindow || this.popoutWindow.closed ) {
            return;
        }
        try {
            this.popoutWindow.postMessage( {
                source: 'vmplayer-main',
                type: type,
                payload: payload || {}
            }, '*' );
        } catch ( e ) {}
    };

    VMPlayer.prototype.handlePopoutMessage = function ( event ) {
        var data = event.data;
        if ( ! data || 'vmplayer-popout' !== data.source ) {
            return;
        }

        var payload = data.payload || {};

        switch ( data.type ) {
            case 'vmplayer:ready':
                // Popout has loaded. Send it our current state so it can
                // pick up right where the main player left off.
                if ( this.pendingPopoutInit ) {
                    this.postToPopout( 'vmplayer:init', this.pendingPopoutInit );
                    this.pendingPopoutInit = null;
                }
                break;

            case 'vmplayer:state':
                // Popout is updating us on its state so our UI stays in sync.
                if ( 'number' === typeof payload.stationIndex
                    && payload.stationIndex !== this.currentStationIndex
                ) {
                    this.currentStationIndex = payload.stationIndex;
                    this.container.setAttribute( 'data-station-index', payload.stationIndex );
                    this.persistStationIndex();
                    this.renderCurrentStation();
                    this.fetchMetadata();
                }
                if ( 'number' === typeof payload.volume ) {
                    this.volume = payload.volume;
                    this.audio.volume = payload.volume;
                    this.applyVolumeToSlider();
                    this.persistVolume();
                }
                if ( 'boolean' === typeof payload.isMuted && payload.isMuted !== this.isMuted ) {
                    this.isMuted = payload.isMuted;
                    this.audio.muted = payload.isMuted;
                    this.applyMuteState();
                    this.persistMuted();
                }
                // Reflect popout play state on our badge without actually
                // playing audio here.
                if ( 'boolean' === typeof payload.isPlaying ) {
                    this.setOnAir( payload.isPlaying );
                    this.setPlayButtonState( payload.isPlaying ? 'playing' : 'paused' );
                }
                break;

            case 'vmplayer:dock':
                // User asked to dock back to the main player.
                this.popoutWindow = null;
                if ( payload.isPlaying ) {
                    this.play();
                }
                break;

            case 'vmplayer:close':
                // Popout is closing. If main initiated the close as part
                // of a play-takeover, main is already handling playback,
                // so skip the auto-resume path to avoid a double play call.
                this.popoutWindow = null;
                if ( this.expectingPopoutClose ) {
                    this.expectingPopoutClose = false;
                    break;
                }
                if ( config.autoResume && payload.isPlaying ) {
                    this.play();
                }
                break;
        }
    };

    /* ------------------------------------------------------------------
     * Kebab menu
     * --------------------------------------------------------------- */

    VMPlayer.prototype.toggleKebabMenu = function () {
        if ( this.kebabMenuOpen ) {
            this.closeKebabMenu();
            return;
        }
        this.openKebabMenu();
    };

    VMPlayer.prototype.openKebabMenu = function () {
        if ( 'undefined' === typeof window.VMPlayerKebab ) {
            return;
        }
        var self = this;
        window.VMPlayerKebab.render( {
            anchor:         this.dom.kebab,
            strings:        config.strings || {},
            currentStation: this.getCurrentStation(),
            currentIndex:   this.currentStationIndex,
            settings:       {
                autoResume:       !! config.autoResume,
                enableVisualizer: !! config.enableVisualizer
            },
            onSelectMount: function ( mountIndex ) {
                self.switchToMount( mountIndex );
            },
            onToggleSetting: function ( key, value ) {
                self.toggleSetting( key, value );
            },
            onClose: function () {
                self.kebabMenuOpen = false;
            }
        } );
        this.kebabMenuOpen = true;
    };

    VMPlayer.prototype.closeKebabMenu = function () {
        if ( window.VMPlayerKebab ) {
            window.VMPlayerKebab.close();
        }
        this.kebabMenuOpen = false;
    };

    VMPlayer.prototype.switchToMount = function ( mountIndex ) {
        var station = this.getCurrentStation();
        if ( ! station || ! station.mounts || ! station.mounts[ mountIndex ] ) { return; }
        var mount = station.mounts[ mountIndex ];
        if ( ! mount.url ) { return; }

        var wasPlaying = this.isPlaying;
        this.audio.pause();
        this.audio.src = mount.url;
        if ( wasPlaying ) {
            this.play();
        }
    };

    VMPlayer.prototype.toggleSetting = function ( key, value ) {
        if ( 'enableVisualizer' === key ) {
            config.enableVisualizer = value;
            if ( ! value && this.visualizer ) {
                this.visualizer.stop();
                if ( this.dom.visualizer ) {
                    this.dom.visualizer.style.visibility = 'hidden';
                }
            } else if ( value && this.dom.visualizer ) {
                this.dom.visualizer.style.visibility = '';
                if ( this.isPlaying && this.visualizer ) {
                    this.visualizer.start();
                }
            }
        } else if ( 'autoResume' === key ) {
            config.autoResume = value;
        }
    };

    /* ------------------------------------------------------------------
     * Event binding
     * --------------------------------------------------------------- */

    VMPlayer.prototype.bindControls = function () {
        var self = this;

        if ( this.dom.playToggle ) {
            this.dom.playToggle.addEventListener( 'click', function () {
                self.togglePlay();
            } );
        }

        if ( this.dom.muteToggle ) {
            this.dom.muteToggle.addEventListener( 'click', function () {
                self.toggleMute();
            } );
        }

        if ( this.dom.volumeSlider ) {
            this.dom.volumeSlider.addEventListener( 'input', function ( e ) {
                self.setVolume( parseFloat( e.target.value ) / 100 );
            } );
        }

        if ( this.dom.volUp ) {
            this.dom.volUp.addEventListener( 'click', function () {
                self.setVolume( 1.0 );
            } );
        }

        if ( this.dom.prev ) {
            this.dom.prev.addEventListener( 'click', function () {
                self.previousStation();
            } );
        }

        if ( this.dom.next ) {
            this.dom.next.addEventListener( 'click', function () {
                self.nextStation();
            } );
        }

        if ( this.dom.popout ) {
            this.dom.popout.addEventListener( 'click', function () {
                self.openPopout();
            } );
        }

        if ( this.dom.kebab ) {
            this.dom.kebab.addEventListener( 'click', function ( e ) {
                e.stopPropagation();
                self.toggleKebabMenu();
            } );
        }

        // Receive state updates and lifecycle events from the popout window.
        window.addEventListener( 'message', this.handlePopoutMessage.bind( this ) );

        // If the popout window closes for any reason (user hit the X on
        // the browser chrome), we may not receive a close message, so poll.
        var self2 = this;
        window.setInterval( function () {
            if ( self2.popoutWindow && self2.popoutWindow.closed ) {
                self2.popoutWindow = null;
            }
        }, 1000 );

        // Keyboard shortcuts on the container: space toggles play,
        // arrow left/right cycles stations, arrow up/down adjusts volume.
        this.container.addEventListener( 'keydown', function ( e ) {
            if ( e.target && ( 'INPUT' === e.target.tagName || 'TEXTAREA' === e.target.tagName ) ) {
                return;
            }
            switch ( e.key ) {
                case ' ':
                    e.preventDefault();
                    self.togglePlay();
                    break;
                case 'ArrowLeft':
                    self.previousStation();
                    break;
                case 'ArrowRight':
                    self.nextStation();
                    break;
                case 'ArrowUp':
                    e.preventDefault();
                    self.setVolume( Math.min( 1, self.volume + 0.05 ) );
                    break;
                case 'ArrowDown':
                    e.preventDefault();
                    self.setVolume( Math.max( 0, self.volume - 0.05 ) );
                    break;
                case 'm':
                case 'M':
                    self.toggleMute();
                    break;
            }
        } );
    };

    /* ------------------------------------------------------------------
     * Cleanup
     * --------------------------------------------------------------- */

    VMPlayer.prototype.destroy = function () {
        if ( this.metadataTimer ) {
            window.clearInterval( this.metadataTimer );
        }
        if ( this.clockTimer ) {
            window.clearInterval( this.clockTimer );
        }
        if ( this.visualizer ) {
            this.visualizer.destroy();
        }
        if ( this.audio ) {
            this.audio.pause();
            this.audio.src = '';
        }
    };

    /* ==================================================================
     * Bootstrap on DOM ready
     * =============================================================== */

    function boot() {
        var containers = document.querySelectorAll( '.vmplayer' );
        for ( var i = 0; i < containers.length; i++ ) {
            var container = containers[ i ];
            // Prevent double-init if boot fires twice for any reason.
            if ( container.getAttribute( 'data-vmplayer-initialized' ) ) {
                continue;
            }
            container.setAttribute( 'data-vmplayer-initialized', '1' );
            var player = new VMPlayer( container );
            player.init();
        }
    }

    if ( 'loading' === document.readyState ) {
        document.addEventListener( 'DOMContentLoaded', boot );
    } else {
        boot();
    }

    // Expose the class for potential external control.
    window.VMPlayer = VMPlayer;

} )();
