import {
  ExtendedPlayerAPI,
  GroupPlaybackSuspension,
  GroupPlaybackSuspensionReason,
} from '../../utils/GroupPlaybackAPI';
import { Component, ComponentConfig } from '../Component';
import { DOM } from '../../DOM';
import { Event, EventDispatcher, NoArgs } from '../../EventDispatcher';
import { SeekBarLabel } from './SeekBarLabel';
import { UIInstanceManager, SeekPreviewArgs } from '../../UIManager';
import { Timeout } from '../../utils/Timeout';
import { PlayerUtils } from '../../utils/PlayerUtils';
import TimeShiftAvailabilityChangedArgs = PlayerUtils.TimeShiftAvailabilityChangedArgs;
import LiveStreamDetectorEventArgs = PlayerUtils.LiveStreamDetectorEventArgs;
import { TimelineMarker } from '../../UIConfig';
import { PlayerAPI, PlayerEventBase } from 'bitmovin-player';
import { StringUtils } from '../../utils/StringUtils';
import { SeekBarType, SeekBarController } from '../../utils/SeekBarController';
import { i18n } from '../../localization/i18n';
import { BrowserUtils } from '../../utils/BrowserUtils';
import { TimelineMarkersHandler } from '../../utils/TimelineMarkersHandler';
import { getMinBufferLevel } from '../../utils/getMinBufferLevel';

/**
 * Configuration interface for the {@link SeekBar} component.
 *
 * @category Configs
 */
export interface SeekBarConfig extends ComponentConfig {
  /**
   * The label above the seek position.
   */
  label?: SeekBarLabel;
  /**
   * Bar will be vertical instead of horizontal if set to true.
   */
  vertical?: boolean;
  /**
   * The interval in milliseconds in which the playback position on the seek bar will be updated. The shorter the
   * interval, the smoother it looks and the more resource intense it is. The update interval will be kept as steady
   * as possible to avoid jitter.
   * Set to -1 to disable smooth updating and update it on player TimeChanged events instead.
   * Default: 50 (50ms = 20fps).
   */
  smoothPlaybackPositionUpdateIntervalMs?: number;

  /**
   * Used for seekBar control increments and decrements
   */
  keyStepIncrements?: { leftRight: number; upDown: number };

  /**
   * Used to enable/disable snapping to markers on the seek bar when seeking near them.
   * Default: true
   */
  snappingEnabled?: boolean;

  /**
   * Defines tolerance for snapping to markers, if snapping to seek bar markers is enabled.
   */
  snappingRange?: number;

  /**
   * Used to enable/disable seek preview
   */
  enableSeekPreview?: boolean;

  /**
   * Used to add the current playback time into the `aria-label` attribute. This allows screen readers to read the current
   * current time even on platforms that do not support the `aria-valuetext` attribute, such as Samsung Tizen.
   */
  addCurrentTimeToAriaLabel?: boolean;
}

/**
 * Event argument interface for a seek preview event.
 */
export interface SeekPreviewEventArgs extends SeekPreviewArgs {
  /**
   * Tells if the seek preview event comes from a scrubbing.
   */
  scrubbing: boolean;
}

/**
 * @category Components
 */
export interface SeekBarMarker {
  marker: TimelineMarker;
  position: number;
  duration?: number;
  element?: DOM;
}

/**
 * A seek bar to seek within the player's media. It displays the current playback position, amount of buffed data, seek
 * target, and keeps status about an ongoing seek.
 *
 * The seek bar displays different 'bars':
 *  - the playback position, i.e. the position in the media at which the player current playback pointer is positioned
 *  - the buffer position, which usually is the playback position plus the time span that is already buffered ahead
 *  - the seek position, used to preview to where in the timeline a seek will jump to
 *
 *  @category Components
 */
export class SeekBar extends Component<SeekBarConfig> {
  public static readonly SMOOTH_PLAYBACK_POSITION_UPDATE_DISABLED = -1;

  /**
   * The CSS class that is added to the DOM element while the seek bar is in 'seeking' state.
   */
  private static readonly CLASS_SEEKING = 'seeking';
  private static readonly CLASS_CHAPTER_HOVERED = 'hovered';

  private seekBar: DOM;
  private seekBarPlaybackPosition: DOM;
  private seekBarPlaybackPositionMarker: DOM;
  private seekBarBufferPosition: DOM;
  private seekBarSeekPosition: DOM;
  private seekBarBackdrop: DOM;

  private label: SeekBarLabel;

  private seekBarMarkersContainer: DOM;
  private timelineMarkersHandler: TimelineMarkersHandler;

  private uiBoundingRect: DOMRect;

  private player: PlayerAPI;
  private uiManager: UIInstanceManager;

  protected seekBarType: SeekBarType;

  protected isUiShown: boolean;

  /**
   * Buffer of the the current playback position. The position must be buffered in case the element
   * needs to be refreshed with {@link #refreshPlaybackPosition}.
   * @type {number}
   */
  private playbackPositionPercentage = 0;

  private smoothPlaybackPositionUpdater: Timeout;
  private pausedTimeshiftUpdater: Timeout;

  private isUserSeeking = false;
  // We need to track the current ad state manually since our mobile SDKs don't expose an API to check if an ad is active.
  private isAdPlaying: boolean = false;

  private seekBarEvents = {
    /**
     * Fired when a scrubbing seek operation is started.
     */
    onSeek: new EventDispatcher<SeekBar, NoArgs>(),
    /**
     * Fired during a scrubbing seek to indicate that the seek preview (i.e. the video frame) should be updated.
     */
    onSeekPreview: new EventDispatcher<SeekBar, SeekPreviewEventArgs>(),
    /**
     * Fired when a scrubbing seek has finished or when a direct seek is issued.
     */
    onSeeked: new EventDispatcher<SeekBar, number>(),
  };

