Merge branch 'develop'

This commit is contained in:
James Barnsley
2020-04-30 15:46:10 +12:00
41 changed files with 10310 additions and 5744 deletions

View File

@ -42,7 +42,14 @@ RUN git clone https://github.com/jaedb/Iris.git /iris \
&& echo "mopidy ALL=NOPASSWD: /iris/mopidy_iris/system.sh" >> /etc/sudoers
# Install additional Python dependencies
RUN python3.7 -m pip install --no-cache tox mopidy-mpd mopidy-spotify mopidy-local
RUN python3.7 -m pip install --no-cache \
tox \
mopidy-mpd \
mopidy-spotify \
mopidy-local \
Mopidy-GMusic \
# pip not up-to-date for Mopidy-Tidal (https://github.com/tehkillerbee/mopidy-tidal/issues/14)
git+https://github.com/tehkillerbee/mopidy-tidal.git@master
# Start helper script.
COPY docker/entrypoint.sh /entrypoint.sh

View File

@ -1 +1 @@
3.47.0
3.48.0

View File

@ -3,7 +3,7 @@ import pathlib
from mopidy import config, ext
__version__ = "3.47.0"
__version__ = "3.48.0"
logger = logging.getLogger(__name__)

View File

@ -2733,7 +2733,7 @@ main {
position: absolute;
top: 0;
left: 0;
bottom: 60px;
bottom: 50px;
right: 0;
padding-left: 220px;
perspective: 1000px;
@ -3532,8 +3532,8 @@ select {
background: transparent; }
@media (max-width: 799px) {
.button {
padding-left: 30px;
padding-right: 30px; }
padding-left: 28px;
padding-right: 28px; }
.button--large {
width: 100%;
display: block;
@ -4588,7 +4588,7 @@ select {
background: rgba(0, 0, 0, 0.2);
border-radius: 2px;
height: 4px;
top: 42%;
top: 50%;
right: 0;
left: 0;
margin-top: -3px; }
@ -4664,125 +4664,157 @@ select {
bottom: 0;
right: 0;
left: 0;
height: 60px;
transform-style: preserve-3d;
border-left: 230px solid transparent; }
.dark-theme .playback-controls {
background: #383734;
color: #FFFFFF;
box-shadow: 0 -1px 10px rgba(0, 0, 0, 0.6);
border-color: #383734; }
.light-theme .playback-controls {
background: #FFFFFF;
color: #121212;
border-color: #FFFFFF;
box-shadow: 0 -1px 8px rgba(0, 0, 0, 0.1); }
.playback-controls .current-track {
display: flex;
flex-flow: row;
align-items: center;
align-content: center; }
.playback-controls__background {
height: 100%;
width: 100%;
position: absolute;
bottom: 0;
z-index: -1; }
.dark-theme .playback-controls {
color: #FFFFFF; }
.dark-theme .playback-controls__background {
background: #383734;
box-shadow: 0 -1px 10px rgba(0, 0, 0, 0.6); }
.light-theme .playback-controls {
color: #121212; }
.light-theme .playback-controls__background {
background: #FFFFFF;
box-shadow: 0 -1px 8px rgba(0, 0, 0, 0.1); }
.playback-controls .current-track__wrapper {
width: 210px;
left: -230px;
padding-right: 20px;
z-index: 1;
text-decoration: none; }
@keyframes slide_left {
0% {
transform: translateX(0px);
transform: translateX(0);
opacity: 1; }
100% {
transform: translateX(-15px);
transform: translateX(-15vw);
opacity: 0; } }
@keyframes slide_right {
0% {
transform: translateX(0px);
transform: translateX(0);
opacity: 1; }
100% {
transform: translateX(15px);
transform: translateX(15vw);
opacity: 0; } }
.playback-controls .current-track__transition {
z-index: 2;
background: #383734; }
.light-theme .playback-controls .current-track__transition {
background: white; }
.playback-controls .current-track__transition .text {
-webkit-transition: all 0.15s ease-in-out;
-moz-transition: all 0.15s ease-in-out;
-o-transition: all 0.15s ease-in-out;
transition: all 0.15s ease-in-out;
opacity: 0; }
@media (max-width: 799px) {
.playback-controls .current-track__transition {
animation-duration: 0.25s;
animation-timing-function: ease-out; }
.playback-controls .current-track__transition--previous {
animation-name: slide_right; }
.playback-controls .current-track__transition--next {
animation-name: slide_left; } }
.playback-controls .current-track .thumbnail {
float: left;
cursor: pointer;
width: 44px;
margin: 8px;
margin-right: 12px; }
.playback-controls .current-track .thumbnail__image {
background-color: rgba(128, 128, 128, 0.5); }
.playback-controls .current-track .thumbnail:before {
-webkit-transition: all 0.2s ease-in-out;
-moz-transition: all 0.2s ease-in-out;
-o-transition: all 0.2s ease-in-out;
transition: all 0.2s ease-in-out;
display: block;
content: "\F065";
font-family: "FontAwesome";
position: absolute;
top: 50%;
left: 0;
bottom: 0;
right: 0;
margin-top: -9px;
text-align: center;
font-size: 18px;
color: #FFFFFF;
z-index: 3;
opacity: 0; }
.playback-controls .current-track .thumbnail:after {
-webkit-transition: all 0.2s ease-in-out;
-moz-transition: all 0.2s ease-in-out;
-o-transition: all 0.2s ease-in-out;
transition: all 0.2s ease-in-out;
content: "";
display: block;
background: rgba(0, 0, 0, 0.75);
@keyframes slide_from_left {
0% {
transform: translateX(15vw);
opacity: 0; }
100% {
transform: translateX(0);
opacity: 1; } }
@keyframes slide_from_right {
0% {
transform: translateX(-15vw);
opacity: 0; }
100% {
transform: translateX(0);
opacity: 1; } }
@media (max-width: 799px) {
.playback-controls .current-track__wrapper .current-track__incoming, .playback-controls .current-track__wrapper .current-track__outgoing {
animation-duration: .25s;
animation-timing-function: ease-in-out;
transform: translateX(0); }
.playback-controls .current-track__wrapper[direction="previous"] .current-track__incoming {
animation-name: slide_from_right; }
.playback-controls .current-track__wrapper[direction="previous"] .current-track__outgoing {
animation-name: slide_right; }
.playback-controls .current-track__wrapper[direction="next"] .current-track__incoming {
animation-name: slide_from_left; }
.playback-controls .current-track__wrapper[direction="next"] .current-track__outgoing {
animation-name: slide_left; } }
.playback-controls .current-track__wrapper .current-track {
width: 100%;
display: flex;
position: relative;
align-items: center; }
.playback-controls .current-track__wrapper .current-track__outgoing {
z-index: 2;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
border-radius: 3px;
opacity: 0;
z-index: 2; }
.playback-controls .current-track .thumbnail:hover:after, .playback-controls .current-track .thumbnail:hover:before {
opacity: 1; }
.playback-controls .current-track .text {
opacity: 1;
line-height: 1.1rem;
font-size: 0.9rem;
padding-top: 1.05rem; }
.playback-controls .current-track .text .title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis; }
.playback-controls .current-track .text .links-sentence {
opacity: 0.5;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis; }
.playback-controls .current-track .text .links-sentence__item {
color: inherit;
text-decoration: none;
border-bottom: 1px dotted transparent; }
bottom: 0;
left: 0; }
.playback-controls .current-track__wrapper .current-track__outgoing .text {
-webkit-transition: all 0.15s ease-in-out;
-moz-transition: all 0.15s ease-in-out;
-o-transition: all 0.15s ease-in-out;
transition: all 0.15s ease-in-out;
opacity: 0; }
.playback-controls .current-track__wrapper .current-track .thumbnail {
cursor: pointer;
width: 44px;
margin: 8px;
margin-right: 12px; }
.playback-controls .current-track__wrapper .current-track .thumbnail__image {
background-color: rgba(128, 128, 128, 0.5); }
.playback-controls .current-track__wrapper .current-track .thumbnail:before {
-webkit-transition: all 0.2s ease-in-out;
-moz-transition: all 0.2s ease-in-out;
-o-transition: all 0.2s ease-in-out;
transition: all 0.2s ease-in-out;
display: block;
content: "\F065";
font-family: "FontAwesome";
position: absolute;
top: 50%;
left: 0;
bottom: 0;
right: 0;
margin-top: -9px;
text-align: center;
font-size: 18px;
color: #FFFFFF;
z-index: 3;
opacity: 0; }
.playback-controls .current-track__wrapper .current-track .thumbnail:after {
-webkit-transition: all 0.2s ease-in-out;
-moz-transition: all 0.2s ease-in-out;
-o-transition: all 0.2s ease-in-out;
transition: all 0.2s ease-in-out;
content: "";
display: block;
background: rgba(0, 0, 0, 0.75);
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
border-radius: 3px;
opacity: 0;
z-index: 2; }
.playback-controls .current-track__wrapper .current-track .thumbnail:hover:after, .playback-controls .current-track__wrapper .current-track .thumbnail:hover:before {
opacity: 1; }
.playback-controls .current-track__wrapper .current-track .text {
opacity: 1;
line-height: 1.1rem;
font-size: 0.9rem;
min-width: 0; }
.playback-controls .current-track__wrapper .current-track .text .title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis; }
.playback-controls .current-track__wrapper .current-track .text .links-sentence {
opacity: 0.5;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis; }
.playback-controls .current-track__wrapper .current-track .text .links-sentence__item {
color: inherit;
text-decoration: none;
border-bottom: 1px dotted transparent; }
.playback-controls .control {
-webkit-transition: all 0.2s ease-in-out;
-moz-transition: all 0.2s ease-in-out;
@ -4840,155 +4872,138 @@ select {
color: #08d58f !important; }
.playback-controls .control:hover, .playback-controls .control:focus {
opacity: 1; }
.playback-controls section {
position: absolute;
bottom: 10px; }
.playback-controls section.playback {
bottom: 7px;
left: 0;
width: 10rem; }
.playback-controls section.playback .control {
padding-left: 4px;
padding-right: 4px; }
.playback-controls section.playback .control .icon {
font-size: 1.3rem; }
.playback-controls section.playback .control.play {
margin-bottom: -0.8rem; }
.light-theme .playback-controls section.playback .control.play {
color: #08d58f; }
.playback-controls section.playback .control.play .icon {
font-size: 3rem; }
.playback-controls section.progress {
bottom: 13px;
left: 10rem;
right: 25rem;
padding-left: 20px;
padding-right: 20px; }
.playback-controls section.progress .slider {
position: absolute;
bottom: 2px;
left: 60px;
right: 60px; }
.playback-controls section.progress .slider__track__progress {
-webkit-transition: all 0.2s linear;
-moz-transition: all 0.2s linear;
-o-transition: all 0.2s linear;
transition: all 0.2s linear; }
.playback-controls section.progress .current {
font-size: 11px;
position: absolute;
bottom: 6px;
left: 0;
width: 50px;
text-align: right; }
.playback-controls section.progress .total {
font-size: 11px;
position: absolute;
bottom: 6px;
right: 0;
width: 50px; }
.playback-controls section.settings {
right: 14rem;
width: 10rem;
text-align: center; }
.playback-controls section.volume {
right: 2rem;
width: 10rem;
font-size: 18px; }
.playback-controls section.volume .slider__wrapper {
position: absolute;
bottom: 6px;
right: 0;
left: 30px; }
.playback-controls section.triggers {
.playback-controls section.playback {
display: flex;
align-items: center; }
.playback-controls section.playback .control {
padding: 4px; }
.playback-controls section.playback .control .icon {
font-size: 1.3rem; }
.light-theme .playback-controls section.playback .control.play {
color: #08d58f; }
.playback-controls section.playback .control.play .icon {
font-size: 3rem; }
.playback-controls section.progress {
flex-grow: 1;
display: flex;
align-items: center; }
.playback-controls section.progress .slider {
flex-grow: 1; }
.playback-controls section.progress .slider__track__progress {
-webkit-transition: all 0.2s linear;
-moz-transition: all 0.2s linear;
-o-transition: all 0.2s linear;
transition: all 0.2s linear; }
.playback-controls section.progress .time {
font-size: 11px;
width: 40px; }
.playback-controls section.progress .time--current {
text-align: right;
padding-right: 10px; }
.playback-controls section.progress .time--total {
padding-left: 10px; }
.playback-controls section.settings {
text-align: center;
padding: 0 30px; }
.playback-controls section.volume {
font-size: 18px;
width: 10rem;
padding-right: 30px;
display: flex;
align-items: center; }
.playback-controls section.volume .slider__wrapper {
flex-grow: 1; }
.playback-controls section.triggers {
display: none; }
@media (max-width: 799px) {
.playback-controls--touch-enabled .control.next {
display: none; }
@media (max-width: 799px) {
.playback-controls {
border-left: none; }
.playback-controls--touch-enabled .control.next {
display: none; }
.playback-controls .current-track {
width: auto;
bottom: 0.65rem;
left: 80px;
right: 80px;
text-align: center;
font-size: 0.9rem;
line-height: 1.2em;
margin: auto; }
.playback-controls .current-track .title,
.playback-controls .current-track .artist {
.playback-controls .current-track__wrapper {
order: 2;
flex-grow: 1;
text-align: center;
font-size: 0.9rem;
line-height: 1.2em;
margin: 0;
padding: 0;
display: flex; }
.playback-controls .current-track__wrapper .text {
padding: 0;
flex-grow: 1; }
.playback-controls .current-track__wrapper .text .title,
.playback-controls .current-track__wrapper .text .artist {
padding-top: 0; }
.playback-controls .current-track .thumbnail {
display: none; }
.playback-controls section.playback {
left: 0;
width: 6rem; }
.playback-controls section.playback .control.play {
margin-bottom: -1rem; }
.playback-controls section.playback .control.previous {
display: none; }
.playback-controls section.playback .control.stop {
display: none; }
.playback-controls section.settings {
.playback-controls .current-track__wrapper .thumbnail-wrapper {
display: none; }
.playback-controls section.volume {
.playback-controls section.playback {
order: 1;
width: 6rem; }
.playback-controls section.playback .control.play {
padding: 4px; }
.playback-controls section.playback .control.previous, .playback-controls section.playback .control.stop {
display: none; }
.playback-controls section.progress {
display: none; }
.playback-controls section.triggers {
display: block;
right: 10px; }
.playback-controls section.triggers .icon {
font-size: 1.2rem; } }
@media only screen and (max-width: 799px) and (-webkit-min-device-pixel-ratio: 2), only screen and (max-width: 799px) and (min--moz-device-pixel-ratio: 2), only screen and (max-width: 799px) and (-o-min-device-pixel-ratio: 2), only screen and (max-width: 799px) and (min-device-pixel-ratio: 2), only screen and (max-width: 799px) and (min-resolution: 192dpi), only screen and (max-width: 799px) and (min-resolution: 2dppx) {
.playback-controls section.triggers {
bottom: 4px; }
.playback-controls section.triggers .icon {
font-size: 1.4rem; } }
@media (max-width: 799px) {
.playback-controls section.triggers .sidebar-toggle.open {
opacity: 1 !important; }
.playback-controls section.triggers .sidebar-toggle.open:before {
background: rgba(0, 0, 0, 0.8);
content: "";
display: block;
position: fixed;
bottom: 0;
right: 0;
width: 100vw;
height: 100vh;
z-index: 99; }
.playback-controls--expanded {
height: 140px;
max-height: 100%; }
.playback-controls--expanded section.settings {
display: block;
right: 0;
bottom: 90px;
padding-right: 10px;
text-align: right;
width: 100px; }
.playback-controls--expanded section.volume {
width: auto;
display: block;
right: 100px;
left: 10px;
bottom: 88px; } }
@media only screen and (max-width: 799px) and (-webkit-min-device-pixel-ratio: 2), only screen and (max-width: 799px) and (min--moz-device-pixel-ratio: 2), only screen and (max-width: 799px) and (-o-min-device-pixel-ratio: 2), only screen and (max-width: 799px) and (min-device-pixel-ratio: 2), only screen and (max-width: 799px) and (min-resolution: 192dpi), only screen and (max-width: 799px) and (min-resolution: 2dppx) {
.playback-controls--expanded section.volume {
right: 115px; }
.playback-controls--expanded section.volume .slider__wrapper {
bottom: 9px; } }
@media (max-width: 799px) {
.playback-controls--expanded section.progress {
display: block;
bottom: 55px;
left: -15px;
right: -15px; } }
.playback-controls section.settings, .playback-controls section.volume, .playback-controls section.progress {
display: none; }
.playback-controls section.triggers {
display: block;
width: 6rem;
order: 3;
text-align: center; }
.playback-controls section.triggers .icon {
font-size: 1.2rem; } }
@media only screen and (max-width: 799px) and (-webkit-min-device-pixel-ratio: 2), only screen and (max-width: 799px) and (min--moz-device-pixel-ratio: 2), only screen and (max-width: 799px) and (-o-min-device-pixel-ratio: 2), only screen and (max-width: 799px) and (min-device-pixel-ratio: 2), only screen and (max-width: 799px) and (min-resolution: 192dpi), only screen and (max-width: 799px) and (min-resolution: 2dppx) {
.playback-controls .control {
padding: 14px 8px; } }
.playback-controls section.triggers {
bottom: 4px; }
.playback-controls section.triggers .icon {
font-size: 1.4rem; } }
@media (max-width: 799px) {
.playback-controls section.triggers .sidebar-toggle.open {
opacity: 1 !important; }
.playback-controls section.triggers .sidebar-toggle.open:before {
background: rgba(0, 0, 0, 0.8);
content: "";
display: block;
position: fixed;
bottom: 0;
right: 0;
width: 100vw;
height: 100vh;
z-index: 99; }
.playback-controls--expanded {
max-height: 100%; }
.playback-controls--expanded .playback-controls__background {
height: 230px; }
.playback-controls--expanded section.settings {
position: absolute;
display: flex;
right: 0;
bottom: 90px;
padding-right: 10px;
text-align: right;
width: 100px; }
.playback-controls--expanded section.volume {
position: absolute;
width: auto;
display: flex;
right: 100px;
left: 10px;
bottom: 88px; } }
@media only screen and (max-width: 799px) and (-webkit-min-device-pixel-ratio: 2), only screen and (max-width: 799px) and (min--moz-device-pixel-ratio: 2), only screen and (max-width: 799px) and (-o-min-device-pixel-ratio: 2), only screen and (max-width: 799px) and (min-device-pixel-ratio: 2), only screen and (max-width: 799px) and (min-resolution: 192dpi), only screen and (max-width: 799px) and (min-resolution: 2dppx) {
.playback-controls--expanded section.volume {
right: 115px; }
.playback-controls--expanded section.volume .slider__wrapper {
bottom: 9px; } }
@media (max-width: 799px) {
.playback-controls--expanded section.progress {
position: absolute;
display: flex;
bottom: 60px;
left: 0;
right: 0; } }
@media only screen and (max-width: 799px) and (-webkit-min-device-pixel-ratio: 2), only screen and (max-width: 799px) and (min--moz-device-pixel-ratio: 2), only screen and (max-width: 799px) and (-o-min-device-pixel-ratio: 2), only screen and (max-width: 799px) and (min-device-pixel-ratio: 2), only screen and (max-width: 799px) and (min-resolution: 192dpi), only screen and (max-width: 799px) and (min-resolution: 2dppx) {
.playback-controls .control {
padding: 14px 8px; } }
.output-control {
position: relative; }
@ -6208,6 +6223,102 @@ main header {
.sub-views__option {
margin-right: 15px; } }
.sub-tabs__menu::after {
content: "";
clear: both;
display: block; }
.sub-tabs__menu .menu__inner {
display: flex;
margin: 0 0 0 -5px; }
.sub-tabs__menu .menu-item {
-webkit-transition: none !important;
-moz-transition: none !important;
-o-transition: none !important;
transition: none !important;
width: 20%;
min-width: 120px;
max-width: 200px;
padding: 0 5px;
display: block;
box-sizing: border-box;
border: none !important;
cursor: pointer; }
.sub-tabs__menu .menu-item__inner {
position: relative;
padding: 30px 10px;
margin-bottom: 10px;
border-radius: 3px;
display: block;
text-align: center;
box-sizing: border-box; }
.sub-tabs__menu .menu-item__thumbnail {
margin-bottom: 15px;
display: inline-block; }
.sub-tabs__menu .menu-item__icon {
font-size: 40px;
height: 40px;
margin-top: 10px;
margin-bottom: 15px;
display: inline-block; }
.sub-tabs__menu .menu-item__icon.icon--svg {
opacity: 0.8; }
.light-theme .sub-tabs__menu .menu-item__icon.icon--svg {
-webkit-filter: invert(1);
filter: invert(1); }
.sub-tabs__menu .menu-item__title {
margin: 0;
padding: 0;
font-weight: bold;
font-size: 16px; }
.sub-tabs__menu .menu-item:hover .menu-item__inner {
background: #222222; }
.light-theme .sub-tabs__menu .menu-item:hover .menu-item__inner {
background: #ebe8e4; }
.sub-tabs__menu .menu-item:active {
-moz-transform: scale(0.98);
-webkit-transform: scale(0.98);
transform: scale(0.98); }
.sub-tabs__menu .menu-item--active .menu-item__inner {
background: #222222;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
padding-bottom: 40px;
margin-bottom: 0; }
.light-theme .sub-tabs__menu .menu-item--active .menu-item__inner {
background: #ebe8e4; }
.sub-tabs__content {
padding: 20px;
border-radius: 0 0 3px 3px;
background: #222222; }
.sub-tabs__content .field:last-child {
padding-bottom: 0; }
.light-theme .sub-tabs__content {
background: #ebe8e4; }
@media (max-width: 799px) {
.sub-tabs {
margin-left: -20px;
margin-right: -20px; }
.sub-tabs__menu {
overflow-x: auto; }
.sub-tabs__menu::-webkit-scrollbar {
width: 0px;
height: 0px; }
.sub-tabs__menu__inner {
padding: 0 20px; }
.sub-tabs__menu .menu-item {
width: 140px; }
.sub-tabs__menu .menu-item__inner {
padding-top: 10px;
padding-bottom: 10px; }
.sub-tabs__menu .menu-item--active .menu-item__inner {
padding-bottom: 20px; }
.sub-tabs__content {
margin-top: 0; } }
.debug-info {
font-size: 10px;
position: fixed;
@ -6287,101 +6398,6 @@ main header {
white-space: wrap;
max-width: 200px; }
.services .menu::after {
content: "";
clear: both;
display: block; }
.services .menu__inner {
display: flex;
margin: 0 0 0 -5px; }
.services .menu .menu-item {
-webkit-transition: none !important;
-moz-transition: none !important;
-o-transition: none !important;
transition: none !important;
width: 20%;
min-width: 120px;
max-width: 200px;
padding: 0 5px;
display: block;
box-sizing: border-box;
border: none !important; }
.services .menu .menu-item__inner {
position: relative;
padding: 30px 10px;
margin-bottom: 10px;
border-radius: 3px;
display: block;
text-align: center;
box-sizing: border-box; }
.services .menu .menu-item__thumbnail {
margin-bottom: 15px;
display: inline-block; }
.services .menu .menu-item__icon {
font-size: 40px;
height: 40px;
margin-top: 10px;
margin-bottom: 15px;
display: inline-block; }
.services .menu .menu-item__icon.icon--svg {
opacity: 0.8; }
.light-theme .services .menu .menu-item__icon.icon--svg {
-webkit-filter: invert(1);
filter: invert(1); }
.services .menu .menu-item__title {
margin: 0;
padding: 0;
font-weight: bold;
font-size: 16px; }
.services .menu .menu-item:hover .menu-item__inner {
background: #222222; }
.light-theme .services .menu .menu-item:hover .menu-item__inner {
background: #ebe8e4; }
.services .menu .menu-item:active {
-moz-transform: scale(0.98);
-webkit-transform: scale(0.98);
transform: scale(0.98); }
.services .menu .menu-item--active .menu-item__inner {
background: #222222;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
padding-bottom: 40px;
margin-bottom: 0; }
.light-theme .services .menu .menu-item--active .menu-item__inner {
background: #ebe8e4; }
.services .service {
padding: 20px;
border-radius: 0 0 3px 3px;
background: #222222; }
.services .service .field:last-child {
padding-bottom: 0; }
.light-theme .services .service {
background: #ebe8e4; }
@media (max-width: 799px) {
.services {
margin-left: -20px;
margin-right: -20px; }
.services .menu {
overflow-x: auto; }
.services .menu::-webkit-scrollbar {
width: 0px;
height: 0px; }
.services .menu__inner {
padding: 0 20px; }
.services .menu .menu-item {
width: 140px; }
.services .menu .menu-item__inner {
padding-top: 10px;
padding-bottom: 10px; }
.services .menu .menu-item--active .menu-item__inner {
padding-bottom: 20px; }
.services .service {
margin-top: 0; } }
.snapcast button {
margin-bottom: 0px; }
@ -7527,14 +7543,21 @@ main .track-view {
position: absolute;
top: 10px;
right: 10px;
z-index: 99; }
z-index: 99;
display: flex; }
.modal .controls .control {
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
-webkit-transition: all 0.2s ease-in-out;
-moz-transition: all 0.2s ease-in-out;
-o-transition: all 0.2s ease-in-out;
transition: all 0.2s ease-in-out;
display: block;
float: right;
display: flex;
align-items: center;
opacity: 0.5;
cursor: pointer;
padding: 20px;

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -100,8 +100,8 @@
// Release details
// These are automatically injected to built HTML
var build = "1587329101";
var version = "3.47.0";
var build = "1588218286";
var version = "3.48.0";
// Construct the script tag
var js = document.createElement("script");

6510
package-lock.json generated

File diff suppressed because it is too large Load Diff

43
package.json Normal file → Executable file
View File

@ -1,6 +1,6 @@
{
"name": "mopidy-iris",
"version": "3.47.0",
"version": "3.48.0",
"description": "Mopidy HTTP interface",
"repository": "https://github.com/jaedb/iris",
"author": "James Barnsley <james@barnsley.nz>",
@ -10,25 +10,26 @@
},
"main": "app.js",
"dependencies": {
"fast-average-color": "^6.0.2",
"history": "^4.10.1",
"jquery": "^3.4.1",
"logrocket": "^1.0.3",
"jquery": "^3.5.0",
"logrocket": "^1.0.7",
"mopidy": "^0.5",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-fontawesome": "^1.7.1",
"react-ga": "^2.7.0",
"react-howler": "^3.7.4",
"react-input-range": "1.3.0",
"react-redux": "^6",
"react-redux": "^7",
"react-router": "^5",
"react-router-dom": "^5.1.2",
"react-sortablejs": "^1.5.0"
},
"devDependencies": {
"@babel/plugin-proposal-class-properties": "^7.8.3",
"@babel/preset-env": "^7.7.1",
"@babel/preset-react": "^7.7.0",
"@babel/preset-env": "^7.9.5",
"@babel/preset-react": "^7.9.4",
"babel-core": "^6",
"babel-eslint": "^8.0.1",
"babel-jest": "^24.9.0",
@ -39,30 +40,30 @@
"babel-preset-stage-2": "^6",
"copy-dir": "^0.3.0",
"css-loader": "^0.28.11",
"enzyme": "^3.10.0",
"enzyme-adapter-react-16": "^1.15.1",
"eslint": "^6.6.0",
"eslint-config-airbnb": "^18.0.1",
"eslint-plugin-import": "^2.18.2",
"enzyme": "^3.11.0",
"enzyme-adapter-react-16": "^1.15.2",
"eslint": "^6.8.0",
"eslint-config-airbnb": "^18.1.0",
"eslint-plugin-import": "^2.20.2",
"eslint-plugin-jsx-a11y": "^6.2.3",
"eslint-plugin-react": "^7.16.0",
"eslint-plugin-react": "^7.19.0",
"expose-loader": "0.7.3",
"file-loader": "^3",
"fs-copy-file": "2.1.2",
"jest": "^24.9.0",
"js-sha256": "^0.9.0",
"mini-css-extract-plugin": "^0.6.0",
"node-sass": "^4.13.0",
"react-test-renderer": "^16.12.0",
"redux": "^4.0.4",
"node-sass": "^4.14.0",
"react-test-renderer": "^16.13.1",
"redux": "^4.0.5",
"redux-thunk": "^2.3.0",
"sass-loader": "^7.3.1",
"sortablejs": "^1.10.1",
"sortablejs": "^1.10.2",
"style-loader": "^0.23",
"url-loader": "^1",
"webpack": "^4.41.2",
"webpack-cli": "^3.3.10",
"webpack-dev-server": "^3.9.0",
"webpack": "^4.43.0",
"webpack-cli": "^3.3.11",
"webpack-dev-server": "^3.10.3",
"webpack-strip": "0.1.0"
},
"scripts": {

View File

@ -1,6 +1,6 @@
[metadata]
name = Mopidy-Iris
version = 3.47.0
version = 3.48.0
url = https://github.com/jaedb/iris
author = James Barnsley
author_email = james@barnsley.nz

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -160,7 +160,7 @@ class ContextMenu extends React.Component {
}
handleTouchStart = (e) => {
const { uiActions: { hideContextMenu } } = this.props;
const { menu, uiActions: { hideContextMenu } } = this.props;
if (menu && $(e.target).closest('.context-menu').length <= 0 && $(e.target).closest('.context-menu-trigger').length <= 0) {
hideContextMenu();

View File

@ -7,7 +7,7 @@ export default class TextField extends React.Component {
this.state = {
in_focus: false,
value: (this.props.value ? this.props.value : ''),
value: props.value || '',
};
}
@ -20,31 +20,40 @@ export default class TextField extends React.Component {
return null;
}
handleChange(e) {
handleChange = (e) => {
this.setState({ value: e.target.value });
}
handleFocus(e) {
handleFocus = () => {
this.setState({ in_focus: true });
}
handleBlur(e) {
handleBlur = () => {
const { value, onChange } = this.props;
const { value: stateValue } = this.state;
this.setState({ in_focus: false });
if (this.state.value !== this.props.value) {
this.props.onChange(this.state.value);
if (stateValue !== value) {
onChange(stateValue);
}
}
render() {
render = () => {
const {
className,
type = 'text',
placeholder,
} = this.props;
const { value } = this.state;
return (
<input
className={this.props.className ? this.props.className : ''}
type={this.props.type ? this.props.type : 'text'}
onChange={(e) => this.handleChange(e)}
onFocus={(e) => this.handleFocus(e)}
onBlur={(e) => this.handleBlur(e)}
value={this.state.value}
placeholder={this.props.placeholder ? this.props.placeholder : null}
className={className}
type={type}
onChange={this.handleChange}
onFocus={this.handleFocus}
onBlur={this.handleBlur}
value={value}
placeholder={placeholder}
/>
);
}

View File

@ -66,49 +66,49 @@ class Hotkeys extends React.Component {
prevent = true;
break;
case 'escape':
if (dragging) {
uiActions.dragEnd();
prevent = true;
if (allow_reporting) ReactGA.event({ category: 'Hotkey', action: key, label: 'Dragging' });
} else if (modal) {
window.history.back();
prevent = true;
if (allow_reporting) ReactGA.event({ category: 'Hotkey', action: key, label: 'Modal' });
case 's': // Stop
mopidyActions.stop();
uiActions.createNotification({ content: 'stop', type: 'shortcut' });
prevent = true;
if (allow_reporting) ReactGA.event({ category: 'Hotkey', action: key, label: 'Stop' });
break;
case 'b': // Seek backwards 30 sec
var new_position = play_time_position - 30000;
if (new_position < 0) {
new_position = 0;
}
mopidyActions.setTimePosition(new_position);
uiActions.createNotification({ content: 'fast_rewind', type: 'shortcut' });
prevent = true;
if (allow_reporting) ReactGA.event({ category: 'Hotkey', action: key, label: 'Rewind' });
break;
case 's':
history.push('/search');
case 'f': // Seek forwards 30 sec
mopidyActions.setTimePosition(play_time_position + 30000);
uiActions.createNotification({ content: 'fast_forward', type: 'shortcut' });
prevent = true;
if (allow_reporting) ReactGA.event({ category: 'Hotkey', action: key, label: 'Search' });
break;
case 'q':
history.push('/queue');
prevent = true;
if (allow_reporting) ReactGA.event({ category: 'Hotkey', action: key, label: 'Queue' });
break;
case 'k':
history.push('/kiosk-mode');
prevent = true;
if (allow_reporting) ReactGA.event({ category: 'Hotkey', action: key, label: 'Kiosk mode' });
if (allow_reporting) ReactGA.event({ category: 'Hotkey', action: key, label: 'Fastforward' });
break;
case ',':
window.history.back();
case '<': // Previous track
mopidyActions.previous();
uiActions.createNotification({ content: 'skip_previous', type: 'shortcut' });
prevent = true;
if (allow_reporting) ReactGA.event({ category: 'Hotkey', action: key, label: 'Back' });
if (allow_reporting) ReactGA.event({ category: 'Hotkey', action: key, label: 'Previous' });
break;
case '.':
window.history.forward();
case '>': // Next track
mopidyActions.next();
uiActions.createNotification({ content: 'skip_next', type: 'shortcut' });
prevent = true;
if (allow_reporting) ReactGA.event({ category: 'Hotkey', action: key, label: 'Forward' });
if (allow_reporting) ReactGA.event({ category: 'Hotkey', action: key, label: 'Next' });
break;
case '=':
case '+': // Volume up
if (volume !== 'false') {
volume += 5;
if (volume > 100) {
@ -124,7 +124,7 @@ class Hotkeys extends React.Component {
if (allow_reporting) ReactGA.event({ category: 'Hotkey', action: key, label: 'Volume up' });
break;
case '-':
case '-': // Volume down
if (volume !== 'false') {
volume -= 5;
if (volume < 0) {
@ -140,7 +140,7 @@ class Hotkeys extends React.Component {
if (allow_reporting) ReactGA.event({ category: 'Hotkey', action: key, label: 'Volume down' });
break;
case '0':
case '0': // Mute
if (mute) {
mopidyActions.setMute(false);
uiActions.createNotification({ content: 'volume_up', type: 'shortcut' });
@ -153,36 +153,34 @@ class Hotkeys extends React.Component {
prevent = true;
break;
case ';':
var new_position = play_time_position - 30000;
if (new_position < 0) {
new_position = 0;
case 'escape': // Cancel current action/context
if (dragging) {
uiActions.dragEnd();
prevent = true;
if (allow_reporting) ReactGA.event({ category: 'Hotkey', action: key, label: 'Dragging' });
} else if (modal) {
window.history.back();
prevent = true;
if (allow_reporting) ReactGA.event({ category: 'Hotkey', action: key, label: 'Modal' });
}
mopidyActions.setTimePosition(new_position);
uiActions.createNotification({ content: 'fast_rewind', type: 'shortcut' });
prevent = true;
if (allow_reporting) ReactGA.event({ category: 'Hotkey', action: key, label: 'Rewind' });
break;
case "'":
mopidyActions.setTimePosition(play_time_position + 30000);
uiActions.createNotification({ content: 'fast_forward', type: 'shortcut' });
case '1': // Navigation: Queue
history.push('/queue');
prevent = true;
if (allow_reporting) ReactGA.event({ category: 'Hotkey', action: key, label: 'Fastforward' });
if (allow_reporting) ReactGA.event({ category: 'Hotkey', action: key, label: 'Queue' });
break;
case '[':
mopidyActions.previous();
uiActions.createNotification({ content: 'skip_previous', type: 'shortcut' });
case '2': // Navigation: Search
history.push('/search');
prevent = true;
if (allow_reporting) ReactGA.event({ category: 'Hotkey', action: key, label: 'Previous' });
if (allow_reporting) ReactGA.event({ category: 'Hotkey', action: key, label: 'Search' });
break;
case ']':
mopidyActions.next();
uiActions.createNotification({ content: 'skip_next', type: 'shortcut' });
case '3': // Navigation: Kiosk mode
history.push('/kiosk-mode');
prevent = true;
if (allow_reporting) ReactGA.event({ category: 'Hotkey', action: key, label: 'Next' });
if (allow_reporting) ReactGA.event({ category: 'Hotkey', action: key, label: 'Kiosk mode' });
break;
default:

View File

@ -94,7 +94,6 @@ class PlaybackControls extends React.Component {
setTransition(direction) {
this.setState({
current_track: null,
transition_track: this.state.current_track,
transition_direction: direction,
});
@ -104,7 +103,6 @@ class PlaybackControls extends React.Component {
setTimeout(() => {
this.setState({
transition_track: null,
transition_direction: null,
});
},
250);
@ -173,53 +171,81 @@ class PlaybackControls extends React.Component {
}
render() {
const { next_track, touch_enabled, time_position } = this.props;
const { current_track, expanded } = this.state;
let images = false;
if (current_track && current_track.images) {
images = current_track.images;
}
const {
next_track,
touch_enabled,
time_position,
} = this.props;
const {
current_track,
expanded,
transition_track,
transition_direction,
} = this.state;
return (
<div className={`playback-controls${expanded ? ' playback-controls--expanded' : ''}${touch_enabled ? ' playback-controls--touch-enabled' : ''}`}>
<div className="playback-controls__background" />
<Stream />
{next_track && next_track.images ? <Thumbnail className="hide" size="large" images={next_track.images} /> : null}
{this.state.transition_track && this.state.transition_direction ? (
<div
className={`current-track current-track__transition current-track__transition--${this.state.transition_direction}`}
>
<div className="text">
<div className="title">
{this.state.transition_track.name}
</div>
<div className="artist">
<LinksSentence items={this.state.transition_track.artists} nolinks />
</div>
</div>
</div>
) : null}
<div
className={this.state.transition_track && this.state.transition_direction ? 'current-track current-track--transitioning' : 'current-track'}
onTouchStart={this.handleTouchStart}
onTouchEnd={this.handleTouchEnd}
tabIndex="-1"
className="current-track__wrapper"
transition={transition_track}
direction={transition_direction}
>
<Link className="thumbnail-wrapper" to="/kiosk-mode" tabIndex="-1">
<Thumbnail size="small" images={images} type="track" />
</Link>
<div className="text">
<div className="title">
{current_track ? current_track.name : <span>-</span>}
{transition_track && transition_direction && (
<div className={`current-track current-track__outgoing`}>
<div className="text">
<div className="title">
{transition_track.name}
</div>
<div className="artist">
<LinksSentence items={transition_track.artists} nolinks />
</div>
</div>
</div>
<div className="artist">
{current_track ? <LinksSentence items={current_track.artists} /> : <LinksSentence />}
)}
{current_track && (!transition_track || transition_track.tlid !== current_track.tlid) ? (
<div
className="current-track current-track__incoming"
onTouchStart={this.handleTouchStart}
onTouchEnd={this.handleTouchEnd}
tabIndex="-1"
key={current_track.tlid}
>
<Link className="thumbnail-wrapper" to="/kiosk-mode" tabIndex="-1">
<Thumbnail size="small" images={current_track.images} type="track" />
</Link>
<div className="text">
<div className="title">
{current_track ? current_track.name : <span>-</span>}
</div>
<div className="artist">
{current_track ? <LinksSentence items={current_track.artists} /> : <LinksSentence />}
</div>
</div>
</div>
</div>
) : (
<div
className="current-track"
onTouchStart={this.handleTouchStart}
onTouchEnd={this.handleTouchEnd}
tabIndex="-1"
>
<Link className="thumbnail-wrapper" to="/kiosk-mode" tabIndex="-1">
<Thumbnail size="small" type="track" />
</Link>
<div className="text">
<div className="title">&nbsp;</div>
<div className="artist">&nbsp;</div>
</div>
</div>
)}
</div>
<section className="playback">
@ -233,9 +259,13 @@ class PlaybackControls extends React.Component {
</section>
<section className="progress">
<div className="time time--current">
{time_position ? <Dater type="length" data={time_position} /> : '-'}
</div>
<ProgressSlider />
<span className="current">{ time_position ? <Dater type="length" data={time_position} /> : '-' }</span>
<span className="total">{ current_track ? <Dater type="length" data={current_track.duration} /> : '-' }</span>
<div className="time time--total">
{current_track ? <Dater type="length" data={current_track.duration} /> : '-'}
</div>
</section>
<section className="settings">

183
src/js/components/Servers.js Executable file
View File

@ -0,0 +1,183 @@
import React, { memo, useCallback } from 'react';
import { useStore, useDispatch } from 'react-redux'
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import Link from './Link';
import Icon from './Icon';
import URILink from './URILink';
import TextField from './Fields/TextField';
import { indexToArray } from '../util/arrays';
import * as uiActions from '../services/ui/actions';
import * as coreActions from '../services/core/actions';
import * as mopidyActions from '../services/mopidy/actions';
import { iconFromKeyword } from '../util/helpers';
const Servers = ({
match: { params: { id: serverId } },
history,
}) => {
const store = useStore();
const dispatch = useDispatch();
const {
mopidy: {
servers,
current_server,
ssl,
connected: mopidyConnected,
connecting: mopidyConnecting,
},
pusher: {
connected: pusherConnected,
connecting: pusherConnecting,
},
} = store.getState();
const addServer = () => {
const action = mopidyActions.addServer();
dispatch(action);
history.push(`/settings/servers/${action.server.id}`);
};
const renderMenu = () => (
<div className="sub-tabs__menu" id="servers-menu">
<div className="menu__inner">
{indexToArray(servers).map((server) => {
let status = <span className="status mid_grey-text">Inactive</span>;
if (server.id === current_server) {
if (mopidyConnecting || pusherConnecting) {
status = <span className="status mid_grey-text">Connecting</span>
} else if (!mopidyConnected || !pusherConnected) {
status = <span className="status red-text">Disconnected</span>
} else if (mopidyConnected && pusherConnected) {
status = <span className="status green-text">Connected</span>
}
}
return (
<Link
history={history}
className="menu-item"
activeClassName="menu-item--active"
to={`/settings/servers/${server.id}`}
scrollTo="#servers-menu"
key={server.id}
>
<div className="menu-item__inner">
<Icon className="menu-item__icon" name={iconFromKeyword(server.name) || 'dns'} />
<div className="menu-item__title">
{server.name}
</div>
{status}
</div>
</Link>
);
})}
<span
className="menu-item menu-item--add"
onClick={addServer}
>
<div className="menu-item__inner">
<Icon className="menu-item__icon" name="add" />
<div className="menu-item__title">
Add
</div>
<span className="status mid_grey-text">New server</span>
</div>
</span>
</div>
</div>
);
const renderServer = () => {
if (!serverId) return null;
const server = servers[serverId];
if (!server) return null;
const remove = () => {
dispatch(mopidyActions.removeServer(server.id));
};
const setAsCurrent = () => {
dispatch(mopidyActions.setCurrentServer(server));
};
return (
<div className="sub-tabs__content">
<label className="field">
<div className="name">Name</div>
<div className="input">
<TextField
type="text"
value={server.name}
onChange={value => dispatch(mopidyActions.updateServer({ id: server.id, name: value }))}
/>
</div>
</label>
<label className="field">
<div className="name">Host</div>
<div className="input">
<TextField
value={server.host}
onChange={value => dispatch(mopidyActions.updateServer({ id: server.id, host: value }))}
/>
</div>
</label>
<label className="field">
<div className="name">Port</div>
<div className="input">
<TextField
type="text"
value={server.port}
onChange={value => dispatch(mopidyActions.updateServer({ id: server.id, port: value }))}
/>
</div>
</label>
<div className="field checkbox">
<div className="name">Encryption</div>
<div className="input">
<label>
<input
type="checkbox"
name="ssl"
value={server.ssl}
checked={server.ssl}
onChange={() => dispatch(mopidyActions.updateServer({ id: server.id, ssl: !server.ssl }))}
/>
<span className="label tooltip">
Use SSL
<span className="tooltip__content">Requires a SSL proxy</span>
</span>
</label>
</div>
</div>
<button
type="button"
className="button button--primary"
onClick={setAsCurrent}
>
Switch to this server
</button>
<button
type="button"
className="button button--destructive"
disabled={server.id === current_server}
onClick={remove}
>
Remove
</button>
</div>
);
};
return (
<div className="sub-tabs sub-tabs--servers">
{renderMenu()}
{renderServer()}
</div>
);
};
export default Servers;

View File

@ -7,6 +7,7 @@ import Link from './Link';
import Thumbnail from './Thumbnail';
import Icon from './Icon';
import URILink from './URILink';
import TextField from './Fields/TextField';
import SpotifyAuthenticationFrame from './Fields/SpotifyAuthenticationFrame';
import LastfmAuthenticationFrame from './Fields/LastfmAuthenticationFrame';
import GeniusAuthenticationFrame from './Fields/GeniusAuthenticationFrame';
@ -109,11 +110,8 @@ class Services extends React.Component {
<label className="field">
<div className="name">Country</div>
<div className="input">
<input
type="text"
onChange={(e) => this.setState({ country: e.target.value })}
onFocus={(e) => this.setState({ input_in_focus: 'country' })}
onBlur={(e) => this.props.spotifyActions.set({ country: e.target.value })}
<TextField
onChange={value => this.props.spotifyActions.set({ country: value })}
value={this.state.country}
/>
<div className="description">
@ -322,9 +320,9 @@ class Services extends React.Component {
}
return (
<div className="menu" id="services-menu">
<div className="sub-tabs__menu menu" id="services-menu">
<div className="menu__inner">
<Link history={this.props.history} className="menu-item menu-item--spotify" activeClassName="menu-item--active" to="/settings/spotify" scrollTo="#services-menu">
<Link history={this.props.history} className="menu-item menu-item--spotify" activeClassName="menu-item--active" to="/settings/services/spotify" scrollTo="#services-menu">
<div className="menu-item__inner">
{spotify_icon}
<div className="menu-item__title">
@ -333,7 +331,7 @@ class Services extends React.Component {
{this.props.spotify.authorization ? <span className="status green-text">Authorized</span> : <span className="status mid_grey-text">Read-only</span>}
</div>
</Link>
<Link history={this.props.history} className="menu-item menu-item--lastfm" activeClassName="menu-item--active" to="/settings/lastfm" scrollTo="#services-menu">
<Link history={this.props.history} className="menu-item menu-item--lastfm" activeClassName="menu-item--active" to="/settings/services/lastfm" scrollTo="#services-menu">
<div className="menu-item__inner">
{lastfm_icon}
<div className="menu-item__title">
@ -342,7 +340,7 @@ class Services extends React.Component {
{this.props.lastfm.authorization ? <span className="status green-text">Authorized</span> : <span className="status mid_grey-text">Read-only</span>}
</div>
</Link>
<Link history={this.props.history} className="menu-item menu-item--genius" activeClassName="menu-item--active" to="/settings/genius" scrollTo="#services-menu">
<Link history={this.props.history} className="menu-item menu-item--genius" activeClassName="menu-item--active" to="/settings/services/genius" scrollTo="#services-menu">
<div className="menu-item__inner">
{genius_icon}
<div className="menu-item__title">
@ -351,7 +349,7 @@ class Services extends React.Component {
{this.props.genius.authorization ? <span className="status green-text">Authorized</span> : <span className="status mid_grey-text">Unauthorized</span>}
</div>
</Link>
<Link history={this.props.history} className="menu-item menu-item--snapcast" activeClassName="menu-item--active" to="/settings/snapcast" scrollTo="#services-menu">
<Link history={this.props.history} className="menu-item menu-item--snapcast" activeClassName="menu-item--active" to="/settings/services/snapcast" scrollTo="#services-menu">
<div className="menu-item__inner">
<Icon className="menu-item__icon" name="devices" />
<div className="menu-item__title">
@ -368,7 +366,7 @@ class Services extends React.Component {
)}
</div>
</Link>
<Link history={this.props.history} className="menu-item menu-item--icecast" activeClassName="menu-item--active" to="/settings/icecast" scrollTo="#services-menu">
<Link history={this.props.history} className="menu-item menu-item--icecast" activeClassName="menu-item--active" to="/settings/services/icecast" scrollTo="#services-menu">
<div className="menu-item__inner">
<Icon className="menu-item__icon" name="wifi_tethering" />
<div className="menu-item__title">
@ -386,15 +384,15 @@ class Services extends React.Component {
const { match } = this.props;
switch (match.params.service) {
case 'spotify':
return <div className="service">{this.renderSpotify()}</div>;
return <div className="sub-tabs__content">{this.renderSpotify()}</div>;
case 'lastfm':
return <div className="service">{this.renderLastfm()}</div>;
return <div className="sub-tabs__content">{this.renderLastfm()}</div>;
case 'genius':
return <div className="service">{this.renderGenius()}</div>;
return <div className="sub-tabs__content">{this.renderGenius()}</div>;
case 'icecast':
return <div className="service">{this.renderIcecast()}</div>;
return <div className="sub-tabs__content">{this.renderIcecast()}</div>;
case 'snapcast':
return <div className="service">{<Snapcast match={this.props.match} />}</div>;
return <div className="sub-tabs__content">{<Snapcast match={this.props.match} />}</div>;
default:
return null;
}
@ -402,7 +400,7 @@ class Services extends React.Component {
render() {
return (
<div className="services">
<div className="sub-tabs sub-tabs--services">
{this.renderMenu()}
{this.renderService()}
</div>

View File

@ -4,6 +4,7 @@ import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { collate } from '../util/format';
import { sortItems } from '../util/arrays';
import { iconFromKeyword } from '../util/helpers';
import VolumeControl from './Fields/VolumeControl';
import MuteControl from './Fields/MuteControl';
import TextField from './Fields/TextField';
@ -106,42 +107,17 @@ const SnapcastGroups = (props) => {
!group.clients.filter((client) => client.connected).length
)
);
const icon = () => {
const iconWords = [
{ icon: 'business', words: ['office', 'work'] },
{ icon: 'king_bed', words: ['bed'] },
{ icon: 'weekend', words: ['lounge', 'tv', 'sitting room'] },
{ icon: 'directions_car', words: ['garage', 'laundry'] },
{ icon: 'fitness_center', words: ['gym'] },
{ icon: 'kitchen', words: ['kitchen'] },
{ icon: 'deck', words: ['deck', 'outside'] },
{ icon: 'restaurant_menu', words: ['dining', 'dinner'] },
{ icon: 'laptop', words: ['laptop'] },
{ icon: 'bug_report', words: ['test', 'debug'] },
{ icon: 'child_care', words: ['kids', 'baby'] },
{ icon: 'smartphone', words: ['phone', 'mobile'] },
];
const name = group.name.toLowerCase();
for (let item of iconWords) {
for (let word of item.words) {
if (name.match(new RegExp(`(${word})`, 'gi'))) {
return item.icon;
}
}
};
return 'speaker_group';
}
return (
<Link
className={`snapcast__groups__menu-item menu-item${anyClients ? ' menu-item--no-clients' : ''}`}
activeClassName="menu-item--active"
key={group.id}
history={history}
to={`/settings/snapcast/${group.id}`}
to={`/settings/services/snapcast/${group.id}`}
scrollTo="#services-snapcast-groups"
>
<div className="snapcast__groups__menu-item__inner menu-item__inner">
<Icon className="menu-item__icon" name={icon()} />
<Icon className="menu-item__icon" name={iconFromKeyword(group.name.toLowerCase()) || 'speaker_group'} />
<div className="menu-item__title">
{group.name}
{group.mute && (
@ -154,8 +130,8 @@ const SnapcastGroups = (props) => {
}
return (
<div className="snapcast__groups" id="services-snapcast-groups">
<div className="snapcast__groups__menu menu">
<div className="sub-tabs snapcast__groups" id="services-snapcast-groups">
<div className="sub-tabs__menu snapcast__groups__menu menu">
<div className="menu__inner">
{sortItems(groupsArray, 'name').map((group) => renderMenuItem(group))}
</div>

View File

@ -264,8 +264,7 @@ export function getAlbum(uri, artist, album, mbid = false) {
(response) => {
if (response.album) {
const existing_album = getState().core.albums[uri];
const album = {
const album = {
uri,
images: response.album.image,
listeners: parseInt(response.album.listeners),
@ -342,8 +341,7 @@ export function getImages(context, uri) {
.then(
(response) => {
if (response.album) {
record = { ...record, images: response.album.image };
dispatch(coreActions.albumLoaded(record));
dispatch(coreActions.albumLoaded({ uri, images: response.album.image }));
}
},
);

View File

@ -63,6 +63,15 @@ const localstorageMiddleware = (function () {
);
break;
case 'MOPIDY_UPDATE_SERVERS':
storage.set(
'mopidy',
{
servers: action.servers,
},
);
break;
case 'SPOTIFY_AUTHORIZATION_GRANTED':
if (action.authorization !== undefined) {
var { authorization } = action;

View File

@ -1,5 +1,6 @@
import { createRange } from '../../util/arrays';
import { generateGuid } from '../../util/helpers';
export function set(data) {
return {
@ -8,6 +9,47 @@ export function set(data) {
};
}
export function updateServer(server) {
return {
type: 'MOPIDY_UPDATE_SERVER',
server,
};
}
export function updateServers(servers) {
return {
type: 'MOPIDY_UPDATE_SERVERS',
servers,
};
}
export function addServer() {
return {
type: 'MOPIDY_UPDATE_SERVER',
server: {
id: generateGuid(),
name: 'New server',
host: window.location.hostname,
port: (window.location.port ? window.location.port : (window.location.protocol === 'https:' ? '443' : '80')),
ssl: window.location.protocol === 'https:',
},
};
}
export function setCurrentServer(server) {
return {
type: 'MOPIDY_SET_CURRENT_SERVER',
server,
};
}
export function removeServer(id) {
return {
type: 'MOPIDY_REMOVE_SERVER',
id,
};
}
export function setConnection(data) {
return {
type: 'MOPIDY_SET_CONNECTION',

View File

@ -249,7 +249,7 @@ const MopidyMiddleware = (function () {
var state = store.getState();
socket = new Mopidy({
webSocketUrl: `ws${window.location.protocol === 'https:' ? 's' : ''}://${state.mopidy.host}:${state.mopidy.port}/mopidy/ws/`,
webSocketUrl: `ws${state.mopidy.ssl ? 's' : ''}://${state.mopidy.host}:${state.mopidy.port}/mopidy/ws/`,
callingConvention: 'by-position-or-by-name',
});
@ -284,10 +284,23 @@ const MopidyMiddleware = (function () {
});
break;
case 'MOPIDY_SET_CONNECTION':
store.dispatch(mopidyActions.set(action.data));
case 'MOPIDY_UPDATE_SERVER':
let servers = store.getState().mopidy.servers;
servers[action.server.id] = { ...servers[action.server.id], ...action.server };
store.dispatch(mopidyActions.updateServers(servers));
break;
// Wait 250 ms and then retry connection
case 'MOPIDY_SET_CURRENT_SERVER':
store.dispatch(mopidyActions.set({
current_server: action.server.id,
host: action.server.host,
port: action.server.port,
ssl: action.server.ssl,
connected: false,
connecting: false,
}));
// Wait a moment for store to update, then attempt connection
setTimeout(
() => {
store.dispatch(mopidyActions.connect());
@ -297,6 +310,12 @@ const MopidyMiddleware = (function () {
);
break;
case 'MOPIDY_REMOVE_SERVER':
let remaining_servers = store.getState().mopidy.servers;
delete remaining_servers[action.id];
store.dispatch(mopidyActions.updateServers(remaining_servers));
break;
case 'SET_WINDOW_FOCUS':
// Focus has just been regained
@ -2355,11 +2374,13 @@ const MopidyMiddleware = (function () {
}
}
const action_data = {
type: (`${action.context}_LOADED`).toUpperCase(),
};
action_data[action.context] = records;
store.dispatch(action_data);
if (records.length) {
const action_data = {
type: (`${action.context}_LOADED`).toUpperCase(),
};
action_data[action.context] = records;
store.dispatch(action_data);
}
});
}

View File

@ -6,12 +6,15 @@ export default function reducer(mopidy = {}, action) {
case 'MOPIDY_SET':
return { ...mopidy, ...action.data };
case 'MOPIDY_UPDATE_SERVERS':
return { ...mopidy, servers: action.servers };
case 'MOPIDY_CONNECT':
case 'MOPIDY_CONNECTING':
return {
...mopidy,
connected: false,
connecting: true,
connecting: true,
};
case 'MOPIDY_CONNECTED':

View File

@ -1,6 +1,5 @@
import { createStore, applyMiddleware, combineReducers } from 'redux';
import thunk from 'redux-thunk';
import { generateGuid } from '../util/helpers';
@ -60,7 +59,17 @@ let state = {
connected: false,
host: window.location.hostname,
port: (window.location.port ? window.location.port : (window.location.protocol === 'https:' ? '443' : '80')),
ssl: (window.location.protocol === 'https:'),
ssl: window.location.protocol === 'https:',
current_server: 'default',
servers: {
default: {
id: 'default',
name: 'Default',
host: window.location.hostname,
port: (window.location.port ? window.location.port : (window.location.protocol === 'https:' ? '443' : '80')),
ssl: window.location.protocol === 'https:',
},
},
mute: false,
volume: 0,
progress: 0,

View File

@ -410,6 +410,33 @@ const titleCase = function (string) {
return string.charAt(0).toUpperCase() + string.slice(1);
};
/**
* Use a keyword to return an icon name
*/
const iconFromKeyword = (name) => {
const iconWords = [
{ icon: 'business', words: ['office', 'work'] },
{ icon: 'king_bed', words: ['bed'] },
{ icon: 'weekend', words: ['lounge', 'tv', 'sitting room'] },
{ icon: 'directions_car', words: ['garage', 'basement'] },
{ icon: 'local_laundry_service', words: ['laundry'] },
{ icon: 'fitness_center', words: ['gym'] },
{ icon: 'kitchen', words: ['kitchen'] },
{ icon: 'deck', words: ['deck', 'outside'] },
{ icon: 'restaurant_menu', words: ['dining', 'dinner'] },
{ icon: 'laptop', words: ['laptop'] },
{ icon: 'bug_report', words: ['test', 'debug'] },
{ icon: 'child_care', words: ['kids', 'baby'] },
{ icon: 'smartphone', words: ['phone', 'mobile'] },
];
for (let item of iconWords) {
for (let word of item.words) {
if (name.match(new RegExp(`(${word})`, 'gi'))) {
return item.icon;
}
}
};
}
/**
* Scroll to the top of the page
@ -512,6 +539,7 @@ export {
scrollTo,
upgradeSpotifyPlaylistUris,
upgradeSpotifyPlaylistUri,
iconFromKeyword,
};
export default {
@ -536,4 +564,5 @@ export default {
scrollTo,
upgradeSpotifyPlaylistUris,
upgradeSpotifyPlaylistUri,
iconFromKeyword,
};

View File

@ -11,6 +11,7 @@ import TextField from '../components/Fields/TextField';
import Header from '../components/Header';
import Icon from '../components/Icon';
import Services from '../components/Services';
import Servers from '../components/Servers';
import * as coreActions from '../services/core/actions';
import * as uiActions from '../services/ui/actions';
import * as pusherActions from '../services/pusher/actions';
@ -99,39 +100,6 @@ class Settings extends React.Component {
}
}
renderServerStatus() {
const {
mopidy,
pusher,
} = this.props;
let colour = 'grey';
let icon = 'help';
let status = 'Unknown';
let className = null;
if (mopidy.connecting || pusher.connecting) {
icon = 'autorenew';
status = 'Connecting...';
className = 'icon--spin';
} else if (!mopidy.connected || !pusher.connected) {
colour = 'red';
icon = 'close';
status = 'Disconnected';
} else if (mopidy.connected && pusher.connected) {
colour = 'green';
icon = 'check';
status = 'Connected';
}
return (
<span className={`${colour}-text`}>
<Icon className={className} name={icon} />
{' '}
{status}
</span>
);
}
renderLocalScanButton = () => {
const { processes } = this.props.ui;
const loading = processes.local_scan && processes.local_scan.status === 'running';
@ -183,15 +151,6 @@ class Settings extends React.Component {
<a name="server" />
</h4>
<div className="field">
<div className="name">Status</div>
<div className="input">
<div className="text">
{this.renderServerStatus()}
</div>
</div>
</div>
<label className="field">
<div className="name">Username</div>
<div className="input">
@ -205,34 +164,14 @@ class Settings extends React.Component {
</div>
</label>
<form onSubmit={(e) => this.setConfig(e)}>
<label className="field">
<div className="name">Host</div>
<div className="input">
<TextField
value={mopidy.host}
onChange={value => mopidyActions.setConnection({ host: value })}
/>
</div>
</label>
<label className="field">
<div className="name">Port</div>
<div className="input">
<TextField
type="text"
value={mopidy.port}
onChange={value => mopidyActions.setConnection({ port: value })}
/>
</div>
</label>
</form>
<Route path="/settings/:server?/:id?" component={Servers} />
<h4 className="underline">
Services
<a name="services" />
</h4>
<Route path="/settings/:service?/:id?" component={Services} />
<Route path="/settings/:services?/:service?/:id?" component={Services} />
<h4 className="underline">
Interface
@ -433,7 +372,10 @@ class Settings extends React.Component {
<div className="input">
<span className="text">
{this.props.pusher.version.current}
{' installed'}
{' '}
<span className="mid_grey-text">
{build}
</span>
{this.props.pusher.version.upgrade_available ? (
<span className="flag flag--dark">
<Icon name="cloud_download" className="blue-text" />
@ -444,7 +386,7 @@ class Settings extends React.Component {
<Icon name="check" className="green-text" />
{' Up-to-date'}
</span>
)}
)}
</span>
</div>
</div>

View File

@ -71,6 +71,16 @@ class LibraryBrowse extends React.Component {
subdirectory.icons = ['/iris/assets/backgrounds/browse-somafm.jpg'];
break;
case 'Tidal':
subdirectory.icons = ['/iris/assets/backgrounds/browse-tidal.jpg'];
break;
case 'Google':
case 'Google Play':
case 'Google Play Music':
subdirectory.icons = ['/iris/assets/backgrounds/browse-google.jpg'];
break;
default:
subdirectory.icons = ['/iris/assets/backgrounds/browse-default.jpg'];
}

View File

@ -11,6 +11,7 @@ import DropdownField from '../../components/Fields/DropdownField';
import Icon from '../../components/Icon';
import URILink from '../../components/URILink';
import ErrorBoundary from '../../components/ErrorBoundary';
import LazyLoadListener from '../../components/LazyLoadListener';
import * as uiActions from '../../services/ui/actions';
import * as mopidyActions from '../../services/mopidy/actions';
import * as spotifyActions from '../../services/spotify/actions';
@ -20,7 +21,25 @@ import {
import { arrayOf, sortItems } from '../../util/arrays';
class LibraryBrowseDirectory extends React.Component {
constructor(props) {
super(props);
this.state = {
filter: '',
limit: 50,
per_page: 50,
};
}
componentDidMount() {
// Restore any limit defined in our location state
const state = (this.props.location.state ? this.props.location.state : {});
if (state.limit) {
this.setState({
limit: state.limit,
});
}
this.props.uiActions.setWindowTitle('Browse');
this.loadDirectory();
}
@ -48,6 +67,17 @@ class LibraryBrowseDirectory extends React.Component {
}
}
loadMore() {
const new_limit = this.state.limit + this.state.per_page;
this.setState({ limit: new_limit });
// Set our pagination to location state
const state = (this.props.location && this.props.location.state ? this.props.location.state : {});
state.limit = new_limit;
this.props.history.replace({ state });
}
playAll(e, tracks) {
const tracks_uris = arrayOf('uri', tracks);
this.props.mopidyActions.playURIs(tracks_uris, `iris:browse:${this.props.uri}`);
@ -98,7 +128,7 @@ class LibraryBrowseDirectory extends React.Component {
}
renderSubdirectories(subdirectories) {
if (this.props.view == 'list') {
if (this.props.view === 'list') {
return (
<List
nocontext
@ -143,12 +173,20 @@ class LibraryBrowseDirectory extends React.Component {
);
}
let tracks = (this.props.directory.tracks && this.props.directory.tracks.length > 0 ? this.props.directory.tracks : null);
tracks = sortItems(tracks, 'name');
let subdirectories = (this.props.directory.subdirectories && this.props.directory.subdirectories.length > 0 ? this.props.directory.subdirectories : null);
subdirectories = sortItems(subdirectories, 'name');
const total_items = (tracks ? tracks.length : 0) + (subdirectories ? subdirectories.length : 0);
subdirectories = subdirectories.slice(0, this.state.limit);
let all_tracks = null;
let tracks = null;
let limit_remaining = this.state.limit - subdirectories;
if (limit_remaining > 0) {
all_tracks = (this.props.directory.tracks && this.props.directory.tracks.length > 0 ? this.props.directory.tracks : null);
all_tracks = sortItems(all_tracks, 'name');
tracks = all_tracks.slice(0, limit_remaining);
}
const view_options = [
{
label: 'Thumbnails',
@ -171,7 +209,7 @@ class LibraryBrowseDirectory extends React.Component {
handleChange={(value) => { this.props.uiActions.set({ library_directory_view: value }); this.props.uiActions.hideContextMenu(); }}
/>
{tracks && (
<a className="button button--no-hover" onClick={(e) => { this.props.uiActions.hideContextMenu(); this.playAll(e, tracks); }}>
<a className="button button--no-hover" onClick={(e) => { this.props.uiActions.hideContextMenu(); this.playAll(e, all_tracks); }}>
<Icon name="play_circle_filled" />
Play all
</a>
@ -198,12 +236,18 @@ class LibraryBrowseDirectory extends React.Component {
{tracks && (
<TrackList
tracks={this.props.directory.tracks}
tracks={tracks}
uri={`iris:browse:${this.props.uri}`}
className="library-local-track-list"
/>
)}
<LazyLoadListener
loadKey={total_items > this.state.limit ? this.state.limit : total_items}
showLoader={this.state.limit < total_items}
loadMore={() => this.loadMore()}
/>
</ErrorBoundary>
</section>
</div>

View File

@ -169,8 +169,12 @@ class KioskMode extends React.Component {
}
const extraControls = (
<div className="control" onClick={this.toggleLyrics} style={show_lyrics ? { opacity: 1 } : {}}>
<Icon name="queue_music" className={show_lyrics ? 'turquoise-text' : null} />
<div className="control" onClick={this.toggleLyrics}>
{show_lyrics ? <Icon name="toggle_on" className="turquoise-text" />
: <Icon name="toggle_off" />}
<div style={{ paddingLeft: '6px', fontWeight: 'bold' }}>
Lyrics
</div>
</div>
);

View File

@ -24,12 +24,12 @@ class Modal extends React.Component {
<div className={`modal ${className}`}>
<div className="controls">
{extraControls}
{!noclose && (
<div className="control close" onClick={(e) => window.history.back()}>
<Icon name="close" className="white" />
</div>
)}
{extraControls}
</div>
<div className="content">

View File

@ -27,9 +27,9 @@
@import 'components/filter-field';
@import 'components/sources-priority-field';
@import 'components/sub-views';
@import 'components/sub-tabs';
@import 'components/debug';
@import 'components/tooltip';
@import 'components/services';
@import 'components/snapcast';
@import 'components/popularity';
@import 'components/colour-field';

View File

@ -4,152 +4,206 @@
bottom: 0;
right: 0;
left: 0;
height: 60px;
transform-style: preserve-3d;
border-left: 230px solid transparent;
display: flex;
flex-flow: row;
align-items: center;
align-content: center;
&__background {
height: 100%;
width: 100%;
position: absolute;
z-index: -1;
}
.dark-theme & {
background: colour(grey);
color: colour(white);
box-shadow: 0 -1px 10px rgba(0, 0, 0, 0.6);
border-color: colour(grey);
&__background {
background: colour(grey);
box-shadow: 0 -1px 10px rgba(0, 0, 0, 0.6);
}
}
.light-theme & {
background: colour(white);
color: colour(darkest_grey);
border-color: colour(white);
box-shadow: 0 -1px 8px rgba(0, 0, 0, 0.1);
&__background {
background: colour(white);
box-shadow: 0 -1px 8px rgba(0, 0, 0, 0.1);
}
}
.current-track {
position: absolute;
bottom: 0;
.current-track__wrapper {
width: 210px;
left: -230px;
padding-right: 20px;
z-index: 1;
text-decoration: none;
@keyframes slide_left {
0% {
transform: translateX(0px);
transform: translateX(0);
opacity: 1;
}
100% {
transform: translateX(-15px);
transform: translateX(-15vw);
opacity: 0;
}
}
@keyframes slide_right {
0% {
transform: translateX(0px);
transform: translateX(0);
opacity: 1;
}
100% {
transform: translateX(15px);
transform: translateX(15vw);
opacity: 0;
}
}
&__transition {
z-index: 2;
background: colour(grey);
.light-theme & {
background: lighten(colour(white), 15%);
}
.text {
@include animate(0.15s);
@keyframes slide_from_left {
0% {
transform: translateX(15vw);
opacity: 0;
}
100% {
transform: translateX(0);
opacity: 1;
}
}
@include responsive($bp_medium) {
animation-duration: 0.25s;
animation-timing-function: ease-out;
@keyframes slide_from_right {
0% {
transform: translateX(-15vw);
opacity: 0;
}
100% {
transform: translateX(0);
opacity: 1;
}
}
&--previous {
@include responsive($bp_medium) {
.current-track {
&__incoming,
&__outgoing {
animation-duration: .25s;
animation-timing-function: ease-in-out;
transform: translateX(0);
}
}
&[direction="previous"] {
.current-track__incoming {
animation-name: slide_from_right;
}
.current-track__outgoing {
animation-name: slide_right;
}
}
&--next {
&[direction="next"] {
.current-track__incoming {
animation-name: slide_from_left;
}
.current-track__outgoing {
animation-name: slide_left;
}
}
}
.thumbnail {
float: left;
cursor: pointer;
width: 44px;
margin: 8px;
margin-right: 12px;
.current-track {
width: 100%;
display: flex;
position: relative;
align-items: center;
&__image {
background-color: colour(middle_grey);
}
&:before {
@include animate();
display: block;
content: "\f065";
font-family: "FontAwesome";
position: absolute;
top: 50%;
left: 0;
bottom: 0;
right: 0;
margin-top: -9px;
text-align: center;
font-size: 18px;
color: colour(white);
z-index: 3;
opacity: 0;
}
&:after {
@include animate();
content: "";
display: block;
background: rgba(0, 0, 0, 0.75);
&__outgoing {
z-index: 2;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
border-radius: 3px;
opacity: 0;
z-index: 2;
}
bottom: 0;
left: 0;
&:hover {
&:after,
&:before {
opacity: 1;
.text {
@include animate(0.15s);
opacity: 0;
}
}
}
.text {
opacity: 1;
line-height: 1.1rem;
font-size: 0.9rem;
padding-top: 1.05rem;
.thumbnail {
cursor: pointer;
width: 44px;
margin: 8px;
margin-right: 12px;
.title {
@include one_line_text;
&__image {
background-color: colour(middle_grey);
}
&:before {
@include animate();
display: block;
content: "\f065";
font-family: "FontAwesome";
position: absolute;
top: 50%;
left: 0;
bottom: 0;
right: 0;
margin-top: -9px;
text-align: center;
font-size: 18px;
color: colour(white);
z-index: 3;
opacity: 0;
}
&:after {
@include animate();
content: "";
display: block;
background: rgba(0, 0, 0, 0.75);
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
border-radius: 3px;
opacity: 0;
z-index: 2;
}
&:hover {
&:after,
&:before {
opacity: 1;
}
}
}
.links-sentence {
opacity: 0.5;
display: block;
@include one_line_text;
.text {
opacity: 1;
line-height: 1.1rem;
font-size: 0.9rem;
min-width: 0;
&__item {
color: inherit;
text-decoration: none;
border-bottom: 1px dotted transparent;
.title {
@include one_line_text;
}
.links-sentence {
opacity: 0.5;
display: block;
@include one_line_text;
&__item {
color: inherit;
text-decoration: none;
border-bottom: 1px dotted transparent;
}
}
}
}
@ -186,25 +240,17 @@
}
section {
position: absolute;
bottom: 10px;
&.playback {
bottom: 7px;
left: 0;
width: 10rem;
display: flex;
align-items: center;
.control {
padding-left: 4px;
padding-right: 4px;
padding: 4px;
.icon {
font-size: 1.3rem;
}
&.play {
margin-bottom: -0.8rem;
.light-theme & {
color: colour(turquoise);
}
@ -217,18 +263,12 @@
}
&.progress {
bottom: 13px;
left: 10rem;
right: 25rem;
padding-left: 20px;
padding-right: 20px;
flex-grow: 1;
display: flex;
align-items: center;
.slider {
position: absolute;
bottom: 2px;
left: 60px;
right: 60px;
flex-grow: 1;
&__track {
&__progress {
@include animate(0.2s, linear);
@ -236,40 +276,35 @@
}
}
.current {
.time {
font-size: 11px;
position: absolute;
bottom: 6px;
left: 0;
width: 50px;
text-align: right;
}
width: 40px;
.total {
font-size: 11px;
position: absolute;
bottom: 6px;
right: 0;
width: 50px;
&--current {
text-align: right;
padding-right: 10px;
}
&--total {
padding-left: 10px;
}
}
}
&.settings {
right: 14rem;
width: 10rem;
text-align: center;
padding: 0 30px;
}
&.volume {
right: 2rem;
width: 10rem;
font-size: 18px;
width: 10rem;
padding-right: 30px;
display: flex;
align-items: center;
.slider__wrapper {
position: absolute;
bottom: 6px;
right: 0;
left: 30px;
flex-grow: 1;
}
}
@ -279,67 +314,63 @@
}
@include responsive($bp_medium) {
border-left: none;
&--touch-enabled .control.next {
display: none;
}
.current-track {
width: auto;
bottom: 0.65rem;
left: 80px;
right: 80px;
.current-track__wrapper {
order: 2;
flex-grow: 1;
text-align: center;
font-size: 0.9rem;
line-height: 1.2em;
margin: auto;
margin: 0;
padding: 0;
display: flex;
.title,
.artist {
padding-top: 0;
.text {
padding: 0;
flex-grow: 1;
.title,
.artist {
padding-top: 0;
}
}
.thumbnail {
.thumbnail-wrapper {
display: none;
}
}
section {
&.playback {
left: 0;
order: 1;
width: 6rem;
.control {
&.play {
margin-bottom: -1rem;
}
&.previous {
display: none;
padding: 4px;
}
&.previous,
&.stop {
display: none;
}
}
}
&.settings {
display: none;
}
&.volume {
display: none;
}
&.settings,
&.volume,
&.progress {
display: none;
}
&.triggers {
display: block;
right: 10px;
width: 6rem;
order: 3;
text-align: center;
.icon {
font-size: 1.2rem;
@ -372,12 +403,16 @@
}
&--expanded {
height: 140px;
max-height: 100%;
.playback-controls__background {
height: 230px;
}
section {
&.settings {
display: block;
position: absolute;
display: flex;
right: 0;
bottom: 90px;
padding-right: 10px;
@ -386,8 +421,9 @@
}
&.volume {
position: absolute;
width: auto;
display: block;
display: flex;
right: 100px;
left: 10px;
bottom: 88px;
@ -402,10 +438,11 @@
}
&.progress {
display: block;
bottom: 55px;
left: -15px;
right: -15px;
position: absolute;
display: flex;
bottom: 60px;
left: 0;
right: 0;
}
}
}

View File

@ -18,7 +18,7 @@
width: 1px;
border: 0;
}
&::-moz-range-thumb {
width: 1px;
border: 0;
@ -45,7 +45,7 @@
background: rgba(0,0,0,0.2);
border-radius: 2px;
height: 4px;
top: 42%;
top: 50%;
right: 0;
left: 0;
margin-top: -3px;

View File

@ -1,10 +1,10 @@
.services {
.sub-tabs {
.menu {
&__menu {
@include clearfix();
&__inner {
.menu__inner {
display: flex;
margin: 0 0 0 -5px;
}
@ -18,6 +18,7 @@
display: block;
box-sizing: border-box;
border: none !important;
cursor: pointer;
&__inner {
position: relative;
@ -89,7 +90,7 @@
}
}
.service {
&__content {
padding: 20px;
border-radius: 0 0 3px 3px;
background: lighten(colour(dark_grey), 4%);
@ -107,7 +108,7 @@
margin-left: -20px;
margin-right: -20px;
.menu {
&__menu {
overflow-x: auto;
&::-webkit-scrollbar{
@ -135,7 +136,7 @@
}
}
.service {
&__content {
margin-top: 0;
}
}

View File

@ -93,7 +93,7 @@ main {
position: absolute;
top: 0;
left: 0;
bottom: 60px;
bottom: 50px;
right: 0;
padding-left: 220px;
perspective: 1000px;

View File

@ -398,9 +398,9 @@ select {
}
@include responsive($bp_medium) {
padding-left: 30px;
padding-right: 30px;
padding-left: 28px;
padding-right: 28px;
&--large {
width: 100%;
display: block;

View File

@ -22,11 +22,13 @@
top: 10px;
right: 10px;
z-index: 99;
display: flex;
.control {
@include noselect();
@include animate();
display: block;
float: right;
display: flex;
align-items: center;
opacity: 0.5;
cursor: pointer;
padding: 20px;