diff --git a/.github/header.png b/.github/header.png index 9d0d036ee..5c3499d9f 100644 Binary files a/.github/header.png and b/.github/header.png differ diff --git a/.github/workflows/javadoc-pages.yml b/.github/workflows/javadoc-pages.yml new file mode 100644 index 000000000..5c86b394b --- /dev/null +++ b/.github/workflows/javadoc-pages.yml @@ -0,0 +1,28 @@ +name: Deploy Javadoc + +on: + push: + branches: + - master + - main + - development # build from development branch (for now) + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Deploy JavaDoc 🚀 + uses: MathieuSoysal/Javadoc-publisher.yml@v2.4.0 + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + javadoc-branch: javadoc + java-version: 17 + target-folder: javadoc # url will be https://.github.io//javadoc, This can be left as nothing to generate javadocs in the root folder. + project: maven # or gradle + subdirectories: api + custom-command: mvn javadoc:aggregate -pl :openaudiomc-api + - name: Ensure docs directory exists + run: mkdir -p docs + + - name: Copy API docs + run: cp -r api/target/site/apidocs/* docs/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index 23d1f18bd..ae49c33f0 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,5 @@ test-server-spigot-legacy/ test-server-bungee/ test-server-vistas/ test-server/ -.buildconfig \ No newline at end of file +.buildconfig +javadoc/ \ No newline at end of file diff --git a/README.md b/README.md index faa26e7a2..ce5f663ff 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Please note that the test for `vistas-server` test cases require a redis server * **Website** - * **Discord Community** - * **Documentation** - +* **JavaDocs** - * **Client** - # Codebase core terminology diff --git a/modules/gamemode-voice-filter-module/.gitignore b/api/.gitignore similarity index 100% rename from modules/gamemode-voice-filter-module/.gitignore rename to api/.gitignore diff --git a/api/dependency-reduced-pom.xml b/api/dependency-reduced-pom.xml new file mode 100644 index 000000000..81b022480 --- /dev/null +++ b/api/dependency-reduced-pom.xml @@ -0,0 +1,103 @@ + + + + OpenAudioMc-Parent + com.craftmend.openaudiomc + 1.2 + + 4.0.0 + openaudiomc-api + openaudiomc-api + ${oa.version} + + + + true + src/main/resources + + + openaudiomc-api + + + maven-compiler-plugin + 3.8.1 + + ${java.version} + ${java.version} + + + + org.projectlombok + lombok-maven-plugin + 1.18.20.0 + + + generate-sources + + delombok + + + + + ${project.basedir}/src/main/java + ${delombok.output} + false + + + + maven-javadoc-plugin + + + attach-javadocs + + jar + + + + + 8 + ${delombok.output} + none + + + + maven-shade-plugin + 3.2.4 + + + package + + shade + + + + + + + + + spigotmc-repo + https://hub.spigotmc.org/nexus/content/groups/public/ + + + + + org.projectlombok + lombok + 1.18.30 + provided + + + org.spigotmc + spigot-api + 1.13.2-R0.1-SNAPSHOT + provided + + + + 1.8 + ${project.build.directory}/delombok + UTF-8 + src/main/java + + diff --git a/api/pom.xml b/api/pom.xml new file mode 100644 index 000000000..16c6d76e0 --- /dev/null +++ b/api/pom.xml @@ -0,0 +1,125 @@ + + + 4.0.0 + + openaudiomc-api + ${oa.version} + jar + openaudiomc-api + + + com.craftmend.openaudiomc + OpenAudioMc-Parent + ../pom.xml + 1.2 + + + + 1.8 + UTF-8 + src/main/java + ${project.build.directory}/delombok + + + + openaudiomc-api + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + ${java.version} + ${java.version} + + + + org.projectlombok + lombok-maven-plugin + 1.18.20.0 + + ${project.basedir}/src/main/java + ${delombok.output} + false + + + + generate-sources + + delombok + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + 8 + ${delombok.output} + none + + + + attach-javadocs + + jar + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.2.4 + + + package + + shade + + + + + + + + src/main/resources + true + + + + + + + spigotmc-repo + https://hub.spigotmc.org/nexus/content/groups/public/ + + + + + + org.jetbrains + annotations + ${deps.jetbrainsannot.version} + + + + org.projectlombok + lombok + ${deps.lombok.version} + provided + + + + + org.spigotmc + spigot-api + ${deps.spigot.version} + provided + + + + \ No newline at end of file diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/ApiHolder.java b/api/src/main/java/com/craftmend/openaudiomc/api/ApiHolder.java new file mode 100644 index 000000000..8803377fd --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/ApiHolder.java @@ -0,0 +1,55 @@ +package com.craftmend.openaudiomc.api; + +/** + * Internal class used to initialize the API once based on services from the plugin. + * Calling any of these methods will throw an exception if the API is already initiated. + */ +public class ApiHolder { + + static ClientApi clientApiInstance; + static WorldApi worldApiInstance; + static VoiceApi voiceApiInstance; + static MediaApi mediaApiInstance; + static EventApi eventApiInstance; + + public static void initiate( + ClientApi clientApi + ) { + if (clientApiInstance != null) throw new IllegalStateException("Api already initiated"); + + clientApiInstance = clientApi; + } + + public static void initiate( + WorldApi worldApi + ) { + if (worldApiInstance != null) throw new IllegalStateException("Api already initiated"); + + worldApiInstance = worldApi; + } + + public static void initiate( + VoiceApi voiceApi + ) { + if (voiceApiInstance != null) throw new IllegalStateException("Api already initiated"); + + voiceApiInstance = voiceApi; + } + + public static void initiate( + MediaApi mediaApi + ) { + if (mediaApiInstance != null) throw new IllegalStateException("Api already initiated"); + + mediaApiInstance = mediaApi; + } + + public static void initiate( + EventApi eventApi + ) { + if (eventApiInstance != null) throw new IllegalStateException("Api already initiated"); + + eventApiInstance = eventApi; + } + +} diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/ClientApi.java b/api/src/main/java/com/craftmend/openaudiomc/api/ClientApi.java new file mode 100644 index 000000000..9d6641fae --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/ClientApi.java @@ -0,0 +1,47 @@ +package com.craftmend.openaudiomc.api; + +import com.craftmend.openaudiomc.api.clients.Client; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Collection; +import java.util.UUID; + +/** + * The ClientApi is a collection of methods to interact with clients, and get information about them + */ +public interface ClientApi { + + /** + * Get an instance of the client api. May be null if the plugin is not loaded yet + * @return instance + */ + static ClientApi getInstance() { + if (ApiHolder.clientApiInstance == null) { + throw new IllegalStateException("OpenAudioMc has not been initialized yet"); + } + return ApiHolder.clientApiInstance; + } + + /** + * Get a client by a player UUID, or null if the player is not online or not registered yet + * @param clientUuid the UUID of the player + * @return the client instance, or null if the client is not connected + */ + @Nullable Client getClient(UUID clientUuid); + + /** + * Get all clients that are currently known to the server + * @return All clients + */ + @NotNull + Collection getAllClients(); + + /** + * Check if a client is registered, and has an active web connection + * @param uuid the UUID of the player + * @return true if the player is connected, false if not or not registered + */ + boolean isConnected(UUID uuid); + +} diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/EventApi.java b/api/src/main/java/com/craftmend/openaudiomc/api/EventApi.java new file mode 100644 index 000000000..39ccfac3d --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/EventApi.java @@ -0,0 +1,49 @@ +package com.craftmend.openaudiomc.api; + +import com.craftmend.openaudiomc.api.events.BaseEvent; +import com.craftmend.openaudiomc.api.events.SingleHandler; + +/** + * This is the event api, which is used to register and call events. + * We use this instead of the bukkit event system to allow for cross-platform compatibility + */ +public interface EventApi { + + /** + * Get an instance of the event api. May be null if the plugin is not loaded yet + * @return instance + */ + static EventApi getInstance() { + if (ApiHolder.eventApiInstance == null) { + throw new IllegalStateException("OpenAudioMc has not been initialized yet"); + } + return ApiHolder.eventApiInstance; + } + + /** + * Register a listener for events annotated with @Handler + * @param listener the listener to register + */ + void registerHandlers(Object listener); + + /** + * Unregister a listener for events annotated with @Handler + * @param listener the listener to unregister + */ + void unregisterHandlers(Object listener); + + /** + * Call an event + * @param event the event to call + */ + BaseEvent callEvent(BaseEvent event); + + /** + * Register a handler for a specific event + * @param event the event to listen for + * @param handler the handler to call + * @param the event type + */ + void registerHandler(Class event, SingleHandler handler); + +} diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/MediaApi.java b/api/src/main/java/com/craftmend/openaudiomc/api/MediaApi.java new file mode 100644 index 000000000..65865b062 --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/MediaApi.java @@ -0,0 +1,126 @@ +package com.craftmend.openaudiomc.api; + +import com.craftmend.openaudiomc.api.clients.Client; +import com.craftmend.openaudiomc.api.media.Media; +import com.craftmend.openaudiomc.api.media.UrlMutation; +import org.jetbrains.annotations.NotNull; + + +/** + * The MediaApi is a collection of methods to interact with media, and get information about them + */ +public interface MediaApi { + + /** + * Get an instance of the media api. May be null if the plugin is not loaded yet + * + * @return instance + */ + static MediaApi getInstance() { + if (ApiHolder.mediaApiInstance == null) { + throw new IllegalStateException("OpenAudioMc has not been initialized yet"); + } + return ApiHolder.mediaApiInstance; + } + + /** + * Create a new media instance with a source, and automatically translate the source + * (if needed) and register a normalized time for the start instant. + * + * @param source the source of the media + * @return a new media instance + */ + @NotNull + Media createMedia(@NotNull String source); + + /** + * Force a client to preload a media source, so it's ready to play when needed. + * This will force the client to download the entire file and cache it. Once a media is played + * (through any method) it will look in the cache and take it from there if it's available. + * This method is useful for preloading media sources that are not played often, but should be + * ready to play at any time (like a sound effects, shows, etc). + * You can also make the client make a new copy after being taken by the pool so there'll always be a copy in the cache, + * which can be useful for frequently played media as long as you take really good care of clearing it too (to prevent memory leaks) + * + * @param client the client to preload the media for + * @param mediaSource the media source to preload + * @param keepCopy if the client should keep a copy of the media after being taken by the pool + */ + void preloadMediaSource(Client client, String mediaSource, boolean keepCopy); + + /** + * Force a client to preload a media, so it's ready to play when needed. + * This will force the client to download the entire file and cache it. Once a media is played + * (through any method) it will look in the cache and take it from there if it's available. + * This method is useful for preloading media sources that are not played often, but should be + * ready to play at any time (like a sound effects, shows, etc). + * You can also make the client make a new copy after being taken by the pool so there'll always be a copy in the cache, + * which can be useful for frequently played media as long as you take really good care of clearing it too (to prevent memory leaks) + * + * @param client the client to preload the media for + * @param media the media to preload + * @param keepCopy if the client should keep a copy of the media after being taken by the pool + */ + void preloadMedia(Client client, Media media, boolean keepCopy); + + /** + * Clear all preloaded media for a client, including entries with keepCopy set to true + * + * @param client the client to clear the preloaded media for + */ + void clearPreloadedMedia(Client client); + + /** + * Translate server-sided aliases, playlists or other sources to a valid source. + * This is automatically done by createMedia, but you might want to do this manually. + * + * @param source the source to translate + * @return the translated source + */ + @NotNull + String translateSource(@NotNull String source); + + /** + * URL mutations can be used to register custom server-side media hooks or source translators. + * An example use case would be a custom media server aliased by hypixel:, which can be resolved + * to https://hypixel.com/media/* by a mutation. + * + * @param prefix the prefix to register the mutation for, + * the mutation will only be called for media sources starting with this prefix + * @param mutation the mutation to register + */ + void registerMutation(@NotNull String prefix, @NotNull UrlMutation mutation); + + /** + * Get the current epoch time, but normalized to the start of the current media. + * This timecodes is normalized based on heartbeats from an open audio server, to eliminate + * timezone changes between this server and the web-client (because the player might be in a different timezone) + * + * @return the current epoch time, but normalized to the start of the current media + */ + long getNormalizedCurrentEpoch(); + + /** + * Play a media for a client + * + * @param clients Target clients + * @param media Media instance + */ + void playFor(@NotNull Media media, @NotNull Client... clients); + + /** + * Stop all media (except regions and speakers) for a client + * + * @param clients Target clients + */ + void stopFor(@NotNull Client... clients); + + /** + * Stop a specific media by ID for a client + * + * @param id Media ID + * @param clients Target clients + */ + void stopFor(@NotNull String id, @NotNull Client... clients); + +} diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/VoiceApi.java b/api/src/main/java/com/craftmend/openaudiomc/api/VoiceApi.java new file mode 100644 index 000000000..31f43dc46 --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/VoiceApi.java @@ -0,0 +1,108 @@ +package com.craftmend.openaudiomc.api; + +import com.craftmend.openaudiomc.api.clients.Client; +import com.craftmend.openaudiomc.api.voice.CustomPlayerFilter; +import com.craftmend.openaudiomc.api.voice.VoicePeerOptions; + +import java.util.List; +import java.util.UUID; + +/** + * The VoiceApi contains registry, as well as control endpoints for voice-chat related features. + * This implementation is only available on the Spigot instance, even if the plugin is running in a BungeeCord/Velocity or Vistas network. + * Accessing this API on a non-spigot instance will result in undefined behavior or runtime exceptions. + */ +public interface VoiceApi { + + /** + * Get the voice api instance, or null if the plugin is not loaded yet + * + * @return instance + */ + static VoiceApi getInstance() { + if (ApiHolder.voiceApiInstance == null) { + throw new IllegalStateException("OpenAudioMc has not been initialized yet"); + } + return ApiHolder.voiceApiInstance; + } + + /** + * Register a client as a voice-chat peer + * + * @param haystack The client that will be the host + * @param needle The client that will be the peer + * @return true if the client was registered, false if the client was already registered + */ + boolean hasPeer(Client haystack, Client needle); + + /** + * Register a client as a voice-chat peer + * + * @param haystack The client that will be the host + * @param needle The client that will be the peer + * @return true if the client was registered, false if the client was already registered + */ + boolean hasPeer(Client haystack, UUID needle); + + /** + * Push new options for a peer, changing how its rendered in the client + * + * @param client The web client that should receive this update + * @param peerToUpdate The peer that should be updated + * @param options The new options + */ + void updatePeerOptions(Client client, Client peerToUpdate, VoicePeerOptions options); + + /** + * Add a peer (partner) to someone's voice chat. + * This would let the client hear the peerToAdd as a global voice (without spatial audio/distance) until it's removed. + * + * @param client The web client that should receive this update + * @param peerToAdd The peer that should be added + * @param visible Whether the peer should be visible in the client + * @param mutual Whether the peer should also hear the client (repeat the call for mutual) + */ + void addStaticPeer(Client client, Client peerToAdd, boolean visible, boolean mutual); + + /** + * Remove a global peer from someone's voice chat. + * This would remove a static peer if they have been added through addStaticPeer, but not + * if they have been added through the regular voice-chat system. + * + * @param client The web client that should receive this update + * @param peerToRemove The peer that should be removed + * @param mutual Whether the peer should also stop hearing the client (repeat the call for mutual) + */ + void removeStaticPeer(Client client, Client peerToRemove, boolean mutual); + + /** + * Adds a {@link CustomPlayerFilter} to the list of functions to filter out players in {@code com.craftmend.openaudiomc.spigot.modules.voicechat.filters.PeerFilter#wrap(Stream, Player)} (which lives in the plugin, not api). + * These functions are called in {@code com.craftmend.openaudiomc.spigot.modules.voicechat.filters.PeerFilter#wrap(Stream, Player)} to allow for plugins to add custom sorting for + * players. An example being staff members shouldn't be heard by other players so adding a custom function implementation via + * {@link #addFilterFunction(CustomPlayerFilter)} allows for such functionality to exist. + *
+ * Please read the documentation in the {@link CustomPlayerFilter} before planning your implementation, + * because you are probably better off using the event system for most use-cases. + * + * @param customPlayerFilter The {@link CustomPlayerFilter} to be added to the list of functions + * @author DiamondDagger590 + */ + void addFilterFunction(CustomPlayerFilter customPlayerFilter); + + /** + * Returns a copy of the internal {@link List} of {@link CustomPlayerFilter}s. This means + * modifications done to the {@link List} returned by this method will not result + * in changes in the actual list. + *
+ * These functions will be called in whatever order they are stored in. + *
+ * These functions are called in {@code com.craftmend.openaudiomc.spigot.modules.voicechat.filters.PeerFilter#wrap(Stream, Player)} to allow for plugins to add custom sorting for + * players. An example being staff members shouldn't be heard by other players so adding a custom function implementation via + * {@link #addFilterFunction(CustomPlayerFilter)} allows for such functionality to exist. + * + * @return A copied {@link List} of {@link CustomPlayerFilter}s + * @author DiamondDagger590 + */ + List getCustomPlayerFilters(); + +} diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/WorldApi.java b/api/src/main/java/com/craftmend/openaudiomc/api/WorldApi.java new file mode 100644 index 000000000..d223808d7 --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/WorldApi.java @@ -0,0 +1,50 @@ +package com.craftmend.openaudiomc.api; + +import com.craftmend.openaudiomc.api.regions.AudioRegion; +import com.craftmend.openaudiomc.api.speakers.BasicSpeaker; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Collection; + + +/** + * The WorldApi is a collection of methods to interact with features that is linked to worlds. + * This API is only available if the OpenAudioMc plugin on spigot, and is not available on the bungee side. + */ +public interface WorldApi { + + /** + * Get an instance of the world api. May be null if the plugin is not loaded yet + * @return instance + */ + static WorldApi getInstance() { + if (ApiHolder.worldApiInstance == null) { + throw new IllegalStateException("OpenAudioMc has not been initialized yet"); + } + return ApiHolder.worldApiInstance; + } + + /** + * Get all regions at a location + * @param x x + * @param y y + * @param z z + * @param world world + * @return regions + */ + @NotNull + Collection getRegionsAt(int x, int y, int z, @NotNull String world); + + /** + * Get a speaker at a location, or null if invalid + * @param x x + * @param y y + * @param z z + * @param world world + * @return speaker + */ + @Nullable + BasicSpeaker getSpeakerAt(int x, int y, int z, @NotNull String world); + +} diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/basic/Actor.java b/api/src/main/java/com/craftmend/openaudiomc/api/basic/Actor.java new file mode 100644 index 000000000..5f8b25759 --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/basic/Actor.java @@ -0,0 +1,42 @@ +package com.craftmend.openaudiomc.api.basic; + +import java.util.UUID; + +/** + * An actor is a further abstraction of a User from within OpenAudioMc. + * A user is an object representing a platform specific user whose type is given as a parameter to the user class itself. + * An actor is a more abstract version of this, and is used to represent any user, regardless of platform. + */ +public interface Actor { + + /** + * Get the name of the actor (usually the player name) + * @return the name of the actor + */ + String getName(); + + /** + * Get the unique id of the actor (usually the player uuid) + * @return the unique id of the actor + */ + UUID getUniqueId(); + + /** + * If the actor is an administrator (usually a player with OP if we're running on a spigot host, otherwise determined by the platform) + * @return if the actor is an administrator + */ + boolean isAdministrator(); + + /** + * Check if the actor has a certain permission node. This uses the underlying platform's permission system if available. + * @param permissionNode the permission node to check for + * @return if the actor has the permission node + */ + boolean hasPermission(String permissionNode); + + /** + * Make the actor execute a command. This is usually a wrapper around the platform's command sender system. + */ + void sendMessage(String message); + +} diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/basic/package-info.java b/api/src/main/java/com/craftmend/openaudiomc/api/basic/package-info.java new file mode 100644 index 000000000..3bb7072b0 --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/basic/package-info.java @@ -0,0 +1,4 @@ +/** + * This package contains some core abstractions and interfaces that are used in the OpenAudioMc API, mostly to wrap platform native components. + */ +package com.craftmend.openaudiomc.api.basic; \ No newline at end of file diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/clients/Client.java b/api/src/main/java/com/craftmend/openaudiomc/api/clients/Client.java new file mode 100644 index 000000000..da6c784d1 --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/clients/Client.java @@ -0,0 +1,78 @@ +package com.craftmend.openaudiomc.api.clients; + +import com.craftmend.openaudiomc.api.basic.Actor; +import com.craftmend.openaudiomc.api.media.Media; +import org.jetbrains.annotations.NotNull; + +/** + * A player session represents the state of an online player and its corresponding web client connection. + * It's used to interact with the webclient, determine and change state and hook back into the platform specific user object. + */ +public interface Client { + + /** + * Get the actor of the underlying User (usually a player) + * @return the actor + */ + Actor getActor(); + + /** + * Add a on connect handler, which fires when the client gets opened for the player + * + * @param runnable Handler + */ + void onConnect(Runnable runnable); + + /** + * Add a on connect handler, which fires when the client gets closed for by player + * + * @param runnable Handler + */ + void onDisconnect(Runnable runnable); + + /** + * If this client currently has the web session open + * @return if the client is connected + */ + boolean isConnected(); + + /** + * If this session has an active voice chat instance + * @return if the client is in a voice chat + */ + boolean hasVoicechatEnabled(); + + /** + * If this the actor's microphone is muted, false if the actor is not in a voice chat + * @return if the microphone is muted + */ + boolean isMicrophoneMuted(); + + /** + * Get the volume of the client (media volume, 0-100, -1 if unknown or not applicable) + * @return the volume + */ + int getVolume(); + + /** + * If the actor is currently in moderation mode + * @return if the actor is moderating + */ + boolean isModerating(); + + /** + * Play a media for this client + * @param media the media to play + */ + void playMedia(@NotNull Media media); + + /** + * Forcefully remove a player from my proximity chat peers. This will cause the otherClient to trigger a new ClientPeerAddEvent + * Useful for refreshing peers when someone changes game modes, for example. + * This method will not do anything if this user isn't in voice chat, if they aren't a peer, or if the other client isn't in voice chat. + * This action is **one-sided**. The other client will still be listening to this client. + * @param otherClient the client to remove + */ + void kickProximityPeer(@NotNull Client otherClient); + +} diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/clients/package-info.java b/api/src/main/java/com/craftmend/openaudiomc/api/clients/package-info.java new file mode 100644 index 000000000..798d70c29 --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/clients/package-info.java @@ -0,0 +1,4 @@ +/** + * This package contains all the classes that are related to the client system. + */ +package com.craftmend.openaudiomc.api.clients; \ No newline at end of file diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/events/BaseEvent.java b/api/src/main/java/com/craftmend/openaudiomc/api/events/BaseEvent.java new file mode 100644 index 000000000..99b217040 --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/events/BaseEvent.java @@ -0,0 +1,8 @@ +package com.craftmend.openaudiomc.api.events; + +/** + * Base event class, all events should extend this class + */ +public abstract class BaseEvent { + +} diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/events/Cancellable.java b/api/src/main/java/com/craftmend/openaudiomc/api/events/Cancellable.java new file mode 100644 index 000000000..cd1160bf0 --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/events/Cancellable.java @@ -0,0 +1,20 @@ +package com.craftmend.openaudiomc.api.events; + +/** + * Represents an event that can be cancelled + */ +public interface Cancellable { + + /** + * Check if the event is cancelled + * @return true if the event is cancelled + */ + boolean isCancelled(); + + /** + * Set the event to cancelled + * @param cancelled true if the event should be cancelled + */ + void setCancelled(boolean cancelled); + +} diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/events/CancellableClientEvent.java b/api/src/main/java/com/craftmend/openaudiomc/api/events/CancellableClientEvent.java new file mode 100644 index 000000000..73790789b --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/events/CancellableClientEvent.java @@ -0,0 +1,30 @@ +package com.craftmend.openaudiomc.api.events; + +import com.craftmend.openaudiomc.api.clients.Client; + +/** + * A cancellable client event + */ +public class CancellableClientEvent extends ClientEvent implements Cancellable { + + private boolean cancelled = false; + + /** + * Create a new client event + * + * @param client the client that this event is about + */ + public CancellableClientEvent(Client client) { + super(client); + } + + @Override + public boolean isCancelled() { + return cancelled; + } + + @Override + public void setCancelled(boolean cancelled) { + this.cancelled = cancelled; + } +} diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/events/CancellableEvent.java b/api/src/main/java/com/craftmend/openaudiomc/api/events/CancellableEvent.java new file mode 100644 index 000000000..23855af54 --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/events/CancellableEvent.java @@ -0,0 +1,19 @@ +package com.craftmend.openaudiomc.api.events; + +/** + * Represents an event that can be cancelled + */ +public class CancellableEvent extends BaseEvent implements Cancellable { + + private boolean cancelled = false; + + @Override + public boolean isCancelled() { + return cancelled; + } + + @Override + public void setCancelled(boolean cancelled) { + this.cancelled = cancelled; + } +} diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/events/ClientEvent.java b/api/src/main/java/com/craftmend/openaudiomc/api/events/ClientEvent.java new file mode 100644 index 000000000..7772be78a --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/events/ClientEvent.java @@ -0,0 +1,27 @@ +package com.craftmend.openaudiomc.api.events; + +import com.craftmend.openaudiomc.api.clients.Client; + +/** + * Base event for all events carrying a client + */ +public class ClientEvent extends BaseEvent { + + private final Client client; + + /** + * Create a new client event + * @param client the client that this event is about + */ + public ClientEvent(Client client) { + this.client = client; + } + + /** + * Get the client that this event is about + * @return the client + */ + public Client getClient() { + return client; + } +} diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/events/Handler.java b/api/src/main/java/com/craftmend/openaudiomc/api/events/Handler.java new file mode 100644 index 000000000..150e45967 --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/events/Handler.java @@ -0,0 +1,16 @@ +package com.craftmend.openaudiomc.api.events; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +/** + * This is a marker annotation for event handlers, for bukkit-style listeners + * See {@link com.craftmend.openaudiomc.api.EventApi} for more information + */ +public @interface Handler { + +} diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/events/SingleHandler.java b/api/src/main/java/com/craftmend/openaudiomc/api/events/SingleHandler.java new file mode 100644 index 000000000..d8c93136e --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/events/SingleHandler.java @@ -0,0 +1,13 @@ +package com.craftmend.openaudiomc.api.events; + +/** + * This is a type templated lambda for single event handlers. + * See {@link com.craftmend.openaudiomc.api.EventApi} for more information + */ +@FunctionalInterface +public interface SingleHandler { + + @Handler + void handle(T event); + +} diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/events/client/ClientAuthenticationEvent.java b/api/src/main/java/com/craftmend/openaudiomc/api/events/client/ClientAuthenticationEvent.java new file mode 100644 index 000000000..37c2001dc --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/events/client/ClientAuthenticationEvent.java @@ -0,0 +1,29 @@ +package com.craftmend.openaudiomc.api.events.client; + +import com.craftmend.openaudiomc.api.basic.Actor; +import com.craftmend.openaudiomc.api.events.CancellableEvent; +import lombok.Getter; +import lombok.Setter; + +@Getter +/** + * This event is called before a client session is authenticated. + * Cancelling this event will prevent the client from being authenticated and reload the web page. + * Keep in mind that this event is run from the socket thread, and is blocking other clients from connecting. + */ +public class ClientAuthenticationEvent extends CancellableEvent { + + private Actor actor; + private String token; + + /** + * This event is called when a client is attempting to authenticate + * + * @param actor The actor that is trying to authenticate + * @param token The token that is being used + */ + public ClientAuthenticationEvent(Actor actor, String token) { + this.actor = actor; + this.token = token; + } +} diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/events/client/ClientConnectEvent.java b/api/src/main/java/com/craftmend/openaudiomc/api/events/client/ClientConnectEvent.java new file mode 100644 index 000000000..4d06d7446 --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/events/client/ClientConnectEvent.java @@ -0,0 +1,19 @@ +package com.craftmend.openaudiomc.api.events.client; + +import com.craftmend.openaudiomc.api.clients.Client; +import com.craftmend.openaudiomc.api.events.ClientEvent; + +/** + * This event is called whenever a client is successfully connected, or a player switched to this + * server with the web client open + */ +public class ClientConnectEvent extends ClientEvent { + /** + * Create a new client connect event, representing a client that has connected to the web client + * + * @param client the client that this event is about + */ + public ClientConnectEvent(Client client) { + super(client); + } +} diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/events/client/ClientDisconnectEvent.java b/api/src/main/java/com/craftmend/openaudiomc/api/events/client/ClientDisconnectEvent.java new file mode 100644 index 000000000..734ba46f6 --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/events/client/ClientDisconnectEvent.java @@ -0,0 +1,19 @@ +package com.craftmend.openaudiomc.api.events.client; + +import com.craftmend.openaudiomc.api.clients.Client; +import com.craftmend.openaudiomc.api.events.ClientEvent; + +/** + * This event is called whenever a client is successfully disconnected, or a player switched to this + * server with the web client closed + */ +public class ClientDisconnectEvent extends ClientEvent { + /** + * Create a new client event that represents a client that has disconnected from the web client + * + * @param client the client that this event is about + */ + public ClientDisconnectEvent(Client client) { + super(client); + } +} diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/events/client/ClientEnableVoiceEvent.java b/api/src/main/java/com/craftmend/openaudiomc/api/events/client/ClientEnableVoiceEvent.java new file mode 100644 index 000000000..99eb8d8c8 --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/events/client/ClientEnableVoiceEvent.java @@ -0,0 +1,16 @@ +package com.craftmend.openaudiomc.api.events.client; + +import com.craftmend.openaudiomc.api.clients.Client; +import com.craftmend.openaudiomc.api.events.CancellableClientEvent; + +/** + * This event is called whenever a client enables voice chat. + * This event is cancellable, and if cancelled, the client will not be able to enable voice chat (not showing the tab) + */ +public class ClientEnableVoiceEvent extends CancellableClientEvent { + + public ClientEnableVoiceEvent(Client client) { + super(client); + } + +} diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/events/client/ClientPeerAddEvent.java b/api/src/main/java/com/craftmend/openaudiomc/api/events/client/ClientPeerAddEvent.java new file mode 100644 index 000000000..e26a4f7f4 --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/events/client/ClientPeerAddEvent.java @@ -0,0 +1,33 @@ +package com.craftmend.openaudiomc.api.events.client; + +import com.craftmend.openaudiomc.api.clients.Client; +import com.craftmend.openaudiomc.api.events.CancellableClientEvent; +import com.craftmend.openaudiomc.api.voice.VoicePeerOptions; +import lombok.Getter; +import lombok.Setter; + +@Getter +/** + * This event is called whenever a peer is being added to the client, and can be cancelled to prevent the peer from being added. + * This is useful to set up single-sided voice connections, without having to implement a filter. + */ +public class ClientPeerAddEvent extends CancellableClientEvent { + + private Client peer; + + @Setter + private VoicePeerOptions options; + + /** + * Create a new client event + * + * @param client the client that this event is about + * @param peer the peer that was added + * @param options the options that were used to add the peer + */ + public ClientPeerAddEvent(Client client, Client peer, VoicePeerOptions options) { + super(client); + this.peer = peer; + this.options = options; + } +} diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/events/client/ClientPeerRemovedEvent.java b/api/src/main/java/com/craftmend/openaudiomc/api/events/client/ClientPeerRemovedEvent.java new file mode 100644 index 000000000..c81e07bb5 --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/events/client/ClientPeerRemovedEvent.java @@ -0,0 +1,26 @@ +package com.craftmend.openaudiomc.api.events.client; + +import com.craftmend.openaudiomc.api.clients.Client; +import com.craftmend.openaudiomc.api.events.ClientEvent; +import lombok.Getter; +import lombok.Setter; + +@Getter +/** + * This event is called whenever a peer is being removed from the client + */ +public class ClientPeerRemovedEvent extends ClientEvent { + + private Client peer; + + /** + * Create a new client event + * + * @param client the client that this event is about + * @param peer the peer that was removed + */ + public ClientPeerRemovedEvent(Client client, Client peer) { + super(client); + this.peer = peer; + } +} diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/events/client/MediaErrorEvent.java b/api/src/main/java/com/craftmend/openaudiomc/api/events/client/MediaErrorEvent.java new file mode 100644 index 000000000..00015fdf3 --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/events/client/MediaErrorEvent.java @@ -0,0 +1,43 @@ +package com.craftmend.openaudiomc.api.events.client; + +import com.craftmend.openaudiomc.api.clients.Client; +import com.craftmend.openaudiomc.api.events.ClientEvent; +import com.craftmend.openaudiomc.api.media.MediaError; + +/** + * This event is called whenever a media source fails to load or play for the web client + */ +public class MediaErrorEvent extends ClientEvent { + + private final String mediaSource; + private final MediaError mediaError; + + /** + * This event resembles an internal error in the web client (like bad or failed HTTP media requests). + * + * @param client the client that this event is about + */ + public MediaErrorEvent(Client client, String mediaSource, MediaError mediaError) { + super(client); + this.mediaSource = mediaSource; + this.mediaError = mediaError; + } + + /** + * Get the media source that failed + * + * @return the media source + */ + public String getMediaSource() { + return mediaSource; + } + + /** + * Get the media error + * + * @return the media error + */ + public MediaError getMediaError() { + return mediaError; + } +} diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/events/client/MicrophoneMuteEvent.java b/api/src/main/java/com/craftmend/openaudiomc/api/events/client/MicrophoneMuteEvent.java new file mode 100644 index 000000000..5ce4c2402 --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/events/client/MicrophoneMuteEvent.java @@ -0,0 +1,18 @@ +package com.craftmend.openaudiomc.api.events.client; + +import com.craftmend.openaudiomc.api.clients.Client; +import com.craftmend.openaudiomc.api.events.ClientEvent; + +/** + * This event is called whenever a player explicitly mutes their microphone + */ +public class MicrophoneMuteEvent extends ClientEvent { + /** + * This is a simple event that gets called whenever a player explicitly mutes their microphone + * + * @param client the client that this event is about + */ + public MicrophoneMuteEvent(Client client) { + super(client); + } +} diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/events/client/MicrophoneUnmuteEvent.java b/api/src/main/java/com/craftmend/openaudiomc/api/events/client/MicrophoneUnmuteEvent.java new file mode 100644 index 000000000..86b80b276 --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/events/client/MicrophoneUnmuteEvent.java @@ -0,0 +1,18 @@ +package com.craftmend.openaudiomc.api.events.client; + +import com.craftmend.openaudiomc.api.clients.Client; +import com.craftmend.openaudiomc.api.events.ClientEvent; + +/** + * This event is called whenever a player explicitly unmutes their microphone or joins voice chat + */ +public class MicrophoneUnmuteEvent extends ClientEvent { + /** + * This is a simple event that gets called whenever a player explicitly unmutes their microphone + * + * @param client the client that this event is about + */ + public MicrophoneUnmuteEvent(Client client) { + super(client); + } +} diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/events/client/SystemReloadEvent.java b/api/src/main/java/com/craftmend/openaudiomc/api/events/client/SystemReloadEvent.java new file mode 100644 index 000000000..223e868a0 --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/events/client/SystemReloadEvent.java @@ -0,0 +1,10 @@ +package com.craftmend.openaudiomc.api.events.client; + +import com.craftmend.openaudiomc.api.events.BaseEvent; + +/** + * This event is called whenever the plugin is reloaded + */ +public class SystemReloadEvent extends BaseEvent { + // no additional data, fired when the plugin is reloaded for whatever reason +} diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/events/client/VoicechatDeafenEvent.java b/api/src/main/java/com/craftmend/openaudiomc/api/events/client/VoicechatDeafenEvent.java new file mode 100644 index 000000000..d43a0efcb --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/events/client/VoicechatDeafenEvent.java @@ -0,0 +1,18 @@ +package com.craftmend.openaudiomc.api.events.client; + +import com.craftmend.openaudiomc.api.clients.Client; +import com.craftmend.openaudiomc.api.events.ClientEvent; + +/** + * This event is called whenever a player explicitly deafens their audio + */ +public class VoicechatDeafenEvent extends ClientEvent { + /** + * This is a simple event that gets called whenever a player explicitly deafens their audio + * + * @param client the client that this event is about + */ + public VoicechatDeafenEvent(Client client) { + super(client); + } +} diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/events/client/VoicechatPeerTickEvent.java b/api/src/main/java/com/craftmend/openaudiomc/api/events/client/VoicechatPeerTickEvent.java new file mode 100644 index 000000000..9d70661c1 --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/events/client/VoicechatPeerTickEvent.java @@ -0,0 +1,13 @@ +package com.craftmend.openaudiomc.api.events.client; + +import com.craftmend.openaudiomc.api.events.BaseEvent; + +/** + * This event is called whenever the voicechat system ticked, this is useful for things like + * comparing changes in peer states after updates were processed + */ +public class VoicechatPeerTickEvent extends BaseEvent { + + // An empty event that gets fired whenever the voicechat system ticked, this is useful for things like + +} diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/events/client/VoicechatReadyEvent.java b/api/src/main/java/com/craftmend/openaudiomc/api/events/client/VoicechatReadyEvent.java new file mode 100644 index 000000000..f3a130f18 --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/events/client/VoicechatReadyEvent.java @@ -0,0 +1,18 @@ +package com.craftmend.openaudiomc.api.events.client; + +import com.craftmend.openaudiomc.api.clients.Client; +import com.craftmend.openaudiomc.api.events.ClientEvent; + +/** + * This event is called whenever a client is ready to use voicechat + */ +public class VoicechatReadyEvent extends ClientEvent { + /** + * Fired whenever a client is ready to use voicechat + * + * @param client the client that this event is about + */ + public VoicechatReadyEvent(Client client) { + super(client); + } +} diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/events/client/VoicechatUndeafenEvent.java b/api/src/main/java/com/craftmend/openaudiomc/api/events/client/VoicechatUndeafenEvent.java new file mode 100644 index 000000000..2ca897c40 --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/events/client/VoicechatUndeafenEvent.java @@ -0,0 +1,18 @@ +package com.craftmend.openaudiomc.api.events.client; + +import com.craftmend.openaudiomc.api.clients.Client; +import com.craftmend.openaudiomc.api.events.ClientEvent; + +/** + * This event is called whenever a player explicitly undeafens their audio + */ +public class VoicechatUndeafenEvent extends ClientEvent { + /** + * This is a simple event that gets called whenever a player explicitly undeafens their audio + * + * @param client the client that this event is about + */ + public VoicechatUndeafenEvent(Client client) { + super(client); + } +} diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/events/client/package-info.java b/api/src/main/java/com/craftmend/openaudiomc/api/events/client/package-info.java new file mode 100644 index 000000000..7805e5d68 --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/events/client/package-info.java @@ -0,0 +1,5 @@ +/** + * This package contains all events that are related to the client, such as the client connecting, disconnecting, or changing settings. + * Events which implement {@link com.craftmend.openaudiomc.api.events.Cancellable} can be cancelled, which will prevent the action from being applied. + */ +package com.craftmend.openaudiomc.api.events.client; \ No newline at end of file diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/events/package-info.java b/api/src/main/java/com/craftmend/openaudiomc/api/events/package-info.java new file mode 100644 index 000000000..d32e3572e --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/events/package-info.java @@ -0,0 +1,6 @@ +/** + * This package contains our internal event system, which is used to listen to events that are fired by the OpenAudioMc + * Events provided by OA should be listened to through this system, not bukkit/spigot's event system because OA's events + * are cross-platform and can be fired from the web interface as well. + */ +package com.craftmend.openaudiomc.api.events; \ No newline at end of file diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/media/Media.java b/api/src/main/java/com/craftmend/openaudiomc/api/media/Media.java new file mode 100644 index 000000000..7cfca3545 --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/media/Media.java @@ -0,0 +1,125 @@ +package com.craftmend.openaudiomc.api.media; + +import com.craftmend.openaudiomc.api.MediaApi; +import lombok.Getter; +import lombok.Setter; + +import java.util.UUID; + +@Getter +/** + * A Media object represents the full state of a media file, including all settings and options. + * This file is parsed by the client and used to play media of any type, also used internally for regions and speakers. + */ +public class Media { + + /** + * Source value for the media. Typically, a web compatible web link or translatable OA value + */ + private final String source; + + /** + * The unique id of the media, used by the client to keep track of media pools. + * This is a random UUID by default, but can be set to a custom value and will be used to identify the media + * for regions, stop commands and other features. + */ + @Setter + private String mediaId = UUID.randomUUID().toString(); + + /** + * An epoch millisecond timestamp of when the media started playing, used by the client to calculate the current position + * if keepup is configured (time spent + startAtMillis) + */ + @Setter + private long startInstant; + + /** + * Keep timeout is the amount of seconds that the openaudiomc plugin runtime should keep track of this media for. + * Used to retroactively play media if a client connected too late. optional, -1 by default to disable. + */ + @Setter + private transient int keepTimeout = -1; + + /** + * If the media should attempt to pick up where its currently according to the time spent since the start instant. + */ + @Setter + private boolean doPickup = false; + + /** + * If the media should loop (jumping back to startAtMillis and playing again) + */ + @Setter + private boolean loop = false; + + /** + * Fade time is the amount of milliseconds it takes to fade in or out. 0 by default, but can be used to create smooth transitions + * between multiple regions, or to create a fade in effect. + */ + @Setter + private int fadeTime = 0; + + /** + * The volume of the media, 0-100 + */ + @Setter + private int volume = 100; + + /** + * If this media will mute current regions while playing. This is used to prevent overlapping media in regions. + */ + @Setter + private boolean muteRegions = false; + + /** + * If this media will mute the speakers of the client. This is used to prevent overlapping media with speakers. + */ + @Setter + private boolean muteSpeakers = false; + + /** + * The starting point of the media, in milliseconds. 0 by default, but can be used to skip intros or start at a certain point. + */ + @Setter + private int startAtMillis = 0; + + /** + * The flag of the media, used to identify the type of media. This is used by the client to apply different settings + * based on the type of media. This is set to DEFAULT by default, but can be set to REGION or SPEAKER to apply different settings. + */ + @Setter + private MediaFlag flag = MediaFlag.DEFAULT; + + /** + * Create a new media based on a url + * the source will first be processed by the mutation api + * so you can just use addons without needing to wor§§ry + * + * @param source the resource url + */ + public Media(String source) { + this.source = MediaApi.getInstance().translateSource(source); + this.startInstant = MediaApi.getInstance().getNormalizedCurrentEpoch(); + } + + /** + * You can apply multiple options. + * Used by the commands to allow settings via JSON + * + * @param options The options. Selected via the command + * @return instance of self + */ + public Media applySettings(MediaOptions options) { + this.loop = options.isLoop(); + this.keepTimeout = options.getExpirationTimeout(); + if (options.getId() != null) this.mediaId = options.getId(); + this.doPickup = options.isPickUp(); + this.setFadeTime(options.getFadeTime()); + this.volume = options.getVolume(); + this.muteRegions = options.isMuteRegions(); + this.muteSpeakers = options.isMuteSpeakers(); + this.startAtMillis = options.getStartAtMillis(); + return this; + } + +} diff --git a/plugin/src/main/java/com/craftmend/openaudiomc/generic/networking/enums/MediaError.java b/api/src/main/java/com/craftmend/openaudiomc/api/media/MediaError.java similarity index 81% rename from plugin/src/main/java/com/craftmend/openaudiomc/generic/networking/enums/MediaError.java rename to api/src/main/java/com/craftmend/openaudiomc/api/media/MediaError.java index bd5ca3949..cbf1d0dd3 100644 --- a/plugin/src/main/java/com/craftmend/openaudiomc/generic/networking/enums/MediaError.java +++ b/api/src/main/java/com/craftmend/openaudiomc/api/media/MediaError.java @@ -1,7 +1,11 @@ -package com.craftmend.openaudiomc.generic.networking.enums; +package com.craftmend.openaudiomc.api.media; import lombok.Getter; +/** + * This enum contains all possible media errors that can occur when trying to play a media file. + * This can be used to determine what went wrong, and to provide a better user experience. + */ public enum MediaError { YOUTUBE_ERR("The provided youtube video is invalid, private or is unavailable. We recommend that you upload your own content."), diff --git a/plugin/src/main/java/com/craftmend/openaudiomc/generic/media/enums/MediaFlag.java b/api/src/main/java/com/craftmend/openaudiomc/api/media/MediaFlag.java similarity index 72% rename from plugin/src/main/java/com/craftmend/openaudiomc/generic/media/enums/MediaFlag.java rename to api/src/main/java/com/craftmend/openaudiomc/api/media/MediaFlag.java index 2b9f9d56f..1f10ff913 100644 --- a/plugin/src/main/java/com/craftmend/openaudiomc/generic/media/enums/MediaFlag.java +++ b/api/src/main/java/com/craftmend/openaudiomc/api/media/MediaFlag.java @@ -1,4 +1,4 @@ -package com.craftmend.openaudiomc.generic.media.enums; +package com.craftmend.openaudiomc.api.media; /** * The 3 types of sounds, used by the client to mark the WebAudioType diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/media/MediaOptions.java b/api/src/main/java/com/craftmend/openaudiomc/api/media/MediaOptions.java new file mode 100644 index 000000000..b1e078fae --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/media/MediaOptions.java @@ -0,0 +1,82 @@ +package com.craftmend.openaudiomc.api.media; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +/** + * This class is a model for media options, which are used to configure media objects. + * Media options are used to configure the behavior of a media object, such as volume, looping, and fading. + * These can be set on the Media object itself too, but this serves as an intermediate object to help apply serialized media options. + */ +public class MediaOptions { + + /** + * If the media should repeat when it ends + */ + private boolean loop = false; + + /** + * The id of the media, this is used to identify the media + */ + private String id; + + /** + * Keep expirationTimeout/keepTimeout is the amount of seconds that the openaudiomc plugin runtime should keep track of this media for. + * Used to retroactively play media if a client connected too late. optional, -1 by default to disable. + */ + private int expirationTimeout = -1; + + /** + * If the media should attempt to pick up where its currently according to the time spent since the start instant. + */ + private boolean pickUp = true; + + /** + * Fade time is the amount of milliseconds it takes to fade in or out. 0 by default, but can be used to create smooth transitions + * between multiple regions, or to create a fade in effect. + */ + private int fadeTime = 0; + + /** + * The volume of the media, 0-100 + */ + private int volume = 100; + + /** + * If this media will mute current regions while playing. This is used to prevent overlapping media in regions. + */ + private boolean muteSpeakers = false; + + /** + * If this media will mute the speakers of the client. This is used to prevent overlapping media with speakers. + */ + private boolean muteRegions = false; + + /** + * The starting point of the media, in milliseconds. 0 by default, but can be used to skip intros or start at a certain point. + */ + private int startAtMillis = 0; + + /** + * validation rules for the media options + * @return a validation result + */ + public OptionalError validate() { + if (volume > 100) + return new OptionalError(true, "Volume may not be over 100"); + + if (volume < 0) + return new OptionalError(true, "Volume may not be lower than 0"); + + if (volume == 0) + return new OptionalError(true, "You shouldn't even play it if the volume is 0"); + + if (fadeTime < 0) + return new OptionalError(true, "Fade time can't be negative"); + + return new OptionalError(false, ""); + } + +} diff --git a/plugin/src/main/java/com/craftmend/openaudiomc/generic/media/objects/OptionalError.java b/api/src/main/java/com/craftmend/openaudiomc/api/media/OptionalError.java similarity index 67% rename from plugin/src/main/java/com/craftmend/openaudiomc/generic/media/objects/OptionalError.java rename to api/src/main/java/com/craftmend/openaudiomc/api/media/OptionalError.java index 89ea33ebf..5ad712096 100644 --- a/plugin/src/main/java/com/craftmend/openaudiomc/generic/media/objects/OptionalError.java +++ b/api/src/main/java/com/craftmend/openaudiomc/api/media/OptionalError.java @@ -1,10 +1,13 @@ -package com.craftmend.openaudiomc.generic.media.objects; +package com.craftmend.openaudiomc.api.media; import lombok.AllArgsConstructor; import lombok.Getter; @Getter @AllArgsConstructor +/** + * Represents an optional error + */ public class OptionalError { private boolean error; diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/media/UrlMutation.java b/api/src/main/java/com/craftmend/openaudiomc/api/media/UrlMutation.java new file mode 100644 index 000000000..2e430bf16 --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/media/UrlMutation.java @@ -0,0 +1,20 @@ +package com.craftmend.openaudiomc.api.media; + +import org.jetbrains.annotations.NotNull; + +/** + * URL mutations can be used to register custom server-side media hooks or source translators. + * An example use case would be a custom media server aliased by hypixel:, which can be resolved + * to https://hypixel.com/media/* by a mutation. See {@link com.craftmend.openaudiomc.api.MediaApi the media api} for more information. + */ +public interface UrlMutation { + /** + * Translate a custom source to a full media URL. + * + * @param original The original source as given in the createMedia method or any command + * @return the URL that should be used for playback + */ + @NotNull + String onRequest(@NotNull String original); + +} diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/media/package-info.java b/api/src/main/java/com/craftmend/openaudiomc/api/media/package-info.java new file mode 100644 index 000000000..a4006b186 --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/media/package-info.java @@ -0,0 +1,5 @@ +/** + * This package contains all models to create and mutate media objects. + * Please see the {@link com.craftmend.openaudiomc.api.MediaApi} for more information on how to use this package. + */ +package com.craftmend.openaudiomc.api.media; \ No newline at end of file diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/package-info.java b/api/src/main/java/com/craftmend/openaudiomc/api/package-info.java new file mode 100644 index 000000000..cddfc5104 --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/package-info.java @@ -0,0 +1,10 @@ +/** + * The OpenAudioMc API + * + *

+ * This package contains the API for OpenAudioMc, which is a plugin that allows you to create custom audio experiences in Minecraft. + *

+ * + * @since 6.10.0 + */ +package com.craftmend.openaudiomc.api; \ No newline at end of file diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/regions/AudioRegion.java b/api/src/main/java/com/craftmend/openaudiomc/api/regions/AudioRegion.java new file mode 100644 index 000000000..f05c92b4e --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/regions/AudioRegion.java @@ -0,0 +1,39 @@ +package com.craftmend.openaudiomc.api.regions; + +import com.craftmend.openaudiomc.api.media.Media; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Represents a region that can play audio. Obtainable through {@link com.craftmend.openaudiomc.api.WorldApi the world api} + */ +public interface AudioRegion { + + /** + * Get the media playing in this region + * @return media + */ + @NotNull + Media getMedia(); + + /** + * Get the region id + * @return id + */ + @NotNull + String getRegionId(); + + /** + * Get the world this region is in, can be null if its legacy + * @return world + */ + @Nullable + String getWorld(); + + /** + * Get the priority of this region + * @return priority + */ + int getPriority(); + +} diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/regions/package-info.java b/api/src/main/java/com/craftmend/openaudiomc/api/regions/package-info.java new file mode 100644 index 000000000..70ed952fd --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/regions/package-info.java @@ -0,0 +1,4 @@ +/** + * This package contains all the classes and interfaces that are related to the region system. + */ +package com.craftmend.openaudiomc.api.regions; \ No newline at end of file diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/speakers/BasicSpeaker.java b/api/src/main/java/com/craftmend/openaudiomc/api/speakers/BasicSpeaker.java new file mode 100644 index 000000000..4ba432b7a --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/speakers/BasicSpeaker.java @@ -0,0 +1,62 @@ +package com.craftmend.openaudiomc.api.speakers; + +import com.craftmend.openaudiomc.api.media.Media; +import org.jetbrains.annotations.NotNull; + +import java.util.Set; +import java.util.UUID; + +/** + * Represents a basic speaker placed in the world + * Obtainable through {@link com.craftmend.openaudiomc.api.WorldApi the world api} + */ +public interface BasicSpeaker { + + /** + * Get the location of the speaker + * @return location + */ + @NotNull + Loc getLocation(); + + /** + * Get the media that's being played by this speaker + * @return media + */ + @NotNull + Media getMedia(); + + /** + * Get the speaker id + * @return id + */ + @NotNull + UUID getSpeakerId(); + + /** + * Get the type of speaker (spatial audio, or static) + * @return speaker type + */ + SpeakerType getSpeakerType(); + + /** + * Get extra options for the speaker + * @return options + */ + @NotNull + Set getExtraOptions(); + + /** + * Get the radius of the speaker + * @return radius + */ + @NotNull + Integer getRadius(); + + /** + * If this speaker is currently directly powered by redstone + * @return is powered + */ + boolean isRedstonePowered(); + +} diff --git a/plugin/src/main/java/com/craftmend/openaudiomc/spigot/modules/speakers/enums/ExtraSpeakerOptions.java b/api/src/main/java/com/craftmend/openaudiomc/api/speakers/ExtraSpeakerOptions.java similarity index 57% rename from plugin/src/main/java/com/craftmend/openaudiomc/spigot/modules/speakers/enums/ExtraSpeakerOptions.java rename to api/src/main/java/com/craftmend/openaudiomc/api/speakers/ExtraSpeakerOptions.java index 7d0f46d62..82aacd3ca 100644 --- a/plugin/src/main/java/com/craftmend/openaudiomc/spigot/modules/speakers/enums/ExtraSpeakerOptions.java +++ b/api/src/main/java/com/craftmend/openaudiomc/api/speakers/ExtraSpeakerOptions.java @@ -1,13 +1,18 @@ -package com.craftmend.openaudiomc.spigot.modules.speakers.enums; +package com.craftmend.openaudiomc.api.speakers; -import com.craftmend.openaudiomc.spigot.modules.speakers.objects.Speaker; import lombok.Getter; import java.util.Arrays; import java.util.function.Predicate; +/** + * Represents extra options for a speaker + */ public enum ExtraSpeakerOptions { + /** + * This option will ignore audio synchronization for this speaker specifically + */ IGNORE_SYNCHRONIZATION( true, "Ignore Synchronization", @@ -16,6 +21,10 @@ public enum ExtraSpeakerOptions { ), + @Deprecated + /** + * This setting is no longer exposed or shown, but still exists for backwards compatibility + */ PROCESS_OBSTRUCTIONS( false, "Process Obstructions", @@ -23,6 +32,9 @@ public enum ExtraSpeakerOptions { speaker -> speaker.getSpeakerType() == SpeakerType.SPEAKER_3D ), + /** + * This setting will make the speaker play once, and then never again until the player leaves the area and re-enters + */ PLAY_ONCE( true, "Play Once", @@ -30,6 +42,9 @@ public enum ExtraSpeakerOptions { speaker -> true ), + /** + * This setting will only make the speaker active/discoverable when its directly powered by redstone + */ REQUIRES_REDSTONE( true, "Requires Redstone", @@ -37,31 +52,44 @@ public enum ExtraSpeakerOptions { speaker -> true ), + /** + * This setting will make the media timestamp reset once it loses redstone power + */ RESET_PLAYTHROUGH_ON_REDSTONE_LOSS( true, "Reset Playthrough on Redstone Loss", "When the speaker loses redstone power, it will reset the playthrough", speaker -> true + ), + + /** + * This setting will mute all the regions for the player as long as they are in range of this speaker + */ + OVERWRITE_REGIONS( + true, + "Overwrite Regions", + "Temporarily mute regions that are playing audio", + speaker -> true ) ; @Getter private boolean display; @Getter private String title; @Getter private String description; - private Predicate[] predicates; + private Predicate[] predicates; - ExtraSpeakerOptions(boolean display, String title, String description, Predicate... requirementChecks) { + ExtraSpeakerOptions(boolean display, String title, String description, Predicate... requirementChecks) { this.display = display; this.title = title; this.description = description; this.predicates = requirementChecks; } - public boolean isCompatibleWith(Speaker speaker) { + public boolean isCompatibleWith(BasicSpeaker speaker) { return Arrays.stream(this.predicates).allMatch(predicate -> predicate.test(speaker)); } - public boolean isEnabledFor(Speaker speaker) { + public boolean isEnabledFor(BasicSpeaker speaker) { return speaker.getExtraOptions().contains(this) && isCompatibleWith(speaker); } diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/speakers/Loc.java b/api/src/main/java/com/craftmend/openaudiomc/api/speakers/Loc.java new file mode 100644 index 000000000..ab0bad3c2 --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/speakers/Loc.java @@ -0,0 +1,59 @@ +package com.craftmend.openaudiomc.api.speakers; + +import org.jetbrains.annotations.NotNull; + +/** + * Represents a location in the world, regardless of the server implementation + */ +public interface Loc { + + /** + * Get the x coordinate + * @return x + */ + int getX(); + + /** + * Get the y coordinate + * @return y + */ + int getY(); + + /** + * Get the z coordinate + * @return z + */ + int getZ(); + + /** + * Get the world name + * @return world + */ + @NotNull + String getWorld(); + + /** + * Set the x coordinate + * @param x x + */ + void setX(int x); + + /** + * Set the y coordinate + * @param y y + */ + void setY(int y); + + /** + * Set the z coordinate + * @param z z + */ + void setZ(int z); + + /** + * Set the world name + * @param world world + */ + void setWorld(@NotNull String world); + +} diff --git a/plugin/src/main/java/com/craftmend/openaudiomc/spigot/modules/speakers/enums/SpeakerType.java b/api/src/main/java/com/craftmend/openaudiomc/api/speakers/SpeakerType.java similarity index 79% rename from plugin/src/main/java/com/craftmend/openaudiomc/spigot/modules/speakers/enums/SpeakerType.java rename to api/src/main/java/com/craftmend/openaudiomc/api/speakers/SpeakerType.java index 484c1028e..a83692c4d 100644 --- a/plugin/src/main/java/com/craftmend/openaudiomc/spigot/modules/speakers/enums/SpeakerType.java +++ b/api/src/main/java/com/craftmend/openaudiomc/api/speakers/SpeakerType.java @@ -1,7 +1,10 @@ -package com.craftmend.openaudiomc.spigot.modules.speakers.enums; +package com.craftmend.openaudiomc.api.speakers; import lombok.Getter; +/** + * Represents a speaker type + */ public enum SpeakerType { SPEAKER_2D("2D", "Only bases volume on distance"), diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/speakers/package-info.java b/api/src/main/java/com/craftmend/openaudiomc/api/speakers/package-info.java new file mode 100644 index 000000000..b1f9eac12 --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/speakers/package-info.java @@ -0,0 +1,4 @@ +/** + * This package contains all the classes and interfaces that are related to the speaker system. + */ +package com.craftmend.openaudiomc.api.speakers; \ No newline at end of file diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/voice/CustomPlayerFilter.java b/api/src/main/java/com/craftmend/openaudiomc/api/voice/CustomPlayerFilter.java new file mode 100644 index 000000000..7b32b7bd0 --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/voice/CustomPlayerFilter.java @@ -0,0 +1,47 @@ +package com.craftmend.openaudiomc.api.voice; + +import org.bukkit.entity.Player; + +/** + *

This represents a function that can be implemented by any plugin in order to modify how players will be checked + * against each other.

+ * + *

The easiest example is a game plugin, which will not let players hear each other if they are on different teams, or moderation + * where you would prevent two players from hearing each other if one of them has been muted/punished.

+ * + *

This function is called AFTER the other sanity checks at the time of writing, meaning that these functions will + * be called assuming the players are in valid range of each other and so forth.

+ * + *

Please note that a filter is only called in one direction, meaning that if its called for playerA -> playerB, it will not be called for playerB -> playerA. + * This is done as an optimization to prevent poor scaling with a large amount of players.

+ * + *

Because of that, filters should only be used to filter players that should be considered for voicechat. + * If you with to setup one-directional voicechat, then you should use {@link com.craftmend.openaudiomc.api.events.client.ClientPeerAddEvent}, which will be called twice + * (once for each player) and allow you to cancel the event if you don't want the players to be able to hear each other.

+ * + *

Filters are managed through the {@link com.craftmend.openaudiomc.api.VoiceApi}

+ * + * @author DiamondDagger590 + * @author Mats + * @since 6.10.0 + */ +@FunctionalInterface +public interface CustomPlayerFilter { + + /** + * This method is effectively a filter call from a Stream. + * The return value decides if the two players should be allowed to connect in voicechat, granted that the events (which are only fired + * for players who pass) aren't cancelled. + *
+ * This method is called once for every combination of players (ignoring order) + *
+ * If this combination should result in a valid connection, then the method should return {@code true}. + * If the combination should not be valid, then the method should return {@code false}, which will also prevent further + * events or filters from being called for this combination. + * + * @param listener The {@link Player} searching for other players to listen to + * @param possibleSpeaker The {@link Player} who is being checked to see if they can be heard + * @return {@code true} if the listener should be able to hear the possibleSpeaker + */ + boolean isPlayerValidListener(Player listener, Player possibleSpeaker); +} diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/voice/VoicePeerOptions.java b/api/src/main/java/com/craftmend/openaudiomc/api/voice/VoicePeerOptions.java new file mode 100644 index 000000000..3c81e12c5 --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/voice/VoicePeerOptions.java @@ -0,0 +1,48 @@ +package com.craftmend.openaudiomc.api.voice; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor + +/** + * Voice chat options are special flags set for each peer, giving the client extra information on how the connection + * should be treated and rendered. The initial state is defined by the default values in this class, and get passed during + * the connection request packet sent towards the client. These settings can also be changed on the fly, by pushing them + * in a separate client options update packet without needing the session to reload. + */ +public class VoicePeerOptions implements Cloneable { + + /** + * Singleton default. Means we don't have to create a new object every time we want to use the default. + */ + public static final VoicePeerOptions DEFAULT = new VoicePeerOptions(); + + /** + * Weather or not the peer should be visible in the web UI. + * The client will still setup a voice stream to the peer if set to false, but completely + * hide the person's name/uuid from the web UI. + * This can be used for example to hide an opponent in a game, while still letting them hear each other. + */ + private boolean visible = true; + + /** + * This flag decides if the stream should be rendered as a spatial stream. This also requires the client to receive + * location updates every few ticks. Setting it to false just renders it as a normal mono stream (think discord/teamspeak). + * The icon next to someone's name also gets controlled by this flag. + */ + private boolean spatialAudio = true; + + /** + * Clone the object + * @return a clone of the object + */ + @Override + public VoicePeerOptions clone() { + return new VoicePeerOptions(visible, spatialAudio); + } + +} diff --git a/api/src/main/java/com/craftmend/openaudiomc/api/voice/package-info.java b/api/src/main/java/com/craftmend/openaudiomc/api/voice/package-info.java new file mode 100644 index 000000000..7413fddd9 --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/api/voice/package-info.java @@ -0,0 +1,4 @@ +/** + * This package contains all classes and interfaces that are related to the voice chat feature of OpenAudioMc. + */ +package com.craftmend.openaudiomc.api.voice; \ No newline at end of file diff --git a/api/src/main/java/com/craftmend/openaudiomc/generic/media/objects/Media.java b/api/src/main/java/com/craftmend/openaudiomc/generic/media/objects/Media.java new file mode 100644 index 000000000..02febb68c --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/generic/media/objects/Media.java @@ -0,0 +1,11 @@ +package com.craftmend.openaudiomc.generic.media.objects; + +@Deprecated +/** + * @deprecated This class only exists for backwards compatibility, and will be removed in the future. Use {@link com.craftmend.openaudiomc.api.media.Media} instead. + */ +public class Media extends com.craftmend.openaudiomc.api.media.Media { + public Media(String source) { + super(source); + } +} diff --git a/api/src/main/java/com/craftmend/openaudiomc/generic/media/objects/MediaOptions.java b/api/src/main/java/com/craftmend/openaudiomc/generic/media/objects/MediaOptions.java new file mode 100644 index 000000000..aca2eb125 --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/generic/media/objects/MediaOptions.java @@ -0,0 +1,8 @@ +package com.craftmend.openaudiomc.generic.media.objects; + +@Deprecated +/** + * @deprecated This class only exists for backwards compatibility, and will be removed in the future. Use {@link com.craftmend.openaudiomc.api.media.MediaOptions} instead. + */ +public class MediaOptions extends com.craftmend.openaudiomc.api.media.MediaOptions { +} diff --git a/api/src/main/java/com/craftmend/openaudiomc/generic/media/objects/package-info.java b/api/src/main/java/com/craftmend/openaudiomc/generic/media/objects/package-info.java new file mode 100644 index 000000000..103014245 --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/generic/media/objects/package-info.java @@ -0,0 +1,6 @@ +/** + * This package only contains some deprecated classes by the name/classpath of some old commonly used classes. + * They are kept here to prevent breaking changes in the API, but are not used anymore and will be removed in the future. + */ +@Deprecated +package com.craftmend.openaudiomc.generic.media.objects; \ No newline at end of file diff --git a/api/src/main/java/com/craftmend/openaudiomc/spigot/modules/voicechat/filters/CustomFilterFunction.java b/api/src/main/java/com/craftmend/openaudiomc/spigot/modules/voicechat/filters/CustomFilterFunction.java new file mode 100644 index 000000000..ac370b6d3 --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/spigot/modules/voicechat/filters/CustomFilterFunction.java @@ -0,0 +1,11 @@ +package com.craftmend.openaudiomc.spigot.modules.voicechat.filters; + +import com.craftmend.openaudiomc.api.voice.CustomPlayerFilter; + +@FunctionalInterface +@Deprecated +/** + * This is a deprecated class, only kept to prevent breaking changes in the API due to class path. + * Please use {@link CustomPlayerFilter} instead + */ +public interface CustomFilterFunction extends CustomPlayerFilter {} diff --git a/api/src/main/java/com/craftmend/openaudiomc/spigot/modules/voicechat/filters/package-info.java b/api/src/main/java/com/craftmend/openaudiomc/spigot/modules/voicechat/filters/package-info.java new file mode 100644 index 000000000..025d6e792 --- /dev/null +++ b/api/src/main/java/com/craftmend/openaudiomc/spigot/modules/voicechat/filters/package-info.java @@ -0,0 +1,6 @@ +/** + * This package only contains some deprecated classes by the name/classpath of some old commonly used classes. + * They are kept here to prevent breaking changes in the API, but are not used anymore and will be removed in the future. + */ +@Deprecated +package com.craftmend.openaudiomc.spigot.modules.voicechat.filters; \ No newline at end of file diff --git a/client/package-lock.json b/client/package-lock.json index 3e1e43b3c..e640df9ee 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -19,6 +19,7 @@ "react-router": "^6.4.5", "react-router-dom": "^6.4.5", "react-toastify": "9.1.3", + "react-tooltip": "^5.26.0", "redux": "^4.2.0", "socket.io-client": "^2.5.0", "web-vitals": "^2.1.4", @@ -40,7 +41,7 @@ "postcss-cli": "^10.1.0", "qrcode-terminal": "^0.12.0", "tailwindcss": "^3.3.3", - "vite": "^4.4.8", + "vite": "^4.4.12", "vite-plugin-eslint": "^1.8.1", "vite-plugin-svgr": "^3.2.0" } @@ -55,9 +56,9 @@ } }, "node_modules/@adobe/css-tools": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.2.0.tgz", - "integrity": "sha512-E09FiIft46CmH5Qnjb0wsW54/YQd69LsxeKUOWawmws1XWvyFGURnAChH0mlr7YPFR1ofwvUQfcL0J3lMxXqPA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.3.2.tgz", + "integrity": "sha512-DA5a1C0gD/pLOvhv33YMrbf2FK3oUzwNl9oOJqE4XVjuEtt6XIakRcsd7eLiOSPkp1kTRQGICTA8cKra/vFbjw==", "dev": true }, "node_modules/@alloc/quick-lru": { @@ -988,6 +989,28 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.0.tgz", + "integrity": "sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==", + "dependencies": { + "@floating-ui/utils": "^0.2.1" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.1.tgz", + "integrity": "sha512-iA8qE43/H5iGozC3W0YSnVSW42Vh522yyM1gj+BqRwVsTNOyr231PsXDaV04yT39PsO0QL2QpbI/M0ZaLUQgRQ==", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.1" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz", + "integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==" + }, "node_modules/@fontsource/roboto": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.0.8.tgz", @@ -2337,6 +2360,11 @@ "node": ">=8" } }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -5694,6 +5722,19 @@ "react-dom": ">=16" } }, + "node_modules/react-tooltip": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/react-tooltip/-/react-tooltip-5.26.0.tgz", + "integrity": "sha512-UBbwy3fo1KYDwRCOWwM6AEfQsk9shgVfNkXFqgwS33QHplzg7xao/7mX/6wd+lE6KSZzhUNTkB5TNk9SMaBV/A==", + "dependencies": { + "@floating-ui/dom": "^1.0.0", + "classnames": "^2.3.0" + }, + "peerDependencies": { + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -6575,14 +6616,14 @@ } }, "node_modules/vite": { - "version": "4.4.8", - "resolved": "https://registry.npmjs.org/vite/-/vite-4.4.8.tgz", - "integrity": "sha512-LONawOUUjxQridNWGQlNizfKH89qPigK36XhMI7COMGztz8KNY0JHim7/xDd71CZwGT4HtSRgI7Hy+RlhG0Gvg==", + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.4.12.tgz", + "integrity": "sha512-KtPlUbWfxzGVul8Nut8Gw2Qe8sBzWY+8QVc5SL8iRFnpnrcoCaNlzO40c1R6hPmcdTwIPEDkq0Y9+27a5tVbdQ==", "dev": true, "dependencies": { "esbuild": "^0.18.10", - "postcss": "^8.4.26", - "rollup": "^3.25.2" + "postcss": "^8.4.27", + "rollup": "^3.27.1" }, "bin": { "vite": "bin/vite.js" diff --git a/client/package.json b/client/package.json index b7b3d9362..55c6f5763 100644 --- a/client/package.json +++ b/client/package.json @@ -16,6 +16,7 @@ "react-router": "^6.4.5", "react-router-dom": "^6.4.5", "react-toastify": "9.1.3", + "react-tooltip": "^5.26.0", "redux": "^4.2.0", "socket.io-client": "^2.5.0", "web-vitals": "^2.1.4", @@ -58,7 +59,7 @@ "postcss-cli": "^10.1.0", "qrcode-terminal": "^0.12.0", "tailwindcss": "^3.3.3", - "vite": "^4.4.8", + "vite": "^4.4.12", "vite-plugin-eslint": "^1.8.1", "vite-plugin-svgr": "^3.2.0" } diff --git a/client/public/en.lang b/client/public/en.lang index 6d67d0161..ac7002fb6 100644 --- a/client/public/en.lang +++ b/client/public/en.lang @@ -64,7 +64,7 @@ vc.settingsDisablePositionalAudio=Disable Positional Audio vc.automaticAdjustments=Adjust automatically vc.sensitivity=Noise Gate vc.aboutSensitivity=Configure the noise gate of your microphone, this will automatically mute your microphone when you're not talking. Fully to the left will disable the noise gate, fully to the right will mute your microphone all the time. -vc.peerTable={total} people within voice range, {talking} talking +vc.peerTable=connected to {total} players, {talking} talking vc.toggleMicrophone=Toggle Microphone vc.you=you vc.safetyTitle=For your safety @@ -96,7 +96,11 @@ vc.peersHiddenTitle=Voicechat players are hidden vc.peersHiddenText=This server has disabled the ability to see who is in voice chat to prevent unfair advantages, you can still hear them though. vc.isTalking=is talking vc.people=people +vc.person=person vc.deafened=You are currently deafened +vc.tooltip.local=Proximity Voice Chat +vc.tooltip.global=Global Voice Chat + settings.voicechat.echocancel.title=Echo cancellation settings.voicechat.echocancel.body=This will attempt to cancel out any echo that your microphone picks up, this is useful if you're using speakers instead of headphones but may make your harder to hear on some platforms. @@ -140,4 +144,6 @@ settings.interpolation.title=Location smoothing settings.interpolation.body=Automatically smooth movements to minimize speaker/voicechat stuttering while walking. This adds extra movement delay, but sounds better. settings.interpolation.button=Enable smoothing +network.serverUnhealthy={serverName}'s connection to OpenAudioMc is unstable, client functionality may be limited until the connection is restored. + tooltip.close=Close diff --git a/client/public/metadata.json b/client/public/metadata.json index 9c9f63a3d..106394c15 100644 --- a/client/public/metadata.json +++ b/client/public/metadata.json @@ -1 +1 @@ -{"buildMajor":1,"buildMinor":125,"buildRevision":194,"buildTag":"dev","buildDate":"Sun Jan 14 2024","build":"1.125.194 dev"} \ No newline at end of file +{"buildMajor":1,"buildMinor":125,"buildRevision":224,"buildTag":"dev","buildDate":"Fri Feb 23 2024","build":"1.125.224 dev"} \ No newline at end of file diff --git a/client/public/nl.lang b/client/public/nl.lang index b658831dc..6503004a7 100644 --- a/client/public/nl.lang +++ b/client/public/nl.lang @@ -137,4 +137,6 @@ settings.interpolation.title=Locatie versoepelen settings.interpolation.body=Automatisch bewegingen versoepelen om het stotteren van de luidsprekers/voicechat tijdens het lopen te minimaliseren. Dit voegt extra bewegingsvertraging toe, maar klinkt beter. settings.interpolation.button=Versoepelen inschakelen +network.serverUnhealthy=De verbinding tussen {serverName} en OpenAudioMc ondervindt problemen. Sommige functies werken mogelijk niet goed tot dit is opgelost. + tooltip.close=Sluiten diff --git a/client/src/App.jsx b/client/src/App.jsx index 37fb2b591..444686001 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1,4 +1,5 @@ import './App.css'; +import 'react-tooltip/dist/react-tooltip.css'; import React from 'react'; import { Provider } from 'react-redux'; import OpenAudioAppContainer from './client/OpenAudioAppContainer'; diff --git a/client/src/client/config/MagicValues.jsx b/client/src/client/config/MagicValues.jsx index 32ee57ab6..0358959df 100644 --- a/client/src/client/config/MagicValues.jsx +++ b/client/src/client/config/MagicValues.jsx @@ -13,3 +13,7 @@ export function isDomainOfficial(d) { } return false; } + +export function isCurrentClientDomainOfficial() { + return isDomainOfficial(window.location.hostname); +} diff --git a/client/src/client/services/debugging/DebugStatistic.js b/client/src/client/services/debugging/DebugStatistic.js index 2b52820e6..8706a936d 100644 --- a/client/src/client/services/debugging/DebugStatistic.js +++ b/client/src/client/services/debugging/DebugStatistic.js @@ -9,4 +9,5 @@ export const DebugStatistic = { VB_EVENTS: { name: 'VB Events', fill: false, advancesItself: false }, CACHED_STREAMS: { name: 'Cached Streams', fill: false, advancesItself: false }, PRELOADED_SOUNDS: { name: 'Preloaded Sounds', fill: false, advancesItself: false }, + MEDIA_LOAD_TIME: { name: 'Media Load Time (MS)', fill: false, advancesItself: false }, }; diff --git a/client/src/client/services/media/MediaManager.jsx b/client/src/client/services/media/MediaManager.jsx index 47c31fe17..ac008e4a0 100644 --- a/client/src/client/services/media/MediaManager.jsx +++ b/client/src/client/services/media/MediaManager.jsx @@ -73,7 +73,7 @@ export const MediaManager = new class IMediaManager { } destroySounds(soundId, all, instantly, transition, atTheEnd = () => {}) { - debugLog('Destroying sounds', soundId, all, instantly, transition, atTheEnd); + debugLog('Destroying sounds', soundId, all, instantly, transition); let time = transition; if (time == null) { time = 500; diff --git a/client/src/client/services/media/objects/Channel.jsx b/client/src/client/services/media/objects/Channel.jsx index 5d99bc1be..adb86c1e9 100644 --- a/client/src/client/services/media/objects/Channel.jsx +++ b/client/src/client/services/media/objects/Channel.jsx @@ -143,7 +143,7 @@ export class Channel { tick() { // tick all sounds this.sounds.forEach((sound) => { - sound.tick(); + sound.tick.bind(sound)(); }); } diff --git a/client/src/client/services/media/objects/Sound.jsx b/client/src/client/services/media/objects/Sound.jsx index 294dd06a3..98b9737f8 100644 --- a/client/src/client/services/media/objects/Sound.jsx +++ b/client/src/client/services/media/objects/Sound.jsx @@ -1,12 +1,12 @@ -import { GetAudio } from '../../../util/AudioFactory'; -import { AUDIO_ENDPOINTS, AudioSourceProcessor } from '../../../util/AudioSourceProcessor'; +import { AudioSourceProcessor } from '../../../util/AudioSourceProcessor'; import { TimeService } from '../../time/TimeService'; import { SocketManager } from '../../socket/SocketModule'; import * as PluginChannel from '../../../util/PluginChannel'; import { ReportError } from '../../../util/ErrorReporter'; import { getGlobalState } from '../../../../state/store'; import { debugLog } from '../../debugging/DebugService'; -import { isDomainOfficial } from '../../../config/MagicValues'; +import { AudioPreloader } from '../../preloading/AudioPreloader'; +import { isProxyRequired, proxifyUrl } from '../utils/corsutil'; export class Sound extends AudioSourceProcessor { constructor(opts = {}) { @@ -30,24 +30,32 @@ export class Sound extends AudioSourceProcessor { this.initCallbacks = []; this.startedLoading = false; this.destroyed = false; + this.usesDateSync = false; + this.startAtMillis = 0; + this.needsCors = false; + } + + withCors() { + this.needsCors = true; } whenInitialized(f) { if (this.loaded) { - f(); + f.bind(this)(); } else { this.initCallbacks.push(f); } } - async load(source, allowCaching = true) { - if (this.startedLoading) return; + async load(source) { + if (this.startedLoading) { + return; + } this.startedLoading = true; this.rawSource = source; + this.soundElement = await AudioPreloader.getResource(source, this.needsCors); + this.source = this.soundElement.src; - source = await this.translate(source); - - this.soundElement = await GetAudio(source, true, allowCaching); // mute default if (this.options.startMuted) { this.soundElement.volume = 0; @@ -60,10 +68,6 @@ export class Sound extends AudioSourceProcessor { this.handleError(); }; - // set source - this.soundElement.src = source; - this.source = source; - // set attributes this.soundElement.setAttribute('preload', 'auto'); this.soundElement.setAttribute('controls', 'none'); @@ -85,6 +89,11 @@ export class Sound extends AudioSourceProcessor { }); } + getMediaQueryParam(key, defaultValue = null) { + const url = new URL(this.source); + return url.searchParams.get(key) || defaultValue; + } + finalize() { return new Promise(((resolve) => { this.soundElement.onended = async () => { @@ -94,7 +103,18 @@ export class Sound extends AudioSourceProcessor { runnable(); }); if (this.loop) { - this.soundElement.src = await this.translate(this.rawSource); + // possibly fetch next playlist entry + const nextSource = await this.translate(this.rawSource); + // Did it change? then re-handle + if (nextSource !== this.source) { + if (this.needsCors && isProxyRequired(nextSource)) { + this.soundElement.src = proxifyUrl(nextSource); + } else { + // no cors needed, just yeet + this.soundElement.src = nextSource; + } + this.source = nextSource; + } this.setTime(0); this.soundElement.play(); } else { @@ -135,11 +155,24 @@ export class Sound extends AudioSourceProcessor { tick() { if (!this.loaded && this.soundElement != null) { // do we have metadata? - if (this.soundElement.readyState >= 2) { - debugLog(`Ready state is ${this.soundElement.readyState}, metadata is available`); + + const bypassBuffer = this.getMediaQueryParam('oaSkipBuffer') === 'true'; + + const loadedFinished = this.soundElement.hasAttribute('stopwatchReady') + || bypassBuffer; // alternatively allow a bypass + + let requiredReadyState = 4; + if (bypassBuffer) { + requiredReadyState = 3; + } + + if (this.soundElement.readyState >= requiredReadyState && loadedFinished) { + const loadDuration = parseFloat(this.soundElement.getAttribute('stopwatchTime') || 0); + debugLog(`Ready state is ${this.soundElement.readyState}, metadata is available. Loading took ${loadDuration}s.`); this.loaded = true; + for (let i = 0; i < this.initCallbacks.length; i++) { - const shouldStop = this.initCallbacks[i](); + const shouldStop = this.initCallbacks[i].bind(this)(); if (shouldStop) { debugLog('Stopping init callbacks'); this.initCallbacks = []; @@ -147,6 +180,13 @@ export class Sound extends AudioSourceProcessor { } } + // are we not syncing? in that case, we may need to set our own start time + if (!this.usesDateSync) { + if (this.startAtMillis > 0) { + this.setTime(this.startAtMillis / 1000); + } + } + // did this sound get shut down? if (this.gotShutDown) { this.soundElement.pause(); @@ -154,10 +194,16 @@ export class Sound extends AudioSourceProcessor { // eslint-disable-next-line no-console console.warn('Sound got shut down while loading'); } + } else { + // debugLog('Media not ready yet', this.soundElement.readyState, this.soundElement.hasAttribute('stopwatchReady')); } } } + setStartAt(startAt) { + this.startAtMillis = startAt; + } + handleError() { if (this.hadError) { if (this.error.type === 'error') { @@ -211,17 +257,6 @@ export class Sound extends AudioSourceProcessor { addNode(player, node) { if (this.controller == null) { - this.soundElement.crossOrigin = 'anonymous'; - const ownDomain = getDomain(); - // proxy if we're on a different domain - if (ownDomain != null) { - const isOfficial = isDomainOfficial(ownDomain); - if (!isOfficial) { - if (!this.soundElement.src.includes(getDomain())) { - this.soundElement.src = AUDIO_ENDPOINTS.PROXY + this.soundElement.src; - } - } - } this.controller = player.audioCtx.createMediaElementSource(this.soundElement); } this.controller.connect(node); @@ -268,15 +303,22 @@ export class Sound extends AudioSourceProcessor { } startDate(date) { + this.usesDateSync = true; this.whenInitialized(() => { // debugLog('Starting synced media'); const start = new Date(date); const predictedNow = TimeService.getPredictedTime(); - const seconds = (predictedNow - start) / 1000; - // debugLog(`Started ${seconds} ago`); + const msDiff = Math.max(predictedNow.getTime() - start.getTime(), 1); + let seconds = msDiff / 1000; + + // add at startAt timestamp to the seconds to still apply the offset + if (this.startAtMillis) { + seconds += this.startAtMillis / 1000; + } + const length = this.soundElement.duration; - // debugLog(`Length ${length} seconds`); const loops = Math.floor(seconds / length); + debugLog('Loops', loops, 'Seconds', seconds, 'Length', length, this.destroyed, this.soundElement.readyState); const remainingSeconds = seconds % length; // are we allowed to loop? @@ -288,7 +330,6 @@ export class Sound extends AudioSourceProcessor { return; } } - this.setTime(remainingSeconds); }); } @@ -336,20 +377,3 @@ if ( ) ; } - -/* eslint-enable */ - -function getDomain() { - const fullHostname = window.location.hostname; - const hostnameParts = fullHostname.split('.'); - if (hostnameParts.length > 2) { - return hostnameParts.slice(-2).join('.'); - } - - // are there no parts? then just return null because we're likely on localhost - if (hostnameParts.length === 1) { - return null; - } - - return fullHostname; -} diff --git a/client/src/client/services/media/utils/MediaLoadStopwatch.jsx b/client/src/client/services/media/utils/MediaLoadStopwatch.jsx new file mode 100644 index 000000000..5700cead2 --- /dev/null +++ b/client/src/client/services/media/utils/MediaLoadStopwatch.jsx @@ -0,0 +1,70 @@ +import { debugLog, feedDebugValue } from '../../debugging/DebugService'; +import { DebugStatistic } from '../../debugging/DebugStatistic'; + +export function WatchMediaPerformance(soundElement) { + // print stack + return new MediaPerformanceWatcher(soundElement); +} + +export class MediaPerformanceWatcher { + constructor(soundElement) { + this.soundElement = soundElement; + this.running = false; + this.finished = false; + + this.soundElement.addEventListener('canplaythrough', () => { + this.stop(); + }); + + // is it loading? + this.soundElement.addEventListener('loadstart', this.start.bind(this)); + + // register waiting + this.soundElement.addEventListener('waiting', this.handleDehydration.bind(this)); + + // is ready state already 4? + if (this.soundElement.readyState === 4) { + this.start(); + this.stop(true); + } + } + + start() { + if (this.running) { + return; + } + this.running = true; + this.start = performance.now(); + } + + handleDehydration() { + + } + + stop(earlyStop = false) { + if (!this.running) { + return 0; + } + this.running = false; + this.finished = true; + const end = performance.now(); + const time = end - this.start; + // convert to integer + let integ = Math.round(time); + feedDebugValue(DebugStatistic.MEDIA_LOAD_TIME, integ); + // convert to seconds + integ /= 1000; + + // set ready + this.soundElement.setAttribute('stopwatchReady', 'true'); + this.soundElement.setAttribute('stopwatchTime', integ); + + if (earlyStop) { + debugLog(`Early stop of media load ${this.soundElement.src} after ${integ}s`); + return 0; + } + debugLog(`Media load time for ${this.soundElement.src}: ${integ}s`); + + return time; + } +} diff --git a/client/src/client/services/media/utils/corsutil.js b/client/src/client/services/media/utils/corsutil.js new file mode 100644 index 000000000..d9315e4fb --- /dev/null +++ b/client/src/client/services/media/utils/corsutil.js @@ -0,0 +1,47 @@ +import { isDomainOfficial } from '../../../config/MagicValues'; +import { AUDIO_ENDPOINTS } from '../../../util/AudioSourceProcessor'; + +export function isProxyRequired(url) { + // compare origins + const isSourceOfficial = isDomainOfficial(getDomainOfStr(url)); + + // only cors of neither the source nor the current domain is official + // we cannot expect the user to be serving cors headers + if (!isSourceOfficial) { + // we don't need cors if the source is the same webserver as this client, assuming cors policy is set up correctly + // and we aren't running on a different subdomain + if (!url.includes(getDomain())) { + // we need to proxy the audio, unfortunately + return true; + } + } + return false; +} + +function getQueryParam(url, key, defaultValue = null) { + const urlObj = new URL(url); + return urlObj.searchParams.get(key) || defaultValue; +} + +export function proxifyUrl(url) { + if (url.indexOf(AUDIO_ENDPOINTS.PROXY) === -1 && getQueryParam(url, 'oaNoCors') !== 'true') { + return AUDIO_ENDPOINTS.PROXY + encodeURIComponent(url); + } + // already proxified + return url; +} + +function getDomainOfStr(str) { + const url = new URL(str); + return getDomain(url.hostname); +} + +function getDomain(of = window.location.hostname) { + const fullHostname = of; + const hostnameParts = fullHostname.split('.'); + if (hostnameParts.length > 2) { + return hostnameParts.slice(-2).join('.'); + } + + return fullHostname; +} diff --git a/client/src/client/services/preloading/AudioPreloader.jsx b/client/src/client/services/preloading/AudioPreloader.jsx new file mode 100644 index 000000000..97cf1682f --- /dev/null +++ b/client/src/client/services/preloading/AudioPreloader.jsx @@ -0,0 +1,130 @@ +import { AudioSourceProcessor } from '../../util/AudioSourceProcessor'; +import { PreloadedMedia } from './PreloadedMedia'; +import { debugLog, feedDebugValue } from '../debugging/DebugService'; +import { DebugStatistic } from '../debugging/DebugStatistic'; +import { isProxyRequired, proxifyUrl } from '../media/utils/corsutil'; + +export const AudioPreloader = new class IAudPreload { + constructor() { + this.sourceRewriter = new AudioSourceProcessor(); + this.namespaces = {}; // each namespace has a list of media + } + + async fetch(source, namespace, replenish = false) { + source = await this.sourceRewriter.translate(source); + debugLog(`Preloading audio: ${source}`); + const media = new PreloadedMedia(source, namespace, replenish); + + if (this.namespaces[namespace] == null) { + this.namespaces[namespace] = []; + } + + this.namespaces[namespace].push(media); + this.submitStatistic(); + + // handle errors + media.onErr(() => { + debugLog(`Preloaded media failed: ${source}`); + this.findAndRemoveMedia(source, true); + }); + } + + drop(namespace) { + // does the namespace exist? + if (this.namespaces[namespace] == null) { + return; + } + + // loop through all media in the namespace + let deleteCount = 0; + this.namespaces[namespace].forEach((media) => { + // remove the media from the source + media.preDelete(); + deleteCount += 1; + }); + + // eslint-disable-next-line no-console + console.log(`Dropped ${deleteCount} media from namespace ${namespace}`); + + // delete the namespace + delete this.namespaces[namespace]; + this.submitStatistic(); + } + + findAndRemoveMedia(source, skipReplenish = false) { + // loop through all namespaces + // eslint-disable-next-line no-restricted-syntax + for (const namespace in this.namespaces) { + // eslint-disable-next-line no-prototype-builtins + if (this.namespaces.hasOwnProperty(namespace)) { + // loop through all media in the namespace + // eslint-disable-next-line no-restricted-syntax + for (const media of this.namespaces[namespace]) { + // does the media match the source? + if (media.source === source) { + // this is the one we want! now also remove it from the namespace + const countPre = this.namespaces[namespace].length; + this.namespaces[namespace].splice(this.namespaces[namespace].indexOf(media), 1); + const countPost = this.namespaces[namespace].length; + if (countPre === countPost) { + // eslint-disable-next-line no-console + console.warn('Could not remove media from namespace'); + } else { + this.submitStatistic(); + if (media.keepCopy && !skipReplenish) { + // this media is set to replenish, so we need to fetch it again + debugLog(`Replenishing media: ${source}`); + this.fetch(source, namespace, true); + } + } + return media; + } + } + } + } + return null; + } + + async getResource(source, corsRequired = false) { + source = await this.sourceRewriter.translate(source); + + // find a preloaded media that matches the source + let media = this.findAndRemoveMedia(source); + + let cacheCorsSafe = true; + if (corsRequired) { + if (isProxyRequired(source)) { + cacheCorsSafe = false; + } + } + + // ignore cache if we need cors and the source is not cors safe + if (media == null || !cacheCorsSafe) { + // possibly adapt source + if (corsRequired && !cacheCorsSafe) { + source = proxifyUrl(source); + // log + if (media != null) { + debugLog(`Preloaded media was not cors safe, adapting source to ${source}`); + } + } + media = new PreloadedMedia(source, null, corsRequired); + } else { + debugLog(`Using preloaded media, found ${media.source} in namespace ${media.namespace}, and it already has ready state ${media.audio.readyState} with stopwatch value ${media.audio.hasAttribute('stopwatchReady')}`); + } + + return media.audio; + } + + submitStatistic() { + let count = 0; + // eslint-disable-next-line no-restricted-syntax + for (const namespace in this.namespaces) { + // eslint-disable-next-line no-prototype-builtins + if (this.namespaces.hasOwnProperty(namespace)) { + count += this.namespaces[namespace].length; + } + } + feedDebugValue(DebugStatistic.PRELOADED_SOUNDS, count); + } +}(); diff --git a/client/src/client/services/preloading/PreloadedMedia.jsx b/client/src/client/services/preloading/PreloadedMedia.jsx new file mode 100644 index 000000000..1ade205f2 --- /dev/null +++ b/client/src/client/services/preloading/PreloadedMedia.jsx @@ -0,0 +1,41 @@ +import { WatchMediaPerformance } from '../media/utils/MediaLoadStopwatch'; + +export class PreloadedMedia { + constructor(source, namespace, keepCopy = false) { + this.source = source; + this.keepCopy = keepCopy; + this.namespace = namespace; + this.failed = false; + this.failHandlers = []; + const soundElement = new Audio(); + + // catch errors + soundElement.addEventListener('error', () => { + this.failed = true; + this.failHandlers.forEach((handler) => { + handler(); + }); + }); + + soundElement.crossOrigin = 'anonymous'; + + WatchMediaPerformance(soundElement); + soundElement.autoplay = false; + soundElement.src = source; + soundElement.load(); + + this.audio = soundElement; + } + + onErr(f) { + if (this.failed) { + f(); + } else { + this.failHandlers.push(f); + } + } + + preDelete() { + // optionally do something before the media is deleted + } +} diff --git a/client/src/client/services/socket/HandlerRegistry.jsx b/client/src/client/services/socket/HandlerRegistry.jsx index 05fc89aaa..cf513e788 100644 --- a/client/src/client/services/socket/HandlerRegistry.jsx +++ b/client/src/client/services/socket/HandlerRegistry.jsx @@ -15,6 +15,7 @@ import { handleSpeakerCreate } from './handlers/speakers/HandleSpeakerCreate'; import { HandleSpeakerDestroy } from './handlers/speakers/HandleSpeakerDestroy'; import { HandleVoiceModerationStatus } from './handlers/voice/HandleVoiceModerationStatus'; import { HandleVoiceDeafen } from './handlers/voice/HandleToggleDeafenCommand'; +import { HandleVoicePeerOptionsUpdate } from './handlers/voice/HandleVoicePeerOptionsUpdate'; export class HandlerRegistry { constructor(socket) { @@ -46,5 +47,6 @@ export class HandlerRegistry { registerClassHandler('ClientVoiceChatToggleDeafenPayload', HandleVoiceDeafen); registerClassHandler('ClientVoiceBlurUiPayload', HandleVoiceBlur); registerClassHandler('ClientModerationStatusPayload', HandleVoiceModerationStatus); + registerClassHandler('ClientVoiceOptionsPayload', HandleVoicePeerOptionsUpdate); } } diff --git a/client/src/client/services/socket/SocketModule.jsx b/client/src/client/services/socket/SocketModule.jsx index 1e8c28ad1..c2fadb676 100644 --- a/client/src/client/services/socket/SocketModule.jsx +++ b/client/src/client/services/socket/SocketModule.jsx @@ -53,6 +53,14 @@ export const SocketManager = new class ISocketManager { TimeService.sync(timeStamp, hoursOffset); }); + this.socket.on('server-reconnecting', () => { + setGlobalState({ isServerHealthy: false }); + }); + + this.socket.on('server-reconnected', () => { + setGlobalState({ isServerHealthy: true }); + }); + this.socket.on('disconnect', () => { MediaManager.destroySounds(null, true); diff --git a/client/src/client/services/socket/handlers/HandleCreateMedia.jsx b/client/src/client/services/socket/handlers/HandleCreateMedia.jsx index 204f2bba9..c088a4298 100644 --- a/client/src/client/services/socket/handlers/HandleCreateMedia.jsx +++ b/client/src/client/services/socket/handlers/HandleCreateMedia.jsx @@ -18,6 +18,7 @@ export async function handleCreateMedia(data) { const { flag } = data.media; const { maxDistance } = data; const { muteRegions, muteSpeakers } = data.media; + const { startAt } = data.media; let volume = 100; // only if its a new version and provided, then use that volume @@ -67,7 +68,7 @@ export async function handleCreateMedia(data) { await createdMedia.load(source); createdChannel.setChannelVolume(0); createdMedia.setLooping(looping); - + createdMedia.setStartAt(startAt); // convert distance if (maxDistance !== 0) { const startVolume = convertDistanceToVolume(maxDistance, distance); diff --git a/client/src/client/services/socket/handlers/HandlePrefetch.jsx b/client/src/client/services/socket/handlers/HandlePrefetch.jsx index b7dc07225..0e273b31c 100644 --- a/client/src/client/services/socket/handlers/HandlePrefetch.jsx +++ b/client/src/client/services/socket/handlers/HandlePrefetch.jsx @@ -1,20 +1,33 @@ -import { ClearPrefetchedMedia, PreFetch } from '../../../util/AudioFactory'; import { getGlobalState } from '../../../../state/store'; +import { AudioPreloader } from '../../preloading/AudioPreloader'; export function handlePrefetchPacket(data) { - if (data.clear) { - // clear all prefetched bullshit + const { clear, source } = data; + let { origin, keepCopy } = data; + // clear = bool, whether the origin context should be cleared + // origin = string, the origin context + // source = untranslated media source + + // if origin is null, default to global + if (origin == null) { + origin = 'global'; + } + + if (keepCopy == null) { + keepCopy = false; + } + + if (clear) { setTimeout(() => { - ClearPrefetchedMedia(); - }, 2500); + AudioPreloader.drop(origin); + }, 500); } else { if (!getGlobalState().settings.prefetchMedia) { return; } - const toFetch = data.source; - // fetch a file + setTimeout(() => { - PreFetch(toFetch); - }, 2500); + AudioPreloader.fetch(source, origin, keepCopy); + }, 500); } } diff --git a/client/src/client/services/socket/handlers/speakers/HandleSpeakerCreate.jsx b/client/src/client/services/socket/handlers/speakers/HandleSpeakerCreate.jsx index 5c0265fd1..9d9832c23 100644 --- a/client/src/client/services/socket/handlers/speakers/HandleSpeakerCreate.jsx +++ b/client/src/client/services/socket/handlers/speakers/HandleSpeakerCreate.jsx @@ -17,6 +17,7 @@ export function handleSpeakerCreate(data) { const hasExtraProperties = speaker.hasOwnProperty('doLoop'); const doLoop = hasExtraProperties ? speaker.doLoop : true; const doPickup = hasExtraProperties ? speaker.doPickup : true; + const cancelRegions = hasExtraProperties ? speaker.cancelRegions : false; // create speaker const speakerData = new Speaker( @@ -28,6 +29,7 @@ export function handleSpeakerCreate(data) { speaker.startInstant, doLoop, doPickup, + cancelRegions, ); // add it to the render queue diff --git a/client/src/client/services/socket/handlers/voice/HandleVoicePeerOptionsUpdate.jsx b/client/src/client/services/socket/handlers/voice/HandleVoicePeerOptionsUpdate.jsx new file mode 100644 index 000000000..3db7c00a6 --- /dev/null +++ b/client/src/client/services/socket/handlers/voice/HandleVoicePeerOptionsUpdate.jsx @@ -0,0 +1,17 @@ +import { VoiceModule } from '../../../voice/VoiceModule'; +import { peerOptionsFromObj } from '../../../voice/peers/VoicePeerOptions'; + +export function HandleVoicePeerOptionsUpdate(data) { + const { targetPeerKey, options } = data; + + const peer = VoiceModule.peerMap.get(targetPeerKey); + + if (!peer) { + // eslint-disable-next-line no-console + console.warn(`Peer ${targetPeerKey} not found`); + } + + // force feed the options, which we'll need to translate first + const parsedOptions = peerOptionsFromObj(options); + peer.updateOptions(parsedOptions); +} diff --git a/client/src/client/services/socket/handlers/voice/HandleVoiceSubscription.jsx b/client/src/client/services/socket/handlers/voice/HandleVoiceSubscription.jsx index a23d28387..7eb27b71c 100644 --- a/client/src/client/services/socket/handlers/voice/HandleVoiceSubscription.jsx +++ b/client/src/client/services/socket/handlers/voice/HandleVoiceSubscription.jsx @@ -3,6 +3,7 @@ import { reportVital } from '../../../../util/vitalreporter'; import { StringifyError } from '../../../../util/errorreformat'; import { debugLog } from '../../../debugging/DebugService'; import { closeSessionTab } from '../../../../util/closure'; +import { peerOptionsFromObj, VoicePeerOptions } from '../../../voice/peers/VoicePeerOptions'; export function HandleVoiceSubscription(data) { // We need to separate this into two cases: @@ -22,13 +23,30 @@ export function HandleVoiceSubscription(data) { // modern handling const { peers } = data; for (let i = 0; i < peers.length; i++) { - addPeer(peers[i].playerUuid, peers[i].playerName, peers[i].streamKey, peers[i].location); + const { + playerUuid, playerName, streamKey, location, + } = peers[i]; + + let options = null; + if (peers[i].options) { + options = peerOptionsFromObj(peers[i].options); + } else { + options = new VoicePeerOptions(); + } + + addPeer( + playerUuid, + playerName, + streamKey, + location, + options, + ); } } -function addPeer(uuid, playerName, streamKey, location) { +function addPeer(uuid, playerName, streamKey, location, options) { try { - VoiceModule.addPeer(uuid, playerName, streamKey, location); + VoiceModule.addPeer(uuid, playerName, streamKey, location, options); } catch (e) { // check if its not a ConnectionClosedError if (e.name !== 'ConnectionClosedError') { diff --git a/client/src/client/services/voice/VoiceModule.jsx b/client/src/client/services/voice/VoiceModule.jsx index b431f230b..86ed89083 100644 --- a/client/src/client/services/voice/VoiceModule.jsx +++ b/client/src/client/services/voice/VoiceModule.jsx @@ -8,7 +8,7 @@ import { MicrophoneProcessor } from './processing/MicrophoneProcessor'; import { SocketManager } from '../socket/SocketModule'; import * as PluginChannel from '../../util/PluginChannel'; import { VoicePeer } from './peers/VoicePeer'; -import { feedDebugValue } from '../debugging/DebugService'; +import { debugLog, feedDebugValue } from '../debugging/DebugService'; import { DebugStatistic } from '../debugging/DebugStatistic'; import { setTab } from '../../../components/tabwindow/TabWindow'; import { StringifyError } from '../../util/errorreformat'; @@ -46,7 +46,6 @@ export const VoiceModule = new class IVoiceModule { this.isUpdatingMic = false; let lastPreferredMic = getGlobalState().settings.preferredMicId; - let lastSurroundValue = getGlobalState().settings.voicechatSurroundSound; let onSettingsChange = () => { const state = getGlobalState().settings; if (lastPreferredMic !== state.preferredMicId) { @@ -55,13 +54,6 @@ export const VoiceModule = new class IVoiceModule { this.changeInput(lastPreferredMic); } } - - if (lastSurroundValue !== state.voicechatSurroundSound) { - lastSurroundValue = state.voicechatSurroundSound; - if (this.isReady()) { - this.onSurroundUpdate(); - } - } }; onSettingsChange = onSettingsChange.bind(this); store.subscribe(onSettingsChange); @@ -132,27 +124,6 @@ export const VoiceModule = new class IVoiceModule { deviceLoader.getUserMedia(getGlobalState().settings.preferredMicId); } - onSurroundUpdate() { - SocketManager.send(PluginChannel.RTC_READY, { enabled: false }); - - setGlobalState({ - loadingOverlay: { - visible: true, - title: getTranslation(null, 'vc.reloadingPopupTitle'), - message: getTranslation(null, 'vc.reloadingPopup'), - }, - voiceState: { - peers: [], - }, - }); - - setTimeout(() => { - // hide the loading popup - setGlobalState({ loadingOverlay: { visible: false } }); - SocketManager.send(PluginChannel.RTC_READY, { enabled: true }); - }, 2000); - } - panic() { setGlobalState({ loadingOverlay: { @@ -210,8 +181,8 @@ export const VoiceModule = new class IVoiceModule { }, 3500); } - addPeer(playerUuid, playerName, playerStreamKey, location) { - this.peerMap.set(playerStreamKey, new VoicePeer(playerName, playerUuid, playerStreamKey, location)); + addPeer(playerUuid, playerName, playerStreamKey, location, options) { + this.peerMap.set(playerStreamKey, new VoicePeer(playerName, playerUuid, playerStreamKey, location, options)); feedDebugValue(DebugStatistic.VOICE_PEERS, this.peerMap.size); } @@ -236,6 +207,23 @@ export const VoiceModule = new class IVoiceModule { } removePeer(playerStreamKey) { + // FALLBACK! IF WE GET A UUID WE NEED TO RECOVER AND FIND THE PEER'S STREAM KEY + if (playerStreamKey.length > 32) { + debugLog('FALLBACK: UUID DETECTED, TRYING TO RECOVER STREAM KEY'); + let foundKey = null; + this.peerMap.forEach((peer, key) => { + if (peer.peerUuid === playerStreamKey) { + foundKey = key; + } + }); + if (foundKey) { + playerStreamKey = foundKey; + } else { + debugLog('FALLBACK: COULD NOT RECOVER STREAM KEY, ABORTING'); + return; + } + } + const peer = this.peerMap.get(playerStreamKey); if (peer) { peer.stop(); diff --git a/client/src/client/services/voice/peers/PeerStream.jsx b/client/src/client/services/voice/peers/PeerStream.jsx index 675ad4ac2..e5a73f48a 100644 --- a/client/src/client/services/voice/peers/PeerStream.jsx +++ b/client/src/client/services/voice/peers/PeerStream.jsx @@ -8,14 +8,14 @@ import { Hark } from '../../../util/hark'; import { ConnectionClosedError } from '../errors/ConnectionClosedError'; export class PeerStream { - constructor(peerStreamKey, volume) { + constructor(peerStreamKey, volume, useSpatialAudio) { this.peerStreamKey = peerStreamKey; this.volume = volume; this.volBooster = 1.5; this.harkEvents = null; this.pannerId = null; this.globalVolumeNodeId = null; - this.useSpatialAudio = getGlobalState().settings.voicechatSurroundSound; + this.useSpatialAudio = useSpatialAudio; this.pannerNode = null; this.x = 0; @@ -32,8 +32,7 @@ export class PeerStream { // request the stream const streamRequest = VoiceModule.peerManager.requestStream(this.peerStreamKey); - // when the stream is ready, we can start it - streamRequest.onFinish(async (stream) => { + const streamReadyHandler = async (stream) => { this.audio_elem = new Audio(); this.audio_elem.autoplay = true; this.audio_elem.muted = true; @@ -47,7 +46,7 @@ export class PeerStream { // Workaround for the Chrome bug await this.audio_elem.play(); - const source = ctx.createMediaStreamSource(stream); + this.sourceNode = ctx.createMediaStreamSource(stream); this.mediaStream = stream; // speaking indicator @@ -61,27 +60,11 @@ export class PeerStream { setGlobalState({ voiceState: { peers: { [this.peerStreamKey]: { speaking: false } } } }); }); - // spatial audio handling, depends on the settings - let outputNode = null; - - if (this.useSpatialAudio) { - this.pannerNode = ctx.createPanner(); - this.pannerId = applyPannerSettings(this.pannerNode); - this.setLocation(this.x, this.y, this.z, true); - source.connect(this.gainNode); - this.gainNode.connect(this.pannerNode); - outputNode = this.pannerNode; - } else { - // just do gain - source.connect(this.gainNode); - outputNode = this.gainNode; - } + this.globalSink = ctx.createGain(); + this.globalVolumeNodeId = trackVoiceGainNode(this.globalSink); + this.globalSink.connect(ctx.destination); - const globalVolumeGainNode = ctx.createGain(); - outputNode.connect(globalVolumeGainNode); - this.globalVolumeNodeId = trackVoiceGainNode(globalVolumeGainNode); - this.masterOutputNode = globalVolumeGainNode; - globalVolumeGainNode.connect(ctx.destination); + this.updateSpatialAudioSettings(); // mute if voicechat is deafened if (getGlobalState().settings.voicechatDeafened) { @@ -91,13 +74,67 @@ export class PeerStream { } callback(true); - }); + }; + + // bind context + streamRequest.onFinish(streamReadyHandler.bind(this)); streamRequest.onReject((e) => { callback(false, new ConnectionClosedError(e)); }); } + updateSpatialAudioSettings() { + const ctx = WorldModule.player.audioCtx; + + // reset source node + if (this.sourceNode !== null) { + this.sourceNode.disconnect(); + } + + // Remove existing panner nodes from tracking + if (this.pannerId !== null) { + untrackPanner(this.pannerId); + } + + // Disconnect and remove existing panner nodes + if (this.pannerNode !== null) { + this.pannerNode.disconnect(); + this.pannerNode = null; + } + + // Create new panner nodes based on the updated spatial audio settings + if (this.useSpatialAudio) { + this.pannerNode = ctx.createPanner(); + this.pannerId = applyPannerSettings( + this.pannerNode, + getGlobalState().voiceState.radius, + ); + this.setLocation(this.x, this.y, this.z, true); + this.gainNode.disconnect(); + this.sourceNode.connect(this.gainNode); + this.gainNode.connect(this.pannerNode); + + // Connect panner node to global sink + this.pannerNode.connect(this.globalSink); + } else { + // If not using spatial audio, reconnect directly to gain node + this.sourceNode.connect(this.gainNode); + this.gainNode.disconnect(); + this.gainNode.connect(ctx.destination); + // Connect gain node to global sink + this.gainNode.connect(this.globalSink); + } + } + + enableSpatialAudio(useSpatialAudio) { + // Update spatial audio settings + this.useSpatialAudio = useSpatialAudio; + + // Update the spatial audio settings without recreating the entire stream + this.updateSpatialAudioSettings(); + } + setMuteOverride(muted) { this.listenerDeafend = muted; // update vol @@ -105,16 +142,14 @@ export class PeerStream { } setLocation(x, y, z, update) { - // is surround enabled? - if (!this.useSpatialAudio) return; + this.x = x; + this.y = y; + this.z = z; - if (update && this.pannerNode !== null) { + if (this.useSpatialAudio && this.pannerNode && update) { const position = new Position(new Vector3(x, y, z)); position.applyTo(this.pannerNode); } - this.x = x; - this.y = y; - this.z = z; } setVolume(volume) { diff --git a/client/src/client/services/voice/peers/VoicePeer.jsx b/client/src/client/services/voice/peers/VoicePeer.jsx index ac5548982..4c19b6ea6 100644 --- a/client/src/client/services/voice/peers/VoicePeer.jsx +++ b/client/src/client/services/voice/peers/VoicePeer.jsx @@ -6,7 +6,9 @@ import { Vector3 } from '../../../util/math/Vector3'; import { VoiceModule } from '../VoiceModule'; export class VoicePeer { - constructor(peerName, peerUuid, peerStreamKey, location) { + constructor(peerName, peerUuid, peerStreamKey, location, options) { + this.options = options; + // register in global state setGlobalState({ voiceState: { @@ -18,6 +20,7 @@ export class VoicePeer { speaking: false, muted: false, loading: true, + options: this.options, }, }, }, @@ -32,7 +35,7 @@ export class VoicePeer { this.interpolator = new Interpolator(); // initialize stream - this.stream = new PeerStream(peerStreamKey, getVolumeForPeer(this.peerUuid)); + this.stream = new PeerStream(peerStreamKey, getVolumeForPeer(this.peerUuid), this.options.spatialAudio); this.stream.setLocation(location.x, location.y, location.z); // start, and handle when it's ready @@ -52,6 +55,18 @@ export class VoicePeer { }); } + updateOptions(changedOptions) { + if (changedOptions.spatialAudio !== undefined && changedOptions.spatialAudio !== this.options.spatialAudio) { + if (this.stream !== null) { + this.stream.enableSpatialAudio(changedOptions.spatialAudio); + } + } + + this.options = changedOptions; + // update global state + setGlobalState({ voiceState: { peers: { [this.peerStreamKey]: { options: this.options } } } }); + } + updateLocation(x, y, z) { this.interpolator.onMove = (l) => { this.stream.setLocation(l.x, l.y, l.z, true); diff --git a/client/src/client/services/voice/peers/VoicePeerOptions.jsx b/client/src/client/services/voice/peers/VoicePeerOptions.jsx new file mode 100644 index 000000000..9b2720ccd --- /dev/null +++ b/client/src/client/services/voice/peers/VoicePeerOptions.jsx @@ -0,0 +1,20 @@ +import { getGlobalState } from '../../../../state/store'; +import { debugLog } from '../../debugging/DebugService'; + +export class VoicePeerOptions { + constructor( + visible = true, + spatialAudio = getGlobalState().settings.voicechatSurroundSound, + ) { + this.visible = visible; + this.spatialAudio = spatialAudio; + } +} + +export function peerOptionsFromObj(obj) { + debugLog('peerOptionsFromObj', obj); + return new VoicePeerOptions( + (obj.visible !== undefined) ? obj.visible : true, + (obj.spatialAudio !== undefined) ? obj.spatialAudio : getGlobalState().settings.voicechatSurroundSound, + ); +} diff --git a/client/src/client/services/world/WorldModule.jsx b/client/src/client/services/world/WorldModule.jsx index 0fbc8b146..0f81deb73 100644 --- a/client/src/client/services/world/WorldModule.jsx +++ b/client/src/client/services/world/WorldModule.jsx @@ -45,7 +45,7 @@ export const WorldModule = new class IWorldModule { return locations; } - async getMediaForSource(source, startInstant, doLoop = true, doPickup = true) { + async getMediaForSource(source, startInstant, doLoop = true, doPickup = true, cancelRegions = false) { const loaded = this.audioMap.get(source); if (loaded != null) return loaded; @@ -54,9 +54,9 @@ export const WorldModule = new class IWorldModule { return null; } - const created = new SpeakerPlayer(source, startInstant, doLoop, doPickup); + const created = new SpeakerPlayer(source, startInstant, doLoop, doPickup, cancelRegions); this.audioMap.set(source, created); - await created.initialize(); + created.initializeSpeaker(); return created; } @@ -125,7 +125,7 @@ export const WorldModule = new class IWorldModule { // eslint-disable-next-line no-restricted-syntax for (const element of doFor) { // eslint-disable-next-line no-await-in-loop - const media = await this.getMediaForSource(element.source, element.speaker.startInstant, element.speaker.doLoop, element.speaker.doPickup); + const media = await this.getMediaForSource(element.source, element.speaker.startInstant, element.speaker.doLoop, element.speaker.doPickup, element.speaker.cancelRegions); media.updateLocation(element.speaker, this, this.player); } } diff --git a/client/src/client/services/world/objects/Speaker.js b/client/src/client/services/world/objects/Speaker.js index b5afd26e9..a5f29063e 100644 --- a/client/src/client/services/world/objects/Speaker.js +++ b/client/src/client/services/world/objects/Speaker.js @@ -1,5 +1,5 @@ export class Speaker { - constructor(id, source, location, type, maxDistance, startInstant, doLoop = true, doPickup = true) { + constructor(id, source, location, type, maxDistance, startInstant, doLoop = true, doPickup = true, cancelRegions = false) { this.id = id; this.source = source; this.location = location; @@ -8,8 +8,8 @@ export class Speaker { this.startInstant = startInstant; this.doLoop = doLoop; this.doPickup = doPickup; - this.channel = null; + this.cancelRegions = cancelRegions; } getDistance(world, player) { diff --git a/client/src/client/services/world/objects/SpeakerPlayer.jsx b/client/src/client/services/world/objects/SpeakerPlayer.jsx index 3c09647f3..536e7cbbc 100644 --- a/client/src/client/services/world/objects/SpeakerPlayer.jsx +++ b/client/src/client/services/world/objects/SpeakerPlayer.jsx @@ -8,7 +8,7 @@ import { debugLog } from '../../debugging/DebugService'; /* eslint-disable no-console */ export class SpeakerPlayer { - constructor(source, startInstant, doLoop = true, doPickup = true) { + constructor(source, startInstant, doLoop = true, doPickup = true, cancelRegions = false) { this.id = `SPEAKER__${source}`; this.speakerNodes = new Map(); @@ -16,41 +16,56 @@ export class SpeakerPlayer { this.startInstant = startInstant; this.doLoop = doLoop; this.doPickup = doPickup; + this.cancelRegions = cancelRegions; - debugLog('Speaker props: ', this.id, this.source, this.startInstant, this.doLoop, this.doPickup, 'initialized: ', this.initialized); + debugLog('Speaker props: ', this.id, this.source, this.startInstant, this.doLoop, this.doPickup, this.cancelRegions, 'initialized: ', this.initialized); this.initialized = false; this.whenInitialized = []; } - async initialize() { + async initializeSpeaker() { const createdChannel = new Channel(this.id); createdChannel.trackable = true; createdChannel.setTag('SPEAKER'); createdChannel.setTag(this.id); this.channel = createdChannel; + const createdMedia = new Sound(this.source); this.media = createdMedia; + this.media.withCors(); + + if (this.cancelRegions) { + MediaManager.mixer.incrementInhibitor('REGION'); + } + + MediaManager.mixer.whenFinished(this.id, () => { + // undo inhibit + if (this.cancelRegions) { + debugLog('Decrementing region inhibit from speaker'); + MediaManager.mixer.decrementInhibitor('REGION'); + } + }); + createdChannel.mixer = MediaManager.mixer; createdChannel.addSound(createdMedia); MediaManager.mixer.addChannel(createdChannel); - createdMedia.whenInitialized(async () => { + await createdMedia.load(this.source); + + createdMedia.whenInitialized(() => { createdChannel.setChannelVolume(100); createdMedia.setLooping(this.doLoop); if (this.doPickup) { createdMedia.startDate(this.startInstant, true); } - await createdMedia.finalize(); MediaManager.mixer.updateCurrent(); - if (this.doPickup) { - createdMedia.startDate(this.startInstant, true); - } - createdMedia.finish(); }); + await createdMedia.finalize(); + this.initialized = true; } @@ -90,8 +105,6 @@ export class SpeakerPlayer { if (!this.media.destroyed) { console.log('Failed to destroy a world sound, so I had to do it again.'); this.media.destroy(); - } else { - console.log('It got destroyed successfully'); } }); } diff --git a/client/src/client/services/world/objects/SpeakerRenderNode.jsx b/client/src/client/services/world/objects/SpeakerRenderNode.jsx index b183ccc4e..73788b4f1 100644 --- a/client/src/client/services/world/objects/SpeakerRenderNode.jsx +++ b/client/src/client/services/world/objects/SpeakerRenderNode.jsx @@ -10,22 +10,24 @@ export class SpeakerRenderNode { this.media = media; this.pannerId = null; - media.load(source, false) - .then(() => { - channel.fadeChannel(100, 100); - media.addNode(player, this.pannerNode); + media.whenInitialized(() => { + channel.fadeChannel(100, 100); + media.addNode(player, this.pannerNode); - this.pannerId = applyPannerSettings(this.pannerNode, speaker.maxDistance); + this.pannerId = applyPannerSettings(this.pannerNode, speaker.maxDistance); - const { location } = speaker; - const position = new Position(location); - position.applyTo(this.pannerNode); + const { location } = speaker; + const position = new Position(location); + position.applyTo(this.pannerNode); - this.pannerNode.connect(player.audioCtx.destination); - }); + this.pannerNode.connect(player.audioCtx.destination); + }); } preDelete() { untrackPanner(this.pannerId); + if (this.pannerNode) { + this.pannerNode.disconnect(); + } } } diff --git a/client/src/client/util/AudioFactory.jsx b/client/src/client/util/AudioFactory.jsx deleted file mode 100644 index cb934c829..000000000 --- a/client/src/client/util/AudioFactory.jsx +++ /dev/null @@ -1,37 +0,0 @@ -import { AudioSourceProcessor } from './AudioSourceProcessor'; -import { feedDebugValue } from '../services/debugging/DebugService'; -import { DebugStatistic } from '../services/debugging/DebugStatistic'; - -// eslint-disable-next-line import/no-mutable-exports -export let prefetchedSounds = {}; -const pro = new AudioSourceProcessor(); - -export function ClearPrefetchedMedia() { - prefetchedSounds = {}; - feedDebugValue(DebugStatistic.PRELOADED_SOUNDS, 0); -} - -export async function PreFetch(source) { - source = await pro.translate(source); - const soundElement = new Audio(); - soundElement.autoplay = false; - soundElement.src = source; - soundElement.load(); - prefetchedSounds[source] = soundElement; - feedDebugValue(DebugStatistic.PRELOADED_SOUNDS, Object.keys(prefetchedSounds).length); - return soundElement; -} - -export async function GetAudio(source, isTranslated = false, allowCaching = true) { - if (!allowCaching) { - return new Audio(); - } - if (!isTranslated) { - source = await pro.translate(source); - } - const loaded = prefetchedSounds[source]; - if (loaded != null) { - return loaded; - } - return new Audio(); -} diff --git a/client/src/components/connectionwarning/ServerConnectionWarning.jsx b/client/src/components/connectionwarning/ServerConnectionWarning.jsx new file mode 100644 index 000000000..3b506d7f3 --- /dev/null +++ b/client/src/components/connectionwarning/ServerConnectionWarning.jsx @@ -0,0 +1,28 @@ +import { connect } from 'react-redux'; +import React from 'react'; +import { msg } from '../../client/OpenAudioAppContainer'; + +function ServerConnectionWarning(props) { + if (props.isServerHealthy) { + return null; + } + + const message = msg('network.serverUnhealthy').replace('{serverName}', props.settings.serverDisplayName || 'the server'); + + return ( +
+
+ {message} +
+
+ ); +} + +export default connect(mapStateToProps)(ServerConnectionWarning); + +function mapStateToProps(state) { + return { + isServerHealthy: state.isServerHealthy, + settings: state.settings, + }; +} diff --git a/client/src/components/icons/cog.jsx b/client/src/components/icons/cog.jsx index cea169a07..423ba71ca 100644 --- a/client/src/components/icons/cog.jsx +++ b/client/src/components/icons/cog.jsx @@ -2,10 +2,21 @@ import React from 'react'; export function CogSVG() { return ( - - - - + + ); } diff --git a/client/src/components/icons/globalsvg.jsx b/client/src/components/icons/globalsvg.jsx new file mode 100644 index 000000000..5474edb50 --- /dev/null +++ b/client/src/components/icons/globalsvg.jsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { msg } from '../../client/OpenAudioAppContainer'; + +export function GlobalSvg() { + // remix of https://www.svgrepo.com/svg/299039/group-team?edit=true + // by: SVG Repo + return ( + + + + + + + ); +} diff --git a/client/src/components/icons/musicnote.jsx b/client/src/components/icons/musicnote.jsx new file mode 100644 index 000000000..5cf06e405 --- /dev/null +++ b/client/src/components/icons/musicnote.jsx @@ -0,0 +1,26 @@ +import React from 'react'; + +export function MusicNoteSvg() { + return ( + + + + + + + + + + ); +} diff --git a/client/src/components/icons/peermuted.jsx b/client/src/components/icons/peermuted.jsx new file mode 100644 index 000000000..cbbb250b4 --- /dev/null +++ b/client/src/components/icons/peermuted.jsx @@ -0,0 +1,21 @@ +import React from 'react'; + +export function PeerMutedSvg() { + return ( + + + + + + + + ); +} diff --git a/client/src/components/icons/proxsvg.jsx b/client/src/components/icons/proxsvg.jsx new file mode 100644 index 000000000..5f171af63 --- /dev/null +++ b/client/src/components/icons/proxsvg.jsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { msg } from '../../client/OpenAudioAppContainer'; + +export function ProximitySvg() { + // source: https://www.svgrepo.com/svg/526380/translation-2 + return ( + + + + + + + + + + + + ); +} diff --git a/client/src/components/icons/voicechat.jsx b/client/src/components/icons/voicechat.jsx new file mode 100644 index 000000000..8b7cff0e5 --- /dev/null +++ b/client/src/components/icons/voicechat.jsx @@ -0,0 +1,55 @@ +import React from 'react'; + +export function VoiceChatSvg() { + return ( + + + + + + + + + + ); +} diff --git a/client/src/components/mixer/MixerStateView.jsx b/client/src/components/mixer/MixerStateView.jsx index d14967170..8d28b23c6 100644 --- a/client/src/components/mixer/MixerStateView.jsx +++ b/client/src/components/mixer/MixerStateView.jsx @@ -76,6 +76,13 @@ export default class MixerStateView extends React.Component { {' '} {String(sound.loaded)} +
  • + Position + : + {' '} + {(sound.soundElement && sound.soundElement.duration && sound.soundElement.currentTime) + ? `${sound.soundElement.currentTime.toFixed(2)} / ${sound.soundElement.duration.toFixed(2)}` : 'null'} +
  • Started Loading : diff --git a/client/src/components/tabwindow/TabWindow.jsx b/client/src/components/tabwindow/TabWindow.jsx index bf5e8bda5..d5bccca57 100644 --- a/client/src/components/tabwindow/TabWindow.jsx +++ b/client/src/components/tabwindow/TabWindow.jsx @@ -5,6 +5,7 @@ import { connect } from 'react-redux'; import { showTextModal } from '../modal/InputModal'; import { setGlobalState } from '../../state/store'; import { msg } from '../../client/OpenAudioAppContainer'; +import ServerConnectionWarning from '../connectionwarning/ServerConnectionWarning'; export const setTab = (tab) => { setGlobalState({ @@ -57,6 +58,7 @@ class TabWindow extends Component {
    + {pages[this.props.currentTab].content}
    @@ -71,12 +73,12 @@ class TabWindow extends Component {
    -
    +
    {pages.map((page, index) => (
    + + ); } diff --git a/client/src/components/voice/VoicePeerRow.jsx b/client/src/components/voice/VoicePeerRow.jsx index f294a2adf..2504552e5 100644 --- a/client/src/components/voice/VoicePeerRow.jsx +++ b/client/src/components/voice/VoicePeerRow.jsx @@ -4,6 +4,9 @@ import './voicecard.css'; import Cookies from 'js-cookie'; import { VoiceModule } from '../../client/services/voice/VoiceModule'; import { getVolumeForPeer } from '../../client/services/voice/peers/VoicePeer'; +import { PeerMutedSvg } from '../icons/peermuted'; +import { ProximitySvg } from '../icons/proxsvg'; +import { GlobalSvg } from '../icons/globalsvg'; export class VoicePeerRow extends React.Component { static propTypes = { @@ -16,6 +19,7 @@ export class VoicePeerRow extends React.Component { hideVolume: PropTypes.bool, altText: PropTypes.string, minimal: PropTypes.bool, + spatialAudio: PropTypes.bool, }; static defaultProps = { @@ -27,6 +31,7 @@ export class VoicePeerRow extends React.Component { hideVolume: false, altText: null, minimal: false, + spatialAudio: false, }; constructor(props) { @@ -125,23 +130,8 @@ export class VoicePeerRow extends React.Component {

    - {muted ? ( - - - - - - - - ) : null} + {muted ? () : null} + {this.props.spatialAudio ? () : } {name} {showVolume ? ( diff --git a/client/src/metadata.json b/client/src/metadata.json index 9c9f63a3d..106394c15 100644 --- a/client/src/metadata.json +++ b/client/src/metadata.json @@ -1 +1 @@ -{"buildMajor":1,"buildMinor":125,"buildRevision":194,"buildTag":"dev","buildDate":"Sun Jan 14 2024","build":"1.125.194 dev"} \ No newline at end of file +{"buildMajor":1,"buildMinor":125,"buildRevision":224,"buildTag":"dev","buildDate":"Fri Feb 23 2024","build":"1.125.224 dev"} \ No newline at end of file diff --git a/client/src/oa.css b/client/src/oa.css index 0ed2fe702..dcadae235 100644 --- a/client/src/oa.css +++ b/client/src/oa.css @@ -1086,10 +1086,19 @@ footer { } .navbar-bg { - background: linear-gradient(156deg, #000 5%, color-mix(in srgb,var(--primary-accent), #000 65%), color-mix(in srgb,var(--primary-accent),#000 70%)); - border-right-color: color-mix(in srgb,var(--primary-accent), #000 65%); - border-right-style: solid; - border-right-width: 2px; + background: linear-gradient( + 156deg, #07020D 50%, + color-mix(in srgb, var(--primary-accent), #07020D 60%), + #07020D 100% + ); +} + +.navbar-bg-button { + background-color: #0F0A0A; +} + +.navbar-button-active { + background-color: color-mix(in srgb, var(--primary-accent), #07020D 55%) !important; } .login-image { @@ -1178,11 +1187,11 @@ span.tab { } pre { - display:inline; - margin:0; - padding:0; - border:0; - outline:0; + display: inline; + margin: 0; + padding: 0; + border: 0; + outline: 0; } .common-rounded { @@ -1192,6 +1201,7 @@ pre { .serverimage { content: var(--background-image); } + .disabled-bt { pointer-events: none; opacity: 0.4; /* Adjust the opacity value to control the level of disabled effect */ diff --git a/client/src/state/store.jsx b/client/src/state/store.jsx index f6278dad3..9e7d94f61 100644 --- a/client/src/state/store.jsx +++ b/client/src/state/store.jsx @@ -28,6 +28,7 @@ const initialState = { isPremium: false, isClaimed: false, + isServerHealthy: true, clientSupportsVoiceChat: false, // not valid https browserSupportsVoiceChat: false, // no webrtc at all browserSupportIsLimited: false, // operagx, broken settings? diff --git a/client/src/views/client/ClientView.jsx b/client/src/views/client/ClientView.jsx index c61f77d26..0e6a3b5c2 100644 --- a/client/src/views/client/ClientView.jsx +++ b/client/src/views/client/ClientView.jsx @@ -9,13 +9,13 @@ import { LoadingSpinnerBox } from '../../components/loading/LoadingSpinnerBox'; import { GrayoutPage } from '../../components/layout/GrayoutPage'; import { StaticFooter } from '../../components/footer/StaticFooter'; import { InputModal } from '../../components/modal/InputModal'; -import { SpeakerSvg } from '../../components/icons/speaker'; -import { MicrophoneSVG } from '../../components/icons/microphone'; import { CogSVG } from '../../components/icons/cog'; import { DebugSVG } from '../../components/icons/debug'; import { getTranslation } from '../../client/OpenAudioAppContainer'; import { OaStyleCard } from '../../components/card/OaStyleCard'; import DebugPage from './pages/debug/DebugPage'; +import { MusicNoteSvg } from '../../components/icons/musicnote'; +import { VoiceChatSvg } from '../../components/icons/voicechat'; function ClientView(props) { const { title, message, footer } = props.loadingOverlay; @@ -27,16 +27,19 @@ function ClientView(props) { } - buttonContent={} + buttonContent={} subtext={props.hasPlayingMedia ? getTranslation(null, 'navbar.isPlaying') : null} colorWhenHasSubtext />