'use client';

import { throttle } from 'lodash-es';
import { reaction, runInAction } from 'mobx';
import { v4 } from 'uuid';

import { isVideoHooksPath } from '@/components/hooksPlayer/utils';
import { ContextType } from '@/logging/contextTypes';
import { getDisplayVolumeFromAudioVolume } from '@/utils/audio';
import { getClipTitle } from '@/utils/clip';
import { getDeviceId, getDeviceType, parseSafariVersion } from '@/utils/device';
import { ActionName, EventNames } from '@/utils/event-names';
import { valueOrDefault } from '@/utils/utils';

import { Clip } from './clipStore';
import { RootStore, Substore } from './rootStore';
import { makeAutoObservableSubstore } from './utils';

const PLAY_STATE_STORAGE_KEY = 'play-state';

const OPUS_DATE_CUTOFF = new Date('2025-04-27');
const M4A_DATE_CUTOFF = new Date('2025-09-03');

/**
 * Player events that can be used to track play time.
 */
export enum PlayerEvents {
  PLAY = 'play',
  PAUSE = 'pause',
  WAITING = 'waiting',
  STALLED = 'stalled',
  SEEKED = 'seeked',
  SEEKING = 'seeking',
  PLAYING = 'playing',
  ENDED = 'ended',
  SUSPEND = 'suspend',
  ERROR = 'error',
  DURATION_CHANGE = 'durationchange',
  TIME_UPDATE = 'timeupdate',
}

export class PlaybarStore implements Substore {
  isPlaying = false;
  currentTime = 0;
  duration = 0;
  startTime = 0;
  endTime = 0;

  clip: Clip | null = null;
  songSessionId: string = '';
  previousSongSessionId: string = '';
  actionIndex = -1;

  noClipPlayCallback: (() => void) | null = null;

  audioContext: AudioContext | null = null;
  audioData: { [clipId: string]: AudioBuffer } = {};
  recentlyPlayedIds: string[] = [];

  sourceNode: MediaElementAudioSourceNode | null = null;
  speedProcessor: AudioWorkletNode | null = null;
  analyserNode: AnalyserNode | null = null;
  private hasAudioAnalyzerInitialized: boolean = false;

  audioElement: HTMLAudioElement | null = null;
  // Silent Audio Element is used to keep MediaSession alive on mobile
  silentAudioElement: HTMLAudioElement | null = null;

  repeat: boolean = false;

  loadingStream: boolean = false;
  loadingDuration: boolean = false;

  volume: number = 1.0;
  muted: boolean = false;

  /**
   * {boolean} Whether playbar initialization has completed
   */
  isInitialized = false;

  /**
   * {boolean} Determine if event listeners have been set on the audio element.
   */
  hasSetListeners = false;

  /**
   * {boolean} Used by the Song page to preload a clip without triggering a play
   */
  isClipPreloaded = false;

  isDragging = false;

  disableStepForward = false;

  showSongQueue: boolean | null = null;

  // Living Radio

  // This defines whether we are in regular playback mode or radio mode. When this is active, we actively fetch for radio songs and sync playback location.
  // Don't use this directly, use setLivingRadioMode instead.
  isLivingRadioMode: boolean = false;

  // Internal setter to flip radio mode without side effects (avoids recursion when toggling play state)
  setIsLivingRadioModeWithoutTogglePlay = (isLivingRadioMode: boolean) => {
    if (isLivingRadioMode !== this.isLivingRadioMode) {
      this.isLivingRadioMode = isLivingRadioMode;
    }
  };

  setIsLivingRadioMode = (isLivingRadioMode: boolean) => {
    if (!this.isLivingRadioMode && isLivingRadioMode && this.isPlaying) {
      this.togglePlay(false);
    }
    this.setIsLivingRadioModeWithoutTogglePlay(isLivingRadioMode);
  };

  // Mobile comments modal state
  isMobileCommentsModalOpen: boolean = false;
  shouldOpenMobileCommentsModal: boolean = false;

  readonly root: RootStore;

  toggleShowSongQueue = () => {
    this.showSongQueue = !!!this.showSongQueue;
  };

  get apiClient() {
    return this.root.apiClient;
  }
  get logger() {
    return this.root.logger;
  }

  // Helper method to check if current clip is in preview mode
  get isInPreviewMode(): boolean {
    // same as clip.preview_seconds !== undefined && !== null
    if (this.clip?.preview_seconds == null) {
      return false;
    }

    return this.clip.preview_seconds > 0;
  }

  // DO NOT ADD INIT LOGIC IN constructor. instead add it in the component init logic if you can
  constructor(root: RootStore) {
    this.root = root;
    makeAutoObservableSubstore(this);
  }

  // this will run on every page! considering adding any it in the component init logic instead
  initialize() {
    this.loadFromLocalStorage();
    this.initLocalStorageUpdate();
  }

  // Method to handle beforeunload event
  handleBeforeUnload = () => {
    this.upsertPlaybarState();
  };

  get shuffle() {
    return this.root.queue.shuffle;
  }

