ligatura 0.1.0
ligatura: ^0.1.0 copied to clipboard
A high-performance Flutter renderer for fixed-format JSON timeline previews.
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,
),
],
);
}