Skip to content

Commit

Permalink
feat(providers): add WebNowPlaying provider and integrate Jotai store
Browse files Browse the repository at this point in the history
  • Loading branch information
busybox11 committed Dec 2, 2024
1 parent 6c4ac89 commit 275f4ae
Show file tree
Hide file tree
Showing 12 changed files with 222 additions and 53 deletions.
16 changes: 16 additions & 0 deletions app/components/ClientOnly.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { ReactNode } from "@tanstack/react-router";
import { useEffect, useState } from "react";

export default function ClientOnly({ children }: { children: ReactNode }) {
const [hasMounted, setHasMounted] = useState(false);

useEffect(() => {
setHasMounted(true);
}, []);

if (!hasMounted) {
return null;
}

return <>{children}</>;
}
25 changes: 15 additions & 10 deletions app/components/contexts/PlayerProviders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,28 @@ import { IProviderClient } from "@/types/providers/client";
import { useLocalStorage } from "usehooks-ts";
import { PlayerState } from "@/types/player";
import { useAtomValue, useSetAtom } from "jotai";
import { activePlayerAtom, playerStateAtom } from "@/state/player";
import {
activePlayerAtom,
lastUsedProviderAtom,
playerStateAtom,
providersAtom,
} from "@/state/player";

const PlayerProvidersContext = createContext<{
[key: string]: IProviderClient;
}>({});

type LastUsedProvider = {
id: string;
date: number;
} | null;

