<link href="//maxcdn.bootstrapcdn.com/bootstrap/4.1.1/css/bootstrap.min.css" rel="stylesheet" id="bootstrap-css">
<script src="//maxcdn.bootstrapcdn.com/bootstrap/4.1.1/js/bootstrap.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<!------ Include the above in your HEAD tag ---------->
<!-- app root container -->
<div class="app-wrap" id="app" v-cloak>
<!-- app player container -->
<main class="player-wrap fx fx-fade-in" ref="playerWrap" style="opacity: 0">
<!-- bg absolute elements -->
<figure class="player-bg" ref="playerBg"></figure>
<canvas class="player-canvas" ref="playerCanvas"></canvas>
<!-- main player layout -->
<section class="player-layout">
<!-- player top header -->
<header class="player-header flex-row flex-middle flex-stretch">
<h2 class="text-clip flex-1"><i class="fa fa-headphones"></i> <span>TEBİMTEBİTAGEM GAZETESİ RADYO TELEVİZYONU </span></h2>
<button class="text-nowrap common-btn" @click="toggleSidebar( true )"><i class="fa fa-bars"></i></button>
</header>
<!-- player middle content area -->
<main class="player-content flex-row">
<!-- default greet message -->
<section class="player-greet" v-if="!hasChannel && !hasErrors">
<div class="fx fx-slide-left push-bottom"><h1>Pick a Station</h1></div>
<div class="fx fx-slide-left fx-delay-1 push-bottom">This is a music streaming player for the channels provided by harunpehlivan.fm.tc Just pick a station from the sidebar to the right to start listening.</div>
<div class="fx fx-slide-up fx-delay-2 pad-top"><button class="cta-btn" @click="toggleSidebar( true )"><i class="fa fa-headphones"> </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>
<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>
<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>
</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"> | {{ channel.title }}</span>
</div>
</section>
<!-- player links -->
<section class="player-links text-nowrap">
<a class="common-btn text-faded" href="https://github.com/harunpehlivan" title="View on Github" target="_blank">
<i class="fa fa-github"></i>
</a>
<a class="common-btn text-faded" href="https://codepen.io/harunpehlivan" title="Codepen Projects" target="_blank">
<i class="fa fa-codepen"></i>
</a>
</section>
</footer>
</section> <!-- layout wrapper -->
<!-- player stations overlay + sidebar -->
<section class="player-stations" :class="{ 'visible': sidebar }" @click="toggleSidebar( false )">
<aside class="player-stations-sidebar" @click.stop>
<!-- sidebar search -->
<header class="player-stations-header flex-row flex-middle flex-stretch">
<div class="form-input push-right">
<i class="fa fa-search"></i>
<input type="text" placeholder="Search station..." v-model="searchText" />
</div>
<button class="common-btn" @click="toggleSidebar( false )"><i class="fa fa-times-circle"></i></button>
</header>
<!-- sidebar stations list -->
<ul class="player-stations-list">
<li class="player-stations-list-item flex-row flex-top flex-stretch" v-for="c of channelsList" :key="c.id" @click="selectChannel( c )" :class="{ 'active': c.active }">
<figure class="push-right if-small">
<img class="img-round" width="70" height="70" :src="c.largeimage" :alt="c.title" />
</figure>
<aside class="flex-1">
<div class="flex-row flex-middle flex-space">
<h6 class="text-bright text-clip">{{ c.title }}</h6>
<div class="text-secondary"><i class="fa fa-headphones"></i> {{ c.listeners | toCommas( 0 ) }}</div>
</div>
<div class="text-small">
<span class="text-faded text-uppercase text-small">{{ c.genre | toSpaces }}</span> <br />
{{ c.description }}
</div>
</aside>
</li>
</ul>
<!-- sidebar sort options -->
<footer class="player-stations-footer flex-row flex-middle flex-stretch">
<div class="flex-1 push-right">
<span @click="toggleSortOrder()" class="fa clickable" :class="{ 'fa-sort-amount-desc': sortOrder === 'desc', 'fa-sort-amount-asc': sortOrder === 'asc' }"> </span>
<span class="text-faded">Sort: </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> </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% );
}
}
}
/**
* TEBİMTEBİTAGEM GAZETESİ RADYO TELEVİZYONU Web Player
* Author: Rainner Lins (2018)
* Site: http://harunpehlivan.fm.tc/
/**
* Sphere object
*/
const Sphere = {
group : null,
shapes : [],
move : new THREE.Vector3( 0, 0, 0 ),
ease : 8,
create( box, scene ) {
this.group = new THREE.Object3D();
let shape1 = new THREE.CircleGeometry( 1, 10 );
let shape2 = new THREE.CircleGeometry( 2, 20 );
let points = new THREE.SphereGeometry( 100, 30, 14 ).vertices;
let material = new THREE.MeshLambertMaterial( { color: 0xffffff, opacity: 0, side: THREE.DoubleSide } );
let center = new THREE.Vector3( 0, 0, 0 );
let radius = 12;
for ( let i = 0; i < points.length; i++ ) {
let { x, y, z } = points[ i ];
let home = { x, y, z };
let cycle = THREE.Math.randInt( 0, 100 );
let pace = THREE.Math.randInt( 10, 30 );
let shape = new THREE.Mesh( ( i % 2 ) ? shape1 : shape2, material );
shape.position.set( x, y, z );
shape.lookAt( center );
shape.userData = { radius, cycle, pace, home };
this.group.add( shape );
}
this.group.position.set( 500, 0, 0 );
this.group.rotation.x = ( Math.PI / 2 ) + .6;
scene.add( this.group );
},
update( box, mouse, freq ) {
let bass = ( Math.floor( freq[ 1 ] | 0 ) / 255 );
this.move.x = ( box.width * .06 ) + -( mouse.x * 0.02 );
this.group.position.x += ( this.move.x - this.group.position.x ) / this.ease;
this.group.position.y += ( this.move.y - this.group.position.y ) / this.ease;
this.group.position.z = 10 + ( bass * 80 );
this.group.rotation.y -= 0.003;
for ( let i = 0; i < this.group.children.length; i++ ) {
let shape = this.group.children[ i ];
let { radius, cycle, pace, home } = shape.userData;
shape.position.set( home.x, home.y, home.z );
shape.translateZ( bass * Math.sin( cycle / pace ) * radius );
shape.userData.cycle++;
}
},
};
/**
* Vue filters
*/
Vue.filter( 'toCommas', ( num, decimals ) => {
let o = { style: 'decimal', minimumFractionDigits: decimals, maximumFractionDigits: decimals };
return new Intl.NumberFormat( 'en-US', o ).format( num );
});
Vue.filter( 'toSpaces', ( str ) => {
return String( str || '' ).trim().replace( /[^\w\`\'\-]+/g, ' ' ).trim();
});
Vue.filter( 'toText', ( str, def ) => {
str = String( str || '' ).replace( /[^\w\`\'\-\.\!\?]+/g, ' ' ).trim();
return str || String( def || '' );
});
/**
* Vue app
*/
new Vue({
el: '#app',
data: {
// toggles
init: false,
playing: false,
loading: false,
sidebar: false,
// channels stuff
channels: [], // all channels
channel: {}, // selected channel
songs: [], // recent tracks
track: {}, // current track
errors: {}, // error messages
// animation stuff
fxBox: null,
fxRenderer: null,
fxScene: null,
fxColor: null,
fxLight: null,
fxCamera: null,
fxMouse: { x: 0, y: 0 },
fxObjects: [],
// audio stuff
audio: new Audio(),
context: new AudioContext(),
freqData: new Uint8Array(),
audioSrc: null,
audioGain: null,
analyser: null,
volume: 0.5,
// timer stuff
timeStart: 0,
timeDisplay: '00:00:00',
timeItv: null,
// sorting stuff
searchText: '',
sortParam: 'listeners',
sortOrder: 'desc',
// timer stuff
anf: null,
sto: null,
itv: null,
},
// watch methods
watch: {
// when app is ready
init() {
setTimeout( this.setupCanvas, 100 );
setTimeout( this.initSidebar, 500 );
},
// watch playing status
playing() {
if ( this.playing ) { this.startClock(); }
else { this.stopClock(); }
},
// update player volume
volume() {
this.setVolume( this.volume );
}
},
// computed methods
computed: {
// filter channels list
channelsList() {
let list = this.channels.slice();
let search = this.searchText.replace( /[^\w\s\-]+/g, '' ).replace( /[\r\s\t\n]+/g, ' ' ).trim();
if ( search && search.length > 1 ) {
let reg = new RegExp( '^('+ search +')', 'i' );
list = list.filter( i => reg.test( i.title +' '+ i.description ) );
}
if ( this.sortParam ) {
list = this.sortList( list, this.sortParam, this.sortOrder );
}
if ( this.channel.id ) {
list = list.map( i => {
i.active = ( this.channel.id === i.id ) ? true : false;
return i;
});
}
return list;
},
// filter songs list
songsList() {
let list = this.songs.slice();
return list;
},
// sort-by label for buttons, etc
sortLabel() {
switch ( this.sortParam ) {
case 'title' : return 'Station Name';
case 'listeners' : return 'Listeners Count';
case 'genre' : return 'Music Genre';
}
},
// check if audio can be played
canPlay() {
return ( this.channel.id && !this.loading ) ? true : false;
},
// check if a channel is selected
hasChannel() {
return this.channel.id ? true : false;
},
// check if there are tracks loaded
hasSongs() {
return this.songs.length ? true : false;
},
// check if there are errors to show
hasErrors() {
return ( this.checkError( 'init' ) || this.checkError( 'stream' ) ) ? true : false;
},
},
// custom methods
methods: {
// set an erro message
setError( key, err ) {
let errors = Object.assign( {}, this.errors );
errors[ key ] = String( err || '' ).trim();
if ( err ) console.warn( 'ERROR('+ key +'):', err );
this.errors = errors;
this.init = true;
},
// check if an error has been set for a key
checkError( key ) {
return ( key && this.errors.hasOwnProperty( key ) && this.errors[ key ] );
},
// clear all error messages
clearErrors() {
Object.keys( this.errors ).forEach( key => {
this.errors[ key ] = '';
});
},
// reset selected channel
resetPlayer() {
this.channel = {};
this.songs = [];
this.clearErrors();
this.getChannels( true );
},
// try resuming stream problem if possible
tryAgain() {
if ( this.checkError( 'init' ) ) return this.resetPlayer();
if ( this.channel.id ) return this.playChannel( this.channel );
},
// show/hide the sidebar
toggleSidebar( toggle ) {
this.sidebar = ( typeof toggle === 'boolean' ) ? toggle : false;
},
// show sidebar at startup if there are no errors
initSidebar() {
if ( this.checkError( 'init' ) ) return;
this.toggleSidebar( true );
},
// toggle stream playback for current selected channel
togglePlay() {
if ( this.loading ) return;
if ( this.playing ) return this.closeAudio();
if ( this.channel.id ) return this.playChannel( this.channel );
},
// toggle sort order
toggleSortOrder() {
this.sortOrder = ( this.sortOrder === 'asc' ) ? 'desc' : 'asc';
},
// apply sorting and toggle order
sortBy( param, order ) {
if ( this.sortParam === param ) { this.toggleSortOrder(); }
else { this.sortOrder = order || 'asc'; }
this.sortParam = param;
},
// sort an array by key and order
sortList( list, param, order ) {
return list.sort( ( a, b ) => {
if ( a.hasOwnProperty( param ) && b.hasOwnProperty( param ) ) {
let _a = a[ param ];
let _b = b[ param ];
_a = ( typeof _a === 'string' ) ? _a.toUpperCase() : _a;
_b = ( typeof _b === 'string' ) ? _b.toUpperCase() : _b;
if ( order === 'asc' ) {
if ( _a < _b ) return -1;
if ( _a > _b ) return 1;
}
if ( order === 'desc' ) {
if ( _a > _b ) return -1;
if ( _a < _b ) return 1;
}
}
return 0;
});
},
// get channels data from api
getChannels( sidebar ) {
let endpoint = 'http://harunpehlivan.fm.tc/channels.json';
let emsg = [ 'There was a problem trying to load the list of available channels from TEBİMTEBİTAGEM GAZETESİ RADYO TELEVİZYONU.' ];
axios.get( endpoint ).then( res => {
if ( !res || !res.data || !res.data.channels ) {
emsg.push( 'The API response did not have any channels data available at this time.' );
emsg.push( 'Status: Channels API Error.' );
return this.setError( 'channels', emsg.join( ' ' ) );
}
for ( let c of res.data.channels ) {
if ( !Array.isArray( c.playlists ) ) continue;
// filter and sanitize list of channels
c.twitter = c.twitter ? 'https://twitter.com/@'+ c.twitter : ''; // full twitter url
c.plsfile = c.playlists.filter( p => ( p.format === 'mp3' && /^(highest|high)$/.test( p.quality ) ) ).shift().url || '';
c.mp3file = 'http://ice1.harunpehlivan.fm.tc/'+ c.id +'-128-mp3'; // assumed stream url
c.songsurl = 'http://harunpehlivan.fm.tc/songs/'+ c.id +'.json'; // songs data url
c.infourl = 'http://harunpehlivan.fm.tc'+ c.id +'/'; // channel page url
c.listeners = c.listeners | 0; // force numeric
c.updated = c.updated | 0; // force numeric
c.active = false; // select state
// update selected channel
if ( this.isCurrentChannel( c ) ) {
c.active = true;
this.channel = Object.assign( this.channel, c );
}
}
this.channels = res.data.channels.slice();
if ( sidebar ) this.toggleSidebar( true );
this.setError( 'init', '' );
this.setError( 'channels', '' );
})
.catch( e => {
emsg.push( 'Try again, or check your internet connection.' );
emsg.push( 'Status: '+ String( e.message || 'Channels API Error' ) +'.' );
let errstr = emsg.join( ' ' );
if ( !this.channels.length ) this.setError( 'init', errstr );
this.setError( 'channels', errstr );
});
},
// fetch songs for a channel
fetchSongs( channel, cb ) {
if ( !channel || !channel.id || !channel.songsurl ) return;
if ( !this.isCurrentChannel( channel ) ) { this.songs = []; this.track = {}; }
let emsg = [ 'There was a problem trying to load the list of songs for channel '+ channel.title +' from SomaFM.' ];
axios.get( channel.songsurl ).then( res => {
if ( !res || !res.data || !res.data.songs ) {
emsg.push( 'The API response did not have any songs data available at this time.' );
emsg.push( 'Status: Songs API Error.' );
return this.setError( 'songs', emsg.join( ' ' ) );
}
let songs = res.data.songs.slice();
this.track = songs.shift();
this.songs = songs.slice( 0, 3 );
this.setError( 'songs', '' );
if ( typeof cb === 'function' ) cb( songs );
})
.catch( e => {
emsg.push( 'Try again, or check your internet connection.' );
emsg.push( 'Status: '+ String( e.message || 'Songs API Error' ) +'.' );
this.setError( 'songs', emsg.join( ' ' ) );
});
},
// run maintenance tasks on a timer
setupMaintenance() {
this.itv = setInterval( () => {
this.getChannels(); // update channels
this.fetchSongs( this.channel ); // update channel tracks
// ...
}, 1000 * 30 );
},
// setup animation canvas
setupCanvas() {
if ( !this.$refs.playerWrap ) return;
if ( !this.$refs.playerCanvas ) return;
// default canvas and player dimensions
const player = this.$refs.playerWrap;
const canvas = this.$refs.playerCanvas;
// setup THREE renderer and replace default canvas
this.fxBox = player.getBoundingClientRect();
this.fxScene = new THREE.Scene();
this.fxRenderer = new THREE.WebGLRenderer( { alpha: true, antialias: true, precision: 'highp' } );
this.fxRenderer.setClearColor( 0x000000, 0 );
this.fxRenderer.setPixelRatio( window.devicePixelRatio );
this.fxRenderer.domElement.className = canvas.className;
// setup camera
this.fxCamera = new THREE.PerspectiveCamera( 60, ( this.fxBox.width / this.fxBox.height ), 0.1, 20000 );
this.fxCamera.lookAt( this.fxScene.position );
this.fxCamera.position.set( 0, 0, 300 );
this.fxCamera.rotation.set( 0, 0, 0 );
// light color
this.fxColor = new THREE.Color();
this.fxColor.setHSL( this.fxHue, 1, .5 );
// setup light source
this.fxLight = new THREE.PointLight( 0xffffff, 4, 400 );
this.fxLight.position.set( 0, 0, 420 );
this.fxLight.castShadow = false;
this.fxLight.target = this.fxScene;
this.fxLight.color = this.fxColor;
this.fxScene.add( this.fxLight );
// setup canvas and events
canvas.parentNode.replaceChild( this.fxRenderer.domElement, canvas );
window.addEventListener( 'mousemove', this.updateMousePosition );
window.addEventListener( 'resize', this.updateStageSize );
// add objects
this.fxObjects.push( Sphere );
// setup objects and start animation
for ( let o of this.fxObjects ) o.create( this.fxBox, this.fxScene );
this.updateStageSize();
this.updateAnimations();
},
// update mouse position from center of canvas
updateMousePosition( e ) {
if ( !this.fxBox || !e ) return;
this.fxMouse.x = Math.max( 0, e.pageX || e.clientX || 0 ) - ( this.fxBox.left + ( this.fxBox.width / 2 ) );
this.fxMouse.y = Math.max( 0, e.pageY || e.clientY || 0 ) - ( this.fxBox.top + ( this.fxBox.height / 2 ) );
},
// update canvas size
updateStageSize() {
if ( !this.$refs.playerWrap || !this.fxRenderer ) return;
this.fxBox = this.$refs.playerWrap.getBoundingClientRect();
this.fxCamera.aspect = ( this.fxBox.width / this.fxBox.height );
this.fxCamera.updateProjectionMatrix();
this.fxRenderer.setSize( this.fxBox.width, this.fxBox.height );
},
// update light color based on audio freq
updateStageLight() {
let dist = Math.floor( this.freqData[ 1 ] | 0 ) / 255;
let color = Math.floor( this.freqData[ 16 ] | 0 ) / 255;
this.fxLight.distance = 360 + ( 140 * dist );
this.fxColor.setHSL( color, .5, .5 );
},
// update custom objects in 3d scene
updateSceneObjects() {
for ( let o of this.fxObjects ) {
o.update( this.fxBox, this.fxMouse, this.freqData );
}
},
// audio visualizer animation loop
updateAnimations() {
this.anf = requestAnimationFrame( this.updateAnimations );
if ( !this.fxRenderer || !this.fxCamera || !this.analyser || !this.freqData ) return;
this.analyser.getByteFrequencyData( this.freqData );
this.updateSceneObjects();
this.updateStageLight();
this.fxRenderer.render( this.fxScene, this.fxCamera );
},
// setup audio routing and stream events
setupAudio() {
// setup audio sources
this.audioSrc = this.context.createMediaElementSource( this.audio );
this.audioGain = this.context.createGain();
this.analyser = this.context.createAnalyser();
// connect sources
this.audioSrc.connect( this.audioGain );
this.audioSrc.connect( this.analyser );
this.audioGain.connect( this.context.destination );
this.setVolume( this.volume );
// check when stream can start playing
this.audio.addEventListener( 'canplay', e => {
this.audio.play();
this.freqData = new Uint8Array( this.analyser.frequencyBinCount );
});
// check if stream is buffering
this.audio.addEventListener( 'waiting', e => {
this.playing = false;
this.loading = true;
});
// check if stream is done buffering
this.audio.addEventListener( 'playing', e => {
this.setError( 'stream', '' );
this.playing = true;
this.loading = false;
});
// check if stream has ended
this.audio.addEventListener( 'ended', e => {
this.playing = false;
this.loading = false;
});
// check for steam error
this.audio.addEventListener( 'error', e => {
let emsg = [];
emsg.push( 'The selected audio stream could not load, or has stopped loading.' );
emsg.push( 'Try again, or check your internet connection.' );
emsg.push( 'Status: '+ String( e.message || 'Stream URL Error' ) +'.' );
this.setError( 'stream', emsg.join( ' ' ) );
this.playing = false;
this.loading = false;
});
},
// set audio volume
setVolume( volume ) {
if ( !this.audioGain ) return;
volume = parseFloat( volume ) || 0;
volume = ( volume < 0 ) ? 0 : volume;
volume = ( volume > 1 ) ? 1 : volume;
this.audioGain.gain.value = volume;
},
// checks is a channel is currently selected
isCurrentChannel( channel ) {
if ( !channel || !channel.id || !this.channel.id ) return false;
if ( this.channel.id !== channel.id ) return false;
return true;
},
// play audio stream for a channel
playChannel( channel ) {
if ( this.playing ) return;
this.clearErrors();
this.audio.src = channel.mp3file +'/?x='+ Date.now();
this.audio.crossOrigin = 'anonymous';
this.audio.load();
},
// select a channel to play
selectChannel( channel ) {
if ( !channel || !channel.id ) return;
if ( this.isCurrentChannel( channel ) ) return;
this.closeAudio();
this.toggleSidebar( false );
this.playChannel( channel );
this.fetchSongs( channel );
this.channel = channel;
},
// close active audio
closeAudio() {
this.setError( 'stream', '' );
try { this.audio.pause(); } catch ( e ) {}
try { this.audio.stop(); } catch ( e ) {}
try { this.audio.close(); } catch ( e ) {}
this.playing = false;
},
// start tracking playback time
startClock() {
this.stopClock();
this.timeStart = Date.now();
this.timeItv = setInterval( this.updateClock, 1000 );
this.updateClock();
},
// update tracking playback time
updateClock() {
let p = n => ( n < 10 ) ? '0'+n : ''+n;
let elapsed = ( Date.now() - this.timeStart ) / 1000;
let seconds = Math.floor( elapsed % 60 );
let minutes = Math.floor( elapsed / 60 % 60 );
let hours = Math.floor( elapsed / 3600 );
this.timeDisplay = p( hours ) +':'+ p( minutes ) +':'+ p( seconds );
},
// stop tracking playback time
stopClock() {
if ( this.timeItv ) clearInterval( this.timeItv );
this.timeItv = null;
},
// clear timer refs
clearTimers() {
if ( this.sto ) clearTimeout( this.sto );
if ( this.itv ) clearInterval( this.itv );
if ( this.anf ) cancelAnimationFrame( this.anf );
},
},
// on app mounted
mounted() {
this.getChannels();
this.setupAudio();
this.setupMaintenance();
},
// on app destroyed
destroyed() {
this.closeAudio();
this.clearTimers();
}
});