<!-- app root container -->
<div class="app-wrap" id="app" v-cloak>

  <!-- app player container -->
  <main class="player-wrap fx fx-fade-in" ref="playerWrap" style="opacity: 0">

    <!-- bg absolute elements -->
    <figure class="player-bg" ref="playerBg"></figure>
    <canvas class="player-canvas" ref="playerCanvas"></canvas>

    <!-- main player layout -->
    <section class="player-layout">

      <!-- player top header -->
      <header class="player-header flex-row flex-middle flex-stretch">
        <h2 class="text-clip flex-1"><i class="fa fa-headphones"></i> <span>TEBİMTEBİTAGEM GAZETESİ RADYO TELEVİZYONU </span></h2>
        <button class="text-nowrap common-btn" @click="toggleSidebar( true )"><i class="fa fa-bars"></i></button>
      </header>

      <!-- player middle content area -->
      <main class="player-content flex-row">

        <!-- default greet message -->
        <section class="player-greet" v-if="!hasChannel && !hasErrors">
          <div class="fx fx-slide-left push-bottom"><h1>Pick a Station</h1></div>
          <div class="fx fx-slide-left fx-delay-1 push-bottom">This is a music streaming player for the channels provided by harunpehlivan.fm.tc Just pick a station from the sidebar to the right to start listening.</div>
          <div class="fx fx-slide-up fx-delay-2 pad-top"><button class="cta-btn" @click="toggleSidebar( true )"><i class="fa fa-headphones">&nbsp;</i> View Stations</button></div>
        </section>

        <!-- show selected channel info if possible -->
        <section class="player-channel flex-1" v-if="hasChannel && !hasErrors" :key="channel.id">
          <div class="flex-autorow flex-middle flex-stretch">

            <!-- station details -->
            <div class="flex-item flex-1">
              <!-- station -->
              <div class="push-bottom pad-bottom border-bottom">
                <div class="flex-row flex-middle">
                  <img class="img-round fx fx-drop-in fx-delay-1" :src="channel.largeimage" width="80" height="80" :alt="channel.title" />
                  <div class="pad-left fx fx-slide-left fx-delay-2">
                    <div class="text-clip text-uppercase">{{ channel.genre | toSpaces }}</div>
                    <h2 class="text-clip">{{ channel.title }}</h2>
                  </div>
                </div>
              </div>
              <!-- description -->
              <div class="push-bottom pad-bottom border-bottom fx fx-slide-up fx-delay-3">
                {{ channel.description }}
              </div>
              <!-- current track -->
              <div class="push-bottom pad-bottom border-bottom fx fx-slide-up fx-delay-4" :key="track.date">
                <div><span class="text-faded">DJ:</span> <span class="text-default">{{ channel.dj | toText( 'N/A' ) }}</span></div>
                <div><span class="text-faded">Playing:</span> <span class="text-secondary">{{ track.title | toText( 'N/A' ) }}</span></div>
                <div><span class="text-faded">From:</span> <span class="text-bright">{{ track.album | toText( 'N/A' ) }}</span></div>
                <div><span class="text-faded">By:</span> <span class="text-default">{{ track.artist | toText( 'N/A' ) }}</span></div>
              </div>
              <!-- buttons -->
              <div class="push-bottom">
                <a class="cta-btn text-nowrap fx fx-slide-up fx-delay-5" :href="channel.twitter" title="Open link" target="_blank">
                  <i class="fa fa-twitter"></i> Twitter
                </a> &nbsp;
                <a class="cta-btn text-nowrap fx fx-slide-up fx-delay-6" :href="channel.infourl" title="Channel page" target="_blank">
                  <span class="fx fx-notx fx-ibk fx-drop-in fx-delay-1" :key="channel.listeners"><i class="fa fa-headphones"></i> {{ channel.listeners | toCommas( 0 ) }}</span>
                </a> &nbsp;
                <a class="cta-btn text-nowrap fx fx-slide-up fx-delay-7" :href="channel.plsfile" title="Download PLS" target="_blank">
                  <i class="fa fa-download"></i>
                </a> &nbsp;
              </div>
            </div>

            <!-- songs list -->
            <div class="flex-item flex-1">
              <div class="push-bottom">
                <h5 class="fx fx-slide-left fx-delay-1">Recent Tracks</h5>
              </div>
              <div class="card push-bottom" v-if="!hasSongs">
                There are no songs loaded yet for this station.
              </div>
              <ul class="player-tracklist push-bottom" v-if="hasSongs">
                <li v-for="( s, i ) of songsList" :key="s.date" class="card fx" :class="'fx-slide-left fx-delay-' + ( i + 2 )">
                  <div><span class="text-secondary">{{ s.title | toText( 'N/A' ) }}</span></div>
                  <div><span class="text-faded">From:</span> <span class="text-bright">{{ s.album | toText( 'N/A' ) }}</span></div>
                  <div><span class="text-faded">By:</span> <span class="text-default">{{ s.artist | toText( 'N/A' ) }}</span></div>
                </li>
              </ul>
            </div>

          </div>
        </section>

        <!-- show tracks for selected channel if possible -->
        <section class="player-errors flex-1 text-center" v-if="hasErrors" key="errors">
          <div class="push-bottom fx fx-drop-in fx-delay-1">
            <i class="fa fa-plug text-huge text-faded"></i>
          </div>
          <div class="push-bottom fx fx-slide-up fx-delay-2">
            <h3>Oops, there's a problem!</h3>
          </div>
          <hr />
          <div class="text-primary push-bottom fx fx-slide-up fx-delay-3" v-if="errors.init" v-text="errors.init"></div>
          <div class="text-primary push-bottom fx fx-slide-up fx-delay-4" v-if="errors.stream" v-text="errors.stream"></div>
          <hr />
          <button class="cta-btn text-nowrap fx fx-slide-up fx-delay-5" @click="tryAgain">
            <i class="fa fa-refresh"></i> Try again
          </button>
        </section>

      </main>

      <!-- player footer with controls -->
      <footer class="player-footer flex-row flex-middle flex-space">
        <!-- player controls -->
        <section class="player-controls flex-row flex-middle push-right" :class="{ 'disabled': !canPlay }">
          <button class="common-btn" @click="togglePlay()">
            <i v-if="playing" class="fa fa-stop fx fx-drop-in" key="stop"></i>
            <i v-else class="fa fa-play fx fx-drop-in" key="play"></i>
          </button>
          <div class="form-slider push-left">
            <i class="fa fa-volume-down"></i>
            <input class="common-slider" type="range" min="0.0" max="1.0" step="0.1" value="0.5" v-model="volume" />
            <i class="fa fa-volume-up"></i>
          </div>
          <div class="text-clip push-left">
            <span>{{ timeDisplay }}</span>
            <span class="fx fx-fade-in fx-delay-1" v-if="hasChannel" :key="channel.id">&nbsp;|&nbsp;{{ channel.title }}</span>
          </div>
        </section>
        <!-- player links -->
        <section class="player-links text-nowrap">
          <a class="common-btn text-faded" href="https://github.com/harunpehlivan" title="View on Github" target="_blank">
            <i class="fa fa-github"></i>
          </a> &nbsp;
          <a class="common-btn text-faded" href="https://codepen.io/harunpehlivan" title="Codepen Projects" target="_blank">
            <i class="fa fa-codepen"></i>
          </a>
        </section>
      </footer>

    </section> <!-- layout wrapper -->

    <!-- player stations overlay + sidebar -->
    <section class="player-stations" :class="{ 'visible': sidebar }" @click="toggleSidebar( false )">
      <aside class="player-stations-sidebar" @click.stop>
        <!-- sidebar search -->
        <header class="player-stations-header flex-row flex-middle flex-stretch">
          <div class="form-input push-right">
            <i class="fa fa-search"></i>
            <input type="text" placeholder="Search station..." v-model="searchText" />
          </div>
          <button class="common-btn" @click="toggleSidebar( false )"><i class="fa fa-times-circle"></i></button>
        </header>
        <!-- sidebar stations list -->
        <ul class="player-stations-list">
          <li class="player-stations-list-item flex-row flex-top flex-stretch" v-for="c of channelsList" :key="c.id" @click="selectChannel( c )" :class="{ 'active': c.active }">
            <figure class="push-right if-small">
              <img class="img-round" width="70" height="70" :src="c.largeimage" :alt="c.title" />
            </figure>
            <aside class="flex-1">
              <div class="flex-row flex-middle flex-space">
                <h6 class="text-bright text-clip">{{ c.title }}</h6>
                <div class="text-secondary"><i class="fa fa-headphones"></i> {{ c.listeners | toCommas( 0 ) }}</div>
              </div>
              <div class="text-small">
                <span class="text-faded text-uppercase text-small">{{ c.genre | toSpaces }}</span> <br />
                {{ c.description }}
              </div>
            </aside>
          </li>
        </ul>
        <!-- sidebar sort options -->
        <footer class="player-stations-footer flex-row flex-middle flex-stretch">
          <div class="flex-1 push-right">
            <span @click="toggleSortOrder()" class="fa clickable" :class="{ 'fa-sort-amount-desc': sortOrder === 'desc', 'fa-sort-amount-asc': sortOrder === 'asc' }">&nbsp;</span>
            <span class="text-faded">Sort: &nbsp;</span>
            <span class="text-secondary popover">
              <span class="clickable">{{ sortLabel }}</span>
              <span class="popover-box popover-top">
                <button @click="sortBy( 'title', 'asc' )">Station Name</button>
                <button @click="sortBy( 'listeners', 'desc' )">Listeners Count</button>
                <button @click="sortBy( 'genre', 'asc' )">Music Genre</button>
              </span>
            </span>
          </div>
          <div>&nbsp;</div>
        </footer>
      </aside>
    </section>

  </main> <!-- player -->