  constructor(config: SeekBarConfig = {}) {
    super(config);

    const keyStepIncrements = this.config.keyStepIncrements || {
      leftRight: 1,
      upDown: 5,
    };

    this.config = this.mergeConfig(
      config,
      {
        cssClass: 'ui-seekbar',
        vertical: false,
        smoothPlaybackPositionUpdateIntervalMs: 50,
        keyStepIncrements,
        ariaLabel: i18n.getLocalizer('seekBar'),
        tabIndex: 0,
        snappingRange: 1,
        enableSeekPreview: true,
        snappingEnabled: true,
      },
      this.config,
    );

    this.label = this.config.label;
  }

  initialize(): void {
    super.initialize();

    if (this.hasLabel()) {
      this.getLabel().initialize();
    }
  }

  protected setAriaSliderMinMax(min: string, max: string) {
    this.getDomElement().attr('aria-valuemin', min);
    this.getDomElement().attr('aria-valuemax', max);
  }

  private setAriaSliderValues() {
    if (this.seekBarType === SeekBarType.Live) {
      const timeshiftValue = Math.ceil(this.player.getTimeShift()).toString();
      const timeShiftText = `${i18n.performLocalization(i18n.getLocalizer('seekBar.timeshift'))} ${i18n.performLocalization(i18n.getLocalizer('seekBar.value'))}: ${timeshiftValue}`;

      this.getDomElement().attr('aria-valuenow', timeshiftValue);
      this.getDomElement().attr('aria-valuetext', timeShiftText);

      if (this.config.addCurrentTimeToAriaLabel) {
        this.getDomElement().attr('aria-label', `${i18n.performLocalization(this.config.ariaLabel)}: ${timeShiftText}`);
      }
    } else if (this.seekBarType === SeekBarType.Vod) {
      const ariaValueText = `${StringUtils.secondsToText(this.player.getCurrentTime())} ${i18n.performLocalization(i18n.getLocalizer('seekBar.durationText'))} ${StringUtils.secondsToText(this.player.getDuration())}`;
      this.getDomElement().attr('aria-valuenow', Math.floor(this.player.getCurrentTime()).toString());
      this.getDomElement().attr('aria-valuetext', ariaValueText);

      if (this.config.addCurrentTimeToAriaLabel) {
        this.getDomElement().attr(
          'aria-label',
          `${i18n.performLocalization(this.config.ariaLabel)}: ${StringUtils.secondsToText(this.player.getCurrentTime())}`,
        );
      }
    }
  }

  private getPlaybackPositionPercentage(): number {
    if (this.player.isLive()) {
      return 100 - (100 / this.player.getMaxTimeShift()) * this.player.getTimeShift();
    }

    return (100 / this.player.getDuration()) * this.getRelativeCurrentTime();
  }

  private updateBufferLevel(playbackPositionPercentage: number): void {
    let bufferLoadedPercentageLevel: number;
    if (this.player.isLive()) {
      // Always show full buffer for live streams
      bufferLoadedPercentageLevel = 100;
    } else {
      bufferLoadedPercentageLevel = playbackPositionPercentage + getMinBufferLevel(this.player);
    }

    this.setBufferPosition(bufferLoadedPercentageLevel);
  }

