ligatura 0.1.0 copy "ligatura: ^0.1.0" to clipboard
ligatura: ^0.1.0 copied to clipboard

A high-performance Flutter renderer for fixed-format JSON timeline previews.

example/lib/main.dart

import 'dart:typed_data';

import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:ligatura/ligatura.dart';

void main() => runApp(const PreviewExampleApp());

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

  @override
  Widget build(BuildContext context) => MaterialApp(
    debugShowCheckedModeBanner: false,
    title: 'Ligatura Preview',
    theme: ThemeData(
      brightness: Brightness.dark,
      colorScheme: ColorScheme.fromSeed(
        seedColor: const Color(0xff72ead4),
        brightness: Brightness.dark,
      ),
      scaffoldBackgroundColor: const Color(0xff0c1012),
      useMaterial3: true,
    ),
    home: const PreviewStudioPage(),
  );
}

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

  @override
  State<PreviewStudioPage> createState() => _PreviewStudioPageState();
}

class _PreviewStudioPageState extends State<PreviewStudioPage> {
  final _controller = LigaturaController();
  late LigaturaAsset _chart = LigaturaAsset.flutterAsset(
    'assets/demo_chart.json',
  );
  LigaturaAsset? _audio;
  late LigaturaSource _source = LigaturaSource(chart: _chart);
  String _chartName = 'Demo chart';
  String _audioName = 'Silent preview';

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  Future<void> _pickChart() async {
    final result = await FilePicker.platform.pickFiles(
      type: FileType.custom,
      allowedExtensions: const ['json'],
      withData: false,
    );
    final file = result?.files.single;
    if (file == null) return;
    final asset = _pickedAsset(file);
    setState(() {
      _chart = asset;
      _chartName = file.name;
      _source = LigaturaSource(chart: asset, audio: _audio);
    });
  }

  Future<void> _pickAudio() async {
    final result = await FilePicker.platform.pickFiles(
      type: FileType.audio,
      withData: false,
    );
    final file = result?.files.single;
    if (file == null) return;
    final asset = _pickedAsset(file);
    setState(() {
      _audio = asset;
      _audioName = file.name;
      _source = LigaturaSource(chart: _chart, audio: asset);
    });
  }

  LigaturaAsset _pickedAsset(PlatformFile file) {
    if (file.path case final path?) {
      return LigaturaAsset.file(path, name: file.name);
    }
    if (file.bytes case final Uint8List bytes) {
      return LigaturaAsset.memory(bytes, name: file.name);
    }
    throw StateError('The selected file has no readable data.');
  }

  @override
  Widget build(BuildContext context) => Scaffold(
    appBar: AppBar(
      titleSpacing: 20,
      title: const Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            'LIGATURA PREVIEW',
            style: TextStyle(fontWeight: FontWeight.w800),
          ),
          Text(
            'Flutter high-performance chart renderer',
            style: TextStyle(fontSize: 11, color: Colors.white54),
          ),
        ],
      ),
      actions: [
        TextButton.icon(
          onPressed: _pickChart,
          icon: const Icon(Icons.data_object_rounded),
          label: const Text('Open chart'),
        ),
        const SizedBox(width: 8),
        TextButton.icon(
          onPressed: _pickAudio,
          icon: const Icon(Icons.audio_file_rounded),
          label: const Text('Open audio'),
        ),
        const SizedBox(width: 12),
      ],
    ),
    body: LayoutBuilder(
      builder: (context, constraints) {
        final compact = constraints.maxWidth < 840;
        final preview = Expanded(
          child: Padding(
            padding: const EdgeInsets.all(16),
            child: ClipRRect(
              borderRadius: BorderRadius.circular(20),
              child: LigaturaPlayer(
                source: _source,
                controller: _controller,
                autoplay: true,
              ),
            ),
          ),
        );
        final controls = SizedBox(
          width: compact ? null : 320,
          height: compact ? 246 : null,
          child: _ControlsPanel(
            controller: _controller,
            chartName: _chartName,
            audioName: _audioName,
          ),
        );
        return compact
            ? Column(children: [preview, controls])
            : Row(children: [preview, controls]);
      },
    ),
  );
}

class _ControlsPanel extends StatelessWidget {
  const _ControlsPanel({
    required this.controller,
    required this.chartName,
    required this.audioName,
  });

  final LigaturaController controller;
  final String chartName;
  final String audioName;

