diff --git a/packages/components/albums/src/playable-album-cover.ts b/packages/components/albums/src/playable-album-cover.ts index 501f9b2..018013e 100644 --- a/packages/components/albums/src/playable-album-cover.ts +++ b/packages/components/albums/src/playable-album-cover.ts @@ -8,6 +8,7 @@ import "@echo/components-ui-atoms"; import { StreamConsumer } from "@echo/components-shared-controllers"; enum PlayStatus { + Loading, Playing, Paused, NotPlaying, @@ -149,7 +150,9 @@ export class PlayableAlbumCover extends LitElement { this._playStatus = playerStatus.status._tag === "Playing" ? PlayStatus.Playing - : PlayStatus.Paused; + : playerStatus.status._tag === "Loading" + ? PlayStatus.Loading + : PlayStatus.Paused; }, }); } @@ -169,10 +172,17 @@ export class PlayableAlbumCover extends LitElement { class="album-cover" /> `} - `; diff --git a/packages/components/icons/index.ts b/packages/components/icons/index.ts index d6c0bcd..5fc42f3 100644 --- a/packages/components/icons/index.ts +++ b/packages/components/icons/index.ts @@ -1,6 +1,7 @@ export * from "./src/chevron-icon"; export * from "./src/cross-icon"; export * from "./src/done-icon"; +export * from "./src/loader-icon"; export * from "./src/next-icon"; export * from "./src/play-icon"; export * from "./src/plus-icon"; diff --git a/packages/components/icons/src/loader-icon.ts b/packages/components/icons/src/loader-icon.ts new file mode 100644 index 0000000..b764d3e --- /dev/null +++ b/packages/components/icons/src/loader-icon.ts @@ -0,0 +1,45 @@ +import { LitElement, css, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +/** + * Icon that shows a loader. + */ +@customElement("loader-icon") +export class LoaderIcon extends LitElement { + @property({ type: Number }) size = 24; + @property({ type: Boolean }) animated = true; + + static styles = css` + .animated { + animation: spin 2s linear infinite; + } + + @keyframes spin { + to { + transform: rotate(360deg); + } + } + `; + + render() { + return html` + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "loader-icon": LoaderIcon; + } +} diff --git a/packages/components/player/src/player.ts b/packages/components/player/src/player.ts index ed505cc..e943c9b 100644 --- a/packages/components/player/src/player.ts +++ b/packages/components/player/src/player.ts @@ -11,6 +11,7 @@ import { Match, Option } from "effect"; import { EffectFn } from "@echo/components-shared-controllers/src/effect-fn.controller"; import { ButtonType } from "@echo/components-ui-atoms"; import "@echo/components-icons"; +import { classMap } from "lit/directives/class-map.js"; /** * Component that displays the current status of the player. @@ -28,6 +29,7 @@ export class EchoPlayer extends LitElement { it, like WebScrobbler to scrobble tracks. */ Match.value(playerState.status).pipe( + Match.tag("Loading", () => {}), Match.tag("Playing", ({ album, trackIndex }) => { const track = album.tracks[trackIndex]; this.dispatchEvent( @@ -74,6 +76,22 @@ export class EchoPlayer extends LitElement { border-radius: 5%; } + .pulsating { + animation: pulsate 1s ease-out infinite; + } + + @keyframes pulsate { + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.05); + } + 100% { + transform: scale(1); + } + } + .track-info { display: flex; flex-direction: column; @@ -105,7 +123,12 @@ export class EchoPlayer extends LitElement { Match.tag("Paused", (st) => this._renderActivePlayer(player, st)), Match.orElse( () => html` -
+
`, diff --git a/packages/core/types/src/model/player-state.ts b/packages/core/types/src/model/player-state.ts index 9228e13..866ba05 100644 --- a/packages/core/types/src/model/player-state.ts +++ b/packages/core/types/src/model/player-state.ts @@ -5,11 +5,13 @@ import type { Album } from "./album"; * Defines whether the player is playing, paused or stopped. */ export type PlayingStatus = + | { _tag: "Loading"; album: Album; trackIndex: number } | { _tag: "Playing"; album: Album; trackIndex: number } | { _tag: "Paused"; album: Album; trackIndex: number } | { _tag: "Stopped" }; -export const { Playing, Paused, Stopped } = Data.taggedEnum(); +export const { Loading, Playing, Paused, Stopped } = + Data.taggedEnum(); /** * Represents the current state of the player. diff --git a/packages/services/app-init/src/app-init.ts b/packages/services/app-init/src/app-init.ts index 1f7148e..3b8b9a4 100644 --- a/packages/services/app-init/src/app-init.ts +++ b/packages/services/app-init/src/app-init.ts @@ -146,7 +146,7 @@ const syncPageTitleWithPlayer = (player: IPlayer) => yield* playerState.changes.pipe( Stream.runForEach((state) => Match.value(state.status).pipe( - Match.tag("Stopped", () => + Match.tag("Stopped", "Loading", () => Effect.sync(() => (document.title = "Echo")), ), Match.tag("Playing", "Paused", ({ album, trackIndex }) => diff --git a/packages/services/player/src/player.ts b/packages/services/player/src/player.ts index 5f73a33..9967cd6 100644 --- a/packages/services/player/src/player.ts +++ b/packages/services/player/src/player.ts @@ -13,6 +13,7 @@ import { type PlayerState, type Track, type Album, + Loading, } from "@echo/core-types"; import { Array, @@ -243,6 +244,7 @@ const consumeCommandsInBackground = ( UpdateState({ updateFn: (state) => Match.value(state.status).pipe( + Match.tag("Loading", () => state), Match.tag("Playing", ({ album, trackIndex }) => isPlaying ? state @@ -313,6 +315,11 @@ const playTracks = ({ ); yield* commandQueue.offer(SyncPlayerState({ withMediaPlayer: player })); + yield* commandQueue.offer( + UpdateState({ + updateFn: toLoadingState(album, trackIndex), + }), + ); yield* playTrack(provider, player, requestedTrack.value); yield* commandQueue.offer( UpdateState({ @@ -467,6 +474,7 @@ const toPlayingState = ? [ ...currentState.previouslyPlayedAlbums, ...Match.value(currentState.status).pipe( + Match.tag("Loading", () => [album]), Match.tag("Playing", ({ album }) => [album]), Match.tag("Paused", ({ album }) => [album]), Match.tag("Stopped", () => []), @@ -478,6 +486,15 @@ const toPlayingState = } satisfies PlayerState; }; +/** + * Sets the player state to loading the given track from the given album. + */ +const toLoadingState = + (album: Album, trackIndex: number) => (currentState: PlayerState) => ({ + ...currentState, + status: Loading({ album, trackIndex }), + }); + const PlayerLiveWithState = Layer.scoped(Player, makePlayer); const PlayerStateLive = Layer.effect(