Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ulixee
GitHub Repository: ulixee/secret-agent
Path: blob/main/replay/frontend/src/pages/playbar/index.vue
1030 views
<template lang="pug">
  .FooterPage.Page
    .Playbar.Component(:style="cssVars")
      button.start(v-if="!isPlaying" @click.prevent="play")
        span.label Start
        Icon(:src="ICON_PLAY" :size="14")
      button.start(v-if="isPlaying" @click.prevent="pause")
        span.label Stop
        Icon(:src="ICON_PAUSE" :size="14")
      .slider-wrapper(ref="sliderWrapper")
        VueSlider(ref="slider" tooltip="none" :marks="ticks" :interval="0.01" :duration="0" :min="0" :max="100" :dragOnClick="true" :hideLabel="true" v-model="currentTickValue" @change="onValueChange"
          @mousemove.native="onHoverPlaybar")
          template(v-slot:mark="{ pos, value }" )
            .vue-slider-mark(:style="{ left: `${pos}%`, height:'100%', width:'4px' }", :class="{hovered:isHovered(value)}")
              .vue-slider-mark-step
      button.output(@click.prevent="toggleOutput" :class="{selected:isShowingOutput}")
        span.label Output
        Icon(:src="ICON_BRACKETS" :size="14" )
</template>

<script lang="ts">
import { ipcRenderer } from 'electron';
import { Component, Vue } from 'vue-property-decorator';
import Icon from '~frontend/components/Icon.vue';
import { TOOLBAR_HEIGHT } from '~shared/constants/design';
import { ICON_PAUSE, ICON_PLAY, ICON_BRACKETS } from '~frontend/constants/icons';
import NoCache from '~frontend/lib/NoCache';
import VueSlider from 'vue-slider-component';
import 'vue-slider-component/theme/default.css';
import ITickState from '~shared/interfaces/ITickState';
import { getTheme } from '~shared/utils/themes';
import settings from '~frontend/lib/settings';

// @ts-ignore
@Component({ components: { Icon, VueSlider } })
export default class Playbar extends Vue {
  private readonly ICON_PLAY = ICON_PLAY;
  private readonly ICON_BRACKETS = ICON_BRACKETS;
  private readonly ICON_PAUSE = ICON_PAUSE;

  private hoveredValue = '';
  private durationMillis = 0;
  private ticks: number[] = [];
  private currentTickValue = 0;
  private tickRealtimeOffsetMs = 0;
  private isPlaying = false;

  private nextTimeout: number;
  private isShowingOutput = true;

  private get theme() {
    return getTheme(settings.theme);
  }

  @NoCache
  private get cssVars() {
    return {
      '--toolbarHeight': `${TOOLBAR_HEIGHT - 2}px`,
      '--toolbarBackgroundColor': this.theme.toolbarBackgroundColor,
      '--toolbarBorderColor': this.theme.toolbarBottomLineBackgroundColor,
      '--toolbarLightForeground':
        this.theme.toolbarLightForeground || '1px solid rgba(0, 0, 0, 0.12)',
      '--toolbarBorderBottomColor': this.theme.toolbarBottomLineBackgroundColor,
    };
  }

  mounted() {
    ipcRenderer.on('ticks:load', (e, tickState: ITickState) => {
      console.log('ticks:load', tickState);
      this.pause();
      this.currentTickValue = tickState.currentTickOffset ?? 0;
      this.loadTickState(tickState);
    });

    ipcRenderer.on('ticks:change-offset', (e, offset: number) => {
      this.currentTickValue = offset;
      const playbarOffsetPercent = this.closestTick(offset);
      this.hoverTick(playbarOffsetPercent);
    });

    ipcRenderer.on('start', () => {
      this.play();
    });

    ipcRenderer.on('ticks:updated', (e, tickState: ITickState) => {
      console.log('ticks:updated', tickState);

      const startSessionMillis = this.durationMillis;
      this.loadTickState(tickState);

      if (startSessionMillis && this.currentTickValue) {
        if (this.durationMillis < startSessionMillis) {
          this.currentTickValue *= this.durationMillis / startSessionMillis;
        } else {
          this.currentTickValue *= startSessionMillis / this.durationMillis;
        }
      }
    });
  }

  private loadTickState(tickState: ITickState) {
    this.ticks = tickState.ticks;
    this.durationMillis = tickState.durationMillis;
  }

  private play() {
    this.tickRealtimeOffsetMs = 0;
    this.isPlaying = true;
    this.playbackTick();
  }

  private async playbackTick() {
    const next = await ipcRenderer.invoke('next-tick', this.tickRealtimeOffsetMs ?? 0) ?? {};
    this.currentTickValue = next.playbarOffset || 0;
    let millisToNextTick = Number(next.millisToNextTick || 10);

    console.log(
      'Playbar at %s. Next tick in %s. Previous offset of %s',
      this.currentTickValue,
      millisToNextTick,
      this.tickRealtimeOffsetMs,
    );
    if (millisToNextTick < 0) {
      this.tickRealtimeOffsetMs = millisToNextTick;
      millisToNextTick = 0;
    } else {
      this.tickRealtimeOffsetMs = 0;
    }
    if (this.currentTickValue === 100) {
      this.isPlaying = false;
    }

    if (this.isPlaying) {
      this.nextTimeout = setTimeout(this.playbackTick.bind(this), millisToNextTick) as any;
    }
  }