  getAudioPlayerEvent(
    actionName: string,
    clipUserId: string
  ): Record<string, any> {
    this.actionIndex = this.actionIndex + 1;
    const isSongAutoplayQueue =
      this.root.queue.activeQueue == null ||
      this.root.queue.contextClipCount == null
        ? false
        : this.root.queue.activeQueue === 'context' &&
          this.root.queue.contextQueueIndex >= this.root.queue.contextClipCount;
    const isSongManualQueue = this.root.queue.activeQueue === 'manual';
    return {
      songSessionId: this.songSessionId,
      hasClip: this.clip?.id != null,
      songId: this.clip?.id || undefined,
      contextId: this.root.queue.contextId ?? undefined,
      contextType: this.root.queue.contextType ?? undefined,
      isSongAutoplayQueue,
      isSongManualQueue,
      startTime: this.startTime,
      endTime: this.endTime,
      isPlaying: this.isPlaying,
      playDuration: this.endTime - this.startTime,
      isAudioElementNull: !this.audioElement,
      audioElementCurrentTime: this.audioElement?.currentTime,
      actionName: actionName,
      isUserSongOwner:
        this.root.session?.userId !== undefined &&
        this.root.session?.userId === clipUserId,
      volume: this.displayVolume * 100,
      clickSourceUrl: typeof location !== 'undefined' ? location.pathname : '',
      isShuffleOn: this.shuffle,
      isAutoplayOn: this.root.queue.continuous, // @TODO: Maintaining legacy logging, for now
      isRepeatOn: this.repeat,
      userId: this.root.session?.userId,
      previousSongSessionId: this.previousSongSessionId,
      actionIndex: this.actionIndex,
      songLength: this.clip?.metadata?.duration || 0,
      metadata: {
        surfaceType: this.root.queue.surfaceType,
        surfaceId: this.root.queue.surfaceId,
        browserHistory: this.root.navigation.navigationHistory,
      },
    };
  }

  setSilentAudioElement(audioElement: HTMLAudioElement) {
    this.silentAudioElement = audioElement;
  }

  /**
   * Setting up listeners for the audio element to track the play time.
   * @param audioElement
   * @param self
   */
  handleSetupListeners(audioElement: HTMLAudioElement) {
    const handlePlayerEvent = (eventName: string, el: any) => {
      if (el.target.id == 'active-audio-play') {
        switch (eventName) {
          case PlayerEvents.ERROR:
            break;
          case PlayerEvents.PAUSE:
            if (this.isPlaying) {
              this.endTime = this.audioElement?.currentTime || 0;
              this.logger.segmentTrack(
                EventNames.audioPlayerEvent,
                this.getAudioPlayerEvent(
                  this.endTime === this.duration ? 'SongEnd' : 'PauseSong',
                  this.clip?.user_id || ''
                ),
                this.root.session
              );
              this.startTime = this.endTime;
              this.isPlaying = false;
            }
            break;
          case PlayerEvents.WAITING:
          case PlayerEvents.STALLED:
          case PlayerEvents.SEEKING:
            // Pause playtime tracking while seeking
            break;
          case PlayerEvents.PLAYING:
          // just treat the same as play for now
          case PlayerEvents.PLAY:
            // Enable playtime tracking when playing
            break;
        }
      }
    };

    /**
     * Handle player events that can be used to track play time.
     */
    const playerEventHandler = (ev: Event) => {
      runInAction(() => {
        handlePlayerEvent(ev.type, ev);
      });
    };

    /**
     * Handle player error events, that can negatively impact the user experience.
     */
    const handlePlayerError = (ev: Event) => {
      playerEventHandler(ev);
    };

    /**
     * Handle song starting event.
     *
     * If song is starting from the beginning, track the event.
     */

    const handleSongStarted = async (ev: Event) => {
      playerEventHandler(ev);
      // Potentially fetch more clips once a song starts playing, so that when the
      // current song ends, the next songs are already in the queue.
      await this.root.queue.autoplayRunwayCheck();
    };

    /**
     * Handle song ended event.
     */
    const handleSongEnded = (ev: Event) => {
      playerEventHandler(ev);
    };

    /**
     * Event fired when timeline scrubber has been released.
     */
    audioElement.addEventListener(PlayerEvents.SEEKED, playerEventHandler);

    /**
     * Event fired when timeline scrubber is being dragged.
     */
    audioElement.addEventListener(PlayerEvents.SEEKING, playerEventHandler);

    /**
     * Event fired when audio player transitions into playing
     * state.
     */
    audioElement.addEventListener(PlayerEvents.PLAYING, handleSongStarted);

    /**
     * Event fired when audio player starts playing.
     */
    audioElement.addEventListener(PlayerEvents.PLAY, playerEventHandler);

    /**
     * Event fired when audio player stops playing.
     */
    audioElement.addEventListener(PlayerEvents.PAUSE, playerEventHandler);

    /**
     * The ended event is fired when playback or streaming has stopped because
     * the end of the media was reached or because no further data is available.
     */
    audioElement.addEventListener(PlayerEvents.ENDED, handleSongEnded);

    /**
     * The suspend event is fired when media data loading has been suspended.
     */
    audioElement.addEventListener(PlayerEvents.SUSPEND, playerEventHandler);

    /**
     * Event fired when timeline scrubber has been released,
     * but the audio is still loading.
     */
    audioElement.addEventListener(PlayerEvents.WAITING, playerEventHandler);

    /**
     * Fired when the user agent is trying to fetch media data,
     * but data is unexpectedly not forthcoming.
     */
    audioElement.addEventListener(PlayerEvents.STALLED, handlePlayerError);

    /**
     * The error event is fired when the resource could not be
     * loaded due to an error (for example, a network connectivity
     * problem).
     */
    audioElement.addEventListener(PlayerEvents.ERROR, handlePlayerError);
  }

