diff --git a/domain/player/package.json b/domain/player/package.json index 4b2a8b58..919f7ca3 100644 --- a/domain/player/package.json +++ b/domain/player/package.json @@ -23,6 +23,7 @@ "classnames": "^2.3.1", "fp-ts": "^2.11.9", "fp-ts-local-storage": "^1.0.3", + "io-ts": "^2.2.16", "lodash.throttle": "^4.1.1", "retry-ts": "^0.1.3", "zustand": "^3.7.1" diff --git a/domain/player/src/entities/Tracks.ts b/domain/player/src/entities/Tracks.ts index 3c5aba30..0b4dd233 100644 --- a/domain/player/src/entities/Tracks.ts +++ b/domain/player/src/entities/Tracks.ts @@ -8,13 +8,14 @@ import * as NumberFP from "fp-ts/number"; import { identity, pipe } from "fp-ts/function"; import * as Track from "./Track"; +import * as Volume from "./Volume"; type $Empty = { + volume: Volume.Volume; autoplayEnabled: boolean; }; -type $Loaded = { - autoplayEnabled: boolean; +type $Loaded = $Empty & { selected: string; alreadyPlayed: boolean; allTracks: ReadonlyNonEmptyArrayFP.ReadonlyNonEmptyArray<{ @@ -32,6 +33,8 @@ export type Tracks = Union.Type; export type Empty = ReturnType; export type Loaded = ReturnType; +export const volume = TracksAPI.lensFromProp("volume").get; +export const setVolume = TracksAPI.lensFromProp("volume").set; export const autoplayEnabled = TracksAPI.lensFromProp("autoplayEnabled").get; export const setAutoplay = TracksAPI.lensFromProp("autoplayEnabled").set; @@ -39,13 +42,15 @@ export const isEmpty = TracksAPI.is.Empty; export const { fold } = TracksAPI; -export const create = (data: { autoplayEnabled: boolean }) => +export const create = (data: { autoplayEnabled: boolean; volume: number }) => TracksAPI.of.Empty({ + volume: Volume.create(data.volume), autoplayEnabled: data.autoplayEnabled, }); const toLoaded = (track: Track.Track, weight: number) => (tracks: Empty) => TracksAPI.of.Loaded({ + volume: volume(tracks), autoplayEnabled: autoplayEnabled(tracks), selected: Track.id(track), alreadyPlayed: false, diff --git a/domain/player/src/entities/Volume.ts b/domain/player/src/entities/Volume.ts new file mode 100644 index 00000000..dd17c0c4 --- /dev/null +++ b/domain/player/src/entities/Volume.ts @@ -0,0 +1,15 @@ +import * as Union from "@fp51/opaque-union"; + +type $Volume = { + value: number; // [0-1] +}; + +const VolumeAPI = Union.of({ + Volume: Union.type<$Volume>(), +}); + +export type Volume = Union.Type; + +export const create = (value: number) => VolumeAPI.of.Volume({ value }); + +export const value = VolumeAPI.lensFromProp("value").get; diff --git a/domain/player/src/index.ts b/domain/player/src/index.ts index 27a1f002..3877ec71 100644 --- a/domain/player/src/index.ts +++ b/domain/player/src/index.ts @@ -2,6 +2,7 @@ import * as TrackSource from "./entities/TrackSource"; import * as Track from "./entities/Track"; import * as Tracks from "./entities/Tracks"; import * as Position from "./entities/Position"; +import * as Volume from "./entities/Volume"; import { usePlayer, shallowEqual } from "./store"; import { playOrPause, play } from "./repositories/playPause"; @@ -12,12 +13,14 @@ import { loadSoundcloud, } from "./repositories/track"; import { saveAutoplayChoice } from "./repositories/autoplay"; +import { updateVolume } from "./repositories/volume"; export { Track, Tracks, TrackSource, Position, + Volume, usePlayer, shallowEqual, playOrPause, @@ -27,4 +30,5 @@ export { loadBandcamp, loadSoundcloud, saveAutoplayChoice, + updateVolume, }; diff --git a/domain/player/src/localStorage.ts b/domain/player/src/localStorage.ts index 1cbcd496..aa772476 100644 --- a/domain/player/src/localStorage.ts +++ b/domain/player/src/localStorage.ts @@ -3,46 +3,58 @@ import * as IOEither from "fp-ts/IOEither"; import * as IO from "fp-ts/IO"; import * as Option from "fp-ts/Option"; import * as Either from "fp-ts/Either"; +import * as D from "io-ts/Decoder"; +import * as C from "io-ts/Codec"; import { pipe } from "fp-ts/function"; -const autoplayEnabledItemName = "cmd-player-autoplayEnabled"; - -function parseValue(value: string): Option.Option { - switch (value) { - case "true": - return Option.some(true); - - case "false": - return Option.some(false); - } +type LocalStorageIO = { + readOrElse: (defaultValue: () => T) => IO.IO; + silentWrite: (value: T) => IO.IO; +}; + +function buildLocalStorageIO( + key: string, + codec: C.Codec +): LocalStorageIO { + const readOrElse = (defaultValue: () => T) => + pipe( + IOEither.tryCatch(LocalStorageFP.getItem(key), Either.toError), + IOEither.map((value) => + pipe( + value, + Option.map(codec.decode), + Option.map(Either.getOrElse(defaultValue)), + Option.getOrElse(defaultValue) + ) + ), + IO.map(Either.getOrElse(defaultValue)) + ); + + const silentWrite = (value: T): IOEither.IOEither => + IOEither.tryCatch( + pipe(value, codec.encode, (serializedValue) => + LocalStorageFP.setItem(key, serializedValue) + ), + Either.toError + ); - return Option.none; + return { + readOrElse, + silentWrite, + }; } -function serializeValue(value: boolean): string { - if (value) { - return "true"; - } +const BooleanCodec = C.make(D.boolean, { + encode: String, +}); - return "false"; -} - -export const readLocalStorageAutoplay: IO.IO = pipe( - IOEither.tryCatch( - LocalStorageFP.getItem(autoplayEnabledItemName), - Either.toError - ), - IO.map(Either.getOrElse((): Option.Option => Option.none)), - IO.map(Option.chain(parseValue)), - IO.map(Option.getOrElse((): boolean => true)) // default true +export const autoplayEnabled = buildLocalStorageIO( + "cmd-player-autoplayEnabled", + BooleanCodec ); -export const writeLocalStorageAutoplay = ( - value: boolean -): IOEither.IOEither => - pipe( - IOEither.tryCatch( - LocalStorageFP.setItem(autoplayEnabledItemName, serializeValue(value)), - Either.toError - ) - ); +const NumberCodec = C.make(D.number, { + encode: String, +}); + +export const volume = buildLocalStorageIO("cmd-player-volume", NumberCodec); diff --git a/domain/player/src/repositories/autoplay.ts b/domain/player/src/repositories/autoplay.ts index c00f4af5..072940fb 100644 --- a/domain/player/src/repositories/autoplay.ts +++ b/domain/player/src/repositories/autoplay.ts @@ -1,18 +1,14 @@ import * as IO from "fp-ts/IO"; -import * as IOEither from "fp-ts/IOEither"; import { pipe } from "fp-ts/function"; import * as Tracks from "../entities/Tracks"; import * as Store from "../store"; -import { writeLocalStorageAutoplay } from "../localStorage"; +import { autoplayEnabled } from "../localStorage"; -export function saveAutoplayChoice(autoplayEnabled: boolean): IO.IO { +export function saveAutoplayChoice(enabled: boolean): IO.IO { return pipe( - writeLocalStorageAutoplay(autoplayEnabled), - IOEither.chainFirstIOK(() => - Store.write(Tracks.setAutoplay(autoplayEnabled)) - ), - IOEither.getOrElse((): IO.IO => IO.of(undefined)) // silence error + autoplayEnabled.silentWrite(enabled), + IO.chainFirst(() => Store.write(Tracks.setAutoplay(enabled))) ); } diff --git a/domain/player/src/repositories/playPause.ts b/domain/player/src/repositories/playPause.ts index 472d7cae..e1d9419c 100644 --- a/domain/player/src/repositories/playPause.ts +++ b/domain/player/src/repositories/playPause.ts @@ -7,12 +7,14 @@ import * as Tracks from "../entities/Tracks"; import * as Store from "../store"; import * as TrackRepo from "./track"; +import { setVolumeForCurrentTrack } from "./volume"; function selectAndPlayTrack(track: Track.Initialized) { return (state: Tracks.Loaded): IO.IO => { return pipe( Store.write(() => pipe(state, Tracks.selectTrack(track), Tracks.playing)), - IO.chain(() => TrackRepo.play(track)) + IO.chain(() => TrackRepo.play(track)), + IO.chain(setVolumeForCurrentTrack) ); }; } @@ -25,7 +27,7 @@ export const playOrPause = pipe( return IO.of(undefined); } - const selectedTrack = pipe(state, Tracks.selectedTrack); + const selectedTrack = Tracks.selectedTrack(state); // nothing to do here if (!Track.isInteractive(selectedTrack)) { diff --git a/domain/player/src/repositories/track.ts b/domain/player/src/repositories/track.ts index afac00fb..ea8f8d46 100644 --- a/domain/player/src/repositories/track.ts +++ b/domain/player/src/repositories/track.ts @@ -73,6 +73,12 @@ function pauseYoutube(source: Source.Youtube): IO.IO { return () => (Source.player(source) as any).pauseVideo(); } +function setVolumeYoutube(volume: number) { + return (source: Source.Youtube) => (): IO.IO => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (Source.player(source) as any).setVolume(volume * 100); +} + function resetSoundcloud(source: Source.Soundcloud): IO.IO { // eslint-disable-next-line @typescript-eslint/no-explicit-any return () => (Source.widget(source) as any).seekTo(0); @@ -88,6 +94,12 @@ function pauseSoundcloud(source: Source.Soundcloud): IO.IO { return () => (Source.widget(source) as any).pause(); } +function setVolumeSoundcloud(volume: number) { + return (source: Source.Soundcloud) => (): IO.IO => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (Source.widget(source) as any).setVolume(volume * 100); +} + function resetBandcamp(source: Source.Bandcamp): IO.IO { return () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -105,6 +117,14 @@ function playBandcamp(source: Source.Bandcamp): IO.IO { return () => (Source.audio(source) as any).play(); } +function setVolumeBandcamp(volume: number) { + return (source: Source.Bandcamp): IO.IO => + () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (Source.audio(source) as any).volue = volume; + }; +} + export function reset(track: Track.Initialized): IO.IO { return pipe( track, @@ -141,6 +161,19 @@ export function pause(track: Track.Initialized): IO.IO { ); } +export function setVolume(volume: number) { + return (track: Track.Initialized): IO.IO => + pipe( + track, + Track.source, + Source.fold({ + Youtube: setVolumeYoutube(volume), + Soundcloud: setVolumeSoundcloud(volume), + Bandcamp: setVolumeBandcamp(volume), + }) + ); +} + const aborted = doIfSelectedTrack((track: Track.Initialized) => Store.write( Tracks.modifyIfNotEmpty( diff --git a/domain/player/src/repositories/volume.ts b/domain/player/src/repositories/volume.ts new file mode 100644 index 00000000..82617bac --- /dev/null +++ b/domain/player/src/repositories/volume.ts @@ -0,0 +1,41 @@ +import * as IO from "fp-ts/IO"; +import { pipe } from "fp-ts/function"; + +import * as Volume from "../entities/Volume"; +import * as Tracks from "../entities/Tracks"; +import * as Track from "../entities/Track"; + +import * as Store from "../store"; +import { volume } from "../localStorage"; + +import * as TrackRepo from "./track"; + +export function setVolumeForCurrentTrack(): IO.IO { + return () => { + const state = Store.read(); + + // nothing to do here + if (Tracks.isEmpty(state)) { + return IO.of(undefined); + } + + const selectedTrack = Tracks.selectedTrack(state); + + // nothing to do here + if (!Track.isInteractive(selectedTrack)) { + return IO.of(undefined); + } + + const value = pipe(state, Tracks.volume, Volume.value); + + return TrackRepo.setVolume(value)(selectedTrack); + }; +} + +export function updateVolume(newVolume: Volume.Volume): IO.IO { + return pipe( + volume.silentWrite(Volume.value(newVolume)), + IO.chainFirst(() => Store.write(Tracks.setVolume(newVolume))), + IO.chain(setVolumeForCurrentTrack) + ); +} diff --git a/domain/player/src/store.ts b/domain/player/src/store.ts index e6c8926a..54791042 100644 --- a/domain/player/src/store.ts +++ b/domain/player/src/store.ts @@ -1,15 +1,19 @@ import * as IO from "fp-ts/IO"; +import { sequenceS } from "fp-ts/Apply"; import { pipe } from "fp-ts/function"; import createHook from "zustand"; import createStore from "zustand/vanilla"; import * as Tracks from "./entities/Tracks"; -import { readLocalStorageAutoplay } from "./localStorage"; +import { autoplayEnabled, volume } from "./localStorage"; const initTracks: IO.IO = pipe( - readLocalStorageAutoplay, - IO.map((autoplayEnabled) => Tracks.create({ autoplayEnabled })) + sequenceS(IO.io)({ + autoplayEnabled: autoplayEnabled.readOrElse(() => true), + volume: volume.readOrElse(() => 0.8), + }), + IO.map(Tracks.create) ); const store = createStore(initTracks); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72a2371a..abe225b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -129,6 +129,7 @@ importers: classnames: ^2.3.1 fp-ts: ^2.11.9 fp-ts-local-storage: ^1.0.3 + io-ts: ^2.2.16 lodash.throttle: ^4.1.1 retry-ts: ^0.1.3 tslib: ^2.3.1 @@ -139,6 +140,7 @@ importers: classnames: 2.3.1 fp-ts: 2.11.9 fp-ts-local-storage: 1.0.3_fp-ts@2.11.9 + io-ts: 2.2.16_fp-ts@2.11.9 lodash.throttle: 4.1.1 retry-ts: 0.1.3_fp-ts@2.11.9 zustand: 3.7.1