From 9c0f02999272dfcf94a481697c2478cde83abc09 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 8 Sep 2023 13:40:43 -0400 Subject: [PATCH] feat: POC player Based on https://codepen.io/keithpickering/pen/wjYJKM --- package-lock.json | 22 ++ package.json | 1 + src/client/components/player/Player.tsx | 340 ++++++++++++++++++ .../components/player/PlayerControls.tsx | 111 ++++++ .../components/player/PlayerTimestamp.tsx | 62 ++++ src/client/components/player/Playlist.tsx | 33 ++ src/client/components/player/README.md | 1 + src/client/components/player/controls.scss | 40 +++ src/client/components/player/player.scss | 113 ++++++ src/client/components/player/playerBase.scss | 10 + src/client/components/player/playlist.scss | 62 ++++ src/client/components/player/timestamp.scss | 46 +++ 12 files changed, 841 insertions(+) create mode 100644 src/client/components/player/Player.tsx create mode 100644 src/client/components/player/PlayerControls.tsx create mode 100644 src/client/components/player/PlayerTimestamp.tsx create mode 100644 src/client/components/player/Playlist.tsx create mode 100644 src/client/components/player/README.md create mode 100644 src/client/components/player/controls.scss create mode 100644 src/client/components/player/player.scss create mode 100644 src/client/components/player/playerBase.scss create mode 100644 src/client/components/player/playlist.scss create mode 100644 src/client/components/player/timestamp.scss 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( + + ); + } +}*/ 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; + } +} -- 2.51.2