diff --git a/js/activity.js b/js/activity.js
index 5705c38c57..3b368058bb 100644
--- a/js/activity.js
+++ b/js/activity.js
@@ -180,6 +180,7 @@ if (_THIS_IS_MUSIC_BLOCKS_) {
"widgets/sampler",
"widgets/reflection",
"widgets/legobricks",
+ "widgets/timeline",
"activity/lilypond",
"activity/abc",
"activity/midi",
diff --git a/js/logo.js b/js/logo.js
index 6574a73419..bf1dfc13c1 100644
--- a/js/logo.js
+++ b/js/logo.js
@@ -183,6 +183,7 @@ class Logo {
this.oscilloscopeTurtles = [];
this.meterWidget = null;
this.statusMatrix = null;
+ this.timeline = null;
this.legobricks = null;
this.evalFlowDict = {};
@@ -1180,6 +1181,16 @@ class Logo {
this.statusMatrix.init(this.activity);
}
+ // Set up timeline widget.
+ if (window.widgetWindows.isOpen("timeline")) {
+ // Ensure widget has been created before trying to initialize it
+ if (this.timeline === null) {
+ this.timeline = new Timeline();
+ }
+
+ this.timeline.init(this.activity);
+ }
+
// Execute turtle code here
/*
===========================================================================
diff --git a/js/widgets/TIMELINE_TESTING.md b/js/widgets/TIMELINE_TESTING.md
new file mode 100644
index 0000000000..c95c8ee1c6
--- /dev/null
+++ b/js/widgets/TIMELINE_TESTING.md
@@ -0,0 +1,56 @@
+# Timeline Widget Testing Guide
+
+## Opening the Timeline Widget
+
+To test the timeline widget, you can use the browser console to open it manually.
+
+### Method 1: Using Browser Console
+
+1. Start Music Blocks:
+ ```bash
+ npm run serve
+ ```
+
+2. Open `http://127.0.0.1:3000` in your browser
+
+3. Open the browser console (F12 or right-click → Inspect → Console)
+
+4. Run the following command to open the timeline widget:
+ ```javascript
+ globalActivity.logo.timeline = new Timeline();
+ globalActivity.logo.timeline.init(globalActivity);
+ ```
+
+## Testing the Playhead
+
+1. After opening the timeline widget, create a simple music program:
+ - Drag a "Start" block onto the canvas
+ - Add some "Note" blocks inside it
+
+2. Press the Play button (▶) in the toolbar
+
+3. Observe the timeline widget:
+ - The playhead (red vertical line) should move across the timeline
+ - The movement should be synchronized with the music playback
+
+4. Press Stop and Play again to verify the playhead resets
+
+## Expected Behavior
+
+- **Widget Window**: A window titled "timeline" should appear with a canvas
+- **Timeline**: A horizontal gray line across the canvas
+- **Playhead**: A red vertical line with a circle at the top that moves during playback
+- **Smooth Animation**: The playhead should move smoothly using requestAnimationFrame
+- **No Side Effects**: Music playback should work exactly as before
+
+## Troubleshooting
+
+If the widget doesn't appear:
+- Check the browser console for errors
+- Verify that `Timeline` class is loaded (type `Timeline` in console)
+- Ensure `globalActivity` is available (type `globalActivity` in console)
+
+If the playhead doesn't move:
+- Verify music is playing
+- Check that `globalActivity.turtles.ithTurtle(0).singer.currentBeat` is updating
+- Look for console errors during playback
diff --git a/js/widgets/timeline.js b/js/widgets/timeline.js
new file mode 100644
index 0000000000..da9f12f96e
--- /dev/null
+++ b/js/widgets/timeline.js
@@ -0,0 +1,155 @@
+/* eslint-disable no-undef */
+// Copyright (c) 2026 Music Blocks Contributors
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the The GNU Affero General Public
+// License as published by the Free Software Foundation; either
+// version 3 of the License, or (at your option) any later version.
+//
+// You should have received a copy of the GNU Affero General Public
+// License along with this library; if not, write to the Free Software
+// Foundation, 51 Franklin Street, Suite 500 Boston, MA 02110-1335 USA
+
+// This widget displays a read-only timeline with a moving playhead
+// synchronized to music playback.
+
+/* global _ */
+
+/* exported Timeline */
+class Timeline {
+ static CANVAS_WIDTH = 800;
+ static CANVAS_HEIGHT = 100;
+ static TIMELINE_Y = 50;
+ static PLAYHEAD_COLOR = "#FF0000";
+ static TIMELINE_COLOR = "#333333";
+
+ /**
+ * Initializes the timeline widget.
+ * @param {Object} activity - The activity object containing turtles and logo
+ */
+ init(activity) {
+ this.activity = activity;
+ this.isOpen = true;
+ this.playheadPosition = 0;
+ this.animationFrameId = null;
+
+ // Create widget window
+ this.widgetWindow = window.widgetWindows.windowFor(this, "timeline", "timeline");
+ this.widgetWindow.clear();
+ this.widgetWindow.show();
+
+ // Create canvas element
+ this.canvas = document.createElement("canvas");
+ this.canvas.width = Timeline.CANVAS_WIDTH;
+ this.canvas.height = Timeline.CANVAS_HEIGHT;
+ this.canvas.style.backgroundColor = "#FFFFFF";
+ this.canvas.style.border = "1px solid #CCCCCC";
+
+ this.ctx = this.canvas.getContext("2d");
+
+ // Add canvas to widget body
+ this.widgetWindow.getWidgetBody().appendChild(this.canvas);
+
+ // Set up close handler
+ this.widgetWindow.onclose = () => {
+ this.isOpen = false;
+ this._stopAnimation();
+ this.widgetWindow.destroy();
+ };
+
+ // Draw initial timeline
+ this._drawTimeline();
+
+ // Start animation loop
+ this._startAnimation();
+
+ // Center the widget
+ this.widgetWindow.sendToCenter();
+ }
+
+ /**
+ * Draws the timeline and playhead on the canvas
+ * @private
+ */
+ _drawTimeline() {
+ // Clear canvas
+ this.ctx.clearRect(0, 0, Timeline.CANVAS_WIDTH, Timeline.CANVAS_HEIGHT);
+
+ // Draw timeline base (horizontal line)
+ this.ctx.strokeStyle = Timeline.TIMELINE_COLOR;
+ this.ctx.lineWidth = 2;
+ this.ctx.beginPath();
+ this.ctx.moveTo(10, Timeline.TIMELINE_Y);
+ this.ctx.lineTo(Timeline.CANVAS_WIDTH - 10, Timeline.TIMELINE_Y);
+ this.ctx.stroke();
+
+ // Draw playhead (vertical line)
+ this.ctx.strokeStyle = Timeline.PLAYHEAD_COLOR;
+ this.ctx.lineWidth = 3;
+ this.ctx.beginPath();
+ const playheadX = 10 + this.playheadPosition;
+ this.ctx.moveTo(playheadX, 20);
+ this.ctx.lineTo(playheadX, 80);
+ this.ctx.stroke();
+
+ // Draw playhead indicator (small circle at top)
+ this.ctx.fillStyle = Timeline.PLAYHEAD_COLOR;
+ this.ctx.beginPath();
+ this.ctx.arc(playheadX, 20, 5, 0, 2 * Math.PI);
+ this.ctx.fill();
+ }
+
+ /**
+ * Updates the playhead position based on current playback state
+ * @private
+ */
+ _updatePlayhead() {
+ if (!this.isOpen || !this.activity) {
+ return;
+ }
+
+ // Get the first turtle's current beat
+ // In a more complete implementation, we might track all turtles
+ const turtle = this.activity.turtles.ithTurtle(0);
+ if (turtle && turtle.singer) {
+ const currentBeat = turtle.singer.currentBeat || 0;
+ const beatsPerMeasure = turtle.singer.beatsPerMeasure || 4;
+
+ // Calculate playhead position
+ // For this minimal version, we'll use a simple linear mapping
+ // Assuming a fixed number of measures (e.g., 8 measures visible)
+ const totalBeats = beatsPerMeasure * 8;
+ const normalizedBeat = currentBeat % totalBeats;
+ const maxWidth = Timeline.CANVAS_WIDTH - 20; // Account for margins
+ this.playheadPosition = (normalizedBeat / totalBeats) * maxWidth;
+ }
+
+ // Redraw timeline with updated playhead
+ this._drawTimeline();
+ }
+
+ /**
+ * Starts the animation loop for playhead updates
+ * @private
+ */
+ _startAnimation() {
+ const animate = () => {
+ if (this.isOpen) {
+ this._updatePlayhead();
+ this.animationFrameId = requestAnimationFrame(animate);
+ }
+ };
+ animate();
+ }
+
+ /**
+ * Stops the animation loop
+ * @private
+ */
+ _stopAnimation() {
+ if (this.animationFrameId) {
+ cancelAnimationFrame(this.animationFrameId);
+ this.animationFrameId = null;
+ }
+ }
+}
diff --git a/msie_flashFallback/flashFallback.js b/msie_flashFallback/flashFallback.js
new file mode 100755
index 0000000000..e404a11ab0
--- /dev/null
+++ b/msie_flashFallback/flashFallback.js
@@ -0,0 +1,239 @@
+/*
+ A fallback to flash for wav-output (for IE 10)
+ Please mind that wav data has to be copied to an ArrayBuffer object internally,
+ since we may not send binary data to the swf.
+ This may take some time and memory for longer utterances.
+*/
+
+var meSpeakFlashFallback = new (function () {
+ var swfDefaultId = "meSpeakFallback",
+ swfDefaultUrl = "meSpeakFallback.swf",
+ swfElementId = "",
+ swfViaAX = false,
+ swfInstalled = false,
+ swfHasLoaded = false,
+ swfVol = 1;
+
+ // public
+
+ function swfInstallFallback(swfUrl, swfId, parentElementOrId) {
+ var parentEl, url;
+ if (swfInstalled) return true;
+ if (!swfIsAvailable(10)) return false;
+ swfInstalled = true;
+ // set defaults
+ swfElementId = swfId && typeof swfId == "string" ? swfId : swfDefaultId;
+ url = swfUrl && typeof swfUrl == "string" ? swfUrl : swfDefaultUrl;
+ if (parentElementOrId) {
+ if (typeof parentElementOrId == "string") {
+ parentEl = document.getElementById(parentElementOrId);
+ } else if (typeof parentElementOrId == "object") {
+ parentEl = parentElementOrId = null;
+ }
+ }
+ if (!parentEl) parentEl = document.getElementsByTagName("body")[0];
+ if (!parentEl) return false;
+ // inject
+ var obj = swfCreate(
+ {
+ data: url,
+ width: "2",
+ height: "2",
+ id: swfElementId,
+ name: swfElementId,
+ align: "top"
+ },
+ {
+ quality: "low",
+ bgcolor: "transparent",
+ allowscriptaccess: "sameDomain",
+ allowfullscreen: "false"
+ }
+ );
+ parentEl.appendChild(obj);
+ swfRegisterUnloadHandler();
+ return true;
+ }
+
+ function swfReady() {
+ return swfHasLoaded;
+ }
+
+ function swfSetVolume(v) {
+ if (wfHasLoaded) {
+ var obj = document.getElementById(swfElementId);
+ if (obj) el.setVolume(v);
+ }
+ swfVol = v;
+ }
+
+ function swfSpeak(txt, options) {
+ if (swfHasLoaded && window.meSpeak) {
+ var obj = document.getElementById(swfElementId);
+ if (obj) {
+ if (typeof options !== "object") options = {};
+ options.rawdata = "array";
+ obj.play(meSpeak.speak(txt, options));
+ }
+ }
+ }
+
+ function swf10Available() {
+ return swfIsAvailable(10);
+ }
+
+ function swfFallbackHandshake() {
+ swfHasLoaded = true;
+ if (swfVol != 1) swfSetVolume(swfVol);
+ if (window.console) console.log("meSpeak-SWF-fallback available.");
+ }
+
+ // private: a stripped-down version of swfobject.js
+
+ function swfIsAvailable(leastMajorVersion) {
+ // returns Boolean: flashplayer and version at least 10.x
+ var sf = "Shockwave Flash",
+ sfm = "application/x-shockwave-flash";
+ if (navigator.plugins !== undefined && typeof navigator.plugins[sf] == "object") {
+ var d = navigator.plugins[sf].description;
+ if (
+ d &&
+ !(
+ typeof navigator.mimeTypes !== "undefined" &&
+ navigator.mimeTypes[sfm] &&
+ !navigator.mimeTypes[sfm].enabledPlugin
+ )
+ ) {
+ d = d.replace(/^.*\s+(\S+\s+\S+$)/, "$1");
+ if (leastMajorVersion <= parseInt(d.replace(/^(.*)\..*$/, "$1"), 10)) return true;
+ }
+ } else if (window.ActiveXObject) {
+ try {
+ var a = new ActiveXObject("ShockwaveFlash.ShockwaveFlash");
+ if (a) {
+ swfViaAX = true;
+ d = a.GetVariable("$version");
+ if (d) {
+ d = d.split(" ")[1].split(",");
+ if (leastMajorVersion <= parseInt(d[0], 10)) return true;
+ }
+ }
+ } catch (e) {
+ //
+ }
+ }
+ return false;
+ }
+
+ function swfCreate(attributes, params) {
+ if (swfViaAX) {
+ var att = "",
+ par = "",
+ i;
+ for (i in attributes) {
+ var a = i.toLowerCase;
+ if (a == "data") {
+ params.movie = attributes[i];
+ } else if (a == "styleclass") {
+ att += ' class="' + attributes[i] + '"';
+ } else if (a != "classid") {
+ att += " " + i + '="' + attributes[i] + '"';
+ }
+ }
+ for (i in params) {
+ if (params[i] != Object.prototype[i])
+ par += ' ';
+ }
+ var el = document.createElement("div");
+ el.outerHTML =
+ '";
+ return el;
+ } else {
+ var o = document.createElement("object");
+ o.setAttribute("type", "application/x-shockwave-flash");
+ for (var i in attributes) {
+ if (attributes[i] != Object.prototype[i]) {
+ var a = i.toLowerCase();
+ if (a == "styleclass") {
+ o.setAttribute("class", attributes[i]);
+ } else if (a != "styleclass") {
+ o.setAttribute(i, attributes[i]);
+ }
+ }
+ }
+ for (i in params) {
+ if (attributes[i] != Object.prototype[i] && i.toLowerCase() != "movie") {
+ var p = document.createElement("param");
+ p.setAttribute("name", i);
+ p.setAttribute("value", attributes[i]);
+ o.appendChild(p);
+ }
+ }
+ return o;
+ }
+ }
+
+ function swfRemove(obj) {
+ try {
+ if (typeof obj == "string") obj = document.getElementById(obj);
+ if (!obj || typeof obj != "object") return;
+ if (swfViaAX) {
+ obj.style.display = "none";
+ swfRemoveObjectInIE(obj.id);
+ } else if (obj.parentNode) {
+ obj.parentNode.removeChild(obj);
+ }
+ swfInstalled = false;
+ } catch (e) {
+ //
+ }
+ }
+
+ function swfRemoveObjectInIE(id) {
+ var obj = document.getElementById(obj);
+ if (obj) {
+ if (obj.readyState == 4) {
+ for (var i in obj) {
+ if (typeof obj[i] == "function") obj[i] = null;
+ }
+ if (obj.parentNode) obj.parentNode.removeChild(obj);
+ } else {
+ setTimeout(function () {
+ swfRemoveObjectInIE(id);
+ }, 10);
+ }
+ }
+ }
+
+ function swfUnloadHandler() {
+ if (swfElementId) swfRemove(swfElementId);
+ if (!window.addEventListener && window.detachEvent)
+ window.detachEvent("onunload", swfUnloadHandler);
+ }
+
+ function swfRegisterUnloadHandler() {
+ if (window.addEventListener) {
+ window.addEventListener("unload", swfUnloadHandler, false);
+ } else if (window.attachEvent) {
+ window.attachEvent("onunload", swfUnloadHandler);
+ }
+ }
+
+ return {
+ install: swfInstallFallback,
+ isAvailable: swf10Available,
+ ready: swfReady,
+ speak: swfSpeak,
+ setVolume: swfSetVolume,
+ swfFallbackHandshake: swfFallbackHandshake
+ };
+})();
+
+function meSpeakFallbackHandshake() {
+ // handshake handler with swf external interface
+ meSpeakFlashFallback.swfFallbackHandshake();
+}