</div> <!-- wrapper -->
// spacing and padding
$padSpace: 1em;
$padSmall: $padSpace / 2;
$headerHeight: 3.5em;
$bgImg: 'https://res.cloudinary.com/tercuman-b-l-m-merkez/image/upload/v1561460680/ServQuick.jpg_g0rquc.png'; 

// document colors
$colorDocument: #8086a0;
$colorDocumentDark: #1e1f30;
$colorDocumentDarker: darken( $colorDocumentDark, 10% );
$colorDocumentLight: #a0a6b0;
$colorDocumentText: desaturate( lighten( $colorDocumentDark, 40% ), 5% );

// common colors
$colorPrimary: crimson;
$colorPrimaryText: lighten( $colorPrimary, 40% );
$colorSecondary: cornflowerblue;
$colorSecondaryText: darken( $colorSecondary, 40% );
$colorDefault: lightslategray;
$colorDefaultText: darken( $colorDefault, 40% );
$colorGrey: slategray;
$colorGreyText: darken( $colorGrey, 40% );
$colorBright: whitesmoke;
$colorBrightText: darken( $colorBright, 40% );
$colorOverlay: rgba( black, 0.4 );
$colorCard: rgba( black, 0.08 );

// borders and lines
$lineWidth: 2px;
$lineStyle: solid;
$lineColor: rgba( black, 0.08 );
$lineJoin: 6px;

// base font options
$fontFamily: 'Roboto Condensed', sans-serif;
$fontSize: 20px;
$fontSpace: 1.2em;
$fontWeight: 700;

// shadow styles
$shadowContainer: 0 1px 30px rgba( black, 0.8 );
$shadowOverlay: 0 1px 20px rgba( black, 0.6 );
$shadowPaper: 0 1px 3px rgba( black, 0.5 );

