Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions dirplayer-js-api/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions dirplayer-js-api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
9 changes: 5 additions & 4 deletions src/components/IconButton/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <BaseIconButton onClick={onClick} disabled={disabled} title={title}>
export default function FontAwesomeIconButton({ icon, onClick, disabled, title, active }: IFontAwesomeIconButtonProps) {
return <BaseIconButton onClick={onClick} disabled={disabled} title={title} active={active}>
<FontAwesomeIcon icon={icon} />
</BaseIconButton>
}
Expand All @@ -27,8 +28,8 @@ export function ReactIconButton({ icon: IconComponent, onClick, disabled, title
}


function BaseIconButton({ onClick, disabled, title, children }: PropsWithChildren<IIconButtonProps>) {
return <button className={styles.iconButton} onClick={onClick} disabled={disabled} title={title}>
function BaseIconButton({ onClick, disabled, title, active, children }: PropsWithChildren<IIconButtonProps>) {
return <button className={active ? styles.iconButtonActive : styles.iconButton} onClick={onClick} disabled={disabled} title={title}>
{children}
</button>
}
6 changes: 6 additions & 0 deletions src/components/IconButton/styles.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,9 @@
background-color: #ddd;
}
}

.iconButtonActive {
composes: iconButton;
background-color: #c8c8d0;
border-color: #99a;
}
13 changes: 8 additions & 5 deletions src/components/PlaybackControls/index.tsx
Original file line number Diff line number Diff line change
@@ -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());
Expand Down Expand Up @@ -62,10 +63,12 @@ function McpToggle() {
}

export default function PlaybackControls() {
const isPlaying = useAppSelector(state => state.vm.isPlaying);

return <div className={styles.container}>
<IconButton icon={faPlay} onClick={() => { play() }} />
<IconButton icon={faStop} onClick={() => { stop() }} />
<IconButton icon={faRotateBack} onClick={() => { reset() }} />
<IconButton icon={faBackwardStep} onClick={() => { rewind() }} title="Rewind" />
<IconButton icon={faStop} onClick={() => { stop() }} active={!isPlaying} title="Stop" />
<IconButton icon={faPlay} onClick={() => { play() }} active={isPlaying} title="Play" />
{isElectron() && <>
<div className={styles.spacer} />
<McpToggle />
Expand Down
2 changes: 1 addition & 1 deletion src/components/PlaybackControls/styles.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
padding: 8px 16px;
display: flex;
flex-direction: row;
gap: 16px;
gap: 8px;
justify-content: flex-start;
align-items: center;
}
Expand Down
10 changes: 9 additions & 1 deletion src/store/vmSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ interface VMSliceState {
scriptInstanceSnapshots: Record<ScriptInstanceId, JsBridgeDatum>,
channelSnapshots: Record<number, ScoreSpriteSnapshot>,
subscribedMemberTokens: TMemberSubscription[],
isPlaying: boolean,
isMovieLoaded: boolean,
debugMessages: DebugMessage[],
}
Expand All @@ -44,6 +45,7 @@ const initialState: VMSliceState = {
scriptInstanceSnapshots: {},
channelSnapshots: {},
subscribedMemberTokens: [],
isPlaying: false,
isMovieLoaded: false,
debugMessages: [],
}
Expand Down Expand Up @@ -225,6 +227,12 @@ const vmSlice = createSlice({
subscribedMemberTokens: state.subscribedMemberTokens.filter(t => t.id !== action.payload),
}
},
playbackStateChanged: (state, action: PayloadAction<boolean>) => {
return {
...state,
isPlaying: action.payload,
}
},
movieLoaded: (state) => {
return {
...state,
Expand Down Expand Up @@ -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
7 changes: 5 additions & 2 deletions src/vm/callbacks.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -98,6 +98,9 @@ export function initVmCallbacks() {
},
onChannelDisplayNameChanged: (channelNumber: number, displayName: string) => {
store.dispatch(channelDisplayNameChanged({ channelNumber, displayName }));
}
},
onPlaybackStateChanged: (isPlaying: boolean) => {
store.dispatch(playbackStateChanged(isPlaying));
},
});
}
6 changes: 6 additions & 0 deletions vm-rust/src/js_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<js_sys::Object>);
pub fn onBreakpointListChanged(data: Vec<js_sys::Object>);
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -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]) {}
Expand Down
19 changes: 16 additions & 3 deletions vm-rust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions vm-rust/src/player/bitmap/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
13 changes: 2 additions & 11 deletions vm-rust/src/player/handlers/datum_handlers/sound_channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2601,37 +2601,28 @@ 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");
}
}
}

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");
}
}
}
Expand Down
Loading
Loading