├── 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 | 
11 |
12 | 
13 |
14 | [Soundwrap Status Website](http://status.soundwrap.app)
15 |
16 | 
17 |
18 | **If you want to support this app and what I do:**
19 |
20 | [](https://www.paypal.com/donate/?hosted_button_id=HAA8RD9LJQ2ZW)
21 |
22 |
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 |
--------------------------------------------------------------------------------