  setAudioElement(audioElement: HTMLAudioElement) {
    this.audioElement = audioElement;

    if (!this.hasSetListeners) {
      this.hasSetListeners = true;
      this.handleSetupListeners(this.audioElement);
    }

    if (this.audioElement.duration) {
      this.duration = this.audioElement.duration;
    }

    // If in preview mode and trying to seek beyond preview duration, skip to next song
    // Only applies when preview_seconds is defined and > 0
    if (
      this.isInPreviewMode &&
      this.clip?.preview_seconds != null &&
      this.audioElement?.currentTime > this.clip.preview_seconds
    ) {
      this.stepForward();
      return;
    }

    this.audioElement.addEventListener(PlayerEvents.DURATION_CHANGE, () => {
      runInAction(() => {
        if (this.audioElement?.duration === Infinity) {
          // TODO: Handle streaming better
          this.duration = 60;
          this.loadingDuration = true;
        } else {
          this.duration = this.audioElement?.duration || 0;
          this.loadingDuration = false;
        }
        this.loadingStream = false;
      });
    });

    this.isPlaying = !this.audioElement.paused;

    this.audioElement.addEventListener(PlayerEvents.TIME_UPDATE, () => {
      // Check if we've exceeded preview duration and should skip to next song
      // Only applies when preview_seconds is defined and > 0
      if (
        this.isInPreviewMode &&
        this.clip?.preview_seconds != null &&
        (this.audioElement?.currentTime || 0) > this.clip.preview_seconds
      ) {
        const skippedSuccessfully = this.stepForward();
        if (!skippedSuccessfully && this.audioElement) {
          this.audioElement.currentTime = this.clip.preview_seconds;
          this.audioElement.pause();
        }
        return;
      }

      if ((this.audioElement?.currentTime || 0) > this.duration) {
        this.handleEnded();
        return;
      }

      if (
        !this.clip?.metadata.duration &&
        this.loadingDuration &&
        (this.audioElement?.currentTime || 0) > this.duration - 10
      ) {
        this.duration += 30;
      }
      this.debouncedSetCurrentTime();
    });

    this.audioElement.addEventListener(PlayerEvents.ENDED, this.handleEnded);

    this.audioElement.addEventListener(PlayerEvents.PLAY, () => {
      runInAction(() => {
        this.isPlaying = true;
        if ('mediaSession' in navigator && this.clip) {
          const clip = this.clip;

          let artwork: MediaImage[] = [];
          const title = getClipTitle(clip);
          if (clip.image_url) {
            artwork = [
              { src: clip.image_url, sizes: '256x256', type: 'image/png' },
            ];
          }

          navigator.mediaSession.metadata = new MediaMetadata({
            title: title,
            artist: clip.metadata?.tags
              ? `Suno - ${clip.metadata?.tags}`
              : 'Suno',
            artwork: artwork,
          });

          navigator.mediaSession.setActionHandler('play', () => {
            // Do not let media session restart the playbar if we're on hooks
            if (isVideoHooksPath(window.location.pathname)) {
              return;
            }
            runInAction(() => {
              this.audioElement?.play();
              this.endTime = this.audioElement?.currentTime || 0;
              this.startTime = this.audioElement?.currentTime || 0;
              this.logger.segmentTrack(
                EventNames.audioPlayerEvent,
                this.getAudioPlayerEvent(
                  ActionName.playSongInMediaSession,
                  this.clip?.user_id || ''
                ),
                this.root.session
              );
            });
          });
          navigator.mediaSession.setActionHandler('pause', () =>
            this.audioElement?.pause()
          );

          navigator.mediaSession.setActionHandler('nexttrack', () => {
            this.stepForward();
          });

          navigator.mediaSession.setActionHandler('previoustrack', () => {
            this.stepBackward();
          });

          navigator.mediaSession.setActionHandler('seekto', (details) => {
            runInAction(() => {
              if (details.seekTime && this.audioElement) {
                if (this.isPlaying) {
                  this.endTime = this.audioElement?.currentTime || 0;
                  this.logger.segmentTrack(
                    EventNames.audioPlayerEvent,
                    this.getAudioPlayerEvent(
                      ActionName.seekProgressBarPauseSongInMediaSession,
                      this.clip?.user_id || ''
                    ),
                    this.root.session
                  );
                  this.startTime = this.endTime;
                }

                this.audioElement.currentTime = details.seekTime;
                this.currentTime = details.seekTime;

                this.startTime = this.audioElement?.currentTime;
                this.endTime = this.audioElement?.currentTime;
                this.logger.segmentTrack(
                  EventNames.audioPlayerEvent,
                  this.getAudioPlayerEvent(
                    ActionName.seekProgressBarPlaySongInMediaSession,
                    this.clip?.user_id || ''
                  ),
                  this.root.session
                );
              }
            });
          });
        }
      });
    });

    this.audioElement.addEventListener(PlayerEvents.PAUSE, () => {
      runInAction(() => {
        this.isPlaying = false;
      });
    });
  }

  shouldUseM4a = (clip: Clip) => {
    if (!clip.audio_url) {
      return false;
    }

    // Check feature flag first
    if (!this.root.session.checkGate('enable-m4a-audio')) {
      return false;
    }

    // Not streaming m4a yet
    if (clip.audio_url.includes('audiopipe')) {
      return false;
    }

    if (new Date(clip.created_at) < M4A_DATE_CUTOFF) {
      // we started dual-writing opus sometime around this date
      return false;
    }

    // The same logic for opus in webm applies to opus in m4a
    return this.shouldUseOpusWebm(clip);
  };

