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

retracted

高性能 Flutter 平台 Milthm 铺面预览器。

example/lib/main.dart

import 'dart:async';

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

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

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

  @override
  Widget build(BuildContext context) => MaterialApp(
    title: 'Pluviora',
    debugShowCheckedModeBanner: false,
    theme: ThemeData(
      brightness: Brightness.dark,
      colorSchemeSeed: const Color(0xff83b8ff),
      scaffoldBackgroundColor: const Color(0xff091124),
    ),
    home: const PreviewHome(),
  );
}

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

  @override
  State<PreviewHome> createState() => _PreviewHomeState();
}

class _PreviewHomeState extends State<PreviewHome> {
  String? _json;
  String? _js;
  String? _music;
  String? _illustration;
  final Map<String, String> _storyboards = {};
  PluvioraSource? _source;
  PluvioraController _controller = PluvioraController();
  Timer? _refresh;

  static const _audioExtensions = ['ogg', 'opus', 'wav', 'mp3', 'flac', 'm4a'];
  static const _imageExtensions = ['avif', 'png', 'jpg', 'jpeg', 'webp'];

  @override
  void initState() {
    super.initState();
    _refresh = Timer.periodic(const Duration(milliseconds: 100), (_) {
      if (mounted && _source != null) setState(() {});
    });
  }

  Future<String?> _pickOne(List<String> extensions) async {
    final result = await FilePicker.pickFiles(
      type: FileType.custom,
      allowedExtensions: extensions,
    );
    return result?.files.single.path;
  }

  Future<void> _pickBundle() async {
    final result = await FilePicker.pickFiles(
      allowMultiple: true,
      type: FileType.any,
    );
    final paths = result?.paths.whereType<String>().toList() ?? const [];
    if (paths.isEmpty) return;
    final jsons = paths.where((path) => _extension(path) == 'json').toList();
    final scripts = paths.where((path) => _extension(path) == 'js').toList();
    final audio = paths
        .where((path) => _audioExtensions.contains(_extension(path)))
        .toList();
    final images = paths
        .where((path) => _imageExtensions.contains(_extension(path)))
        .toList();
    final json = jsons.firstOrNull;
    final stem = json == null ? null : _stem(json);
    final pairedIllustration =
        images.where((path) => _stem(path) == stem).firstOrNull ??
        images.firstOrNull;
    setState(() {
      _json = json ?? _json;
      _js =
          scripts.where((path) => _stem(path) == stem).firstOrNull ??
          scripts.firstOrNull ??
          _js;
      _music =
          audio.where((path) => _stem(path) == stem).firstOrNull ??
          audio.firstOrNull ??
          _music;
      _illustration = pairedIllustration ?? _illustration;
      for (final image in images) {
        if (image != pairedIllustration) {
          _storyboards[_basename(image)] = image;
        }
      }
    });
  }

  Future<void> _pickStoryboards() async {
    final result = await FilePicker.pickFiles(
      allowMultiple: true,
      type: FileType.custom,
      allowedExtensions: _imageExtensions,
    );
    setState(() {
      for (final path
          in result?.paths.whereType<String>() ?? const <String>[]) {
        _storyboards[_basename(path)] = path;
      }
    });
  }

  void _start() {
    if (_json == null || _music == null) return;
    setState(() {
      _source = PluvioraSource.files(
        chart: _json!,
        music: _music!,
        archiveJs: _js,
        illustration: _illustration,
        storyboardAssets: _storyboards,
      );
    });
  }

  void _closePreview() {
    setState(() {
      _source = null;
      _controller.dispose();
      _controller = PluvioraController();
    });
  }