// transition props
$fxSpeed: 400ms;
$fxEase: cubic-bezier( 0.215, 0.610, 0.355, 1.000 );
$fxSpeedOffset: calc( #{$fxSpeed} / 3 );
$fxSlideDist: 80px;
$fxShrinkScale: .4;
$fxGrowScale: 1.4;
$fxRotateAmount: 8deg;

// screen sizes
$sizeSmall: 420px;
$sizeMedium: 720px;
$sizeLarge: 1200px;

// screen breakpoints
$screenSmall: "only screen and (min-width : #{$sizeSmall})";
$screenMedium: "only screen and (min-width : #{$sizeMedium})";
$screenLarge: "only screen and (min-width : #{$sizeLarge})";

// page reset
*, *:before, *:after {
  margin: 0;
  padding: 0;
  border: 0;
  outline: none;
  background-color: transparent;
  text-transform: none;
  text-shadow: none;
  box-shadow: none;
  box-sizing: border-box;
  appearance: none;
  -webkit-overflow-scrolling: touch;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  transform-style: flat;
  transition:
    border-color $fxSpeed $fxEase,
    background-color $fxSpeed $fxEase,
    opacity $fxSpeed $fxEase,
    transform $fxSpeed $fxEase;
}

// block types
article, aside, details, figcaption, figure, footer, header, hgroup,
menu, nav, section, main, summary, div, h1, h2, h3, h4, h5, h6, hr,
p, ol, ul, form, img {
  display: block;
}

// form elements
input, textarea, select, optgroup, option, button {
  font-family: inherit;
  font-size: inherit;
  font-weight: normal;
  line-height: inherit;
  color: inherit;
}
select, button {
  cursor: pointer;
}

// links
a {
  color: $colorSecondary;

  &:hover {
    color: lighten( $colorSecondary, 10% );
  }
}

// horizontal lines
hr {
  display: block;
  overflow: hidden;
  margin: $padSpace 0;
  height: 0;
  border: 0;
  border-bottom: $lineWidth $lineStyle $lineColor;
}

// document setup
html, body {
  display: block;
  position: relative;
  max-width: 100vw;
  min-height: 100vh;
}
html {
  overflow: hidden;
  overflow-y: auto;
}
body {
  font-family: $fontFamily;
  font-weight: $fontWeight;
  font-size: calc( #{$fontSize} - 6px );
  line-height: $fontSpace;
  color: $colorDocumentText;
  background-size: cover;
  background-color: $colorDocument;
  background-image:
    linear-gradient( 217deg, rgba( $colorPrimary, .8 ), rgba( $colorPrimary, 0 ) 70.71% ),
    linear-gradient( 127deg, rgba( $colorDocument, 1 ), rgba( $colorDocument, 0 ) 70.71% ),
    linear-gradient( 336deg, rgba( $colorSecondary, .8 ), rgba( $colorSecondary, 0 ) 70.71% );

  @media #{$screenSmall} {
    font-size: calc( #{$fontSize} - 4px );
  }
  @media #{$screenMedium} {
    font-size: calc( #{$fontSize} - 2px );
  }
  @media #{$screenLarge} {
    font-size: $fontSize;
  }
}

// media query helpers
.if-small {
  display: none;
  @media #{$screenSmall} {
    display: initial;
  }
}
.if-medium {
  display: none;
  @media #{$screenMedium} {
    display: initial;
  }
}
.if-large {
  display: none;
  @media #{$screenLarge} {
    display: initial;
  }
}

// not rendered
.hidden, [hidden], [v-cloak] {
  display: none;
}

// visible but not usable
.disabled, [disabled] {
  pointer-events: none;
  opacity: 0.5;
}

// clickable elms
.clickable {
  cursor: pointer;
}

// common card style
.card {
  padding: $padSpace;
  background-color: $colorCard;
  border-radius: $lineJoin;
}

// margin helpers
.push-top { margin-top: $padSpace; }
.push-right { margin-right: $padSpace; }
.push-bottom { margin-bottom: $padSpace; }
.push-left { margin-left: $padSpace; }
.push-all { margin: $padSpace; }

// padding helpers
.pad-top { padding-top: $padSpace; }
.pad-right { padding-right: $padSpace; }
.pad-bottom { padding-bottom: $padSpace; }
.pad-left { padding-left: $padSpace; }
.pad-all { padding: $padSpace; }

// border helpers
.border-top { border-top: $lineWidth $lineStyle $lineColor; }
.border-right { border-right: $lineWidth $lineStyle $lineColor; }
.border-bottom { border-bottom: $lineWidth $lineStyle $lineColor; }
.border-left { border-left: $lineWidth $lineStyle $lineColor; }

// shadow helpers
.shadow-box { box-shadow: $shadowPaper; }
.shadow-text { text-shadow: $shadowPaper; }

// animations on
.fx {
  position: relative;
  animation-direction: normal;
  animation-duration: $fxSpeed;
  animation-timing-function: $fxEase;
  animation-iteration-count: 1;
  animation-fill-mode: forwards;
}

// disable transitions on element
.fx-notx {
  transition: none !important;
}

// convert inline elements into inline-block
.fx-ibk {
  display: inline-block !important;
}

// effect delays
@for $i from 1 through 8 {
  .fx-delay-#{$i} {
    animation-delay: calc( #{$fxSpeedOffset} * #{$i} );
  }
}

// spin right animation
@keyframes spinRight {
    0% { transform: rotate( 0deg ); }
  100% { transform: rotate( 359deg ); }
}
.fx-spin-right {
  animation-name: spinRight;
  animation-duration: 1s;
  animation-timing-function: linear;
  animation-iteration-count: infinite;
}

// spin right animation
@keyframes spinLeft {
    0% { transform: rotate( 359deg ); }
  100% { transform: rotate( 0deg ); }
}
.fx-spin-left {
  animation-name: spinLeft;
  animation-duration: 1s;
  animation-timing-function: linear;
  animation-iteration-count: infinite;
}

// fade-in animation
@keyframes fadeIn {
    0% { opacity: 0; }
  100% { opacity: 1; }
}
.fx-fade-in {
  opacity: 0;
  animation-name: fadeIn;
}

// fade-out animation
@keyframes fadeOut {
    0% { opacity: 1; }
  100% { opacity: 0; }
}
.fx-fade-out {
  opacity: 1;
  animation-name: fadeOut;
}

// drop-in animation (scale)
@keyframes dropIn {
    0% { opacity: 0; transform: scale( $fxGrowScale ); }
  100% { opacity: 1; transform: scale( 1 ); }
}
.fx-drop-in {
  opacity: 0;
  transform: scale( $fxGrowScale );
  animation-name: dropIn;
}

// zoom-in animation (modal, alert, etc)
@keyframes zoomIn {
    0% { opacity: 0; transform: scale( $fxShrinkScale ); }
  100% { opacity: 1; transform: scale( 1 ); }
}
.fx-zoom-in {
  opacity: 0;
  transform: scale( $fxShrinkScale );
  animation-name: zoomIn;
}

// zoom-out animation (modal, alert, etc)
@keyframes zoomOut {
    0% { opacity: 1; transform: scale( 1 ); }
  100% { opacity: 0; transform: scale( $fxShrinkScale ); }
}
.fx-zoom-out {
  opacity: 1;
  transform: scale( 1 );
  animation-name: zoomOut;
}

// slide in to the left
@keyframes slideLeft {
    0% { opacity: 0; transform: translateX( $fxSlideDist ); }
  100% { opacity: 1; transform: translateX( 0 ); }
}
.fx-slide-left {
  opacity: 0;
  transform: translateX( $fxSlideDist );
  animation-name: slideLeft;
}

