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