  @override
  Widget build(BuildContext context) => AnimatedBuilder(
    animation: controller,
    builder: (context, _) {
      final metadata = controller.metadata;
      return ColoredBox(
        color: const Color(0xff151b1e),
        child: ListView(
          padding: const EdgeInsets.all(20),
          children: [
            Text(chartName, maxLines: 1, overflow: TextOverflow.ellipsis),
            const SizedBox(height: 4),
            Text(
              audioName,
              maxLines: 1,
              overflow: TextOverflow.ellipsis,
              style: const TextStyle(color: Colors.white54, fontSize: 12),
            ),
            const SizedBox(height: 16),
            if (metadata != null)
              Wrap(
                spacing: 8,
                runSpacing: 8,
                children: [
                  _Badge('${metadata.noteCount} NOTES'),
                  _Badge('${metadata.lineCount} LINES'),
                  _Badge('${metadata.baseBpm.toStringAsFixed(0)} BPM'),
                ],
              ),
            const SizedBox(height: 16),
            ValueListenableBuilder(
              valueListenable: controller.positionListenable,
              builder: (context, position, _) {
                final duration = controller.duration;
                final maximum = duration.inMilliseconds.toDouble();
                return Column(
                  children: [
                    Slider(
                      value: position.inMilliseconds
                          .clamp(0, maximum)
                          .toDouble(),
                      max: maximum <= 0 ? 1 : maximum,
                      onChanged: controller.loadState == LigaturaLoadState.ready
                          ? (value) => controller.seek(
                              Duration(milliseconds: value.round()),
                            )
                          : null,
                    ),
                    Row(
                      mainAxisAlignment: MainAxisAlignment.spaceBetween,
                      children: [Text(_time(position)), Text(_time(duration))],
                    ),
                  ],
                );
              },
            ),
            const SizedBox(height: 8),
            Row(
              children: [
                IconButton.filled(
                  onPressed: controller.loadState != LigaturaLoadState.ready
                      ? null
                      : () {
                          if (controller.playbackState ==
                              LigaturaPlaybackState.playing) {
                            controller.pause();
                          } else {
                            controller.play();
                          }
                        },
                  icon: Icon(
                    controller.playbackState == LigaturaPlaybackState.playing
                        ? Icons.pause_rounded
                        : Icons.play_arrow_rounded,
                  ),
                ),
                const SizedBox(width: 12),
                Expanded(
                  child: SegmentedButton<double>(
                    segments: const [
                      ButtonSegment(value: 0.75, label: Text('0.75×')),
                      ButtonSegment(value: 1, label: Text('1×')),
                      ButtonSegment(value: 1.25, label: Text('1.25×')),
                    ],
                    selected: {controller.playbackRate},
                    onSelectionChanged: (value) =>
                        controller.setPlaybackRate(value.first),
                  ),
                ),
              ],
            ),
            const SizedBox(height: 20),
            _ValueSlider(
              label: 'FLOW SPEED',
              value: controller.flowSpeed.toDouble(),
              min: -6,
              max: 40,
              divisions: 46,
              display: '${controller.flowSpeed}',
              onChanged: (value) => controller.setFlowSpeed(value.round()),
            ),
            _ValueSlider(
              label: 'NOTE SCALE',
              value: controller.noteScale,
              min: 0.6,
              max: 1.6,
              divisions: 20,
              display: '${controller.noteScale.toStringAsFixed(2)}×',
              onChanged: controller.setNoteScale,
            ),
            _ValueSlider(
              label: 'REVELATION',
              value: controller.revelationScale,
              min: 0.35,
              max: 1,
              divisions: 13,
              display: '${controller.revelationScale.toStringAsFixed(2)}×',
              onChanged: controller.setRevelationScale,
            ),
          ],
        ),
      );
    },
  );

  static String _time(Duration value) {
    final seconds = value.inSeconds;
    final minutes = seconds ~/ 60;
    return '$minutes:${(seconds % 60).toString().padLeft(2, '0')}';
  }
}

class _Badge extends StatelessWidget {
  const _Badge(this.label);
  final String label;

  @override
  Widget build(BuildContext context) => DecoratedBox(
    decoration: BoxDecoration(
      borderRadius: BorderRadius.circular(20),
      color: const Color(0xff263135),
    ),
    child: Padding(
      padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
      child: Text(label, style: const TextStyle(fontSize: 11)),
    ),
  );
}

class _ValueSlider extends StatelessWidget {
  const _ValueSlider({
    required this.label,
    required this.value,
    required this.min,
    required this.max,
    required this.divisions,
    required this.display,
    required this.onChanged,
  });

  final String label;
  final double value;
  final double min;
  final double max;
  final int divisions;
  final String display;
  final ValueChanged<double> onChanged;

  @override
  Widget build(BuildContext context) => Column(
    children: [
      Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: [
          Text(label, style: const TextStyle(fontSize: 11, letterSpacing: 1.2)),
          Text(display, style: const TextStyle(color: Color(0xff72ead4))),
        ],
      ),
      Slider(
        value: value,
        min: min,
        max: max,
        divisions: divisions,
        onChanged: onChanged,
      ),
    ],
  );
}
0
likes
145
points
--
downloads

Documentation

API reference

Publisher

unverified uploader

A high-performance Flutter renderer for fixed-format JSON timeline previews.

Repository (GitHub)
View/report issues

Topics

#flutter #renderer #timeline #custom-painter

License

MIT (license)

Dependencies

flutter, flutter_soloud

More

Packages that depend on ligatura