  shouldUseOpusWebm = (clip: Clip) => {
    if (!clip.audio_url) {
      return false;
    }

    if (new Date(clip.created_at) < OPUS_DATE_CUTOFF) {
      // we started dual-writing opus sometime around 2025-03-23
      // picking a bit after that with an easy to remember date
      return false;
    }

    // https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Browser_detection_using_the_user_agent#browser_name_and_version
    const userAgentIncludesSafari = /safari/i.test(navigator.userAgent);
    const userAgentIncludesChromeOrChromium = /chrome|chromium/i.test(
      navigator.userAgent
    );
    const isSafari =
      userAgentIncludesSafari && !userAgentIncludesChromeOrChromium;

    if (isSafari) {
      // Safari support is questionable: https://caniuse.com/opus
      // theoretically >= 17.5 can be used so we can inspect safari version
      const safariVersion = parseSafariVersion(navigator.userAgent);
      if (
        safariVersion &&
        (safariVersion.major > 17 ||
          (safariVersion.major === 17 && safariVersion.minor >= 5))
      ) {
        return true;
      }

      return false;
    }

    if (clip.audio_url.includes('audiopipe')) {
      // redundant, but leaving for explicitness
      return true;
    }

    return true;
  };

  playClip(
    clip: Clip,
    playActionName?: string | null,
    pauseActionName?: string | null,
    isPaused: boolean = false,
    currentTime: number = 0,
    skipPlayCountIncrement: boolean = false
  ) {
    // For now we will not track the event if there is no new playable song
    if (!this.audioElement) return;
    if (!clip) return;
    if (!clip.audio_url) return;

    // Early return for lockedPreview clips (preview_seconds === 0) - don't trigger anything
    if (clip.preview_seconds === 0) return;

    const shouldUseM4a = this.shouldUseM4a(clip);
    const shouldUseOpusWebm = this.shouldUseOpusWebm(clip);
    let clipAudioUrl = clip.audio_url;

    if (shouldUseM4a) {
      clipAudioUrl = clipAudioUrl.replace('.mp3', '.m4a');
    } else if (shouldUseOpusWebm) {
      clipAudioUrl = clipAudioUrl.replace('.mp3', '.webm');
    }
    runInAction(() => {
      if (!this.audioElement) return;
      this.setIsLivingRadioMode(false);
      if (clipAudioUrl.includes('audiopipe')) {
        // audiopipe URLs are like: https://audiopipe-dev.suno.ai/?item_id=333dd95f-ed98-4c52-b0f5-496bedc5dafc
        clipAudioUrl += '&format=webm';
        this.loadingDuration = true;
      }

      if (clip.status === 'streaming') {
        this.loadingStream = true;
      }

      if (this.isPlaying) {
        this.endTime = this.audioElement.currentTime;
        this.logger.segmentTrack(
          EventNames.audioPlayerEvent,
          this.getAudioPlayerEvent(
            pauseActionName ? pauseActionName : ActionName.playNewSongPauseSong,
            this.clip?.user_id || ''
          ),
          this.root.session
        );
        this.startTime = this.endTime;
      }

      this.clip = clip;
      this.isClipPreloaded = false;
      // Sandwich the source change between a play and pause of the silent audio element
      // to ensure that the media session stays active.
      // Otherwise, the media session will switch to another media app.
      this.silentAudioElement?.play();
      this.audioElement.src = clipAudioUrl;
      this.audioElement.volume = this.volume;
      this.audioElement.muted = this.muted;
      this.audioElement.currentTime = currentTime;
      this.silentAudioElement?.pause();

      if (!isPaused) {
        this.audioElement.play()?.catch((e) => {
          if (
            this.audioElement &&
            clip.audio_url &&
            clipAudioUrl.endsWith('m4a')
          ) {
            // fallback to opus/webm if m4a fails
            if (shouldUseOpusWebm) {
              this.audioElement.src = clipAudioUrl.replace('.m4a', '.webm');
              this.audioElement.play()?.catch(() => {
                // fallback to mp3 if opus also fails
                if (this.audioElement && clip.audio_url) {
                  this.audioElement.src = clip.audio_url;
                  this.audioElement.play()?.catch((e3) => {
                    throw e3;
                  });
                }
              });
            } else {
              // fallback directly to mp3 if opus not supported
              if (clip.audio_url) {
                this.audioElement.src = clip.audio_url;
                this.audioElement.play()?.catch((e2) => {
                  throw e2;
                });
              }
            }
          } else if (
            this.audioElement &&
            clip.audio_url &&
            clipAudioUrl.includes('webm')
          ) {
            // fallback to non-opus (mp3) if opus fails
            this.audioElement.src = clip.audio_url;
            this.audioElement.play()?.catch((e) => {
              throw e;
            });
          } else {
            throw e;
          }
        });
        this.root.session.clearTooltipOnPlay();
      }

      this.isPlaying = !this.audioElement.paused;
      if (!skipPlayCountIncrement) {
        this.incrementPlayCountWithSpec();
      }
      this.logger.segmentTrack(
        EventNames.audioPlayerEvent,
        this.getAudioPlayerEvent(
          playActionName ? playActionName : 'PlayNewSong',
          this.clip?.user_id || ''
        ),
        this.root.session
      );
      // this.incrementPlayCount();
      this.updatePlaybarState();
    });
  }

  setupPlaybarInfoForRadio = ({ currentTime }: { currentTime: number }) => {
    this.currentTime = currentTime;
    this.isClipPreloaded = true;
  };

