Skip to content

Commit 8cbb539

Browse files
committed
[#314] Refactor AutomationRunner to AutomationController
- Rename AutomationRunner to AutomationController - Add lifecycle states: PLAYING, STOPPED, CLOSED - Use ExecutorService instead of raw Thread - Add AutomationListener interface for state/index changes - Move event deserialization from runner to call sites - Replace Thread.sleep with preciseSleepNanos - Replace parseSeconds with RadixUtils.parseRadix - TapePlayerGui implements AutomationListener directly - Disable add/remove/move buttons during automation play - Update buttons when automation finishes via stateChanged - Add word wrap renderer to timeline event column - Rename automation toolbar icons to auto-* prefix - Add AutomationControllerTest with async state tests - Remove old AutomationRunnerTest
1 parent 65502f6 commit 8cbb539

13 files changed

Lines changed: 752 additions & 506 deletions

File tree

emuStudio.toml

Whitespace-only changes.
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
/* SPDX-FileCopyrightText: 2006-2026 Peter Jakubčo
2+
SPDX-License-Identifier: GPL-3.0-or-later */
3+
package net.emustudio.plugins.device.audiotape_player;
4+
5+
import net.emustudio.emulib.runtime.helpers.RadixUtils;
6+
import net.jcip.annotations.GuardedBy;
7+
import net.jcip.annotations.ThreadSafe;
8+
import org.slf4j.Logger;
9+
import org.slf4j.LoggerFactory;
10+
11+
import java.nio.file.Path;
12+
import java.util.List;
13+
import java.util.Objects;
14+
import java.util.Optional;
15+
import java.util.Queue;
16+
import java.util.concurrent.*;
17+
18+
import static net.emustudio.emulib.runtime.helpers.SleepUtils.preciseSleepNanos;
19+
20+
/**
21+
* Executes a list of automation events sequentially.
22+
* Designed to run on a background thread.
23+
*/
24+
@ThreadSafe
25+
public class AutomationController implements AutoCloseable {
26+
private static final Logger LOGGER = LoggerFactory.getLogger(AutomationController.class);
27+
28+
public enum State {
29+
PLAYING,
30+
STOPPED,
31+
CLOSED // terminal state
32+
}
33+
34+
public interface AutomationListener {
35+
void stateChanged(State state);
36+
37+
void currentIndexChanged(int index);
38+
}
39+
40+
private final ExecutorService pool = Executors.newFixedThreadPool(1);
41+
private final Object lock = new Object();
42+
@GuardedBy("lock")
43+
private State state = State.STOPPED;
44+
@GuardedBy("lock")
45+
private Future<?> future;
46+
47+
private final Queue<State> stateNotifications = new ConcurrentLinkedQueue<>();
48+
49+
private AutomationListener listener;
50+
private final RadixUtils radixUtils = RadixUtils.getInstance();
51+
private final TapePlaybackController controller;
52+
53+
public AutomationController(TapePlaybackController controller) {
54+
this.controller = Objects.requireNonNull(controller);
55+
}
56+
57+
public void reset() {
58+
stop();
59+
}
60+
61+
public void setListener(AutomationListener listener) {
62+
this.listener = listener;
63+
}
64+
65+
public boolean isPlaying() {
66+
synchronized (lock) {
67+
return this.state == State.PLAYING;
68+
}
69+
}
70+
71+
public void play(List<AutomationEvent> events) {
72+
AutomationListener tmpListener = listener;
73+
74+
synchronized (lock) {
75+
if (this.state == State.STOPPED) {
76+
this.state = State.PLAYING;
77+
78+
LOGGER.info("AudioTape started with {} events", events.size());
79+
this.future = pool.submit(() -> {
80+
int currrentIndex = 0;
81+
try {
82+
for (; currrentIndex < events.size(); currrentIndex++) {
83+
if (tmpListener != null) {
84+
tmpListener.currentIndexChanged(currrentIndex);
85+
}
86+
87+
AutomationEvent event = events.get(currrentIndex);
88+
LOGGER.info("AudioTape event [{}]: {}", currrentIndex, event.getDescription());
89+
executeEvent(event);
90+
}
91+
LOGGER.info("AudioTape finished");
92+
} catch (InterruptedException e) {
93+
LOGGER.info("AudioTape interrupted at event [{}]", currrentIndex);
94+
controller.stop(false);
95+
Thread.currentThread().interrupt();
96+
} finally {
97+
synchronized (lock) {
98+
this.state = this.state == State.CLOSED ? this.state : State.STOPPED;
99+
stateNotifications.add(this.state);
100+
}
101+
notifyStateChange();
102+
}
103+
});
104+
}
105+
}
106+
}
107+
108+
public void stop() {
109+
synchronized (lock) {
110+
if (this.state == State.PLAYING) {
111+
Future<?> tmpFuture = this.future;
112+
this.future = null;
113+
if (tmpFuture != null) {
114+
tmpFuture.cancel(true);
115+
}
116+
this.state = State.STOPPED;
117+
}
118+
stateNotifications.add(this.state);
119+
}
120+
notifyStateChange();
121+
}
122+
123+
@Override
124+
public void close() {
125+
synchronized (lock) {
126+
this.state = State.CLOSED;
127+
Future<?> tmpFuture = this.future;
128+
this.future = null;
129+
if (tmpFuture != null) {
130+
tmpFuture.cancel(true);
131+
}
132+
pool.shutdown();
133+
stateNotifications.add(this.state);
134+
}
135+
notifyStateChange();
136+
try {
137+
if (!pool.awaitTermination(5, TimeUnit.SECONDS)) {
138+
pool.shutdownNow();
139+
}
140+
} catch (InterruptedException e) {
141+
Thread.currentThread().interrupt();
142+
}
143+
}
144+
145+
private void executeEvent(AutomationEvent event) throws InterruptedException {
146+
switch (event.getType()) {
147+
case LOAD_TAPE:
148+
String path = event.getParameter();
149+
if (!path.isEmpty()) {
150+
controller.load(Path.of(path));
151+
} else {
152+
LOGGER.warn("LOAD_TAPE event has no path, skipping");
153+
}
154+
break;
155+
case DELAY:
156+
int seconds = radixUtils.parseRadix(event.getParameter());
157+
if (seconds > 0) {
158+
preciseSleepNanos(TimeUnit.SECONDS.toNanos(seconds));
159+
}
160+
break;
161+
case PLAY:
162+
controller.play();
163+
waitForPlaybackEnd();
164+
break;
165+
case STOP:
166+
controller.stop(false);
167+
break;
168+
case RESET:
169+
controller.reset();
170+
break;
171+
case UNLOAD:
172+
controller.stop(true);
173+
break;
174+
}
175+
}
176+
177+
private void waitForPlaybackEnd() throws InterruptedException {
178+
do {
179+
TapePlaybackController.CassetteState state = controller.getState();
180+
if (state != TapePlaybackController.CassetteState.PLAYING) {
181+
break;
182+
}
183+
preciseSleepNanos(TimeUnit.MILLISECONDS.toNanos(100));
184+
} while (getState() == State.PLAYING);
185+
}
186+
187+
private State getState() {
188+
synchronized (lock) {
189+
return this.state;
190+
}
191+
}
192+
193+
private void notifyStateChange() {
194+
Optional.ofNullable(stateNotifications.poll()).ifPresent(listener::stateChanged);
195+
}
196+
}

plugins/device/audiotape-player/src/main/java/net/emustudio/plugins/device/audiotape_player/AutomationRunner.java

Lines changed: 0 additions & 138 deletions
This file was deleted.

plugins/device/audiotape-player/src/main/java/net/emustudio/plugins/device/audiotape_player/DeviceImpl.java

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,7 @@ public class DeviceImpl extends AbstractDevice {
3030
private TapePlayerGui gui;
3131
private TapePlaybackController controller;
3232
private TapePlaybackImpl cassetteListener;
33-
private AutomationRunner automationRunner;
34-
private Thread automationThread;
33+
private AutomationController automationController;
3534
private JFrame parentFrame;
3635

3736
public DeviceImpl(long pluginID, ApplicationApi applicationApi, PluginSettings settings) {
@@ -65,20 +64,14 @@ public void initialize() throws PluginInitializationException {
6564
public void reset() {
6665
this.controller.reset();
6766
if (automaticEmulation && !guiSupported) {
68-
List<String> storedEvents = settings.getArray(SettingsDialog.SETTINGS_KEY_EVENTS);
69-
List<AutomationEvent> events = AutomationEvent.deserializeAll(storedEvents);
70-
if (!events.isEmpty()) {
71-
automationRunner = new AutomationRunner(controller, events);
72-
automationThread = new Thread(automationRunner, "audiotape-automation");
73-
automationThread.setDaemon(true);
74-
}
67+
automationController = new AutomationController(controller);
7568
}
7669
}
7770

7871
@Override
7972
public void destroy() {
80-
if (automationRunner != null) {
81-
automationRunner.cancel();
73+
if (automationController != null) {
74+
automationController.close();
8275
}
8376
this.controller.close();
8477
if (guiIOset || gui != null) {
@@ -112,14 +105,17 @@ public void showGUI(JFrame parent) {
112105
}
113106
this.gui.setVisible(true);
114107

115-
// Start automation if runner is ready
116-
if (automationRunner != null && automationThread != null && !automationThread.isAlive()) {
117-
gui.setAutomationRunner(automationRunner);
118-
automationThread.start();
108+
// Start automation if controller is ready
109+
if (automationController != null && !automationController.isPlaying()) {
110+
List<String> storedEvents = settings.getArray(SettingsDialog.SETTINGS_KEY_EVENTS);
111+
List<AutomationEvent> events = AutomationEvent.deserializeAll(storedEvents);
112+
automationController.play(events);
119113
}
120-
} else if (automationRunner != null && automationThread != null && !automationThread.isAlive()) {
114+
} else if (automationController != null && !automationController.isPlaying()) {
121115
// No GUI - just start automation
122-
automationThread.start();
116+
List<String> storedEvents = settings.getArray(SettingsDialog.SETTINGS_KEY_EVENTS);
117+
List<AutomationEvent> events = AutomationEvent.deserializeAll(storedEvents);
118+
automationController.play(events);
123119
}
124120
}
125121

0 commit comments

Comments
 (0)