  configure(player: PlayerAPI, uimanager: UIInstanceManager, configureSeek: boolean = true): void {
    super.configure(player, uimanager);

    this.player = player;
    this.uiManager = uimanager;

    // Apply scaling transform to the backdrop bar to have all bars rendered similarly
    // (the call must be up here to be executed for the volume slider as well)
    this.setPosition(this.seekBarBackdrop, 100);

    // Add seekbar controls to the seekbar
    const seekBarController = new SeekBarController(
      this.config.keyStepIncrements,
      player,
      uimanager.getConfig().volumeController,
    );

    seekBarController.setSeekBarControls(this.getDomElement(), () => this.seekBarType);

    // The configureSeek flag can be used by subclasses to disable configuration as seek bar. E.g. the volume
    // slider is reusing this component but adds its own functionality, and does not need the seek functionality.
    // This is actually a hack, the proper solution would be for both seek bar and volume sliders to extend
    // a common base slider component and implement their functionality there.
    if (!configureSeek) {
      this.seekBarType = SeekBarType.Volume;

      return;
    }

    const resumeSeekBarUpdates = () => {
      this.isUiShown = true;
      if (this.smoothPlaybackPositionUpdater && !player.isLive() && !this.smoothPlaybackPositionUpdater.isActive()) {
        playbackPositionHandler(null, true);
        this.smoothPlaybackPositionUpdater.start();
      }
    };

    player.on(player.exports.PlayerEvent.AdStarted, () => {
      this.isAdPlaying = true;
      resumeSeekBarUpdates();
    });
    player.on(player.exports.PlayerEvent.AdBreakFinished, () => {
      this.isAdPlaying = false;
    });
    player.on(player.exports.PlayerEvent.SourceUnloaded, () => {
      this.isAdPlaying = false;
    });

    uimanager.onControlsShow.subscribe(resumeSeekBarUpdates);

    uimanager.onControlsHide.subscribe(() => {
      // Keep seekbar always active during the playback of a linear ad
      if (this.isAdPlaying) {
        return;
      }

      this.isUiShown = false;
      if (this.smoothPlaybackPositionUpdater && this.smoothPlaybackPositionUpdater.isActive()) {
        this.smoothPlaybackPositionUpdater.clear();
      }
    });

    let isPlaying = false;
    let scrubbing = false;
    let isPlayerSeeking = false;
    let suspension: GroupPlaybackSuspension | undefined;

    // Update playback and buffer positions
    const playbackPositionHandler = (event: PlayerEventBase = null, forceUpdate: boolean = false) => {
      if (this.isUserSeeking) {
        // We caught a seek preview seek, do not update the seekbar
        return;
      }

      let playbackPositionPercentage = this.getPlaybackPositionPercentage();

      this.updateBufferLevel(playbackPositionPercentage);

      // The segment request finished is used to help the playback position move, when the smooth playback position is not enabled.
      // At the same time when the user is scrubbing, we also move the position of the seekbar to display a preview during scrubbing.
      // When the user is scrubbing we do not record this as a user seek operation, as the user has yet to finish their seek,
      // but we should not move the playback position to not create a jumping behaviour.
      if (
        scrubbing &&
        event &&
        event.type === player.exports.PlayerEvent.SegmentRequestFinished &&
        playbackPositionPercentage !== this.playbackPositionPercentage
      ) {
        playbackPositionPercentage = this.playbackPositionPercentage;
      }

      if (player.isLive()) {
        if (player.getMaxTimeShift() === 0) {
          // This case must be explicitly handled to avoid division by zero
          this.setPlaybackPosition(100);
        } else {
          if (!this.isSeeking()) {
            this.setPlaybackPosition(playbackPositionPercentage);
          }

          this.setAriaSliderMinMax(player.getMaxTimeShift().toString(), '0');
        }
      } else {
        // Update playback position only in paused state or in the initial startup state where player is neither
        // paused nor playing. Playback updates are handled in the Timeout below.
        const isInInitialStartupState =
          this.config.smoothPlaybackPositionUpdateIntervalMs === SeekBar.SMOOTH_PLAYBACK_POSITION_UPDATE_DISABLED ||
          forceUpdate ||
          player.isPaused();
        const isNeitherPausedNorPlaying = player.isPaused() === player.isPlaying();

        if ((isInInitialStartupState || isNeitherPausedNorPlaying) && !this.isSeeking()) {
          this.setPlaybackPosition(playbackPositionPercentage);
        }

        this.setAriaSliderMinMax('0', player.getDuration().toString());
      }

      if (this.isUiShown) {
        this.setAriaSliderValues();
      }
    };

    // Update seekbar upon these events
    // init playback position when the player is ready
    player.on(player.exports.PlayerEvent.Ready, playbackPositionHandler);
    // update playback position when it changes
    player.on(player.exports.PlayerEvent.TimeChanged, playbackPositionHandler);
    // update bufferlevel when buffering is complete
    player.on(player.exports.PlayerEvent.StallEnded, playbackPositionHandler);
    // update playback position when a timeshift has finished
    player.on(player.exports.PlayerEvent.TimeShifted, playbackPositionHandler);
    // update bufferlevel when a segment has been downloaded
    player.on(player.exports.PlayerEvent.SegmentRequestFinished, playbackPositionHandler);

    this.configureLivePausedTimeshiftUpdater(player, uimanager, playbackPositionHandler);

    // Seek handling
    const onPlayerSeek = () => {
      isPlayerSeeking = true;
      this.setSeeking(true);
      scrubbing = false;
    };

    const onPlayerSeeked = (event: PlayerEventBase = null) => {
      isPlayerSeeking = false;
      this.setSeeking(false);

      // update playback position when a seek has finished
      playbackPositionHandler(event, true);
    };

    const restorePlayingState = function () {
      // Continue playback after seek if player was playing when seek started
      if (isPlaying) {
        // use the same issuer here as in the pause on seek
        player.play('ui-seek');
      }
    };

    player.on(player.exports.PlayerEvent.Seek, onPlayerSeek);
    player.on(player.exports.PlayerEvent.Seeked, onPlayerSeeked);
    player.on(player.exports.PlayerEvent.TimeShift, onPlayerSeek);
    player.on(player.exports.PlayerEvent.TimeShifted, onPlayerSeeked);

    const isGroupPlaybackAPIAvailable = (player: PlayerAPI): player is ExtendedPlayerAPI => {
      return !!(player as ExtendedPlayerAPI).groupPlayback;
    };

    this.onSeek.subscribe(sender => {
      // track seeking status so we can catch events from seek preview seeks
      this.isUserSeeking = true;
      // Notify UI manager of started seek
      uimanager.onSeek.dispatch(sender);

      if (isGroupPlaybackAPIAvailable(player) && player.groupPlayback.hasJoined() && !suspension) {
        suspension = player.groupPlayback.beginSuspension(GroupPlaybackSuspensionReason.UserIsScrubbing);
      }

      // Save current playback state before performing the seek
      if (!isPlayerSeeking) {
        isPlaying = player.isPlaying();

        // Pause playback while seeking
        if (isPlaying) {
          // use a different issuer here, as play/pause on seek is not "really" triggerd by the user
          player.pause('ui-seek');
        }
      }
    });

    this.onSeekPreview.subscribe((sender: SeekBar, args: SeekPreviewEventArgs) => {
      // Notify UI manager of seek preview
      uimanager.onSeekPreview.dispatch(sender, args);
      scrubbing = args.scrubbing;
    });

    // Set enableSeekPreview if set in the uimanager config
    if (typeof uimanager.getConfig().enableSeekPreview === 'boolean') {
      this.config.enableSeekPreview = uimanager.getConfig().enableSeekPreview;
    }

    // Rate-limited scrubbing seek
    if (this.config.enableSeekPreview) {
      this.onSeekPreview.subscribeRateLimited(this.seekWhileScrubbing, 200);
    }

    this.onSeeked.subscribe((sender, percentage) => {
      this.isUserSeeking = false;

      // Do the seek
      this.seek(percentage);

      // Notify UI manager of finished seek
      uimanager.onSeeked.dispatch(sender);

      // Continue playback after seek if player was playing when seek started
      restorePlayingState();

      if (isGroupPlaybackAPIAvailable(player) && player.groupPlayback.hasJoined() && suspension) {
        const proposedPlaybackTime = this.getTargetSeekPosition(percentage);
        player.groupPlayback.endSuspension(suspension, { proposedPlaybackTime });
        suspension = undefined;
      }
    });

    if (this.hasLabel()) {
      // Configure a seekbar label that is internal to the seekbar)
      this.getLabel().configure(player, uimanager);
    }

    // Hide seekbar for live sources without timeshift
    let isLive = false;
    let hasTimeShift = false;
    const switchVisibility = (isLive: boolean, hasTimeShift: boolean) => {
      if (isLive && !hasTimeShift) {
        this.hide();
      } else {
        this.show();
      }
      playbackPositionHandler(null, true);
      this.refreshPlaybackPosition();
    };
    const liveStreamDetector = new PlayerUtils.LiveStreamDetector(player, uimanager);
    liveStreamDetector.onLiveChanged.subscribe((sender, args: LiveStreamDetectorEventArgs) => {
      isLive = args.live;
      if (isLive && this.smoothPlaybackPositionUpdater != null) {
        this.smoothPlaybackPositionUpdater.clear();
        this.seekBarType = SeekBarType.Live;
      } else {
        this.seekBarType = SeekBarType.Vod;
      }
      switchVisibility(isLive, hasTimeShift);
    });
    const timeShiftDetector = new PlayerUtils.TimeShiftAvailabilityDetector(player);
    timeShiftDetector.onTimeShiftAvailabilityChanged.subscribe((sender, args: TimeShiftAvailabilityChangedArgs) => {
      hasTimeShift = args.timeShiftAvailable;
      switchVisibility(isLive, hasTimeShift);
    });
    // Initial detection
    liveStreamDetector.detect();
    timeShiftDetector.detect();

    // Refresh the playback position when the player resized or the UI is configured. The playback position marker
    // is positioned absolutely and must therefore be updated when the size of the seekbar changes.
    player.on(player.exports.PlayerEvent.PlayerResized, () => {
      this.refreshPlaybackPosition();
      this.uiBoundingRect = this.uiManager.getUI().getDomElement().get(0).getBoundingClientRect();
    });
    // Additionally, when this code is called, the seekbar is not part of the UI yet and therefore does not have a size,
    // resulting in a wrong initial position of the marker. Refreshing it once the UI is configured solved this issue.
    uimanager.onConfigured.subscribe(() => {
      this.refreshPlaybackPosition();
    });
    // It can also happen when a new source is loaded
    player.on(player.exports.PlayerEvent.SourceLoaded, () => {
      this.refreshPlaybackPosition();
    });
    // Add markers when a source is loaded or update when a marker is added or removed
    uimanager.getConfig().events.onUpdated.subscribe(() => {
      playbackPositionHandler();
    });

    // Set the snappingRange if set in the uimanager config
    if (typeof uimanager.getConfig().seekbarSnappingRange === 'number') {
      this.config.snappingRange = uimanager.getConfig().seekbarSnappingRange;
    }

    if (typeof uimanager.getConfig().seekbarSnappingEnabled === 'boolean') {
      this.config.snappingEnabled = uimanager.getConfig().seekbarSnappingEnabled;
    }

    // Initialize seekbar
    playbackPositionHandler(); // Set the playback position
    this.setBufferPosition(0);
    this.setSeekPosition(0);
    if (this.config.smoothPlaybackPositionUpdateIntervalMs !== SeekBar.SMOOTH_PLAYBACK_POSITION_UPDATE_DISABLED) {
      this.configureSmoothPlaybackPositionUpdater(player, uimanager);
    }

    // Initialize markers
    this.initializeTimelineMarkers(player, uimanager);
  }

