Pen Settings

HTML

CSS

CSS Base

Vendor Prefixing

Add External Stylesheets/Pens

Any URLs added here will be added as <link>s in order, and before the CSS in the editor. You can use the CSS from another Pen by using its URL and the proper URL extension.

+ add another resource

JavaScript

Babel includes JSX processing.

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

Packages

Add Packages

Search for and use JavaScript packages from npm here. By selecting a package, an import statement will be added to the top of the JavaScript editor for this package.

Behavior

Auto Save

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.

Format on Save

If enabled, your code will be formatted when you actively save your Pen. Note: your code becomes un-folded during formatting.

Editor Settings

Code Indentation

Want to change your Syntax Highlighting theme, Fonts and more?

Visit your global Editor Settings.

HTML

              
                <h1 class=-sr-only>A mostly* accessible audio playlist player.</h1>

<p class=-sr-only>*Just the progress bar to replace.</p>

<section class=player id=player aria-label="Music player" v-cloak>

  <transition-group tag=div class=transition_cover :name=transitionName>
    <div class=cover
         v-if="$index === currentTrackIndex"
         v-for="(track, $index) in tracks"
         :key="$index + 0">

      <!-- Fade img in, if not already preloaded -->
      <img class=cover_bg :src=track.cover onload="this.style.opacity=1" alt="" role=presentation>

      <!-- UX: better to keep focus on activating button, 
           but announce any change of track to SR via role=alert. -->
      <div class=cover_copy role=alert>

        <h2 class=cover_artist><span class=-sr-only>Artist: </span>{{ currentTrack.artist }}</h2>

        <h3 class=cover_title><span class=-sr-only>Track </span><span class=cover_num>{{ currentTrack.num }}.</span> {{ currentTrack.title }}</h3>

      </div>

    </div>
  </transition-group>

  <!-- Duplicated .cover_copy inserted here by JS to maintain document flow -->

  <div class=controls-volume>

    <!-- div.volume is hidden if volume control is unsupported (iOS) -->
    <div class=volume>

      <!-- Following Heydon Pickerings advice:
            - Toggle either aria-pressed, or the label copy.
            - UX: Text hints make more useful SR announcements than "selected".
      -->
      <span class=btn_wrap>
        <!-- data-toggle is the toggle class,
             data-states-icon creates a switched layer -->
        <button class=btn-mute data-toggle=-js-isMuted data-states-icon=muted ref=btnMute @click=muteTrack>
          <span class=-sr-only>{{isMuted ? 'Muted' : 'Mute'}}</span>
          <svg class="btn_svg-mute" focusable="false" aria-hidden="true" viewBox="0 0 96 96">
            <g class="btn_icon" stroke-width="4">
              <path class="svg_vol-speak"
                    d="M33 38l14-13v46L33 57V38zm-8 1h-2v17h2V39z"/>
              <!-- Layers are switched between set-A and set-B -->
              <g class="svg_vol-level set-A" :opacity="isMuted ? 0 : 1">
                <path :opacity="volume > 0 ? 1 : 0" d="M57 45c2 2 2 4 0 6"/>
                <path :opacity="volume > 0.33 ? 1 : 0" d="M63 40c3 5 3 11 0 16"/>
                <path :opacity="volume > 0.66 ? 1 : 0" d="M69 34c5 9 5 19 0 28"/>
              </g>
              <path class="svg_vol-mute set-B" :opacity="isMuted ? 1 : 0" d="M57 36l16 24m-16 0l16-24"/>
            </g>
          </svg>
        </button>
      </span>

      <label class=-sr-only aria-hidden=true for=vol_range>Volume</label>
      <input class=range_input id=vol_range type=range
             min=0 max=100 step=5 value=50
             aria-label=Volume
             ref=volRange @input=volRange
             >

    </div>

    <span class=btn_wrap>
      <!-- data-toggle is a toggled classname
           data-states-circle creates the circle animation layer -->
      <button class=btn-play data-toggle=-js-isPlaying data-states-circle=animated ref=btnPlay @click=playTrack>

          <span class=-sr-only>{{isTimerPlaying ? 'Pause' : 'Play'}}: {{ currentTrack.title }}</span>

        <svg class="btn_svg-play" focusable="false" aria-hidden="true" viewBox="0 0 96 96">
          <g class="btn_icon">
            <path d="M37,28L37,68"/> <!-- play/pause left -->
            <!-- <path d="M60,28L60,68"/> - pause right -->
            <path class="play_top" d="M37,28L67,48"/>
            <path class="play_base" d="M37,68L67,48"/>
          </g>
        </svg>
      </button>
    </span>

  </div>


  <div class=controls-track>

    <span class=btn_wrap>
      <button class=btn-prev ref=btnPrev @click=prevTrack @mouseover=prefetch() @focus=prefetch()>
        <span class=-sr-only>Previous: Track {{ currentTrack.prevNum }}</span>
        <svg class="btn_svg-prev" focusable="false" aria-hidden="true" viewBox="0 0 96 96">
          <path class="btn_icon" d="M28 35v26M41 48h31M53 35L41 48l12 13"/>
        </svg>
      </button>
    </span>

    <div class=progress>
      <div class=progress_duration aria-hidden=true>{{ duration }}</div>
      <label class=progress_bar for=progbar>
        <span class=-sr-only>Track progress {{ barWidth }}%</span>

        <!-- Change to a range input, so AT devices may also skip during a track - Once this has changed the project will be considered complete.-->
        <progress id=progBar class=progress_current
                  max=100 aria-hidden=true
                  :value=barWidth
                  ref=progress @click=progressTrack></progress>
      </label>
      <div class=progress_time aria-hidden=true>{{ currentTime }}</div>
    </div>

    <span class=btn_wrap>
      <button class=btn-next ref=btnNext @click=nextTrack @mouseover=prefetch(true) @focus=prefetch(true)>
        <span class=-sr-only>Next: Track {{ currentTrack.nextNum }}</span>
        <svg class="btn_svg-next" focusable="false" aria-hidden="true" viewBox="0 0 96 96">
          <path class="btn_icon" d="M69 35v26M26 48h31M45 35l12 13-12 13"/>
        </svg>
      </button>
    </span>

  </div>

