diff --git a/dirplayer-js-api/index.d.ts b/dirplayer-js-api/index.d.ts index 5800ae05b..b4ce107ce 100644 --- a/dirplayer-js-api/index.d.ts +++ b/dirplayer-js-api/index.d.ts @@ -50,6 +50,7 @@ type TVmCallbacks = { onScriptInstanceSnapshot: (scriptInstanceRef: ScriptInstanceId, scriptInstance: JsBridgeDatum) => void, onChannelChanged: (channelNumber: number, channelData: ScoreSpriteSnapshot) => void, onChannelDisplayNameChanged: (channelNumber: number, displayName: string) => void, + onPlaybackStateChanged?: (isPlaying: boolean) => void, onExternalEvent?: (event: string) => void, } declare let vmCallbacks: TVmCallbacks | undefined; diff --git a/dirplayer-js-api/index.js b/dirplayer-js-api/index.js index 805578cbb..ae257fb4e 100644 --- a/dirplayer-js-api/index.js +++ b/dirplayer-js-api/index.js @@ -87,6 +87,10 @@ export function onChannelDisplayNameChanged(channel, displayName) { vmCallbacks.onChannelDisplayNameChanged(channel, displayName) } +export function onPlaybackStateChanged(isPlaying) { + vmCallbacks?.onPlaybackStateChanged?.(isPlaying) +} + export function onExternalEvent(event) { if (vmCallbacks?.onExternalEvent) { vmCallbacks.onExternalEvent(event); diff --git a/src/components/IconButton/index.tsx b/src/components/IconButton/index.tsx index 6a12507cd..760684410 100644 --- a/src/components/IconButton/index.tsx +++ b/src/components/IconButton/index.tsx @@ -8,13 +8,14 @@ interface IIconButtonProps { onClick: () => void, disabled?: boolean, title?: string, + active?: boolean, } interface IFontAwesomeIconButtonProps extends IIconButtonProps { icon: IconProp, } -export default function FontAwesomeIconButton({ icon, onClick, disabled, title }: IFontAwesomeIconButtonProps) { - return +export default function FontAwesomeIconButton({ icon, onClick, disabled, title, active }: IFontAwesomeIconButtonProps) { + return } @@ -27,8 +28,8 @@ export function ReactIconButton({ icon: IconComponent, onClick, disabled, title } -function BaseIconButton({ onClick, disabled, title, children }: PropsWithChildren) { - return } diff --git a/src/components/IconButton/styles.module.css b/src/components/IconButton/styles.module.css index 807880d33..041fe66a4 100644 --- a/src/components/IconButton/styles.module.css +++ b/src/components/IconButton/styles.module.css @@ -11,3 +11,9 @@ background-color: #ddd; } } + +.iconButtonActive { + composes: iconButton; + background-color: #c8c8d0; + border-color: #99a; +} diff --git a/src/components/PlaybackControls/index.tsx b/src/components/PlaybackControls/index.tsx index 4ea2ecdff..82d6f0d9d 100644 --- a/src/components/PlaybackControls/index.tsx +++ b/src/components/PlaybackControls/index.tsx @@ -1,10 +1,11 @@ import { useState } from 'react' -import { faPlay, faStop, faRotateBack } from '@fortawesome/free-solid-svg-icons' +import { faPlay, faStop, faBackwardStep } from '@fortawesome/free-solid-svg-icons' import IconButton from '../IconButton' import styles from './styles.module.css' -import { play, stop, reset } from 'vm-rust' +import { play, stop, rewind } from 'vm-rust' import { isElectron } from '../../utils/electron' import { isMcpEnabled, setMcpEnabled, getMcpPort, setMcpPort, getMcpUrl } from '../../mcp' +import { useAppSelector } from '../../store/hooks' function McpToggle() { const [enabled, setEnabled] = useState(() => isMcpEnabled()); @@ -62,10 +63,12 @@ function McpToggle() { } export default function PlaybackControls() { + const isPlaying = useAppSelector(state => state.vm.isPlaying); + return
- { play() }} /> - { stop() }} /> - { reset() }} /> + { rewind() }} title="Rewind" /> + { stop() }} active={!isPlaying} title="Stop" /> + { play() }} active={isPlaying} title="Play" /> {isElectron() && <>
diff --git a/src/components/PlaybackControls/styles.module.css b/src/components/PlaybackControls/styles.module.css index a02e2a9ae..9a2592843 100644 --- a/src/components/PlaybackControls/styles.module.css +++ b/src/components/PlaybackControls/styles.module.css @@ -2,7 +2,7 @@ padding: 8px 16px; display: flex; flex-direction: row; - gap: 16px; + gap: 8px; justify-content: flex-start; align-items: center; } diff --git a/src/store/vmSlice.ts b/src/store/vmSlice.ts index 5417c3c6d..24eeefeef 100644 --- a/src/store/vmSlice.ts +++ b/src/store/vmSlice.ts @@ -27,6 +27,7 @@ interface VMSliceState { scriptInstanceSnapshots: Record, channelSnapshots: Record, subscribedMemberTokens: TMemberSubscription[], + isPlaying: boolean, isMovieLoaded: boolean, debugMessages: DebugMessage[], } @@ -44,6 +45,7 @@ const initialState: VMSliceState = { scriptInstanceSnapshots: {}, channelSnapshots: {}, subscribedMemberTokens: [], + isPlaying: false, isMovieLoaded: false, debugMessages: [], } @@ -225,6 +227,12 @@ const vmSlice = createSlice({ subscribedMemberTokens: state.subscribedMemberTokens.filter(t => t.id !== action.payload), } }, + playbackStateChanged: (state, action: PayloadAction) => { + return { + ...state, + isPlaying: action.payload, + } + }, movieLoaded: (state) => { return { ...state, @@ -271,5 +279,5 @@ export const selectGlobals = (state: VMSliceState) => state.globals export const selectDebugMessages = (state: VMSliceState) => state.debugMessages // Action creators are generated for each case reducer function -export const { ready, castListChanged, castLibNameChanged, castMemberListChanged, scoreChanged, frameChanged, scopeListChanged, onScriptError, breakpointListChanged, scriptErrorCleared, globalsChanged, setTimeoutHandle, removeTimeoutHandle, datumSnapshot, scriptInstanceSnapshot, channelChanged, memberSubscribed, memberUnsubscribed, castMemberChanged, channelDisplayNameChanged, movieLoaded, movieUnloaded, debugMessageAdded, debugContentAdded, debugMessagesCleared } = vmSlice.actions +export const { ready, castListChanged, castLibNameChanged, castMemberListChanged, scoreChanged, frameChanged, scopeListChanged, onScriptError, breakpointListChanged, scriptErrorCleared, globalsChanged, setTimeoutHandle, removeTimeoutHandle, datumSnapshot, scriptInstanceSnapshot, channelChanged, memberSubscribed, memberUnsubscribed, castMemberChanged, channelDisplayNameChanged, playbackStateChanged, movieLoaded, movieUnloaded, debugMessageAdded, debugContentAdded, debugMessagesCleared } = vmSlice.actions export default vmSlice.reducer diff --git a/src/vm/callbacks.ts b/src/vm/callbacks.ts index 3681244f4..4aabacbdb 100644 --- a/src/vm/callbacks.ts +++ b/src/vm/callbacks.ts @@ -1,6 +1,6 @@ import { ICastMemberRef, JsBridgeBreakpoint, OnScriptErrorData, registerVmCallbacks } from "dirplayer-js-api"; import store from "../store"; -import { breakpointListChanged, castLibNameChanged, castListChanged, castMemberChanged, castMemberListChanged, channelChanged, channelDisplayNameChanged, datumSnapshot, debugContentAdded, debugMessageAdded, debugMessagesCleared, frameChanged, globalsChanged, movieLoaded, onScriptError, removeTimeoutHandle, scopeListChanged, scoreChanged, scriptErrorCleared, scriptInstanceSnapshot, setTimeoutHandle } from "../store/vmSlice"; +import { breakpointListChanged, castLibNameChanged, castListChanged, castMemberChanged, castMemberListChanged, channelChanged, channelDisplayNameChanged, datumSnapshot, debugContentAdded, debugMessageAdded, debugMessagesCleared, frameChanged, globalsChanged, movieLoaded, onScriptError, playbackStateChanged, removeTimeoutHandle, scopeListChanged, scoreChanged, scriptErrorCleared, scriptInstanceSnapshot, setTimeoutHandle } from "../store/vmSlice"; import { OnMovieLoadedCallbackData, trigger_timeout } from 'vm-rust' import { DatumRef, IVMScope, JsBridgeDatum, MemberSnapshot, ScoreSnapshot, ScoreSpriteSnapshot } from "."; import { onMemberSelected } from "../store/uiSlice"; @@ -98,6 +98,9 @@ export function initVmCallbacks() { }, onChannelDisplayNameChanged: (channelNumber: number, displayName: string) => { store.dispatch(channelDisplayNameChanged({ channelNumber, displayName })); - } + }, + onPlaybackStateChanged: (isPlaying: boolean) => { + store.dispatch(playbackStateChanged(isPlaying)); + }, }); } diff --git a/vm-rust/src/js_api.rs b/vm-rust/src/js_api.rs index ae3e4cc0d..83936bf2e 100644 --- a/vm-rust/src/js_api.rs +++ b/vm-rust/src/js_api.rs @@ -209,6 +209,7 @@ extern "C" { pub fn onChannelChanged(channel: i16, snapshot: js_sys::Object); pub fn onChannelDisplayNameChanged(channel: i16, display_name: &str); pub fn onFrameChanged(frame: u32); + pub fn onPlaybackStateChanged(is_playing: bool); pub fn onScriptError(data: js_sys::Object); pub fn onScopeListChanged(scopes: Vec); pub fn onBreakpointListChanged(data: Vec); @@ -957,6 +958,10 @@ impl JsApi { onFrameChanged(frame); } + pub fn dispatch_playback_state_changed(is_playing: bool) { + onPlaybackStateChanged(is_playing); + } + pub fn dispatch_debug_message(message: &str) { onDebugMessage(&&safe_string(message)); } @@ -1563,6 +1568,7 @@ impl JsApi { pub fn dispatch_score_changed() {} pub fn dispatch_channel_changed(_: i16) {} pub fn dispatch_frame_changed(_: u32) {} + pub fn dispatch_playback_state_changed(_: bool) {} pub fn dispatch_debug_message(_: &str) {} pub fn dispatch_debug_content(_: js_sys::Object) {} pub fn dispatch_debug_bitmap(_: u32, _: u32, _: &[u8]) {} diff --git a/vm-rust/src/lib.rs b/vm-rust/src/lib.rs index f568792da..733215d15 100644 --- a/vm-rust/src/lib.rs +++ b/vm-rust/src/lib.rs @@ -82,10 +82,23 @@ pub fn stop() { } #[wasm_bindgen] -pub fn reset() { - reserve_player_mut(|player| { - player.reset(); +pub fn is_playing() -> bool { + reserve_player_ref(|player| player.is_playing) +} + +#[wasm_bindgen] +pub fn rewind() { + let was_playing = reserve_player_mut(|player| { + let was = player.is_playing; + player.stop(); + player.pending_rewind = true; + was }); + if !was_playing { + spawn_local(async move { + player::perform_rewind().await; + }); + } } // Debug commands bypass the command queue to avoid deadlocks when a breakpoint diff --git a/vm-rust/src/player/bitmap/manager.rs b/vm-rust/src/player/bitmap/manager.rs index 58de205e4..7e9daa019 100644 --- a/vm-rust/src/player/bitmap/manager.rs +++ b/vm-rust/src/player/bitmap/manager.rs @@ -18,6 +18,11 @@ impl BitmapManager { } } + pub fn reset(&mut self) { + self.bitmaps.clear(); + self.ref_counter = 0; + } + pub fn add_bitmap(&mut self, bitmap: Bitmap) -> BitmapRef { self.ref_counter += 1; diff --git a/vm-rust/src/player/handlers/datum_handlers/sound_channel.rs b/vm-rust/src/player/handlers/datum_handlers/sound_channel.rs index 3daf1cf03..174e67522 100644 --- a/vm-rust/src/player/handlers/datum_handlers/sound_channel.rs +++ b/vm-rust/src/player/handlers/datum_handlers/sound_channel.rs @@ -2601,22 +2601,17 @@ impl SoundChannel { if let Some(ref source) = self.source_node { let _ = source.stop_with_when(0.0); let _ = source.disconnect(); - debug!("🛑 Stopped previous sound"); } - self.source_node = None; } pub fn pause(&mut self) { if self.status == SoundStatus::Playing { self.status = SoundStatus::Paused; - - if let Some(ref source) = self.source_node { - // Suspend the audio context — stops all nodes temporarily + if self.source_node.is_some() { if let Some(ref ctx) = self.audio_context { let _ = ctx.suspend(); } - debug!("⏸️ Paused playback"); } } } @@ -2624,14 +2619,10 @@ impl SoundChannel { pub fn resume(&mut self) { if self.status == SoundStatus::Paused { self.status = SoundStatus::Playing; - // Don't reset playback_start_context_time on resume - original start is still valid - - if let Some(ref source) = self.source_node { - // Resume the AudioContext + if self.source_node.is_some() { if let Some(ref ctx) = self.audio_context { let _ = ctx.resume(); } - debug!("▶️ Resumed playback"); } } } diff --git a/vm-rust/src/player/mod.rs b/vm-rust/src/player/mod.rs index 189195e02..54ae1fa19 100644 --- a/vm-rust/src/player/mod.rs +++ b/vm-rust/src/player/mod.rs @@ -265,6 +265,8 @@ pub struct DirPlayer { /// Pending gotoNetMovie operation: (task_id, frame_destination). /// Overwritten by subsequent gotoNetMovie/go-to-movie calls (cancels previous). pub pending_goto_net_movie: Option<(u32, MovieFrameTarget)>, + pub pending_rewind: bool, + pub movie_initialized: bool, /// True while a net movie transition is in progress. /// Prevents the event loop from dispatching external events during the transition. pub is_in_transition: bool, @@ -409,6 +411,8 @@ impl DirPlayer { eval_scope_index: None, delay_until: None, pending_goto_net_movie: None, + pending_rewind: false, + movie_initialized: false, is_in_transition: false, script_instance_list_cache: FxHashMap::default(), last_sprite_prop_ref: None, @@ -489,13 +493,24 @@ impl DirPlayer { } self.is_playing = true; self.is_script_paused = false; + JsApi::dispatch_playback_state_changed(true); + + let resume = self.movie_initialized; use crate::js_api::safe_string; - debug!("Loading Movie: {} (version: {})", safe_string(&self.movie.file_name), self.movie.dir_version); + debug!("Loading Movie: {} (version: {}) resume={}", safe_string(&self.movie.file_name), self.movie.dir_version, resume); async_std::task::spawn_local(async move { - run_movie_init_sequence().await; + if !resume { + run_movie_init_sequence().await; + } run_frame_loop().await; + // If a rewind was requested during init or after the frame loop exited, + // process it now that no other async task is competing. + let should_rewind = reserve_player_ref(|player| player.pending_rewind); + if should_rewind { + perform_rewind().await; + } }); } @@ -824,15 +839,10 @@ impl DirPlayer { } pub fn stop(&mut self) { - // TODO dispatch stop movie self.is_playing = false; self.next_frame = None; - //scopes.clear(); - // currentBreakpoint?.completer.completeError(CancelledException()); - // currentBreakpoint = null; - //self.timeout_manager.clear(); - //notifyListeners(); - + self.sound_manager.stop_all(); + JsApi::dispatch_playback_state_changed(false); warn!("Profiler report: {}", get_profiler_report()); } @@ -840,7 +850,7 @@ impl DirPlayer { self.stop(); // Clear all references before resetting the allocator - // This ensures all DatumRef and ScriptInstanceRef objects are dropped properly + // to ensure DatumRef and ScriptInstanceRef objects are dropped properly debug!("Clearing scopes"); self.scopes.clear(); debug!("Clearing globals"); @@ -849,15 +859,23 @@ impl DirPlayer { self.timeout_manager.clear(); debug!("Clearing debug datum refs"); self.debug_datum_refs.clear(); - // netManager.clear(); debug!("Resetting score"); self.movie.score.reset(); self.script_instance_list_cache.clear(); self.movie.current_frame = 1; - // TODO cancel breakpoints + self.movie.frame_script_instance = None; + self.movie.frame_script_member = None; + self.last_initialized_frame = None; self.current_breakpoint = None; self.scope_count = 0; self.pending_goto_net_movie = None; + self.pending_rewind = false; + self.movie_initialized = false; + self.virtual_scripts.clear(); + self.sound_manager.stop_all(); + // Note: bitmap_manager is intentionally not reset here. The font manager + // holds bitmap_refs (e.g. system font) that would become dangling. + // Movie bitmaps get replaced by load_movie_from_dir on reload. debug!("Resetting allocator"); // Now it's safe to reset the allocator @@ -2489,12 +2507,17 @@ async fn stop_movie_sequence() { player_wait_available().await; } +fn should_cancel_init() -> bool { + reserve_player_ref(|player| player.pending_rewind || !player.is_playing) +} + /// Run the movie initialization sequence: prepareMovie, beginSprite, behavior init, /// stepFrame, prepareFrame, startMovie, enterFrame, exitFrame. /// Shared by `play()` and `transition_to_net_movie`. async fn run_movie_init_sequence() { // prepareMovie dispatch_system_event_to_timeouts(&"prepareMovie".to_string(), &vec![]).await; + if should_cancel_init() { return; } if let Err(err) = player_invoke_global_event(&"prepareMovie".to_string(), &vec![]).await { if err.code != ScriptErrorCode::Abort { @@ -2504,10 +2527,12 @@ async fn run_movie_init_sequence() { } player_wait_available().await; + if should_cancel_init() { return; } // Dispatch streamStatus for any resources already loaded by the time prepareMovie // enables the handler (movie file, external casts, etc.) stream_status::dispatch_pending_stream_status().await; + if should_cancel_init() { return; } // Initialize sprites reserve_player_mut(|player| { @@ -2517,6 +2542,7 @@ async fn run_movie_init_sequence() { }); player_wait_available().await; + if should_cancel_init() { return; } // Collect behaviors that need initialization let behaviors_to_init: Vec<(ScriptInstanceRef, u32)> = reserve_player_mut(|player| { @@ -2548,6 +2574,7 @@ async fn run_movie_init_sequence() { } player_wait_available().await; + if should_cancel_init() { return; } reserve_player_mut(|player| { player.is_in_frame_update = true; @@ -2557,8 +2584,10 @@ async fn run_movie_init_sequence() { &"beginSprite".to_string(), &vec![] ).await; + if should_cancel_init() { return; } player_wait_available().await; + if should_cancel_init() { return; } reserve_player_mut(|player| { for sprite_list in begin_sprite_nums.iter() { @@ -2613,6 +2642,7 @@ async fn run_movie_init_sequence() { } player_wait_available().await; + if should_cancel_init() { return; } // stepFrame to actorList let actor_list_snapshot = reserve_player_ref(|player| { @@ -2660,7 +2690,9 @@ async fn run_movie_init_sequence() { }); dispatch_system_event_to_timeouts(&"prepareFrame".to_string(), &vec![]).await; + if should_cancel_init() { return; } let _ = dispatch_event_to_all_behaviors(&"prepareFrame".to_string(), &vec![]).await; + if should_cancel_init() { return; } reserve_player_mut(|player| { player.in_prepare_frame = false; @@ -2668,6 +2700,7 @@ async fn run_movie_init_sequence() { // startMovie dispatch_system_event_to_timeouts(&"startMovie".to_string(), &vec![]).await; + if should_cancel_init() { return; } if let Err(err) = player_invoke_global_event(&"startMovie".to_string(), &vec![]).await { if err.code != ScriptErrorCode::Abort { @@ -2677,6 +2710,7 @@ async fn run_movie_init_sequence() { } player_wait_available().await; + if should_cancel_init() { return; } // enterFrame reserve_player_mut(|player| { @@ -2684,21 +2718,27 @@ async fn run_movie_init_sequence() { }); let _ = dispatch_event_to_all_behaviors(&"enterFrame".to_string(), &vec![]).await; + if should_cancel_init() { return; } reserve_player_mut(|player| { player.in_enter_frame = false; }); player_wait_available().await; + if should_cancel_init() { return; } // exitFrame dispatch_system_event_to_timeouts(&"exitFrame".to_string(), &vec![]).await; + if should_cancel_init() { return; } let _ = dispatch_event_to_all_behaviors(&"exitFrame".to_string(), &vec![]).await; + if should_cancel_init() { return; } player_wait_available().await; + if should_cancel_init() { return; } reserve_player_mut(|player| { player.is_in_frame_update = false; + player.movie_initialized = true; }); } @@ -3136,6 +3176,46 @@ pub async fn run_single_frame() -> (bool, bool) { (is_playing, is_script_paused) } +/// Reload the movie from the stored DirectorFile and stop at frame 1. +/// Called from the frame loop when `pending_rewind` is set, or directly +/// when the movie is not playing (e.g. after an error). +pub async fn perform_rewind() { + let was_playing = reserve_player_ref(|player| player.is_playing); + + reserve_player_mut(|player| { + player.is_playing = false; + player.is_in_transition = true; + }); + + if was_playing { + stop_movie_sequence().await; + } + + let dir_file = reserve_player_mut(|player| { + player.movie.file.take() + }); + + let Some(dir_file) = dir_file else { + log::warn!("rewind: no DirectorFile stored, cannot reload"); + reserve_player_mut(|player| { player.is_in_transition = false; }); + return; + }; + + reserve_player_mut(|player| { + player.reset(); + }); + + reserve_player_mut_async(|player| { + Box::pin(async move { + player.load_movie_from_dir(dir_file).await; + }) + }).await; + + reserve_player_mut(|player| { + player.is_in_transition = false; + }); +} + pub async fn run_frame_loop() { unsafe { let player = PLAYER_OPT.as_ref().unwrap(); @@ -3174,6 +3254,16 @@ pub async fn run_frame_loop() { continue; } + let should_rewind = reserve_player_ref(|player| player.pending_rewind); + if should_rewind { + perform_rewind().await; + (is_playing, _) = reserve_player_ref(|player| { + (player.is_playing, player.is_script_paused) + }); + last_frame_time = chrono::Local::now(); + continue; + } + // Run one frame cycle (scripts + advance) let (playing, is_script_paused) = run_single_frame().await; is_playing = playing;