diff --git a/lib/services/time.dart b/lib/services/time.dart index 79f1a34..0ac304a 100644 --- a/lib/services/time.dart +++ b/lib/services/time.dart @@ -20,6 +20,33 @@ extension DurationFormatter on Duration { return '${isNegative ? '-' : ''}$hours:$minutes:$seconds'; } + String formatShortDuration() { + final isNegative = inMicroseconds < 0; + final positiveDuration = isNegative ? -this : this; + + final hours = positiveDuration.inHours; + final minutes = (positiveDuration.inMinutes % 60).toString().padLeft( + 2, + '0', + ); + final seconds = (positiveDuration.inSeconds % 60).toString().padLeft( + 2, + '0', + ); + final milliseconds = (positiveDuration.inMilliseconds % 1000) + .toString() + .padLeft(3, '0'); + + String result; + if (hours > 0) { + result = + '${isNegative ? '-' : ''}${hours.toString().padLeft(2, '0')}:$minutes:$seconds.$milliseconds'; + } else { + result = '${isNegative ? '-' : ''}$minutes:$seconds.$milliseconds'; + } + return result; + } + String formatOffset() { final isNegative = inMicroseconds < 0; final positiveDuration = isNegative ? -this : this; diff --git a/lib/widgets/content/audio.dart b/lib/widgets/content/audio.dart new file mode 100644 index 0000000..879645f --- /dev/null +++ b/lib/widgets/content/audio.dart @@ -0,0 +1,146 @@ +import 'dart:developer'; + +import 'package:flutter/material.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/pods/network.dart'; +import 'package:island/services/time.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:styled_widget/styled_widget.dart'; + +class UniversalAudio extends ConsumerStatefulWidget { + final String uri; + final bool autoplay; + const UniversalAudio({super.key, required this.uri, this.autoplay = false}); + + @override + ConsumerState createState() => _UniversalAudioState(); +} + +class _UniversalAudioState extends ConsumerState { + Player? _player; + + Duration _duration = Duration(seconds: 1); + Duration _duartionBuffered = Duration(seconds: 1); + Duration _position = Duration(seconds: 0); + + bool _sliderWorking = false; + Duration _sliderPosition = Duration(seconds: 0); + + void _openAudio() async { + final url = widget.uri; + MediaKit.ensureInitialized(); + + _player = Player(); + _player!.stream.position.listen((value) { + _position = value; + if (!_sliderWorking) _sliderPosition = _position; + setState(() {}); + }); + _player!.stream.buffer.listen((value) { + _duartionBuffered = value; + setState(() {}); + }); + _player!.stream.duration.listen((value) { + _duration = value; + setState(() {}); + }); + + String? uri; + final inCacheInfo = await DefaultCacheManager().getFileFromCache(url); + if (inCacheInfo == null) { + log('[MediaPlayer] Miss cache: $url'); + final token = ref.watch(tokenProvider)?.token; + DefaultCacheManager().downloadFile( + url, + authHeaders: {'Authorization': 'AtField $token'}, + ); + uri = url; + } else { + uri = inCacheInfo.file.path; + log('[MediaPlayer] Hit cache: $url'); + } + + _player!.open(Media(uri), play: widget.autoplay); + } + + @override + void initState() { + super.initState(); + _openAudio(); + } + + @override + void dispose() { + super.dispose(); + _player?.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_player == null) { + return Center(child: CircularProgressIndicator()); + } + + return Card( + color: Theme.of(context).colorScheme.surfaceContainerLowest, + child: Row( + children: [ + IconButton.filled( + onPressed: () { + _player!.playOrPause().then((_) { + if (mounted) setState(() {}); + }); + }, + icon: + _player!.state.playing + ? const Icon(Symbols.pause, fill: 1, color: Colors.white) + : const Icon( + Symbols.play_arrow, + fill: 1, + color: Colors.white, + ), + ), + const Gap(20), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Text( + '${_position.formatShortDuration()} / ${_duration.formatShortDuration()}', + ), + ], + ), + Slider( + value: _sliderPosition.inMilliseconds.toDouble(), + secondaryTrackValue: + _duartionBuffered.inMilliseconds.toDouble(), + max: _duration.inMilliseconds.toDouble(), + onChangeStart: (_) { + _sliderWorking = true; + }, + onChanged: (value) { + _sliderPosition = Duration(milliseconds: value.toInt()); + setState(() {}); + }, + onChangeEnd: (value) { + _sliderPosition = Duration(milliseconds: value.toInt()); + _sliderWorking = false; + _player!.seek(_sliderPosition); + }, + year2023: true, + padding: EdgeInsets.zero, + ), + ], + ), + ), + ], + ).padding(horizontal: 24, vertical: 16), + ); + } +} diff --git a/lib/widgets/content/cloud_file_collection.dart b/lib/widgets/content/cloud_file_collection.dart index 00fcad3..23916eb 100644 --- a/lib/widgets/content/cloud_file_collection.dart +++ b/lib/widgets/content/cloud_file_collection.dart @@ -63,6 +63,27 @@ class CloudFileList extends HookConsumerWidget { if (files.isEmpty) return const SizedBox.shrink(); if (files.length == 1) { final isImage = files.first.mimeType?.startsWith('image') ?? false; + final isAudio = files.first.mimeType?.startsWith('audio') ?? false; + final widgetItem = ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: _CloudFileListEntry( + file: files.first, + heroTag: heroTags.first, + isImage: isImage, + disableZoomIn: disableZoomIn, + onTap: () { + if (!isImage) { + return; + } + if (!disableZoomIn) { + context.pushTransparentRoute( + CloudFileZoomIn(item: files.first, heroTag: heroTags.first), + rootNavigator: true, + ); + } + }, + ), + ); return Container( padding: padding, constraints: BoxConstraints( @@ -70,29 +91,14 @@ class CloudFileList extends HookConsumerWidget { minWidth: minWidth ?? 0, maxWidth: files.length == 1 ? maxWidth : double.infinity, ), - child: AspectRatio( - aspectRatio: calculateAspectRatio(), - child: ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(8)), - child: _CloudFileListEntry( - file: files.first, - heroTag: heroTags.first, - isImage: isImage, - disableZoomIn: disableZoomIn, - onTap: () { - if (!isImage) { - return; - } - if (!disableZoomIn) { - context.pushTransparentRoute( - CloudFileZoomIn(item: files.first, heroTag: heroTags.first), - rootNavigator: true, - ); - } - }, - ), - ), - ), + height: isAudio ? 180 : null, + child: + isAudio + ? widgetItem + : AspectRatio( + aspectRatio: calculateAspectRatio(), + child: widgetItem, + ), ); } diff --git a/lib/widgets/content/cloud_files.dart b/lib/widgets/content/cloud_files.dart index bdd7b8f..bc21332 100644 --- a/lib/widgets/content/cloud_files.dart +++ b/lib/widgets/content/cloud_files.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -5,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/models/file.dart'; import 'package:island/pods/config.dart'; import 'package:island/services/time.dart'; +import 'package:island/widgets/content/audio.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:styled_widget/styled_widget.dart'; @@ -49,6 +52,14 @@ class CloudFileWidget extends ConsumerWidget { aspectRatio: ratio, child: CloudVideoWidget(item: item), ), + "audio" => Center( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: math.min(360, MediaQuery.of(context).size.width * 0.8), + ), + child: UniversalAudio(uri: uri), + ), + ), _ => Text('Unable render for ${item.mimeType}'), }; diff --git a/lib/widgets/post/compose_recorder.dart b/lib/widgets/post/compose_recorder.dart index 3879d1e..98c2d75 100644 --- a/lib/widgets/post/compose_recorder.dart +++ b/lib/widgets/post/compose_recorder.dart @@ -88,7 +88,7 @@ class ComposeRecorder extends HookConsumerWidget { children: [ const Gap(32), Text( - recordingDuration.value.formatDuration(), + recordingDuration.value.formatShortDuration(), ).fontSize(20).bold().padding(bottom: 8), SizedBox( height: 120,