405 lines
13 KiB
JavaScript
405 lines
13 KiB
JavaScript
/**
|
|
* The client-side Playlist document which extends the common BasePlaylist model.
|
|
* @extends foundry.documents.BasePlaylist
|
|
* @mixes ClientDocumentMixin
|
|
*
|
|
* @see {@link Playlists} The world-level collection of Playlist documents
|
|
* @see {@link PlaylistSound} The PlaylistSound embedded document within a parent Playlist
|
|
* @see {@link PlaylistConfig} The Playlist configuration application
|
|
*/
|
|
class Playlist extends ClientDocumentMixin(foundry.documents.BasePlaylist) {
|
|
|
|
|
|
/* -------------------------------------------- */
|
|
/* Properties */
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Playlists may have a playback order which defines the sequence of Playlist Sounds
|
|
* @type {string[]}
|
|
*/
|
|
_playbackOrder;
|
|
|
|
/**
|
|
* The order in which sounds within this playlist will be played (if sequential or shuffled)
|
|
* Uses a stored seed for randomization to guarantee that all clients generate the same random order.
|
|
* @type {string[]}
|
|
*/
|
|
get playbackOrder() {
|
|
if ( this._playbackOrder !== undefined ) return this._playbackOrder;
|
|
switch ( this.mode ) {
|
|
|
|
// Shuffle all tracks
|
|
case CONST.PLAYLIST_MODES.SHUFFLE:
|
|
let ids = this.sounds.map(s => s.id);
|
|
const mt = new foundry.dice.MersenneTwister(this.seed ?? 0);
|
|
let shuffle = ids.reduce((shuffle, id) => {
|
|
shuffle[id] = mt.random();
|
|
return shuffle;
|
|
}, {});
|
|
ids.sort((a, b) => shuffle[a] - shuffle[b]);
|
|
return this._playbackOrder = ids;
|
|
|
|
// Sorted sequential playback
|
|
default:
|
|
const sorted = this.sounds.contents.sort(this._sortSounds.bind(this));
|
|
return this._playbackOrder = sorted.map(s => s.id);
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @inheritdoc */
|
|
get visible() {
|
|
return this.isOwner || this.playing;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
/* Methods */
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Find all content links belonging to a given {@link Playlist} or {@link PlaylistSound}.
|
|
* @param {Playlist|PlaylistSound} doc The Playlist or PlaylistSound.
|
|
* @returns {NodeListOf<Element>}
|
|
* @protected
|
|
*/
|
|
static _getSoundContentLinks(doc) {
|
|
return document.querySelectorAll(`a[data-link][data-uuid="${doc.uuid}"]`);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @inheritdoc */
|
|
prepareDerivedData() {
|
|
this.playing = this.sounds.some(s => s.playing);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Begin simultaneous playback for all sounds in the Playlist.
|
|
* @returns {Promise<Playlist>} The updated Playlist document
|
|
*/
|
|
async playAll() {
|
|
if ( this.sounds.size === 0 ) return this;
|
|
const updateData = { playing: true };
|
|
const order = this.playbackOrder;
|
|
|
|
// Handle different playback modes
|
|
switch (this.mode) {
|
|
|
|
// Soundboard Only
|
|
case CONST.PLAYLIST_MODES.DISABLED:
|
|
updateData.playing = false;
|
|
break;
|
|
|
|
// Sequential or Shuffled Playback
|
|
case CONST.PLAYLIST_MODES.SEQUENTIAL:
|
|
case CONST.PLAYLIST_MODES.SHUFFLE:
|
|
const paused = this.sounds.find(s => s.pausedTime);
|
|
const nextId = paused?.id || order[0];
|
|
updateData.sounds = this.sounds.map(s => {
|
|
return {_id: s.id, playing: s.id === nextId};
|
|
});
|
|
break;
|
|
|
|
// Simultaneous - play all tracks
|
|
case CONST.PLAYLIST_MODES.SIMULTANEOUS:
|
|
updateData.sounds = this.sounds.map(s => {
|
|
return {_id: s.id, playing: true};
|
|
});
|
|
break;
|
|
}
|
|
|
|
// Update the Playlist
|
|
return this.update(updateData);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Play the next Sound within the sequential or shuffled Playlist.
|
|
* @param {string} [soundId] The currently playing sound ID, if known
|
|
* @param {object} [options={}] Additional options which configure the next track
|
|
* @param {number} [options.direction=1] Whether to advance forward (if 1) or backwards (if -1)
|
|
* @returns {Promise<Playlist>} The updated Playlist document
|
|
*/
|
|
async playNext(soundId, {direction=1}={}) {
|
|
if ( ![CONST.PLAYLIST_MODES.SEQUENTIAL, CONST.PLAYLIST_MODES.SHUFFLE].includes(this.mode) ) return null;
|
|
|
|
// Determine the next sound
|
|
if ( !soundId ) {
|
|
const current = this.sounds.find(s => s.playing);
|
|
soundId = current?.id || null;
|
|
}
|
|
let next = direction === 1 ? this._getNextSound(soundId) : this._getPreviousSound(soundId);
|
|
if ( !this.playing ) next = null;
|
|
|
|
// Enact playlist updates
|
|
const sounds = this.sounds.map(s => {
|
|
return {_id: s.id, playing: s.id === next?.id, pausedTime: null};
|
|
});
|
|
return this.update({sounds});
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Begin playback of a specific Sound within this Playlist.
|
|
* Determine which other sounds should remain playing, if any.
|
|
* @param {PlaylistSound} sound The desired sound that should play
|
|
* @returns {Promise<Playlist>} The updated Playlist
|
|
*/
|
|
async playSound(sound) {
|
|
const updates = {playing: true};
|
|
switch ( this.mode ) {
|
|
case CONST.PLAYLIST_MODES.SEQUENTIAL:
|
|
case CONST.PLAYLIST_MODES.SHUFFLE:
|
|
updates.sounds = this.sounds.map(s => {
|
|
let isPlaying = s.id === sound.id;
|
|
return {_id: s.id, playing: isPlaying, pausedTime: isPlaying ? s.pausedTime : null};
|
|
});
|
|
break;
|
|
default:
|
|
updates.sounds = [{_id: sound.id, playing: true}];
|
|
}
|
|
return this.update(updates);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Stop playback of a specific Sound within this Playlist.
|
|
* Determine which other sounds should remain playing, if any.
|
|
* @param {PlaylistSound} sound The desired sound that should play
|
|
* @returns {Promise<Playlist>} The updated Playlist
|
|
*/
|
|
async stopSound(sound) {
|
|
return this.update({
|
|
playing: this.sounds.some(s => (s.id !== sound.id) && s.playing),
|
|
sounds: [{_id: sound.id, playing: false, pausedTime: null}]
|
|
});
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* End playback for any/all currently playing sounds within the Playlist.
|
|
* @returns {Promise<Playlist>} The updated Playlist document
|
|
*/
|
|
async stopAll() {
|
|
return this.update({
|
|
playing: false,
|
|
sounds: this.sounds.map(s => {
|
|
return {_id: s.id, playing: false};
|
|
})
|
|
});
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Cycle the playlist mode
|
|
* @return {Promise.<Playlist>} A promise which resolves to the updated Playlist instance
|
|
*/
|
|
async cycleMode() {
|
|
const modes = Object.values(CONST.PLAYLIST_MODES);
|
|
let mode = this.mode + 1;
|
|
mode = mode > Math.max(...modes) ? modes[0] : mode;
|
|
for ( let s of this.sounds ) {
|
|
s.playing = false;
|
|
}
|
|
return this.update({sounds: this.sounds.toJSON(), mode: mode});
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Get the next sound in the cached playback order. For internal use.
|
|
* @private
|
|
*/
|
|
_getNextSound(soundId) {
|
|
const order = this.playbackOrder;
|
|
let idx = order.indexOf(soundId);
|
|
if (idx === order.length - 1) idx = -1;
|
|
return this.sounds.get(order[idx+1]);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Get the previous sound in the cached playback order. For internal use.
|
|
* @private
|
|
*/
|
|
_getPreviousSound(soundId) {
|
|
const order = this.playbackOrder;
|
|
let idx = order.indexOf(soundId);
|
|
if ( idx === -1 ) idx = 1;
|
|
else if (idx === 0) idx = order.length;
|
|
return this.sounds.get(order[idx-1]);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Define the sorting order for the Sounds within this Playlist. For internal use.
|
|
* If sorting alphabetically, the sounds are sorted with a locale-independent comparator
|
|
* to ensure the same order on all clients.
|
|
* @private
|
|
*/
|
|
_sortSounds(a, b) {
|
|
switch ( this.sorting ) {
|
|
case CONST.PLAYLIST_SORT_MODES.ALPHABETICAL: return a.name.compare(b.name);
|
|
case CONST.PLAYLIST_SORT_MODES.MANUAL: return a.sort - b.sort;
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @inheritdoc */
|
|
toAnchor({classes=[], ...options}={}) {
|
|
if ( this.playing ) classes.push("playing");
|
|
if ( !this.isOwner ) classes.push("disabled");
|
|
return super.toAnchor({classes, ...options});
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @inheritdoc */
|
|
_onClickDocumentLink(event) {
|
|
if ( this.playing ) return this.stopAll();
|
|
return this.playAll();
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
/* Event Handlers */
|
|
/* -------------------------------------------- */
|
|
|
|
/** @inheritdoc */
|
|
async _preUpdate(changed, options, user) {
|
|
if ((("mode" in changed) || ("playing" in changed)) && !("seed" in changed)) {
|
|
changed.seed = Math.floor(Math.random() * 1000);
|
|
}
|
|
return super._preUpdate(changed, options, user);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @inheritdoc */
|
|
_onUpdate(changed, options, userId) {
|
|
super._onUpdate(changed, options, userId);
|
|
if ( "seed" in changed || "mode" in changed || "sorting" in changed ) this._playbackOrder = undefined;
|
|
if ( ("sounds" in changed) && !game.audio.locked ) this.sounds.forEach(s => s.sync());
|
|
this.#updateContentLinkPlaying(changed);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @inheritdoc */
|
|
_onDelete(options, userId) {
|
|
super._onDelete(options, userId);
|
|
this.sounds.forEach(s => s.sound?.stop());
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @inheritdoc */
|
|
_onCreateDescendantDocuments(parent, collection, documents, data, options, userId) {
|
|
super._onCreateDescendantDocuments(parent, collection, documents, data, options, userId);
|
|
if ( options.render !== false ) this.collection.render();
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @inheritdoc */
|
|
_onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) {
|
|
super._onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId);
|
|
if ( options.render !== false ) this.collection.render();
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @inheritdoc */
|
|
_onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId) {
|
|
super._onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId);
|
|
if ( options.render !== false ) this.collection.render();
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Handle callback logic when an individual sound within the Playlist concludes playback naturally
|
|
* @param {PlaylistSound} sound
|
|
* @internal
|
|
*/
|
|
async _onSoundEnd(sound) {
|
|
switch ( this.mode ) {
|
|
case CONST.PLAYLIST_MODES.SEQUENTIAL:
|
|
case CONST.PLAYLIST_MODES.SHUFFLE:
|
|
return this.playNext(sound.id);
|
|
case CONST.PLAYLIST_MODES.SIMULTANEOUS:
|
|
case CONST.PLAYLIST_MODES.DISABLED:
|
|
const updates = {playing: true, sounds: [{_id: sound.id, playing: false, pausedTime: null}]};
|
|
for ( let s of this.sounds ) {
|
|
if ( (s !== sound) && s.playing ) break;
|
|
updates.playing = false;
|
|
}
|
|
return this.update(updates);
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Handle callback logic when playback for an individual sound within the Playlist is started.
|
|
* Schedule auto-preload of next track
|
|
* @param {PlaylistSound} sound
|
|
* @internal
|
|
*/
|
|
async _onSoundStart(sound) {
|
|
if ( ![CONST.PLAYLIST_MODES.SEQUENTIAL, CONST.PLAYLIST_MODES.SHUFFLE].includes(this.mode) ) return;
|
|
const apl = CONFIG.Playlist.autoPreloadSeconds;
|
|
if ( Number.isNumeric(apl) && Number.isFinite(sound.sound.duration) ) {
|
|
setTimeout(() => {
|
|
if ( !sound.playing ) return;
|
|
const next = this._getNextSound(sound.id);
|
|
next?.load();
|
|
}, (sound.sound.duration - apl) * 1000);
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Update the playing status of this Playlist in content links.
|
|
* @param {object} changed The data changes.
|
|
*/
|
|
#updateContentLinkPlaying(changed) {
|
|
if ( "playing" in changed ) {
|
|
this.constructor._getSoundContentLinks(this).forEach(el => el.classList.toggle("playing", changed.playing));
|
|
}
|
|
if ( "sounds" in changed ) changed.sounds.forEach(update => {
|
|
const sound = this.sounds.get(update._id);
|
|
if ( !("playing" in update) || !sound ) return;
|
|
this.constructor._getSoundContentLinks(sound).forEach(el => el.classList.toggle("playing", update.playing));
|
|
});
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
/* Importing and Exporting */
|
|
/* -------------------------------------------- */
|
|
|
|
/** @inheritdoc */
|
|
toCompendium(pack, options={}) {
|
|
const data = super.toCompendium(pack, options);
|
|
if ( options.clearState ) {
|
|
data.playing = false;
|
|
for ( let s of data.sounds ) {
|
|
s.playing = false;
|
|
}
|
|
}
|
|
return data;
|
|
}
|
|
}
|