</section>


<svg class=-sr-only focusable="false" aria-hidden="true">
  <defs>

    <!-- play circle gradient for animation -->
    <linearGradient id="play_gradient" x1="0%" y1="0%" x2="33%" y2="0%" spreadMethod="reflect">
      <stop offset="0%" stop-color="var(--playRotationColor)"/>
      <stop offset="100%" stop-color="var(--btnHighlight)"/>
    </linearGradient>

    <!-- circle border for all buttons, embedded by JS -->
    <symbol id="circle">
      <circle cx="48" cy="48" r="45"/>
    </symbol>

  </defs>
</svg>



<!--

Latest working psychedelic player: https://websemantics.uk/listening/

Redeveloped around this code:
  https://codepen.io/JavaScriptJunkie/pen/qBWrRyg
  https://github.com/muhammederdem/mini-player

Plan is to create a Vue app from the basic source above.
Step 1: Redevelop with symantic HTML and WCAG-2 accessibility. DONE.
Step 2: Add (IMHO) essential features. DONE.
Step 3: Examine HTML, extract templates for component usage. WIP.

Story so far:
Replaced generic divs with semantic HTML elements.
Replaced prefetch, audio and image, now activated just before requirement.
Buffering of audio is indicated by fast spinning play button circle.
Drag/swipe enabled where supported.
Removed volume controls where unsupported.
Added WCAG Accessibility:
  Buttons 48px minimum diameter.
  Keyboard friendly.
  Scales to 200% (min display: 320 x 480px).
  Portrait and landscape modes.
  Track change SR announcements.
  Labeled buttons.
  Reduced motion compatible.
  AA color contrast (caveat: SVG symbol rather than whole button).
Moved button animations to the compositing layer for mobile efficiency.

Also see (most built purely to support this project): 
  Animated SVG player buttons: https://codepen.io/2kool2/pen/NWbygWz
  Card transition playground: https://codepen.io/2kool2/pen/OJbzovV
  Drag/swipe to change images: https://codepen.io/2kool2/pen/Yzprxjq
  Animated play button: https://codepen.io/2kool2/pen/LYbZZdm
  Volume change support: https://codepen.io/2kool2/pen/PobNaqJ
  Psychedelic CSS animation: https://codepen.io/2kool2/pen/NWRwNrR
  Minimal audio player: https://codepen.io/2kool2/pen/JjdyEgB


I'm using this project in an attempt to learn Vue.js.
I've taken a basic Vue player and updated the HTML to my personal standard, plus added essential features (IMHO).
Next I wish to refactor into a more data driven structure.

To do:
  Use Vue Components to break down large main thread.
  Internalise btn anims to Vue where possible.

  When I finally replace <progress> with <input type=range> this project will be considered finished.

Testing notes:

Safari - disabled animated CSS filters.
iOS - Removed volume and mute controls.

-->