  playOrTogglePreloadedClip = (clip?: Clip) => {
    const clipToPlay = clip || this.clip;
    if (clipToPlay && !this.isClipPreloaded) {
      this.togglePlay();
    } else {
      this.noClipPlayCallback?.();
    }
  };

  incrementPlayCount = () => {
    this.apiClient.POST('/api/gen/{gen_id}/increment_play_count/', {
      params: {
        path: { gen_id: this.clip?.id || '' },
      },
    });
  };

  incrementPlayCountWithSpec = () => {
    const playCount = this.clip?.play_count ?? 0;

    const baseSampleFactor = 1;
    const playCountFactor = 100;

    const sampleFactor =
      baseSampleFactor + Math.floor(playCount / playCountFactor);

    runInAction(() => {
      // Update the following state when a new song loaded
      this.previousSongSessionId = this.songSessionId;
      this.songSessionId = v4();
      this.startTime = 0;
      this.endTime = 0;
      this.actionIndex = -1;
    });

    this.apiClient.POST('/api/gen/{gen_id}/increment_play_count/v2', {
      params: { path: { gen_id: this.clip?.id || '' } },
      body: { sample_factor: sampleFactor },
    });
  };

  handleEnded = () => {
    this.isPlaying = false;
    if (this.repeat) {
      if (!this.audioElement) {
        return;
      }

      this.audioElement.currentTime = 0;

      this.startTime = 0;
      this.endTime = 0;
      this.isPlaying = true;
      this.audioElement.play().catch((_error) => {
        this.isPlaying = false;
      });
      this.incrementPlayCountWithSpec();
      this.logger.segmentTrack(
        EventNames.audioPlayerEvent,
        this.getAudioPlayerEvent(
          ActionName.autoRepeatPlaySong,
          this.clip?.user_id || ''
        ),
        this.root.session
      );
    } else if (this.root.queue.continuous) {
      this.stepForward(
        false,
        ActionName.autoPlayNewSong,
        ActionName.autoRepeatPauseSong
      );
    }
  };

  toggleShuffle = (shuffle?: boolean) => {
    this.root.queue.toggleShuffle(shuffle);
    this.updatePlaybarState();
  };
  toggleRepeat = (repeat = !this.repeat) => {
    this.repeat = repeat;
    this.updatePlaybarState();
  };

  togglePlay = (shouldPlay = this.audioElement?.paused ?? true) => {
    if (!this.audioElement) return;

    // If starting regular playback while in live radio mode, exit radio mode first
    // Use the internal setter to avoid calling back into togglePlay
    if (shouldPlay && this.isLivingRadioMode) {
      this.setIsLivingRadioModeWithoutTogglePlay(false);
    }
    const wasPlaying = this.isPlaying;

    // No clip? Try playing the first clip in the queue
    runInAction(() => {
      if (!this.clip?.id) {
        if (shouldPlay && this.root.queue.contextClips.length) {
          this.root.queue.contextQueueIndex = 0;
          this.root.queue.activeQueue = 'context';
          this.root.queue.contextClipCount = null;
          const currentClip = this.root.queue.getCurrentClip();
          if (currentClip) {
            this.playClip(currentClip);
          }
        }

        return;
      }
      if (!this.audioElement) return;

      if (shouldPlay) {
        this.audioElement.play()?.catch((e) => {
          if (
            this.audioElement &&
            this.clip?.audio_url &&
            this.audioElement.src &&
            this.audioElement.src.endsWith('m4a')
          ) {
            // fallback to opus/webm if m4a fails for some reason
            const shouldUseOpusWebm = this.shouldUseOpusWebm(this.clip);
            if (shouldUseOpusWebm) {
              this.audioElement.src = this.audioElement.src.replace(
                '.m4a',
                '.webm'
              );
              this.audioElement.play()?.catch(() => {
                // fallback to mp3 if opus also fails
                if (this.audioElement && this.clip?.audio_url) {
                  this.audioElement.src = this.clip.audio_url;
                  this.audioElement.play()?.catch((e3) => {
                    throw e3;
                  });
                }
              });
            } else {
              // fallback directly to mp3 if opus not supported
              this.audioElement.src = this.clip.audio_url;
              this.audioElement.play()?.catch((e2) => {
                throw e2;
              });
            }
          } else if (
            this.audioElement &&
            this.clip?.audio_url &&
            this.audioElement.src &&
            this.audioElement.src.includes('webm')
          ) {
            // fallback to non-opus (mp3) if opus fails for some reason
            this.audioElement.src = this.clip.audio_url;
            this.audioElement.play()?.catch((e) => {
              throw e;
            });
          } else {
            throw e;
          }
        });
        this.isPlaying = true;
        this.endTime = this.audioElement.currentTime;
        this.startTime = this.audioElement.currentTime;
        this.logger.segmentTrack(
          EventNames.audioPlayerEvent,
          this.getAudioPlayerEvent('PlaySong', this.clip?.user_id || ''),
          this.root.session
        );
      } else {
        this.audioElement.pause();
        this.isPlaying = false;
        this.endTime = this.audioElement.currentTime;
        this.logger.segmentTrack(
          EventNames.audioPlayerEvent,
          this.getAudioPlayerEvent('PauseSong', this.clip?.user_id || ''),
          this.root.session
        );
        // NOTE: Updating startTime AFTER pause logging!
        this.startTime = this.endTime;
      }

      // Update backend playbar state only if `isPlaying` actually changed
      if (wasPlaying !== this.isPlaying) {
        this.updatePlaybarState();
      }
    });
  };