  @override
  Widget build(BuildContext context) {
    final source = _source;
    if (source != null) return _preview(source);
    return Scaffold(
      appBar: AppBar(title: const Text('Pluviora')),
      body: ListView(
        padding: const EdgeInsets.all(20),
        children: [
          const Text(
            '选择 Milthm 归档运行时文件',
            style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700),
          ),
          const SizedBox(height: 8),
          const Text('JSON 与音乐为必选;同名 JS 只用于恢复音符静态顺序,不会被执行。'),
          const SizedBox(height: 24),
          FilledButton.icon(
            onPressed: _pickBundle,
            icon: const Icon(Icons.folder_open),
            label: const Text('一次选择并自动配对'),
          ),
          const SizedBox(height: 16),
          _fileTile('铺面 JSON *', _json, () async {
            final path = await _pickOne(const ['json']);
            if (path != null) setState(() => _json = path);
          }),
          _fileTile('同名 JS', _js, () async {
            final path = await _pickOne(const ['js']);
            if (path != null) setState(() => _js = path);
          }),
          _fileTile('音乐 *', _music, () async {
            final path = await _pickOne(_audioExtensions);
            if (path != null) setState(() => _music = path);
          }),
          _fileTile('曲绘 AVIF / PNG / JPEG', _illustration, () async {
            final path = await _pickOne(_imageExtensions);
            if (path != null) setState(() => _illustration = path);
          }),
          _fileTile(
            'Storyboard 素材(${_storyboards.length})',
            _storyboards.isEmpty ? null : _storyboards.keys.join('、'),
            _pickStoryboards,
          ),
          const SizedBox(height: 20),
          FilledButton(
            onPressed: _json != null && _music != null ? _start : null,
            child: const Padding(
              padding: EdgeInsets.symmetric(vertical: 12),
              child: Text('开始预览'),
            ),
          ),
        ],
      ),
    );
  }

  Widget _preview(PluvioraSource source) {
    final durationMs = _controller.duration.inMilliseconds;
    final positionMs = _controller.position.inMilliseconds.clamp(0, durationMs);
    return Scaffold(
      body: Stack(
        fit: StackFit.expand,
        children: [
          PluvioraPlayer(source: source, controller: _controller),
          Positioned(
            left: 12,
            right: 12,
            bottom: 10,
            child: SafeArea(
              child: DecoratedBox(
                decoration: BoxDecoration(
                  color: Colors.black.withValues(alpha: 0.55),
                  borderRadius: BorderRadius.circular(18),
                ),
                child: Row(
                  children: [
                    IconButton(
                      onPressed: _closePreview,
                      icon: const Icon(Icons.close),
                    ),
                    IconButton(
                      onPressed:
                          _controller.loadState == PluvioraLoadState.ready
                          ? () {
                              if (_controller.playbackState ==
                                  PluvioraPlaybackState.playing) {
                                _controller.pause();
                              } else {
                                _controller.play();
                              }
                            }
                          : null,
                      icon: Icon(
                        _controller.playbackState ==
                                PluvioraPlaybackState.playing
                            ? Icons.pause
                            : Icons.play_arrow,
                      ),
                    ),
                    Expanded(
                      child: Slider(
                        value: positionMs.toDouble(),
                        max: durationMs <= 0 ? 1 : durationMs.toDouble(),
                        onChanged: durationMs <= 0
                            ? null
                            : (value) => _controller.seek(
                                Duration(milliseconds: value.round()),
                              ),
                      ),
                    ),
                    Padding(
                      padding: const EdgeInsets.only(right: 14),
                      child: Text(
                        '${_time(_controller.position)} / ${_time(_controller.duration)}',
                        style: const TextStyle(
                          fontFeatures: [FontFeature.tabularFigures()],
                        ),
                      ),
                    ),
                  ],
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget _fileTile(String title, String? path, VoidCallback onTap) => Card(
    child: ListTile(
      title: Text(title),
      subtitle: Text(path == null ? '未选择' : _basename(path)),
      trailing: const Icon(Icons.chevron_right),
      onTap: onTap,
    ),
  );

  static String _basename(String path) =>
      path.replaceAll('\\', '/').split('/').last;
  static String _extension(String path) =>
      _basename(path).split('.').last.toLowerCase();
  static String _stem(String path) {
    final name = _basename(path);
    final dot = name.lastIndexOf('.');
    return (dot < 0 ? name : name.substring(0, dot)).toLowerCase();
  }

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

  @override
  void dispose() {
    _refresh?.cancel();
    _controller.dispose();
    super.dispose();
  }
}
0
likes
0
points
341
downloads

Publisher

unverified uploader

Weekly Downloads

高性能 Flutter 平台 Milthm 铺面预览器。

Repository (GitHub)
View/report issues

Topics

#milthm #rhythm-game #beatmap #flutter #ffi

License

unknown (license)

Dependencies

code_assets, ffi, flutter, flutter_avif, flutter_soloud, hooks, logging, native_toolchain_c

More

Packages that depend on pluviora