<!-- Footer codepen include -->
[[[https://codepen.io/2kool2/pen/mKeeGM]]]
              
            
!

CSS

              
                *,
*::before,
*::after {
  box-sizing: border-box;
}

body {
  color: #fff;
  background-color: #3a3a3a;
  margin: 0;
  padding: 4px;
  width: 100vw;
  min-height: 100vh;
  overflow: hidden;
}
@media (min-width: 480px) {
  body {
    /* Just for visual centring
       - Not WCAG compatable at 200% on smaller display ports */
    display: grid;
    place-content: center;
  }
}
@media (min-height: 480px) {
  body {
    /* Just for visual centring
       - Not WCAG compatable at 200% on smaller display ports */
    display: grid;
    align-content: center;
  }
}

:not(.-js-supportsVolumeControl) body {
  /* Just to indicate no support for volume controls */
  background-color: #5a5a5a;
}

/* Remove animations and transitions if preferred */

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    animation-delay: -1ms !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
    background-attachment: initial !important;
    transition-delay: 0s !important;
  }
}

/* SCREEN READER ONLY */
.-sr-only {
  position: absolute !important;
  width: 1px !important;
  height: 1px !important;
  white-space: nowrap !important;
  clip: rect(0 0 0 0) !important;
  clip-path: inset(50%) !important;
  overflow: hidden !important;
}


/* PLAYER */

.player {

  --colorText: #fff;
  
  --bgProgress: hsl(214, 82%, 59%); /*  #418cec */
  --bgTrack: hsla(214, 30%, 72%, 0.5);
  --bgTrackHover: hsla(214, 82%, 59%, .5);
  --bgTravel: hsl(214, 30%, 72%); /* #a3b3ce */

  --bgThumb: #fff;
  --bgThumbHover: rgba(0,0,0,.65);
  --borderThumb: hsla(214, 82%, 100%, .25);
  --borderThumbHover: hsl(214, 100%, 59%);
  --shadowThumb: .125rem .125rem .25rem rgba(0,0,0,.5);
  --shadowThumbHover: 0 .25em .5em rgba(0,0,0,.75);
  --transformThumbHover: scale(1.75);
  --whrThumb: 1.25rem;

  /* Ensures enough colour contrast to meet WCAG-2 for 
        white text on white background
        for both text and SVG icons.
  */
  /* Firefox/Opera/Chrome */
  --colorContrastWCAG: drop-shadow(0 0 .666px #000) drop-shadow(0 0 .666px #000) drop-shadow(0 0 .666px #000);
  /* Safari 14 */
  --colorContrastWCAG: drop-shadow(0 0 1px #000) drop-shadow(0 0 1px #000) drop-shadow(0 0 1px #000);
}

.player {
  /* Must be in px to meet WCAG 200% text size @ 320px width viewport */
  --pad: 8px;

  position: relative;
  width: calc(100vw - 8px);
  min-width: 312px;
  max-width: 30rem;
  padding: var(--pad);
  margin: 0 auto;
  perspective: 1000px;

  font-family: sans-serif;
  text-rendering: optimizeSpeed;
  color: var(--colorText);
}

/* Must be stated in px here, to meet WCAG 200% text size @ 320px width viewport */
@media (min-width: 480px) {
  .player {
    --pad: 16px;
  }
}
@media (min-width: 30em) {
  .player {
    --pad: 1rem;
  }
}

.player > * + * + * {
  padding-top: calc(var(--pad) * 2);
}

.cover {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;

  padding: var(--pad);
  border-radius: 1rem;
  box-shadow: 0px 1rem 2rem -.25rem rgba(0,0,0,.8);

  transition: transformZ(0);
  will-change: transform, opacity;
}
.cover_bg {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;

  opacity: 0; /* updates via inline JS onload */
  object-fit: cover;
  border-radius: 1rem;
  transition: transformZ(0);
  transition: opacity .3s ease-out;
  animation: hueSatRotate 24s infinite linear;
}
/* Safari will not animate CSS filter values (it just steps) */
.-js-isSafari .cover_bg {
  animation: none;
}
  @keyframes hueSatRotate {
    /* WARNING: Filter animations are very CPU / GPU hungry
    - disable if you experience any issues! */
    0% {
      filter: hue-rotate(0deg) saturate(100%) contrast(100%);
    }
    50% {
      filter: hue-rotate(180deg) saturate(300%) contrast(200%);
    }
    100% {
      filter: hue-rotate(359deg) saturate(100%) contrast(100%);
    }
  }
.cover_artist {
  font-family: serif;
  font-size: 2rem;
  font-weight: 400;
  letter-spacing: .02em;
  margin: 0 0 .25rem;
  filter: var(--colorContrastWCAG);
}
.cover_title {
  font-size: 1.17rem;
  font-weight: 100;
  letter-spacing: .01em;
  margin: 0;
  filter: var(--colorContrastWCAG);
}
.cover_num {
  font-family: serif;
  letter-spacing: -0.05em;
  opacity: .9;
}


[class^="controls-"] {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: .5rem;
  /* Chrome/Safari/Opera: Keep controls on top during card change animation */
  -webkit-transform: translate3d(0,0, 30px) scale(.97);
}


.volume {
  visibility: hidden;
  position: relative;
  display: flex;
  gap: .5rem;
/*   gap: .5rem; - fails in Safari*/
  flex: 1 1 0px;
  align-items: center;
}
/* iOS does not support JS volume control */
.-js-supportsVolumeControl .volume {
  visibility: visible;
}

.range_input[type=range] {
  -webkit-appearance: none;
  position: relative;
  width: calc(100% - 4.25rem);
  height: .5rem;
  border-radius: .25rem;
  background: transparent;
  opacity: .5;
  transition: opacity .3s ease-out;

  /* Safari repair (for gap) */
/*   margin: .5rem 0; */
}

.-js-hasMouse:not(.-js-hasTouch) .range_input[type=range] {
  opacity: .25; /* Safari minimum */
}
input[type=range]:focus {
  outline: none;
}
.volume:hover > .range_input[type=range],
.volume:focus-within > .range_input[type=range] {
  opacity: 1;
}

/* Webkit range */
input[type=range]::-webkit-slider-runnable-track {
  width: 100%;
  height: .5rem;
  cursor: pointer;
  background-color: var(--bgTrack);
  border-radius: .25rem;
  transition: all .3s ease-out;
}
input[type=range]::-webkit-slider-thumb {
  -webkit-appearance: none;
  margin-top: -.5rem;

  width: var(--whrThumb);
  height: var(--whrThumb);
  cursor: pointer;
  background-color: var(--bgThumb);
  border: .25rem solid var(--borderThumb);
  border-radius: var(--whrThumb);
  box-shadow: var(--shadowThumb);
  transition: all .3s ease-out;
}
input[type=range]:hover::-webkit-slider-thumb,
input[type=range]:focus::-webkit-slider-thumb {
  background-color: var(--bgThumbHover);
  border: .125rem solid var(--borderThumbHover);
  box-shadow: var(--shadowThumbHover);
  transform: var(--transformThumbHover);
}

/* Mozilla range */
input[type=range]::-moz-range-track {
  width: 100%;
  height: .5rem;
  cursor: pointer;
  background-color: var(--bgTrack);
  border-radius: .25rem;
  transition: all .3s ease-out;
}
input[type=range]::-moz-range-thumb {
  width: var(--whrThumb);
  height: var(--whrThumb);
  cursor: pointer;
  background-color: var(--bgThumb);
  border: 2px solid var(--borderThumb);
  border-radius: var(--whrThumb);
  box-shadow: var(--shadowThumb);
  transition: all .3s ease-out;
}
input[type=range]:hover::-moz-range-thumb,
input[type=range]:focus::-moz-range-thumb {
  background-color: var(--bgThumbHover);
  border: 2px solid var(--borderThumbHover);
  box-shadow: var(--shadowThumbHover);
  transform: var(--transformThumbHover);
}

/* Microsoft range - UNTESTED */
input[type=range]::-ms-track {
  width: 100%;
  height: .5rem;
  cursor: pointer;
  color: transparent;
  background-color: transparent;
  border: 0 solid transparent;
  border-radius: .25rem;
  box-shadow: 0 0 0 #000;
  transition: all .3s ease-out;
}
input[type=range]::-ms-fill-lower,
input[type=range]::-ms-fill-upper {
  background-color: var(--bgTrack);
  border: 0 solid #000;
  border-radius: .5rem;
  box-shadow: 0 0 0 #000;
}
input[type=range]::-ms-thumb {
  margin-top: 1px;
  box-shadow: var(--shadowThumb);
  border: .25rem solid var(--borderThumb);
  height: var(--whrThumb);
  width: var(--whrThumb);
  border-radius: var(--whrThumb);
  background-color: var(--bgThumb);
  cursor: pointer;
  transition: all .3s ease-out;
}
input[type=range]:hover::-ms-thumb,
input[type=range]:focus::-ms-thumb {
  border: .125rem solid var(--borderThumbHover);
  background-color: var(--bgThumbHover);
  transform: var(--transformThumbHover);
}





/* Must replace with an input[range] */
.progress {
  flex: 1 1 0px;
  font-size: .85rem;
  line-height: .85;
  letter-spacing: .05em;
  padding: 0 8px;
  opacity: 0.5;
}
.progress_duration {
  text-align: right;
  padding-top: .125rem;
  filter: var(--colorContrastWCAG);
}
.progress_bar {
  display: block;
  cursor: pointer;
  margin: 0 0 .5rem 0;
}
.progress_current {
  width: 100%;
  height: .5rem;
  background-color: var(--bgTravel);
  border: 0;
  border-radius: .5rem;
}
.progress_current::-webkit-progress-bar {
  border-radius: .5rem;
  background-color: var(--bgTrack);
}
.progress_current::-webkit-progress-value {
  border-radius: .5rem;
  background-color: var(--bgProgress);
}
.progress_current::-moz-progress-bar {
  border-radius: .5rem;
  background-color: var(--bgProgress);
}
.progress_current::-ms-fill {
  border-radius: .5rem;
  background-color: var(--bgProgress);
}

.progress_time {
  font-weight: 100;
  filter: var(--colorContrastWCAG);
}


/* BUTTONS */

/* Button VARS */

body {
  
  --btnColor: #fff;
  --btnHighlight: hsl(214, 100%, 59%);

  --btnHoverScale: 1.13;
  --btnPressedScale: 0.8;

  --btnTimings: 0.3s ease-out;

  --btnBg: #000;
  --btnBgOpacity: .528; /* Min #777 against white bg - to meet WCAG-2 minimum contrast. */

  --rotationSpeed: 2s; /* Set to .5s whilst loading */
  --playRotationColor: #0f0;

  --mutePulseSpeed: 2s;
  --mutePulseColor: #f00;
  
  --shadowBtn: 0 .125rem .25rem rgba(0,0,0,.5);
  --shadowBtnHover: 0 .25rem .5rem rgba(0,0,0,.75);
}


/* BUTTONS */

/* Button wrapper controls hover & click scaling, and also box-shadows. These cannot be on the button, as they will force the clickable area to be square instead of round. */

.btn_wrap {
  position: relative;
  display: grid; /* Collapse box */
  border-radius: 50%;
  margin: 0 auto;
  transform: translatez(0);
  transition: transform var(--btnTimings);
}
.btn_wrap::before,
.btn_wrap::after {
  content: '';
  position: absolute;
  z-index: -1;
  width: 100%;
  height: 100%;
  top: 0;
  left: 0;
  border-radius: 50%;
}
.btn_wrap::before {
  box-shadow: var(--shadowBtn);
}
.btn_wrap::after {
  box-shadow: var(--shadowBtnHover);
  opacity: 0;
  transform: translatez(0);
  transition: opacity var(--btnTimings);
}
.btn_wrap:hover::after,
.btn_wrap:focus-within::after {
  opacity: 1;
}
[class^="btn_wrap"]:hover,
[class^="btn_wrap"]:focus-within {
  transform: scale(var(--btnHoverScale)) translatez(0);
}
[class^="btn_wrap"].-js-clicked {
  animation: btn-pressed var(--btnTimings) forwards;
}
  @keyframes btn-pressed {
    50% {
      transform: scale(var(--btnPressedScale)) translatez(0);
    }
  }

button {
  -webkit-user-select: none;
  -moz-user-select: none;
  -ms-user-select: none;
  -webkit-appearance: button;
  -webkit-appearance: none;
  -moz-appearance: none;
}
button::-moz-focus-inner {
  padding: 0 !important;
  border: 0 none !important;
}

[class^="btn-"] {

  width: 3rem;
  height: 3rem;

  background-color: transparent;
  border-radius: 50%;
  overflow: hidden;
  position: relative;
  display: inline-block;
  margin: 0;
  padding: 0;
  border: none;
  cursor: pointer;
}
[class^="btn-"]:focus {
  outline: 0 solid;
}

/* SVG */

[class^="btn_svg-"] {
  fill: none;
  stroke-width: 8;
  stroke-linecap: round;
  stroke-linejoin: round;
  border-radius: 50%;
  pointer-events: none;
}

/* Circle */

[class^="btn_circle"] {
  fill: var(--btnBg);
  stroke-width: 6;
  transform-origin: center;
  transition: opacity var(--btnTimings);
}
.btn_circle {
  stroke: var(--btnColor);
  opacity: var(--btnBgOpacity);
}
.btn_circle-highlight {
  stroke: var(--btnHighlight);
  opacity: 0;
}
[class^="btn-"]:hover .btn_circle-highlight:not(.animated),
[class^="btn-"]:focus .btn_circle-highlight:not(.animated) {
  opacity: 1;
}

/* Play - Animated circles */
[class^="btn_circle"].animated {
  stroke: url(#play_gradient);
  opacity: 0;
}
.-js-isPlaying .btn_circle {
  opacity: 0;
}
.-js-isPlaying .btn_circle.animated {
  opacity: var(--btnBgOpacity);
}
.-js-isPlaying:hover .btn_circle-highlight.animated,
.-js-isPlaying:focus .btn_circle-highlight.animated {
  opacity: 1;
}
.-js-isPlaying [class^="btn_circle"].animated {
  animation: rotatePlay var(--rotationSpeed) linear infinite;
}
  @keyframes rotatePlay {
    100% {
      transform: rotate(359deg);
    }
  }


/* Icon */

[class^="btn_icon"] {
  transition: opacity var(--btnTimings);
}
.btn_icon {
  stroke: var(--btnColor);
  opacity: 1;
}
.btn_icon-highlight {
  stroke: var(--btnHighlight);
  opacity: 0;
}
[class^="btn-"]:hover .btn_icon-highlight,
[class^="btn-"]:focus .btn_icon-highlight {
  opacity: 1;
}



/* MUTE */

[class^="btn_icon"]:not(.muted) .set-B,
[class^="btn_icon"].muted .set-A {
  display: none;
}
.btn_icon .svg_vol-speak {
  fill: var(--btnColor);
}
.btn_icon-highlight .svg_vol-speak {
  fill: var(--btnHighlight);
}
.-js-isMuted [class^="btn_icon"]:not(.muted) {
  opacity: 0;
}

.-js-isMuted .svg_vol-mute {
  opacity: 1;
  animation: pulseColor var(--mutePulseSpeed) alternate linear infinite;
}
  @keyframes pulseColor {
    50% {
      stroke: var(--mutePulseColor);
    }
  }
.btn-mute:hover .svg_vol-mute,
.btn-mute:focus .svg_vol-mute {
  animation: none;
}


/* PLAY */

.btn-play {
  width: 6rem;
  height: 6rem;
/*   margin: 0 auto 2rem; */
  margin: 0 auto;
}

/* Play state animation */
.btn_svg-play [class^="play_"] {
  transform-origin: center;
  transform: translate3d(0, 0, 0);
  transition: transform var(--btnTimings);
}
.-js-isPlaying .play_top {
  transform: rotate(56.5deg) translate3d(1%,-1%, 0);
}
.-js-isPlaying .play_base {
  transform: rotate(-56.5deg) translate3d(1%,1%,0);
}


/* Vue */

[v-cloak] {
  display:none;
}

/* Card Transitions
   - Playground: https://codepen.io/2kool2/pen/OJbzovV?editors=0100
   - With additional help from:
     https://medium.com/vue-mastery/how-to-create-vue-js-transitions-6487dffd0baa
*/
.player {
  --duration: .9s;
  --perspective: 1000px;
  --rotateY: -60deg;
  --translateX: 100%;

  --resetToZero: rotateY(0deg) translateX(0);
  --reverseRotateY: calc(0deg - var(--rotateY));
  --reverseTranslateX: calc(0% - var(--translateX));

  transform-style: preserve-3d;
  perspective: var(--perspective);
}
.cover + .cover {
  opacity: 0;
  transform: var(--resetToZero);
}
[class*="fadeCard-"][class*="-active"] {
  pointer-events: none;
  animation: var(--animName) var(--duration) forwards ease-out;
}
[class*="fadeCard-"][class*="-active"] .cover_bg {
  filter: none;
}

.fadeCard-next-leave-to {
  --animName: offToLeft;
}
.fadeCard-next-enter-to {
  --animName: onFromRight;
}
.fadeCard-prev-leave-to {
  --animName: offToRight;
}
.fadeCard-prev-enter-to {
  --animName: onFromLeft;
}

@keyframes onFromLeft {
  0% {
    opacity: 0;
    transform:
      rotateY(var(--rotateY))
      translateX(var(--reverseTranslateX));
  }
  75% {
    opacity: 1;
  }
  100% {
    opacity: 1;
    transform: var(--resetToZero);
  }
}
@keyframes offToLeft {
  0% {
    opacity: 1;
    transform: var(--resetToZero);
  }
  25% {
    opacity: 1;
  }
  100% {
    opacity: 0;
    transform:
      rotateY(var(--rotateY))
      translateX(var(--reverseTranslateX));
  }
}
@keyframes onFromRight {
  0% {
    opacity: 0;
    transform:
      rotateY(var(--reverseRotateY))
      translateX(var(--translateX));
  }
  75% {
    opacity: 1;
  }
  100% {
    opacity: 1;
    transform: var(--resetToZero);
  }
}
@keyframes offToRight {
  0% {
    opacity: 1;
    transform: var(--resetToZero);
  }
  25% {
    opacity: 1;
  }
  100% {
    opacity: 0;
    transform:
      rotateY(var(--reverseRotateY))
      translateX(var(--translateX));
  }
}





/* Unused but nice to play with when applied to text headings: */
.blendmode {
  text-shadow: none;
  color: #fff;
  background: #fff;
  mix-blend-mode: exclusion;
  mix-blend-mode: color-dodge;
  background-clip: text;
  text-fill-color: transparent;
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
}
              
            
!

JS

              
                console.clear();

// Feature support (add classes to root)

// iOS will not allow JS to alter volume
var supportsVolumeControl = (function (window, document) {
  'use strict';

  var audio = document.createElement('audio');
  audio.volume = 0.01;
  document.body.appendChild(audio);

  audio.addEventListener('volumechange', _ =>
     audio.volume === 0.01 &&
     document.documentElement.classList.add("-js-supportsVolumeControl")
  , {once: true});

  document.body.removeChild(audio);

}(window, document));


var Touch_Detection = (function (window, document) {
  'use strict';
  window.addEventListener('touchstart', _ => 
    document.documentElement.classList.add('-js-hasTouch')
  , {once: true});
}(window, document));


var Mouse_Detection = (function (window, document) {
  'use strict';
  window.addEventListener('mouseover', _ => 
    document.documentElement.classList.add('-js-hasMouse')
  , {once: true});
}(window, document));


// Safari will not animate the CSS filter values (bg image)
// https://stackoverflow.com/questions/7944460/detect-safari-browser
var isSafari = (function (window, document) {
  'use strict';

  if (/^((?!chrome|android).)*safari/i.test(navigator.userAgent)) {
    document.documentElement.classList.add('-js-isSafari');
    return true;
  }
  return false;

}(window, document));




// YouTube : 'https://www.youtube.com/watch?v=XiAvWJDQpDw'
const tracks = [
  {
    title: 'You\'re Not There',
    artist: 'Listening',
    num: 1,
    cover: 'https://websemantics.uk/listening/i/7.jpg',
    source: 'https://websemantics.uk/listening/mp3/Listening_-_Listening_(1968)/01.listening.youre.not.there.mp3'
  },
  {
    title: 'Laugh At The Stars',
    artist: 'Listening',
    num: 2,
    cover: 'https://websemantics.uk/listening/i/2.jpg',
    source: 'https://websemantics.uk/listening/mp3/Listening_-_Listening_(1968)/02.listening.laugh.at.the.stars.mp3'
  },
  {
    title: '9/8 song',
    artist: 'Listening',
    num: 3,
    cover: 'https://websemantics.uk/listening/i/3.jpg',
    source: 'https://websemantics.uk/listening/mp3/Listening_-_Listening_(1968)/03.listening.98.song.mp3'
  },
  {
    title: 'Stoned Is',
    artist: 'Listening',
    num: 4,
    cover: 'https://websemantics.uk/listening/i/12.jpg',
    source: 'https://websemantics.uk/listening/mp3/Listening_-_Listening_(1968)/04.listening.stoned.is.mp3'
  },
  {
    title: 'Forget It, Man!',
    artist: 'Listening',
    num: 5,
    cover: 'https://websemantics.uk/listening/i/13.jpg',
    source: 'https://websemantics.uk/listening/mp3/Listening_-_Listening_(1968)/05.listening.forget.it.man.mp3'
  },
  {
    title: 'I Can Teach You',
    artist: 'Listening',
    num: 6,
    cover: 'https://websemantics.uk/listening/i/6.jpg',
    source: 'https://websemantics.uk/listening/mp3/Listening_-_Listening_(1968)/06.listening.i.can.teach.you.mp3'
  },
  {
    title: 'So Happy',
    artist: 'Listening',
    num: 7,
    cover: 'https://websemantics.uk/listening/i/1.jpg',
    source: 'https://websemantics.uk/listening/mp3/Listening_-_Listening_(1968)/07.listening.so.happy.mp3'
  },
  {
    title: 'Cuando',
    artist: 'Listening',
    num: 8,
    cover: 'https://websemantics.uk/listening/i/8.jpg',
    source: 'https://websemantics.uk/listening/mp3/Listening_-_Listening_(1968)/08.listening.cuando.mp3'
  },
  {
    title: 'Baby: Where Are You?',
    artist: 'Listening',
    num: 9,
    cover: 'https://websemantics.uk/listening/i/9.jpg',
    source: 'https://websemantics.uk/listening/mp3/Listening_-_Listening_(1968)/09.listening.baby.where.are.you.mp3'
  },
  {
    title: 'Fantasy',
    artist: 'Listening',
    num: 10,
    cover: 'https://websemantics.uk/listening/i/10.jpg',
    source: 'https://websemantics.uk/listening/mp3/Listening_-_Listening_(1968)/10.listening.fantasy.mp3'
  },
  {
    title: 'See You Again',
    artist: 'Listening',
    num: 11,
    cover: 'https://websemantics.uk/listening/i/11.jpg',
    source: 'https://websemantics.uk/listening/mp3/Listening_-_Listening_(1968)/11.listening.see.you.again.mp3'
  }
];




// Vue.js
// Redeveloped around this player:
// https://github.com/muhammederdem/mini-player
// Thanks Muhammed, the deconstruction has has been invaluable.

new Vue({
  el: '#player',
  data() {
    return {
      audio: null,
      barWidth: null,
      duration: null,
      currentTime: null,
      isTimerPlaying: false,
      isMuted: false,
      volume: .5,

      clickedClass: '-js-clicked',

      playingSpin: '2s',
      bufferingSpin: '0.5s',

      isChanging: false,
      minDragTravel: 88, 
      direction: 0,
      positionX: {
        start: 0,
        end: 0
      },
      tracks,
      currentTrack: null,
      currentTrackIndex: 0,
      transitionName: null
    };
  },


  computed: {
    supportsVolume: _ => !!document.documentElement.classList.contains("-js-supportsVolumeControl"),

    hasPointerEvents: _ => !!window.PointerEvent
  },


  methods: {

// PREFETCHING AUDIO AND IMAGE

    appendPrefetchLink({id, href, as}) {
      if (document.getElementById(id)) return;
      const link = document.createElement('link');
      link.rel = 'prefetch';
      link.id = id;
      link.href = href;
      link.as = as;
      document.head.appendChild(link);
    },

    prefetch(isNextTrack) {
      let idx = this.currentTrackIndex;
      !!isNextTrack ? idx++ : idx--;
      if (idx < 0) idx = this.tracks.length - 1;
      if (idx >= this.tracks.length) idx = 0;

      const track = this.tracks[idx];
      this.appendPrefetchLink({
        id: 'img_' + track.num,
        href: track.cover,
        as: 'image/jpeg'
      });
      this.appendPrefetchLink({
        id: 'audio_' + track.num,
        href: track.source,
        as: 'audio/mpeg'
      });
    },

// INPUTS: BUTTON, RANGE, PROGRESS

    // DEPRESS BUTTON ONCLICK (and focus)
    // addPressedBtnClass(btn) {
    //   btn.classList.add(this.clickedClass);
    //   btn.addEventListener('animationend', e => {
    //     btn.classList.remove(this.clickedClass);
    //   }, {once: true});
    //   btn.focus();
    // },

    // PLAY BUTTON

    playTrack() {
      // NOTE: Play/Pause animation is currently an independent function
      // this.addPressedBtnClass(this.$refs.btnPlay);
      this.isTimerPlaying = this.audio.paused;
      this.audio.paused ? this.audio.play() : this.audio.pause();
    },

    // MUTE BUTTON AND VOLUME RANGE

    muteTrack() {
      if (!this.supportsVolume) return;
      // this.addPressedBtnClass(this.$refs.btnMute);
      this.isMuted = !this.isMuted;
      this.audio.volume = this.isMuted ? 0 : this.volume;
    },

    volRange() {
      if (!this.supportsVolume) return;
      const volRange = this.$refs.volRange;
      if (!volRange) return;
      const vol = volRange.value;
      volRange.setAttribute('value', vol);
      this.volume = vol / 100;
      if (this.isMuted) return;
      this.audio.volume = this.volume;
    },

    // TRACK PROGRESS

    generateTime() {
      let width = (100 / this.audio.duration) * this.audio.currentTime;
      this.barWidth = ~~width;
      let durmin = Math.floor(this.audio.duration / 60);
      let dursec = Math.floor(this.audio.duration - durmin * 60);
      let curmin = Math.floor(this.audio.currentTime / 60);
      let cursec = Math.floor(this.audio.currentTime - curmin * 60);

      curmin = curmin.toString().padStart(2, '0');
      cursec = cursec.toString().padStart(2, '0');
      this.currentTime = curmin + ':' + cursec;

      // Prevent nan:nan from display
      if (durmin !== ~~durmin) return;

      durmin = durmin.toString().padStart(2, '0');
      dursec = dursec.toString().padStart(2, '0');
      this.duration = durmin + ':' + dursec;
    },

    updateBar(e) {
      const rect = e.target.getBoundingClientRect();
      const position = e.clientX - rect.left;

      let percentage = (100 * position) / this.$refs.progress.offsetWidth;
      if (percentage > 100) percentage = 100;
      if (percentage < 0) percentage = 0;

      this.barWidth = ~~percentage;
      this.audio.currentTime = (this.audio.duration * percentage) / 100;
    },

    progressTrack(e) {
      this.audio.pause();
      this.updateBar(e);
      if (this.isTimerPlaying) {
        this.audio.play();
      } else {
        this.$refs.btnPlay.click();
      }
    },

    // PREVIOUS / NEXT TRACK BUTTONS

    setTrack(btn) {
      // this.addPressedBtnClass(btn);
      this.isShowCover = false;
      this.currentTrack = this.tracks[this.currentTrackIndex];
      this.resetPlayer();
      this.$refs.btnMute.focus();
      btn.focus();
    },
    
    // References DOM - Is there not a Vue flag for this?
    isTransitioning() {
      return (document.querySelector('[class*="fadeCard-"][class*="-active"]'));
    },

    prevTrack() {
      if (this.isTransitioning()) return;
      this.transitionName = 'fadeCard-prev';
      if (--this.currentTrackIndex < 0) {
        this.currentTrackIndex = this.tracks.length - 1;
      }
      this.setTrack(this.$refs.btnPrev);
    },

    nextTrack() {
      if (this.isTransitioning()) return;
      this.transitionName = 'fadeCard-next';
      if (++this.currentTrackIndex >= this.tracks.length) {
        this.currentTrackIndex = 0;
      }
      this.setTrack(this.$refs.btnNext);
    },

// BUFFERING AUDIO - CIRCLE INDICATOR
    // Increase play btn circle spin rate while waiting for audio to buffer.

    circleSpinRate(dur) {
      requestAnimationFrame(_ => 
        this.$refs.btnPlay.setAttribute('style', '--rotationSpeed:' + dur)
      );
    },

    bufferingAudio() {
      this.audio.addEventListener('waiting', _ =>
       this.circleSpinRate(this.bufferingSpin)
      );
      this.audio.addEventListener('playing', _ => 
        this.circleSpinRate(this.playingSpin)
      );
    },

// DRAG OR SWIPE TO CHANGE TRACK

    isButtonInputProgress(e) {
      return ['BUTTON', 'INPUT', 'PROGRESS'].includes(e.target.tagName);
    },

    getCard() {
      this.direction === 1 && this.prevTrack();
      this.direction === -1 && this.nextTrack();
    },

    setDirectionOfDrag() {
      const {positionX, minDragTravel} = this;
      const {start, end} = positionX;

      const isEndMore = end > (start + minDragTravel);
      const isEndLess = end < (start - minDragTravel);

      let {direction} = this;
      if (isEndMore) direction = 1;
      // -1, 0, 1 === previous, current, next
      this.direction = isEndLess ? -1 : direction;
    },

    dragEnd(e) {
      if (this.isButtonInputProgress(e)) return;
      this.positionX.end = e.clientX;
      this.setDirectionOfDrag();
      this.getCard(); // Let's do stuff then.
    },

    dragStart(e) {
      if (this.isButtonInputProgress(e)) return;
      e.preventDefault();
      this.direction = 0;
      this.positionX.start = e.clientX;
      this.$el.addEventListener('pointerup', this.dragEnd, {once: true});
    },

    initDragEvents() {
      if (!this.hasPointerEvents) return;
      this.$el.addEventListener('pointerdown', this.dragStart);
    },


    resetPlayer() {
      this.barWidth = 0;
      this.audio.currentTime = 0;
      this.audio.src = this.currentTrack.source;
      this.initDragEvents();

      this.currentTrack.prevNum = this.currentTrack.num - 1;
      if (this.currentTrack.prevNum <= 0) {
        this.currentTrack.prevNum = this.tracks.length;
      }

      this.currentTrack.nextNum = this.currentTrack.num + 1;
      if (this.currentTrack.nextNum > this.tracks.length) {
        this.currentTrack.nextNum = 1;
      }

      this.isTimerPlaying ? this.audio.play() : this.audio.pause();
    }

  },


  beforeCreate() {
    // Duplicate Artist and Title into HTML, outside of the transition group, purely to preserve the document flow.
    // Required to pass WCAG-2 text zoom 200%.
    const player = document.getElementById('player');
    if (!player) return;
    const group = player.querySelector('.transition_cover');
    const original = player.querySelector('.cover_copy');
    if (!(group && original)) return;
    const copy = original.cloneNode(true);
    copy.removeAttribute('role');
    copy.setAttribute('aria-hidden', true);
    copy.setAttribute('style', 'visibility:hidden!important');
    player.insertBefore(copy, group.nextSibling);
  },


  created() {
    let vm = this;
    this.currentTrack = this.tracks[0];
    this.currentTrack.prevNum = this.tracks.length;
    this.currentTrack.nextNum = 2;

    this.audio = new Audio();
    this.audio.src = this.currentTrack.source;

    vm.bufferingAudio();

    this.audio.ontimeupdate = _ => vm.generateTime();
    this.audio.onloadedmetadata = _ => vm.generateTime();
    
    this.audio.onended = _ => {

      // Store currently focused object
      const currentFocusElement = document.activeElement;

      // Move focus to next track btn (fires prefetch)
      this.nextTrack();
      this.isTimerPlaying = true;

      // Move focus back onto originally focused btn
      currentFocusElement && currentFocusElement.focus();
    };
    this.$nextTick(_ => {
      vm.initDragEvents();
    });
  }

});






// Add border circles, and duplicate icons, for state actions.
var SVG_states = (function (window, document) {

  'use strict';

  const buttons = document.querySelectorAll('[class^=btn-]');

  const iconClass = 'btn_icon';
  const defaultIconStates = [iconClass, `${iconClass}-highlight`];
  const iconStateAttr = 'data-states-icon';

  const circleClass = 'btn_circle';
  const defaultCircleStates = [circleClass, `${circleClass}-highlight`];
  const circleStateAttr = 'data-states-circle';
  const symbolId = '#circle';
  const xmlns = 'http://www.w3.org/2000/svg';

  const add_circles = (btn, svg, icon) => {
    let states = [...defaultCircleStates];
    const circleStates = btn.getAttribute(circleStateAttr);
    if (circleStates) {
      defaultCircleStates.forEach(defState => {
        states.push(`${defState} ${circleStates}`);
      });
    }
    states.forEach(state => {
      const use = document.createElementNS(xmlns, 'use');
      use.setAttribute('href', symbolId);
      use.setAttribute('class', state);
      svg.insertBefore(use, icon);
    });
  };

  const duplicate_icon = (btn, svg, icon) => {
    let states = [...defaultIconStates];
    const iconStates = btn.getAttribute(iconStateAttr);
    if (iconStates) {
      defaultIconStates.forEach(defState => {
        states.push(`${defState} ${iconStates}`);
      });
    }
    delete states[0];
    states.forEach(state => {
      const clone = icon.cloneNode(true);
      clone.setAttribute('class', state);
      svg.appendChild(clone);
    });
  };

  Object.values(buttons).forEach(
    btn => {
      const svg = btn.querySelector('svg');
      const icon = svg?.querySelector('.' + iconClass);
      if (!icon) return;

      add_circles(btn, svg, icon);
      duplicate_icon(btn, svg, icon);
    }
  );

}(window, document));




// Add state actions to buttons and parent wrapper
var Button_Clicked = (function (window, document) {

  'use strict';

  const buttons = document.querySelectorAll('[class^=btn-]');
  const clickedClass = '-js-clicked';
  const toggleAttr = 'data-toggle';

  const remove_clicked = e => {
    e.stopPropagation();
    window.requestAnimationFrame(_ => {
      e.target.classList.remove(clickedClass);
    });
  };

  const btn_clicked = e => {
    e.stopPropagation();
    const btn = e.target;
    const btnWrap = btn.parentElement;
    if (btnWrap.classList.contains(clickedClass)) {
      remove_clicked(e);
    }
    const toggleClass = btn.getAttribute(toggleAttr);
    window.requestAnimationFrame(_ => {
      btnWrap.classList.add(clickedClass);
      btnWrap.addEventListener('animationend', remove_clicked, {once: true});
      btn.classList.toggle(toggleClass);
    });
    btn.focus();
  };

  Object.values(buttons).forEach(
    btn => {
      btn.addEventListener('click', btn_clicked);
    });

}(window, document));

              
            
!
999px

Console