diff --git a/package-lock.json b/package-lock.json
index 2fc86ec2..393ee019 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -48,6 +48,7 @@
"react-redux": "^8.1.2",
"react-router-dom": "^6.15.0",
"safe-stable-stringify": "^1.1.1",
+ "sass": "^1.66.1",
"spotify-web-api-node": "^5.0.2",
"superagent": "^8.0.9",
"tailwindcss": "^3.3.3",
@@ -11896,6 +11897,11 @@
"url": "https://opencollective.com/immer"
}
},
+ "node_modules/immutable": {
+ "version": "4.3.4",
+ "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.4.tgz",
+ "integrity": "sha512-fsXeu4J4i6WNWSikpI88v/PcVflZz+6kMhUfIwc5SY+poQRPnaf5V7qds6SUyUN3cVxEzuCab7QIoLOQ+DQ1wA=="
+ },
"node_modules/import-fresh": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
@@ -20793,6 +20799,22 @@
"integrity": "sha512-ZRwKbh/eQ6w9vmTjkuG0Ioi3HBwPFce0O+v//ve+aOq1oeCy7jMV2qzzAlpsNuqpqCBjjriM1lbtZbF/Q8jVyA==",
"dev": true
},
+ "node_modules/sass": {
+ "version": "1.66.1",
+ "resolved": "https://registry.npmjs.org/sass/-/sass-1.66.1.tgz",
+ "integrity": "sha512-50c+zTsZOJVgFfTgwwEzkjA3/QACgdNsKueWPyAR0mRINIvLAStVQBbPg14iuqEQ74NPDbXzJARJ/O4SI1zftA==",
+ "dependencies": {
+ "chokidar": ">=3.0.0 <4.0.0",
+ "immutable": "^4.0.0",
+ "source-map-js": ">=0.6.2 <2.0.0"
+ },
+ "bin": {
+ "sass": "sass.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/sass-loader": {
"version": "12.6.0",
"resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-12.6.0.tgz",
diff --git a/package.json b/package.json
index d2724f1b..1eca3922 100644
--- a/package.json
+++ b/package.json
@@ -85,6 +85,7 @@
"react-redux": "^8.1.2",
"react-router-dom": "^6.15.0",
"safe-stable-stringify": "^1.1.1",
+ "sass": "^1.66.1",
"spotify-web-api-node": "^5.0.2",
"superagent": "^8.0.9",
"tailwindcss": "^3.3.3",
diff --git a/src/client/components/player/Player.tsx b/src/client/components/player/Player.tsx
new file mode 100644
index 00000000..a29fa4df
--- /dev/null
+++ b/src/client/components/player/Player.tsx
@@ -0,0 +1,340 @@
+import React, {useState, useCallback} from 'react';
+import './player.scss';
+import PlayerTimestamp from "./PlayerTimestamp";
+import PlayerControls from "./PlayerControls";
+
+export interface PlayerProps {
+ tracks: Track[]
+
+}
+
+export interface Track {
+ name: string
+ artist: string
+ album: string
+ year: number
+ duration: number
+ artwork: string
+}
+
+export const sampleTracks: Track[] = [
+ {
+ name: "Somebody Hates Me",
+ artist: "Reel Big Fish",
+ album: "Why Do They Rock So Hard?",
+ year: 1998,
+ duration: 210,
+ artwork: "https://is5-ssl.mzstatic.com/image/thumb/Music/59/db/b5/mzi.nloomwwj.jpg/268x0w.jpg"
+ },
+ {
+ name: "Beer",
+ artist: "Reel Big Fish",
+ album: "We're Not Happy Til You're Not Happy",
+ year: 2005,
+ duration: 233,
+ artwork: "https://upload.wikimedia.org/wikipedia/en/9/9d/Reel_Big_Fish_-_We%27re_Not_Happy_%27til_You%27re_Not_Happy_cover.jpg"
+ },
+ {
+ name: "We Will Fall Together",
+ artist: "Streetlight Manifesto",
+ album: "Somewhere In The Between",
+ year: 2007,
+ duration: 289,
+ artwork: "https://upload.wikimedia.org/wikipedia/en/9/95/Streetlight_Manifesto_-_Somewhere_in_the_Between.jpg"
+ },
+ {
+ name: "Welcome To The Jungle",
+ artist: "Guns N' Roses",
+ album: "Appetite For Destruction",
+ year: 1987,
+ duration: 277,
+ artwork: "https://upload.wikimedia.org/wikipedia/en/6/60/GunsnRosesAppetiteforDestructionalbumcover.jpg"
+ },
+ {
+ name: "Estranged",
+ artist: "Guns N' Roses",
+ album: "Use Your Illusion II",
+ year: 1991,
+ duration: 563,
+ artwork: "https://www.lifeofvinyl.com/images/super/0720642442012.jpg"
+ },
+ {
+ name: "Cheer Up!",
+ artist: "Reel Big Fish",
+ album: "Cheer Up!",
+ year: 2002,
+ duration: 163,
+ artwork: "https://upload.wikimedia.org/wikipedia/en/f/f9/Reel_Big_Fish_-_Cheer_Up%21_cover.jpg"
+ }
+];
+
+const Player = (props: PlayerProps) => {
+ const [playStatus, setPlayStatus] = useState(false);
+ const [currentTrack, setCurrentTrack] = useState(0);
+ const [nextTrack, setNextTrack] = useState(1);
+ const [prevTrack, setPrevTrack] = useState(props.tracks.length - 1);
+ const [currentTime, setCurrentTime] = useState(0);
+ const [sliding, setSliding] = useState(0);
+ const [viewMode, setViewMode] = useState('player');
+
+ const toggleViewMode = useCallback(() => {
+ let newViewMode = "";
+ switch(viewMode) {
+ case "player":
+ newViewMode = "playlist";
+ break;
+ case "playlist":
+ newViewMode = "player"
+ break;
+ }
+ setViewMode(newViewMode);
+ }, [viewMode, setViewMode]);
+
+ const togglePlay = useCallback(() => {},[playStatus, setPlayStatus]);
+ const changeTrack = useCallback((track) => {}, [props.tracks, nextTrack, prevTrack, setCurrentTrack, setSliding, setCurrentTrack, setNextTrack, setPrevTrack]);
+ const toggleNextTrack = useCallback(() => {}, [nextTrack, changeTrack]);
+ const togglePrevTrack = useCallback(() => {}, [prevTrack, changeTrack]);
+ const setTime = useCallback((time) => {}, [props.tracks, currentTrack, setNextTrack, setCurrentTime]);
+
+ const aPrevTrack = props.tracks[prevTrack];
+ const aCurrentTrack = props.tracks[currentTrack];
+ const aNextTrack = props.tracks[nextTrack];
+
+ let slidingStatus = "";
+ switch (sliding) {
+ case -1:
+ slidingStatus = "is-sliding-prev";
+ break;
+ case 1:
+ slidingStatus = "is-sliding-next";
+ break;
+ }
+
+ let playlistIcon = "fa fa-fw ";
+ if (viewMode == "playlist") {
+ playlistIcon += "fa-times";
+ } else {
+ playlistIcon += "fa-bars";
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {/**/}
+
+ {/**/}
+
+
+ {aCurrentTrack.name}
+ {aCurrentTrack.artist}
+
+
+ {/* {}} />*/}
+
+
+ );
+}
+
+export default Player;
+
+/*
+export class PlayerC extends React.Component {
+ static get defaultProps() {
+ return {
+
+ }
+ }
+
+ constructor(props, defaultProps) {
+ super(props, defaultProps);
+
+ this.state = {
+ playStatus: 0,
+ currentTrack: 0,
+ nextTrack: 1,
+ prevTrack: this.props.tracks.length-1,
+ currentTime: 0,
+ sliding: 0,
+ likedTracks: [],
+ viewMode: "player"
+ }
+
+ this.togglePlay = this.togglePlay.bind(this);
+ this.toggleViewMode = this.toggleViewMode.bind(this);
+ this.nextTrack = this.nextTrack.bind(this);
+ this.prevTrack = this.prevTrack.bind(this);
+ this.likeTrack = this.likeTrack.bind(this);
+ }
+
+ toggleViewMode() {
+ let viewMode = "";
+ switch(this.state.viewMode) {
+ case "player":
+ viewMode = "playlist";
+ break;
+ case "playlist":
+ viewMode = "player"
+ break;
+ }
+
+ this.setState({
+ viewMode: viewMode
+ });
+ }
+
+ togglePlay() {
+ let status = this.state.playStatus;
+ if (status == 0) {
+ // Play
+ status = 1;
+ this.timer = setInterval(() => {
+ this.setTime(this.state.currentTime + 1);
+ }, 1000);
+ } else {
+ // Pause
+ status = 0;
+ clearInterval(this.timer);
+ }
+ this.setState({ playStatus: status });
+ }
+
+ changeTrack(track, dir) {
+ if (this.state.sliding == 0) {
+ let this_track = track;
+ let next_track = track + 1;
+ let prev_track = track - 1;
+
+ if (next_track >= this.props.tracks.length) next_track = 0;
+ if (prev_track < 0) prev_track = this.props.tracks.length - 1;
+
+ this.setState({
+ sliding: dir
+ });
+
+ setTimeout(() => {
+ this.setState({
+ sliding: 0,
+ currentTrack: this_track,
+ nextTrack: next_track,
+ prevTrack: prev_track,
+ currentTime: 0
+ });
+ }, 500);
+ }
+ }
+
+ nextTrack() {
+ this.changeTrack(this.state.nextTrack, 1);
+ }
+
+ prevTrack() {
+ if (this.state.currentTime < 2) {
+ this.changeTrack(this.state.prevTrack, -1);
+ } else {
+ this.setState({
+ currentTime: 0
+ });
+ }
+ }
+
+ likeTrack() {
+ let likedTracks = this.state.likedTracks;
+ let found = false;
+ for (let i = 0; i < this.state.likedTracks.length; i++) {
+ if (this.state.likedTracks[i] == this.state.currentTrack) {
+ found = true;
+ likedTracks.splice(i, 1);
+ break;
+ }
+ }
+
+ if (!found) {
+ likedTracks.push(this.state.currentTrack);
+ }
+
+ this.setState({
+ likedTracks: likedTracks
+ });
+ }
+
+ setTime(time) {
+ time = Math.floor(time);
+ if (time > this.props.tracks[this.state.currentTrack].duration) {
+ this.nextTrack();
+ } else {
+ this.setState({ currentTime: time });
+ }
+ }
+
+ render() {
+ const prevTrack = this.props.tracks[this.state.prevTrack];
+ const currentTrack = this.props.tracks[this.state.currentTrack];
+ const nextTrack = this.props.tracks[this.state.nextTrack];
+
+ let sliding = "";
+ switch (this.state.sliding) {
+ case -1:
+ sliding = "is-sliding-prev";
+ break;
+ case 1:
+ sliding = "is-sliding-next";
+ break;
+ }
+
+ let playlistIcon = "fa fa-fw ";
+ if (this.state.viewMode == "playlist") {
+ playlistIcon += "fa-times";
+ } else {
+ playlistIcon += "fa-bars";
+ }
+
+ let isLiked = false;
+ for (let i = 0; i < this.state.likedTracks.length; i++) {
+ if (this.state.likedTracks[i] == this.state.currentTrack) {
+ isLiked = true;
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+ {currentTrack.name}
+ {currentTrack.artist}
+
+
+
+
+
+ );
+ }
+}
+*/
diff --git a/src/client/components/player/PlayerControls.tsx b/src/client/components/player/PlayerControls.tsx
new file mode 100644
index 00000000..caff1a33
--- /dev/null
+++ b/src/client/components/player/PlayerControls.tsx
@@ -0,0 +1,111 @@
+import React from "react";
+import './controls.scss';
+
+export interface ControlsProps {
+ isPlaying: boolean
+ isLiked: boolean
+ prevTrack: () => any
+ togglePlay: () => any
+ nextTrack: () => any
+ likeTrack: () => any
+}
+
+const Controls = (props: ControlsProps) => {
+ let playClass = "fa fa-fw ";
+ if (!props.isPlaying) {
+ playClass += "fa-play";
+ } else {
+ playClass += "fa-pause";
+ }
+
+ let likeClass = "button like ";
+ let heartClass = "fa fa-fw ";
+ if (!props.isLiked) {
+ heartClass += "fa-heart-o";
+ } else {
+ likeClass += "is-liked";
+ heartClass += "fa-heart";
+ }
+
+ return(
+
+
+
+
+
+
+
+ );
+}
+
+/*export class ControlsC extends React.Component {
+ render() {
+ let playClass = "fa fa-fw ";
+ if (!this.props.isPlaying) {
+ playClass += "fa-play";
+ } else {
+ playClass += "fa-pause";
+ }
+
+ let likeClass = "button like ";
+ let heartClass = "fa fa-fw ";
+ if (!this.props.isLiked) {
+ heartClass += "fa-heart-o";
+ } else {
+ likeClass += "is-liked";
+ heartClass += "fa-heart";
+ }
+
+ return(
+
+
+
+
+
+
+
+ );
+ }
+}*/
+
+export default Controls;
diff --git a/src/client/components/player/PlayerTimestamp.tsx b/src/client/components/player/PlayerTimestamp.tsx
new file mode 100644
index 00000000..11ec7c7f
--- /dev/null
+++ b/src/client/components/player/PlayerTimestamp.tsx
@@ -0,0 +1,62 @@
+import React from "react";
+import './timestamp.scss';
+
+export interface TimestampProps {
+ current: number
+ duration: number
+}
+
+const convertTime = (rawTime: number) => {
+ let mins = Math.floor(rawTime / 60);
+ let seconds = rawTime - (mins * 60);
+ let secStr: string = seconds.toString();
+ if (seconds < 10) {
+ secStr = "0" + seconds;
+ }
+ return mins + ":" + secStr;
+}
+
+const Timestamp = (props: TimestampProps) => {
+ return(
+
+
+ {convertTime(props.current)}
+
+
+
+ {convertTime(props.duration - props.current)}
+
+
+ );
+}
+/*export class TimestampC extends React.Component {
+ convertTime(time) {
+ let mins = Math.floor(time / 60);
+ let seconds = time - (mins * 60);
+ if (seconds < 10) {
+ seconds = "0" + seconds;
+ }
+ time = mins + ":" + seconds;
+ return time;
+ }
+
+ render() {
+ return(
+
+
+ {this.convertTime(this.props.current)}
+
+
+
+ {this.convertTime(this.props.duration - this.props.current)}
+
+
+ );
+ }
+}*/
+
+export default Timestamp;
diff --git a/src/client/components/player/Playlist.tsx b/src/client/components/player/Playlist.tsx
new file mode 100644
index 00000000..3a929cee
--- /dev/null
+++ b/src/client/components/player/Playlist.tsx
@@ -0,0 +1,33 @@
+import React from 'react';
+/*export class Playlist extends React.Component {
+ render() {
+ const tracks = this.props.tracks;
+
+ let isHidden = "";
+ if (!this.props.isVisible) {
+ isHidden = "is-hidden";
+ }
+
+ return(
+
+ {tracks.map((track, i) => {
+ return(
+ - this.props.changeTrack(i)}>
+
+
+
+
+
+
+
+
{track.name}
+
{track.artist}
+
+
+
+ )
+ })}
+
+ );
+ }
+}*/
diff --git a/src/client/components/player/README.md b/src/client/components/player/README.md
new file mode 100644
index 00000000..138bd96d
--- /dev/null
+++ b/src/client/components/player/README.md
@@ -0,0 +1 @@
+Player designed based on [React Music Player UI](https://codepen.io/keithpickering/pen/wjYJKM) by [Keith Pickering](https://keithpickering.github.io/)
diff --git a/src/client/components/player/controls.scss b/src/client/components/player/controls.scss
new file mode 100644
index 00000000..57759951
--- /dev/null
+++ b/src/client/components/player/controls.scss
@@ -0,0 +1,40 @@
+@use './playerBase.scss';
+$spacing: 1.5rem;
+$primary: #00ACC1;
+
+
+.controls {
+ display: flex;
+ justify-content: space-around;
+ align-items: center;
+ height: $spacing*2.5;
+ text-align: center;
+ background: $primary;
+
+ .button {
+ height: 100%;
+ width: $spacing*2.5;
+ border: 0;
+ border-radius: 0;
+ transition: background 250ms;
+ box-shadow: none !important;
+ background: transparent;
+ color: white;
+
+ &:hover {
+ background: lighten($primary, 4%);
+ }
+
+ &.play {
+ font-size: 1.6rem;
+ }
+
+ &.like.is-liked {
+ background: #F06292;
+ }
+
+ .icon {
+ //transition: transform 150ms;
+ }
+ }
+}
diff --git a/src/client/components/player/player.scss b/src/client/components/player/player.scss
new file mode 100644
index 00000000..be8d39a7
--- /dev/null
+++ b/src/client/components/player/player.scss
@@ -0,0 +1,113 @@
+@use './playerBase.scss';
+$spacing: 1.5rem;
+$primary: #00ACC1;
+
+
+.player {
+ color: black;
+ overflow: hidden;
+ max-width: 90%;
+ max-height: 90vh;
+ //min-height: 487px;
+ border-radius: 5px;
+ background: white;
+ box-shadow: 0 10px 20px rgba(0,0,0,0.19), 0 6px 6px rgba(0,0,0,0.23);
+
+ &__bg {
+ position: fixed;
+ top: -40px;
+ right: -40px;
+ bottom: -40px;
+ left: -40px;
+ background-size: cover;
+ background-position: center;
+ filter: blur(20px);
+ z-index: -2;
+
+ &:after {
+ content: "";
+ position: absolute;
+ top: -40px;
+ right: -40px;
+ bottom: -40px;
+ left: -40px;
+ background: rgba(#263238, 0.8);
+ transition: background 1s;
+
+ [class*="is-sliding"] & {
+ background: rgba(#263238, 1);
+ }
+ }
+
+ &.prev,
+ &.next {
+ opacity: 0;
+ z-index: -1;
+ }
+ }
+
+ &__head {
+ padding: $spacing/2 $spacing;
+ }
+
+ &__art {
+ position: relative;
+ padding-bottom: 100%;
+ width: 100%;
+ border-bottom: 1px solid #ECEFF1;
+ background: darken(#263238, 10%);
+
+ .is-sliding-next & {
+ transform: translate(-100%, 0);
+ transition: transform 500ms ease-in-out;
+ }
+
+ .is-sliding-prev & {
+ transform: translate(100%, 0);
+ transition: transform 500ms ease-in-out;
+ }
+
+ > img {
+ position: absolute;
+ top: 0;
+ left: 0;
+ display: block;
+ width: 100%;
+ margin: 0 auto;
+
+ &.prev {
+ left: -100%;
+ }
+
+ &.next {
+ left: 100%;
+ }
+ }
+ }
+
+ &__body {
+ text-align: center;
+ padding: $spacing;
+ transition: all 250ms;
+
+ [class*="is-sliding"] & {
+ opacity: 0;
+ transform: scale(0.95);
+ }
+
+ .title {
+ position: relative;
+ width: 100%;
+ font-size: 1.2rem;
+ white-space: nowrap;
+ }
+
+ .subtitle {
+ font-size: 0.75rem;
+ color: #90A4AE;
+ padding-top: $spacing/4;
+ margin-bottom: $spacing;
+ text-transform: uppercase;
+ }
+ }
+}
diff --git a/src/client/components/player/playerBase.scss b/src/client/components/player/playerBase.scss
new file mode 100644
index 00000000..a0919afa
--- /dev/null
+++ b/src/client/components/player/playerBase.scss
@@ -0,0 +1,10 @@
+$spacing: 1.5rem;
+$primary: #00ACC1;
+
+.button {
+ &:active {
+ > .icon {
+ transform: scale(0.85);
+ }
+ }
+}
diff --git a/src/client/components/player/playlist.scss b/src/client/components/player/playlist.scss
new file mode 100644
index 00000000..8360194b
--- /dev/null
+++ b/src/client/components/player/playlist.scss
@@ -0,0 +1,62 @@
+$spacing: 1.5rem;
+$primary: #00ACC1;
+
+
+.playlist {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ padding: $spacing;
+ padding-right: $spacing*2;
+ overflow: auto;
+ background: #263238;
+
+ &__item.media {
+ align-items: center;
+ border-top: 2px solid #37474F;
+
+ &:first-child {
+ border-top: 0;
+ }
+
+ .title,
+ .subtitle {
+ white-space: nowrap;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .title {
+ font-size: 0.85rem;
+ color: #B0BEC5;
+ }
+
+ .subtitle {
+ margin-top: -$spacing/2;
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ color: #607D8B
+ }
+ }
+}
+
+.toggle-playlist {
+ position: absolute;
+ top: $spacing/2;
+ right: $spacing/2;
+ border: 0;
+ outline: 0;
+ background: transparent;
+ text-shadow: rgba(black, 0.75) 0 0 5px;
+ box-shadow: none !important;
+ color: white !important;
+ z-index: 1;
+ transition: all 500ms;
+
+ [class*="is-sliding"] & {
+ opacity: 0;
+ }
+}
diff --git a/src/client/components/player/timestamp.scss b/src/client/components/player/timestamp.scss
new file mode 100644
index 00000000..fe055ebd
--- /dev/null
+++ b/src/client/components/player/timestamp.scss
@@ -0,0 +1,46 @@
+@use './playerBase.scss';
+$spacing: 1.5rem;
+$primary: #00ACC1;
+
+
+.timestamp {
+ position: relative;
+ width: 100%;
+ height: $spacing;
+
+ &__progress,
+ &__current,
+ &__total {
+ position: absolute;
+ top: 0;
+ font-size: 0.75rem;
+ }
+
+ &__progress {
+ position: relative;
+ top: $spacing/4;
+ left: 50%;
+ height: $spacing/4;
+ width: calc(100% - #{$spacing*3});
+ transform: translate(-50%, 0);
+ border-radius: 5px;
+ overflow: hidden;
+ background: #ECEFF1;
+
+ > div {
+ position: absolute;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ background: $primary;
+ }
+ }
+
+ &__current {
+ left: 0;
+ }
+
+ &__total {
+ right: 0;
+ }
+}