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 = + '" + + par + + ""; + 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(); +}