  private initializeTimelineMarkers(player: PlayerAPI, uimanager: UIInstanceManager): void {
    const timelineMarkerConfig = {
      cssPrefix: this.config.cssPrefix,
      snappingRange: this.config.snappingRange,
    };
    this.timelineMarkersHandler = new TimelineMarkersHandler(
      timelineMarkerConfig,
      () => this.seekBar.width(),
      this.seekBarMarkersContainer,
    );
    this.timelineMarkersHandler.initialize(player, uimanager);
  }

  private seekWhileScrubbing = (sender: SeekBar, args: SeekPreviewEventArgs) => {
    if (args.scrubbing) {
      this.seek(args.position);
    }
  };

  private getTargetSeekPosition = (percentage: number) => {
    let target: number;
    if (this.player.isLive()) {
      const maxTimeShift = this.player.getMaxTimeShift();
      target = maxTimeShift - maxTimeShift * (percentage / 100);
    } else {
      const seekableRangeStart = PlayerUtils.getSeekableRangeStart(this.player, 0);
      const relativeSeekTarget = this.player.getDuration() * (percentage / 100);
      target = relativeSeekTarget + seekableRangeStart;
    }

    return target;
  };

  private seek = (percentage: number) => {
    const targetPlaybackPosition = this.getTargetSeekPosition(percentage);
    if (this.player.isLive()) {
      this.player.timeShift(targetPlaybackPosition, 'ui');
    } else {
      this.player.seek(targetPlaybackPosition, 'ui');
    }
  };

