diff --git a/apps/grapevine/assets/js/play/redux/mediaReducer.js b/apps/grapevine/assets/js/play/redux/mediaReducer.js new file mode 100644 index 00000000..350715f7 --- /dev/null +++ b/apps/grapevine/assets/js/play/redux/mediaReducer.js @@ -0,0 +1,227 @@ +import {Howl, Howler} from 'howler'; +import {createReducer} from "reduxsauce"; +import _ from "underscore"; + +import {Types} from "./actions"; + +/* + * Validations + */ + +export const validKey = (key) => { + return typeof key == "string"; +} + +export const validName = (name) => { + return typeof name == "string" && name.endsWith(".mp3"); +} + +export const validPriority = (priority) => { + return typeof priority == "number" && priority >= 1 && priority <= 100; +} + +export const validTag = (tag) => { + return typeof tag == "string"; +} + +export const validType = (type) => { + return type == "music" || type == "sound"; +} + +export const validUrl = (url) => { + return typeof url == "string" && (url.startsWith("http://") || url.startsWith("https://")); +} + +export class Filter { + constructor(attrs) { + if (validName(attrs.name)) { + this.name = attrs.name; + } + + if (validType(attrs.type)) { + this.type = attrs.type; + } + + if (validTag(attrs.tag)) { + this.tag = attrs.tag; + } + + if (validPriority(attrs.priority)) { + this.priority = attrs.priority; + } + + if (validKey(attrs.key)) { + this.key = attrs.key; + } + } + + matches(filter) { + let matchKeys = ["type", "name", "tag", "key"]; + + matchKeys = _.filter(matchKeys, (key) => { + return key in filter; + }); + + let matchedKeys = _.all(matchKeys, (key) => { + return filter[key] == this[key]; + }); + + return matchedKeys && this.priorityMatch(filter); + } + + /* + * Private-ish + */ + + priorityMatch(filter) { + if (!filter.priority) { + return true; + } + + if (!this.priority) { + return false; + } + + if (this.priority <= filter.priority) { + return true; + } + + return false; + } +} + +export class Player { + constructor() { + this.activeMedia = []; + } + + play(media) { + _.each(this.activeMedia, (activeMedia) => { + if (activeMedia.key == media.key) { + activeMedia.stop(); + } + }); + + this.activeMedia = _.reject(this.activeMedia, (activeMedia) => { + return activeMedia.key == media.key; + }); + + media.play(); + + this.activeMedia.push(media); + } + + stop(filter) { + filter = new Filter(filter); + + _.filter(this.activeMedia, (activeMedia) => { + return activeMedia.matchFilter(filter); + }).map((media) => { + media.stop() + }); + + this.activeMedia = _.reject(this.activeMedia, (activeMedia) => { + return !activeMedia.isPlaying(); + }); + } +} + +const baseUrl = (attrs, defaults) => { + let url; + + if ("url" in attrs && validUrl(attrs.url)) { + url = attrs.url; + } else if ("url" in defaults && validUrl(defaults.url)) { + url = defaults.url; + } + + if (url && !url.endsWith("/")) { + url = url + "/"; + } + + return url; +} + +export class Media { + constructor(attrs, defaults = {}) { + this.filter = new Filter(attrs); + + this.type = this.filter.type; + this.key = this.filter.key; + this.priority = this.filter.priority; + this.tag = this.filter.tag; + this.name = this.filter.name; + + if (baseUrl(attrs, defaults)) { + this.url = baseUrl(attrs, defaults) + this.filter.name; + } else { + throw "Invalid URL! Music cannot be played"; + } + + this.howler = new Howl({ + src: [this.url], + html5: true + }); + } + + isPlaying() { + return this.howler.playing(); + } + + play() { + console.log("playing"); + this.howler.play(); + } + + matchFilter(filter) { + return this.filter.matches(filter); + } + + stop() { + console.log("stopping"); + this.howler.stop(); + } +} + +const INITIAL_STATE = { + defaults: {}, + player: new Player(), +} + +export const mediaReceiveGMCP = (state, action) => { + let key; + + switch (action.message) { + case "Client.Media.Default": + let defaults = {}; + + if (validUrl(action.data.url)) { + defaults.url = action.data.url; + } + + return {...state, defaults: defaults}; + + case "Client.Media.Play": + console.log("Want to play music..."); + console.log(action); + + let media = new Media(action.data, state.defaults); + + state.player.play(media); + + return state; + + case "Client.Media.Stop": + state.player.stop(action.data); + return state; + + default: + return state; + } +}; + +export const HANDLERS = { + [Types.SOCKET_RECEIVE_GMCP]: mediaReceiveGMCP, +} + +export const mediaReducer = createReducer(INITIAL_STATE, HANDLERS); diff --git a/apps/grapevine/assets/js/play/redux/mediaReducer.test.js b/apps/grapevine/assets/js/play/redux/mediaReducer.test.js new file mode 100644 index 00000000..910395ed --- /dev/null +++ b/apps/grapevine/assets/js/play/redux/mediaReducer.test.js @@ -0,0 +1,131 @@ +import { + Filter, + Media, + validKey, + validName, + validPriority, + validTag, + validType, + validUrl, +} from "./mediaReducer"; + +describe("validating", () => { + test("validates name", () => { + expect(validName("file.mp3")).toEqual(true); + expect(validName("file.ogg")).toEqual(false); + expect(validName("anything else")).toEqual(false); + }); + + test("validates type", () => { + expect(validType("music")).toEqual(true); + expect(validType("sound")).toEqual(true); + expect(validType("anything else")).toEqual(false); + }); + + test("validates tag", () => { + expect(validTag("combat")).toEqual(true); + expect(validTag(10)).toEqual(false); + }); + + test("validates priority", () => { + expect(validPriority(50)).toEqual(true); + expect(validPriority(1)).toEqual(true); + expect(validPriority(100)).toEqual(true); + expect(validPriority(-1)).toEqual(false); + expect(validPriority("anything else")).toEqual(false); + }); + + test("validates key", () => { + expect(validKey("a key")).toEqual(true); + expect(validKey(1)).toEqual(false); + }); + + test("validates url", () => { + expect(validUrl("http://example.com/")).toEqual(true); + expect(validUrl("https://example.com/")).toEqual(true); + expect(validUrl("ftp://example.com/")).toEqual(false); + expect(validUrl("anything else")).toEqual(false); + }); +}); + +describe("filters match", () => { + test("matches based on key", () => { + let filter = new Filter({key: "ambient"}); + expect(filter.matches(new Filter({key: "ambient"}))).toEqual(true); + + filter = new Filter({key: "ambient"}); + expect(filter.matches(new Filter({key: "other"}))).toEqual(false); + }); + + test("matches based on name", () => { + let filter = new Filter({name: "file.mp3"}); + expect(filter.matches({name: "file.mp3"})).toEqual(true); + + filter = new Filter({name: "file.mp3"}); + expect(filter.matches({name: "other.mp3"})).toEqual(false); + }); + + test("matches based on type", () => { + let filter = new Filter({type: "music"}); + expect(filter.matches({type: "music"})).toEqual(true); + + filter = new Filter({type: "music"}); + expect(filter.matches({type: "sound"})).toEqual(false); + }); + + test("matches based on tag", () => { + let filter = new Filter({tag: "background"}); + expect(filter.matches({tag: "background"})).toEqual(true); + + filter = new Filter({tag: "background"}); + expect(filter.matches({tag: "foreground"})).toEqual(false); + }); + + test("matches based on priority", () => { + let filter = new Filter({priority: 50}); + expect(filter.matches({priority: 50})).toEqual(true); + + filter = new Filter({priority: 50}); + expect(filter.matches({priority: 65})).toEqual(true); + + filter = new Filter({priority: 50}); + expect(filter.matches({priority: 25})).toEqual(false); + }); + + test("matches based on combinations", () => { + let filter = new Filter({tag: "background", priority: 50}); + expect(filter.matches({tag: "background", priority: 65})).toEqual(true); + + filter = new Filter({type: "music", priority: 50}); + expect(filter.matches({type: "music", priority: 35})).toEqual(false); + + filter = new Filter({type: "music", priority: 50}); + expect(filter.matches({key: "background"})).toEqual(false); + + filter = new Filter({type: "music", priority: 50}); + expect(filter.matches({priority: 65})).toEqual(true); + }); +}); + +describe("media class - file url", () => { + test("uses the default url to construct the url", () => { + let media = new Media({name: "file.mp3"}, {url: "https://example.com/"}); + expect(media.url).toEqual("https://example.com/file.mp3"); + }); + + test("includes a trailing slash automatically", () => { + let media = new Media({name: "file.mp3"}, {url: "https://example.com"}); + expect(media.url).toEqual("https://example.com/file.mp3"); + }); + + test("uses the specific url if provided", () => { + let media = new Media({name: "file.mp3", url: "https://example.com"}); + expect(media.url).toEqual("https://example.com/file.mp3"); + }); + + test("ignores invalid urls", () => { + expect(() => { + new Media({name: "file.mp3"}, {url: "ftp://example.com/"}); + }).toThrowError(/Music/); + }); +}); diff --git a/apps/grapevine/assets/js/play/redux/store.js b/apps/grapevine/assets/js/play/redux/store.js index f492e4b7..d75f8656 100644 --- a/apps/grapevine/assets/js/play/redux/store.js +++ b/apps/grapevine/assets/js/play/redux/store.js @@ -1,5 +1,6 @@ import {combineReducers, createStore, compose} from 'redux'; +import {mediaReducer} from "./mediaReducer"; import {modalReducer} from "./modalReducer"; import {promptReducer} from "./promptReducer"; import {settingsReducer} from "./settingsReducer"; @@ -93,6 +94,7 @@ export const getVoiceVoices = (state) => { // Reducers let rootReducer = combineReducers({ + media: mediaReducer, modal: modalReducer, prompt: promptReducer, settings: settingsReducer, diff --git a/apps/grapevine/assets/package.json b/apps/grapevine/assets/package.json index ba9f79b1..c2151e48 100644 --- a/apps/grapevine/assets/package.json +++ b/apps/grapevine/assets/package.json @@ -10,6 +10,7 @@ "dependencies": { "@grapevine/ansi": "^1.0.0", "chart.js": "^2.7.3", + "howler": "^2.1.3", "jquery": "^3.3.1", "moment": "^2.24.0", "moment-timezone": "^0.5.26", diff --git a/apps/grapevine/assets/yarn.lock b/apps/grapevine/assets/yarn.lock index 3b73b561..dd5317a2 100644 --- a/apps/grapevine/assets/yarn.lock +++ b/apps/grapevine/assets/yarn.lock @@ -3209,6 +3209,11 @@ hosted-git-info@^2.1.4: resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.4.tgz#44119abaf4bc64692a16ace34700fed9c03e2546" integrity sha512-pzXIvANXEFrc5oFFXRMkbLPQ2rXRoDERwDLyrcUxGhaZhgP54BBSl9Oheh7Vv0T090cszWBxPjkQQ5Sq1PbBRQ== +howler@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/howler/-/howler-2.1.3.tgz#07c88618f8767e879407a4d647fe2d6d5f15f121" + integrity sha512-PSGbOi1EYgw80C5UQbxtJM7TmzD+giJunIMBYyH3RVzHZx2fZLYBoes0SpVVHi/SFa1GoNtgXj/j6I7NOKYBxQ== + hsl-regex@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/hsl-regex/-/hsl-regex-1.0.0.tgz#d49330c789ed819e276a4c0d272dffa30b18fe6e" @@ -5192,7 +5197,7 @@ performance-now@^2.1.0: integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= "phoenix@file:../deps/phoenix": - version "1.4.6" + version "1.4.12" "phoenix_html@file:../deps/phoenix_html": version "2.13.3" diff --git a/apps/telnet/lib/telnet/features.ex b/apps/telnet/lib/telnet/features.ex index 480d88ec..988b194f 100644 --- a/apps/telnet/lib/telnet/features.ex +++ b/apps/telnet/lib/telnet/features.ex @@ -40,9 +40,17 @@ defmodule GrapevineTelnet.Features do """ def cache_message(state, message, data) + def cache_message(state, "Client.Media.Default", data) do + force_cache_message(state, "Client.Media.Default", data) + end + + def cache_message(state, "Client.Media" <> _, _data), do: state + def cache_message(state, "Client.Modals" <> _, _data), do: state - def cache_message(state, message, data) do + def cache_message(state, message, data), do: force_cache_message(state, message, data) + + defp force_cache_message(state, message, data) do cache = Map.put(state.features.message_cache, message, data) features = Map.put(state.features, :message_cache, cache) Map.put(state, :features, features) @@ -92,10 +100,18 @@ defmodule GrapevineTelnet.Features do @doc """ Base packages that the client will try to turn on """ - def base_packages(), do: ["Client.Modals 1"] + def base_packages(), do: ["Client.Modals 1", "Client.Media 1"] @doc """ Base messages that are known for the client """ - def base_messagse(), do: ["Client.Modals.Open"] + def base_messagse() do + [ + "Client.Modals.Open", + "Client.Media.Default", + "Client.Media.Load", + "Client.Media.Play", + "Client.Media.Stop" + ] + end end