  userSetCurrentProgress = (progress: number) => {
    const updatedTime = (progress / 100) * this.duration;
    return this.userSetCurrentProgressWithTime(updatedTime);
  };

  userSetCurrentProgressWithTime = (time: number) => {
    if (!this.audioElement) return;
    const updatedTime = time;
    if (Number.isNaN(updatedTime)) return;
    runInAction(() => {
      if (this.audioElement) {
        if (this.isPlaying) {
          this.endTime = this.audioElement?.currentTime || 0;
          this.logger.segmentTrack(
            EventNames.audioPlayerEvent,
            this.getAudioPlayerEvent(
              ActionName.seekProgressBarPauseSong,
              this.clip?.user_id || ''
            )
          );
          this.startTime = this.endTime;
        }

        this.audioElement.currentTime = updatedTime;
        this.currentTime = this.audioElement.currentTime;

        this.startTime = this.audioElement?.currentTime;
        this.endTime = this.audioElement?.currentTime;
        this.logger.segmentTrack(
          EventNames.audioPlayerEvent,
          this.getAudioPlayerEvent(
            ActionName.seekProgressBarPlaySong,
            this.clip?.user_id || ''
          ),
          this.root.session
        );
      }
    });
    this.updatePlaybarState();
  };

  /**
   * Gets the current time directly from the audio element, which will be more
   * accurate than `currentTime` but doesn't have any inherent reactivity
   */
  getCurrentTime() {
    return this.clip ? this.audioElement?.currentTime || 0 : 0;
  }

  setCurrentTime = () => {
    runInAction(() => {
      this.currentTime = this.audioElement?.currentTime || 0;
    });
  };
  debouncedSetCurrentTime = throttle(this.setCurrentTime, 1000);

  setDuration = (duration: number) => {
    this.duration = duration;
  };

  setIsMobileCommentsModalOpen = (isMobileCommentsModalOpen: boolean) => {
    this.isMobileCommentsModalOpen = isMobileCommentsModalOpen;
  };

  openMobileCommentsModal = () => {
    this.shouldOpenMobileCommentsModal = true;
  };

  resetMobileCommentsModalTrigger = () => {
    this.shouldOpenMobileCommentsModal = false;
  };

  setClip = (clip: Clip | null) => {
    this.setIsLivingRadioMode(false);
    this.clip = clip;
  };

  /**
   * Pause and unset the playbar clip
   *
   * This unsets the audio element `src` to ensure that we don't allow a
   * "ghost" version of the clip to play somehow (e.g. using media keys)
   */
  unsetClip = () => {
    if (this.audioElement) {
      if (!this.audioElement.paused) {
        this.audioElement.pause();
      }
      this.audioElement.currentTime = 0;
      this.audioElement.src = '';
    }
    if (this.clip) {
      this.clip = null;
      this.duration = 0;
      this.currentTime = 0;
      this.startTime = 0;
      this.endTime = 0;
    }
  };

  setLoadingDuration = (loadingDuration: boolean) => {
    this.loadingDuration = loadingDuration;
  };

  setNoClipPlayCallback = (callback: () => void) => {
    this.noClipPlayCallback = callback;
  };

  private restartCurrentSong = (
    actionName: string = ActionName.backwardRepeatSong
  ) => {
    if (!this.audioElement) {
      return false;
    }

    if (this.isPlaying) {
      this.endTime = this.audioElement.currentTime;
      this.logger.segmentTrack(
        EventNames.audioPlayerEvent,
        this.getAudioPlayerEvent(
          this.isPlaying ? 'ForwardRepeatSong' : 'BackwardPauseSong',
          this.clip?.user_id || ''
        ),
        this.root.session
      );
      this.startTime = this.endTime;
    }

    this.audioElement.currentTime = 0;
    this.startTime = 0;
    this.endTime = 0;

    // If audio was playing, start it again
    if (this.isPlaying) {
      this.audioElement.play().catch(() => {
        // Silently handle play errors
      });
    }

    this.logger.segmentTrack(
      EventNames.audioPlayerEvent,
      this.getAudioPlayerEvent(actionName, this.clip?.user_id || ''),
      this.root.session
    );
    this.updatePlaybarState();
    return true;
  };

  stepBackward = () => {
    const REWIND_THRESHOLD_SECS = 3.0;

    // If repeat is on, always restart the current song regardless of current time
    if (this.repeat) {
      this.restartCurrentSong();
      return;
    }

    if (this.currentTime > REWIND_THRESHOLD_SECS) {
      if (this.isPlaying) {
        this.endTime = this.audioElement?.currentTime || 0;
        this.logger.segmentTrack(
          EventNames.audioPlayerEvent,
          this.getAudioPlayerEvent(
            'BackwardPauseSong',
            this.clip?.user_id || ''
          ),
          this.root.session
        );
        this.startTime = this.endTime;
      }
      // seek to beginning of currently playing song
      // if currentTime is greater than the rewind threshold
      if (this.audioElement) {
        this.audioElement.currentTime = 0;
      }
      this.startTime = 0;
      this.endTime = 0;
      this.logger.segmentTrack(
        EventNames.audioPlayerEvent,
        this.getAudioPlayerEvent(
          ActionName.backwardRepeatSong,
          this.clip?.user_id || ''
        ),
        this.root.session
      );
    } else {
      const indexUpdated = this.root.queue.setToPreviousClip();
      const clip = this.root.queue.getCurrentClip();
      this.root.queue.setCurrentPlayingSongIsRemoved(false);

      if (clip && indexUpdated) {
        this.playClip(
          clip,
          ActionName.backwardPlayNewSong,
          ActionName.backwardPauseSong
        );
      } else if (!indexUpdated) {
        // If there's no previous song in the queue, restart the current song (like loop behavior)
        this.restartCurrentSong();
      }
    }
    this.updatePlaybarState();
  };

