├── server ├── .nvmrc ├── prod.json ├── tslint.json ├── eas.json ├── tsconfig.json ├── package.json └── src │ └── index.ts ├── assets ├── images │ ├── icon.png │ ├── favicon.png │ ├── splash.png │ ├── old │ │ ├── icon.png │ │ ├── splash.png │ │ ├── favicon.png │ │ └── adaptive-icon.png │ ├── adaptive-icon.png │ ├── github-image.jpeg │ └── soundcheck-promotion.jpg └── fonts │ └── SpaceMono-Regular.ttf ├── tsconfig.json ├── babel.config.js ├── metro.config.js ├── components ├── StyledText.tsx ├── __tests__ │ └── StyledText-test.js ├── SwitchComponent.tsx ├── HeaderComponent.tsx ├── ButtonComponent.tsx ├── Themed.tsx ├── TextInputComponent.tsx ├── SpotifyPlayer.tsx ├── GradientText.tsx ├── Button.tsx ├── Card.tsx ├── EditScreenInfo.tsx ├── PlayerGuessDetailsComponent.tsx └── AddNonAuthPlayerModal.tsx ├── types ├── socket.d.ts ├── room.d.ts ├── auth.d.ts ├── socket.ts └── spotify.d.ts ├── .gitignore ├── constants ├── Layout.ts └── Colors.ts ├── utils └── socket.ts ├── index.js ├── hooks ├── useColorScheme.ts ├── useSpotifyAuth.ts ├── useEnviroment.ts ├── useCachedResources.ts └── useSpotify.ts ├── eas.json ├── navigation ├── LinkingConfiguration.ts └── index.tsx ├── screens ├── NotFoundScreen.tsx ├── SoundcheckScreen.tsx ├── JoinRoomScreen.tsx ├── ProfileScreen.tsx ├── SeachScreen.tsx ├── AddPlaylistModal.tsx ├── HomeScreen.tsx ├── CreateRoomScreen.tsx ├── TopArtistsScreen.tsx ├── LoginScreen.tsx ├── TopSongsScreen.tsx └── RoomScreen.tsx ├── app.json ├── README.md ├── types.tsx ├── package.json ├── context └── authContext.tsx └── App.tsx /server/.nvmrc: -------------------------------------------------------------------------------- 1 | 19 -------------------------------------------------------------------------------- /assets/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredrikburmester/soundwrap/HEAD/assets/images/icon.png -------------------------------------------------------------------------------- /assets/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredrikburmester/soundwrap/HEAD/assets/images/favicon.png -------------------------------------------------------------------------------- /assets/images/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredrikburmester/soundwrap/HEAD/assets/images/splash.png -------------------------------------------------------------------------------- /assets/images/old/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredrikburmester/soundwrap/HEAD/assets/images/old/icon.png -------------------------------------------------------------------------------- /assets/images/old/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredrikburmester/soundwrap/HEAD/assets/images/old/splash.png -------------------------------------------------------------------------------- /assets/images/old/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredrikburmester/soundwrap/HEAD/assets/images/old/favicon.png -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "expo/tsconfig.base", 3 | "compilerOptions": { 4 | "strict": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /assets/images/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredrikburmester/soundwrap/HEAD/assets/images/adaptive-icon.png -------------------------------------------------------------------------------- /assets/images/github-image.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredrikburmester/soundwrap/HEAD/assets/images/github-image.jpeg -------------------------------------------------------------------------------- /assets/fonts/SpaceMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredrikburmester/soundwrap/HEAD/assets/fonts/SpaceMono-Regular.ttf -------------------------------------------------------------------------------- /assets/images/old/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredrikburmester/soundwrap/HEAD/assets/images/old/adaptive-icon.png -------------------------------------------------------------------------------- /assets/images/soundcheck-promotion.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredrikburmester/soundwrap/HEAD/assets/images/soundcheck-promotion.jpg -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function(api) { 2 | api.cache(true); 3 | return { 4 | presets: ['babel-preset-expo'] 5 | }; 6 | }; 7 | -------------------------------------------------------------------------------- /server/prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "apps": [ 3 | { 4 | "name": "sc-ts-ws-backend", 5 | "script": "npm run start", 6 | "env": { 7 | } 8 | }, 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /metro.config.js: -------------------------------------------------------------------------------- 1 | // Learn more https://docs.expo.io/guides/customizing-metro 2 | const { getDefaultConfig } = require('expo/metro-config'); 3 | 4 | module.exports = getDefaultConfig(__dirname); 5 | -------------------------------------------------------------------------------- /components/StyledText.tsx: -------------------------------------------------------------------------------- 1 | import { Text, TextProps } from './Themed'; 2 | 3 | export function MonoText(props: TextProps) { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /server/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "warning", 3 | "extends": [ 4 | "tslint:recommended" 5 | ], 6 | "jsRules": {}, 7 | "rules": { 8 | "trailing-comma": [ false ] 9 | }, 10 | "rulesDirectory": [] 11 | } -------------------------------------------------------------------------------- /types/socket.d.ts: -------------------------------------------------------------------------------- 1 | export interface REQUEST_TO_JOIN_ROOM { 2 | roomCode: string; 3 | user: IUser; 4 | } 5 | 6 | export interface REQUEST_TO_CREATE_ROOM { 7 | user: IUser; 8 | roomCode: string; 9 | timeRange: string; 10 | songsPerUser: number; 11 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .expo/ 3 | dist/ 4 | npm-debug.* 5 | *.jks 6 | *.p8 7 | *.p12 8 | *.key 9 | *.mobileprovision 10 | *.orig.* 11 | web-build/ 12 | 13 | # macOS 14 | .DS_Store 15 | 16 | server/logs 17 | server/rooms 18 | ios/Soundcheck/Supporting/Expo.plist 19 | -------------------------------------------------------------------------------- /constants/Layout.ts: -------------------------------------------------------------------------------- 1 | import { Dimensions } from 'react-native'; 2 | 3 | const width = Dimensions.get('window').width; 4 | const height = Dimensions.get('window').height; 5 | 6 | export default { 7 | window: { 8 | width, 9 | height, 10 | }, 11 | isSmallDevice: width < 375, 12 | }; 13 | -------------------------------------------------------------------------------- /types/room.d.ts: -------------------------------------------------------------------------------- 1 | import { SongItem } from "./spotify" 2 | 3 | export interface IRoom { 4 | roomCode: string; 5 | host: IUser; 6 | players: IUser[]; 7 | gamePosition: number; 8 | songs: {song: SongItem, player: IUser}[] 9 | songsPerUser: number; 10 | currentSongIndex: number; 11 | timeRange: string; 12 | } -------------------------------------------------------------------------------- /utils/socket.ts: -------------------------------------------------------------------------------- 1 | // // @ts-nocheck 2 | import { io } from "socket.io-client"; 3 | import { getEnvVars } from '../hooks/useEnviroment'; 4 | 5 | const env = getEnvVars(); 6 | 7 | const socket = io(env.wsurl, { 8 | path: '/ws', 9 | autoConnect: true, 10 | transports: ['websocket'], 11 | }); 12 | 13 | export default socket; -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import { registerRootComponent } from 'expo'; 2 | 3 | import App from './App'; 4 | 5 | // registerRootComponent calls AppRegistry.registerComponent('main', () => App); 6 | // It also ensures that whether you load the app in Expo Go or in a native build, 7 | // the environment is set up appropriately 8 | registerRootComponent(App); 9 | -------------------------------------------------------------------------------- /components/__tests__/StyledText-test.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | 4 | import { MonoText } from '../StyledText'; 5 | 6 | it(`renders correctly`, () => { 7 | const tree = renderer.create(Snapshot test!).toJSON(); 8 | 9 | expect(tree).toMatchSnapshot(); 10 | }); 11 | -------------------------------------------------------------------------------- /server/eas.json: -------------------------------------------------------------------------------- 1 | { 2 | "cli": { 3 | "version": ">= 2.8.0" 4 | }, 5 | "build": { 6 | "development": { 7 | "developmentClient": true, 8 | "distribution": "internal" 9 | }, 10 | "preview": { 11 | "distribution": "internal" 12 | }, 13 | "production": {} 14 | }, 15 | "submit": { 16 | "production": {} 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /hooks/useColorScheme.ts: -------------------------------------------------------------------------------- 1 | import { ColorSchemeName, useColorScheme as _useColorScheme } from 'react-native'; 2 | 3 | // The useColorScheme value is always either light or dark, but the built-in 4 | // type suggests that it can be null. This will not happen in practice, so this 5 | // makes it a bit easier to work with. 6 | export default function useColorScheme(): NonNullable { 7 | return _useColorScheme() as NonNullable; 8 | } 9 | -------------------------------------------------------------------------------- /components/SwitchComponent.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, } from "react" 2 | import { Switch } from "react-native" 3 | 4 | interface Props { 5 | value: boolean 6 | onValueChange: (value: boolean) => void 7 | } 8 | 9 | export const SwitchComponent: React.FC = ({ value, onValueChange }) => { 10 | 11 | return ( 12 | 18 | ) 19 | } -------------------------------------------------------------------------------- /types/auth.d.ts: -------------------------------------------------------------------------------- 1 | export interface IGuess { 2 | currentSongIndex: number, 3 | guess: string 4 | } 5 | 6 | export interface IUser { 7 | id: string; 8 | name: string; 9 | avatar: string; 10 | score: number; 11 | guesses: IGuess[] 12 | } 13 | 14 | export interface IAuth { 15 | authenticated: boolean, 16 | token: string, 17 | user: IUser | null, 18 | } 19 | 20 | export type AuthContextType = { 21 | auth: IAuth; 22 | login: (token: string) => void; 23 | logout: () => void; 24 | isAuthenticated: () => Promise; 25 | }; 26 | 27 | export type NonAuthUser = { 28 | name: string; 29 | songs: Item[]; 30 | } -------------------------------------------------------------------------------- /eas.json: -------------------------------------------------------------------------------- 1 | { 2 | "cli": { 3 | "version": ">= 2.7.1" 4 | }, 5 | "build": { 6 | "development": { 7 | "developmentClient": true, 8 | "distribution": "internal", 9 | "env": { "ANDROID_HOME": "/Applications/Android Studio.app/Contents/jre/Contents/Home" } 10 | }, 11 | "preview": { 12 | "distribution": "internal", 13 | "env": { "ANDROID_HOME": "/Applications/Android Studio.app/Contents/jre/Contents/Home" } 14 | }, 15 | "production": { 16 | "env": { "ANDROID_HOME": "/Applications/Android Studio.app/Contents/jre/Contents/Home" } 17 | } 18 | }, 19 | "submit": { 20 | "production": {} 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 4 | "strict": true, /* Enable all strict type-checking options. */ 5 | "skipLibCheck": true, /* Skip type checking all .d.ts files. */ 6 | "module": "commonjs", 7 | "esModuleInterop": true, 8 | "target": "es6", 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "sourceMap": true, 12 | "outDir": "./dist", 13 | "baseUrl": "./src", 14 | "paths": { 15 | "*": [ 16 | "node_modules/*" 17 | ] 18 | } 19 | }, 20 | "include": [ 21 | "src/**/*" 22 | ] 23 | } -------------------------------------------------------------------------------- /components/HeaderComponent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { TouchableOpacity, View } from 'react-native' 3 | import { SafeAreaView } from 'react-native-safe-area-context' 4 | import { Text } from '../components/Themed' 5 | import Colors from '../constants/Colors' 6 | 7 | interface Props { 8 | title?: string 9 | } 10 | 11 | export const HeaderComponent: React.FC = ({ title }) => { 12 | return ( 13 | 14 | 15 | Home 16 | 17 | 18 | ) 19 | } -------------------------------------------------------------------------------- /constants/Colors.ts: -------------------------------------------------------------------------------- 1 | const tintColorLight = '#1F1F1E'; 2 | const tintColorDark = '#1F1F1E'; 3 | 4 | export default { 5 | light: { 6 | text: 'white', 7 | background: '#1F1F1E', 8 | tint: tintColorDark, 9 | tabIconDefault: '#ccc', 10 | tabIconSelected: tintColorDark, 11 | primary: '#239637', 12 | }, 13 | dark: { 14 | text: 'white', 15 | background: '#1F1F1E', 16 | tint: tintColorDark, 17 | tabIconDefault: '#ccc', 18 | tabIconSelected: tintColorDark, 19 | primary: '#239637', 20 | }, 21 | primary: '#22B458', 22 | primaryLight: '#31D86E', 23 | secondary: '#DB7C26', 24 | third: '#3891A6', 25 | background: '#1F1F1E', 26 | backgroundDark: '#1a1a19', 27 | error: '#BB0A21', 28 | primaryDark: '#17783A' 29 | }; 30 | -------------------------------------------------------------------------------- /types/socket.ts: -------------------------------------------------------------------------------- 1 | export const enum ClientEmits { 2 | REQUEST_TO_JOIN_ROOM = "requestToJoinRoom", 3 | REQUEST_TO_CREATE_ROOM = "requestToCreateRoom", 4 | JOIN_ROOM = "joinRoom", 5 | GUESS = "guess", 6 | LEAVE_ROOM = "leaveRoom", 7 | START_GAME = "startGame", 8 | NEXT_SONG = "nextSong", 9 | DISCONNECT = "disconnect", 10 | } 11 | 12 | export const enum ServerEmits { 13 | REQUEST_TO_JOIN_ROOM_ACCEPTED = "requestToJoinRoomAccepted", 14 | REQUEST_TO_JOIN_ROOM_REJECTED = "requestToJoinRoomRejected", 15 | REQUEST_TO_CREATE_ROOM_ACCEPTED = "requestToCreateRoomAccepted", 16 | REQUEST_TO_CREATE_ROOM_REJECTED = "requestToCreateRoomRejected", 17 | NO_SONGS_AVAILABLE = "noSongsAvailable", 18 | ROOM_UPDATED = "roomUpdated", 19 | PLAYER_MADE_A_GUESS = "playerMadeAGuess", 20 | } -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "dist/server/src/index.js", 6 | "scripts": { 7 | "prebuild": "tslint -c tslint.json -p tsconfig.json --fix", 8 | "build": "tsc", 9 | "prestart": "npm run build", 10 | "start": "node ." 11 | }, 12 | "engines" : { 13 | "node" : ">=19.0.0" 14 | }, 15 | "author": "", 16 | "type": "commonjs", 17 | "license": "ISC", 18 | "devDependencies": { 19 | "@types/express": "^4.17.14", 20 | "@types/node": "^16.18.3", 21 | "concurrently": "^7.6.0", 22 | "nodemon": "^2.0.20", 23 | "tslint": "^6.1.3", 24 | "typescript": "^4.9.3" 25 | }, 26 | "dependencies": { 27 | "cors": "^2.8.5", 28 | "dotenv": "^16.0.3", 29 | "express": "^4.18.2", 30 | "socket.io": "^4.4.1" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /hooks/useSpotifyAuth.ts: -------------------------------------------------------------------------------- 1 | import { SpotifyMeResult } from "../types/spotify" 2 | 3 | export const useSpotifyAuth = () => { 4 | const getMe = async (token: string) => { 5 | const response = await fetch("https://api.spotify.com/v1/me", { 6 | headers: { 7 | Authorization: `Bearer ${token}`, 8 | }, 9 | }); 10 | 11 | if(response.status !== 200) { 12 | throw new Error("Failed to fetch user data"); 13 | } 14 | 15 | return response.json() as Promise; 16 | } 17 | 18 | const getTokenStatus = async (token: string) => { 19 | const response = await fetch("https://api.spotify.com/v1/me", { 20 | headers: { 21 | Authorization: `Bearer ${token}`, 22 | }, 23 | }); 24 | 25 | return response.status; 26 | } 27 | 28 | return { 29 | getMe, 30 | getTokenStatus, 31 | }; 32 | } -------------------------------------------------------------------------------- /hooks/useEnviroment.ts: -------------------------------------------------------------------------------- 1 | import Constants from 'expo-constants'; 2 | import { Platform } from 'react-native'; 3 | 4 | interface IEnv { 5 | development: { 6 | wsurl: string; 7 | }, 8 | preview: { 9 | wsurl: string; 10 | }, 11 | production: { 12 | wsurl: string; 13 | } 14 | } 15 | 16 | 17 | const ENV: IEnv = { 18 | development: { 19 | wsurl: 'http://localhost:5000', 20 | }, 21 | preview: { 22 | wsurl: 'https://ws.soundwrap.app', 23 | }, 24 | production: { 25 | wsurl: 'https://ws.soundwrap.app', 26 | }, 27 | }; 28 | 29 | export const getEnvVars = (env = Constants.manifest?.releaseChannel): { wsurl: string } => { 30 | // What is __DEV__ ? 31 | // This variable is set to true when react-native is running in Dev mode. 32 | // __DEV__ is true when run locally, but false when published. 33 | if (__DEV__) { 34 | return ENV.development; 35 | } else if (env === 'staging') { 36 | return ENV.preview; 37 | } 38 | 39 | return ENV.production; 40 | }; -------------------------------------------------------------------------------- /navigation/LinkingConfiguration.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Learn more about deep linking with React Navigation 3 | * https://reactnavigation.org/docs/deep-linking 4 | * https://reactnavigation.org/docs/configuring-links 5 | */ 6 | 7 | import { LinkingOptions } from '@react-navigation/native'; 8 | import * as Linking from 'expo-linking'; 9 | 10 | import { RootStackParamList } from '../types'; 11 | 12 | const linking: LinkingOptions = { 13 | prefixes: [Linking.createURL('/')], 14 | config: { 15 | screens: { 16 | Login: 'login', 17 | Home: 'home', 18 | Profile: 'profile', 19 | TopSongs: 'top-songs', 20 | TopArtists: 'top-artists', 21 | Soundcheck: 'soundcheck', 22 | Search: 'search', 23 | Create: 'create', 24 | Room: 'room', 25 | Join: 'join', 26 | AddNonAuthPlayerModal: 'add-non-auth-player', 27 | PlayerGuessDetails: 'player-guess-details', 28 | NotFound: '*', 29 | }, 30 | }, 31 | }; 32 | 33 | export default linking; 34 | -------------------------------------------------------------------------------- /screens/NotFoundScreen.tsx: -------------------------------------------------------------------------------- 1 | import { StyleSheet, TouchableOpacity } from 'react-native'; 2 | 3 | import { Text, View } from '../components/Themed'; 4 | import { RootStackScreenProps } from '../types'; 5 | 6 | export default function NotFoundScreen({ navigation }: RootStackScreenProps<'NotFound'>) { 7 | return ( 8 | 9 | This screen doesn't exist. 10 | navigation.replace('Root')} style={styles.link}> 11 | Go to home screen! 12 | 13 | 14 | ); 15 | } 16 | 17 | const styles = StyleSheet.create({ 18 | container: { 19 | flex: 1, 20 | alignItems: 'center', 21 | justifyContent: 'center', 22 | padding: 20, 23 | }, 24 | title: { 25 | fontSize: 20, 26 | fontWeight: 'bold', 27 | }, 28 | link: { 29 | marginTop: 15, 30 | paddingVertical: 15, 31 | }, 32 | linkText: { 33 | fontSize: 14, 34 | color: '#2e78b7', 35 | }, 36 | }); 37 | -------------------------------------------------------------------------------- /components/ButtonComponent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { TouchableOpacity, View, } from 'react-native' 3 | import { Text } from '../components/Themed' 4 | import Colors from '../constants/Colors' 5 | 6 | interface Props { 7 | title?: string 8 | onPress: () => void 9 | color?: string 10 | size?: 'sm' | 'lg' 11 | style?: any 12 | children?: any 13 | } 14 | 15 | export const ButtonComponent: React.FC = ({ title, onPress, color, size, style, children }) => { 16 | 17 | return ( 18 | onPress()} 29 | > 30 | {title && {title}} 31 | {children && 32 | {children} 33 | } 34 | 35 | ) 36 | } -------------------------------------------------------------------------------- /hooks/useCachedResources.ts: -------------------------------------------------------------------------------- 1 | import { FontAwesome } from '@expo/vector-icons'; 2 | import * as Font from 'expo-font'; 3 | import * as SplashScreen from 'expo-splash-screen'; 4 | import { useEffect, useState } from 'react'; 5 | 6 | export default function useCachedResources() { 7 | const [isLoadingComplete, setLoadingComplete] = useState(false); 8 | 9 | // Load any resources or data that we need prior to rendering the app 10 | useEffect(() => { 11 | async function loadResourcesAndDataAsync() { 12 | try { 13 | SplashScreen.preventAutoHideAsync(); 14 | 15 | // Load fonts 16 | await Font.loadAsync({ 17 | ...FontAwesome.font, 18 | 'space-mono': require('../assets/fonts/SpaceMono-Regular.ttf'), 19 | }); 20 | } catch (e) { 21 | // We might want to provide this error information to an error reporting service 22 | console.warn(e); 23 | } finally { 24 | setLoadingComplete(true); 25 | SplashScreen.hideAsync(); 26 | } 27 | } 28 | 29 | loadResourcesAndDataAsync(); 30 | }, []); 31 | 32 | return isLoadingComplete; 33 | } 34 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "Soundwrap", 4 | "slug": "soundcheckgame", 5 | "version": "1.0.14", 6 | "orientation": "portrait", 7 | "icon": "./assets/images/icon.png", 8 | "scheme": "soundcheckgame", 9 | "userInterfaceStyle": "automatic", 10 | "splash": { 11 | "image": "./assets/images/splash.png", 12 | "resizeMode": "contain", 13 | "backgroundColor": "#1F1F1E" 14 | }, 15 | "updates": { 16 | "fallbackToCacheTimeout": 0, 17 | "url": "https://u.expo.dev/1dd17ac9-40f0-4f3d-94a7-5dc33e719e93" 18 | }, 19 | "assetBundlePatterns": [ 20 | "**/*" 21 | ], 22 | "ios": { 23 | "supportsTablet": true, 24 | "bundleIdentifier": "com.fredrikburmester.soundcheckgame" 25 | }, 26 | "android": { 27 | "adaptiveIcon": { 28 | "foregroundImage": "./assets/images/adaptive-icon.png", 29 | "backgroundColor": "#1F1F1E" 30 | }, 31 | "package": "com.fredrikburmester.soundcheckgame" 32 | }, 33 | "web": { 34 | "favicon": "./assets/images/favicon.png" 35 | }, 36 | "extra": { 37 | "eas": { 38 | "projectId": "da76d42b-746e-4f97-9110-5fe5d50cec16" 39 | } 40 | }, 41 | "runtimeVersion": { 42 | "policy": "sdkVersion" 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Soundwrap 3 | 4 | ⚠️ This app and repository is not in use. A new (web-based) version of this game can be found at [https://soundwrap.app](https://soundwrap.app) 5 | 6 | ----- 7 | 8 | **Check out your top songs and artists from Spotify, create playlists, join your friends and guess each others favourite songs.** 9 | 10 | ![statping](https://img.shields.io/badge/dynamic/json?color=purple&label=Soundwrap&query=%24.services&url=https://status.soundwrap.app/health&suffix=%20services) 11 | 12 | ![statping](https://img.shields.io/badge/dynamic/json?url=https://status.soundwrap.app/api/services/9&label=Soundwrap%20Backend&query=%24.online_24_hours&suffix=%%20uptime%20last%2024h) 13 | 14 | [Soundwrap Status Website](http://status.soundwrap.app) 15 | 16 | ![Soundwrap](./assets/images/github-image.jpeg) 17 | 18 | **If you want to support this app and what I do:** 19 | 20 | [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/donate/?hosted_button_id=HAA8RD9LJQ2ZW) 21 | 22 | Buy Me A Coffee 23 | -------------------------------------------------------------------------------- /screens/SoundcheckScreen.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect } from 'react' 2 | import { ScrollView } from 'react-native' 3 | import { RootStackScreenProps } from '../types' 4 | import { Card } from '../components/Card' 5 | import Colors from '../constants/Colors' 6 | import useColorScheme from '../hooks/useColorScheme' 7 | import { AuthContext } from '../context/authContext' 8 | import { AuthContextType } from '../types/auth' 9 | 10 | export default function SoundcheckScreen({ navigation }: RootStackScreenProps<'Soundcheck'>) { 11 | const colorScheme = useColorScheme() 12 | const { auth } = useContext(AuthContext) as AuthContextType 13 | 14 | useEffect(() => { 15 | navigation.setOptions({ 16 | headerStyle: { 17 | backgroundColor: Colors.background, 18 | }, 19 | headerBlurEffect: 'dark', 20 | title: "Let's play!" 21 | }) 22 | 23 | }, []) 24 | 25 | return ( 26 | 27 | navigation.navigate('Join')} style={{ height: 100, marginBottom: 20 }} /> 28 | navigation.navigate('Create')} style={{ marginBottom: 20, backgroundColor: Colors.primaryDark }} /> 29 | 30 | ) 31 | } 32 | 33 | 34 | -------------------------------------------------------------------------------- /components/Themed.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Learn more about Light and Dark modes: 3 | * https://docs.expo.io/guides/color-schemes/ 4 | */ 5 | 6 | import { Text as DefaultText, View as DefaultView } from 'react-native'; 7 | 8 | import Colors from '../constants/Colors'; 9 | import useColorScheme from '../hooks/useColorScheme'; 10 | 11 | export function useThemeColor( 12 | props: { light?: string; dark?: string }, 13 | colorName: keyof typeof Colors.light & keyof typeof Colors.dark 14 | ) { 15 | const theme = useColorScheme(); 16 | const colorFromProps = props[theme]; 17 | 18 | if (colorFromProps) { 19 | return colorFromProps; 20 | } else { 21 | return Colors[theme][colorName]; 22 | } 23 | } 24 | 25 | type ThemeProps = { 26 | lightColor?: string; 27 | darkColor?: string; 28 | }; 29 | 30 | export type TextProps = ThemeProps & DefaultText['props']; 31 | export type ViewProps = ThemeProps & DefaultView['props']; 32 | 33 | export function Text(props: TextProps) { 34 | const { style, lightColor, darkColor, ...otherProps } = props; 35 | const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text'); 36 | 37 | return ; 38 | } 39 | 40 | export function View(props: ViewProps) { 41 | const { style, lightColor, darkColor, ...otherProps } = props; 42 | const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background'); 43 | 44 | return ; 45 | } 46 | -------------------------------------------------------------------------------- /components/TextInputComponent.tsx: -------------------------------------------------------------------------------- 1 | import { View, Text, TextInput, Pressable } from "react-native" 2 | import Colors from "../constants/Colors" 3 | import React, { useRef } from "react" 4 | 5 | interface Props { 6 | title: string 7 | value: string 8 | onChange: (value: string) => void 9 | autoCapitalize?: 'none' | 'sentences' | 'words' | 'characters' 10 | placeholder?: string 11 | autoFocus?: boolean 12 | } 13 | 14 | export const TextInputComponent: React.FC = ({ title, onChange, value, autoCapitalize, placeholder, autoFocus }) => { 15 | let inputRef = useRef(null) 16 | 17 | return ( 18 | { 19 | if(inputRef && inputRef.current) { 20 | inputRef.current.focus() 21 | } 22 | } 23 | }> 24 | 33 | {title} 34 | 48 | 49 | 50 | 51 | ) 52 | } 53 | 54 | -------------------------------------------------------------------------------- /screens/JoinRoomScreen.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect, useState } from 'react' 2 | import { StyleSheet, Image, FlatList, TouchableOpacity, ScrollView } from 'react-native' 3 | import { RootStackParamList, RootStackScreenProps } from '../types' 4 | import Colors from '../constants/Colors' 5 | import useColorScheme from '../hooks/useColorScheme' 6 | import { TextInputComponent } from '../components/TextInputComponent' 7 | import { ButtonComponent } from '../components/ButtonComponent' 8 | 9 | export default function JoinRoomScreen({ navigation }: RootStackScreenProps<'Join'>) { 10 | const colorScheme = useColorScheme() 11 | const [roomCode, setRoomCode] = useState('') 12 | 13 | const joinRoom = () => { 14 | navigation.navigate('Room', { roomCode: roomCode, songsPerUser: undefined, timeRange: undefined, createRoom: false, nonAuthUser: undefined }) 15 | } 16 | 17 | useEffect(() => { 18 | navigation.setOptions({ 19 | title: 'Join Room', 20 | headerBackTitle: 'Back', 21 | headerStyle: { 22 | backgroundColor: Colors.background, 23 | }, 24 | headerBlurEffect: 'dark', 25 | }) 26 | }, []) 27 | 28 | return ( 29 | 30 | { 33 | setRoomCode(value) 34 | }} 35 | value={roomCode} 36 | placeholder="(ex. GFDS)" 37 | autoCapitalize='characters' 38 | autoFocus={false} /> 39 | 40 | 41 | ) 42 | } 43 | 44 | 45 | -------------------------------------------------------------------------------- /types.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Learn more about using TypeScript with React Navigation: 3 | * https://reactnavigation.org/docs/typescript/ 4 | */ 5 | 6 | import { BottomTabScreenProps } from '@react-navigation/bottom-tabs' 7 | import { CompositeScreenProps, NavigatorScreenParams } from '@react-navigation/native' 8 | import { NativeStackScreenProps } from '@react-navigation/native-stack' 9 | import { IUser, NonAuthUser } from './types/auth' 10 | import { SongItem } from './types/spotify' 11 | 12 | declare global { 13 | namespace ReactNavigation { 14 | interface RootParamList extends RootStackParamList { } 15 | } 16 | } 17 | 18 | export type RootStackParamList = { 19 | // Root: NavigatorScreenParams | undefined 20 | AddPlaylistModal: undefined 21 | NotFound: undefined 22 | Login: undefined 23 | TopSongs: undefined 24 | TopArtists: undefined 25 | Soundcheck: undefined 26 | Create: undefined 27 | Join: undefined 28 | Home: undefined 29 | Profile: undefined 30 | Search: undefined 31 | AddNonAuthPlayerModal: undefined 32 | PlayerGuessDetails: { user: IUser, songs: {song: SongItem, player: IUser}[]} 33 | Room: { roomCode: string, songsPerUser: number | undefined, timeRange: string | undefined, createRoom: boolean, nonAuthUser: NonAuthUser | undefined } 34 | } 35 | 36 | export type RootStackScreenProps = NativeStackScreenProps< 37 | RootStackParamList, 38 | Screen 39 | > 40 | 41 | // export type RootTabParamList = { 42 | // TabOne: undefined 43 | // TabTwo: undefined 44 | // } 45 | 46 | // export type RootTabScreenProps = CompositeScreenProps< 47 | // BottomTabScreenProps, 48 | // NativeStackScreenProps 49 | // > 50 | -------------------------------------------------------------------------------- /screens/ProfileScreen.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect } from 'react' 2 | import { ScrollView, StyleSheet, Image } from 'react-native' 3 | import { AuthContextType } from '../types/auth' 4 | import { AuthContext } from '../context/authContext' 5 | import { Card } from '../components/Card' 6 | import Colors from '../constants/Colors' 7 | import { RootStackScreenProps } from '../types' 8 | 9 | import * as WebBrowser from 'expo-web-browser' 10 | 11 | export default function TabTwoScreen({ navigation }: RootStackScreenProps<'Profile'>) { 12 | 13 | const { logout } = useContext(AuthContext) as AuthContextType 14 | 15 | const openSpotifyAndLogOut = async () => { 16 | let result = await WebBrowser.openAuthSessionAsync('https://accounts.spotify.com/logout/', 'soundcheckgame://', { 17 | showInRecents: false, 18 | showTitle: false, 19 | }).then(({ type }: { type: any }) => { 20 | logout() 21 | // how do i close the broswer window? 22 | }) 23 | } 24 | 25 | useEffect(() => { 26 | navigation.setOptions({ 27 | headerLargeTitle: true, 28 | headerLargeStyle: { 29 | backgroundColor: Colors.background, 30 | }, 31 | headerBlurEffect: 'dark', 32 | }) 33 | }, []) 34 | 35 | return ( 36 | 37 | 38 | 39 | 40 | ) 41 | } 42 | 43 | -------------------------------------------------------------------------------- /components/SpotifyPlayer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect, useState } from "react" 2 | import { AuthContext } from "../context/authContext" 3 | import { AuthContextType } from "../types/auth" 4 | import { WebView } from 'react-native-webview' 5 | import Colors from "../constants/Colors" 6 | import { ActivityIndicator } from "react-native" 7 | 8 | interface Props { 9 | title?: string 10 | onPress?: () => void 11 | songUri?: string 12 | } 13 | export const SpotifyPlayer: React.FC = ({ title, songUri }) => { 14 | const correctSongUri = songUri?.replace('spotify:track:', '') 15 | 16 | // https://open.spotify.com/embed/track/4iV5W9uYEdYUVa79Axb7Rh 17 | const [opacity, setOpacity] = useState(0) 18 | return ( 19 | <> 20 | {!opacity && } 21 | setOpacity(1)} 24 | useWebKit={true} 25 | originWhitelist={['*']} 26 | allowsInlineMediaPlayback={true} 27 | source={{ 28 | html: ` 29 | 30 | 31 | 32 | 33 | 34 | 35 | 47 | 48 | 49 | 50 | 51 | 52 | ` }} /> 53 | 54 | ) 55 | } -------------------------------------------------------------------------------- /components/GradientText.tsx: -------------------------------------------------------------------------------- 1 | import MaskedView from "@react-native-masked-view/masked-view" 2 | import { LinearGradient } from "expo-linear-gradient" 3 | import React, { useEffect, useRef } from "react" 4 | import { Animated, Easing } from "react-native" 5 | import Colors from "../constants/Colors" 6 | import { Text, View } from '../components/Themed' 7 | import { transform } from "@babel/core" 8 | 9 | interface Props { 10 | text: string 11 | style: any 12 | } 13 | 14 | export const GradientText: React.FC = ({ style, text }) => { 15 | const fadeAnim = useRef(new Animated.Value(0)).current 16 | 17 | useEffect(() => { 18 | Animated.timing(fadeAnim, { 19 | useNativeDriver: true, 20 | toValue: 1, 21 | duration: 50000, 22 | easing: Easing.linear 23 | }).start() 24 | }, [fadeAnim]) 25 | 26 | let ga = [] 27 | for (let i = 0; i < 20; i++) { 28 | ga.push(Colors.primaryDark) 29 | ga.push(Colors.primaryDark) 30 | ga.push(Colors.primaryDark) 31 | ga.push(Colors.primaryLight) 32 | } 33 | 34 | return ( 35 | 36 | 43 | 49 | {text} 50 | 51 | 52 | } 53 | > 54 | 69 | 75 | 76 | 77 | ) 78 | } -------------------------------------------------------------------------------- /components/Button.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react' 3 | import { Text, View, StyleSheet, Pressable, ActivityIndicator, TouchableHighlight, TouchableOpacity } from 'react-native' 4 | 5 | interface Props { 6 | title: string 7 | disabled?: boolean 8 | color?: 'green' | 'red' 9 | size?: 'sm' | 'lg' 10 | onPress?: () => void 11 | } 12 | 13 | const activeButton = ({ onPress, title, color, size }: Props) => { 14 | let bg = 'white' 15 | if (color === 'green') { 16 | bg = '#1DB753' 17 | } else if (color === 'red') { 18 | bg = '#FF0000' 19 | } 20 | 21 | const styles = StyleSheet.create({ 22 | button: { 23 | alignItems: 'center', 24 | justifyContent: 'center', 25 | paddingVertical: size == 'lg' ? 12 : 2, 26 | borderRadius: 50, 27 | border: 'solid', 28 | borderWidth: 3, 29 | borderColor: '#1DB753', 30 | elevation: 3, 31 | backgroundColor: 'transparent', 32 | width: size == 'lg' ? 150 : 135, 33 | }, 34 | text: { 35 | fontSize: 14, 36 | lineHeight: 21, 37 | fontWeight: 'bold', 38 | letterSpacing: 0.25, 39 | color: 'white', 40 | }, 41 | }) 42 | 43 | return ( 44 | 45 | {title} 46 | 47 | ) 48 | } 49 | 50 | const inactiveButton = ({ size, title }: Props) => { 51 | const styles = StyleSheet.create({ 52 | inactiveButton: { 53 | alignItems: 'center', 54 | justifyContent: 'center', 55 | paddingVertical: size == 'lg' ? 12 : 2, 56 | borderRadius: 50, 57 | border: 'solid', 58 | borderWidth: 3, 59 | borderColor: '#1DB753', 60 | elevation: 3, 61 | backgroundColor: 'transparent', 62 | width: size == 'lg' ? 150 : 135, 63 | }, 64 | 65 | }) 66 | return ( 67 | 68 | 69 | 70 | ) 71 | } 72 | 73 | export default function Button(props: Props) { 74 | const { onPress, title, disabled } = props 75 | return disabled ? inactiveButton(props) : activeButton(props) 76 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "soundcheckgame", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "android": "expo run:android", 6 | "ios": "expo run:ios", 7 | "web": "expo start --web", 8 | "test": "jest --watchAll", 9 | "start": "expo start --dev-client" 10 | }, 11 | "jest": { 12 | "preset": "jest-expo" 13 | }, 14 | "dependencies": { 15 | "@expo/vector-icons": "^13.0.0", 16 | "@react-native-async-storage/async-storage": "~1.17.3", 17 | "@react-native-masked-view/masked-view": "0.2.8", 18 | "@react-native-picker/picker": "2.4.8", 19 | "@react-native-segmented-control/segmented-control": "2.4.0", 20 | "@react-navigation/bottom-tabs": "^6.0.5", 21 | "@react-navigation/native": "^6.0.2", 22 | "@react-navigation/native-stack": "^6.1.0", 23 | "@shopify/flash-list": "1.3.1", 24 | "expo": "~47.0.8", 25 | "expo-asset": "~8.6.2", 26 | "expo-auth-session": "~3.7.2", 27 | "expo-blur": "~12.0.1", 28 | "expo-constants": "~14.0.2", 29 | "expo-font": "~11.0.1", 30 | "expo-haptics": "~12.0.1", 31 | "expo-linear-gradient": "~12.0.1", 32 | "expo-linking": "~3.2.3", 33 | "expo-random": "~13.0.0", 34 | "expo-splash-screen": "~0.17.5", 35 | "expo-status-bar": "~1.4.2", 36 | "expo-system-ui": "~2.0.1", 37 | "expo-updates": "~0.15.6", 38 | "expo-web-browser": "~12.0.0", 39 | "react": "18.1.0", 40 | "react-dom": "18.1.0", 41 | "react-native": "0.70.5", 42 | "react-native-platform-searchbar": "^1.2.3", 43 | "react-native-safe-area-context": "4.4.1", 44 | "react-native-screens": "~3.18.0", 45 | "react-native-svg": "13.4.0", 46 | "react-native-toast-message": "^2.1.5", 47 | "react-native-web": "~0.18.9", 48 | "react-native-webview": "11.23.1", 49 | "socket.io-client": "^4.4.1", 50 | "spotify-web-api-node": "^5.0.2" 51 | }, 52 | "devDependencies": { 53 | "@babel/core": "^7.12.9", 54 | "@types/react": "~18.0.24", 55 | "@types/react-native": "~0.70.6", 56 | "jest": "^26.6.3", 57 | "jest-expo": "~47.0.1", 58 | "react-test-renderer": "18.1.0", 59 | "typescript": "^4.6.3" 60 | }, 61 | "private": true 62 | } 63 | -------------------------------------------------------------------------------- /context/authContext.tsx: -------------------------------------------------------------------------------- 1 | import { useSpotifyAuth } from '../hooks/useSpotifyAuth' 2 | import { createContext, useEffect, useState } from 'react' 3 | import { AuthContextType, IAuth, IUser } from '../types/auth' 4 | import AsyncStorage from '@react-native-async-storage/async-storage' 5 | 6 | interface Props { 7 | children: React.ReactNode 8 | } 9 | 10 | export const AuthContext = createContext(null) 11 | 12 | const AuthProvider: React.FC = ({ children }) => { 13 | const [auth, setAuth] = useState( 14 | { 15 | authenticated: false, 16 | token: '', 17 | user: null, 18 | } 19 | ) 20 | 21 | const { getMe } = useSpotifyAuth() 22 | 23 | const isAuthenticated = async () => { 24 | const jsonValue = await AsyncStorage.getItem('@auth') 25 | if (jsonValue) { 26 | const authObject = JSON.parse(jsonValue) as IAuth 27 | if (authObject) { 28 | setAuth({ 29 | authenticated: false, 30 | token: '', 31 | user: null, 32 | }) 33 | } 34 | } 35 | return auth.authenticated 36 | } 37 | 38 | const login = async (token: string) => { 39 | try { 40 | const res = await getMe(token) 41 | 42 | const me = { 43 | id: res.id, 44 | name: res.display_name, 45 | avatar: res.images.length > 0 ? res.images[0].url : 'https://picsum.photos/200', 46 | score: 0, 47 | guesses: [] 48 | } 49 | 50 | const authObject = { 51 | authenticated: true, 52 | token: token, 53 | user: me, 54 | } 55 | 56 | setAuth(authObject) 57 | 58 | await AsyncStorage.setItem('@auth', JSON.stringify(authObject)) 59 | } catch (e) { 60 | console.log('[error] getting me') 61 | } 62 | } 63 | 64 | const logout = async () => { 65 | setAuth({ authenticated: false, token: '', user: null }) 66 | await AsyncStorage.removeItem('@auth') 67 | } 68 | 69 | useEffect(() => { 70 | isAuthenticated() 71 | }, []) 72 | 73 | return ( 74 | 75 | {children} 76 | 77 | ) 78 | } 79 | 80 | export default AuthProvider; 81 | 82 | 83 | -------------------------------------------------------------------------------- /components/Card.tsx: -------------------------------------------------------------------------------- 1 | import { View, Text, StyleSheet, Touchable, TouchableOpacity, TouchableHighlight } from "react-native" 2 | import Colors from "../constants/Colors" 3 | import Button from "./Button" 4 | import useColorScheme from '../hooks/useColorScheme' 5 | import React from "react" 6 | import Ionicons from '@expo/vector-icons/Ionicons' 7 | import * as Haptics from 'expo-haptics' 8 | 9 | interface Props { 10 | title: string 11 | description: string 12 | color?: 'error' | string 13 | onPress: () => void 14 | style?: any 15 | iconRight?: string 16 | iconLeft?: string 17 | onAdd?: () => void 18 | } 19 | 20 | export const Card: React.FC = ({ title, description, onPress, color, style, iconRight, iconLeft }) => { 21 | const colorScheme = useColorScheme() 22 | 23 | let _color = Colors.primary 24 | 25 | if (color === 'error') { 26 | _color = Colors.error 27 | } else if (color) { 28 | _color = color 29 | } 30 | 31 | const styles = StyleSheet.create({ 32 | card: { 33 | padding: 18, 34 | backgroundColor: _color, 35 | borderRadius: 10, 36 | justifyContent: 'center', 37 | }, 38 | cardTitle: { 39 | fontSize: 18, 40 | fontWeight: 'bold', 41 | color: Colors[colorScheme].text, 42 | }, 43 | cardDescription: { 44 | fontSize: 14, 45 | color: Colors[colorScheme].text, 46 | opacity: 0.6, 47 | }, 48 | }) 49 | 50 | const click = () => { 51 | Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light) 52 | onPress() 53 | } 54 | 55 | return ( 56 | 57 | 58 | { /* @ts-ignore */} 59 | {iconLeft && } 60 | 61 | {description} 62 | {title} 63 | 64 | { /* @ts-ignore */} 65 | 66 | 67 | 68 | ) 69 | } 70 | 71 | -------------------------------------------------------------------------------- /screens/SeachScreen.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useState } from "react" 2 | import { View, Text, ActivityIndicator, ScrollView } from "react-native" 3 | import { RootStackScreenProps } from "../types" 4 | import SearchBar from 'react-native-platform-searchbar' 5 | import Button from "../components/Button" 6 | import { searchForTracks } from "../hooks/useSpotify" 7 | import { AuthContext } from "../context/authContext" 8 | import { AuthContextType } from "../types/auth" 9 | import { ButtonComponent } from "../components/ButtonComponent" 10 | import { Item, Tracks2 } from "../types/spotify" 11 | import { Card } from "../components/Card" 12 | import * as Linking from 'expo-linking' 13 | 14 | 15 | export default function SearchScreen({ route, navigation }: RootStackScreenProps<'Search'>) { 16 | const [value, setValue] = useState('') 17 | const [loading, setLoading] = useState(false) 18 | const [result, setResult] = useState() 19 | const { auth } = useContext(AuthContext) as AuthContextType 20 | 21 | const search = () => { 22 | setLoading(true) 23 | searchForTracks(auth.token, value).then((res) => { 24 | setResult(res.tracks) 25 | setLoading(false) 26 | }) 27 | } 28 | 29 | return ( 30 | 31 | 32 | 33 | 41 | {loading ? ( 42 | 43 | ) : undefined} 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | {result && result.items.map((track: Item) => ( 53 | 54 | Linking.openURL(track.href)} /> 55 | 56 | ))} 57 | 58 | ) 59 | } -------------------------------------------------------------------------------- /components/EditScreenInfo.tsx: -------------------------------------------------------------------------------- 1 | import * as WebBrowser from 'expo-web-browser'; 2 | import { StyleSheet, TouchableOpacity } from 'react-native'; 3 | 4 | import Colors from '../constants/Colors'; 5 | import { MonoText } from './StyledText'; 6 | import { Text, View } from './Themed'; 7 | 8 | export default function EditScreenInfo({ path }: { path: string }) { 9 | return ( 10 | 11 | 12 | 16 | Open up the code for this screen: 17 | 18 | 19 | 23 | {path} 24 | 25 | 26 | 30 | Change any of the text, save the file, and your app will automatically update. 31 | 32 | 33 | 34 | 35 | 36 | 37 | Tap here if your app doesn't automatically update after making changes 38 | 39 | 40 | 41 | 42 | ); 43 | } 44 | 45 | function handleHelpPress() { 46 | WebBrowser.openBrowserAsync( 47 | 'https://docs.expo.io/get-started/create-a-new-app/#opening-the-app-on-your-phonetablet' 48 | ); 49 | } 50 | 51 | const styles = StyleSheet.create({ 52 | getStartedContainer: { 53 | alignItems: 'center', 54 | marginHorizontal: 50, 55 | }, 56 | homeScreenFilename: { 57 | marginVertical: 7, 58 | }, 59 | codeHighlightContainer: { 60 | borderRadius: 3, 61 | paddingHorizontal: 4, 62 | }, 63 | getStartedText: { 64 | fontSize: 17, 65 | lineHeight: 24, 66 | textAlign: 'center', 67 | }, 68 | helpContainer: { 69 | marginTop: 15, 70 | marginHorizontal: 20, 71 | alignItems: 'center', 72 | }, 73 | helpLink: { 74 | paddingVertical: 15, 75 | }, 76 | helpLinkText: { 77 | textAlign: 'center', 78 | }, 79 | }); 80 | -------------------------------------------------------------------------------- /screens/AddPlaylistModal.tsx: -------------------------------------------------------------------------------- 1 | import { StatusBar } from 'expo-status-bar' 2 | import { ActivityIndicator, Platform, StyleSheet, TextInput, TouchableOpacity } from 'react-native' 3 | 4 | import { Text, View } from '../components/Themed' 5 | import Button from '../components/Button' 6 | import { AuthContextType, IAuth } from '../types/auth' 7 | import React, { useContext, useEffect, useState } from 'react' 8 | import { AuthContext } from '../context/authContext' 9 | import { addSongsToPlaylist, createPlaylist, getUserPlaylists } from '../hooks/useSpotify' 10 | import Colors from '../constants/Colors' 11 | import { SafeAreaFrameContext, SafeAreaView } from 'react-native-safe-area-context' 12 | 13 | export default function ModalScreen() { 14 | const { logout, auth } = useContext(AuthContext) as AuthContextType 15 | const [text, onChangeText] = useState('') 16 | const [playlists, setPlaylists] = useState([]) 17 | const [loading, setLoading] = useState(false) 18 | 19 | const create = () => { 20 | setLoading(true) 21 | 22 | createPlaylist(auth.token, text).then((res) => { 23 | addSongsToPlaylist(auth.token, res.id, []).then((res) => { 24 | setLoading(false) 25 | }) 26 | }) 27 | } 28 | 29 | 30 | return ( 31 | 32 | Add your top songs to a playlist 33 | Playlist name 34 | 50 | {!loading && 51 | Create 52 | } 53 | {loading && 54 | 55 | } 56 | 57 | ) 58 | } 59 | 60 | const styles = StyleSheet.create({ 61 | container: { 62 | flex: 1, 63 | padding: 18, 64 | }, 65 | title: { 66 | fontSize: 20, 67 | fontWeight: 'bold', 68 | marginBottom: 18, 69 | }, 70 | }) 71 | -------------------------------------------------------------------------------- /components/PlayerGuessDetailsComponent.tsx: -------------------------------------------------------------------------------- 1 | import { StatusBar } from 'expo-status-bar' 2 | import React, { useContext, useEffect, useRef, useState } from 'react' 3 | import { ActivityIndicator, ImageBackground, Platform, ScrollView, StyleSheet, Switch } from 'react-native' 4 | 5 | import { Text, View } from '../components/Themed' 6 | import Colors from '../constants/Colors' 7 | import { AuthContext } from '../context/authContext' 8 | import { AuthContextType, NonAuthUser } from '../types/auth' 9 | import { RootStackScreenProps } from '../types' 10 | 11 | export default function PlayerGuessDetailsComponent({ navigation, route }: RootStackScreenProps<'PlayerGuessDetails'>) { 12 | const { auth } = useContext(AuthContext) as AuthContextType 13 | const { user, songs } = route.params 14 | 15 | useEffect(() => { 16 | navigation.setOptions({ 17 | headerTitle: user.name, 18 | headerShown: true, 19 | headerStyle: { 20 | backgroundColor: Colors.background, 21 | }, 22 | }) 23 | 24 | }, []) 25 | 26 | const getUserGuessForSong = (index: number) => { 27 | const userGuess = user.guesses.find((guess) => guess.currentSongIndex === index) 28 | 29 | if (userGuess) { 30 | return userGuess.guess 31 | } 32 | 33 | return '' 34 | } 35 | 36 | return ( 37 | 38 | 39 | {songs.map((song, index) => { 40 | return ( 41 | 42 | {index + 1} 43 | 44 | 45 | {song.song.name} 46 | {song.song.artists[0].name} 47 | Guess: {getUserGuessForSong(index)} 48 | Correct was: {song.player.id} 49 | 50 | 51 | 52 | ) 53 | })} 54 | 55 | 56 | ) 57 | } 58 | 59 | const styles = StyleSheet.create({ 60 | container: { 61 | flex: 1, 62 | alignItems: 'center', 63 | justifyContent: 'center', 64 | }, 65 | title: { 66 | fontSize: 20, 67 | fontWeight: 'bold', 68 | }, 69 | separator: { 70 | marginVertical: 30, 71 | height: 1, 72 | width: '80%', 73 | }, 74 | }) 75 | -------------------------------------------------------------------------------- /App.tsx: -------------------------------------------------------------------------------- 1 | import { StatusBar } from 'expo-status-bar' 2 | import React, { useContext, useEffect, useRef, useState } from 'react' 3 | import { SafeAreaProvider } from 'react-native-safe-area-context' 4 | 5 | import useCachedResources from './hooks/useCachedResources' 6 | import useColorScheme from './hooks/useColorScheme' 7 | import Navigation from './navigation' 8 | import AuthProvider, { AuthContext } from './context/authContext' 9 | import Toast, { BaseToast, ErrorToast } from 'react-native-toast-message' 10 | 11 | import socket from './utils/socket' 12 | import Colors from './constants/Colors' 13 | import { AppState } from 'react-native' 14 | import { AuthContextType } from './types/auth' 15 | 16 | export default function App() { 17 | const isLoadingComplete = useCachedResources() 18 | const colorScheme = useColorScheme() 19 | 20 | const toastConfig = { 21 | success: (props: any) => ( 22 | 34 | ), 35 | error: (props: any) => ( 36 | 48 | ), 49 | } 50 | 51 | const showToast = (type: 'success' | 'error' | 'info', text1: string, text2: string) => { 52 | Toast.show({ 53 | type: type, 54 | text1: text1, 55 | text2: text2, 56 | }) 57 | } 58 | 59 | useEffect(() => { 60 | socket.connect() 61 | 62 | socket.on('connect', () => { 63 | console.log('connect') 64 | }) 65 | 66 | socket.on('disconnect', () => { 67 | console.log('disconnected') 68 | }) 69 | 70 | socket.on('error', (title: string, error: string) => { 71 | showToast('error', title, error) 72 | }) 73 | 74 | socket.on('info', (title: string, message: string) => { 75 | showToast('success', title, message) 76 | }) 77 | 78 | return () => { 79 | socket.disconnect() 80 | } 81 | }, []) 82 | 83 | if (!isLoadingComplete) { 84 | return null 85 | } else { 86 | return ( 87 | 88 | 89 | {true && } 90 | 91 | 95 | 96 | 97 | ) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /screens/HomeScreen.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect, useState } from 'react' 2 | import { StyleSheet, Image, FlatList, TouchableOpacity, ScrollView } from 'react-native' 3 | import { AuthContextType, IUser } from '../types/auth' 4 | import { Text, View } from '../components/Themed' 5 | import { AuthContext } from '../context/authContext' 6 | import { Card } from '../components/Card' 7 | import Colors from '../constants/Colors' 8 | import useColorScheme from '../hooks/useColorScheme' 9 | import { RootStackScreenProps } from '../types' 10 | 11 | export default function HomeScreen({ navigation }: RootStackScreenProps<'Home'>) { 12 | const colorScheme = useColorScheme() 13 | const { auth } = useContext(AuthContext) as AuthContextType 14 | 15 | const cards = [ 16 | { 17 | id: 0, 18 | title: 'Top songs', 19 | description: 'Check out your top songs', 20 | onPress: () => navigation.navigate('TopSongs'), 21 | icon: 'musical-notes-outline' 22 | }, 23 | { 24 | id: 1, 25 | title: 'Top artists', 26 | description: 'Check out your top artists', 27 | onPress: () => navigation.navigate('TopArtists'), 28 | icon: 'person-outline' 29 | }, 30 | ] 31 | 32 | const styles = StyleSheet.create({ 33 | container: { 34 | flex: 1, 35 | backgroundColor: Colors[colorScheme].background, 36 | }, 37 | title: { 38 | fontSize: 30, 39 | fontWeight: 'bold', 40 | marginBottom: 20, 41 | }, 42 | avatar: { 43 | width: 100, 44 | height: 100, 45 | borderRadius: 50, 46 | marginVertical: 20, 47 | borderColor: Colors[colorScheme].primary, 48 | borderStyle: 'solid', 49 | borderWidth: 2, 50 | }, 51 | }) 52 | 53 | useEffect(() => { 54 | navigation.setOptions({ 55 | headerLargeTitle: true, 56 | headerLargeStyle: { 57 | backgroundColor: Colors.background, 58 | }, 59 | headerStyle: { 60 | backgroundColor: Colors.background, 61 | }, 62 | headerShadowVisible: false, 63 | headerBlurEffect: 'dark', 64 | headerRight: () => ( 65 | navigation.navigate('Profile')}> 66 | 67 | 68 | ), 69 | }) 70 | }, []) 71 | 72 | return ( 73 | 74 | navigation.navigate('Soundcheck')} /> 75 | 76 | {cards.map(card => ( 77 | 78 | ))} 79 | 80 | ) 81 | } 82 | 83 | 84 | -------------------------------------------------------------------------------- /hooks/useSpotify.ts: -------------------------------------------------------------------------------- 1 | import { AuthContextType } from "../types/auth" 2 | import { AuthContext } from "../context/authContext" 3 | import { useContext } from "react" 4 | import { ArtistItem, SongItem, SpotifyMeResult, SpotifyMyPlaylistsResult, SpotifySearchResponse, SpotifyTopArtistsResult,SpotifyTopTracksResult, Tracks2 } from "../types/spotify" 5 | 6 | export const useSpotify = () => { 7 | const { logout } = useContext(AuthContext) as AuthContextType; 8 | 9 | const getTopSongs = async (token: string, timeRange: string, offset = 0, limit = 25) => { 10 | const response = await fetch(`https://api.spotify.com/v1/me/top/tracks?time_range=${timeRange}&offset=${offset}&limit=${limit}`, { 11 | headers: { 12 | Authorization: `Bearer ${token}`, 13 | }, 14 | }); 15 | 16 | if(response.status === 401) { 17 | logout() 18 | return { items: [] as SongItem[] } as SpotifyTopTracksResult; 19 | } 20 | 21 | return response.json() as Promise; 22 | } 23 | 24 | const getTopArtists = async (token: string, timeRange: string) => { 25 | const response = await fetch(`https://api.spotify.com/v1/me/top/artists?time_range=${timeRange}`, { 26 | headers: { 27 | Authorization: `Bearer ${token}`, 28 | }, 29 | }); 30 | 31 | if(response.status === 401) { 32 | logout() 33 | return { items: [] as ArtistItem[] } as SpotifyTopArtistsResult; 34 | } 35 | 36 | return response.json() as Promise; 37 | } 38 | 39 | const getUserPlaylists = async (token: string) => { 40 | const response = await fetch("https://api.spotify.com/v1/me/playlists", { 41 | headers: { 42 | Authorization: `Bearer ${token}`, 43 | }, 44 | }); 45 | const data = response.json() 46 | return data as Promise; 47 | } 48 | 49 | const createPlaylist = async (token: string, name: string) => { 50 | const response = await fetch("https://api.spotify.com/v1/me/playlists", { 51 | method: "POST", 52 | headers: { 53 | Authorization: `Bearer ${token}`, 54 | "Content-Type": "application/json", 55 | }, 56 | body: JSON.stringify({ 57 | name, 58 | }), 59 | }); 60 | return response.json(); 61 | } 62 | 63 | const addSongsToPlaylist = async (token: string, playlistId: string, songIds: string[]) => { 64 | const response = await fetch(`https://api.spotify.com/v1/playlists/${playlistId}/tracks`, { 65 | method: "POST", 66 | headers: { 67 | Authorization: `Bearer ${token}`, 68 | "Content-Type": "application/json", 69 | }, 70 | body: JSON.stringify({ 71 | uris: songIds, 72 | }), 73 | }); 74 | return response.json(); 75 | } 76 | 77 | const createAndAddSongsToPlaylist = async (token: string, name: string, songs: SongItem[]) => { 78 | console.log(token, name, songs); 79 | const songIds = songs.map((song) => song.uri); 80 | const { id } = await createPlaylist(token, name); 81 | await addSongsToPlaylist(token, id, songIds); 82 | } 83 | 84 | const searchForTracks = async (token: string, query: string, limit = 50) => { 85 | const response = await fetch(`https://api.spotify.com/v1/search?q=${query}&type=track&limit=${limit}`, { 86 | headers: { 87 | Authorization: `Bearer ${token}`, 88 | }, 89 | }); 90 | 91 | return response.json() as Promise; 92 | } 93 | 94 | return { 95 | getTopSongs, 96 | getTopArtists, 97 | getUserPlaylists, 98 | createPlaylist, 99 | addSongsToPlaylist, 100 | createAndAddSongsToPlaylist, 101 | searchForTracks, 102 | }; 103 | } -------------------------------------------------------------------------------- /types/spotify.d.ts: -------------------------------------------------------------------------------- 1 | export interface SpotifyTopTracksResult { 2 | items: SongItem[] 3 | total: number 4 | limit: number 5 | offset: number 6 | href: string 7 | previous: any 8 | next: string 9 | } 10 | 11 | export interface SpotifyTopArtistsResult { 12 | items: ArtistItem[] 13 | total: number 14 | limit: number 15 | offset: number 16 | href: string 17 | previous: any 18 | next: string 19 | } 20 | 21 | export interface SongItem { 22 | album: Album 23 | artists: Artist2[] 24 | available_markets: string[] 25 | disc_number: number 26 | duration_ms: number 27 | explicit: boolean 28 | external_ids: ExternalIds 29 | external_urls: ExternalUrls4 30 | href: string 31 | id: string 32 | is_local: boolean 33 | name: string 34 | popularity: number 35 | preview_url: string 36 | track_number: number 37 | type: string 38 | uri: string 39 | } 40 | 41 | export interface Album { 42 | album_type: string 43 | artists: Artist[] 44 | available_markets: string[] 45 | external_urls: ExternalUrls2 46 | href: string 47 | id: string 48 | images: Image[] 49 | name: string 50 | release_date: string 51 | release_date_precision: string 52 | total_tracks: number 53 | type: string 54 | uri: string 55 | } 56 | 57 | export interface Artist { 58 | external_urls: ExternalUrls 59 | href: string 60 | id: string 61 | name: string 62 | type: string 63 | uri: string 64 | } 65 | 66 | export interface Artist2 { 67 | external_urls: ExternalUrls 68 | href: string 69 | id: string 70 | name: string 71 | type: string 72 | uri: string 73 | } 74 | 75 | export interface ExternalIds { 76 | isrc: string 77 | } 78 | 79 | export interface SpotifyMeResult { 80 | display_name: string 81 | email: string 82 | external_urls: ExternalUrls 83 | followers: Followers 84 | href: string 85 | id: string 86 | images: Image[] 87 | type: string 88 | uri: string 89 | } 90 | 91 | export interface ExternalUrls { 92 | spotify: string 93 | } 94 | 95 | export interface Followers { 96 | href: any 97 | total: number 98 | } 99 | 100 | export interface ArtistItem { 101 | external_urls: ExternalUrls 102 | followers: Followers 103 | genres: string[] 104 | href: string 105 | id: string 106 | images: Image[] 107 | name: string 108 | popularity: number 109 | type: string 110 | uri: string 111 | } 112 | 113 | export interface SpotifyMyPlaylistsResult { 114 | href: string 115 | items: PlaylsitItem[] 116 | limit: number 117 | next: string 118 | offset: number 119 | previous: any 120 | total: number 121 | } 122 | 123 | export interface PlaylsitItem { 124 | collaborative: boolean 125 | description: string 126 | external_urls: ExternalUrls 127 | href: string 128 | id: string 129 | images: Image[] 130 | name: string 131 | owner: Owner 132 | primary_color: any 133 | public: boolean 134 | snapshot_id: string 135 | tracks: Tracks 136 | type: string 137 | uri: string 138 | } 139 | 140 | export interface Image { 141 | height: any 142 | url: string 143 | width: any 144 | } 145 | 146 | export interface Owner { 147 | display_name: string 148 | external_urls: ExternalUrls 149 | href: string 150 | id: string 151 | type: string 152 | uri: string 153 | } 154 | 155 | export interface Tracks { 156 | href: string 157 | total: number 158 | } 159 | 160 | export interface SpotifySearchResponse { 161 | tracks: Tracks2 162 | } 163 | 164 | export interface Tracks2 { 165 | href: string 166 | items: Item[] 167 | limit: number 168 | next: string 169 | offset: number 170 | previous: any 171 | total: number 172 | } 173 | 174 | export interface Item { 175 | album: Album 176 | artists: Artist2[] 177 | available_markets: string[] 178 | disc_number: number 179 | duration_ms: number 180 | explicit: boolean 181 | external_ids: ExternalIds 182 | external_urls: ExternalUrls4 183 | href: string 184 | id: string 185 | is_local: boolean 186 | name: string 187 | popularity: number 188 | preview_url: string 189 | track_number: number 190 | type: string 191 | uri: string 192 | } 193 | -------------------------------------------------------------------------------- /components/AddNonAuthPlayerModal.tsx: -------------------------------------------------------------------------------- 1 | import { StatusBar } from 'expo-status-bar' 2 | import React, { useContext, useEffect, useRef, useState } from 'react' 3 | import { ActivityIndicator, Platform, ScrollView, StyleSheet, Switch } from 'react-native' 4 | 5 | import EditScreenInfo from '../components/EditScreenInfo' 6 | import { Text, View } from '../components/Themed' 7 | import AsyncStorage from '@react-native-async-storage/async-storage' 8 | import Colors from '../constants/Colors' 9 | import { TextInputComponent } from './TextInputComponent' 10 | import { AuthContext } from '../context/authContext' 11 | import { AuthContextType, NonAuthUser } from '../types/auth' 12 | import { Item, Tracks2 } from '../types/spotify' 13 | import { Card } from './Card' 14 | import SearchBar from 'react-native-platform-searchbar' 15 | import { searchForTracks } from '../hooks/useSpotify' 16 | import { ButtonComponent } from './ButtonComponent' 17 | import { RootStackScreenProps } from '../types' 18 | 19 | export default function AddNonAuthPlayerModal({ navigation }: RootStackScreenProps<'AddNonAuthPlayerModal'>) { 20 | const [value, setValue] = useState('') 21 | const [loading, setLoading] = useState(false) 22 | const [result, setResult] = useState() 23 | const [songs, setSongs] = useState([]) 24 | const [name, setName] = useState('') 25 | const { auth } = useContext(AuthContext) as AuthContextType 26 | 27 | const timeout = useRef() 28 | 29 | useEffect(() => { 30 | clearTimeout(timeout.current) 31 | 32 | timeout.current = setTimeout(() => { 33 | search() 34 | }, 500) 35 | }, [value]) 36 | 37 | const search = () => { 38 | console.log('searching') 39 | setLoading(true) 40 | searchForTracks(auth.token, value, 3).then((res) => { 41 | setResult(res.tracks) 42 | setLoading(false) 43 | }) 44 | } 45 | 46 | const addSongToUser = (item: Item) => { 47 | setSongs([...songs, item]) 48 | } 49 | 50 | 51 | const addUser = async () => { 52 | // @ts-ignore 53 | navigation.navigate({ 54 | name: 'Room', 55 | params: { 56 | nonAuthUser: { name, songs } as NonAuthUser, 57 | }, 58 | merge: true, 59 | }) 60 | } 61 | 62 | return ( 63 | 64 | setName(value)} /> 65 | 73 | {loading ? ( 74 | 75 | ) : undefined} 76 | 77 | 78 | {songs.map((track: Item) => 79 | result?.items.includes(track) ? ( 80 | <> 81 | ) : ( 82 | { }} icon='checkmark' style={{ height: 60, backgroundColor: Colors.dark }} /> 83 | ) 84 | )} 85 | {result && result.items.map((track: Item, index: number) => ( 86 | 87 | addSongToUser(track)} icon={songs.includes(track) ? 'checkmark' : 'add'} style={{ height: 60, backgroundColor: Colors.dark }} /> 88 | 89 | ))} 90 | 91 | 92 | 93 | 94 | ) 95 | } 96 | 97 | const styles = StyleSheet.create({ 98 | container: { 99 | flex: 1, 100 | alignItems: 'center', 101 | justifyContent: 'center', 102 | }, 103 | title: { 104 | fontSize: 20, 105 | fontWeight: 'bold', 106 | }, 107 | separator: { 108 | marginVertical: 30, 109 | height: 1, 110 | width: '80%', 111 | }, 112 | }) 113 | -------------------------------------------------------------------------------- /navigation/index.tsx: -------------------------------------------------------------------------------- 1 | import { NavigationContainer, DefaultTheme, DarkTheme } from '@react-navigation/native' 2 | import { createNativeStackNavigator } from '@react-navigation/native-stack' 3 | import * as React from 'react' 4 | import { useContext, useEffect, useRef, useState } from 'react' 5 | import { ColorSchemeName, Pressable, TouchableWithoutFeedback, Vibration, Image, TouchableOpacity, TouchableHighlight, AppState } from 'react-native' 6 | import { AuthContextType, IAuth } from '../types/auth' 7 | 8 | import { AuthContext } from '../context/authContext' 9 | import LoginScreen from '../screens/LoginScreen' 10 | import NotFoundScreen from '../screens/NotFoundScreen' 11 | import TopArtistsScreen from '../screens/TopArtistsScreen' 12 | import TopSongsScreen from '../screens/TopSongsScreen' 13 | import { RootStackParamList } from '../types' 14 | import LinkingConfiguration from './LinkingConfiguration' 15 | import SoundcheckScreen from '../screens/SoundcheckScreen' 16 | import CreateRoomScreen from '../screens/CreateRoomScreen' 17 | import JoinRoomScreen from '../screens/JoinRoomScreen' 18 | import RoomScreen from '../screens/RoomScreen' 19 | import HomeScreen from '../screens/HomeScreen' 20 | import ProfileScreen from '../screens/ProfileScreen' 21 | import SearchScreen from '../screens/SeachScreen' 22 | import AddNonAuthPlayerModal from '../components/AddNonAuthPlayerModal' 23 | import PlayerGuessDetailsComponent from '../components/PlayerGuessDetailsComponent' 24 | import { useSpotifyAuth } from '../hooks/useSpotifyAuth' 25 | import socket from '../utils/socket' 26 | 27 | type Props = { 28 | colorScheme: ColorSchemeName 29 | } 30 | 31 | const MyTheme = { 32 | ...DarkTheme, 33 | colors: { 34 | ...DarkTheme.colors, 35 | background: '#1F1F1E', 36 | }, 37 | } 38 | 39 | const Navigation: React.FC = ({ colorScheme }: Props) => { 40 | return ( 41 | 44 | 45 | 46 | ) 47 | } 48 | 49 | export default Navigation 50 | 51 | const Stack = createNativeStackNavigator() 52 | 53 | function RootNavigator() { 54 | const { auth, logout } = useContext(AuthContext) as AuthContextType 55 | const appState = useRef(AppState.currentState) 56 | const [appStateVisible, setAppStateVisible] = useState(appState.current) 57 | const { getTokenStatus } = useSpotifyAuth() 58 | 59 | useEffect(() => { 60 | const subscription = AppState.addEventListener("change", async nextAppState => { 61 | if ( 62 | appState.current.match(/inactive|background/) && 63 | nextAppState === "active" 64 | ) { 65 | const status = await getTokenStatus(auth.token) 66 | if (status === 401) { 67 | logout() 68 | } else { 69 | } 70 | } 71 | 72 | appState.current = nextAppState 73 | setAppStateVisible(appState.current) 74 | }) 75 | 76 | socket.on('logout', () => { 77 | console.log('logout') 78 | logout() 79 | }) 80 | 81 | return () => { 82 | subscription.remove() 83 | } 84 | }, []) 85 | 86 | if (!auth.authenticated) { 87 | return ( 88 | 89 | 90 | 91 | ) 92 | } else { 93 | return ( 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | ) 113 | } 114 | } -------------------------------------------------------------------------------- /screens/CreateRoomScreen.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect, useRef, useState } from 'react' 2 | import { StyleSheet, Image, FlatList, TouchableOpacity, ScrollView, TextInput, Switch } from 'react-native' 3 | import { Text, View } from '../components/Themed' 4 | import { RootStackParamList, RootStackScreenProps } from '../types' 5 | import useColorScheme from '../hooks/useColorScheme' 6 | import { Picker } from '@react-native-picker/picker' 7 | import Toast from 'react-native-toast-message' 8 | import { TextInputComponent } from '../components/TextInputComponent' 9 | import { ButtonComponent } from '../components/ButtonComponent' 10 | import { useFocusEffect } from '@react-navigation/native'; 11 | import Colors from '../constants/Colors' 12 | 13 | const generateRandomString = (length: number) => { 14 | let text = '' 15 | const possible = 'ABCDEFGHJKLMNPQRSTWXYZ23456789' 16 | 17 | for (let i = 0; i < length; i++) { 18 | text += possible.charAt(Math.floor(Math.random() * possible.length)) 19 | } 20 | return text 21 | } 22 | 23 | export default function CreateRoomScreen({ navigation }: RootStackScreenProps<'Create'>) { 24 | const colorScheme = useColorScheme() 25 | const [roomCode, setRoomCode] = useState('') 26 | const [songsPerUser, setSongsPerUser] = useState(2) 27 | const [timeRange, setTimeRange] = useState('medium_term') 28 | 29 | const styles = StyleSheet.create({ 30 | container: { 31 | flex: 1, 32 | backgroundColor: Colors[colorScheme].background, 33 | }, 34 | title: { 35 | fontSize: 30, 36 | fontWeight: 'bold', 37 | marginBottom: 20, 38 | }, 39 | avatar: { 40 | width: 100, 41 | height: 100, 42 | borderRadius: 50, 43 | marginVertical: 20, 44 | borderColor: Colors[colorScheme].primary, 45 | borderStyle: 'solid', 46 | borderWidth: 2, 47 | }, 48 | }) 49 | 50 | useEffect(() => { 51 | navigation.setOptions({ 52 | title: 'Create room', 53 | headerBackTitle: 'Back', 54 | headerStyle: { 55 | backgroundColor: Colors.background, 56 | }, 57 | headerBlurEffect: 'dark', 58 | }) 59 | }, []) 60 | 61 | useFocusEffect( 62 | React.useCallback(() => { 63 | setRoomCode(generateRandomString(4)) 64 | }, []) 65 | ); 66 | 67 | const showToast = (type: 'success' | 'error' | 'info', text1: string, text2: string) => { 68 | Toast.show({ 69 | type: type, 70 | text1: text1, 71 | text2: text2, 72 | }) 73 | } 74 | 75 | const createRoom = () => { 76 | navigation.navigate('Room', { roomCode: roomCode, songsPerUser: songsPerUser, timeRange: timeRange, createRoom: true, nonAuthUser: undefined }) 77 | } 78 | 79 | return ( 80 | 81 | { 82 | if (value.length <= 10) { 83 | setRoomCode(value) 84 | } else { 85 | showToast('error', 'Room code too long', 'Must be less than 10 characters long') 86 | } 87 | }} value={roomCode} placeholder="(ex. GFDS)" autoCapitalize='characters' /> 88 | 89 | Number of songs per user 90 | 95 | setSongsPerUser(itemValue) 96 | }> 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | Time range 109 | 114 | setTimeRange(itemValue) 115 | }> 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | ) 124 | } 125 | 126 | 127 | -------------------------------------------------------------------------------- /screens/TopArtistsScreen.tsx: -------------------------------------------------------------------------------- 1 | import SegmentedControl from '@react-native-segmented-control/segmented-control' 2 | import React, { useContext, useEffect, useMemo, useRef, useState } from 'react' 3 | import { ActivityIndicator, FlatList, ImageBackground, ScrollView, StyleSheet, Animated, Platform } from 'react-native' 4 | import { useSafeAreaInsets } from 'react-native-safe-area-context' 5 | import { AuthContextType } from '../types/auth' 6 | import { ArtistItem, SpotifyTopArtistsResult } from '../types/spotify' 7 | import { useSpotify } from '../hooks/useSpotify' 8 | import { Text, View } from '../components/Themed' 9 | import Colors from '../constants/Colors' 10 | import { AuthContext } from '../context/authContext' 11 | import useColorScheme from '../hooks/useColorScheme' 12 | import { useHeaderHeight } from '@react-navigation/elements' 13 | 14 | enum TimeRange { 15 | SHORT = 'short_term', 16 | MEDIUM = 'medium_term', 17 | LONG = 'long_term', 18 | } 19 | 20 | export default function TopArtistsScreen() { 21 | const { logout, auth } = useContext(AuthContext) as AuthContextType 22 | const [artists, setArtists] = useState([]) 23 | const [loading, setLoading] = useState(false) 24 | const [selectedSegment, setSelectedSegment] = useState(0) 25 | const [timeRange, setTimeRange] = useState('short_term') 26 | const { getTopArtists } = useSpotify() 27 | 28 | useEffect(() => { 29 | setLoading(true) 30 | fadeAnim.setValue(0) 31 | getTopArtists(auth.token, timeRange).then((res: SpotifyTopArtistsResult) => { 32 | setArtists(res.items) 33 | }).finally(() => { 34 | setLoading(false) 35 | }) 36 | }, [timeRange]) 37 | 38 | const ArtistCard = ({ artist, index }: { artist: ArtistItem, index: number }) => ( 39 | 40 | 41 | #{index + 1} 42 | 43 | {artist.name} 44 | 45 | 46 | 47 | ) 48 | 49 | const colorScheme = useColorScheme() 50 | 51 | const changeTimeRange = (index: number) => { 52 | setSelectedSegment(index) 53 | setTimeRange(index === 0 ? TimeRange.SHORT : index === 1 ? TimeRange.MEDIUM : TimeRange.LONG) 54 | } 55 | const fadeAnim = useRef(new Animated.Value(0)).current 56 | 57 | useEffect(() => { 58 | Animated.timing(fadeAnim, { 59 | toValue: 1, 60 | duration: 500, 61 | useNativeDriver: true 62 | }).start() 63 | }, [fadeAnim, artists]) 64 | 65 | const insets = useSafeAreaInsets() 66 | const headerHeight = useHeaderHeight() 67 | 68 | return ( 69 | 70 | { 71 | Platform.OS === 'ios' ? ( 72 | { 76 | changeTimeRange(event.nativeEvent.selectedSegmentIndex) 77 | }} 78 | tintColor="#fefefe" 79 | appearance="light" 80 | style={{ margin: 20, marginTop: 20 }} 81 | /> 82 | ) : ( 83 | 84 | { 88 | changeTimeRange(event.nativeEvent.selectedSegmentIndex) 89 | }} 90 | appearance="dark" 91 | tintColor={Colors.primary} 92 | style={{ margin: 20, marginTop: 20 }} 93 | fontStyle={{ color: 'white' }} 94 | tabStyle={{ }} 95 | /> 96 | 97 | ) 98 | } 99 | {!loading && artists.map((artist, index) => ( 100 | 106 | 107 | 108 | 109 | 110 | ))} 111 | {artists.length === 0 && !loading && You have no top artists for this time period!} 112 | {loading && 113 | 114 | } 115 | 116 | ) 117 | } 118 | const styles = StyleSheet.create({ 119 | artistCard: { 120 | backgroundColor: 'black', 121 | borderRadius: 10, 122 | marginHorizontal: 20, 123 | }, 124 | artistIndex: { 125 | fontSize: 25, 126 | fontWeight: 'bold', 127 | color: 'white', 128 | opacity: 0.5, 129 | }, 130 | 131 | image: { 132 | flexDirection: 'row', 133 | alignItems: 'center', 134 | padding: 20, 135 | resizeMode: "contain", 136 | }, 137 | artistInfo: { 138 | flexDirection: 'column', 139 | marginLeft: 20, 140 | backgroundColor: 'transparent', 141 | width: '80%', 142 | }, 143 | artistName: { 144 | fontSize: 18, 145 | fontWeight: 'bold', 146 | }, 147 | artistArtist: { 148 | fontSize: 16, 149 | color: 'lightgray', 150 | }, 151 | container: { 152 | width: '100%', 153 | flex: 1, 154 | }, 155 | title: { 156 | fontSize: 12, 157 | fontWeight: 'bold', 158 | marginTop: 20, 159 | marginLeft: 20, 160 | marginRight: 20, 161 | }, 162 | separator: { 163 | marginVertical: 30, 164 | height: 1, 165 | }, 166 | }) 167 | -------------------------------------------------------------------------------- /screens/LoginScreen.tsx: -------------------------------------------------------------------------------- 1 | import { ActivityIndicator, Animated, Easing, Pressable, SafeAreaView, StyleSheet, TouchableOpacity } from 'react-native' 2 | 3 | import { Text, View } from '../components/Themed' 4 | import { RootStackScreenProps } from '../types' 5 | import { AuthContextType, IAuth } from '../types/auth' 6 | import { AuthContext } from '../context/authContext' 7 | import React, { useContext, useEffect, useRef, useState } from 'react' 8 | import * as WebBrowser from 'expo-web-browser' 9 | import { makeRedirectUri, ResponseType, useAuthRequest } from 'expo-auth-session' 10 | import Colors from '../constants/Colors' 11 | import * as Haptics from 'expo-haptics' 12 | import { GradientText } from '../components/GradientText' 13 | 14 | // Endpoint 15 | const discovery = { 16 | authorizationEndpoint: 'https://accounts.spotify.com/authorize', 17 | tokenEndpoint: 'https://accounts.spotify.com/api/token', 18 | } 19 | 20 | export default function LoginScreen({ navigation }: RootStackScreenProps<'Login'>) { 21 | const { login } = useContext(AuthContext) as AuthContextType 22 | const [loading, setLoading] = useState(false) 23 | const fadeAnim = useRef(new Animated.Value(0)).current 24 | const slideAnim = useRef(new Animated.Value(-400)).current 25 | const slideAnim2 = useRef(new Animated.Value(-800)).current 26 | const slideAnim3 = useRef(new Animated.Value(-1600)).current 27 | const slideAnim4 = useRef(new Animated.Value(200)).current 28 | 29 | useEffect(() => { 30 | Animated.timing(slideAnim, { 31 | toValue: 0, 32 | duration: 1500, 33 | useNativeDriver: true, 34 | easing: Easing.out(Easing.exp), 35 | delay: 500 36 | }).start() 37 | Animated.timing(slideAnim2, { 38 | toValue: 0, 39 | duration: 1500, 40 | useNativeDriver: true, 41 | easing: Easing.out(Easing.exp), 42 | delay: 500 43 | }).start() 44 | Animated.timing(slideAnim3, { 45 | toValue: 0, 46 | duration: 1500, 47 | useNativeDriver: true, 48 | easing: Easing.out(Easing.exp), 49 | delay: 500 50 | }).start() 51 | Animated.timing(slideAnim4, { 52 | toValue: 0, 53 | duration: 1500, 54 | useNativeDriver: true, 55 | easing: Easing.out(Easing.exp), 56 | delay: 1000 57 | }).start() 58 | Animated.timing(fadeAnim, { 59 | toValue: 1, 60 | duration: 1500, 61 | useNativeDriver: true, 62 | easing: Easing.out(Easing.exp), 63 | delay: 2000 64 | }).start() 65 | }, [slideAnim, slideAnim2, slideAnim3, slideAnim4, fadeAnim]) 66 | 67 | // useEffect(() => { 68 | // Animated.timing(scaleAnim, { 69 | // toValue: 1, 70 | // duration: 1000, 71 | // useNativeDriver: true 72 | // }).start() 73 | // }, [scaleAnim]) 74 | 75 | 76 | const requestTokenAndLogin = async () => { 77 | Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light) 78 | setLoading(true) 79 | promptAsync() 80 | } 81 | 82 | const [request, response, promptAsync] = useAuthRequest({ 83 | responseType: ResponseType.Token, 84 | clientId: 'bad02ecfaf4046638a1daa7f60cbe42b', 85 | scopes: ['user-read-email', 'playlist-modify-public', 'user-top-read', 'playlist-read-private', 'streaming', 'user-modify-playback-state', 'user-read-private', 'user-read-playback-state', 'user-read-playback-position', 'user-modify-playback-state', 'user-read-currently-playing'], 86 | usePKCE: false, 87 | redirectUri: makeRedirectUri({ 88 | scheme: 'soundcheckgame', 89 | }), 90 | 91 | }, 92 | discovery 93 | ) 94 | 95 | useEffect(() => { 96 | if (response?.type === 'success') { 97 | const { access_token } = response.params 98 | login(access_token) 99 | setLoading(false) 100 | } else { 101 | console.log('[error] could not log in') 102 | setLoading(false) 103 | } 104 | }, [response]) 105 | 106 | return ( 107 | 108 | 117 | 123 | Welcome to 124 | 125 | 126 | 135 | 136 | 137 | 146 | 153 | {'Check out your top songs and artists from Spotify, create mix-tapes and playlists!\n\nPlay and win over your friends by guessing their favourite songs!'} 154 | 155 | 156 | 161 | This app is not affiliated with Spotify AB or any of its partners in any way 162 | 163 | 172 | {!loading && 174 | [{ 175 | padding: 12, 176 | backgroundColor: Colors.primary, 177 | borderRadius: 10, 178 | justifyContent: 'center', 179 | alignItems: 'center', 180 | width: '100%', 181 | height: 48 182 | }, pressed ? { opacity: 0.5 } : {}]} 183 | onPress={requestTokenAndLogin} 184 | > 185 | Login with Spotify 186 | } 187 | 188 | {loading && 189 | 190 | } 191 | 192 | 193 | ) 194 | } 195 | 196 | const styles = StyleSheet.create({ 197 | container: { 198 | flex: 1, 199 | alignItems: 'center', 200 | justifyContent: 'center', 201 | padding: 18 202 | }, 203 | title: { 204 | fontSize: 40, 205 | fontWeight: 'bold', 206 | justifyContent: 'flex-start', 207 | alignItems: 'flex-start', 208 | }, 209 | description: { 210 | fontSize: 14, 211 | textAlign: 'start', 212 | marginBottom: 20, 213 | marginHorizontal: 20, 214 | }, 215 | description2: { 216 | fontSize: 14, 217 | textAlign: 'center', 218 | marginBottom: 20, 219 | marginHorizontal: 20, 220 | color: 'gray', 221 | marginTop: 'auto', 222 | opacity: 0.5 223 | }, 224 | description3: { 225 | fontSize: 11, 226 | textAlign: 'center', 227 | marginBottom: 20, 228 | marginHorizontal: 20, 229 | color: 'gray', 230 | opacity: 0.4, 231 | marginTop: 'auto' 232 | }, 233 | separator: { 234 | marginVertical: 30, 235 | height: 1, 236 | width: '80%', 237 | }, 238 | button: { 239 | margin: 20, 240 | backgroundColor: '#841584', 241 | }, 242 | }) 243 | -------------------------------------------------------------------------------- /screens/TopSongsScreen.tsx: -------------------------------------------------------------------------------- 1 | import { FlashList } from '@shopify/flash-list' 2 | import React, { useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' 3 | import { ActivityIndicator, Animated, ImageBackground, ImageBackgroundBase, ScrollView, StyleSheet, Alert, TouchableOpacity, FlatList, Platform, Linking } from 'react-native' 4 | import { AuthContextType } from '../types/auth' 5 | import { Text, View } from '../components/Themed' 6 | import { AuthContext } from '../context/authContext' 7 | import SegmentedControl from '@react-native-segmented-control/segmented-control' 8 | import { SongItem, SpotifyTopTracksResult } from '../types/spotify' 9 | import Ionicons from '@expo/vector-icons/Ionicons' 10 | import * as Haptics from 'expo-haptics' 11 | import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' 12 | import { useHeaderHeight } from '@react-navigation/elements' 13 | import Colors from '../constants/Colors' 14 | import { useSpotify } from '../hooks/useSpotify' 15 | 16 | enum TimeRange { 17 | SHORT = 'short_term', 18 | MEDIUM = 'medium_term', 19 | LONG = 'long_term', 20 | } 21 | 22 | const SongCard = ({ song, index }: { song: SongItem, index: number }) => ( 23 | 24 | 25 | #{index + 1} 26 | 27 | {song.name} 28 | {song.artists[0].name} 29 | 30 | 31 | 32 | ) 33 | 34 | interface ICachedSongs { 35 | [key: string]: SongItem[] 36 | } 37 | 38 | export default function TopSongsScreen({ navigation }: any) { 39 | const { logout, auth } = useContext(AuthContext) as AuthContextType 40 | const [songs, setSongs] = useState([]) 41 | const [loading, setLoading] = useState(true) 42 | const [loadingMore, setLoadingMore] = useState(false) 43 | const [timeRange, setTimeRange] = useState('short_term') 44 | const [selectedSegment, setSelectedSegment] = useState(0) 45 | const fadeAnim = useRef(new Animated.Value(0)).current 46 | const newPlaylistName = '⭐️ Top songs ' + timeRange.split('_').join(' ') + ' ⭐️ ' + new Date().toLocaleDateString() 47 | const stateRef = useRef([]) 48 | stateRef.current = songs 49 | 50 | const timeRangeRef = useRef('') 51 | timeRangeRef.current = timeRange 52 | 53 | const insets = useSafeAreaInsets() 54 | const headerHeight = useHeaderHeight() 55 | 56 | const { createAndAddSongsToPlaylist, getTopSongs } = useSpotify(); 57 | 58 | const createTwoButtonAlert = () => { 59 | Alert.alert('Save as playlist', 'Do you want to save your top songs to a playlist in your Spotify account?', [ 60 | { 61 | text: 'Cancel', 62 | onPress: () => console.log('Cancel Pressed'), 63 | style: 'cancel', 64 | }, 65 | { text: 'Save', onPress: () => createAndAddSongsToPlaylist(auth.token, newPlaylistName, stateRef.current) }, 66 | ]) 67 | } 68 | 69 | const openInSpotifyAlert = (item: SongItem) => { 70 | Alert.alert('Open in Spotify?', '', [ 71 | { 72 | text: 'Cancel', 73 | onPress: () => console.log('Cancel Pressed'), 74 | style: 'cancel', 75 | }, 76 | { text: 'Open', onPress: () => { 77 | Linking.openURL(item.external_urls.spotify) 78 | Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light) 79 | } }, 80 | ]) 81 | } 82 | 83 | const getMoreSongs = async () => { 84 | setLoadingMore(true) 85 | getTopSongs(auth.token, timeRange, songs.length, 5).then((res: SpotifyTopTracksResult) => { 86 | setSongs([...songs, ...res.items]) 87 | }).finally(() => { 88 | setLoadingMore(false) 89 | }) 90 | } 91 | 92 | const changeTimeRange = (index: number) => { 93 | setSelectedSegment(index) 94 | setTimeRange(index === 0 ? TimeRange.SHORT : index === 1 ? TimeRange.MEDIUM : TimeRange.LONG) 95 | } 96 | 97 | useEffect(() => { 98 | navigation.setOptions({ 99 | title: 'Top Songs', headerLargeTitle: true, headerBlurEffect: 'dark', headerTransparent: true, headerRight: () => ( 100 | { 101 | createTwoButtonAlert() 102 | Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light) 103 | }}> 104 | 105 | 106 | ), 107 | }) 108 | }, []) 109 | 110 | useEffect(() => { 111 | setLoading(true) 112 | fadeAnim.setValue(0) 113 | 114 | getTopSongs(auth.token, timeRange).then((res: SpotifyTopTracksResult) => { 115 | setSongs(res.items) 116 | }).finally(() => { 117 | setLoading(false) 118 | }) 119 | }, [timeRange]) 120 | 121 | useEffect(() => { 122 | Animated.timing(fadeAnim, { 123 | toValue: 1, 124 | duration: 300, 125 | useNativeDriver: true 126 | }).start() 127 | }, [fadeAnim, songs]) 128 | 129 | return ( 130 | { 140 | changeTimeRange(event.nativeEvent.selectedSegmentIndex) 141 | }} 142 | tintColor="#fefefe" 143 | appearance="light" 144 | style={{ margin: 20, marginTop: 20 }} 145 | /> 146 | ) : ( 147 | 148 | { 152 | changeTimeRange(event.nativeEvent.selectedSegmentIndex) 153 | }} 154 | appearance="dark" 155 | tintColor={Colors.primary} 156 | style={{ margin: 20, marginTop: 20 }} 157 | fontStyle={{ color: 'white' }} 158 | tabStyle={{ }} 159 | /> 160 | 161 | ) 162 | } 163 | refreshing={loading} 164 | renderItem={({item, index}) => ( 165 | <> 166 | { 167 | 173 | 174 | openInSpotifyAlert(item) 175 | }> 176 | 177 | 178 | 179 | 180 | } 181 | 182 | )} 183 | ListFooterComponent={ 184 | <> 185 | {!loading && You have no top songs for this time period!} 186 | {loading || loadingMore && } 187 | 188 | } 189 | onEndReached={() => getMoreSongs()} 190 | /> 191 | ) 192 | } 193 | 194 | const styles = StyleSheet.create({ 195 | songCard: { 196 | borderRadius: 10, 197 | marginHorizontal: 20, 198 | }, 199 | songIndex: { 200 | fontSize: 25, 201 | fontWeight: 'bold', 202 | color: 'white', 203 | opacity: 0.5, 204 | }, 205 | 206 | image: { 207 | flexDirection: 'row', 208 | alignItems: 'center', 209 | padding: 20, 210 | resizeMode: "contain", 211 | }, 212 | songInfo: { 213 | flexDirection: 'column', 214 | marginLeft: 20, 215 | width: '80%', 216 | backgroundColor: 'transparent', 217 | }, 218 | songName: { 219 | fontSize: 18, 220 | fontWeight: 'bold', 221 | }, 222 | songArtist: { 223 | fontSize: 16, 224 | color: 'lightgray', 225 | }, 226 | container: { 227 | width: '100%', 228 | flex: 1, 229 | marginTop: 0, 230 | }, 231 | loadingContainer: { 232 | width: '100%', 233 | flex: 1, 234 | justifyContent: 'center', 235 | alignItems: 'center', 236 | }, 237 | title: { 238 | fontSize: 12, 239 | fontWeight: 'bold', 240 | marginTop: 20, 241 | marginLeft: 20, 242 | marginRight: 20, 243 | }, 244 | separator: { 245 | marginVertical: 30, 246 | height: 1, 247 | }, 248 | }) -------------------------------------------------------------------------------- /server/src/index.ts: -------------------------------------------------------------------------------- 1 | import cors from 'cors' 2 | import { createServer } from 'http' 3 | import { Server } from 'socket.io' 4 | import express from 'express'; 5 | import { IUser } from '../../types/auth'; 6 | import { IRoom } from '../../types/room'; 7 | import { ServerEmits, ClientEmits } from '../../types/socket'; 8 | import { SongItem, SpotifyTopTracksResult } from '../../types/spotify'; 9 | import * as fs from 'fs'; 10 | import { ok } from 'assert' 11 | 12 | const port = 5000; 13 | const app = express() 14 | app.use(cors()) 15 | const httpServer = createServer(app) 16 | 17 | const io = new Server(httpServer, { 18 | path: '/ws', 19 | cors: { 20 | origin: '*', 21 | methods: ['GET', 'POST'], 22 | }, 23 | }) 24 | 25 | 26 | const rooms = [] as IRoom[]; 27 | const errors = [] as string[]; 28 | 29 | const sendRoomUpdates = (roomCode: string) => { 30 | const room = rooms.find((room) => room.roomCode === roomCode); 31 | if (room) { 32 | io.to(roomCode).emit(ServerEmits.ROOM_UPDATED, room); 33 | } 34 | }; 35 | 36 | const logMessage = (message: string, type: 'info' | 'error') => { 37 | const dateTime = new Date().toLocaleString('sv-SE', { 38 | timeZone: 'Europe/Stockholm', 39 | }); 40 | 41 | // Open logs/info.log or logs/error.log 42 | const logStream = fs.createWriteStream 43 | (`logs/${ 44 | type === 'info' ? 'info' : 'error' 45 | }.log`, { flags: 'a' }); 46 | 47 | // Write log 48 | logStream.write(`[${dateTime}] ${message}\n`); 49 | } 50 | 51 | const getTopSongsForUser = async (timeRange: string, songsPerUser: number, token: string, user: IUser) => { 52 | const response = await fetch( 53 | `https://api.spotify.com/v1/me/top/tracks?time_range=${timeRange}&limit=${songsPerUser}`, 54 | { 55 | headers: { 56 | Authorization: `Bearer ${token}`, 57 | }, 58 | } 59 | ); 60 | 61 | if(response.status === 200) { 62 | const data = (await response.json()) as SpotifyTopTracksResult; 63 | return data.items.map((item) => ({ 64 | song: item, 65 | player: user, 66 | })); 67 | } else { 68 | errors.push('Try logging in again'); 69 | return []; 70 | } 71 | }; 72 | 73 | app.get('/', (req, res) => { 74 | res.send('Hey this is still in development!'); 75 | }); 76 | 77 | io.on('connection', (socket) => { 78 | socket.on(ClientEmits.REQUEST_TO_JOIN_ROOM, ({roomCode, user, token}: {roomCode: string, user: IUser, token: string}) => { 79 | if(!roomCode) { 80 | socket.emit(ServerEmits.REQUEST_TO_JOIN_ROOM_REJECTED); 81 | socket.emit('error', "You forgot the code", 'No code, no room, no game...'); 82 | return; 83 | } 84 | 85 | if(!user) { 86 | socket.emit(ServerEmits.REQUEST_TO_JOIN_ROOM_REJECTED); 87 | socket.emit('logout'); 88 | return; 89 | } 90 | 91 | if(!token) { 92 | socket.emit(ServerEmits.REQUEST_TO_JOIN_ROOM_REJECTED); 93 | socket.emit('logout'); 94 | return; 95 | } 96 | 97 | const room = rooms.find((room) => room.roomCode === roomCode); 98 | if (!room) { 99 | socket.emit(ServerEmits.REQUEST_TO_JOIN_ROOM_REJECTED); 100 | socket.emit('error', 'No such room', 'Try another room or create one'); 101 | return; 102 | } 103 | 104 | if (room.gamePosition === 0) { 105 | // Get top songs for user 106 | getTopSongsForUser(room.timeRange, room.songsPerUser, token, user).then((songs) => { 107 | if(errors.length > 0) { 108 | socket.emit(ServerEmits.REQUEST_TO_JOIN_ROOM_REJECTED); 109 | socket.emit('error', 'Error getting top songs', errors.pop()); 110 | socket.emit('logout') 111 | return; 112 | } 113 | 114 | if(songs.length === 0) { 115 | socket.emit(ServerEmits.REQUEST_TO_JOIN_ROOM_REJECTED); 116 | socket.emit('error', 'No top songs', 'Do you even use Spotify?'); 117 | socket.emit('logout') 118 | return; 119 | } 120 | 121 | room.players = [...room.players, user]; 122 | room.songs = [...room.songs, ...songs]; 123 | socket.join(roomCode) 124 | socket.emit(ServerEmits.REQUEST_TO_JOIN_ROOM_ACCEPTED, room); 125 | sendRoomUpdates(roomCode); 126 | }); 127 | } else if (room.gamePosition === 1) { 128 | // check if user is already in room 129 | const userAlreadyInRoom = room.players.find((player) => player.id === user.id); 130 | 131 | if (userAlreadyInRoom) { 132 | socket.emit(ServerEmits.REQUEST_TO_JOIN_ROOM_ACCEPTED, room); 133 | return; 134 | } 135 | } else if (room.gamePosition === 2) { 136 | // check if user is already in room 137 | const userAlreadyInRoom = room.players.find((player) => player.id === user.id); 138 | 139 | if (userAlreadyInRoom) { 140 | socket.emit(ServerEmits.REQUEST_TO_JOIN_ROOM_ACCEPTED, room); 141 | return; 142 | } 143 | } 144 | }) 145 | 146 | socket.on('addNonAuthUser', ({roomCode, nonAuthUser, songs}: {roomCode: string, nonAuthUser: IUser, songs: SongItem[]}) => { 147 | const room = rooms.find((room) => room.roomCode === roomCode); 148 | if (!room) { 149 | return; 150 | } 151 | 152 | const nonAuthUserSongs = songs.map((song) => ({ 153 | song, 154 | player: nonAuthUser, 155 | })); 156 | 157 | room.players = [...room.players, nonAuthUser]; 158 | room.songs = [...room.songs, ...nonAuthUserSongs]; 159 | 160 | sendRoomUpdates(roomCode); 161 | }) 162 | 163 | socket.on(ClientEmits.REQUEST_TO_CREATE_ROOM, ({roomCode, user, timeRange, songsPerUser, token}: {roomCode: string, user: IUser, timeRange: string, songsPerUser: number, token: string}) => { 164 | if (!roomCode || !user || !timeRange || !songsPerUser || !token) { 165 | socket.emit(ServerEmits.REQUEST_TO_CREATE_ROOM_REJECTED, "You're missing some information"); 166 | socket.emit('error', 'Missing information', 'Please restart the app and try again'); 167 | return; 168 | } 169 | 170 | const room = rooms.find((room) => room.roomCode === roomCode); 171 | if (!room) { 172 | // Get user top songs 173 | getTopSongsForUser(timeRange, songsPerUser, token, user).then((songs) => { 174 | 175 | if(errors.length !== 0) { 176 | socket.emit(ServerEmits.REQUEST_TO_CREATE_ROOM_REJECTED); 177 | socket.emit('error', "Error", errors.pop()) 178 | socket.emit('logout') 179 | return; 180 | } 181 | 182 | if(songs.length === 0) { 183 | socket.emit(ServerEmits.REQUEST_TO_CREATE_ROOM_REJECTED); 184 | socket.emit('error', 'No top songs', 'Logging in and out again might help'); 185 | socket.emit('logout') 186 | return; 187 | } 188 | // Create room 189 | const newRoom = { 190 | roomCode, 191 | host: user, 192 | players: [user], 193 | gamePosition: 0, 194 | songs, 195 | songsPerUser, 196 | currentSongIndex: 0, 197 | timeRange 198 | }; 199 | rooms.push(newRoom); 200 | socket.join(roomCode) 201 | socket.emit(ServerEmits.REQUEST_TO_CREATE_ROOM_ACCEPTED, newRoom); 202 | logMessage(`${user.name} created room ${roomCode}`, 'info'); 203 | }).catch((err) => { 204 | logMessage(err, 'error'); 205 | socket.emit(ServerEmits.REQUEST_TO_CREATE_ROOM_REJECTED); 206 | socket.emit('error', 'Something went wrong', 'Plase log in and out again'); 207 | }); 208 | } else { 209 | // if user in room, join room 210 | if (room.players.find((player) => player.id === user.id)) { 211 | socket.emit(ServerEmits.REQUEST_TO_CREATE_ROOM_ACCEPTED, room); 212 | return; 213 | } 214 | socket.emit(ServerEmits.REQUEST_TO_CREATE_ROOM_REJECTED); 215 | socket.emit('error', 'Room already exists', 'Use another code'); 216 | } 217 | }) 218 | 219 | socket.on(ClientEmits.GUESS, ({roomCode, guess, user, currentSongIndex}: {roomCode: string, guess: string, user: IUser, currentSongIndex: number}) => { 220 | // Check if guess is correct 221 | // 222 | // guess: string - the userid of the person guessd on 223 | // user: IUser - the user who made the guess 224 | // currentSongIndex: number - the index of the song in the room.songs array 225 | // 226 | 227 | const room = rooms.find((room) => room.roomCode === roomCode); 228 | if (!room) { 229 | return; 230 | } 231 | 232 | if (room.gamePosition === 1) { 233 | const player = room.players.find((player) => player.id === user.id) as IUser; 234 | 235 | if (!player) { 236 | return; 237 | } 238 | 239 | const currentGuess = player.guesses.find((guess) => guess.currentSongIndex === currentSongIndex); 240 | const answer = room.songs[currentSongIndex].player.id; 241 | const correct = answer === guess; 242 | 243 | logMessage(`${user.name} guessed ${guess} on ${room.songs[currentSongIndex].player.name} for song ${currentSongIndex} and it was ${correct ? 'correct' : 'incorrect'}`, 'info'); 244 | 245 | // If guess already exists AND the new guess differs 246 | if (currentGuess && currentGuess.guess !== guess) { 247 | // If the old guess was correct, remove a point from the player 248 | if(currentGuess.guess === answer && !correct) { 249 | player.score-- 250 | // If the old guess was incorrect, and this guess is correct, add a point to the player 251 | } else if (currentGuess.guess !== answer && correct) { 252 | player.score++ 253 | } 254 | 255 | currentGuess.guess = guess; 256 | } else if(currentGuess && currentGuess.guess === guess ) { 257 | // Do nothing 258 | } else { // New guess 259 | player.guesses.push({guess, currentSongIndex}); 260 | if(correct) player.score++ 261 | } 262 | 263 | sendRoomUpdates(roomCode); 264 | } 265 | }) 266 | 267 | socket.on('requestRoomUpdate', (roomCode: string) => { 268 | const room = rooms.find((room) => room.roomCode === roomCode); 269 | if (!room) { 270 | return; 271 | } 272 | sendRoomUpdates(roomCode); 273 | }) 274 | 275 | socket.on(ClientEmits.LEAVE_ROOM, ({roomCode, user}: {roomCode: string, user: IUser}) => { 276 | const room = rooms.find(room => room.roomCode === roomCode) 277 | if (room) { 278 | const playerIndex = room.players.findIndex(player => player.id === user.id) 279 | if (playerIndex !== -1) { 280 | // Only remove player if in lobby 281 | if(room.gamePosition === 0) { 282 | room.players.splice(playerIndex, 1) 283 | room.songs = room.songs.filter(song => song.player.id !== user.id) 284 | if(room.host.id === user.id) { 285 | room.host = room.players[0] 286 | } 287 | socket.leave(roomCode) 288 | } 289 | logMessage(`${user.name} left ${roomCode} with users ${room?.players.map(user => user.name)}`, 'info') 290 | } 291 | } 292 | sendRoomUpdates(roomCode) 293 | }) 294 | 295 | socket.on(ClientEmits.START_GAME, ({roomCode}: {roomCode: string}) => { 296 | const room = rooms.find(room => room.roomCode === roomCode) 297 | 298 | if (room) { 299 | room.gamePosition = 1 300 | logMessage(`${room.host.name} started a game in ${roomCode}`, 'info') 301 | 302 | // Shuffle songs 303 | room.songs = shuffle(room.songs) 304 | 305 | sendRoomUpdates(roomCode) 306 | } 307 | }) 308 | 309 | socket.on(ClientEmits.NEXT_SONG, ({roomCode}: {roomCode: string}) => { 310 | const room = rooms.find(room => room.roomCode === roomCode) 311 | if (room) { 312 | if(room.currentSongIndex >= room.songs.length - 1) { 313 | room.gamePosition = 2 314 | logMessage(`Game ended in ${roomCode}`, 'info') 315 | 316 | // save room to file 317 | const roomData = JSON.stringify(room, null, 2) 318 | fs.writeFile 319 | (`./rooms/${roomCode}.json`, roomData, (err) => { 320 | if (err) { 321 | console.error(err) 322 | return 323 | } 324 | // file written successfully 325 | }) 326 | 327 | } else { 328 | room.currentSongIndex += 1 329 | } 330 | sendRoomUpdates(roomCode) 331 | } 332 | }) 333 | 334 | socket.on(ClientEmits.DISCONNECT, () => { 335 | // 336 | }) 337 | }) 338 | 339 | httpServer.listen(port, '0.0.0.0') 340 | 341 | function shuffle(songs: {song: SongItem, player: IUser}[]): {song: SongItem, player: IUser}[] { 342 | const shuffledSongs = songs.map(song => ({sort: Math.random(), value: song})) 343 | .sort((a, b) => a.sort - b.sort) 344 | .map(song => song.value) 345 | 346 | return shuffledSongs 347 | } 348 | -------------------------------------------------------------------------------- /screens/RoomScreen.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect, useRef, useState } from 'react' 2 | import { StyleSheet, Image, FlatList, TouchableOpacity, ScrollView, TextInput, Switch, Button, Alert, Pressable, TouchableHighlight, RefreshControl, ActivityIndicator } from 'react-native' 3 | import { AuthContextType, IAuth, IGuess, IUser, NonAuthUser } from '../types/auth' 4 | import { Text, View } from '../components/Themed' 5 | import { AuthContext } from '../context/authContext' 6 | import { RootStackParamList, RootStackScreenProps } from '../types' 7 | import Colors from '../constants/Colors' 8 | import socket from '../utils/socket' 9 | import { Ionicons } from '@expo/vector-icons' 10 | import { IRoom } from '../types/room' 11 | import { FlashList } from '@shopify/flash-list' 12 | import { ButtonComponent } from '../components/ButtonComponent' 13 | import { SpotifyPlayer } from '../components/SpotifyPlayer' 14 | import { SongItem } from '../types/spotify' 15 | import { 16 | ClientEmits, 17 | ServerEmits 18 | } from '../types/socket' 19 | import { useSpotify } from '../hooks/useSpotify' 20 | import * as Haptics from 'expo-haptics' 21 | 22 | export default function RoomScreen({ route, navigation }: RootStackScreenProps<'Room'>) { 23 | 24 | const createRoom = route.params.createRoom as boolean 25 | 26 | const [loading, setLoading] = useState(false) 27 | const [songsPerUser, setSongsPerUser] = useState(route.params.songsPerUser) 28 | const [roomCode, setRoomCode] = useState(route.params.roomCode) 29 | const [timeRange, setTimeRange] = useState(route.params.timeRange) 30 | const [connected, setConnected] = useState(false) 31 | const [songs, setSongs] = useState<{ song: SongItem, player: IUser }[]>([]) 32 | const [isHost, setIsHost] = useState(false) 33 | const [players, setPlayers] = useState([]) 34 | const [currentSongIndex, setCurrentSongIndex] = useState(1) 35 | const [gamePosition, setGamePosition] = useState(0) 36 | const [guess, setGuess] = useState('') 37 | // const [nonAuthUsers, setNonAuthUsers] = useState([]) 38 | 39 | const { auth, logout } = useContext(AuthContext) as AuthContextType 40 | 41 | const connectedRef = useRef(connected) 42 | connectedRef.current = connected 43 | 44 | const hostRef = useRef(isHost) 45 | hostRef.current = isHost 46 | 47 | const gamePositionRef = useRef(gamePosition) 48 | gamePositionRef.current = gamePosition 49 | 50 | const guessRef = useRef(guess) 51 | guessRef.current = guess 52 | 53 | const currentSongIndexRef = useRef(currentSongIndex) 54 | currentSongIndexRef.current = currentSongIndex 55 | 56 | const { createAndAddSongsToPlaylist } = useSpotify() 57 | 58 | if (!auth.user) { 59 | navigation.navigate('Home') 60 | return null 61 | } 62 | 63 | const guessOnPress = (newGuess: string) => { 64 | // Takes the user ID as the guess and sends it to the server 65 | setGuess(newGuess) 66 | socket.emit(ClientEmits.GUESS, { guess: newGuess, roomCode: roomCode, user: auth.user, currentSongIndex: currentSongIndex }) 67 | } 68 | 69 | const leaveRoom = () => { 70 | socket.emit(ClientEmits.LEAVE_ROOM, { roomCode: roomCode, user: auth.user }) 71 | navigation.navigate('Home') 72 | } 73 | 74 | const openLeaveRoomAlert = () => { 75 | Alert.alert('Leave room', 'Are you sure you want to leave the room?', [ 76 | { 77 | text: 'Cancel', 78 | onPress: () => console.log('Cancel Pressed'), 79 | style: 'cancel', 80 | }, 81 | { text: 'Leave', onPress: () => leaveRoom() }, 82 | ]) 83 | } 84 | 85 | const openSaveSongsAlert = () => { 86 | Alert.alert('Save Songs To Playlist', 'Do you want to save the songs in this game to a playlist in your Spotify account?', [ 87 | { 88 | text: 'Cancel', 89 | onPress: () => console.log('Cancel Pressed'), 90 | style: 'cancel', 91 | }, 92 | { text: 'Save', onPress: saveSongs }, 93 | ]) 94 | } 95 | 96 | const saveSongs = () => { 97 | const newPlaylistName = `Soundcheck - ${roomCode}` 98 | createAndAddSongsToPlaylist(auth.token, newPlaylistName, songs.map((song: any) => song.song)) 99 | } 100 | 101 | const generateRandomId = () => { 102 | return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15) 103 | } 104 | 105 | useEffect(() => { 106 | if (route.params?.nonAuthUser) { 107 | // setNonAuthUsers([...nonAuthUsers, route.params.nonAuthUser]) 108 | 109 | // convert non-auth user to user for merging with players 110 | const newUser: IUser = { 111 | id: generateRandomId(), 112 | name: route.params.nonAuthUser.name, 113 | avatar: 'https://picsum.photos/200', 114 | score: 0, 115 | guesses: [] 116 | } 117 | 118 | socket.emit('addNonAuthUser', { roomCode: roomCode, nonAuthUser: newUser, songs: route.params.nonAuthUser.songs }) 119 | 120 | setPlayers([...players, newUser]) 121 | } 122 | }, [route.params?.nonAuthUser]) 123 | 124 | useEffect(() => { 125 | if (createRoom) { 126 | socket.emit('requestToCreateRoom', { 127 | roomCode: roomCode, 128 | user: auth.user, 129 | token: auth.token, 130 | songsPerUser: songsPerUser, 131 | timeRange: timeRange 132 | }) 133 | } else { 134 | socket.emit(ClientEmits.REQUEST_TO_JOIN_ROOM, { 135 | roomCode: roomCode, 136 | user: auth.user, 137 | token: auth.token 138 | }) 139 | } 140 | 141 | socket.on(ServerEmits.REQUEST_TO_CREATE_ROOM_ACCEPTED, (room: IRoom) => { 142 | setConnected(true) 143 | setIsHost(true) 144 | setSongs(room.songs) 145 | setPlayers(room.players) 146 | }) 147 | 148 | socket.on(ServerEmits.REQUEST_TO_CREATE_ROOM_REJECTED, () => { 149 | navigation.navigate('Home') 150 | }) 151 | 152 | socket.on(ServerEmits.REQUEST_TO_JOIN_ROOM_ACCEPTED, (room: IRoom) => { 153 | setIsHost(false) 154 | setSongs(room.songs) 155 | setPlayers(room.players) 156 | setCurrentSongIndex(room.currentSongIndex) 157 | setGamePosition(room.gamePosition) 158 | setConnected(true) 159 | }) 160 | 161 | socket.on(ServerEmits.REQUEST_TO_JOIN_ROOM_REJECTED, () => { 162 | navigation.navigate('Home') 163 | }) 164 | 165 | socket.on(ServerEmits.ROOM_UPDATED, (room: IRoom) => { 166 | setPlayers(room.players) 167 | setSongs(room.songs) 168 | setIsHost(room.host.id === auth.user?.id) 169 | 170 | // New song 171 | if (room.currentSongIndex !== currentSongIndexRef.current) { 172 | 173 | // Check if the guess was correct 174 | // Vibrate accordingly 175 | if (gamePositionRef.current !== 0) { 176 | const guess = guessRef.current 177 | const correct = room.songs[room.currentSongIndex].player.id 178 | if (guess === correct) { 179 | Haptics.notificationAsync( 180 | Haptics.NotificationFeedbackType.Success 181 | ) 182 | } else { 183 | Haptics.notificationAsync( 184 | Haptics.NotificationFeedbackType.Error 185 | ) 186 | } 187 | } 188 | 189 | 190 | setGuess('') 191 | } 192 | 193 | setCurrentSongIndex(room.currentSongIndex) 194 | setGamePosition(room.gamePosition) 195 | setLoading(false) 196 | 197 | if (room.gamePosition === 2) { 198 | // Clear all room data 199 | setGuess('') 200 | setCurrentSongIndex(0) 201 | setGamePosition(2) 202 | setConnected(false) 203 | setIsHost(false) 204 | } 205 | }) 206 | }, []) 207 | 208 | useEffect(() => { 209 | navigation.setOptions({ 210 | title: ``, 211 | headerBackTitleVisible: true, 212 | headerBackTitle: 'Back', 213 | headerBackVisible: true, 214 | headerLargeTitle: false, 215 | headerStyle: { 216 | backgroundColor: Colors.background, 217 | }, 218 | headerShadowVisible: false, 219 | headerRight: () => ( 220 | <> 221 | {gamePositionRef.current === 0 && openLeaveRoomAlert()} />} 222 | {gamePositionRef.current === 2 && openSaveSongsAlert()} />} 223 | 224 | ), 225 | }) 226 | }, [gamePositionRef.current]) 227 | 228 | const onRefresh = () => { 229 | socket.connect() 230 | socket.emit('requestRoomUpdate', roomCode) 231 | } 232 | 233 | const openGuessDetailModal = (user: IUser) => { 234 | navigation.navigate('PlayerGuessDetails', { user: user, songs: songs }) 235 | } 236 | 237 | const nextSong = () => { 238 | setLoading(true) 239 | socket.emit('nextSong', { roomCode: roomCode }) 240 | } 241 | 242 | if (gamePosition === 0) { 243 | return ( 244 | } 246 | data={players} 247 | renderItem={({ item }) => 248 | 249 | 250 | 251 | } 252 | ItemSeparatorComponent={() => } 253 | ListHeaderComponent={() => 254 | Room code 258 | {roomCode} 263 | 264 | {isHost && "Other users can join the game by entering the room code. Start the game when everyone's ready! "} 265 | {!isHost && 'Waiting for host to start the game... Invite others by sharing the room code with them! '} 266 | Who's gonna win? 267 | {/* 268 | 269 | */} 270 | } 271 | ListFooterComponent={() => 272 | <> 273 | {isHost && 274 | socket.emit('startGame', { roomCode })} /> 275 | } 276 | 277 | } 278 | keyExtractor={(player) => player.id} 279 | estimatedItemSize={100} 280 | /> 281 | ) 282 | } else if (gamePosition === 1) { 283 | return ( 284 | }> 285 | 286 | {currentSongIndex + 1} of {songs.length} 290 | Whos song is this? 295 | Click on the player you think this song belongs to. 299 | 300 | {players.map((item) => 301 | guessOnPress(item.id)} key={item.id}> 302 | 303 | guess.currentSongIndex == currentSongIndex) ? 'Guessed' : ''} /> 304 | 305 | 306 | )} 307 | 308 | {songs && songs.length > currentSongIndex && 309 | } 310 | 311 | {isHost && !loading && 312 | 313 | } 314 | {isHost && loading && 315 | 316 | 317 | 318 | } 319 | 320 | ) 321 | } else { 322 | return ( 323 | b.score - a.score)} 326 | renderItem={({ item, index }) => 327 | 328 | {index + 1} 334 | openGuessDetailModal(item)}> 335 | 336 | 337 | 338 | } 339 | ItemSeparatorComponent={() => } 340 | ListHeaderComponent={() => 341 | {roomCode} 345 | Hey you made it to the end! 350 | } 351 | ListFooterComponent={() => 352 | 353 | 354 | } 355 | keyExtractor={(player) => player.id} 356 | estimatedItemSize={100} 357 | /> 358 | ) 359 | } 360 | } 361 | 362 | interface IUserCard { 363 | avatar: string 364 | name: string 365 | style?: any 366 | description?: string 367 | } 368 | 369 | const UserCard = ({ avatar, name, style, description }: IUserCard) => { 370 | const localStyles = { flexDirection: 'row', alignItems: 'center', backgroundColor: Colors.backgroundDark, padding: 20, borderRadius: 10, height: 80 } 371 | const allStyles = [localStyles, style] 372 | 373 | return ( 374 | 375 | 376 | 377 | {name} 378 | {description && {description}} 379 | 380 | 381 | ) 382 | } 383 | 384 | --------------------------------------------------------------------------------