Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
227 changes: 227 additions & 0 deletions apps/grapevine/assets/js/play/redux/mediaReducer.js
Original file line number Diff line number Diff line change
@@ -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);
131 changes: 131 additions & 0 deletions apps/grapevine/assets/js/play/redux/mediaReducer.test.js
Original file line number Diff line number Diff line change
@@ -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/);
});
});
2 changes: 2 additions & 0 deletions apps/grapevine/assets/js/play/redux/store.js
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -93,6 +94,7 @@ export const getVoiceVoices = (state) => {
// Reducers

let rootReducer = combineReducers({
media: mediaReducer,
modal: modalReducer,
prompt: promptReducer,
settings: settingsReducer,
Expand Down
1 change: 1 addition & 0 deletions apps/grapevine/assets/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading