├── app
├── screens
│ ├── Player.tsx
│ ├── PlayerController
│ │ ├── PlayerControllerHeader.android.tsx
│ │ ├── PlayerControllerDeviceButton.tsx
│ │ ├── PlayerControllerHeader.tsx
│ │ └── PlayerControllerTracks.tsx
│ ├── SignIn.tsx
│ ├── DevicePicker.tsx
│ ├── PlayerController.tsx
│ └── MyPlaylists.tsx
├── util
│ ├── confirm.ts
│ ├── alertAsync.web.ts
│ ├── confirmAsync.web.ts
│ ├── alertAsync.ts
│ └── confirmAsync.ts
├── assets
│ ├── icon.png
│ ├── favicon.png
│ ├── playlist.png
│ ├── splash.png
│ ├── background.jpg
│ └── foreground.png
├── vercel.json
├── babel.config.js
├── metro.config.js
├── .expo-shared
│ └── assets.json
├── .gitignore
├── webpack.config.js
├── __generated__
│ └── AppEntry.js
├── tsconfig.json
├── styleguide
│ └── index.ts
├── components
│ ├── StatusBar.tsx
│ ├── AnchorButton.tsx
│ ├── Spacer.tsx
│ ├── DeviceIcon.tsx
│ ├── TrackImage.tsx
│ ├── Text.tsx
│ ├── Button.tsx
│ ├── playlists
│ │ └── index.tsx
│ ├── PlayerStatusBottomControl.tsx
│ └── PlaybackDaemon.tsx
├── hooks
│ ├── useInterval.ts
│ ├── useAssets.ts
│ ├── usePersistedData.ts
│ └── useSpotifyAuth.ts
├── types
│ ├── index.ts
│ └── recoil.d.ts
├── app.json
├── state
│ ├── index.ts
│ └── LocalStorage.ts
├── App.tsx
├── package.json
├── navigation
│ ├── index.tsx
│ └── index.web.tsx
└── api
│ └── index.ts
├── Procfile
├── .gitignore
├── server
├── .gitignore
├── package.json
├── api
│ └── index.ts
└── tsconfig.json
├── _static
├── ios.png
├── android.png
└── mockups.png
├── index.js
├── package.json
└── README.md
/app/screens/Player.tsx:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/util/confirm.ts:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/Procfile:
--------------------------------------------------------------------------------
1 | web: cd server && yarn start
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | yarn-error.log
3 |
--------------------------------------------------------------------------------
/server/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | build
3 |
--------------------------------------------------------------------------------
/_static/ios.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/brentvatne/hour-power/HEAD/_static/ios.png
--------------------------------------------------------------------------------
/app/screens/PlayerController/PlayerControllerHeader.android.tsx:
--------------------------------------------------------------------------------
1 | export default () => null;
--------------------------------------------------------------------------------
/_static/android.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/brentvatne/hour-power/HEAD/_static/android.png
--------------------------------------------------------------------------------
/_static/mockups.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/brentvatne/hour-power/HEAD/_static/mockups.png
--------------------------------------------------------------------------------
/app/assets/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/brentvatne/hour-power/HEAD/app/assets/icon.png
--------------------------------------------------------------------------------
/app/assets/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/brentvatne/hour-power/HEAD/app/assets/favicon.png
--------------------------------------------------------------------------------
/app/assets/playlist.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/brentvatne/hour-power/HEAD/app/assets/playlist.png
--------------------------------------------------------------------------------
/app/assets/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/brentvatne/hour-power/HEAD/app/assets/splash.png
--------------------------------------------------------------------------------
/app/assets/background.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/brentvatne/hour-power/HEAD/app/assets/background.jpg
--------------------------------------------------------------------------------
/app/assets/foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/brentvatne/hour-power/HEAD/app/assets/foreground.png
--------------------------------------------------------------------------------
/app/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": 2,
3 | "routes": [{ "handle": "filesystem" }, { "src": "/.*", "dest": "/index.html" }]
4 | }
--------------------------------------------------------------------------------
/app/util/alertAsync.web.ts:
--------------------------------------------------------------------------------
1 | export default function alertAsync({ message }: { message: string }) {
2 | alert(message);
3 | }
4 |
--------------------------------------------------------------------------------
/app/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = function(api) {
2 | api.cache(true);
3 | return {
4 | presets: ['babel-preset-expo'],
5 | };
6 | };
7 |
--------------------------------------------------------------------------------
/app/metro.config.js:
--------------------------------------------------------------------------------
1 | const { createMetroConfiguration } = require('expo-yarn-workspaces');
2 |
3 | module.exports = createMetroConfiguration(__dirname);
4 |
--------------------------------------------------------------------------------
/app/util/confirmAsync.web.ts:
--------------------------------------------------------------------------------
1 | export default async function confirmAsync({ message }: { message: string }) {
2 | return window.confirm(message);
3 | }
4 |
--------------------------------------------------------------------------------
/app/.expo-shared/assets.json:
--------------------------------------------------------------------------------
1 | {
2 | "12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true,
3 | "40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true
4 | }
5 |
--------------------------------------------------------------------------------
/app/util/alertAsync.ts:
--------------------------------------------------------------------------------
1 | import { Alert } from "react-native";
2 | type AlertOptions = {
3 | title: string;
4 | message: string;
5 | };
6 |
7 | export default function alertAsync({ title, message }: AlertOptions) {
8 | Alert.alert(title, message);
9 | }
10 |
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/**/*
2 | .expo/*
3 | npm-debug.*
4 | *.jks
5 | *.p8
6 | *.p12
7 | *.key
8 | *.mobileprovision
9 | *.orig.*
10 | web-build/
11 | web-report/
12 | yarn-error.log
13 |
14 | # macOS
15 | .DS_Store
16 |
17 | .vercel
18 | google-services.json
19 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | // @generated by expo-yarn-workspaces
2 |
3 | import { registerRootComponent } from 'expo';
4 | import { activateKeepAwake } from 'expo-keep-awake';
5 |
6 | import App from './App';
7 |
8 | if (__DEV__) {
9 | activateKeepAwake();
10 | }
11 |
12 | registerRootComponent(App);
13 |
--------------------------------------------------------------------------------
/app/webpack.config.js:
--------------------------------------------------------------------------------
1 | const createExpoWebpackConfigAsync = require('@expo/webpack-config');
2 |
3 | module.exports = async function (env, argv) {
4 | const config = await createExpoWebpackConfigAsync({...env, offline: false }, argv);
5 | // Customize the config before returning it.
6 | return config;
7 | };
8 |
--------------------------------------------------------------------------------
/app/__generated__/AppEntry.js:
--------------------------------------------------------------------------------
1 | // @generated by expo-yarn-workspaces
2 |
3 | import { registerRootComponent } from 'expo';
4 | import { activateKeepAwake } from 'expo-keep-awake';
5 |
6 | import App from '../App';
7 |
8 | if (__DEV__) {
9 | activateKeepAwake();
10 | }
11 |
12 | registerRootComponent(App);
13 |
--------------------------------------------------------------------------------
/app/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowSyntheticDefaultImports": true,
4 | "jsx": "react-native",
5 | "lib": ["dom", "esnext"],
6 | "moduleResolution": "node",
7 | "noEmit": true,
8 | "skipLibCheck": true,
9 | "resolveJsonModule": true,
10 | "strict": true
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "hour-power",
3 | "private": true,
4 | "scripts": {
5 | "postinstall": "expo-yarn-workspaces postinstall"
6 | },
7 | "engines": {
8 | "node": "14.x",
9 | "yarn": "1.x"
10 | },
11 | "workspaces": [
12 | "app",
13 | "server"
14 | ],
15 | "dependencies": {
16 | "expo-yarn-workspaces": "~1.2.1"
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/app/styleguide/index.ts:
--------------------------------------------------------------------------------
1 | import { Platform } from "react-native";
2 |
3 | export const colors = {
4 | light: "#eee",
5 | neongreen: "#67bd64",
6 | loading: Platform.OS === "ios" ? "#ccc" : "#7d72b6",
7 | };
8 |
9 | export const icons = {};
10 |
11 | export const images = {
12 | background: require("../assets/background.jpg"),
13 | placeholder: require("../assets/playlist.png"),
14 | };
15 |
--------------------------------------------------------------------------------
/app/components/StatusBar.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Platform } from "react-native";
3 | import { StatusBar as ExpoStatusBar, StatusBarProps } from "expo-status-bar";
4 |
5 | export default function StatusBar(props: StatusBarProps) {
6 | if (Platform.OS === "android") {
7 | return ;
8 | }
9 |
10 | return ;
11 | }
12 |
--------------------------------------------------------------------------------
/app/components/AnchorButton.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useRef } from "react";
2 | import { A } from "@expo/html-elements";
3 | import { useActive, useHover, useFocus } from "react-native-web-hooks";
4 |
5 | export default function AnchorButton(props: any) {
6 | const ref = useRef(null);
7 | const isHovered = useHover(ref);
8 |
9 | return (
10 |
15 | {props.children}
16 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "hour-power-server",
3 | "version": "1.0.0",
4 | "main": "index.js",
5 | "author": "Brent Vatne",
6 | "license": "MIT",
7 | "scripts": {
8 | "start": "yarn build && yarn api:prod",
9 | "build": "tsc",
10 | "api:dev": "ts-node api/index.ts",
11 | "api:prod": "node build/api/index.js"
12 | },
13 | "dependencies": {
14 | "@types/node-fetch": "^2.5.7",
15 | "axios": "^0.19.2",
16 | "fastify": "^3.9.2",
17 | "node-fetch": "^2.6.0",
18 | "typescript": "^4.1.3"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/app/components/Spacer.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { View } from "react-native";
3 |
4 | export const HR = ({ color = "#eee", size = 1, spaceAround = 15 }) => (
5 |
13 | );
14 |
15 | export const Vertical = ({ size }: { size: number }) => (
16 |
17 | );
18 |
19 | export const Horizontal = ({ size }: { size: number }) => (
20 |
21 | );
22 |
--------------------------------------------------------------------------------
/app/hooks/useInterval.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from "react";
2 |
3 | export default function useInterval(
4 | callback: () => void,
5 | delay: number | null | false
6 | ) {
7 | const savedCallback = useRef(callback);
8 |
9 | // Remember the latest callback.
10 | useEffect(() => {
11 | savedCallback.current = callback;
12 | });
13 |
14 | // Set up the interval.
15 | useEffect(() => {
16 | if (delay === null || delay === false) return undefined;
17 | const tick = () => savedCallback.current();
18 | const id = setInterval(tick, delay);
19 | return () => clearInterval(id);
20 | }, [delay]);
21 | }
22 |
--------------------------------------------------------------------------------
/app/util/confirmAsync.ts:
--------------------------------------------------------------------------------
1 | import { Alert } from "react-native";
2 |
3 | type NativeConfirmOptions = {
4 | title: string;
5 | message: string;
6 | confirmButtonText?: string;
7 | };
8 |
9 | export default async function confirmAsync(options: NativeConfirmOptions) {
10 | return await new Promise((resolve, reject) => {
11 | Alert.alert(options.title, options.message, [
12 | {
13 | text: options.confirmButtonText ?? "OK",
14 | onPress: () => resolve(true),
15 | },
16 | {
17 | text: "Cancel",
18 | onPress: () => resolve(false),
19 | style: "cancel",
20 | },
21 | ]);
22 | });
23 | }
24 |
--------------------------------------------------------------------------------
/app/hooks/useAssets.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from "react";
2 | import { Asset } from "expo-asset";
3 |
4 | export default function useAssets(assets: { [key: string]: any }) {
5 | let [assetsLoaded, setAssetsLoaded] = useState(false);
6 |
7 | useEffect(() => {
8 | async function loadAssetsAsync() {
9 | try {
10 | const assetAssetIds = Object.values(assets);
11 | const assetLoadingPromises = assetAssetIds.map((assetId) =>
12 | Asset.fromModule(assetId).downloadAsync()
13 | );
14 |
15 | await Promise.all(assetLoadingPromises);
16 | } catch (e) {
17 | console.warn(e);
18 | } finally {
19 | setAssetsLoaded(true);
20 | }
21 | }
22 |
23 | loadAssetsAsync();
24 | });
25 |
26 | return assetsLoaded;
27 | }
28 |
--------------------------------------------------------------------------------
/app/components/DeviceIcon.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Fontisto } from "@expo/vector-icons";
3 | import { Device } from "../api";
4 |
5 | export default function DeviceIcon({ type }: { type: Device["type"] }) {
6 | let iconName = "codepen";
7 | if (type === "TV") {
8 | iconName = "tv";
9 | } else if (type === "Smartphone") {
10 | iconName = "mobile-alt";
11 | } else if (type === "GameConsole") {
12 | iconName = "codepen";
13 | } else if (type === "Computer") {
14 | iconName = "laptop";
15 | } else if (type === "Tablet") {
16 | iconName = "tablet";
17 | } else if (type === "Speaker") {
18 | iconName = "codepen";
19 | } else if (type === "Automobile") {
20 | iconName = "automobile";
21 | }
22 |
23 | return ;
24 | }
25 |
--------------------------------------------------------------------------------
/app/types/index.ts:
--------------------------------------------------------------------------------
1 | export type Credentials = {
2 | token: string;
3 | refreshToken: string;
4 | expiresIn: number;
5 | lastRefreshed: string; // date obj as string for serialization
6 | };
7 |
8 |
9 | export type PersistedData = {
10 | credentials: Credentials;
11 | [key: string]: any;
12 | };
13 |
14 | // JSON type
15 | // https://github.com/microsoft/TypeScript/issues/1897#issuecomment-580962081
16 |
17 | export type Json =
18 | | null
19 | | boolean
20 | | number
21 | | string
22 | | Json[]
23 | | { [prop: string]: Json };
24 |
25 | export type JsonCompatible = {
26 | [P in keyof T]: T[P] extends Json
27 | ? T[P]
28 | : Pick extends Required>
29 | ? never
30 | : T[P] extends (() => any) | undefined
31 | ? never
32 | : JsonCompatible;
33 | };
34 |
35 |
--------------------------------------------------------------------------------
/app/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "name": "Hour Power",
4 | "slug": "hour-power",
5 | "scheme": "hourpower",
6 | "platforms": ["ios", "android", "web"],
7 | "version": "3.0.0",
8 | "orientation": "portrait",
9 | "icon": "./assets/icon.png",
10 | "userInterfaceStyle": "light",
11 | "splash": {
12 | "image": "./assets/splash.png",
13 | "resizeMode": "contain",
14 | "backgroundColor": "#5a5b99"
15 | },
16 | "updates": {
17 | "fallbackToCacheTimeout": 0
18 | },
19 | "assetBundlePatterns": ["**/*"],
20 | "primaryColor": "#5a5b99",
21 | "android": {
22 | "package": "xyz.bront.hourpower",
23 | "versionCode": 13,
24 | "permissions": [],
25 | "adaptiveIcon": {
26 | "foregroundImage": "./assets/foreground.png",
27 | "backgroundColor": "#5a5b99"
28 | }
29 | },
30 | "ios": {
31 | "supportsTablet": false,
32 | "bundleIdentifier": "xyz.bront.hourpower",
33 | "buildNumber": "3"
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/app/hooks/usePersistedData.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import * as LocalStorage from "../state/LocalStorage";
3 | import { currentUserState } from "../state";
4 | import { useRecoilState } from "recoil";
5 |
6 | // Pull data from local persistent storage into state
7 | export default function usePersistedData(): boolean {
8 | const [dataLoaded, setDataLoaded] = useState(false);
9 | const [currentUser, setCurrentUser] = useRecoilState(currentUserState);
10 |
11 | useEffect(() => {
12 | if (currentUser.isAuthenticated !== null) {
13 | setDataLoaded(true);
14 | }
15 | }, [currentUser.isAuthenticated]);
16 |
17 | useEffect(() => {
18 | async function initializeAsync() {
19 | let persistedData;
20 | try {
21 | persistedData = await LocalStorage.loadAsync();
22 | } catch (e) {
23 | console.log(e);
24 | } finally {
25 | setCurrentUser({
26 | isAuthenticated: !!persistedData?.credentials?.token,
27 | });
28 | }
29 | }
30 |
31 | initializeAsync();
32 | }, []);
33 |
34 | return dataLoaded;
35 | }
36 |
--------------------------------------------------------------------------------
/app/state/index.ts:
--------------------------------------------------------------------------------
1 | import { atom } from "recoil";
2 | import { Device, Playlist, Track } from "../api";
3 |
4 | type PlayerSelection = {
5 | device: null | Device;
6 | playlist: null | Playlist;
7 | tracks: Track[];
8 | trackDuration: number;
9 | };
10 |
11 | type PlaybackStatus = {
12 | playlistId: null | string;
13 | isPlaying: boolean;
14 | previousTrack: null | Track;
15 | currentTrack: null | Track;
16 | nextTrack: null | Track;
17 | elapsedTime: number;
18 | };
19 |
20 | type CurrentUser = {
21 | isAuthenticated: boolean | null;
22 | };
23 |
24 | export const currentUserState = atom({
25 | key: "CurrentUser",
26 | default: {
27 | isAuthenticated: null, // unknown if authenticated
28 | },
29 | });
30 |
31 | export const playerSelectionState = atom({
32 | key: "PlayerSelection",
33 | default: {
34 | device: null,
35 | playlist: null,
36 | tracks: [],
37 | trackDuration: 60000, // default is 60000 for power hour ofc
38 | },
39 | });
40 |
41 | export const playbackStatusState = atom({
42 | key: "PlaybackStatus",
43 | default: {
44 | playlistId: null,
45 | isPlaying: false,
46 | previousTrack: null,
47 | currentTrack: null,
48 | nextTrack: null,
49 | elapsedTime: 0,
50 | },
51 | });
52 |
--------------------------------------------------------------------------------
/app/screens/PlayerController/PlayerControllerDeviceButton.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { BorderlessButton } from "react-native-gesture-handler";
3 | import { useSafeArea } from "react-native-safe-area-context";
4 |
5 | import DeviceIcon from "../../components/DeviceIcon";
6 | import * as Text from "../../components/Text";
7 | import { Device } from "../../api";
8 |
9 | type Props = {
10 | device: Device | null;
11 | navigation: any;
12 | };
13 |
14 | export default function PlayerControllerDeviceButton({
15 | device,
16 | navigation,
17 | }: Props) {
18 | const insets = useSafeArea();
19 |
20 | if (!device) {
21 | return null;
22 | }
23 |
24 | return (
25 | navigation.navigate("DevicePicker")}
37 | >
38 |
39 |
40 | Listening on: {device.name}
41 |
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/app/screens/PlayerController/PlayerControllerHeader.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { View } from "react-native";
3 | import { BorderlessButton } from "react-native-gesture-handler";
4 | import { Ionicons } from "@expo/vector-icons";
5 |
6 | import * as Text from "../../components/Text";
7 |
8 | export default function PlayerControllerHeader({ title, navigation }: any) {
9 | return (
10 | <>
11 |
20 | navigation.goBack()}
22 | hitSlop={{ top: 20, right: 20, left: 20, bottom: 20 }}
23 | style={{ width: 50 }}
24 | >
25 |
26 |
27 |
35 |
36 | {title}
37 |
38 |
39 |
40 | >
41 | );
42 | }
43 |
--------------------------------------------------------------------------------
/app/components/TrackImage.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { StyleSheet, Image, View } from "react-native";
3 | // @ts-ignore
4 | import FadeIn from "react-native-fade-in-image";
5 |
6 | import { Track } from "../api";
7 | import { images } from "../styleguide";
8 |
9 | export default function TrackImage({
10 | track,
11 | size,
12 | style,
13 | faded,
14 | }: {
15 | track: Track | null;
16 | size?: "large";
17 | style?: any;
18 | faded?: boolean;
19 | }) {
20 | let source;
21 | if (!track || track === null) {
22 | source = null;
23 | } else {
24 | source = track.images[0] ? { uri: track.images[0] } : images.placeholder;
25 | }
26 |
27 | return (
28 |
29 | {source === null ? (
30 |
31 | ) : (
32 |
33 | )}
34 |
35 | );
36 | }
37 |
38 | const styles = StyleSheet.create({
39 | large: {
40 | transform: [{ scale: 1.8 }],
41 | elevation: 2,
42 | zIndex: 1000,
43 | shadowOffset: { width: 0, height: 2 },
44 | shadowRadius: 10,
45 | shadowColor: "black",
46 | shadowOpacity: 0.4,
47 | },
48 | faded: {
49 | opacity: 0.6,
50 | },
51 | });
52 |
--------------------------------------------------------------------------------
/app/components/Text.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Text, TextProperties } from "react-native";
3 |
4 | interface Props extends TextProperties {
5 | children?: any;
6 | }
7 |
8 | export const Light = (props: Props) => (
9 |
13 | );
14 |
15 | export const Regular = (props: Props) => (
16 |
20 | );
21 |
22 | export const Italic = (props: Props) => (
23 |
27 | );
28 |
29 | export const SemiBold = (props: Props) => (
30 |
34 | );
35 |
36 | export const Bold = (props: Props) => (
37 |
41 | );
42 |
43 | export const Title = (props: Props) => (
44 |
45 | );
46 |
47 | export const Subtitle = (props: Props) => (
48 |
49 | );
50 |
51 | export const Secondary = (props: Props) => (
52 |
53 | );
54 |
--------------------------------------------------------------------------------
/app/state/LocalStorage.ts:
--------------------------------------------------------------------------------
1 | import AsyncStorage from "@react-native-community/async-storage";
2 | import { PersistedData, Credentials, JsonCompatible } from "../types";
3 |
4 | const KEY = "@hourpower/";
5 |
6 | export async function setAuthCredentialsAsync(credentials: Credentials) {
7 | await setAsync("credentials", credentials);
8 | }
9 |
10 | export async function getAuthCredentialsAsync(): Promise {
11 | return getAsync("credentials", {});
12 | }
13 |
14 | export async function getTokenAsync() {
15 | const credentials = await getAuthCredentialsAsync();
16 | return credentials.token;
17 | }
18 |
19 | export async function clearAsync() {
20 | await AsyncStorage.clear();
21 | }
22 |
23 | export async function setAsync>(
24 | key: string,
25 | value: T
26 | ) {
27 | const data = await loadAsync();
28 | const updated = {
29 | ...(data ?? {}),
30 | [key]: value,
31 | };
32 |
33 | await saveAsync(updated);
34 | }
35 |
36 | export async function getAsync(key: string, fallback: T | null = null) {
37 | const data = await loadAsync();
38 | return data?.[key] ?? fallback;
39 | }
40 |
41 | export async function saveAsync(data: any) {
42 | await AsyncStorage.setItem(KEY, JSON.stringify(data));
43 | }
44 |
45 | export async function loadAsync(): Promise {
46 | try {
47 | const data = await AsyncStorage.getItem(KEY);
48 | if (!data) {
49 | return null;
50 | }
51 | return JSON.parse(data);
52 | } catch (e) {
53 | return null;
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/app/screens/PlayerController/PlayerControllerTracks.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Platform, View } from "react-native";
3 |
4 | import { Track } from "../../api";
5 | import * as Text from "../../components/Text";
6 | import TrackImage from "../../components/TrackImage";
7 |
8 | type Props = {
9 | previous: Track | null;
10 | current: Track | null;
11 | next: Track | null;
12 | };
13 |
14 | export default function PlayerControllerTracks({
15 | previous,
16 | current,
17 | next,
18 | }: Props) {
19 | return (
20 |
21 |
30 |
31 |
32 |
33 |
34 |
42 |
43 | {current?.name ?? " "}
44 |
45 |
46 | {current?.artists ? current?.artists.join(", ") : " "}
47 |
48 |
49 |
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/app/App.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { LogBox } from "react-native";
3 | import AppLoading from "expo-app-loading";
4 | import { Fontisto, Ionicons } from "@expo/vector-icons";
5 | import {
6 | useFonts,
7 | SourceSansPro_300Light,
8 | SourceSansPro_400Regular,
9 | SourceSansPro_400Regular_Italic,
10 | SourceSansPro_600SemiBold,
11 | SourceSansPro_700Bold,
12 | } from "@expo-google-fonts/source-sans-pro";
13 | import { SafeAreaProvider } from "react-native-safe-area-context";
14 | import { RecoilRoot } from "recoil";
15 |
16 | import { icons, images } from "./styleguide";
17 | import Navigation from "./navigation";
18 | import StatusBar from "./components/StatusBar";
19 | import PlaybackDaemon from "./components/PlaybackDaemon";
20 | import useAssets from "./hooks/useAssets";
21 | import usePersistedData from "./hooks/usePersistedData";
22 |
23 | export default function AppContainer() {
24 | return (
25 | <>
26 |
27 |
28 |
29 |
30 |
31 |
32 | >
33 | );
34 | }
35 |
36 | function App() {
37 | let [fontsLoaded] = useFonts({
38 | SourceSansPro_300Light,
39 | SourceSansPro_400Regular,
40 | SourceSansPro_400Regular_Italic,
41 | SourceSansPro_600SemiBold,
42 | SourceSansPro_700Bold,
43 | ...Fontisto.font,
44 | ...Ionicons.font,
45 | });
46 |
47 | let assetsLoaded = useAssets({ ...icons, ...images });
48 | let dataLoaded = usePersistedData();
49 |
50 | if (!fontsLoaded || !assetsLoaded || !dataLoaded) {
51 | return ;
52 | }
53 |
54 | return (
55 | <>
56 |
57 |
58 | >
59 | );
60 | }
61 |
62 | // Note sure where this is coming from, but...
63 | if (__DEV__) {
64 | LogBox &&
65 | LogBox.ignoreLogs([
66 | "Setting a timer for a long period of time",
67 | "Native splash screen is already",
68 | ]);
69 | }
70 |
--------------------------------------------------------------------------------
/app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "hour-power-app",
3 | "version": "1.0.0",
4 | "main": "__generated__/AppEntry.js",
5 | "scripts": {
6 | "start": "expo start",
7 | "android": "expo start --android",
8 | "ios": "expo start --ios",
9 | "web": "expo start --web",
10 | "eject": "expo eject",
11 | "postinstall": "expo-yarn-workspaces postinstall"
12 | },
13 | "dependencies": {
14 | "@expo-google-fonts/source-sans-pro": "^0.1.0",
15 | "@expo/html-elements": "^0.0.0-alpha.5",
16 | "@react-native-community/async-storage": "~1.12.0",
17 | "@react-native-community/hooks": "^2.5.1",
18 | "@react-native-community/masked-view": "0.1.10",
19 | "@react-navigation/native": "^5.8.10",
20 | "@react-navigation/stack": "^5.12.8",
21 | "@types/react-native-htmlview": "^0.12.2",
22 | "expo": "^40.0.0",
23 | "expo-app-loading": "^1.0.1",
24 | "expo-asset": "~8.2.1",
25 | "expo-auth-session": "~3.0.0",
26 | "expo-constants": "~9.3.3",
27 | "expo-font": "~8.4.0",
28 | "expo-keep-awake": "~8.4.0",
29 | "expo-linking": "~2.0.0",
30 | "expo-random": "~10.0.0",
31 | "expo-status-bar": "~1.0.3",
32 | "expo-updates": "~0.4.1",
33 | "expo-web-browser": "~8.6.0",
34 | "react": "16.13.1",
35 | "react-dom": "16.13.1",
36 | "react-native": "https://github.com/expo/react-native/archive/sdk-40.0.1.tar.gz",
37 | "react-native-fade-in-image": "^1.5.0",
38 | "react-native-gesture-handler": "~1.8.0",
39 | "react-native-htmlview": "^0.16.0",
40 | "react-native-reanimated": "~1.13.0",
41 | "react-native-safe-area-context": "3.1.9",
42 | "react-native-screens": "~2.15.0",
43 | "react-native-web": "~0.13.12",
44 | "react-native-web-hooks": "^3.0.1",
45 | "react-query": "^1.5.2",
46 | "recoil": "~0.1.1",
47 | "spotify-web-api-js": "~1.5.0"
48 | },
49 | "devDependencies": {
50 | "@babel/core": "~7.9.0",
51 | "@expo/webpack-config": "~0.12.45",
52 | "@types/react": "~16.9.35",
53 | "@types/react-native": "~0.63.2",
54 | "babel-preset-expo": "8.3.0",
55 | "typescript": "~4.0.0"
56 | },
57 | "private": true
58 | }
59 |
--------------------------------------------------------------------------------
/app/components/Button.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Alert, TouchableHighlight, View } from "react-native";
3 | import * as Linking from "expo-linking";
4 | import { Fontisto } from "@expo/vector-icons";
5 |
6 | import * as Text from "../components/Text";
7 | import { colors } from "../styleguide";
8 | import alertAsync from "../util/alertAsync";
9 |
10 | type ButtonProps =
11 | | {
12 | onPress: Function;
13 | children: any;
14 | label?: never;
15 | }
16 | | {
17 | onPress: Function;
18 | label: string;
19 | children?: never;
20 | };
21 |
22 | export function Green(props: ButtonProps) {
23 | return (
24 | props.onPress()}
26 | underlayColor="green"
27 | style={{
28 | marginHorizontal: 20,
29 | backgroundColor: colors.neongreen,
30 | paddingVertical: 20,
31 | borderRadius: 5,
32 | paddingHorizontal: 30,
33 | }}
34 | >
35 |
42 | {props.label ? (
43 |
46 | {props.label}
47 |
48 | ) : (
49 | props.children
50 | )}
51 |
52 |
53 | );
54 | }
55 |
56 | export function OpenSpotify() {
57 | return (
58 | {
60 | try {
61 | await Linking.openURL("spotify://");
62 | } catch (e) {
63 | alertAsync({
64 | title: "Unable to open Spotify",
65 | message:
66 | "It looks like you do not have Spotify installed! If you do, then, uh, sorry. Go start it manually the old fashioned way.",
67 | });
68 | }
69 | }}
70 | >
71 |
72 |
73 | Open the Spotify app
74 |
75 |
76 | );
77 | }
78 |
--------------------------------------------------------------------------------
/app/screens/SignIn.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from "react";
2 | import { Platform } from "react-native";
3 | import { ScrollView } from "react-native-gesture-handler";
4 | import { Fontisto } from "@expo/vector-icons";
5 | import { useSafeArea } from "react-native-safe-area-context";
6 | import { currentUserState } from "../state";
7 | import { useRecoilState } from "recoil";
8 |
9 | import * as Text from "../components/Text";
10 | import * as Spacer from "../components/Spacer";
11 | import * as Button from "../components/Button";
12 | import useSpotifyAuth from "../hooks/useSpotifyAuth";
13 |
14 | export default function SignIn({ navigation }: any) {
15 | const { isAuthenticated, error, authenticateAsync } = useSpotifyAuth();
16 | const [currentUser, setCurrentUser] = useRecoilState(currentUserState);
17 | const insets = useSafeArea();
18 |
19 | // TODO: Sort this mess out
20 | useEffect(() => {
21 | if (currentUser.isAuthenticated && isAuthenticated) {
22 | navigation.replace("MyPlaylists");
23 | }
24 | }, [currentUser, isAuthenticated]);
25 |
26 | useEffect(() => {
27 | if (isAuthenticated) {
28 | setCurrentUser({ isAuthenticated: true });
29 | }
30 | }, [isAuthenticated]);
31 |
32 | useEffect(() => {
33 | if (error) {
34 | alert(error);
35 | }
36 | }, [error]);
37 |
38 | return (
39 |
54 | ⚡️🎶️️
55 | Hour Power!
56 |
64 | One song each minute for one hour. Songs all come from the best
65 | collection in the world — your own.
66 |
67 |
68 | authenticateAsync()}>
69 |
70 |
71 | Sign in with Spotify
72 |
73 |
74 |
75 |
76 |
77 | *requires a Spotify Premium account
78 |
79 |
80 |
81 | );
82 | }
83 |
--------------------------------------------------------------------------------
/app/hooks/useSpotifyAuth.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { Platform } from "react-native";
3 | import { useAuthRequest, makeRedirectUri } from "expo-auth-session";
4 | import * as WebBrowser from "expo-web-browser";
5 | import Constants from "expo-constants";
6 | import { fetchTokenAsync } from "../api";
7 | import * as LocalStorage from "../state/LocalStorage";
8 |
9 | const discovery = {
10 | authorizationEndpoint: "https://accounts.spotify.com/authorize",
11 | tokenEndpoint: "https://accounts.spotify.com/api/token",
12 | };
13 |
14 | const USE_PROXY = Platform.select({
15 | web: false,
16 | default: Constants.appOwnership === "standalone" ? false : true,
17 | });
18 | const REDIRECT_URI = makeRedirectUri({
19 | useProxy: USE_PROXY,
20 | native: "hourpower://redirect",
21 | });
22 | const CLIENT_ID = "26e6599e588547c4b4615b0723b0f15f";
23 |
24 | WebBrowser.maybeCompleteAuthSession();
25 |
26 | export default function useSpotifyAuth() {
27 | const [error, setError] = useState(null);
28 | const [isAuthenticated, setIsAuthenticated] = useState(false);
29 |
30 | const [authRequest, authResponse, promptAsync] = useAuthRequest(
31 | {
32 | clientId: CLIENT_ID,
33 | usePKCE: false,
34 | scopes: [
35 | "streaming",
36 | "user-read-email",
37 | "playlist-modify-public",
38 | "playlist-read-private",
39 | "user-read-playback-state",
40 | "app-remote-control",
41 | "user-read-playback-state",
42 | "user-modify-playback-state",
43 | "user-read-currently-playing",
44 | "user-library-read",
45 | ],
46 | redirectUri: REDIRECT_URI,
47 | extraParams: {
48 | // On Android it will just skip right past sign in otherwise
49 | show_dialog: "true",
50 | },
51 | },
52 | discovery
53 | );
54 |
55 | useEffect(() => {
56 | async function updateFromAuthResponseAsync() {
57 | if (authResponse === null) {
58 | return;
59 | } else if (authResponse.type === "error") {
60 | setError(authResponse.error);
61 | return;
62 | } else if (authResponse.type === "success") {
63 | const result = await fetchTokenAsync(
64 | authResponse.params.code,
65 | REDIRECT_URI
66 | );
67 | if (result.error || !result.token) {
68 | setError(result.error ?? "Unknown error");
69 | } else {
70 | await LocalStorage.setAuthCredentialsAsync({
71 | ...result,
72 | lastRefreshed: new Date(),
73 | });
74 | setIsAuthenticated(true);
75 | }
76 | }
77 | }
78 |
79 | if (!isAuthenticated) {
80 | updateFromAuthResponseAsync();
81 | }
82 | }, [authResponse]);
83 |
84 | return {
85 | error,
86 | isAuthenticated,
87 | authenticateAsync: () => promptAsync({ useProxy: USE_PROXY }),
88 | };
89 | }
90 |
--------------------------------------------------------------------------------
/app/navigation/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Platform } from "react-native";
3 | import { NavigationContainer } from "@react-navigation/native";
4 | import { createNativeStackNavigator } from "react-native-screens/native-stack";
5 | import { enableScreens } from "react-native-screens";
6 | import { useRecoilState } from "recoil";
7 |
8 | import SignIn from "../screens/SignIn";
9 | import MyPlaylists from "../screens/MyPlaylists";
10 | import DevicePicker from "../screens/DevicePicker";
11 | import PlayerController from "../screens/PlayerController";
12 | import { playerSelectionState, currentUserState } from "../state";
13 |
14 | enableScreens();
15 | const RootStack = createNativeStackNavigator();
16 | const PlayerStack = createNativeStackNavigator();
17 |
18 | export function Root() {
19 | const [currentUser] = useRecoilState(currentUserState);
20 |
21 | return (
22 |
32 |
37 |
42 |
47 |
48 | );
49 | }
50 |
51 | function Player() {
52 | const [playerSelection] = useRecoilState(playerSelectionState);
53 |
54 | return (
55 |
68 |
77 |
82 |
83 | );
84 | }
85 |
86 | export default function Navigation() {
87 | return (
88 |
89 |
90 |
91 | );
92 | }
93 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Hour Power
2 |
3 | Sign in with Spotify, pick a playlist, music will start on your selected device and the songs will change each minute! Neato.
4 |
5 | [Find it on the Apple App Store](https://apps.apple.com/us/app/hour-power/id1515672448?ls=1) and [the Google Play Store](https://play.google.com/store/apps/details?id=xyz.bront.hourpower)
6 |
7 | Built and shipped to stores within three days as part of an internal [@expo](https://github.com/expo) hackathon.
8 |
9 | ## Mockups
10 |
11 | 
12 |
13 | Built using [Excalidraw](https://excalidraw.com/)
14 |
15 | ## Screenshots
16 |
17 | 
18 | 
19 |
20 | ## Tools
21 |
22 | - [Expo managed workflow](https://docs.expo.io/introduction/managed-vs-bare/) - built apps for store [using Expo build service](https://docs.expo.io/distribution/building-standalone-apps/)
23 | - [expo-updates](https://docs.expo.io/versions/latest/sdk/updates/) for over-the-air updates.
24 | - [@expo/google-fonts](https://github.com/expo/google-fonts) for including Google Fonts.
25 | - [@expo/vector-icons](https://docs.expo.io/guides/icons/) and the related [icon directory](https://icons.expo.fyi/).
26 | - [expo-auth-session](https://docs.expo.io/versions/latest/sdk/auth-session/) to authenticate with Spotify.
27 | - Splash screens and icons were generated using [Icon Builder](https://buildicon.netlify.app/).
28 | - [react-navigation](https://reactnavigation.org) with [createNativeStackNavigator](https://github.com/software-mansion/react-native-screens/tree/master/native-stack)
29 | - [react-native-reanimated](https://github.com/software-mansion/react-native-reanimated) for smoothly animating device list appearance on iOS.
30 | - [recoil](https://recoiljs.org/) via the [Naturalclar/recoil](https://github.com/Naturalclar/recoil) fork for shared state, in order to try it out in React Native (upstream support coming soon)
31 | - [spotify-web-api-js](https://github.com/JMPerez/spotify-web-api-js) for an easy-to-use, TypeScript compatible wrapper for the Spotify API.
32 | - [react-query](https://github.com/tannerlinsley/react-query) to make firing the API calls from components a pleasant experience.
33 | - Minimal backend for getting access token is built using [Vercel's](https://vercel.com/) [now.sh](https://now.sh/) serverless functions.
34 | - A bunch of other libraries for the things you'd expect them to be for, to name a few: @react-native-community/async-storage, @react-native-community/hooks, react-native-htmlview, react-native-safe-area-context
35 |
36 | ## Develop it on your machine
37 |
38 | Ping me on Twitter [@notbrent](https://twitter.com/notbrent) if you actually want to do this and I will write instructions for you.
39 |
40 | ## License
41 |
42 | Do whatever you want with this, I do not care.
43 |
44 | ## Prior art
45 |
46 | I got the idea to build this from [a post on /r/reactnative](https://www.reddit.com/r/reactnative/comments/ggds8s/power_hour_playlist_my_first_react_native_app/) where someone showed off an app called Power Hour that does the same thing in different ways.
--------------------------------------------------------------------------------
/app/navigation/index.web.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { TouchableOpacity } from "react-native";
3 | import { NavigationContainer } from "@react-navigation/native";
4 | import { createStackNavigator } from "@react-navigation/stack";
5 | import { useRecoilState } from "recoil";
6 | import * as Linking from "expo-linking";
7 | import { Fontisto } from "@expo/vector-icons";
8 |
9 | import SignIn from "../screens/SignIn";
10 | import MyPlaylists from "../screens/MyPlaylists";
11 | import DevicePicker from "../screens/DevicePicker";
12 | import PlayerController from "../screens/PlayerController";
13 | import { playerSelectionState, currentUserState } from "../state";
14 |
15 | const RootStack = createStackNavigator();
16 | const PlayerStack = createStackNavigator();
17 |
18 | export function Root() {
19 | const [currentUser] = useRecoilState(currentUserState);
20 |
21 | const authenticatedRoutes = (
22 | <>
23 |
24 |
25 | >
26 | );
27 |
28 | return (
29 |
33 |
34 | {currentUser.isAuthenticated ? authenticatedRoutes : null}
35 |
36 | );
37 | }
38 |
39 | function Player() {
40 | const [playerSelection] = useRecoilState(playerSelectionState);
41 |
42 | return (
43 | {
48 | if (navigation.dangerouslyGetState().routes.length === 1) {
49 | return {
50 | headerLeft: () => ,
51 | };
52 | } else {
53 | return {};
54 | }
55 | }}
56 | >
57 |
64 |
69 |
70 | );
71 | }
72 |
73 | function ClosePlayerButton({ navigation }: any) {
74 | return (
75 | navigation.navigate("MyPlaylists")}
77 | hitSlop={{ top: 20, right: 20, left: 20, bottom: 20 }}
78 | style={{ width: 40, marginLeft: 20, marginTop: 2 }}
79 | >
80 |
81 |
82 | );
83 | }
84 |
85 | // TODO: if not authenticated, always go to sign in...
86 | const linking = {
87 | prefixes: [Linking.makeUrl()],
88 | config: {
89 | SignIn: "/sign-in",
90 | MyPlaylists: "/",
91 | Player: {
92 | screens: {
93 | DevicePicker: "/devices",
94 | PlayerController: "/playing",
95 | },
96 | },
97 | },
98 | };
99 |
100 | export default function Navigation() {
101 | return (
102 |
103 |
104 |
105 | );
106 | }
107 |
--------------------------------------------------------------------------------
/app/components/playlists/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Platform, Image, StyleSheet, View } from "react-native";
3 | import HTMLView from "react-native-htmlview";
4 | import { RectButton } from "react-native-gesture-handler";
5 | // @ts-ignore: oh my, no types from my very own library, que verguenza
6 | import FadeIn from "react-native-fade-in-image";
7 | import * as WebBrowser from "expo-web-browser";
8 |
9 | import { Playlist } from "../../api";
10 | import * as Text from "../Text";
11 | import * as Spacer from "../Spacer";
12 | import AnchorButton from "../AnchorButton";
13 |
14 | export function PlaylistCover({ images }: { images: string[] }) {
15 | return (
16 |
17 |
28 |
29 | );
30 | }
31 | export function PlaylistDescription({ text }: { text: string | null }) {
32 | if (!text) {
33 | return null;
34 | }
35 |
36 | return (
37 | ${text.trim()}`}
39 | textComponentProps={{ style: styles.description }}
40 | addLineBreaks={false}
41 | stylesheet={styles}
42 | onLinkPress={(url) => {
43 | WebBrowser.openBrowserAsync(url);
44 | }}
45 | />
46 | );
47 | }
48 |
49 | function PlatformRectButton({ onPress, children }: any) {
50 | if (Platform.OS === "web") {
51 | return {children};
52 | } else {
53 | return (
54 |
55 | {children}
56 |
57 | );
58 | }
59 | }
60 |
61 | export function PlaylistItem({
62 | data,
63 | style,
64 | onPress,
65 | }: {
66 | data: Playlist;
67 | style: any;
68 | onPress: any;
69 | }) {
70 | return (
71 |
81 |
82 |
90 |
91 |
92 |
93 | {data.name}
94 | by {data.author}
95 |
96 |
97 |
98 |
99 |
100 |
101 | );
102 | }
103 |
104 | const styles = StyleSheet.create({
105 | // TODO: add back a support by overriding the component is uses to render and
106 | // use borderlessbutton instead, so it works with rectbutton row wrapper
107 | a: {
108 | color: "#000",
109 | },
110 | description: {
111 | fontFamily: "SourceSansPro_400Regular",
112 | fontSize: 16,
113 | },
114 | title: {
115 | fontSize: 18,
116 | flexWrap: "wrap",
117 | },
118 | });
119 |
--------------------------------------------------------------------------------
/server/api/index.ts:
--------------------------------------------------------------------------------
1 | import fetch from "node-fetch";
2 | import fastify, { FastifyReply } from "fastify";
3 |
4 | const CLIENT_ID = process.env.SPOTIFY_CLIENT_ID;
5 | const CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET;
6 |
7 | if (!CLIENT_ID || !CLIENT_SECRET) {
8 | throw new Error(
9 | "SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET environment variables must be set"
10 | );
11 | }
12 |
13 | const server = fastify();
14 |
15 | function sendJson(res: FastifyReply, code: number, json: any) {
16 | res
17 | .status(code)
18 | .header("Content-Type", "application/json; charset=utf-8")
19 | .send(json);
20 | }
21 |
22 | server.get("/", async (_req, res) => {
23 | res.send("Hello 👋");
24 | });
25 |
26 | server.post<{
27 | Body: {
28 | code: string;
29 | redirectUri: string;
30 | refreshToken?: string;
31 | };
32 | }>("/token", async (req, res) => {
33 | res.headers({
34 | "Access-Control-Allow-Credentials": "true",
35 | "Access-Control-Allow-Origin": "*",
36 | "Access-Control-Allow-Methods": "GET,OPTIONS",
37 | "Access-Control-Allow-Headers":
38 | "X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version",
39 | });
40 |
41 | if (req.method === "OPTIONS") {
42 | res.status(200);
43 | res.send();
44 | return;
45 | }
46 |
47 | if (!(req.body.code && req.body.redirectUri) && !req.body.refreshToken) {
48 | sendJson(res, 500, {
49 | error: `Invalid request body, please include either: code and redirectUri or refreshToken`,
50 | });
51 |
52 | return;
53 | }
54 |
55 | try {
56 | if (req.body.refreshToken) {
57 | const { token, expiresIn } = await refreshTokenAsync(
58 | req.body.refreshToken
59 | );
60 | sendJson(res, 200, { token, expiresIn });
61 | } else {
62 | const { token, refreshToken, expiresIn } = await fetchTokenAsync({
63 | code: req.body.code,
64 | redirectUri: req.body.redirectUri,
65 | });
66 | sendJson(res, 200, { token, refreshToken, expiresIn });
67 | }
68 | } catch (e) {
69 | console.log("error!");
70 | sendJson(res, 500, { error: e.message });
71 | }
72 | });
73 |
74 | async function postAsync(params: any) {
75 | const response = await fetch("https://accounts.spotify.com/api/token", {
76 | method: "POST",
77 | headers: {
78 | Accept: "application/json",
79 | "Content-Type": "application/x-www-form-urlencoded",
80 | },
81 | body: new URLSearchParams(params).toString(),
82 | });
83 |
84 | return await response.json();
85 | }
86 |
87 | async function fetchTokenAsync({
88 | code,
89 | redirectUri,
90 | }: {
91 | code: string;
92 | redirectUri: string;
93 | }) {
94 | const params = {
95 | grant_type: "authorization_code",
96 | code,
97 | redirect_uri: redirectUri,
98 | client_id: CLIENT_ID,
99 | client_secret: CLIENT_SECRET,
100 | };
101 |
102 | const result = await postAsync(params);
103 |
104 | if (result && result.access_token) {
105 | return {
106 | token: result.access_token,
107 | refreshToken: result.refresh_token,
108 | expiresIn: result.expires_in,
109 | };
110 | } else {
111 | throw new Error(JSON.stringify(result));
112 | }
113 | }
114 |
115 | async function refreshTokenAsync(refreshToken: string) {
116 | const params = {
117 | grant_type: "refresh_token",
118 | refresh_token: refreshToken,
119 | client_id: CLIENT_ID,
120 | client_secret: CLIENT_SECRET,
121 | };
122 |
123 | const result = await postAsync(params);
124 |
125 | if (result && result.access_token) {
126 | return { token: result.access_token, expiresIn: result.expires_in };
127 | } else {
128 | throw new Error(JSON.stringify(result));
129 | }
130 | }
131 |
132 | server.listen(process.env.PORT ?? 3000, '0.0.0.0', (err, address) => {
133 | if (err) {
134 | console.log(err.message);
135 | process.exit(1);
136 | }
137 | console.log(`server listening on ${address}`);
138 | });
139 |
--------------------------------------------------------------------------------
/app/components/PlayerStatusBottomControl.tsx:
--------------------------------------------------------------------------------
1 | import React, { useMemo } from "react";
2 | import {
3 | Platform,
4 | TouchableWithoutFeedback,
5 | StyleSheet,
6 | View,
7 | } from "react-native";
8 | import { useSafeArea } from "react-native-safe-area-context";
9 | import {
10 | State as GestureState,
11 | PanGestureHandler,
12 | TapGestureHandler,
13 | TapGestureHandlerStateChangeEvent,
14 | PanGestureHandlerStateChangeEvent,
15 | } from "react-native-gesture-handler";
16 | import { useNavigation } from "@react-navigation/native";
17 | import { useRecoilState } from "recoil";
18 |
19 | import * as Text from "../components/Text";
20 | import * as Spacer from "../components/Spacer";
21 | import TrackImage from "./TrackImage";
22 | import { playbackStatusState, playerSelectionState } from "../state";
23 |
24 | export const PLAYER_STATUS_BOTTOM_CONTROL_HEIGHT = 65;
25 |
26 | export default function PlayerStatusBottomControl() {
27 | const insets = useSafeArea();
28 | const navigation = useNavigation();
29 |
30 | const [playbackStatus] = useRecoilState(playbackStatusState);
31 | const [playerSelection] = useRecoilState(playerSelectionState);
32 |
33 | const handleHandlerStateChange = (
34 | e: PanGestureHandlerStateChangeEvent | TapGestureHandlerStateChangeEvent
35 | ) => {
36 | if (e.nativeEvent.state === GestureState.ACTIVE) {
37 | navigation.navigate("Player");
38 | }
39 | };
40 |
41 | const memoizedControl = useMemo(() => {
42 | if (!playbackStatus.currentTrack || !playerSelection.playlist) {
43 | return null;
44 | }
45 |
46 | return (
47 |
52 |
53 |
65 |
69 |
70 |
78 |
82 | {playerSelection.playlist.name}
83 |
84 |
88 | {playbackStatus.currentTrack.name}{" "}
89 | — {playbackStatus.currentTrack.artists.join(",")}
90 |
91 |
92 |
93 |
94 |
95 | );
96 | }, [playbackStatus.currentTrack, playerSelection.playlist, navigation]);
97 |
98 | return memoizedControl;
99 | }
100 |
101 | const styles = StyleSheet.create({
102 | // @ts-ignore: position fixed is fine
103 | container: {
104 | backgroundColor: "#000",
105 | shadowOffset: { width: 0, height: 2 },
106 | shadowRadius: 10,
107 | shadowColor: "black",
108 | shadowOpacity: 0.4,
109 | ...Platform.select({
110 | web: {
111 | position: "fixed",
112 | bottom: 0,
113 | left: 0,
114 | right: 0,
115 | },
116 | default: {
117 | elevation: 3,
118 | position: "absolute",
119 | bottom: 0,
120 | left: 0,
121 | right: 0,
122 | },
123 | }),
124 | },
125 | });
126 |
--------------------------------------------------------------------------------
/app/api/index.ts:
--------------------------------------------------------------------------------
1 | import SpotifyWebApi from "spotify-web-api-js";
2 | import * as LocalStorage from "../state/LocalStorage";
3 |
4 | export type Playlist = {
5 | id: string;
6 | name: string;
7 | href: string;
8 | author: string;
9 | description: string | null;
10 | trackCount: any;
11 | images: string[];
12 | uri: string;
13 | };
14 |
15 | export type Device = {
16 | id: string | null;
17 | name: string;
18 | type:
19 | | "Computer"
20 | | "Tablet"
21 | | "Smartphone"
22 | | "Speaker"
23 | | "TV"
24 | | "AVR"
25 | | "STB"
26 | | "AudioDongle"
27 | | "GameConsole"
28 | | "CastVideo"
29 | | "CastAudio"
30 | | "Automobile"
31 | | "Unknown";
32 | isActive: boolean;
33 | isRestricted: boolean;
34 | };
35 |
36 | /** Our server */
37 |
38 | // Change these to whatever makes sense for your app!
39 |
40 | const TOKEN_ENDPOINT = "https://hour-power.herokuapp.com/token";
41 | // const TOKEN_ENDPOINT = __DEV__
42 | // ? "http://localhost:3000/token"
43 | // : "https://hourpower-server.now.sh/api/token";
44 |
45 | export async function refreshTokenAsync(refreshToken: string) {
46 | const response = await fetch(TOKEN_ENDPOINT, {
47 | method: "POST",
48 | redirect: "follow",
49 | headers: {
50 | Accept: "application/json",
51 | "Content-Type": "application/json",
52 | },
53 | body: JSON.stringify({
54 | refreshToken,
55 | }),
56 | });
57 |
58 | return await response.json();
59 | }
60 |
61 | export async function fetchTokenAsync(code: string, redirectUri: string) {
62 | const response = await fetch(TOKEN_ENDPOINT, {
63 | method: "POST",
64 | headers: {
65 | Accept: "application/json",
66 | "Content-Type": "application/json",
67 | },
68 | body: JSON.stringify({
69 | code,
70 | redirectUri,
71 | }),
72 | });
73 |
74 | return await response.json();
75 | }
76 |
77 | /** Spotify */
78 |
79 | export type Track = {
80 | id: string;
81 | name: string;
82 | uri: string;
83 | images: string[];
84 | artists: string[];
85 | isPlayable: boolean;
86 | durationMs: number;
87 | };
88 |
89 | export async function fetchTracksAsync(playlistId: string) {
90 | const client = await _getClientAsync();
91 | const result = await client.getPlaylistTracks(playlistId, {
92 | market: "from_token",
93 | fields:
94 | "items(track(id,name,uri,is_playable,duration_ms,album(images),artists(name)))",
95 | });
96 |
97 | return result.items.map((item: typeof result.items[0]) => {
98 | const { track } = item;
99 | // Skip podcast episodes I guess?
100 | if (!track || !track.hasOwnProperty("artists")) {
101 | return;
102 | }
103 |
104 | return {
105 | id: track.id,
106 | name: track.name,
107 | uri: track.uri,
108 | isPlayable: track.is_playable,
109 | durationMs: track.duration_ms,
110 | // @ts-ignore
111 | artists: track.artists?.map((artist) => artist.name) ?? [],
112 | // @ts-ignore
113 | images: track.album?.images.map((image) => image.url) ?? [],
114 | } as Track;
115 | });
116 | }
117 |
118 | export async function playTrackAsync({
119 | uri,
120 | deviceId,
121 | time,
122 | }: {
123 | uri: string;
124 | deviceId: string;
125 | time?: number;
126 | }) {
127 | const client = await _getClientAsync();
128 | return await client.play({
129 | uris: [uri],
130 | device_id: deviceId,
131 | position_ms: time ?? 0,
132 | });
133 | }
134 |
135 | export async function fetchPlaylistsAsync(): Promise {
136 | const client = await _getClientAsync();
137 | // @ts-ignore: the type for this is wrong, the first param should be undefined | Object
138 | const result = await client.getUserPlaylists({ limit: 50 });
139 |
140 | return result.items.map(
141 | (p: typeof result.items[0]) =>
142 | ({
143 | id: p.id,
144 | name: p.name,
145 | author: p.owner.display_name,
146 | description: p.description!,
147 | trackCount: p.tracks.total,
148 | href: p.href,
149 | uri: p.uri,
150 | images: p.images.map((image: typeof p.images[0]) => image.url),
151 | } as Playlist)
152 | );
153 | }
154 |
155 | export async function pauseAsync(): Promise {
156 | const client = await _getClientAsync();
157 | return await client.pause();
158 | }
159 |
160 | export async function fetchDevicesAsync(): Promise {
161 | const client = await _getClientAsync();
162 | const result = await client.getMyDevices();
163 | return result.devices
164 | .map(
165 | (d: typeof result.devices[0]) =>
166 | ({
167 | id: d.id,
168 | name: d.name,
169 | isActive: d.is_active,
170 | isRestricted: d.is_restricted,
171 | type: d.type,
172 | } as Device)
173 | )
174 | .sort((a: Device, b: Device) => (a.isActive && b.isActive ? 0 : -1));
175 | }
176 |
177 | // Handle refreshing token whenever it's coming due
178 | async function _getValidTokenAsync() {
179 | const credentials = await LocalStorage.getAuthCredentialsAsync();
180 | let d = new Date(credentials.lastRefreshed);
181 | try {
182 | let lastRefreshedDate = new Date(credentials.lastRefreshed);
183 | // If there's only 600 seconds left to use token, go ahead and refresh it
184 | if (
185 | new Date().getTime() - lastRefreshedDate.getTime() >
186 | credentials.expiresIn - 600
187 | ) {
188 | const result = await refreshTokenAsync(credentials.refreshToken);
189 | const newAuthCredentials = {
190 | ...credentials,
191 | ...result,
192 | };
193 | await LocalStorage.setAuthCredentialsAsync(newAuthCredentials);
194 | return newAuthCredentials.token;
195 | }
196 | } catch (e) {
197 | console.log(e);
198 | }
199 |
200 | return credentials.token;
201 | }
202 |
203 | async function _getClientAsync() {
204 | const token = await _getValidTokenAsync();
205 | const client = new SpotifyWebApi();
206 | client.setAccessToken(token);
207 | return client;
208 | }
209 |
--------------------------------------------------------------------------------
/server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */
4 |
5 | /* Basic Options */
6 | // "incremental": true, /* Enable incremental compilation */
7 | "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */
8 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
9 | // "lib": [], /* Specify library files to be included in the compilation. */
10 | // "allowJs": true, /* Allow javascript files to be compiled. */
11 | // "checkJs": true, /* Report errors in .js files. */
12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
13 | // "declaration": true, /* Generates corresponding '.d.ts' file. */
14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
15 | // "sourceMap": true, /* Generates corresponding '.map' file. */
16 | // "outFile": "./", /* Concatenate and emit output to single file. */
17 | "outDir": "./build", /* Redirect output structure to the directory. */
18 | "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
19 | // "composite": true, /* Enable project compilation */
20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
21 | // "removeComments": true, /* Do not emit comments to output. */
22 | // "noEmit": true, /* Do not emit outputs. */
23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */
24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
26 |
27 | /* Strict Type-Checking Options */
28 | "strict": true, /* Enable all strict type-checking options. */
29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
30 | // "strictNullChecks": true, /* Enable strict null checks. */
31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */
32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
36 |
37 | /* Additional Checks */
38 | "noUnusedLocals": true, /* Report errors on unused locals. */
39 | "noUnusedParameters": true, /* Report errors on unused parameters. */
40 | "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
41 | "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
42 | "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
43 |
44 | /* Module Resolution Options */
45 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
46 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
47 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
48 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
49 | // "typeRoots": [], /* List of folders to include type definitions from. */
50 | // "types": [], /* Type declaration files to be included in compilation. */
51 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
52 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
53 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
54 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
55 |
56 | /* Source Map Options */
57 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
58 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
59 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
60 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
61 |
62 | /* Experimental Options */
63 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
64 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
65 |
66 | /* Advanced Options */
67 | "skipLibCheck": true, /* Skip type checking of declaration files. */
68 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/app/types/recoil.d.ts:
--------------------------------------------------------------------------------
1 | declare module "recoil" {
2 | // @include state.d.ts
3 | type NodeKey = string;
4 | type AtomValues = Map>;
5 | type ComponentCallback = (state: TreeState) => void;
6 | type TreeState = Readonly<{
7 | isSnapshot: boolean;
8 | transactionMetadata: object;
9 | dirtyAtoms: Set;
10 | atomValues: AtomValues;
11 | nonvalidatedAtoms: Map;
12 | nodeDeps: Map>;
13 | nodeToNodeSubscriptions: Map>;
14 | nodeToComponentSubscriptions: Map<
15 | NodeKey,
16 | Map
17 | >;
18 | }>;
19 |
20 | // @include node.d.ts
21 | class DefaultValue {}
22 |
23 | // @include recoilRoot.d.ts
24 | import * as React from "react";
25 | interface RecoilRootProps {
26 | initializeState?: (options: {
27 | set: (recoilVal: RecoilState, newVal: T) => void;
28 | setUnvalidatedAtomValues: (atomMap: Map) => void;
29 | }) => void;
30 | }
31 | const RecoilRoot: React.FC;
32 |
33 | // @include atom.d.ts
34 | interface AtomOptions {
35 | key: NodeKey;
36 | default: RecoilValue | Promise | T;
37 | dangerouslyAllowMutability?: boolean;
38 | }
39 | /**
40 | * Creates an atom, which represents a piece of writeable state
41 | */
42 | function atom(options: AtomOptions): RecoilState;
43 |
44 | // @include selector.d.ts
45 | type GetRecoilValue = (recoilVal: RecoilValue) => T;
46 | type SetRecoilState = (
47 | recoilVal: RecoilState,
48 | newVal: T | DefaultValue | ((prevValue: T) => T | DefaultValue)
49 | ) => void;
50 | type ResetRecoilState = (recoilVal: RecoilState) => void;
51 | interface ReadOnlySelectorOptions {
52 | key: string;
53 | get: (opts: { get: GetRecoilValue }) => Promise | RecoilValue | T;
54 | dangerouslyAllowMutability?: boolean;
55 | }
56 | interface ReadWriteSelectorOptions extends ReadOnlySelectorOptions {
57 | set: (
58 | opts: {
59 | set: SetRecoilState;
60 | get: GetRecoilValue;
61 | reset: ResetRecoilState;
62 | },
63 | newValue: T | DefaultValue
64 | ) => void;
65 | }
66 | function selector(options: ReadWriteSelectorOptions): RecoilState;
67 | function selector(
68 | options: ReadOnlySelectorOptions
69 | ): RecoilValueReadOnly;
70 |
71 | // @include hooks.d.ts
72 | type SetterOrUpdater = (valOrUpdater: ((currVal: T) => T) | T) => void;
73 | type Resetter = () => void;
74 | type CallbackInterface = Readonly<{
75 | getPromise: (recoilVal: RecoilValue) => Promise;
76 | getLoadable: (recoilVal: RecoilValue) => Loadable;
77 | set: (
78 | recoilVal: RecoilState,
79 | valOrUpdater: ((currVal: T) => T) | T
80 | ) => void;
81 | reset: (recoilVal: RecoilState) => void;
82 | }>;
83 | /**
84 | * Returns the value of an atom or selector (readonly or writeable) and
85 | * subscribes the components to future updates of that state.
86 | */
87 | function useRecoilValue(recoilValue: RecoilValue): T;
88 | /**
89 | * Returns a Loadable representing the status of the given Recoil state
90 | * and subscribes the component to future updates of that state. Useful
91 | * for working with async selectors.
92 | */
93 | function useRecoilValueLoadable(recoilValue: RecoilValue): Loadable;
94 | /**
95 | * Returns a tuple where the first element is the value of the recoil state
96 | * and the second is a setter to update that state. Subscribes component
97 | * to updates of the given state.
98 | */
99 | function useRecoilState(
100 | recoilState: RecoilState
101 | ): [T, SetterOrUpdater];
102 | /**
103 | * Returns a tuple where the first element is a Loadable and the second
104 | * element is a setter function to update the given state. Subscribes
105 | * component to updates of the given state.
106 | */
107 | function useRecoilStateLoadable(
108 | recoilState: RecoilState
109 | ): [Loadable, SetterOrUpdater];
110 | /**
111 | * Returns a setter function for updating Recoil state. Does not subscribe
112 | * the component to the given state.
113 | */
114 | function useSetRecoilState(
115 | recoilState: RecoilState
116 | ): SetterOrUpdater;
117 | /**
118 | * Returns a function that will reset the given state to its default value.
119 | */
120 | function useResetRecoilState(recoilState: RecoilState): Resetter;
121 | /**
122 | * Returns a function that will run the callback that was passed when
123 | * calling this hook. Useful for accessing Recoil state in response to
124 | * events.
125 | */
126 | function useRecoilCallback, Return>(
127 | fn: (interface: CallbackInterface, ...args: Args) => Return,
128 | deps?: ReadonlyArray
129 | ): (...args: Args) => Return;
130 |
131 | // @include loadable.d.ts
132 | type ResolvedLoadablePromiseInfo = Readonly<{
133 | value: T;
134 | upstreamState__INTERNAL_DO_NOT_USE?: TreeState;
135 | }>;
136 | type LoadablePromise = Promise>;
137 | type Loadable =
138 | | Readonly<{
139 | state: "hasValue";
140 | contents: T;
141 | }>
142 | | Readonly<{
143 | state: "hasError";
144 | contents: Error;
145 | }>
146 | | Readonly<{
147 | state: "loading";
148 | contents: LoadablePromise;
149 | }>;
150 |
151 | // @include recoilValue.d.ts
152 | class AbstractRecoilValue {
153 | tag: "Writeable";
154 | valTag: T;
155 | key: NodeKey;
156 | constructor(newKey: NodeKey);
157 | }
158 | class AbstractRecoilValueReadonly {
159 | tag: "Readonly";
160 | valTag: T;
161 | key: NodeKey;
162 | constructor(newKey: NodeKey);
163 | }
164 | class RecoilState extends AbstractRecoilValue {}
165 | class RecoilValueReadOnly extends AbstractRecoilValueReadonly {}
166 | type RecoilValue = RecoilValueReadOnly | RecoilState;
167 | function isRecoilValue(val: unknown): val is RecoilValue;
168 | }
169 |
--------------------------------------------------------------------------------
/app/screens/DevicePicker.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from "react";
2 | import { ActivityIndicator, Platform, View } from "react-native";
3 | import { useQuery } from "react-query";
4 | import {
5 | BorderlessButton,
6 | ScrollView,
7 | RectButton,
8 | } from "react-native-gesture-handler";
9 | import { Fontisto } from "@expo/vector-icons";
10 | import { useRecoilState } from "recoil";
11 | import { useNavigation } from "@react-navigation/native";
12 | import { Transitioning, Transition } from "react-native-reanimated";
13 | import { useAppState } from "@react-native-community/hooks";
14 |
15 | import { Device, fetchDevicesAsync } from "../api";
16 | import { colors } from "../styleguide";
17 | import DeviceIcon from "../components/DeviceIcon";
18 | import StatusBar from "../components/StatusBar";
19 | import * as Text from "../components/Text";
20 | import * as Spacer from "../components/Spacer";
21 | import * as Button from "../components/Button";
22 | import useInterval from "../hooks/useInterval";
23 | import { playerSelectionState } from "../state";
24 |
25 | const transition =
26 | Platform.OS === "ios" ? (
27 |
28 |
29 |
30 |
31 |
32 | ) : null;
33 |
34 | export default function DevicePicker({ navigation, route }: any) {
35 | const { data, isFetching, refetch } = useQuery("devices", fetchDevicesAsync, {
36 | manual: true,
37 | });
38 | const items = (data ?? []).map((item: Device) => (
39 |
40 | ));
41 |
42 | // Refetch when the app foregrounds
43 | const currentAppState = useAppState();
44 | useEffect(() => {
45 | if (currentAppState === "active") {
46 | refetch();
47 | }
48 | }, [currentAppState]);
49 |
50 | // Refetch every 10 seconds when app is active
51 | useInterval(
52 | () => {
53 | refetch();
54 | },
55 | currentAppState === "active" ? 10000 : null
56 | );
57 |
58 | const transtioningRef = React.useRef();
59 |
60 | if (Platform.OS === "ios") {
61 | transtioningRef.current?.animateNextTransition();
62 | }
63 |
64 | return (
65 |
66 | {Platform.OS === "ios" ? (
67 |
68 | ) : (
69 |
70 | )}
71 |
72 |
73 | {isFetching && !data ? : items}
74 |
75 |
76 |
77 |
78 |
85 |
86 | Don't see your device listed here?
87 |
88 |
89 | Open the Spotify app and then come back here!
90 |
91 |
92 |
93 |
94 |
95 |
96 | );
97 | }
98 |
99 | function LoadingPlaceholder() {
100 | return (
101 |
109 |
110 |
111 | );
112 | }
113 |
114 | function DevicePickerHeaderIOS({ navigation }: any) {
115 | return (
116 | <>
117 |
126 | navigation.goBack()}
128 | hitSlop={{ top: 20, right: 20, left: 20, bottom: 20 }}
129 | style={{ width: 40 }}
130 | >
131 |
132 |
133 |
141 | Select a device
142 |
143 |
144 | >
145 | );
146 | }
147 |
148 | function DeviceItem({ data }: { data: Device }) {
149 | const [playerSelection, setPlayerSelection] = useRecoilState(
150 | playerSelectionState
151 | );
152 | const navigation = useNavigation();
153 | const isSelected = playerSelection.device?.id === data?.id;
154 | const NameTextComponent = isSelected ? Text.Bold : Text.Regular;
155 |
156 | return (
157 | {
159 | setPlayerSelection((oldPlayerSelection) => ({
160 | ...oldPlayerSelection,
161 | device: data,
162 | }));
163 |
164 | // When we show the DevicePicker prior to the player controller, we want to replace
165 | // it with PlayerController when we move away from it. But when the PlayerController
166 | // presents the DevicePicker (by selecting it from the PlayerController screen) then
167 | // we want to navigate to it (so we jump back to it rather than add a second entry).
168 | if (navigation.dangerouslyGetState().routes.length > 1) {
169 | navigation.navigate("PlayerController");
170 | } else {
171 | // @ts-ignore
172 | navigation.replace("PlayerController");
173 | }
174 | }}
175 | style={{
176 | opacity: data.isRestricted ? 0.5 : 1,
177 | flex: 1,
178 | flexDirection: "row",
179 | paddingHorizontal: 15,
180 | paddingVertical: 15,
181 | }}
182 | >
183 |
190 |
191 |
192 |
193 | {data.name}
194 |
195 |
196 | );
197 | }
198 |
--------------------------------------------------------------------------------
/app/components/PlaybackDaemon.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useCallback } from "react";
2 | import { Platform } from "react-native";
3 | import { useRecoilState } from "recoil";
4 | import { activateKeepAwake, deactivateKeepAwake } from "expo-keep-awake";
5 | import { useAppState } from "@react-native-community/hooks";
6 |
7 | import useInterval from "../hooks/useInterval";
8 | import { playerSelectionState, playbackStatusState } from "../state";
9 | import { Playlist, playTrackAsync, pauseAsync } from "../api";
10 | import alertAsync from "../util/alertAsync";
11 |
12 | // A power hour in ms
13 | const ONE_HOUR = 60000 * 60;
14 |
15 | export default function PlaybackDaemon() {
16 | const [playbackStatus, setPlaybackStatus] = useRecoilState(
17 | playbackStatusState
18 | );
19 | const [playerSelection, setPlayerSelection] = useRecoilState(
20 | playerSelectionState
21 | );
22 |
23 | const selectedPlaylistRef = useRef(playerSelection.playlist);
24 | const isPlayingRef = useRef(playbackStatus.isPlaying);
25 |
26 | const handleAppBackgrounded = useCallback(() => {
27 | if (playbackStatus.isPlaying && Platform.OS !== "web") {
28 | alertAsync({
29 | title: "Power hour paused",
30 | message:
31 | "The Hour Power app needs to be open and in the foreground while you're doing the power hour! You can resume now that you're back. Just, uh, don't go away again.",
32 | });
33 |
34 | setPlaybackStatus((oldPlaybackStatus) => ({
35 | ...oldPlaybackStatus,
36 | isPlaying: false,
37 | }));
38 | }
39 | }, [playbackStatus.isPlaying, setPlaybackStatus]);
40 |
41 | const appState = useAppState();
42 | useEffect(() => {
43 | // TODO(web): remove this requirement by playing sound on repeat in background
44 | if (appState === "background") {
45 | handleAppBackgrounded();
46 | }
47 | }, [appState]);
48 |
49 | // Keep the screen active while playing
50 | useEffect(() => {
51 | if (playbackStatus.isPlaying) {
52 | activateKeepAwake();
53 | } else {
54 | deactivateKeepAwake();
55 | }
56 | }, [playbackStatus.isPlaying]);
57 |
58 | // Update the elapsed time while playing
59 | useInterval(
60 | () => {
61 | setPlaybackStatus((oldPlaybackStatus) => ({
62 | ...oldPlaybackStatus,
63 | elapsedTime: oldPlaybackStatus.elapsedTime + 1000,
64 | }));
65 | },
66 | playbackStatus.isPlaying ? 1000 : null
67 | );
68 |
69 | const playTrackForTimeAsync = useCallback(
70 | async (elapsedTime, selection) => {
71 | // If we end up calling play with no device or no playlist, then just bail out
72 | if (!selection.device || !selection.playlist) {
73 | return;
74 | }
75 |
76 | const realTrackNumber = Math.floor(elapsedTime / selection.trackDuration);
77 | const trackIndex = realTrackNumber % selection.tracks.length;
78 | const track = selection.tracks[trackIndex];
79 | const trackTime = elapsedTime - realTrackNumber * selection.trackDuration;
80 |
81 | const nextTrackIndex = (trackIndex + 1) % selection.tracks.length;
82 | const nextTrack = selection.tracks[nextTrackIndex];
83 |
84 | const previousTrackIndex = (trackIndex - 1) % selection.tracks.length;
85 | const previousTrack =
86 | elapsedTime < selection.trackDuration
87 | ? null
88 | : selection.tracks[previousTrackIndex];
89 |
90 | if (!track || !nextTrack) {
91 | console.error("No track or next track..");
92 | return;
93 | }
94 |
95 | try {
96 | await playTrackAsync({
97 | uri: track.uri,
98 | deviceId: selection.device?.id!,
99 | time: trackTime,
100 | });
101 | } catch (e) {
102 | alertAsync({
103 | title: "Uh oh!",
104 | message: `Something went wrong when playing the track, maybe double check that the device you want to use is still available.`,
105 | });
106 |
107 | // Something went wrong so we need to pause, can't keep playing
108 | setPlaybackStatus((oldPlaybackStatus) => ({
109 | ...oldPlaybackStatus,
110 | isPlaying: false,
111 | }));
112 | return;
113 | }
114 |
115 | setPlaybackStatus((oldPlaybackStatus) => ({
116 | ...oldPlaybackStatus,
117 | previousTrack,
118 | currentTrack: track,
119 | nextTrack,
120 | }));
121 | },
122 | [setPlaybackStatus]
123 | );
124 |
125 | // Handle any changes in isPlaying or playlist or device
126 | useEffect(() => {
127 | // Reset elapsed time if playlist changes
128 | const newPlaylistId = playerSelection.playlist?.id;
129 | const oldPlaylistId = selectedPlaylistRef.current?.id;
130 | if (newPlaylistId && newPlaylistId !== oldPlaylistId) {
131 | setPlaybackStatus((oldPlaybackStatus) => ({
132 | ...oldPlaybackStatus,
133 | elapsedTime: 0,
134 | previousTrack: null,
135 | currentTrack: null,
136 | nextTrack: null,
137 | }));
138 | } else if (!newPlaylistId && oldPlaylistId) {
139 | // good bye, playlist!?? do nothing here for now I guess?
140 | }
141 |
142 | // Play or pause
143 | const { playlistId, isPlaying } = playbackStatus;
144 | const { playlist, device } = playerSelection;
145 | const wasPlaying = isPlayingRef.current;
146 | if (
147 | (!wasPlaying && isPlaying) ||
148 | (playlistId !== playlist?.id && isPlaying && playlist && device)
149 | ) {
150 | playTrackForTimeAsync(playbackStatus.elapsedTime, playerSelection);
151 | } else if (!isPlaying && wasPlaying) {
152 | try {
153 | pauseAsync();
154 | } catch (e) {
155 | // TODO: check if error....
156 | }
157 | }
158 |
159 | selectedPlaylistRef.current = playerSelection.playlist;
160 | isPlayingRef.current = playbackStatus.isPlaying;
161 | }, [
162 | playbackStatus.isPlaying,
163 | playerSelection.playlist,
164 | playerSelection.device,
165 | ]);
166 |
167 | // Switch tracks every time we hit a certain duration
168 | useEffect(() => {
169 | if (playbackStatus.elapsedTime > ONE_HOUR) {
170 | alertAsync({
171 | title: "Power hour completed!",
172 | message:
173 | "Wow, how anti-climactic. Maybe in a future update this will be more interesting.",
174 | });
175 |
176 | setPlaybackStatus((oldPlaybackStatus) => ({
177 | ...oldPlaybackStatus,
178 | isPlaying: false,
179 | // add a completed flag? or derive it? I dunno whatever
180 | }));
181 | return;
182 | }
183 |
184 | if (
185 | playbackStatus.isPlaying &&
186 | playbackStatus.elapsedTime % playerSelection.trackDuration === 0
187 | ) {
188 | playTrackForTimeAsync(playbackStatus.elapsedTime, playerSelection);
189 | }
190 | }, [playbackStatus.elapsedTime, playerSelection]);
191 |
192 | return null;
193 | }
194 |
--------------------------------------------------------------------------------
/app/screens/PlayerController.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useCallback, useMemo } from "react";
2 | import { Platform, View } from "react-native";
3 | import { useResetRecoilState, useRecoilState } from "recoil";
4 | import { BorderlessButton } from "react-native-gesture-handler";
5 | import StatusBar from "../components/StatusBar";
6 | import { Fontisto } from "@expo/vector-icons";
7 |
8 | import { playerSelectionState, playbackStatusState } from "../state";
9 | import * as Spacer from "../components/Spacer";
10 | import * as Text from "../components/Text";
11 | import PlayerControllerHeader from "./PlayerController/PlayerControllerHeader";
12 | import PlayerControllerTracks from "./PlayerController/PlayerControllerTracks";
13 | import PlayerControllerDeviceButton from "./PlayerController/PlayerControllerDeviceButton";
14 |
15 | // A power hour in ms
16 | const ONE_HOUR = 60000 * 60;
17 |
18 | export default function PlayerController({ navigation }: any) {
19 | const resetPlayerSelection = useResetRecoilState(playerSelectionState);
20 | const resetPlaybackStatus = useResetRecoilState(playbackStatusState);
21 | const [playerSelection] = useRecoilState(playerSelectionState);
22 | const [playbackStatus, setPlaybackStatus] = useRecoilState(
23 | playbackStatusState
24 | );
25 |
26 | if (!playerSelection.playlist) {
27 | navigation.navigate("MyPlaylists");
28 | } else if (!playerSelection.device) {
29 | navigation.navigate("DevicePicker");
30 | }
31 |
32 | useEffect(() => {
33 | navigation.setOptions({
34 | title: playerSelection.playlist?.name,
35 | });
36 | }, [playerSelection.playlist?.name]);
37 |
38 | useEffect(() => {
39 | if (!playbackStatus.isPlaying && playbackStatus.elapsedTime === 0) {
40 | handlePlay();
41 | }
42 | }, []);
43 |
44 | const handlePause = useCallback(() => {
45 | setPlaybackStatus((oldPlaybackStatus) => ({
46 | ...oldPlaybackStatus,
47 | isPlaying: false,
48 | }));
49 | }, [setPlaybackStatus]);
50 |
51 | const handlePlay = useCallback(() => {
52 | setPlaybackStatus((oldPlaybackStatus) => ({
53 | ...oldPlaybackStatus,
54 | isPlaying: true,
55 | }));
56 | }, [setPlaybackStatus]);
57 |
58 | const handleStop = useCallback(() => {
59 | navigation.navigate("MyPlaylists");
60 | resetPlayerSelection();
61 | resetPlaybackStatus();
62 | }, [resetPlaybackStatus, resetPlayerSelection]);
63 |
64 | const memoizedHeader = useMemo(
65 | () =>
66 | Platform.OS === "ios" ? (
67 |
71 | ) : null,
72 | [Platform.OS, navigation, playerSelection.playlist?.name]
73 | );
74 |
75 | const memoizedTracksDisplay = useMemo(
76 | () => (
77 |
82 | ),
83 | [
84 | playbackStatus.previousTrack,
85 | playbackStatus.currentTrack,
86 | playbackStatus.nextTrack,
87 | ]
88 | );
89 |
90 | const memoizedDeviceControl = useMemo(
91 | () => (
92 |
96 | ),
97 | [playerSelection.device, navigation]
98 | );
99 |
100 | const memoizedPlaybackControls = useMemo(
101 | () => (
102 |
103 |
104 |
105 | {playbackStatus.isPlaying ? (
106 |
107 | ) : (
108 |
109 | )}
110 |
111 | ),
112 | [handleStop, handlePause, handlePlay, playbackStatus.isPlaying]
113 | );
114 |
115 | const currentTrackNumber = useMemo(() => {
116 | return Math.floor(
117 | playbackStatus.elapsedTime / playerSelection.trackDuration
118 | );
119 | }, [playbackStatus.elapsedTime, playerSelection.trackDuration]);
120 |
121 | const totalTracks = useMemo(() => {
122 | return Math.ceil(ONE_HOUR / playerSelection.trackDuration);
123 | }, [playerSelection.trackDuration]);
124 |
125 | const memoizedTrackCount = useMemo(
126 | () => (
127 |
128 |
129 | Track {currentTrackNumber + 1} of {totalTracks}
130 |
131 |
132 | ),
133 | [currentTrackNumber, totalTracks]
134 | );
135 |
136 | return (
137 |
138 | {memoizedHeader}
139 | {memoizedTracksDisplay}
140 |
141 | {memoizedTrackCount}
142 |
146 | {memoizedPlaybackControls}
147 |
148 |
149 | {memoizedDeviceControl}
150 |
151 |
152 |
153 | );
154 | }
155 |
156 | type ProgressBarProps = {
157 | elapsedTime: number;
158 | trackDuration: number;
159 | };
160 |
161 | function ProgressBar(props: ProgressBarProps) {
162 | const progressTrack =
163 | ((props.elapsedTime % props.trackDuration) / props.trackDuration) * 100;
164 |
165 | return (
166 |
176 |
183 |
184 | );
185 | }
186 |
187 | type ButtonProps = {
188 | onPress: () => void;
189 | };
190 |
191 | function StopButton({ onPress }: ButtonProps) {
192 | return (
193 |
197 |
198 |
199 | );
200 | }
201 |
202 | function PauseButton({ onPress }: ButtonProps) {
203 | return (
204 |
208 |
209 |
210 | );
211 | }
212 |
213 | function PlayButton({ onPress }: ButtonProps) {
214 | return (
215 |
219 |
220 |
221 | );
222 | }
223 |
--------------------------------------------------------------------------------
/app/screens/MyPlaylists.tsx:
--------------------------------------------------------------------------------
1 | import React, {
2 | useCallback,
3 | useState,
4 | useRef,
5 | useMemo,
6 | useEffect,
7 | } from "react";
8 | import {
9 | ActivityIndicator,
10 | Dimensions,
11 | FlatList,
12 | Platform,
13 | View,
14 | Animated,
15 | } from "react-native";
16 | import { BorderlessButton } from "react-native-gesture-handler";
17 | import { useNavigation } from "@react-navigation/native";
18 | import { useQuery } from "react-query";
19 | import { useSafeAreaInsets } from "react-native-safe-area-context";
20 | import { useResetRecoilState, useRecoilState } from "recoil";
21 | import { Fontisto } from "@expo/vector-icons";
22 |
23 | import * as Button from "../components/Button";
24 | import * as LocalStorage from "../state/LocalStorage";
25 | import * as Text from "../components/Text";
26 | import * as Spacer from "../components/Spacer";
27 | import confirmAsync from "../util/confirmAsync";
28 | import StatusBar from "../components/StatusBar";
29 | import PlayerStatusBottomControl, {
30 | PLAYER_STATUS_BOTTOM_CONTROL_HEIGHT,
31 | } from "../components/PlayerStatusBottomControl";
32 | import { fetchPlaylistsAsync, fetchTracksAsync, Playlist, Track } from "../api";
33 | import { PlaylistItem } from "../components/playlists";
34 | import {
35 | playbackStatusState,
36 | playerSelectionState,
37 | currentUserState,
38 | } from "../state";
39 | import { colors, images } from "../styleguide";
40 |
41 | const AnimatedFlatList = Animated.createAnimatedComponent(FlatList);
42 |
43 | function MyPlaylistsHeader({
44 | animatedScrollValue,
45 | }: {
46 | animatedScrollValue: Animated.Value;
47 | }) {
48 | const insets = useSafeAreaInsets();
49 | const navigation = useNavigation();
50 | const resetPlayerSelection = useResetRecoilState(playerSelectionState);
51 | const resetPlaybackStatus = useResetRecoilState(playbackStatusState);
52 | const [currentUser, setCurrentUser] = useRecoilState(currentUserState);
53 |
54 | const clearAllState = useCallback(() => {
55 | LocalStorage.clearAsync();
56 | resetPlaybackStatus();
57 | resetPlayerSelection();
58 | setCurrentUser({ isAuthenticated: false });
59 | // We can't reset isAuthenticated or it will become null... Need to reset to false
60 | // @ts-ignore
61 | navigation.replace("SignIn");
62 | }, [navigation, resetPlaybackStatus, resetPlayerSelection]);
63 |
64 | const imageScale = useMemo(
65 | () =>
66 | animatedScrollValue.interpolate({
67 | inputRange: [-400, 0],
68 | outputRange: [5, 1],
69 | extrapolateRight: "clamp",
70 | }),
71 | [animatedScrollValue]
72 | );
73 |
74 | const textScale = useMemo(
75 | () =>
76 | animatedScrollValue.interpolate({
77 | inputRange: [-400, 0],
78 | outputRange: [2, 1],
79 | extrapolateRight: "clamp",
80 | }),
81 | [animatedScrollValue]
82 | );
83 |
84 | const textOpacity = useMemo(
85 | () =>
86 | animatedScrollValue.interpolate({
87 | inputRange: [0, 100],
88 | outputRange: [1, 0.1],
89 | extrapolateRight: "clamp",
90 | }),
91 | [animatedScrollValue]
92 | );
93 |
94 | const buttonOpacity = useMemo(
95 | () =>
96 | animatedScrollValue.interpolate({
97 | inputRange: [-200, 0, 100],
98 | outputRange: [0.1, 1, 0.1],
99 | extrapolate: "clamp",
100 | }),
101 | [animatedScrollValue]
102 | );
103 |
104 | return (
105 |
115 | {Platform.OS === "web" ? null : (
116 |
128 | )}
129 |
132 |
133 | Your Playlists
134 |
135 |
136 |
152 | {
154 | if (
155 | await confirmAsync({
156 | title: "Sign out?",
157 | message:
158 | "Are you sure you want to sign out? I mean, it doesn't really matter, just thought I'd check in with you.",
159 | confirmButtonText: "Continue",
160 | })
161 | ) {
162 | clearAllState();
163 | }
164 | }}
165 | >
166 |
167 |
168 |
169 |
170 | );
171 | }
172 |
173 | function LoadingPlaceholder() {
174 | const dimensions = Dimensions.get("window");
175 |
176 | return (
177 |
187 |
188 |
189 | );
190 | }
191 |
192 | function TrackLoadingOverlay({
193 | isFetchingTracks,
194 | }: {
195 | isFetchingTracks: boolean;
196 | }) {
197 | let appearValue = useRef(new Animated.Value(0));
198 |
199 | useEffect(() => {
200 | if (isFetchingTracks) {
201 | Animated.spring(appearValue.current, {
202 | toValue: 1,
203 | useNativeDriver: true,
204 | }).start();
205 | } else {
206 | Animated.spring(appearValue.current, {
207 | toValue: 0,
208 | useNativeDriver: true,
209 | }).start();
210 | }
211 | }, [isFetchingTracks]);
212 |
213 | return (
214 |
240 |
241 |
242 | Fetching playlist tracks...
243 |
244 |
245 | );
246 | }
247 |
248 | function EmptyListPlaceholder() {
249 | return (
250 |
262 |
263 | Uh oh, you don't seem to have any playlists on your account.
264 |
265 |
266 |
267 | Go ahead and open Spotify and save some playlists or make some, then
268 | come back.
269 |
270 |
271 |
272 |
273 | );
274 | }
275 |
276 | function List({
277 | playlists,
278 | isFetchingPlaylists,
279 | animatedScrollValue,
280 | }: {
281 | playlists: Playlist[] | undefined;
282 | isFetchingPlaylists: boolean;
283 | animatedScrollValue: Animated.Value;
284 | }) {
285 | const insets = useSafeAreaInsets();
286 | const navigation = useNavigation();
287 | const [isFetchingTracks, setIsFetchingTracks] = useState(false);
288 | const [playerSelection, setPlayerSelection] = useRecoilState(
289 | playerSelectionState
290 | );
291 |
292 | // This is a big boy function - lots going on here. It's scary.
293 | const handlePressItem = useCallback(
294 | async (item: Playlist) => {
295 | // If a playlist is already selected and playing, ensure user wants to stop it rather than just switching playlists willy nilly
296 | if (playerSelection.playlist && playerSelection.playlist.id !== item.id) {
297 | if (
298 | !(await confirmAsync({
299 | title: `Start a new playlist?`,
300 | message: `You previously selected "${playerSelection.playlist?.name}", if you continue we will switch to "${item.name}".`,
301 | confirmButtonText: "Continue",
302 | }))
303 | ) {
304 | return;
305 | }
306 | }
307 |
308 | try {
309 | setIsFetchingTracks(true);
310 | // Switching playlists!
311 | if (playerSelection.playlist?.id !== item.id) {
312 | const tracks = await fetchTracksAsync(item.id);
313 | const filteredTracks = tracks.filter((track: Track | undefined) => {
314 | if (!track || track.durationMs < 60000 || !track.isPlayable) {
315 | return false;
316 | }
317 | return true;
318 | }) as Track[];
319 |
320 | setPlayerSelection((oldPlayerSelection) => ({
321 | ...oldPlayerSelection,
322 | playlist: item,
323 | tracks: filteredTracks,
324 | }));
325 | }
326 |
327 | requestAnimationFrame(() => {
328 | navigation.navigate("Player");
329 | });
330 | } catch (e) {
331 | console.log(e);
332 | alert("Something went wrong while fetching playlist tracks! Ruh roh..");
333 | } finally {
334 | setIsFetchingTracks(false);
335 | }
336 | },
337 | [playerSelection, setPlayerSelection, navigation]
338 | );
339 |
340 | const renderItem = useCallback(
341 | ({ item, index }: { item: Playlist; index: number }) => (
342 | requestAnimationFrame(() => handlePressItem(item))}
346 | style={[
347 | // rounded corners on the top of the first row
348 | index === 0
349 | ? { borderTopLeftRadius: 10, borderTopRightRadius: 10 }
350 | : null,
351 | // rounded corners on the bottom of the last row
352 | index === playlists!.length - 1
353 | ? {
354 | borderBottomLeftRadius: 10,
355 | borderBottomRightRadius: 10,
356 | }
357 | : null,
358 | ]}
359 | />
360 | ),
361 | [playlists, handlePressItem]
362 | );
363 |
364 | const ListHeaderComponent = useMemo(
365 | () => ,
366 | [animatedScrollValue]
367 | );
368 |
369 | const ListEmptyComponent = useMemo(
370 | () =>
371 | isFetchingPlaylists ? : ,
372 | [isFetchingPlaylists]
373 | );
374 |
375 | return (
376 |
386 | {Platform.OS === "web" ? (
387 |
397 | ) : null}
398 | item.id}
411 | renderItem={renderItem}
412 | style={{
413 | flex: 1,
414 | backgroundColor: Platform.OS === "web" ? "transparent" : "#000",
415 | ...Platform.select({
416 | web: {
417 | alignSelf: "center",
418 | maxWidth: 1000,
419 | width: "100vw",
420 | marginHorizontal: 30,
421 | },
422 | }),
423 | }}
424 | contentContainerStyle={{
425 | justifyContent: "center",
426 | paddingBottom:
427 | insets.bottom + PLAYER_STATUS_BOTTOM_CONTROL_HEIGHT - 5,
428 | }}
429 | />
430 | {isFetchingTracks ? null : }
431 |
432 |
433 | );
434 | }
435 |
436 | export default function MyPlaylists(props: any) {
437 | const { data, isFetching } = useQuery("playlists", fetchPlaylistsAsync);
438 | const insets = useSafeAreaInsets();
439 | const scrollValue = useRef(new Animated.Value(0));
440 | const underlayOpacity = useMemo(
441 | () =>
442 | scrollValue.current.interpolate({
443 | inputRange: [0, 200],
444 | outputRange: [0, 0.7],
445 | extrapolate: "clamp",
446 | }),
447 | [scrollValue.current]
448 | );
449 |
450 | return (
451 |
452 |
457 |
458 |
469 |
470 | );
471 | }
472 |
--------------------------------------------------------------------------------