/** * 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} * @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} 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} 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} 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} 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} 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.} 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; } }