  /**
   * Update seekbar while a live stream with DVR window is paused.
   * The playback position stays still and the position indicator visually moves towards the back.
   */
  private configureLivePausedTimeshiftUpdater(
    player: PlayerAPI,
    uimanager: UIInstanceManager,
    playbackPositionHandler: () => void,
  ): void {
    // Regularly update the playback position while the timeout is active
    this.pausedTimeshiftUpdater = new Timeout(1000, playbackPositionHandler, true);

    // Start updater when a live stream with timeshift window is paused
    player.on(player.exports.PlayerEvent.Paused, () => {
      if (player.isLive() && player.getMaxTimeShift() < 0) {
        this.pausedTimeshiftUpdater.start();
      }
    });

    // Stop updater when playback continues (no matter if the updater was started before)
    player.on(player.exports.PlayerEvent.Play, () => this.pausedTimeshiftUpdater.clear());
  }

  private configureSmoothPlaybackPositionUpdater(player: PlayerAPI, uimanager: UIInstanceManager): void {
    /*
     * Playback position update
     *
     * We do not update the position directly from the TimeChanged event, because it arrives very jittery and
     * results in a jittery position indicator since the CSS transition time is statically set.
     * To work around this issue, we maintain a local playback position that is updated in a stable regular interval
     * and kept in sync with the player.
     */
    let currentTimeSeekBar = 0;
    let currentTimePlayer = 0;
    const updateIntervalMs = 50;
    const currentTimeUpdateDeltaSecs = updateIntervalMs / 1000;

    this.smoothPlaybackPositionUpdater = new Timeout(
      updateIntervalMs,
      () => {
        if (this.isSeeking()) {
          return;
        }

        try {
          // Reset the currentTimeSeekBar and set the position to 0 if the player has no duration
          if (this.player.getDuration() === 0) {
            this.setPlaybackPosition(0);
            currentTimeSeekBar = 0;
            return;
          }

          currentTimeSeekBar += currentTimeUpdateDeltaSecs;

          currentTimePlayer = this.getRelativeCurrentTime();
        } catch (error) {
          // Detect if the player has been destroyed and stop updating if so
          if (error instanceof player.exports.PlayerAPINotAvailableError) {
            this.smoothPlaybackPositionUpdater.clear();
          }

          // If the current time cannot be read it makes no sense to continue
          return;
        }

        // Sync currentTime of seekbar to player
        const currentTimeDelta = currentTimeSeekBar - currentTimePlayer;
        // If the delta is larger that 2 secs, directly jump the seekbar to the
        // player time instead of smoothly fast forwarding/rewinding.
        if (Math.abs(currentTimeDelta) > 2) {
          currentTimeSeekBar = currentTimePlayer;
        }
        // If currentTimeDelta is negative and below the adjustment threshold,
        // the player is ahead of the seekbar and we 'fast forward' the seekbar
        else if (currentTimeDelta <= -currentTimeUpdateDeltaSecs) {
          currentTimeSeekBar += currentTimeUpdateDeltaSecs;
        }
        // If currentTimeDelta is positive and above the adjustment threshold,
        // the player is behind the seekbar and we 'rewind' the seekbar
        else if (currentTimeDelta >= currentTimeUpdateDeltaSecs) {
          currentTimeSeekBar -= currentTimeUpdateDeltaSecs;
        }

        const playbackPositionPercentage = (100 / player.getDuration()) * currentTimeSeekBar;
        this.setPlaybackPosition(playbackPositionPercentage);
      },
      true,
    );

    const startSmoothPlaybackPositionUpdater = () => {
      if (!player.isLive()) {
        currentTimeSeekBar = this.getRelativeCurrentTime();
        this.smoothPlaybackPositionUpdater.start();
      }
    };

    const stopSmoothPlaybackPositionUpdater = () => {
      this.smoothPlaybackPositionUpdater.clear();
    };

    player.on(player.exports.PlayerEvent.Play, startSmoothPlaybackPositionUpdater);
    player.on(player.exports.PlayerEvent.Playing, startSmoothPlaybackPositionUpdater);
    player.on(player.exports.PlayerEvent.Paused, stopSmoothPlaybackPositionUpdater);
    player.on(player.exports.PlayerEvent.PlaybackFinished, stopSmoothPlaybackPositionUpdater);
    player.on(player.exports.PlayerEvent.Seeked, () => {
      currentTimeSeekBar = this.getRelativeCurrentTime();
    });
    player.on(player.exports.PlayerEvent.SourceUnloaded, stopSmoothPlaybackPositionUpdater);

    if (player.isPlaying()) {
      startSmoothPlaybackPositionUpdater();
    }
  }

  private getRelativeCurrentTime(): number {
    return PlayerUtils.getCurrentTimeRelativeToSeekableRange(this.player);
  }

  release(): void {
    super.release();

    if (this.smoothPlaybackPositionUpdater) {
      // object must not necessarily exist, e.g. in volume slider subclass
      this.smoothPlaybackPositionUpdater.clear();
    }

    if (this.pausedTimeshiftUpdater) {
      this.pausedTimeshiftUpdater.clear();
    }

    if (this.config.enableSeekPreview) {
      this.onSeekPreview.unsubscribe(this.seekWhileScrubbing);
    }
  }

