From 30809176112863c6a4ad3b25fcd259562e221664 Mon Sep 17 00:00:00 2001 From: sleepyfran Date: Mon, 9 Sep 2024 10:54:58 +0200 Subject: [PATCH] Naive implementation to player state sync --- packages/core/types/src/services/index.ts | 1 - .../core/types/src/services/media-player.ts | 52 --------- .../core/types/src/services/media-provider.ts | 30 ++++- .../html-audio-media-player/index.ts | 29 +++-- packages/services/player/src/player.ts | 105 +++++++++++++++++- packages/services/player/src/state.ts | 19 +++- 6 files changed, 163 insertions(+), 73 deletions(-) delete mode 100644 packages/core/types/src/services/media-player.ts diff --git a/packages/core/types/src/services/index.ts b/packages/core/types/src/services/index.ts index 5bce642..98aa0a0 100644 --- a/packages/core/types/src/services/index.ts +++ b/packages/core/types/src/services/index.ts @@ -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"; diff --git a/packages/core/types/src/services/media-player.ts b/packages/core/types/src/services/media-player.ts deleted file mode 100644 index ce7cfb8..0000000 --- a/packages/core/types/src/services/media-player.ts +++ /dev/null @@ -1,52 +0,0 @@ -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/media-provider.ts b/packages/core/types/src/services/media-provider.ts index 2ad83cf..ebffa5e 100644 --- a/packages/core/types/src/services/media-provider.ts +++ b/packages/core/types/src/services/media-provider.ts @@ -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", @@ -60,10 +60,36 @@ export type FileBasedMediaPlayer = { readonly playFile: (file: URL) => Effect; }; +/** + * Wrapper around a string to represent a media player ID. + */ +export type MediaPlayerId = string & Brand.Brand<"MediaPlayerId">; +export const MediaPlayerId = Brand.nominal(); + +/** + * 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; + + /** + * Disposes of the media player. + */ + readonly dispose: Effect; +}; /** * A factory that can create a new instance of the media player. diff --git a/packages/infrastructure/html-audio-media-player/index.ts b/packages/infrastructure/html-audio-media-player/index.ts index 8c56748..877dea4 100644 --- a/packages/infrastructure/html-audio-media-player/index.ts +++ b/packages/infrastructure/html-audio-media-player/index.ts @@ -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({ @@ -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}`); @@ -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(); + } + }), }; }), }), diff --git a/packages/services/player/src/player.ts b/packages/services/player/src/player.ts index 70f39fc..bade5d5 100644 --- a/packages/services/player/src/player.ts +++ b/packages/services/player/src/player.ts @@ -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({ @@ -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, @@ -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* () { @@ -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), ); diff --git a/packages/services/player/src/state.ts b/packages/services/player/src/state.ts index 6ebd0c9..915f095 100644 --- a/packages/services/player/src/state.ts +++ b/packages/services/player/src/state.ts @@ -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; /** * Tag that can provide a ref to the current state of the player. */ export class PlayerStateRef extends Context.Tag( "@echo/services-player/PlayerStateRef", -)>() {} +)() {} + +export type ICurrentlyActivePlayerRef = SubscriptionRef.SubscriptionRef< + Option.Option +>; + +/** + * Tag that can provide a ref to the currently active media player, if any. + */ +export class CurrentlyActivePlayerRef extends Context.Tag( + "@echo/services-player/CurrentlyActivePlayerRef", +)() {}