verovio_flutter 0.3.1 copy "verovio_flutter: ^0.3.1" to clipboard
verovio_flutter: ^0.3.1 copied to clipboard

Flutter FFI plugin for Verovio music notation rendering. Supports Android, iOS, and Web (WASM).

example/lib/main.dart

import 'package:flutter/material.dart';
import 'dart:async';
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:verovio_flutter/verovio_flutter.dart';

void main() {
  runApp(const VerovioFlutterDemo());
}

class VerovioFlutterDemo extends StatelessWidget {
  const VerovioFlutterDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Verovio Flutter - Web Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
        useMaterial3: true,
      ),
      home: const VerovioWebDemoPage(),
    );
  }
}

class VerovioWebDemoPage extends StatefulWidget {
  const VerovioWebDemoPage({super.key});

  @override
  State<VerovioWebDemoPage> createState() => _VerovioWebDemoPageState();
}

class _VerovioWebDemoPageState extends State<VerovioWebDemoPage> {
  static const String meiData = '''<?xml version="1.0" encoding="UTF-8"?>
<mei xmlns="http://www.music-encoding.org/ns/mei" meiversion="5.0">
  <meiHead/>
  <music>
    <body>
      <mdiv>
        <score>
          <scoreDef meter.count="4" meter.unit="4" key.sig="0"/>
          <section>
            <measure n="1">
              <staff n="1">
                <layer n="1">
                  <note pname="c" oct="4" dur="4"/>
                  <note pname="d" oct="4" dur="4"/>
                  <note pname="e" oct="4" dur="4"/>
                  <note pname="f" oct="4" dur="4"/>
                </layer>
              </staff>
            </measure>
            <measure n="2">
              <staff n="1">
                <layer n="1">
                  <note pname="g" oct="4" dur="4"/>
                  <note pname="a" oct="4" dur="4"/>
                  <note pname="b" oct="4" dur="4"/>
                  <note pname="c" oct="5" dur="4"/>
                </layer>
              </staff>
            </measure>
          </section>
        </score>
      </mdiv>
    </body>
  </music>
</mei>''';

  late Future<DemoRenderResult?> _renderFuture;
  int _currentPage = 1;
  int _totalPages = 1;
  Offset? _lastClickOffset;
  List<ElementHit>? _lastHitElements;
  bool _showHitInfo = true;
  bool _isLoading = false;

  @override
  void initState() {
    super.initState();
    _renderFuture = _loadAndRender();
  }

  Future<DemoRenderResult?> _loadAndRender() async {
    try {
      final service = await VerovioAsyncService.spawn(
        resourcePath: '/verovio',
      );

      final optionsMap = {
        'pageHeight': 2970,
        'pageWidth': 2100,
        'scale': 40,
      };
      await service.setOptionsJson(jsonEncode(optionsMap));

      // Try to load MXL file
      try {
        final zipBytes = await rootBundle.load('assets/testdata/test_score.mxl');
        final uint8list = zipBytes.buffer.asUint8List(
          zipBytes.offsetInBytes,
          zipBytes.lengthInBytes,
        );
        await service.loadZipDataBuffer(uint8list);
        debugPrint('✓ Loaded test score from MXL');
      } catch (e) {
        debugPrint('Note: MXL loading failed, using MEI instead: $e');
        await service.loadData(meiData);
      }

      final pageCount = await service.pageCount;
      setState(() => _totalPages = pageCount);

      // Render first page with hit_map
      final result = await service.renderPageWithHitMap(_currentPage);
      final svg = result.svg;
      final hitMap = result.hitMap;

      await service.dispose();

      return DemoRenderResult(
        svg: svg,
        hitMap: hitMap,
        pageIndex: _currentPage,
      );
    } catch (e) {
      debugPrint('Error in demo: $e');
      return null;
    }
  }