  protected toDomElement(): DOM {
    if (this.config.vertical) {
      this.config.cssClasses.push('vertical');
    }

    const seekBarContainer = new DOM(
      'div',
      {
        id: this.config.id,
        class: this.getCssClasses(),
        role: 'slider',
        'aria-label': i18n.performLocalization(this.config.ariaLabel),
        tabindex: this.config.tabIndex.toString(),
      },
      this,
    );

    const seekBar = new DOM('div', {
      class: this.prefixCss('seekbar'),
    });
    this.seekBar = seekBar;

    const seekBarBarsContainer = new DOM('div', {
      class: this.prefixCss('seekbar-bars'),
    });

    // Indicator that shows the buffer fill level
    const seekBarBufferLevel = new DOM('div', {
      class: this.prefixCss('seekbar-bufferlevel'),
    });
    this.seekBarBufferPosition = seekBarBufferLevel;

    // Indicator that shows the current playback position
    const seekBarPlaybackPosition = new DOM('div', {
      class: this.prefixCss('seekbar-playbackposition'),
    });
    this.seekBarPlaybackPosition = seekBarPlaybackPosition;

    // A marker of the current playback position, e.g. a dot or line
    const seekBarPlaybackPositionMarker = new DOM('div', {
      class: this.prefixCss('seekbar-playbackposition-marker'),
    });
    this.seekBarPlaybackPositionMarker = seekBarPlaybackPositionMarker;

    // Indicator that show where a seek will go to
    const seekBarSeekPosition = new DOM('div', {
      class: this.prefixCss('seekbar-seekposition'),
    });
    this.seekBarSeekPosition = seekBarSeekPosition;

    // Indicator that shows the full seekbar
    const seekBarBackdrop = new DOM('div', {
      class: this.prefixCss('seekbar-backdrop'),
    });
    this.seekBarBackdrop = seekBarBackdrop;

    const seekBarChapterMarkersContainer = new DOM('div', {
      class: this.prefixCss('seekbar-markers'),
    });
    this.seekBarMarkersContainer = seekBarChapterMarkersContainer;

    seekBarBarsContainer.append(
      this.seekBarBackdrop,
      this.seekBarBufferPosition,
      this.seekBarSeekPosition,
      this.seekBarPlaybackPosition,
    );

    seekBar.append(seekBarBarsContainer, this.seekBarMarkersContainer, this.seekBarPlaybackPositionMarker);

    let seeking = false;

    // Define handler functions so we can attach/remove them later
    const mouseTouchMoveHandler = (e: MouseEvent | TouchEvent) => {
      e.preventDefault();
      // Avoid propagation to VR handler
      if (this.player.vr != null) {
        e.stopPropagation();
      }

      const offset = this.getOffset(e);
      const targetPercentage = 100 * offset;
      const seekPositionPx = offset * this.seekBar.width();

      this.setSeekPosition(targetPercentage);
      this.setPlaybackPosition(targetPercentage);
      this.onSeekPreviewEvent(targetPercentage, seekPositionPx, true);
    };

    const mouseTouchUpHandler = (e: MouseEvent | TouchEvent) => {
      e.preventDefault();

      // Remove handlers, seek operation is finished
      new DOM(document).off('touchmove mousemove', mouseTouchMoveHandler);
      new DOM(document).off('touchend mouseup', mouseTouchUpHandler);

      let targetPercentage = 100 * this.getOffset(e);

      if (this.config.snappingEnabled) {
        const matchingMarker = this.timelineMarkersHandler?.getMarkerAtPosition(targetPercentage);
        targetPercentage = matchingMarker ? matchingMarker.position : targetPercentage;
      }

      this.setSeeking(false);
      seeking = false;

      // Update the UI in case we only have a click or touch
      this.setSeekPosition(targetPercentage);
      this.setPlaybackPosition(targetPercentage);

      // Fire seeked event
      this.onSeekedEvent(targetPercentage);
    };

    // A seek always start with a touchstart or mousedown directly on the seekbar.
    // To track a mouse seek also outside the seekbar (for touch events this works automatically),
    // so the user does not need to take care that the mouse always stays on the seekbar, we attach the mousemove
    // and mouseup handlers to the whole document. A seek is triggered when the user lifts the mouse key.
    // A seek mouse gesture is thus basically a click with a long time frame between down and up events.
    seekBar.on('touchstart mousedown', (e: MouseEvent | TouchEvent) => {
      const isTouchEvent = BrowserUtils.isTouchSupported && this.isTouchEvent(e);

      // Prevent selection of DOM elements (also prevents mousedown if current event is touchstart)
      e.preventDefault();
      // Avoid propagation to VR handler
      if (this.player.vr != null) {
        e.stopPropagation();
      }

      this.setSeeking(true); // Set seeking class on DOM element
      seeking = true; // Set seek tracking flag

      // Fire seeked event
      this.onSeekEvent();

      // Add handler to track the seek operation over the whole document
      // This enables that scrubbing doesn't require the mouse to stay inside the UI elements itself and works
      // on the whole document.
      new DOM(document).on(isTouchEvent ? 'touchmove' : 'mousemove', mouseTouchMoveHandler);
      new DOM(document).on(isTouchEvent ? 'touchend' : 'mouseup', mouseTouchUpHandler);
    });

    // Display seek target indicator when mouse hovers or finger slides over seekbar
    seekBar.on('touchmove mousemove', (e: MouseEvent | TouchEvent) => {
      e.preventDefault();

      if (seeking) {
        mouseTouchMoveHandler(e);
      }

      const offset = this.getOffset(e);
      const seekPositionPercentage = 100 * offset;
      const seekPositionPx = offset * this.seekBar.width();

      this.setSeekPosition(seekPositionPercentage);
      this.onSeekPreviewEvent(seekPositionPercentage, seekPositionPx, false);

      if (this.hasLabel() && this.getLabel().isHidden()) {
        this.getLabel().show();
      }
    });

    // Hide seek target indicator when mouse or finger leaves seekbar
    seekBar.on('touchend mouseleave', (e: MouseEvent | TouchEvent) => {
      e.preventDefault();

      this.setSeekPosition(0);

      if (this.hasLabel()) {
        this.getLabel().hide();
      }
    });

    seekBarContainer.append(seekBar);

    if (this.label) {
      seekBarContainer.append(this.label.getDomElement());
    }

    return seekBarContainer;
  }

