├── src ├── react-app-env.d.ts ├── assets │ ├── hf-logo.png │ ├── logo-dark.svg │ ├── logo-light.svg │ ├── audiodrawing-dark.svg │ └── audiodrawing-light.svg ├── state │ ├── accessToken.tsx │ ├── index.ts │ ├── playbackInformation.tsx │ ├── spotifyAPI.tsx │ ├── userInformation.tsx │ └── roomInformation.tsx ├── setupTests.ts ├── routes │ ├── index.ts │ ├── ChooseSong.tsx │ ├── LoginToSpotify.tsx │ ├── SearchOrShare.tsx │ ├── ShareSong.tsx │ ├── LandingPage.tsx │ ├── Rooms.tsx │ └── Room.tsx ├── components │ ├── SongControlButton.tsx │ ├── LogoButton.tsx │ ├── ToggleColorMode.tsx │ ├── RepeatedBackground.tsx │ ├── ConfirmHomeModal.tsx │ ├── JoinPrivateRoom.tsx │ ├── RepeatedBackgroundLanding.tsx │ ├── RadioOption.tsx │ ├── layout.tsx │ ├── RoomSongDisplay.tsx │ ├── SongControl.tsx │ ├── seo.tsx │ ├── ListenerDisplay.tsx │ ├── RouteToHome.tsx │ ├── SongDisplay.tsx │ └── InstructionFAB.tsx ├── util │ └── getHashParams.js ├── firebase │ ├── destroyRoom.ts │ ├── index.ts │ ├── removeUserFromRoom.ts │ ├── updateRoom.ts │ ├── transferRoomOwnership.ts │ ├── addUserToRoom.ts │ └── createRoom.ts ├── index.tsx ├── logo.svg ├── hooks │ ├── usePlaybackMonitor.tsx │ └── useUserMonitor.tsx ├── App.tsx └── serviceWorker.ts ├── .firebaserc ├── public ├── robots.txt ├── favicon.ico ├── logo192.png ├── logo512.png ├── manifest.json └── index.html ├── database.rules.json ├── functions ├── .gitignore ├── .runtimeconfig.json ├── tsconfig.json ├── src │ ├── util │ │ └── generateRandomString.ts │ └── index.ts ├── package.json └── tslint.json ├── tsconfig.json ├── firebase.json ├── .gitignore ├── package.json └── README.md /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "listen-together-hf" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /database.rules.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | ".read": true, 4 | ".write": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hfellerhoff/listentogether-hackathon/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hfellerhoff/listentogether-hackathon/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hfellerhoff/listentogether-hackathon/HEAD/public/logo512.png -------------------------------------------------------------------------------- /src/assets/hf-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hfellerhoff/listentogether-hackathon/HEAD/src/assets/hf-logo.png -------------------------------------------------------------------------------- /functions/.gitignore: -------------------------------------------------------------------------------- 1 | ## Compiled JavaScript files 2 | **/*.js 3 | **/*.js.map 4 | 5 | # Typescript v1 declaration files 6 | typings/ 7 | 8 | node_modules/ -------------------------------------------------------------------------------- /functions/.runtimeconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "spotify": { 3 | "client_secret": "6ab949abd9354c7d9dc163260023ea6b", 4 | "client_id": "eef10b7a7a2244958f72341568faac74" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/state/accessToken.tsx: -------------------------------------------------------------------------------- 1 | import { atom } from 'recoil'; 2 | 3 | export type AccessToken = string | null; 4 | 5 | export const accessTokenState = atom({ 6 | key: 'accessTokenState', 7 | default: null, 8 | }); 9 | -------------------------------------------------------------------------------- /src/state/index.ts: -------------------------------------------------------------------------------- 1 | export { spotifyApiState } from './spotifyAPI'; 2 | export { accessTokenState } from './accessToken'; 3 | export { playbackInformationState } from './playbackInformation'; 4 | export { userInformationState } from './userInformation'; 5 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /src/state/playbackInformation.tsx: -------------------------------------------------------------------------------- 1 | import { atom } from 'recoil'; 2 | 3 | export type PlaybackInformation = SpotifyApi.CurrentPlaybackResponse | null; 4 | 5 | export const playbackInformationState = atom({ 6 | key: 'playbackInformationState', 7 | default: null, 8 | }); 9 | -------------------------------------------------------------------------------- /src/state/spotifyAPI.tsx: -------------------------------------------------------------------------------- 1 | import { atom } from 'recoil'; 2 | import Spotify from 'spotify-web-api-js'; 3 | 4 | export type SpotifyAPI = Spotify.SpotifyWebApiJs; 5 | 6 | export const spotifyApiState = atom({ 7 | key: 'spotifyApiState', 8 | default: new Spotify(), 9 | }); 10 | -------------------------------------------------------------------------------- /src/routes/index.ts: -------------------------------------------------------------------------------- 1 | export { ShareSong } from './ShareSong'; 2 | export { SearchOrShare } from './SearchOrShare'; 3 | export { ChooseSong } from './ChooseSong'; 4 | export { LoginToSpotify } from './LoginToSpotify'; 5 | export { Room } from './Room'; 6 | export { Rooms } from './Rooms'; 7 | export { LandingPage } from './LandingPage'; 8 | -------------------------------------------------------------------------------- /functions/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "noImplicitReturns": true, 5 | "noUnusedLocals": true, 6 | "outDir": "lib", 7 | "sourceMap": true, 8 | "strict": true, 9 | "target": "es2017" 10 | }, 11 | "compileOnSave": true, 12 | "include": [ 13 | "src" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /src/components/SongControlButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Button, ButtonProps } from '@chakra-ui/core'; 3 | 4 | const SongControlButton: React.FC = (props) => { 5 | return ( 6 | 9 | ); 10 | }; 11 | 12 | export default SongControlButton; 13 | -------------------------------------------------------------------------------- /src/util/getHashParams.js: -------------------------------------------------------------------------------- 1 | const getHashParams = () => { 2 | if (typeof window === undefined) return {}; 3 | var hashParams = {}; 4 | var e, 5 | r = /([^&;=]+)=?([^&;]*)/g, 6 | q = window.location.hash.substring(1); 7 | while ((e = r.exec(q))) { 8 | hashParams[e[1]] = decodeURIComponent(e[2]); 9 | } 10 | return hashParams; 11 | }; 12 | 13 | export default getHashParams; 14 | -------------------------------------------------------------------------------- /functions/src/util/generateRandomString.ts: -------------------------------------------------------------------------------- 1 | const generateRandomString = (length: number) => { 2 | let text = ''; 3 | const possible = 4 | 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 5 | 6 | for (let i = 0; i < length; i++) { 7 | text += possible.charAt(Math.floor(Math.random() * possible.length)); 8 | } 9 | return text; 10 | }; 11 | 12 | export default generateRandomString; 13 | -------------------------------------------------------------------------------- /src/state/userInformation.tsx: -------------------------------------------------------------------------------- 1 | import { atom } from 'recoil'; 2 | 3 | export type UserInformation = { 4 | connected: boolean; 5 | room?: { 6 | id: string; 7 | isOwner: boolean; 8 | }; 9 | details: SpotifyApi.CurrentUsersProfileResponse; 10 | }; 11 | export type UserInformationState = UserInformation | null; 12 | 13 | export const userInformationState = atom({ 14 | key: 'userInformationState', 15 | default: null, 16 | }); 17 | -------------------------------------------------------------------------------- /src/components/LogoButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import logo from '../assets/hf-logo.png'; 3 | import { Image, Link } from '@chakra-ui/core'; 4 | 5 | interface Props {} 6 | 7 | const LogoButton = (props: Props) => { 8 | return ( 9 | 14 | 15 | 16 | ); 17 | }; 18 | 19 | export default LogoButton; 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "react" 17 | }, 18 | "include": ["src"] 19 | } 20 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Listen Together", 3 | "name": "Listen Together", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /src/components/ToggleColorMode.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useColorMode, Button, Icon } from '@chakra-ui/core'; 3 | 4 | interface Props {} 5 | 6 | const ToggleColorMode = (props: Props) => { 7 | const { colorMode, toggleColorMode } = useColorMode(); 8 | 9 | return ( 10 | 20 | ); 21 | }; 22 | 23 | export default ToggleColorMode; 24 | -------------------------------------------------------------------------------- /src/firebase/destroyRoom.ts: -------------------------------------------------------------------------------- 1 | import firebase from '.'; 2 | import { UserInformation } from '../state/userInformation'; 3 | import { RoomInformation } from '../state/roomInformation'; 4 | 5 | const destroyRoom = async (room: RoomInformation, user: UserInformation) => { 6 | if (!room || !user) return; 7 | 8 | try { 9 | firebase 10 | .database() 11 | .ref('rooms/' + room.id) 12 | .remove(); 13 | 14 | firebase 15 | .database() 16 | .ref('users/' + user?.details.id) 17 | .update({ 18 | room: null, 19 | }); 20 | } catch (error) { 21 | console.error(error); 22 | } 23 | }; 24 | 25 | export default destroyRoom; 26 | -------------------------------------------------------------------------------- /src/state/roomInformation.tsx: -------------------------------------------------------------------------------- 1 | import { atom } from 'recoil'; 2 | 3 | export type User = { 4 | id: string; 5 | imageUrl: string; 6 | name: string; 7 | }; 8 | 9 | export interface RoomInformation { 10 | id: string; 11 | isPublic: boolean; 12 | song: { 13 | addedAt: number; 14 | id: string; 15 | progress: number; 16 | uri: string; 17 | isPlaying: boolean; 18 | }; 19 | owner: User; 20 | listeners: { 21 | [id: string]: User; 22 | }; 23 | } 24 | 25 | export type RoomInformationState = RoomInformation | null; 26 | 27 | export const roomInformationState = atom({ 28 | key: 'roomInformationState', 29 | default: null, 30 | }); 31 | -------------------------------------------------------------------------------- /src/firebase/index.ts: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase/app'; 2 | import 'firebase/database'; 3 | import 'firebase/functions'; 4 | import 'firebase/analytics'; 5 | 6 | // Your web app's Firebase configuration 7 | const config = { 8 | apiKey: 'AIzaSyBJN6iQiAzL4K4-rxH6WZJ3KSI1zABRIHM', 9 | authDomain: 'listen-together-hf.firebaseapp.com', 10 | databaseURL: 'https://listen-together-hf.firebaseio.com', 11 | projectId: 'listen-together-hf', 12 | storageBucket: 'listen-together-hf.appspot.com', 13 | messagingSenderId: '825222209044', 14 | appId: '1:825222209044:web:92cf00b67151bfef041101', 15 | measurementId: 'G-XFPTGVP38S', 16 | }; 17 | 18 | firebase.initializeApp(config); 19 | firebase.analytics(); 20 | 21 | export default firebase; 22 | -------------------------------------------------------------------------------- /src/firebase/removeUserFromRoom.ts: -------------------------------------------------------------------------------- 1 | import firebase from '.'; 2 | import { UserInformation } from '../state/userInformation'; 3 | import { RoomInformation } from '../state/roomInformation'; 4 | 5 | const removeUserFromRoom = async ( 6 | room: RoomInformation, 7 | user: UserInformation 8 | ) => { 9 | if (!room || !user) return; 10 | 11 | console.log(`Removing ${user.details.id} from listeners...`); 12 | 13 | try { 14 | firebase 15 | .database() 16 | .ref(`rooms/${room.id}/listeners/${user.details.id}`) 17 | .remove(); 18 | 19 | firebase.database().ref(`users/${user.details.id}/room`).remove(); 20 | } catch (error) { 21 | console.error(error); 22 | } 23 | }; 24 | 25 | export default removeUserFromRoom; 26 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "database": { 3 | "rules": "database.rules.json" 4 | }, 5 | "functions": { 6 | "predeploy": [ 7 | "npm --prefix \"$RESOURCE_DIR\" run lint", 8 | "npm --prefix \"$RESOURCE_DIR\" run build" 9 | ] 10 | }, 11 | "hosting": { 12 | "public": "build", 13 | "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], 14 | "rewrites": [ 15 | { 16 | "source": "**", 17 | "destination": "/index.html" 18 | }, 19 | { 20 | "source": "/", 21 | "function": "app" 22 | }, 23 | { 24 | "source": "/login", 25 | "function": "app" 26 | }, 27 | { 28 | "source": "/callback", 29 | "function": "app" 30 | } 31 | ] 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/routes/ChooseSong.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Stack, Heading, Button, Text } from '@chakra-ui/core'; 3 | import { FaSpotify } from 'react-icons/fa'; 4 | 5 | interface Props { 6 | checkPlayback: () => void; 7 | } 8 | 9 | export const ChooseSong = ({ checkPlayback }: Props) => { 10 | return ( 11 | 12 | STEP 1 13 | Choose Something to Listen To 14 | 15 | Open Spotify on any of your devices and pick a song. Once we detect that 16 | your song is playing, it will show up here. 17 | 18 | 27 | 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /src/firebase/updateRoom.ts: -------------------------------------------------------------------------------- 1 | import firebase from '.'; 2 | import { PlaybackInformation } from '../state/playbackInformation'; 3 | import { RoomInformation } from '../state/roomInformation'; 4 | 5 | const updateRoom = async ( 6 | room: RoomInformation, 7 | songInformation: PlaybackInformation 8 | ) => { 9 | if (!songInformation) return; 10 | 11 | if (songInformation.item) { 12 | try { 13 | firebase 14 | .database() 15 | .ref(`rooms/${room.id}/song`) 16 | .update({ 17 | id: songInformation.item.id, 18 | addedAt: Date.now(), 19 | progress: songInformation.progress_ms 20 | ? songInformation.progress_ms 21 | : 0, 22 | uri: songInformation.item.uri, 23 | isPlaying: songInformation.is_playing, 24 | }); 25 | // console.log('Successfully updated room.'); 26 | } catch (error) { 27 | console.error(error); 28 | } 29 | } 30 | }; 31 | 32 | export default updateRoom; 33 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | Listen Together 19 | 20 | 21 | 22 |
23 | 24 | 25 | -------------------------------------------------------------------------------- /functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "functions", 3 | "scripts": { 4 | "lint": "tslint --project tsconfig.json", 5 | "build": "tsc", 6 | "serve": "npm run build && firebase emulators:start --only functions", 7 | "shell": "npm run build && firebase functions:shell", 8 | "start": "npm run shell", 9 | "deploy": "firebase deploy --only functions", 10 | "logs": "firebase functions:log" 11 | }, 12 | "engines": { 13 | "node": "8" 14 | }, 15 | "main": "lib/index.js", 16 | "dependencies": { 17 | "cookie-parser": "^1.4.5", 18 | "cors": "^2.8.5", 19 | "dotenv": "^8.2.0", 20 | "express": "^4.17.1", 21 | "firebase-admin": "^8.10.0", 22 | "firebase-functions": "^3.6.1", 23 | "request": "^2.88.2" 24 | }, 25 | "devDependencies": { 26 | "@types/cookie-parser": "^1.4.2", 27 | "@types/cors": "^2.8.6", 28 | "@types/request": "^2.48.5", 29 | "firebase-functions-test": "^0.2.0", 30 | "tslint": "^5.12.0", 31 | "typescript": "^3.8.0" 32 | }, 33 | "private": true 34 | } 35 | -------------------------------------------------------------------------------- /src/components/RepeatedBackground.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useColorMode } from '@chakra-ui/core'; 3 | 4 | interface Props {} 5 | 6 | const RepeatedBackground = (props: Props) => { 7 | const { colorMode } = useColorMode(); 8 | 9 | return ( 10 |
20 | ); 21 | }; 22 | 23 | export default RepeatedBackground; 24 | -------------------------------------------------------------------------------- /src/firebase/transferRoomOwnership.ts: -------------------------------------------------------------------------------- 1 | import firebase from '.'; 2 | import { UserInformationState } from '../state/userInformation'; 3 | import { User, RoomInformationState } from '../state/roomInformation'; 4 | 5 | const transferRoomOwnership = async ( 6 | room: RoomInformationState, 7 | previousOwner: User, 8 | newOwner: UserInformationState 9 | ) => { 10 | console.log('TODO: Room owner transfer function.'); 11 | console.log(!!room, !!previousOwner, !!newOwner); 12 | if (!room || !previousOwner || !newOwner) return; 13 | 14 | try { 15 | // TODO 16 | // const listenerRef = firebase 17 | // .database() 18 | // .ref(`rooms/${room.id}/listeners/${user.details.id}`); 19 | // const userRef = firebase.database().ref(`users/${user.details.id}`); 20 | // listenerRef.onDisconnect().remove(); 21 | // listenerRef.update(userToAdd); 22 | // userRef.update({ 23 | // room: { 24 | // id: room.id, 25 | // isOwner: false, 26 | // }, 27 | // }); 28 | } catch (error) { 29 | console.error(error); 30 | } 31 | }; 32 | 33 | export default transferRoomOwnership; 34 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | import * as serviceWorker from './serviceWorker'; 5 | import { 6 | ThemeProvider, 7 | theme, 8 | ColorModeProvider, 9 | CSSReset, 10 | } from '@chakra-ui/core'; 11 | import { BrowserRouter } from 'react-router-dom'; 12 | import { RecoilRoot } from 'recoil'; 13 | import { HelmetProvider } from 'react-helmet-async'; 14 | 15 | ReactDOM.render( 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | , 30 | document.getElementById('root') 31 | ); 32 | 33 | // If you want your app to work offline and load faster, you can change 34 | // unregister() to register() below. Note this comes with some pitfalls. 35 | // Learn more about service workers: https://bit.ly/CRA-PWA 36 | serviceWorker.unregister(); 37 | -------------------------------------------------------------------------------- /src/firebase/addUserToRoom.ts: -------------------------------------------------------------------------------- 1 | import firebase from '.'; 2 | import { UserInformation } from '../state/userInformation'; 3 | import { RoomInformation, User } from '../state/roomInformation'; 4 | 5 | const addUserToRoom = async (room: RoomInformation, user: UserInformation) => { 6 | if (!room || !user) return; 7 | 8 | try { 9 | const userToAdd: User = { 10 | id: user.details.id, 11 | name: user.details.display_name 12 | ? user.details.display_name 13 | : user.details.email, 14 | imageUrl: user.details.images 15 | ? user.details.images[0] 16 | ? user.details.images[0].url 17 | : '' 18 | : '', 19 | }; 20 | 21 | const listenerRef = firebase 22 | .database() 23 | .ref(`rooms/${room.id}/listeners/${user.details.id}`); 24 | 25 | const userRef = firebase.database().ref(`users/${user.details.id}`); 26 | 27 | listenerRef.onDisconnect().remove(); 28 | 29 | listenerRef.update(userToAdd); 30 | userRef.update({ 31 | room: { 32 | id: room.id, 33 | isOwner: false, 34 | }, 35 | }); 36 | } catch (error) { 37 | console.error(error); 38 | } 39 | }; 40 | 41 | export default addUserToRoom; 42 | -------------------------------------------------------------------------------- /src/components/ConfirmHomeModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Modal, 4 | ModalOverlay, 5 | ModalContent, 6 | ModalHeader, 7 | ModalCloseButton, 8 | ModalBody, 9 | ModalFooter, 10 | Button, 11 | } from '@chakra-ui/core'; 12 | 13 | interface Props { 14 | isOpen: boolean; 15 | onClose: () => void; 16 | onDestroy: () => void; 17 | } 18 | 19 | const ConfirmHomeModal = ({ isOpen, onClose, onDestroy }: Props) => { 20 | return ( 21 | 22 | 23 | 24 | Are you sure? 25 | 26 | 27 | Heading home will destroy this room, removing everyone who is 28 | listening from it. Are you sure you'd like to leave? 29 | 30 | 31 | 32 | 35 | 38 | 39 | 40 | 41 | ); 42 | }; 43 | 44 | export default ConfirmHomeModal; 45 | -------------------------------------------------------------------------------- /src/components/JoinPrivateRoom.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useHistory } from 'react-router-dom'; 3 | import { Button, Text, Input, Icon, Flex } from '@chakra-ui/core'; 4 | import { FiUsers } from 'react-icons/fi'; 5 | 6 | interface Props {} 7 | 8 | const JoinPrivateRoom = (props: Props) => { 9 | const history = useHistory(); 10 | const [showInput, setShowInput] = useState(false); 11 | const [roomID, setRoomID] = useState(''); 12 | const handleIDChange = (event: React.ChangeEvent) => 13 | setRoomID(event.target.value); 14 | 15 | if (showInput) { 16 | return ( 17 | 18 | 24 | 27 | 28 | ); 29 | } 30 | 31 | return ( 32 | 41 | ); 42 | }; 43 | 44 | export default JoinPrivateRoom; 45 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | ListenTogether -------------------------------------------------------------------------------- /src/components/RepeatedBackgroundLanding.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useColorMode } from '@chakra-ui/core'; 3 | 4 | interface Props {} 5 | 6 | const RepeatedBackgroundLanding = (props: Props) => { 7 | const { colorMode } = useColorMode(); 8 | 9 | return ( 10 |
20 |
32 |
33 | ); 34 | }; 35 | 36 | export default RepeatedBackgroundLanding; 37 | -------------------------------------------------------------------------------- /src/components/RadioOption.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Button, Box, Heading, Text, Icon } from '@chakra-ui/core'; 3 | 4 | interface Props { 5 | title: string; 6 | description: string; 7 | isChecked?: boolean; 8 | isDisabled?: boolean; 9 | value?: string | boolean; 10 | children?: JSX.Element | JSX.Element[] | string; 11 | } 12 | 13 | const RadioOption = React.forwardRef((props: Props, ref) => { 14 | const { isChecked, isDisabled, value, ...rest } = props; 15 | 16 | const onClick = isDisabled ? { onClick: () => {} } : {}; 17 | 18 | return ( 19 | 47 | ); 48 | }); 49 | 50 | export default RadioOption; 51 | -------------------------------------------------------------------------------- /src/assets/logo-dark.svg: -------------------------------------------------------------------------------- 1 | ListenTogether -------------------------------------------------------------------------------- /src/assets/logo-light.svg: -------------------------------------------------------------------------------- 1 | ListenTogether -------------------------------------------------------------------------------- /src/components/layout.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Layout component that queries for data 3 | * with Gatsby's useStaticQuery component 4 | * 5 | * See: https://www.gatsbyjs.org/docs/use-static-query/ 6 | */ 7 | 8 | import React from 'react'; 9 | 10 | import SEO from './seo'; 11 | import { Stack, useColorMode } from '@chakra-ui/core'; 12 | 13 | interface Props { 14 | children: JSX.Element | JSX.Element[]; 15 | title: string; 16 | centered?: boolean; 17 | maxW?: number; 18 | boxed?: boolean; 19 | } 20 | 21 | const Layout = ({ children, title, centered, boxed, maxW }: Props) => { 22 | const { colorMode } = useColorMode(); 23 | 24 | return ( 25 | <> 26 | 27 |
28 | {centered ? ( 29 | 38 | {boxed ? ( 39 | 44 | {children} 45 | 46 | ) : ( 47 | { children } 48 | )} 49 | 50 | ) : ( 51 | children 52 | )} 53 |
54 | 55 | ); 56 | }; 57 | 58 | export default Layout; 59 | -------------------------------------------------------------------------------- /src/firebase/createRoom.ts: -------------------------------------------------------------------------------- 1 | import firebase from '.'; 2 | import shortid from 'shortid'; 3 | import { RoomInformation } from '../state/roomInformation'; 4 | import { SpotifyAPI } from '../state/spotifyAPI'; 5 | 6 | const createRoom = async ( 7 | spotifyAPI: SpotifyAPI, 8 | accessToken: string, 9 | songInformation: SpotifyApi.CurrentPlaybackResponse, 10 | isPublic: boolean 11 | ) => { 12 | const user = await spotifyAPI.getMe(accessToken); 13 | 14 | const id = shortid.generate(); 15 | 16 | if (user && songInformation.item) { 17 | const document: RoomInformation = { 18 | id, 19 | isPublic, 20 | song: { 21 | id: songInformation.item.id, 22 | addedAt: Date.now(), 23 | progress: songInformation.progress_ms ? songInformation.progress_ms : 0, 24 | uri: songInformation.item.uri, 25 | isPlaying: songInformation.is_playing, 26 | }, 27 | owner: { 28 | id: user.id, 29 | name: user.display_name ? user.display_name : user.email, 30 | imageUrl: user.images ? (user.images[0] ? user.images[0].url : '') : '', 31 | }, 32 | listeners: {}, 33 | }; 34 | try { 35 | const roomRef = firebase.database().ref(`rooms/${id}`); 36 | roomRef.onDisconnect().remove(); 37 | roomRef.set(document); 38 | firebase 39 | .database() 40 | .ref(`users/${user.id}`) 41 | .update({ 42 | room: { 43 | id: document.id, 44 | isOwner: true, 45 | }, 46 | }); 47 | } catch (error) { 48 | console.error(error); 49 | } 50 | } 51 | 52 | return id; 53 | }; 54 | 55 | export default createRoom; 56 | -------------------------------------------------------------------------------- /src/components/RoomSongDisplay.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Flex, 4 | Stack, 5 | Heading, 6 | Slider, 7 | Image, 8 | Text, 9 | SliderTrack, 10 | SliderFilledTrack, 11 | SliderThumb, 12 | Box, 13 | } from '@chakra-ui/core'; 14 | import { Link } from 'react-router-dom'; 15 | import { RoomInformation } from '../state/roomInformation'; 16 | 17 | interface Props { 18 | room: RoomInformation; 19 | track: SpotifyApi.TrackObjectFull; 20 | } 21 | 22 | const RoomSongDisplay = ({ room, track }: Props) => { 23 | return ( 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | {room.owner.name}'s Room 32 | 33 | {track.name} •{' '} 34 | {track.artists.map( 35 | (artist, index) => 36 | `${artist.name}${ 37 | track.artists.length - 1 === index ? '' : ', ' 38 | }` 39 | )} 40 | 41 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | ); 55 | }; 56 | 57 | export default RoomSongDisplay; 58 | -------------------------------------------------------------------------------- /src/routes/LoginToSpotify.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Heading, 4 | Button, 5 | Stack, 6 | Text, 7 | Link as ExternalLink, 8 | } from '@chakra-ui/core'; 9 | import { FaSpotify } from 'react-icons/fa'; 10 | import LogoButton from '../components/LogoButton'; 11 | 12 | interface Props {} 13 | 14 | const loginLink = 15 | !process.env.NODE_ENV || process.env.NODE_ENV === 'development' 16 | ? 'http://localhost:5001/listen-together-hf/us-central1/app/login' 17 | : 'https://us-central1-listen-together-hf.cloudfunctions.net/app/login'; 18 | 19 | export const LoginToSpotify = (props: Props) => { 20 | return ( 21 | 22 | Listen Together 23 | 24 | Grab some friends, connect your Spotify account, and listen to music in 25 | sync with each other. 26 | 27 | 28 | 38 | 39 | 40 | Built by 41 | 42 | for{' '} 43 | 48 | Same Home Different Hacks 49 | {' '} 50 | – June 2020 51 | 52 | 53 | ); 54 | }; 55 | 56 | export default LoginToSpotify; 57 | -------------------------------------------------------------------------------- /src/hooks/usePlaybackMonitor.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { useRecoilState, useRecoilValue } from 'recoil'; 3 | import { 4 | accessTokenState, 5 | playbackInformationState, 6 | spotifyApiState, 7 | } from '../state'; 8 | 9 | const usePlaybackMonitor = (shouldStartCheckingPlayback: boolean) => { 10 | const spotifyAPI = useRecoilValue(spotifyApiState); 11 | const accessToken = useRecoilValue(accessTokenState); 12 | const [songInformation, setSongInformation] = useRecoilState( 13 | playbackInformationState 14 | ); 15 | 16 | const [fetchInterval, setFetchInterval] = useState( 17 | null 18 | ); 19 | 20 | useEffect(() => { 21 | const getMyCurrentPlaybackState = async () => { 22 | // console.log('Checking playback state...'); 23 | try { 24 | spotifyAPI.setAccessToken(accessToken); 25 | const response = await spotifyAPI.getMyCurrentPlaybackState(); 26 | // console.log(response); 27 | 28 | setSongInformation(response); 29 | return response; 30 | } catch (error) { 31 | console.error(error); 32 | } 33 | }; 34 | 35 | const startCheckingPlayback = () => { 36 | if (accessToken) { 37 | getMyCurrentPlaybackState(); 38 | setFetchInterval(setInterval(getMyCurrentPlaybackState, 500)); 39 | } 40 | }; 41 | 42 | if (shouldStartCheckingPlayback && !fetchInterval) startCheckingPlayback(); 43 | 44 | return () => { 45 | if (fetchInterval) clearInterval(fetchInterval); 46 | }; 47 | }, [ 48 | accessToken, 49 | fetchInterval, 50 | setSongInformation, 51 | shouldStartCheckingPlayback, 52 | spotifyAPI, 53 | ]); 54 | 55 | return songInformation; 56 | }; 57 | 58 | export default usePlaybackMonitor; 59 | -------------------------------------------------------------------------------- /src/routes/SearchOrShare.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { 3 | Stack, 4 | Text, 5 | Heading, 6 | RadioButtonGroup, 7 | Button, 8 | } from '@chakra-ui/core'; 9 | import RadioOption from '../components/RadioOption'; 10 | import { Redirect } from 'react-router-dom'; 11 | 12 | export type SearchOrShareResponse = 'search' | 'share'; 13 | 14 | interface Props {} 15 | 16 | export const SearchOrShare = (props: Props) => { 17 | const [value, setValue] = useState('share'); 18 | const [submitted, setSubmitted] = useState(false); 19 | 20 | if (submitted) 21 | return ; 22 | 23 | return ( 24 | 25 | Welcome! You're successfully signed in. 26 | How would you like to listen together? 27 | setValue(e as SearchOrShareResponse)} 33 | > 34 | 39 | 44 | 45 | 55 | 56 | ); 57 | }; 58 | 59 | export default SearchOrShare; 60 | -------------------------------------------------------------------------------- /src/components/SongControl.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Flex, Spinner } from '@chakra-ui/core'; 3 | import SongControlButton from './SongControlButton'; 4 | import { FiPause, FiSkipBack, FiSkipForward, FiPlay } from 'react-icons/fi'; 5 | import { useRecoilValue } from 'recoil'; 6 | import { spotifyApiState } from '../state'; 7 | 8 | interface Props { 9 | isPlaying: boolean; 10 | isOwner: boolean; 11 | } 12 | 13 | const SongControl = ({ isPlaying, isOwner }: Props) => { 14 | const spotifyApi = useRecoilValue(spotifyApiState); 15 | const [changeToIsPlaying, setChangeToIsPlaying] = useState(true); 16 | 17 | useEffect(() => { 18 | setChangeToIsPlaying(isPlaying); 19 | }, [isPlaying]); 20 | 21 | return ( 22 | 23 | spotifyApi.skipToPrevious()} 25 | isDisabled={!isOwner} 26 | > 27 | 28 | 29 | { 32 | setChangeToIsPlaying(!isPlaying); 33 | isPlaying ? spotifyApi.pause() : spotifyApi.play(); 34 | }} 35 | > 36 | {changeToIsPlaying === isPlaying ? ( 37 | isPlaying ? ( 38 | 39 | ) : ( 40 | 41 | ) 42 | ) : ( 43 | 44 | )} 45 | 46 | spotifyApi.skipToNext()} 48 | isDisabled={!isOwner} 49 | > 50 | 51 | 52 | 53 | ); 54 | }; 55 | 56 | export default SongControl; 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | firebase-debug.log* 8 | 9 | # Firebase cache 10 | .firebase/ 11 | 12 | # Firebase config 13 | 14 | # Uncomment this if you'd like others to create their own Firebase project. 15 | # For a team working on the same Firebase project(s), it is recommended to leave 16 | # it commented so all members can deploy to the same project(s) in .firebaserc. 17 | # .firebaserc 18 | 19 | # Runtime data 20 | pids 21 | *.pid 22 | *.seed 23 | *.pid.lock 24 | 25 | # Directory for instrumented libs generated by jscoverage/JSCover 26 | lib-cov 27 | 28 | # Coverage directory used by tools like istanbul 29 | coverage 30 | 31 | # nyc test coverage 32 | .nyc_output 33 | 34 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 35 | .grunt 36 | 37 | # Bower dependency directory (https://bower.io/) 38 | bower_components 39 | 40 | # node-waf configuration 41 | .lock-wscript 42 | 43 | # Compiled binary addons (http://nodejs.org/api/addons.html) 44 | build/Release 45 | 46 | # Dependency directories 47 | node_modules/ 48 | 49 | # Optional npm cache directory 50 | .npm 51 | 52 | # Optional eslint cache 53 | .eslintcache 54 | 55 | # Optional REPL history 56 | .node_repl_history 57 | 58 | # Output of 'npm pack' 59 | *.tgz 60 | 61 | # Yarn Integrity file 62 | .yarn-integrity 63 | 64 | # dotenv environment variables file 65 | .env 66 | 67 | 68 | ###### REACT ###### 69 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 70 | 71 | # dependencies 72 | /node_modules 73 | /.pnp 74 | .pnp.js 75 | 76 | # testing 77 | /coverage 78 | 79 | # production 80 | /build 81 | 82 | # misc 83 | .DS_Store 84 | .env.local 85 | .env.development.local 86 | .env.test.local 87 | .env.production.local 88 | 89 | npm-debug.log* 90 | yarn-debug.log* 91 | yarn-error.log* 92 | -------------------------------------------------------------------------------- /src/components/seo.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * SEO component that queries for data with 3 | * Gatsby's useStaticQuery React hook 4 | * 5 | * See: https://www.gatsbyjs.org/docs/use-static-query/ 6 | */ 7 | 8 | import React from 'react'; 9 | import { Helmet } from 'react-helmet-async'; 10 | 11 | interface Props { 12 | title: string; 13 | description?: string; 14 | lang?: string; 15 | meta?: ConcatArray; 16 | } 17 | 18 | function SEO({ description, lang = 'en', meta = [], title = `` }: Props) { 19 | const site = { 20 | siteMetadata: { 21 | title: 'Listen Together', 22 | description: 'A site to listen to Spotify together.', 23 | author: 'Henry Fellerhoff', 24 | }, 25 | }; 26 | 27 | const metaDescription = description || site.siteMetadata.description; 28 | 29 | return ( 30 | 71 | ); 72 | } 73 | 74 | export default SEO; 75 | -------------------------------------------------------------------------------- /src/hooks/useUserMonitor.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useRecoilState, useRecoilValue } from 'recoil'; 3 | import { 4 | userInformationState, 5 | spotifyApiState, 6 | accessTokenState, 7 | } from '../state'; 8 | import firebase from '../firebase'; 9 | import { useObject } from 'react-firebase-hooks/database'; 10 | 11 | const useUserMonitor = () => { 12 | const spotifyAPI = useRecoilValue(spotifyApiState); 13 | const accessToken = useRecoilValue(accessTokenState); 14 | const [user, setUser] = useRecoilState(userInformationState); 15 | const [value, loading, error] = useObject( 16 | firebase.database().ref('users/' + user?.details.id) 17 | ); 18 | 19 | useEffect(() => { 20 | if (!loading && !error && value) { 21 | const document = value.val(); 22 | if (document) setUser(document); 23 | } 24 | }, [value, loading, error, setUser]); 25 | 26 | useEffect(() => { 27 | const updateUser = async () => { 28 | if (!accessToken) return; 29 | try { 30 | const response = await spotifyAPI.getMe({ 31 | access_token: accessToken, 32 | }); 33 | 34 | const userRef = firebase.database().ref(`users/${response.id}`); 35 | 36 | // When the user disconnects 37 | userRef.onDisconnect().update({ 38 | connected: false, 39 | room: null, 40 | }); 41 | 42 | const updatedUser = { 43 | connected: true, 44 | details: response, 45 | }; 46 | 47 | setUser({ 48 | connected: true, 49 | details: response, 50 | }); 51 | 52 | userRef.update(updatedUser); 53 | } catch (error) { 54 | console.error('User fetch error:'); 55 | console.error(error); 56 | } 57 | }; 58 | 59 | if (accessToken && !user) updateUser(); 60 | }, [accessToken, spotifyAPI, user, setUser]); 61 | }; 62 | 63 | export default useUserMonitor; 64 | -------------------------------------------------------------------------------- /src/components/ListenerDisplay.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Flex, Image, Text, Tooltip, Box, Button } from '@chakra-ui/core'; 3 | import { FaUser, FaCrown } from 'react-icons/fa'; 4 | import { User, roomInformationState } from '../state/roomInformation'; 5 | import { useRecoilValue } from 'recoil'; 6 | import { userInformationState } from '../state'; 7 | import transferRoomOwnership from '../firebase/transferRoomOwnership'; 8 | // import ScaleLoader from 'react-spinners/ScaleLoader'; 9 | 10 | interface Props { 11 | user: User; 12 | isOwner?: boolean; 13 | } 14 | 15 | const ListenerDisplay = ({ user, isOwner }: Props) => { 16 | const globalUser = useRecoilValue(userInformationState); 17 | const room = useRecoilValue(roomInformationState); 18 | 19 | return ( 20 | 21 | 22 | {user.imageUrl ? ( 23 | 24 | ) : ( 25 | 33 | 34 | 35 | )} 36 | 37 | {user.name} 38 | 39 | 40 | {isOwner ? ( 41 | 42 | 43 | 44 | 45 | 46 | ) : globalUser?.room?.isOwner ? ( 47 | // TODO: Room ownership transfer 48 | // 51 | <> 52 | ) : ( 53 | <> 54 | )} 55 | 56 | ); 57 | }; 58 | 59 | export default ListenerDisplay; 60 | -------------------------------------------------------------------------------- /src/components/RouteToHome.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Button } from '@chakra-ui/core'; 3 | import { FiHome } from 'react-icons/fi'; 4 | import { useRecoilValue, useRecoilState } from 'recoil'; 5 | import { userInformationState } from '../state'; 6 | import { useHistory } from 'react-router-dom'; 7 | import removeUserFromRoom from '../firebase/removeUserFromRoom'; 8 | import { roomInformationState } from '../state/roomInformation'; 9 | import ConfirmHomeModal from './ConfirmHomeModal'; 10 | import destroyRoom from '../firebase/destroyRoom'; 11 | 12 | interface Props {} 13 | 14 | const RouteToHome = (props: Props) => { 15 | const history = useHistory(); 16 | const user = useRecoilValue(userInformationState); 17 | const [room, setRoom] = useRecoilState(roomInformationState); 18 | const [modalVisible, setModalVisible] = useState(false); 19 | 20 | const onGoHomeFromModal = () => { 21 | if (room && user) { 22 | destroyRoom(room, user); 23 | setModalVisible(false); 24 | setRoom(null); 25 | history.push(`/`); 26 | } 27 | }; 28 | 29 | const onClick = () => { 30 | if (room) { 31 | if (room.owner.id === user?.details.id) { 32 | setModalVisible(true); 33 | } else { 34 | if (user) { 35 | removeUserFromRoom(room, user); 36 | setRoom(null); 37 | history.push(`/`); 38 | } 39 | } 40 | } else { 41 | history.push(`/`); 42 | } 43 | }; 44 | 45 | return ( 46 | <> 47 | 57 | setModalVisible(false)} 60 | onDestroy={onGoHomeFromModal} 61 | /> 62 | 63 | ); 64 | }; 65 | 66 | export default RouteToHome; 67 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@chakra-ui/core": "^0.8.0", 7 | "@emotion/core": "^10.0.28", 8 | "@emotion/styled": "^10.0.27", 9 | "@testing-library/jest-dom": "^4.2.4", 10 | "@testing-library/react": "^9.3.2", 11 | "@testing-library/user-event": "^7.1.2", 12 | "@types/jest": "^24.0.0", 13 | "@types/node": "^12.0.0", 14 | "@types/react": "^16.9.0", 15 | "@types/react-dom": "^16.9.0", 16 | "@types/react-helmet": "^6.0.0", 17 | "@types/react-router-dom": "^5.1.5", 18 | "@types/shortid": "0.0.29", 19 | "emotion-theming": "^10.0.27", 20 | "firebase": "^7.15.1", 21 | "react": "^16.13.1", 22 | "react-dom": "^16.13.1", 23 | "react-firebase-hooks": "^2.2.0", 24 | "react-helmet-async": "^1.0.6", 25 | "react-icons": "^3.10.0", 26 | "react-router-dom": "^5.2.0", 27 | "react-scripts": "3.4.1", 28 | "react-spinners": "^0.9.0", 29 | "react-text-loop": "^2.3.0", 30 | "recoil": "0.0.8", 31 | "shortid": "^2.2.15", 32 | "spotify-web-api-js": "^1.4.0", 33 | "typescript": "^3.7.5" 34 | }, 35 | "scripts": { 36 | "serve": "firebase serve --only functions", 37 | "start": "react-scripts start", 38 | "start:functions": "cd functions && npm run serve", 39 | "start:all": "concurrently \"npm run start\" \"cd functions && npm run serve\"", 40 | "deploy:web": "npm run build && firebase deploy --only hosting", 41 | "build": "react-scripts build", 42 | "test": "react-scripts test", 43 | "eject": "react-scripts eject" 44 | }, 45 | "eslintConfig": { 46 | "extends": "react-app" 47 | }, 48 | "browserslist": { 49 | "production": [ 50 | ">0.2%", 51 | "not dead", 52 | "not op_mini all" 53 | ], 54 | "development": [ 55 | "last 1 chrome version", 56 | "last 1 firefox version", 57 | "last 1 safari version" 58 | ] 59 | }, 60 | "devDependencies": { 61 | "@types/recoil": "0.0.1" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/routes/ShareSong.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { 3 | Stack, 4 | Heading, 5 | Text, 6 | Button, 7 | RadioButtonGroup, 8 | } from '@chakra-ui/core'; 9 | import SongDisplay from '../components/SongDisplay'; 10 | import RadioOption from '../components/RadioOption'; 11 | import { PlaybackInformation } from '../state/playbackInformation'; 12 | 13 | interface Props { 14 | songInformation: PlaybackInformation; 15 | createRoom: (isPublic: boolean) => void; 16 | } 17 | 18 | export const ShareSong = ({ songInformation, createRoom }: Props) => { 19 | const [selection, setSelection] = useState('public'); 20 | 21 | return ( 22 | 23 | STEP 2 24 | Share Your Music 25 | 26 | {songInformation ? ( 27 | 28 | 29 | How would you like to share your music? 30 | 31 | setSelection(e as string)} 37 | > 38 | 43 | 48 | 49 | 59 | 60 | ) : ( 61 | <> 62 | )} 63 | 64 | ); 65 | }; 66 | 67 | export default ShareSong; 68 | -------------------------------------------------------------------------------- /src/components/SongDisplay.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Flex, 4 | Stack, 5 | Heading, 6 | Image, 7 | Text, 8 | Slider, 9 | SliderTrack, 10 | SliderFilledTrack, 11 | SliderThumb, 12 | Spinner, 13 | } from '@chakra-ui/core'; 14 | import { PlaybackInformation } from '../state/playbackInformation'; 15 | 16 | interface Props { 17 | songInformation: PlaybackInformation; 18 | } 19 | 20 | const SongDisplay = ({ songInformation }: Props) => { 21 | if (songInformation) 22 | if (songInformation.item) { 23 | return ( 24 | <> 25 | 26 | 27 | 28 | 29 | {songInformation.item.name} 30 | 31 | 32 | {songInformation.item.artists.map( 33 | (artist, index) => 34 | `${artist.name}${ 35 | songInformation.item 36 | ? songInformation.item.artists.length - 1 === index 37 | ? '' 38 | : ', ' 39 | : '' 40 | }` 41 | )} 42 | 43 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | ); 60 | } 61 | return ( 62 | 63 | 64 | 65 | If you haven't started to play a song yet, please do that now. 66 | Otherwise, you may be looking at this loading spinner for quite awhile{' '} 67 | 68 | 😉 69 | 70 | 71 | 72 | ); 73 | }; 74 | 75 | export default SongDisplay; 76 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Listen Together 2 | Grab some friends, connect your Spotify account, and listen to music in sync with each other. 3 | 4 | ### Inspiration 5 | I love making playlists and talking about music with my friends, and so I decided to expand upon that by allowing friends to hop in a room and all listen to music in sync with one another. 6 | 7 | ### What it does 8 | Listen Together allows groups of people to listen to music in sync with one another via Spotify. When the group owner makes any change in the song they are listening to, anyone who is listening and in the group with receive those same changes. It allows for truly synced music listening, which stands in contrast to Spotify's built-in feature of playing a song that a friend is listening to. 9 | 10 | ### How I built it 11 | Listen Together is built with React on the frontend and Node.js / Express running through Firebase functions on the backend. This was used purely to authenticate users with Spotify. In addition, I leveraged Firebase's realtime database to accomplish the live music syncing. 12 | 13 | ### Challenges I ran into 14 | Data fetching and calling the Spotify API at the right times was the source of many of my bugs, as the nature of the application means that the client has to stay in constant sync with both a user's Spotify application on their phone/computer and the Firebase realtime database. 15 | 16 | ### Accomplishments that I'm proud of 17 | Considering I worked alone and tackled an API I had never looked at before, I am quite proud of what I was able to accomplish. While I've used firebase in the past, I haven't worked on an application that was quite this realtime, and as a result there were many roadblocks I hadn't seen before that I was able to overcome. 18 | 19 | ### What I learned 20 | I learned a lot about the Spotify API, as well as dipping my toes in dealing with OAuth for authentication. I hadn't used either of those things before. 21 | 22 | ### What's next for Listen Together 23 | I was only able to implement public room sharing during the Hackathon, but I'd love to add functionality for users to send a private room link to only their friends. Possible integrations with other services could be neat as well, although I'm not positive about the feasibility of that. 24 | 25 | ### Built With 26 | - chakra-ui 27 | - firebase 28 | - javascript 29 | - react 30 | - spotify 31 | - typescript 32 | 33 | ### Try it out 34 | https://listen.henryfellerhoff.com 35 | -------------------------------------------------------------------------------- /src/components/InstructionFAB.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { 3 | useColorMode, 4 | Link as ChakraLink, 5 | Button, 6 | Modal, 7 | ModalOverlay, 8 | ModalContent, 9 | ModalHeader, 10 | ModalCloseButton, 11 | ModalBody, 12 | ModalFooter, 13 | List, 14 | ListItem, 15 | Text, 16 | } from '@chakra-ui/core'; 17 | import { FiHelpCircle, FiMail } from 'react-icons/fi'; 18 | 19 | interface Props {} 20 | 21 | const InstructionFAB = (props: Props) => { 22 | const { colorMode } = useColorMode(); 23 | const [isOpen, setIsOpen] = useState(false); 24 | 25 | const onClose = () => setIsOpen(false); 26 | 27 | return ( 28 | <> 29 | 44 | 45 | 46 | 47 | Having Trouble? 48 | 49 | 50 | 51 | 52 | Listen Together works by controlling the playback of one of your 53 | open instances of Spotify. Make sure Spotify is open on either 54 | your computer or phone so that Spotify has something to play 55 | music on. 56 | 57 | 58 | If you're still having trouble, try playing something on the 59 | device you'd like to listen on before joining the room. This 60 | tells Spotify that your device is active and ready to be used. 61 | 62 | 63 | 64 | 65 | 66 | 67 | 70 | 71 | 74 | 75 | 76 | 77 | 78 | ); 79 | }; 80 | 81 | export default InstructionFAB; 82 | -------------------------------------------------------------------------------- /src/routes/LandingPage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Box, 4 | Image, 5 | useColorMode, 6 | Flex, 7 | Text, 8 | Button, 9 | Heading, 10 | } from '@chakra-ui/core'; 11 | import logoLight from '../assets/logo-light.svg'; 12 | import logoDark from '../assets/logo-dark.svg'; 13 | import audioDrawingLight from '../assets/audiodrawing-light.svg'; 14 | import audioDrawingDark from '../assets/audiodrawing-dark.svg'; 15 | import { FaSpotify } from 'react-icons/fa'; 16 | import TextLoop from 'react-text-loop'; 17 | 18 | interface Props {} 19 | 20 | const loginLink = 21 | !process.env.NODE_ENV || process.env.NODE_ENV === 'development' 22 | ? 'http://localhost:5001/listen-together-hf/us-central1/app/login' 23 | : 'https://us-central1-listen-together-hf.cloudfunctions.net/app/login'; 24 | 25 | export const LandingPage = (props: Props) => { 26 | const { colorMode } = useColorMode(); 27 | 28 | return ( 29 | 30 | 31 | 36 | 37 | 38 | 52 | 53 | 54 | Jam out 55 | Be the DJ 56 | Share music 57 | Listen together 58 | 59 | 60 | no matter where you are. 61 | 62 | 63 | Grab some friends, connect your Spotify account, and listen to 64 | music in sync with each other. 65 | 66 | 67 | 68 | 77 | 78 | {/* */} 89 | 90 | 91 | 92 | 97 | 98 | 99 | 100 | 101 | ); 102 | }; 103 | 104 | export default LandingPage; 105 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Switch, Route, Redirect, useHistory } from 'react-router-dom'; 3 | import Layout from './components/layout'; 4 | import RepeatedBackground from './components/RepeatedBackground'; 5 | import getHashParams from './util/getHashParams'; 6 | import ToggleColorMode from './components/ToggleColorMode'; 7 | import usePlaybackMonitor from './hooks/usePlaybackMonitor'; 8 | import { 9 | SearchOrShare, 10 | ChooseSong, 11 | ShareSong, 12 | Room, 13 | Rooms, 14 | LandingPage, 15 | } from './routes'; 16 | import createRoom from './firebase/createRoom'; 17 | import RouteToHome from './components/RouteToHome'; 18 | import { useRecoilState, useRecoilValue } from 'recoil'; 19 | import { spotifyApiState, accessTokenState } from './state'; 20 | import useUserMonitor from './hooks/useUserMonitor'; 21 | import RepeatedBackgroundLanding from './components/RepeatedBackgroundLanding'; 22 | import InstructionFAB from './components/InstructionFAB'; 23 | 24 | const App = () => { 25 | const params = getHashParams() as { access_token: string }; 26 | const history = useHistory(); 27 | 28 | const [isCheckingPlayback, setIsCheckingPlayback] = useState(false); 29 | 30 | const spotifyAPI = useRecoilValue(spotifyApiState); 31 | const [accessToken, setAccessToken] = useRecoilState(accessTokenState); 32 | 33 | const songInformation = usePlaybackMonitor(isCheckingPlayback); 34 | useUserMonitor(); 35 | 36 | if (params.access_token && !accessToken) setAccessToken(params.access_token); 37 | 38 | const handleCreateRoom = async (isPublic: boolean) => { 39 | if (songInformation && accessToken) { 40 | const id = await createRoom( 41 | spotifyAPI, 42 | accessToken, 43 | songInformation as SpotifyApi.CurrentPlaybackResponse, 44 | isPublic 45 | ); 46 | history.push(`/rooms/${id}`); 47 | } 48 | }; 49 | 50 | return ( 51 | <> 52 | 53 | 54 | 55 | {accessToken ? ( 56 | 57 | ) : ( 58 | <> 59 | 60 | 61 | 62 | 63 | 64 | )} 65 | 66 | {accessToken ? ( 67 | <> 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | {isCheckingPlayback ? ( 90 | 91 | ) : ( 92 | 93 | { 95 | setIsCheckingPlayback(true); 96 | }} 97 | /> 98 | 99 | )} 100 | 101 | 102 | 103 | 107 | 108 | 109 | 110 | 111 | ) : ( 112 | 113 | )} 114 | 115 | 116 | ); 117 | }; 118 | 119 | export default App; 120 | -------------------------------------------------------------------------------- /src/routes/Rooms.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { useList } from 'react-firebase-hooks/database'; 3 | import firebase from '../firebase'; 4 | import { 5 | Box, 6 | Spinner, 7 | Text, 8 | Button, 9 | Heading, 10 | Stack, 11 | Flex, 12 | } from '@chakra-ui/core'; 13 | import { Link, Redirect } from 'react-router-dom'; 14 | import RoomSongDisplay from '../components/RoomSongDisplay'; 15 | import { useRecoilValue } from 'recoil'; 16 | import { 17 | accessTokenState, 18 | spotifyApiState, 19 | userInformationState, 20 | } from '../state'; 21 | import { RoomInformation } from '../state/roomInformation'; 22 | import JoinPrivateRoom from '../components/JoinPrivateRoom'; 23 | 24 | interface Props {} 25 | 26 | export const Rooms = () => { 27 | const accessToken = useRecoilValue(accessTokenState); 28 | const spotifyApi = useRecoilValue(spotifyApiState); 29 | const user = useRecoilValue(userInformationState); 30 | const [snapshots, loading, error] = useList(firebase.database().ref('rooms')); 31 | const [rooms, setRooms] = useState([]); 32 | const [tracks, setTracks] = useState( 33 | null 34 | ); 35 | 36 | useEffect(() => { 37 | const fetchTracks = async () => { 38 | if (!snapshots) return; 39 | const roomDocuments = snapshots.map((snapshot) => snapshot.val()); 40 | setRooms(roomDocuments); 41 | 42 | if (roomDocuments.length === 0) setTracks([]); 43 | 44 | const roomDocumentIDs = roomDocuments.map((room) => room.song.id); 45 | 46 | try { 47 | const response = await spotifyApi.getTracks(roomDocumentIDs); 48 | setTracks(response.tracks); 49 | } catch (error) { 50 | console.error(error); 51 | } 52 | }; 53 | 54 | if (!loading && !error && snapshots) { 55 | spotifyApi.setAccessToken(accessToken); 56 | fetchTracks(); 57 | } 58 | 59 | // eslint-disable-next-line react-hooks/exhaustive-deps 60 | }, [accessToken, error, loading, snapshots]); 61 | 62 | if (loading || !tracks) return ; 63 | 64 | let publicTrackCount = 0; 65 | for (const room of rooms) { 66 | if (user) { 67 | if (room.owner && user.details) { 68 | if (room.owner.id === user.details.id) { 69 | return ; 70 | } 71 | } 72 | } 73 | if (room.isPublic) publicTrackCount += 1; 74 | } 75 | 76 | return ( 77 | 78 | 83 | 84 | Public Rooms 85 | 86 | 87 | 88 | {publicTrackCount === 0 ? ( 89 | 90 | 91 | It looks like there aren't any people sharing their music right now. 92 | Why don't you become the first? 93 | 94 | 95 | 104 | 105 | 106 | ) : ( 107 | <> 108 | {tracks.map((track, index) => { 109 | if (rooms) { 110 | if (rooms[index].isPublic) { 111 | return ( 112 | 117 | ); 118 | } 119 | } 120 | return <>; 121 | })} 122 | Don't see anything interesting? 123 | 124 | 133 | 134 | 135 | )} 136 | 137 | ); 138 | }; 139 | 140 | export default Rooms; 141 | -------------------------------------------------------------------------------- /functions/src/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Brief disclaimer: much of this code is borrowed/inspired from this 3 | GitHub library by a Spotify developer here: 4 | 5 | https://github.com/spotify/web-api-auth-examples 6 | 7 | If you have any questions, that would be the best place to check first 8 | as they know much more about this than I do. 9 | 10 | Good luck! 11 | */ 12 | 13 | import * as functions from 'firebase-functions'; 14 | import * as express from 'express'; 15 | import * as request from 'request'; 16 | import * as cors from 'cors'; 17 | import * as querystring from 'querystring'; 18 | import * as cookieParser from 'cookie-parser'; 19 | import * as dotenv from 'dotenv'; 20 | import generateRandomString from './util/generateRandomString'; 21 | dotenv.config(); 22 | 23 | // This solution is incredibly temporary and hacky: 24 | // Firebase doesn't support process.env.NODE_ENV, so 25 | // this is what I am doing until I figure out a better 26 | // solution. If you are hosting this on a real Node 27 | // server and not Firebase Functions, swap out this 28 | // ungodly boolean below for the commented code. 29 | const isDevelopment = true; // !process.env.NODE_ENV || process.env.NODE_ENV === 'development' 30 | 31 | const redirect_uri = isDevelopment 32 | ? 'http://localhost:5001/listen-together-hf/us-central1/app/callback' 33 | : 'https://us-central1-listen-together-hf.cloudfunctions.net/app/callback'; 34 | const callbackURI = isDevelopment 35 | ? 'http://localhost:3000/#' 36 | : 'https://listentogether.app/#'; 37 | 38 | const stateKey = 'spotify_auth_state'; 39 | 40 | // I don't believe the "streaming" permission is necessary, but I left it in as 41 | // I was debating testing out the beta of Spotify's Web Playback API 42 | const scope = 43 | 'user-read-private user-read-email user-read-playback-state user-modify-playback-state streaming'; 44 | 45 | const app = express(); 46 | const { client_id, client_secret } = functions.config().spotify; 47 | 48 | app 49 | .use(express.static(__dirname + '/public')) 50 | .use(cors()) 51 | .use(cookieParser()); 52 | 53 | app.get('/login', (req, res) => { 54 | const state = generateRandomString(16); 55 | res.cookie(stateKey, state); 56 | 57 | // Request authentication from Spotify 58 | res.redirect( 59 | 'https://accounts.spotify.com/authorize?' + 60 | querystring.stringify({ 61 | response_type: 'code', 62 | client_id, 63 | scope, 64 | redirect_uri: redirect_uri, 65 | state, 66 | }) 67 | ); 68 | }); 69 | 70 | app.get('/callback', function (req, res) { 71 | // your application requests refresh and access tokens 72 | // after checking the state parameter 73 | 74 | const code = req.query.code || null; 75 | const state = req.query.state || null; 76 | const storedState = req.cookies ? req.cookies[stateKey] : null; 77 | 78 | if (state === null || state !== storedState) { 79 | res.redirect( 80 | callbackURI + 81 | querystring.stringify({ 82 | error: 'state_mismatch', 83 | }) 84 | ); 85 | } else { 86 | res.clearCookie(stateKey); 87 | const authOptions = { 88 | url: 'https://accounts.spotify.com/api/token', 89 | form: { 90 | code: code, 91 | redirect_uri: redirect_uri, 92 | grant_type: 'authorization_code', 93 | }, 94 | headers: { 95 | Authorization: 96 | 'Basic ' + 97 | new Buffer(client_id + ':' + client_secret).toString('base64'), 98 | }, 99 | json: true, 100 | }; 101 | 102 | request.post(authOptions, function (error, response, body) { 103 | if (!error && response.statusCode === 200) { 104 | const access_token = body.access_token; 105 | const refresh_token = body.refresh_token; 106 | 107 | // Pass the access token back to the Listen Together client 108 | // to be able to make client-side API requests 109 | res.redirect( 110 | callbackURI + 111 | querystring.stringify({ 112 | access_token: access_token, 113 | refresh_token: refresh_token, 114 | }) 115 | ); 116 | } else { 117 | res.redirect( 118 | callbackURI + 119 | querystring.stringify({ 120 | error: 'invalid_token', 121 | }) 122 | ); 123 | } 124 | }); 125 | } 126 | }); 127 | 128 | // Specific to Firebase – I don't believe this is needed for a standalone Node server 129 | exports.app = functions.https.onRequest(app); 130 | -------------------------------------------------------------------------------- /functions/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | // -- Strict errors -- 4 | // These lint rules are likely always a good idea. 5 | 6 | // Force function overloads to be declared together. This ensures readers understand APIs. 7 | "adjacent-overload-signatures": true, 8 | 9 | // Do not allow the subtle/obscure comma operator. 10 | "ban-comma-operator": true, 11 | 12 | // Do not allow internal modules or namespaces . These are deprecated in favor of ES6 modules. 13 | "no-namespace": true, 14 | 15 | // Do not allow parameters to be reassigned. To avoid bugs, developers should instead assign new values to new vars. 16 | "no-parameter-reassignment": true, 17 | 18 | // Force the use of ES6-style imports instead of /// imports. 19 | "no-reference": true, 20 | 21 | // Do not allow type assertions that do nothing. This is a big warning that the developer may not understand the 22 | // code currently being edited (they may be incorrectly handling a different type case that does not exist). 23 | "no-unnecessary-type-assertion": true, 24 | 25 | // Disallow nonsensical label usage. 26 | "label-position": true, 27 | 28 | // Disallows the (often typo) syntax if (var1 = var2). Replace with if (var2) { var1 = var2 }. 29 | "no-conditional-assignment": true, 30 | 31 | // Disallows constructors for primitive types (e.g. new Number('123'), though Number('123') is still allowed). 32 | "no-construct": true, 33 | 34 | // Do not allow super() to be called twice in a constructor. 35 | "no-duplicate-super": true, 36 | 37 | // Do not allow the same case to appear more than once in a switch block. 38 | "no-duplicate-switch-case": true, 39 | 40 | // Do not allow a variable to be declared more than once in the same block. Consider function parameters in this 41 | // rule. 42 | "no-duplicate-variable": [true, "check-parameters"], 43 | 44 | // Disallows a variable definition in an inner scope from shadowing a variable in an outer scope. Developers should 45 | // instead use a separate variable name. 46 | "no-shadowed-variable": true, 47 | 48 | // Empty blocks are almost never needed. Allow the one general exception: empty catch blocks. 49 | "no-empty": [true, "allow-empty-catch"], 50 | 51 | // Functions must either be handled directly (e.g. with a catch() handler) or returned to another function. 52 | // This is a major source of errors in Cloud Functions and the team strongly recommends leaving this rule on. 53 | "no-floating-promises": true, 54 | 55 | // Do not allow any imports for modules that are not in package.json. These will almost certainly fail when 56 | // deployed. 57 | "no-implicit-dependencies": true, 58 | 59 | // The 'this' keyword can only be used inside of classes. 60 | "no-invalid-this": true, 61 | 62 | // Do not allow strings to be thrown because they will not include stack traces. Throw Errors instead. 63 | "no-string-throw": true, 64 | 65 | // Disallow control flow statements, such as return, continue, break, and throw in finally blocks. 66 | "no-unsafe-finally": true, 67 | 68 | // Expressions must always return a value. Avoids common errors like const myValue = functionReturningVoid(); 69 | "no-void-expression": [true, "ignore-arrow-function-shorthand"], 70 | 71 | // Disallow duplicate imports in the same file. 72 | "no-duplicate-imports": true, 73 | 74 | 75 | // -- Strong Warnings -- 76 | // These rules should almost never be needed, but may be included due to legacy code. 77 | // They are left as a warning to avoid frustration with blocked deploys when the developer 78 | // understand the warning and wants to deploy anyway. 79 | 80 | // Warn when an empty interface is defined. These are generally not useful. 81 | "no-empty-interface": {"severity": "warning"}, 82 | 83 | // Warn when an import will have side effects. 84 | "no-import-side-effect": {"severity": "warning"}, 85 | 86 | // Warn when variables are defined with var. Var has subtle meaning that can lead to bugs. Strongly prefer const for 87 | // most values and let for values that will change. 88 | "no-var-keyword": {"severity": "warning"}, 89 | 90 | // Prefer === and !== over == and !=. The latter operators support overloads that are often accidental. 91 | "triple-equals": {"severity": "warning"}, 92 | 93 | // Warn when using deprecated APIs. 94 | "deprecation": {"severity": "warning"}, 95 | 96 | // -- Light Warnings -- 97 | // These rules are intended to help developers use better style. Simpler code has fewer bugs. These would be "info" 98 | // if TSLint supported such a level. 99 | 100 | // prefer for( ... of ... ) to an index loop when the index is only used to fetch an object from an array. 101 | // (Even better: check out utils like .map if transforming an array!) 102 | "prefer-for-of": {"severity": "warning"}, 103 | 104 | // Warns if function overloads could be unified into a single function with optional or rest parameters. 105 | "unified-signatures": {"severity": "warning"}, 106 | 107 | // Prefer const for values that will not change. This better documents code. 108 | "prefer-const": {"severity": "warning"}, 109 | 110 | // Multi-line object literals and function calls should have a trailing comma. This helps avoid merge conflicts. 111 | "trailing-comma": {"severity": "warning"} 112 | }, 113 | 114 | "defaultSeverity": "error" 115 | } 116 | -------------------------------------------------------------------------------- /src/serviceWorker.ts: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | type Config = { 24 | onSuccess?: (registration: ServiceWorkerRegistration) => void; 25 | onUpdate?: (registration: ServiceWorkerRegistration) => void; 26 | }; 27 | 28 | export function register(config?: Config) { 29 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 30 | // The URL constructor is available in all browsers that support SW. 31 | const publicUrl = new URL( 32 | process.env.PUBLIC_URL, 33 | window.location.href 34 | ); 35 | if (publicUrl.origin !== window.location.origin) { 36 | // Our service worker won't work if PUBLIC_URL is on a different origin 37 | // from what our page is served on. This might happen if a CDN is used to 38 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 39 | return; 40 | } 41 | 42 | window.addEventListener('load', () => { 43 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 44 | 45 | if (isLocalhost) { 46 | // This is running on localhost. Let's check if a service worker still exists or not. 47 | checkValidServiceWorker(swUrl, config); 48 | 49 | // Add some additional logging to localhost, pointing developers to the 50 | // service worker/PWA documentation. 51 | navigator.serviceWorker.ready.then(() => { 52 | console.log( 53 | 'This web app is being served cache-first by a service ' + 54 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 55 | ); 56 | }); 57 | } else { 58 | // Is not localhost. Just register service worker 59 | registerValidSW(swUrl, config); 60 | } 61 | }); 62 | } 63 | } 64 | 65 | function registerValidSW(swUrl: string, config?: Config) { 66 | navigator.serviceWorker 67 | .register(swUrl) 68 | .then(registration => { 69 | registration.onupdatefound = () => { 70 | const installingWorker = registration.installing; 71 | if (installingWorker == null) { 72 | return; 73 | } 74 | installingWorker.onstatechange = () => { 75 | if (installingWorker.state === 'installed') { 76 | if (navigator.serviceWorker.controller) { 77 | // At this point, the updated precached content has been fetched, 78 | // but the previous service worker will still serve the older 79 | // content until all client tabs are closed. 80 | console.log( 81 | 'New content is available and will be used when all ' + 82 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 83 | ); 84 | 85 | // Execute callback 86 | if (config && config.onUpdate) { 87 | config.onUpdate(registration); 88 | } 89 | } else { 90 | // At this point, everything has been precached. 91 | // It's the perfect time to display a 92 | // "Content is cached for offline use." message. 93 | console.log('Content is cached for offline use.'); 94 | 95 | // Execute callback 96 | if (config && config.onSuccess) { 97 | config.onSuccess(registration); 98 | } 99 | } 100 | } 101 | }; 102 | }; 103 | }) 104 | .catch(error => { 105 | console.error('Error during service worker registration:', error); 106 | }); 107 | } 108 | 109 | function checkValidServiceWorker(swUrl: string, config?: Config) { 110 | // Check if the service worker can be found. If it can't reload the page. 111 | fetch(swUrl, { 112 | headers: { 'Service-Worker': 'script' } 113 | }) 114 | .then(response => { 115 | // Ensure service worker exists, and that we really are getting a JS file. 116 | const contentType = response.headers.get('content-type'); 117 | if ( 118 | response.status === 404 || 119 | (contentType != null && contentType.indexOf('javascript') === -1) 120 | ) { 121 | // No service worker found. Probably a different app. Reload the page. 122 | navigator.serviceWorker.ready.then(registration => { 123 | registration.unregister().then(() => { 124 | window.location.reload(); 125 | }); 126 | }); 127 | } else { 128 | // Service worker found. Proceed as normal. 129 | registerValidSW(swUrl, config); 130 | } 131 | }) 132 | .catch(() => { 133 | console.log( 134 | 'No internet connection found. App is running in offline mode.' 135 | ); 136 | }); 137 | } 138 | 139 | export function unregister() { 140 | if ('serviceWorker' in navigator) { 141 | navigator.serviceWorker.ready 142 | .then(registration => { 143 | registration.unregister(); 144 | }) 145 | .catch(error => { 146 | console.error(error.message); 147 | }); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/assets/audiodrawing-dark.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/audiodrawing-light.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/routes/Room.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import firebase from '../firebase'; 3 | import { useObject } from 'react-firebase-hooks/database'; 4 | import { useParams, Link } from 'react-router-dom'; 5 | import { Heading, Spinner, Button, Text, Box, Alert } from '@chakra-ui/core'; 6 | import RoomSongDisplay from '../components/RoomSongDisplay'; 7 | import { FaSpotify } from 'react-icons/fa'; 8 | import updateRoom from '../firebase/updateRoom'; 9 | import { useRecoilValue, useRecoilState } from 'recoil'; 10 | import { 11 | accessTokenState, 12 | spotifyApiState, 13 | userInformationState, 14 | playbackInformationState, 15 | } from '../state'; 16 | import addUserToRoom from '../firebase/addUserToRoom'; 17 | import removeUserFromRoom from '../firebase/removeUserFromRoom'; 18 | import { 19 | roomInformationState, 20 | RoomInformation, 21 | } from '../state/roomInformation'; 22 | import SongControl from '../components/SongControl'; 23 | import Layout from '../components/layout'; 24 | import ListenerDisplay from '../components/ListenerDisplay'; 25 | import destroyRoom from '../firebase/destroyRoom'; 26 | 27 | interface Props { 28 | checkingPlayback: boolean; 29 | setShouldCheckPlayback: (value: boolean) => void; 30 | } 31 | 32 | export const Room = ({ checkingPlayback, setShouldCheckPlayback }: Props) => { 33 | const { roomID } = useParams(); 34 | const accessToken = useRecoilValue(accessTokenState); 35 | const spotifyApi = useRecoilValue(spotifyApiState); 36 | const user = useRecoilValue(userInformationState); 37 | const [room, setRoom] = useRecoilState(roomInformationState); 38 | const playbackInformation = useRecoilValue(playbackInformationState); 39 | const [track, setTrack] = useState< 40 | SpotifyApi.SingleTrackResponse | undefined 41 | >(); 42 | 43 | const [value, loading, error] = useObject( 44 | firebase.database().ref('rooms/' + roomID) 45 | ); 46 | 47 | const [lastTrackFetch, setLastTrackFetch] = useState(0); 48 | // const [lastSynced, setLastSynced] = useState(0); 49 | 50 | const [isOwner, setIsOwner] = useState(false); 51 | const [isListening, setIsListening] = useState(false); 52 | const [shouldBeListening, setShouldBeListening] = useState(false); 53 | const [isDeleted, setIsDeleted] = useState(false); 54 | const [stopSearching, setStopSearching] = useState(false); 55 | 56 | useEffect(() => { 57 | spotifyApi.setAccessToken(accessToken); 58 | }, [accessToken, spotifyApi]); 59 | 60 | useEffect(() => { 61 | if (loading || error || !value) return; 62 | 63 | const fetchTrack = async () => { 64 | if (!value) return; 65 | 66 | const document = (await value.val()) as RoomInformation; 67 | if (!document) { 68 | setIsDeleted(true); 69 | return; 70 | } 71 | setRoom(document); 72 | const trackDocumentID = document.song.id; 73 | 74 | try { 75 | const response = await spotifyApi.getTrack(trackDocumentID); 76 | setTrack(response); 77 | } catch (error) { 78 | console.error(error); 79 | } 80 | }; 81 | 82 | if (Date.now() - lastTrackFetch > 1000 && !stopSearching) { 83 | fetchTrack(); 84 | setLastTrackFetch(Date.now()); 85 | } 86 | }, [ 87 | value, 88 | loading, 89 | error, 90 | spotifyApi, 91 | lastTrackFetch, 92 | stopSearching, 93 | setRoom, 94 | ]); 95 | 96 | useEffect(() => { 97 | if (!stopSearching) { 98 | if (!checkingPlayback) { 99 | setShouldCheckPlayback(true); 100 | } 101 | if (isOwner && playbackInformation && room) { 102 | updateRoom(room, playbackInformation); 103 | } 104 | } 105 | if (!isOwner && shouldBeListening && room) { 106 | if (user) { 107 | const now = Date.now(); 108 | const progress = now - room.song.addedAt + room.song.progress; 109 | 110 | if (!room.song.isPlaying && playbackInformation?.is_playing) { 111 | spotifyApi.pause(); 112 | } else if (room.song.isPlaying && !playbackInformation?.is_playing) { 113 | spotifyApi.play({ 114 | uris: [room.song.uri], 115 | position_ms: progress, 116 | }); 117 | } else if (room.song.isPlaying) { 118 | // && now - lastSynced > 1000 119 | // setLastSynced(now); 120 | let isOutOfSync = true; 121 | if (playbackInformation) { 122 | const progressDifference = Math.abs( 123 | (playbackInformation.progress_ms || 0) - progress 124 | ); 125 | const isSameSong = playbackInformation.item?.id === room.song.id; 126 | console.log('-----------'); 127 | console.log('Progress Difference: ' + progressDifference); 128 | if (progressDifference < 1000 && isSameSong) isOutOfSync = false; 129 | 130 | console.log('In Sync: ' + !isOutOfSync); 131 | if (isOutOfSync) { 132 | console.log('Syncing up...'); 133 | spotifyApi.play({ 134 | uris: [room.song.uri], 135 | position_ms: progress, 136 | }); 137 | } 138 | } 139 | } 140 | if (!isListening) { 141 | addUserToRoom(room, user); 142 | setIsListening(true); 143 | } 144 | } 145 | } else if (!isOwner && !shouldBeListening && isListening && room && user) { 146 | try { 147 | spotifyApi.pause(); 148 | removeUserFromRoom(room, user); 149 | setIsListening(false); 150 | } catch (error) { 151 | console.error(error); 152 | } 153 | } 154 | }, [ 155 | accessToken, 156 | checkingPlayback, 157 | isListening, 158 | isOwner, 159 | playbackInformation, 160 | room, 161 | roomID, 162 | setShouldCheckPlayback, 163 | shouldBeListening, 164 | spotifyApi, 165 | stopSearching, 166 | user, 167 | ]); 168 | 169 | useEffect(() => { 170 | if (room && track && user) { 171 | setIsOwner(room.owner.id === user.details.id); 172 | } 173 | }, [ 174 | track, 175 | user, 176 | isOwner, 177 | checkingPlayback, 178 | setShouldCheckPlayback, 179 | spotifyApi, 180 | room, 181 | ]); 182 | 183 | const buttonColor = isOwner ? {} : { color: '#9EA5B3' }; 184 | const variantColor = isOwner ? { variantColor: 'red' } : {}; 185 | 186 | const handleLeaveRoom = () => { 187 | setStopSearching(true); 188 | if (isOwner) { 189 | if (room && user) { 190 | setIsDeleted(true); 191 | destroyRoom(room, user); 192 | } 193 | } else { 194 | if (user && room) { 195 | spotifyApi.pause(); 196 | removeUserFromRoom(room, user); 197 | } 198 | } 199 | }; 200 | 201 | return ( 202 | 208 | 209 | 219 | 220 | {room && track ? ( 221 | <> 222 | 223 | 224 | 225 | Currently Listening 226 | 227 | {isDeleted ? ( 228 | 229 | 230 | This room has been deleted by the owner. 231 | 232 | 233 | 234 | 235 | 236 | ) : ( 237 | <> 238 | 239 | 240 | {room.listeners ? ( 241 | Object.values(room.listeners).map((listener, index) => ( 242 | 243 | )) 244 | ) : ( 245 | <> 246 | )} 247 | 248 | 267 | 268 | )} 269 | 270 | ) : ( 271 | 272 | 273 | 274 | Not loading? Make sure your room ID is correct and try again. 275 | 276 | 277 | 280 | 281 | 282 | )} 283 | {!room?.isPublic ? ( 284 | 285 | 286 | This is a private room. Share this ID with your friends to allow 287 | them to join: 288 | 289 | {roomID} 290 | 291 | ) : ( 292 | 293 | Room ID: {roomID} 294 | 295 | )} 296 | 297 | ); 298 | }; 299 | 300 | export default Room; 301 | --------------------------------------------------------------------------------