export function PlayerProvidersProvider({
children,
}: Readonly<{
children: ReactNode;
}>) {
const navigate = useNavigate();

const [_lastUsed, setLastUsed, _removeLastUsed] =
useLocalStorage<LastUsedProvider>("lastUsedProvider", null);
const setLastUsed = useSetAtom(lastUsedProviderAtom);
const setActivePlayer = useSetAtom(activePlayerAtom);
const setPlayerState = useSetAtom(playerStateAtom);
const setProviders = useSetAtom(providersAtom);

const providerInstancesRef = useRef<{ [key: string]: IProviderClient }>({});

Expand All @@ -51,6 +51,10 @@ export function PlayerProvidersProvider({
id,
new provider({
onAuth: () => handleAuth(id),
onUnregister: () => {
setActivePlayer(null);
navigate({ to: "/" });
},
sendPlayerState: (playerObj: PlayerState) => {
setPlayerState(playerObj);
console.log(playerObj);
Expand All @@ -62,6 +66,7 @@ export function PlayerProvidersProvider({

// Store references to provider instances
providerInstancesRef.current = instances;
setProviders(instances);
return instances;
}, []);

Expand All @@ -73,11 +78,11 @@ export function PlayerProvidersProvider({
}

export function usePlayerProviders() {
const [lastUsed] = useLocalStorage<LastUsedProvider>("lastUsed", null);
const lastUsedProvider = useAtomValue(lastUsedProviderAtom);
const activePlayer = useAtomValue(activePlayerAtom);

return {
lastUsedProvider: lastUsed,
lastUsedProvider,
providers: useContext(PlayerProvidersContext),
activePlayer,
};
Expand Down
49 changes: 49 additions & 0 deletions app/components/index/ProvidersBtns.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import ClientOnly from "@/components/ClientOnly";
import { usePlayerProviders } from "@/components/contexts/PlayerProviders";
import { useMemo } from "react";

export default function ProvidersBtns() {
const { providers, lastUsedProvider } = usePlayerProviders();

const { defaultProvider, otherProviders } = useMemo(() => {
const defaultProviderId =
lastUsedProvider ? lastUsedProvider.id : "spotify";

const defaultProvider = Object.values(providers).find(
(provider) => provider.meta.id === defaultProviderId
)!;
const otherProviders = Object.values(providers).filter(
(provider) => provider.meta.id !== defaultProviderId
);

return {
defaultProvider,
otherProviders,
};
}, [providers]);

return (
<ClientOnly>
<div className="flex flex-col gap-4">
<button
onClick={() => defaultProvider.authenticate()}
className="bg-[#15883D] px-12 py-3 rounded-full text-lg tracking-wide active:scale-95 transition mx-auto"
>
Login with {defaultProvider.meta.name}
</button>

{Object.entries(otherProviders).map(([id, provider]) => {
return (
<button
key={id}
onClick={() => provider.authenticate()}
className="border-b-2 text-white/70 hover:text-white border-white/50 hover:border-white/70 text-lg tracking-wide active:scale-95 transition mx-auto"
>
{provider.meta.name}
</button>
);
})}
</div>
</ClientOnly>
);
}
10 changes: 9 additions & 1 deletion app/providers/spotify/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,16 @@ export default class SpotifyProvider implements IProviderClient {

// API event handlers
private onAuth: () => void;
private onUnregister: () => void;
private sendPlayerState: (playerObj: PlayerState) => void;

constructor({ onAuth, sendPlayerState }: IProviderClientConstructor) {
constructor({
onAuth,
onUnregister,
sendPlayerState,
}: IProviderClientConstructor) {
this.onAuth = onAuth;
this.onUnregister = onUnregister;
this.sendPlayerState = sendPlayerState;
}

Expand Down Expand Up @@ -84,5 +90,7 @@ export default class SpotifyProvider implements IProviderClient {
clearInterval(this._playerLoopInstance);
this._playerLoopInstance = NaN;
}

this.onUnregister();
}
}
78 changes: 78 additions & 0 deletions app/providers/webnowplaying/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import {
IProviderClient,
IProviderClientConstructor,
} from "@/types/providers/client";
import { PlayerState } from "@/types/player";
import webnowplayingProviderMeta from "@/providers/webnowplaying";

export default class WebNowPlayingProvider implements IProviderClient {
readonly meta = webnowplayingProviderMeta;
isAuthenticated = false;

// API event handlers
private onAuth: () => void;
private onUnregister: () => void;
private sendPlayerState: (playerObj: PlayerState) => void;

constructor({
onAuth,
onUnregister,
sendPlayerState,
}: IProviderClientConstructor) {
this.onAuth = onAuth;
this.onUnregister = onUnregister;
this.sendPlayerState = sendPlayerState;
}

// Private properties and methods
private _lastPlaybackState: PlayerState | null = null;

private _handleMessage = (msg: MessageEvent) => {
if (msg.data.type != "wnp-info" || !msg.data.player) return;

this._lastPlaybackState = msg.data.player;
};
private _beginListening() {
window.addEventListener("message", this._handleMessage);
}
private _endListening() {
window.postMessage(
{
type: "wnp-info",
subscribe: false,
},
"*"
);
window.removeEventListener("message", this._handleMessage);
this.isAuthenticated = false;
}

// Public implemented methods

async authenticate() {
window.postMessage(
{
type: "wnp-info",
subscribe: true,
},
"*"
);
this.isAuthenticated = true;

this.onAuth();
}

async callback() {
await this.authenticate();
}

async registerPlayer() {
this._beginListening();
}

async unregisterPlayer() {
this._endListening();

this.onUnregister();
}
}
8 changes: 8 additions & 0 deletions app/providers/webnowplaying/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { ProviderMeta } from "@/types/providers/meta";

const webnowplayingProviderMeta: ProviderMeta = {
name: "WebNowPlaying",
id: "webnowplaying",
};

export default webnowplayingProviderMeta;
9 changes: 6 additions & 3 deletions app/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import "@fontsource-variable/outfit";

import appCss from "../styles/app.css?url";
import { PlayerProvidersProvider } from "@/components/contexts/PlayerProviders";
import { JotaiStoreProvider } from "@/state/store";

export const Route = createRootRoute({
meta: () => [
Expand Down Expand Up @@ -37,9 +38,11 @@ export const Route = createRootRoute({
function RootComponent() {
return (
<RootDocument>
<PlayerProvidersProvider>
<Outlet />
</PlayerProvidersProvider>
<JotaiStoreProvider>
<PlayerProvidersProvider>
<Outlet />
</PlayerProvidersProvider>
</JotaiStoreProvider>
</RootDocument>
);
}
Expand Down
41 changes: 2 additions & 39 deletions app/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,13 @@ import { createFileRoute } from "@tanstack/react-router";
import { MiscLinks } from "@/components/MiscLinks";
import { usePlayerProviders } from "@/components/contexts/PlayerProviders";
import { useMemo } from "react";
import ProvidersBtns from "@/components/index/ProvidersBtns";

export const Route = createFileRoute("/")({
component: Home,
});

function Home() {
const { providers, lastUsedProvider } = usePlayerProviders();

const { defaultProvider, otherProviders } = useMemo(() => {
const defaultProviderId =
lastUsedProvider ? lastUsedProvider.id : "spotify";

const defaultProvider = Object.values(providers).find(
(provider) => provider.meta.id === defaultProviderId
)!;
const otherProviders = Object.values(providers).filter(
(provider) => provider.meta.id !== defaultProviderId
);

return {
defaultProvider,
otherProviders,
};
}, [providers]);

return (
<main className="flex flex-col items-center justify-center h-screen gap-12">
<section className="flex flex-row gap-6 items-center">
Expand All @@ -43,26 +25,7 @@ function Home() {
</div>
</section>

<div className="flex flex-col gap-4">
<button
onClick={() => defaultProvider.authenticate()}
className="bg-[#15883D] px-12 py-3 rounded-full text-lg tracking-wide active:scale-95 transition mx-auto"
>
Login with {defaultProvider.meta.name}
</button>

{Object.entries(otherProviders).map(([id, provider]) => {
return (
<button
key={id}
onClick={() => provider.authenticate()}
className="border-b-2 text-white/70 hover:text-white border-white/50 hover:border-white/70 text-lg tracking-wide active:scale-95 transition mx-auto"
>
{provider.meta.name}
</button>
);
})}
</div>
<ProvidersBtns />
</main>
);
}
5 changes: 5 additions & 0 deletions app/routes/playing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,14 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
import usePlayer from "@/hooks/usePlayer";

import { twMerge } from "tailwind-merge";
import { store } from "@/state/store";
import { activeProviderAtom } from "@/state/player";

export const Route = createFileRoute("/playing")({
component: PlayingRouteComponent,
onLeave: async () => {
await store.get(activeProviderAtom)?.unregisterPlayer();
},
});

const msToTime = (ms: number) => {
Expand Down
23 changes: 23 additions & 0 deletions app/state/player.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
import { PlayerState } from "@/types/player";
import { IProviderClient } from "@/types/providers/client";
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";

type LastUsedProvider = {
id: string;
date: number;
} | null;

export const providersAtom = atom<Record<string, IProviderClient>>({});
export const activePlayerAtom = atom<null | string>(null);
export const lastUsedProviderAtom = atomWithStorage<LastUsedProvider>(
"lastUsedProvider",
null
);

export const activeProviderAtom = atom((get) => {
const activePlayer = get(activePlayerAtom);
const providers = get(providersAtom);

if (!activePlayer) return null;
if (!providers[activePlayer]) return null;

return providers[activePlayer];
});

export const playerStateAtom = atom<PlayerState>(null);
10 changes: 10 additions & 0 deletions app/state/store.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { createStore, Provider } from "jotai";
import { ReactNode } from "react";

export const store = createStore();

export function JotaiStoreProvider({
children,
}: Readonly<{ children: ReactNode }>) {
return <Provider store={store}>{children}</Provider>;
}
1 change: 1 addition & 0 deletions app/types/providers/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ProviderMeta } from "@/types/providers/meta";

export interface IProviderClientConstructor {
onAuth: () => void;
onUnregister: () => void;
sendPlayerState: (playerObj: PlayerState) => void;
}

Expand Down

0 comments on commit 275f4ae

Please sign in to comment.