  /**
   * Gets the horizontal offset of a mouse/touch event point from the left edge of the seek bar.
   * @param eventPageX the pageX coordinate of an event to calculate the offset from
   * @returns {number} a number in the range of [0, 1], where 0 is the left edge and 1 is the right edge
   */
  private getHorizontalOffset(eventPageX: number): number {
    const elementOffsetPx = this.seekBar.offset().left;
    const widthPx = this.seekBar.width();
    const offsetPx = eventPageX - elementOffsetPx;
    const offset = (1 / widthPx) * offsetPx;

    return this.sanitizeOffset(offset);
  }

  /**
   * Gets the vertical offset of a mouse/touch event point from the bottom edge of the seek bar.
   * @param eventPageY the pageX coordinate of an event to calculate the offset from
   * @returns {number} a number in the range of [0, 1], where 0 is the bottom edge and 1 is the top edge
   */
  private getVerticalOffset(eventPageY: number): number {
    const elementOffsetPx = this.seekBar.offset().top;
    const widthPx = this.seekBar.height();
    const offsetPx = eventPageY - elementOffsetPx;
    const offset = (1 / widthPx) * offsetPx;

    return 1 - this.sanitizeOffset(offset);
  }

  /**
   * Gets the mouse or touch event offset for the current configuration (horizontal or vertical).
   * @param e the event to calculate the offset from
   * @returns {number} a number in the range of [0, 1]
   * @see #getHorizontalOffset
   * @see #getVerticalOffset
   */
  private getOffset(e: MouseEvent | TouchEvent): number {
    if (BrowserUtils.isTouchSupported && this.isTouchEvent(e)) {
      if (this.config.vertical) {
        return this.getVerticalOffset(e.type === 'touchend' ? e.changedTouches[0].pageY : e.touches[0].pageY);
      } else {
        return this.getHorizontalOffset(e.type === 'touchend' ? e.changedTouches[0].pageX : e.touches[0].pageX);
      }
    } else if (e instanceof MouseEvent) {
      if (this.config.vertical) {
        return this.getVerticalOffset(e.pageY);
      } else {
        return this.getHorizontalOffset(e.pageX);
      }
    } else {
      if (console) {
        console.warn('invalid event');
      }
      return 0;
    }
  }

  /**
   * Sanitizes the mouse offset to the range of [0, 1].
   *
   * When tracking the mouse outside the seek bar, the offset can be outside the desired range and this method
   * limits it to the desired range. E.g. a mouse event left of the left edge of a seek bar yields an offset below
   * zero, but to display the seek target on the seek bar, we need to limit it to zero.
   *
   * @param offset the offset to sanitize
   * @returns {number} the sanitized offset.
   */
  private sanitizeOffset(offset: number) {
    // Since we track mouse moves over the whole document, the target can be outside the seek range,
    // and we need to limit it to the [0, 1] range.
    if (offset < 0) {
      offset = 0;
    } else if (offset > 1) {
      offset = 1;
    }

    return offset;
  }

  /**
   * Sets the position of the playback position indicator.
   * @param percent a number between 0 and 100 as returned by the player
   */
  setPlaybackPosition(percent: number) {
    this.playbackPositionPercentage = percent;

    // Set position of the bar
    this.setPosition(this.seekBarPlaybackPosition, percent);

    // Set position of the marker
    const totalSize = this.config.vertical
      ? this.seekBar.height() - this.seekBarPlaybackPositionMarker.height()
      : this.seekBar.width();
    let px = (totalSize / 100) * percent;
    if (this.config.vertical) {
      px = this.seekBar.height() - px - this.seekBarPlaybackPositionMarker.height();
    }

    const style = this.config.vertical
      ? // -ms-transform required for IE9
        // -webkit-transform required for Android 4.4 WebView
        {
          transform: 'translateY(' + px + 'px)',
          '-ms-transform': 'translateY(' + px + 'px)',
          '-webkit-transform': 'translateY(' + px + 'px)',
        }
      : {
          transform: 'translateX(' + px + 'px)',
          '-ms-transform': 'translateX(' + px + 'px)',
          '-webkit-transform': 'translateX(' + px + 'px)',
        };
    this.seekBarPlaybackPositionMarker.css(style);
  }

  /**
   * Refreshes the playback position. Can be used by subclasses to refresh the position when
   * the size of the component changes.
   */
  protected refreshPlaybackPosition() {
    this.setPlaybackPosition(this.playbackPositionPercentage);
  }

  /**
   * Sets the position until which media is buffered.
   * @param percent a number between 0 and 100
   */
  setBufferPosition(percent: number) {
    this.setPosition(this.seekBarBufferPosition, percent);
  }

  /**
   * Sets the position where a seek, if executed, would jump to.
   * @param percent a number between 0 and 100
   */
  setSeekPosition(percent: number) {
    this.setPosition(this.seekBarSeekPosition, percent);
  }

  /**
   * Set the actual position (width or height) of a DOM element that represent a bar in the seek bar.
   * @param element the element to set the position for
   * @param percent a number between 0 and 100
   */
  private setPosition(element: DOM, percent: number) {
    let scale = percent / 100;

    // When the scale is exactly 1 or very near 1 (and the browser internally rounds it to 1), browsers seem to render
    // the elements differently and the height gets slightly off, leading to mismatching heights when e.g. the buffer
    // level bar has a width of 1 and the playback position bar has a width < 1. A jittering buffer level around 1
    // leads to an even worse flickering effect.
    // Various changes in CSS styling and DOM hierarchy did not solve the issue so the workaround is to avoid a scale
    // of exactly 1.
    if (scale >= 0.99999 && scale <= 1.00001) {
      scale = 0.99999;
    }

    const style = this.config.vertical
      ? // -ms-transform required for IE9
        // -webkit-transform required for Android 4.4 WebView
        {
          transform: 'scaleY(' + scale + ')',
          '-ms-transform': 'scaleY(' + scale + ')',
          '-webkit-transform': 'scaleY(' + scale + ')',
        }
      : {
          transform: 'scaleX(' + scale + ')',
          '-ms-transform': 'scaleX(' + scale + ')',
          '-webkit-transform': 'scaleX(' + scale + ')',
        };
    element.css(style);
  }