// slide in to the right
@keyframes slideRight {
    0% { opacity: 0; transform: translateX( calc( 0 - #{$fxSlideDist} ) ); }
  100% { opacity: 1; transform: translateX( 0 ); }
}
.fx-slide-right {
  opacity: 0;
  transform: translateX( calc( 0 - #{$fxSlideDist} ) );
  animation-name: slideRight;
}

// slide in to the top
@keyframes slideUp {
    0% { opacity: 0; transform: translateY( $fxSlideDist ); }
  100% { opacity: 1; transform: translateY( 0 ); }
}
.fx-slide-up {
  opacity: 0;
  transform: translateY( $fxSlideDist );
  animation-name: slideUp;
}

// slide in to the bottom
@keyframes slideDown {
    0% { opacity: 0; transform: translateY( calc( 0 - #{$fxSlideDist} ) ); }
  100% { opacity: 1; transform: translateY( 0 ); }
}
.fx-slide-down {
  opacity: 0;
  transform: translateY( calc( 0 - #{$fxSlideDist} ) );
  animation-name: slideDown;
}

// pulse opacity
@keyframes pulseFade {
    0% { opacity: 0.7; }
   50% { opacity: 1.0; }
  100% { opacity: 0.7; }
}
.fx-pulse {
  opacity: 0.7;
  animation-name: pulseFade;
  animation-duration: 1s;
  animation-timing-function: linear;
  animation-iteration-count: infinite;
}

// flex helpers
.flex-row { display: flex; flex-direction: row; flex-wrap: nowrap; }
.flex-wrap { flex-wrap: wrap; }
.flex-left { justify-content: flex-start; }
.flex-center { justify-content: center; }
.flex-right { justify-content: flex-end; }
.flex-space { justify-content: space-between; }
.flex-around { justify-content: space-around; }
.flex-stretch { justify-content: stretch; }
.flex-top { align-items: flex-start; }
.flex-middle { align-items: center; }
.flex-bottom { align-items: flex-end; }
.flex-half { flex: .5; }
.flex-1 { flex: 1; }
.flex-2 { flex: 2; }
.flex-3 { flex: 3; }
.flex-4 { flex: 4; }
.flex-5 { flex: 5; }

// auto switch between column and row
.flex-autorow {
  display: flex;
  flex-direction: column;
  flex-wrap: nowrap;

  & > .flex-item {
    flex: 1;
    width: 100%;
    margin: 0 0 $padSpace 0; // push bottom
    &:last-of-type { margin: 0; }
  }

  @media #{$screenMedium} {
    flex-direction: row;

    & > .flex-item {
      margin: 0 $padSpace 0 0; // push right
      &:last-of-type { margin: 0; }
    }
  }
}

// rouded image
.img-round {
  overflow: hidden;
  text-indent: -1000px;
  border-radius: 1000px;
  border: $lineWidth solid $colorBright;
  background-color: lighten( $colorDocumentDark, 10% );
  background-image: linear-gradient( 45deg, lighten( $colorDocumentDark, 10% ), lighten( $colorDocumentDark, 25% ) );
  box-shadow: $shadowPaper;
}

// centered image
.img-center {
  display: block;
  margin: 0 auto;
}

// common large bright text buttons
.common-btn {
  display: inline-block;
  text-align: center;
  font-size: 180%;
  font-weight: normal;
  line-height: 1em;
  width: 1em;
  color: $colorBright;

  &:hover {
    color: darken( $colorBright, 20% );
  }
}

// common cta button/link
.cta-btn {
  display: inline-block;
  text-decoration: none;
  padding: ( $padSpace / 2 ) $padSpace;
  color: $colorPrimaryText;
  background-color: darken( desaturate( $colorPrimary, 10% ), 10% );
  border-radius: 100px;
  box-shadow: $shadowPaper;
  line-height: 1.1em;

  &:hover {
    color: lighten( $colorPrimaryText, 5% );
    background-color: darken( $colorPrimary, 5% );
  }
}

// common form input wrapper
.form-input {
  display: flex;
  flex: 1;
  flex-direction: row;
  align-items: center;
  justify-content: stretch;
  color: $colorBright;

  & > input {
    flex: 1;
    line-height: 1.5em;
    padding: 0 ( $padSpace / 2 );
  }
}

// common form slider container
@mixin sliderTrack {
  width: 100%;
  height: 3px;
  background-color: lighten( $colorDocumentDark, 10% );
  color: transparent !important;
  border-color: transparent !important;
  border-radius: $lineJoin !important;
  border: 0 !important;
}
@mixin sliderThumb {
  width: 1em;
  height: 1em;
  margin: -.4em 0 0 0;
  border-radius: 50%;
  box-shadow: $shadowPaper;
  background-color: $colorBright;
  transition: background $fxSpeed $fxEase;
  color: transparent !important;
  border-color: transparent !important;
  border: 0 !important;
  cursor: pointer;
  &:hover {
    background-color: darken( $colorBright, 20% );
  }
}
.form-slider {
  display: flex;
  position: relative;
  flex-direction: row;
  align-items: center;
  justify-content: stretch;
  width: 100%;
  max-width: 6em;
  line-height: 1em;

  & > input {
    -webkit-appearance: none;
    appearance: none;
    width: 100%;
    margin: 0 .5em;
    // track
    &::-webkit-slider-runnable-track { @include sliderTrack; }
    &::-moz-range-track { @include sliderTrack; }
    &::-ms-track { @include sliderTrack; }
    // thumb
    &::-webkit-slider-thumb { -webkit-appearance: none; @include sliderThumb; }
    &::-moz-range-thumb { @include sliderThumb; }
    &::-ms-thumb { @include sliderThumb; }
  }
}


// common absolute popover
@keyframes popoverShow {
    0% { transform: translateX( -50% ) scale( .8 ); opacity: 0; }
   35% { transform: translateX( -50% ) scale( 1.2 ); opacity: .8; }
  100% { transform: translateX( -50% ) scale( 1 ); opacity: 1; }
}
.popover {
  position: relative;

  .popover-box {
    display: none;
    position: absolute;
    padding: ( $padSpace / 2 ) 0;
    max-width: 300px;
    min-height: 100px;
    left: 50%;
    bottom: 50%;
    transition: none;
    transform: translateX( -50% );
    background-color: lighten( $colorDocumentDark, 8% );
    border-radius: $lineJoin;
    box-shadow: $shadowOverlay;
    animation: popoverShow $fxSpeed $fxEase forwards;
    z-index: 2000;

    &:before {
      content: '';
      display: none;
      position: absolute;
      transition: none;
      width: 0;
      height: 0;
      transform: translateX( -50% );
      left: 50%;
      z-index: 2001;
    }

    & > button {
      display: block;
      width: 100%;
      text-align: left;
      padding: ( $padSpace / 2 ) $padSpace;
      line-height: 1.2em;
      white-space: nowrap;
      background-color: rgba( $colorDocumentDark, 0 );

      &:hover {
        background-color: rgba( $colorDocumentDark, .2 );
      }
      & + button {
        border-top: $lineWidth $lineStyle $lineColor;
      }
    }

    &.popover-left {
      transform: none;
      left: auto;
      right: 0;
    }
    &.popover-right {
      transform: none;
      left: 0;
      right: auto;
    }
    &.popover-top {
      top: auto;
      bottom: 100%;
      &:before {
        display: block;
        top: auto;
        bottom: -10px;;
        border-left: 10px solid transparent;
        border-right: 10px solid transparent;
        border-top: 10px solid lighten( $colorDocumentDark, 8% );
      }
    }
    &.popover-bottom {
      top: 100%;
      bottom: auto;
      &:before {
        display: block;
        top: -10px;
        bottom: auto;
        border-left: 10px solid transparent;
        border-right: 10px solid transparent;
        border-bottom: 10px solid lighten( $colorDocumentDark, 8% );
      }
    }
  }
  &:hover > .popover-box,
  &:active > .popover-box {
    display: block;
  }
}

// headings
h1, h2, h3, h4, h5, h6 {
  display: block;
  font-weight: normal;
  line-height: 1.1em;
  color: $colorBright;
}
h1 { font-size: 220%; }
h2 { font-size: 200%; }
h3 { font-size: 180%; }
h4 { font-size: 160%; }
h5 { font-size: 140%; }
h6 { font-size: 120%; }

// text helpers
.text-left { text-align: left; }
.text-right { text-align: right; }
.text-center { text-align: center; }
.text-justify { text-align: justify; }
.text-uppercase { text-transform: uppercase; }
.text-lowercase { text-transform: lowercase; }
.text-capitalize { text-transform: capitalize; }
.text-underline { text-decoration: underline; }
.text-striked { text-decoration: line-through; }
.text-italic { font-style: italic; }
.text-bold { font-weight: bold; }
.text-nowrap { white-space: nowrap; }
.text-clip { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.text-primary { color: $colorPrimary; }
.text-secondary { color: $colorSecondary; }
.text-grey { color: $colorGrey; }
.text-bright { color: $colorBright; }
.text-faded { opacity: 0.5; }
.text-big { font-size: 120%; }
.text-bigger { font-size: 180%; }
.text-huge { font-size: 240%; }
.text-small { font-size: 90%; }
.text-condense { letter-spacing: -1px; }

// app root
.app-wrap {
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: center;
  flex-wrap: nowrap;
  min-height: 100vh;
  width: 100%;
}

// player container
.player-wrap {
  display: block;
  overflow: hidden;
  position: relative;
  flex: 1;
  width: 100%;
  height: 100vh;
  background-color: $colorDocumentDark;

  & > .player-bg,
  & > .player-canvas {
    display: block;
    position: absolute;
    left: 0;
    top: 0;
    width: 100%;
    height: 100%;
    z-index: 0;
  }
  & > .player-bg {
    background-image: url( $bgImg );
    background-position: bottom right;
    background-repeat: no-repeat;
    background-size: cover;
    opacity: .4;
  }
  @media #{$screenMedium} {
    margin: 0 ( $padSpace * 2 );
    max-width: 1080px;
    height: calc( 100vh - ( #{$padSpace} * 4 ) );
    max-height: 700px;
    border-radius: $lineJoin;
    box-shadow: $shadowContainer;
  }
}

// player layout container
.player-layout {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  justify-content: stretch;
  height: 100%;

  .player-header,
  .player-content,
  .player-footer {
    position: relative;
  }
  .player-header,
  .player-footer {
    padding: 0 $padSpace;
    height: $headerHeight;
    min-height: $headerHeight;
    background-color: $colorCard;
  }
  .player-header {
    & > h2 {
      color: $colorPrimary;
      i { vertical-align: bottom; }
    }
  }
  .player-content {
    flex: 1;
    height: 100%;
    overflow: hidden;
    overflow-y: auto;
    padding: $padSpace;

    & > section {
      margin: auto 0; // prevent vertical aligned flex item from overflowing
    }
    @media #{$screenMedium} {
      padding: $padSpace ( $padSpace * 2 );
    }
  }
}

// player greeting message
.player-greet {
  flex: 1;
  @media #{$screenMedium} { flex: .5; }
}

// player tracklist
.player-tracklist {
  display: block;
  position: relative;
  list-style: none;

  & > li + li {
    margin-top: ( $padSpace / 2 );
  }
}

// player footer controls
.player-controls {
  position: relative;
}

// player stations sidebar
.player-stations {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  background-color: rgba( $colorOverlay, 0 );
  pointer-events: none;
  z-index: 1;

  .player-stations-sidebar {
    display: flex;
    flex-direction: column;
    flex-wrap: nowrap;
    justify-content: stretch;
    position: absolute;
    top: 0;
    right: -320px;
    width: 320px;
    min-height: 100%;
    max-height: 100%;
    background-color: lighten( $colorDocumentDark, 2% );

    @media #{$screenSmall} {
      right: -420px;
      width: 420px;
    }

    .player-stations-header,
    .player-stations-footer {
      padding: 0 $padSpace;
      min-height: $headerHeight;
      box-shadow: 0 0 3px rgba( black, 0.3 );
    }
    .player-stations-list {
      display: block;
      list-style: none;
      overflow: hidden;
      overflow-y: auto;
      margin-left: -10px;
      padding-left: 10px;
      flex: 1;

      .player-stations-list-item {
        position: relative;
        padding: $padSpace;
        background-color: rgba( black, 0.1 );
        cursor: pointer;

        &:nth-child( odd ) {
          background-color: rgba( black, 0.18 );
        }
        &:hover {
          background-color: rgba( black, 0 );
        }
        &.active {
          background-color: darken( $colorDocumentDark, 2% );
          h6 { color: $colorPrimary; }
        }
      }
    }
  }

  // slide out
  &.visible {
    background-color: $colorOverlay;
    pointer-events: auto;
    z-index: 1000;

    .player-stations-sidebar {
      transform: translateX( -320px );
      box-shadow: $shadowOverlay;
      @media #{$screenSmall} { transform: translateX( -420px ); }
    }
    .player-stations-list-item.active:before {
      content: '';
      display: block;
      position: absolute;
      transition: none;
      transform: translateY( -50% );
      top: 50%;
      left: -10px;
      border-top: 10px solid transparent;
      border-bottom: 10px solid transparent;
      border-right: 10px solid darken( $colorDocumentDark, 2% );
    }
  }
}
View Compiled
/**
 * TEBİMTEBİTAGEM GAZETESİ RADYO TELEVİZYONU Web Player
 * Author: Rainner Lins (2018)
 * Site: http://harunpehlivan.fm.tc/


/**
 * Sphere object
 */
const Sphere = {
  group  : null,
  shapes : [],
  move   : new THREE.Vector3( 0, 0, 0 ),
  ease   : 8,

  create( box, scene ) {
    this.group   = new THREE.Object3D();
    let shape1   = new THREE.CircleGeometry( 1, 10 );
    let shape2   = new THREE.CircleGeometry( 2, 20 );
    let points   = new THREE.SphereGeometry( 100, 30, 14 ).vertices;
    let material = new THREE.MeshLambertMaterial( { color: 0xffffff, opacity: 0, side: THREE.DoubleSide } );
    let center   = new THREE.Vector3( 0, 0, 0 );
    let radius   = 12;

    for ( let i = 0; i < points.length; i++ ) {
      let { x, y, z } = points[ i ];
      let home  = { x, y, z };
      let cycle = THREE.Math.randInt( 0, 100 );
      let pace  = THREE.Math.randInt( 10, 30 );
      let shape = new THREE.Mesh( ( i % 2 ) ? shape1 : shape2, material );

      shape.position.set( x, y, z );
      shape.lookAt( center );
      shape.userData = { radius, cycle, pace, home };
      this.group.add( shape );
    }
    this.group.position.set( 500, 0, 0 );
    this.group.rotation.x = ( Math.PI / 2 ) + .6;
    scene.add( this.group );
  },

  update( box, mouse, freq ) {
    let bass = ( Math.floor( freq[ 1 ] | 0 ) / 255 );
    this.move.x = ( box.width * .06 ) + -( mouse.x * 0.02 );
    this.group.position.x += ( this.move.x - this.group.position.x ) / this.ease;
    this.group.position.y += ( this.move.y - this.group.position.y ) / this.ease;
    this.group.position.z = 10 + ( bass * 80 );
    this.group.rotation.y -= 0.003;

    for ( let i = 0; i < this.group.children.length; i++ ) {
      let shape = this.group.children[ i ];
      let { radius, cycle, pace, home } = shape.userData;
      shape.position.set( home.x, home.y, home.z );
      shape.translateZ( bass * Math.sin( cycle / pace ) * radius );
      shape.userData.cycle++;
    }
  },
};

/**
 * Vue filters
 */
Vue.filter( 'toCommas', ( num, decimals ) => {
 let o = { style: 'decimal', minimumFractionDigits: decimals, maximumFractionDigits: decimals };
 return new Intl.NumberFormat( 'en-US', o ).format( num );
});
Vue.filter( 'toSpaces', ( str ) => {
 return String( str || '' ).trim().replace( /[^\w\`\'\-]+/g, ' ' ).trim();
});
Vue.filter( 'toText', ( str, def ) => {
 str = String( str || '' ).replace( /[^\w\`\'\-\.\!\?]+/g, ' ' ).trim();
 return str || String( def || '' );
});

/**
 * Vue app
 */
new Vue({
  el: '#app',
  data: {
    // toggles
    init: false,
    playing: false,
    loading: false,
    sidebar: false,
    // channels stuff
    channels: [], // all channels
    channel: {},  // selected channel
    songs: [],    // recent tracks
    track: {},    // current track
    errors: {},   // error messages
    // animation stuff
    fxBox: null,
    fxRenderer: null,
    fxScene: null,
    fxColor: null,
    fxLight: null,
    fxCamera: null,
    fxMouse: { x: 0, y: 0 },
    fxObjects: [],
    // audio stuff
    audio: new Audio(),
    context: new AudioContext(),
    freqData: new Uint8Array(),
    audioSrc: null,
    audioGain: null,
    analyser: null,
    volume: 0.5,
    // timer stuff
    timeStart: 0,
    timeDisplay: '00:00:00',
    timeItv: null,
    // sorting stuff
    searchText: '',
    sortParam: 'listeners',
    sortOrder: 'desc',
    // timer stuff
    anf: null,
    sto: null,
    itv: null,
  },

  // watch methods
  watch: {

    // when app is ready
    init() {
      setTimeout( this.setupCanvas, 100 );
      setTimeout( this.initSidebar, 500 );
    },

    // watch playing status
    playing() {
      if ( this.playing ) { this.startClock(); }
      else { this.stopClock(); }
    },

    // update player volume
    volume() {
      this.setVolume( this.volume );
    }
  },

  // computed methods
  computed: {

    // filter channels list
    channelsList() {
      let list = this.channels.slice();
      let search = this.searchText.replace( /[^\w\s\-]+/g, '' ).replace( /[\r\s\t\n]+/g, ' ' ).trim();

      if ( search && search.length > 1 ) {
        let reg = new RegExp( '^('+ search +')', 'i' );
        list = list.filter( i => reg.test( i.title +' '+ i.description ) );
      }
      if ( this.sortParam ) {
        list = this.sortList( list, this.sortParam, this.sortOrder );
      }
      if ( this.channel.id ) {
        list = list.map( i => {
          i.active = ( this.channel.id === i.id ) ? true : false;
          return i;
        });
      }
      return list;
    },

    // filter songs list
    songsList() {
      let list = this.songs.slice();
      return list;
    },

    // sort-by label for buttons, etc
    sortLabel() {
      switch ( this.sortParam ) {
        case 'title'     : return 'Station Name';
        case 'listeners' : return 'Listeners Count';
        case 'genre'     : return 'Music Genre';
      }
    },

    // check if audio can be played
    canPlay() {
      return ( this.channel.id && !this.loading ) ? true : false;
    },

    // check if a channel is selected
    hasChannel() {
      return this.channel.id ? true : false;
    },

    // check if there are tracks loaded
    hasSongs() {
      return this.songs.length ? true : false;
    },

    // check if there are errors to show
    hasErrors() {
      return ( this.checkError( 'init' ) || this.checkError( 'stream' ) ) ? true : false;
    },
  },

  // custom methods
  methods: {

    // set an erro message
    setError( key, err ) {
      let errors = Object.assign( {}, this.errors );
      errors[ key ] = String( err || '' ).trim();
      if ( err ) console.warn( 'ERROR('+ key +'):', err );
      this.errors = errors;
      this.init = true;
    },

    // check if an error has been set for a key
    checkError( key ) {
      return ( key && this.errors.hasOwnProperty( key ) && this.errors[ key ] );
    },

    // clear all error messages
    clearErrors() {
      Object.keys( this.errors ).forEach( key => {
        this.errors[ key ] = '';
      });
    },

    // reset selected channel
    resetPlayer() {
      this.channel = {};
      this.songs = [];
      this.clearErrors();
      this.getChannels( true );
    },

    // try resuming stream problem if possible
    tryAgain() {
      if ( this.checkError( 'init' ) ) return this.resetPlayer();
      if ( this.channel.id ) return this.playChannel( this.channel );
    },

    // show/hide the sidebar
    toggleSidebar( toggle ) {
      this.sidebar = ( typeof toggle === 'boolean' ) ? toggle : false;
    },

    // show sidebar at startup if there are no errors
    initSidebar() {
      if ( this.checkError( 'init' ) ) return;
      this.toggleSidebar( true );
    },

    // toggle stream playback for current selected channel
    togglePlay() {
      if ( this.loading ) return;
      if ( this.playing ) return this.closeAudio();
      if ( this.channel.id ) return this.playChannel( this.channel );
    },

    // toggle sort order
    toggleSortOrder() {
      this.sortOrder = ( this.sortOrder === 'asc' ) ? 'desc' : 'asc';
    },

    // apply sorting and toggle order
    sortBy( param, order ) {
      if ( this.sortParam === param ) { this.toggleSortOrder(); }
      else { this.sortOrder = order || 'asc'; }
      this.sortParam = param;
    },

    // sort an array by key and order
    sortList( list, param, order ) {
      return list.sort( ( a, b ) => {
        if ( a.hasOwnProperty( param ) && b.hasOwnProperty( param ) ) {
          let _a = a[ param ];
          let _b = b[ param ];

          _a = ( typeof _a === 'string' ) ? _a.toUpperCase() : _a;
          _b = ( typeof _b === 'string' ) ? _b.toUpperCase() : _b;

          if ( order === 'asc' ) {
            if ( _a < _b ) return -1;
            if ( _a > _b ) return 1;
          }
          if ( order === 'desc' ) {
            if ( _a > _b ) return -1;
            if ( _a < _b ) return 1;
          }
        }
        return 0;
      });
    },

    // get channels data from api
    getChannels( sidebar ) {
      let endpoint = 'http://harunpehlivan.fm.tc/channels.json';
      let emsg = [ 'There was a problem trying to load the list of available channels from TEBİMTEBİTAGEM GAZETESİ RADYO TELEVİZYONU.' ];

      axios.get( endpoint ).then( res => {
        if ( !res || !res.data || !res.data.channels ) {
          emsg.push( 'The API response did not have any channels data available at this time.' );
          emsg.push( 'Status: Channels API Error.' );
          return this.setError( 'channels', emsg.join( ' ' ) );
        }
        for ( let c of res.data.channels ) {
          if ( !Array.isArray( c.playlists ) ) continue;
          // filter and sanitize list of channels
          c.twitter   = c.twitter ? 'https://twitter.com/@'+ c.twitter : ''; // full twitter url
          c.plsfile   = c.playlists.filter( p => ( p.format === 'mp3' && /^(highest|high)$/.test( p.quality ) ) ).shift().url || '';
          c.mp3file   = 'http://ice1.harunpehlivan.fm.tc/'+ c.id +'-128-mp3'; // assumed stream url
          c.songsurl  = 'http://harunpehlivan.fm.tc/songs/'+ c.id +'.json'; // songs data url
          c.infourl   = 'http://harunpehlivan.fm.tc'+ c.id +'/'; // channel page url
          c.listeners = c.listeners | 0; // force numeric
          c.updated   = c.updated | 0; // force numeric
          c.active    = false; // select state
          // update selected channel
          if ( this.isCurrentChannel( c ) ) {
            c.active = true;
            this.channel = Object.assign( this.channel, c );
          }
        }
        this.channels = res.data.channels.slice();
        if ( sidebar ) this.toggleSidebar( true );
        this.setError( 'init', '' );
        this.setError( 'channels', '' );
      })
      .catch( e => {
        emsg.push( 'Try again, or check your internet connection.' );
        emsg.push( 'Status: '+ String( e.message || 'Channels API Error' ) +'.' );
        let errstr = emsg.join( ' ' );
        if ( !this.channels.length ) this.setError( 'init', errstr );
        this.setError( 'channels', errstr );
      });
    },

    // fetch songs for a channel
    fetchSongs( channel, cb ) {
      if ( !channel || !channel.id || !channel.songsurl ) return;
      if ( !this.isCurrentChannel( channel ) ) { this.songs = []; this.track = {}; }
      let emsg = [ 'There was a problem trying to load the list of songs for channel '+ channel.title +' from SomaFM.' ];

      axios.get( channel.songsurl ).then( res => {
        if ( !res || !res.data || !res.data.songs ) {
          emsg.push( 'The API response did not have any songs data available at this time.' );
          emsg.push( 'Status: Songs API Error.' );
          return this.setError( 'songs', emsg.join( ' ' ) );
        }
        let songs  = res.data.songs.slice();
        this.track = songs.shift();
        this.songs = songs.slice( 0, 3 );
        this.setError( 'songs', '' );
        if ( typeof cb === 'function' ) cb( songs );
      })
      .catch( e => {
        emsg.push( 'Try again, or check your internet connection.' );
        emsg.push( 'Status: '+ String( e.message || 'Songs API Error' ) +'.' );
        this.setError( 'songs', emsg.join( ' ' ) );
      });
    },

    // run maintenance tasks on a timer
    setupMaintenance() {
      this.itv = setInterval( () => {
        this.getChannels(); // update channels
        this.fetchSongs( this.channel ); // update channel tracks
        // ...
      }, 1000 * 30 );
    },

    // setup animation canvas
    setupCanvas() {
      if ( !this.$refs.playerWrap ) return;
      if ( !this.$refs.playerCanvas ) return;
      // default canvas and player dimensions
      const player = this.$refs.playerWrap;
      const canvas = this.$refs.playerCanvas;
      // setup THREE renderer and replace default canvas
      this.fxBox = player.getBoundingClientRect();
      this.fxScene = new THREE.Scene();
      this.fxRenderer = new THREE.WebGLRenderer( { alpha: true, antialias: true, precision: 'highp' } );
      this.fxRenderer.setClearColor( 0x000000, 0 );
      this.fxRenderer.setPixelRatio( window.devicePixelRatio );
      this.fxRenderer.domElement.className = canvas.className;
      // setup camera
      this.fxCamera = new THREE.PerspectiveCamera( 60, ( this.fxBox.width / this.fxBox.height ), 0.1, 20000 );
      this.fxCamera.lookAt( this.fxScene.position );
      this.fxCamera.position.set( 0, 0, 300 );
      this.fxCamera.rotation.set( 0, 0, 0 );
      // light color
      this.fxColor = new THREE.Color();
      this.fxColor.setHSL( this.fxHue, 1, .5 );
      // setup light source
      this.fxLight = new THREE.PointLight( 0xffffff, 4, 400 );
      this.fxLight.position.set( 0, 0, 420 );
      this.fxLight.castShadow = false;
      this.fxLight.target = this.fxScene;
      this.fxLight.color = this.fxColor;
      this.fxScene.add( this.fxLight );
      // setup canvas and events
      canvas.parentNode.replaceChild( this.fxRenderer.domElement, canvas );
      window.addEventListener( 'mousemove', this.updateMousePosition );
      window.addEventListener( 'resize', this.updateStageSize );
      // add objects
      this.fxObjects.push( Sphere );
      // setup objects and start animation
      for ( let o of this.fxObjects ) o.create( this.fxBox, this.fxScene );
      this.updateStageSize();
      this.updateAnimations();
    },

    // update mouse position from center of canvas
    updateMousePosition( e ) {
      if ( !this.fxBox || !e ) return;
      this.fxMouse.x = Math.max( 0, e.pageX || e.clientX || 0 ) - ( this.fxBox.left + ( this.fxBox.width / 2 ) );
      this.fxMouse.y = Math.max( 0, e.pageY || e.clientY || 0 ) - ( this.fxBox.top + ( this.fxBox.height / 2 ) );
    },

    // update canvas size
    updateStageSize() {
      if ( !this.$refs.playerWrap || !this.fxRenderer ) return;
      this.fxBox = this.$refs.playerWrap.getBoundingClientRect();
      this.fxCamera.aspect = ( this.fxBox.width / this.fxBox.height );
      this.fxCamera.updateProjectionMatrix();
      this.fxRenderer.setSize( this.fxBox.width, this.fxBox.height );
    },

    // update light color based on audio freq
    updateStageLight() {
      let dist  = Math.floor( this.freqData[ 1 ] | 0 ) / 255;
      let color = Math.floor( this.freqData[ 16 ] | 0 ) / 255;
      this.fxLight.distance = 360 + ( 140 * dist );
      this.fxColor.setHSL( color, .5, .5 );
    },

    // update custom objects in 3d scene
    updateSceneObjects() {
      for ( let o of this.fxObjects ) {
        o.update( this.fxBox, this.fxMouse, this.freqData );
      }
    },

    // audio visualizer animation loop
    updateAnimations() {
      this.anf = requestAnimationFrame( this.updateAnimations );
      if ( !this.fxRenderer || !this.fxCamera || !this.analyser || !this.freqData ) return;
      this.analyser.getByteFrequencyData( this.freqData );
      this.updateSceneObjects();
      this.updateStageLight();
      this.fxRenderer.render( this.fxScene, this.fxCamera );
    },

    // setup audio routing and stream events
    setupAudio() {
      // setup audio sources
      this.audioSrc  = this.context.createMediaElementSource( this.audio );
      this.audioGain = this.context.createGain();
      this.analyser  = this.context.createAnalyser();
      // connect sources
      this.audioSrc.connect( this.audioGain );
      this.audioSrc.connect( this.analyser );
      this.audioGain.connect( this.context.destination );
      this.setVolume( this.volume );
      // check when stream can start playing
      this.audio.addEventListener( 'canplay', e => {
        this.audio.play();
        this.freqData = new Uint8Array( this.analyser.frequencyBinCount );
      });
      // check if stream is buffering
      this.audio.addEventListener( 'waiting', e => {
        this.playing = false;
        this.loading = true;
      });
      // check if stream is done buffering
      this.audio.addEventListener( 'playing', e => {
        this.setError( 'stream', '' );
        this.playing = true;
        this.loading = false;
      });
      // check if stream has ended
      this.audio.addEventListener( 'ended', e => {
        this.playing = false;
        this.loading = false;
      });
      // check for steam error
      this.audio.addEventListener( 'error', e => {
        let emsg = [];
        emsg.push( 'The selected audio stream could not load, or has stopped loading.' );
        emsg.push( 'Try again, or check your internet connection.' );
        emsg.push( 'Status: '+ String( e.message || 'Stream URL Error' ) +'.' );
        this.setError( 'stream', emsg.join( ' ' ) );
        this.playing = false;
        this.loading = false;
      });
    },

    // set audio volume
    setVolume( volume ) {
      if ( !this.audioGain ) return;
      volume = parseFloat( volume ) || 0;
      volume = ( volume < 0 ) ? 0 : volume;
      volume = ( volume > 1 ) ? 1 : volume;
      this.audioGain.gain.value = volume;
    },

    // checks is a channel is currently selected
    isCurrentChannel( channel ) {
      if ( !channel || !channel.id || !this.channel.id ) return false;
      if ( this.channel.id !== channel.id ) return false;
      return true;
    },

    // play audio stream for a channel
    playChannel( channel ) {
      if ( this.playing ) return;
      this.clearErrors();
      this.audio.src = channel.mp3file +'/?x='+ Date.now();
      this.audio.crossOrigin = 'anonymous';
      this.audio.load();
    },

    // select a channel to play
    selectChannel( channel ) {
      if ( !channel || !channel.id ) return;
      if ( this.isCurrentChannel( channel ) ) return;
      this.closeAudio();
      this.toggleSidebar( false );
      this.playChannel( channel );
      this.fetchSongs( channel );
      this.channel = channel;
    },

    // close active audio
    closeAudio() {
      this.setError( 'stream', '' );
      try { this.audio.pause(); } catch ( e ) {}
      try { this.audio.stop(); } catch ( e ) {}
      try { this.audio.close(); } catch ( e ) {}
      this.playing = false;
    },

    // start tracking playback time
    startClock() {
      this.stopClock();
      this.timeStart = Date.now();
      this.timeItv = setInterval( this.updateClock, 1000 );
      this.updateClock();
    },

    // update tracking playback time
    updateClock() {
      let p = n => ( n < 10 ) ? '0'+n : ''+n;
      let elapsed = ( Date.now() - this.timeStart ) / 1000;
      let seconds = Math.floor( elapsed % 60 );
      let minutes = Math.floor( elapsed / 60 % 60 );
      let hours   = Math.floor( elapsed / 3600 );
      this.timeDisplay = p( hours ) +':'+ p( minutes ) +':'+ p( seconds );
    },

    // stop tracking playback time
    stopClock() {
      if ( this.timeItv ) clearInterval( this.timeItv );
      this.timeItv = null;
    },

    // clear timer refs
    clearTimers() {
      if ( this.sto ) clearTimeout( this.sto );
      if ( this.itv ) clearInterval( this.itv );
      if ( this.anf ) cancelAnimationFrame( this.anf );
    },
  },

  // on app mounted
  mounted() {
    this.getChannels();
    this.setupAudio();
    this.setupMaintenance();
  },

  // on app destroyed
  destroyed() {
    this.closeAudio();
    this.clearTimers();
  }
});

External CSS

  1. https:////fonts.googleapis.com/css?family=Roboto+Condensed:700
  2. https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/three.js/96/three.min.js
  2. https://cdnjs.cloudflare.com/ajax/libs/axios/0.18.0/axios.min.js
  3. https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.17/vue.min.js