  Future<void> _goToPage(int pageNum) async {
    if (pageNum < 1 || pageNum > _totalPages) return;

    setState(() {
      _currentPage = pageNum;
      _lastClickOffset = null;
      _lastHitElements = null;
      _isLoading = true;
      _renderFuture = _loadAndRender();
    });

    await _renderFuture;
    setState(() => _isLoading = false);
  }

  void _onSvgTap(Offset offset) async {
    setState(() => _lastClickOffset = offset);

    try {
      final result = await _renderFuture;
      if (result?.hitMap == null) {
        debugPrint('No hit map available');
        return;
      }

      final hits = hitTestPointAll(
        result!.hitMap!,
        offset,
      );

      setState(() => _lastHitElements = hits);

      if (hits.isNotEmpty) {
        final ids = hits.map((h) => h.id).join(', ');
        debugPrint('✓ Hit ${hits.length} element(s) at (${offset.dx.toStringAsFixed(1)}, ${offset.dy.toStringAsFixed(1)}): $ids');
      } else {
        debugPrint('○ No hits at (${offset.dx.toStringAsFixed(1)}, ${offset.dy.toStringAsFixed(1)})');
      }
    } catch (e) {
      debugPrint('Error in hit test: $e');
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Verovio Flutter - Web Demo'),
        centerTitle: false,
      ),
      body: Column(
        children: [
          // Status bar
          Container(
            color: Colors.blue[50],
            padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Text('Page $_currentPage of $_totalPages'),
                if (_lastClickOffset != null)
                  Text(
                    'Last click: (${_lastClickOffset!.dx.toStringAsFixed(0)}, ${_lastClickOffset!.dy.toStringAsFixed(0)})',
                    style: const TextStyle(fontSize: 12),
                  ),
              ],
            ),
          ),
          // Control panel
          Padding(
            padding: const EdgeInsets.all(12.0),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                // Page navigation
                Row(
                  children: [
                    Expanded(
                      child: Slider(
                        value: _currentPage.toDouble(),
                        min: 1,
                        max: _totalPages.toDouble(),
                        divisions: _totalPages > 1 ? _totalPages - 1 : 1,
                        label: 'Page $_currentPage',
                        onChanged: (value) {
                          _goToPage(value.toInt());
                        },
                      ),
                    ),
                    ElevatedButton(
                      onPressed: _currentPage > 1 ? () => _goToPage(_currentPage - 1) : null,
                      child: const Text('Prev'),
                    ),
                    const SizedBox(width: 8),
                    ElevatedButton(
                      onPressed: _currentPage < _totalPages ? () => _goToPage(_currentPage + 1) : null,
                      child: const Text('Next'),
                    ),
                  ],
                ),
                const SizedBox(height: 8),
                // Hit info toggle
                Row(
                  children: [
                    Checkbox(
                      value: _showHitInfo,
                      onChanged: (value) {
                        setState(() => _showHitInfo = value ?? true);
                      },
                    ),
                    const Text('Show hit info'),
                  ],
                ),
              ],
            ),
          ),
          const Divider(height: 0),
          // Render area with click detection
          Expanded(
            child: FutureBuilder<DemoRenderResult?>(
              future: _renderFuture,
              builder: (context, snapshot) {
                if (snapshot.connectionState == ConnectionState.waiting || _isLoading) {
                  return const Center(
                    child: CircularProgressIndicator(),
                  );
                } else if (snapshot.hasError || snapshot.data == null) {
                  return Center(
                    child: Padding(
                      padding: const EdgeInsets.all(16.0),
                      child: Text(
                        'Error: ${snapshot.error ?? "Failed to load"}',
                        textAlign: TextAlign.center,
                      ),
                    ),
                  );
                }

                final result = snapshot.data!;
                final viewBoxSize = result.hitMap?.viewBox;
                final width = viewBoxSize?.width ?? 2100.0;
                final height = viewBoxSize?.height ?? 2970.0;

                return SingleChildScrollView(
                  child: SingleChildScrollView(
                    scrollDirection: Axis.horizontal,
                    child: GestureDetector(
                      onTapDown: (details) {
                        _onSvgTap(details.globalPosition);
                      },
                      child: Stack(
                        children: [
                          SvgPicture.string(
                            result.svg,
                            width: width,
                            height: height,
                          ),
                          // Hit highlight overlay
                          if (_showHitInfo && _lastHitElements != null && _lastHitElements!.isNotEmpty)
                            Positioned(
                              child: SizedBox(
                                width: width,
                                height: height,
                                child: CustomPaint(
                                  painter: HitHighlightPainter(
                                    hits: _lastHitElements!,
                                    lastClick: _lastClickOffset,
                                  ),
                                ),
                              ),
                            ),
                        ],
                      ),
                    ),
                  ),
                );
              },
            ),
          ),
          // Hit info panel
          if (_showHitInfo)
            Container(
              color: Colors.grey[100],
              padding: const EdgeInsets.all(12),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.stretch,
                mainAxisSize: MainAxisSize.min,
                children: [
                  if (_lastHitElements != null && _lastHitElements!.isNotEmpty) ...[
                    Text(
                      'Hit elements: ${_lastHitElements!.length}',
                      style: const TextStyle(fontWeight: FontWeight.bold),
                    ),
                    ..._lastHitElements!.take(5).map((hit) {
                      final bbox = hit.bbox;
                      return Padding(
                        padding: const EdgeInsets.only(top: 4),
                        child: Text(
                          '• ${hit.id} (${hit.type}) bbox=(${bbox.left.toStringAsFixed(0)},${bbox.top.toStringAsFixed(0)},${bbox.width.toStringAsFixed(0)},${bbox.height.toStringAsFixed(0)})',
                          style: const TextStyle(fontSize: 12),
                          maxLines: 1,
                          overflow: TextOverflow.ellipsis,
                        ),
                      );
                    }),
                    if (_lastHitElements!.length > 5)
                      Text(
                        '... and ${_lastHitElements!.length - 5} more',
                        style: const TextStyle(fontSize: 12, fontStyle: FontStyle.italic),
                      ),
                  ] else if (_lastClickOffset != null)
                    const Text(
                      'No hits at this location',
                      style: TextStyle(color: Colors.grey),
                    ),
                ],
              ),
            ),
        ],
      ),
    );
  }
}

