diff --git a/Content.Client/Audio/Jukebox/JukeboxBoundUserInterface.cs b/Content.Client/Audio/Jukebox/JukeboxBoundUserInterface.cs
new file mode 100644
index 0000000000..072730d65d
--- /dev/null
+++ b/Content.Client/Audio/Jukebox/JukeboxBoundUserInterface.cs
@@ -0,0 +1,119 @@
+using Content.Shared.Audio.Jukebox;
+using Robust.Client.Audio;
+using Robust.Client.Player;
+using Robust.Shared.Audio.Components;
+using Robust.Shared.Player;
+using Robust.Shared.Prototypes;
+
+namespace Content.Client.Audio.Jukebox;
+
+public sealed class JukeboxBoundUserInterface : BoundUserInterface
+{
+ [Dependency] private readonly IPlayerManager _player = default!;
+ [Dependency] private readonly IPrototypeManager _protoManager = default!;
+
+ [ViewVariables]
+ private JukeboxMenu? _menu;
+
+ public JukeboxBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey)
+ {
+ IoCManager.InjectDependencies(this);
+ }
+
+ protected override void Open()
+ {
+ base.Open();
+
+ _menu = new JukeboxMenu();
+ _menu.OnClose += Close;
+ _menu.OpenCentered();
+
+ _menu.OnPlayPressed += args =>
+ {
+ if (args)
+ {
+ SendMessage(new JukeboxPlayingMessage());
+ }
+ else
+ {
+ SendMessage(new JukeboxPauseMessage());
+ }
+ };
+
+ _menu.OnStopPressed += () =>
+ {
+ SendMessage(new JukeboxStopMessage());
+ };
+
+ _menu.OnSongSelected += SelectSong;
+
+ _menu.SetTime += SetTime;
+ PopulateMusic();
+ Reload();
+ }
+
+ ///
+ /// Reloads the attached menu if it exists.
+ ///
+ public void Reload()
+ {
+ if (_menu == null || !EntMan.TryGetComponent(Owner, out JukeboxComponent? jukebox))
+ return;
+
+ _menu.SetAudioStream(jukebox.AudioStream);
+
+ if (_protoManager.TryIndex(jukebox.SelectedSongId, out var songProto))
+ {
+ var length = EntMan.System().GetAudioLength(songProto.Path.Path.ToString());
+ _menu.SetSelectedSong(songProto.Name, (float) length.TotalSeconds);
+ }
+ else
+ {
+ _menu.SetSelectedSong(string.Empty, 0f);
+ }
+ }
+
+ public void PopulateMusic()
+ {
+ _menu?.Populate(_protoManager.EnumeratePrototypes());
+ }
+
+ public void SelectSong(ProtoId songid)
+ {
+ SendMessage(new JukeboxSelectedMessage(songid));
+ }
+
+ public void SetTime(float time)
+ {
+ var sentTime = time;
+
+ // You may be wondering, what the fuck is this
+ // Well we want to be able to predict the playback slider change, of which there are many ways to do it
+ // We can't just use SendPredictedMessage because it will reset every tick and audio updates every frame
+ // so it will go BRRRRT
+ // Using ping gets us close enough that it SHOULD, MOST OF THE TIME, fall within the 0.1 second tolerance
+ // that's still on engine so our playback position never gets corrected.
+ if (EntMan.TryGetComponent(Owner, out JukeboxComponent? jukebox) &&
+ EntMan.TryGetComponent(jukebox.AudioStream, out AudioComponent? audioComp))
+ {
+ audioComp.PlaybackPosition = time;
+ }
+
+ SendMessage(new JukeboxSetTimeMessage(sentTime));
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+ if (!disposing)
+ return;
+
+ if (_menu == null)
+ return;
+
+ _menu.OnClose -= Close;
+ _menu.Dispose();
+ _menu = null;
+ }
+}
+
diff --git a/Content.Client/Audio/Jukebox/JukeboxMenu.xaml b/Content.Client/Audio/Jukebox/JukeboxMenu.xaml
new file mode 100644
index 0000000000..e8d39a9b11
--- /dev/null
+++ b/Content.Client/Audio/Jukebox/JukeboxMenu.xaml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/Audio/Jukebox/JukeboxMenu.xaml.cs b/Content.Client/Audio/Jukebox/JukeboxMenu.xaml.cs
new file mode 100644
index 0000000000..e0904eece8
--- /dev/null
+++ b/Content.Client/Audio/Jukebox/JukeboxMenu.xaml.cs
@@ -0,0 +1,166 @@
+using Content.Shared.Audio.Jukebox;
+using Robust.Client.Audio;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Audio.Components;
+using Robust.Shared.Input;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Timing;
+using FancyWindow = Content.Client.UserInterface.Controls.FancyWindow;
+
+namespace Content.Client.Audio.Jukebox;
+
+[GenerateTypedNameReferences]
+public sealed partial class JukeboxMenu : FancyWindow
+{
+ [Dependency] private readonly IEntityManager _entManager = default!;
+ private AudioSystem _audioSystem;
+
+ ///
+ /// Are we currently 'playing' or paused for the play / pause button.
+ ///
+ private bool _playState;
+
+ ///
+ /// True if playing, false if paused.
+ ///
+ public event Action? OnPlayPressed;
+ public event Action? OnStopPressed;
+ public event Action>? OnSongSelected;
+ public event Action? SetTime;
+
+ private EntityUid? _audio;
+
+ private float _lockTimer;
+
+ public JukeboxMenu()
+ {
+ RobustXamlLoader.Load(this);
+ IoCManager.InjectDependencies(this);
+ _audioSystem = _entManager.System();
+
+ MusicList.OnItemSelected += args =>
+ {
+ var entry = MusicList[args.ItemIndex];
+
+ if (entry.Metadata is not string juke)
+ return;
+
+ OnSongSelected?.Invoke(juke);
+ };
+
+ PlayButton.OnPressed += args =>
+ {
+ OnPlayPressed?.Invoke(!_playState);
+ };
+
+ StopButton.OnPressed += args =>
+ {
+ OnStopPressed?.Invoke();
+ };
+ PlaybackSlider.OnReleased += PlaybackSliderKeyUp;
+
+ SetPlayPauseButton(_audioSystem.IsPlaying(_audio), force: true);
+ }
+
+ public JukeboxMenu(AudioSystem audioSystem)
+ {
+ _audioSystem = audioSystem;
+ }
+
+ public void SetAudioStream(EntityUid? audio)
+ {
+ _audio = audio;
+ }
+
+ private void PlaybackSliderKeyUp(Slider args)
+ {
+ SetTime?.Invoke(PlaybackSlider.Value);
+ _lockTimer = 0.5f;
+ }
+
+ ///
+ /// Re-populates the list of jukebox prototypes available.
+ ///
+ public void Populate(IEnumerable jukeboxProtos)
+ {
+ MusicList.Clear();
+
+ foreach (var entry in jukeboxProtos)
+ {
+ MusicList.AddItem(entry.Name, metadata: entry.ID);
+ }
+ }
+
+ public void SetPlayPauseButton(bool playing, bool force = false)
+ {
+ if (_playState == playing && !force)
+ return;
+
+ _playState = playing;
+
+ if (playing)
+ {
+ PlayButton.Text = Loc.GetString("jukebox-menu-buttonpause");
+ return;
+ }
+
+ PlayButton.Text = Loc.GetString("jukebox-menu-buttonplay");
+ }
+
+ public void SetSelectedSong(string name, float length)
+ {
+ SetSelectedSongText(name);
+ PlaybackSlider.MaxValue = length;
+ PlaybackSlider.SetValueWithoutEvent(0);
+ }
+
+ protected override void FrameUpdate(FrameEventArgs args)
+ {
+ base.FrameUpdate(args);
+
+ if (_lockTimer > 0f)
+ {
+ _lockTimer -= args.DeltaSeconds;
+ }
+
+ PlaybackSlider.Disabled = _lockTimer > 0f;
+
+ if (_entManager.TryGetComponent(_audio, out AudioComponent? audio))
+ {
+ DurationLabel.Text = $@"{TimeSpan.FromSeconds(audio.PlaybackPosition):mm\:ss} / {_audioSystem.GetAudioLength(audio.FileName):mm\:ss}";
+ }
+ else
+ {
+ DurationLabel.Text = $"00:00 / 00:00";
+ }
+
+ if (PlaybackSlider.Grabbed)
+ return;
+
+ if (audio != null || _entManager.TryGetComponent(_audio, out audio))
+ {
+ PlaybackSlider.SetValueWithoutEvent(audio.PlaybackPosition);
+ }
+ else
+ {
+ PlaybackSlider.SetValueWithoutEvent(0f);
+ }
+
+ SetPlayPauseButton(_audioSystem.IsPlaying(_audio, audio));
+ }
+
+ public void SetSelectedSongText(string? text)
+ {
+ if (!string.IsNullOrEmpty(text))
+ {
+ SongName.Text = text;
+ }
+ else
+ {
+ SongName.Text = "---";
+ }
+ }
+}
diff --git a/Content.Client/Audio/Jukebox/JukeboxSystem.cs b/Content.Client/Audio/Jukebox/JukeboxSystem.cs
new file mode 100644
index 0000000000..53bde82a78
--- /dev/null
+++ b/Content.Client/Audio/Jukebox/JukeboxSystem.cs
@@ -0,0 +1,153 @@
+using Content.Shared.Audio.Jukebox;
+using Robust.Client.Animations;
+using Robust.Client.GameObjects;
+using Robust.Shared.Prototypes;
+
+namespace Content.Client.Audio.Jukebox;
+
+
+public sealed class JukeboxSystem : SharedJukeboxSystem
+{
+ [Dependency] private readonly IPrototypeManager _protoManager = default!;
+ [Dependency] private readonly AnimationPlayerSystem _animationPlayer = default!;
+ [Dependency] private readonly SharedAppearanceSystem _appearanceSystem = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ SubscribeLocalEvent(OnAppearanceChange);
+ SubscribeLocalEvent(OnAnimationCompleted);
+ SubscribeLocalEvent(OnJukeboxAfterState);
+
+ _protoManager.PrototypesReloaded += OnProtoReload;
+ }
+
+ public override void Shutdown()
+ {
+ base.Shutdown();
+ _protoManager.PrototypesReloaded -= OnProtoReload;
+ }
+
+ private void OnProtoReload(PrototypesReloadedEventArgs obj)
+ {
+ if (!obj.WasModified())
+ return;
+
+ var query = AllEntityQuery();
+
+ while (query.MoveNext(out _, out var ui))
+ {
+ if (!ui.OpenInterfaces.TryGetValue(JukeboxUiKey.Key, out var baseBui) ||
+ baseBui is not JukeboxBoundUserInterface bui)
+ {
+ continue;
+ }
+
+ bui.PopulateMusic();
+ }
+ }
+
+ private void OnJukeboxAfterState(Entity ent, ref AfterAutoHandleStateEvent args)
+ {
+ if (!TryComp(ent, out UserInterfaceComponent? ui))
+ return;
+
+ if (!ui.OpenInterfaces.TryGetValue(JukeboxUiKey.Key, out var baseBui) ||
+ baseBui is not JukeboxBoundUserInterface bui)
+ {
+ return;
+ }
+
+ bui.Reload();
+ }
+
+ private void OnAnimationCompleted(EntityUid uid, JukeboxComponent component, AnimationCompletedEvent args)
+ {
+ if (!TryComp(uid, out var sprite))
+ return;
+
+ if (!TryComp(uid, out var appearance) ||
+ !_appearanceSystem.TryGetData(uid, JukeboxVisuals.VisualState, out var visualState, appearance))
+ {
+ visualState = JukeboxVisualState.On;
+ }
+
+ UpdateAppearance(uid, visualState, component, sprite);
+ }
+
+ private void OnAppearanceChange(EntityUid uid, JukeboxComponent component, ref AppearanceChangeEvent args)
+ {
+ if (args.Sprite == null)
+ return;
+
+ if (!args.AppearanceData.TryGetValue(JukeboxVisuals.VisualState, out var visualStateObject) ||
+ visualStateObject is not JukeboxVisualState visualState)
+ {
+ visualState = JukeboxVisualState.On;
+ }
+
+ UpdateAppearance(uid, visualState, component, args.Sprite);
+ }
+
+ private void UpdateAppearance(EntityUid uid, JukeboxVisualState visualState, JukeboxComponent component, SpriteComponent sprite)
+ {
+ SetLayerState(JukeboxVisualLayers.Base, component.OffState, sprite);
+
+ switch (visualState)
+ {
+ case JukeboxVisualState.On:
+ SetLayerState(JukeboxVisualLayers.Base, component.OnState, sprite);
+ break;
+
+ case JukeboxVisualState.Off:
+ SetLayerState(JukeboxVisualLayers.Base, component.OffState, sprite);
+ break;
+
+ case JukeboxVisualState.Select:
+ PlayAnimation(uid, JukeboxVisualLayers.Base, component.SelectState, 1.0f, sprite);
+ break;
+ }
+ }
+
+ private void PlayAnimation(EntityUid uid, JukeboxVisualLayers layer, string? state, float animationTime, SpriteComponent sprite)
+ {
+ if (string.IsNullOrEmpty(state))
+ return;
+
+ if (!_animationPlayer.HasRunningAnimation(uid, state))
+ {
+ var animation = GetAnimation(layer, state, animationTime);
+ sprite.LayerSetVisible(layer, true);
+ _animationPlayer.Play(uid, animation, state);
+ }
+ }
+
+ private static Animation GetAnimation(JukeboxVisualLayers layer, string state, float animationTime)
+ {
+ return new Animation
+ {
+ Length = TimeSpan.FromSeconds(animationTime),
+ AnimationTracks =
+ {
+ new AnimationTrackSpriteFlick
+ {
+ LayerKey = layer,
+ KeyFrames =
+ {
+ new AnimationTrackSpriteFlick.KeyFrame(state, 0f)
+ }
+ }
+ }
+ };
+ }
+
+ private void SetLayerState(JukeboxVisualLayers layer, string? state, SpriteComponent sprite)
+ {
+ if (string.IsNullOrEmpty(state))
+ return;
+
+ sprite.LayerSetVisible(layer, true);
+ sprite.LayerSetAutoAnimated(layer, true);
+ sprite.LayerSetState(layer, state);
+ }
+}
diff --git a/Content.Server/Audio/Jukebox/JukeboxSystem.cs b/Content.Server/Audio/Jukebox/JukeboxSystem.cs
new file mode 100644
index 0000000000..bfb9b2099a
--- /dev/null
+++ b/Content.Server/Audio/Jukebox/JukeboxSystem.cs
@@ -0,0 +1,152 @@
+using Content.Server.Power.Components;
+using Content.Server.Power.EntitySystems;
+using Content.Shared.Audio.Jukebox;
+using Robust.Server.GameObjects;
+using Robust.Shared.Audio;
+using Robust.Shared.Audio.Components;
+using Robust.Shared.Audio.Systems;
+using Robust.Shared.Prototypes;
+using JukeboxComponent = Content.Shared.Audio.Jukebox.JukeboxComponent;
+
+namespace Content.Server.Audio.Jukebox;
+
+
+public sealed class JukeboxSystem : SharedJukeboxSystem
+{
+ [Dependency] private readonly IPrototypeManager _protoManager = default!;
+ [Dependency] private readonly AppearanceSystem _appearanceSystem = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ SubscribeLocalEvent(OnJukeboxSelected);
+ SubscribeLocalEvent(OnJukeboxPlay);
+ SubscribeLocalEvent(OnJukeboxPause);
+ SubscribeLocalEvent(OnJukeboxStop);
+ SubscribeLocalEvent(OnJukeboxSetTime);
+ SubscribeLocalEvent(OnComponentInit);
+ SubscribeLocalEvent(OnComponentShutdown);
+
+ SubscribeLocalEvent(OnPowerChanged);
+ }
+
+ private void OnComponentInit(EntityUid uid, JukeboxComponent component, ComponentInit args)
+ {
+ if (HasComp(uid))
+ {
+ TryUpdateVisualState(uid, component);
+ }
+ }
+
+ private void OnJukeboxPlay(EntityUid uid, JukeboxComponent component, ref JukeboxPlayingMessage args)
+ {
+ if (Exists(component.AudioStream))
+ {
+ Audio.SetState(component.AudioStream, AudioState.Playing);
+ }
+ else
+ {
+ component.AudioStream = Audio.Stop(component.AudioStream);
+
+ if (string.IsNullOrEmpty(component.SelectedSongId) ||
+ !_protoManager.TryIndex(component.SelectedSongId, out var jukeboxProto))
+ {
+ return;
+ }
+
+ component.AudioStream = Audio.PlayPvs(jukeboxProto.Path, uid, AudioParams.Default.WithMaxDistance(10f))?.Entity;
+ Dirty(uid, component);
+ }
+ }
+
+ private void OnJukeboxPause(Entity ent, ref JukeboxPauseMessage args)
+ {
+ Audio.SetState(ent.Comp.AudioStream, AudioState.Paused);
+ }
+
+ private void OnJukeboxSetTime(EntityUid uid, JukeboxComponent component, JukeboxSetTimeMessage args)
+ {
+ var offset = (args.Session.Channel.Ping * 1.5f) / 1000f;
+ Audio.SetPlaybackPosition(component.AudioStream, args.SongTime + offset);
+ }
+
+ private void OnPowerChanged(Entity entity, ref PowerChangedEvent args)
+ {
+ TryUpdateVisualState(entity);
+
+ if (!this.IsPowered(entity.Owner, EntityManager))
+ {
+ Stop(entity);
+ }
+ }
+
+ private void OnJukeboxStop(Entity entity, ref JukeboxStopMessage args)
+ {
+ Stop(entity);
+ }
+
+ private void Stop(Entity entity)
+ {
+ Audio.SetState(entity.Comp.AudioStream, AudioState.Stopped);
+ Dirty(entity);
+ }
+
+ private void OnJukeboxSelected(EntityUid uid, JukeboxComponent component, JukeboxSelectedMessage args)
+ {
+ if (!Audio.IsPlaying(component.AudioStream))
+ {
+ component.SelectedSongId = args.SongId;
+ DirectSetVisualState(uid, JukeboxVisualState.Select);
+ component.Selecting = true;
+ component.AudioStream = Audio.Stop(component.AudioStream);
+ }
+
+ Dirty(uid, component);
+ }
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out var comp))
+ {
+ if (comp.Selecting)
+ {
+ comp.SelectAccumulator += frameTime;
+ if (comp.SelectAccumulator >= 0.5f)
+ {
+ comp.SelectAccumulator = 0f;
+ comp.Selecting = false;
+
+ TryUpdateVisualState(uid, comp);
+ }
+ }
+ }
+ }
+
+ private void OnComponentShutdown(EntityUid uid, JukeboxComponent component, ComponentShutdown args)
+ {
+ component.AudioStream = Audio.Stop(component.AudioStream);
+ }
+
+ private void DirectSetVisualState(EntityUid uid, JukeboxVisualState state)
+ {
+ _appearanceSystem.SetData(uid, JukeboxVisuals.VisualState, state);
+ }
+
+ private void TryUpdateVisualState(EntityUid uid, JukeboxComponent? jukeboxComponent = null)
+ {
+ if (!Resolve(uid, ref jukeboxComponent))
+ return;
+
+ var finalState = JukeboxVisualState.On;
+
+ if (!this.IsPowered(uid, EntityManager))
+ {
+ finalState = JukeboxVisualState.Off;
+ }
+
+ _appearanceSystem.SetData(uid, JukeboxVisuals.VisualState, finalState);
+ }
+}
diff --git a/Content.Shared/Audio/Jukebox/JukeboxComponent.cs b/Content.Shared/Audio/Jukebox/JukeboxComponent.cs
new file mode 100644
index 0000000000..f9bb385f52
--- /dev/null
+++ b/Content.Shared/Audio/Jukebox/JukeboxComponent.cs
@@ -0,0 +1,80 @@
+using Robust.Shared.GameStates;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Audio.Jukebox;
+
+[NetworkedComponent, RegisterComponent, AutoGenerateComponentState(true)]
+[Access(typeof(SharedJukeboxSystem))]
+public sealed partial class JukeboxComponent : Component
+{
+ [DataField, AutoNetworkedField]
+ public ProtoId? SelectedSongId;
+
+ [DataField, AutoNetworkedField]
+ public EntityUid? AudioStream;
+
+ ///
+ /// RSI state for the jukebox being on.
+ ///
+ [DataField]
+ public string? OnState;
+
+ ///
+ /// RSI state for the jukebox being on.
+ ///
+ [DataField]
+ public string? OffState;
+
+ ///
+ /// RSI state for the jukebox track being selected.
+ ///
+ [DataField]
+ public string? SelectState;
+
+ [ViewVariables]
+ public bool Selecting;
+
+ [ViewVariables]
+ public float SelectAccumulator;
+}
+
+[Serializable, NetSerializable]
+public sealed class JukeboxPlayingMessage : BoundUserInterfaceMessage;
+
+[Serializable, NetSerializable]
+public sealed class JukeboxPauseMessage : BoundUserInterfaceMessage;
+
+[Serializable, NetSerializable]
+public sealed class JukeboxStopMessage : BoundUserInterfaceMessage;
+
+[Serializable, NetSerializable]
+public sealed class JukeboxSelectedMessage(ProtoId songId) : BoundUserInterfaceMessage
+{
+ public ProtoId SongId { get; } = songId;
+}
+
+[Serializable, NetSerializable]
+public sealed class JukeboxSetTimeMessage(float songTime) : BoundUserInterfaceMessage
+{
+ public float SongTime { get; } = songTime;
+}
+
+[Serializable, NetSerializable]
+public enum JukeboxVisuals : byte
+{
+ VisualState
+}
+
+[Serializable, NetSerializable]
+public enum JukeboxVisualState : byte
+{
+ On,
+ Off,
+ Select,
+}
+
+public enum JukeboxVisualLayers : byte
+{
+ Base
+}
diff --git a/Content.Shared/Audio/Jukebox/JukeboxPrototype.cs b/Content.Shared/Audio/Jukebox/JukeboxPrototype.cs
new file mode 100644
index 0000000000..256f22f2a6
--- /dev/null
+++ b/Content.Shared/Audio/Jukebox/JukeboxPrototype.cs
@@ -0,0 +1,23 @@
+using Robust.Shared.Audio;
+using Robust.Shared.Prototypes;
+
+namespace Content.Shared.Audio.Jukebox;
+
+///
+/// Soundtrack that's visible on the jukebox list.
+///
+[Prototype]
+public sealed class JukeboxPrototype : IPrototype
+{
+ [IdDataField]
+ public string ID { get; } = string.Empty;
+
+ ///
+ /// User friendly name to use in UI.
+ ///
+ [DataField(required: true)]
+ public string Name = string.Empty;
+
+ [DataField(required: true)]
+ public SoundPathSpecifier Path = default!;
+}
diff --git a/Content.Shared/Audio/Jukebox/JukeboxUi.cs b/Content.Shared/Audio/Jukebox/JukeboxUi.cs
new file mode 100644
index 0000000000..bf1fc3d5d2
--- /dev/null
+++ b/Content.Shared/Audio/Jukebox/JukeboxUi.cs
@@ -0,0 +1,11 @@
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Audio.Jukebox;
+
+
+[Serializable, NetSerializable]
+public enum JukeboxUiKey : byte
+{
+ Key,
+}
diff --git a/Content.Shared/Audio/Jukebox/SharedJukeboxSystem.cs b/Content.Shared/Audio/Jukebox/SharedJukeboxSystem.cs
new file mode 100644
index 0000000000..1a8f9cb3bb
--- /dev/null
+++ b/Content.Shared/Audio/Jukebox/SharedJukeboxSystem.cs
@@ -0,0 +1,8 @@
+using Robust.Shared.Audio.Systems;
+
+namespace Content.Shared.Audio.Jukebox;
+
+public abstract class SharedJukeboxSystem : EntitySystem
+{
+ [Dependency] protected readonly SharedAudioSystem Audio = default!;
+}
diff --git a/Resources/Audio/Jukebox/attributions.yml b/Resources/Audio/Jukebox/attributions.yml
new file mode 100644
index 0000000000..8e48560ac6
--- /dev/null
+++ b/Resources/Audio/Jukebox/attributions.yml
@@ -0,0 +1,22 @@
+- files: ["sector11.ogg"]
+ license: "CC-BY-NC-SA-3.0"
+ copyright: "-Sector11 by MashedByMachines. Converted to mono OGG."
+ source: "https://www.newgrounds.com/audio/listen/312622"
+
+- files: ["mod.flip-flap.ogg"]
+ license: "Custom"
+ copyright: "Flip Flap by X-ceed is licensed under a short but clear license (see flip-flap.txt in Audio/Lobby) and is free for non-commercial use. Converted to mono OGG."
+ source: "http://aminet.net/package/mods/xceed/Flipflap"
+
+- files: ["title3.ogg"]
+ license: "CC-BY-NC-SA-3.0"
+ copyright: "Title3 by Cuboos. It is a remix of the song 'Tintin on the Moon'. Converted to mono OGG."
+ source: "https://www.youtube.com/watch?v=YKVmXn-Gv0M"
+
+- files:
+ - "constellations.ogg"
+ - "drifting.ogg"
+ - "starlight.ogg"
+ license: "CC-BY-3.0"
+ copyright: "Constellations by Qwertyquerty. Converted to mono OGG."
+ source: "https://www.youtube.com/channel/UCPYbhBUGhH7n_G4HLK2YipQ"
diff --git a/Resources/Audio/Jukebox/constellations.ogg b/Resources/Audio/Jukebox/constellations.ogg
new file mode 100644
index 0000000000..f177489465
Binary files /dev/null and b/Resources/Audio/Jukebox/constellations.ogg differ
diff --git a/Resources/Audio/Jukebox/drifting.ogg b/Resources/Audio/Jukebox/drifting.ogg
new file mode 100644
index 0000000000..321c098bbd
Binary files /dev/null and b/Resources/Audio/Jukebox/drifting.ogg differ
diff --git a/Resources/Audio/Jukebox/flip-flap.ogg b/Resources/Audio/Jukebox/flip-flap.ogg
new file mode 100644
index 0000000000..07c47c00be
Binary files /dev/null and b/Resources/Audio/Jukebox/flip-flap.ogg differ
diff --git a/Resources/Audio/Jukebox/sector11.ogg b/Resources/Audio/Jukebox/sector11.ogg
new file mode 100644
index 0000000000..f0ce993b7e
Binary files /dev/null and b/Resources/Audio/Jukebox/sector11.ogg differ
diff --git a/Resources/Audio/Jukebox/starlight.ogg b/Resources/Audio/Jukebox/starlight.ogg
new file mode 100644
index 0000000000..31e23416f9
Binary files /dev/null and b/Resources/Audio/Jukebox/starlight.ogg differ
diff --git a/Resources/Audio/Jukebox/title3.ogg b/Resources/Audio/Jukebox/title3.ogg
new file mode 100644
index 0000000000..5cfe80b535
Binary files /dev/null and b/Resources/Audio/Jukebox/title3.ogg differ
diff --git a/Resources/Locale/en-US/jukebox/jukebox-menu.ftl b/Resources/Locale/en-US/jukebox/jukebox-menu.ftl
new file mode 100644
index 0000000000..d015976cc4
--- /dev/null
+++ b/Resources/Locale/en-US/jukebox/jukebox-menu.ftl
@@ -0,0 +1,5 @@
+jukebox-menu-title = Jukebox
+jukebox-menu-selectedsong = Selected Song:
+jukebox-menu-buttonplay = Play
+jukebox-menu-buttonpause = Pause
+jukebox-menu-buttonstop = Stop
diff --git a/Resources/Prototypes/Catalog/Jukebox/Standard.yml b/Resources/Prototypes/Catalog/Jukebox/Standard.yml
new file mode 100644
index 0000000000..e9d86874c5
--- /dev/null
+++ b/Resources/Prototypes/Catalog/Jukebox/Standard.yml
@@ -0,0 +1,35 @@
+- type: jukebox
+ id: FlipFlap
+ name: X-CEED - Flip Flap
+ path:
+ path: /Audio/Jukebox/flip-flap.ogg
+
+- type: jukebox
+ id: Tintin
+ name: Jeroen Tel - Tintin on the Moon
+ path:
+ path: /Audio/Jukebox/title3.ogg
+
+- type: jukebox
+ id: Thunderdome
+ name: MashedByMachines - Sector 11
+ path:
+ path: /Audio/Jukebox/sector11.ogg
+
+- type: jukebox
+ id: Constellations
+ name: Qwertyquerty - Constellations
+ path:
+ path: /Audio/Jukebox/constellations.ogg
+
+- type: jukebox
+ id: Drifting
+ name: Qwertyquerty - Drifting
+ path:
+ path: /Audio/Jukebox/drifting.ogg
+
+- type: jukebox
+ id: starlight
+ name: Qwertyquerty - Starlight
+ path:
+ path: /Audio/Jukebox/starlight.ogg
diff --git a/Resources/Prototypes/Entities/Objects/Devices/Circuitboards/Machine/production.yml b/Resources/Prototypes/Entities/Objects/Devices/Circuitboards/Machine/production.yml
index 7a9e60ac56..ebc8237cb2 100644
--- a/Resources/Prototypes/Entities/Objects/Devices/Circuitboards/Machine/production.yml
+++ b/Resources/Prototypes/Entities/Objects/Devices/Circuitboards/Machine/production.yml
@@ -1369,4 +1369,18 @@
MatterBin: 1
Manipulator: 3
materialRequirements:
- Glass: 1
\ No newline at end of file
+ Glass: 1
+
+- type: entity
+ parent: BaseMachineCircuitboard
+ id: JukeboxCircuitBoard
+ name: jukebox machine board
+ description: A machine printed circuit board for a jukebox.
+ components:
+ - type: MachineBoard
+ prototype: Jukebox
+ materialRequirements:
+ WoodPlank: 5
+ Steel: 2
+ Glass: 5
+ Cable: 2
diff --git a/Resources/Prototypes/Entities/Structures/Machines/jukebox.yml b/Resources/Prototypes/Entities/Structures/Machines/jukebox.yml
new file mode 100644
index 0000000000..76b8ddd36b
--- /dev/null
+++ b/Resources/Prototypes/Entities/Structures/Machines/jukebox.yml
@@ -0,0 +1,59 @@
+- type: entity
+ id: Jukebox
+ name: jukebox
+ parent: [ BaseMachinePowered, ConstructibleMachine ]
+ description: A machine capable of playing a wide variety of tunes. Enjoyment not guaranteed.
+ components:
+ - type: Sprite
+ sprite: Structures/Machines/jukebox.rsi
+ layers:
+ - state: "off"
+ map: ["enum.JukeboxVisualLayers.Base"]
+ - type: Transform
+ anchored: true
+ - type: Jukebox
+ onState: on
+ offState: off
+ selectState: select
+ - type: Machine
+ board: JukeboxCircuitBoard
+ - type: Appearance
+ - type: ApcPowerReceiver
+ powerLoad: 100
+ - type: ExtensionCableReceiver
+ - type: ActivatableUI
+ key: enum.JukeboxUiKey.Key
+ - type: ActivatableUIRequiresPower
+ - type: UserInterface
+ interfaces:
+ - key: enum.JukeboxUiKey.Key
+ type: JukeboxBoundUserInterface
+ - type: Damageable
+ damageContainer: Inorganic
+ damageModifierSet: Metallic
+ - type: Destructible
+ thresholds:
+ - trigger:
+ !type:DamageTrigger
+ damage: 75
+ behaviors:
+ - !type:DoActsBehavior
+ acts: ["Destruction"]
+ - !type:SpawnEntitiesBehavior
+ spawn:
+ SheetSteel1:
+ min: 1
+ max: 2
+ - type: Physics
+ bodyType: Static
+ - type: Fixtures
+ fixtures:
+ fix1:
+ shape:
+ !type:PhysShapeAabb
+ bounds: "-0.25,-0.45,0.25,0.45"
+ mask:
+ - MachineMask
+ layer:
+ - MachineLayer
+ density: 200
diff --git a/Resources/Prototypes/Entities/Structures/Machines/lathe.yml b/Resources/Prototypes/Entities/Structures/Machines/lathe.yml
index c32f2992f9..6f7e4cd6ab 100644
--- a/Resources/Prototypes/Entities/Structures/Machines/lathe.yml
+++ b/Resources/Prototypes/Entities/Structures/Machines/lathe.yml
@@ -446,6 +446,7 @@
- TelecomServerCircuitboard
- MassMediaCircuitboard
- ReagentGrinderIndustrialMachineCircuitboard
+ - JukeboxCircuitBoard
- type: MaterialStorage
whitelist:
tags:
diff --git a/Resources/Prototypes/Recipes/Lathes/electronics.yml b/Resources/Prototypes/Recipes/Lathes/electronics.yml
index c4417f868b..c9998c4c34 100644
--- a/Resources/Prototypes/Recipes/Lathes/electronics.yml
+++ b/Resources/Prototypes/Recipes/Lathes/electronics.yml
@@ -965,3 +965,11 @@
Steel: 100
Glass: 900
Gold: 100
+
+- type: latheRecipe
+ id: JukeboxCircuitBoard
+ result: JukeboxCircuitBoard
+ completetime: 4
+ materials:
+ Steel: 100
+ Glass: 900
diff --git a/Resources/Prototypes/Research/civilianservices.yml b/Resources/Prototypes/Research/civilianservices.yml
index 79dac27510..afb1f0ff50 100644
--- a/Resources/Prototypes/Research/civilianservices.yml
+++ b/Resources/Prototypes/Research/civilianservices.yml
@@ -70,6 +70,7 @@
- BorgModuleClowning
- DawInstrumentMachineCircuitboard
- MassMediaCircuitboard
+ - JukeboxCircuitBoard
- type: technology
id: RoboticCleanliness
diff --git a/Resources/Textures/Structures/Machines/jukebox.rsi/meta.json b/Resources/Textures/Structures/Machines/jukebox.rsi/meta.json
new file mode 100644
index 0000000000..f447b26ddf
--- /dev/null
+++ b/Resources/Textures/Structures/Machines/jukebox.rsi/meta.json
@@ -0,0 +1,31 @@
+{
+ "version": 1,
+ "size": {
+ "x": 32,
+ "y": 32
+ },
+ "license": "CC-BY-SA-3.0",
+ "copyright": "Taken from https://github.com/tgstation/tgstation at f349b842c84f500399bd5673e5e34a6bc45b001a, direct dmi link https://github.com/tgstation/tgstation/blob/f349b842c84f500399bd5673e5e34a6bc45b001a/icons/obj/stationobjs.dmi",
+ "states": [
+ {
+ "name": "on"
+ },
+ {
+ "name": "off"
+ },
+ {
+ "name": "select",
+ "delays": [
+ [
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1
+ ]
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/Resources/Textures/Structures/Machines/jukebox.rsi/off.png b/Resources/Textures/Structures/Machines/jukebox.rsi/off.png
new file mode 100644
index 0000000000..f3c24b1c56
Binary files /dev/null and b/Resources/Textures/Structures/Machines/jukebox.rsi/off.png differ
diff --git a/Resources/Textures/Structures/Machines/jukebox.rsi/on.png b/Resources/Textures/Structures/Machines/jukebox.rsi/on.png
new file mode 100644
index 0000000000..b397adc16a
Binary files /dev/null and b/Resources/Textures/Structures/Machines/jukebox.rsi/on.png differ
diff --git a/Resources/Textures/Structures/Machines/jukebox.rsi/select.png b/Resources/Textures/Structures/Machines/jukebox.rsi/select.png
new file mode 100644
index 0000000000..0dd6d81373
Binary files /dev/null and b/Resources/Textures/Structures/Machines/jukebox.rsi/select.png differ