diff --git a/packages/components/add-provider/package.json b/packages/components/add-provider/package.json
index a40b79b..7abc669 100644
--- a/packages/components/add-provider/package.json
+++ b/packages/components/add-provider/package.json
@@ -9,6 +9,7 @@
"dependencies": {
"@echo/core-types": "^1.0.0",
"@echo/services-bootstrap": "^1.0.0",
+ "@echo/services-bootstrap-services": "^1.0.0",
"@effect-rx/rx": "^0.33.8",
"@effect-rx/rx-react": "^0.30.11",
"effect": "^3.2.8"
@@ -17,4 +18,4 @@
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22"
}
-}
+}
\ No newline at end of file
diff --git a/packages/components/add-provider/src/AddProvider.tsx b/packages/components/add-provider/src/AddProvider.tsx
index b36e103..9f8106a 100644
--- a/packages/components/add-provider/src/AddProvider.tsx
+++ b/packages/components/add-provider/src/AddProvider.tsx
@@ -1,21 +1,36 @@
import {
+ ActiveMediaProviderCache,
AvailableProviders,
MediaProviderMainThreadBroadcastChannel,
type Authentication,
type AuthenticationInfo,
type FolderMetadata,
+ type MediaPlayer,
type MediaProvider,
type ProviderMetadata,
} from "@echo/core-types";
-import { LazyLoadedProvider, MainLive } from "@echo/services-bootstrap";
+import { LazyLoadedProvider } from "@echo/services-bootstrap";
+import { AppLive } from "@echo/services-bootstrap-services";
+import { LazyLoadedMediaPlayer } from "@echo/services-bootstrap/src/loaders";
import { Rx } from "@effect-rx/rx";
import { useRx } from "@effect-rx/rx-react";
import { Effect, Match } from "effect";
import { useCallback, useEffect, useMemo } from "react";
-const runtime = Rx.runtime(MainLive);
-const loadProviderFn = runtime.fn((metadata: ProviderMetadata) =>
- LazyLoadedProvider.load(metadata),
+const runtime = Rx.runtime(AppLive);
+const loadProviderFn = runtime.fn(LazyLoadedProvider.load);
+const loadMediaPlayerFn = runtime.fn(
+ ({
+ metadata,
+ authInfo,
+ }: {
+ metadata: ProviderMetadata;
+ authInfo: AuthenticationInfo;
+ }) =>
+ Effect.gen(function* () {
+ const { createMediaPlayer } = yield* LazyLoadedMediaPlayer.load(metadata);
+ return yield* createMediaPlayer(authInfo);
+ }),
);
export const AddProvider = () => {
@@ -105,6 +120,18 @@ const listRootFn = runtime.fn(
(mediaProvider: MediaProvider) => mediaProvider.listRoot,
);
+const addMediaProviderToCacheFn = runtime.fn(
+ ({
+ metadata,
+ provider,
+ player,
+ }: {
+ metadata: ProviderMetadata;
+ provider: MediaProvider;
+ player: MediaPlayer;
+ }) => ActiveMediaProviderCache.add(metadata, provider, player),
+);
+
const SelectRoot = ({
authInfo,
metadata,
@@ -114,16 +141,34 @@ const SelectRoot = ({
metadata: ProviderMetadata;
createMediaProvider: (authInfo: AuthenticationInfo) => MediaProvider;
}) => {
+ const [mediaPlayerStatus, createMediaPlayer] = useRx(loadMediaPlayerFn);
+
const mediaProvider = useMemo(
() => createMediaProvider(authInfo),
[createMediaProvider, authInfo],
);
const [listStatus, listRoot] = useRx(listRootFn);
+ const [, addMediaProviderToCache] = useRx(addMediaProviderToCacheFn);
useEffect(() => {
listRoot(mediaProvider);
- });
+ createMediaPlayer({ metadata, authInfo });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // TODO: Refactor this mess somehow?
+ useEffect(() => {
+ Match.value(mediaPlayerStatus).pipe(
+ Match.tag("Success", ({ value: player }) => {
+ addMediaProviderToCache({
+ metadata,
+ provider: mediaProvider,
+ player,
+ });
+ }),
+ );
+ }, [mediaPlayerStatus, mediaProvider, metadata, addMediaProviderToCache]);
return (
diff --git a/packages/components/library/package.json b/packages/components/library/package.json
index ddc8cfd..fb1c6c5 100644
--- a/packages/components/library/package.json
+++ b/packages/components/library/package.json
@@ -8,8 +8,9 @@
},
"dependencies": {
"@echo/core-types": "^1.0.0",
- "@echo/services-bootstrap": "^1.0.0",
+ "@echo/services-bootstrap-services": "^1.0.0",
"@echo/services-library": "^1.0.0",
+ "@echo/services-player": "^1.0.0",
"@effect-rx/rx": "^0.33.8",
"@effect-rx/rx-react": "^0.30.11",
"effect": "^3.2.8"
diff --git a/packages/components/library/src/Library.tsx b/packages/components/library/src/Library.tsx
index 0419cf2..a26cf70 100644
--- a/packages/components/library/src/Library.tsx
+++ b/packages/components/library/src/Library.tsx
@@ -1,38 +1,30 @@
-import { Library, type Track } from "@echo/core-types";
-import { MainLive } from "@echo/services-bootstrap";
+import { Library, Player } from "@echo/core-types";
+import { AppLive } from "@echo/services-bootstrap-services";
import { Rx } from "@effect-rx/rx";
import { Layer, Stream } from "effect";
-import { Suspense, useState } from "react";
+import { Suspense } from "react";
import { LibraryLive } from "@echo/services-library";
-import { useRxSuspenseSuccess } from "@effect-rx/rx-react";
+import { PlayerLive } from "@echo/services-player";
+import { useRx, useRxSuspenseSuccess } from "@effect-rx/rx-react";
-const runtime = Rx.runtime(LibraryLive.pipe(Layer.provide(MainLive)));
+const runtime = Rx.runtime(
+ Layer.mergeAll(LibraryLive, PlayerLive).pipe(Layer.provide(AppLive)),
+);
const observeLibrary = runtime.rx(Stream.unwrap(Library.observeAlbums()));
+const playAlbumFn = runtime.fn(Player.playAlbum);
const UserLibrary = () => {
- const [src, setSrc] = useState
(undefined);
const albums = useRxSuspenseSuccess(observeLibrary).value;
-
- const playFirstTrack = (tracks: Track[]) => {
- const track = tracks[0];
- switch (track.resource.type) {
- case "file":
- setSrc(track.resource.uri);
- break;
- case "api":
- break;
- }
- };
+ const [, playAlbum] = useRx(playAlbumFn);
return (
-
{albums.map((album) => (
{album.name}
{album.artist.name}
-
+
))}
diff --git a/packages/components/provider-status/package.json b/packages/components/provider-status/package.json
index 88250e7..576ca60 100644
--- a/packages/components/provider-status/package.json
+++ b/packages/components/provider-status/package.json
@@ -8,8 +8,7 @@
},
"dependencies": {
"@echo/core-types": "^1.0.0",
- "@echo/services-bootstrap": "^1.0.0",
- "@echo/services-media-provider-status": "^1.0.0",
+ "@echo/services-bootstrap-services": "^1.0.0",
"@effect-rx/rx": "^0.33.8",
"@effect-rx/rx-react": "^0.30.11",
"effect": "^3.2.8"
diff --git a/packages/components/provider-status/src/ProviderStatus.tsx b/packages/components/provider-status/src/ProviderStatus.tsx
index 5b89945..93157ac 100644
--- a/packages/components/provider-status/src/ProviderStatus.tsx
+++ b/packages/components/provider-status/src/ProviderStatus.tsx
@@ -1,13 +1,10 @@
-import { MediaProviderStatusLive } from "@echo/services-media-provider-status";
-import { MainLive } from "@echo/services-bootstrap";
+import { AppLive } from "@echo/services-bootstrap-services";
import { Rx } from "@effect-rx/rx";
-import { Layer, Match } from "effect";
+import { Match } from "effect";
import { useRxValue } from "@effect-rx/rx-react";
import { MediaProviderStatus } from "@echo/core-types";
-const runtime = Rx.runtime(
- MediaProviderStatusLive.pipe(Layer.provide(MainLive)),
-);
+const runtime = Rx.runtime(AppLive);
const providerStatus = runtime.subscriptionRef(MediaProviderStatus.observe);
diff --git a/packages/core/types/src/model/file-system.ts b/packages/core/types/src/model/file-system.ts
index 5467c7d..10a0f6a 100644
--- a/packages/core/types/src/model/file-system.ts
+++ b/packages/core/types/src/model/file-system.ts
@@ -1,9 +1,23 @@
+import { Brand } from "effect";
+
+/**
+ * A unique identifier for a folder in a media provider's file system.
+ */
+export type FolderId = string & Brand.Brand<"FolderId">;
+export const FolderId = Brand.nominal
();
+
+/**
+ * A unique identifier for a file in a media provider's file system.
+ */
+export type FileId = string & Brand.Brand<"FileId">;
+export const FileId = Brand.nominal();
+
/**
* Defines a folder in a media provider's file system.
*/
export type FolderMetadata = {
_tag: "folder";
- id: string;
+ id: FolderId;
name: string;
};
@@ -12,7 +26,7 @@ export type FolderMetadata = {
*/
export type FileMetadata = {
_tag: "file";
- id: string;
+ id: FileId;
name: string;
byteSize: number;
mimeType: string | undefined;
diff --git a/packages/core/types/src/model/track.ts b/packages/core/types/src/model/track.ts
index 6536157..9d10ed2 100644
--- a/packages/core/types/src/model/track.ts
+++ b/packages/core/types/src/model/track.ts
@@ -5,6 +5,7 @@ import type {
ApiBasedProviderId,
FileBasedProviderId,
} from "./provider-metadata";
+import type { FileId } from "./file-system";
/**
* Wrapper around a string to represent a track id.
@@ -17,7 +18,7 @@ export const TrackId = Brand.nominal();
*/
export type StreamingResource =
// TODO: Do not store URIs. They seem to expire after some time. Instead save the track ID and the provider ID.
- | { type: "file"; provider: FileBasedProviderId; uri: string }
+ | { type: "file"; provider: FileBasedProviderId; fileId: FileId }
| { type: "api"; provider: ApiBasedProviderId };
/**
diff --git a/packages/core/types/src/services/active-media-provider-cache.ts b/packages/core/types/src/services/active-media-provider-cache.ts
new file mode 100644
index 0000000..72f5e47
--- /dev/null
+++ b/packages/core/types/src/services/active-media-provider-cache.ts
@@ -0,0 +1,49 @@
+import { Effect, Option } from "effect";
+import type { ProviderId, ProviderMetadata } from "../model";
+import type { MediaPlayer, MediaProvider } from "./media-provider";
+
+type ProviderWithMetadata = {
+ readonly metadata: ProviderMetadata;
+ readonly provider: MediaProvider;
+ readonly player: MediaPlayer;
+};
+
+/**
+ * Defines a cache of all currently active providers.
+ */
+export type MediaProviderById = Map;
+
+/**
+ * Service that allows to cache active media providers and observe changes to
+ * them.
+ */
+export type IActiveMediaProviderCache = {
+ /**
+ * Adds the given provider to the cache. If a provider with the same ID is
+ * already present, it will be replaced. Upon being added, the service will
+ * listen to the provider's state changes and remove it from the cache once
+ * it becomes inactive.
+ */
+ readonly add: (
+ metadata: ProviderMetadata,
+ provider: MediaProvider,
+ player: MediaPlayer,
+ ) => Effect.Effect;
+
+ /**
+ * Returns a media provider, if it is currently active, or none otherwise.
+ */
+ readonly get: (providerId: ProviderId) => Effect.Effect<
+ Option.Option<{
+ provider: MediaProvider;
+ player: MediaPlayer;
+ }>
+ >;
+};
+
+/**
+ * Tag to identify the ActiveMediaProviderCache service.
+ */
+export class ActiveMediaProviderCache extends Effect.Tag(
+ "@echo/core-types/ActiveMediaProviderCache",
+)() {}
diff --git a/packages/core/types/src/services/index.ts b/packages/core/types/src/services/index.ts
index 20d510e..3ce358f 100644
--- a/packages/core/types/src/services/index.ts
+++ b/packages/core/types/src/services/index.ts
@@ -1,9 +1,11 @@
+export * from "./active-media-provider-cache";
export * from "./authentication";
export * from "./broadcast-channel";
export * from "./crypto";
export * from "./database";
export * from "./library";
export * from "./metadata-provider";
-export * from "./mediaProvider";
+export * from "./media-player";
+export * from "./media-provider";
export * from "./player";
export * from "./provider-status";
diff --git a/packages/core/types/src/services/media-player.ts b/packages/core/types/src/services/media-player.ts
new file mode 100644
index 0000000..ce7cfb8
--- /dev/null
+++ b/packages/core/types/src/services/media-player.ts
@@ -0,0 +1,52 @@
+import { Effect, Stream } from "effect";
+
+/**
+ * Current state in which the media player is in.
+ */
+export type MediaPlayerState =
+ | { _tag: "idle" }
+ | { _tag: "playing" }
+ | { _tag: "paused" };
+
+/**
+ * Service that provides media playback capabilities and exposes the current
+ * state of the player.
+ */
+type IMediaPlayer = {
+ /**
+ * Starts the player with the given source.
+ */
+ readonly play: (fromSource: TStreamingSource) => Effect.Effect;
+
+ /**
+ * Returns a stream that emits the current player state and any subsequent
+ * changes to it.
+ */
+ readonly observe: Effect.Effect>;
+};
+
+/**
+ * A media player that can play tracks that are hosted on a file-system.
+ */
+export type IFileMediaPlayer = IMediaPlayer;
+
+/**
+ * A media player that can play tracks that are hosted on a third-party API.
+ */
+export type IApiMediaPlayer = IMediaPlayer;
+
+/**
+ * Tag to identify a file-based media player.
+ */
+export class FileMediaPlayer extends Effect.Tag("@echo/core-types/Library")<
+ FileMediaPlayer,
+ IMediaPlayer
+>() {}
+
+/**
+ * Tag to identify an API-based media player.
+ */
+export class ApiMediaPlayer extends Effect.Tag("@echo/core-types/Library")<
+ ApiMediaPlayer,
+ IMediaPlayer
+>() {}
diff --git a/packages/core/types/src/services/mediaProvider.ts b/packages/core/types/src/services/media-provider.ts
similarity index 56%
rename from packages/core/types/src/services/mediaProvider.ts
rename to packages/core/types/src/services/media-provider.ts
index c26253e..2ad83cf 100644
--- a/packages/core/types/src/services/mediaProvider.ts
+++ b/packages/core/types/src/services/media-provider.ts
@@ -3,6 +3,7 @@ import type {
AuthenticationInfo,
FolderMetadata,
FolderContentMetadata,
+ FileId,
} from "../model";
import type { Authentication } from "./authentication";
import { Context } from "effect";
@@ -11,6 +12,17 @@ export enum FileBasedProviderError {
NotFound = "not-found",
}
+/**
+ * Error raised when a file or item that was asked to be played was not found.
+ */
+export class PlayNotFoundError extends Error {
+ static readonly _tag = "PlayError";
+
+ constructor() {
+ super();
+ }
+}
+
/**
* A provider that provides its data via a file system. For example, OneDrive.
*/
@@ -26,6 +38,11 @@ export type FileBasedProvider = {
readonly listFolder: (
folder: FolderMetadata,
) => Effect;
+
+ /**
+ * Retrieves the URL to access a file given its ID.
+ */
+ readonly fileUrlById: (fileId: FileId) => Effect;
};
/**
@@ -33,6 +50,34 @@ export type FileBasedProvider = {
*/
export type MediaProvider = FileBasedProvider;
+/**
+ * A media player that can play files from a file-based provider.
+ */
+export type FileBasedMediaPlayer = {
+ /**
+ * Attempts to stream the given file via a file-based media player.
+ */
+ readonly playFile: (file: URL) => Effect;
+};
+
+/**
+ * Defines all types of media players that are available in the app.
+ */
+export type MediaPlayer = FileBasedMediaPlayer;
+
+/**
+ * A factory that can create a new instance of the media player.
+ */
+export type MediaPlayerFactory = {
+ /**
+ * Creates a new instance of the media player based on the given authentication
+ * info.
+ */
+ readonly createMediaPlayer: (
+ authInfo: AuthenticationInfo,
+ ) => Effect;
+};
+
/**
* A factory that can provide an instance to the authentication provider that
* pairs with this media provider, and can create a new instance of the media
@@ -54,3 +99,7 @@ export type MediaProviderFactory = {
export const MediaProviderFactory = Context.GenericTag(
"@echo/core-types/ProviderFactory",
);
+
+export const MediaPlayerFactory = Context.GenericTag(
+ "@echo/core-types/MediaPlayerFactory",
+);
diff --git a/packages/core/types/src/services/player.ts b/packages/core/types/src/services/player.ts
index 52a8002..5ec3412 100644
--- a/packages/core/types/src/services/player.ts
+++ b/packages/core/types/src/services/player.ts
@@ -1,15 +1,30 @@
-import { Context, Effect, Stream } from "effect";
-import type { Album, PlayerState } from "../model";
+import { Effect, Stream } from "effect";
+import type { Album, PlayerState, ProviderId } from "../model";
+import type { PlayNotFoundError } from "./media-provider";
+
+/**
+ * Error that indicates that the provider of a certain track is not ready
+ * to stream yet.
+ */
+export class ProviderNotReady extends Error {
+ static readonly _tag = "ProviderNotReady";
+
+ constructor(providerId: ProviderId) {
+ super(`Provider with ID ${providerId} is not ready to stream yet.`);
+ }
+}
/**
* Service that provides a way to interact with the player and its state.
*/
-export type Player = {
+export type IPlayer = {
/**
* Plays the given album, detecting the source from each track and delegating
* the playback to the appropriate media provider.
*/
- readonly playAlbum: (album: Album) => Effect.Effect;
+ readonly playAlbum: (
+ album: Album,
+ ) => Effect.Effect;
/**
* Returns a stream that emits the current player state and any subsequent
@@ -21,4 +36,7 @@ export type Player = {
/**
* Tag to identify the player service.
*/
-export const Player = Context.GenericTag("@echo/core-types/Player");
+export class Player extends Effect.Tag("@echo/core-types/Player")<
+ Player,
+ IPlayer
+>() {}
diff --git a/packages/infrastructure/html-audio-media-player/index.ts b/packages/infrastructure/html-audio-media-player/index.ts
new file mode 100644
index 0000000..8c56748
--- /dev/null
+++ b/packages/infrastructure/html-audio-media-player/index.ts
@@ -0,0 +1,52 @@
+import { MediaPlayerFactory, PlayNotFoundError } from "@echo/core-types";
+import { Effect, Layer } from "effect";
+
+const make = Effect.succeed(
+ MediaPlayerFactory.of({
+ createMediaPlayer: () =>
+ Effect.gen(function* () {
+ let audioElement = document.querySelector("audio");
+ if (!audioElement) {
+ yield* Effect.log(
+ "Creating invisible audio element for media playback.",
+ );
+
+ audioElement = document.createElement("audio");
+ audioElement.style.display = "none";
+ document.body.appendChild(audioElement);
+
+ yield* Effect.log("Audio element appended to the DOM.");
+ }
+
+ return {
+ playFile: (trackUrl) =>
+ Effect.gen(function* () {
+ yield* Effect.log(`Requesting to play ${trackUrl.href}`);
+ audioElement.src = trackUrl.href;
+ yield* Effect.tryPromise({
+ try: () => audioElement.play(),
+ catch: () => new PlayNotFoundError(),
+ });
+ }),
+ // observe: Effect.succeed(
+ // Stream.async((emit) => {
+ // // TODO: Keep track in the state? If something, it can be done via a ref.
+ // audio.onplay = () => emit.single({ _tag: "playing" });
+ // audio.onpause = () => emit.single({ _tag: "paused" });
+ // audio.onended = () => emit.single({ _tag: "idle" });
+ // }),
+ // ),
+ };
+ }),
+ }),
+);
+
+/**
+ * Implementation of the media player service using the HTML5 audio element.
+ * Adds an invisible HTML5 audio element to the DOM upon creating the layer and
+ * uses that element to play audio files on demand.
+ */
+export const HtmlAudioMediaPlayerFactoryLive = Layer.effect(
+ MediaPlayerFactory,
+ make,
+);
diff --git a/packages/infrastructure/html-audio-media-player/package.json b/packages/infrastructure/html-audio-media-player/package.json
new file mode 100644
index 0000000..25aea3b
--- /dev/null
+++ b/packages/infrastructure/html-audio-media-player/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "@echo/infrastructure-html-audio-media-player",
+ "private": true,
+ "version": "1.0.0",
+ "description": "Contains the HtmlAudioMediaPlayer related infrastructure",
+ "main": "index.js",
+ "scripts": {
+ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@echo/core-types": "^1.0.0",
+ "effect": "^3.2.8"
+ }
+}
\ No newline at end of file
diff --git a/packages/infrastructure/html-audio-media-player/tsconfig.json b/packages/infrastructure/html-audio-media-player/tsconfig.json
new file mode 100644
index 0000000..6953ff5
--- /dev/null
+++ b/packages/infrastructure/html-audio-media-player/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "extends": "../../../tsconfig.json",
+ "include": [
+ "src",
+ "index.ts"
+ ]
+}
\ No newline at end of file
diff --git a/packages/infrastructure/onedrive-provider/index.ts b/packages/infrastructure/onedrive-provider/index.ts
index e8aacf9..4d430d3 100644
--- a/packages/infrastructure/onedrive-provider/index.ts
+++ b/packages/infrastructure/onedrive-provider/index.ts
@@ -3,9 +3,9 @@ import { MsalAuthenticationLive } from "./src/msal-authentication";
import { OneDriveProviderLive } from "./src/onedrive-provider";
/**
- * Layer that can be used to construct the OneDrive provider given the configuration
- * of the app. The returned provider can then be used to retrieve the authentication
- * provider (in this case, MSAL) or create a new instance of the OneDrive provider
+ * Layer that can be used to construct the OneDrive provider. The returned
+ * provider can then be used to retrieve the authentication provider
+ * (in this case, MSAL) or create a new instance of the OneDrive provider
* given the authentication info returned by MSAL.
*/
export const OneDriveProviderFactoryLive = OneDriveProviderLive.pipe(
diff --git a/packages/infrastructure/onedrive-provider/src/apis/file-url-by-id.graph-api.ts b/packages/infrastructure/onedrive-provider/src/apis/file-url-by-id.graph-api.ts
new file mode 100644
index 0000000..de8162a
--- /dev/null
+++ b/packages/infrastructure/onedrive-provider/src/apis/file-url-by-id.graph-api.ts
@@ -0,0 +1,28 @@
+import { Client } from "@microsoft/microsoft-graph-client";
+import { FileBasedProviderError, FileId } from "@echo/core-types";
+import { Effect } from "effect";
+import type { DriveItem } from "@microsoft/microsoft-graph-types";
+
+type FolderItem = Pick & {
+ "@microsoft.graph.downloadUrl": string;
+};
+
+/**
+ * API that can be used to retrieve the URL to download a file by its ID.
+ */
+export type FileUrlByIdApi = (
+ fileId: FileId,
+) => Effect.Effect;
+
+/**
+ * Creates a function that retrieves the download URL of a given file by its ID.
+ */
+export const createFileUrlById =
+ (client: Client) =>
+ (fileId: FileId): Effect.Effect =>
+ Effect.tryPromise({
+ try: () => client.api(`/me/drive/items/${fileId}`).get(),
+ catch: () => FileBasedProviderError.NotFound,
+ }).pipe(
+ Effect.map((item) => new URL(item["@microsoft.graph.downloadUrl"])),
+ );
diff --git a/packages/infrastructure/onedrive-provider/src/apis/list-folder.graph-api.ts b/packages/infrastructure/onedrive-provider/src/apis/list-folder.graph-api.ts
index a77f4d4..3c9e608 100644
--- a/packages/infrastructure/onedrive-provider/src/apis/list-folder.graph-api.ts
+++ b/packages/infrastructure/onedrive-provider/src/apis/list-folder.graph-api.ts
@@ -4,6 +4,8 @@ import {
FileBasedProviderError,
type FolderMetadata,
type FolderContentMetadata,
+ FolderId,
+ FileId,
} from "@echo/core-types";
import type { CollectionResult } from "./types.ts";
import { Effect } from "effect";
@@ -37,13 +39,13 @@ export const createListFolder =
return item.folder
? {
_tag: "folder" as const,
- id: item.id,
+ id: FolderId(item.id),
name: item.name,
}
: item.file && item["@microsoft.graph.downloadUrl"]
? {
_tag: "file" as const,
- id: item.id,
+ id: FileId(item.id),
name: item.name,
byteSize: item.size ?? 0,
mimeType: item.file.mimeType ?? undefined,
diff --git a/packages/infrastructure/onedrive-provider/src/apis/list-root.graph-api.ts b/packages/infrastructure/onedrive-provider/src/apis/list-root.graph-api.ts
index 86c6bd7..ec0320a 100644
--- a/packages/infrastructure/onedrive-provider/src/apis/list-root.graph-api.ts
+++ b/packages/infrastructure/onedrive-provider/src/apis/list-root.graph-api.ts
@@ -1,6 +1,10 @@
import { Client } from "@microsoft/microsoft-graph-client";
import type { DriveItem } from "@microsoft/microsoft-graph-types";
-import { FileBasedProviderError, type FolderMetadata } from "@echo/core-types";
+import {
+ FileBasedProviderError,
+ FolderId,
+ type FolderMetadata,
+} from "@echo/core-types";
import type { CollectionResult } from "./types.ts";
import { Effect } from "effect";
@@ -30,7 +34,7 @@ export const createListRoot = (
return item.folder
? {
_tag: "folder" as const,
- id: item.id,
+ id: FolderId(item.id),
name: item.name,
}
: [];
diff --git a/packages/infrastructure/onedrive-provider/src/onedrive-provider.ts b/packages/infrastructure/onedrive-provider/src/onedrive-provider.ts
index f24217c..7ecb900 100644
--- a/packages/infrastructure/onedrive-provider/src/onedrive-provider.ts
+++ b/packages/infrastructure/onedrive-provider/src/onedrive-provider.ts
@@ -4,6 +4,7 @@ import { MsalAuthentication } from "./msal-authentication";
import { Client, type ClientOptions } from "@microsoft/microsoft-graph-client";
import { createListRoot } from "./apis/list-root.graph-api";
import { createListFolder } from "./apis/list-folder.graph-api";
+import { createFileUrlById } from "./apis/file-url-by-id.graph-api";
/**
* Implementation of the OneDrive provider that uses MSAL for authentication and
@@ -17,7 +18,7 @@ export const OneDriveProviderLive = Layer.effect(
Effect.gen(function* () {
const msalAuth = yield* MsalAuthentication;
- return {
+ return MediaProviderFactory.of({
authenticationProvider: Effect.succeed(msalAuth),
createMediaProvider: (authInfo) => {
const options: ClientOptions = {
@@ -31,8 +32,9 @@ export const OneDriveProviderLive = Layer.effect(
return {
listRoot: createListRoot(client),
listFolder: createListFolder(client),
+ fileUrlById: createFileUrlById(client),
};
},
- };
+ });
}),
);
diff --git a/packages/services/active-media-provider-cache/index.ts b/packages/services/active-media-provider-cache/index.ts
new file mode 100644
index 0000000..1b4ee39
--- /dev/null
+++ b/packages/services/active-media-provider-cache/index.ts
@@ -0,0 +1,65 @@
+import {
+ ActiveMediaProviderCache,
+ MediaProviderMainThreadBroadcastChannel,
+ type MediaProviderById,
+} from "@echo/core-types";
+import { Effect, Layer, Option, Ref } from "effect";
+
+const makeActiveMediaProviderCache = Effect.gen(function* () {
+ const providerByIdRef = yield* Ref.make(new Map());
+ const broadcastChannel = yield* MediaProviderMainThreadBroadcastChannel;
+
+ // Listen to status updates of the media providers and remove them from the
+ // cache once they become inactive.
+ yield* broadcastChannel.registerResolver("reportStatus", (status) => {
+ if (status.status._tag !== "stopped") {
+ return Effect.void;
+ }
+
+ return Ref.update(providerByIdRef, (current) => {
+ const updatedMap = new Map(current);
+ updatedMap.delete(status.metadata.id);
+ return updatedMap;
+ }).pipe(
+ Effect.andThen(
+ Effect.log(
+ `Removed provider ${status.metadata.id} from cache because it was stopped`,
+ ),
+ ),
+ );
+ });
+
+ return ActiveMediaProviderCache.of({
+ add: (metadata, provider, player) =>
+ Ref.update(providerByIdRef, (current) => {
+ const updatedMap = new Map(current);
+ updatedMap.set(metadata.id, {
+ metadata,
+ provider,
+ player,
+ });
+ return updatedMap;
+ }).pipe(
+ Effect.andThen(Effect.log(`Added provider ${metadata.id} to cache`)),
+ ),
+ get: (providerId) =>
+ Ref.get(providerByIdRef).pipe(
+ Effect.map((providerMap) => {
+ const cachedProvider = providerMap.get(providerId);
+ if (!cachedProvider) {
+ return Option.none();
+ }
+
+ return Option.some({
+ provider: cachedProvider.provider,
+ player: cachedProvider.player,
+ });
+ }),
+ ),
+ });
+});
+
+export const ActiveMediaProviderCacheLive = Layer.effect(
+ ActiveMediaProviderCache,
+ makeActiveMediaProviderCache,
+);
diff --git a/packages/services/active-media-provider-cache/package.json b/packages/services/active-media-provider-cache/package.json
new file mode 100644
index 0000000..b957af7
--- /dev/null
+++ b/packages/services/active-media-provider-cache/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "@echo/services-active-media-provider-cache",
+ "private": true,
+ "version": "1.0.0",
+ "description": "Contains the implementation for the ActiveMediaProviderCache service",
+ "main": "index.js",
+ "scripts": {
+ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@echo/core-types": "^1.0.0",
+ "effect": "^3.2.8"
+ }
+}
\ No newline at end of file
diff --git a/packages/services/active-media-provider-cache/tsconfig.json b/packages/services/active-media-provider-cache/tsconfig.json
new file mode 100644
index 0000000..6953ff5
--- /dev/null
+++ b/packages/services/active-media-provider-cache/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "extends": "../../../tsconfig.json",
+ "include": [
+ "src",
+ "index.ts"
+ ]
+}
\ No newline at end of file
diff --git a/packages/services/bootstrap-services/index.ts b/packages/services/bootstrap-services/index.ts
new file mode 100644
index 0000000..47e5f0e
--- /dev/null
+++ b/packages/services/bootstrap-services/index.ts
@@ -0,0 +1 @@
+export * from "./src/app-live.layer";
diff --git a/packages/services/bootstrap-services/package.json b/packages/services/bootstrap-services/package.json
new file mode 100644
index 0000000..5fa0e1e
--- /dev/null
+++ b/packages/services/bootstrap-services/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "@echo/services-bootstrap-services",
+ "private": true,
+ "version": "1.0.0",
+ "description": "Exposes a shared service layer that includes services that should only be initialized once",
+ "main": "index.js",
+ "scripts": {
+ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@echo/core-types": "^1.0.0",
+ "@echo/services-bootstrap": "^1.0.0",
+ "@echo/services-active-media-provider-cache": "^1.0.0",
+ "@echo/services-media-provider-status": "^1.0.0",
+ "effect": "^3.2.8"
+ }
+}
\ No newline at end of file
diff --git a/packages/services/bootstrap-services/src/app-live.layer.ts b/packages/services/bootstrap-services/src/app-live.layer.ts
new file mode 100644
index 0000000..dfd6e9d
--- /dev/null
+++ b/packages/services/bootstrap-services/src/app-live.layer.ts
@@ -0,0 +1,14 @@
+import { ActiveMediaProviderCacheLive } from "@echo/services-active-media-provider-cache";
+import { MediaProviderStatusLive } from "@echo/services-media-provider-status";
+import { MainLive } from "@echo/services-bootstrap";
+import { Layer } from "effect";
+
+/**
+ * Main layer to be used in the app thread that includes all infrastructure
+ * dependencies of the main layer and a set of core services that should only
+ * be initialized once.
+ */
+export const AppLive = Layer.mergeAll(
+ ActiveMediaProviderCacheLive,
+ MediaProviderStatusLive,
+).pipe(Layer.provideMerge(MainLive));
diff --git a/packages/services/bootstrap-services/src/vite-env.d.ts b/packages/services/bootstrap-services/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/packages/services/bootstrap-services/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/packages/services/bootstrap-services/tsconfig.json b/packages/services/bootstrap-services/tsconfig.json
new file mode 100644
index 0000000..6953ff5
--- /dev/null
+++ b/packages/services/bootstrap-services/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "extends": "../../../tsconfig.json",
+ "include": [
+ "src",
+ "index.ts"
+ ]
+}
\ No newline at end of file
diff --git a/packages/services/bootstrap/package.json b/packages/services/bootstrap/package.json
index eaadfa7..8880e04 100644
--- a/packages/services/bootstrap/package.json
+++ b/packages/services/bootstrap/package.json
@@ -14,6 +14,7 @@
"@echo/infrastructure-browser-crypto": "^1.0.0",
"@echo/infrastructure-dexie-database": "^1.0.0",
"@echo/infrastructure-mmb-metadata-provider": "^1.0.0",
+ "@echo/infrastructure-html-audio-media-player": "^1.0.0",
"@echo/infrastructure-onedrive-provider": "^1.0.0",
"@echo/workers-media-provider": "^1.0.0",
"effect": "^3.5.8"
diff --git a/packages/services/bootstrap/src/layers.ts b/packages/services/bootstrap/src/layers.ts
index 1ea0ed1..1c6c567 100644
--- a/packages/services/bootstrap/src/layers.ts
+++ b/packages/services/bootstrap/src/layers.ts
@@ -8,6 +8,7 @@ import { DexieDatabaseLive } from "@echo/infrastructure-dexie-database";
import { MmbMetadataProviderLive } from "@echo/infrastructure-mmb-metadata-provider";
import { LazyLoadedProviderLive } from "./loaders/provider";
import { AppConfigLive } from "./app-config";
+import { LazyLoadedMediaPlayerLive } from "./loaders/player";
/**
* Exports a layer that can provide all dependencies that are needed in the
@@ -17,6 +18,7 @@ export const MainLive = MediaProviderMainThreadBroadcastChannelLive.pipe(
Layer.provideMerge(MediaProviderWorkerBroadcastChannelLive),
Layer.provideMerge(BrowserCryptoLive),
Layer.provideMerge(LazyLoadedProviderLive),
+ Layer.provideMerge(LazyLoadedMediaPlayerLive),
Layer.provideMerge(DexieDatabaseLive),
Layer.provideMerge(AppConfigLive),
);
diff --git a/packages/services/bootstrap/src/loaders/index.ts b/packages/services/bootstrap/src/loaders/index.ts
index d563d05..8bb6e41 100644
--- a/packages/services/bootstrap/src/loaders/index.ts
+++ b/packages/services/bootstrap/src/loaders/index.ts
@@ -1,2 +1,3 @@
export { LazyLoadedProvider } from "./provider";
+export { LazyLoadedMediaPlayer } from "./player";
export * from "./workers";
diff --git a/packages/services/bootstrap/src/loaders/player.ts b/packages/services/bootstrap/src/loaders/player.ts
new file mode 100644
index 0000000..946f9ce
--- /dev/null
+++ b/packages/services/bootstrap/src/loaders/player.ts
@@ -0,0 +1,70 @@
+import { Effect, Layer } from "effect";
+import {
+ type ProviderMetadata,
+ MediaPlayerFactory,
+ ProviderType,
+} from "@echo/core-types";
+
+/**
+ * Service that can lazily load a media player.
+ */
+export type ILazyLoadedMediaPlayer = {
+ readonly load: (metadata: ProviderMetadata) => Effect.Effect<{
+ createMediaPlayer: MediaPlayerFactory["createMediaPlayer"];
+ }>;
+};
+
+/**
+ * Tag to identify the lazy loaded player service.
+ */
+export class LazyLoadedMediaPlayer extends Effect.Tag(
+ "@echo/services-bootstrap/LazyLoadedMediaPlayer",
+)() {}
+
+/**
+ * Lazy loads a media player based on the given metadata.
+ */
+const lazyLoadFromMetadata = (
+ metadata: ProviderMetadata,
+): Effect.Effect> => {
+ if (metadata.type === ProviderType.FileBased) {
+ return Effect.promise(async () => {
+ const { HtmlAudioMediaPlayerFactoryLive } = await import(
+ "@echo/infrastructure-html-audio-media-player"
+ );
+ return HtmlAudioMediaPlayerFactoryLive;
+ });
+ }
+
+ throw new Error(
+ `No package available for media player with type: ${metadata.type}`,
+ );
+};
+
+const createLazyLoadedMediaPlayer = (metadata: ProviderMetadata) =>
+ Effect.gen(function* () {
+ const providerFactory = yield* MediaPlayerFactory;
+
+ return {
+ metadata,
+ createMediaPlayer: providerFactory.createMediaPlayer,
+ };
+ });
+
+/**
+ * A layer that can lazily load a media player based on the metadata provided.
+ */
+export const LazyLoadedMediaPlayerLive = Layer.succeed(
+ LazyLoadedMediaPlayer,
+ LazyLoadedMediaPlayer.of({
+ load: (metadata) =>
+ Effect.gen(function* () {
+ const mediaPlayerFactory = yield* lazyLoadFromMetadata(metadata);
+
+ return yield* Effect.provide(
+ createLazyLoadedMediaPlayer(metadata),
+ mediaPlayerFactory,
+ );
+ }),
+ }),
+);
diff --git a/packages/services/bootstrap/src/loaders/provider.ts b/packages/services/bootstrap/src/loaders/provider.ts
index 33f62bb..ae76a7b 100644
--- a/packages/services/bootstrap/src/loaders/provider.ts
+++ b/packages/services/bootstrap/src/loaders/provider.ts
@@ -1,11 +1,11 @@
import { Effect, Layer } from "effect";
import {
FileBasedProviderId,
- AppConfig,
type ProviderMetadata,
MediaProviderFactory,
type Authentication,
} from "@echo/core-types";
+import { AppConfigLive } from "../app-config";
/**
* Service that can lazily load a media provider.
@@ -30,13 +30,13 @@ export class LazyLoadedProvider extends Effect.Tag(
*/
const lazyLoadFromMetadata = (
metadata: ProviderMetadata,
-): Effect.Effect> => {
+): Effect.Effect> => {
if (metadata.id === FileBasedProviderId.OneDrive) {
return Effect.promise(async () => {
const { OneDriveProviderFactoryLive } = await import(
"@echo/infrastructure-onedrive-provider"
);
- return OneDriveProviderFactoryLive;
+ return OneDriveProviderFactoryLive.pipe(Layer.provide(AppConfigLive));
});
}
@@ -60,21 +60,17 @@ const createLazyLoadedProvider = (metadata: ProviderMetadata) =>
/**
* A layer that can lazily load a media provider based on the metadata provided.
*/
-export const LazyLoadedProviderLive = Layer.effect(
+export const LazyLoadedProviderLive = Layer.succeed(
LazyLoadedProvider,
- Effect.gen(function* () {
- const appConfig = yield* AppConfig;
-
- return LazyLoadedProvider.of({
- load: (metadata) =>
- Effect.gen(function* () {
- const providerFactory = yield* lazyLoadFromMetadata(metadata);
+ LazyLoadedProvider.of({
+ load: (metadata) =>
+ Effect.gen(function* () {
+ const providerFactory = yield* lazyLoadFromMetadata(metadata);
- return yield* Effect.provide(
- createLazyLoadedProvider(metadata),
- providerFactory,
- );
- }).pipe(Effect.provideService(AppConfig, appConfig)),
- });
+ return yield* Effect.provide(
+ createLazyLoadedProvider(metadata),
+ providerFactory,
+ );
+ }),
}),
);
diff --git a/packages/services/player/src/player.ts b/packages/services/player/src/player.ts
index 5e338ba..cbd3390 100644
--- a/packages/services/player/src/player.ts
+++ b/packages/services/player/src/player.ts
@@ -1,39 +1,74 @@
-import { Player, type PlayerState } from "@echo/core-types";
+import {
+ ActiveMediaProviderCache,
+ Player,
+ PlayNotFoundError,
+ ProviderNotReady,
+ type PlayerState,
+} from "@echo/core-types";
import { Effect, Layer, Option, PubSub, Ref, Stream } from "effect";
import { PlayerStateRef } from "./state";
-const PlayerLiveWithState = Layer.effect(
- Player,
- Effect.gen(function* () {
- const state = yield* PlayerStateRef;
- // TODO: Remove all this and switch to subscription ref.
- const statePubSub = yield* PubSub.dropping({
- capacity: 1,
- replay: 1,
- });
-
- // Yield initial state to subscribers.
- yield* statePubSub.publish(yield* state.get);
-
- return Player.of({
- playAlbum: (_album) =>
- Effect.gen(function* () {
- yield* Ref.update(state, (current) => ({
- ...current,
- status: "playing" as const,
- }));
-
- yield* statePubSub.publish(yield* state.get);
- }),
- observe: Effect.sync(() =>
- Stream.fromPubSub(statePubSub).pipe(
- Stream.tap((state) => Effect.logInfo("Player state changed", state)),
- Stream.ensuring(Effect.logInfo("Player state stream closed")),
- ),
+const makePlayer = Effect.gen(function* () {
+ const state = yield* PlayerStateRef;
+ const providerCache = yield* ActiveMediaProviderCache;
+
+ // TODO: Remove all this and switch to subscription ref.
+ const statePubSub = yield* PubSub.dropping({
+ capacity: 1,
+ replay: 1,
+ });
+
+ // Yield initial state to subscribers.
+ yield* statePubSub.publish(yield* state.get);
+
+ return Player.of({
+ playAlbum: (album) =>
+ Effect.gen(function* () {
+ // TODO: Make work with more than just the first track.
+ const firstTrack = album.tracks[0];
+ if (!firstTrack) {
+ return;
+ }
+
+ const streamingProvider = firstTrack.resource.provider;
+ const provider = yield* providerCache.get(streamingProvider);
+ if (Option.isNone(provider)) {
+ yield* Effect.logError(
+ `Attempted to play track ${firstTrack.id}, which is registered with the provider ${streamingProvider}, but the provider is not active.`,
+ );
+ return yield* Effect.fail(new ProviderNotReady(streamingProvider));
+ }
+
+ switch (firstTrack.resource.type) {
+ case "file": {
+ const file = yield* provider.value.provider
+ .fileUrlById(firstTrack.resource.fileId)
+ .pipe(Effect.mapError(() => new PlayNotFoundError()));
+ yield* provider.value.player.playFile(file);
+ break;
+ }
+ default:
+ // TODO: Remove once API streaming is implemented.
+ return Effect.void;
+ }
+
+ yield* Ref.update(state, (current) => ({
+ ...current,
+ status: "playing" as const,
+ }));
+
+ yield* statePubSub.publish(yield* state.get);
+ }),
+ observe: Effect.sync(() =>
+ Stream.fromPubSub(statePubSub).pipe(
+ Stream.tap((state) => Effect.logInfo("Player state changed", state)),
+ Stream.ensuring(Effect.logInfo("Player state stream closed")),
),
- });
- }),
-);
+ ),
+ });
+});
+
+const PlayerLiveWithState = Layer.effect(Player, makePlayer);
const PlayerStateLive = Layer.effect(
PlayerStateRef,
diff --git a/packages/workers/media-provider/src/sync/file-based-sync.ts b/packages/workers/media-provider/src/sync/file-based-sync.ts
index d5b95ca..d8c16c1 100644
--- a/packages/workers/media-provider/src/sync/file-based-sync.ts
+++ b/packages/workers/media-provider/src/sync/file-based-sync.ts
@@ -385,7 +385,7 @@ const createTrack = (
resource: {
type: "file",
provider: FileBasedProviderId.OneDrive /* TODO: Take from metadata. */,
- uri: file.downloadUrl,
+ fileId: file.id,
},
};
});