diff --git a/code/__DEFINES/__globals.dm b/code/__DEFINES/__globals.dm index 3639d5592553c6..08b5ad225a8579 100644 --- a/code/__DEFINES/__globals.dm +++ b/code/__DEFINES/__globals.dm @@ -40,7 +40,6 @@ /// Create a list global with an initializer expression #define GLOBAL_LIST_INIT(X, InitValue) GLOBAL_RAW(/list/##X); GLOBAL_MANAGED(X, InitValue) -/// Create a list global that is initialized as an empty list #define GLOBAL_LIST_EMPTY(X) GLOBAL_LIST_INIT(X, list()) /// Create a typed list global with an initializer expression @@ -60,3 +59,9 @@ /// Create a typed null global #define GLOBAL_DATUM(X, Typepath) GLOBAL_RAW(Typepath/##X); GLOBAL_UNMANAGED(X) + +/// Create an alist global with an initializer expression +#define GLOBAL_ALIST_INIT(X, InitValue) GLOBAL_RAW(/alist/##X); GLOBAL_MANAGED(X, InitValue) + +// Create an alist global that is initialized as an empty list +#define GLOBAL_ALIST_EMPTY(X) GLOBAL_ALIST_INIT(X, alist()) diff --git a/code/__DEFINES/dcs/signals/signals_mob/signals_mob_main.dm b/code/__DEFINES/dcs/signals/signals_mob/signals_mob_main.dm index c9f9b283271dd0..fd5ce4a3013ebc 100644 --- a/code/__DEFINES/dcs/signals/signals_mob/signals_mob_main.dm +++ b/code/__DEFINES/dcs/signals/signals_mob/signals_mob_main.dm @@ -56,6 +56,8 @@ #define COMSIG_MOB_CLIENT_MOVE_NOGRAV "mob_client_move_nograv" /// From base of /client/Move(): (direction, old_dir) #define COMSIG_MOB_CLIENT_MOVED "mob_client_moved" +/// From base of /client/Move(): but sent to the client over mob +#define COMSIG_MOB_CLIENT_MOVED_CLIENT_SEND "client_moved" /// From base of /client/proc/change_view() (mob/source, new_size) #define COMSIG_MOB_CLIENT_CHANGE_VIEW "mob_client_change_view" /// From base of /mob/proc/reset_perspective() : () diff --git a/code/__DEFINES/logging.dm b/code/__DEFINES/logging.dm index 62e7cee425a8bf..9e8db2ae7d891a 100644 --- a/code/__DEFINES/logging.dm +++ b/code/__DEFINES/logging.dm @@ -98,6 +98,7 @@ #define LOG_CATEGORY_FILTER "filter" #define LOG_CATEGORY_MANIFEST "manifest" #define LOG_CATEGORY_MECHA "mecha" +#define LOG_CATEGORY_MUSIC "music" // DopplerShift add #define LOG_CATEGORY_PAPER "paper" #define LOG_CATEGORY_QDEL "qdel" #define LOG_CATEGORY_RUNTIME "runtime" diff --git a/code/__DEFINES/sound.dm b/code/__DEFINES/sound.dm index 244da1dda81303..ea7675ed818e78 100644 --- a/code/__DEFINES/sound.dm +++ b/code/__DEFINES/sound.dm @@ -12,9 +12,11 @@ #define CHANNEL_ELEVATOR 1014 #define CHANNEL_ESCAPEMENU 1013 #define CHANNEL_WEATHER 1012 +#define CHANNEL_WALKMAN 1011 // Doppler edit +#define CHANNEL_MASTER_VOLUME 1010 // Doppler edit //THIS SHOULD ALWAYS BE THE LOWEST ONE! //KEEP IT UPDATED -#define CHANNEL_HIGHEST_AVAILABLE 1011 +#define CHANNEL_HIGHEST_AVAILABLE 1009 #define MAX_INSTRUMENT_CHANNELS (128 * 6) diff --git a/code/__DEFINES/~doppler_defines/admin.dm b/code/__DEFINES/~doppler_defines/admin.dm index d9b0c563b5035b..7c7c0430634918 100644 --- a/code/__DEFINES/~doppler_defines/admin.dm +++ b/code/__DEFINES/~doppler_defines/admin.dm @@ -1 +1,10 @@ #define MUTE_LOOC (1<<6) + +/// Sends all admins the chosen sound +#define SEND_ADMINS_NOTIFICATION_SOUND(sound_to_play) for(var/client/X in GLOB.admins){X.mob.playsound_local(null, sound_to_play, 100, vary = FALSE, channel = CHANNEL_ADMIN_SOUNDS, pressure_affected = FALSE, use_reverb = FALSE);} + +/// Sends a message in adminchat +#define SEND_ADMINCHAT_MESSAGE(message) to_chat(GLOB.admins, type = MESSAGE_TYPE_ADMINCHAT, html = message, confidential = TRUE) + +/// Sends a message in adminchat with the chosen notification sound +#define SEND_NOTIFIED_ADMIN_MESSAGE(sound, message) SEND_ADMINS_NOTIFICATION_SOUND(sound); SEND_ADMINCHAT_MESSAGE(message) diff --git a/code/__DEFINES/~doppler_defines/cassettes.dm b/code/__DEFINES/~doppler_defines/cassettes.dm new file mode 100644 index 00000000000000..053a3efd94996d --- /dev/null +++ b/code/__DEFINES/~doppler_defines/cassettes.dm @@ -0,0 +1,22 @@ +#ifndef CASSETTE_BASE_DIR +/// Path to the base directory for cassette stuff +#define CASSETTE_BASE_DIR "data/cassette_storage/" +#endif +/// Path to the file containing a list of cassette IDs. +#define CASSETTE_ID_FILE (CASSETTE_BASE_DIR + "ids.json") +/// Path to the data for the cassette of the given ID. +#define CASSETTE_FILE(id) (CASSETTE_BASE_DIR + "[id].json") + +/// This cassette is unapproved, and has not been submitted for review. +#define CASSETTE_STATUS_UNAPPROVED 0 +/// This cassette is under review. +#define CASSETTE_STATUS_REVIEWING 1 +/// This cassette has been approved. +#define CASSETTE_STATUS_APPROVED 2 +/// This cassette has been denied. +#define CASSETTE_STATUS_DENIED 3 + +/// The maximum amount of songs one side of a cassette tape can hold. +#define MAX_SONGS_PER_CASSETTE_SIDE 7 + +#define PLAY_CASSETTE_SOUND(sfx) playsound(src, ##sfx, vol = 90, vary = FALSE, mixer_channel = CHANNEL_MACHINERY) diff --git a/code/__DEFINES/~doppler_defines/floxy.dm b/code/__DEFINES/~doppler_defines/floxy.dm new file mode 100644 index 00000000000000..b592d3dbfc2dad --- /dev/null +++ b/code/__DEFINES/~doppler_defines/floxy.dm @@ -0,0 +1,12 @@ +#define FLOXY_STATUS_PENDING "pending" +#define FLOXY_STATUS_DOWNLOADING "downloading" +#define FLOXY_STATUS_METADATA "metadata" +#define FLOXY_STATUS_COMPLETED "completed" +#define FLOXY_STATUS_FAILED "failed" + +/// File is on disk and available in the cache +#define MEDIA_ENTRY_AVAILABLE (1 << 0) +/// File is on disk but marked as deleted +#define MEDIA_ENTRY_DELETED (1 << 1) +/// Neither the output file nor the deleted file is present on disk +#define MEDIA_ENTRY_MISSING (1 << 2) diff --git a/code/__DEFINES/~doppler_defines/logging.dm b/code/__DEFINES/~doppler_defines/logging.dm index 639329454b5ccf..2c5a03c1df5782 100644 --- a/code/__DEFINES/~doppler_defines/logging.dm +++ b/code/__DEFINES/~doppler_defines/logging.dm @@ -9,3 +9,4 @@ // Game categories #define LOG_CATEGORY_GAME_SUBTLE "game-subtle" +#define LOG_CATEGORY_FLOXY "floxy" diff --git a/code/__DEFINES/~doppler_defines/misc.dm b/code/__DEFINES/~doppler_defines/misc.dm new file mode 100644 index 00000000000000..2b6341c0728c20 --- /dev/null +++ b/code/__DEFINES/~doppler_defines/misc.dm @@ -0,0 +1,4 @@ +//world/proc/shelleo +#define SHELLEO_ERRORLEVEL 1 +#define SHELLEO_STDOUT 2 +#define SHELLEO_STDERR 3 diff --git a/code/__DEFINES/~doppler_defines/radio.dm b/code/__DEFINES/~doppler_defines/radio.dm new file mode 100644 index 00000000000000..ad6c08ab6d8fc3 --- /dev/null +++ b/code/__DEFINES/~doppler_defines/radio.dm @@ -0,0 +1,4 @@ +#define RADIO_CHANNEL_RADIO "Radio" +#define RADIO_KEY_RADIO ":=" + +#define FREQ_RADIO 1443 // the frequency for the radio host broadcast diff --git a/code/__DEFINES/~doppler_defines/signals.dm b/code/__DEFINES/~doppler_defines/signals.dm index eb564d0987de5b..e0ba0c7e8a51b8 100644 --- a/code/__DEFINES/~doppler_defines/signals.dm +++ b/code/__DEFINES/~doppler_defines/signals.dm @@ -27,3 +27,40 @@ #define COMSIG_SOULCATCHER_UPDATE_JOINABILITY "soulcatcher_update_joinability" /// For modifying a mob holder based on what it's holding #define COMSIG_ADDING_MOB_HOLDER_SPECIALS "adding_mob_holder_specials" + +/* + +CARBON SIGNALS + +*/ +#define COMSIG_CARBON_EQUIP_EARS "carbon_ears_equip" +#define COMSIG_CARBON_UNEQUIP_EARS "carbon_ears_unequip" + +/* + +GLOBAL SIGNALS + +*/ + +/// Sent whenever a mob becomes capable of hearing DJ music: (mob/listener) +#define COMSIG_GLOB_ADD_MUSIC_LISTENER "!add_music_listener" +/// Sent whenever a mob becomes no longer capable of hearing DJ music: (mob/listener) +#define COMSIG_GLOB_REMOVE_MUSIC_LISTENER "!remove_music_listener" + +/* + +STORAGE SIGNALS + +*/ + +/// Sent after dumping out the contents: (atom/dest_object, mob/user) +#define COMSIG_STORAGE_DUMP_ONTO_POST_TRANSFER "storage_dump_onto_post_transfer" + +/* + +TGUI SIGNALS + +*/ + +/// TGUI panel is ready. Sent to both client and mob. +#define COMSIG_TGUI_PANEL_READY "tgui_panel_ready" diff --git a/code/__DEFINES/~doppler_defines/sound.dm b/code/__DEFINES/~doppler_defines/sound.dm index a6d43ca996cbde..e01344585576cc 100644 --- a/code/__DEFINES/~doppler_defines/sound.dm +++ b/code/__DEFINES/~doppler_defines/sound.dm @@ -6,3 +6,16 @@ #define SFX_BRICK_PICKUP "brick_pickup" #define SFX_JINGLEBELL "jingle_bell" #define SFX_SKATER "skater" + +#define SFX_DJSTATION_OPENTAKEOUT "djstation_opentakeout" +#define SFX_DJSTATION_PUTINANDCLOSE "djstation_putinandclose" +#define SFX_DJSTATION_OPENPUTINANDCLOSE "djstation_openputinandclose" +#define SFX_DJSTATION_OPENTAKEOUTANDCLOSE "djstation_opentakeoutandclose" +#define SFX_DJSTATION_PLAY "djstation_play" +#define SFX_DJSTATION_STOP "djstation_stop" +#define SFX_DJSTATION_TRACKSWITCH "djstation_trackswitch" + +#define SFX_CASSETTE_PUT_IN "cassette_tape_put_in" +#define SFX_CASSETTE_TAKE_OUT "cassette_tape_take_out" +#define SFX_CASSETTE_DUMP "cassette_tape_dump" +#define SFX_CASSETTE_ASMR "cassette_tape_asmr" diff --git a/code/__DEFINES/~doppler_defines/traits/declarations.dm b/code/__DEFINES/~doppler_defines/traits/declarations.dm index 9e36c4dfb67d67..0383cbfeb1c0b3 100644 --- a/code/__DEFINES/~doppler_defines/traits/declarations.dm +++ b/code/__DEFINES/~doppler_defines/traits/declarations.dm @@ -22,3 +22,11 @@ // makes it so held items float by their head #define TRAIT_FLOATING_HELD "held_items_float" +/// This mob can hear the music from the DJ station. +#define TRAIT_CAN_HEAR_MUSIC "can_hear_radio" + +/// This mob is currently listening to a walkman. +#define TRAIT_LISTENING_TO_WALKMAN "listening_to_walkman" + +/// Prevents the affected object from opening a loot window via alt click. See atom/AltClick() +#define TRAIT_ALT_CLICK_BLOCKER "no_alt_click" diff --git a/code/__HELPERS/logging/_logging.dm b/code/__HELPERS/logging/_logging.dm index 18da92ce68f1cc..9c4b25ca7a0f34 100644 --- a/code/__HELPERS/logging/_logging.dm +++ b/code/__HELPERS/logging/_logging.dm @@ -266,3 +266,6 @@ GLOBAL_LIST_INIT(testing_global_profiler, list("_PROFILE_NAME" = "Global")) return "([AREACOORD(T)])" else if(A.loc) return "(UNKNOWN (?, ?, ?))" + +/proc/log_music(text, list/data) + logger.Log(LOG_CATEGORY_MUSIC, text, data) diff --git a/code/__HELPERS/~doppler_helpers/logging.dm b/code/__HELPERS/~doppler_helpers/logging.dm index 3284327796ce82..35e9b421679764 100644 --- a/code/__HELPERS/~doppler_helpers/logging.dm +++ b/code/__HELPERS/~doppler_helpers/logging.dm @@ -1,3 +1,6 @@ /// This logs subtle emotes in game.log /proc/log_subtle(text, list/data) logger.Log(LOG_CATEGORY_GAME_SUBTLE, text, data) + +/proc/log_floxy(text, list/data) + logger.Log(LOG_CATEGORY_FLOXY, text, data) diff --git a/code/__HELPERS/~doppler_helpers/text.dm b/code/__HELPERS/~doppler_helpers/text.dm new file mode 100644 index 00000000000000..03129956148571 --- /dev/null +++ b/code/__HELPERS/~doppler_helpers/text.dm @@ -0,0 +1,20 @@ +/// Checks to see if a string starts with http:// or https:// +/proc/is_http_protocol(text) + var/static/regex/http_regex + if(isnull(http_regex)) + http_regex = new("^https?://") + return findtext(text, http_regex) + +/// Parses a JWT payload, returning it as a list. +/// This doesn't do signature verification or anything, I'm just using this to get the expiry time. +/proc/parse_jwt_payload(jwt) as /list + var/list/split = splittext(jwt, ".") + if(length(split) != 3) + return null + var/payload_base64 = split[2] + // rust-g fucking segfaults if you pass base64 without padding ;) + var/padding_needed = length(payload_base64) % 4 + if(padding_needed != 0) + for(var/i = 1 to (4 - padding_needed)) + payload_base64 += "=" + return json_decode(rustg_decode_base64(payload_base64)) diff --git a/code/_globalvars/_regexes.dm b/code/_globalvars/_regexes.dm index 934296fe1bcbbb..b90ebc659e9052 100644 --- a/code/_globalvars/_regexes.dm +++ b/code/_globalvars/_regexes.dm @@ -1,5 +1,4 @@ //These are a bunch of regex datums for use /((any|every|no|some|head|foot)where(wolf)?\sand\s)+(\.[\.\s]+\s?where\?)?/i -GLOBAL_DATUM_INIT(is_http_protocol, /regex, regex("^https?://")) GLOBAL_DATUM_INIT(is_website, /regex, regex("http|www.|\[a-z0-9_-]+.(com|org|net|mil|edu)+", "i")) GLOBAL_DATUM_INIT(is_email, /regex, regex("\[a-z0-9_-]+@\[a-z0-9_-]+.\[a-z0-9_-]+", "i")) diff --git a/code/_globalvars/traits/_traits.dm b/code/_globalvars/traits/_traits.dm index 9babd0c709197a..ddcee8151a8191 100644 --- a/code/_globalvars/traits/_traits.dm +++ b/code/_globalvars/traits/_traits.dm @@ -212,6 +212,7 @@ GLOBAL_LIST_INIT(traits_by_type, list( "TRAIT_CANNOT_OPEN_PRESENTS" = TRAIT_CANNOT_OPEN_PRESENTS, "TRAIT_CANT_RIDE" = TRAIT_CANT_RIDE, "TRAIT_CAN_GET_AI_TRACKING_MESSAGE" = TRAIT_CAN_GET_AI_TRACKING_MESSAGE, + "TRAIT_CAN_HEAR_MUSIC" = TRAIT_CAN_HEAR_MUSIC, "TRAIT_CAN_HOLD_ITEMS" = TRAIT_CAN_HOLD_ITEMS, "TRAIT_CAN_MOUNT_CYBORGS" = TRAIT_CAN_MOUNT_CYBORGS, "TRAIT_CAN_MOUNT_HUMANS" = TRAIT_CAN_MOUNT_HUMANS, @@ -368,6 +369,7 @@ GLOBAL_LIST_INIT(traits_by_type, list( "TRAIT_LIGHT_DRINKER" = TRAIT_LIGHT_DRINKER, "TRAIT_LIGHT_STEP" = TRAIT_LIGHT_STEP, "TRAIT_LIMBATTACHMENT" = TRAIT_LIMBATTACHMENT, + "TRAIT_LISTENING_TO_WALKMAN" = TRAIT_LISTENING_TO_WALKMAN, "TRAIT_LITERATE" = TRAIT_LITERATE, "TRAIT_LIVERLESS_METABOLISM" = TRAIT_LIVERLESS_METABOLISM, "TRAIT_LOUD_BINARY" = TRAIT_LOUD_BINARY, diff --git a/code/controllers/configuration/entries/general.dm b/code/controllers/configuration/entries/general.dm index b01611fbef7577..6275778cec3175 100644 --- a/code/controllers/configuration/entries/general.dm +++ b/code/controllers/configuration/entries/general.dm @@ -781,6 +781,8 @@ /datum/config_entry/flag/allow_tracy_queue protection = CONFIG_ENTRY_LOCKED +/datum/config_entry/flag/cassettes_in_db + /** * Tgui ui_act payloads larger than 2kb are split into chunks a maximum of 1kb in size. * This flag represents the maximum chunk count the server is willing to receive. diff --git a/code/datums/looping_sounds/_looping_sound.dm b/code/datums/looping_sounds/_looping_sound.dm index 2b2b00e19a1621..a1590e6c4a3011 100644 --- a/code/datums/looping_sounds/_looping_sound.dm +++ b/code/datums/looping_sounds/_looping_sound.dm @@ -42,6 +42,7 @@ var/use_reverb = TRUE /// Are we ignoring walls? Defaults to TRUE. var/ignore_walls = TRUE + var/channel // Dopplerstation addition: The sound channel to play on, random if not provided. If using this, you should probably also set direct to TRUE. // State stuff /// The source of the sound, or the recipient of the sound. diff --git a/code/game/communications.dm b/code/game/communications.dm index 9e40080a49d20f..d66c4dd4553969 100644 --- a/code/game/communications.dm +++ b/code/game/communications.dm @@ -109,6 +109,7 @@ GLOBAL_LIST_INIT(default_radio_channels, list( RADIO_CHANNEL_CTF_GREEN = FREQ_CTF_GREEN, RADIO_CHANNEL_CTF_RED = FREQ_CTF_RED, RADIO_CHANNEL_CTF_YELLOW = FREQ_CTF_YELLOW, + RADIO_CHANNEL_RADIO = RADIO_KEY_RADIO, // doppler add: radio host channel STATUS_DISPLAY_RELAY = FREQ_STATUS_DISPLAYS, )) @@ -120,6 +121,7 @@ GLOBAL_LIST_INIT(reserved_radio_frequencies, list( "[FREQ_CTF_BLUE]" = RADIO_CHANNEL_CTF_BLUE, "[FREQ_CTF_GREEN]" = RADIO_CHANNEL_CTF_GREEN, "[FREQ_CTF_YELLOW]" = RADIO_CHANNEL_CTF_YELLOW, + "[FREQ_RADIO]" = RADIO_CHANNEL_RADIO, // doppler add: radio host channel "[FREQ_STATUS_DISPLAYS]" = STATUS_DISPLAY_RELAY, )) diff --git a/code/game/objects/items/devices/radio/headset.dm b/code/game/objects/items/devices/radio/headset.dm index 9e81f9b9d2b0b9..00889cda3d8a25 100644 --- a/code/game/objects/items/devices/radio/headset.dm +++ b/code/game/objects/items/devices/radio/headset.dm @@ -12,6 +12,7 @@ GLOBAL_LIST_INIT(channel_tokens, list( RADIO_CHANNEL_SERVICE = RADIO_TOKEN_SERVICE, MODE_BINARY = MODE_TOKEN_BINARY, RADIO_CHANNEL_AI_PRIVATE = RADIO_TOKEN_AI_PRIVATE, + RADIO_CHANNEL_RADIO = RADIO_KEY_RADIO, RADIO_CHANNEL_ENTERTAINMENT = RADIO_TOKEN_ENTERTAINMENT, )) @@ -141,10 +142,12 @@ GLOBAL_LIST_INIT(channel_tokens, list( if(!(slot_flags & slot)) return + ADD_TRAIT(user, TRAIT_CAN_HEAR_MUSIC, REF(src)) grant_headset_languages(user) /obj/item/radio/headset/dropped(mob/user, silent) . = ..() + REMOVE_TRAIT(user, TRAIT_CAN_HEAR_MUSIC, REF(src)) remove_headset_languages(user) // Headsets do not become hearing sensitive as broadcasting instead controls their talk_into capabilities diff --git a/code/game/objects/items/devices/radio/radio.dm b/code/game/objects/items/devices/radio/radio.dm index d2d479693f9e88..3f72bbb94741c1 100644 --- a/code/game/objects/items/devices/radio/radio.dm +++ b/code/game/objects/items/devices/radio/radio.dm @@ -89,6 +89,18 @@ /// If TRUE, will set the icon in initializations. VAR_PRIVATE/should_update_icon = FALSE + // Doppler add START + + /// radio host frequency handle: if TRUE, the radio can receive and broadcast on the radio host frequency + var/radio_host = FALSE + + /// If TRUE, then this message will always be received intact, regardless of exospheric anomalies / processor issues. + var/lossless = FALSE + /// If TRUE, then this radio will always use universal_transmission, bypassing tcomms servers, allowing everyone on connected z-levels to hear it. + /// Implies `lossless = TRUE` too. + var/universal = FALSE + // Doppler add END + /// A very brief cooldown to prevent regular radio sounds from overlapping. COOLDOWN_DECLARE(audio_cooldown) /// A very brief cooldown to prevent "important" radio sounds from overlapping. @@ -172,6 +184,7 @@ add_radio(src, GLOB.default_radio_channels[channel_name]) add_radio(src, frequency) + add_radio(src, FREQ_RADIO) // doppler add: radio host frequency /obj/item/radio/proc/make_syndie() // Turns normal radios into Syndicate radios! qdel(keyslot) @@ -299,6 +312,10 @@ if(!talking_movable.try_speak(message, ignore_spam = TRUE, filterproof = TRUE)) return + // doppler add: radio host frequency handle + if(channel == FREQ_RADIO && !radio_host) + return + if(use_command) spans |= SPAN_COMMAND @@ -420,6 +437,10 @@ // allow checks: are we listening on that frequency? if (input_frequency == frequency) return TRUE + // doppler add: radio host frequency + if (input_frequency == FREQ_RADIO) + return TRUE + for(var/ch_name in channels) if(channels[ch_name] & FREQ_LISTENING) if(GLOB.default_radio_channels[ch_name] == text2num(input_frequency) || special_channels & RADIO_SPECIAL_SYNDIE) diff --git a/code/game/say.dm b/code/game/say.dm index f093925c801e04..3351bf39d73a1e 100644 --- a/code/game/say.dm +++ b/code/game/say.dm @@ -21,6 +21,7 @@ GLOBAL_LIST_INIT(freqtospan, list( "[FREQ_CTF_BLUE]" = "blueteamradio", "[FREQ_CTF_GREEN]" = "greenteamradio", "[FREQ_CTF_YELLOW]" = "yellowteamradio", + "[FREQ_RADIO]" = "radioradio", "[FREQ_STATUS_DISPLAYS]" = "captaincast", )) diff --git a/code/modules/admin/topic.dm b/code/modules/admin/topic.dm index 054a507b6a2e46..f6b72f690ea5bc 100644 --- a/code/modules/admin/topic.dm +++ b/code/modules/admin/topic.dm @@ -1438,3 +1438,12 @@ else if(href_list["debug_z_levels"]) return SSadmin_verbs.dynamic_invoke_verb(usr, /datum/admin_verb/debug_z_levels) + + // doppler add START + var/id = text2num(href_list["open_music_review"]) + var/datum/cassette_review/cassette_review = GLOB.cassette_reviews[id] + if(cassette_review) + cassette_review.ui_interact(usr) + else + to_chat(usr, span_warning("Cassette review not found!"), type = MESSAGE_TYPE_ADMINLOG, confidential = TRUE) + // doppler add END diff --git a/code/modules/admin/verbs/playsound.dm b/code/modules/admin/verbs/playsound.dm index 48788da7968845..04642af17594c4 100644 --- a/code/modules/admin/verbs/playsound.dm +++ b/code/modules/admin/verbs/playsound.dm @@ -1,8 +1,3 @@ -//world/proc/shelleo -#define SHELLEO_ERRORLEVEL 1 -#define SHELLEO_STDOUT 2 -#define SHELLEO_STDERR 3 - ADMIN_VERB(play_sound, R_SOUND, "Play Global Sound", "Play a sound to all connected players.", ADMIN_CATEGORY_FUN, sound as sound) var/freq = 1 var/vol = tgui_input_number(user, "What volume would you like the sound to play at?", max_value = 100) @@ -66,9 +61,8 @@ GLOBAL_VAR_INIT(web_sound_cooldown, 0) /proc/web_sound(mob/user, input, credit) if(!check_rights(R_SOUND)) return - var/ytdl = CONFIG_GET(string/invoke_youtubedl) - if(!ytdl) - to_chat(user, span_boldwarning("yt-dlp was not configured, action unavailable"), confidential = TRUE) //Check config.txt for the INVOKE_YOUTUBEDL value + if(!CONFIG_GET(string/floxy_url)) + to_chat(user, span_boldwarning("Floxy was not configured, action unavailable."), type = MESSAGE_TYPE_ADMINLOG, confidential = TRUE) //Check config.txt for the INVOKE_YOUTUBEDL value return var/web_sound_url = "" var/stop_web_sounds = FALSE @@ -76,65 +70,74 @@ GLOBAL_VAR_INIT(web_sound_cooldown, 0) var/duration = 0 if(istext(input)) var/shell_scrubbed_input = shell_url_scrub(input) - var/list/output = world.shelleo("[ytdl] --geo-bypass --format \"bestaudio\[ext=mp3]/best\[ext=mp4]\[height <= 360]/bestaudio\[ext=m4a]/bestaudio\[ext=aac]\" --dump-single-json --no-playlist -- \"[shell_scrubbed_input]\"") - var/errorlevel = output[SHELLEO_ERRORLEVEL] - var/stdout = output[SHELLEO_STDOUT] - var/stderr = output[SHELLEO_STDERR] - if(errorlevel) - to_chat(user, span_boldwarning("yt-dlp URL retrieval FAILED:"), confidential = TRUE) - to_chat(user, span_warning("[stderr]"), confidential = TRUE) + var/list/info = SSfloxy.download_and_wait(input, timeout = 30 SECONDS, discard_failed = TRUE) + if(!info) + to_chat(user, span_boldwarning("Failed to fetch [input]"), type = MESSAGE_TYPE_ADMINLOG, confidential = TRUE) + return + /* + else if(info["status"] != FLOXY_STATUS_COMPLETED) + to_chat(user, span_boldwarning("Floxy returned status '[info["status"]]' while trying to fetch [input]"), type = MESSAGE_TYPE_ADMINLOG, confidential = TRUE) + */ return - var/list/data - try - data = json_decode(stdout) - catch(var/exception/e) - to_chat(user, span_boldwarning("yt-dlp JSON parsing FAILED:"), confidential = TRUE) - to_chat(user, span_warning("[e]: [stdout]"), confidential = TRUE) + if(length(info["endpoints"])) + web_sound_url = info["endpoints"][1] + else + log_floxy("Floxy did not return a music endpoint for [input]") + to_chat(user, span_boldwarning("Floxy did not return an endpoint for [input]! That's weird!"), type = MESSAGE_TYPE_ADMINLOG, confidential = TRUE) return - if (data["url"]) - web_sound_url = data["url"] - var/title = "[data["title"]]" - var/webpage_url = title - if (data["webpage_url"]) - webpage_url = "[title]" - music_extra_data["duration"] = DisplayTimeText(data["duration"] * 1 SECONDS) - music_extra_data["link"] = data["webpage_url"] - music_extra_data["artist"] = data["artist"] - music_extra_data["upload_date"] = data["upload_date"] - music_extra_data["album"] = data["album"] - duration = data["duration"] * 1 SECONDS + var/list/metadata = info["metadata"] + var/webpage_url = info["url"] + var/title = webpage_url + if(metadata) + if(metadata["title"]) + title = metadata["title"] + if(metadata["url"]) + webpage_url = "[title]" + if(metadata["duration"]) + duration = metadata["duration"] * 1 SECONDS + music_extra_data["duration"] = DisplayTimeText(duration) + if(metadata["artist"]) + music_extra_data["artist"] = metadata["artist"] + if(metadata["album"]) + music_extra_data["album"] = metadata["album"] if (duration > 10 MINUTES) if((tgui_alert(user, "This song is over 10 minutes long. Are you sure you want to play it?", "Length Warning!", list("No", "Yes", "Cancel")) != "Yes")) return var/res = tgui_alert(user, "Show the title of and link to this song to the players?\n[title]", "Show Info?", list("Yes", "No", "Cancel")) switch(res) if("Yes") - music_extra_data["title"] = data["title"] + music_extra_data["title"] = title + music_extra_data["link"] = info["url"] if("No") music_extra_data["link"] = "Song Link Hidden" music_extra_data["title"] = "Song Title Hidden" music_extra_data["artist"] = "Song Artist Hidden" - music_extra_data["upload_date"] = "Song Upload Date Hidden" music_extra_data["album"] = "Song Album Hidden" + if("Custom Title") + var/custom_title = tgui_input_text(user, "Enter the title to show to players", "Custom sound info", null) + if (!length(custom_title)) + tgui_alert(user, "No title specified, using default.", "Custom sound info", list("Okay")) + else + music_extra_data["title"] = custom_title if("Cancel", null) return var/anon = tgui_alert(user, "Display who played the song?", "Credit Yourself?", list("Yes", "No", "Cancel")) switch(anon) if("Yes") if(res == "Yes") - to_chat(world, span_boldannounce("[user.key] played: [webpage_url]"), confidential = TRUE) + to_chat(world, span_boldannounce("[user.key] played: [webpage_url]"), type = MESSAGE_TYPE_OOC, confidential = TRUE) else - to_chat(world, span_boldannounce("[user.key] played a sound"), confidential = TRUE) + to_chat(world, span_boldannounce("[user.key] played a sound"), type = MESSAGE_TYPE_OOC, confidential = TRUE) if("No") if(res == "Yes") - to_chat(world, span_boldannounce("An admin played: [webpage_url]"), confidential = TRUE) + to_chat(world, span_boldannounce("An admin played: [webpage_url]"), type = MESSAGE_TYPE_OOC, confidential = TRUE) if("Cancel", null) return if(credit) to_chat(world, span_boldannounce(credit), confidential = TRUE) SSblackbox.record_feedback("nested tally", "played_url", 1, list("[user.ckey]", "[input]")) log_admin("[key_name(user)] played web sound: [input]") - message_admins("[key_name(user)] played web sound: [input]") + message_admins("[key_name(user)] played web sound: [webpage_url]") else //pressed ok with blank log_admin("[key_name(user)] stopped web sounds.") @@ -142,9 +145,9 @@ GLOBAL_VAR_INIT(web_sound_cooldown, 0) message_admins("[key_name(user)] stopped web sounds.") web_sound_url = null stop_web_sounds = TRUE - if(web_sound_url && !findtext(web_sound_url, GLOB.is_http_protocol)) + if(web_sound_url && !is_http_protocol(web_sound_url)) tgui_alert(user, "The media provider returned a content URL that isn't using the HTTP or HTTPS protocol. This is a security risk and the sound will not be played.", "Security Risk", list("OK")) - to_chat(user, span_boldwarning("BLOCKED: Content URL not using HTTP(S) Protocol!"), confidential = TRUE) + to_chat(user, span_boldwarning("BLOCKED: Content URL not using HTTP(S) Protocol!"), type = MESSAGE_TYPE_ADMINLOG, confidential = TRUE) return if(web_sound_url || stop_web_sounds) @@ -165,7 +168,7 @@ GLOBAL_VAR_INIT(web_sound_cooldown, 0) BLACKBOX_LOG_ADMIN_VERB("Play Internet Sound") ADMIN_VERB_CUSTOM_EXIST_CHECK(play_web_sound) - return !!CONFIG_GET(string/invoke_youtubedl) + return !!CONFIG_GET(string/floxy_url) ADMIN_VERB(play_web_sound, R_SOUND, "Play Internet Sound", "Play a given internet sound to all players.", ADMIN_CATEGORY_FUN) if(!CLIENT_COOLDOWN_FINISHED(GLOB, web_sound_cooldown)) @@ -173,17 +176,32 @@ ADMIN_VERB(play_web_sound, R_SOUND, "Play Internet Sound", "Play a given interne Would you like to override?", "Musicalis Interruptus", list("No","Yes")) != "Yes") return + if(GLOB.dj_booth?.broadcasting) + var/prompt = tgui_alert( + user, + message = "The on-station cassette player is currently broadcasting, please be courteous of others when playing music over them, as they have a cooldown, and admins do not. \ + Do you still want to play a sound?", + title = "Heads up!", + buttons = list("Yes", "No", "Stop Music") + ) + if(prompt == "Stop Music") + web_sound(user, null) + return + if(prompt != "Yes") + return + var/web_sound_input = tgui_input_text(user, "Enter content URL (supported sites only, leave blank to stop playing)", "Play Internet Sound", null) if(length(web_sound_input)) web_sound_input = trim(web_sound_input) - if(findtext(web_sound_input, ":") && !findtext(web_sound_input, GLOB.is_http_protocol)) - to_chat(user, span_boldwarning("Non-http(s) URIs are not allowed."), confidential = TRUE) - to_chat(user, span_warning("For youtube-dl shortcuts like ytsearch: please use the appropriate full URL from the website."), confidential = TRUE) + if(findtext(web_sound_input, ":") && !is_http_protocol(web_sound_input)) + to_chat(user, span_boldwarning("Non-http(s) URIs are not allowed."), type = MESSAGE_TYPE_ADMINLOG, confidential = TRUE) + to_chat(user, span_warning("For youtube-dl shortcuts like ytsearch: please use the appropriate full URL from the website."), type = MESSAGE_TYPE_ADMINLOG, confidential = TRUE) return - web_sound(user.mob, web_sound_input) + var/shell_scrubbed_input = shell_url_scrub(web_sound_input) + web_sound(user, shell_scrubbed_input) else - web_sound(user.mob, null) + web_sound(user, null) ADMIN_VERB(set_round_end_sound, R_SOUND, "Set Round End Sound", "Set the sound that plays on round end.", ADMIN_CATEGORY_FUN, sound as sound) var/volume = tgui_input_number(user, "What volume would you like this sound to play at?", max_value = 100) @@ -206,8 +224,3 @@ ADMIN_VERB(stop_sounds, R_NONE, "Stop All Playing Sounds", "Stops all playing so CLIENT_COOLDOWN_RESET(GLOB, web_sound_cooldown) BLACKBOX_LOG_ADMIN_VERB("Stop All Playing Sounds") - -//world/proc/shelleo -#undef SHELLEO_ERRORLEVEL -#undef SHELLEO_STDOUT -#undef SHELLEO_STDERR diff --git a/code/modules/logging/categories/log_category_misc.dm b/code/modules/logging/categories/log_category_misc.dm index 4d69c7cb35bdd4..53cc81415cca1d 100644 --- a/code/modules/logging/categories/log_category_misc.dm +++ b/code/modules/logging/categories/log_category_misc.dm @@ -68,3 +68,7 @@ /datum/log_category/cave_generation category = LOG_CATEGORY_CAVE_GENERATION + +// Doppler addition +/datum/log_category/music + category = LOG_CATEGORY_MUSIC diff --git a/code/modules/mob/dead/observer/observer.dm b/code/modules/mob/dead/observer/observer.dm index 968a6090b89606..d511529ce4ecd8 100644 --- a/code/modules/mob/dead/observer/observer.dm +++ b/code/modules/mob/dead/observer/observer.dm @@ -146,8 +146,10 @@ GLOBAL_VAR_INIT(observer_default_invisibility, INVISIBILITY_OBSERVER) show_data_huds() SSpoints_of_interest.make_point_of_interest(src) - ADD_TRAIT(src, TRAIT_HEAR_THROUGH_DARKNESS, INNATE_TRAIT) - ADD_TRAIT(src, TRAIT_GOOD_HEARING, INNATE_TRAIT) + // Doppler edit START + add_traits(list(TRAIT_HEAR_THROUGH_DARKNESS, TRAIT_CAN_HEAR_MUSIC, TRAIT_GOOD_HEARING), INNATE_TRAIT) + on_can_hear_music_trait_gain(src) + // Doppler edit END /mob/dead/observer/get_photo_description(obj/item/camera/camera) if(!invisibility || camera.see_ghosts) diff --git a/code/modules/mob/living/carbon/inventory.dm b/code/modules/mob/living/carbon/inventory.dm index 8ab032018078d1..cf2d668d11685f 100644 --- a/code/modules/mob/living/carbon/inventory.dm +++ b/code/modules/mob/living/carbon/inventory.dm @@ -154,6 +154,11 @@ if(ITEM_SLOT_HANDS) put_in_hands(equipping) update_held_items() + // doppler add START + if(ITEM_SLOT_EARS) + SEND_SIGNAL(src, COMSIG_CARBON_EQUIP_EARS, I) + not_handled = TRUE + // doppler add END else not_handled = TRUE @@ -222,12 +227,15 @@ if(!QDELETED(src)) update_worn_legcuffs() - // Not an else-if because we're probably equipped in another slot - if(item_dropping == internal && (QDELETED(src) || QDELETED(item_dropping) || item_dropping.loc != src)) + // Not an else-if because we're probably equipped in another slot ifoppler dedid(item_dropping == internal && (QDELETED(src) || QDELETED(item_dropping) || item_dropping.loc != src)) cutoff_internals() if(!QDELETED(src)) update_mob_action_buttons(UPDATE_BUTTON_STATUS) +// Doppler edit: send unequip signal for ears slot + if(get_slot_by_item(item_dropping) == ITEM_SLOT_EARS) + SEND_SIGNAL(src, COMSIG_CARBON_UNEQUIP_EARS, item_dropping, force, newloc, no_move, invdrop, silent) + /// Adds the passed item's coverage to the mob's coverage related flags /mob/living/carbon/proc/add_item_coverage(obj/item/item) var/pre_coverage = obscured_slots diff --git a/code/modules/mob/living/silicon/silicon.dm b/code/modules/mob/living/silicon/silicon.dm index a5a08116affa1b..a53928bacd0c47 100644 --- a/code/modules/mob/living/silicon/silicon.dm +++ b/code/modules/mob/living/silicon/silicon.dm @@ -69,6 +69,7 @@ var/static/list/traits_to_apply = list( TRAIT_ADVANCEDTOOLUSER, TRAIT_ASHSTORM_IMMUNE, + TRAIT_CAN_HEAR_MUSIC, // Doppler addition, allows silicon mobs to hear music and have it show up on their hud TRAIT_LITERATE, TRAIT_MADNESS_IMMUNE, TRAIT_MARTIAL_ARTS_IMMUNE, diff --git a/code/modules/requests/request_manager.dm b/code/modules/requests/request_manager.dm index d2257515f8f1b5..84e07f30954473 100644 --- a/code/modules/requests/request_manager.dm +++ b/code/modules/requests/request_manager.dm @@ -250,7 +250,7 @@ GLOBAL_DATUM_INIT(requests, /datum/request_manager, new) if(request.req_type != REQUEST_INTERNET_SOUND) to_chat(usr, "Request doesn't have a sound to play.", confidential = TRUE) return TRUE - if(findtext(request.message, ":") && !findtext(request.message, GLOB.is_http_protocol)) + if(findtext(request.message, ":") && !is_http_protocol(request.message)) // Doppler edit - if(findtext(request.message, ":") && !findtext(request.message, GLOB.is_http_protocol)) to_chat(usr, "Request is not a valid URL.", confidential = TRUE) return TRUE diff --git a/code/modules/tgui_panel/audio.dm b/code/modules/tgui_panel/audio.dm index 680696159943ac..6899852bb0e9b3 100644 --- a/code/modules/tgui_panel/audio.dm +++ b/code/modules/tgui_panel/audio.dm @@ -22,7 +22,7 @@ /datum/tgui_panel/proc/play_music(url, extra_data) if(!is_ready()) return - if(!findtext(url, GLOB.is_http_protocol)) + if(!is_ready() || !is_http_protocol(url)) // Doppler edit - if(!findtext(url, GLOB.is_http_protocol)) return var/list/payload = list() if(length(extra_data) > 0) diff --git a/code/modules/tgui_panel/tgui_panel.dm b/code/modules/tgui_panel/tgui_panel.dm index 24b1e865ffc46e..15b5360e6f4522 100644 --- a/code/modules/tgui_panel/tgui_panel.dm +++ b/code/modules/tgui_panel/tgui_panel.dm @@ -87,6 +87,11 @@ ), ), )) + // Doppler add START + if(client) + SEND_SIGNAL(client, COMSIG_TGUI_PANEL_READY) + SEND_SIGNAL(client?.mob, COMSIG_TGUI_PANEL_READY) + // Doppler add END return TRUE if(type == "audio/setAdminMusicVolume") diff --git a/interface/stylesheet.dm b/interface/stylesheet.dm index 65fda5e42375ef..6e7ccb0019ed2b 100644 --- a/interface/stylesheet.dm +++ b/interface/stylesheet.dm @@ -47,6 +47,7 @@ em {font-style: normal; font-weight: bold;} .blueteamradio {color: #0000ff;} .greenteamradio {color: #00ff00;} .yellowteamradio {color: #d1ba22;} +.radioradio {color: #FFC0CB;} .captaincast {color: #00ff99;} .yell { font-weight: bold;} diff --git a/modular_doppler/cassettes/code/communications.dm b/modular_doppler/cassettes/code/communications.dm new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/modular_doppler/cassettes/code/controllers/floxy.dm b/modular_doppler/cassettes/code/controllers/floxy.dm new file mode 100644 index 00000000000000..6719fbd81c81b1 --- /dev/null +++ b/modular_doppler/cassettes/code/controllers/floxy.dm @@ -0,0 +1,243 @@ +SUBSYSTEM_DEF(floxy) + name = "Floxy" + wait = 1 SECONDS + runlevels = ALL +init_order = INIT_ORDER_FLOXY +#ifdef UNIT_TESTS + flags = SS_NO_INIT | SS_NO_FIRE +#else + flags = SS_HIBERNATE +#endif + /// Base URL for Floxy. + var/base_url + /// List of IDs that we're waiting on results from. + var/list/pending_ids = list() + /// Assoc list of [id] -> completed requests. + /// If a value is null, that means it errored somehow, and floxy.log should be checked for more info. + var/alist/completed_ids = alist() + var/alist/cached_metadata = alist() + /// world.realtime value of when the current auth token will expire + var/auth_expiry + /// Auth token used for the header. + VAR_PRIVATE/auth_token + + var/static/list/default_headers = list( + "Content-Type" = "application/json", + "Accept" = "application/json", + ) + +/datum/controller/subsystem/floxy/PreInit() + . = ..() + hibernate_checks = list( + NAMEOF(src, pending_ids), + ) + +/datum/controller/subsystem/floxy/Initialize() + base_url = CONFIG_GET(string/floxy_url) + var/username = CONFIG_GET(string/floxy_username) + var/password = CONFIG_GET(string/floxy_password) + if(!base_url || !username || !password) + can_fire = FALSE + return SS_INIT_NO_NEED + if(!login(username, password)) + return SS_INIT_FAILURE + return SS_INIT_SUCCESS + +/datum/controller/subsystem/floxy/Recover() + base_url = SSfloxy.base_url + pending_ids = SSfloxy.pending_ids + completed_ids = SSfloxy.completed_ids + cached_metadata = SSfloxy.cached_metadata + auth_expiry = SSfloxy.auth_expiry + auth_token = SSfloxy.auth_token + +/// Clears the completed IDs and metadata. +/datum/controller/subsystem/floxy/proc/clear_cache() + completed_ids.Cut() + cached_metadata.Cut() + +/datum/controller/subsystem/floxy/fire(resumed) + renew_if_needed() + for(var/id in pending_ids) + var/list/info = http_basicasync("api/media/[id]", method = RUSTG_HTTP_METHOD_GET) + if(!info) + pending_ids -= id + continue + var/status = info["status"] + if(status != FLOXY_STATUS_COMPLETED && status != FLOXY_STATUS_FAILED) + continue + pending_ids -= id + log_floxy("[id] [status]") + testing("FLOXY: [id] [status]\n---[json_encode(info, JSON_PRETTY_PRINT)]\n---") + completed_ids[id] = info + +/datum/controller/subsystem/floxy/stat_entry(msg) + if(auth_token) + msg += "Authenticated | Pending: [length(pending_ids)] | Completed: [length(completed_ids)]" + if(auth_expiry) + msg += " | Renews in [DisplayTimeText(auth_expiry - world.realtime, 60)])" + else + msg = "Unauthenticated" + return ..() + +#ifndef TESTING +/datum/controller/subsystem/floxy/can_vv_get(var_name) + if(var_name == NAMEOF(src, auth_token) || var_name == NAMEOF(src, default_headers) || var_name == NAMEOF(src, base_url)) + return FALSE + return ..() + +/datum/controller/subsystem/floxy/vv_edit_var(var_name, var_value) + if(var_name == NAMEOF(src, auth_token) || var_name == NAMEOF(src, default_headers) || var_name == NAMEOF(src, base_url)) + return FALSE + return ..() + +/datum/controller/subsystem/floxy/CanProcCall(procname) + if(procname == "login" || procname == "http_basicasync") + return FALSE + return ..() +#endif + +/datum/controller/subsystem/floxy/proc/queue_media(url, profile = "ogg-opus", ttl, clean_title = FALSE) + if(!url) + CRASH("No URL passed to SSfloxy.queue") + if(!is_http_protocol(url)) + CRASH("Invalid URL passed to SSfloxy.queue") + renew_if_needed() + var/list/params = list("url" = url) + if(profile) + params["profile"] = profile + if(ttl) + params["ttl"] = ttl + if(!clean_title) + params["dontCleanTitle"] = "true" + + var/list/response = http_basicasync("api/media/queue?[list2params(params)]") + if(!response) + return FALSE + var/id = response["id"] + if(!id) + CRASH("Queue didn't return ID?") + url = response["url"] + if(id in pending_ids) + log_floxy("Ignoring duplicate queue attempt: [url] (ID: [id])") + return response + if(response["status"] == FLOXY_STATUS_COMPLETED) + completed_ids[id] = response + log_floxy("[url] was already completed (ID: [id])") + else + pending_ids |= id + log_floxy("Queued [url] (ID: [id])") + return response + +/datum/controller/subsystem/floxy/proc/delete_media(id, force = FALSE, hard = FALSE) + if(!id) + CRASH("No ID passed to SSfloxy.delete_media") + renew_if_needed() + var/list/params = list() + if(force) + params["force"] = "true" + if(hard) + params["hard"] = "true" + var/datum/http_response/response = http_basicasync("api/media/[id]?[list2params(params)]", method = RUSTG_HTTP_METHOD_DELETE, just_response = TRUE) + if(!response) + return FALSE + pending_ids -= id + completed_ids -= id + cached_metadata -= id + log_floxy("Deleted media ID: [id]") + return TRUE + +/datum/controller/subsystem/floxy/proc/fetch_media_metadata(url, clean_title = FALSE) as /list + if(!url) + CRASH("No URL passed to SSfloxy.fetch_media_metadata") + if(!is_http_protocol(url)) + CRASH("Invalid URL passed to SSfloxy.fetch_media_metadata") + if(cached_metadata[url]) + return cached_metadata[url] + renew_if_needed() + var/list/params = list("url" = url) + if(!clean_title) + params["dontCleanTitle"] = "true" + var/list/metadata = http_basicasync("api/ytdlp?[list2params(params)]", method = RUSTG_HTTP_METHOD_GET, timeout = 15 SECONDS) + if(metadata) + cached_metadata[url] = metadata + return metadata + +/datum/controller/subsystem/floxy/proc/query_media(id) as /list + if(!id) + return null + if(completed_ids[id]) + return completed_ids[id] + if(id in pending_ids) + return list("id" = id, "status" = FLOXY_STATUS_PENDING) + return null + +/datum/controller/subsystem/floxy/proc/download_and_wait(url, profile = "ogg-opus", ttl, clean_title = FALSE, timeout, discard_failed = FALSE) + var/list/queue_info = queue_media(url, profile, ttl, clean_title) + var/id = queue_info?["id"] + if(!id) + return null + if(timeout) + UNTIL_OR_TIMEOUT(id in completed_ids, timeout) + else + UNTIL(id in completed_ids) + var/list/info = query_media(id) + if(discard_failed && info?["status"] == FLOXY_STATUS_FAILED) + completed_ids -= id + return info + +/datum/controller/subsystem/floxy/proc/login(username, password) + auth_token = null + auth_expiry = null + if(!username || !password) + log_floxy("No username/password given for Floxy login!") + return FALSE + var/list/account_info = http_basicasync("api/login", list("username" = username, "password" = password), timeout = 5 SECONDS, auth = FALSE) + if(!account_info) + return FALSE + auth_token = account_info["token"] + var/list/jwt_info = parse_jwt_payload(auth_token) + if(jwt_info?["exp"]) + auth_expiry = ((jwt_info["exp"] - 946684800) * 10) - 1 MINUTES // convert unix timestamp to world.realtime, but 1 minute earlier bc i don't trust this shit to be accurate + var/list/user_info = account_info["user"] + log_floxy("Logged into Floxy as [user_info["username"]] ([user_info["email"]], [user_info["id"]])") + testing("FLOXY: logged in\n---\n[json_encode(account_info, JSON_PRETTY_PRINT)]\n---") + return TRUE + +/datum/controller/subsystem/floxy/proc/renew_if_needed() + if(!auth_token || !auth_expiry || auth_expiry > world.realtime) + return + var/username = CONFIG_GET(string/floxy_username) + var/list/new_info = http_basicasync("api/token", list("username" = username), timeout = 5 SECONDS) + if(!new_info) + return + auth_token = new_info["token"] + var/list/jwt_info = parse_jwt_payload(auth_token) + if(jwt_info?["exp"]) + auth_expiry = ((jwt_info["exp"] - 946684800) * 10) - 1 MINUTES + else + auth_expiry = null + +/datum/controller/subsystem/floxy/proc/http_basicasync(path, list/body, method = RUSTG_HTTP_METHOD_POST, decode_json = TRUE, timeout = 10 SECONDS, auth = TRUE, just_response = FALSE) + var/list/headers = default_headers + if(auth) + headers = default_headers.Copy() + headers["Authorization"] = "Bearer [auth_token]" + var/datum/http_request/request = new( + method, + "[base_url]/[path]", + json_encode(body), + headers + ) + request.begin_async() + UNTIL_OR_TIMEOUT(request.is_complete(), timeout) + var/datum/http_response/response = request.into_response() + if(response.errored) + log_floxy("Floxy response errored: [response.error || "N/A"]") + CRASH("Floxy response errored: [response.error || "N/A"]") + else if(decode_json) + return json_decode(response.body) + else if(just_response) + return response + else + return response.body diff --git a/modular_doppler/cassettes/code/controllers/general.dm b/modular_doppler/cassettes/code/controllers/general.dm new file mode 100644 index 00000000000000..9280dcd63579f4 --- /dev/null +++ b/modular_doppler/cassettes/code/controllers/general.dm @@ -0,0 +1,13 @@ +/datum/config_entry/string/floxy_url + protection = CONFIG_ENTRY_LOCKED + +/datum/config_entry/string/floxy_url/ValidateAndSet(str_val) + if(!is_http_protocol(str_val)) + return FALSE + return ..() + +/datum/config_entry/string/floxy_username + protection = CONFIG_ENTRY_LOCKED | CONFIG_ENTRY_HIDDEN + +/datum/config_entry/string/floxy_password + protection = CONFIG_ENTRY_LOCKED | CONFIG_ENTRY_HIDDEN diff --git a/modular_doppler/cassettes/code/datums/_looping_sound.dm b/modular_doppler/cassettes/code/datums/_looping_sound.dm new file mode 100644 index 00000000000000..7929785c0de431 --- /dev/null +++ b/modular_doppler/cassettes/code/datums/_looping_sound.dm @@ -0,0 +1,6 @@ +/// Returns the start sound. +/datum/looping_sound/proc/get_start_sound() + return islist(start_sound) ? pick_weight_recursive(start_sound) : start_sound + +/datum/looping_sound/proc/get_end_sound() + return islist(end_sound) ? pick_weight_recursive(end_sound) : end_sound diff --git a/modular_doppler/cassettes/code/datums/cassette.dm b/modular_doppler/cassettes/code/datums/cassette.dm new file mode 100644 index 00000000000000..70fd50514811d0 --- /dev/null +++ b/modular_doppler/cassettes/code/datums/cassette.dm @@ -0,0 +1,38 @@ +/datum/looping_sound/cassette_track_switch + mid_sounds = list( + 'modular_doppler/cassettes/sound/machine_track_switch_loop1.ogg', + 'modular_doppler/cassettes/sound/machine_track_switch_loop2.ogg', + 'modular_doppler/cassettes/sound/machine_track_switch_loop3.ogg', + 'modular_doppler/cassettes/sound/machine_track_switch_loop4.ogg', + 'modular_doppler/cassettes/sound/machine_track_switch_loop5.ogg', + ) + mid_length = 1.4 SECONDS + start_sound = list( + 'modular_doppler/cassettes/sound/machine_track_switch_start1.ogg', + 'modular_doppler/cassettes/sound/machine_track_switch_start2.ogg', + 'modular_doppler/cassettes/sound/machine_track_switch_start3.ogg', + 'modular_doppler/cassettes/sound/machine_track_switch_start4.ogg', + 'modular_doppler/cassettes/sound/machine_track_switch_start5.ogg', + ) + start_length = 0.5 SECONDS + end_sound = list( + 'modular_doppler/cassettes/sound/machine_track_switch_end1.ogg', + 'modular_doppler/cassettes/sound/machine_track_switch_end2.ogg', + 'modular_doppler/cassettes/sound/machine_track_switch_end3.ogg', + 'modular_doppler/cassettes/sound/machine_track_switch_end4.ogg', + 'modular_doppler/cassettes/sound/machine_track_switch_end5.ogg', + ) + volume = 75 + /// The index of the current sound being played. + var/index = 1 + +// funky snowflake procs so that start1 always goes with loop1 and end1, start2 goes with loop2 and end2, etc etc etc +/datum/looping_sound/cassette_track_switch/get_start_sound() + index = rand(1, length(start_sound)) + return start_sound[index] + +/datum/looping_sound/cassette_track_switch/get_end_sound() + return end_sound[index] + +/datum/looping_sound/cassette_track_switch/get_sound(_mid_sounds) + return mid_sounds[index] diff --git a/modular_doppler/cassettes/code/modules/admin/verbs/spawn_mixtape.dm b/modular_doppler/cassettes/code/modules/admin/verbs/spawn_mixtape.dm new file mode 100644 index 00000000000000..4c2d23baeb0c55 --- /dev/null +++ b/modular_doppler/cassettes/code/modules/admin/verbs/spawn_mixtape.dm @@ -0,0 +1,72 @@ +ADMIN_VERB(spawn_mixtape, R_FUN, "Spawn Mixtape", "Select an approved mixtape to spawn at your location.", ADMIN_CATEGORY_GAME) + new /datum/mixtape_spawner(user.mob) + +/datum/mixtape_spawner + +/datum/mixtape_spawner/New(mob/user) + ui_interact(user) + +/datum/mixtape_spawner/ui_state(mob/user) + return ADMIN_STATE(R_FUN) + +/datum/mixtape_spawner/ui_close() + qdel(src) + +/datum/mixtape_spawner/ui_interact(mob/user, datum/tgui/ui) + ui = SStgui.try_update_ui(user, src, ui) + if(!ui) + ui = new(user, src, "MixtapeSpawner") + ui.set_autoupdate(FALSE) // everything's in ui_static_data anyways + ui.open() + +/datum/mixtape_spawner/ui_static_data(mob/user) + var/list/cassettes = list() + for(var/id, value in SScassettes.cassettes) + var/datum/cassette/cassette = value + if(cassette.status != CASSETTE_STATUS_APPROVED) + continue + var/list/sides = list() + for(var/datum/cassette_side/side as anything in list(cassette.front, cassette.back)) + var/list/songs = list() + for(var/datum/cassette_song/song as anything in side.songs) + songs += list(list( + "name" = song.name, + "url" = song.url, + "duration" = song.duration * 1 SECONDS, + "artist" = song.artist, + "album" = song.album, + )) + sides += list(list( + "design" = side.design || /datum/cassette_side::design, + "songs" = songs, + )) + cassettes += list(list( + "id" = id, + "name" = html_decode(cassette.name), + "desc" = html_decode(cassette.desc), + "author" = list( + "ckey" = cassette.author.ckey, + "name" = cassette.author.name, + ), + "sides" = sides, + )) + + return list("cassettes" = cassettes) + +/datum/mixtape_spawner/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state) + . = ..() + if(.) + return + var/mob/user = ui.user + if(action == "spawn") + . = TRUE + var/id = params["id"] + if(!id) + to_chat(user, span_warning("No cassette ID given!"), type = MESSAGE_TYPE_ADMINLOG, confidential = TRUE) + return + var/datum/cassette/cassette = SScassettes.load_cassette(id) + if(!cassette) + to_chat(user, span_warning("Could not load a cassette with the id of '[id]'"), type = MESSAGE_TYPE_ADMINLOG, confidential = TRUE) + return + new /obj/item/cassette_tape(user.drop_location(), cassette) + log_admin("[key_name(user)] created mixtape [id] at [AREACOORD(user)].") diff --git a/modular_doppler/cassettes/code/modules/assets/cassettes.dm b/modular_doppler/cassettes/code/modules/assets/cassettes.dm new file mode 100644 index 00000000000000..cbba9aa3148486 --- /dev/null +++ b/modular_doppler/cassettes/code/modules/assets/cassettes.dm @@ -0,0 +1,9 @@ +/datum/asset/spritesheet_batched/cassettes + name = "cassettes" + +/datum/asset/spritesheet_batched/cassettes/create_spritesheets() + for(var/_name, icon_state in GLOB.cassette_icons) + var/id = sanitize_css_class_name("[icon_state]") + var/datum/universal_icon/icon = uni_icon(/obj/item/cassette_tape::icon, icon_state) + icon.crop(10, 11, 23, 21) + insert_icon(id, icon) diff --git a/modular_doppler/cassettes/code/modules/cassette/cassette.dm b/modular_doppler/cassettes/code/modules/cassette/cassette.dm new file mode 100644 index 00000000000000..dff59820f4a851 --- /dev/null +++ b/modular_doppler/cassettes/code/modules/cassette/cassette.dm @@ -0,0 +1,144 @@ +GLOBAL_LIST_INIT(cassette_icons, list( + "Blank Cassette" = "cassette_flip", + "Blue Sticker" = "cassette_gray", + "Green Sticker" = "cassette_green", + "Orange Sticker" = "cassette_orange", + "Pink Stripped Sticker" = "cassette_pink_stripe", + "Purple Sticker" = "cassette_purple", + "Rainbow Sticker" = "cassette_rainbow", + "Red and Black Sticker" = "cassette_red_black", + "Red Stripped Sticker" = "cassette_red_stripe", + "Camo Sticker" = "cassette_camo", + "Rising Sun Sticker" = "cassette_rising_sun", + "Ocean Sticker" = "cassette_ocean", + "Aesthetic Sticker" = "cassette_aesthetic", +)) + +/obj/item/cassette_tape + name = "blank cassette tape" + desc = "A blank cassette tape, waiting for music to be added." + icon = 'modular_doppler/cassettes/icons/walkman.dmi' + icon_state = "cassette_flip" + w_class = WEIGHT_CLASS_SMALL + item_flags = NOBLUDGEON + hitsound = SFX_CASSETTE_ASMR + drop_sound = SFX_CASSETTE_ASMR + pickup_sound = SFX_CASSETTE_ASMR + usesound = SFX_CASSETTE_ASMR + mob_throw_hit_sound = SFX_CASSETTE_DUMP + block_sound = SFX_CASSETTE_DUMP + equip_sound = SFX_CASSETTE_ASMR + + /// If the cassette is flipped, for playing second list of songs. + var/flipped = FALSE + /// The data for this cassette. + var/datum/cassette/cassette_data + /// Should we just spawn a random cassette? + var/random = FALSE + /// ID of the cassette to spawn in as by default. + var/id + +/obj/item/cassette_tape/Initialize(mapload, spawned_id) + ..() + if(spawned_id) + id = spawned_id + return INITIALIZE_HINT_LATELOAD + +/obj/item/cassette_tape/LateInitialize() + if(id) + cassette_data = SScassettes.load_cassette(id)?.copy() + else if(random && length(GLOB.approved_ids)) + cassette_data = SScassettes.load_cassette(pick(GLOB.approved_ids))?.copy() + cassette_data ||= new + update_appearance(UPDATE_NAME | UPDATE_DESC | UPDATE_ICON_STATE) + +/obj/item/cassette_tape/Destroy(force) + cassette_data = null + return ..() + +/obj/item/cassette_tape/attack_self(mob/user) + . = ..() + flipped = !flipped + user.balloon_alert(user, "flipped cassette") + playsound(src, SFX_CASSETTE_ASMR, 50, FALSE) + + update_appearance(UPDATE_ICON_STATE) + +/obj/item/cassette_tape/update_name(updates) + name = cassette_data.name || src::name + return ..() + +/obj/item/cassette_tape/update_desc(updates) + desc = cassette_data.desc || src::desc + return ..() + +/obj/item/cassette_tape/update_icon_state() + icon_state = get_current_side()?.design || src::icon_state + return ..() + +/obj/item/cassette_tape/examine(mob/user) + . = ..() + switch(cassette_data.status) + if(CASSETTE_STATUS_UNAPPROVED) + . += span_warning("It appears to be a bootleg tape, quality is not a guarantee!") + . += span_notice("In order to play this tape for the whole station, it must be submitted to the Space Board of Music and approved.") + if(CASSETTE_STATUS_REVIEWING) + . += span_warning("It seems this tape is still being reviewed by the Space Board of Music.") + if(CASSETTE_STATUS_APPROVED) + . += span_info("This cassette has been approved by the Space Board of Music, and can be played for the whole station with the Cassette Player.") + if(CASSETTE_STATUS_DENIED) + . += span_warning("This cassette has been denied by the Space Board of Music! Perhaps make some changes and re-submit it?") + else + stack_trace("Unknown status [cassette_data.status] for cassette [cassette_data.name] ([cassette_data.id])") + + if(cassette_data.author.name) + . += span_info("Mixed by [span_name(cassette_data.author.name)]") + +/obj/item/cassette_tape/item_interaction(mob/living/user, obj/item/tool, list/modifiers) + if(!IS_WRITING_UTENSIL(tool)) + return NONE + var/choice = tgui_input_list(user, "What would you like to change?", items = list("Cassette Name", "Cassette Description", "Cancel")) + switch(choice) + if("Cassette Name") + ///the name we are giving the cassette + var/newcassettename = reject_bad_text(tgui_input_text(user, "Write a new Cassette name:", name, html_decode(name), max_length = MAX_NAME_LEN)) + if(!user.can_perform_action(src, TRUE)) + return ITEM_INTERACT_BLOCKING + if(length_char(newcassettename) > MAX_NAME_LEN) + to_chat(user, span_warning("That name is too long!")) + return ITEM_INTERACT_BLOCKING + if(!newcassettename) + to_chat(user, span_warning("That name is invalid.")) + return ITEM_INTERACT_BLOCKING + cassette_data.name = newcassettename + update_appearance(UPDATE_NAME) + return ITEM_INTERACT_SUCCESS + if("Cassette Description") + ///the description we are giving the cassette + var/newdesc = tgui_input_text(user, "Write a new description:", name, html_decode(desc), max_length = 180) + if(!user.can_perform_action(src, TRUE)) + return ITEM_INTERACT_BLOCKING + if (length_char(newdesc) > 180) + to_chat(user, span_warning("That description is too long!")) + return ITEM_INTERACT_BLOCKING + if(!newdesc) + to_chat(user, span_warning("That description is invalid.")) + return ITEM_INTERACT_BLOCKING + cassette_data.desc = newdesc + update_appearance(UPDATE_DESC) + return ITEM_INTERACT_SUCCESS + return ITEM_INTERACT_BLOCKING + +/obj/item/cassette_tape/proc/get_current_side() as /datum/cassette_side + return cassette_data?.get_side(!flipped) + +/obj/item/cassette_tape/blank +// id = "blank" + +/obj/item/cassette_tape/friday + id = "friday" + +/obj/item/cassette_tape/random + name = "Not Correctly Created Random Cassette" + desc = "How did this happen?" + random = TRUE diff --git a/modular_doppler/cassettes/code/modules/cassette/cassette_approval.dm b/modular_doppler/cassettes/code/modules/cassette/cassette_approval.dm new file mode 100644 index 00000000000000..f0ef086af6a714 --- /dev/null +++ b/modular_doppler/cassettes/code/modules/cassette/cassette_approval.dm @@ -0,0 +1,140 @@ +/// Assoc list of review IDs -> /datum/cassette_review instances +GLOBAL_ALIST_EMPTY(cassette_reviews) + +/datum/cassette_review + /// The actual cassette data + var/datum/cassette/cassette_data + /// The cassette tape item that was given to the postbox. + var/obj/item/cassette_tape/tape + /// Ckey of the original submitter + var/submitter_ckey + /// Weakref to the mob that originally submitted the tape. + var/datum/weakref/original_submitter_mob + /// Just used as an "ID" for the review itself. + var/review_id = 0 + var/action_taken = FALSE + +/datum/cassette_review/New(mob/submitter, obj/item/cassette_tape/tape) + . = ..() + var/static/last_review_id = 0 + + src.cassette_data = tape.cassette_data + src.tape = tape + src.submitter_ckey = submitter.ckey + src.original_submitter_mob = WEAKREF(submitter) + src.review_id = ++last_review_id + GLOB.cassette_reviews[review_id] = src + +/datum/cassette_review/Destroy(force) + GLOB.cassette_reviews -= review_id + return_tape_to_submitter() + cassette_data = null + original_submitter_mob = null + return ..() + +/datum/cassette_review/proc/return_tape_to_submitter() + if(QDELETED(tape)) + return + var/mob/living/submitter_mob = original_submitter_mob?.resolve() + if(istype(submitter_mob)) + tape.forceMove(submitter_mob.drop_location()) + if(iscarbon(submitter_mob)) + var/mob/living/carbon/submitter_carbon = submitter_mob + var/static/list/slots = list( + "hands" = ITEM_SLOT_HANDS, + "backpack" = ITEM_SLOT_BACK, + "right pocket" = ITEM_SLOT_RPOCKET, + "left pocket" = ITEM_SLOT_LPOCKET, + ) + submitter_carbon.equip_in_one_of_slots(tape, slots, qdel_on_fail = FALSE) + tape = null + else + QDEL_NULL(tape) + +/datum/cassette_review/ui_interact(mob/user, datum/tgui/ui) + . = ..() + ui = SStgui.try_update_ui(user, src, ui) + if(!ui) + ui = new(user, src, "CassetteReview", "[submitter_ckey]'s Cassette") + ui.set_autoupdate(FALSE) + ui.open() + +/datum/cassette_review/ui_static_data(mob/user) + return list( + "cassette" = cassette_data.export(), + "can_approve" = check_rights_for(user.client, R_FUN), + ) + +/datum/cassette_review/ui_state(mob/user) + return ADMIN_STATE(R_ADMIN) + +/datum/cassette_review/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state) + . = ..() + if(.) + return + if(!check_rights(R_FUN)) + return + var/mob/user = ui.user + switch(action) + if("approve") + . = TRUE + action_taken = TRUE + cassette_data.id = "[random_string(16, GLOB.hex_characters)]_[submitter_ckey]" + cassette_data.status = CASSETTE_STATUS_APPROVED + cassette_data.approved_ckey = user.ckey + cassette_data.approved_time = rustg_unix_timestamp() + cassette_data.save_to_file() + SScassettes.cassettes[cassette_data.id] = cassette_data + tape.cassette_data = cassette_data.copy() + SScassettes.save_ids_json() + log_admin("[key_name(user)] has APPROVED [submitter_ckey]'s submitted tape \"[cassette_data.name]\" ([cassette_data.id])") + message_admins("[key_name(user)] has APPROVED [submitter_ckey]'s submitted tape \"[cassette_data.name]\" ([cassette_data.id])") + var/datum/persistent_client/original_submitter = GLOB.persistent_clients_by_ckey[submitter_ckey] + to_chat(original_submitter.mob, span_big(span_notice("You can feel the Space Board of Music has approved your cassette: [cassette_data.name]."))) + qdel(src) + if("deny") + . = TRUE + action_taken = TRUE + cassette_data.status = CASSETTE_STATUS_DENIED + cassette_data.deleted_ckey = user.ckey + cassette_data.deleted_time = rustg_unix_timestamp() + tape.cassette_data = cassette_data.copy() + log_admin("[key_name(user)] has DENIED [submitter_ckey]'s submitted tape \"[cassette_data.name]\" ([cassette_data.id])") + message_admins("[key_name(user)] has DENIED [submitter_ckey]'s submitted tape \"[cassette_data.name]\" ([cassette_data.id])") + var/datum/persistent_client/original_submitter = GLOB.persistent_clients_by_ckey[submitter_ckey] + if(original_submitter) + to_chat(original_submitter.mob, span_big(span_notice("You feel a wave of disappointment wash over you, the Space Board of Music has rejected your cassette: [cassette_data.name]."))) + qdel(src) + +/datum/cassette_review/proc/operator""() + return "[submitter_ckey]: \"[html_decode(cassette_data.name)]\" (#[review_id])" + +#define ADMIN_OPEN_REVIEW(id) "(Open Review)" + +/proc/submit_cassette_for_review(mob/user, obj/item/cassette_tape/tape) + if(!user.client || !istype(tape)) + return + + tape.cassette_data.author.name = user.real_name + tape.cassette_data.author.ckey = user.ckey + + var/datum/cassette_review/new_review = new(user, tape) + + SEND_NOTIFIED_ADMIN_MESSAGE('sound/items/bikehorn.ogg', "[span_big(span_admin("[span_prefix("MUSIC APPROVAL:")] [key_name(user)] [ADMIN_OPEN_REVIEW(new_review.review_id)] \ + has requested a review on their cassette."))]") + to_chat(user, span_big(span_notice("Your Cassette has been sent to the Space Board of Music for review, you will be notified when an outcome has been made."))) + tape.moveToNullspace() + +#undef ADMIN_OPEN_REVIEW + +ADMIN_VERB(cassette_reviews, R_ADMIN, "Cassette Reviews", "Review submitted cassettes", ADMIN_CATEGORY_GAME) + if(!length(GLOB.cassette_reviews)) + to_chat(user, span_warning("No cassettes are currently pending for review!"), type = MESSAGE_TYPE_ADMINLOG) + return + var/list/options = list() + for(var/_id, review in GLOB.cassette_reviews) + options += review + var/datum/cassette_review/review = tgui_input_list(user, "Which cassette review would you like to open?", "Cassette Reviews", options, ui_state = ADMIN_STATE(R_ADMIN)) + if(!review) + return + review.ui_interact(user.mob) diff --git a/modular_doppler/cassettes/code/modules/cassette/cassette_db/cassette_datum.dm b/modular_doppler/cassettes/code/modules/cassette/cassette_db/cassette_datum.dm new file mode 100644 index 00000000000000..2bfbab40cc8e12 --- /dev/null +++ b/modular_doppler/cassettes/code/modules/cassette/cassette_db/cassette_datum.dm @@ -0,0 +1,188 @@ +/datum/cassette + /// The unique ID of the cassette. example: "4c5d8d69e021a64_alice123456" + var/id + /// The name of the cassette. + var/name + /// The description of the cassette. + var/desc + /// The status of this cassette. + var/status = CASSETTE_STATUS_UNAPPROVED + /// The time this cassette tape was submitted (unix timestamp) + var/submitted_time + /// The ckey of the admin who approved this tape + var/approved_ckey + /// The time this cassette tape was approved (unix timestamp) + var/approved_time + /// The ckey of the admin who deleted this tape, if status is deleted. this also counts as the field for who denied it if status is denied. + var/deleted_ckey + /// The time this cassette tape was deleted (unix timestamp) + var/deleted_time + /// Information about the author of this cassette. + var/datum/cassette_author/author + + /// The front side of the cassette. + var/datum/cassette_side/front + /// The back side of the cassette. + var/datum/cassette_side/back + +/datum/cassette/New() + . = ..() + author = new + front = new + back = new + +/datum/cassette/Destroy(force) + QDEL_NULL(author) + QDEL_NULL(front) + QDEL_NULL(back) + return ..() + +/// Create a copy of this cassette. +/datum/cassette/proc/copy() as /datum/cassette + var/datum/cassette/cassette = new + cassette.import(export()) // lazy + return cassette + +/// Imports cassette data from JSON/list. +/datum/cassette/proc/import(list/data) + if("id" in data) + id = data["id"] + name = data["name"] + desc = data["desc"] + if("status" in data) + status = data["status"] + else + status = data["approved"] ? CASSETTE_STATUS_APPROVED : CASSETTE_STATUS_UNAPPROVED + + submitted_time = data["submitted_time"] + approved_ckey = data["approved_ckey"] + approved_time = data["approved_time"] + deleted_ckey = data["deleted_ckey"] + deleted_time = data["deleted_time"] + + author.name = data["author_name"] + author.ckey = data["author_ckey"] + + for(var/side_name, side_data in data["songs"]) + var/datum/cassette_side/side + if(side_name == "side1") + side = front + else if(side_name == "side2") + side = back + else + stack_trace("Unexpected side name '[side_name]' in cassette [name]") + continue + side.design = side_data["icon"] || /datum/cassette_side::design + for(var/list/track as anything in side_data["tracks"]) + side.songs += new /datum/cassette_song(track["title"], track["url"], track["duration"], track["artist"], track["album"]) + +/// Exports cassette date in the old format. +/datum/cassette/proc/export() as /list + . = list( + "id" = id, + "name" = name, + "desc" = desc, + "status" = status, + "author_name" = author.name, + "author_ckey" = author.ckey, + "submitted_time" = submitted_time, + "approved_ckey" = approved_ckey, + "approved_time" = approved_time, + "deleted_ckey" = deleted_ckey, + "deleted_time" = deleted_time, + "songs" = list( + "side1" = list(), + "side2" = list(), + ), + ) + for(var/i in 1 to 2) + var/datum/cassette_side/side = get_side(i % 2) // side2 = 0, side1 = 1 + .["songs"]["side[i]"] = side.export() + +/// Saves the cassette to the data folder, in JSON format. +/datum/cassette/proc/save_to_file() + if(!id) + CRASH("Attempted to save cassette without an ID to disk") + log_music("Saving cassette [id] to [CASSETTE_FILE(id)]") + rustg_file_write(json_encode(export(), JSON_PRETTY_PRINT), CASSETTE_FILE(id)) + if(!rustg_file_exists(CASSETTE_FILE(id))) + CRASH("okay wtf we failed to save cassette [id], check folder permissions!!") + +/// Simple helper to get a side of the cassette. +/// TRUE is front side, FALSE is back side. +/datum/cassette/proc/get_side(front_side = TRUE) as /datum/cassette_side + return front_side ? front : back + +/// Returns a list of all the song names in this cassette. +/// Really only useful for searching for cassettes via contained song names. +/datum/cassette/proc/list_song_names() as /list + . = list() + for(var/datum/cassette_song/song as anything in front.songs + back.songs) + . |= song.name + +/datum/cassette_author + /// The character name of the cassette author. + var/name + /// The ckey of the cassette author. + var/ckey + +/datum/cassette_side + /// The design of this side of the cassette. + var/design = "cassette_flip" + /// The songs on this side of the cassette. + var/list/datum/cassette_song/songs = list() + +/// Imports data for this cassette side to the JSON format used by the database. +/datum/cassette_side/proc/import(list/data) + design = data["icon"] + for(var/list/song_data as anything in data["tracks"]) + var/datum/cassette_song/song = new + song.import(song_data) + songs += song + +/// Exports data from this cassette side in the JSON format used by the database. +/datum/cassette_side/proc/export() + . = list("icon" = design, "tracks" = list()) + for(var/datum/cassette_song/song as anything in songs) + .["tracks"] += list(song.export()) + +/datum/cassette_side/Destroy(force) + QDEL_LIST(songs) + return ..() + +/datum/cassette_song + /// The name of the song. + var/name + /// The URL of the song. + var/url + /// The duration of the song (in seconds) + var/duration = 0 + var/artist + var/album + +/datum/cassette_song/New(name, url, duration, artist, album) + . = ..() + src.name = name + src.url = url + src.duration = isnum(duration) ? max(duration, 0) : 0 + src.artist = artist + src.album = album + +/datum/cassette_song/proc/import(list/data) + name = data["title"] + url = data["url"] + duration = data["duration"] + artist = data["artist"] + album = data["album"] + +/datum/cassette_song/proc/export() + return list( + "title" = name, + "url" = url, + "duration" = duration, + "artist" = artist, + "album" = album, + ) + +/datum/cassette_song/proc/operator""() + return "[name || "Untitled Song"]" diff --git a/modular_doppler/cassettes/code/modules/cassette/cassette_db/cassette_manager.dm b/modular_doppler/cassettes/code/modules/cassette/cassette_db/cassette_manager.dm new file mode 100644 index 00000000000000..dce5fe652e839c --- /dev/null +++ b/modular_doppler/cassettes/code/modules/cassette/cassette_db/cassette_manager.dm @@ -0,0 +1,180 @@ +SUBSYSTEM_DEF(cassettes) + name = "Cassettes" + flags = SS_NO_FIRE + /// An associative list of IDs to cassette data. + var/list/datum/cassette/cassettes = list() + +/datum/controller/subsystem/cassettes/Initialize() + . = SS_INIT_FAILURE + if(CONFIG_GET(flag/cassettes_in_db) && !CONFIG_GET(flag/sql_enabled)) + stack_trace("CASSETTES_IN_DB was enabled, despite the SQL database not being enabled! Disabling CASSETTES_IN_DB.") + CONFIG_SET(flag/cassettes_in_db, FALSE) + if(CONFIG_GET(flag/cassettes_in_db)) + if(!SSdbcore.Connect()) + CRASH("Database-based cassettes are enabled, but a connection to the database could not be established!") + if(!load_all_cassettes_from_db()) + CRASH("Failed to load all cassettes from database!") + else + if(!load_all_cassettes_from_json()) + CRASH("Failed to load all cassettes from data folder!") + return SS_INIT_SUCCESS + +/datum/controller/subsystem/cassettes/Recover() + flags |= SS_NO_INIT + cassettes = SScassettes.cassettes + +/// Loads the cassette with the given ID. +/// If `db` is TRUE, it will load the cassette from the database. +/// If `db` is FALSE, the cassette will be loaded from a JSON in the `data/cassette_storage` folder. +/// If `db` is null (the default), it will load from the database if the `CASSETTES_IN_DB` config option is set, otherwise it will load from the JSON files. +/datum/controller/subsystem/cassettes/proc/load_cassette(id, db = null) as /datum/cassette + if(!id) + return null + else if(istype(id, /datum/cassette)) // so i can be lazy + return id + if(id in cassettes) + return cassettes[id] + if(isnull(db)) + db = CONFIG_GET(flag/cassettes_in_db) + var/datum/cassette/cassette_data = db ? load_cassette_from_db_raw(id) : load_cassette_from_json_raw(id) + if(cassette_data) + cassettes[id] = cassette_data + return cassette_data + +/// Loads the cassette with the given ID from a JSON in the `data/cassette_storage` folder. +/// This does not check the SScassettes.cassettes cache, and you should not use this - this is only used to initialize SScassettes.cassettes +/datum/controller/subsystem/cassettes/proc/load_cassette_from_json_raw(id) as /datum/cassette + var/cassette_file = CASSETTE_FILE(id) + if(!rustg_file_exists(cassette_file)) + return null + var/cassette_file_data = rustg_file_read(cassette_file) + if(!rustg_json_is_valid(cassette_file_data)) + CRASH("Cassette file [cassette_file] had invalid JSON!") + var/list/cassette_json = json_decode(cassette_file_data) + var/datum/cassette/cassette_data = new + cassette_data.import(cassette_json) + cassette_data.id = id + return cassette_data + +/// Loads the cassette with the given ID from the database. +/datum/controller/subsystem/cassettes/proc/load_cassette_from_db_raw(id) as /datum/cassette + if(!SSdbcore.Connect() || !id) + return + var/datum/db_query/query_cassette = SSdbcore.NewQuery("SELECT `name`, `desc`, `status`, `author_name`, `author_ckey`, `front`, `back` FROM [format_table_name("cassettes")] WHERE `id` = :id", list("id" = id)) + if(!query_cassette.Execute() || !query_cassette.NextRow()) + qdel(query_cassette) + return + var/name = query_cassette.item[1] + var/desc = query_cassette.item[2] + var/status = query_cassette.item[3] + var/author_name = query_cassette.item[4] + var/author_ckey = query_cassette.item[5] + var/list/front = json_decode(query_cassette.item[6]) + var/list/back = json_decode(query_cassette.item[7]) + qdel(query_cassette) + + var/datum/cassette/cassette = new + cassette.id = id + cassette.name = name + cassette.desc = desc + cassette.status = status + cassette.author.name = author_name + cassette.author.ckey = author_ckey + cassette.front.import(front) + cassette.back.import(back) + return cassette + +/// Returns an associative list of id to cassette datums, of all existing saved cassettes. +/// This uses the database. +/datum/controller/subsystem/cassettes/proc/load_all_cassettes_from_db() + . = FALSE + if(!SSdbcore.Connect()) + CRASH("Failed to connect to database") + var/datum/db_query/query_cassettes = SSdbcore.NewQuery("SELECT `id`, `name`, `desc`, `status`, `author_name`, `author_ckey`, `front`, `back` FROM [format_table_name("cassettes")]") + if(!query_cassettes.Execute()) + qdel(query_cassettes) + CRASH("Failed to load cassettes from database") + var/loaded = 0 + while(query_cassettes.NextRow()) + var/id = query_cassettes.item[1] + var/name = query_cassettes.item[2] + var/desc = query_cassettes.item[3] + var/status = query_cassettes.item[4] + var/author_name = query_cassettes.item[5] + var/author_ckey = query_cassettes.item[6] + var/list/front = json_decode(query_cassettes.item[7]) + var/list/back = json_decode(query_cassettes.item[8]) + + var/datum/cassette/cassette = new + cassette.id = id + cassette.name = name + cassette.desc = desc + cassette.status = status + cassette.author.name = author_name + cassette.author.ckey = author_ckey + cassette.front.import(front) + cassette.back.import(back) + + cassettes[id] = cassette + loaded++ + qdel(query_cassettes) + log_music("Loaded [loaded] cassettes from the database!") + return TRUE + +/// Returns an associative list of id to cassette datums, of all existing saved cassettes. +/// This uses JSON files. +/datum/controller/subsystem/cassettes/proc/load_all_cassettes_from_json() + . = FALSE + if(!rustg_file_exists(CASSETTE_ID_FILE)) // this just means there's no cassettes at all i guess? which is valid. + return TRUE + var/list/ids = json_decode(rustg_file_read(CASSETTE_ID_FILE)) + for(var/id in ids) + if(!ids) + continue + var/datum/cassette/cassette_data = load_cassette_from_json_raw(id) + if(isnull(cassette_data)) + stack_trace("Failed to load cassette [id]") + continue + cassettes[id] = cassette_data + return TRUE + +/// Updates the ids.json file on-disk. +/datum/controller/subsystem/cassettes/proc/save_ids_json() + var/list/ids = list() + if(rustg_file_exists(CASSETTE_ID_FILE)) + // Verify that each cassette ID still exists and is still considered "approved" before adding them to the list. + for(var/id in json_decode(rustg_file_read(CASSETTE_ID_FILE))) + if(!rustg_file_exists(CASSETTE_FILE(id))) + continue + ids += id + for(var/id, value in cassettes) + var/datum/cassette/cassette = value + if(cassette.status != CASSETTE_STATUS_APPROVED) + ids -= id + else + ids |= id + rustg_file_write(json_encode(ids), CASSETTE_ID_FILE) + +/// Returns all the cassettes that match the given arguments. +/datum/controller/subsystem/cassettes/proc/filtered_cassettes(status, user_ckey, list/id_blacklist) as /list + RETURN_TYPE(/list/datum/cassette) + . = list() + if(!isnull(user_ckey)) + user_ckey = ckey(user_ckey) + for(var/id, value in cassettes) + var/datum/cassette/cassette = value + if(!isnull(id_blacklist) && (id in id_blacklist)) + continue + if(!isnull(user_ckey) && ckey(cassette.author.ckey) != user_ckey) + continue + if(!isnull(status) && cassette.status != status) + continue + . += cassette + +/// Returns a list containing up to the specified amount of random, unique cassettes that match the given arguments. +/datum/controller/subsystem/cassettes/proc/unique_random_cassettes(amount = 1, status = CASSETTE_STATUS_APPROVED, user_ckey, list/id_blacklist) as /list + RETURN_TYPE(/list/datum/cassette) + . = list() + var/list/cassettes = filtered_cassettes(status, user_ckey, id_blacklist) + for(var/i in 1 to min(amount, length(cassettes))) + . += pick_n_take(cassettes) diff --git a/modular_doppler/cassettes/code/modules/cassette/cassette_db/sound_dependencies/sound_channels.dm b/modular_doppler/cassettes/code/modules/cassette/cassette_db/sound_dependencies/sound_channels.dm new file mode 100644 index 00000000000000..ac354b3405333f --- /dev/null +++ b/modular_doppler/cassettes/code/modules/cassette/cassette_db/sound_dependencies/sound_channels.dm @@ -0,0 +1,35 @@ +GLOBAL_LIST_INIT(used_sound_channels, list( + CHANNEL_MASTER_VOLUME, + CHANNEL_MACHINERY, +)) + +GLOBAL_LIST_INIT(proxy_sound_channels, list( + CHANNEL_MACHINERY, +)) + +GLOBAL_DATUM_INIT(cached_mixer_channels, /alist, alist()) + +/proc/guess_mixer_channel(soundin) + var/sound_text_string + if(istype(soundin, /sound)) + var/sound/bleh = soundin + sound_text_string = "[bleh.file]" + else + sound_text_string = "[soundin]" + if(GLOB.cached_mixer_channels[sound_text_string]) + return GLOB.cached_mixer_channels[sound_text_string] + else if(findtext(sound_text_string, "machines/")) + . = GLOB.cached_mixer_channels[sound_text_string] = CHANNEL_MACHINERY + else + return FALSE + +/// Calculates the "adjusted" volume for a user's volume mixer +/proc/calculate_mixed_volume(client/client, volume, mixer_channel) + . = volume + var/list/channels = client?.prefs?.channel_volume + if(isnull(channels)) + return . + . *= channels["[CHANNEL_MASTER_VOLUME]"] * 0.01 + if(isnull(mixer_channel) || !("[mixer_channel]" in channels)) + return . + . *= channels["[mixer_channel]"] * 0.01 diff --git a/modular_doppler/cassettes/code/modules/cassette/machines/cassette_rack.dm b/modular_doppler/cassettes/code/modules/cassette/machines/cassette_rack.dm new file mode 100644 index 00000000000000..673739212e405b --- /dev/null +++ b/modular_doppler/cassettes/code/modules/cassette/machines/cassette_rack.dm @@ -0,0 +1,102 @@ +#define MAX_STORED_CASSETTES 28 +#define DEFAULT_CASSETTES_TO_SPAWN 5 +#define DEFAULT_BLANKS_TO_SPAWN 10 + +/obj/structure/cassette_rack + name = "cassette pouch" + desc = "Safely holds cassettes for storage." + icon = 'modular_doppler/cassettes/icons/radio_station.dmi' + icon_state = "cassette_pouch" + anchored = FALSE + density = FALSE + +/obj/structure/cassette_rack/Initialize(mapload) + . = ..() + create_storage(storage_type = /datum/storage/cassette_rack) + if(mapload) + set_anchored(TRUE) + +/obj/structure/cassette_rack/update_overlays() + . = ..() + var/number = length(contents) ? min(length(contents), 7) : 0 + . += mutable_appearance(icon, "[icon_state]_[number]") + +/datum/storage/cassette_rack + max_slots = MAX_STORED_CASSETTES + max_specific_storage = WEIGHT_CLASS_SMALL + max_total_storage = WEIGHT_CLASS_SMALL * MAX_STORED_CASSETTES + numerical_stacking = TRUE + +/datum/storage/cassette_rack/New() + . = ..() + set_holdable(/obj/item/cassette_tape) + RegisterSignal(src, COMSIG_STORAGE_DUMP_POST_TRANSFER, PROC_REF(post_dump)) + RegisterSignal(src, COMSIG_STORAGE_DUMP_ONTO_POST_TRANSFER, PROC_REF(post_dumpall)) + +// Allow opening on a normal left click +/datum/storage/cassette_rack/on_attack(datum/source, mob/user) + if(QDELETED(user) || !user.Adjacent(parent) || !user.canUseStorage()) + return ..() + INVOKE_ASYNC(src, PROC_REF(open_storage), user) + return COMPONENT_CANCEL_ATTACK_CHAIN + +/datum/storage/cassette_rack/item_insertion_feedback(mob/user, obj/item/thing, override = FALSE, sound = SFX_RUSTLE, sound_vary = TRUE) + . = ..(user, thing, override, SFX_CASSETTE_PUT_IN, FALSE) + +/datum/storage/cassette_rack/attempt_remove(obj/item/thing, atom/newLoc, silent = FALSE, visual_updates = TRUE, sound = SFX_RUSTLE, sound_vary = TRUE) + . = ..(thing, newLoc, FALSE, visual_updates, SFX_CASSETTE_TAKE_OUT, FALSE) + +// /datum/storage/cassette_rack/dump_content_at(atom/dest_object, mob/user, sound = SFX_RUSTLE, sound_vary = TRUE) +// . = ..(dest_object, user, SFX_CASSETTE_DUMP, FALSE) + +/datum/storage/cassette_rack/proc/post_dump(datum/storage/source, atom/dest_object, mob/user) + SIGNAL_HANDLER + playsound(parent, SFX_CASSETTE_DUMP, 50, FALSE, -4) + +/datum/storage/cassette_rack/proc/post_dumpall(datum/storage/source, atom/dest_object, mob/user) + SIGNAL_HANDLER + playsound(parent, SFX_CASSETTE_DUMP, 50, FALSE, -4) + +/obj/structure/cassette_rack/prefilled + var/spawn_random = DEFAULT_CASSETTES_TO_SPAWN + var/spawn_blanks = DEFAULT_BLANKS_TO_SPAWN + +/obj/structure/cassette_rack/prefilled/Initialize(mapload) + . = ..() + REGISTER_REQUIRED_MAP_ITEM(1, INFINITY) + RegisterSignal(SSdcs, COMSIG_GLOB_CREWMEMBER_JOINED, PROC_REF(spawn_curator_tapes)) + for(var/i in 1 to spawn_blanks) + new /obj/item/cassette_tape/blank(src) + for(var/id in unique_random_tapes(spawn_random)) + new /obj/item/cassette_tape(src, id) + update_appearance() + +/obj/structure/cassette_rack/prefilled/Destroy() + UnregisterSignal(SSdcs, COMSIG_GLOB_CREWMEMBER_JOINED) + return ..() + +/obj/structure/cassette_rack/prefilled/proc/spawn_curator_tapes(datum/source, mob/living/new_crewmember, rank) + SIGNAL_HANDLER + if(QDELETED(new_crewmember) || new_crewmember.stat == DEAD || !new_crewmember.ckey) + return + if(!istype(new_crewmember.mind?.assigned_role, /datum/job/curator)) + return + add_user_tapes(new_crewmember.ckey) + +/obj/structure/cassette_rack/prefilled/proc/add_user_tapes(user_ckey, max_amt = 3, expand_max_size = TRUE) + var/list/existing_cassettes = list() + for(var/obj/item/cassette_tape/tape in src) + if(tape.cassette_data.id) + existing_cassettes |= tape.cassette_data.id + var/amount_spawned = 0 + for(var/datum/cassette/cassette as anything in SScassettes.unique_random_cassettes(max_amt, CASSETTE_STATUS_APPROVED, user_ckey, existing_cassettes)) + new /obj/item/cassette_tape(src, cassette) + amount_spawned++ + if(expand_max_size && !QDELETED(atom_storage) && amount_spawned > 0) + atom_storage.max_slots += amount_spawned + atom_storage.max_total_storage += amount_spawned * WEIGHT_CLASS_SMALL + return TRUE + +#undef DEFAULT_BLANKS_TO_SPAWN +#undef DEFAULT_CASSETTES_TO_SPAWN +#undef MAX_STORED_CASSETTES diff --git a/modular_doppler/cassettes/code/modules/cassette/machines/dj_station.dm b/modular_doppler/cassettes/code/modules/cassette/machines/dj_station.dm new file mode 100644 index 00000000000000..7e6ef7850ec5af --- /dev/null +++ b/modular_doppler/cassettes/code/modules/cassette/machines/dj_station.dm @@ -0,0 +1,471 @@ +GLOBAL_DATUM(dj_booth, /obj/machinery/dj_station) + +/obj/machinery/dj_station + name = "Cassette Player" + desc = "Plays Space Music Board approved cassettes for anyone in the station to listen to." + + icon = 'modular_doppler/cassettes/icons/radio_station.dmi' + icon_state = "cassette_player" + + use_power = NO_POWER_USE + processing_flags = NONE + + resistance_flags = INDESTRUCTIBLE | LAVA_PROOF | FIRE_PROOF | UNACIDABLE | ACID_PROOF + interaction_flags_machine = INTERACT_MACHINE_REQUIRES_LITERACY + + anchored = TRUE + density = TRUE + move_resist = MOVE_FORCE_OVERPOWERING + + processing_flags = START_PROCESSING_MANUALLY + subsystem_type = /datum/controller/subsystem/processing/fastprocess // to try to keep it as seamless as possible when the song ends + + /// Is someone currently ejecting the tape? + var/is_ejecting = FALSE + /// Are we currently broadcasting a song? + var/broadcasting = FALSE + /// Are we currently switching tracks? + var/switching_tracks = FALSE + /// The currently inserted cassette, if any. + var/obj/item/cassette_tape/inserted_tape + /// The song currently being played, if any. + var/datum/cassette_song/playing + /// Extra metadata sent to the tgui panel. + var/list/playing_extra_data + /// The direct URL endpoint of the song being played. + var/music_endpoint + /// The REALTIMEOFDAY that the current song was started. + var/song_start_time + /// Looping sound used when switching cassette tracks. + var/datum/looping_sound/cassette_track_switch/switch_sound + /// If this can play bootleg tapes or not. + var/can_play_bootlegs = FALSE + + /// How long of a cooldown between playing two songs. + var/song_cooldown = 2 MINUTES + + COOLDOWN_DECLARE(next_song_timer) + COOLDOWN_DECLARE(fake_loading_time) + +/obj/machinery/dj_station/Initialize(mapload) + . = ..() + REGISTER_REQUIRED_MAP_ITEM(1, INFINITY) + register_context() + if(QDELETED(GLOB.dj_booth) && (!mapload || is_station_level(loc?.z))) + GLOB.dj_booth = src + ADD_TRAIT(src, TRAIT_ALT_CLICK_BLOCKER, INNATE_TRAIT) + switch_sound = new(src) + RegisterSignal(SSdcs, COMSIG_GLOB_ADD_MUSIC_LISTENER, PROC_REF(on_add_listener)) + RegisterSignal(SSdcs, COMSIG_GLOB_REMOVE_MUSIC_LISTENER, PROC_REF(on_remove_listener)) + +/obj/machinery/dj_station/Destroy() + UnregisterSignal(SSdcs, list(COMSIG_GLOB_ADD_MUSIC_LISTENER, COMSIG_GLOB_REMOVE_MUSIC_LISTENER)) + QDEL_NULL(switch_sound) + if(!QDELETED(inserted_tape)) + inserted_tape.forceMove(drop_location()) + inserted_tape = null + playing = null + if(GLOB.dj_booth == src) + GLOB.dj_booth = null + for(var/mob/listener in GLOB.music_listeners) + UnregisterSignal(listener, COMSIG_TGUI_PANEL_READY) + listener.client?.tgui_panel?.stop_music() + return ..() + +/obj/machinery/dj_station/add_context(atom/source, list/context, obj/item/held_item, mob/user) + . = NONE + if(istype(held_item, /obj/item/cassette_tape)) + context[SCREENTIP_CONTEXT_LMB] = inserted_tape ? "Swap Tape" : "Insert Tape" + . = CONTEXTUAL_SCREENTIP_SET + else if(!held_item) + context[SCREENTIP_CONTEXT_LMB] = "Open UI" + . = CONTEXTUAL_SCREENTIP_SET + + if(inserted_tape) + context[SCREENTIP_CONTEXT_CTRL_LMB] = "Eject Tape" + . = CONTEXTUAL_SCREENTIP_SET + +/obj/machinery/dj_station/examine(mob/user) + . = ..() + if(inserted_tape) + . += span_notice("It currently has a tape inserted.") + if(!COOLDOWN_FINISHED(src, next_song_timer)) + . += span_notice("It's currently on cooldown, it will be able to play another song in [DisplayTimeText(COOLDOWN_TIMELEFT(src, next_song_timer))].") + +/obj/machinery/dj_station/update_overlays() + . = ..() + if(broadcasting) + . += mutable_appearance(icon, "[icon_state]_on") + . += emissive_appearance(icon, "[icon_state]_on_e", src, alpha = src.alpha) + +/obj/machinery/dj_station/process() + if(!playing?.duration || !broadcasting || !song_start_time) + return PROCESS_KILL + if(REALTIMEOFDAY > (song_start_time + (playing.duration * 1 SECONDS))) + end_processing() // doing this instead of PROCESS_KILL because i think there's a possibility of this sleeping? + log_music("Song \"[playing.name]\" from [inserted_tape.name] ([inserted_tape.cassette_data?.id || "no cassette id"]) finished playing at [AREACOORD(src)]") + PLAY_CASSETTE_SOUND(SFX_DJSTATION_STOP) + COOLDOWN_START(src, next_song_timer, song_cooldown) + broadcasting = FALSE + song_start_time = 0 + update_appearance(UPDATE_OVERLAYS) + SStgui.update_uis(src) + +/obj/machinery/dj_station/item_interaction(mob/living/user, obj/item/tool, list/modifiers) + if(!istype(tool, /obj/item/cassette_tape)) + return NONE + if(DOING_INTERACTION_WITH_TARGET(user, src)) + return ITEM_INTERACT_BLOCKING + if(is_ejecting) + balloon_alert(user, "already inserting/ejecting") + return ITEM_INTERACT_BLOCKING + if(broadcasting) + balloon_alert(user, "stop the current track first!") + return ITEM_INTERACT_BLOCKING + + is_ejecting = TRUE + + var/obj/item/cassette_tape/old_tape = inserted_tape + if(old_tape) + PLAY_CASSETTE_SOUND(SFX_DJSTATION_OPENTAKEOUT) + if (!do_after(user, 1.3 SECONDS, src)) + is_ejecting = FALSE + return ITEM_INTERACT_BLOCKING + old_tape.forceMove(drop_location()) + inserted_tape = null + + if (old_tape) + sleep(0.2 SECONDS) + PLAY_CASSETTE_SOUND(SFX_DJSTATION_PUTINANDCLOSE) + if (!do_after(user, 1.3 SECONDS, src)) + is_ejecting = FALSE + return ITEM_INTERACT_BLOCKING + else + PLAY_CASSETTE_SOUND(SFX_DJSTATION_OPENPUTINANDCLOSE) + if (!do_after(user, 2.2 SECONDS, src)) + is_ejecting = FALSE + return ITEM_INTERACT_BLOCKING + if(user.transferItemToLoc(tool, src)) + balloon_alert(user, "inserted tape") + inserted_tape = tool + if(old_tape) + user.put_in_hands(old_tape) + is_ejecting = FALSE + update_static_data_for_all_viewers() + return ITEM_INTERACT_SUCCESS + +/obj/machinery/dj_station/proc/eject_tape(mob/user) + if(is_ejecting) + balloon_alert(user, "already ejecting tape!") + return FALSE + if(!inserted_tape) + balloon_alert(user, "no tape inserted!") + return FALSE + if(switching_tracks) + balloon_alert(user, "busy switching tracks!") + return FALSE + if(broadcasting) + balloon_alert(user, "stop the current track first!") + return FALSE + end_processing() + is_ejecting = TRUE + balloon_alert(user, "ejecting tape...") + PLAY_CASSETTE_SOUND(SFX_DJSTATION_OPENTAKEOUTANDCLOSE) + if (!do_after(user, 1.5 SECONDS, src)) + is_ejecting = FALSE + return FALSE + inserted_tape.forceMove(drop_location()) + is_ejecting = FALSE + log_music("[key_name(user)] ejected [inserted_tape.name] ([inserted_tape.cassette_data?.id || "no cassette id"]) at [AREACOORD(src)]") + balloon_alert(user, "tape ejected") + user.put_in_hands(inserted_tape) + inserted_tape = null + update_static_data_for_all_viewers() + return TRUE + +/obj/machinery/dj_station/click_ctrl(mob/user) + if(!can_interact(user)) + return NONE + return eject_tape(user) ? CLICK_ACTION_SUCCESS : CLICK_ACTION_BLOCKING + +/obj/machinery/dj_station/ui_interact(mob/user, datum/tgui/ui) + ui = SStgui.try_update_ui(user, src, ui) + if(!ui) + ui = new(user, src, "DjStation") + ui.open() + +/obj/machinery/dj_station/ui_data(mob/user) + var/datum/cassette_side/current_side = inserted_tape?.get_current_side() + return list( + "broadcasting" = broadcasting, + "song_cooldown" = COOLDOWN_TIMELEFT(src, next_song_timer), + "progress" = (song_start_time && playing?.duration) ? ((REALTIMEOFDAY - song_start_time) / (playing.duration * 1 SECONDS)) : 0, + "side" = inserted_tape?.flipped, + "current_song" = switching_tracks ? null : (current_side ? current_side.songs.Find(playing) - 1 : null), + "switching_tracks" = switching_tracks, + ) + +/obj/machinery/dj_station/ui_static_data(mob/user) + . = list("cassette" = null) + var/datum/cassette/cassette = inserted_tape?.cassette_data + if(cassette) + var/datum/cassette_side/side = inserted_tape.get_current_side() + .["cassette"] = list( + "name" = html_decode(cassette.name), + "desc" = html_decode(cassette.desc), + "author" = cassette.author?.name, + "design" = side?.design || /datum/cassette_side::design, + "songs" = list(), + ) + for(var/datum/cassette_song/song as anything in side?.songs) + .["cassette"]["songs"] += list(list( + "name" = song.name, + "url" = song.url, + "length" = song.duration * 1 SECONDS, // convert to deciseconds + "artist" = song.artist, + "album" = song.album, + )) + +/obj/machinery/dj_station/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state) + . = ..() + if (.) + return . + + var/mob/user = ui.user + testing("dj station [action]([json_encode(params)])") + switch(action) + if("eject", "play", "stop") + if(switching_tracks) + balloon_alert(user, "busy switching tracks!") + return TRUE + + switch(action) + if("eject") + eject_tape(user) + return TRUE + if("play") + . = TRUE + play(user) + if("stop") + . = TRUE + stop(user) + if("set_track") + . = TRUE + if(switching_tracks) + balloon_alert(user, "already switching tracks!") + return + if(broadcasting) + balloon_alert(user, "stop the current track first!") + return + var/index = params["index"] + if(!isnum(index)) + CRASH("tried to pass non-number index ([index]) to set_track??? this is prolly a bug.") + index++ + if(!inserted_tape) + balloon_alert(user, "no cassette tape inserted!") + return + + if(!can_play_bootlegs && inserted_tape.cassette_data?.status != CASSETTE_STATUS_APPROVED) + balloon_alert(user, "cannot play bootleg tapes!") + return + + // Are both sides blank + if(!length(inserted_tape.cassette_data?.front?.songs) && !length(inserted_tape.cassette_data?.back?.songs)) + balloon_alert(user, "this cassette is blank!") + return + var/list/cassette_songs = inserted_tape.get_current_side()?.songs + + var/song_count = length(cassette_songs) + if(!song_count) + balloon_alert(user, "no tracks on this side!") + return + var/datum/cassette_song/found_track = cassette_songs[index] + if(!found_track) + balloon_alert(user, "that track doesnt exist!") + return + if(playing && (cassette_songs.Find(playing) == index)) + PLAY_CASSETTE_SOUND(SFX_DJSTATION_STOP) + balloon_alert(user, "already on that track!") + return + log_music("[key_name(user)] switched the track to \"[found_track.name]\" from [inserted_tape.name] ([inserted_tape.cassette_data?.id || "no cassette id"]) at [AREACOORD(src)]") + switching_tracks = TRUE + if(broadcasting) + broadcasting = FALSE + song_start_time = 0 + update_appearance(UPDATE_OVERLAYS) + INVOKE_ASYNC(src, PROC_REF(stop_for_all_listeners)) + if(playing) + PLAY_CASSETTE_SOUND(SFX_DJSTATION_STOP) + sleep(0.2 SECONDS) + switch_sound.start() + SStgui.update_uis(src) + COOLDOWN_START(src, fake_loading_time, 3 SECONDS) + var/list/info = SSfloxy.download_and_wait(found_track.url, timeout = 30 SECONDS, discard_failed = TRUE) + testing(fieldset_block("info for [html_encode(found_track.url)]", html_encode(json_encode(info, JSON_PRETTY_PRINT)), "boxed_message purple_box")) + // fake loading time in case there's already a download cached and it returns immediately + if(!COOLDOWN_FINISHED(src, fake_loading_time)) + // waow that was fast, are you failed, + if(info?["status"] == FLOXY_STATUS_FAILED) + SSfloxy.delete_media(info["id"], hard = TRUE, force = TRUE) + info = SSfloxy.download_and_wait(found_track.url, timeout = 30 SECONDS, discard_failed = TRUE) + else + var/remaining = rand(4, 8) SECONDS - COOLDOWN_TIMELEFT(src, fake_loading_time) + testing("waiting extra [remaining] seconds to simulate loading time") + sleep(remaining) + if(info) + playing = found_track + if(length(info["endpoints"])) + music_endpoint = info["endpoints"][1] + else + log_floxy("Floxy did not return a music endpoint for [found_track.url]") + stack_trace("Floxy did not return a music endpoint for [found_track.url]") + balloon_alert(user, "the loader mechanism malfunctioned!") + log_floxy("endpoint for [playing.url]: [music_endpoint]") + var/list/metadata = info["metadata"] + if(metadata) + if(metadata["title"]) + playing.name = metadata["title"] + if(metadata["artist"]) + playing.artist = metadata["artist"] + if(metadata["album"]) + playing.album = metadata["album"] + if(playing.duration <= 0 && metadata["duration"]) + playing.duration = metadata["duration"] + playing_extra_data = list( + "title" = playing.name, + "link" = playing.url, + "artist" = playing.artist, + "album" = playing.album, + ) + if(playing.duration > 0) + playing_extra_data["duration"] = DisplayTimeText(playing.duration * 1 SECONDS) + else + playing = null + playing_extra_data = null + music_endpoint = null + song_start_time = 0 + balloon_alert(user, "it got stuck! try again?") + INVOKE_ASYNC(src, PROC_REF(stop_for_all_listeners)) + switching_tracks = FALSE + SStgui.update_uis(src) + switch_sound.stop() + +/obj/machinery/dj_station/proc/play(mob/user, force = FALSE) + if(!user && usr) + user = usr + + if(!force) + if(is_ejecting) + if(user) + balloon_alert(user, "currently inserting/ejecting tape!") + return FALSE + if(!playing || !music_endpoint) + if(user) + balloon_alert(user, "no track set!") + return FALSE + if(broadcasting) + if(user) + balloon_alert(user, "song already playing!") + return FALSE + PLAY_CASSETTE_SOUND(SFX_DJSTATION_PLAY) + song_start_time = REALTIMEOFDAY + broadcasting = TRUE + update_appearance(UPDATE_OVERLAYS) + INVOKE_ASYNC(src, PROC_REF(play_to_all_listeners)) + SStgui.update_uis(src) + log_music("[key_name(user)] [force ? "forcefully ": ""]began playing the track \"[playing.name]\" from [inserted_tape.name] ([inserted_tape.cassette_data?.id || "no cassette id"]) at [AREACOORD(src)]") + message_admins("[ADMIN_LOOKUPFLW(user)] [force ? "forcefully ": ""]began playing the track \"[playing.name]\" from [inserted_tape.name] ([inserted_tape.cassette_data?.id || "no cassette id"])") + begin_processing() + return TRUE + +/obj/machinery/dj_station/proc/stop(mob/user, force = FALSE) + if(!user && usr) + user = usr + + if(!force) + if(!playing || !broadcasting) + if(user) + balloon_alert(user, "not playing!") + return FALSE + if(is_ejecting) + if(user) + balloon_alert(user, "currently inserting/ejecting tape!") + return FALSE + end_processing() + PLAY_CASSETTE_SOUND(SFX_DJSTATION_STOP) + broadcasting = FALSE + COOLDOWN_START(src, next_song_timer, song_cooldown) + update_appearance(UPDATE_OVERLAYS) + song_start_time = 0 + INVOKE_ASYNC(src, PROC_REF(stop_for_all_listeners)) + SStgui.update_uis(src) + log_music("[key_name(user)] [force ? "forcefully ": ""] stopped playing song \"[playing.name]\" from [inserted_tape.name] ([inserted_tape.cassette_data?.id || "no cassette id"]) at [AREACOORD(src)]") + return TRUE + +// It cannot be stopped. +/obj/machinery/dj_station/take_damage(damage_amount, damage_type, damage_flag, sound_effect, attack_dir, armour_penetration) + return + +/obj/machinery/dj_station/emp_act(severity) + return + +// Funny. +/obj/machinery/dj_station/bullet_act(obj/projectile/hitting_projectile, def_zone, piercing_hit) + SHOULD_CALL_PARENT(FALSE) + visible_message(span_warning("[hitting_projectile] bounces harmlessly off of [src]!")) + // doesn't actually do any damage, this is meant to annoy people when they try to shoot it bc someone played pickle rick + hitting_projectile.damage = 0 + hitting_projectile.stamina = 0 + hitting_projectile.reflect(src) + return BULLET_ACT_FORCE_PIERCE + +// TODO: clean all of this shit up +/obj/machinery/dj_station/proc/play_to_all_listeners() + if(GLOB.dj_booth != src || !broadcasting || !music_endpoint || !playing) + return + for(var/mob/listener as anything in GLOB.music_listeners) + if(QDELETED(listener) || !listener.client?.fully_created || HAS_TRAIT(listener, TRAIT_LISTENING_TO_WALKMAN) || !listener.client?.prefs?.read_preference(/datum/preference/toggle/hear_music)) + continue + listener.client?.tgui_panel?.play_music(music_endpoint, playing_extra_data) + +/obj/machinery/dj_station/proc/stop_for_all_listeners() + if(GLOB.dj_booth != src) + return + for(var/mob/listener as anything in GLOB.music_listeners) + if(QDELETED(listener) || HAS_TRAIT(listener, TRAIT_LISTENING_TO_WALKMAN)) + continue + listener.client?.tgui_panel?.stop_music() + +/obj/machinery/dj_station/proc/on_listener_ready(mob/listener) + SIGNAL_HANDLER + addtimer(CALLBACK(src, PROC_REF(play_for_listener), listener), 0.5 SECONDS, TIMER_UNIQUE | TIMER_OVERRIDE) + +/obj/machinery/dj_station/proc/play_for_listener(mob/listener) + if(GLOB.dj_booth != src || !broadcasting || !music_endpoint || !playing) + return + if(QDELETED(listener) || !HAS_TRAIT(listener, TRAIT_CAN_HEAR_MUSIC) || HAS_TRAIT(listener, TRAIT_LISTENING_TO_WALKMAN) || !listener.client?.prefs?.read_preference(/datum/preference/toggle/hear_music)) + return + var/list/extra_data = playing_extra_data.Copy() + var/start = floor((REALTIMEOFDAY - song_start_time) / 10) + if(start > 0) + extra_data["start"] = start + listener.client?.tgui_panel?.play_music(music_endpoint, extra_data) + +/obj/machinery/dj_station/proc/on_add_listener(datum/source, mob/listener) + SIGNAL_HANDLER + if(GLOB.dj_booth != src) + return + RegisterSignal(listener, COMSIG_TGUI_PANEL_READY, PROC_REF(on_listener_ready)) + if(!broadcasting || !playing || !music_endpoint || HAS_TRAIT(listener, TRAIT_LISTENING_TO_WALKMAN) || !listener.client?.prefs?.read_preference(/datum/preference/toggle/hear_music)) + return + var/list/extra_data = playing_extra_data.Copy() + var/start = floor((REALTIMEOFDAY - song_start_time) / 10) + if(start > 0) + extra_data["start"] = start + listener.client?.tgui_panel?.play_music(music_endpoint, extra_data) + +/obj/machinery/dj_station/proc/on_remove_listener(datum/source, mob/listener) + SIGNAL_HANDLER + UnregisterSignal(listener, COMSIG_TGUI_PANEL_READY) + if(GLOB.dj_booth == src) + listener.client?.tgui_panel?.stop_music() diff --git a/modular_doppler/cassettes/code/modules/cassette/machines/postbox.dm b/modular_doppler/cassettes/code/modules/cassette/machines/postbox.dm new file mode 100644 index 00000000000000..e7d324cacc21ac --- /dev/null +++ b/modular_doppler/cassettes/code/modules/cassette/machines/postbox.dm @@ -0,0 +1,50 @@ +/obj/machinery/cassette_postbox + name = "Cassette Postbox" + desc = "Has a slit specifically to fit cassettes into it." + icon = 'modular_doppler/cassettes/icons/radio_station.dmi' + icon_state = "postbox" + + max_integrity = 100000 //lol + resistance_flags = INDESTRUCTIBLE + anchored = TRUE + density = TRUE + +/obj/machinery/cassette_postbox/Initialize(mapload) + . = ..() + REGISTER_REQUIRED_MAP_ITEM(1, INFINITY) + +/obj/machinery/cassette_postbox/item_interaction(mob/living/user, obj/item/cassette_tape/tape, list/modifiers) + if(!istype(tape, /obj/item/cassette_tape) || !user.client) + return NONE + + if(tape.cassette_data.status == CASSETTE_STATUS_APPROVED) + to_chat(user, span_notice("This tape is already approved!")) + return ITEM_INTERACT_BLOCKING + + if(!length(tape.cassette_data?.front?.songs) && !length(tape.cassette_data?.back?.songs)) + to_chat(user, span_notice("This tape is blank!")) + return + + var/list/admin_count = get_admin_counts(R_FUN) + if(!length(admin_count["present"])) + to_chat(user, span_notice("The postbox refuses your cassette, it seems the Space Board is out for lunch.")) + return ITEM_INTERACT_BLOCKING + + if(!tape.cassette_data.name) + to_chat(user, span_notice("Please name your tape before submitting it, you can't change this later!")) + return ITEM_INTERACT_BLOCKING + + if(!tape.cassette_data.desc) + to_chat(user, span_notice("Please add a description to your tape before submitting it, you can't change this later!")) + return ITEM_INTERACT_BLOCKING + + var/choice = tgui_alert(user, "Are you sure? This costs 5k Monkecoins", "Mailbox", list("Yes", "No")) + if(choice != "Yes") + return ITEM_INTERACT_BLOCKING + + var/secondchoice = tgui_alert(user, "Please make sure to Adminhelp and check for any available admins that can review your cassette before submitting, you will not be refunded if it is denied. If an admin does not review your cassette, and you are connected at the end of the round, you may be refunded.", "Mailbox", list("Acknowledge", "Cancel")) + if(secondchoice != "Acknowledge") + return ITEM_INTERACT_BLOCKING + + submit_cassette_for_review(user, tape) + return ITEM_INTERACT_SUCCESS diff --git a/modular_doppler/cassettes/code/modules/cassette/machines/radio_mic.dm b/modular_doppler/cassettes/code/modules/cassette/machines/radio_mic.dm new file mode 100644 index 00000000000000..091cce7bbbccb5 --- /dev/null +++ b/modular_doppler/cassettes/code/modules/cassette/machines/radio_mic.dm @@ -0,0 +1,46 @@ +/obj/item/radio/radio_mic + name = "Radio Microphone" + desc = "Used to talk over the radio" + + icon = 'modular_doppler/cassettes/icons/radio_station.dmi' + icon_state = "unce_machine" + + radio_host = TRUE + universal = TRUE + command = TRUE + + lossless = TRUE + + density = TRUE + anchored = TRUE + resistance_flags = INDESTRUCTIBLE + pass_flags_self = parent_type::pass_flags_self | LETPASSCLICKS + + /// overlay when speaker is on + overlay_speaker_idle = null + /// overlay when recieving a message + overlay_speaker_active = null + + /// overlay when mic is on + overlay_mic_idle = null + /// overlay when speaking a message (is displayed simultaniously with speaker_active) + overlay_mic_active = null + +/obj/item/radio/radio_mic/Initialize(mapload) + . = ..() + REGISTER_REQUIRED_MAP_ITEM(1, INFINITY) + + frequency = FREQ_RADIO + broadcasting = TRUE + use_command = TRUE + + perform_update_icon = FALSE + should_update_icon = FALSE + + set_broadcasting(TRUE) + +/obj/item/radio/radio_mic/ui_interact(mob/user, datum/tgui/ui, datum/ui_state/state) + return + +/obj/item/radio/screwdriver_act(mob/living/user, obj/item/tool) + add_fingerprint(user) diff --git a/modular_doppler/cassettes/code/modules/cassette/machines/stationary_mixer.dm b/modular_doppler/cassettes/code/modules/cassette/machines/stationary_mixer.dm new file mode 100644 index 00000000000000..885610d391f42d --- /dev/null +++ b/modular_doppler/cassettes/code/modules/cassette/machines/stationary_mixer.dm @@ -0,0 +1,287 @@ +/obj/machinery/cassette_deck + name = "Advanced Cassette Deck" + desc = "A more advanced less portable Cassette Deck. Useful for recording songs from our generation, or customizing the style of your cassettes." + icon ='modular_doppler/cassettes/icons/adv_cassette_deck.dmi' + icon_state = "cassette_deck" + density = TRUE + pass_flags = PASSTABLE + interaction_flags_atom = parent_type::interaction_flags_atom | INTERACT_ATOM_REQUIRES_ANCHORED + ///cassette tape used in adding songs or customizing + var/obj/item/cassette_tape/tape + ///Selection used to remove songs + var/selection + var/busy = FALSE + +/obj/machinery/cassette_deck/Initialize(mapload) + . = ..() + REGISTER_REQUIRED_MAP_ITEM(1, INFINITY) + register_context() + +/obj/machinery/cassette_deck/Destroy() + if(!QDELETED(tape)) + tape.forceMove(drop_location()) + tape = null + return ..() + +/obj/machinery/cassette_deck/add_context(atom/source, list/context, obj/item/held_item, mob/user) + . = NONE + if(istype(held_item, /obj/item/cassette_tape)) + context[SCREENTIP_CONTEXT_LMB] = "Insert Tape" + . = CONTEXTUAL_SCREENTIP_SET + else if(!held_item && tape) + context[SCREENTIP_CONTEXT_LMB] = "Modify/View Tape" + . = CONTEXTUAL_SCREENTIP_SET + + if(tape) + context[SCREENTIP_CONTEXT_CTRL_LMB] = "Eject Tape" + . = CONTEXTUAL_SCREENTIP_SET + +/obj/machinery/cassette_deck/wrench_act(mob/living/user, obj/item/tool) + . = ..() + default_unfasten_wrench(user, tool) + return ITEM_INTERACT_SUCCESS + +/obj/machinery/cassette_deck/click_ctrl(mob/user) + if(!can_interact(user)) + return NONE + return eject_tape(user) ? CLICK_ACTION_SUCCESS : CLICK_ACTION_BLOCKING + +/obj/machinery/cassette_deck/item_interaction(mob/living/user, obj/item/tool, list/modifiers) + if(!istype(tool, /obj/item/cassette_tape)) + return NONE + if(tape) + balloon_alert(user, "remove the current tape!") + return ITEM_INTERACT_BLOCKING + if(!user.transferItemToLoc(tool, src)) + balloon_alert(user, "failed to insert tape!") + return ITEM_INTERACT_BLOCKING + tape = tool + playsound(src, 'sound/weapons/handcuffs.ogg', vol = 20, vary = TRUE, CHANNEL_MACHINERY) + balloon_alert(user, "tape inserted") + SStgui.update_uis(src) + return ITEM_INTERACT_SUCCESS + +/obj/machinery/cassette_deck/proc/eject_tape(mob/user) + if(!tape) + balloon_alert(user, "no tape inserted!") + return FALSE + if(busy) + balloon_alert(user, "busy!") + return FALSE + tape.forceMove(drop_location()) + user?.put_in_hands(tape) + tape = null + SStgui.update_uis(src) + return TRUE + +/* +/obj/machinery/cassette_deck/ui_interact(mob/user, datum/tgui/ui) + ui = SStgui.try_update_ui(user, src, ui) + if(!ui) + ui = new(user, src, "CassetteDeck", name) + ui.set_autoupdate(FALSE) + ui.open() +*/ + +/obj/machinery/cassette_deck/attack_hand(mob/living/user, list/modifiers) + . = ..() + if(.) + return + if(!tape) + balloon_alert(user, "no tape inserted!") + return + if(busy) + balloon_alert(user, "busy!") + return + tgui_alert(user, "Please ahelp before creating a tape and check for available admins. Failure to do so will result in your tape being denied.", "Notice", list("I Understand")) + var/action = tgui_input_list(user, "What would you like to do with this tape?", html_decode(tape.cassette_data.name), list("Add Track", "Remove Track", "View Tracks", "Change Design", "Eject Tape")) + if(!action || !tape || busy) + return + switch(action) + if("Add Track") + try_add_track(user) + if("Remove Track") + try_remove_track(user) + if("View Tracks") + view_tracks(user) + if("Change Design") + try_change_design(user) + if("Eject Tape") + eject_tape(user) + +/obj/machinery/cassette_deck/proc/try_add_track(mob/user) + busy = TRUE + SStgui.update_uis(src) + var/url = trimtext(tgui_input_text(user, "Paste the URL for the song you would like to add.", "Add Track", encode = FALSE)) + busy = FALSE + if(!url) + balloon_alert(user, "no URL given!") + SStgui.update_uis(src) + return + if(!add_track(user, url)) + balloon_alert(user, "failed to add track") + SStgui.update_uis(src) + +/obj/machinery/cassette_deck/proc/try_remove_track(mob/user) + var/datum/cassette_side/side = tape.get_current_side() + if(!length(side.songs)) + balloon_alert(user, "no tracks to remove!") + return + var/list/tracks = list() + for(var/idx = 1 to length(side.songs)) + var/datum/cassette_song/track = side.songs[idx] + tracks += "([idx]) [track.name]" + busy = TRUE + SStgui.update_uis(src) + var/track_to_remove = tgui_input_list(user, "Which track would you like to remove?", html_decode(tape.cassette_data.name), tracks) + busy = FALSE + if(!track_to_remove) + balloon_alert(user, "no track selected!") + SStgui.update_uis(src) + return + var/idx = tracks.Find(track_to_remove) + if(idx) + side.songs.Cut(idx, idx + 1) + balloon_alert(user, "track removed") + playsound(src, 'sound/weapons/handcuffs.ogg', vol = 20, vary = TRUE, CHANNEL_MACHINERY) + else + balloon_alert(user, "error removing track?") + SStgui.update_uis(src) + +/obj/machinery/cassette_deck/proc/view_tracks(mob/user) + if(busy) + balloon_alert(user, "busy!") + return + var/datum/cassette_side/side = tape.get_current_side() + var/list/tracks = side.songs.Copy() + if(!length(tracks)) + balloon_alert(user, "no tracks on side!") + return + var/datum/cassette_song/track_to_open = tgui_input_list(user, "Select a track to open its URL in your browser.", html_decode(tape.cassette_data.name), tracks) + if(track_to_open) + DIRECT_OUTPUT(user, link(track_to_open.url)) + +/obj/machinery/cassette_deck/proc/try_change_design(mob/user) + if(busy) + balloon_alert(user, "busy!") + return + busy = TRUE + SStgui.update_uis(src) + var/new_design = tgui_input_list(user, "Select a sticker design to use for this side of the tape!", html_decode(tape.cassette_data.name), assoc_to_keys(GLOB.cassette_icons)) + busy = FALSE + if(!new_design || !(new_design in GLOB.cassette_icons)) + balloon_alert(user, "no design selected!") + SStgui.update_uis(src) + return + tape.get_current_side().design = GLOB.cassette_icons[new_design] + tape.update_appearance(UPDATE_ICON_STATE) + balloon_alert(user, "selected [new_design] design") + SStgui.update_uis(src) + +/obj/machinery/cassette_deck/proc/add_track(mob/user, url) + if(!url) + return FALSE + if(busy) + balloon_alert(user, "busy!") + return FALSE + var/datum/cassette_side/side = tape?.get_current_side() + if(!side) + balloon_alert(user, "no tape inserted!") + return FALSE + if(length(side.songs) >= MAX_SONGS_PER_CASSETTE_SIDE) + balloon_alert(user, "tape side is full!") + return FALSE + if(!is_http_protocol(url)) + balloon_alert(user, "invalid URL!") + return FALSE + if(findtext(url, "spotify.com") || findtext(url, "music.apple.com") || findtext(url, "deezer.com") || findtext(url, "tidal.com")) + balloon_alert(user, "unsupported service!") + to_chat(user, span_warning("This URL is unsupported. Try a YouTube, Bandcamp, or Soundcloud URL.")) + return FALSE + busy = TRUE + SStgui.update_uis(src) + var/list/metadata = SSfloxy.fetch_media_metadata(url) + busy = FALSE + if(!metadata) + balloon_alert(user, "failed to fetch music metadata!") + to_chat(user, span_warning("Failed to fetch music metadata. Are you trying to use an unsupported service (i.e Spotify)? Try a YouTube, Bandcamp, or Soundcloud URL if so.")) + SStgui.update_uis(src) + return FALSE + var/datum/cassette_song/song = new(metadata["title"], metadata["url"], metadata["duration"], metadata["artist"], metadata["album"]) + side.songs += song + tape.cassette_data.status = CASSETTE_STATUS_UNAPPROVED // reset to unapproved + SStgui.update_uis(src) + balloon_alert(user, "track added!") + to_chat(user, span_notice("Added new track \"[metadata["title"]]\" to [tape]")) + playsound(src, 'sound/weapons/handcuffs.ogg', vol = 20, vary = TRUE, CHANNEL_MACHINERY) + return TRUE + +/obj/machinery/cassette_deck/ui_assets(mob/user) + return list( + get_asset_datum(/datum/asset/spritesheet_batched/cassettes), + ) + +/obj/machinery/cassette_deck/ui_data(mob/user) + . = list( + "cassette" = null, + "busy" = busy, + ) + var/datum/cassette/cassette = tape?.cassette_data + if(cassette) + var/datum/cassette_side/side = tape.get_current_side() + .["cassette"] = list( + "name" = html_decode(cassette.name), + "desc" = html_decode(cassette.desc), + "status" = cassette.status, + "author" = cassette.author?.name, + "design" = side?.design || /datum/cassette_side::design, + "songs" = list(), + ) + for(var/datum/cassette_song/song as anything in side?.songs) + .["cassette"]["songs"] += list(list( + "name" = song.name, + "url" = song.url, + "length" = song.duration * 1 SECONDS, // convert to deciseconds + "artist" = song.artist, + "album" = song.album, + )) + +/obj/machinery/cassette_deck/ui_static_data(mob/user) + return list("icons" = GLOB.cassette_icons) + +/obj/machinery/cassette_deck/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state) + . = ..() + if(.) + return + var/mob/user = ui.user + var/datum/cassette_side/side = tape?.get_current_side() + if(!side) + balloon_alert(user, "no tape inserted!") + return + switch(action) + if("remove") + . = TRUE + var/index = params["index"] + if(!isnum(index)) + CRASH("tried to pass non-number index ([index]) to remove??? this is prolly a bug.") + index++ + if(index > length(side.songs)) + CRASH("tried to remove track [index] from tape while there were only [length(side.songs)] songs???") + side.songs.Cut(index, index + 1) + balloon_alert(user, "removed track") + SStgui.update_uis(src) + if("add") + add_track(params["url"]) + return TRUE + if("eject") + eject_tape(user) + return TRUE + if("set_design") + . = TRUE + var/new_design = params["design"] + if(!new_design || !(new_design in GLOB.cassette_icons)) + return + side.design = GLOB.cassette_icons[new_design] + tape.update_appearance(UPDATE_ICON_STATE) + balloon_alert(user, "selected [new_design] design") + SStgui.update_uis(src) diff --git a/modular_doppler/cassettes/code/modules/cassette/media/HTML5_player.dm b/modular_doppler/cassettes/code/modules/cassette/media/HTML5_player.dm new file mode 100644 index 00000000000000..58c6cd2233da39 --- /dev/null +++ b/modular_doppler/cassettes/code/modules/cassette/media/HTML5_player.dm @@ -0,0 +1,48 @@ +// IT IS FINALLY TIME. IT IS HERE. Converted to HTML5 - Leshana +var/const/PLAYER_HTML5_HTML={" + + + + + + + + + +"} + +// Legacy player using Windows Media Player OLE object. +// I guess it will work in IE on windows, and BYOND uses IE on windows, so alright! +var/const/PLAYER_WMP_HTML={" + + "} diff --git a/modular_doppler/cassettes/code/modules/cassette/media/__base_machine.dm b/modular_doppler/cassettes/code/modules/cassette/media/__base_machine.dm new file mode 100644 index 00000000000000..196a7ab8c55d6a --- /dev/null +++ b/modular_doppler/cassettes/code/modules/cassette/media/__base_machine.dm @@ -0,0 +1,63 @@ +/proc/mobs_in_area(var/area/A) + var/list/mobs = list() + for(var/M in GLOB.mob_list) + if(get_area(M) == A) + mobs += M + return mobs + +// Machinery serving as a media source. +/obj/machinery/media + var/playing = 0 // Am I playing right now? + var/media_url = "" // URL of media I am playing + var/media_start_time = 0 // world.time when it started playing + var/volume = 1 // 0 - 1 for ease of coding. + + // ~Leshana - Transmitters unimplemented + +// Notify everyone in the area of new music. +// YOU MUST SET MEDIA_URL AND MEDIA_START_TIME YOURSELF! +/obj/machinery/media/proc/update_music() + update_media_source() + // Send update to clients. + for(var/mob/M in range(15)) + if(M && M.client) + M.update_music() + +/obj/machinery/media/proc/update_media_source() + // Check if there's a media source already. + for(var/area/A in get_areas_in_range(15, src)) + if(A.media_source && A.media_source != src) // If it does, the new media source replaces it. basically, the last media source arrived gets played on top. + A.media_source.disconnect_media_source() // You can turn a media source off and on for it to come back on top. + A.media_source = src + return + else + A.media_source = src + +/obj/machinery/media/proc/disconnect_media_source() + for(var/area/A in get_areas_in_range(15, src)) + // Update Media Source. + A.media_source = null + + // Clients + for(var/mob/M as anything in range(15)) + M.update_music() + +/obj/machinery/media/Move() + disconnect_media_source() + . = ..() + if(anchored) + update_music() + +/obj/machinery/media/forceMove(var/atom/destination) + disconnect_media_source() + . = ..() + if(anchored) + update_music() + +/obj/machinery/media/Initialize() + . = ..() + update_media_source() + +/obj/machinery/media/Destroy() + disconnect_media_source() + . = ..() diff --git a/modular_doppler/cassettes/code/modules/cassette/media/_media_source.dm b/modular_doppler/cassettes/code/modules/cassette/media/_media_source.dm new file mode 100644 index 00000000000000..dac3e23bc5bba9 --- /dev/null +++ b/modular_doppler/cassettes/code/modules/cassette/media/_media_source.dm @@ -0,0 +1,124 @@ +/datum/media_source + /// A list of mobs currently listening to this media source. + var/list/mob/listeners + /// The current track being played by this source. + var/datum/media_track/current_track + /// The base volume of the media. + var/volume = 100 + /// The time this media source started playing. + var/start_time + /// The mixer channel to use to multiply the volume by. + var/mixer_channel + +/datum/media_source/New(datum/media_track/track, volume, mixer_channel) + src.current_track = track + if(!isnull(volume)) + src.volume = volume + if(!isnull(mixer_channel)) + src.mixer_channel = "[mixer_channel]" + else if(!isnull(src.mixer_channel)) + src.mixer_channel = "[src.mixer_channel]" + +/datum/media_source/Destroy(force) + for(var/mob/listener as anything in listeners) + remove_listener(listener) + current_track = null + return ..() + +/datum/media_source/proc/add_listener(mob/target) + if(!ismob(target)) + CRASH("Attempted to add non-mob as a listener to a [type]") + if(QDELING(target) || QDELETED(src)) + return + if(target in listeners) + update_for_listener(target) + return + RegisterSignal(target, COMSIG_QDELETING, PROC_REF(remove_listener)) + RegisterSignal(target, COMSIG_MOVABLE_MOVED, PROC_REF(update_for_listener_signal)) + LAZYADD(listeners, target) + target.add_media_source(src) + +/datum/media_source/proc/remove_listener(mob/target) + SIGNAL_HANDLER + if(isnull(target) || !(target in listeners)) + return + UnregisterSignal(target, list(COMSIG_QDELETING, COMSIG_MOVABLE_MOVED)) + LAZYREMOVE(listeners, target) + LAZYREMOVE(target.available_media_sources, src) + target.remove_media_source(src) + +/datum/media_source/proc/get_position(mob/target, x_ptr, y_ptr, z_ptr) + *x_ptr = 0 + *y_ptr = 0 + *z_ptr = 0 + return TRUE + +/datum/media_source/proc/get_volume(mob/target) + . = volume + if(isnull(mixer_channel)) + return volume + var/client/client = CLIENT_FROM_VAR(target) + var/list/channel_volume = client?.prefs?.channel_volume + if("[CHANNEL_MASTER_VOLUME]" in channel_volume) + . *= (channel_volume["[CHANNEL_MASTER_VOLUME]"] / 100) + if(mixer_channel in channel_volume) + . *= (channel_volume[mixer_channel] / 100) + +/datum/media_source/proc/get_balance(mob/target) + return 0 + +/// Wrapper for update_for_listener to be used with signals, to avoid passing extra args in. +/datum/media_source/proc/update_for_listener_signal(mob/target) + SIGNAL_HANDLER + update_for_listener(target) + +/datum/media_source/proc/update_for_listener(mob/target, force_update_time = FALSE) + if(!QDELETED(src) && !QDELETED(target)) + target.update_media_source(force_update_time) + +/datum/media_source/proc/play_for_listener(mob/target, datum/media_player/media_player, update_time = FALSE) + if(QDELETED(src) || QDELETED(media_player) || !current_track?.url) + return + var/x = 0 + var/y = 0 + var/z = 0 + // get_position returns FALSE if they shouldn't be hearing it anyways + if(!get_position(target, &x, &y, &z)) + CRASH("Tried to play media for mob that should not be able to hear it!") + var/volume = get_volume(target) + var/balance = get_balance(target) + if(media_player.current_url == current_track.url) + media_player.set_volume(volume) + media_player.set_position(x, y, z) + media_player.set_panning(balance) + else + media_player.play(current_track.url, volume, x, y, z, balance) + if(update_time) + var/time = get_current_time() + if(time > 0) + media_player.set_time(time) + +/datum/media_source/proc/update_for_all_listeners(force_update_time = FALSE) + for(var/mob/listener as anything in listeners) + update_for_listener(listener, force_update_time) + +/datum/media_source/proc/get_current_time() + if(!start_time) + return 0 + if(isnull(current_track) || !current_track.url || !current_track.duration) + start_time = 0 + return 0 + var/current_time = max(REALTIMEOFDAY - start_time, 0) + if(current_time > current_track.duration) + return 0 + return current_time / 10 + +/datum/media_source/proc/get_priority(mob/target) + return -1 + +/datum/media_source/vv_edit_var(var_name, var_value) + . = ..() + if(!.) + return + if(var_name in list(NAMEOF(src, current_track), NAMEOF(src, volume), NAMEOF(src, start_time), NAMEOF(src, mixer_channel))) + update_for_all_listeners() diff --git a/modular_doppler/cassettes/code/modules/cassette/media/media_manager.dm b/modular_doppler/cassettes/code/modules/cassette/media/media_manager.dm new file mode 100644 index 00000000000000..a03ce2c0cd3cec --- /dev/null +++ b/modular_doppler/cassettes/code/modules/cassette/media/media_manager.dm @@ -0,0 +1,245 @@ +/********************** + * AWW SHIT IT'S TIME FOR RADIO + * + * Concept stolen from D2K5 + * Rewritten by N3X15 for vgstation + * Adapted by Leshana for VOREStation + * Adapted by Dwasint for Monkestation + * Adapted by Chelxox for DopplerShift + ***********************/ + +// Uncomment to test the mediaplayer +//#define DEBUG_MEDIAPLAYER + +#ifdef DEBUG_MEDIAPLAYER +#define MP_DEBUG(x) to_chat(owner,x) +#warn Please comment out #define DEBUG_MEDIAPLAYER before committing. +#else +#define MP_DEBUG(x) +#endif + +// Set up player on login. +/client/New() + . = ..() + media = new /datum/media_manager(src) + media.open() + media.update_music() + +// Stop media when the round ends. I guess so it doesn't play forever or something (for some reason?) +/proc/stop_all_media() + // Stop all music. + for(var/mob/M in GLOB.mob_list) + if(M && M.client) + M.stop_all_music() + // SHITTY HACK TO AVOID RACE CONDITION WITH SERVER REBOOT. + sleep(10) // TODO - Leshana - see if this is needed + +// Update when moving between areas. +// TODO - While this direct override might technically be faster, probably better code to use observer or hooks ~Leshana +/area/Entered(var/mob/M) + // Note, we cannot call ..() first, because it would update lastarea. + if(!istype(M)) + return ..() + if(M.client?.media && !M.client.media.forced) + M.update_music() + return ..() + +// +// ### Media variable on /client ### +/client + // Set on Login + var/datum/media_manager/media = null + +/client/verb/change_volume() + set name = "Set Volume" + set category = "OOC" + set desc = "Set jukebox volume" + set_new_volume(usr) + +/client/proc/set_new_volume(var/mob/user) + if(!QDELETED(src.media) || !istype(src.media)) + to_chat(user, "You have no media datum to change, if you're not in the lobby tell an admin.") + return + var/value = input(usr, "Choose your Jukebox volume.", "Jukebox volume", media.volume) + value = round(max(0, min(100, value))) + media.update_volume(value) + +// +// ### Media procs on mobs ### +// These are all convenience functions, simple delegations to the media datum on mob. +// But their presense and null checks make other coder's life much easier. +// + +/mob/proc/update_music() + if (client?.media && !client.media.forced) + client.media.update_music() + if(!client.media.signal_synced && !istype(client.mob, /mob/dead/new_player)) + client.media.RegisterSignal(client, COMSIG_MOB_CLIENT_MOVED_CLIENT_SEND, PROC_REF(recalc_volume)) + client.media.signal_synced = TRUE + else if (!istype(client.mob, /mob/dead/new_player) && client.media.signal_synced) + var/area/A = get_area(src) + var/obj/machinery/media/M = A?.media_source + if(!M || !M.playing) + client.media.signal_synced = FALSE + client.media.UnregisterSignal(client, COMSIG_MOB_CLIENT_MOVED_CLIENT_SEND) + +/mob/proc/stop_all_music() + client?.media.stop_music() + +/mob/proc/force_music(var/url, var/start, var/volume=1) + if (client?.media) + if(url == "") + client.media.forced = 0 + client.media.update_music() + else + client.media.forced = 1 + client.media.push_music(url, start, volume) + return + +// +// ### Define media source to areas ### +// Each area may have at most one media source that plays songs into that area. +// We keep track of that source so any mob entering the area can lookup what to play. +// +/area + // For now, only one media source per area allowed + // Possible Future: turn into a list, then only play the first one that's playing. + var/obj/machinery/media/media_source = null + +// +// ### Media Manager Datum +// + +/datum/media_manager + var/url = "" // URL of currently playing media + var/start_time = 0 // world.time when it started playing *in the source* (Not when started playing for us) + var/source_volume = 1 // Volume as set by source. Actual volume = "volume * source_volume" + var/rate = 1 // Playback speed. For Fun(tm) + var/volume = 50 // Client's volume modifier. Actual volume = "volume * source_volume" + var/client/owner // Client this is actually running in + var/forced=0 // If true, current url overrides area media sources + var/playerstyle // Choice of which player plugin to use + var/const/WINDOW_ID = "infowindow.mediapanel" // Which elem in skin.dmf to use + var/balance=0 // do you know what insanity is? Value from -100 to 100 where -100 is left and 100 is right + var/signal_synced = 0 //used to check if we have our signal created + +/datum/media_manager/New(var/client/C) + ASSERT(istype(C)) + src.owner = C + +// Actually pop open the player in the background. +/datum/media_manager/proc/open() + playerstyle = PLAYER_WMP_HTML + owner << browse(null, "window=[WINDOW_ID]") + owner << browse(playerstyle, "window=[WINDOW_ID]") + send_update() + +// Tell the player to play something via JS. +/datum/media_manager/proc/send_update() + if(!(owner.prefs)) + return + + if(!owner.prefs.read_preference(/datum/preference/toggle/hear_music)) + owner << output(list2params(list("", (world.time - 0) / 10, volume * 1, 0)), "[WINDOW_ID]:SetMusic") + return // Don't send anything other than a cancel to people with SOUND_STREAMING pref disabled + + MP_DEBUG("Sending update to mediapanel ([url], [(world.time - start_time) / 10], [volume * source_volume])...") + owner << output(list2params(list(url, (world.time - start_time) / 10, volume * source_volume, balance)), "[WINDOW_ID]:SetMusic") + +/datum/media_manager/proc/push_music(var/targetURL, var/targetStartTime, var/targetVolume, var/targetBalance) + if (url != targetURL || abs(targetStartTime - start_time) > 1) + url = targetURL + start_time = targetStartTime + source_volume = clamp(targetVolume, 0, 1) + balance = targetBalance + send_update() + +/datum/media_manager/proc/stop_music() + push_music("", 0, 1) + +/datum/media_manager/proc/update_volume(var/value) + volume = value + send_update() + +// Scan for media sources and use them. +/datum/media_manager/proc/update_music() + var/targetURL = "" + var/targetStartTime = 0 + var/targetVolume = 0 + var/targetBalance = 0 + + if (forced || !owner || !owner.mob) + return + + var/area/A = get_area(owner.mob) + if(!A) + MP_DEBUG("client=[owner], mob=[owner.mob] not in an area! loc=[owner.mob.loc]. Aborting.") + stop_music() + return + var/obj/machinery/media/M = A.media_source + if(M && M.playing) + var/dist = get_dist(owner.mob, M) + var/x_dist = (owner.mob.x - M.x) * 10 + + targetURL = M.media_url + targetStartTime = M.media_start_time + targetVolume = max(0, M.volume - (dist * 0.1)) + targetBalance = x_dist + + //MP_DEBUG("Found audio source: [M.media_url] @ [(world.time - start_time) / 10]s.") + push_music(targetURL, targetStartTime, targetVolume, targetBalance) + +/mob/proc/recalc_volume() + if (client?.media && !client.media.forced) + client.media.recalc_volume() + +/datum/media_manager/proc/recalc_volume() + if(!(owner.prefs)) + return + + if(!owner.prefs.read_preference(/datum/preference/toggle/hear_music)) + return // Don't send anything other than a cancel to people with SOUND_STREAMING pref disabled + + var/targetVolume = 0 + var/targetBalance = 0 + + if (forced || !owner || !owner.mob) + return + + var/area/A = get_area(owner.mob) + if(!A) + MP_DEBUG("client=[owner], mob=[owner.mob] not in an area! loc=[owner.mob.loc]. Aborting.") + stop_music() + return + + var/obj/machinery/media/M = A.media_source + if(M && M.playing) + var/dist = get_dist(owner.mob, M) + var/x_dist = -(owner.mob.x - M.x) * 10 + + targetVolume = max(0, M.volume - (dist * 0.1)) + targetBalance = x_dist + push_volume_recalc(targetVolume, targetBalance) + +/datum/media_manager/proc/push_volume_recalc(var/targetVolume, var/targetBalance) + source_volume = clamp(targetVolume, 0, 1) + balance = targetBalance + send_volume_update() + +// Tell the player to play something via JS. +/datum/media_manager/proc/send_volume_update() + if(!(owner.prefs)) + return + /* + if(!owner.is_preference_enabled(/datum/client_preference/play_jukebox) && url != "") + return // Don't send anything other than a cancel to people with SOUND_STREAMING pref disabled + */ + MP_DEBUG("Sending volume update to mediapanel ([volume * source_volume], [balance])...") + owner << output(list2params(list(volume * source_volume, balance)), "[WINDOW_ID]:SetVolume") + +/datum/media_manager/proc/return_eastwest(mob/source, obj/machinery/media/target) + if(source.x < target.x) + return EAST + if(source.x == target.x) + return NONE + return WEST diff --git a/modular_doppler/cassettes/code/modules/cassette/media/media_mob_helpers.dm b/modular_doppler/cassettes/code/modules/cassette/media/media_mob_helpers.dm new file mode 100644 index 00000000000000..10622e90a3b619 --- /dev/null +++ b/modular_doppler/cassettes/code/modules/cassette/media/media_mob_helpers.dm @@ -0,0 +1,59 @@ +/mob + /// The current media source being listened to, if any. + var/datum/media_source/current_media_source + /// A list of available media sources. + var/list/datum/media_source/available_media_sources + +/mob/Login() + . = ..() + current_media_source?.play_for_listener(src, client?.media_player, update_time = TRUE) + +/mob/proc/add_media_source(datum/media_source/media_source) + LAZYOR(available_media_sources, media_source) + update_media_source() + +/mob/proc/remove_media_source(datum/media_source/media_source) + LAZYREMOVE(available_media_sources, media_source) + update_media_source() + +/mob/proc/update_media_source(force_update_time = FALSE) + var/datum/media_player/media_player = client?.media_player + if(!isnull(current_media_source) && (!LAZYLEN(available_media_sources) || QDELETED(src) || QDELING(current_media_source))) + media_player?.stop() + current_media_source.remove_listener(src) + current_media_source = null + return + var/datum/media_source/best_source + var/best_source_priority + for(var/datum/media_source/media_source as anything in available_media_sources) + if(!media_source.current_track?.url) + continue + var/priority = media_source.get_priority(src) + if(priority == INFINITY) + best_source = media_source + break + else if(priority <= 0) + continue + if(isnull(best_source) || priority > best_source_priority) + best_source = media_source + best_source_priority = priority + if(isnull(best_source)) + media_player?.stop() + current_media_source = null + return + var/should_update_time = force_update_time || (current_media_source != best_source) + current_media_source = best_source + if(!QDELETED(media_player)) + best_source.play_for_listener(src, media_player, should_update_time) + +/mob/dead/new_player/Login() + . = ..() + GLOB.lobby_media.add_listener(src) + +/mob/proc/update_media_volume(channel) + if(isnull(current_media_source)) + return + if(channel != CHANNEL_MASTER_VOLUME && (isnull(current_media_source.mixer_channel) || current_media_source.mixer_channel != "[channel]")) + return + var/new_volume = current_media_source.get_volume(src) + client?.media_player?.set_volume(new_volume) diff --git a/modular_doppler/cassettes/code/modules/cassette/media/media_player.dm b/modular_doppler/cassettes/code/modules/cassette/media/media_player.dm new file mode 100644 index 00000000000000..57ac31bd46cbdd --- /dev/null +++ b/modular_doppler/cassettes/code/modules/cassette/media/media_player.dm @@ -0,0 +1,248 @@ +//#define MM2_DEBUGGING + +#ifdef MM2_DEBUGGING +#define MM2_DEBUG(x) message_admins("\[MEDIA MANAGER 2 DEBUG\] " + x) +#define MEDIA_WINDOW_ID "mm2" +#warn COMMENT OUT MM2_DEBUGGING BEFORE DEPLOYING!!! +#else +#define MM2_DEBUG(x) +#define MEDIA_WINDOW_ID "outputwindow.mediapanel" +#endif + +/client + var/datum/media_player/media_player + +/datum/media_player + /// The client that this media manager is owned by. + VAR_FINAL/client/owner + /// Is our window a browser control? + VAR_FINAL/is_browser = FALSE + /// Is the media manager ready to do stuff yet? + VAR_FINAL/ready = FALSE + /// The URL the media player attempting to load, if any. + VAR_FINAL/loading_url = FALSE + /// The current URL being played. + VAR_FINAL/current_url + /// The volume of the last update. + VAR_PRIVATE/last_volume + /// The relative X coordinate of the last update. + VAR_PRIVATE/last_x + /// The relative Y coordinate of the last update. + VAR_PRIVATE/last_y + /// The relative Z coordinate of the last update. + VAR_PRIVATE/last_z + /// The balance of the last update. + VAR_PRIVATE/last_balance + /// Callbacks to run when we get the "ready" message back fron the media manager. + VAR_PRIVATE/list/ready_callbacks + var/static/base_html + +/datum/media_player/New(client/owner) + src.owner = owner + if(!isnull(owner) && owner.media_player != src && !QDELETED(owner.media_player)) + CRASH("tried to initialize a second media player for [key_name(owner)] when they already had a non-qdeleted media_player!") + if(isnull(base_html)) + init_base_html() + open() + +/datum/media_player/Destroy(force) + LAZYNULL(ready_callbacks) + close() + owner = null + return ..() + +/datum/media_player/proc/open() + set waitfor = FALSE + var/html = replacetextEx(base_html, "media:href", REF(src)) + close() +#ifndef MM2_DEBUGGING + owner << browse(html, "window=" + MEDIA_WINDOW_ID) +#else + owner << browse(html, "window=" + MEDIA_WINDOW_ID + ";size=100x100;can_minimize=0;can_close=0;") +#endif + is_browser = winexists(owner, MEDIA_WINDOW_ID) == "BROWSER" + +/datum/media_player/proc/close() + ready = FALSE + if(!isnull(owner)) + owner << browse(null, "window=" + MEDIA_WINDOW_ID) + +/// Calls a JS function in the media manager. +/// If the media manager isn't ready yet, then the call will be queued, and all queued calls will be invoked in order when it does become ready. +/datum/media_player/proc/media_call(name, ...) + PRIVATE_PROC(TRUE) + if(QDELETED(src) || isnull(owner)) + return + var/target = is_browser ? (MEDIA_WINDOW_ID + ":" + name) : (MEDIA_WINDOW_ID + ".browser:" + name) + var/params = list2params(args.Copy(2)) + if(ready) + MM2_DEBUG("call: target=[target], params=[json_encode(args.Copy(2))]") + owner << output(params, target) + else + MM2_DEBUG("queueing ready callback: target=[target], params=[json_encode(args.Copy(2))]") + LAZYADD(ready_callbacks, CALLBACK(src, PROC_REF(__ready_callback), target, params)) + +/// Wrapper proc for ready callbacks made by media_call - basically a stripped down version of media_call that won't create more ready callbacks. +/// This should NEVER be called directly. +/datum/media_player/proc/__ready_callback(target, params) + PRIVATE_PROC(TRUE) + if(!isnull(owner)) + MM2_DEBUG("calling ready callback: target=[target], params=[params]") + owner << output(params, target) + +/datum/media_player/proc/init_base_html() + var/js = file2text("code/modules/media/assets/media_player.js") + base_html = file2text("code/modules/media/assets/media_player.html") + base_html = replacetextEx(base_html, "", "") + +/datum/media_player/proc/set_position(x = 0, y = 0, z = 0) + if(last_x != x || last_y != y || last_z != z) + last_x = x + last_y = y + last_z = z + media_call("set_position", x, y, z) + +/datum/media_player/proc/set_panning(balance = 0) + if(last_balance != balance) + last_balance = balance + media_call("set_panning", balance) + +/datum/media_player/proc/set_time(time = 0) + media_call("set_time", time) + +/datum/media_player/proc/set_volume(volume = 100) + if(last_volume != volume) + last_volume = volume + media_call("set_volume", volume) + +/datum/media_player/proc/play(url, volume = 100, x = 0, y = 0, z = 0, balance = 0) + if(url == loading_url) + return + loading_url = url + last_x = x + last_y = y + last_z = z + last_volume = volume + last_balance = balance + media_call("play", url, volume, null, x, y, z, balance) + +/datum/media_player/proc/pause() + media_call("pause") + +/datum/media_player/proc/stop() + if(isnull(loading_url) && !isnull(current_url)) + media_call("stop") + +/datum/media_player/proc/on_ready() + if(ready) + CRASH("readied twice") + if(QDELETED(src) || isnull(owner)) + return + on_clear() + ready = TRUE + MM2_DEBUG("ready for [key_name(owner)]") + for(var/datum/callback/callback as anything in ready_callbacks) + callback?.Invoke() + LAZYNULL(ready_callbacks) + +/datum/media_player/proc/on_clear() + current_url = null + loading_url = null + last_volume = null + last_x = null + last_y = null + last_z = null + last_balance = null + +/datum/media_player/Topic(href, list/href_list) + . = ..() + var/message_type = href_list["type"] + if(!message_type) + return + var/list/params = isnull(href_list["params"]) ? list() : json_decode(href_list["params"]); + if(QDELETED(src)) + return + switch(message_type) + if("ready") + on_ready() + if("clear") + on_clear() + if("playing") + current_url = params["url"] + loading_url = null + if("error") + MM2_DEBUG("error: [params["message"]]") + stack_trace(params["message"]) + MM2_DEBUG("topic: [json_encode(href_list - "params", JSON_PRETTY_PRINT)]\nparams: [json_encode(params, JSON_PRETTY_PRINT)]") + +/client/verb/reload_mm2() + set name = "Force Reload Media Player" + set desc = "Forcefully reloads your client's media player (used for lobby and jukebox music)" + set category = "OOC" + + if(!QDELETED(media_player)) + QDEL_NULL(media_player) + media_player = new(src) + +#ifdef MM2_DEBUGGING +/client/verb/mm2_play() + set name = "MM2: Play" + set category = "MM2" + + var/url = trimtext(tgui_input_text(src, "What to play?", "Media Manager 2", default = "https://files.catbox.moe/29g5xp.mp3", encode = FALSE)) + if(url) + media_player.play(url) + MM2_DEBUG("playing") + +/client/verb/mm2_pause() + set name = "MM2: Pause" + set category = "MM2" + + media_player.pause() + MM2_DEBUG("paused") + +/client/verb/mm2_stop() + set name = "MM2: Stop" + set category = "MM2" + + media_player.stop() + MM2_DEBUG("stopped") + +/client/verb/mm2_set_position() + set name = "MM2: Set Position" + set category = "MM2" + + var/x = tgui_input_number(src, "Set X Value", "Media Manager 2", default = 0, min_value = -10, max_value = 10) || 0 + var/y = tgui_input_number(src, "Set Y Value", "Media Manager 2", default = 0, min_value = -10, max_value = 10) || 0 + media_player.set_position(x, y) + MM2_DEBUG("set pos to [x],[y]") + +/client/verb/mm2_set_time() + set name = "MM2: Set Time" + set category = "MM2" + + var/time = tgui_input_number(src, "Set Time (Seconds)", "Media Manager 2", default = 0, min_value = 0, round_value = FALSE) || 0 + media_player.set_time(time) + MM2_DEBUG("set time to [time]") + +/client/verb/mm2_reload_all() + set name = "MM2: Reload Base HTML/JS" + set category = "MM2" + + reload_all_mm2() + MM2_DEBUG("reloaded all") + +/proc/reload_all_mm2() + var/did_re_init = FALSE + for(var/client/client in GLOB.clients) + var/datum/media_player/mm2 = client?.media_player + if(QDELETED(mm2)) + continue + if(!did_re_init) + mm2.base_html = null + mm2.init_base_html() + did_re_init = TRUE + mm2.open() +#endif + +#undef MEDIA_WINDOW_ID diff --git a/modular_doppler/cassettes/code/modules/cassette/media/media_track.dm b/modular_doppler/cassettes/code/modules/cassette/media/media_track.dm new file mode 100644 index 00000000000000..00fa70893b6e06 --- /dev/null +++ b/modular_doppler/cassettes/code/modules/cassette/media/media_track.dm @@ -0,0 +1,40 @@ +/// Music track available for playing in a media machine. +/datum/media_track + /// URL to load song from. + var/url + /// The title of the song. + var/title + /// The song's length in deciseconds. + var/duration + /// The song's creator. + var/artist + /// The song's musical genre. + var/genre + // Should this show up in the regular playlist or secret playlist? + var/secret = FALSE + /// Should this be available as an option for lobby music? + var/lobby = FALSE + +/datum/media_track/New(url, title, duration, artist = "", genre = "", secret = FALSE, lobby = FALSE) + src.url = url + src.title = title + src.artist = artist + src.genre = genre + src.duration = duration + src.secret = secret + src.lobby = lobby + +/datum/media_track/proc/display() + SHOULD_BE_PURE(TRUE) + . = "\"[title]\"" + if(artist) + . += " by [artist]" + +/datum/media_track/proc/get_data() as /list + return list( + "ref" = REF(src), + "title" = title, + "artist" = artist, + "genre" = genre, + "duration" = duration, + ) diff --git a/modular_doppler/cassettes/code/modules/cassette/media/media_track_manager.dm b/modular_doppler/cassettes/code/modules/cassette/media/media_track_manager.dm new file mode 100644 index 00000000000000..9458ca265172e0 --- /dev/null +++ b/modular_doppler/cassettes/code/modules/cassette/media/media_track_manager.dm @@ -0,0 +1,221 @@ +GLOBAL_LIST_INIT(jukebox_track_files, list("code/modules/cassettes/track_folder/base_tracks.json")) + +///Tracks are sorted by genre then by title inside that. +/proc/cmp_media_track_asc(datum/media_track/A, datum/media_track/B) + var/genre_sort = sorttext(B.genre || "Uncategorized", A.genre || "Uncategorized") + return genre_sort || sorttext(B.title, A.title) + +SUBSYSTEM_DEF(media_tracks) + name = "Media Tracks" + flags = SS_NO_FIRE + + /// Every track, including secret + var/list/all_tracks = list() + /// Non-secret jukebox tracks + var/list/jukebox_tracks = list() + /// Lobby music tracks + var/list/lobby_tracks = list() + ///have we picked our lobby song yet? + var/first_lobby_play = TRUE + ///current picked lobby song + var/datum/media_track/current_lobby_track + +/datum/controller/subsystem/media_tracks/Initialize(timeofday) + if(!length(GLOB.jukebox_track_files)) + return SS_INIT_NO_NEED + load_tracks() + sort_tracks() + return SS_INIT_SUCCESS + +/datum/controller/subsystem/media_tracks/proc/load_tracks() + for(var/filename in GLOB.jukebox_track_files) + message_admins("Loading jukebox track(s): [filename]") + + if(!fexists(filename)) + log_runtime("File not found: [filename]") + continue + + var/list/json_data = json_decode(file2text(filename)) + + if(!islist(json_data)) + log_runtime("Failed to read tracks from [filename], json_decode failed.") + continue + + var/is_json_obj = FALSE + var/is_json_arr = FALSE + switch(json_encode(json_data)[1]) + if("{") + is_json_obj = TRUE + if("\[") + is_json_arr = TRUE + // Some files could be an object, since SSticker adds lobby tracks from jsons that aren't arrays + if(is_json_obj) + process_track(json_data, filename) + else if(is_json_arr) + for(var/entry in json_data) + process_track(entry, filename) + else + // how did we end up here? + log_runtime("Failed to read tracks from [filename], is not object or array.") + +/datum/controller/subsystem/media_tracks/proc/process_track(list/entry, filename) + // Critical problems that will prevent the track from working + if(!istext(entry["url"])) + log_runtime("Jukebox entry in [filename]: bad or missing 'url'. Tracks must have a URL.") + return + if(!istext(entry["title"])) + log_runtime("Jukebox entry in [filename]: bad or missing 'title'. Tracks must have a title.") + return + if(!isnum(entry["duration"])) + log_runtime("Jukebox entry in [filename]: bad or missing 'duration'. Tracks must have a duration (in deciseconds).") + return + + // Noncritical problems, we can keep going anyway, but warn so it can be fixed + if(!istext(entry["artist"])) + warning("Jukebox entry in [filename], [entry["title"]]: bad or missing 'artist'. Please consider crediting the artist.") + if(!istext(entry["genre"])) + warning("Jukebox entry in [filename], [entry["title"]]: bad or missing 'genre'. Please consider adding a genre.") + + var/datum/media_track/track = new(entry["url"], entry["title"], entry["duration"], entry["artist"], entry["genre"]) + + track.secret = entry["secret"] + track.lobby = entry["lobby"] + + all_tracks += track + +/datum/controller/subsystem/media_tracks/proc/sort_tracks() + message_admins("Sorting media tracks...") + sortTim(all_tracks, GLOBAL_PROC_REF(cmp_media_track_asc)) + + jukebox_tracks.Cut() + lobby_tracks.Cut() + + for(var/datum/media_track/track as anything in all_tracks) + if(!track.secret) + jukebox_tracks += track + if(track.lobby) + lobby_tracks += track + + message_admins("Total tracks - Jukebox: [length(jukebox_tracks)] - Lobby: [length(lobby_tracks)]") + +/datum/controller/subsystem/media_tracks/proc/manual_track_add(mob/user = usr) + if(!check_rights(R_DEBUG | R_FUN)) + return + + // Required + var/url = tgui_input_text(user, "REQUIRED: Provide URL for track, or paste JSON if you know what you're doing. See code comments.", "Track URL", multiline = TRUE) + if(!url) + return + + var/list/json + if(rustg_json_is_valid(url)) + json = json_decode(url) + + /** + * Alternatively to using a series of inputs, you can use json and paste it in. + * The json base element needs to be an array, even if it's only one song, so wrap it in [] + * The songs are json object literals inside the base array and use these keys: + * "url": the url for the song (REQUIRED) (text) + * "title": the title of the song (REQUIRED) (text) + * "duration": duration of song in 1/10ths of a second (seconds * 10) (REQUIRED) (number) + * "artist": artist of the song (text) + * "genre": artist of the song, REALLY try to match an existing one (text) + * "secret": only on hacked jukeboxes (true/false) + * "lobby": plays in the lobby (true/false) + */ + + if(islist(json)) + for(var/song in json) + if(!islist(song)) + to_chat(user, span_warning("Song appears to be malformed.")) + continue + var/list/songdata = song + if(!songdata["url"] || !songdata["title"] || !songdata["duration"]) + to_chat(user, span_warning("URL, Title, or Duration was missing from a song. Skipping")) + continue + var/datum/media_track/track = new(songdata["url"], songdata["title"], songdata["duration"], songdata["artist"], songdata["genre"], songdata["secret"], songdata["lobby"]) + all_tracks += track + + message_admins("New media track added by [key_name(user)]: [track.title]") + sort_tracks() + return + + var/title = tgui_input_text(user, "REQUIRED: Provide title for track", "Track Title") + if(!title) + return + + var/duration = tgui_input_number(user, "REQUIRED: Provide duration for track (in deciseconds, aka seconds*10)", "Track Duration") + if(!duration) + return + + // Optional + var/artist = tgui_input_text(user, "Optional: Provide artist for track", "Track Artist") + if(isnull(artist)) // Cancel rather than empty string + return + + var/genre = tgui_input_text(user, "Optional: Provide genre for track (try to match an existing one)", "Track Genre") + if(isnull(genre)) // Cancel rather than empty string + return + + var/secret = tgui_alert(user, "Optional: Mark track as secret?", "Track Secret", list("Yes", "Cancel", "No")) + if(secret == "Cancel") + return + else if(secret == "Yes") + secret = TRUE + else + secret = FALSE + + var/lobby = tgui_alert(user, "Optional: Mark track as lobby music?", "Track Lobby", list("Yes", "Cancel", "No")) + if(lobby == "Cancel") + return + else if(secret == "Yes") + secret = TRUE + else + secret = FALSE + + var/datum/media_track/track = new(url, title, duration, artist, genre) + + track.secret = secret + track.lobby = lobby + + all_tracks += track + + message_admins("New media track added by [key_name(user)]: [title]") + sort_tracks() + +/datum/controller/subsystem/media_tracks/proc/manual_track_remove(mob/user = usr) + if(!check_rights(R_DEBUG|R_FUN)) + return + + var/track_to_remove = tgui_input_text(user, "Input track title or URL to remove (must be exact)", "Remove Track") + if(!track_to_remove) + return + + var/found_track = FALSE + for(var/datum/media_track/track as anything in all_tracks) + if(track.title != track_to_remove && track.url != track_to_remove) + continue + all_tracks -= track + qdel(track) + message_admins("Media track removed by [key_name(user)]: [track]") + found_track = TRUE + + if(found_track) + sort_tracks() + else + to_chat(user, span_warning("Couldn't find a track matching the specified parameters.")) + +/datum/controller/subsystem/media_tracks/vv_get_dropdown() + . = ..() + VV_DROPDOWN_OPTION("", "---") + VV_DROPDOWN_OPTION("add_track", "Add New Track") + VV_DROPDOWN_OPTION("remove_track", "Remove Track") + +/datum/controller/subsystem/media_tracks/vv_do_topic(list/href_list) + . = ..() + if(href_list["add_track"] && check_rights(R_FUN)) + manual_track_add() + href_list["datumrefresh"] = "\ref[src]" + if(href_list["remove_track"] && check_rights(R_FUN)) + manual_track_remove() + href_list["datumrefresh"] = "\ref[src]" diff --git a/modular_doppler/cassettes/code/modules/cassette/media/prefs.dm b/modular_doppler/cassettes/code/modules/cassette/media/prefs.dm new file mode 100644 index 00000000000000..1f4c378b78e6fd --- /dev/null +++ b/modular_doppler/cassettes/code/modules/cassette/media/prefs.dm @@ -0,0 +1,21 @@ +/// Whether or not to toggle ambient occlusion, the shadows around people +/datum/preference/toggle/hear_music + category = PREFERENCE_CATEGORY_GAME_PREFERENCES + savefile_key = "hearmusic" + savefile_identifier = PREFERENCE_PLAYER + default_value = TRUE + +/datum/preference/toggle/hear_music/apply_to_client(client/client, value) + . = ..() + if(istype(client, /datum/client_interface)) + return + if(client.media) + if(!value) + var/area/A = get_area(client.mob) + if(!A) + return + var/obj/machinery/media/M = A.media_source + if(M && M.playing) + client.media.stop_music() + + client.media.update_music() diff --git a/modular_doppler/cassettes/code/modules/cassette/mob_can_hear.dm b/modular_doppler/cassettes/code/modules/cassette/mob_can_hear.dm new file mode 100644 index 00000000000000..393b1b1f59cef4 --- /dev/null +++ b/modular_doppler/cassettes/code/modules/cassette/mob_can_hear.dm @@ -0,0 +1,36 @@ +/// A list of all mobs that can hear music. +GLOBAL_LIST_EMPTY_TYPED(music_listeners, /mob) + +/mob/Initialize(mapload) + . = ..() + RegisterSignal(src, SIGNAL_ADDTRAIT(TRAIT_CAN_HEAR_MUSIC), PROC_REF(on_can_hear_music_trait_gain)) + RegisterSignal(src, SIGNAL_REMOVETRAIT(TRAIT_CAN_HEAR_MUSIC), PROC_REF(on_can_hear_music_trait_loss)) + + RegisterSignal(src, SIGNAL_REMOVETRAIT(TRAIT_LISTENING_TO_WALKMAN), PROC_REF(on_stop_listening_to_walkman)) + + // just in case we already have the trait + if(HAS_TRAIT(src, TRAIT_CAN_HEAR_MUSIC)) + on_can_hear_music_trait_gain(src) + +/mob/Destroy(force) + on_can_hear_music_trait_loss(src) + return ..() + +/mob/proc/on_can_hear_music_trait_gain(datum/source) + SIGNAL_HANDLER + if(src in GLOB.music_listeners) + return + GLOB.music_listeners += src + SEND_GLOBAL_SIGNAL(COMSIG_GLOB_ADD_MUSIC_LISTENER, src) + +/mob/proc/on_can_hear_music_trait_loss(datum/source) + SIGNAL_HANDLER + if(!(src in GLOB.music_listeners)) + return + GLOB.music_listeners -= src + SEND_GLOBAL_SIGNAL(COMSIG_GLOB_REMOVE_MUSIC_LISTENER, src) + +/mob/proc/on_stop_listening_to_walkman(datum/source) + SIGNAL_HANDLER + if(client?.tgui_panel?.is_ready() && !QDELETED(GLOB.dj_booth) && (src in GLOB.music_listeners)) + GLOB.dj_booth.play_for_listener(src) diff --git a/modular_doppler/cassettes/code/modules/cassette/random_cassette_collection.dm b/modular_doppler/cassettes/code/modules/cassette/random_cassette_collection.dm new file mode 100644 index 00000000000000..672888e897b5c3 --- /dev/null +++ b/modular_doppler/cassettes/code/modules/cassette/random_cassette_collection.dm @@ -0,0 +1,18 @@ +GLOBAL_LIST_INIT(approved_ids, initialize_approved_ids()) + +/proc/unique_random_tapes(amt = 1) + . = list() + if(!length(GLOB.approved_ids)) + GLOB.approved_ids = initialize_approved_ids() + if(!length(GLOB.approved_ids)) + return + var/list/ids_to_choose = GLOB.approved_ids.Copy() + amt = min(amt, length(ids_to_choose)) + for(var/i in 1 to amt) + . += pick_n_take(ids_to_choose) + +/proc/initialize_approved_ids() + var/ids_exist = file("data/cassette_storage/ids.json") + if(!fexists(ids_exist)) + return list() + return json_decode(file2text(ids_exist)) diff --git a/modular_doppler/cassettes/code/modules/cassette/walkman/_walkmen.dm b/modular_doppler/cassettes/code/modules/cassette/walkman/_walkmen.dm new file mode 100644 index 00000000000000..dab7ea0f8ea319 --- /dev/null +++ b/modular_doppler/cassettes/code/modules/cassette/walkman/_walkmen.dm @@ -0,0 +1,267 @@ + + +/obj/item/walkman + name = "walkman" + desc = "A cassette player that first hit the market over 200 years ago. Crazy how these never went out of style." + icon = 'icons/obj/cassettes/walkman.dmi' + icon_state = "walkman" + w_class = WEIGHT_CLASS_SMALL + item_flags = NOBLUDGEON + custom_price = PAYCHECK_CREW * 6 // walkman crate is a better deal + custom_premium_price = PAYCHECK_CREW * 6 + actions_types = list(/datum/action/item_action/walkman/play_pause, /datum/action/item_action/walkman/next_song, /datum/action/item_action/walkman/restart_song) + /// The currently inserted cassette, if any. + var/obj/item/cassette_tape/inserted_tape + /// The song currently selected if any. + var/datum/cassette_song/current_song + /// Is a song currently playing? + var/playing = FALSE + /// Extra metadata sent to the tgui panel. + var/list/playing_extra_data + /// The direct URL endpoint of the song being played. + var/music_endpoint + /// The REALTIMEOFDAY that the current song was started. + var/song_start_time + /// What kind of walkman design style to use. + var/design = 1 + /// Are we busy fetching a song? + var/busy = FALSE + /// The mob currently listening + var/mob/living/current_listener + +/obj/item/walkman/Initialize(mapload) + . = ..() + design = rand(1, 5) + register_context() + update_appearance(UPDATE_OVERLAYS) + +/obj/item/walkman/Destroy(force) + stop_listening() + if(!QDELETED(inserted_tape)) + inserted_tape.forceMove(drop_location()) + inserted_tape = null + return ..() + +/obj/item/walkman/add_context(atom/source, list/context, obj/item/held_item, mob/user) + . = NONE + if(inserted_tape) + context[SCREENTIP_CONTEXT_CTRL_LMB] = "Eject Tape" + return CONTEXTUAL_SCREENTIP_SET + else if(istype(held_item, /obj/item/cassette_tape)) + context[SCREENTIP_CONTEXT_LMB] = "Insert Tape" + return CONTEXTUAL_SCREENTIP_SET + +/obj/item/walkman/examine(mob/user) + . = ..() + if(inserted_tape) + . += span_info("The \"[inserted_tape.cassette_data.name]\" cassette is inserted.") + if(current_song) + . += span_info("The track \"[current_song.name]\" is [playing ? "playing" : "selected"].") + . += span_info("Ctrl-Click to eject the tape.") + else + . += span_info("No tape is inserted.") + +/obj/item/walkman/ui_action_click(mob/user, actiontype) + if(busy) + user.balloon_alert(user, "walkman busy!") + return + if(!inserted_tape) + user.balloon_alert(user, "no tape inserted!") + return + if(istype(actiontype, /datum/action/item_action/walkman/next_song)) + next_song(user) + else if(istype(actiontype, /datum/action/item_action/walkman/restart_song)) + if(playing) + song_start_time = REALTIMEOFDAY + play_for_listener(user) + user.balloon_alert(user, "song restarted") + else + user.balloon_alert(user, "no song playing!") + else + return ..() + +/obj/item/walkman/attack_self(mob/user, modifiers) + if(busy) + user.balloon_alert(user, "walkman busy!") + return + if(!inserted_tape) + user.balloon_alert(user, "no tape inserted!") + return + if(playing) + stop_listening(user) + user.balloon_alert(user, "stopped song") + else if(music_endpoint) + playing = TRUE + start_listening(user) + user.balloon_alert(user, "played song") + else + user.balloon_alert(user, "no track loaded!") + +/obj/item/walkman/update_overlays() + . = ..() + . += "+[design]" + if(inserted_tape) + if(playing && music_endpoint) + . += "+playing" + else + . += "+empty" + +/obj/item/walkman/item_interaction(mob/living/user, obj/item/cassette_tape/tape, list/modifiers) + if(!istype(tape, /obj/item/cassette_tape)) + return NONE + if(busy) + user.balloon_alert(user, "walkman busy!") + return ITEM_INTERACT_BLOCKING + if(inserted_tape) + user.balloon_alert(user, "already a tape inserted!") + return ITEM_INTERACT_BLOCKING + if(busy) + user.balloon_alert(user, "walkman busy!") + return CLICK_ACTION_BLOCKING + if(!user.transferItemToLoc(tape, src)) + user.balloon_alert(user, "failed to insert tape!") + return ITEM_INTERACT_BLOCKING + inserted_tape = tape + user.balloon_alert(user, "inserted tape") + update_appearance(UPDATE_OVERLAYS) + // preemptively queue the first song + var/list/songs = inserted_tape.get_current_side()?.songs + if(length(songs) > 0) + var/datum/cassette_song/first_song = songs[1] + SSfloxy.queue_media(first_song.url) + return ITEM_INTERACT_SUCCESS + +/obj/item/walkman/item_ctrl_click(mob/user) + if(!can_interact(user)) + return NONE + if(!inserted_tape) + user.balloon_alert(user, "no tape inserted!") + return CLICK_ACTION_BLOCKING + if(busy) + user.balloon_alert(user, "walkman busy!") + return CLICK_ACTION_BLOCKING + stop_listening() + inserted_tape.forceMove(drop_location()) + user.put_in_hands(inserted_tape) + user.balloon_alert(user, "ejected tape") + inserted_tape = null + update_appearance(UPDATE_OVERLAYS) + return CLICK_ACTION_SUCCESS + +/obj/item/walkman/process(seconds_per_tick) + if(!playing || !current_song?.duration || !song_start_time) + return PROCESS_KILL + if(REALTIMEOFDAY > (song_start_time + (current_song.duration * 1 SECONDS))) + PLAY_CASSETTE_SOUND(SFX_DJSTATION_STOP) + stop_listening() + +/obj/item/walkman/proc/start_listening(mob/living/listener) + if(!playing || !current_song || !music_endpoint || QDELETED(listener)) + return + if(current_listener) + stop_listening() + + current_listener = listener + RegisterSignal(current_listener, COMSIG_QDELETING, PROC_REF(stop_listening)) + RegisterSignal(current_listener, COMSIG_TGUI_PANEL_READY, PROC_REF(play_for_listener)) + ADD_TRAIT(current_listener, TRAIT_LISTENING_TO_WALKMAN, REF(src)) + + song_start_time = REALTIMEOFDAY + play_for_listener(current_listener) + update_appearance(UPDATE_OVERLAYS) + +/obj/item/walkman/proc/stop_listening() + current_song = null + playing = FALSE + playing_extra_data = null + music_endpoint = null + song_start_time = 0 + STOP_PROCESSING(SSprocessing, src) + update_appearance(UPDATE_OVERLAYS) + if(!current_listener) + return + UnregisterSignal(current_listener, list(COMSIG_QDELETING, COMSIG_TGUI_PANEL_READY)) + current_listener.client?.tgui_panel?.stop_music() + REMOVE_TRAIT(current_listener, TRAIT_LISTENING_TO_WALKMAN, REF(src)) + current_listener = null + +/obj/item/walkman/proc/play_for_listener(mob/living/listener) + if(!music_endpoint || !current_song || QDELETED(listener)) + return + var/list/extra_data = playing_extra_data.Copy() + var/start = floor((REALTIMEOFDAY - song_start_time) / 10) + if(start > 0) + extra_data["start"] = start + playing = TRUE + listener.client?.tgui_panel?.play_music(music_endpoint, extra_data) + START_PROCESSING(SSobj, src) + +/obj/item/walkman/dropped(mob/user, silent) + . = ..() + stop_listening() + +/obj/item/walkman/proc/next_song(mob/user) + if(!inserted_tape) + user.balloon_alert(user, "no tape inserted!") + return + var/datum/cassette_side/side = inserted_tape.get_current_side() + var/song_amt = length(side?.songs) + if(!song_amt) + user.balloon_alert(user, "no tracks to play!") + return + var/new_idx = WRAP_UP(current_song ? side.songs.Find(current_song) : 0, song_amt) + var/datum/cassette_song/new_track = side.songs[new_idx] + busy = TRUE + var/list/info = SSfloxy.download_and_wait(new_track.url, timeout = 30 SECONDS, discard_failed = TRUE) + busy = FALSE + if(!info || info["status"] == FLOXY_STATUS_FAILED) + user.balloon_alert(user, "failed to select track #[new_idx]") + return + current_song = new_track + if(length(info["endpoints"])) + music_endpoint = info["endpoints"][1] + else + log_floxy("Floxy did not return a music endpoint for [new_track.url]") + stack_trace("Floxy did not return a music endpoint for [new_track.url]") + balloon_alert(user, "the loader mechanism malfunctioned!") + return + var/list/metadata = info["metadata"] + if(metadata) + if(metadata["title"]) + current_song.name = metadata["title"] + if(metadata["artist"]) + current_song.artist = metadata["artist"] + if(metadata["album"]) + current_song.album = metadata["album"] + if(current_song.duration <= 0 && metadata["duration"]) + current_song.duration = metadata["duration"] + playing_extra_data = list( + "title" = current_song.name, + "link" = current_song.url, + "artist" = current_song.artist, + "album" = current_song.album, + ) + if(current_song.duration > 0) + playing_extra_data["duration"] = DisplayTimeText(current_song.duration * 1 SECONDS) + user.balloon_alert(user, "track #[new_idx] selected") + to_chat(user, span_notice("Selected track \"[current_song.name]\".")) + +/* + ACTION BUTTONS +*/ + +/datum/action/item_action/walkman + button_icon = 'modular_doppler/cassettes/icons/walkman.dmi' + background_icon_state = "bg_tech_blue" + +/datum/action/item_action/walkman/play_pause + name = "Play/Pause" + button_icon_state = "walkman_playpause" + +/datum/action/item_action/walkman/next_song + name = "Next song" + button_icon_state = "walkman_next" + +/datum/action/item_action/walkman/restart_song + name = "Restart song" + button_icon_state = "walkman_restart" diff --git a/modular_doppler/cassettes/code/modules/mob/human/dummy.dm b/modular_doppler/cassettes/code/modules/mob/human/dummy.dm new file mode 100644 index 00000000000000..693cac849eb90c --- /dev/null +++ b/modular_doppler/cassettes/code/modules/mob/human/dummy.dm @@ -0,0 +1,5 @@ +/mob/living/carbon/human/dummy/on_can_hear_music_trait_gain(datum/source) + return + +/mob/living/carbon/human/dummy/on_can_hear_music_trait_loss(datum/source) + return diff --git a/modular_doppler/cassettes/code/modules/track_folder/base_tracks.json b/modular_doppler/cassettes/code/modules/track_folder/base_tracks.json new file mode 100644 index 00000000000000..b494bf48763d11 --- /dev/null +++ b/modular_doppler/cassettes/code/modules/track_folder/base_tracks.json @@ -0,0 +1,4768 @@ +[ + { + "url": "https://files.catbox.moe/notc7y.mp3", + "title": "Flip-Flap (Title One)", + "duration": 1500, + "artist": "X-CEED", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Jazz" + }, + { + "url": "https://files.catbox.moe/b24m07.mp3", + "title": "Robocop Theme (Title Two)", + "duration": 1180, + "artist": "Cboyardee", + "secret": false, + "lobby": true, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/qn5tdv.mp3", + "title": "Tin Tin on the Moon (Remix)", + "duration": 2450, + "artist": "Jeroen Tel (Remixed by Cuboos)", + "secret": false, + "lobby": true, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/uk3pr2.mp3", + "title": "Phoron Will Make Us Rich", + "duration": 1370, + "artist": "Earthcrusher", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/k25nxo.mp3", + "title": "Spaceman's Dilemma", + "duration": 2080, + "artist": "Leslie Fish", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Folk and Indie" + }, + { + "url": "https://files.catbox.moe/y1dxvn.mp3", + "title": "Banned from Argo", + "duration": 3150, + "artist": "Leslie Fish", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Folk and Indie" + }, + { + "url": "https://files.catbox.moe/zlct4c.mp3", + "title": "Stayin' Alive", + "duration": 2420, + "artist": "The Bee Gees", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Disco, Funk, Soul, and R&B" + }, + { + "url": "https://files.catbox.moe/dluppg.m4a", + "title": "Tannhauser Overture", + "duration": 9360, + "artist": "Richard Wagner", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Classical and Orchestral" + }, + { + "url": "https://files.catbox.moe/b4j5jc.m4a", + "title": "Another Song About the Weekend", + "duration": 2250, + "artist": "A Day to Remember", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/n36iy9.m4a", + "title": "Truly Madly Deeply", + "duration": 2770, + "artist": "Savage Garden", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Pop" + }, + { + "url": "https://files.catbox.moe/t88x7z.m4a", + "title": "Adult Education", + "duration": 3230, + "artist": "Hall and Oates", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Pop" + }, + { + "url": "https://files.catbox.moe/k38d32.mp3", + "title": "That's All", + "duration": 2640, + "artist": "Genesis", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Pop" + }, + { + "url": "https://files.catbox.moe/b59gnc.mp3", + "title": "Faith", + "duration": 1580, + "artist": "George Michael", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Pop" + }, + { + "url": "https://files.catbox.moe/twy9ln.m4a", + "title": "Last Christmas", + "duration": 2630, + "artist": "Wham!", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Pop" + }, + { + "url": "https://files.catbox.moe/m00qlo.mp3", + "title": "Dreams", + "duration": 2920, + "artist": "Van Halen", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/fwvkr7.m4a", + "title": "Geronimo's Cadillac", + "duration": 1960, + "artist": "Modern Talking", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Disco, Funk, Soul, and R&B" + }, + { + "url": "https://files.catbox.moe/py8x5m.m4a", + "title": "Home Sweet Home", + "duration": 2400, + "artist": "Motley Crue", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/ius137.mp3", + "title": "Too Young to Fall in Love", + "duration": 2100, + "artist": "Motley Crue", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/dc6s20.mp3", + "title": "Working for the Weekend", + "duration": 2220, + "artist": "Loverboy", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/977069.m4a", + "title": "Miles Away", + "duration": 2520, + "artist": "Winger", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/i5q08z.m4a", + "title": "Run, Sally, Run!", + "duration": 2870, + "artist": "Carpenter Brut", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/34kdfv.mp3", + "title": "Kickstart my Heart", + "duration": 2830, + "artist": "Motley Crue", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/ul3m8e.mp3", + "title": "Bizarre Love Triangle", + "duration": 2610, + "artist": "New Order", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Pop" + }, + { + "url": "https://files.catbox.moe/8f5h2b.mp3", + "title": "Regret", + "duration": 2490, + "artist": "New Order", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Pop" + }, + { + "url": "https://files.catbox.moe/8r1ifw.m4a", + "title": "Rule Britannia Overture", + "duration": 7320, + "artist": "Richard Wagner", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Classical and Orchestral" + }, + { + "url": "https://files.catbox.moe/75o3mo.m4a", + "title": "Da Ya Think I'm Sexy?", + "duration": 3270, + "artist": "Rod Stewart", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Disco, Funk, Soul, and R&B" + }, + { + "url": "https://files.catbox.moe/s37xwb.m4a", + "title": "Missing You", + "duration": 2690, + "artist": "John Waite", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Pop" + }, + { + "url": "https://files.catbox.moe/nayw83.mp3", + "title": "Red Velvet", + "duration": 2080, + "artist": "NicolArmarfi", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Jazz" + }, + { + "url": "https://files.catbox.moe/m8qpa4.m4a", + "title": "Space Age Love Song", + "duration": 2270, + "artist": "A Flock of Seagulls", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/shjfud.m4a", + "title": "Streets of Laredo", + "duration": 1690, + "artist": "Marty Robbins", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Country and Western" + }, + { + "url": "https://files.catbox.moe/2qdzcj.m4a", + "title": "Crockett's Theme", + "duration": 2040, + "artist": "Jan Hammer", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/8ft96j.m4a", + "title": "Take Me Home", + "duration": 3510, + "artist": "Phil Collins", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Pop" + }, + { + "url": "https://files.catbox.moe/q5c942.m4a", + "title": "Moonlight Sonata", + "duration": 8990, + "artist": "Ludwig van Beethoven", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Classical and Orchestral" + }, + { + "url": "https://files.catbox.moe/rlutyi.m4a", + "title": "We Built This City", + "duration": 2960, + "artist": "Starship", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/1rgsqr.m4a", + "title": "A Night on Bald Mountain", + "duration": 6830, + "artist": "Modest Mussorgsky", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Classical and Orchestral" + }, + { + "url": "https://files.catbox.moe/frq3un.m4a", + "title": "Keep on Loving You", + "duration": 2000, + "artist": "REO Speedwagon", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/z3v10u.m4a", + "title": "Lone Star", + "duration": 1470, + "artist": "Lost Weekend Western Swing Band", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Country and Western" + }, + { + "url": "https://files.catbox.moe/zg7gkh.m4a", + "title": "In The Shadow of the Valley", + "duration": 1870, + "artist": "Lost Weekend Western Swing Band", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Country and Western" + }, + { + "url": "https://files.catbox.moe/v4xtn8.m4a", + "title": "Blue Moon", + "duration": 1710, + "artist": "Frank Sinatra", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Jazz" + }, + { + "url": "https://files.catbox.moe/w2ysbr.m4a", + "title": "I Will Wait", + "duration": 2760, + "artist": "Mumford and Sons", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Folk and Indie" + }, + { + "url": "https://files.catbox.moe/jim3bp.m4a", + "title": "You Can't Hurry Love", + "duration": 1760, + "artist": "Phil Collins", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Pop" + }, + { + "url": "https://files.catbox.moe/r6jh89.m4a", + "title": "Big Iron", + "duration": 2350, + "artist": "Marty Robbins", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Country and Western" + }, + { + "url": "https://files.catbox.moe/h698sa.m4a", + "title": "Overnight Sensation", + "duration": 2380, + "artist": "Firehouse", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/bmik55.mp3", + "title": "Lightnin' Strikes Again", + "duration": 2270, + "artist": "Dokken", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/0e0wmd.m4a", + "title": "Gettin' Jiggy Wit It", + "duration": 2270, + "artist": "Will Smith", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Hip-Hop and Rap" + }, + { + "url": "https://files.catbox.moe/fm20mk.m4a", + "title": "Lay It Down", + "duration": 2050, + "artist": "Ratt", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/xogao9.m4a", + "title": "Night Fever", + "duration": 2110, + "artist": "The Bee Gees", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Disco, Funk, Soul, and R&B" + }, + { + "url": "https://files.catbox.moe/8gvr40.m4a", + "title": "Robot Rock", + "duration": 2870, + "artist": "Daft Punk", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/owzo1t.m4a", + "title": "Space Jam", + "duration": 3040, + "artist": "Quad City DJs", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Hip-Hop and Rap" + }, + { + "url": "https://files.catbox.moe/t4t9qi.m4a", + "title": "Take Me Home, Country Roads", + "duration": 1980, + "artist": "John Denver", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Country and Western" + }, + { + "url": "https://files.catbox.moe/il9tge.m4a", + "title": "White Collar Crime", + "duration": 4620, + "artist": "Simon Viklund", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Jazz" + }, + { + "url": "https://files.catbox.moe/bcniut.m4a", + "title": "Carmen Miranda's Ghost", + "duration": 1340, + "artist": "Leslie Fish", + "secret": false, + "lobby": true, + "jukebox": true, + "genre": "Folk and Indie" + }, + { + "url": "https://files.catbox.moe/yg44gl.mp3", + "title": "Dawson's Christian", + "duration": 2660, + "artist": "Duane Elms", + "secret": false, + "lobby": true, + "jukebox": true, + "genre": "Folk and Indie" + }, + { + "url": "https://files.catbox.moe/fhvudh.m4a", + "title": "Good Ship Manatee", + "duration": 1700, + "artist": "Leslie Fish", + "secret": false, + "lobby": true, + "jukebox": true, + "genre": "Folk and Indie" + }, + { + "url": "https://files.catbox.moe/v5wm23.mp3", + "title": "Bomber", + "duration": 1920, + "artist": "Duane Elms", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Folk and Indie" + }, + { + "url": "https://files.catbox.moe/ych1i1.m4a", + "title": "Some Kind of Hero", + "duration": 4070, + "artist": "Leslie Fish", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Folk and Indie" + }, + { + "url": "https://files.catbox.moe/7rfftg.m4a", + "title": "Guardians", + "duration": 2370, + "artist": "Leslie Fish", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Folk and Indie" + }, + { + "url": "https://files.catbox.moe/yg7ndh.m4a", + "title": "One Last Battle", + "duration": 1580, + "artist": "Leslie Fish", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Folk and Indie" + }, + { + "url": "https://files.catbox.moe/8c3n30.m4a", + "title": "New Sins for Old", + "duration": 1520, + "artist": "Leslie Fish", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Folk and Indie" + }, + { + "url": "https://files.catbox.moe/t9i5vh.mp3", + "title": "Space Hero", + "duration": 1680, + "artist": "Leslie Fish", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Folk and Indie" + }, + { + "url": "https://files.catbox.moe/xbj8ep.mp3", + "title": "Sam Jones", + "duration": 5090, + "artist": "Leslie Fish", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Folk and Indie" + }, + { + "url": "https://files.catbox.moe/iiggpj.mp3", + "title": "Spacer's Home", + "duration": 2190, + "artist": "Duane Elms", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Folk and Indie" + }, + { + "url": "https://files.catbox.moe/1vowpl.m4a", + "title": "Harder, Better, Faster, Stronger", + "duration": 2260, + "artist": "Daft Punk", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/fnse07.m4a", + "title": "Crazy Train", + "duration": 2960, + "artist": "Ozzy Osbourne", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/z7b6ih.m4a", + "title": "Poison Arrow", + "duration": 2040, + "artist": "ABC", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Pop" + }, + { + "url": "https://files.catbox.moe/d874c4.m4a", + "title": "Everything Counts", + "duration": 2390, + "artist": "Depeche Mode", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/p9yfvg.m4a", + "title": "Legend Has It", + "duration": 2050, + "artist": "Run the Jewels", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Hip-Hop and Rap" + }, + { + "url": "https://files.catbox.moe/gg4pti.m4a", + "title": "Rasputin", + "duration": 2650, + "artist": "Boney M", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Disco, Funk, Soul, and R&B" + }, + { + "url": "https://files.catbox.moe/u3ylxd.m4a", + "title": "Still D.R.E", + "duration": 2740, + "artist": "Dr. Dre (feat. Snoop Dogg)", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Hip-Hop and Rap" + }, + { + "url": "https://files.catbox.moe/2r3cbk.m4a", + "title": "It Was a Good Day", + "duration": 2600, + "artist": "Ice Cube", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Hip-Hop and Rap" + }, + { + "url": "https://files.catbox.moe/asthsu.m4a", + "title": "A Kiss to Build a Dream On", + "duration": 1840, + "artist": "Louis Armstrong", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Jazz" + }, + { + "url": "https://files.catbox.moe/rze7qx.m4a", + "title": "(I Always Kill) The Things I Love", + "duration": 1750, + "artist": "The Real Tuesday Weld (feat. Claudia Brucken)", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Jazz" + }, + { + "url": "https://files.catbox.moe/5o0uwa.mp3", + "title": "Invisible Touch", + "duration": 2080, + "artist": "Gensis", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Pop" + }, + { + "url": "https://files.catbox.moe/pdtbyi.m4a", + "title": "Tainted Love", + "duration": 1620, + "artist": "Soft Cell", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Pop" + }, + { + "url": "https://files.catbox.moe/j58vt2.mp3", + "title": "Everybody Wants To Rule The World", + "duration": 2520, + "artist": "Tears for Fears", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Pop" + }, + { + "url": "https://files.catbox.moe/llja8t.m4a", + "title": "Pink Cloud Days", + "duration": 1890, + "artist": "A.L.I.S.O.N", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/vbydgp.m4a", + "title": "Ethical Constraints Removed", + "duration": 2310, + "artist": "zircon & Jonathan Peros", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/y5tpk4.m4a", + "title": "The Blue Valley", + "duration": 7250, + "artist": "Karsten Koch", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/4vxedh.m4a", + "title": "Miami Disco", + "duration": 2700, + "artist": "Perturbator", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/dw5h51.m4a", + "title": "Shattered Dreams", + "duration": 2050, + "artist": "Johnny Hates Jazz", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Pop" + }, + { + "url": "https://files.catbox.moe/3zfcq0.mp3", + "title": "Miami", + "duration": 2730, + "artist": "Jasper Byrne", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/ecicu2.mp3", + "title": "Voyager", + "duration": 2140, + "artist": "Jasper Byrne", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/zfwxms.m4a", + "title": "Clouds of Fire", + "duration": 1170, + "artist": "Hector", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/5pl46n.m4a", + "title": "Every Day Is Night", + "duration": 2200, + "artist": "Garoad", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/0y4tjc.m4a", + "title": "Angel", + "duration": 3790, + "artist": "Massive Attack", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/ke8uk5.m4a", + "title": "Beware the Beast", + "duration": 2260, + "artist": "Carpenter Brut", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/u7yyft.m4a", + "title": "The Promise", + "duration": 2200, + "artist": "When In Rome", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Pop" + }, + { + "url": "https://cdn.discordapp.com/attachments/528973352728264714/956446080638283826/Deus_Ex_-_009_-_UNATCO_-_Ambient.mp3", + "title": "UNATCO - Ambience", + "duration": 1610, + "artist": "Michiel van den Bos", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/gcej08.m4a", + "title": "UNATCO - Conversation", + "duration": 1720, + "artist": "Michiel van den Bos", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://cdn.discordapp.com/attachments/528973352728264714/956453078750593044/EdenShard_-_Deus_Ex-_Revision_Original_Soundtrack_-_06_The_Oath_of_Service.mp3", + "title": "Oath of Service", + "duration": 2160, + "artist": "EdenShard", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://cdn.discordapp.com/attachments/528973352728264714/956453288600039434/EdenShard_-_Deus_Ex-_Revision_Original_Soundtrack_-_08_Mind_the_Synaptic_Gap.mp3", + "title": "Mind the Synaptic Gap", + "duration": 5460, + "artist": "EdenShard", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/pf929m.m4a", + "title": "Virtual Insanity", + "duration": 3400, + "artist": "Jamiroquai", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Disco, Funk, Soul, and R&B" + }, + { + "url": "https://files.catbox.moe/lu1gif.m4a", + "title": "Freek'n You", + "duration": 3790, + "artist": "Jodeci", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Disco, Funk, Soul, and R&B" + }, + { + "url": "https://files.catbox.moe/g368sb.m4a", + "title": "And The Beat Goes On", + "duration": 4500, + "artist": "The Whispers", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Disco, Funk, Soul, and R&B" + }, + { + "url": "https://files.catbox.moe/5ikte3.m4a", + "title": "Kiss From a Rose", + "duration": 2880, + "artist": "Seal", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Disco, Funk, Soul, and R&B" + }, + { + "url": "https://files.catbox.moe/cqqyzj.m4a", + "title": "Sexual Healing", + "duration": 2430, + "artist": "Marvin Gaye", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Disco, Funk, Soul, and R&B" + }, + { + "url": "https://files.catbox.moe/t9mwa9.m4a", + "title": "Sometimes", + "duration": 2530, + "artist": "Miami Horror", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Pop" + }, + { + "url": "https://files.catbox.moe/hdi0l9.m4a", + "title": "Nightcall", + "duration": 2580, + "artist": "Kavinsky", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/4pul1m.m4a", + "title": "Wash Away", + "duration": 2070, + "artist": "Alkaline Trio", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/whcm2r.m4a", + "title": "You Get What You Give", + "duration": 3000, + "artist": "New Radicals", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/adxl47.m4a", + "title": "Let's Dance", + "duration": 2500, + "artist": "David Bowie", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/50sksv.m4a", + "title": "Do It Again", + "duration": 3560, + "artist": "Steely Dan", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/j3ntvh.m4a", + "title": "Reelin' In The Years", + "duration": 2750, + "artist": "Steely Dan", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/utxphs.m4a", + "title": "Fire", + "duration": 1750, + "artist": "The Crazy World of Arthur Brown", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/5sh98r.m4a", + "title": "Everlong", + "duration": 2500, + "artist": "Foo Fighters", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/fdjdmj.m4a", + "title": "Limelight", + "duration": 2590, + "artist": "Rush", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/hdlhuf.m4a", + "title": "Revolution", + "duration": 2030, + "artist": "The Beatles", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/9hg3cr.m4a", + "title": "Dance the Night Away", + "duration": 1880, + "artist": "Van Halen", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/c961k1.m4a", + "title": "Blue Monday", + "duration": 4430, + "artist": "New Order", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Pop" + }, + { + "url": "https://files.catbox.moe/gldcwv.m4a", + "title": "Dancin' In the Ruins", + "duration": 2410, + "artist": "Blue Oyster Cult", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/1ckdtl.m4a", + "title": "The Boys Are Back In Town", + "duration": 2690, + "artist": "Thin Lizzy", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/w8plzv.m4a", + "title": "What It Takes", + "duration": 3110, + "artist": "Aerosmith", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/kdrv1r.m4a", + "title": "Angel", + "duration": 3080, + "artist": "Aerosmith", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/eh54mt.m4a", + "title": "Dude (Looks Like A Lady)", + "duration": 2650, + "artist": "Aerosmith", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/yczf2s.m4a", + "title": "Baba O'Riley", + "duration": 3000, + "artist": "The Who", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/v5376r.m4a", + "title": "Broken Wings", + "duration": 3430, + "artist": "Mr. Mister", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/r27dka.m4a", + "title": "Can't Fight This Feeling", + "duration": 2940, + "artist": "REO Speedwagon", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/a69sg1.m4a", + "title": "Talk Dirty To Me", + "duration": 2240, + "artist": "Poison", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/agtxnz.mp3", + "title": "Jump", + "duration": 2420, + "artist": "Van Halen", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/s8fzga.m4a", + "title": "Itz Nothin", + "duration": 2040, + "artist": "Young Roscoe", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Hip-Hop and Rap" + }, + { + "url": "https://files.catbox.moe/5qgnr9.m4a", + "title": "The Power Of Love", + "duration": 2340, + "artist": "Huey Lewis and The News", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/whbte8.m4a", + "title": "Just a Gigolo / I Ain't Got Nobody", + "duration": 2780, + "artist": "David Lee Roth", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/r32v4i.mp3", + "title": "True Faith", + "duration": 3540, + "artist": "New Order", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Pop" + }, + { + "url": "https://files.catbox.moe/oi7rov.m4a", + "title": "I Want You", + "duration": 2320, + "artist": "Savage Garden", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Pop" + }, + { + "url": "https://files.catbox.moe/tshasp.m4a", + "title": "Space Asshole", + "duration": 2340, + "artist": "Chris Remo", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Folk and Indie" + }, + { + "url": "https://files.catbox.moe/yfg6if.m4a", + "title": "Far Away", + "duration": 2810, + "artist": "Jose Gonzalez", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Folk and Indie" + }, + { + "url": "https://files.catbox.moe/6rbq2q.m4a", + "title": "All That Could Ever Be", + "duration": 3960, + "artist": "Rik Schaffer", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Folk and Indie" + }, + { + "url": "https://files.catbox.moe/l8baty.m4a", + "title": "Sleepwalking", + "duration": 2170, + "artist": "The Chain Gang of 1974", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Folk and Indie" + }, + { + "url": "https://files.catbox.moe/n6wwdu.m4a", + "title": "Take It On the Run", + "duration": 2390, + "artist": "REO Speedwagon", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/xwcipt.m4a", + "title": "Radio Ga Ga", + "duration": 3480, + "artist": "Queen", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/kq2ak3.m4a", + "title": "Dancing With Myself", + "duration": 2900, + "artist": "Billy Idol", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/ufk5gk.m4a", + "title": "Get Down Saturday Night", + "duration": 4100, + "artist": "Oliver Cheatham", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Disco, Funk, Soul, and R&B" + }, + { + "url": "https://files.catbox.moe/3pvlwa.m4a", + "title": "Disco Inferno", + "duration": 2130, + "artist": "The Trammps", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Disco, Funk, Soul, and R&B" + }, + { + "url": "https://files.catbox.moe/mt0w0u.mp3", + "title": "Wayfarer", + "duration": 2710, + "artist": "Kavinsky", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/wsjaml.m4a", + "title": "Papa Was A Rolling Stone", + "duration": 4180, + "artist": "The Temptations", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Disco, Funk, Soul, and R&B" + }, + { + "url": "https://files.catbox.moe/ln6qoi.m4a", + "title": "I Don't Care Anymore", + "duration": 3050, + "artist": "Phil Collins", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/sp6pks.m4a", + "title": "Throwing It All Away", + "duration": 2300, + "artist": "Genesis", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Pop" + }, + { + "url": "https://files.catbox.moe/1kv7kx.mp3", + "title": "In Too Deep", + "duration": 2980, + "artist": "Genesis", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Pop" + }, + { + "url": "https://files.catbox.moe/u42u20.mp3", + "title": "Easy Lover", + "duration": 3020, + "artist": "Phil Collins", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Pop" + }, + { + "url": "https://files.catbox.moe/vwlu1a.mp3", + "title": "How To Be A Millionaire", + "duration": 2150, + "artist": "ABC", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Pop" + }, + { + "url": "https://files.catbox.moe/r6d520.mp3", + "title": "Hip To Be Square", + "duration": 2450, + "artist": "Huey Lewis and The News", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/i7qhge.m4a", + "title": "You've Got Another Thing Coming", + "duration": 3100, + "artist": "Judas Priest", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/16y3iy.m4a", + "title": "Live Wire", + "duration": 1940, + "artist": "Motley Crue", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/4sdesv.m4a", + "title": "Ace of Spades", + "duration": 1670, + "artist": "Motorhead", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/3qqt2v.m4a", + "title": "Rise Of The Chaos Wizards", + "duration": 2370, + "artist": "Gloryhammer", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/fgcdu2.m4a", + "title": "Turn Up the Radio", + "duration": 2780, + "artist": "Autograph", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/86ylwc.mp3", + "title": "Bark at the Moon", + "duration": 2560, + "artist": "Ozzy Osbourne", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/nfxzcn.m4a", + "title": "Cum on Feel the Noize", + "duration": 2900, + "artist": "Quiet Riot", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/gl22vl.m4a", + "title": "The Hellion / Electric Eye", + "duration": 2610, + "artist": "Judas Priest", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/j4fvlc.mp3", + "title": "2 Minutes To Midnight", + "duration": 3600, + "artist": "Iron Maiden", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/sj1o0z.m4a", + "title": "Restless Gypsy", + "duration": 2990, + "artist": "W.A.S.P", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/lkkq00.m4a", + "title": "Universe On Fire", + "duration": 2460, + "artist": "Gloryhammer", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/ftmgxf.m4a", + "title": "Girls, Girls, Girls", + "duration": 2700, + "artist": "Motley Crue", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/uciolb.m4a", + "title": "To Hell with the Devil", + "duration": 2440, + "artist": "Stryper", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/6x9zur.m4a", + "title": "God Blessed Video", + "duration": 2100, + "artist": "Alcatrazz", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/gmgh6w.m4a", + "title": "Cumin' Atcha Live", + "duration": 2660, + "artist": "Tesla", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/zti66s.m4a", + "title": "Heavy Metal: The Black and Silver", + "duration": 2000, + "artist": "Blue Oyster Cult", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/8ew21k.m4a", + "title": "Mars For The Rich", + "duration": 2510, + "artist": "King Gizzard & the Lizard Wizard", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/di9apc.m4a", + "title": "Insane in the Membrane", + "duration": 2100, + "artist": "Cypress Hill", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Hip-Hop and Rap" + }, + { + "url": "https://files.catbox.moe/8nyu9s.m4a", + "title": "Just a Friend", + "duration": 2380, + "artist": "Biz Markie", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Hip-Hop and Rap" + }, + { + "url": "https://files.catbox.moe/2b0wu2.m4a", + "title": "Intergalactic", + "duration": 2290, + "artist": "Beastie Boys", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Hip-Hop and Rap" + }, + { + "url": "https://files.catbox.moe/nv66ck.mp3", + "title": "Wild Child", + "duration": 3120, + "artist": "W.A.S.P", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/z3k8rs.m4a", + "title": "Icarus' Dream Suite Op.4", + "duration": 5140, + "artist": "Yngwie Malmsteen", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/57ghcq.m4a", + "title": "Fantasy", + "duration": 3030, + "artist": "Aldo Nova", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/kwwvsg.m4a", + "title": "Nocturne Op.9 No.2", + "duration": 2690, + "artist": "Frederic Chopin", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Classical and Orchestral" + }, + { + "url": "https://files.catbox.moe/zab3he.mp3", + "title": "Concord", + "duration": 1850, + "artist": "NicolArmarfi & Blue123", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Classical and Orchestral" + }, + { + "url": "https://files.catbox.moe/e52gys.mp3", + "title": "Clair de Lune", + "duration": 3570, + "artist": "Claude Debussy", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Classical and Orchestral" + }, + { + "url": "https://files.catbox.moe/e7nk9a.mp3", + "title": "Gymnopedie No.1", + "duration": 1870, + "artist": "Erik Satie", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Classical and Orchestral" + }, + { + "url": "https://files.catbox.moe/wf7jr8.mp3", + "title": "Johans Waltz", + "duration": 2860, + "artist": "Andreas Waldetoft", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Classical and Orchestral" + }, + { + "url": "https://files.catbox.moe/15ybh9.mp3", + "title": "Handel This", + "duration": 890, + "artist": "Andreas Waldetoft", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Classical and Orchestral" + }, + { + "url": "https://files.catbox.moe/0294ze.m4a", + "title": "Lohengrin: Prelude", + "duration": 5930, + "artist": "Richard Wagner", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Classical and Orchestral" + }, + { + "url": "https://files.catbox.moe/xgq1tr.m4a", + "title": "Concerto Grosso no. 10 in B Minor - Allegro", + "duration": 2150, + "artist": "Antonio Vivaldi", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Classical and Orchestral" + }, + { + "url": "https://files.catbox.moe/vxe2qt.m4a", + "title": "Ancient Stones", + "duration": 2850, + "artist": "Jeremy Soule", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Classical and Orchestral" + }, + { + "url": "https://files.catbox.moe/8bdxq3.m4a", + "title": "Secunda", + "duration": 1230, + "artist": "Jeremy Soule", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Classical and Orchestral" + }, + { + "url": "https://files.catbox.moe/lqgoiv.m4a", + "title": "Far Horizons", + "duration": 3310, + "artist": "Jeremy Soule", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Classical and Orchestral" + }, + { + "url": "https://files.catbox.moe/azzap9.mp3", + "title": "Time of Change", + "duration": 2020, + "artist": "FlybyNo", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Classical and Orchestral" + }, + { + "url": "https://files.catbox.moe/m67gzk.m4a", + "title": "Dreamer's Rest", + "duration": 2380, + "artist": "Vindsvept", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Classical and Orchestral" + }, + { + "url": "https://files.catbox.moe/tnx2fb.mp3", + "title": "Moment of Decision", + "duration": 2430, + "artist": "NicolArmarfi", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Classical and Orchestral" + }, + { + "url": "https://files.catbox.moe/c7yeau.mp3", + "title": "In My Dreams", + "duration": 2600, + "artist": "Dokken", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/u8xh6b.m4a", + "title": "Wasted Years", + "duration": 3090, + "artist": "Iron Maiden", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/utjagr.m4a", + "title": "Round and Round", + "duration": 2650, + "artist": "Ratt", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/mbzbla.m4a", + "title": "Crystal Ball", + "duration": 2950, + "artist": "Yngwie Malmsteen", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/p9cnth.m4a", + "title": "I Remember You", + "duration": 3130, + "artist": "Skid Row", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/pkhotw.m4a", + "title": "I Won't Forget You", + "duration": 2150, + "artist": "Poison", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/231ksl.m4a", + "title": "Pjanoo (Club Mix)", + "duration": 4500, + "artist": "Eric Prydz", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/58hrry.m4a", + "title": "Inferno Galore", + "duration": 2250, + "artist": "Carpenter Brut", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/vsdg7v.m4a", + "title": "Low Rider", + "duration": 1910, + "artist": "War", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Disco, Funk, Soul, and R&B" + }, + { + "url": "https://files.catbox.moe/gu2yfz.m4a", + "title": "How Deep Is Your Love", + "duration": 2450, + "artist": "The Bee Gees", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Disco, Funk, Soul, and R&B" + }, + { + "url": "https://files.catbox.moe/slm18k.m4a", + "title": "You Dropped A Bomb On Me", + "duration": 3120, + "artist": "The Gap Band", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Disco, Funk, Soul, and R&B" + }, + { + "url": "https://files.catbox.moe/24cqmw.m4a", + "title": "Every Rose Has It's Thorn", + "duration": 2600, + "artist": "Poison", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/v3otpl.m4a", + "title": "Fallen Angel", + "duration": 2380, + "artist": "Poison", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/iezn71.m4a", + "title": "Baby Hold On", + "duration": 2100, + "artist": "Eddie Money", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/e8w3ym.m4a", + "title": "Two Tickets To Paradise", + "duration": 2370, + "artist": "Eddie Money", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/cavpff.m4a", + "title": "Take Me Home Tonight", + "duration": 2110, + "artist": "Eddie Money", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/n8l93a.m4a", + "title": "Same Ol' Situation (S.O.S.)", + "duration": 2530, + "artist": "Motely Crue", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/gnr6py.m4a", + "title": "Metal Health (Bang Your Head)", + "duration": 3160, + "artist": "Quiet Riot", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/1drakx.m4a", + "title": "Shout At The Devil", + "duration": 1950, + "artist": "Motley Crue", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/1kevft.m4a", + "title": "Walk With Me in Hell", + "duration": 3110, + "artist": "Lamb of God", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/9cdsmd.m4a", + "title": "Crazy (A Suitable Case For Treatment)", + "duration": 2070, + "artist": "Nazareth", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/6oq1qw.m4a", + "title": "Freezing Moon", + "duration": 3830, + "artist": "Mayhem", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/jrc74a.m4a", + "title": "Raining Blood", + "duration": 2540, + "artist": "Slayer", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/ey07dt.m4a", + "title": "Marching Off to War", + "duration": 2490, + "artist": "Motorhead", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/a77r2l.m4a", + "title": "Hymn Of The Forsaken", + "duration": 3880, + "artist": "Climmhazard", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/omm3y6.m4a", + "title": "Victory Song", + "duration": 6390, + "artist": "Ensiferum", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/sx7kbr.m4a", + "title": "Dunkelheit", + "duration": 4210, + "artist": "Burzum", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/r6uftn.m4a", + "title": "Sick Bubblegum", + "duration": 2240, + "artist": "Rob Zombie", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/ysee6q.m4a", + "title": "Laser Enforcer", + "duration": 2540, + "artist": "Slough Feg", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/sxcus4.m4a", + "title": "Veteran of the Psychic Wars", + "duration": 2890, + "artist": "Blue Oyster Cult", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/qkxavx.m4a", + "title": "Tiger! Tiger!", + "duration": 2530, + "artist": "Slough Feg", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/u7rhxc.m4a", + "title": "Hyperdrive", + "duration": 2140, + "artist": "The Devin Townsend Project", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/narev2.m4a", + "title": "Judas", + "duration": 2500, + "artist": "Fozzy", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/pguvz2.m4a", + "title": "Alone Again", + "duration": 2600, + "artist": "Dokken", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/0gol3u.m4a", + "title": "Superbeast", + "duration": 2200, + "artist": "Rob Zombie", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/irrxal.m4a", + "title": "Perihelion", + "duration": 1910, + "artist": "King Gizzard & The Lizard Wizard", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/f9yw8d.m4a", + "title": "Self-Immolate", + "duration": 2680, + "artist": "King Gizzard & The Lizard Wizard", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/zmyym2.m4a", + "title": "Planet B", + "duration": 2360, + "artist": "King Gizzard & The Lizard Wizard", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/isa0lb.m4a", + "title": "On The Wind", + "duration": 2250, + "artist": "Dream Evil", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/oi9dt1.m4a", + "title": "Catch The Rainbow", + "duration": 3960, + "artist": "Rainbow", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/1ozes3.m4a", + "title": "Peace Sells", + "duration": 2440, + "artist": "Megadeth", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url": "https://files.catbox.moe/iyi2ll.m4a", + "title": "Never Gonna Give You Up", + "duration": 2130, + "artist": "Rick Astley", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Pop" + }, + { + "url": "https://files.catbox.moe/s46vw9.m4a", + "title": "I Fought The Law", + "duration": 1630, + "artist": "The Clash", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/3blefo.m4a", + "title": "Piano Man", + "duration": 3400, + "artist": "Billy Joel", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/qq4cbx.m4a", + "title": "Mr. Blue Sky", + "duration": 3030, + "artist": "Electric Light Orchestra", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/uxwl09.m4a", + "title": "Here I Go Again (1987 Version)", + "duration": 2750, + "artist": "Whitesnake", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/jgigel.m4a", + "title": "Starman", + "duration": 2540, + "artist": "David Bowie", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/nq2w6w.m4a", + "title": "Space Oddity", + "duration": 3180, + "artist": "David Bowie", + "secret": false, + "lobby": true, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/2n2gdq.m4a", + "title": "Changes", + "duration": 2170, + "artist": "David Bowie", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/jzucsv.mp3", + "title": "A Horse With No Name", + "duration": 2470, + "artist": "America", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/d05y2k.m4a", + "title": "Space Oddity (Cover)", + "duration": 3330, + "artist": "Chris Hadfield", + "secret": false, + "lobby": true, + "jukebox": true, + "genre": "Pop" + }, + { + "url": "https://files.catbox.moe/1gbbu1.m4a", + "title": "Cruel, Cruel World (Josh Homme)", + "duration": 2140, + "artist": "Josh Homme", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Country and Western" + }, + { + "url": "https://files.catbox.moe/3941ms.m4a", + "title": "House Building", + "duration": 2200, + "artist": "David 'Fergie' Ferguson", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Country and Western" + }, + { + "url": "https://files.catbox.moe/rf6v2o.m4a", + "title": "Folsom Prison Blues", + "duration": 1680, + "artist": "Johnny Cash", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Country and Western" + }, + { + "url": "https://files.catbox.moe/1deyd1.m4a", + "title": "A White Sport Coat (And a Pink Carnation)", + "duration": 1480, + "artist": "Marty Robbins", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Country and Western" + }, + { + "url": "https://files.catbox.moe/r7asbn.m4a", + "title": "Lovers Of The World", + "duration": 1680, + "artist": "Jerry Wallace", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Country and Western" + }, + { + "url": "https://files.catbox.moe/gdd5v3.m4a", + "title": "Cruel, Cruel World (Willie Nelson)", + "duration": 2660, + "artist": "Willie Nelson", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Country and Western" + }, + { + "url": "https://files.catbox.moe/vrj0cl.m4a", + "title": "The Story Of A Soldier", + "duration": 2390, + "artist": "Ennio Morricone", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Country and Western" + }, + { + "url": "https://files.catbox.moe/wdswuf.m4a", + "title": "Rawhide", + "duration": 1200, + "artist": "Frankie Laine", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Country and Western" + }, + { + "url": "https://files.catbox.moe/sa9lg3.m4a", + "title": "Psycho", + "duration": 2160, + "artist": "Eddie Noack", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Country and Western" + }, + { + "url": "https://files.catbox.moe/j84ln2.m4a", + "title": "Before You Use That Gun", + "duration": 1520, + "artist": "Eddie Noack", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Country and Western" + }, + { + "url": "https://files.catbox.moe/3ae5p2.m4a", + "title": "Rhinestone Cowboy", + "duration": 2090, + "artist": "Glen Campbell", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Country and Western" + }, + { + "url": "https://files.catbox.moe/hbc1vd.m4a", + "title": "Flowers on the Wall", + "duration": 1390, + "artist": "The Statler Brothers", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Country and Western" + }, + { + "url": "https://files.catbox.moe/7ibtm1.m4a", + "title": "Bed Of Roses", + "duration": 1480, + "artist": "The Statler Brothers", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Country and Western" + }, + { + "url": "https://files.catbox.moe/mv8xql.m4a", + "title": "New York City", + "duration": 1710, + "artist": "The Statler Brothers", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Country and Western" + }, + { + "url": "https://files.catbox.moe/5mtinr.m4a", + "title": "The Devil Went Down to Georgia", + "duration": 2140, + "artist": "The Charlie Daniels Band", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Country and Western" + }, + { + "url": "https://files.catbox.moe/qvd0qg.m4a", + "title": "Cottonwood Tree", + "duration": 2370, + "artist": "Marty Robbins", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Country and Western" + }, + { + "url": "https://files.catbox.moe/378qb4.m4a", + "title": "Ain't I Right", + "duration": 1980, + "artist": "Marty Robbins", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Country and Western" + }, + { + "url": "https://files.catbox.moe/5sgpkn.m4a", + "title": "Wish It Was True", + "duration": 2280, + "artist": "The White Buffalo", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Country and Western" + }, + { + "url": "https://files.catbox.moe/de3b29.m4a", + "title": "Burn The Witch", + "duration": 2480, + "artist": "Shawn James", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Country and Western" + }, + { + "url": "https://files.catbox.moe/av3j0o.m4a", + "title": "Sink The Bismarck", + "duration": 1940, + "artist": "Johnny Horton", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Country and Western" + }, + { + "url": "https://files.catbox.moe/8z0ueu.m4a", + "title": "Mr. Shorty", + "duration": 3030, + "artist": "Marty Robbins", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Country and Western" + }, + { + "url": "https://files.catbox.moe/0wwk0d.m4a", + "title": "Uneasy Rider", + "duration": 3180, + "artist": "The Charlie Daniels Band", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Country and Western" + }, + { + "url": "https://files.catbox.moe/dws0dz.m4a", + "title": "Uneasy Rider '88", + "duration": 2640, + "artist": "The Charlie Daniels Band", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Country and Western" + }, + { + "url": "https://files.catbox.moe/ci1gka.m4a", + "title": "For A Few Dollars More", + "duration": 1680, + "artist": "Ennio Morricone", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Country and Western" + }, + { + "url": "https://files.catbox.moe/pbw43i.m4a", + "title": "Faster Than Light", + "duration": 2370, + "artist": "Ben Prunty", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/va5a54.m4a", + "title": "Rebecca", + "duration": 2540, + "artist": "Tesla Boy", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/g3mwvn.m4a", + "title": "Zero Point Non-Response", + "duration": 3330, + "artist": "Mega Drive", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/fu3mf9.m4a", + "title": "Edge of Reality", + "duration": 3300, + "artist": "Mega Drive", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/nspw3i.m4a", + "title": "What Is Love", + "duration": 2700, + "artist": "Haddaway", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/8kyy4o.mp3", + "title": "Time Lapse", + "duration": 2600, + "artist": "Kalax", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/yuwdr5.m4a", + "title": "So Far Away", + "duration": 2820, + "artist": "Lazerhawk", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/hlsk69.m4a", + "title": "Children of the Omnissiah", + "duration": 1060, + "artist": "Guillaume David", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/t7q36u.m4a", + "title": "Noosphere", + "duration": 3880, + "artist": "Guillaume David", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/e2lxd4.m4a", + "title": "Resonance", + "duration": 2120, + "artist": "HOME", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/fnngli.m4a", + "title": "We're Finally Landing", + "duration": 2720, + "artist": "HOME", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/36juno.mp3", + "title": "Head First", + "duration": 2130, + "artist": "HOME", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/bvwhm0.m4a", + "title": "Dust", + "duration": 3020, + "artist": "M|O|O|N", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/b6urx6.m4a", + "title": "Silent Strike", + "duration": 2970, + "artist": "Garth Knight", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/ywr4gd.mp3", + "title": "Night Train", + "duration": 2440, + "artist": "Mitch Murder", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/rgtjn3.m4a", + "title": "Welcome to VA-11 Hall-A", + "duration": 1880, + "artist": "Garoad", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/86wwz1.m4a", + "title": "Drive Me Wild", + "duration": 2280, + "artist": "Garoad", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/j14cpw.m4a", + "title": "Sexualizer", + "duration": 2990, + "artist": "Perturbator (feat. Flash Arnold)", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/3zb1hx.m4a", + "title": "Into The Labyrinth", + "duration": 3100, + "artist": "Kraddy", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/qxadpn.m4a", + "title": "Turbo Killer", + "duration": 2080, + "artist": "Carpenter Brut", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/dclq52.m4a", + "title": "Deep Cover", + "duration": 4850, + "artist": "Sun Araw", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/wpws58.m4a", + "title": "Fragments of Bach I", + "duration": 3060, + "artist": "Compilerbau", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/ltlzpi.mp3", + "title": "Leather Teeth", + "duration": 2330, + "artist": "Carpenter Brut", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/bem2ur.m4a", + "title": "Cheerleader Effect", + "duration": 2170, + "artist": "Carpenter Brut", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/hwfg7e.m4a", + "title": "Hairspray Hurricane", + "duration": 3310, + "artist": "Carpenter Brut", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/xx00ka.m4a", + "title": "Sunday Lunch", + "duration": 1950, + "artist": "Carpenter Brut", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/4ju4p1.mp3", + "title": "Lone Digger", + "duration": 2300, + "artist": "Caravan Palace", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/4c0lmk.m4a", + "title": "Dessert", + "duration": 1840, + "artist": "Jun Chikuma", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/g0ms06.m4a", + "title": "Propane Nightmares", + "duration": 3130, + "artist": "Pendulum", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/7i23kv.m4a", + "title": "Overdrive (Instrumental)", + "duration": 2260, + "artist": "Lily Arciniega", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/re4is1.m4a", + "title": "Tusk", + "duration": 2180, + "artist": "Fleetwood Mac", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Folk and Indie" + }, + { + "url": "https://files.catbox.moe/xztay0.m4a", + "title": "Rock On Rockall", + "duration": 1740, + "artist": "The Wolfe Tones", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Folk and Indie" + }, + { + "url": "https://files.catbox.moe/6ryyk0.m4a", + "title": "Apocalypse", + "duration": 2900, + "artist": "Cigarettes After Sex", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Folk and Indie" + }, + { + "url": "https://files.catbox.moe/2z6lpx.mp3", + "title": "Pioneer's Song", + "duration": 1790, + "artist": "Leslie Fish", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Folk and Indie" + }, + { + "url": "https://files.catbox.moe/e55ey0.mp3", + "title": "Bones", + "duration": 1200, + "artist": "Leslie Fish", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Folk and Indie" + }, + { + "url": "https://files.catbox.moe/evilyr.m4a", + "title": "Age of Consent", + "duration": 3150, + "artist": "New Order", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Pop" + }, + { + "url": "https://files.catbox.moe/lgot68.m4a", + "title": "Maneater", + "duration": 2730, + "artist": "Hall & Oates", + "secret": false, + "lobby": true, + "jukebox": true, + "genre": "Pop" + }, + { + "url": "https://files.catbox.moe/8e4bb0.m4a", + "title": "Ordinary World", + "duration": 3400, + "artist": "Duran Duran", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Pop" + }, + { + "url": "https://files.catbox.moe/leulle.m4a", + "title": "Forever Young", + "duration": 2260, + "artist": "Alphaville", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Pop" + }, + { + "url": "https://files.catbox.moe/z8bxjp.m4a", + "title": "Sussudio", + "duration": 2640, + "artist": "Phil Collins", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Pop" + }, + { + "url": "https://files.catbox.moe/wti40y.m4a", + "title": "One More Night", + "duration": 2910, + "artist": "Phil Collins", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Pop" + }, + { + "url": "https://files.catbox.moe/zifzgk.m4a", + "title": "Little Girls", + "duration": 2230, + "artist": "Oingo Boingo", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Pop" + }, + { + "url": "https://files.catbox.moe/5yk0mm.m4a", + "title": "Everything She Wants", + "duration": 3920, + "artist": "Wham!", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Pop" + }, + { + "url": "https://files.catbox.moe/5ye8et.m4a", + "title": "Careless Whisper", + "duration": 3030, + "artist": "George Michael", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Pop" + }, + { + "url": "https://files.catbox.moe/9a5dov.m4a", + "title": "Space Cowboy", + "duration": 2160, + "artist": "Jamiroquai", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Disco, Funk, Soul, and R&B" + }, + { + "url": "https://files.catbox.moe/dpjb9r.m4a", + "title": "Video Killed The Radio Star", + "duration": 2530, + "artist": "The Buggles", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Pop" + }, + { + "url": "https://files.catbox.moe/367b33.m4a", + "title": "Honey, There's No Time", + "duration": 2610, + "artist": "Feng Suave", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Pop" + }, + { + "url": "https://files.catbox.moe/q5kk5l.m4a", + "title": "Sink into the Floor", + "duration": 2790, + "artist": "Feng Suave", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Pop" + }, + { + "url": "https://files.catbox.moe/w7d9qk.m4a", + "title": "This Train", + "duration": 2850, + "artist": "Turk Murphy's San Francisco Jazz Band", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Jazz" + }, + { + "url": "https://files.catbox.moe/79d8gs.mp3", + "title": "Love Theme", + "duration": 5710, + "artist": "Andrew Hale", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Jazz" + }, + { + "url": "https://files.catbox.moe/u1h4vg.m4a", + "title": "Jingle Jangle Jingle", + "duration": 1970, + "artist": "Kay Kyser", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Jazz" + }, + { + "url": "https://files.catbox.moe/ky7dvn.m4a", + "title": "Personality", + "duration": 1690, + "artist": "Johnny Mercer & The Pied Pipers", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Jazz" + }, + { + "url": "https://files.catbox.moe/5jm318.m4a", + "title": "Let's Face The Music And Dance", + "duration": 1710, + "artist": "Frank Sinatra", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Jazz" + }, + { + "url": "https://files.catbox.moe/jhjidm.m4a", + "title": "I'll Get By", + "duration": 1700, + "artist": "Frank Fontaine", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Jazz" + }, + { + "url": "https://files.catbox.moe/bwg49y.m4a", + "title": "That Face!", + "duration": 1430, + "artist": "Frank Sinatra Jr", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Jazz" + }, + { + "url": "https://files.catbox.moe/2wmc3x.m4a", + "title": "Mack the Knife", + "duration": 1840, + "artist": "Bobby Darin", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Jazz" + }, + { + "url": "https://files.catbox.moe/q2t0i0.m4a", + "title": "The Day The Clown Cried", + "duration": 1900, + "artist": "Jimmy Beaumont & The Skyliners", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Jazz" + }, + { + "url": "https://files.catbox.moe/c06zik.m4a", + "title": "Invitation To The Blues", + "duration": 3240, + "artist": "Tom Waits", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Jazz" + }, + { + "url": "https://files.catbox.moe/1z0mli.m4a", + "title": "Drunk On The Moon", + "duration": 3060, + "artist": "Tom Waits", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Jazz" + }, + { + "url": "https://files.catbox.moe/dqu0t5.m4a", + "title": "La Vie En Rose", + "duration": 2050, + "artist": "Louis Armstrong", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Jazz" + }, + { + "url": "https://files.catbox.moe/0ys4q8.m4a", + "title": "So What", + "duration": 5690, + "artist": "Miles Davis Sextet", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Jazz" + }, + { + "url": "https://files.catbox.moe/nr7btw.m4a", + "title": "Just The Two Of Us", + "duration": 4430, + "artist": "Grover Washington Jr", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Jazz" + }, + { + "url": "https://files.catbox.moe/tb9ys2.m4a", + "title": "Quiet Nights Of Quiet Stars", + "duration": 1640, + "artist": "Frank Sinatra & Antonio Carlos Jobim", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Jazz" + }, + { + "url": "https://files.catbox.moe/zd1sw1.m4a", + "title": "Drinking Water", + "duration": 1530, + "artist": "Frank Sinatra & Antonio Carlos Jobim", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Jazz" + }, + { + "url": "https://files.catbox.moe/fwtdoh.m4a", + "title": "Ol' Man River (Showboat)", + "duration": 2340, + "artist": "Paul Robseon", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Jazz" + }, + { + "url": "https://files.catbox.moe/mp8tab.m4a", + "title": "Juke Box Hero", + "duration": 2590, + "artist": "Foreigner", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/n0ezuw.m4a", + "title": "Waiting for a Girl Like You", + "duration": 2890, + "artist": "Foreigner", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/gd49uk.m4a", + "title": "I Want to Know What Love Is", + "duration": 3040, + "artist": "Foreigner", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/s36f97.m4a", + "title": "She Sells Sanctuary", + "duration": 2620, + "artist": "The Cult", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/62lks9.m4a", + "title": "No One Knows", + "duration": 2580, + "artist": "Queens of the Stone Age", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/e4sltu.m4a", + "title": "Like a Stone", + "duration": 2940, + "artist": "Audioslave", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/yitsz9.m4a", + "title": "Friday I'm in Love", + "duration": 2140, + "artist": "The Cure", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Pop" + }, + { + "url": "https://files.catbox.moe/dwyjkt.m4a", + "title": "Ozymandias", + "duration": 1920, + "artist": "Red Vox", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/wmpgcr.mp3", + "title": "Welcome To The Jungle", + "duration": 2690, + "artist": "Guns N' Roses", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/20exnb.mp3", + "title": "Fortunate Son", + "duration": 1400, + "artist": "Creedence Clearwater Revival", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/8xzmw5.mp3", + "title": "Bohemian Rhapsody", + "duration": 3580, + "artist": "Queen", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/7x2cvf.mp3", + "title": "Rocket Man", + "duration": 2820, + "artist": "Elton John", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/xjgch6.m4a", + "title": "Smooth Sailing", + "duration": 2910, + "artist": "Queens of the Stone Age", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/f8ebif.m4a", + "title": "Face Melter (How to Do Impossible Things)", + "duration": 2100, + "artist": "Death Grips", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Hip-Hop and Rap" + }, + { + "url": "https://files.catbox.moe/6i31kh.m4a", + "title": "Rapper's Delight", + "duration": 8850, + "artist": "The Sugar Hill Gang", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Hip-Hop and Rap" + }, + { + "url": "https://files.catbox.moe/3y9k43.m4a", + "title": "Super Freak", + "duration": 2020, + "artist": "Rick James", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Hip-Hop and Rap" + }, + { + "url": "https://files.catbox.moe/93ynna.m4a", + "title": "Party All The Time", + "duration": 2530, + "artist": "Eddie Murphy", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Hip-Hop and Rap" + }, + { + "url": "https://files.catbox.moe/4s8xyr.m4a", + "title": "Funky Cold Medina", + "duration": 2480, + "artist": "Tone-Loc", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Hip-Hop and Rap" + }, + { + "url": "https://files.catbox.moe/jzpkv4.m4a", + "title": "Children's Story", + "duration": 2420, + "artist": "Slick Rick", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Hip-Hop and Rap" + }, + { + "url": "https://files.catbox.moe/tk1zno.m4a", + "title": "Voila", + "duration": 2080, + "artist": "Death Grips", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Hip-Hop and Rap" + }, + { + "url": "https://files.catbox.moe/jvhl18.m4a", + "title": "Black Ballons Reprise", + "duration": 1620, + "artist": "Flying Lotus (feat. Denzel Curry)", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Hip-Hop and Rap" + }, + { + "url": "https://files.catbox.moe/59nnfc.m4a", + "title": "Take_it_Back_v2", + "duration": 1690, + "artist": "Denzel Curry & Kenny Beats", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Hip-Hop and Rap" + }, + { + "url": "https://files.catbox.moe/pkwkd1.m4a", + "title": "All Caps", + "duration": 1340, + "artist": "Madvillain", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Hip-Hop and Rap" + }, + { + "url": "https://files.catbox.moe/d7zx95.m4a", + "title": "Express Yourself", + "duration": 2650, + "artist": "N.W.A", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Hip-Hop and Rap" + }, + { + "url": "https://files.catbox.moe/fgn4pf.m4a", + "title": "BLACK BALLOONS", + "duration": 2100, + "artist": "Denzel Curry", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Hip-Hop and Rap" + }, + { + "url": "https://files.catbox.moe/o7hb3j.m4a", + "title": "Meat Grinder", + "duration": 1310, + "artist": "Madvillain", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Hip-Hop and Rap" + }, + { + "url": "https://files.catbox.moe/8o0z0l.m4a", + "title": "Keep Your Eyes Peeled", + "duration": 3040, + "artist": "Queens of the Stone Age", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/ri8l16.m4a", + "title": "Sleep Drifter", + "duration": 2840, + "artist": "King Gizzard & The Lizard Wizard", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/zz1p3n.m4a", + "title": "Open Water", + "duration": 4330, + "artist": "King Gizzard & The Lizard Wizard", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/prllpr.m4a", + "title": "Gamma Knife", + "duration": 2910, + "artist": "King Gizzard & The Lizard Wizard", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/v66qvy.m4a", + "title": "Baby Gotterdammerung", + "duration": 1880, + "artist": "Monster Magnet", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/isus1b.m4a", + "title": "Robot Stop", + "duration": 2100, + "artist": "King Gizzard & The Lizard Wizard", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/qhionp.mp3", + "title": "Come Sail Away", + "duration": 3650, + "artist": "Styx", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/vm93di.m4a", + "title": "Bullet Girl", + "duration": 3210, + "artist": "John Butler Trio", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/f3gvmk.m4a", + "title": "Holland, 1945", + "duration": 1920, + "artist": "Neutral Milk Hotel", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url": "https://files.catbox.moe/8rn8uz.mp3", + "title": "I Wupped Batman's Ass", + "duration": 3270, + "artist": "Wesley Willis Fiasco", + "secret": true, + "lobby": false, + "jukebox": true + }, + { + "url" : "https://cdn.discordapp.com/attachments/404660884531707906/676726491806957568/Cassette.mp3", + "title" : "Cassette", + "duration" :2310, + "artist" : "Efence", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Ambience" + }, + { + "url" : "https://cdn.discordapp.com/attachments/676384242426707979/676730040624480264/Glowing_Red_Dust.mp3", + "title" : "Red Glowing Dust", + "duration" :3370, + "artist" : "Jón Hallur Haraldsson (also known as RealX)", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Ambience" + }, + { + + "url" : "https://cdn.discordapp.com/attachments/676384242426707979/676730820530274323/Stellardrone_-_Eternity.mp3", + "title" : "Eternity", + "duration" :3810, + "artist" : "Stellardrone", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Ambience" + }, + { + "url" : "https://cdn.discordapp.com/attachments/612993188655005697/676732888712216596/DJ-ZEK_-_Simulate.mp3", + "title" : "Simulate", + "duration" :4850, + "artist" : "Fawxtrot (now know as DJ Zek)", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Ambience" + }, + { + "url" : "https://cdn.discordapp.com/attachments/520929567540772873/707899715261562960/FitnessGram_20_Meter_PACER_Test_Full_Length_OFFICIAL_Audio_Version.mp3", + "title" : "The FitnessGram Pacer Test", + "duration" : 13720, + "artist" : "The Cooper Institute (FitnessGram)", + "secret": true, + "lobby": false, + "jukebox": true, + "genre": "Ambience" + }, + { + "url" : "https://cdn.discordapp.com/attachments/575066308362895444/701639140109844530/Peaceful_Orbits.mp3", + "title" : "Peaceful Orbits", + "duration" : 2040, + "artist" : "TeknoAXE", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Ambience" + }, + { + "url" : "https://cdn.discordapp.com/attachments/575066308362895444/701639311896084511/Passing_Time_in_an_Escape_Pod.mp3", + "title" : "Passing Time in an Escape Pod", + "duration" : 2300, + "artist" : "TeknoAXE", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Ambience" + }, + { + "url" : "https://cdn.discordapp.com/attachments/331435060735508480/613310956927451137/Persona_5_48_-_Beneath_the_Mask_-rain-.mp3", + "title" : "Beneath the Mask -Rain Version-", + "duration" : 2790, + "artist" : "Persona 5", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Jazz" + }, + { + "url" : "https://cdn.discordapp.com/attachments/331037116094087168/543191420370944001/DragonSharkOriginal3.mp3", + "title" : "Terrorbyte/Dragon Shark theme", + "duration" : 2100, + "artist" : "RetroSpecter", + "secret": true, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url" : "https://cdn.discordapp.com/attachments/528973352728264714/583441087633555456/Prey_-_Everything_is_Going_to_Be_Ok_online-audio-converter.com.mp3", + "title" : "Everything Is Going To Be Ok", + "duration" : 1600, + "artist" : "Mick Gordon", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url" : "https://cdn.discordapp.com/attachments/311691674130710528/463590234978910228/Saga_of_Tanya_the_Evil_-_Jingo_Jungle_Opening_ENGLISH_ver_AmaLee.mp3", + "title" : "Jingo Jungle", + "duration" : 2440, + "artist" : "Leeandlie http://bit.ly/Leeandlie", + "secret": true, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url" : "https://cdn.discordapp.com/attachments/311691674130710528/468345155498541066/TheMadnessOfFate.mp3", + "title" : "Madness of Fate", + "duration" :4030, + "artist" : "NIIC the singing dog - http://bit.ly/NIICDOGYT", + "secret": true, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url" : "https://cdn.discordapp.com/attachments/311691674130710528/449909217403469837/Hard_Bass_School_-_narkotik_kal.mp3", + "title" : "Narkotik kal", + "duration" : 2350, + "artist" : "Hardbass", + "secret": true, + "lobby": false, + "jukebox": true, + "genre": "Ambience" + }, + { + "url" : "https://cdn.discordapp.com/attachments/528973352728264714/613807206047154217/TERRORBYTE_-_HELIOS.mp3", + "title" : "Helios", + "duration" : 2850, + "artist" : "TERRORBYTE", + "secret": true, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url" : "https://cdn.discordapp.com/attachments/377217220041900034/453308819422838784/System_Of_A_Down_-_Shimmy_11.mp3", + "title" : "Shimmy", + "duration" : 1110, + "artist" : "System of a Down", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url" : "https://cdn.discordapp.com/attachments/377217220041900034/453308919067049995/System_Of_A_Down_-_Science_10.mp3", + "title" : "Science", + "duration" : 1630, + "artist" : "System of a Down", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url" : "https://cdn.discordapp.com/attachments/377217220041900034/453308944706699284/System_Of_A_Down_-_Deer_Dance_03.mp3", + "title" : "deer dance", + "duration" : 1750, + "artist" : "System of a Down", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url" : "https://cdn.discordapp.com/attachments/377217220041900034/453308945411473428/System_Of_A_Down_-_Atwa.mp3", + "title" : "Atwa", + "duration" : 1790, + "artist" : "System of a Down", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url" : "https://cdn.discordapp.com/attachments/377217220041900034/453308969772122112/System_Of_A_Down_-_Forest_08.mp3", + "title" : "Forest", + "duration" : 2400, + "artist" : "System of a Down", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url" : "https://cdn.discordapp.com/attachments/377217220041900034/453308978089426985/System_Of_A_Down_-_Prison_Song_01.mp3", + "title" : "Prison song", + "duration" : 2110, + "artist" : "System of a Down", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url" : "https://cdn.discordapp.com/attachments/377217220041900034/453308978089426985/System_Of_A_Down_-_Prison_Song_01.mp3", + "title" : "SPIDERS!", + "duration" : 2150, + "artist" : "System of a Down", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url" : "https://cdn.discordapp.com/attachments/377217220041900034/453309000780611604/System_Of_A_Down_-_Aerials_14.mp3", + "title" : "Aerials", + "duration" : 2320, + "artist" : "System of a Down", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url" : "https://cdn.discordapp.com/attachments/377217220041900034/453309015955472394/System_Of_A_Down_-_Jet_Pilot_04.mp3", + "title" : "Jet Pilot", + "duration" : 1260, + "artist" : "System of a Down", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url" : "https://cdn.discordapp.com/attachments/377217220041900034/454653832945860608/Skeleton_Man-_The_Axis_of_Awesome.mp3", + "title" : "Skeleton Man", + "duration" : 1510, + "artist" : "AoW", + "secret": true, + "lobby": false, + "jukebox": true, + "genre": "Classical and Orchestral" + }, + { + "url" : "https://cdn.discordapp.com/attachments/745916615606140938/775593951259918336/Nanook_Rubs_It.mp3", + "title" : "Nanook Rubs It", + "duration" :2770, + "artist" : "Frank Zappa", + "secret": true, + "lobby": false, + "jukebox": true, + "genre": "Country and Western" + }, + { + "url" : "https://cdn.discordapp.com/attachments/745916615606140938/775593948307521566/Muffin_Man.mp3", + "title" : "Muffin Man", + "duration" :3370, + "artist" : "Frank Zappa", + "secret": true, + "lobby": false, + "jukebox": true, + "genre": "Folk and Indie" + }, + { + "url" : "https://cdn.discordapp.com/attachments/404660884531707906/456271996314058752/Edgar_Rothermich_-01_Theme_from_Silent_Hill_jungle-vibe.com.mp3", + "title" : "Silent Hill", + "duration" : 1760, + "artist" : "Akira Yamaoka and Edgar Rothermich", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Country and Western" + }, + { + "url" : "https://cdn.discordapp.com/attachments/377217220041900034/460490120240300052/Nightcore_-_Tetris.mp3", + "title" : "Tetris Remixed", + "duration" : 1910, + "artist" : "nightcore", + "secret": true, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url" : "https://cdn.discordapp.com/attachments/377217220041900034/460490219167154186/VERSACE_2017_-_TIX__The_Pssy_Project.mp3", + "title" : "Versance", + "duration" : 2030, + "artist" : "PussyProject", + "secret": true, + "lobby": false, + "jukebox": true, + "genre": "Classical and Orchestral" + }, + { + "url" : "https://cdn.discordapp.com/attachments/377217220041900034/460490229523021834/-_.mp3", + "title" : "Russian Techno", + "duration" : 2400, + "artist" : "Some russians", + "secret": true, + "lobby": false, + "jukebox": true, + "genre": "Hip-Hop and Rap" + }, + { + "url" : "https://cdn.discordapp.com/attachments/377217220041900034/460490231611654144/Rock_Dog_2017_Movie_Official_Lyric_Video_Glorious_by_Adam_Friedman.mp3", + "title" : "Gloriouse", + "duration" : 2200, + "artist" : "Rock Dog", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Rock" + }, + { + "url" : "https://cdn.discordapp.com/attachments/377217220041900034/460490245725487104/Gigi_DAgostino_-_You_Spin_Me_Round__Tecno_Fes_2_.mp3", + "title" : "You spin me around", + "duration" : 2580, + "artist" : "Gigi DAgostino", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Pop" + }, + { + "url" : "https://cdn.discordapp.com/attachments/377217220041900034/460490246610747402/-__Kyary_Pamyu_Pamyu_-.mp3", + "title" : "Pamyu Pamyu", + "duration" : 2640, + "artist" : "Kyary", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Pop" + }, + { + "url" : "https://cdn.discordapp.com/attachments/377217220041900034/457628201200648202/Berserk_-_S3RL_ft_Iceman.mp3", + "title" : "Beserk", + "duration" :1620, + "artist" : "S3RL ft Iceman", + "secret": true, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url" : "https://cdn.discordapp.com/attachments/458630047167807488/520025625852117002/Nick_Hakim_-_Pour_Another.mp3", + "title" : "Pour Another", + "duration" :3510, + "artist" : "Nick Hakim", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Jazz" + }, + { + "url" : "https://cdn.discordapp.com/attachments/526693916859170816/559236326264668190/Ocean_Man.mp3", + "title" : "Ocean Man", + "duration" : 1270, + "artist" : "Ween", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Classical and Orchestral" + }, + { + "url" : "https://cdn.discordapp.com/attachments/458630047167807488/520025672702492682/Nick_Hakim_-_I_Dont_Know.mp3", + "title" : "I dont know", + "duration" :2990, + "artist" : "Nick Hakim", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Jazz" + }, + { + "url" : "https://cdn.discordapp.com/attachments/404660884531707906/614356720503881728/Underfell_Megalovania.mp3", + "title" : "Underfell", + "duration" :1830, + "artist" : "keno9988iii", + "secret": true, + "lobby": false, + "jukebox": true, + "genre": "Metal" + }, + { + "url" : "https://cdn.discordapp.com/attachments/661630061983825938/663112074880352276/Headhunterz_-_Scrap_Attack_HQ.mp3", + "title" : "Scrap Attack", + "duration" :3730, + "artist" : "Headhunterz", + "secret": true, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url" : "https://files.catbox.moe/h967s5.mp3", + "title" : "MEGALOVANIA", + "duration" : 1416, + "artist" : "Toby Fox", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Video Game" + }, + { + "url" : "https://files.catbox.moe/yumfgs.mp3", + "title" : "ASGORE", + "duration" : 1416, + "artist" : "Toby Fox", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Video Game" + }, + { + "url" : "https://files.catbox.moe/ewn2u4.mp3", + "title" : "Disco Descent", + "duration" : 1800, + "artist" : "Danny Baranowsky", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url" : "https://files.catbox.moe/8rns1d.mp3", + "title" : "Konga Conga Kappa", + "duration" : 1130, + "artist" : "Danny Baranowsky", + "secret": false, + "lobby": false, + "jukebox": true, + "genre": "Electronic" + }, + { + "url": "https://files.catbox.moe/3h38kn.mp3", + "title": "Among Us", + "duration": 720, + "genre": "Disco, Funk, Soul, and R&B", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/rqhxj1.mp3", + "title": "Leggy Zone", + "duration": 1470, + "genre": "Electronic", + "secret": true, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/e6l6mw.mp3", + "title": "Shop Theme", + "duration": 8910, + "genre": "Electronic", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/q2ob3n.mp3", + "title": "PONPONPON", + "duration": 2430, + "artist": "Kyary Pamyu Pamyu", + "genre": "J-Pop", + "secret": false, + "lobby": false + }, + { + "url": "https://files.catbox.moe/o8flhu.mp3", + "title": "Man's Road", + "duration": 2060, + "artist": "America", + "genre": "Rock", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/0gb5qs.mp3", + "title": "Passionflower", + "duration": 3840, + "artist": "Jon Gomm", + "genre": "Rock", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/p5e28z.mp3", + "title": "What the Fuck", + "duration": 2420, + "artist": "Koh", + "genre": "Folk and Indie", + "secret": true, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/0f2xma.mp3", + "title": "Bonetrousle", + "duration": 580, + "artist": "Toby Fox", + "genre": "Video Game", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/6ivc7w.mp3", + "title": "Bury the Light", + "duration": 5830, + "artist": "Victor Borba & Casey Edwards", + "genre": "Video Game", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/pbtbjv.mp3", + "title": "Dog Song", + "duration": 380, + "artist": "Toby Fox", + "genre": "Video Game", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/4v3eeb.mp3", + "title": "Anticipation", + "duration": 230, + "artist": "Toby Fox", + "genre": "Video Game", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/0q8ch6.mp3", + "title": "Smash Clown", + "duration": 820, + "artist": "Goat", + "genre": "Space", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/ygcg2e.mp3", + "title": "Honk The Police", + "duration": 590, + "artist": "Goat", + "genre": "Space", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/h0yswi.mp3", + "title": "Cargonia", + "duration": 630, + "artist": "Goat", + "genre": "Space", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/xg76ja.mp3", + "title": "Hail Cargonia", + "duration": 980, + "artist": "Goat", + "genre": "Space", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/tbr2rc.mp3", + "title": "Banned From Cargo", + "duration": 2450, + "artist": "Goat", + "genre": "Space", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/tw2et2.mp3", + "title": "Periphery Dixie", + "duration": 1200, + "artist": "Goat", + "genre": "Space", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/rm5hcs.mp3", + "title": "Cargonia (cover)", + "duration": 2730, + "artist": "Dadbot5000", + "genre": "Space", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/ybfof9.mp3", + "title": "The Legend", + "duration": 1120, + "artist": "Toby Fox", + "genre": "Video Game", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/ugfx06.mp3", + "title": "Field of Hopes and Dreams", + "duration": 1610, + "artist": "Toby Fox", + "genre": "Video Game", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/wr08om.mp3", + "title": "Checker Dance", + "duration": 780, + "artist": "Toby Fox", + "genre": "Video Game", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/4au954.mp3", + "title": "Vs Susie", + "duration": 810, + "artist": "Toby Fox", + "genre": "Video Game", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/c9y5x2.mp3", + "title": "Golden Wind", + "duration": 2930, + "artist": "Yugo Kanno", + "genre": "Anime", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/ymvtem.mp3", + "title": "Sono Chi no Sadame", + "duration": 2610, + "artist": "Hiroaki TOMMY Tominaga", + "genre": "Anime", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/z73u05.mp3", + "title": "Bloody Stream", + "duration": 2590, + "artist": "Coda", + "genre": "Anime", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/yg3643.mp3", + "title": "Stand Proud", + "duration": 2750, + "artist": "Jin Hashimoto", + "genre": "Anime", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/344qrf.mp3", + "title": "Sono Chi no Kioku ~end of THE WORLD~", + "duration": 2620, + "artist": "JO☆STARS", + "genre": "Anime", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/0n6y6v.mp3", + "title": "Crazy Noisy Bizarre Town", + "duration": 1840, + "artist": "THE DU", + "genre": "Anime", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/j3xzw6.mp3", + "title": "Chase", + "duration": 1450, + "artist": "Batta", + "genre": "Anime", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/luf1bh.mp3", + "title": "Great Days", + "duration": 2400, + "artist": "Karen Aoki & Daisuke Hasegaw", + "genre": "Anime", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/ytp4d2.mp3", + "title": "Fighting Gold", + "duration": 2540, + "artist": "Coda", + "genre": "Anime", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/q1xjdp.mp3", + "title": "I Want It That Way", + "duration": 2120, + "artist": "Backstreet Boys", + "genre": "Pop", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/pxpkat.mp3", + "title": "Shop Theme Cover", + "duration": 1770, + "artist": "Songe", + "genre": "Video Game", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/nz03o1.mp3", + "title": "Deep Space 9 (Cover)", + "duration": 1220, + "artist": "Heropoint", + "genre": "Rock", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/n4vcnq.mp3", + "title": "Trouble Shooting Star", + "duration": 1460, + "artist": "", + "genre": "Video Game", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/byy378.mp3", + "title": "The Lord of the Rings The Fellowship of the its my book they will walk if I tell them to", + "duration": 970, + "artist": "Tom Cardy", + "genre": "Folk and Indie", + "secret": true, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/akgbup.mp3", + "title": "Flight of the Bumblebee", + "duration": 2050, + "artist": "Rimsky Korsakov", + "genre": "Classical and Orchestral", + "secret": true, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/milk8o.mp3", + "title": "As Heaven Is Wide", + "duration": 2880, + "artist": "Garbage", + "genre": "Rock", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/8mh82l.mp3", + "title": "Combat Montage", + "duration": 1410, + "artist": "Kevin Manthei", + "genre": "Rock", + "secret": true, + "lobby": false, + "jukebox": true + }, + + { + "url": "https://files.catbox.moe/qa6ahi.mp3", + "title": "Deadlines", + "duration": 1570, + "artist": "Network Music Ensemble", + "genre": "Rock", + "secret": false, + "lobby": true, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/cdmm5k.mp3", + "title": "Holding Out For A Hero", + "duration": 2620, + "artist": "Ella Mae Bowen", + "genre": "Country and Western", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/jlv9eb.mp3", + "title": "Jiko Bunseki", + "duration": 1420, + "artist": "Yuki Hayashi", + "genre": "Anime", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/vzbcsc.mp3", + "title": "Let It End", + "duration": 2220, + "artist": "Karliene", + "genre": "Folk and Indie", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/nsfwxe.mp3", + "title": "Proof Of A Hero (16 bit)", + "duration": 1410, + "artist": "Bulby", + "genre": "Video Game", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/ywoaqa.mp3", + "title": "Proof Of A Hero", + "duration": 1340, + "artist": "Kouda Masato", + "genre": "Video Game", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/xlrbbe.mp3", + "title": "Pure Imagination", + "duration": 2060, + "artist": "Fiona Apple", + "genre": "Electronic", + "secret": false, + "lobby": true, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/q3f6sq.mp3", + "title": "Rhapsody In Blue", + "duration": 9870, + "artist": "Kamil Hala, Slovak Philharmonic Orchestra, Libor Pešek", + "genre": "Classical and Orchestral", + "secret": false, + "lobby": true, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/vzsm5k.mp3", + "title": "Surface Of SR388", + "duration": 1710, + "artist": "BG Ollie", + "genre": "Video Game", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/ngtv3e.mp3", + "title": "Where My Heart Will Take Me", + "duration": 0, + "artist": "Russell Watson", + "genre": "Country and Western", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/cfq7bo.mp3", + "title": "Kiss From A Rose", + "duration": 2860, + "artist": "Dan Avidan & Super Guitar Bros", + "genre": "Acoustic", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/4mypzc.mp3", + "title": "Black Hole Sun", + "duration": 3210, + "artist": "Dan Avidan & Super Guitar Bros", + "genre": "Acoustic", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/qpkv1q.mp3", + "title": "I Still Haven't Found What I'm Looking For", + "duration": 2870, + "artist": "Dan Avidan & Super Guitar Bros", + "genre": "Acoustic", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/g8vawq.mp3", + "title": "Nights In White Satin", + "duration": 2710, + "artist": "Dan Avidan & Super Guitar Bros", + "genre": "Acoustic", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/01ljjx.mp3", + "title": "Land Of Confusion", + "duration": 2850, + "artist": "Dan Avidan & Super Guitar Bros", + "genre": "Acoustic", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/2iugx4.mp3", + "title": "Aspirations", + "duration": 3010, + "artist": "Dan Avidan & Super Guitar Bros", + "genre": "Acoustic", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/m3cwu1.mp3", + "title": "Head Over Heels", + "duration": 2420, + "artist": "Dan Avidan & Super Guitar Bros", + "genre": "Acoustic", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/xtizlg.mp3", + "title": "Get Lucky", + "duration": 2490, + "artist": "Dan Avidan & Super Guitar Bros", + "genre": "Acoustic", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/dym0sp.mp3", + "title": "God Only Knows", + "duration": 1810, + "artist": "Dan Avidan & Super Guitar Bros", + "genre": "Acoustic", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/i8gml0.mp3", + "title": "Scarborough Fair", + "duration": 2020, + "artist": "Dan Avidan & Super Guitar Bros", + "genre": "Acoustic", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/chc70l.mp3", + "title": "The Sphere A Kind Of Dream", + "duration": 620, + "artist": "Dan Avidan & Super Guitar Bros", + "genre": "Acoustic", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/w48oqv.mp3", + "title": "Pushin' The Speed of Light", + "duration": 2130, + "artist": "Julia Ecklar & Anne Prather", + "genre": "Folk and Indie", + "secret": false, + "lobby": true, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/zacsky.mp3", + "title": "S.T.L.", + "duration": 1190, + "artist": "Free Fall & Other Delights", + "genre": "Folk and Indie", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/9a3ow3.mp3", + "title": "Phantom Lover of the Star Drive", + "duration": 1930, + "artist": "Free Fall & Other Delights", + "genre": "Folk and Indie", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/eo59pv.mp3", + "title": "Molecular Clouds", + "duration": 1560, + "artist": "Free Fall & Other Delights", + "genre": "Folk and Indie", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/227jw9.mp3", + "title": "A Reconsideration (Zero-G Sex)", + "duration": 2530, + "artist": "Free Fall & Other Delights", + "genre": "Folk and Indie", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/yvn8bu.mp3", + "title": "Stuck Here", + "duration": 1360, + "artist": "Free Fall & Other Delights", + "genre": "Folk and Indie", + "secret": false, + "lobby": true, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/nsnf73.mp3", + "title": "The Ballad of Transport 18", + "duration": 2660, + "artist": "Leslie Fish", + "genre": "Folk and Indie", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/hwsbsi.mp3", + "title": "Don't Push That Button", + "duration": 2450, + "artist": "Duane Elms", + "genre": "Folk and Indie", + "secret": false, + "lobby": true, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/iizfw9.mp3", + "title": "Late Night at the Draco Tavern", + "duration": 2600, + "artist": "Duane Elms", + "genre": "Folk and Indie", + "secret": false, + "lobby": true, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/4zaljk.mp3", + "title": "Startide Rising", + "duration": 1050, + "artist": "Duane Elms", + "genre": "Folk and Indie", + "secret": false, + "lobby": true, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/9y01ry.mp3", + "title": "F.A.P. Feline American Princess", + "duration": 2640, + "artist": "Leslie Fish", + "genre": "Folk and Indie", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/s7u3vi.mp3", + "title": "Hymn to Breaking Strain", + "duration": 2710, + "artist": "Julia Ecklar & Leslie Fish", + "genre": "Folk and Indie", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/2t5tjk.mp3", + "title": "Waiting For The Sun", + "duration": 1910, + "artist": "Alistair Lindsay", + "genre": "Video Game", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/8r5d7q.mp3", + "title": "Take me to Snurch (Snail Church)", + "duration": 2370, + "artist": "fairyeiry", + "secret": true, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/fjjcdw.mp3", + "title": "Bearded Dragon", + "duration": 2370, + "artist": "Beat Fatigue", + "genre": "Electronic", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/2dzinb.mp3", + "title": "A Good Song Never Dies", + "duration": 2010, + "artist": "Saint Motel", + "genre": "Pop", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/73nan0.mp3", + "title": "Ya Soldat", + "duration": 1880, + "artist": "5'nizza", + "genre": "Video Game", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/p1lwg8.mp3", + "title": "Space Ghost Coast to Coast", + "duration": 1880, + "artist": "Glass Animals", + "genre": "Pop", + "secret": false, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/n10l5v.mp3", + "title": "Sucklet's Song", + "duration": 1840, + "artist": "emamouse", + "genre": "Folk and Indie", + "secret": true, + "lobby": false, + "jukebox": true + }, + { + "url": "https://files.catbox.moe/6wbuyh.mp3", + "title": "King Sucklet", + "duration": 1720, + "artist": "emamouse", + "genre": "Folk and Indie", + "secret": true, + "lobby": false, + "jukebox": true + } + ] diff --git a/modular_doppler/cassettes/code/objects/bags.dm b/modular_doppler/cassettes/code/objects/bags.dm new file mode 100644 index 00000000000000..34d65e91195e1a --- /dev/null +++ b/modular_doppler/cassettes/code/objects/bags.dm @@ -0,0 +1,11 @@ +/obj/item/storage/bag/books/Initialize(mapload) + . = ..() + atom_storage.max_specific_storage = WEIGHT_CLASS_NORMAL + atom_storage.max_total_storage = 21 + atom_storage.max_slots = 7 + atom_storage.set_holdable(list( + /obj/item/book, + /obj/item/spellbook, + /obj/item/poster, + /obj/item/cassette_tape, + )) diff --git a/modular_doppler/cassettes/code/objects/implant_misc.dm b/modular_doppler/cassettes/code/objects/implant_misc.dm new file mode 100644 index 00000000000000..a84c63c522fd6b --- /dev/null +++ b/modular_doppler/cassettes/code/objects/implant_misc.dm @@ -0,0 +1,9 @@ +/obj/item/implant/radio/implant(mob/living/target, mob/user, silent, force) + . = ..() + if(.) + ADD_TRAIT(target, TRAIT_CAN_HEAR_MUSIC, REF(src)) + +/obj/item/implant/radio/removed(mob/living/source, silent, special) + . = ..() + if(.) + REMOVE_TRAIT(source, TRAIT_CAN_HEAR_MUSIC, REF(src)) diff --git a/modular_doppler/cassettes/icons/adv_cassette_deck.dmi b/modular_doppler/cassettes/icons/adv_cassette_deck.dmi new file mode 100644 index 00000000000000..c0bad50b718380 Binary files /dev/null and b/modular_doppler/cassettes/icons/adv_cassette_deck.dmi differ diff --git a/modular_doppler/cassettes/icons/cassette_library.dmi b/modular_doppler/cassettes/icons/cassette_library.dmi new file mode 100644 index 00000000000000..2e3d0ec3ffd386 Binary files /dev/null and b/modular_doppler/cassettes/icons/cassette_library.dmi differ diff --git a/modular_doppler/cassettes/icons/radio_station.dmi b/modular_doppler/cassettes/icons/radio_station.dmi new file mode 100644 index 00000000000000..22735c0169ef3b Binary files /dev/null and b/modular_doppler/cassettes/icons/radio_station.dmi differ diff --git a/modular_doppler/cassettes/icons/walkman.dmi b/modular_doppler/cassettes/icons/walkman.dmi new file mode 100644 index 00000000000000..7ba2a20a7ab785 Binary files /dev/null and b/modular_doppler/cassettes/icons/walkman.dmi differ diff --git a/modular_doppler/cassettes/sound/machine_open_put_in_andclose1.ogg b/modular_doppler/cassettes/sound/machine_open_put_in_andclose1.ogg new file mode 100644 index 00000000000000..b0fa31d81e138d Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_open_put_in_andclose1.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_open_put_in_andclose2.ogg b/modular_doppler/cassettes/sound/machine_open_put_in_andclose2.ogg new file mode 100644 index 00000000000000..b3311869fa6d10 Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_open_put_in_andclose2.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_open_put_in_andclose3.ogg b/modular_doppler/cassettes/sound/machine_open_put_in_andclose3.ogg new file mode 100644 index 00000000000000..00ce3932cb07e9 Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_open_put_in_andclose3.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_open_put_in_andclose4.ogg b/modular_doppler/cassettes/sound/machine_open_put_in_andclose4.ogg new file mode 100644 index 00000000000000..068da300f72831 Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_open_put_in_andclose4.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_open_put_in_andclose5.ogg b/modular_doppler/cassettes/sound/machine_open_put_in_andclose5.ogg new file mode 100644 index 00000000000000..c409191c184113 Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_open_put_in_andclose5.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_open_takeout1.ogg b/modular_doppler/cassettes/sound/machine_open_takeout1.ogg new file mode 100644 index 00000000000000..8f5c17e5bedba9 Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_open_takeout1.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_open_takeout2.ogg b/modular_doppler/cassettes/sound/machine_open_takeout2.ogg new file mode 100644 index 00000000000000..d909791100eba5 Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_open_takeout2.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_open_takeout3.ogg b/modular_doppler/cassettes/sound/machine_open_takeout3.ogg new file mode 100644 index 00000000000000..52299bdd6ccd50 Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_open_takeout3.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_open_takeout4.ogg b/modular_doppler/cassettes/sound/machine_open_takeout4.ogg new file mode 100644 index 00000000000000..3976bb4759e972 Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_open_takeout4.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_open_takeout5.ogg b/modular_doppler/cassettes/sound/machine_open_takeout5.ogg new file mode 100644 index 00000000000000..77d2094056e5ac Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_open_takeout5.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_open_takeout6.ogg b/modular_doppler/cassettes/sound/machine_open_takeout6.ogg new file mode 100644 index 00000000000000..9d3d2414131c05 Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_open_takeout6.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_open_takeout_andclose1.ogg b/modular_doppler/cassettes/sound/machine_open_takeout_andclose1.ogg new file mode 100644 index 00000000000000..337eb351b544d2 Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_open_takeout_andclose1.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_open_takeout_andclose2.ogg b/modular_doppler/cassettes/sound/machine_open_takeout_andclose2.ogg new file mode 100644 index 00000000000000..d908961c330657 Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_open_takeout_andclose2.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_open_takeout_andclose3.ogg b/modular_doppler/cassettes/sound/machine_open_takeout_andclose3.ogg new file mode 100644 index 00000000000000..1d2205890c5d8e Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_open_takeout_andclose3.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_open_takeout_andclose4.ogg b/modular_doppler/cassettes/sound/machine_open_takeout_andclose4.ogg new file mode 100644 index 00000000000000..e413f6f4036e4f Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_open_takeout_andclose4.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_play1.ogg b/modular_doppler/cassettes/sound/machine_play1.ogg new file mode 100644 index 00000000000000..6f18abc3764343 Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_play1.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_play2.ogg b/modular_doppler/cassettes/sound/machine_play2.ogg new file mode 100644 index 00000000000000..8347eee827ee83 Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_play2.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_play3.ogg b/modular_doppler/cassettes/sound/machine_play3.ogg new file mode 100644 index 00000000000000..404cbdac897b69 Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_play3.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_play4.ogg b/modular_doppler/cassettes/sound/machine_play4.ogg new file mode 100644 index 00000000000000..7ba05e84a0d939 Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_play4.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_play5.ogg b/modular_doppler/cassettes/sound/machine_play5.ogg new file mode 100644 index 00000000000000..704f038e30cdf2 Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_play5.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_play6.ogg b/modular_doppler/cassettes/sound/machine_play6.ogg new file mode 100644 index 00000000000000..626d916aaa342b Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_play6.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_put_in_and_close1.ogg b/modular_doppler/cassettes/sound/machine_put_in_and_close1.ogg new file mode 100644 index 00000000000000..bacc8f5dc0a6f5 Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_put_in_and_close1.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_put_in_and_close2.ogg b/modular_doppler/cassettes/sound/machine_put_in_and_close2.ogg new file mode 100644 index 00000000000000..ee8969f73281d6 Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_put_in_and_close2.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_put_in_and_close3.ogg b/modular_doppler/cassettes/sound/machine_put_in_and_close3.ogg new file mode 100644 index 00000000000000..5a0153caf0ae99 Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_put_in_and_close3.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_put_in_and_close4.ogg b/modular_doppler/cassettes/sound/machine_put_in_and_close4.ogg new file mode 100644 index 00000000000000..7754e4a7b3648e Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_put_in_and_close4.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_put_in_and_close5.ogg b/modular_doppler/cassettes/sound/machine_put_in_and_close5.ogg new file mode 100644 index 00000000000000..84568cec794491 Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_put_in_and_close5.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_put_in_and_close6.ogg b/modular_doppler/cassettes/sound/machine_put_in_and_close6.ogg new file mode 100644 index 00000000000000..2e66f8d347a2ab Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_put_in_and_close6.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_stop1.ogg b/modular_doppler/cassettes/sound/machine_stop1.ogg new file mode 100644 index 00000000000000..db45edf831a237 Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_stop1.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_stop2.ogg b/modular_doppler/cassettes/sound/machine_stop2.ogg new file mode 100644 index 00000000000000..0fe045119905e8 Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_stop2.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_stop3.ogg b/modular_doppler/cassettes/sound/machine_stop3.ogg new file mode 100644 index 00000000000000..f23ed0d1ed6c89 Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_stop3.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_track_switch1.ogg b/modular_doppler/cassettes/sound/machine_track_switch1.ogg new file mode 100644 index 00000000000000..cfcb7a790ecd72 Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_track_switch1.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_track_switch2.ogg b/modular_doppler/cassettes/sound/machine_track_switch2.ogg new file mode 100644 index 00000000000000..fb19cd8331c1d7 Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_track_switch2.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_track_switch3.ogg b/modular_doppler/cassettes/sound/machine_track_switch3.ogg new file mode 100644 index 00000000000000..3a6b4c4f6dd9f2 Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_track_switch3.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_track_switch4.ogg b/modular_doppler/cassettes/sound/machine_track_switch4.ogg new file mode 100644 index 00000000000000..8865f8dbe91592 Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_track_switch4.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_track_switch5.ogg b/modular_doppler/cassettes/sound/machine_track_switch5.ogg new file mode 100644 index 00000000000000..50f44b3ef43697 Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_track_switch5.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_track_switch_end1.ogg b/modular_doppler/cassettes/sound/machine_track_switch_end1.ogg new file mode 100644 index 00000000000000..89e5b698fef392 Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_track_switch_end1.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_track_switch_end2.ogg b/modular_doppler/cassettes/sound/machine_track_switch_end2.ogg new file mode 100644 index 00000000000000..deca6515c20937 Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_track_switch_end2.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_track_switch_end3.ogg b/modular_doppler/cassettes/sound/machine_track_switch_end3.ogg new file mode 100644 index 00000000000000..64d473d270e8b8 Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_track_switch_end3.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_track_switch_end4.ogg b/modular_doppler/cassettes/sound/machine_track_switch_end4.ogg new file mode 100644 index 00000000000000..3e85de4bd2c9a5 Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_track_switch_end4.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_track_switch_end5.ogg b/modular_doppler/cassettes/sound/machine_track_switch_end5.ogg new file mode 100644 index 00000000000000..2beb1b7b3d068e Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_track_switch_end5.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_track_switch_loop1.ogg b/modular_doppler/cassettes/sound/machine_track_switch_loop1.ogg new file mode 100644 index 00000000000000..bbd5185a8b1a31 Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_track_switch_loop1.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_track_switch_loop2.ogg b/modular_doppler/cassettes/sound/machine_track_switch_loop2.ogg new file mode 100644 index 00000000000000..48ae537e44c14b Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_track_switch_loop2.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_track_switch_loop3.ogg b/modular_doppler/cassettes/sound/machine_track_switch_loop3.ogg new file mode 100644 index 00000000000000..528aeaeaf27573 Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_track_switch_loop3.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_track_switch_loop4.ogg b/modular_doppler/cassettes/sound/machine_track_switch_loop4.ogg new file mode 100644 index 00000000000000..b508e5871f6676 Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_track_switch_loop4.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_track_switch_loop5.ogg b/modular_doppler/cassettes/sound/machine_track_switch_loop5.ogg new file mode 100644 index 00000000000000..ddc10bbe7c2f73 Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_track_switch_loop5.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_track_switch_start1.ogg b/modular_doppler/cassettes/sound/machine_track_switch_start1.ogg new file mode 100644 index 00000000000000..ecfdb1ae33cbe1 Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_track_switch_start1.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_track_switch_start2.ogg b/modular_doppler/cassettes/sound/machine_track_switch_start2.ogg new file mode 100644 index 00000000000000..19b6ab1877bc0a Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_track_switch_start2.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_track_switch_start3.ogg b/modular_doppler/cassettes/sound/machine_track_switch_start3.ogg new file mode 100644 index 00000000000000..908ec5e12bce5c Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_track_switch_start3.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_track_switch_start4.ogg b/modular_doppler/cassettes/sound/machine_track_switch_start4.ogg new file mode 100644 index 00000000000000..e070e92a9b5597 Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_track_switch_start4.ogg differ diff --git a/modular_doppler/cassettes/sound/machine_track_switch_start5.ogg b/modular_doppler/cassettes/sound/machine_track_switch_start5.ogg new file mode 100644 index 00000000000000..4824fa6b75a8e1 Binary files /dev/null and b/modular_doppler/cassettes/sound/machine_track_switch_start5.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_asmr1.ogg b/modular_doppler/cassettes/sound/tape_asmr1.ogg new file mode 100644 index 00000000000000..c853fd437c540c Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_asmr1.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_asmr10.ogg b/modular_doppler/cassettes/sound/tape_asmr10.ogg new file mode 100644 index 00000000000000..a267749faa1da4 Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_asmr10.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_asmr11.ogg b/modular_doppler/cassettes/sound/tape_asmr11.ogg new file mode 100644 index 00000000000000..2e015a6b689e15 Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_asmr11.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_asmr12.ogg b/modular_doppler/cassettes/sound/tape_asmr12.ogg new file mode 100644 index 00000000000000..24826bfa4efed7 Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_asmr12.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_asmr13.ogg b/modular_doppler/cassettes/sound/tape_asmr13.ogg new file mode 100644 index 00000000000000..f90895f7ee408f Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_asmr13.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_asmr14.ogg b/modular_doppler/cassettes/sound/tape_asmr14.ogg new file mode 100644 index 00000000000000..c7093d51b78774 Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_asmr14.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_asmr15.ogg b/modular_doppler/cassettes/sound/tape_asmr15.ogg new file mode 100644 index 00000000000000..45569fade5154b Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_asmr15.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_asmr16.ogg b/modular_doppler/cassettes/sound/tape_asmr16.ogg new file mode 100644 index 00000000000000..1e957298fa0356 Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_asmr16.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_asmr17.ogg b/modular_doppler/cassettes/sound/tape_asmr17.ogg new file mode 100644 index 00000000000000..bf8aad0b261d03 Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_asmr17.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_asmr18.ogg b/modular_doppler/cassettes/sound/tape_asmr18.ogg new file mode 100644 index 00000000000000..60424705161a9e Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_asmr18.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_asmr19.ogg b/modular_doppler/cassettes/sound/tape_asmr19.ogg new file mode 100644 index 00000000000000..758a12628fdc09 Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_asmr19.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_asmr2.ogg b/modular_doppler/cassettes/sound/tape_asmr2.ogg new file mode 100644 index 00000000000000..d5415e902bff1a Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_asmr2.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_asmr3.ogg b/modular_doppler/cassettes/sound/tape_asmr3.ogg new file mode 100644 index 00000000000000..a12cfa63f2129c Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_asmr3.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_asmr4.ogg b/modular_doppler/cassettes/sound/tape_asmr4.ogg new file mode 100644 index 00000000000000..da2dc24ad2d577 Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_asmr4.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_asmr5.ogg b/modular_doppler/cassettes/sound/tape_asmr5.ogg new file mode 100644 index 00000000000000..27128480ec3448 Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_asmr5.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_asmr6.ogg b/modular_doppler/cassettes/sound/tape_asmr6.ogg new file mode 100644 index 00000000000000..21e58a8b615784 Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_asmr6.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_asmr7.ogg b/modular_doppler/cassettes/sound/tape_asmr7.ogg new file mode 100644 index 00000000000000..2c6a10d6743714 Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_asmr7.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_asmr8.ogg b/modular_doppler/cassettes/sound/tape_asmr8.ogg new file mode 100644 index 00000000000000..7f2bd9154f05c8 Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_asmr8.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_asmr9.ogg b/modular_doppler/cassettes/sound/tape_asmr9.ogg new file mode 100644 index 00000000000000..873b1565725ab6 Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_asmr9.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_dump1.ogg b/modular_doppler/cassettes/sound/tape_dump1.ogg new file mode 100644 index 00000000000000..c69df6531f3a48 Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_dump1.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_dump10.ogg b/modular_doppler/cassettes/sound/tape_dump10.ogg new file mode 100644 index 00000000000000..2eb10441cda149 Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_dump10.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_dump2.ogg b/modular_doppler/cassettes/sound/tape_dump2.ogg new file mode 100644 index 00000000000000..ad23a940587b91 Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_dump2.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_dump3.ogg b/modular_doppler/cassettes/sound/tape_dump3.ogg new file mode 100644 index 00000000000000..1fc64dcf1bcc5f Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_dump3.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_dump4.ogg b/modular_doppler/cassettes/sound/tape_dump4.ogg new file mode 100644 index 00000000000000..2bcfda35fcc671 Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_dump4.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_dump5.ogg b/modular_doppler/cassettes/sound/tape_dump5.ogg new file mode 100644 index 00000000000000..2725c33b29d3c5 Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_dump5.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_dump6.ogg b/modular_doppler/cassettes/sound/tape_dump6.ogg new file mode 100644 index 00000000000000..22e8ae6334ef2b Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_dump6.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_dump7.ogg b/modular_doppler/cassettes/sound/tape_dump7.ogg new file mode 100644 index 00000000000000..339fa986ace67e Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_dump7.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_dump8.ogg b/modular_doppler/cassettes/sound/tape_dump8.ogg new file mode 100644 index 00000000000000..1d435b85794959 Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_dump8.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_dump9.ogg b/modular_doppler/cassettes/sound/tape_dump9.ogg new file mode 100644 index 00000000000000..651e64ced1d328 Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_dump9.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_put_in1.ogg b/modular_doppler/cassettes/sound/tape_put_in1.ogg new file mode 100644 index 00000000000000..ceb4663dd98d80 Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_put_in1.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_put_in2.ogg b/modular_doppler/cassettes/sound/tape_put_in2.ogg new file mode 100644 index 00000000000000..966ca3d4841689 Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_put_in2.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_put_in3.ogg b/modular_doppler/cassettes/sound/tape_put_in3.ogg new file mode 100644 index 00000000000000..a948666137d37c Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_put_in3.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_put_in4.ogg b/modular_doppler/cassettes/sound/tape_put_in4.ogg new file mode 100644 index 00000000000000..7ce53b1a44c408 Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_put_in4.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_put_in5.ogg b/modular_doppler/cassettes/sound/tape_put_in5.ogg new file mode 100644 index 00000000000000..bda88f24400ed8 Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_put_in5.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_put_in6.ogg b/modular_doppler/cassettes/sound/tape_put_in6.ogg new file mode 100644 index 00000000000000..7ca96424cf2bb7 Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_put_in6.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_take_out1.ogg b/modular_doppler/cassettes/sound/tape_take_out1.ogg new file mode 100644 index 00000000000000..f8e1810083a930 Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_take_out1.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_take_out2.ogg b/modular_doppler/cassettes/sound/tape_take_out2.ogg new file mode 100644 index 00000000000000..db5d869d0b2b96 Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_take_out2.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_take_out3.ogg b/modular_doppler/cassettes/sound/tape_take_out3.ogg new file mode 100644 index 00000000000000..b383ee61ea78bf Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_take_out3.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_take_out4.ogg b/modular_doppler/cassettes/sound/tape_take_out4.ogg new file mode 100644 index 00000000000000..b73e37fbcb5a13 Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_take_out4.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_take_out5.ogg b/modular_doppler/cassettes/sound/tape_take_out5.ogg new file mode 100644 index 00000000000000..d011b26c0de6e0 Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_take_out5.ogg differ diff --git a/modular_doppler/cassettes/sound/tape_take_out6.ogg b/modular_doppler/cassettes/sound/tape_take_out6.ogg new file mode 100644 index 00000000000000..af59bc831de781 Binary files /dev/null and b/modular_doppler/cassettes/sound/tape_take_out6.ogg differ diff --git a/tgstation.dme b/tgstation.dme index d623063ff4d1e0..4103ab099bcd9e 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -420,6 +420,7 @@ #include "code\__DEFINES\~doppler_defines\automapper.dm" #include "code\__DEFINES\~doppler_defines\banning.dm" #include "code\__DEFINES\~doppler_defines\bodyparts.dm" +#include "code\__DEFINES\~doppler_defines\cassettes.dm" #include "code\__DEFINES\~doppler_defines\cells.dm" #include "code\__DEFINES\~doppler_defines\charging_stomach.dm" #include "code\__DEFINES\~doppler_defines\colony_machine.dm" @@ -440,6 +441,7 @@ #include "code\__DEFINES\~doppler_defines\loadout.dm" #include "code\__DEFINES\~doppler_defines\logging.dm" #include "code\__DEFINES\~doppler_defines\manufacturer_strings.dm" +#include "code\__DEFINES\~doppler_defines\misc.dm" #include "code\__DEFINES\~doppler_defines\mobs.dm" #include "code\__DEFINES\~doppler_defines\modlinks.dm" #include "code\__DEFINES\~doppler_defines\mutant_blacklists.dm" @@ -451,6 +453,7 @@ #include "code\__DEFINES\~doppler_defines\preferences.dm" #include "code\__DEFINES\~doppler_defines\projectiles.dm" #include "code\__DEFINES\~doppler_defines\quirks.dm" +#include "code\__DEFINES\~doppler_defines\radio.dm" #include "code\__DEFINES\~doppler_defines\reagent_forging_tools.dm" #include "code\__DEFINES\~doppler_defines\reagents.dm" #include "code\__DEFINES\~doppler_defines\reskin_defines.dm" @@ -594,6 +597,7 @@ #include "code\__HELPERS\~doppler_helpers\global_lists.dm" #include "code\__HELPERS\~doppler_helpers\logging.dm" #include "code\__HELPERS\~doppler_helpers\mobs.dm" +#include "code\__HELPERS\~doppler_helpers\text.dm" #include "code\__HELPERS\~doppler_helpers\verbs.dm" #include "code\_globalvars\_regexes.dm" #include "code\_globalvars\admin.dm" @@ -6849,6 +6853,37 @@ #include "modular_doppler\bitrunning_prefs_disks\code\outfit_overrides\bitrunner_outfit_override.dm" #include "modular_doppler\carp_infusion\code\carp_knife.dm" #include "modular_doppler\carp_infusion\code\carp_organs.dm" +#include "modular_doppler\cassettes\code\communications.dm" +#include "modular_doppler\cassettes\code\controllers\general.dm" +#include "modular_doppler\cassettes\code\datums\_looping_sound.dm" +#include "modular_doppler\cassettes\code\datums\cassette.dm" +#include "modular_doppler\cassettes\code\modules\admin\verbs\spawn_mixtape.dm" +#include "modular_doppler\cassettes\code\modules\assets\cassettes.dm" +#include "modular_doppler\cassettes\code\modules\cassette\cassette.dm" +#include "modular_doppler\cassettes\code\modules\cassette\cassette_approval.dm" +#include "modular_doppler\cassettes\code\modules\cassette\mob_can_hear.dm" +#include "modular_doppler\cassettes\code\modules\cassette\random_cassette_collection.dm" +#include "modular_doppler\cassettes\code\modules\cassette\cassette_db\cassette_datum.dm" +#include "modular_doppler\cassettes\code\modules\cassette\cassette_db\cassette_manager.dm" +#include "modular_doppler\cassettes\code\modules\cassette\cassette_db\sound_dependencies\sound_channels.dm" +#include "modular_doppler\cassettes\code\modules\cassette\machines\cassette_rack.dm" +#include "modular_doppler\cassettes\code\modules\cassette\machines\dj_station.dm" +#include "modular_doppler\cassettes\code\modules\cassette\machines\postbox.dm" +#include "modular_doppler\cassettes\code\modules\cassette\machines\radio_mic.dm" +#include "modular_doppler\cassettes\code\modules\cassette\machines\stationary_mixer.dm" +#include "modular_doppler\cassettes\code\modules\cassette\media\__base_machine.dm" +#include "modular_doppler\cassettes\code\modules\cassette\media\_media_source.dm" +#include "modular_doppler\cassettes\code\modules\cassette\media\HTML5_player.dm" +#include "modular_doppler\cassettes\code\modules\cassette\media\media_manager.dm" +#include "modular_doppler\cassettes\code\modules\cassette\media\media_mob_helpers.dm" +#include "modular_doppler\cassettes\code\modules\cassette\media\media_player.dm" +#include "modular_doppler\cassettes\code\modules\cassette\media\media_track.dm" +#include "modular_doppler\cassettes\code\modules\cassette\media\media_track_manager.dm" +#include "modular_doppler\cassettes\code\modules\cassette\media\prefs.dm" +#include "modular_doppler\cassettes\code\modules\cassette\walkman\_walkmen.dm" +#include "modular_doppler\cassettes\code\modules\mob\human\dummy.dm" +#include "modular_doppler\cassettes\code\objects\bags.dm" +#include "modular_doppler\cassettes\code\objects\implant_misc.dm" #include "modular_doppler\cell_component\code\cell_component.dm" #include "modular_doppler\chamtoggle\code\datums\mutations\chameleon.dm" #include "modular_doppler\chatroom_soul\code\chatroom_keybinding.dm" diff --git a/tgui/packages/common/other.ts b/tgui/packages/common/other.ts new file mode 100644 index 00000000000000..beb68c4db86ca6 --- /dev/null +++ b/tgui/packages/common/other.ts @@ -0,0 +1,148 @@ +import { BandcampImageSize } from './types'; + +export const getThumbnailUrl = async ( + inputUrl: string, +): Promise => { + if (!inputUrl) return null; + inputUrl = normalizeTrackUrl(inputUrl); + + const cacheKey = `thumb:${inputUrl}`; + const cached = localStorage.getItem(cacheKey); + if (cached !== null) { + return cached === 'null' ? null : cached; + } + + try { + const url = new URL(inputUrl); + let thumbnail: string | null = null; + + // YouTube watch?v=... + if (url.hostname.includes('youtube.com') && url.searchParams.has('v')) { + const id = url.searchParams.get('v'); + thumbnail = `https://img.youtube.com/vi/${id}/hqdefault.jpg`; + } + + // YouTube short link (i sure hope you're not putting youtube short links in your cassettes) + else if (url.hostname === 'youtu.be') { + const id = url.pathname.slice(1); + thumbnail = `https://img.youtube.com/vi/${id}/hqdefault.jpg`; + } + + // soundcloud + else if (url.hostname.includes('soundcloud.com')) { + const clientId = 'AZQrFd27PgXn40c5dbumOYPFBlIRBVbu'; + const apiUrl = `https://api-v2.soundcloud.com/resolve?client_id=${clientId}&url=${encodeURIComponent( + inputUrl, + )}`; + const res = await fetch(apiUrl); + if (res.ok) { + const data = await res.json(); + thumbnail = data.artwork_url || data.user?.avatar_url || null; + } + } + + // Bandcamp + else if (url.hostname.includes('bandcamp.com')) { + const dommy = await getDocumentForURL(url); + if (dommy) { + const firstImg = dommy.querySelector( + "link[rel='shortcut icon']", + ) as HTMLLinkElement; + if (firstImg) { + const imgId = firstImg.href.match(/img\/(.+)_.*\./)?.[1]; + if (imgId) { + thumbnail = getBandcampThumbnailUrl( + imgId, + BandcampImageSize.JPEG_350, + ); + } + } + } + } + + localStorage.setItem(cacheKey, thumbnail ?? 'null'); + + return thumbnail; + } catch { + localStorage.setItem(cacheKey, 'null'); + return null; + } +}; + +export const getDocumentForURL = async (url: URL): Promise => { + try { + const response = await fetch(url); + const htmlString = await response.text(); + + if (htmlString) { + const parser = new DOMParser(); + const doc = parser.parseFromString(htmlString, 'text/html'); + return doc; + } else { + console.log('No HTML content in the response body.'); + return null; + } + } catch (error) { + console.error('Error during fetch or parsing:', error); + return null; + } +}; + +const getBandcampThumbnailUrl = (imgId: string, type: BandcampImageSize) => { + return `https://f4.bcbits.com/img/${imgId}_${type}.jpg`; +}; + +const normalizeTrackUrl = (inputUrl) => { + try { + const url = new URL(inputUrl); + + url.hash = ''; + + // keep only ?v=videoid + if (url.hostname.includes('youtube.com')) { + const videoId = url.searchParams.get('v'); + url.search = ''; + if (videoId) { + url.searchParams.set('v', videoId); + } + return url.toString(); + } + + // shortform, https://youtu.be/videoid, keep path only? + if (url.hostname === 'youtu.be') { + return `https://youtu.be${url.pathname}`; + } + + if (url.hostname.includes('soundcloud.com')) { + url.search = ''; + return url.toString(); + } + + if (url.hostname.includes('bandcamp.com')) { + url.search = ''; + return url.toString(); + } + + const trackingParams = [ + /^utm_/i, + /^fbclid$/i, + /^gclid$/i, + /^mc_cid$/i, + /^mc_eid$/i, + ]; + for (const [key] of url.searchParams) { + if (trackingParams.some((p) => p.test(key))) { + url.searchParams.delete(key); + } + } + + return url.toString(); + } catch { + return inputUrl; + } +}; + +const keyReplacePattern = /[^\da-z]/g; +export const ckey = (key: string): string => { + return key.toLowerCase().replaceAll(keyReplacePattern, '').trim(); +}; diff --git a/tgui/packages/common/types.ts b/tgui/packages/common/types.ts new file mode 100644 index 00000000000000..a88e917f8c55ef --- /dev/null +++ b/tgui/packages/common/types.ts @@ -0,0 +1,29 @@ +/** + * Returns the arguments of a function F as an array. + */ +// prettier-ignore +export type ArgumentsOf + = F extends (...args: infer A) => unknown ? A : never; + +export enum BandcampImageSize { + PNG_2000_8BIT = 0, + PNG_2000_16BIT, + JPEG_350, + JPEG_100, + JPEG_400, + JPEG_700, + JPEG_100_1, + JPEG_150, + JPEG_124, + JPEG_210, + JPEG_1200, + JPEG_172, + JPEG_138, + JPEG_380, + JPEG_368, + JPEG_135, + JPEG_700_1, + JPEG_1024 = 21, + PNG_1024 = 31, + PNG_LARGE_MIDDLECROPPED = 100, +} diff --git a/tgui/packages/tgui-panel/audio/player.ts b/tgui/packages/tgui-panel/audio/player.ts index d82484709df98f..6ebb44d3496f03 100644 --- a/tgui/packages/tgui-panel/audio/player.ts +++ b/tgui/packages/tgui-panel/audio/player.ts @@ -59,6 +59,10 @@ export class AudioPlayer { audio.volume = this.volume; audio.playbackRate = this.options.pitch || 1; + if (this.options.start) { + audio.currentTime = this.options.start; + } + logger.log('playing', url, options); audio.addEventListener('ended', () => { diff --git a/tgui/packages/tgui-panel/styles/tgchat/chat-dark.scss b/tgui/packages/tgui-panel/styles/tgchat/chat-dark.scss index 65499f2f53ce28..b5a0c4167bacb4 100644 --- a/tgui/packages/tgui-panel/styles/tgchat/chat-dark.scss +++ b/tgui/packages/tgui-panel/styles/tgchat/chat-dark.scss @@ -403,6 +403,10 @@ em { color: hsl(60, 98%, 59.8%) !important; } +.radioradio { + color: #ffc0cb !important; +} + .captaincast { color: hsl(157.1, 39.6%, 62.4%); } diff --git a/tgui/packages/tgui-panel/styles/tgchat/chat-light.scss b/tgui/packages/tgui-panel/styles/tgchat/chat-light.scss index 0bc335dcf28a21..f7176ee82235d4 100644 --- a/tgui/packages/tgui-panel/styles/tgchat/chat-light.scss +++ b/tgui/packages/tgui-panel/styles/tgchat/chat-light.scss @@ -410,6 +410,10 @@ em { color: hsl(52.1, 72%, 47.6%) !important; } +.radioradio { + color: #ffc0cb !important; +} + .captaincast { color: hsl(157.1, 39.6%, 62.4%); } diff --git a/tgui/packages/tgui/constants.ts b/tgui/packages/tgui/constants.ts index 1b55c62c9458a2..4835125f0c4b51 100644 --- a/tgui/packages/tgui/constants.ts +++ b/tgui/packages/tgui/constants.ts @@ -163,6 +163,11 @@ export const RADIO_CHANNELS = [ freq: 1459, color: '#1ecc43', }, + { + name: 'Radio', + freq: 1443, + color: '#FFC0CB', + }, ] as const; const GASES = [ diff --git a/tgui/packages/tgui/interfaces/CassetteDeck.tsx b/tgui/packages/tgui/interfaces/CassetteDeck.tsx new file mode 100644 index 00000000000000..0403044de0fcd7 --- /dev/null +++ b/tgui/packages/tgui/interfaces/CassetteDeck.tsx @@ -0,0 +1,620 @@ +import { sortBy } from 'es-toolkit'; +import { + Box, + Button, + DmIcon, + Dropdown, + Input, + Knob, + LabeledList, + Section, + Stack, +} from 'tgui-core/components'; +import { flow } from 'tgui-core/fp'; +import { useBackend, useLocalState } from '../backend'; +import { Window } from '../layouts'; + +const panelStyle = { + 'background-color': '#2e2e2e', + border: '2px solid #555', + 'box-shadow': '0 0 10px #000000a0', + padding: '0.75em', +}; + +const moduleStyle = { + border: '1px solid #1a1a1a', + 'background-color': '#3a3a3a', + padding: '0.5em', + 'margin-bottom': '0.75em', + 'box-shadow': 'inset 1px 1px 3px #000000c0', + 'border-radius': '3px', +}; + +const controlLabelStyle = { + 'font-size': '0.7em', + 'text-align': 'center', + color: '#cccccc', + 'margin-top': '0.2em', + 'font-weight': 'normal', +}; + +const KnobControl = ({ + label, + value, + action, + min = 0, + max = 100, + step = 1, + className, +}) => ( + + {' '} + + {label}{' '} + + {Math.round(value)}{' '} + {' '} + +); + +const ActionButton = ({ + content, + icon, + onClick, + style, + selected = false, + className, +}) => ( + +); + +type CassetteDeckData = { + active: boolean; + track_selected: string; + songs: Array; + // Add any other properties you expect from the backend here +}; + +export const CassetteDeck = (props) => { + const { act, data } = useBackend(); + const { active, track_selected, songs: raw_songs } = data; + + const [selectedDesign, setSelectedDesign] = useLocalState( + 'cassette_design', + 'TYPE IV (Standard)', + ); + + const [selectedAge, setSelectedAge] = useLocalState('cassette_age', 50); + + const [selectedSticker, setSelectedSticker] = useLocalState( + 'cassette_sticker', + 'Default', + ); + + const songs = (raw_songs || []) + .slice() + .sort((a, b) => a.name.localeCompare(b.name)); + + const cassetteTypes = [ + { label: 'TYPE I', value: 'TYPE I', sub: 'CHEAP' }, + { label: 'TYPE II', value: 'TYPE II', sub: 'VALUE' }, + { label: 'TYPE III', value: 'TYPE III', sub: 'STANDARD' }, + { label: 'TYPE IV', value: 'TYPE IV', sub: 'STANDARD' }, + { label: 'TYPE V', value: 'TYPE V', sub: 'STANDARD' }, + { label: 'BYPASS', value: 'BYPASS (Master)', sub: 'MASTER' }, + ].reverse(); + + const stickerDesigns = [ + 'Default', + 'Skull', + 'Fire', + 'Heart', + 'Mix Tape', + 'Cool Cat', + ]; + + return ( + + {' '} + + {' '} + + {' '} + + {' '} + + {' '} + + {' '} + + {' '} + {cassetteTypes.map((type) => { + const isSelected = selectedDesign === type.value; + return ( + + act('set_design', { design: type.value }) + } + > + {' '} + + {' '} + + {type.label}{' '} + {' '} + + {type.sub} + {' '} + {' '} + + ); + })}{' '} + {' '} + {' '} + + {' '} + + {' '} + + {' '} + {' '} + {' '} + {' '} + + {' '} + + {' '} + + {' '} + + {' '} + + {' '} + NEW{' '} + + {' '} + { + setSelectedAge(Number(value)); + act('set_age', { age: Number(value) }); + }} + />{' '} + {' '} + + WORN{' '} + {' '} + {' '} + {' '} + {' '} + {' '} + {' '} + {' '} + {' '} + + {' '} + + {' '} + + {' '} + + {' '} + { + setSelectedSticker(value); + act('select_sticker', { sticker: value }); + }} + />{' '} + {' '} + {' '} + {' '} + {' '} + + {' '} + + {' '} + {' '} + {' '} + {' '} + {' '} + {' '} + {' '} + + {' '} + + {' '} + + {' '} + + {' '} + + IN{' '} + {' '} + + {' '} + + {' '} + + {' '} + {' '} + {' '} + {' '} + {' '} + {' '} + + OUT{' '} + {' '} + {' '} + {' '} + {' '} + + {' '} + {['WOW', 'FLUTTER'].map((section) => ( + + {' '} + + {' '} + + {' '} + + {' '} + {' '} + {' '} + {' '} + {' '} + {' '} + + ))}{' '} + {' '} + + {' '} + + {' '} + + {' '} + {' '} + {' '} + {' '} + {' '} + {' '} + {' '} + + {' '} + + {' '} + + {' '} + + {' '} + + {' '} + + {' '} + {' '} + {' '} + {' '} + {' '} + {' '} + {' '} + + {' '} + + {' '} + + {' '} + + {' '} + {' '} + {' '} + {' '} + act('toggle_nr_comp')} + style={{ width: '100%', 'margin-top': '0.5em' }} + />{' '} + {' '} + {' '} + {' '} + {' '} + {' '} + + {' '} + + {' '} + song.name)} + disabled={active} + selected={track_selected || 'Select a Track'} + onSelected={(value) => act('select_track', { track: value })} + />{' '} + act('remove')} + className="mt-1 Button--red" + />{' '} + {' '} + {' '} + + {' '} + + {' '} + act('url', { url: value })} + />{' '} + act('url')} + className="mt-1 Button--green" + />{' '} + {' '} + {' '} + act('eject')} + className="mt-2 Button--orange" + style={{ width: '100%', 'font-weight': 'bold', padding: '0.5em' }} + />{' '} + {' '} + {' '} + {' '} + + ); +}; diff --git a/tgui/packages/tgui/interfaces/CassetteReview.tsx b/tgui/packages/tgui/interfaces/CassetteReview.tsx new file mode 100644 index 00000000000000..f5f36b8268dfda --- /dev/null +++ b/tgui/packages/tgui/interfaces/CassetteReview.tsx @@ -0,0 +1,129 @@ +import { Box, Button, Collapsible, Section, Stack } from 'tgui-core/components'; +import type { BooleanLike } from 'tgui-core/react'; +import { useBackend } from '../backend'; +import { Window } from '../layouts'; + +enum CassetteStatus { + Unapproved = 0, + Reviewing = 1, + Approved = 2, + Denied = 3, +} + +type CassetteTrack = { + title: string; + url: string; + duration: number; // deciseconds + artist?: string; + album?: string; +}; + +type CassetteSide = { + icon: string; + tracks: CassetteTrack[]; +}; + +type Cassette = { + id: string; + name: string; + desc: string; + author_ckey: string; + author_name: string; + status: CassetteStatus; + songs: { + side1?: CassetteSide; + side2?: CassetteSide; + }; +}; + +type Data = { + cassette: Cassette; + can_approve: BooleanLike; +}; + +export const CassetteReview = () => { + const { + act, + data: { cassette, can_approve }, + } = useBackend(); + + return ( + + + + + + + Author ckey: {cassette.author_ckey} + + Author character: {cassette.author_name} + + + Current Status:{' '} + {cassette.status === CassetteStatus.Approved ? ( + + APPROVED + + ) : cassette.status === CassetteStatus.Denied ? ( + + DENIED + + ) : ( + + Reviewing + + )} + + {[cassette.songs.side1, cassette.songs.side2].map( + (side, idx) => ( + + + + {side?.tracks.map((song, i) => ( + + + {song.title} + + + + + + + + ))} + + + + ), + )} + + + + + {can_approve ? ( + + + act('approve')}>Approve + + + act('deny')}>Deny + + + ) : ( + + Not enough permissions to approve/deny cassettes. + + )} + + + + + ); +}; diff --git a/tgui/packages/tgui/interfaces/DjStation.tsx b/tgui/packages/tgui/interfaces/DjStation.tsx new file mode 100644 index 00000000000000..dc27b93a799e68 --- /dev/null +++ b/tgui/packages/tgui/interfaces/DjStation.tsx @@ -0,0 +1,324 @@ +import { Component } from 'inferno'; +import { + Box, + Button, + Image, + LabeledList, + ProgressBar, + Section, + Stack, +} from 'tgui-core/components'; +import { formatTime } from 'tgui-core/format'; +import type { BooleanLike } from 'tgui-core/react'; +import { getThumbnailUrl } from '../../common/other'; +import { useBackend } from '../backend'; +import { Window } from '../layouts'; +import { LoadingScreen } from './interfaces/common/LoadingScreen'; + +export enum CassetteDesign { + Flip = 'cassette_flip', + Blue = 'cassette_blue', + Gray = 'cassette_gray', + Green = 'cassette_green', + Orange = 'cassette_orange', + PinkStripe = 'cassette_pink_stripe', + Purple = 'cassette_purple', + Rainbow = 'cassette_rainbow', + RedBlack = 'cassette_red_black', + RedStripe = 'cassette_red_stripe', + Camo = 'cassette_camo', + RisingSun = 'cassette_rising_sun', + OrangeBlue = 'cassette_orange_blue', + Ocean = 'cassette_ocean', + Aesthetic = 'cassette_aesthetic', + Solaris = 'cassette_solaris', + Ice = 'cassette_ice', + Lz = 'cassette_lz', + Dam = 'cassette_dam', + Worstmap = 'cassette_worstmap', + Wy = 'cassette_wy', + Ftl = 'cassette_ftl', + Eighties = 'cassette_eighties', + Synth = 'cassette_synth', + WhiteStripe = 'cassette_white_stripe', + Friday = 'cassette_friday', +} + +type Song = { + name: string; + url: string; + length: number; // in deciseconds + artist?: string; + album?: string; +}; + +type Cassette = { + name: string; + desc: string; + author: string; + design: CassetteDesign; + songs: Song[]; +}; + +enum CassetteSide { + A = 0, + B, +} + +type Data = { + broadcasting: BooleanLike; + song_cooldown: number; + progress: number; + cassette: Cassette; + side: CassetteSide; + current_song: number; + switching_tracks: BooleanLike; +}; + +class Controls extends Component<{ data: Data }> { + state: { + thumbnailUrl: string | null; + }; + constructor(props: { data: Data }) { + super(props); + this.state = { thumbnailUrl: null }; + } + + componentDidMount() { + this.fetchThumbnail(); + } + + componentDidUpdate(prevProps: { data: Data }) { + const { current_song, cassette } = this.props.data; + const { current_song: prev_current_song, cassette: prev_cassette } = + prevProps.data; + + if ( + getSong(current_song, cassette)?.url !== + getSong(prev_current_song, prev_cassette)?.url + ) { + this.fetchThumbnail(); + } + } + + private fetchToken = 0; + + async fetchThumbnail() { + const token = ++this.fetchToken; + const { current_song: current_songId } = this.props.data; + if (current_songId === null) return this.setState({ thumbnailUrl: null }); + const current_song = getSong(current_songId, this.props.data.cassette); + if (!current_song?.url) return this.setState({ thumbnailUrl: null }); + const thumb = await getThumbnailUrl(current_song.url); + if (token === this.fetchToken) { + this.setState({ thumbnailUrl: thumb }); + } + } + + render() { + const { act } = useBackend(); + const { + progress, + broadcasting, + current_song: current_songId, + song_cooldown, + } = this.props.data; + const cassette = this.props.data?.cassette; + + const current_song = getSong(current_songId, cassette); + + const { thumbnailUrl } = this.state; + if (current_song === null && thumbnailUrl !== null) { + this.setState({ thumbnailUrl: null }); + } + + return ( + + + + + {current_song?.name || 'N/A'} + + {current_song?.artist && ( + + {current_song.artist} + + )} + {current_song?.album && ( + + {current_song.album} + + )} + + act('play')} + tooltip={ + broadcasting + ? null + : song_cooldown + ? `The DJ station needs time to cool down after playing the last song. Time left: ${formatTime(song_cooldown, 'short')}` + : null + } + > + Play + + act('stop')} + > + Stop + + + + + {broadcasting && current_song + ? `${formatTime(progress * current_song.length, 'short')} / ${formatTime( + current_song.length, + 'short', + )}` + : 'N/A'} + + + + + + {thumbnailUrl && ( + + + + )} + + + ); + } +} + +const AvailableTracks = ({ + songs, + currentSong, +}: { + songs: Song[]; + currentSong: Song | null; +}) => { + const { act } = useBackend(); + + return ( + + {songs.map((song, i) => ( + + + + act('set_track', { index: i })} + > + {song.name} + + + + + + + + + + ))} + + ); +}; + +export const DjStation = () => { + const { act, data } = useBackend(); + const { side, cassette } = data; + const songs = cassette?.songs ?? []; + + const currentSong = getSong(data.current_song, cassette); + + return ( + + + {!!data.switching_tracks && ( + + )} + + + + act('eject')}> + Eject + + } + > + + + {cassette?.author || 'Unknown'} + + + {cassette?.desc || 'No description'} + + + {songs.length} + + + {songs.length + ? formatTime( + songs.reduce((sum, s) => sum + s.length, 0), + 'default', + ) + : 'N/A'} + + + + + {songs?.length ? ( + + ) : ( + + {cassette ? 'No songs on this side.' : 'No tape inserted'} + + )} + + + + + + + + + + + + ); +}; + +const getSong = (index: number, cassette?: Cassette): Song | null => { + return cassette ? cassette.songs[index] : null; +}; diff --git a/tgui/packages/tgui/interfaces/MediaJukebox.js b/tgui/packages/tgui/interfaces/MediaJukebox.js new file mode 100644 index 00000000000000..2e065a57e59737 --- /dev/null +++ b/tgui/packages/tgui/interfaces/MediaJukebox.js @@ -0,0 +1,137 @@ +import { round } from 'common/math'; +import { useBackend } from '../backend'; +import { Box, Button, Collapsible, LabeledList, ProgressBar, Section, Slider } from '../components'; +import { Window } from '../layouts'; + +export const MediaJukebox = (props, context) => { + const { act, data } = useBackend(context); + + const { + playing, + loop_mode, + volume, + current_track_ref, + current_track, + current_genre, + percent, + tracks, + } = data; + + let genre_songs = + tracks.length && + tracks.reduce((acc, obj) => { + let key = obj.genre || 'Uncategorized'; + if (!acc[key]) { + acc[key] = []; + } + acc[key].push(obj); + return acc; + }, {}); + + let true_genre = playing && (current_genre || 'Uncategorized'); + + return ( + + + + + + {(playing && current_track && ( + + {current_track.title} by {current_track.artist || 'Unkown'} + + )) || Stopped} + + + act('play')}> + Play + + act('stop')}> + Stop + + + + act('loopmode', { loopmode: 1 })} + selected={loop_mode === 1}> + Next + + act('loopmode', { loopmode: 2 })} + selected={loop_mode === 2}> + Shuffle + + act('loopmode', { loopmode: 3 })} + selected={loop_mode === 3}> + Repeat + + act('loopmode', { loopmode: 4 })} + selected={loop_mode === 4}> + Once + + + + + + + round(val, 1) + '%'} + onChange={(e, val) => + act('volume', { val: round(val / 100, 2) }) + } + /> + + + + + {(tracks.length && + Object.keys(genre_songs) + .sort() + .map((genre) => ( + + + {genre_songs[genre].map((track) => ( + + act('change_track', { change_track: track.ref }) + }> + {track.title} + + ))} + + + ))) || Error: No songs loaded.} + + + + ); +}; diff --git a/tgui/packages/tgui/interfaces/MixtapeSpawner.tsx b/tgui/packages/tgui/interfaces/MixtapeSpawner.tsx new file mode 100644 index 00000000000000..3b3529502bdeb9 --- /dev/null +++ b/tgui/packages/tgui/interfaces/MixtapeSpawner.tsx @@ -0,0 +1,324 @@ +import { useBackend } from '../backend'; +import { + Box, + Button, + Image, + LabeledList, + ProgressBar, + Section, + Stack, +} from '../components'; +import { formatTime } from '../format'; +import { Window } from '../layouts'; +import { getThumbnailUrl } from '../../common/other'; +import { Component } from 'inferno'; +import { BooleanLike } from 'common/react'; +import { LoadingScreen } from './common/LoadingToolbox'; + +export enum CassetteDesign { + Flip = 'cassette_flip', + Blue = 'cassette_blue', + Gray = 'cassette_gray', + Green = 'cassette_green', + Orange = 'cassette_orange', + PinkStripe = 'cassette_pink_stripe', + Purple = 'cassette_purple', + Rainbow = 'cassette_rainbow', + RedBlack = 'cassette_red_black', + RedStripe = 'cassette_red_stripe', + Camo = 'cassette_camo', + RisingSun = 'cassette_rising_sun', + OrangeBlue = 'cassette_orange_blue', + Ocean = 'cassette_ocean', + Aesthetic = 'cassette_aesthetic', + Solaris = 'cassette_solaris', + Ice = 'cassette_ice', + Lz = 'cassette_lz', + Dam = 'cassette_dam', + Worstmap = 'cassette_worstmap', + Wy = 'cassette_wy', + Ftl = 'cassette_ftl', + Eighties = 'cassette_eighties', + Synth = 'cassette_synth', + WhiteStripe = 'cassette_white_stripe', + Friday = 'cassette_friday', +} + +type Song = { + name: string; + url: string; + length: number; // in deciseconds + artist?: string; + album?: string; +}; + +type Cassette = { + name: string; + desc: string; + author: string; + design: CassetteDesign; + songs: Song[]; +}; + +enum CassetteSide { + A = 0, + B, +} + +type Data = { + broadcasting: BooleanLike; + song_cooldown: number; + progress: number; + cassette: Cassette; + side: CassetteSide; + current_song: number; + switching_tracks: BooleanLike; +}; + +class Controls extends Component<{ data: Data }> { + state: { + thumbnailUrl: string | null; + }; + constructor(props: { data: Data }) { + super(props); + this.state = { thumbnailUrl: null }; + } + + componentDidMount() { + this.fetchThumbnail(); + } + + componentDidUpdate(prevProps: { data: Data }) { + const { current_song, cassette } = this.props.data; + const { current_song: prev_current_song, cassette: prev_cassette } = + prevProps.data; + + if ( + getSong(current_song, cassette)?.url !== + getSong(prev_current_song, prev_cassette)?.url + ) { + this.fetchThumbnail(); + } + } + + private fetchToken = 0; + + async fetchThumbnail() { + const token = ++this.fetchToken; + const { current_song: current_songId } = this.props.data; + if (current_songId === null) return this.setState({ thumbnailUrl: null }); + const current_song = getSong(current_songId, this.props.data.cassette); + if (!current_song?.url) return this.setState({ thumbnailUrl: null }); + const thumb = await getThumbnailUrl(current_song.url); + if (token === this.fetchToken) { + this.setState({ thumbnailUrl: thumb }); + } + } + + render() { + const { act } = useBackend(); + const { + progress, + broadcasting, + current_song: current_songId, + song_cooldown, + } = this.props.data; + const cassette = this.props.data?.cassette; + + const current_song = getSong(current_songId, cassette); + + const { thumbnailUrl } = this.state; + if (current_song === null && thumbnailUrl !== null) { + this.setState({ thumbnailUrl: null }); + } + + return ( + + + + + {current_song?.name || 'N/A'} + + {current_song?.artist && ( + + {current_song.artist} + + )} + {current_song?.album && ( + + {current_song.album} + + )} + + act('play')} + tooltip={ + broadcasting + ? null + : song_cooldown + ? `The DJ station needs time to cool down after playing the last song. Time left: ${formatTime(song_cooldown, 'short')}` + : null + } + > + Play + + act('stop')} + > + Stop + + + + + {broadcasting && current_song + ? `${formatTime(progress * current_song.length, 'short')} / ${formatTime( + current_song.length, + 'short', + )}` + : 'N/A'} + + + + + + {thumbnailUrl && ( + + + + )} + + + ); + } +} + +const AvailableTracks = ({ + songs, + currentSong, +}: { + songs: Song[]; + currentSong: Song | null; +}) => { + const { act } = useBackend(); + + return ( + + {songs.map((song, i) => ( + + + + act('set_track', { index: i })} + > + {song.name} + + + + + + + + + + ))} + + ); +}; + +export const DjStation = () => { + const { act, data } = useBackend(); + const { side, cassette } = data; + const songs = cassette?.songs ?? []; + + const currentSong = getSong(data.current_song, cassette); + + return ( + + + {!!data.switching_tracks && ( + + )} + + + + act('eject')}> + Eject + + } + > + + + {cassette?.author || 'Unknown'} + + + {cassette?.desc || 'No description'} + + + {songs.length} + + + {songs.length + ? formatTime( + songs.reduce((sum, s) => sum + s.length, 0), + 'default', + ) + : 'N/A'} + + + + + {songs?.length ? ( + + ) : ( + + {cassette ? 'No songs on this side.' : 'No tape inserted'} + + )} + + + + + + + + + + + + ); +}; + +const getSong = (index: number, cassette?: Cassette): Song | null => { + return cassette ? cassette.songs[index] : null; +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/hearmusic.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/hearmusic.tsx new file mode 100644 index 00000000000000..b44ebee2425ed8 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/hearmusic.tsx @@ -0,0 +1,8 @@ +import { CheckboxInput, FeatureToggle } from '../base'; + +export const hearmusic: FeatureToggle = { + name: 'Hear In-Game Music', + category: 'SOUND', + description: 'Hear In Game Music From Jukeboxes or Radios', + component: CheckboxInput, +}; diff --git a/tgui/tsconfig.json b/tgui/tsconfig.json index 5fee13f87361e0..1ba413bd46c24b 100644 --- a/tgui/tsconfig.json +++ b/tgui/tsconfig.json @@ -15,7 +15,7 @@ "skipLibCheck": true, "strict": false, "strictNullChecks": true, - "target": "ESNext" + "target": "ES2015" // ESNext }, "include": ["./*.d.ts", "./packages"] }