cssAudio - Activefile-genericCSS - ActiveGeneric - ActiveHTML - ActiveImage - ActiveJS - ActiveSVG - ActiveText - Activefile-genericVideo - ActiveLovehtmlicon-new-collectionicon-personicon-teamlog-outoctocatpop-outspinnerstartv

Pen Settings

CSS Base

Vendor Prefixing

Add External Stylesheets/Pens

Any URL's added here will be added as <link>s in order, and before the CSS in the editor. If you link to another Pen, it will include the CSS from that Pen. If the preprocessor matches, it will attempt to combine them before processing.

+ add another resource

You're using npm packages, so we've auto-selected Babel for you here, which we require to process imports and make it all work. If you need to use a different JavaScript preprocessor, remove the packages in the npm tab.

Add External Scripts/Pens

Any URL's added here will be added as <script>s in order, and run before the JavaScript in the editor. You can use the URL of any other Pen and it will include the JavaScript from that Pen.

+ add another resource

Use npm Packages

We can make npm packages available for you to use in your JavaScript. We use webpack to prepare them and make them available to import. We'll also process your JavaScript with Babel.

⚠️ This feature can only be used by logged in users.

Code Indentation

     

Save Automatically?

If active, Pens will autosave every 30 seconds after being saved once.

Auto-Updating Preview

If enabled, the preview panel updates automatically as you code. If disabled, use the "Run" button to update.

            
              <!-- 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>Soma FM Player</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 SomaFM.com. 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/rainner/soma-fm-player" title="View on Github" target="_blank">
            <i class="fa fa-github"></i>
          </a> &nbsp;
          <a class="common-btn text-faded" href="https://codepen.io/rainner" 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://raw.githubusercontent.com/rainner/soma-fm-player/master/public/img/bg.jpg'; 

// 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% );
    }
  }
}

            
          
!
            
              /**
 * Soma FM Web Player
 * Author: Rainner Lins (2018)
 * Site: https://rainnerlins.com/
 */

//               .andAHHAbnn.
//            .aAHHHAAUUAAHHHAn.
//           dHP^~"        "~^THb.
//     .   .AHF                YHA.   .
//     |  .AHHb.              .dHHA.  |
//     |  HHAUAAHAbn      adAHAAUAHA  |
//     I  HF~"_____        ____ ]HHH  I
//    HHI HAPK""~^YUHb  dAHHHHHHHHHH IHH
//    HHI HHHD> .andHH  HHUUP^~YHHHH IHH
//    YUI ]HHP     "~Y  P~"     THH[ IUP
//     "  `HK                   ]HH'  "
//         THAn.  .d.aAAn.b.  .dHHP
//         ]HHHHAAUP" ~~ "YUAAHHHH[
//         `HHP^~"  .annn.  "~^YHH'
//          YHb    ~" "" "~    dHF
//           "YAb..abdHHbndbndAP"
//            THHAAb.  .adAHHF
//             "UHHHHHHHHHHU"
//               ]HHUUHHHHHH[
//             .adHHb "HHHHHbn.
//      ..andAAHHHHHHb.AHHHHHHHAAbnn..
// .ndAAHHHHHHUUHHHHHHHHHHUP^~"~^YUHHHAAbn.
//   "~^YUHHP"   "~^YUHHUP"        "^YUP^"
//        ""         "~~"

/**
 * 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 = 'https://somafm.com/channels.json';
      let emsg = [ 'There was a problem trying to load the list of available channels from SomaFM.' ];

      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.somafm.com/'+ c.id +'-128-mp3'; // assumed stream url
          c.songsurl  = 'https://somafm.com/songs/'+ c.id +'.json'; // songs data url
          c.infourl   = 'https://somafm.com/'+ 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();
  }
});

            
          
!
999px
🕑 One or more of the npm packages you are using needs to be built. You're the first person to ever need it! We're building it right now and your preview will start updating again when it's ready.
Loading ..................

Console