text: /**
   * 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();
    }
  });
  