Skip to content

Commit 3f04536

Browse files
committed
feat: add apple music login
1 parent cc6b6c7 commit 3f04536

12 files changed

+200
-15
lines changed

src/components/Button/Button.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@ const Button = ({ disabled, children, ...props }: PropsWithChildren<Props>) => {
1111
{...props}
1212
onClick={props.onClick}
1313
className={clsx(
14-
props.className,
1514
disabled
1615
? 'pointer-events-none !bg-primary/20 text-black'
1716
: 'hover:bg-primary/20 active:bg-primary/5',
18-
'inline-flex items-center justify-center whitespace-nowrap rounded-2xl bg-primary/10 py-3 px-5 text-base font-bold text-primary shadow-sm transition-colors'
17+
'inline-flex items-center justify-center whitespace-nowrap rounded-2xl bg-primary/10 py-3 px-5 text-base font-bold text-primary shadow-sm transition-colors',
18+
props.className
1919
)}
2020
>
2121
{children}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import Script from 'next/script';
2+
import { Button } from '@/components/Button';
3+
import { AppleMusicIcon } from '@/components/Icons';
4+
import { useApi, useAuth, useToaster } from '@/hooks';
5+
import { useRouter } from 'next/router';
6+
7+
export const LoginAppleMusicButton = () => {
8+
const api = useApi();
9+
const auth = useAuth();
10+
const toaster = useToaster();
11+
const router = useRouter();
12+
13+
const redirectToLogin = () => {
14+
toaster.error("You're not logged in with Apple");
15+
router.push('/login');
16+
};
17+
18+
const appleMusicKitHandle = async () => {
19+
// eslint-disable-next-line @typescript-eslint/naming-convention
20+
const { id_token } = router.query;
21+
if (!id_token) {
22+
redirectToLogin();
23+
return;
24+
}
25+
26+
const music = MusicKit.getInstance();
27+
const MUT = await music.authorize();
28+
// TODO: integrate in statsfm.js package
29+
await api.http.put('/auth/appleMusic', {
30+
body: JSON.stringify({
31+
userToken: MUT,
32+
idToken: id_token,
33+
}),
34+
authRequired: true,
35+
});
36+
router.push(`/${auth.user!.id}`);
37+
};
38+
39+
const initializeMusicKit = () => {
40+
(async () => {
41+
await MusicKit.configure({
42+
developerToken: process.env.NEXT_PUBLIC_APPLE_DEVELOPER_TOKEN!,
43+
app: {
44+
name: process.env.NEXT_PUBLIC_APPLE_APP_NAME ?? 'testing',
45+
build: process.env.NEXT_PUBLIC_APPLE_APP_BUILD ?? '1978.4.1',
46+
},
47+
});
48+
})();
49+
};
50+
51+
return (
52+
<>
53+
<Script
54+
src="https://js-cdn.music.apple.com/musickit/v1/musickit.js"
55+
async
56+
onReady={initializeMusicKit}
57+
/>
58+
<div className="mt-8 flex flex-col gap-4">
59+
<Button
60+
onClick={appleMusicKitHandle}
61+
className="w-full bg-applemusic/80 text-white hover:bg-applemusic/60 active:bg-applemusic/50"
62+
>
63+
<AppleMusicIcon className="mr-2 !fill-white" hover={false} />
64+
Continue with Apple Music
65+
</Button>
66+
</div>
67+
</>
68+
);
69+
};

src/context/auth.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export const AuthProvider = (
2727

2828
const login = (redirectUrl?: string) => {
2929
if (redirectUrl) Cookies.set('redirectUrl', redirectUrl);
30-
router.push('/api/auth/login');
30+
router.push('/login');
3131
};
3232

3333
const tokenAge = () => {

src/hooks/use-api.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
import * as statsfm from '@/utils/statsfm';
22
import Cookies from 'js-cookie';
33

4-
let apiUrl = 'https://beta-api.stats.fm/api';
4+
let apiUrl = process.env.API_URL ?? 'https://beta-api.stats.fm/api';
55

66
if (process.env.NODE_ENV === 'development')
7-
apiUrl = 'https://beta-api.stats.fm/api';
7+
apiUrl = process.env.API_URL ?? 'https://beta-api.stats.fm/api';
88

99
const ref = new statsfm.Api({
1010
http: {
1111
apiUrl,
1212
},
1313
});
1414

15-
// TODO: maybe memoize the api based on a provided acces token or a piece of state which encloses the accestoken
15+
// TODO: maybe memoize the api based on a provided access token or a piece of state which encloses the accestoken
1616
export const useApi = () => {
1717
// use getApiInstance() for ssr instead of useApi();
1818
// try to set token when token is not set yet,

src/pages/api/auth/login.ts src/pages/api/auth/spotify-login.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,9 @@ const handler = (req: NextApiRequest, res: NextApiResponse) => {
5959
'user-follow-modify',
6060
].join('%20');
6161

62-
const redirectUrl = `https://api.stats.fm/api/v1/auth/redirect/spotify?scope=${scope}&redirect_uri=${origin}/api/auth/callback`;
62+
const redirectUrl = `${
63+
process.env.API_URL ?? 'https://api.stats.fm/api'
64+
}/v1/auth/redirect/spotify?scope=${scope}&redirect_uri=${origin}/api/auth/callback`;
6365
return res.redirect(redirectUrl);
6466
};
6567

src/pages/login-apple-music.tsx

+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { NextPage, GetServerSideProps } from 'next';
2+
import { useToaster } from '@/hooks';
3+
import { Container } from '@/components/Container';
4+
import { useEffect } from 'react';
5+
import { useRouter } from 'next/router';
6+
import Cookies from 'js-cookie';
7+
import { LoginAppleMusicButton } from '@/components/Login/LoginAppleMusicButton';
8+
import type { SSRProps } from '@/utils/ssrUtils';
9+
import { fetchUser } from '@/utils/ssrUtils';
10+
11+
export const getServerSideProps: GetServerSideProps<SSRProps> = async (ctx) => {
12+
const user = await fetchUser(ctx);
13+
14+
return {
15+
props: {
16+
user,
17+
},
18+
};
19+
};
20+
21+
const Login: NextPage<SSRProps> = ({ user }) => {
22+
const router = useRouter();
23+
const toaster = useToaster();
24+
25+
useEffect(() => {
26+
const { redirect, failed } = router.query;
27+
if (failed) {
28+
toaster.error('Something went wrong trying to login. Please try again.');
29+
}
30+
31+
if (!user?.id) router.push('/login');
32+
if (!redirect) return;
33+
Cookies.set('redirectUrl', redirect.toString());
34+
}, [router]);
35+
36+
return (
37+
<Container className="flex min-h-[90vh] pt-24">
38+
<div className="mx-auto mt-48 flex w-96 flex-col px-4">
39+
<h1 className="w-full text-center text-4xl text-white">
40+
Login to Apple Music
41+
</h1>
42+
<LoginAppleMusicButton />
43+
</div>
44+
</Container>
45+
);
46+
};
47+
48+
export default Login;

src/pages/login.tsx

+43-2
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ import type { GetServerSideProps, NextPage } from 'next';
22
import { useAuth, useToaster } from '@/hooks';
33
import { Container } from '@/components/Container';
44
import { Button } from '@/components/Button';
5-
import { SpotifyIcon } from '@/components/Icons';
5+
import { AppleMusicIcon, SpotifyIcon } from '@/components/Icons';
6+
import type { MouseEventHandler } from 'react';
67
import { useEffect } from 'react';
78
import { useRouter } from 'next/router';
89
import Cookies from 'js-cookie';
910
import type { SSRProps } from '@/utils/ssrUtils';
1011
import { fetchUser } from '@/utils/ssrUtils';
12+
import { LoginAppleMusicButton } from '@/components/Login/LoginAppleMusicButton';
1113

1214
export const getServerSideProps: GetServerSideProps<SSRProps> = async (ctx) => {
1315
const user = await fetchUser(ctx);
@@ -34,6 +36,32 @@ const Login: NextPage = () => {
3436
Cookies.set('redirectUrl', redirect.toString());
3537
}, [router]);
3638

39+
const generateQueryString = (q: Record<string, any>): string => {
40+
return Object.entries(q)
41+
.filter(
42+
([_, value]) =>
43+
value !== undefined &&
44+
value !== null &&
45+
value.toString().trim() !== ''
46+
)
47+
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
48+
.join('&');
49+
};
50+
51+
const appleAuthHandle: MouseEventHandler<HTMLButtonElement> = () => {
52+
window.location.href = `https://appleid.apple.com/auth/authorize?${generateQueryString(
53+
{
54+
response_type: 'code',
55+
response_mode: 'form_post',
56+
client_id: process.env.NEXT_PUBLIC_APPLE_CLIENT_ID,
57+
redirect_uri: process.env.NEXT_PUBLIC_APPLE_REDIRECT_URI
58+
? `${process.env.NEXT_PUBLIC_APPLE_REDIRECT_URI}/web`
59+
: '',
60+
scope: 'email name',
61+
}
62+
)}`;
63+
};
64+
3765
return (
3866
<Container className="flex min-h-[90vh] pt-24">
3967
<div className="mx-auto mt-48 flex w-96 flex-col px-4">
@@ -43,14 +71,27 @@ const Login: NextPage = () => {
4371
<div className="mt-8 flex flex-col gap-4">
4472
<Button
4573
onClick={() => {
46-
auth.login();
74+
router.push('/api/auth/spotify-login');
4775
}}
4876
className="w-full bg-primary/80 text-black hover:bg-primary/60 active:bg-primary/50"
4977
>
5078
<SpotifyIcon className="mr-2 !fill-black" />
5179
Continue with Spotify
5280
</Button>
5381
</div>
82+
{auth?.user?.appleMusicAuth ? (
83+
<LoginAppleMusicButton />
84+
) : (
85+
<div className="mt-8 flex flex-col gap-4">
86+
<Button
87+
onClick={appleAuthHandle}
88+
className="w-full bg-applemusic/80 text-white hover:!bg-applemusic/60 active:!bg-applemusic/50"
89+
>
90+
<AppleMusicIcon className="mr-2 !fill-white" hover={false} />
91+
Continue with Apple
92+
</Button>
93+
</div>
94+
)}
5495
</div>
5596
</Container>
5697
);

src/pages/user/[id]/[[...deeplink]].tsx

+3-1
Original file line numberDiff line numberDiff line change
@@ -462,7 +462,9 @@ const User: NextPage<Props> = ({
462462
{user.userBan?.active !== true && (
463463
<Scope value="connections" fallback={<></>}>
464464
<div className="mt-2 flex flex-row items-center gap-2">
465-
<SpotifyLink path={`/user/${user.id}`} />
465+
{user.spotifyAuth && (
466+
<SpotifyLink path={`/user/${user.id}`} />
467+
)}
466468
</div>
467469
</Scope>
468470
)}

src/pages/user/[id]/streams.tsx

+4-4
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ const StreamsPage: NextPage<Props> = ({ userProfile }) => {
7070
const router = useRouter();
7171

7272
const [recentStreams, setRecentStreams] = useState<statsfm.Stream[]>([]);
73-
const [loadMoar, setLoadMoar] = useState(true);
73+
const [loadMore, setLoadMore] = useState(true);
7474

7575
const callbackRef = async () => {
7676
if (!userProfile.privacySettings?.recentlyPlayed) return;
@@ -82,7 +82,7 @@ const StreamsPage: NextPage<Props> = ({ userProfile }) => {
8282
before: new Date(lastEndTime).getTime() || new Date().getTime(),
8383
});
8484

85-
if (streams.length === 0) setLoadMoar(false);
85+
if (streams.length === 0) setLoadMore(false);
8686
setRecentStreams([...(recentStreams || []), ...streams.slice(1)]);
8787
};
8888

@@ -129,7 +129,7 @@ const StreamsPage: NextPage<Props> = ({ userProfile }) => {
129129
<>
130130
<InfiniteScroll
131131
loadMore={callbackRef}
132-
hasMore={loadMoar}
132+
hasMore={loadMore}
133133
loader={
134134
<Spinner
135135
key="bigtimerush"
@@ -145,7 +145,7 @@ const StreamsPage: NextPage<Props> = ({ userProfile }) => {
145145
streams={recentStreams}
146146
/>
147147
</InfiniteScroll>
148-
{!loadMoar && (
148+
{!loadMore && (
149149
<div className="grid w-full place-items-center py-20">
150150
<MdDiscFull />
151151
<p className="m-0 text-text-grey">No streams to load!</p>

src/types/MusicKit.d.ts

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
declare namespace MusicKit {
2+
function configure(configuration: Configuration): Promise<MusicKitInstance>;
3+
4+
function getInstance(): MusicKitInstance;
5+
6+
interface Configuration {
7+
developerToken: string;
8+
app: AppConfiguration;
9+
bitrate?: PlaybackBitrate;
10+
storefrontId?: string;
11+
}
12+
13+
interface AppConfiguration {
14+
name: string;
15+
build?: string;
16+
icon?: string;
17+
}
18+
19+
interface MusicKitInstance {
20+
authorize(): Promise<string>;
21+
}
22+
}

src/utils/ssrUtils.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export const getApiInstance = (accessToken?: string) => {
1111
accessToken,
1212
},
1313
http: {
14-
apiUrl: 'https://beta-api.stats.fm/api',
14+
apiUrl: process.env.API_URL ?? 'https://beta-api.stats.fm/api',
1515
},
1616
});
1717
};

tailwind.config.js

+1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ module.exports = {
2525
plus: '#ffd700',
2626
swipefy: '#DBFF00',
2727
'text-grey': '#727272',
28+
applemusic: 'rgb(251, 35, 60)',
2829
},
2930
fontFamily: {
3031
body: ['var(--font-statsfm-sans)', 'Statsfm Sans'],

0 commit comments

Comments
 (0)