Initial
This commit is contained in:
404
resources/app/client/data/documents/playlist.js
Normal file
404
resources/app/client/data/documents/playlist.js
Normal file
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user