class DemoRenderResult {
  final String svg;
  final PageHitMap? hitMap;
  final int pageIndex;

  DemoRenderResult({
    required this.svg,
    required this.hitMap,
    required this.pageIndex,
  });
}

class HitHighlightPainter extends CustomPainter {
  final List<ElementHit> hits;
  final Offset? lastClick;

  HitHighlightPainter({
    required this.hits,
    required this.lastClick,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = Colors.blue.withAlpha(50)
      ..style = PaintingStyle.fill;

    final strokePaint = Paint()
      ..color = Colors.blue
      ..strokeWidth = 2
      ..style = PaintingStyle.stroke;

    for (final hit in hits) {
      final rect = hit.bbox;
      canvas.drawRect(rect, paint);
      canvas.drawRect(rect, strokePaint);
    }

    // Draw click point
    if (lastClick != null) {
      final clickPaint = Paint()
        ..color = Colors.red
        ..style = PaintingStyle.fill;
      canvas.drawCircle(lastClick!, 8, clickPaint);
    }
  }

  @override
  bool shouldRepaint(HitHighlightPainter oldDelegate) {
    return true;
  }
}
1
likes
150
points
220
downloads

Documentation

API reference

Publisher

verified publishercsa8820.top

Weekly Downloads

Flutter FFI plugin for Verovio music notation rendering. Supports Android, iOS, and Web (WASM).

Homepage
Repository (GitHub)
View/report issues

License

LGPL-3.0 (license)

Dependencies

collection, ffi, flutter, flutter_web_plugins, path_provider, plugin_platform_interface, xml

More

Packages that depend on verovio_flutter

Packages that implement verovio_flutter