  /**
   * Puts the seek bar into or out of seeking state by adding/removing a class to the DOM element. This can be used
   * to adjust the styling while seeking.
   *
   * @param seeking should be true when entering seek state, false when exiting the seek state
   */
  setSeeking(seeking: boolean) {
    if (seeking) {
      this.getDomElement().addClass(this.prefixCss(SeekBar.CLASS_SEEKING));
    } else {
      this.getDomElement().removeClass(this.prefixCss(SeekBar.CLASS_SEEKING));
    }
  }

  /**
   * Checks if the seek bar is currently in the seek state.
   * @returns {boolean} true if in seek state, else false
   */
  isSeeking(): boolean {
    return this.getDomElement().hasClass(this.prefixCss(SeekBar.CLASS_SEEKING));
  }

  /**
   * Checks if the seek bar has a {@link SeekBarLabel}.
   * @returns {boolean} true if the seek bar has a label, else false
   */
  hasLabel(): boolean {
    return this.label != null;
  }

  /**
   * Gets the label of this seek bar.
   * @returns {SeekBarLabel} the label if this seek bar has a label, else null
   */
  getLabel(): SeekBarLabel | null {
    return this.label;
  }

  protected onSeekEvent() {
    this.seekBarEvents.onSeek.dispatch(this);
  }

  private updateLabelPosition = (pixelPosition: number) => {
    if (!this.uiBoundingRect) {
      this.uiBoundingRect = this.uiManager.getUI().getDomElement().get(0).getBoundingClientRect();
    }

    this.label.setPositionInBounds(pixelPosition, this.uiBoundingRect);
  };

  private clearAllThickenedMarkers(): void {
    this.seekBarMarkersContainer
      .find(`.${this.prefixCss('seekbar-marker')}`)
      ?.removeClass(this.prefixCss(SeekBar.CLASS_CHAPTER_HOVERED));
  }

  private thickenMarker(marker: SeekBarMarker | null): void {
    if (marker?.element) {
      marker.element.addClass(this.prefixCss(SeekBar.CLASS_CHAPTER_HOVERED));
    }
  }

  protected onSeekPreviewEvent(percentage: number, targetOffsetPx: number, scrubbing: boolean) {
    const snappedMarker = this.timelineMarkersHandler && this.timelineMarkersHandler.getMarkerAtPosition(percentage);

    let seekPositionPercentage = percentage;

    if (snappedMarker) {
      if (snappedMarker.duration > 0) {
        if (percentage < snappedMarker.position) {
          // Snap the position to the start of the interval if the seek is within the left snap margin
          // We know that we are within a snap margin when we are outside the marker interval but still
          // have a snappedMarker
          seekPositionPercentage = snappedMarker.position;
        } else if (percentage > snappedMarker.position + snappedMarker.duration) {
          // Snap the position to the end of the interval if the seek is within the right snap margin
          seekPositionPercentage = snappedMarker.position + snappedMarker.duration;
        }
      } else {
        // Position markers always snap to their marker position
        seekPositionPercentage = snappedMarker.position;
      }
    }

    if (this.label) {
      this.updateLabelPosition(targetOffsetPx);
    }

    if (scrubbing) {
      this.clearAllThickenedMarkers();
      this.thickenMarker(snappedMarker);
    }

    this.seekBarEvents.onSeekPreview.dispatch(this, {
      scrubbing: scrubbing,
      position: seekPositionPercentage,
      marker: snappedMarker,
    });
  }

  protected onSeekedEvent(percentage: number) {
    this.clearAllThickenedMarkers();
    this.seekBarEvents.onSeeked.dispatch(this, percentage);
  }

  /**
   * Gets the event that is fired when a scrubbing seek operation is started.
   * @returns {Event<SeekBar, NoArgs>}
   */
  get onSeek(): Event<SeekBar, NoArgs> {
    return this.seekBarEvents.onSeek.getEvent();
  }

  /**
   * Gets the event that is fired during a scrubbing seek (to indicate that the seek preview, i.e. the video frame,
   * should be updated), or during a normal seek preview when the seek bar is hovered (and the seek target,
   * i.e. the seek bar label, should be updated).
   * @returns {Event<SeekBar, SeekPreviewEventArgs>}
   */
  get onSeekPreview(): Event<SeekBar, SeekPreviewEventArgs> {
    return this.seekBarEvents.onSeekPreview.getEvent();
  }

  /**
   * Gets the event that is fired when a scrubbing seek has finished or when a direct seek is issued.
   * @returns {Event<SeekBar, number>}
   */
  get onSeeked(): Event<SeekBar, number> {
    return this.seekBarEvents.onSeeked.getEvent();
  }

  protected onShowEvent(): void {
    super.onShowEvent();

    // Refresh the position of the playback position when the seek bar becomes visible. To correctly set the position,
    // the DOM element must be fully initialized and have its size calculated, because the position is set as an
    // absolute value calculated from the size. This required size is not known when it is hidden.
    // For such cases, we refresh the position here in onShow because here it is guaranteed that the component knows
    // its size and can set the position correctly.
    this.refreshPlaybackPosition();
  }

  /**
   * Checks if TouchEvent is supported.
   * @returns {boolean} true if TouchEvent not undefined, else false
   */
  isTouchEvent(e: UIEvent): e is TouchEvent {
    return window.TouchEvent && e instanceof TouchEvent;
  }
}
