diff options
| author | Adam Malczewski <[email protected]> | 2026-06-29 02:25:21 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-29 02:25:21 +0900 |
| commit | 94f074c4ec5d402f948cf6c94772a28a558a52e9 (patch) | |
| tree | afbfced2165ac4db30bf4cb3ddf3e62dcb4ae698 | |
| parent | 485014cdd86ec503543e5fe020703e8e62080af3 (diff) | |
| download | study-player-94f074c4ec5d402f948cf6c94772a28a558a52e9.tar.gz study-player-94f074c4ec5d402f948cf6c94772a28a558a52e9.zip | |
phase 5: RmlUi player UI (progress bar, buttons, checkbox, counter, help)
| -rw-r--r-- | game/study_player/study_player.rb | 391 | ||||
| -rw-r--r-- | game/study_player/ui.rb | 246 | ||||
| -rw-r--r-- | game/study_player/ui/main.rcss | 308 | ||||
| -rw-r--r-- | game/study_player/ui/main.rml | 68 |
4 files changed, 874 insertions, 139 deletions
diff --git a/game/study_player/study_player.rb b/game/study_player/study_player.rb index c0ed5a0..fc3afc7 100644 --- a/game/study_player/study_player.rb +++ b/game/study_player/study_player.rb @@ -777,7 +777,7 @@ module StudyPlayer next unless regions && regions.length >= 0 # Resolve held-key overrides on this frame - smart_held = Rl.key_down?(:s) + smart_held = Rl.key_down?(:s) || (runtime.ui && runtime.ui.smart_play_held?) space_held = Rl.key_down?(:space) # Update smart_play_held in the component (for UI display) @@ -823,6 +823,257 @@ module StudyPlayer end # ============================================================================= +# Class: UI — RmlUi wrapper (Phase 5: full RmlUi player UI) +# +# Creates the Rml::Context, data model, loads the document, and provides +# per-frame sync from flecs state to the RmlUi data model. +# +# The data model is bound by name "player" in both the RML (data-model="player") +# and here. Events from RML data-event-* attributes call back into this class, +# which mutates flecs component state (seek_target, playing, study_mode, etc.). +# +# See: notes/study-player-rewrite-plan.md §6 (RmlUi UI structure) +# docs/API_SPEC_RMLUI.md (RmlUi API) +# .agents/knowledge/rmlui-binding.md (known quirks: left-not-right, FBO size) +# ============================================================================= + +module StudyPlayer + class UI + HELP_TEXT = "SPACE: play/pause LEFT/RIGHT: ±5s V/B: prev/next portion S: hold override M: study mode 0-9: jump ESC: quit".freeze + + attr_reader :context, :model, :doc + + def initialize(runtime, components) + @runtime = runtime + @comps = components + @player_ent = runtime.player_entity + @smart_play_mouse_held = false + + # --- RmlUi setup --- + Rml.load_font("game/ui/LatoLatin-Regular.ttf") + Rml.load_font("game/ui/LatoLatin-Bold.ttf") + Rml.load_font("game/ui/MononokiNerdFontMono-Regular.ttf") + + @context = Rml::Context.new("study-player") + @model = build_model + @doc = @context.load_document("game/study_player/ui/main.rml") + @doc.show + end + + def smart_play_held? + @smart_play_mouse_held + end + + def sync + @model.dirty_all + + # Update progress bar fill width directly (data-style can't handle + # "width: {{var}}" syntax — it parses the property name as a variable). + fill_el = @doc.element("progress-bar-fill") + if fill_el + pb = @player_ent.get(@comps[:playback_state]) + af = @player_ent.get(@comps[:audio_file]) + if pb && af[:duration] > 0 + ratio = StudyPlayer::Core.time_to_ratio(pb[:current_time], af[:duration]) + fill_el.set_property("width", "#{(ratio * 100.0).round(1)}%") + end + end + end + + def process_input + @context.process_input + end + + def render + @context.update + @context.render + end + + private + + def build_model + ctx = @context + af_c = @comps[:audio_file] + pb_c = @comps[:playback_state] + ss_c = @comps[:study_state] + pe = @player_ent + rt = @runtime + + ctx.data_model("player") do |m| + # -- One-way computed bindings -- + m.bind(:loaded) do + pb = pe.get(pb_c) + pb && pb[:loaded] ? true : false + end + + m.bind(:file_name) do + af = pe.get(af_c) + path = af[:path].to_s + path.empty? ? "" : File.basename(path) + end + + m.bind(:elapsed_str) do + pb = pe.get(pb_c) + Core.format_time(pb ? pb[:current_time] : 0.0) + end + + m.bind(:total_str) do + af = pe.get(af_c) + Core.format_time(af[:duration]) + end + + m.bind(:remain_str) do + pb = pe.get(pb_c) + af = pe.get(af_c) + rem = (af[:duration] - (pb ? pb[:current_time] : 0.0)) + rem = 0.0 if rem < 0.0 + Core.format_time(rem) + end + + m.bind(:progress_pct) do + pb = pe.get(pb_c) + af = pe.get(af_c) + dur = af[:duration] + if dur > 0 && pb + (Core.time_to_ratio(pb[:current_time], dur) * 100.0).to_i + else + 0 + end + end + + m.bind(:progress_ratio) do + pb = pe.get(pb_c) + af = pe.get(af_c) + dur = af[:duration] + ratio = (dur > 0 && pb) ? Core.time_to_ratio(pb[:current_time], dur) : 0.0 + "#{(ratio * 100.0).round(1)}%" + end + + m.bind(:status_text) do + pb = pe.get(pb_c) + (pb && pb[:playing]) ? "PLAYING" : "PAUSED" + end + + m.bind(:status_alert) do + pb = pe.get(pb_c) + ss = pe.get(ss_c) + (pb && pb[:playing] && ss && ss[:was_in_silence]) ? true : false + end + + m.bind(:playing) do + pb = pe.get(pb_c) + pb && pb[:playing] ? true : false + end + + m.bind(:portion_label) do + pb = pe.get(pb_c) + af = pe.get(af_c) + regions = rt.silence_regions + dur = af[:duration] + if pb && regions && regions.length >= 0 && dur > 0 + pos_norm = Core.time_to_ratio(pb[:current_time], dur) + current = Core.current_speaking_portion(regions, pos_norm) + total = Core.total_speaking_portions(regions) + "#{current + 1}/#{total}" + else + "-/-" + end + end + + m.bind(:smart_play_held) do + @smart_play_mouse_held + end + + m.bind(:help_text) { HELP_TEXT } + + # -- Two-way values -- + m.value(:study_mode, false) + + # -- Controller events -- + m.event(:toggle_play) do + pb = pe.get(pb_c) + next unless pb + if pb[:loaded] + if pb[:playing] + rt.audio.pause + pb[:playing] = false + else + rt.audio.resume + pb[:playing] = true + end + pe.set(pb_c, pb) + end + end + + m.event(:prev_portion) do + pb = pe.get(pb_c) + af = pe.get(af_c) + regions = rt.silence_regions + dur = af[:duration] + next unless pb && pb[:loaded] && regions && dur > 0 + pos_norm = Core.time_to_ratio(pb[:current_time], dur) + current = Core.current_speaking_portion(regions, pos_norm) + prev_portion = current - 1 + prev_portion = 0 if prev_portion < 0 + target = Core.portion_seek_target(dur, regions, prev_portion) + pb[:seek_target] = target + pb[:seek_pending] = true + pe.set(pb_c, pb) + end + + m.event(:next_portion) do + pb = pe.get(pb_c) + af = pe.get(af_c) + regions = rt.silence_regions + dur = af[:duration] + next unless pb && pb[:loaded] && regions && dur > 0 + pos_norm = Core.time_to_ratio(pb[:current_time], dur) + current = Core.current_speaking_portion(regions, pos_norm) + total = Core.total_speaking_portions(regions) + np = current + 1 + np = total - 1 if np >= total + target = Core.portion_seek_target(dur, regions, np) + pb[:seek_target] = target + pb[:seek_pending] = true + pe.set(pb_c, pb) + end + + m.event(:smart_down) do + @smart_play_mouse_held = true + pb = pe.get(pb_c) + if pb && pb[:loaded] && !pb[:playing] + rt.audio.resume + pb[:playing] = true + pe.set(pb_c, pb) + end + end + + m.event(:smart_up) do + @smart_play_mouse_held = false + end + + m.event(:seek_bar) do |ev| + pb = pe.get(pb_c) + af = pe.get(af_c) + dur = af[:duration] + next unless pb && pb[:loaded] && dur > 0 + + el = ev.current + if el + x = ev.mouse_x - el.absolute_left + ratio = (x.to_f / el.client_width).clamp(0.0, 1.0) + target = Core.ratio_to_seek(ratio, dur) + pb[:seek_target] = target + pb[:seek_pending] = true + pe.set(pb_c, pb) + end + end + end + end + end +end + +# ============================================================================= # Composition Root — init, components, systems, main loop # ============================================================================= @@ -832,13 +1083,14 @@ module StudyPlayer # Runtime: owns non-serializable handles (Rl::Music) and the flecs world. class Runtime - attr_accessor :world, :player_entity, :audio, :components + attr_accessor :world, :player_entity, :audio, :components, :ui attr_accessor :silence_regions, :raw_silence_regions, :analysis_done def initialize(world:, player_entity:, audio:, components:) @world = world @player_entity = player_entity @audio = audio @components = components + @ui = nil @silence_regions = [] @raw_silence_regions = [] @analysis_done = false @@ -849,6 +1101,7 @@ module StudyPlayer Rl.init_window(SCREEN_W, SCREEN_H, "Study Player") Rl.target_fps = 60 Rl.init_audio_device + Rml.init # --- Flecs world + components --- world = Flecs::World.new @@ -879,6 +1132,10 @@ module StudyPlayer components: comps, ) + # --- RmlUi UI --- + ui = UI.new(runtime, comps) + runtime.ui = ui + # --- Register systems --- LoadSystem.build(world, player_entity, runtime, af, pb, nl) SeekSystem.build(world, player_entity, runtime, pb) @@ -915,8 +1172,12 @@ module StudyPlayer .add(nl) end - # --- Main loop --- + # --- Main loop (RmlUi rendering) --- Rl.while_window_open do + # Process RmlUi input (mouse clicks, text) before ECS systems so + # UI events fire and update flecs state this frame. + ui.process_input + dt = Rl.frame_time # Check for quit @@ -925,121 +1186,23 @@ module StudyPlayer break end - # Run ECS systems + # Run ECS systems (keyboard input, seek, update, study) 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)) - - # --- Phase 3: Section counter (speaking portion N / total) --- - regions = runtime.silence_regions - if regions && regions.length >= 0 && duration > 0 - pos_norm = StudyPlayer::Core.time_to_ratio(pos, duration) - current_port = StudyPlayer::Core.current_speaking_portion(regions, pos_norm) - total_port = StudyPlayer::Core.total_speaking_portions(regions) - section_text = "#{current_port + 1}/#{total_port}" - Rl.draw_text(text: section_text, - x: SCREEN_W / 2 - 30, y: 450, font_size: 24, - color: Rl::Color.new(255, 200, 100, 255)) - end - - # --- Phase 4: Study mode indicator + smart play status --- - ss_current = player_entity.get(ss) - study_on = ss_current && ss_current[:study_mode] - smart_h = ss_current && ss_current[:smart_play_held] - in_sil = ss_current && ss_current[:was_in_silence] - - # Study mode indicator (top-right) - mode_text = study_on ? "STUDY MODE: ON" : "STUDY MODE: OFF" - mode_color = study_on ? Rl::Color.new(100, 255, 100, 255) : Rl::Color.new(150, 150, 150, 255) - Rl.draw_text(text: mode_text, - x: SCREEN_W - 220, y: 20, font_size: 18, - color: mode_color) - - # Smart play / hold indicator - if smart_h - Rl.draw_text(text: "S: SMART PLAY (HELD — auto-pause suppressed)", - x: SCREEN_W - 420, y: 50, font_size: 14, - color: Rl::Color.new(255, 255, 100, 255)) - end - if in_sil && study_on - Rl.draw_text(text: "[IN SILENCE REGION]", - x: SCREEN_W / 2 - 70, y: 480, font_size: 16, - color: Rl::Color.new(255, 100, 100, 255)) - end + # Sync UI data model from updated flecs state + ui.sync - # Controls help (updated for Phase 4) - Rl.draw_text(text: "SPACE: play/pause LEFT/RIGHT: -5s/+5s V/B: prev/next portion S: hold to override M: study mode ESC: quit", - x: 20, y: SCREEN_H - 60, font_size: 14, - color: Rl::Color.new(120, 120, 140, 255)) + # Read back two-way study_mode value from the checkbox + ss_current = player_entity.get(ss) + model_study = ui.model[:study_mode] + if ss_current && ss_current[:study_mode] != model_study + ss_current[:study_mode] = model_study + player_entity.set(ss, ss_current) + end - # 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 M: study mode ESC: quit", - x: SCREEN_W / 2 - 130, y: 430, font_size: 16, - color: Rl::Color.new(120, 120, 140, 255)) - end + # Render: RmlUi draws on top of the deep-slate background + Rl.draw(clear_color: Rl::Color.new(26, 26, 46, 255)) do + ui.render end end diff --git a/game/study_player/ui.rb b/game/study_player/ui.rb index 2c3452e..42794da 100644 --- a/game/study_player/ui.rb +++ b/game/study_player/ui.rb @@ -1,11 +1,251 @@ -# Study Player — RmlUi wrapper (Phase 2 stub; Phase 5 implements UI). +# Study Player — RmlUi UI wrapper (Phase 5: full RmlUi player UI). +# +# Creates the Rml::Context, data model, loads the document, and provides +# per-frame sync from flecs state to the RmlUi data model. +# +# The data model is bound by name "player" in both the RML (data-model="player") +# and here. Events from RML data-event-* attributes call back into this module, +# which mutates flecs component state (seek_target, playing, study_mode, etc.). # # See: notes/study-player-rewrite-plan.md §6 (RmlUi UI structure) +# docs/API_SPEC_RMLUI.md (RmlUi API) +# .agents/knowledge/rmlui-binding.md (known quirks: left-not-right, FBO size) module StudyPlayer class UI - def initialize(_runtime) - # Placeholder: Phase 5 loads game/study_player/ui/main.rml here. + HELP_TEXT = "SPACE: play/pause LEFT/RIGHT: ±5s V/B: prev/next portion S: hold override M: study mode 0-9: jump ESC: quit".freeze + + attr_reader :context, :model, :doc + + def initialize(runtime, components) + @runtime = runtime + @comps = components + @player_ent = runtime.player_entity + @smart_play_mouse_held = false + + # --- RmlUi setup --- + Rml.load_font("game/ui/LatoLatin-Regular.ttf") + Rml.load_font("game/ui/LatoLatin-Bold.ttf") + Rml.load_font("game/ui/MononokiNerdFontMono-Regular.ttf") + + @context = Rml::Context.new("study-player") + @model = build_model + @doc = @context.load_document("game/study_player/ui/main.rml") + @doc.show + end + + # Called by the event system to check if the mouse is holding smart-play. + def smart_play_held? + @smart_play_mouse_held + end + + # ------------------------------------------------------------------ + # Per-frame sync: marks all bound variables dirty so the view + # re-reads flecs state on the next update. + # ------------------------------------------------------------------ + def sync + @model.dirty_all + end + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + def process_input + @context.process_input + end + + def render + @context.update + @context.render + end + + private + + # ------------------------------------------------------------------ + # Build the data model + # ------------------------------------------------------------------ + def build_model + ctx = @context + af_c = @comps[:audio_file] + pb_c = @comps[:playback_state] + ss_c = @comps[:study_state] + pe = @player_ent + rt = @runtime + + ctx.data_model("player") do |m| + # -- One-way computed bindings (read each frame from flecs) -- + m.bind(:loaded) do + pb = pe.get(pb_c) + pb && pb[:loaded] ? true : false + end + + m.bind(:file_name) do + af = pe.get(af_c) + path = af[:path].to_s + path.empty? ? "" : File.basename(path) + end + + m.bind(:elapsed_str) do + pb = pe.get(pb_c) + Core.format_time(pb ? pb[:current_time] : 0.0) + end + + m.bind(:total_str) do + af = pe.get(af_c) + Core.format_time(af[:duration]) + end + + m.bind(:remain_str) do + pb = pe.get(pb_c) + af = pe.get(af_c) + rem = (af[:duration] - (pb ? pb[:current_time] : 0.0)) + rem = 0.0 if rem < 0.0 + Core.format_time(rem) + end + + m.bind(:progress_pct) do + pb = pe.get(pb_c) + af = pe.get(af_c) + dur = af[:duration] + if dur > 0 && pb + (Core.time_to_ratio(pb[:current_time], dur) * 100.0).to_i + else + 0 + end + end + + m.bind(:progress_ratio) do + pb = pe.get(pb_c) + af = pe.get(af_c) + dur = af[:duration] + ratio = (dur > 0 && pb) ? Core.time_to_ratio(pb[:current_time], dur) : 0.0 + # Return as percentage string for style="width: ...%;" + "#{(ratio * 100.0).round(1)}%" + end + + m.bind(:status_text) do + pb = pe.get(pb_c) + (pb && pb[:playing]) ? "PLAYING" : "PAUSED" + end + + m.bind(:status_alert) do + pb = pe.get(pb_c) + ss = pe.get(ss_c) + (pb && pb[:playing] && ss && ss[:was_in_silence]) ? true : false + end + + m.bind(:playing) do + pb = pe.get(pb_c) + pb && pb[:playing] ? true : false + end + + m.bind(:portion_label) do + pb = pe.get(pb_c) + af = pe.get(af_c) + regions = rt.silence_regions + dur = af[:duration] + if pb && regions && regions.length >= 0 && dur > 0 + pos_norm = Core.time_to_ratio(pb[:current_time], dur) + current = Core.current_speaking_portion(regions, pos_norm) + total = Core.total_speaking_portions(regions) + "#{current + 1}/#{total}" + else + "-/-" + end + end + + m.bind(:smart_play_held) do + @smart_play_mouse_held + end + + m.bind(:help_text) { HELP_TEXT } + + # -- Two-way values -- + m.value(:study_mode, false) + + # -- Controller events -- + m.event(:toggle_play) do + pb = pe.get(pb_c) + next unless pb + if pb[:loaded] + if pb[:playing] + rt.audio.pause + pb[:playing] = false + else + rt.audio.resume + pb[:playing] = true + end + pe.set(pb_c, pb) + end + end + + m.event(:prev_portion) do + pb = pe.get(pb_c) + af = pe.get(af_c) + regions = rt.silence_regions + dur = af[:duration] + next unless pb && pb[:loaded] && regions && dur > 0 + pos_norm = Core.time_to_ratio(pb[:current_time], dur) + current = Core.current_speaking_portion(regions, pos_norm) + prev_portion = current - 1 + prev_portion = 0 if prev_portion < 0 + target = Core.portion_seek_target(dur, regions, prev_portion) + pb[:seek_target] = target + pb[:seek_pending] = true + pe.set(pb_c, pb) + end + + m.event(:next_portion) do + pb = pe.get(pb_c) + af = pe.get(af_c) + regions = rt.silence_regions + dur = af[:duration] + next unless pb && pb[:loaded] && regions && dur > 0 + pos_norm = Core.time_to_ratio(pb[:current_time], dur) + current = Core.current_speaking_portion(regions, pos_norm) + total = Core.total_speaking_portions(regions) + np = current + 1 + np = total - 1 if np >= total + target = Core.portion_seek_target(dur, regions, np) + pb[:seek_target] = target + pb[:seek_pending] = true + pe.set(pb_c, pb) + end + + m.event(:smart_down) do + @smart_play_mouse_held = true + # Auto-play if paused (matching original behavior) + pb = pe.get(pb_c) + if pb && pb[:loaded] && !pb[:playing] + rt.audio.resume + pb[:playing] = true + pe.set(pb_c, pb) + end + end + + m.event(:smart_up) do + @smart_play_mouse_held = false + end + + m.event(:seek_bar) do |ev| + pb = pe.get(pb_c) + af = pe.get(af_c) + dur = af[:duration] + next unless pb && pb[:loaded] && dur > 0 + + # Compute seek ratio from click position on the progress bar. + # The event target is the #progress-bar-bg element. + el = ev.current + if el + x = ev.mouse_x - el.absolute_left + ratio = (x.to_f / el.client_width).clamp(0.0, 1.0) + target = Core.ratio_to_seek(ratio, dur) + pb[:seek_target] = target + pb[:seek_pending] = true + pe.set(pb_c, pb) + end + end + end end end end diff --git a/game/study_player/ui/main.rcss b/game/study_player/ui/main.rcss index 3fa724d..aee9f87 100644 --- a/game/study_player/ui/main.rcss +++ b/game/study_player/ui/main.rcss @@ -1,17 +1,295 @@ -/* Phase 2: minimal stylesheet stub. Phase 5 adds the full UI. */ +/* Study Player — RmlUi stylesheet. + * + * Colors (from original source): + * bg: #1a1a2e deep slate + * text: #eaeaea near-white + * accent: #e94560 red-pink + * muted: #8c8ca0 grey-blue + * bar-bg: #3c3c3c dark grey + * alert: #a02837 dark red (playing in silence) + * held-bg: #501e32 dark red-purple (smart-play held) + * + * Layout (absolute positioning for 1280x720): + * Use left: only (NOT right: — RmlUi miscomputes it; see rmlui-binding.md). + */ + +/* ------------------------------------------------------------------ */ +/* Document body */ +/* ------------------------------------------------------------------ */ 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; + width: 100%; + height: 100%; + background-color: #1a1a2e; + font-family: Mononoki Nerd Font Mono; + color: #eaeaea; + font-size: 18px; + margin: 0; + padding: 0; +} + +/* ------------------------------------------------------------------ */ +/* Splash view */ +/* ------------------------------------------------------------------ */ +#splash { + position: absolute; + top: 200px; + left: 200px; + width: 880px; + height: 320px; + text-align: center; +} + +#splash-title { + font-size: 72px; + color: #eaeaea; + text-align: center; + margin-bottom: 40px; +} + +#splash-sub { + font-size: 28px; + color: #e94560; + text-align: center; + margin-bottom: 30px; +} + +#splash-hint { + font-size: 18px; + color: #8c8ca0; + text-align: center; +} + +/* ------------------------------------------------------------------ */ +/* Player view */ +/* ------------------------------------------------------------------ */ +#player { + display: block; +} + +/* --- Filename / title --- */ +#title { + position: absolute; + top: 30px; + left: 0px; + width: 1280px; + font-size: 28px; + color: #8c8ca0; + text-align: center; +} + +/* --- Progress bar area --- */ +#progress-area { + display: block; +} + +#elapsed { + position: absolute; + top: 277px; + left: 80px; + width: 120px; + font-size: 20px; + color: #eaeaea; + text-align: right; +} + +#remaining { + position: absolute; + top: 277px; + left: 1080px; + width: 120px; + font-size: 20px; + color: #eaeaea; + text-align: left; +} + +#progress-pct { + position: absolute; + top: 260px; + left: 0px; + width: 1280px; + font-size: 20px; + color: #eaeaea; + text-align: center; +} + +#progress-bar-bg { + position: absolute; + top: 310px; + left: 100px; + width: 1080px; + height: 18px; + background-color: #3c3c3c; + border-radius: 9px; +} + +#progress-bar-fill { + position: absolute; + top: 0px; + left: 0px; + height: 18px; + background-color: #e94560; + border-radius: 9px; +} + +/* --- Status text --- */ +#status { + position: absolute; + top: 355px; + left: 0px; + width: 1280px; + font-size: 22px; + color: #e94560; + text-align: center; +} + +#status.alert { + color: #a02837; +} + +/* --- Play/Pause button (large circle) --- */ +#btn-play-pause { + position: absolute; + top: 410px; + left: 570px; + width: 140px; + height: 140px; + border-width: 3px; + border-color: #e94560; + border-radius: 70px; + background-color: transparent; + color: #eaeaea; + font-size: 36px; + text-align: center; + cursor: pointer; +} + +#btn-play-pause:hover { + background-color: rgba(255, 255, 255, 0.08); +} + +/* Play/pause icons inside the button */ +#icon-play, #icon-pause { + display: block; + font-size: 40px; + color: #eaeaea; + text-align: center; +} + +/* --- Section navigation --- */ +#section-nav { + display: block; +} + +#section-counter { + position: absolute; + top: 480px; + left: 380px; + width: 130px; + font-size: 26px; + color: #8c8ca0; + text-align: center; +} + +#btn-prev { + position: absolute; + top: 480px; + left: 340px; + width: 36px; + height: 36px; + border-width: 2px; + border-color: #8c8ca0; + border-radius: 18px; + background-color: transparent; + color: #eaeaea; + font-size: 16px; + text-align: center; + cursor: pointer; +} + +#btn-prev:hover { + background-color: rgba(255, 255, 255, 0.06); +} + +#btn-next { + position: absolute; + top: 480px; + left: 512px; + width: 36px; + height: 36px; + border-width: 2px; + border-color: #8c8ca0; + border-radius: 18px; + background-color: transparent; + color: #eaeaea; + font-size: 16px; + text-align: center; + cursor: pointer; +} + +#btn-next:hover { + background-color: rgba(255, 255, 255, 0.06); +} + +/* --- Smart Play button --- */ +#btn-smart { + position: absolute; + top: 570px; + left: 500px; + width: 280px; + height: 60px; + border-width: 2px; + border-color: #8c8ca0; + border-radius: 12px; + background-color: rgba(50, 50, 70, 0.7); + color: #eaeaea; + font-size: 24px; + text-align: center; + cursor: pointer; +} + +#btn-smart:hover { + border-color: #e94560; +} + +#btn-smart.held { + border-color: #e94560; + background-color: rgba(80, 30, 50, 0.85); + color: #e94560; +} + +/* --- Bottom bar (always visible) --- */ +#bottom-bar { + display: block; +} + +#help-text { + position: absolute; + top: 688px; + left: 30px; + width: 950px; + font-size: 15px; + color: #8c8ca0; +} + +#study-mode-box { + position: absolute; + top: 684px; + left: 1040px; + width: 210px; + height: 30px; +} + +#cb-study { + display: inline-block; + width: 22px; + height: 22px; + vertical-align: middle; +} + +#study-label { + display: inline-block; + font-size: 16px; + color: #8c8ca0; + vertical-align: middle; + margin-left: 8px; } diff --git a/game/study_player/ui/main.rml b/game/study_player/ui/main.rml index 38d0c54..f19f45c 100644 --- a/game/study_player/ui/main.rml +++ b/game/study_player/ui/main.rml @@ -1,12 +1,66 @@ <rml> <head> - <title>Study Player</title> - <link type="text/rcss" href="main.rcss"/> + <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 data-model="player"> + <!-- ================================================================ --> + <!-- Splash view: shown when no file is loaded --> + <!-- ================================================================ --> + <div id="splash" data-if="!loaded"> + <div id="splash-title">Study Player</div> + <div id="splash-sub">Drop an audio file or run with audio arg</div> + <div id="splash-hint">SPACE: start M: study mode ESC: quit</div> + </div> + + <!-- ================================================================ --> + <!-- Player view: shown when a file is loaded --> + <!-- ================================================================ --> + <div id="player" data-if="loaded"> + <!-- Filename / title --> + <div id="title">{{file_name}}</div> + + <!-- Progress bar area --> + <div id="progress-area"> + <div id="elapsed">{{elapsed_str}}</div> + <div id="progress-bar-bg" data-event-click="seek_bar()"> + <div id="progress-bar-fill"></div> + </div> + <div id="remaining">{{remain_str}}</div> + <div id="progress-pct">{{progress_pct}}%</div> + </div> + + <!-- Status text: PLAYING / PAUSED --> + <div id="status" data-class-alert="status_alert">{{status_text}}</div> + + <!-- Play / Pause button --> + <button id="btn-play-pause" data-event-click="toggle_play()"> + <span id="icon-play" data-if="!playing">▶</span> + <span id="icon-pause" data-if="playing">❚❚</span> + </button> + + <!-- Section navigation: prev / counter / next --> + <div id="section-nav"> + <button id="btn-prev" data-event-click="prev_portion()">◀</button> + <div id="section-counter">{{portion_label}}</div> + <button id="btn-next" data-event-click="next_portion()">▶</button> + </div> + + <!-- Smart Play hold button --> + <button id="btn-smart" + data-class-held="smart_play_held" + data-event-mousedown="smart_down()" + data-event-mouseup="smart_up()">Smart Play</button> + </div> + + <!-- ================================================================ --> + <!-- Bottom bar: always visible (help text + study mode checkbox) --> + <!-- ================================================================ --> + <div id="bottom-bar"> + <div id="help-text">{{help_text}}</div> + <div id="study-mode-box"> + <input type="checkbox" id="cb-study" data-value="study_mode"/> + <span id="study-label">Study Mode</span> + </div> + </div> </body> </rml> |