  canStepForward = () => {
    if (this.disableStepForward) return false;
    // When autoplay is enabled, we can always fetch more clips
    if (this.clip && this.root.queue.autoplay) return true;
    // Manual queue always allows stepping forward
    if (this.root.queue.manualQueue.length > 0) return true;
    // Can't advance if there's no queue or if we're at the end of it
    if (
      !this.root.queue.contextClips.length ||
      (this.root.queue.activeQueue === 'context' &&
        this.root.queue.contextQueueIndex ===
          this.root.queue.contextClips.length - 1)
    )
      return false;
    // By default, allow the button
    return true;
  };

  stepForward = (
    isExplicitNext = true,
    autoForwardPlay?: string | null,
    autoForwardPause?: string | null
  ) => {
    // If repeat is on, restart the current song instead of advancing
    if (this.repeat && !isExplicitNext) {
      return this.restartCurrentSong();
    }

    const indexUpdated = this.root.queue.setToNextClip();

    const clip = this.root.queue.getCurrentClip();
    this.root.queue.setCurrentPlayingSongIsRemoved(false);
    if (clip && indexUpdated) {
      this.playClip(
        this.root.clips.clipById[clip.id],
        autoForwardPlay ? autoForwardPlay : ActionName.forwardPlayNewSong,
        autoForwardPause ? autoForwardPause : ActionName.forwardPausePreSong
      );
      return true;
    }

    // If there's no next song in the queue, restart the current song (like loop behavior)
    if (!indexUpdated) {
      return this.restartCurrentSong();
    }

    return false;
  };

  get progress() {
    if (!this.duration) return 0;
    return this.currentTime / this.duration;
  }

  setVolume = (volume: number) => {
    // Clamp volume between 0 and 1.
    this.volume = Math.max(0, Math.min(1, volume));
    this.muted = this.volume === 0;

    if (this.audioElement) {
      this.audioElement.volume = volume;
      this.audioElement.muted = this.muted;
    }
    this.updatePlaybarState();
  };

  setIsClipPreloaded = (isClipPreloaded: boolean) => {
    this.isClipPreloaded = isClipPreloaded;
  };

  setIsDragging = (isDragging: boolean) => {
    this.isDragging = isDragging;
  };

  toggleMute = () => {
    this.muted = !this.muted;

    // If we just unmuted after manually dragging volume to 0,
    // reset volume.
    const DEFAULT_VOLUME_AFTER_DRAGGING_TO_ZERO = 0.6;
    if (!this.muted && this.volume === 0) {
      this.volume = DEFAULT_VOLUME_AFTER_DRAGGING_TO_ZERO;
    }
    if (this.audioElement) {
      this.audioElement.volume = this.volume;
      this.audioElement.muted = this.muted;
    }
  };

  storageObject = () => {
    return {
      continuous: this.root.queue.continuous,
      prefersContinuous: this.root.queue.prefersContinuous,
      autoplay: this.root.queue.autoplay,
      repeat: this.repeat,
      volume: this.volume,
      muted: this.muted,
      recentlyPlayedIds: this.recentlyPlayedIds,
    };
  };

  initLocalStorageUpdate = () => {
    reaction(this.storageObject, () => {
      if (this.root.isLocalStorageAvailable) {
        const storageStr = JSON.stringify(this.storageObject());
        localStorage.setItem(PLAY_STATE_STORAGE_KEY, storageStr);
      }
    });
  };

  addToRecentlyPlayedIds = (clipId: string) => {
    const MAX_SIZE = 50;
    this.recentlyPlayedIds.push(clipId);
    if (this.recentlyPlayedIds.length > MAX_SIZE) {
      this.recentlyPlayedIds = this.recentlyPlayedIds.slice(0, MAX_SIZE);
    }
  };

  loadFromLocalStorage = () => {
    if (this.root.isLocalStorageAvailable) {
      const storageStr = localStorage.getItem(PLAY_STATE_STORAGE_KEY);
      try {
        const stateJson = JSON.parse(storageStr || '');
        if (stateJson) {
          runInAction(() => {
            if (stateJson.prefersContinuous == null) {
              // Backwards-compatibility with old autoplay state
              this.root.queue.continuous = valueOrDefault(
                stateJson.autoplay,
                true
              );
              this.root.queue.prefersContinuous = valueOrDefault(
                stateJson.autoplay,
                true
              );
              this.root.queue.autoplay = valueOrDefault(
                stateJson.autoplay,
                true
              );
            } else {
              this.root.queue.continuous = valueOrDefault(
                stateJson.continuous,
                true
              );
              this.root.queue.prefersContinuous = valueOrDefault(
                stateJson.prefersContinuous,
                true
              );
              this.root.queue.autoplay = valueOrDefault(
                stateJson.autoplay,
                true
              );
            }
            this.repeat = valueOrDefault(stateJson.repeat, false);
            this.volume = valueOrDefault(stateJson.volume, 1.0);
            this.muted = valueOrDefault(stateJson.muted, false);
            this.recentlyPlayedIds = valueOrDefault(
              stateJson.recentlyPlayedIds,
              []
            );
          });
        }
      } catch (e) {}
    }
  };