  private pause() {
    this.isPlaying = false;
    clearTimeout(this.nextTimeout);
  }

  private toggleOutput() {
    this.isShowingOutput = !this.isShowingOutput;
    ipcRenderer.send('toggle-output-panel', this.isShowingOutput);
  }

  private isHovered(value: number) {
    return this.hoveredValue === String(value);
  }

  private hoverTick(playbarOffsetPercent: number) {
    this.hoveredValue = String(playbarOffsetPercent);

    const sliderRef = this.$refs.slider as VueSlider;
    const containerRect = sliderRef.$refs.container.getBoundingClientRect().toJSON();

    containerRect.x += Math.floor((containerRect.width * Number(playbarOffsetPercent)) / 100);

    ipcRenderer.send('on-tick-hover', containerRect, playbarOffsetPercent);
  }

  private onHoverPlaybar(e: MouseEvent) {
    const sliderRef = this.$refs.slider as VueSlider;
    sliderRef.setScale();
    // @ts-ignore
    const pos = sliderRef.getPosByEvent(e);

    const playbarOffsetPercent = this.closestTick(pos);
    this.hoverTick(playbarOffsetPercent);
  }

  private onValueChange(value: number) {
    // this is called when someone clicks, so pause the playback
    this.pause();
    ipcRenderer.send('on-tick-drag', value);
  }

  private closestTick(pos: number) {
    let closest = this.ticks[0];
    if (pos > this.ticks[this.ticks.length - 1]) {
      return this.ticks[this.ticks.length - 1];
    }
    let closestOffset = 100;
    for (const tick of this.ticks) {
      const offset = Math.abs(tick - pos);
      if (offset < closestOffset) {
        closestOffset = offset;
        closest = tick;
      }
      if (offset > closestOffset) break;
    }
    return closest;
  }
}
</script>

<style lang="scss">
@import '../../assets/style/common-mixins';
@include baseStyle();

.FooterPage {
  background-color: var(--toolbarBackgroundColor);
  border-top: 1px solid var(--toolbarBorderBottomColor);
  box-shadow: 0 0 2px 0 rgba(0, 0, 0, 0.8);
  top: 2px;
  position: relative;
  box-sizing: border-box;
  height: var(--toolbarHeight);

  .Playbar {
    background-color: var(--toolbarBackgroundColor);
    margin: 0;
    padding: 3px 10px 7px;
    position: relative;
    box-sizing: border-box;
    z-index: 100;
    display: flex;
    align-items: center;
    flex-flow: row;
    height: 100%;
    color: rgba(0, 0, 0, 0.8);
    -webkit-app-region: no-drag;

    .start {
      border: 1px solid var(--toolbarBorderColor);
      border-radius: 4px;
      padding: 4px 10px;
      white-space: nowrap;
      cursor: pointer;

      .label {
        margin-right: 5px;
        vertical-align: top;
        font-size: 1.1em;
      }
    }

    .vue-slider-mark:first-child .vue-slider-mark-step,
    .vue-slider-mark:last-child .vue-slider-mark-step {
      display: block;
    }

    .vue-slider {
      cursor: pointer;
    }

    .vue-slider-mark {
      &.error {
        .vue-slider-mark-step {
          background-color: #9a0000;
          margin-top: -150%;
          height: 400%;
          width: 4px;
        }
      }

      .vue-slider-mark-step {
        height: 300%;
        margin-top: -100%;
        width: 2px;
      }

      &.hovered {
        .vue-slider-mark-step {
          background-color: transparent;
          margin-top: -50%;
          margin-left: -50%;
          border: solid #3498db;
          opacity: 1;
          border-width: 0 3px 3px 0;
          display: inline-block;
          height: 100%;
          border-radius: 0;
          transform: rotate(-45deg);
          padding: 3px;
        }
      }
    }

    .slider-wrapper {
      width: 100%;
      position: relative;
      margin: 0 15px;

      .other {
        &:before {
          content: '';
          position: absolute;
          background: #979797;
          height: 4px;
          left: 0;
          width: 100%;
          top: calc(50% - 2px);
        }

        .current-position {
          border: 1px solid rgba(0, 0, 0, 0.3);
          background: var(--toolbarBackgroundColor);
          width: 14px;
          height: 14px;
          border-radius: 50%;
          position: absolute;
          top: calc(50% - 8px);
        }
      }
    }
  }
  .output {
    border: 1px solid var(--toolbarBorderColor);
    border-radius: 4px;
    padding: 4px 10px;
    white-space: nowrap;
    cursor: pointer;

    &.selected {
      background: rgba(0, 0, 0, 0.1);
      color: #3c3c3c;
    }

    .label {
      margin-right: 5px;
      vertical-align: top;
      font-size: 1.1em;
    }
  }
}
</style>