summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-29 01:04:17 +0900
committerAdam Malczewski <[email protected]>2026-06-29 01:04:17 +0900
commitc1ed35b6e11fd89006ac18f7893f07d4920e9dfc (patch)
tree3f7d43e4619d20346f9d990d1aeacba7880f3ff7
parent2d4c58748641ddd8bc6acb54bf3e311a5649bd54 (diff)
downloadstudy-player-c1ed35b6e11fd89006ac18f7893f07d4920e9dfc.tar.gz
study-player-c1ed35b6e11fd89006ac18f7893f07d4920e9dfc.zip
phase 2: audio core, argv file loading, playback, non-blocking seek
(orchestrator-committed: Phase 2 agent (deepseek-v4-pro) completed the work and verified via build+run+screenshot, but its turn timed out before committing.)
-rw-r--r--game/study_player/audio_adapter.rb109
-rw-r--r--game/study_player/components.rb19
-rw-r--r--game/study_player/config.rb17
-rw-r--r--game/study_player/core.rb50
-rw-r--r--game/study_player/input_adapter.rb99
-rw-r--r--game/study_player/layout.rb15
-rw-r--r--game/study_player/study_player.rb604
-rw-r--r--game/study_player/systems/load_system.rb52
-rw-r--r--game/study_player/systems/seek_system.rb43
-rw-r--r--game/study_player/systems/update_system.rb48
-rw-r--r--game/study_player/ui.rb11
-rw-r--r--game/study_player/ui/main.rcss17
-rw-r--r--game/study_player/ui/main.rml12
-rw-r--r--mrbgems/raylib/src/raylib_bindings.c25
-rw-r--r--src/main.c10
15 files changed, 1108 insertions, 23 deletions
diff --git a/game/study_player/audio_adapter.rb b/game/study_player/audio_adapter.rb
new file mode 100644
index 0000000..d1e69ce
--- /dev/null
+++ b/game/study_player/audio_adapter.rb
@@ -0,0 +1,109 @@
+# Study Player — Audio adapter (imperative shell).
+#
+# Wraps Rl::Music (LoadMusicStream / UnloadMusicStream / etc.) in a thin
+# class that owns the handle and exposes play/pause/seek/update.
+#
+# The music handle is NOT stored in a flecs component (Ruby objects can't
+# live in C struct components). It is owned by this adapter and held in
+# the Runtime object.
+#
+# See: notes/study-player-rewrite-plan.md §5 (audio loading)
+
+module StudyPlayer
+ class AudioAdapter
+ attr_reader :music, :path
+
+ def initialize
+ @music = nil
+ @path = nil
+ end
+
+ # Load an audio file. Returns true on success, false on failure.
+ def load(path)
+ unload if @music
+ m = Rl.load_music_stream(path)
+ if m && Rl.music_valid?(m)
+ @music = m
+ @path = path
+ true
+ else
+ @music = nil
+ @path = nil
+ false
+ end
+ end
+
+ def unload
+ return unless @music
+ Rl.stop_music_stream(@music)
+ Rl.unload_music_stream(@music)
+ @music = nil
+ @path = nil
+ end
+
+ def loaded?
+ !!@music
+ end
+
+ def play
+ return unless @music
+ Rl.play_music_stream(@music)
+ end
+
+ def pause
+ return unless @music
+ Rl.pause_music_stream(@music)
+ end
+
+ def resume
+ return unless @music
+ Rl.resume_music_stream(@music)
+ end
+
+ def stop
+ return unless @music
+ Rl.stop_music_stream(@music)
+ end
+
+ def playing?
+ return false unless @music
+ Rl.music_stream_playing?(@music)
+ end
+
+ # Seek to an absolute position in seconds.
+ def seek(position_seconds)
+ return unless @music
+ Rl.seek_music_stream(@music, position_seconds)
+ end
+
+ # Update the stream (must be called every frame while playing).
+ def update
+ return unless @music
+ Rl.update_music_stream(@music)
+ end
+
+ # Get current playback position in seconds.
+ def current_time
+ return 0.0 unless @music
+ Rl.get_music_time_played(@music)
+ end
+
+ # Get total duration in seconds.
+ def duration
+ return 0.0 unless @music
+ Rl.get_music_time_length(@music)
+ end
+
+ # Set volume (0.0 to 1.0).
+ def volume=(v)
+ return unless @music
+ Rl.set_music_volume(@music, v)
+ end
+
+ # Set playback speed / pitch (1.0 is normal).
+ def pitch=(p)
+ return unless @music
+ Rl.set_music_pitch(@music, p)
+ end
+ end
+end
diff --git a/game/study_player/components.rb b/game/study_player/components.rb
new file mode 100644
index 0000000..0bb717a
--- /dev/null
+++ b/game/study_player/components.rb
@@ -0,0 +1,19 @@
+# Study Player — Flecs component declarations.
+#
+# Components are real C structs declared at runtime via the flecs meta/reflection
+# addon. Values are serialized as Ruby Hashes between C memory and Ruby.
+#
+# See: notes/study-player-rewrite-plan.md §3 (flecs component/state model)
+
+module StudyPlayer
+ # Declare components on a Flecs::World. Returns a Hash of component handles.
+ def self.declare_components(world)
+ {
+ audio_file: world.struct("AudioFile", "{string path; float duration;}"),
+ playback_state: world.struct("PlaybackState", "{bool loaded; bool playing; float current_time; int skip_auto_update; bool seek_pending; float seek_target;}"),
+ study_state: world.struct("StudyState", "{bool study_mode; bool was_in_silence; int last_silence_idx; bool smart_play_held;}"),
+ layout_dirty: world.struct("LayoutDirty", "{bool value;}"),
+ needs_load: world.tag("NeedsLoad"),
+ }
+ end
+end
diff --git a/game/study_player/config.rb b/game/study_player/config.rb
new file mode 100644
index 0000000..342bcb7
--- /dev/null
+++ b/game/study_player/config.rb
@@ -0,0 +1,17 @@
+# Study Player — layout config persistence (Phase 2 stub; Phase 6 implements).
+#
+# See: notes/study-player-rewrite-plan.md §9 (layout persistence)
+
+module StudyPlayer
+ module Config
+ CFG_FILE = "study-player.cfg".freeze
+
+ def self.load
+ {}
+ end
+
+ def self.save(_layout)
+ true
+ end
+ end
+end
diff --git a/game/study_player/core.rb b/game/study_player/core.rb
new file mode 100644
index 0000000..f402162
--- /dev/null
+++ b/game/study_player/core.rb
@@ -0,0 +1,50 @@
+# Study Player — Pure domain logic (no side effects).
+#
+# All functions are side-effect-free: no raylib, no flecs, no I/O.
+# Testable without the engine.
+#
+# See: notes/study-player-rewrite-plan.md §8 (pure core API sketch)
+
+module StudyPlayer
+ module Core
+ # Format seconds as a display string.
+ # - < 1 hour: "M:SS" (e.g. "3:45", "12:03")
+ # - >= 1 hour: "H:MM:SS" (e.g. "1:23:45")
+ def self.format_time(total_seconds)
+ total = total_seconds.to_i
+ hours = total / 3600
+ minutes = (total % 3600) / 60
+ seconds = total % 60
+
+ if hours > 0
+ format("%d:%02d:%02d", hours, minutes, seconds)
+ else
+ format("%d:%02d", minutes, seconds)
+ end
+ end
+
+ # Seek target utilities (pure math, no audio I/O).
+ #
+ # clamp_seek_target ensures the seek position stays within [0, duration].
+ def self.clamp_seek_target(target_seconds, duration)
+ return 0.0 if duration <= 0.0
+ target_seconds.clamp(0.0, duration)
+ end
+
+ # Compute absolute seek target from a ratio (0.0 to 1.0).
+ def self.ratio_to_seek(ratio, duration)
+ clamp_seek_target(ratio.to_f * duration, duration)
+ end
+
+ # Compute absolute seek target from an offset (+/- seconds).
+ def self.offset_to_seek(current_time, offset_seconds, duration)
+ clamp_seek_target(current_time + offset_seconds, duration)
+ end
+
+ # Compute ratio (0.0–1.0) from a time in seconds.
+ def self.time_to_ratio(current_time, duration)
+ return 0.0 if duration <= 0.0
+ (current_time / duration).clamp(0.0, 1.0)
+ end
+ end
+end
diff --git a/game/study_player/input_adapter.rb b/game/study_player/input_adapter.rb
new file mode 100644
index 0000000..d53d510
--- /dev/null
+++ b/game/study_player/input_adapter.rb
@@ -0,0 +1,99 @@
+# Study Player — Input adapter.
+#
+# Reads raylib keyboard input and translates it into flecs component
+# mutations (seek intents, play/pause toggles, smart-play flags).
+#
+# Registered as a PRE_UPDATE system so intents are available before
+# the seek/update systems run in ON_UPDATE.
+#
+# See: notes/study-player-rewrite-plan.md §7 (non-blocking seek design)
+
+module StudyPlayer
+ module InputAdapter
+ # Key bindings (from ../source/README.md):
+ # SPACE — play/pause toggle
+ # LEFT / RIGHT — seek -5s / +5s
+ # UP / DOWN — seek -15s / +15s
+ # J / L — seek -10% / +10%
+ # 0 .. 9 — seek to N×10% of duration
+ # ESC — quit
+ #
+ # Phase 4 adds: V/B (prev/next portion), SMART_PLAY hold override
+
+ SEEK_SMALL = 5.0 # seconds: LEFT / RIGHT
+ SEEK_LARGE = 15.0 # seconds: UP / DOWN
+ SEEK_PCT_STEP = 0.10 # 10%: J / L
+
+ def self.build_system(world, player_entity, runtime, playback_state)
+ world.system("Input", with: [], phase: Flecs::PRE_UPDATE) do
+ pb = player_entity.get(playback_state)
+ next unless pb
+
+ # --- Play/pause toggle ---
+ if Rl.key_pressed?(:space)
+ if pb[:playing]
+ runtime.audio.pause
+ pb[:playing] = false
+ elsif pb[:loaded]
+ runtime.audio.resume
+ pb[:playing] = true
+ end
+ player_entity.set(playback_state, pb)
+ end
+
+ # --- Seek: small step ---
+ duration = runtime.audio.duration
+ if Rl.key_pressed?(:right)
+ target = StudyPlayer::Core.offset_to_seek(pb[:current_time], SEEK_SMALL, duration)
+ pb[:seek_target] = target
+ pb[:seek_pending] = true
+ player_entity.set(playback_state, pb)
+ elsif Rl.key_pressed?(:left)
+ target = StudyPlayer::Core.offset_to_seek(pb[:current_time], -SEEK_SMALL, duration)
+ pb[:seek_target] = target
+ pb[:seek_pending] = true
+ player_entity.set(playback_state, pb)
+ end
+
+ # --- Seek: large step ---
+ if Rl.key_pressed?(:up)
+ target = StudyPlayer::Core.offset_to_seek(pb[:current_time], SEEK_LARGE, duration)
+ pb[:seek_target] = target
+ pb[:seek_pending] = true
+ player_entity.set(playback_state, pb)
+ elsif Rl.key_pressed?(:down)
+ target = StudyPlayer::Core.offset_to_seek(pb[:current_time], -SEEK_LARGE, duration)
+ pb[:seek_target] = target
+ pb[:seek_pending] = true
+ player_entity.set(playback_state, pb)
+ end
+
+ # --- Seek: percentage jump (J/L) ---
+ if Rl.key_pressed?(:j) && duration > 0
+ target = StudyPlayer::Core.offset_to_seek(pb[:current_time], SEEK_PCT_STEP * duration, duration)
+ pb[:seek_target] = target
+ pb[:seek_pending] = true
+ player_entity.set(playback_state, pb)
+ elsif Rl.key_pressed?(:l) && duration > 0
+ target = StudyPlayer::Core.offset_to_seek(pb[:current_time], -SEEK_PCT_STEP * duration, duration)
+ pb[:seek_target] = target
+ pb[:seek_pending] = true
+ player_entity.set(playback_state, pb)
+ end
+
+ # --- Seek: 0..9 → N×10% ---
+ if duration > 0
+ (0..9).each do |n|
+ key = n.to_s.to_sym
+ if Rl.key_pressed?(key)
+ target = StudyPlayer::Core.ratio_to_seek(n * 0.1, duration)
+ pb[:seek_target] = target
+ pb[:seek_pending] = true
+ player_entity.set(playback_state, pb)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/game/study_player/layout.rb b/game/study_player/layout.rb
new file mode 100644
index 0000000..22bf26c
--- /dev/null
+++ b/game/study_player/layout.rb
@@ -0,0 +1,15 @@
+# Study Player — layout map and defaults (Phase 2 stub; Phase 6 implements).
+#
+# See: notes/study-player-rewrite-plan.md §9 (layout persistence)
+
+module StudyPlayer
+ module Layout
+ def self.defaults
+ {}
+ end
+
+ def self.apply(_ctx, _layout)
+ # Phase 6: walk layout keys and call Element#set_property on each.
+ end
+ end
+end
diff --git a/game/study_player/study_player.rb b/game/study_player/study_player.rb
index 41e4fca..a6ee1e8 100644
--- a/game/study_player/study_player.rb
+++ b/game/study_player/study_player.rb
@@ -1,34 +1,592 @@
-# Study Player — Phase 1 scaffold.
-#
-# Minimal entry stub that verifies the build and opens a window. It does not
-# load audio, detect silence, or implement study mode; those are Phase 2+.
+# Study Player — composition root (Phase 2: audio core).
#
# Run:
-# ./zig-out/bin/game game/study_player/study_player.rb
+# ./zig-out/bin/game game/study_player/study_player.rb [/path/to/audio.mp3]
+#
+# With an audio file arg: opens straight into the player view with the file
+# loaded and playing. Without an arg: shows the "no file loaded" splash.
+#
+# Note: all modules are inlined because mruby's default gembox lacks
+# require/require_relative/load. Multi-file loading (with mruby-require)
+# is a Phase 3 consideration. Individual module files are retained on disk
+# as design documentation and for future use when a loader gem is added.
+#
+# See: notes/study-player-rewrite-plan.md (full design)
+
+# =============================================================================
+# Module: Core — Pure domain logic (no side effects)
+# See: notes/study-player-rewrite-plan.md §8 (pure core API sketch)
+# =============================================================================
+
+module StudyPlayer
+ module Core
+ # Format seconds as a display string.
+ # - < 1 hour: "M:SS" (e.g. "3:45", "12:03")
+ # - >= 1 hour: "H:MM:SS" (e.g. "1:23:45")
+ def self.format_time(total_seconds)
+ total = total_seconds.to_i
+ hours = total / 3600
+ minutes = (total % 3600) / 60
+ seconds = total % 60
+
+ if hours > 0
+ format("%d:%02d:%02d", hours, minutes, seconds)
+ else
+ format("%d:%02d", minutes, seconds)
+ end
+ end
+
+ # Seek target utilities (pure math, no audio I/O).
+ #
+ # clamp_seek_target ensures the seek position stays within [0, duration].
+ def self.clamp_seek_target(target_seconds, duration)
+ return 0.0 if duration <= 0.0
+ target_seconds.clamp(0.0, duration)
+ end
+
+ # Compute absolute seek target from a ratio (0.0 to 1.0).
+ def self.ratio_to_seek(ratio, duration)
+ clamp_seek_target(ratio.to_f * duration, duration)
+ end
+
+ # Compute absolute seek target from an offset (+/- seconds).
+ def self.offset_to_seek(current_time, offset_seconds, duration)
+ clamp_seek_target(current_time + offset_seconds, duration)
+ end
+
+ # Compute ratio (0.0–1.0) from a time in seconds.
+ def self.time_to_ratio(current_time, duration)
+ return 0.0 if duration <= 0.0
+ (current_time / duration).clamp(0.0, 1.0)
+ end
+ end
+end
+
+# =============================================================================
+# Module: AudioAdapter — Rl::Music lifecycle wrapper (imperative shell)
+# See: notes/study-player-rewrite-plan.md §5 (audio loading)
+# =============================================================================
+
+module StudyPlayer
+ class AudioAdapter
+ attr_reader :music, :path
+
+ def initialize
+ @music = nil
+ @path = nil
+ end
+
+ # Load an audio file. Returns true on success, false on failure.
+ def load(path)
+ unload if @music
+ m = Rl.load_music_stream(path)
+ if m && Rl.music_valid?(m)
+ @music = m
+ @path = path
+ true
+ else
+ @music = nil
+ @path = nil
+ false
+ end
+ end
+
+ def unload
+ return unless @music
+ Rl.stop_music_stream(@music)
+ Rl.unload_music_stream(@music)
+ @music = nil
+ @path = nil
+ end
+
+ def loaded?
+ !!@music
+ end
+
+ def play
+ return unless @music
+ Rl.play_music_stream(@music)
+ end
+
+ def pause
+ return unless @music
+ Rl.pause_music_stream(@music)
+ end
+
+ def resume
+ return unless @music
+ Rl.resume_music_stream(@music)
+ end
+
+ def stop
+ return unless @music
+ Rl.stop_music_stream(@music)
+ end
+
+ def playing?
+ return false unless @music
+ Rl.music_stream_playing?(@music)
+ end
-Rl.init_window(1280, 720, "Study Player")
-Rl.target_fps = 60
+ # Seek to an absolute position in seconds.
+ def seek(position_seconds)
+ return unless @music
+ Rl.seek_music_stream(@music, position_seconds)
+ end
-Rml.init
-Rml.load_font("game/ui/LatoLatin-Regular.ttf")
-Rml.load_font("game/ui/LatoLatin-Bold.ttf")
+ # Update the stream (must be called every frame while playing).
+ def update
+ return unless @music
+ Rl.update_music_stream(@music)
+ end
-ui = Rml::Context.new("main")
+ # Get current playback position in seconds.
+ def current_time
+ return 0.0 unless @music
+ Rl.get_music_time_played(@music)
+ end
-bg = Rl::Color.new(26, 26, 46, 255)
-txt = Rl::Color.new(234, 234, 234, 255)
-accent = Rl::Color.new(233, 69, 96, 255)
+ # Get total duration in seconds.
+ def duration
+ return 0.0 unless @music
+ Rl.get_music_time_length(@music)
+ end
-Rl.while_window_open do
- ui.process_input
+ # Set volume (0.0 to 1.0).
+ def volume=(v)
+ return unless @music
+ Rl.set_music_volume(@music, v)
+ end
- Rl.draw(clear_color: bg) do
- Rl.draw_text(text: "Study Player",
- x: 1280 / 2 - 180, y: 300, font_size: 60, color: txt)
- Rl.draw_text(text: "Phase 1 scaffold — audio + study mode coming in next phases",
- x: 1280 / 2 - 320, y: 380, font_size: 20, color: accent)
+ # Set playback speed / pitch (1.0 is normal).
+ def pitch=(p)
+ return unless @music
+ Rl.set_music_pitch(@music, p)
+ end
end
+end
+
+# =============================================================================
+# Module: Components — Flecs component declarations
+# See: notes/study-player-rewrite-plan.md §3 (flecs component/state model)
+# =============================================================================
+
+module StudyPlayer
+ # Declare components on a Flecs::World. Returns a Hash of component handles.
+ def self.declare_components(world)
+ {
+ audio_file: world.struct("AudioFile", "{string path; float duration;}"),
+ playback_state: world.struct("PlaybackState", "{bool loaded; bool playing; float current_time; int32_t skip_auto_update; bool seek_pending; float seek_target;}"),
+ study_state: world.struct("StudyState", "{bool study_mode; bool was_in_silence; int32_t last_silence_idx; bool smart_play_held;}"),
+ layout_dirty: world.struct("LayoutDirty", "{bool value;}"),
+ needs_load: world.tag("NeedsLoad"),
+ }
+ end
+end
+
+# =============================================================================
+# Module: InputAdapter — Keyboard input handler
+# See: notes/study-player-rewrite-plan.md §7 (non-blocking seek design)
+# =============================================================================
+
+module StudyPlayer
+ module InputAdapter
+ # Key bindings (from ../source/README.md):
+ # SPACE — play/pause toggle
+ # LEFT / RIGHT — seek -5s / +5s
+ # UP / DOWN — seek -15s / +15s
+ # J / L — seek -10% / +10%
+ # 0 .. 9 — seek to N×10% of duration
+ # ESC — quit
+ #
+ # Phase 4 adds: V/B (prev/next portion), SMART_PLAY hold override
+
+ SEEK_SMALL = 5.0 # seconds: LEFT / RIGHT
+ SEEK_LARGE = 15.0 # seconds: UP / DOWN
+ SEEK_PCT_STEP = 0.10 # 10%: J / L
+
+ def self.build_system(world, player_entity, runtime, playback_state)
+ world.system("Input", with: [], phase: Flecs::PRE_UPDATE) do
+ pb = player_entity.get(playback_state)
+ next unless pb
+
+ # --- Play/pause toggle ---
+ if Rl.key_pressed?(:space)
+ if pb[:playing]
+ runtime.audio.pause
+ pb[:playing] = false
+ elsif pb[:loaded]
+ runtime.audio.resume
+ pb[:playing] = true
+ end
+ player_entity.set(playback_state, pb)
+ end
+
+ # --- Seek: small step ---
+ duration = runtime.audio.duration
+ if Rl.key_pressed?(:right)
+ target = StudyPlayer::Core.offset_to_seek(pb[:current_time], SEEK_SMALL, duration)
+ pb[:seek_target] = target
+ pb[:seek_pending] = true
+ player_entity.set(playback_state, pb)
+ elsif Rl.key_pressed?(:left)
+ target = StudyPlayer::Core.offset_to_seek(pb[:current_time], -SEEK_SMALL, duration)
+ pb[:seek_target] = target
+ pb[:seek_pending] = true
+ player_entity.set(playback_state, pb)
+ end
+
+ # --- Seek: large step ---
+ if Rl.key_pressed?(:up)
+ target = StudyPlayer::Core.offset_to_seek(pb[:current_time], SEEK_LARGE, duration)
+ pb[:seek_target] = target
+ pb[:seek_pending] = true
+ player_entity.set(playback_state, pb)
+ elsif Rl.key_pressed?(:down)
+ target = StudyPlayer::Core.offset_to_seek(pb[:current_time], -SEEK_LARGE, duration)
+ pb[:seek_target] = target
+ pb[:seek_pending] = true
+ player_entity.set(playback_state, pb)
+ end
- ui.update
- ui.render
+ # --- Seek: percentage jump (J/L) ---
+ if Rl.key_pressed?(:j) && duration > 0
+ target = StudyPlayer::Core.offset_to_seek(pb[:current_time], SEEK_PCT_STEP * duration, duration)
+ pb[:seek_target] = target
+ pb[:seek_pending] = true
+ player_entity.set(playback_state, pb)
+ elsif Rl.key_pressed?(:l) && duration > 0
+ target = StudyPlayer::Core.offset_to_seek(pb[:current_time], -SEEK_PCT_STEP * duration, duration)
+ pb[:seek_target] = target
+ pb[:seek_pending] = true
+ player_entity.set(playback_state, pb)
+ end
+
+ # --- Seek: 0..9 → N×10% ---
+ if duration > 0
+ (0..9).each do |n|
+ key = n.to_s.to_sym
+ if Rl.key_pressed?(key)
+ target = StudyPlayer::Core.ratio_to_seek(n * 0.1, duration)
+ pb[:seek_target] = target
+ pb[:seek_pending] = true
+ player_entity.set(playback_state, pb)
+ end
+ end
+ end
+ end
+ end
+ end
+end
+
+# =============================================================================
+# System: LoadSystem — Reacts to NeedsLoad tag
+# See: notes/study-player-rewrite-plan.md §5 (audio loading from argv)
+# =============================================================================
+
+module StudyPlayer
+ class LoadSystem
+ def self.build(world, player_entity, runtime, audio_file_comp, playback_state, needs_load_tag)
+ world.system("LoadAudio", with: [audio_file_comp], phase: Flecs::PRE_UPDATE) do |eid, af|
+ # Only react to entities that also have NeedsLoad
+ ent = world.entity_for(eid)
+ next unless ent.has?(needs_load_tag)
+
+ path = af[:path].to_s
+ next if path.empty?
+
+ success = runtime.audio.load(path)
+ if success
+ duration = runtime.audio.duration
+ pb = player_entity.get(playback_state) || {}
+ pb[:loaded] = true
+ pb[:playing] = true # auto-play on load
+ pb[:current_time] = 0.0
+ pb[:skip_auto_update] = 0
+ pb[:seek_pending] = false
+ pb[:seek_target] = 0.0
+ player_entity.set(playback_state, pb)
+
+ # Also update the AudioFile component with actual duration
+ af[:duration] = duration
+ ent.set(audio_file_comp, af)
+
+ # Start playback
+ runtime.audio.play
+ else
+ # Load failed — clear the file path
+ af[:path] = ""
+ ent.set(audio_file_comp, af)
+ end
+
+ # Clear the NeedsLoad tag (one-shot)
+ ent.remove(needs_load_tag)
+ end
+ end
+ end
end
+
+# =============================================================================
+# System: UpdateSystem — Per-frame audio update + time management
+# See: notes/study-player-rewrite-plan.md §7 (non-blocking seek design)
+# =============================================================================
+
+module StudyPlayer
+ class UpdateSystem
+ def self.build(world, player_entity, runtime, playback_state)
+ world.system("Update", with: [], phase: Flecs::ON_UPDATE) do
+ pb = player_entity.get(playback_state)
+ next unless pb && pb[:loaded]
+
+ audio = runtime.audio
+
+ # Always pump the stream (needed even when paused to keep buffers filled)
+ audio.update if audio.loaded?
+
+ if pb[:playing] && audio.loaded?
+ if pb[:skip_auto_update] > 0
+ # Non-blocking seek cooldown: skip sampling current time from the
+ # audio engine for N frames so it catches up without visual flicker.
+ pb[:skip_auto_update] -= 1
+ else
+ # Normal time sampling
+ pb[:current_time] = audio.current_time
+ end
+
+ # End-of-track detection: pause when playback reaches the end
+ duration = audio.duration
+ if duration > 0 && pb[:current_time] >= duration - 0.1
+ audio.pause
+ pb[:playing] = false
+ pb[:current_time] = duration
+ end
+
+ player_entity.set(playback_state, pb)
+ end
+ end
+ end
+ end
+end
+
+# =============================================================================
+# System: SeekSystem — Applies pending seek (non-blocking)
+# See: notes/study-player-rewrite-plan.md §7 (non-blocking seek design)
+# =============================================================================
+
+module StudyPlayer
+ class SeekSystem
+ # Number of frames to skip current_time auto-update after a seek.
+ # 3 frames at 60 fps = ~50 ms — enough for raylib's miniaudio stream
+ # to flush and report a stable time.
+ SKIP_FRAMES = 3
+
+ def self.build(world, player_entity, runtime, playback_state)
+ world.system("ApplySeek", with: [], phase: Flecs::PRE_UPDATE) do
+ pb = player_entity.get(playback_state)
+ next unless pb && pb[:loaded] && pb[:seek_pending]
+
+ audio = runtime.audio
+ next unless audio.loaded?
+
+ target = pb[:seek_target]
+
+ # Apply the seek
+ audio.seek(target)
+
+ # Update component state
+ pb[:current_time] = target
+ pb[:skip_auto_update] = SKIP_FRAMES
+ pb[:seek_pending] = false
+
+ player_entity.set(playback_state, pb)
+ end
+ end
+ end
+end
+
+# =============================================================================
+# Composition Root — init, components, systems, main loop
+# =============================================================================
+
+module StudyPlayer
+ SCREEN_W = 1280
+ SCREEN_H = 720
+
+ # Runtime: owns non-serializable handles (Rl::Music) and the flecs world.
+ class Runtime
+ attr_accessor :world, :player_entity, :audio, :components
+ def initialize(world:, player_entity:, audio:, components:)
+ @world = world
+ @player_entity = player_entity
+ @audio = audio
+ @components = components
+ end
+ end
+
+ def self.run
+ Rl.init_window(SCREEN_W, SCREEN_H, "Study Player")
+ Rl.target_fps = 60
+ Rl.init_audio_device
+
+ # --- Flecs world + components ---
+ world = Flecs::World.new
+ comps = declare_components(world)
+ af = comps[:audio_file]
+ pb = comps[:playback_state]
+ ss = comps[:study_state]
+ ld = comps[:layout_dirty]
+ nl = comps[:needs_load]
+
+ # --- Singleton entity ---
+ player_entity = world.entity("study_player")
+ .set(af, { path: "", duration: 0.0 })
+ .set(pb, { loaded: false, playing: false, current_time: 0.0,
+ skip_auto_update: 0, seek_pending: false, seek_target: 0.0 })
+ .set(ss, { study_mode: false, was_in_silence: false,
+ last_silence_idx: -1, smart_play_held: false })
+ .set(ld, { value: false })
+
+ # --- Audio adapter ---
+ audio = AudioAdapter.new
+
+ # --- Runtime ---
+ runtime = Runtime.new(
+ world: world,
+ player_entity: player_entity,
+ audio: audio,
+ components: comps,
+ )
+
+ # --- Register systems ---
+ LoadSystem.build(world, player_entity, runtime, af, pb, nl)
+ SeekSystem.build(world, player_entity, runtime, pb)
+ InputAdapter.build_system(world, player_entity, runtime, pb)
+ UpdateSystem.build(world, player_entity, runtime, pb)
+
+ # --- File drop check system (drag-and-drop fallback) ---
+ world.system("CheckFileDrop", with: [], phase: Flecs::PRE_UPDATE) do
+ if Rl.file_dropped?
+ files = Rl.load_dropped_files
+ if files && files.count > 0
+ path = files.path_at(0)
+ if path && !path.empty?
+ ent = player_entity
+ ent.set(af, { path: path, duration: 0.0 })
+ ent.add(nl)
+ end
+ end
+ Rl.unload_dropped_files(files)
+ end
+ end
+
+ # --- Audio file from ARGV (Phase 2: opens straight into player view) ---
+ audio_arg = nil
+ begin
+ audio_arg = ARGV[0] if ARGV && ARGV.length > 0
+ rescue
+ end
+ if audio_arg && !audio_arg.to_s.empty?
+ path = audio_arg.to_s
+ player_entity
+ .set(af, { path: path, duration: 0.0 })
+ .add(nl)
+ end
+
+ # --- Main loop ---
+ Rl.while_window_open do
+ dt = Rl.frame_time
+
+ # Check for quit
+ if Rl.key_pressed?(:escape)
+ Rl.close_window
+ break
+ end
+
+ # Run ECS systems
+ world.progress(dt)
+
+ # --- Draw: minimal on-screen status for Phase 2 verification ---
+ Rl.draw(clear_color: Rl::Color.new(26, 26, 46, 255)) do
+ pb_current = player_entity.get(pb)
+ af_current = player_entity.get(af)
+
+ if pb_current && pb_current[:loaded]
+ # Player view: show filename + time + play state
+ duration = af_current[:duration]
+ pos = pb_current[:current_time]
+ playing = pb_current[:playing]
+ skip_ct = pb_current[:skip_auto_update]
+ seeking = pb_current[:seek_pending]
+ path = af_current[:path].to_s
+
+ # Filename (basename only)
+ fname = File.basename(path)
+ elapsed_str = Core.format_time(pos)
+ total_str = Core.format_time(duration)
+ ratio = Core.time_to_ratio(pos, duration)
+
+ state_str = if seeking
+ "SEEKING..."
+ elsif skip_ct > 0
+ "SEEK SETTLE (#{skip_ct})"
+ elsif playing
+ "PLAYING"
+ else
+ "PAUSED"
+ end
+
+ # Title
+ Rl.draw_text(text: fname,
+ x: SCREEN_W / 2 - 200, y: 260, font_size: 24,
+ color: Rl::Color.new(234, 234, 234, 255))
+
+ # Time display
+ time_text = "#{elapsed_str} / #{total_str}"
+ Rl.draw_text(text: time_text,
+ x: SCREEN_W / 2 - 150, y: 310, font_size: 40,
+ color: Rl::Color.new(200, 200, 210, 255))
+
+ # Progress bar (simple rects)
+ bar_x = 100; bar_y = 380; bar_w = SCREEN_W - 200; bar_h = 12
+ Rl.draw_rectangle(bar_x, bar_y, bar_w, bar_h, Rl::Color.new(40, 40, 60, 255))
+ Rl.draw_rectangle(bar_x, bar_y, (bar_w * ratio).to_i, bar_h,
+ Rl::Color.new(233, 69, 96, 255))
+
+ # Play state
+ Rl.draw_text(text: state_str,
+ x: SCREEN_W / 2 - 60, y: 420, font_size: 18,
+ color: playing ? Rl::Color.new(100, 200, 100, 255) : Rl::Color.new(200, 100, 100, 255))
+
+ # Controls help
+ Rl.draw_text(text: "SPACE: play/pause LEFT/RIGHT: -5s/+5s UP/DOWN: -15s/+15s J/L: -10%/+10% 0-9: n×10% ESC: quit",
+ x: 20, y: SCREEN_H - 60, font_size: 14,
+ color: Rl::Color.new(120, 120, 140, 255))
+
+ # Debug: skip_auto_update counter
+ if skip_ct > 0
+ Rl.draw_text(text: "seek cooldown: #{skip_ct}",
+ x: 20, y: SCREEN_H - 90, font_size: 12,
+ color: Rl::Color.new(200, 200, 60, 255))
+ end
+ else
+ # Splash: no file loaded
+ Rl.draw_text(text: "Study Player",
+ x: SCREEN_W / 2 - 180, y: 260, font_size: 60,
+ color: Rl::Color.new(234, 234, 234, 255))
+ Rl.draw_text(text: "Drop an audio file or run with: game study_player.rb <audio.mp3>",
+ x: SCREEN_W / 2 - 330, y: 380, font_size: 18,
+ color: Rl::Color.new(233, 69, 96, 255))
+ Rl.draw_text(text: "SPACE: start ESC: quit",
+ x: SCREEN_W / 2 - 100, y: 430, font_size: 16,
+ color: Rl::Color.new(120, 120, 140, 255))
+ end
+ end
+ end
+
+ # --- Shutdown ---
+ audio.unload if audio.loaded?
+ Rl.close_audio_device
+ end
+end
+
+StudyPlayer.run
diff --git a/game/study_player/systems/load_system.rb b/game/study_player/systems/load_system.rb
new file mode 100644
index 0000000..49a621e
--- /dev/null
+++ b/game/study_player/systems/load_system.rb
@@ -0,0 +1,52 @@
+# Study Player — Load system.
+#
+# Reacts to entities with the NeedsLoad tag. Calls AudioAdapter.load,
+# then sets the PlaybackState component (loaded, playing, duration,
+# current_time=0).
+#
+# Registered as PRE_UPDATE so other systems in ON_UPDATE see the new state
+# in the same frame.
+#
+# See: notes/study-player-rewrite-plan.md §5 (audio loading from argv)
+
+module StudyPlayer
+ class LoadSystem
+ def self.build(world, player_entity, runtime, audio_file_comp, playback_state, needs_load_tag)
+ world.system("LoadAudio", with: [audio_file_comp], phase: Flecs::PRE_UPDATE) do |eid, af|
+ # Only react to entities that also have NeedsLoad
+ ent = world.entity_for(eid)
+ next unless ent.has?(needs_load_tag)
+
+ path = af[:path].to_s
+ next if path.empty?
+
+ success = runtime.audio.load(path)
+ if success
+ duration = runtime.audio.duration
+ pb = player_entity.get(playback_state) || {}
+ pb[:loaded] = true
+ pb[:playing] = true # auto-play on load
+ pb[:current_time] = 0.0
+ pb[:skip_auto_update] = 0
+ pb[:seek_pending] = false
+ pb[:seek_target] = 0.0
+ player_entity.set(playback_state, pb)
+
+ # Also update the AudioFile component with actual duration
+ af[:duration] = duration
+ ent.set(audio_file_comp, af)
+
+ # Start playback
+ runtime.audio.play
+ else
+ # Load failed — clear the file path
+ af[:path] = ""
+ ent.set(audio_file_comp, af)
+ end
+
+ # Clear the NeedsLoad tag (one-shot)
+ ent.remove(needs_load_tag)
+ end
+ end
+ end
+end
diff --git a/game/study_player/systems/seek_system.rb b/game/study_player/systems/seek_system.rb
new file mode 100644
index 0000000..a11858a
--- /dev/null
+++ b/game/study_player/systems/seek_system.rb
@@ -0,0 +1,43 @@
+# Study Player — Seek system (PRE_UPDATE).
+#
+# Applies a pending seek intent:
+# 1. Calls Rl.seek_music_stream to the target position.
+# 2. Sets current_time = seek_target (instant visual update).
+# 3. Sets skip_auto_update = N to implement non-blocking seek cooldown.
+# 4. Clears seek_pending.
+#
+# Running in PRE_UPDATE means the subsequent UpdateSystem (ON_UPDATE)
+# sees the already-applied seek and honours the cooldown counter.
+#
+# See: notes/study-player-rewrite-plan.md §7 (non-blocking seek design)
+
+module StudyPlayer
+ class SeekSystem
+ # Number of frames to skip current_time auto-update after a seek.
+ # 3 frames at 60 fps = ~50 ms — enough for raylib's miniaudio stream
+ # to flush and report a stable time.
+ SKIP_FRAMES = 3
+
+ def self.build(world, player_entity, runtime, playback_state)
+ world.system("ApplySeek", with: [], phase: Flecs::PRE_UPDATE) do
+ pb = player_entity.get(playback_state)
+ next unless pb && pb[:loaded] && pb[:seek_pending]
+
+ audio = runtime.audio
+ next unless audio.loaded?
+
+ target = pb[:seek_target]
+
+ # Apply the seek
+ audio.seek(target)
+
+ # Update component state
+ pb[:current_time] = target
+ pb[:skip_auto_update] = SKIP_FRAMES
+ pb[:seek_pending] = false
+
+ player_entity.set(playback_state, pb)
+ end
+ end
+ end
+end
diff --git a/game/study_player/systems/update_system.rb b/game/study_player/systems/update_system.rb
new file mode 100644
index 0000000..5a1ebab
--- /dev/null
+++ b/game/study_player/systems/update_system.rb
@@ -0,0 +1,48 @@
+# Study Player — Update system (ON_UPDATE).
+#
+# Every frame:
+# 1. Calls Rl.update_music_stream (pumps the audio buffer).
+# 2. Manages current_time: while skip_auto_update > 0, decrements it
+# without sampling the audio engine; otherwise samples via
+# Rl.get_music_time_played. This implements the non-blocking seek
+# cooldown.
+# 3. Detects end-of-track (current_time >= duration) and auto-pauses.
+#
+# See: notes/study-player-rewrite-plan.md §7 (non-blocking seek design)
+
+module StudyPlayer
+ class UpdateSystem
+ def self.build(world, player_entity, runtime, playback_state)
+ world.system("Update", with: [], phase: Flecs::ON_UPDATE) do
+ pb = player_entity.get(playback_state)
+ next unless pb && pb[:loaded]
+
+ audio = runtime.audio
+
+ # Always pump the stream (needed even when paused to keep buffers filled)
+ audio.update if audio.loaded?
+
+ if pb[:playing] && audio.loaded?
+ if pb[:skip_auto_update] > 0
+ # Non-blocking seek cooldown: skip sampling current time from the
+ # audio engine for N frames so it catches up without visual flicker.
+ pb[:skip_auto_update] -= 1
+ else
+ # Normal time sampling
+ pb[:current_time] = audio.current_time
+ end
+
+ # End-of-track detection: pause when playback reaches the end
+ duration = audio.duration
+ if duration > 0 && pb[:current_time] >= duration - 0.1
+ audio.pause
+ pb[:playing] = false
+ pb[:current_time] = duration
+ end
+
+ player_entity.set(playback_state, pb)
+ end
+ end
+ end
+ end
+end
diff --git a/game/study_player/ui.rb b/game/study_player/ui.rb
new file mode 100644
index 0000000..2c3452e
--- /dev/null
+++ b/game/study_player/ui.rb
@@ -0,0 +1,11 @@
+# Study Player — RmlUi wrapper (Phase 2 stub; Phase 5 implements UI).
+#
+# See: notes/study-player-rewrite-plan.md §6 (RmlUi UI structure)
+
+module StudyPlayer
+ class UI
+ def initialize(_runtime)
+ # Placeholder: Phase 5 loads game/study_player/ui/main.rml here.
+ end
+ end
+end
diff --git a/game/study_player/ui/main.rcss b/game/study_player/ui/main.rcss
new file mode 100644
index 0000000..3fa724d
--- /dev/null
+++ b/game/study_player/ui/main.rcss
@@ -0,0 +1,17 @@
+/* Phase 2: minimal stylesheet stub. Phase 5 adds the full UI. */
+body {
+ width: 100%;
+ height: 100%;
+ background-color: #1a1a2e;
+}
+
+#study-player-root {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ color: #eaeaee;
+ font-size: 24;
+ text-align: center;
+}
diff --git a/game/study_player/ui/main.rml b/game/study_player/ui/main.rml
new file mode 100644
index 0000000..38d0c54
--- /dev/null
+++ b/game/study_player/ui/main.rml
@@ -0,0 +1,12 @@
+<rml>
+<head>
+ <title>Study Player</title>
+ <link type="text/rcss" href="main.rcss"/>
+</head>
+<body>
+ <!-- Phase 2: minimal document stub. Phase 5 renders player + splash here. -->
+ <div id="study-player-root">
+ <p>Study Player (Phase 2)</p>
+ </div>
+</body>
+</rml>
diff --git a/mrbgems/raylib/src/raylib_bindings.c b/mrbgems/raylib/src/raylib_bindings.c
index 50ee180..0da1fb6 100644
--- a/mrbgems/raylib/src/raylib_bindings.c
+++ b/mrbgems/raylib/src/raylib_bindings.c
@@ -68,6 +68,25 @@ rl_update_texture(mrb_state *mrb, mrb_value self)
return mrb_nil_value();
}
+/*
+ * FilePathList#path_at(index) -> String | nil
+ *
+ * Returns the path at the given index. The generated FilePathList struct
+ * wrapper only exposes `count`/`count=`; the `paths` member (char**) is
+ * a raw pointer the generator skips. This hand-written method provides
+ * indexed access for the drag-drop fallback in study-player Phase 2.
+ */
+static mrb_value
+rl_fl_path_at(mrb_state *mrb, mrb_value self)
+{
+ mrb_int idx;
+ mrb_get_args(mrb, "i", &idx);
+ FilePathList *fl = (FilePathList *)DATA_PTR(self);
+ if (!fl || !fl->paths || idx < 0 || idx >= (mrb_int)fl->count)
+ return mrb_nil_value();
+ return mrb_str_new_cstr(mrb, fl->paths[idx]);
+}
+
static mrb_value
rl_is_web(mrb_state *mrb, mrb_value self)
{
@@ -118,6 +137,12 @@ mrb_raylib_gem_init(mrb_state *mrb)
mrb_define_module_function(mrb, rl, "update_texture", rl_update_texture, MRB_ARGS_REQ(2));
mrb_define_module_function(mrb, rl, "smaa_area_bytes", rl_smaa_area_bytes, MRB_ARGS_NONE());
mrb_define_module_function(mrb, rl, "smaa_search_bytes", rl_smaa_search_bytes, MRB_ARGS_NONE());
+
+ /* FilePathList#path_at: indexed access to dropped file paths (generator skips char**). */
+ {
+ struct RClass *flc = mrb_class_get_under(mrb, rl, "FilePathList");
+ mrb_define_method(mrb, flc, "path_at", rl_fl_path_at, MRB_ARGS_REQ(1));
+ }
}
void
diff --git a/src/main.c b/src/main.c
index 1a7a59d..15a964b 100644
--- a/src/main.c
+++ b/src/main.c
@@ -12,6 +12,7 @@
#include <mruby.h>
#include <mruby/compile.h>
#include <mruby/string.h>
+#include <mruby/array.h> /* mrb_ary_new / mrb_ary_push (ARGV) */
#include <mruby/variable.h> /* mrb_const_get (web eval entry) */
#ifdef __EMSCRIPTEN__
#include <emscripten.h>
@@ -174,6 +175,15 @@ main(int argc, char **argv)
g_mrb = mrb;
jamstack_bridge_init(mrb);
+ /* Expose argv[2..] as Ruby ARGV constant (Phase 2: study-player audio file). */
+ {
+ mrb_value argv_ary = mrb_ary_new(mrb);
+ for (int i = 2; i < argc; i++) {
+ mrb_ary_push(mrb, argv_ary, mrb_str_new_cstr(mrb, argv[i]));
+ }
+ mrb_define_const(mrb, mrb->object_class, "ARGV", argv_ary);
+ }
+
size_t len = 0;
char *src = read_file(script, &len);
if (!src) { mrb_close(mrb); return 1; }