Skip to content
Closed
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
11 changes: 11 additions & 0 deletions js/logo.js
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@
this.oscilloscopeTurtles = [];
this.meterWidget = null;
this.statusMatrix = null;
this.timeline = null;
this.legobricks = null;

this.evalFlowDict = {};
Expand Down Expand Up @@ -407,7 +408,7 @@
for (let i = 0; i < n; i++) {
const obj = this.connectionStore[turtle][blk].pop();
this.blockList[obj[0]].connections[obj[1]] = obj[2];
if (obj[2] != null) {

Check warning on line 411 in js/logo.js

View workflow job for this annotation

GitHub Actions / Lint and format-check changed JS files

Expected '!==' and instead saw '!='
this.blockList[obj[2]].connections[0] = obj[0];
}
}
Expand Down Expand Up @@ -695,7 +696,7 @@
const tur = logo.activity.turtles.ithTurtle(turtle);

// Using loose null check to catch both null and undefined input blocks
if (blk == null) {

Check warning on line 699 in js/logo.js

View workflow job for this annotation

GitHub Actions / Lint and format-check changed JS files

Expected '===' and instead saw '=='
logo.activity.errorMsg(NOINPUTERRORMSG, parentBlk);
return null;
}
Expand Down Expand Up @@ -746,7 +747,7 @@
} else {
const cblk = currentBlock.connections[1];
// Using loose null check for undefined acceptance
if (cblk == null) {

Check warning on line 750 in js/logo.js

View workflow job for this annotation

GitHub Actions / Lint and format-check changed JS files

Expected '===' and instead saw '=='
logo.activity.errorMsg(NOINPUTERRORMSG, blk);
currentBlock.value = 0;
} else {
Expand Down Expand Up @@ -822,7 +823,7 @@
// already been added to notesPlayed

// Don't split the note if we are already splitting the note
if (split == undefined) split = true;

Check warning on line 826 in js/logo.js

View workflow job for this annotation

GitHub Actions / Lint and format-check changed JS files

Expected '===' and instead saw '=='

const tur = this.activity.turtles.ithTurtle(turtle);

Expand Down Expand Up @@ -971,7 +972,7 @@
}
}

if (parentLoopBlock == null) {

Check warning on line 975 in js/logo.js

View workflow job for this annotation

GitHub Actions / Lint and format-check changed JS files

Expected '===' and instead saw '=='
// Flush the child flow
turtle.queue.pop();
return;
Expand All @@ -980,7 +981,7 @@
// For while and until, we need to add any childflow from the parent to the queue
if (parentLoopBlock.name === "while" || parentLoopBlock.name === "until") {
const childFlow = this.deps.utils.last(parentLoopBlock.connections);
if (childFlow != null) {

Check warning on line 984 in js/logo.js

View workflow job for this annotation

GitHub Actions / Lint and format-check changed JS files

Expected '!==' and instead saw '!='
const queueBlock = new Queue(childFlow, 1, loopBlkIdx);
// We need to keep track of the parent block to the child flow so we can
// unlightlight the parent block after the child flow completes
Expand Down Expand Up @@ -1052,7 +1053,7 @@
}

this.synth.stop();
if (this.synth.recorder && this.synth.recorder.state == "recording")

Check warning on line 1056 in js/logo.js

View workflow job for this annotation

GitHub Actions / Lint and format-check changed JS files

Expected '===' and instead saw '=='
this.synth.recorder.stop();

// Dispose all Tone.js instruments to free decoded AudioBuffers
Expand All @@ -1060,7 +1061,7 @@
// on the next run.
this.synth.disposeAllInstruments();

if (this.cameraID != null) {

Check warning on line 1064 in js/logo.js

View workflow job for this annotation

GitHub Actions / Lint and format-check changed JS files

Expected '!==' and instead saw '!='
this.deps.utils.doStopVideoCam(this.cameraID, this.setCameraID);
}

Expand Down Expand Up @@ -1098,7 +1099,7 @@
if (this.stepQueue[turtle].length > 0) {
if (
turtle in this._unhighlightStepQueue &&
this._unhighlightStepQueue[turtle] != null

Check warning on line 1102 in js/logo.js

View workflow job for this annotation

GitHub Actions / Lint and format-check changed JS files

Expected '!==' and instead saw '!='
) {
if (this.activity.blocks.visible) {
this.activity.blocks.unhighlight(this._unhighlightStepQueue[turtle]);
Expand All @@ -1107,7 +1108,7 @@
}

const blk = this.stepQueue[turtle].pop();
if (blk != null) {

Check warning on line 1111 in js/logo.js

View workflow job for this annotation

GitHub Actions / Lint and format-check changed JS files

Expected '!==' and instead saw '!='
this.runFromBlockNow(this, turtle, blk, 0, null);
}
}
Expand Down Expand Up @@ -1276,6 +1277,16 @@
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
/*
===========================================================================
Expand Down
56 changes: 56 additions & 0 deletions js/widgets/TIMELINE_TESTING.md
Original file line number Diff line number Diff line change
@@ -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
155 changes: 155 additions & 0 deletions js/widgets/timeline.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
Loading
Loading