Files
Foundry-VTT-Docker/resources/app/client/data/documents/playlist.js
2025-01-04 00:34:03 +01:00

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;
}
}