  upsertPlaybarState = () => {
    if (this.root.session.user?.id) {
      this.root.apiClient.POST('/api/music_player/playbar_state', {
        body: {
          playbar_state: this.isPlaying ? 'playing' : 'paused',
          song_index:
            this.root?.queue?.activeQueue === 'context'
              ? this.root.queue.contextQueueIndex
              : null,
          song_play_time: this.currentTime,
          repeat_state: this.repeat ? 'repeat' : 'no-repeat',
          action_time: new Date().toISOString(),
          // @TODO: Needs shuffle flag and order
          song_ids_in_queue: this.root.queue.contextClips.map((clip) =>
            clip.id.toString()
          ),
          volume: this.volume * 100,
          device_id: getDeviceId(),
          device_type: getDeviceType(),
          playlist_context: null,
          device_context: null,
          context_id: this.root?.queue.contextId,
          context_type: this.root?.queue?.contextType,
        },
      });
    }
  };

  // Call this method instead of upsertPlaybarState directly
  updatePlaybarState = () => {
    this.debouncedUpsertPlaybarState();
  };

  debouncedUpsertPlaybarState = throttle(this.upsertPlaybarState, 500);

  initPlaybarState = async () => {
    // Only initialize once per session
    if (this.isInitialized) {
      return;
    }
    // if in the song page, we don't need to init the playbar state
    if (
      typeof window !== 'undefined' &&
      (window.location.pathname.includes('/song/') ||
        window.location.pathname.includes('/s/') ||
        window.location.pathname.includes('/home') ||
        window.location.pathname.includes('/create') ||
        window.location.pathname.includes('/studio') ||
        window.location.pathname.includes('/playlist/') ||
        isVideoHooksPath(window.location.pathname))
    ) {
      return;
    }
    const playbarStateResp = await this.root.apiClient.GET(
      '/api/music_player/playbar_state'
    );

    if (
      !playbarStateResp.data ||
      !playbarStateResp.data.song_ids_in_queue ||
      !playbarStateResp.data.song_ids_in_queue.length
    ) {
      return;
    }
    const clipsResponse = await this.root.apiClient.GET(
      '/api/clips/get_songs_by_ids',
      {
        params: {
          query: { ids: playbarStateResp.data.song_ids_in_queue || [] },
        },
      }
    );
    if (!clipsResponse.data) return;

    runInAction(() => {
      this.root.clips.updateClips(clipsResponse.data.clips);
      this.root.queue.setClips(clipsResponse.data.clips);

      this.isPlaying = false; // always set to false for now
      this.currentTime = playbarStateResp.data.song_play_time || 0;
      this.repeat = playbarStateResp.data.repeat_state === 'repeat' || false;
      // do not set volume for now
      // this.volume = playbarStateResp.data.volume
      //   ? playbarStateResp.data.volume / 100
      //   : 1;
      this.root.queue.setPlayContext({
        clips: clipsResponse.data.clips || [],
        contextId: playbarStateResp.data.context_id || '',
        contextType: playbarStateResp.data.context_type as ContextType,
        currentIndex: playbarStateResp.data.song_index || 0,
      });
      const currentClip =
        this.root.queue.contextClips[playbarStateResp.data.song_index || 0];

      if (currentClip) {
        this.clip = currentClip;
        this.isClipPreloaded = false;

        // Important: If we have an audio element, set its source
        // This ensures the audio is ready to play when the user clicks play
        if (this.audioElement && currentClip.audio_url) {
          let clipAudioUrl = currentClip.audio_url;
          if (this.shouldUseOpusWebm(currentClip)) {
            clipAudioUrl = clipAudioUrl.replace('.mp3', '.webm');
            if (clipAudioUrl.includes('audiopipe')) {
              // audiopipe URLs are like: https://audiopipe-dev.suno.ai/?item_id=333dd95f-ed98-4c52-b0f5-496bedc5dafc
              clipAudioUrl += '&format=webm';
            }
          }

          this.audioElement.src = clipAudioUrl;
          this.audioElement.currentTime = this.currentTime;
          this.audioElement.volume = this.volume;

          // Preload the audio but don't play it yet
          this.audioElement.load();
        }

        // Set the duration if available
        if (currentClip.metadata?.duration) {
          this.duration = currentClip.metadata.duration;
          this.loadingDuration = false;
        }
      }

      // And don't let me see you around here again
      this.isInitialized = true;
    });
  };

  get displayVolume(): number {
    if (this.muted) return 0;
    return getDisplayVolumeFromAudioVolume(this.volume);
  }

  async decodeAudio(clip: Clip): Promise<AudioBuffer | null> {
    if (clip?.audio_url) {
      const response = await fetch(clip.audio_url || '');
      const arrayBuffer = await response.arrayBuffer();
      const AudioContext =
        typeof window !== 'undefined'
          ? window.AudioContext || (window as any).webkitAudioContext
          : undefined;
      if (!this.audioContext && AudioContext) {
        this.audioContext = new AudioContext({ sampleRate: 8000 });
      }
      if (this.audioContext) {
        const audioData = await this.audioContext.decodeAudioData(arrayBuffer);
        this.audioData[clip.id] = audioData;
      }
      return this.audioData[clip.id];
    } else {
      return null;
    }
  }
}
