Skip to content

Commit

Permalink
Naive implementation to player state sync
Browse files Browse the repository at this point in the history
  • Loading branch information
sleepyfran committed Sep 9, 2024
1 parent 520fafe commit 3080917
Show file tree
Hide file tree
Showing 6 changed files with 163 additions and 73 deletions.
1 change: 0 additions & 1 deletion packages/core/types/src/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ export * from "./database";
export * from "./library";
export * from "./local-storage";
export * from "./metadata-provider";
export * from "./media-player";
export * from "./media-provider";
export * from "./player";
export * from "./provider-status";
Expand Down
52 changes: 0 additions & 52 deletions packages/core/types/src/services/media-player.ts

This file was deleted.

30 changes: 28 additions & 2 deletions packages/core/types/src/services/media-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type {
FileId,
} from "../model";
import type { Authentication } from "./authentication";
import { Context } from "effect";
import { Brand, Context, Stream } from "effect";

export enum FileBasedProviderError {
NotFound = "not-found",
Expand Down Expand Up @@ -60,10 +60,36 @@ export type FileBasedMediaPlayer = {
readonly playFile: (file: URL) => Effect<void, PlayNotFoundError>;
};

/**
* Wrapper around a string to represent a media player ID.
*/
export type MediaPlayerId = string & Brand.Brand<"MediaPlayerId">;
export const MediaPlayerId = Brand.nominal<MediaPlayerId>();

/**
* Events that can be emitted by a media player.
*/
export type MediaPlayerEvent = "trackPlaying" | "trackPaused" | "trackEnded";

/**
* Defines all types of media players that are available in the app.
*/
export type MediaPlayer = FileBasedMediaPlayer;
export type MediaPlayer = FileBasedMediaPlayer & {
/**
* The ID of the media player.
*/
readonly id: MediaPlayerId;

/**
* Returns a stream that emits events from the media player.
*/
readonly observe: Stream.Stream<MediaPlayerEvent>;

/**
* Disposes of the media player.
*/
readonly dispose: Effect<void>;
};

/**
* A factory that can create a new instance of the media player.
Expand Down
29 changes: 19 additions & 10 deletions packages/infrastructure/html-audio-media-player/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { MediaPlayerFactory, PlayNotFoundError } from "@echo/core-types";
import { Effect, Layer } from "effect";
import {
MediaPlayerFactory,
MediaPlayerId,
PlayNotFoundError,
} from "@echo/core-types";
import { Effect, Layer, Stream } from "effect";

const make = Effect.succeed(
MediaPlayerFactory.of({
Expand All @@ -19,6 +23,7 @@ const make = Effect.succeed(
}

return {
id: MediaPlayerId("html5-audio"),
playFile: (trackUrl) =>
Effect.gen(function* () {
yield* Effect.log(`Requesting to play ${trackUrl.href}`);
Expand All @@ -28,14 +33,18 @@ const make = Effect.succeed(
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" });
// }),
// ),
observe: Stream.async((emit) => {
// TODO: Keep track in the state? If something, it can be done via a ref.
audioElement.onplay = () => emit.single("trackPlaying");
audioElement.onpause = () => emit.single("trackPaused");
audioElement.onended = () => emit.single("trackEnded");
}),
dispose: Effect.sync(() => {
const audioElement = document.querySelector("audio");
if (audioElement) {
audioElement.remove();
}
}),
};
}),
}),
Expand Down
105 changes: 100 additions & 5 deletions packages/services/player/src/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,25 @@ import {
type PlayerState,
type Track,
} from "@echo/core-types";
import { Effect, Layer, Option, Ref, SubscriptionRef } from "effect";
import { PlayerStateRef } from "./state";
import {
Effect,
Layer,
Match,
Option,
Ref,
Stream,
SubscriptionRef,
} from "effect";
import {
CurrentlyActivePlayerRef,
PlayerStateRef,
type ICurrentlyActivePlayerRef,
type IPlayerStateRef,
} from "./state";

const makePlayer = Effect.gen(function* () {
const state = yield* PlayerStateRef;
const activeMediaPlayer = yield* CurrentlyActivePlayerRef;
const providerCache = yield* ActiveMediaProviderCache;

return Player.of({
Expand All @@ -27,11 +41,12 @@ const makePlayer = Effect.gen(function* () {
return;
}

const providerDependencies = yield* resolveDependenciesForTrack(
const { provider, player } = yield* resolveDependenciesForTrack(
providerCache,
track,
);
yield* playTrack(providerDependencies, track);
yield* syncPlayerState(player, activeMediaPlayer, state);
yield* playTrack(provider, player, track);
yield* Ref.update(state, toPlayingState(track, restOfTracks));
}),
observe: state,
Expand Down Expand Up @@ -59,12 +74,86 @@ const resolveDependenciesForTrack = (
),
);

/**
* Given a media player, an active media player reference and a player state reference,
* synchronizes the player state with the media player's events.
*/
const syncPlayerState = (
mediaPlayer: MediaPlayer,
activeMediaPlayer: ICurrentlyActivePlayerRef,
playerState: IPlayerStateRef,
) =>
Effect.gen(function* () {
yield* overrideActivePlayer(mediaPlayer, activeMediaPlayer);

yield* Effect.log(`Starting to observe player ${mediaPlayer.id}.`);

yield* Effect.forkDaemon(
mediaPlayer.observe.pipe(
Stream.tap((event) =>
Match.value(event).pipe(
Match.when("trackPlaying", () =>
Ref.update(playerState, (currentState) => ({
...currentState,
status: "playing" as const,
})),
),
Match.when("trackEnded", () =>
Ref.update(playerState, (currentState) => ({
...currentState,
status: "stopped" as const,
})),
),
Match.when("trackPaused", () =>
Ref.update(playerState, (currentState) => ({
...currentState,
status: "paused" as const,
})),
),
Match.exhaustive,
),
),
Stream.runDrain,
),
);
});

/**
* Given a media player and an active media player reference, overrides the active
* player with the given one, disposing of the previous one if any. If the given
* player is already active, skips the retrieval.
*/
const overrideActivePlayer = (
mediaPlayer: MediaPlayer,
activeMediaPlayer: ICurrentlyActivePlayerRef,
) =>
Effect.gen(function* () {
const currentMediaPlayer = yield* activeMediaPlayer.get;
if (Option.isSome(currentMediaPlayer)) {
if (currentMediaPlayer.value.id === mediaPlayer.id) {
yield* Effect.log(
`Player ${mediaPlayer.id} is already active, skipping retrieval.`,
);
return;
}

yield* Effect.log(
`Disposing of the current player ${currentMediaPlayer.value.id}.`,
);
yield* currentMediaPlayer.value.dispose;
}

yield* Effect.log(`Setting player ${mediaPlayer.id} as active.`);
yield* Ref.set(activeMediaPlayer, Option.some(mediaPlayer));
});

/**
* Given a provider and a player, attempts to resolve the track's source
* based on its resource type and play it.
*/
const playTrack = (
{ provider, player }: { provider: MediaProvider; player: MediaPlayer },
provider: MediaProvider,
player: MediaPlayer,
track: Track,
) =>
Effect.gen(function* () {
Expand Down Expand Up @@ -117,6 +206,12 @@ const PlayerStateLive = Layer.effect(
} as PlayerState),
);

const CurrentlyActivePlayerLive = Layer.effect(
CurrentlyActivePlayerRef,
SubscriptionRef.make(Option.none()),
);

export const PlayerLive = PlayerLiveWithState.pipe(
Layer.provide(PlayerStateLive),
Layer.provide(CurrentlyActivePlayerLive),
);
19 changes: 16 additions & 3 deletions packages/services/player/src/state.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
import type { PlayerState } from "@echo/core-types";
import { Context, SubscriptionRef } from "effect";
import type { MediaPlayer, PlayerState } from "@echo/core-types";
import { Context, Option, SubscriptionRef } from "effect";

export type IPlayerStateRef = SubscriptionRef.SubscriptionRef<PlayerState>;

/**
* Tag that can provide a ref to the current state of the player.
*/
export class PlayerStateRef extends Context.Tag(
"@echo/services-player/PlayerStateRef",
)<PlayerStateRef, SubscriptionRef.SubscriptionRef<PlayerState>>() {}
)<PlayerStateRef, IPlayerStateRef>() {}

export type ICurrentlyActivePlayerRef = SubscriptionRef.SubscriptionRef<
Option.Option<MediaPlayer>
>;

/**
* Tag that can provide a ref to the currently active media player, if any.
*/
export class CurrentlyActivePlayerRef extends Context.Tag(
"@echo/services-player/CurrentlyActivePlayerRef",
)<CurrentlyActivePlayerRef, ICurrentlyActivePlayerRef>() {}

0 comments on commit 3080917

Please sign in to comment.