diff --git a/ios/Podfile.lock b/ios/Podfile.lock index fd0d8902..bb19e01b 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,20 +1,14 @@ PODS: - - audio_waveforms (0.0.1): - - Flutter - Flutter (1.0.0) DEPENDENCIES: - - audio_waveforms (from `.symlinks/plugins/audio_waveforms/ios`) - Flutter (from `Flutter`) EXTERNAL SOURCES: - audio_waveforms: - :path: ".symlinks/plugins/audio_waveforms/ios" Flutter: :path: Flutter SPEC CHECKSUMS: - audio_waveforms: a6dde7fe7c0ea05f06ffbdb0f7c1b2b2ba6cedcf Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 PODFILE CHECKSUM: e716c32704b29904c5fe233535ab45faecf8c549 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 445c0fc8..d65bc23b 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -209,7 +209,6 @@ 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - 690D4588462089555B03AB06 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -305,23 +304,6 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; - 690D4588462089555B03AB06 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; 6CBBC3DF6451F183E15B477D /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; diff --git a/lib/src/core/l10n/app_localizations.dart b/lib/src/core/l10n/app_localizations.dart index 2cbfa049..d29b8bf6 100644 --- a/lib/src/core/l10n/app_localizations.dart +++ b/lib/src/core/l10n/app_localizations.dart @@ -166,6 +166,12 @@ abstract class AppLocalizations { /// **'Replace'** String get buttonReplace; + /// Change which part of the selected sound is used + /// + /// In en, this message translates to: + /// **'Adjust clip'** + String get buttonAdjustClip; + /// Mute the selected audio track /// /// In en, this message translates to: @@ -1354,6 +1360,18 @@ abstract class AppLocalizations { /// **'Select sound'** String get titleSelectSound; + /// Title for choosing which section of a sound plays over a video + /// + /// In en, this message translates to: + /// **'Select sound clip'** + String get labelSelectSoundClip; + + /// Instruction below the video editor sound waveform + /// + /// In en, this message translates to: + /// **'Drag to choose the part that plays'** + String get hintDragWaveform; + /// Placeholder for sound picker search input /// /// In en, this message translates to: diff --git a/lib/src/core/l10n/app_localizations_en.dart b/lib/src/core/l10n/app_localizations_en.dart index a0a93a09..e305c7b0 100644 --- a/lib/src/core/l10n/app_localizations_en.dart +++ b/lib/src/core/l10n/app_localizations_en.dart @@ -44,6 +44,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get buttonReplace => 'Replace'; + @override + String get buttonAdjustClip => 'Adjust clip'; + @override String get buttonMute => 'Mute'; @@ -679,6 +682,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get titleSelectSound => 'Select sound'; + @override + String get labelSelectSoundClip => 'Select sound clip'; + + @override + String get hintDragWaveform => 'Drag to choose the part that plays'; + @override String get hintSearchSounds => 'Search sounds'; diff --git a/lib/src/core/l10n/intl_en.arb b/lib/src/core/l10n/intl_en.arb index beabc3a8..4e955acc 100644 --- a/lib/src/core/l10n/intl_en.arb +++ b/lib/src/core/l10n/intl_en.arb @@ -62,6 +62,11 @@ "description": "Replace the selected media track" }, + "buttonAdjustClip": "Adjust clip", + "@buttonAdjustClip": { + "description": "Change which part of the selected sound is used" + }, + "buttonMute": "Mute", "@buttonMute": { "description": "Mute the selected audio track" @@ -1103,6 +1108,16 @@ "description": "Title for the recorder sound picker" }, + "labelSelectSoundClip": "Select sound clip", + "@labelSelectSoundClip": { + "description": "Title for choosing which section of a sound plays over a video" + }, + + "hintDragWaveform": "Drag to choose the part that plays", + "@hintDragWaveform": { + "description": "Instruction below the video editor sound waveform" + }, + "hintSearchSounds": "Search sounds", "@hintSearchSounds": { "description": "Placeholder for sound picker search input" diff --git a/lib/src/core/pro_video_editor/models/audio_audition_timing.dart b/lib/src/core/pro_video_editor/models/audio_audition_timing.dart new file mode 100644 index 00000000..d97ae240 --- /dev/null +++ b/lib/src/core/pro_video_editor/models/audio_audition_timing.dart @@ -0,0 +1,82 @@ +import 'package:pro_image_editor/pro_image_editor.dart'; + +Duration audioSelectionDuration({ + required Duration audioDuration, + required Duration selectionWindowDuration, +}) { + if (audioDuration <= Duration.zero || + selectionWindowDuration <= Duration.zero) { + return Duration.zero; + } + return audioDuration < selectionWindowDuration + ? audioDuration + : selectionWindowDuration; +} + +AudioTrack audioTrackForAuditionRange( + AudioTrack track, { + required TrimDurationSpan playbackSpan, + Duration? sourceStart, +}) { + final playbackDuration = playbackSpan.end - playbackSpan.start; + final selectionDuration = audioSelectionDuration( + audioDuration: track.duration, + selectionWindowDuration: playbackDuration, + ); + final maximumStart = track.duration - selectionDuration; + var normalizedSourceStart = + sourceStart ?? track.audioStartTime ?? Duration.zero; + if (normalizedSourceStart < Duration.zero) { + normalizedSourceStart = Duration.zero; + } + if (normalizedSourceStart > maximumStart) { + normalizedSourceStart = maximumStart; + } + return track.copyWith( + audioStartTime: normalizedSourceStart, + audioEndTime: normalizedSourceStart + selectionDuration, + startTime: playbackSpan.start, + endTime: playbackSpan.end, + loop: track.duration < playbackDuration, + ); +} + +TrimDurationSpan audioTrackPreviewRange({ + required AudioTrack track, + required Duration hostStart, + required Duration hostEnd, +}) { + final trackStart = track.startTime ?? hostStart; + final trackEnd = track.endTime ?? hostEnd; + final previewStart = trackStart > hostStart ? trackStart : hostStart; + final previewEnd = trackEnd < hostEnd ? trackEnd : hostEnd; + if (previewEnd <= previewStart) { + return TrimDurationSpan(start: hostStart, end: hostEnd); + } + return TrimDurationSpan(start: previewStart, end: previewEnd); +} + +double audioRangePlaybackProgress({ + required Duration position, + required Duration rangeStart, + required Duration rangeEnd, +}) { + final rangeDuration = rangeEnd - rangeStart; + if (rangeDuration <= Duration.zero) return 0; + return ((position - rangeStart).inMicroseconds / rangeDuration.inMicroseconds) + .clamp(0.0, 1.0) + .toDouble(); +} + +Duration? audioRangeLoopTarget({ + required bool isPlaybackArmed, + required bool isPlaybackCompleted, + required Duration position, + required TrimDurationSpan range, +}) { + final isOutsideRange = position < range.start || position >= range.end; + if (!isPlaybackArmed || (!isPlaybackCompleted && !isOutsideRange)) { + return null; + } + return range.start; +} diff --git a/lib/src/core/pro_video_editor/models/sound_audio_track.dart b/lib/src/core/pro_video_editor/models/sound_audio_track.dart index 34b874ac..d3bb8ea4 100644 --- a/lib/src/core/pro_video_editor/models/sound_audio_track.dart +++ b/lib/src/core/pro_video_editor/models/sound_audio_track.dart @@ -77,7 +77,10 @@ String decodeSoundTrackAudioFileExtension(String? encoded) { if (encoded == null) return _fallbackAudioFileExtension; try { final map = jsonDecode(encoded) as Map; - return _normalizeAudioFileExtension(map['audioFileExtension'] as String?); + return _resolveAudioFormat( + extension: map['audioFileExtension'] as String?, + mimeType: map['audioMimeType'] as String?, + ).extension; } catch (_) { return _fallbackAudioFileExtension; } @@ -87,10 +90,10 @@ String decodeSoundTrackAudioMimeType(String? encoded) { if (encoded == null) return 'audio/mpeg'; try { final map = jsonDecode(encoded) as Map; - return _normalizeAudioMimeType( - map['audioMimeType'] as String?, + return _resolveAudioFormat( extension: map['audioFileExtension'] as String?, - ); + mimeType: map['audioMimeType'] as String?, + ).mimeType; } catch (_) { return 'audio/mpeg'; } @@ -99,13 +102,13 @@ String decodeSoundTrackAudioMimeType(String? encoded) { String audioFileExtension(AudioView audio) { final record = audio.localRecord; if (record is PlyrTrackRecord) { - return _normalizeAudioFileExtension( - record.fileType, + return _resolveAudioFormat( + extension: record.fileType, mimeType: record.audioBlob?.mimeType, - ); + ).extension; } if (record is AudioRecord) { - return _normalizeAudioFileExtension(null, mimeType: record.sound.mimeType); + return _resolveAudioFormat(mimeType: record.sound.mimeType).extension; } return _fallbackAudioFileExtension; } @@ -113,13 +116,13 @@ String audioFileExtension(AudioView audio) { String audioMimeType(AudioView audio) { final record = audio.localRecord; if (record is PlyrTrackRecord) { - return _normalizeAudioMimeType( - record.audioBlob?.mimeType, + return _resolveAudioFormat( extension: record.fileType, - ); + mimeType: record.audioBlob?.mimeType, + ).mimeType; } if (record is AudioRecord) { - return _normalizeAudioMimeType(record.sound.mimeType); + return _resolveAudioFormat(mimeType: record.sound.mimeType).mimeType; } return 'audio/mpeg'; } @@ -172,55 +175,58 @@ bool _isSparkMediaSoundUrl(String url) { uri.pathSegments.first == 'sound'; } -String _normalizeAudioFileExtension(String? value, {String? mimeType}) { - final extension = value?.trim().toLowerCase().replaceFirst('.', ''); - if (extension != null && extension.isNotEmpty) { - final normalized = switch (extension) { - 'mpeg' => 'mp3', - 'mp4' => 'm4a', - 'x-m4a' => 'm4a', - 'x-wav' => 'wav', - _ => extension.replaceAll(RegExp(r'[^a-z0-9]'), ''), - }; - if (normalized.isNotEmpty) return normalized; - } - - final subtype = mimeType?.split('/').last.trim().toLowerCase(); - return switch (subtype) { - 'mpeg' => 'mp3', - 'mp3' => 'mp3', - 'mp4' => 'm4a', - 'm4a' => 'm4a', - 'x-m4a' => 'm4a', - 'aac' => 'aac', - 'vnd.wave' => 'wav', - 'wave' => 'wav', - 'wav' => 'wav', - 'x-wav' => 'wav', - 'flac' => 'flac', - 'ogg' => 'ogg', - _ => _fallbackAudioFileExtension, +_AudioFormat _resolveAudioFormat({String? extension, String? mimeType}) { + final normalizedMime = mimeType?.trim().toLowerCase(); + final mimeFormat = switch (normalizedMime) { + 'audio/mpeg' || 'audio/mp3' => const _AudioFormat('mp3', 'audio/mpeg'), + 'audio/mp4' || + 'audio/m4a' || + 'audio/x-m4a' => const _AudioFormat('m4a', 'audio/mp4'), + 'audio/aac' => const _AudioFormat('aac', 'audio/aac'), + 'audio/vnd.wave' || + 'audio/wave' || + 'audio/wav' || + 'audio/x-wav' => const _AudioFormat('wav', 'audio/wav'), + 'audio/flac' || 'audio/x-flac' => const _AudioFormat('flac', 'audio/flac'), + 'audio/ogg' => const _AudioFormat('ogg', 'audio/ogg'), + _ => null, }; -} + if (mimeFormat != null) return mimeFormat; + + final normalizedExtension = extension + ?.trim() + .toLowerCase() + .replaceFirst('.', '') + .replaceAll(RegExp(r'[^a-z0-9]'), ''); + final extensionFormat = switch (normalizedExtension) { + 'mpeg' || 'mp3' => const _AudioFormat('mp3', 'audio/mpeg'), + 'mp4' || 'm4a' || 'xm4a' => const _AudioFormat('m4a', 'audio/mp4'), + 'aac' => const _AudioFormat('aac', 'audio/aac'), + 'vndwave' || + 'wave' || + 'wav' || + 'xwav' => const _AudioFormat('wav', 'audio/wav'), + 'flac' || 'xflac' => const _AudioFormat('flac', 'audio/flac'), + 'ogg' => const _AudioFormat('ogg', 'audio/ogg'), + _ => null, + }; + if (extensionFormat != null) return extensionFormat; -String _normalizeAudioMimeType(String? mimeType, {String? extension}) { - final normalizedMime = mimeType?.trim().toLowerCase(); if (normalizedMime != null && normalizedMime.startsWith('audio/')) { - return switch (normalizedMime) { - 'audio/x-m4a' => 'audio/mp4', - 'audio/vnd.wave' => 'audio/wav', - 'audio/wave' => 'audio/wav', - 'audio/x-wav' => 'audio/wav', - _ => normalizedMime, - }; + final subtype = normalizedMime + .substring('audio/'.length) + .replaceAll(RegExp(r'[^a-z0-9]'), ''); + if (subtype.isNotEmpty) return _AudioFormat(subtype, normalizedMime); } + if (normalizedExtension != null && normalizedExtension.isNotEmpty) { + return _AudioFormat(normalizedExtension, 'audio/$normalizedExtension'); + } + return const _AudioFormat(_fallbackAudioFileExtension, 'audio/mpeg'); +} - return switch (_normalizeAudioFileExtension(extension)) { - 'm4a' => 'audio/mp4', - 'aac' => 'audio/aac', - 'wav' => 'audio/wav', - 'flac' => 'audio/flac', - 'ogg' => 'audio/ogg', - _ => 'audio/mpeg', - }; +class _AudioFormat { + const _AudioFormat(this.extension, this.mimeType); + + final String extension; + final String mimeType; } diff --git a/lib/src/core/pro_video_editor/services/audio_waveform_extractor.dart b/lib/src/core/pro_video_editor/services/audio_waveform_extractor.dart index 9048696f..23c79664 100644 --- a/lib/src/core/pro_video_editor/services/audio_waveform_extractor.dart +++ b/lib/src/core/pro_video_editor/services/audio_waveform_extractor.dart @@ -1,6 +1,5 @@ import 'dart:io'; -import 'package:audio_waveforms/audio_waveforms.dart'; import 'package:flutter/foundation.dart'; import 'package:path_provider/path_provider.dart'; import 'package:pro_image_editor/pro_image_editor.dart'; @@ -12,8 +11,6 @@ class AudioWaveformExtractor { static final instance = AudioWaveformExtractor._(); - final _waveformController = WaveformExtractionController(); - /// Extracts waveform samples from a video file. /// /// Returns normalized waveform data as a list of doubles (0.0 to 1.0). @@ -39,11 +36,32 @@ class AudioWaveformExtractor { /// Extracts waveform samples from a file path. Future> extractFromPath(String path) async { try { - final waveformData = await _waveformController.extractWaveformData( - path: path, + final waveform = await ProVideoEditor.instance.getWaveform( + WaveformConfigs( + video: EditorVideo.file(path), + resolution: WaveformResolution.medium, + ), ); - - return _normalizeWaveform(waveformData); + final leftChannel = waveform.leftChannel; + final rightChannel = waveform.rightChannel; + final samples = rightChannel == null + ? leftChannel.toList(growable: false) + : List.generate( + leftChannel.length > rightChannel.length + ? leftChannel.length + : rightChannel.length, + (index) { + final left = index < leftChannel.length + ? leftChannel[index] + : 0.0; + final right = index < rightChannel.length + ? rightChannel[index] + : 0.0; + return left.abs() > right.abs() ? left : right; + }, + growable: false, + ); + return _normalizeWaveform(samples); } catch (_) { return []; } @@ -105,9 +123,4 @@ class AudioWaveformExtractor { await file.writeAsBytes(bytes); return file.path; } - - /// Stops any ongoing waveform extraction. - Future stopExtraction() async { - await _waveformController.stopWaveformExtraction(); - } } diff --git a/lib/src/core/pro_video_editor/ui/controllers/audio_audition_controller.dart b/lib/src/core/pro_video_editor/ui/controllers/audio_audition_controller.dart new file mode 100644 index 00000000..168afdbe --- /dev/null +++ b/lib/src/core/pro_video_editor/ui/controllers/audio_audition_controller.dart @@ -0,0 +1,525 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:pro_image_editor/pro_image_editor.dart'; +import 'package:spark/src/core/pro_video_editor/models/audio_audition_timing.dart'; +import 'package:spark/src/core/pro_video_editor/ui/controllers/audio_audition_playback.dart'; + +typedef AudioAuditionErrorHandler = + void Function(String message, Object error, StackTrace stackTrace); + +enum AudioPickerPreviewStatus { idle, loading, ready, failed } + +@immutable +sealed class AudioAuditionState { + const AudioAuditionState({ + required this.previousTrack, + required this.hostSpan, + }); + + final AudioTrack? previousTrack; + final TrimDurationSpan hostSpan; + + bool get blocksHostInteraction; +} + +@immutable +final class AudioPickerAuditionState extends AudioAuditionState { + const AudioPickerAuditionState({ + required super.previousTrack, + required super.hostSpan, + required this.selectedTrack, + required this.previewStatus, + }); + + final AudioTrack? selectedTrack; + final AudioPickerPreviewStatus previewStatus; + + bool get canContinue => + selectedTrack != null && previewStatus == AudioPickerPreviewStatus.ready; + + @override + bool get blocksHostInteraction => false; + + AudioPickerAuditionState copyWith({ + AudioTrack? selectedTrack, + bool clearSelectedTrack = false, + AudioPickerPreviewStatus? previewStatus, + }) { + return AudioPickerAuditionState( + previousTrack: previousTrack, + hostSpan: hostSpan, + selectedTrack: clearSelectedTrack + ? null + : selectedTrack ?? this.selectedTrack, + previewStatus: previewStatus ?? this.previewStatus, + ); + } +} + +@immutable +final class AudioRangeAuditionState extends AudioAuditionState { + const AudioRangeAuditionState({ + required super.previousTrack, + required super.hostSpan, + required this.draft, + required this.playbackSpan, + required this.waveform, + required this.isWaveformLoading, + required this.isScrubbing, + }); + + final AudioTrack draft; + final TrimDurationSpan playbackSpan; + final List waveform; + final bool isWaveformLoading; + final bool isScrubbing; + + @override + bool get blocksHostInteraction => true; + + AudioRangeAuditionState copyWith({ + AudioTrack? draft, + List? waveform, + bool? isWaveformLoading, + bool? isScrubbing, + }) { + return AudioRangeAuditionState( + draft: draft ?? this.draft, + previousTrack: previousTrack, + playbackSpan: playbackSpan, + hostSpan: hostSpan, + waveform: waveform ?? this.waveform, + isWaveformLoading: isWaveformLoading ?? this.isWaveformLoading, + isScrubbing: isScrubbing ?? this.isScrubbing, + ); + } +} + +@immutable +final class AudioAuditionRestoringState extends AudioAuditionState { + const AudioAuditionRestoringState({ + required super.previousTrack, + required super.hostSpan, + required this.blocksHostInteraction, + }); + + @override + final bool blocksHostInteraction; +} + +class AudioAuditionResult { + const AudioAuditionResult({required this.track, required this.waveform}); + + final AudioTrack track; + final List waveform; +} + +class AudioAuditionController extends ChangeNotifier { + AudioAuditionController( + this._playback, + this._loadWaveform, + this._onCommit, + this._onError, + ); + + final AudioAuditionPlayback _playback; + final Future> Function(AudioTrack track) _loadWaveform; + final ValueChanged _onCommit; + final AudioAuditionErrorHandler _onError; + final playbackProgress = ValueNotifier(0.0); + + AudioAuditionState? _state; + int _sessionRevision = 0; + int _playbackRevision = 0; + bool _isPlaybackArmed = false; + bool _isDisposed = false; + Future? _scrubPauseFuture; + + AudioAuditionState? get state => _state; + + AudioRangeAuditionState? get rangeState => switch (_state) { + final AudioRangeAuditionState state => state, + _ => null, + }; + + bool get isActive => _state != null; + + Future beginPicker({ + required AudioTrack? previousTrack, + required TrimDurationSpan hostSpan, + }) async { + if (_state != null) return false; + final session = ++_sessionRevision; + _invalidatePlayback(clearScrubPause: true); + _setState( + AudioPickerAuditionState( + previousTrack: previousTrack, + hostSpan: hostSpan, + selectedTrack: previousTrack, + previewStatus: previousTrack == null + ? AudioPickerPreviewStatus.idle + : AudioPickerPreviewStatus.ready, + ), + ); + await _playback.pausePreview(); + return !_isDisposed && + session == _sessionRevision && + _state is AudioPickerAuditionState; + } + + Future selectPickerTrack(AudioTrack track) async { + final current = _state; + if (current is! AudioPickerAuditionState) return false; + final request = ++_playbackRevision; + _setState( + current.copyWith( + selectedTrack: track, + previewStatus: AudioPickerPreviewStatus.loading, + ), + ); + bool isCurrent() { + final state = _state; + return !_isDisposed && + request == _playbackRevision && + state is AudioPickerAuditionState && + state.selectedTrack?.id == track.id; + } + + try { + await _playback.previewCandidate( + track, + current.hostSpan, + isCurrent: isCurrent, + ); + if (!isCurrent()) return false; + final state = _state! as AudioPickerAuditionState; + _setState(state.copyWith(previewStatus: AudioPickerPreviewStatus.ready)); + return true; + } catch (error, stackTrace) { + _onError('Failed to preview a sound picker track', error, stackTrace); + if (!isCurrent()) return false; + try { + await _playback.pausePreview(); + } catch (pauseError, pauseStackTrace) { + _onError( + 'Failed to pause audio after a sound picker failure', + pauseError, + pauseStackTrace, + ); + } + if (!isCurrent()) return false; + final state = _state! as AudioPickerAuditionState; + _setState( + state.copyWith( + clearSelectedTrack: true, + previewStatus: AudioPickerPreviewStatus.failed, + ), + ); + return false; + } + } + + bool confirmPicker() { + final current = _state; + if (current is! AudioPickerAuditionState || !current.canContinue) { + return false; + } + _beginRange( + track: current.selectedTrack!, + previousTrack: current.previousTrack, + playbackSpan: current.hostSpan, + hostSpan: current.hostSpan, + ); + return true; + } + + bool beginAdjustment({ + required AudioTrack track, + required TrimDurationSpan hostSpan, + required List waveform, + }) { + if (_state != null) return false; + _beginRange( + track: track, + previousTrack: track, + playbackSpan: audioTrackPreviewRange( + track: track, + hostStart: hostSpan.start, + hostEnd: hostSpan.end, + ), + hostSpan: hostSpan, + waveform: waveform, + ); + return true; + } + + void _beginRange({ + required AudioTrack track, + required AudioTrack? previousTrack, + required TrimDurationSpan playbackSpan, + required TrimDurationSpan hostSpan, + List waveform = const [], + }) { + final session = ++_sessionRevision; + _invalidatePlayback(clearScrubPause: true); + final draft = audioTrackForAuditionRange(track, playbackSpan: playbackSpan); + _setState( + AudioRangeAuditionState( + draft: draft, + previousTrack: previousTrack, + playbackSpan: playbackSpan, + hostSpan: hostSpan, + waveform: waveform, + isWaveformLoading: waveform.isEmpty, + isScrubbing: false, + ), + ); + if (waveform.isEmpty) { + unawaited(_loadSessionWaveform(draft, session: session)); + } + unawaited(_restartPreview()); + } + + Future _loadSessionWaveform( + AudioTrack track, { + required int session, + }) async { + try { + final waveform = await _loadWaveform(track); + final current = _state; + if (_isDisposed || + current is! AudioRangeAuditionState || + session != _sessionRevision || + current.draft.id != track.id) { + return; + } + _setState(current.copyWith(waveform: waveform, isWaveformLoading: false)); + } catch (error, stackTrace) { + _onError('Failed to extract audio picker waveform', error, stackTrace); + final current = _state; + if (_isDisposed || + current is! AudioRangeAuditionState || + session != _sessionRevision) { + return; + } + _setState(current.copyWith(isWaveformLoading: false)); + } + } + + Future pauseForScrub() async { + final current = _state; + if (current is! AudioRangeAuditionState) return; + final request = _invalidatePlayback(clearScrubPause: false); + _setState(current.copyWith(isScrubbing: true)); + final previousPause = _scrubPauseFuture; + final pause = _pausePreviewForScrub(); + final Future barrier = previousPause == null + ? pause + : Future.wait([previousPause, pause]).then((_) {}); + _scrubPauseFuture = barrier; + await barrier; + if (request == _playbackRevision && identical(_scrubPauseFuture, barrier)) { + _scrubPauseFuture = null; + } + } + + Future _pausePreviewForScrub() async { + try { + await _playback.pausePreview(); + } catch (error, stackTrace) { + _onError('Failed to pause audio range preview', error, stackTrace); + } + } + + Future previewRange(Duration sourceStart) async { + final session = _sessionRevision; + final request = _playbackRevision; + final pause = _scrubPauseFuture; + await pause; + final current = _state; + if (_isDisposed || + session != _sessionRevision || + request != _playbackRevision) { + return; + } + if (current is! AudioRangeAuditionState) return; + final draft = audioTrackForAuditionRange( + current.draft, + playbackSpan: current.playbackSpan, + sourceStart: sourceStart, + ); + _setState(current.copyWith(draft: draft, isScrubbing: false)); + unawaited(_restartPreview()); + } + + Future _restartPreview() async { + final current = _state; + if (current is! AudioRangeAuditionState) return; + final request = _invalidatePlayback(clearScrubPause: false); + final session = _sessionRevision; + bool isCurrent() => + !_isDisposed && + request == _playbackRevision && + session == _sessionRevision && + _state is AudioRangeAuditionState; + + try { + await _playback.prepareRangePreview( + current.draft, + current.playbackSpan, + isCurrent: isCurrent, + ); + if (!isCurrent()) return; + _isPlaybackArmed = true; + await _playback.startRangePreview( + current.draft, + current.playbackSpan, + isCurrent: isCurrent, + ); + } catch (error, stackTrace) { + await _failPreview( + request: request, + message: 'Failed to start audio range preview', + error: error, + stackTrace: stackTrace, + ); + } + } + + AudioRangeAuditionState? handlePlaybackSnapshot( + AudioAuditionPlaybackSnapshot snapshot, + ) { + final current = _state; + if (current is! AudioRangeAuditionState) return null; + if (!current.isScrubbing) { + playbackProgress.value = audioRangePlaybackProgress( + position: snapshot.position, + rangeStart: current.playbackSpan.start, + rangeEnd: current.playbackSpan.end, + ); + } + final loopTarget = audioRangeLoopTarget( + isPlaybackArmed: _isPlaybackArmed, + isPlaybackCompleted: snapshot.isCompleted, + position: snapshot.position, + range: current.playbackSpan, + ); + if (loopTarget != null) { + _isPlaybackArmed = false; + unawaited(_restartPreview()); + return null; + } + return current; + } + + bool finish(Duration sourceStart) { + final current = _state; + if (current is! AudioRangeAuditionState) return false; + _sessionRevision++; + _invalidatePlayback(clearScrubPause: true); + final track = audioTrackForAuditionRange( + current.draft, + playbackSpan: current.playbackSpan, + sourceStart: sourceStart, + ); + _onCommit(AudioAuditionResult(track: track, waveform: current.waveform)); + _setState(null); + return true; + } + + Future cancel() async { + final current = _state; + if (current == null || current is AudioAuditionRestoringState) return; + final session = ++_sessionRevision; + _invalidatePlayback(clearScrubPause: true); + _setState( + AudioAuditionRestoringState( + previousTrack: current.previousTrack, + hostSpan: current.hostSpan, + blocksHostInteraction: current.blocksHostInteraction, + ), + ); + bool isCurrent() => + !_isDisposed && + session == _sessionRevision && + _state is AudioAuditionRestoringState; + + try { + await _playback.pausePreview(); + if (!isCurrent()) return; + await _playback.restorePrevious( + current.previousTrack, + current.hostSpan, + isCurrent: isCurrent, + ); + } catch (error, stackTrace) { + _onError( + 'Failed to restore audio after cancelling sound selection', + error, + stackTrace, + ); + if (!isCurrent()) return; + try { + await _playback.pausePreview(); + } catch (pauseError, pauseStackTrace) { + _onError( + 'Failed to pause audio after sound restoration failure', + pauseError, + pauseStackTrace, + ); + } + } + if (isCurrent()) _setState(null); + } + + Future _failPreview({ + required int request, + required String message, + required Object error, + required StackTrace stackTrace, + }) async { + _onError(message, error, stackTrace); + final current = _state; + if (_isDisposed || + current is! AudioRangeAuditionState || + request != _playbackRevision) { + return; + } + _invalidatePlayback(clearScrubPause: true); + _setState(current.copyWith(isScrubbing: true)); + try { + await _playback.pausePreview(); + } catch (pauseError, pauseStackTrace) { + _onError( + 'Failed to pause audio after range preview failure', + pauseError, + pauseStackTrace, + ); + } + } + + void _setState(AudioAuditionState? value) { + if (_isDisposed) return; + _state = value; + notifyListeners(); + } + + int _invalidatePlayback({required bool clearScrubPause}) { + _isPlaybackArmed = false; + if (clearScrubPause) _scrubPauseFuture = null; + playbackProgress.value = 0; + return ++_playbackRevision; + } + + @override + void dispose() { + if (_isDisposed) return; + _isDisposed = true; + _sessionRevision++; + _invalidatePlayback(clearScrubPause: true); + _state = null; + playbackProgress.dispose(); + super.dispose(); + } +} diff --git a/lib/src/core/pro_video_editor/ui/controllers/audio_audition_playback.dart b/lib/src/core/pro_video_editor/ui/controllers/audio_audition_playback.dart new file mode 100644 index 00000000..b1ed3208 --- /dev/null +++ b/lib/src/core/pro_video_editor/ui/controllers/audio_audition_playback.dart @@ -0,0 +1,48 @@ +import 'package:flutter/foundation.dart'; +import 'package:pro_image_editor/pro_image_editor.dart'; + +/// Host-neutral playback state consumed by an audio audition session. +@immutable +class AudioAuditionPlaybackSnapshot { + const AudioAuditionPlaybackSnapshot({ + required this.position, + required this.isPlaying, + required this.isCompleted, + }); + + final Duration position; + final bool isPlaying; + final bool isCompleted; +} + +/// Playback operations supplied by the surface hosting an audio audition. +/// +/// A video editor can synchronize these operations with a seekable video, +/// while a recorder can implement them with an audio-only preview clock. +abstract interface class AudioAuditionPlayback { + Future pausePreview(); + + Future previewCandidate( + AudioTrack track, + TrimDurationSpan hostSpan, { + required bool Function() isCurrent, + }); + + Future prepareRangePreview( + AudioTrack track, + TrimDurationSpan playbackSpan, { + required bool Function() isCurrent, + }); + + Future startRangePreview( + AudioTrack track, + TrimDurationSpan playbackSpan, { + required bool Function() isCurrent, + }); + + Future restorePrevious( + AudioTrack? track, + TrimDurationSpan hostSpan, { + required bool Function() isCurrent, + }); +} diff --git a/lib/src/core/pro_video_editor/ui/controllers/video_editor_media_session.dart b/lib/src/core/pro_video_editor/ui/controllers/video_editor_media_session.dart index 20c62212..bfe2329e 100644 --- a/lib/src/core/pro_video_editor/ui/controllers/video_editor_media_session.dart +++ b/lib/src/core/pro_video_editor/ui/controllers/video_editor_media_session.dart @@ -1,6 +1,8 @@ import 'dart:async'; +import 'package:pro_image_editor/pro_image_editor.dart'; import 'package:spark/src/core/pro_video_editor/services/audio_helper_service.dart'; +import 'package:spark/src/core/pro_video_editor/ui/controllers/audio_audition_playback.dart'; import 'package:spark/src/core/pro_video_editor/ui/widgets/timeline/video_timeline_state.dart'; import 'package:video_player/video_player.dart'; @@ -12,7 +14,9 @@ class VideoEditorMediaSession { required this.videoController, required Duration videoDuration, required VideoEditorSeekErrorHandler onSeekError, - }) : audioService = AudioHelperService(videoController: videoController), + AudioHelperService? audioService, + }) : audioService = + audioService ?? AudioHelperService(videoController: videoController), timelineState = VideoTimelineState(videoDuration: videoDuration), timelineSeeks = VideoEditorTimelineSeekCoordinator( videoController: videoController, @@ -41,6 +45,229 @@ class VideoEditorMediaSession { } } +class VideoEditorAudioPlaybackCoordinator { + VideoEditorAudioPlaybackCoordinator(this._media, this._controller) + : _audioService = _media.audioService; + + static const _playbackStartPollInterval = Duration(milliseconds: 10); + static const _playbackStartWaitTimeout = Duration(milliseconds: 220); + + final VideoEditorMediaSession _media; + final ProVideoController _controller; + final AudioHelperService _audioService; + + void _setEditorPlaying(bool isPlaying) { + _controller.isPlayingNotifier.value = isPlaying; + _media.timelineState.setPlaying(isPlaying: isPlaying); + } + + bool rejectPlayRequest({required bool auditionActive}) { + if (!auditionActive) return false; + _setEditorPlaying(false); + return true; + } + + Future pauseEditorPlayback() async { + _setEditorPlaying(false); + await Future.wait([_media.videoController.pause(), _audioService.pause()]); + } + + Future previewAudioCandidate( + AudioTrack track, + TrimDurationSpan hostSpan, { + required bool Function() isCurrent, + }) async { + final isNewTrack = !_audioService.useCustomAudio; + await _audioService.play( + track, + videoPosition: _media.videoController.value.position, + videoStart: hostSpan.start, + videoEnd: hostSpan.end, + forceSeek: true, + ); + if (!isCurrent()) return; + if (isNewTrack) { + await _audioService.setAudioMode(useCustom: true); + } else { + await _audioService.balanceAudio(); + } + } + + Future prepareAudioRange( + AudioTrack track, + TrimDurationSpan playbackSpan, { + required bool Function() isCurrent, + }) async { + await pauseEditorPlayback(); + if (!isCurrent()) return; + await _media.timelineSeeks.seekLatest(playbackSpan.start); + if (!isCurrent()) return; + _controller.setPlayTime(playbackSpan.start); + _media.timelineState.setProgressFromDuration(playbackSpan.start); + await _audioService.prepare( + track, + videoPosition: playbackSpan.start, + videoStart: playbackSpan.start, + videoEnd: playbackSpan.end, + ); + if (!isCurrent()) return; + await _audioService.setAudioMode(useCustom: true); + } + + Future startAudioRange( + AudioTrack track, + TrimDurationSpan playbackSpan, { + required bool Function() isCurrent, + }) async { + if (!isCurrent()) return; + _setEditorPlaying(true); + await _playTrack(track, playbackSpan, isCurrent: isCurrent); + } + + Future playEditorPlayback( + AudioTrack? track, + TrimDurationSpan playbackSpan, { + required bool Function() isCurrent, + }) async { + if (!isCurrent()) return; + _setEditorPlaying(true); + if (track == null) { + await _media.videoController.play(); + return; + } + await _playTrack(track, playbackSpan, isCurrent: isCurrent); + } + + Future _playTrack( + AudioTrack track, + TrimDurationSpan playbackSpan, { + required bool Function() isCurrent, + }) async { + var videoPosition = _media.videoController.value.position; + if (videoPosition < playbackSpan.start || + videoPosition >= playbackSpan.end) { + await _media.timelineSeeks.seekLatest(playbackSpan.start); + if (!isCurrent()) return; + videoPosition = playbackSpan.start; + _controller.setPlayTime(videoPosition); + _media.timelineState.setProgressFromDuration(videoPosition); + } + + final isPlaybackStart = videoPosition == playbackSpan.start; + if (!_media.videoController.value.isPlaying) { + await _media.videoController.play(); + if (!isCurrent()) return; + } + + if (isPlaybackStart) { + final stopwatch = Stopwatch()..start(); + while (stopwatch.elapsed < _playbackStartWaitTimeout) { + if (!isCurrent()) return; + videoPosition = _media.videoController.value.position; + if (videoPosition > playbackSpan.start) break; + await Future.delayed(_playbackStartPollInterval); + } + } + + if (!isCurrent()) return; + await _audioService.play( + track, + videoPosition: videoPosition, + videoStart: playbackSpan.start, + videoEnd: playbackSpan.end, + forceSeek: isPlaybackStart, + ); + } + + Future restoreAudio( + AudioTrack? track, + TrimDurationSpan hostSpan, { + required bool Function() isCurrent, + }) async { + if (track == null) { + await _audioService.setAudioMode(useCustom: false); + return; + } + await _audioService.prepare( + track, + videoPosition: _media.videoController.value.position, + videoStart: hostSpan.start, + videoEnd: hostSpan.end, + ); + if (!isCurrent()) return; + await _audioService.setAudioMode(useCustom: true); + } + + Future synchronize( + AudioTrack track, + TrimDurationSpan playbackSpan, + AudioAuditionPlaybackSnapshot snapshot, { + required bool Function() isCurrent, + }) { + if (!isCurrent()) return Future.value(); + return _audioService.synchronizePlayback( + track, + videoPosition: snapshot.position, + videoStart: playbackSpan.start, + videoEnd: playbackSpan.end, + isVideoPlaying: snapshot.isPlaying, + ); + } +} + +class VideoEditorAudioAuditionPlayback implements AudioAuditionPlayback { + const VideoEditorAudioAuditionPlayback(this._playback); + + final VideoEditorAudioPlaybackCoordinator _playback; + + @override + Future pausePreview() => _playback.pauseEditorPlayback(); + + @override + Future previewCandidate( + AudioTrack track, + TrimDurationSpan hostSpan, { + required bool Function() isCurrent, + }) { + return _playback.previewAudioCandidate( + track, + hostSpan, + isCurrent: isCurrent, + ); + } + + @override + Future prepareRangePreview( + AudioTrack track, + TrimDurationSpan playbackSpan, { + required bool Function() isCurrent, + }) { + return _playback.prepareAudioRange( + track, + playbackSpan, + isCurrent: isCurrent, + ); + } + + @override + Future startRangePreview( + AudioTrack track, + TrimDurationSpan playbackSpan, { + required bool Function() isCurrent, + }) { + return _playback.startAudioRange(track, playbackSpan, isCurrent: isCurrent); + } + + @override + Future restorePrevious( + AudioTrack? track, + TrimDurationSpan hostSpan, { + required bool Function() isCurrent, + }) { + return _playback.restoreAudio(track, hostSpan, isCurrent: isCurrent); + } +} + class VideoEditorTimelineSeekCoordinator { VideoEditorTimelineSeekCoordinator({ required this._videoController, diff --git a/lib/src/core/pro_video_editor/ui/video_editor_grounded_page.dart b/lib/src/core/pro_video_editor/ui/video_editor_grounded_page.dart index f909bd74..e6268ce0 100644 --- a/lib/src/core/pro_video_editor/ui/video_editor_grounded_page.dart +++ b/lib/src/core/pro_video_editor/ui/video_editor_grounded_page.dart @@ -13,9 +13,12 @@ import 'package:spark/src/core/pro_image_editor/story_mention_editing.dart'; import 'package:spark/src/core/pro_video_editor/models/sound_audio_track.dart'; import 'package:spark/src/core/pro_video_editor/models/video_editor_result.dart'; import 'package:spark/src/core/pro_video_editor/services/audio_helper_service.dart'; +import 'package:spark/src/core/pro_video_editor/ui/controllers/audio_audition_controller.dart'; +import 'package:spark/src/core/pro_video_editor/ui/controllers/audio_audition_playback.dart'; import 'package:spark/src/core/pro_video_editor/ui/controllers/video_editor_export_controller.dart'; import 'package:spark/src/core/pro_video_editor/ui/controllers/video_editor_media_session.dart'; import 'package:spark/src/core/pro_video_editor/ui/controllers/video_editor_preview_asset_loader.dart'; +import 'package:spark/src/core/pro_video_editor/ui/widgets/audio/audio_range_selection_overlay.dart'; import 'package:spark/src/core/pro_video_editor/ui/widgets/audio/audio_selection_bottom_sheet.dart'; import 'package:spark/src/core/pro_video_editor/ui/widgets/common/video_editor_configs_builder.dart'; import 'package:spark/src/core/pro_video_editor/ui/widgets/common/video_initializing_widget.dart'; @@ -54,9 +57,6 @@ class _VideoEditorGroundedPageState extends State StoryMentionEditing, SingleTickerProviderStateMixin { static const _storyCanvasSize = Size(1440, 2560); - static const _videoPlaybackStartPollInterval = Duration(milliseconds: 10); - static const _videoPlaybackStartWaitTimeout = Duration(milliseconds: 220); - final _editorKey = GlobalKey(); final bool _useMaterialDesign = platformDesignMode == ImageEditorDesignMode.material; @@ -120,6 +120,8 @@ class _VideoEditorGroundedPageState extends State late ProImageEditorConfigs _configs; VideoEditorRegularChrome? _regularChrome; + AudioAuditionController? _audioAudition; + VideoEditorAudioPlaybackCoordinator? _audioPlayback; @override GlobalKey get storyEditorKey => _editorKey; @@ -136,6 +138,7 @@ class _VideoEditorGroundedPageState extends State @override void dispose() { + _audioAudition?.dispose(); _regularChrome?.dispose(); final media = _media; _media = null; @@ -173,6 +176,7 @@ class _VideoEditorGroundedPageState extends State final metadataFuture = ProVideoEditor.instance.getMetadata(_video); VideoEditorMediaSession? media; VideoEditorRegularChrome? regularChrome; + AudioAuditionController? audioAudition; VideoPlayerController? unownedController; final controllerFuture = createVideoPlayerControllerFromEditorVideo(_video) .then((controller) { @@ -234,6 +238,7 @@ class _VideoEditorGroundedPageState extends State onToggleOriginalAudio: _onToggleOriginalAudio, onToggleCustomAudio: _onToggleCustomAudio, onAddSound: _showAudioSelectionBottomSheet, + onAdjustSound: _adjustCustomAudioClip, onRemoveSound: _removeCustomAudio, onAudioTimingChanged: _onAudioTimingChanged, onTrimChanged: _onTrimChanged, @@ -303,14 +308,30 @@ class _VideoEditorGroundedPageState extends State if (!mounted) return; } + final audioPlayback = VideoEditorAudioPlaybackCoordinator( + media, + proVideoController, + ); + audioAudition = AudioAuditionController( + VideoEditorAudioAuditionPlayback(audioPlayback), + _previewAssetLoader.loadCustomWaveform, + _commitAudioAudition, + (message, error, stackTrace) { + _logger.w(message, error: error, stackTrace: stackTrace); + }, + )..addListener(_onAudioAuditionChanged); + _configs = configs; _media = media; _regularChrome = regularChrome; _proVideoController = proVideoController; + _audioAudition = audioAudition; + _audioPlayback = audioPlayback; _selectedSoundRef = decodeSoundTrackStrongRef(initialAudioTrack?.id); controller.addListener(_onDurationChange); media = null; regularChrome = null; + audioAudition = null; if (!widget.storyMode) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -340,12 +361,24 @@ class _VideoEditorGroundedPageState extends State stackTrace: stackTrace, ); } finally { + audioAudition?.dispose(); regularChrome?.dispose(); await media?.dispose(); await unownedController?.dispose(); } } + void _onAudioAuditionChanged() { + if (!mounted) return; + if (_audioAudition?.isActive == true) { + _shouldResetOnPlaybackComplete = false; + } + _regularChrome?.setOverlayActive( + _audioAudition?.state?.blocksHostInteraction ?? false, + ); + setState(() {}); + } + void _runBackgroundTask(Future future, String failureMessage) { unawaited( future.onError((error, stackTrace) { @@ -385,22 +418,52 @@ class _VideoEditorGroundedPageState extends State final proVideoController = _proVideoController; if (media == null || proVideoController == null) return; final totalVideoDuration = _videoMetadata.duration; - final duration = media.videoController.value.position; + final videoValue = media.videoController.value; + final duration = videoValue.position; if (media.timelineSeeks.isDraining) return; proVideoController.setPlayTime(duration); // Update audio timeline progress media.timelineState.setProgressFromDuration(duration); + final audition = _audioAudition; + if (audition != null && audition.isActive) { + final playbackSnapshot = AudioAuditionPlaybackSnapshot( + position: videoValue.position, + isPlaying: videoValue.isPlaying, + isCompleted: videoValue.isCompleted, + ); + final rangeState = audition.handlePlaybackSnapshot(playbackSnapshot); + if (rangeState != null) { + unawaited( + _synchronizePlayback( + rangeState.draft, + rangeState.playbackSpan, + playbackSnapshot, + isCurrent: () => + mounted && + identical(_audioAudition?.rangeState, rangeState) && + !rangeState.isScrubbing, + ), + ); + } + return; + } + final audioTrack = proVideoController.audioTrack; if (audioTrack != null) { unawaited( _synchronizePlayback( - media, audioTrack, - videoPosition: duration, - videoStart: _playbackStart, - videoEnd: _playbackEnd, - isVideoPlaying: media.videoController.value.isPlaying, + _playbackSpan, + AudioAuditionPlaybackSnapshot( + position: videoValue.position, + isPlaying: videoValue.isPlaying, + isCompleted: videoValue.isCompleted, + ), + isCurrent: () => + mounted && + !(_audioAudition?.isActive ?? false) && + identical(_proVideoController?.audioTrack, audioTrack), ), ); } @@ -422,20 +485,17 @@ class _VideoEditorGroundedPageState extends State } Future _synchronizePlayback( - VideoEditorMediaSession media, - AudioTrack track, { - required Duration videoPosition, - required Duration videoStart, - required Duration videoEnd, - required bool isVideoPlaying, + AudioTrack track, + TrimDurationSpan playbackSpan, + AudioAuditionPlaybackSnapshot snapshot, { + required bool Function() isCurrent, }) async { try { - await media.audioService.synchronizePlayback( + await _audioPlayback?.synchronize( track, - videoPosition: videoPosition, - videoStart: videoStart, - videoEnd: videoEnd, - isVideoPlaying: isVideoPlaying, + playbackSpan, + snapshot, + isCurrent: isCurrent, ); } catch (error, stackTrace) { _logger.w( @@ -452,30 +512,8 @@ class _VideoEditorGroundedPageState extends State Duration get _playbackStart => _durationSpan?.start ?? Duration.zero; Duration get _playbackEnd => _durationSpan?.end ?? _videoMetadata.duration; - - Duration _playablePosition(Duration position) { - final span = _durationSpan; - if (span == null) return position; - if (position < span.start || position >= span.end) { - return span.start; - } - return position; - } - - Future _ensurePlayableVideoPosition( - VideoEditorMediaSession media, - ) async { - if (!identical(_media, media)) return null; - final position = media.videoController.value.position; - final playablePosition = _playablePosition(position); - if (playablePosition == position) return position; - - await media.timelineSeeks.seekLatest(playablePosition); - if (!identical(_media, media)) return null; - _proVideoController?.setPlayTime(playablePosition); - media.timelineState.setProgressFromDuration(playablePosition); - return playablePosition; - } + TrimDurationSpan get _playbackSpan => + TrimDurationSpan(start: _playbackStart, end: _playbackEnd); Future _syncCustomAudioToVideoPosition(Duration position) async { final media = _media; @@ -490,69 +528,6 @@ class _VideoEditorGroundedPageState extends State ); } - Future _playCustomAudioForCurrentVideoPosition(AudioTrack track) async { - final media = _media; - if (media == null) return; - final videoPosition = await _ensurePlayableVideoPosition(media); - var currentTrack = _proVideoController?.audioTrack; - if (videoPosition == null || - !identical(_media, media) || - currentTrack?.id != track.id) { - return; - } - final isPlaybackStart = videoPosition == _playbackStart; - final Duration syncedVideoPosition; - if (isPlaybackStart) { - final startedPosition = await _startVideoPlaybackFromBeginning(media); - currentTrack = _proVideoController?.audioTrack; - if (startedPosition == null || - !identical(_media, media) || - currentTrack?.id != track.id) { - return; - } - syncedVideoPosition = startedPosition; - } else { - if (!media.videoController.value.isPlaying) { - await media.videoController.play(); - currentTrack = _proVideoController?.audioTrack; - if (!identical(_media, media) || currentTrack?.id != track.id) return; - } - syncedVideoPosition = videoPosition; - } - currentTrack = _proVideoController?.audioTrack; - if (currentTrack?.id != track.id) return; - await media.audioService.play( - currentTrack!, - videoPosition: syncedVideoPosition, - videoStart: _playbackStart, - videoEnd: _playbackEnd, - forceSeek: isPlaybackStart, - ); - } - - Future _startVideoPlaybackFromBeginning( - VideoEditorMediaSession media, - ) async { - if (!identical(_media, media)) return null; - if (!media.videoController.value.isPlaying) { - await media.videoController.play(); - if (!identical(_media, media)) return null; - } - - final stopwatch = Stopwatch()..start(); - while (stopwatch.elapsed < _videoPlaybackStartWaitTimeout) { - if (!identical(_media, media)) return null; - final position = media.videoController.value.position; - if (position > _playbackStart) { - return position; - } - await Future.delayed(_videoPlaybackStartPollInterval); - } - - if (!identical(_media, media)) return null; - return media.videoController.value.position; - } - Future _prepareCustomAudioForCurrentVideoPosition( AudioTrack track, ) async { @@ -745,79 +720,43 @@ class _VideoEditorGroundedPageState extends State /// Shows audio selection bottom sheet for choosing & editing audio tracks. Future _showAudioSelectionBottomSheet() async { final media = _media; - if (media == null) return; - await media.videoController.pause(); - if (!mounted || !identical(_media, media)) return; - final initialTrack = _proVideoController?.audioTrack; - final selectedTrack = await showModalBottomSheet( + final audition = _audioAudition; + if (media == null || audition == null) return; + await showAudioSelectionFlow( context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) => FractionallySizedBox( - heightFactor: 0.9, - child: AudioSelectionBottomSheet( - configs: _configs, - videoDuration: _videoMetadata.duration, - initialSelectedTrack: initialTrack, - onTrackPlay: (track) async { - if (!identical(_media, media)) return; - final isNewTrack = !media.audioService.useCustomAudio; - await media.audioService.play( - track, - videoPosition: media.videoController.value.position, - videoStart: _playbackStart, - videoEnd: _playbackEnd, - forceSeek: true, - ); - if (!identical(_media, media)) return; - if (isNewTrack) { - await media.audioService.setAudioMode(useCustom: true); - } else { - await media.audioService.balanceAudio(); - } - }, - onTrackPreviewChanged: (track) async { - if (!identical(_media, media)) return; - await media.audioService.prepare( - track, - videoPosition: media.videoController.value.position, - videoStart: _playbackStart, - videoEnd: _playbackEnd, - ); - }, - onTrackStop: (track) async { - if (!identical(_media, media)) return; - await media.audioService.pause(); - }, - ), - ), + initialTrack: _proVideoController?.audioTrack, + hostSpan: _playbackSpan, + audition: audition, + isCurrent: () => mounted && identical(_media, media), + emptyStateIcon: _configs.audioEditor.icons.audioTrackDefaultIcon, + artworkBackgroundColor: + _configs.audioEditor.style.audioTrackImageBackground, + onError: (message, error, stackTrace) => + _logger.w(message, error: error, stackTrace: stackTrace), ); - if (!mounted || !identical(_media, media)) return; - if (selectedTrack != null) { - _proVideoController?.audioTrack = selectedTrack; - media.timelineState.setCustomAudio( - selectedTrack, - const [], - authorAvatarUrl: decodeSoundTrackAuthorAvatar(selectedTrack.id), - ); - await media.audioService.prepare( - selectedTrack, - videoPosition: media.videoController.value.position, - videoStart: _playbackStart, - videoEnd: _playbackEnd, - ); - if (!mounted || !identical(_media, media)) return; - unawaited(_extractCustomAudioWaveform(selectedTrack)); - setState(() {}); - } else if (initialTrack != null) { - await media.audioService.prepare( - initialTrack, - videoPosition: media.videoController.value.position, - videoStart: _playbackStart, - videoEnd: _playbackEnd, - ); - } else { - await media.audioService.setAudioMode(useCustom: false); + } + + void _adjustCustomAudioClip() { + final track = _proVideoController?.audioTrack; + final audition = _audioAudition; + if (track == null || audition == null) return; + audition.beginAdjustment( + track: track, + hostSpan: _playbackSpan, + waveform: _videoTimelineState.customWaveformData, + ); + } + + void _commitAudioAudition(AudioAuditionResult result) { + _shouldResetOnPlaybackComplete = _videoController.value.isPlaying; + _proVideoController?.audioTrack = result.track; + _videoTimelineState.setCustomAudio( + result.track, + result.waveform, + authorAvatarUrl: decodeSoundTrackAuthorAvatar(result.track.id), + ); + if (result.waveform.isEmpty) { + unawaited(_extractCustomAudioWaveform(result.track)); } } @@ -896,55 +835,105 @@ class _VideoEditorGroundedPageState extends State } Widget _buildEditor() { - return ProImageEditor.video( - _proVideoController!, - key: _editorKey, - callbacks: ProImageEditorCallbacks( - onCompleteWithParameters: generateVideo, - onCloseEditor: onCloseEditor, - mainEditorCallbacks: MainEditorCallbacks( - onAfterViewInit: _regularChrome?.syncEditorViewport, - onSelectedLayerChanged: (layerId) { - _selectedLayerIdNotifier.value = layerId.isEmpty ? null : layerId; - }, - ), - videoEditorCallbacks: VideoEditorCallbacks( - onPause: () { - _shouldResetOnPlaybackComplete = false; - _videoController.pause(); - unawaited(_audioService.pause()); - _videoTimelineState.setPlaying(isPlaying: false); - }, - onPlay: () { - _shouldResetOnPlaybackComplete = true; - final audioTrack = _proVideoController?.audioTrack; - if (audioTrack == null) { - _videoController.play(); - } else { - unawaited(_playCustomAudioForCurrentVideoPosition(audioTrack)); - } - _videoTimelineState.setPlaying(isPlaying: true); - }, - onMuteToggle: (isMuted) async { - _videoTimelineState - ..setOriginalAudioMuted(isMuted: isMuted) - ..setCustomAudioMuted(isMuted: isMuted); - if (isMuted) { - await _audioService.muteAll(); - } else { - await _audioService.unmute(); - } - }, - onTrimSpanUpdate: (durationSpan) { - if (_videoController.value.isPlaying) { - _proVideoController!.pause(); - } - }, - onTrimSpanEnd: _seekToPosition, - ), - stickerEditorCallbacks: StickerEditorCallbacks(onSearchChanged: (_) {}), + final audition = _audioAudition; + final auditionState = audition?.state; + final rangeState = audition?.rangeState; + return PopScope( + canPop: auditionState == null, + onPopInvokedWithResult: (didPop, _) { + if (!didPop && audition?.isActive == true) { + unawaited(audition!.cancel()); + } + }, + child: Stack( + fit: StackFit.expand, + children: [ + ProImageEditor.video( + _proVideoController!, + key: _editorKey, + callbacks: ProImageEditorCallbacks( + onCompleteWithParameters: generateVideo, + onCloseEditor: onCloseEditor, + mainEditorCallbacks: MainEditorCallbacks( + onAfterViewInit: _regularChrome?.syncEditorViewport, + onSelectedLayerChanged: (layerId) { + _selectedLayerIdNotifier.value = layerId.isEmpty + ? null + : layerId; + }, + ), + videoEditorCallbacks: VideoEditorCallbacks( + onPause: () { + _shouldResetOnPlaybackComplete = false; + if (audition?.isActive == true) return; + final playback = _audioPlayback; + if (playback != null) { + unawaited(playback.pauseEditorPlayback()); + } + }, + onPlay: () { + final playback = _audioPlayback; + if (playback == null || + playback.rejectPlayRequest( + auditionActive: audition?.isActive == true, + )) { + return; + } + _shouldResetOnPlaybackComplete = true; + final audioTrack = _proVideoController?.audioTrack; + unawaited( + playback.playEditorPlayback( + audioTrack, + _playbackSpan, + isCurrent: () => + mounted && + identical(_audioPlayback, playback) && + !(_audioAudition?.isActive ?? false) && + identical( + _proVideoController?.audioTrack, + audioTrack, + ), + ), + ); + }, + onMuteToggle: (isMuted) async { + _videoTimelineState + ..setOriginalAudioMuted(isMuted: isMuted) + ..setCustomAudioMuted(isMuted: isMuted); + if (isMuted) { + await _audioService.muteAll(); + } else { + await _audioService.unmute(); + } + }, + onTrimSpanUpdate: (durationSpan) { + if (_videoController.value.isPlaying) { + _proVideoController!.pause(); + } + }, + onTrimSpanEnd: _seekToPosition, + ), + stickerEditorCallbacks: StickerEditorCallbacks( + onSearchChanged: (_) {}, + ), + ), + configs: _configs, + ), + if (rangeState != null && audition != null) + AudioRangeSelectionOverlay( + track: rangeState.draft, + selectionWindowDuration: + rangeState.playbackSpan.end - rangeState.playbackSpan.start, + waveformData: rangeState.waveform, + isWaveformLoading: rangeState.isWaveformLoading, + playbackProgress: audition.playbackProgress, + onScrubStarted: audition.pauseForScrub, + onPreviewRequested: audition.previewRange, + onCancel: () => unawaited(audition.cancel()), + onDone: audition.finish, + ), + ], ), - configs: _configs, ); } } diff --git a/lib/src/core/pro_video_editor/ui/widgets/audio/audio_edit_controls_section.dart b/lib/src/core/pro_video_editor/ui/widgets/audio/audio_edit_controls_section.dart deleted file mode 100644 index 5d588823..00000000 --- a/lib/src/core/pro_video_editor/ui/widgets/audio/audio_edit_controls_section.dart +++ /dev/null @@ -1,231 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:pro_image_editor/pro_image_editor.dart'; -import 'package:spark/src/core/design_system/components/atoms/buttons/app_button.dart'; -import 'package:spark/src/core/l10n/app_localizations.dart'; -import 'package:spark/src/core/pro_video_editor/ui/widgets/audio/sound_artwork.dart'; -import 'package:spark/src/core/pro_video_editor/ui/widgets/audio/audio_timing_controls.dart'; - -/// Displays edit controls for the selected audio track. -/// -/// Includes balance slider, waveform selector, and action buttons. -class AudioEditControlsSection extends StatefulWidget { - /// Creates an [AudioEditControlsSection]. - const AudioEditControlsSection({ - required this.configs, - required this.audioTrack, - required this.videoDuration, - required this.onBalanceChanged, - required this.onTrackChanged, - required this.onTrackChangeEnd, - required this.onChangeTrack, - required this.onConfirm, - super.key, - }); - - /// Configuration settings for the editor. - final ProImageEditorConfigs configs; - - /// The selected audio track to edit. - final AudioTrack audioTrack; - - /// Total duration of the video. - final Duration videoDuration; - - /// Called when balance slider changes. - final void Function(double balance) onBalanceChanged; - - /// Called when any audio timing or playback option changes. - final ValueChanged onTrackChanged; - - /// Called after a timing or volume interaction finishes. - final ValueChanged onTrackChangeEnd; - - /// Called when user wants to change the track. - final VoidCallback onChangeTrack; - - /// Called when user confirms their changes. - final VoidCallback onConfirm; - - @override - State createState() => - _AudioEditControlsSectionState(); -} - -class _AudioEditControlsSectionState extends State { - late AudioEditorConfigs _configs; - late I18nAudioEditor _i18n; - late AudioEditorStyle _style; - final double _balanced = 0.05; - - String get _balanceLabel { - if (widget.audioTrack.volumeBalance < -_balanced) { - return _i18n.balanceLabelOriginal; - } else if (widget.audioTrack.volumeBalance > _balanced) { - return _i18n.balanceLabelOverlay; - } else { - return _i18n.balanceLabelBalanced; - } - } - - @override - void initState() { - super.initState(); - _configs = widget.configs.audioEditor; - _i18n = widget.configs.i18n.audioEditor; - _style = _configs.style; - } - - @override - Widget build(BuildContext context) { - return SingleChildScrollView( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildTrackInfo(), - const SizedBox(height: 24), - if (_configs.enableEditBalance) ...[ - _buildBalanceSlider(), - const SizedBox(height: 24), - ], - if (_configs.enableEditStartTime) ...[ - AudioTimingControls( - track: widget.audioTrack, - videoDuration: widget.videoDuration, - onChanged: widget.onTrackChanged, - onChangeEnd: widget.onTrackChangeEnd, - ), - const SizedBox(height: 32), - ], - _buildActionButtons(), - SizedBox(height: MediaQuery.of(context).viewInsets.bottom + 20), - ], - ), - ); - } - - Widget _buildTrackInfo() { - final colorScheme = Theme.of(context).colorScheme; - - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - SoundArtwork( - imageUrl: widget.audioTrack.image?.networkUrl, - size: 48, - backgroundColor: _style.audioTrackImageBackground.withAlpha(80), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.audioTrack.title, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: colorScheme.onSurface, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - Text( - widget.audioTrack.subtitle, - style: TextStyle( - fontSize: 14, - color: colorScheme.onSurfaceVariant, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ], - ), - ); - } - - Widget _buildBalanceSlider() { - final balanceSliderBackground = _style.balanceSliderBackground; - final colorScheme = Theme.of(context).colorScheme; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - AppLocalizations.of(context).labelAudioBalance, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: colorScheme.onSurface, - ), - ), - const SizedBox(height: 12), - SliderTheme( - data: SliderTheme.of(context).copyWith( - padding: EdgeInsets.zero, - activeTrackColor: balanceSliderBackground, - inactiveTrackColor: balanceSliderBackground.withAlpha(77), - thumbColor: balanceSliderBackground, - thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 12), - trackHeight: 4, - valueIndicatorShape: const PaddleSliderValueIndicatorShape(), - valueIndicatorColor: balanceSliderBackground, - valueIndicatorTextStyle: TextStyle( - color: _style.balanceSliderColor, - fontWeight: FontWeight.w500, - ), - showValueIndicator: ShowValueIndicator.onlyForContinuous, - ), - child: StatefulBuilder( - builder: (context, setState) { - return Slider( - value: widget.audioTrack.volumeBalance, - min: -1, - label: _balanceLabel, - onChanged: (value) { - widget.onBalanceChanged(value); - setState(() {}); - }, - ); - }, - ), - ), - ], - ); - } - - Widget _buildActionButtons() { - return Row( - spacing: 12, - children: [ - Expanded( - child: AppButton( - label: _i18n.editTrack, - onPressed: widget.onChangeTrack, - variant: AppButtonVariant.secondary, - fullWidth: true, - leading: const Icon(Icons.music_note_rounded, size: 18), - ), - ), - Expanded( - child: AppButton( - label: _i18n.confirmChanges, - onPressed: widget.onConfirm, - fullWidth: true, - leading: const Icon(Icons.check_rounded, size: 18), - ), - ), - ], - ); - } -} diff --git a/lib/src/core/pro_video_editor/ui/widgets/audio/audio_range_selection_overlay.dart b/lib/src/core/pro_video_editor/ui/widgets/audio/audio_range_selection_overlay.dart new file mode 100644 index 00000000..21579de4 --- /dev/null +++ b/lib/src/core/pro_video_editor/ui/widgets/audio/audio_range_selection_overlay.dart @@ -0,0 +1,441 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:pro_image_editor/pro_image_editor.dart'; +import 'package:spark/src/core/design_system/components/atoms/buttons/app_button.dart'; +import 'package:spark/src/core/design_system/tokens/colors.dart'; +import 'package:spark/src/core/design_system/tokens/typography.dart'; +import 'package:spark/src/core/l10n/app_localizations.dart'; +import 'package:spark/src/core/pro_video_editor/models/audio_audition_timing.dart'; +import 'package:spark/src/core/pro_video_editor/ui/widgets/audio/audio_waveform.dart'; + +const _waveformHeight = 72.0; +const _selectionHorizontalInset = 28.0; + +class AudioRangeSelectionOverlay extends StatefulWidget { + const AudioRangeSelectionOverlay({ + required this.track, + required this.selectionWindowDuration, + required this.waveformData, + required this.isWaveformLoading, + required this.playbackProgress, + required this.onScrubStarted, + required this.onPreviewRequested, + required this.onCancel, + required this.onDone, + super.key, + }); + + final AudioTrack track; + final Duration selectionWindowDuration; + final List waveformData; + final bool isWaveformLoading; + final ValueListenable playbackProgress; + final VoidCallback onScrubStarted; + final ValueChanged onPreviewRequested; + final VoidCallback onCancel; + final ValueChanged onDone; + + @override + State createState() => + _AudioRangeSelectionOverlayState(); +} + +class _AudioRangeSelectionOverlayState + extends State { + final ScrollController _scrollController = ScrollController(); + bool _didSetInitialPosition = false; + bool _isSettingInitialPosition = false; + bool _isUserScrolling = false; + + Duration get _selectionDuration => audioSelectionDuration( + audioDuration: widget.track.duration, + selectionWindowDuration: widget.selectionWindowDuration, + ); + + Duration get _maximumStart => widget.track.duration - _selectionDuration; + + @override + void didUpdateWidget(covariant AudioRangeSelectionOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.track.id != widget.track.id || + oldWidget.selectionWindowDuration != widget.selectionWindowDuration) { + _didSetInitialPosition = false; + } + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + double _fractionForStart(Duration start) { + final maximumStartUs = _maximumStart.inMicroseconds; + if (maximumStartUs <= 0) return 0; + return (start.inMicroseconds / maximumStartUs).clamp(0.0, 1.0).toDouble(); + } + + void _setInitialScrollPosition() { + if (_didSetInitialPosition || !_scrollController.hasClients) return; + _didSetInitialPosition = true; + final maxScrollExtent = _scrollController.position.maxScrollExtent; + final initialFraction = _fractionForStart( + widget.track.audioStartTime ?? Duration.zero, + ); + _isSettingInitialPosition = true; + try { + _scrollController.jumpTo(maxScrollExtent * initialFraction); + } finally { + _isSettingInitialPosition = false; + } + } + + bool _handleScrollNotification(ScrollNotification notification) { + final isScrollMovement = + notification is ScrollStartNotification || + notification is ScrollUpdateNotification || + notification is OverscrollNotification; + if (isScrollMovement && !_isSettingInitialPosition && !_isUserScrolling) { + _isUserScrolling = true; + widget.onScrubStarted(); + } + + if (notification is ScrollEndNotification && _isUserScrolling) { + _isUserScrolling = false; + widget.onPreviewRequested(_selectedStart()); + } + return false; + } + + Duration _selectedStart() { + final maximumStartUs = _maximumStart.inMicroseconds; + if (maximumStartUs <= 0 || !_scrollController.hasClients) { + return Duration.zero; + } + final position = _scrollController.position; + final fraction = position.maxScrollExtent <= 0 + ? 0.0 + : (position.pixels / position.maxScrollExtent) + .clamp(0.0, 1.0) + .toDouble(); + return Duration(microseconds: (maximumStartUs * fraction).round()); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final overlay = Material( + key: const ValueKey('audio-range-selection-overlay'), + color: Colors.transparent, + child: Stack( + fit: StackFit.expand, + children: [ + const _AudioPickerScrim(), + SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 0), + child: Row( + children: [ + AppButton( + key: const ValueKey('audio-range-cancel'), + label: l10n.buttonCancel, + onPressed: widget.onCancel, + variant: AppButtonVariant.neutral, + size: AppButtonSize.compact, + minWidth: 76, + minHeight: 40, + padding: const EdgeInsets.symmetric(horizontal: 16), + ), + Expanded( + child: Text( + l10n.labelSelectSoundClip, + textAlign: TextAlign.center, + style: AppTypography.textMediumBold.copyWith( + color: AppColors.greyWhite, + ), + ), + ), + AppButton( + key: const ValueKey('audio-range-done'), + label: l10n.buttonDone, + onPressed: () => widget.onDone(_selectedStart()), + size: AppButtonSize.compact, + minWidth: 76, + minHeight: 40, + padding: const EdgeInsets.symmetric(horizontal: 16), + ), + ], + ), + ), + const Spacer(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + children: [ + Text( + widget.track.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppTypography.textMediumBold.copyWith( + color: AppColors.greyWhite, + ), + ), + const SizedBox(height: 4), + Text( + l10n.hintDragWaveform, + textAlign: TextAlign.center, + style: AppTypography.textSmallMedium.copyWith( + color: AppColors.greyWhite.withValues(alpha: 0.72), + ), + ), + ], + ), + ), + const SizedBox(height: 14), + SizedBox( + height: _waveformHeight + 20, + child: LayoutBuilder( + builder: (context, constraints) { + final selectionWidth = math.max( + 1.0, + constraints.maxWidth - _selectionHorizontalInset * 2, + ); + final selectionUs = math.max( + 1, + _selectionDuration.inMicroseconds, + ); + final widthRatio = + widget.track.duration.inMicroseconds / selectionUs; + final waveformWidth = math.max( + selectionWidth, + selectionWidth * widthRatio, + ); + WidgetsBinding.instance.addPostFrameCallback( + (_) => _setInitialScrollPosition(), + ); + + return Stack( + key: const ValueKey('audio-range-waveform-stack'), + alignment: Alignment.center, + children: [ + Positioned( + key: const ValueKey('audio-range-playback-layer'), + left: _selectionHorizontalInset, + right: _selectionHorizontalInset, + child: IgnorePointer( + child: SizedBox( + height: _waveformHeight + 12, + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: ValueListenableBuilder( + valueListenable: widget.playbackProgress, + builder: (context, progress, _) => + _SmoothPlaybackProgressFill( + progress: progress, + ), + ), + ), + ), + ), + ), + NotificationListener( + key: const ValueKey('audio-range-waveform-layer'), + onNotification: _handleScrollNotification, + child: SingleChildScrollView( + key: const ValueKey( + 'audio-range-waveform-scroller', + ), + controller: _scrollController, + scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: _selectionHorizontalInset, + ), + child: AudioWaveform( + size: Size(waveformWidth, _waveformHeight), + samples: widget.waveformData, + color: AppColors.greyWhite.withValues( + alpha: 0.9, + ), + presentation: + AudioWaveformPresentation.selection, + ), + ), + ), + ), + Positioned( + key: const ValueKey( + 'audio-range-selection-border-layer', + ), + left: _selectionHorizontalInset, + right: _selectionHorizontalInset, + child: IgnorePointer( + child: SizedBox( + key: const ValueKey( + 'audio-range-selection-frame', + ), + height: _waveformHeight + 12, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppColors.primary500, + width: 2, + ), + boxShadow: [ + BoxShadow( + color: AppColors.primary500.withValues( + alpha: 0.28, + ), + blurRadius: 16, + ), + ], + ), + ), + ), + ), + ), + if (widget.isWaveformLoading) + const SizedBox.square( + dimension: 22, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.primary500, + ), + ), + ], + ); + }, + ), + ), + const SizedBox(height: 20), + ], + ), + ), + ], + ), + ); + return _AudioPickerModalBoundary( + semanticsLabel: l10n.labelSelectSoundClip, + child: overlay, + ); + } +} + +class _AudioPickerModalBoundary extends StatelessWidget { + const _AudioPickerModalBoundary({ + required this.semanticsLabel, + required this.child, + }); + + final String semanticsLabel; + final Widget child; + + @override + Widget build(BuildContext context) { + return BlockSemantics( + child: Semantics( + container: true, + explicitChildNodes: true, + scopesRoute: true, + label: semanticsLabel, + child: Listener(behavior: HitTestBehavior.opaque, child: child), + ), + ); + } +} + +class _SmoothPlaybackProgressFill extends StatefulWidget { + const _SmoothPlaybackProgressFill({required this.progress}); + + final double progress; + + @override + State<_SmoothPlaybackProgressFill> createState() => + _SmoothPlaybackProgressFillState(); +} + +class _SmoothPlaybackProgressFillState + extends State<_SmoothPlaybackProgressFill> + with SingleTickerProviderStateMixin { + static const _smoothingDuration = Duration(milliseconds: 160); + + late final AnimationController _controller = AnimationController( + vsync: this, + value: _clampedProgress(widget.progress), + ); + + @override + void didUpdateWidget(covariant _SmoothPlaybackProgressFill oldWidget) { + super.didUpdateWidget(oldWidget); + final target = _clampedProgress(widget.progress); + if (target < _controller.value) { + _controller.value = target; + return; + } + _controller.animateTo( + target, + duration: _smoothingDuration, + curve: Curves.linear, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ClipRect( + child: AnimatedBuilder( + animation: _controller, + child: const ColoredBox( + key: ValueKey('audio-range-playback-fill'), + color: AppColors.primary500, + ), + builder: (context, child) => Transform.scale( + key: const ValueKey('audio-range-playback-progress'), + alignment: Alignment.centerLeft, + scaleX: _controller.value, + child: child, + ), + ), + ); + } + + static double _clampedProgress(double progress) { + return progress.clamp(0.0, 1.0).toDouble(); + } +} + +class _AudioPickerScrim extends StatelessWidget { + const _AudioPickerScrim(); + + @override + Widget build(BuildContext context) { + return const IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xB8000000), + Color(0x00000000), + Color(0x12000000), + Color(0xE8000000), + ], + stops: [0, 0.22, 0.54, 1], + ), + ), + ), + ); + } +} diff --git a/lib/src/core/pro_video_editor/ui/widgets/audio/audio_selection_bottom_sheet.dart b/lib/src/core/pro_video_editor/ui/widgets/audio/audio_selection_bottom_sheet.dart index b71ab66d..279c2f7d 100644 --- a/lib/src/core/pro_video_editor/ui/widgets/audio/audio_selection_bottom_sheet.dart +++ b/lib/src/core/pro_video_editor/ui/widgets/audio/audio_selection_bottom_sheet.dart @@ -1,187 +1,126 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:pro_image_editor/pro_image_editor.dart'; import 'package:spark/src/core/design_system/components/atoms/buttons/app_button.dart'; import 'package:spark/src/core/l10n/app_localizations.dart'; -import 'package:spark/src/core/pro_video_editor/ui/widgets/audio/audio_edit_controls_section.dart'; +import 'package:spark/src/core/pro_video_editor/ui/controllers/audio_audition_controller.dart'; import 'package:spark/src/core/pro_video_editor/ui/widgets/audio/audio_track_list_section.dart'; import 'package:spark/src/core/pro_video_editor/ui/widgets/audio/sound_picker_sheet_scaffold.dart'; -/// A bottom sheet for selecting and editing audio tracks. -/// -/// This widget provides a two-state interface: -/// 1. Track selection: Displays a list of available audio tracks -/// 2. Edit controls: Shows balance slider, waveform selector, & action buttons -class AudioSelectionBottomSheet extends StatefulWidget { - /// Creates an [AudioSelectionBottomSheet]. +Future showAudioSelectionFlow({ + required BuildContext context, + required AudioTrack? initialTrack, + required TrimDurationSpan hostSpan, + required AudioAuditionController audition, + required bool Function() isCurrent, + required AudioAuditionErrorHandler onError, + IconData emptyStateIcon = Icons.music_note, + Color artworkBackgroundColor = defaultAudioTrackArtworkBackground, +}) async { + try { + if (!context.mounted || !isCurrent()) return; + final pickerIsCurrent = await audition.beginPicker( + previousTrack: initialTrack, + hostSpan: hostSpan, + ); + if (!pickerIsCurrent || + !context.mounted || + !isCurrent() || + audition.state is! AudioPickerAuditionState) { + return; + } + final confirmed = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => FractionallySizedBox( + heightFactor: 0.9, + child: AudioSelectionBottomSheet( + audition: audition, + emptyStateIcon: emptyStateIcon, + artworkBackgroundColor: artworkBackgroundColor, + ), + ), + ); + if (!isCurrent()) return; + if (confirmed ?? false) { + audition.confirmPicker(); + } else { + await audition.cancel(); + } + } catch (error, stackTrace) { + onError('Failed to complete the sound picker workflow', error, stackTrace); + if (isCurrent()) await audition.cancel(); + } +} + +class AudioSelectionBottomSheet extends StatelessWidget { const AudioSelectionBottomSheet({ - required this.configs, - required this.videoDuration, - required this.onTrackPlay, - required this.onTrackStop, - required this.onTrackPreviewChanged, - this.initialSelectedTrack, + required this.audition, + this.emptyStateIcon = Icons.music_note, + this.artworkBackgroundColor = defaultAudioTrackArtworkBackground, super.key, }); - /// Configuration settings for the editor. - final ProImageEditorConfigs configs; - - /// Total duration of the video. - final Duration videoDuration; - - /// Initial selected track (if any). - final AudioTrack? initialSelectedTrack; - - /// Called when a track should start playing. - final Future Function(AudioTrack track) onTrackPlay; - - /// Called when a track should stop playing. - final Future Function(AudioTrack track) onTrackStop; - - /// Updates temporary preview playback without committing editor state. - final Future Function(AudioTrack track) onTrackPreviewChanged; + final AudioAuditionController audition; + final IconData emptyStateIcon; + final Color artworkBackgroundColor; @override - State createState() => - _AudioSelectionBottomSheetState(); -} - -class _AudioSelectionBottomSheetState extends State { - AudioTrack? _selectedTrack; - bool _showEditControls = false; - int _trackPreviewRequestId = 0; - - @override - void initState() { - super.initState(); - _selectedTrack = widget.initialSelectedTrack; - _showEditControls = _selectedTrack != null; - } - - @override - void dispose() { - if (_selectedTrack != null) { - unawaited(widget.onTrackStop(_selectedTrack!)); - } - super.dispose(); + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: audition, + builder: (context, _) { + final state = audition.state; + if (state is! AudioPickerAuditionState) { + return const SizedBox.shrink(); + } + final l10n = AppLocalizations.of(context); + return SoundPickerSheetScaffold( + title: l10n.titleSelectSound, + onClose: () => Navigator.of(context).pop(false), + footer: _ContinueButton( + enabled: state.canContinue, + onPressed: () => Navigator.of(context).pop(true), + ), + child: AudioTrackListSection( + selectedTrack: state.selectedTrack, + emptyStateIcon: emptyStateIcon, + artworkBackgroundColor: artworkBackgroundColor, + onTrackSelected: (track) => _selectTrack(context, track), + ), + ); + }, + ); } - Future _handleTrackSelection(AudioTrack track) async { - final requestId = ++_trackPreviewRequestId; - setState(() { - _selectedTrack = track; - }); - try { - await widget.onTrackPlay(track); - if (!mounted || requestId != _trackPreviewRequestId) return; - } catch (_) { - if (!mounted || requestId != _trackPreviewRequestId) return; - setState(() { - if (_selectedTrack?.id == track.id) { - _selectedTrack = null; - } - }); + Future _selectTrack(BuildContext context, AudioTrack track) async { + final succeeded = await audition.selectPickerTrack(track); + if (!context.mounted || succeeded) return; + final state = audition.state; + if (state is AudioPickerAuditionState && + state.previewStatus == AudioPickerPreviewStatus.failed) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(AppLocalizations.of(context).errorLoadingSound)), ); } } +} - void _handleContinue() { - final selectedTrack = _selectedTrack; - if (selectedTrack == null) return; - - setState(() { - _showEditControls = true; - }); - } - - void _handleChangeTrack() { - if (_selectedTrack != null) { - unawaited(widget.onTrackStop(_selectedTrack!)); - } - setState(() { - _showEditControls = false; - }); - } - - void _handleConfirm() { - Navigator.of(context).pop(_selectedTrack); - } - - void _handleBalanceChange(double balance) { - final track = _selectedTrack; - if (track == null) return; - _handleTrackChange(track.copyWith(volumeBalance: balance)); - } - - void _handleTrackChange(AudioTrack track) { - setState(() => _selectedTrack = track); - unawaited(widget.onTrackPreviewChanged(track)); - } +class _ContinueButton extends StatelessWidget { + const _ContinueButton({required this.enabled, required this.onPressed}); - void _handleTrackChangeEnd(AudioTrack track) { - unawaited(widget.onTrackPreviewChanged(track)); - } + final bool enabled; + final VoidCallback onPressed; @override Widget build(BuildContext context) { - final i18n = widget.configs.i18n.audioEditor; - final l10n = AppLocalizations.of(context); - - return SoundPickerSheetScaffold( - title: _showEditControls ? i18n.editTrack : l10n.titleSelectSound, - onClose: _showEditControls ? null : () => Navigator.of(context).pop(), - footer: _showEditControls ? null : _buildContinueButton(), - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - transitionBuilder: (child, animation) { - return FadeTransition( - opacity: animation, - child: SlideTransition( - position: Tween( - begin: const Offset(0, 0.1), - end: Offset.zero, - ).animate(animation), - child: child, - ), - ); - }, - child: _showEditControls && _selectedTrack != null - ? AudioEditControlsSection( - key: const ValueKey('edit_controls'), - configs: widget.configs, - audioTrack: _selectedTrack!, - videoDuration: widget.videoDuration, - onBalanceChanged: _handleBalanceChange, - onTrackChanged: _handleTrackChange, - onTrackChangeEnd: _handleTrackChangeEnd, - onChangeTrack: _handleChangeTrack, - onConfirm: _handleConfirm, - ) - : AudioTrackListSection( - key: const ValueKey('track_list'), - configs: widget.configs, - videoDuration: widget.videoDuration, - selectedTrack: _selectedTrack, - onTrackSelected: _handleTrackSelection, - ), - ), - ); - } - - Widget _buildContinueButton() { - final l10n = AppLocalizations.of(context); - return SafeArea( top: false, child: Padding( padding: const EdgeInsets.fromLTRB(20, 12, 20, 16), child: AppButton( - label: l10n.buttonContinue, - onPressed: _selectedTrack == null ? null : _handleContinue, + label: AppLocalizations.of(context).buttonContinue, + onPressed: enabled ? onPressed : null, fullWidth: true, ), ), diff --git a/lib/src/core/pro_video_editor/ui/widgets/audio/audio_timing_controls.dart b/lib/src/core/pro_video_editor/ui/widgets/audio/audio_timing_controls.dart deleted file mode 100644 index f76ca3f6..00000000 --- a/lib/src/core/pro_video_editor/ui/widgets/audio/audio_timing_controls.dart +++ /dev/null @@ -1,174 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:pro_image_editor/pro_image_editor.dart'; -import 'package:spark/src/core/l10n/app_localizations.dart'; - -class AudioTimingControls extends StatelessWidget { - const AudioTimingControls({ - required this.track, - required this.videoDuration, - required this.onChanged, - required this.onChangeEnd, - super.key, - }); - - final AudioTrack track; - final Duration videoDuration; - final ValueChanged onChanged; - final ValueChanged onChangeEnd; - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); - final sourceRange = _rangeFor( - start: track.audioStartTime, - end: track.audioEndTime, - duration: track.duration, - ); - final placementRange = _rangeFor( - start: track.startTime, - end: track.endTime, - duration: videoDuration, - ); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _TimingRangeControl( - label: l10n.labelAudioSourceRange, - range: sourceRange, - duration: track.duration, - onChanged: (range) => onChanged( - track.copyWith( - audioStartTime: _durationAt(range.start, track.duration), - audioEndTime: _durationAt(range.end, track.duration), - ), - ), - onChangeEnd: (range) => onChangeEnd( - track.copyWith( - audioStartTime: _durationAt(range.start, track.duration), - audioEndTime: _durationAt(range.end, track.duration), - ), - ), - ), - const SizedBox(height: 20), - _TimingRangeControl( - label: l10n.labelVideoPlacement, - range: placementRange, - duration: videoDuration, - onChanged: (range) => onChanged( - track.copyWith( - startTime: _durationAt(range.start, videoDuration), - endTime: _durationAt(range.end, videoDuration), - ), - ), - onChangeEnd: (range) => onChangeEnd( - track.copyWith( - startTime: _durationAt(range.start, videoDuration), - endTime: _durationAt(range.end, videoDuration), - ), - ), - ), - const SizedBox(height: 20), - Text( - l10n.labelTrackVolume, - style: Theme.of(context).textTheme.labelLarge, - ), - Slider( - value: track.volume.clamp(0.0, 1.0).toDouble(), - onChanged: (value) => onChanged(track.copyWith(volume: value)), - onChangeEnd: (value) => onChangeEnd(track.copyWith(volume: value)), - ), - SwitchListTile.adaptive( - contentPadding: EdgeInsets.zero, - title: Text(l10n.labelLoopAudio), - value: track.loop, - onChanged: (value) { - final updatedTrack = track.copyWith(loop: value); - onChanged(updatedTrack); - onChangeEnd(updatedTrack); - }, - ), - ], - ); - } - - RangeValues _rangeFor({ - required Duration? start, - required Duration? end, - required Duration duration, - }) { - final durationMs = duration.inMilliseconds; - if (durationMs <= 0) return const RangeValues(0, 1); - final startFraction = (start ?? Duration.zero).inMilliseconds / durationMs; - final endFraction = (end ?? duration).inMilliseconds / durationMs; - final clampedStart = startFraction.clamp(0.0, 1.0).toDouble(); - final clampedEnd = endFraction.clamp(0.0, 1.0).toDouble(); - return RangeValues( - clampedStart <= clampedEnd ? clampedStart : clampedEnd, - clampedStart <= clampedEnd ? clampedEnd : clampedStart, - ); - } - - Duration _durationAt(double fraction, Duration duration) { - return Duration(milliseconds: (duration.inMilliseconds * fraction).round()); - } -} - -class _TimingRangeControl extends StatelessWidget { - const _TimingRangeControl({ - required this.label, - required this.range, - required this.duration, - required this.onChanged, - required this.onChangeEnd, - }); - - final String label; - final RangeValues range; - final Duration duration; - final ValueChanged onChanged; - final ValueChanged onChangeEnd; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text(label, style: Theme.of(context).textTheme.labelLarge), - ), - Text( - '${_format(_durationAt(range.start))} – ' - '${_format(_durationAt(range.end))}', - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), - RangeSlider( - values: range, - min: 0, - max: 1, - onChanged: (value) { - const minimumRange = 0.005; - if (value.end - value.start < minimumRange) return; - onChanged(value); - }, - onChangeEnd: onChangeEnd, - ), - ], - ); - } - - Duration _durationAt(double fraction) { - return Duration(milliseconds: (duration.inMilliseconds * fraction).round()); - } - - String _format(Duration value) { - final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0'); - final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0'); - final tenths = (value.inMilliseconds.remainder(1000) ~/ 100).toString(); - return '$minutes:$seconds.$tenths'; - } -} diff --git a/lib/src/core/pro_video_editor/ui/widgets/audio/audio_track_list_section.dart b/lib/src/core/pro_video_editor/ui/widgets/audio/audio_track_list_section.dart index 5778181c..8d002122 100644 --- a/lib/src/core/pro_video_editor/ui/widgets/audio/audio_track_list_section.dart +++ b/lib/src/core/pro_video_editor/ui/widgets/audio/audio_track_list_section.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:pro_image_editor/pro_image_editor.dart'; +import 'package:pro_image_editor/pro_image_editor.dart' show AudioTrack; import 'package:spark/src/core/design_system/components/atoms/buttons/interactive_pressable.dart'; import 'package:spark/src/core/design_system/tokens/colors.dart'; import 'package:spark/src/core/design_system/tokens/typography.dart'; @@ -10,26 +10,26 @@ import 'package:spark/src/core/pro_video_editor/providers/sound_picker_search_pr import 'package:spark/src/core/pro_video_editor/providers/sound_picker_search_state.dart'; import 'package:spark/src/core/pro_video_editor/ui/widgets/audio/sound_artwork.dart'; +const defaultAudioTrackArtworkBackground = Color(0xFF9E9E9E); + /// Displays a scrollable list of audio tracks for selection. class AudioTrackListSection extends ConsumerStatefulWidget { /// Creates an [AudioTrackListSection]. const AudioTrackListSection({ - required this.configs, - required this.videoDuration, required this.onTrackSelected, this.selectedTrack, + this.emptyStateIcon = Icons.music_note, + this.artworkBackgroundColor = defaultAudioTrackArtworkBackground, super.key, }); - /// Configuration settings for the editor. - final ProImageEditorConfigs configs; - - /// Total duration of the video. - final Duration videoDuration; - /// Currently selected track (if any). final AudioTrack? selectedTrack; + final IconData emptyStateIcon; + + final Color artworkBackgroundColor; + /// Called when a track is tapped. final void Function(AudioTrack track) onTrackSelected; @@ -159,9 +159,9 @@ class _AudioTrackListSectionState extends ConsumerState { return Padding( padding: const EdgeInsets.only(bottom: 8), child: AudioTrackSelectionTile( - configs: widget.configs, audioTrack: audioTrack, isSelected: audioTrack.id == widget.selectedTrack?.id, + artworkBackgroundColor: widget.artworkBackgroundColor, onTap: () => widget.onTrackSelected(audioTrack), ), ); @@ -179,7 +179,7 @@ class _AudioTrackListSectionState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ Icon( - widget.configs.audioEditor.icons.audioTrackDefaultIcon, + widget.emptyStateIcon, size: 48, color: colorScheme.onSurfaceVariant.withAlpha(128), ), @@ -201,22 +201,21 @@ class _AudioTrackListSectionState extends ConsumerState { class AudioTrackSelectionTile extends StatelessWidget { const AudioTrackSelectionTile({ - required this.configs, required this.audioTrack, required this.isSelected, + required this.artworkBackgroundColor, required this.onTap, super.key, }); - final ProImageEditorConfigs configs; final AudioTrack audioTrack; final bool isSelected; + final Color artworkBackgroundColor; final VoidCallback onTap; @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final imageBackground = configs.audioEditor.style.audioTrackImageBackground; final borderRadius = BorderRadius.circular(8); final backgroundColor = isSelected ? AppColors.primary500.withValues(alpha: 0.12) @@ -252,7 +251,7 @@ class AudioTrackSelectionTile extends StatelessWidget { imageUrl: audioTrack.image?.networkUrl, size: 50, borderRadius: 8, - backgroundColor: imageBackground.withAlpha(80), + backgroundColor: artworkBackgroundColor.withAlpha(80), ), const SizedBox(width: 12), Expanded( diff --git a/lib/src/core/pro_video_editor/ui/widgets/audio/audio_waveform.dart b/lib/src/core/pro_video_editor/ui/widgets/audio/audio_waveform.dart new file mode 100644 index 00000000..3ed4b5ae --- /dev/null +++ b/lib/src/core/pro_video_editor/ui/widgets/audio/audio_waveform.dart @@ -0,0 +1,100 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +enum AudioWaveformPresentation { timeline, selection } + +class AudioWaveform extends StatelessWidget { + const AudioWaveform({ + required this.samples, + required this.color, + required this.presentation, + this.size = Size.zero, + super.key, + }); + + final List samples; + final Color color; + final AudioWaveformPresentation presentation; + final Size size; + + @override + Widget build(BuildContext context) { + return CustomPaint( + size: size, + painter: _AudioWaveformPainter( + samples: samples, + color: color, + presentation: presentation, + ), + ); + } +} + +class _AudioWaveformPainter extends CustomPainter { + const _AudioWaveformPainter({ + required this.samples, + required this.color, + required this.presentation, + }); + + final List samples; + final Color color; + final AudioWaveformPresentation presentation; + + @override + void paint(Canvas canvas, Size size) { + if (samples.isEmpty && presentation == AudioWaveformPresentation.timeline) { + return; + } + final strokeWidth = switch (presentation) { + AudioWaveformPresentation.timeline => 2.0, + AudioWaveformPresentation.selection => 2.4, + }; + final barStep = switch (presentation) { + AudioWaveformPresentation.timeline => 4.0, + AudioWaveformPresentation.selection => 5.0, + }; + final paint = Paint() + ..color = color + ..strokeWidth = strokeWidth + ..strokeCap = StrokeCap.round; + final barCount = math.max(1, (size.width / barStep).floor()); + final centerY = size.height / 2; + + for (var index = 0; index < barCount; index++) { + final position = barCount == 1 ? 0.0 : index / (barCount - 1); + final amplitude = _amplitudeAt(position); + final barHeight = switch (presentation) { + AudioWaveformPresentation.timeline => + (amplitude * size.height * 0.7).clamp(2.0, size.height - 4), + AudioWaveformPresentation.selection => + 8 + amplitude * (size.height - 12), + }; + final x = barCount == 1 ? size.width / 2 : position * size.width; + canvas.drawLine( + Offset(x, centerY - barHeight / 2), + Offset(x, centerY + barHeight / 2), + paint, + ); + } + } + + double _amplitudeAt(double position) { + if (samples.isEmpty) { + return (0.3 + + math.sin(position * math.pi * 13).abs() * 0.38 + + math.sin(position * math.pi * 29).abs() * 0.22) + .clamp(0.0, 1.0); + } + final sampleIndex = (position * (samples.length - 1)).round(); + return samples[sampleIndex].abs().clamp(0.0, 1.0); + } + + @override + bool shouldRepaint(covariant _AudioWaveformPainter oldDelegate) { + return oldDelegate.samples != samples || + oldDelegate.color != color || + oldDelegate.presentation != presentation; + } +} diff --git a/lib/src/core/pro_video_editor/ui/widgets/layout/video_editor_bottom_section.dart b/lib/src/core/pro_video_editor/ui/widgets/layout/video_editor_bottom_section.dart index 4bcb8e3e..ffc0dd6e 100644 --- a/lib/src/core/pro_video_editor/ui/widgets/layout/video_editor_bottom_section.dart +++ b/lib/src/core/pro_video_editor/ui/widgets/layout/video_editor_bottom_section.dart @@ -17,6 +17,7 @@ class VideoEditorBottomSection extends StatefulWidget { required this.onToggleOriginalAudio, required this.onToggleCustomAudio, required this.onAddSound, + required this.onAdjustSound, required this.onRemoveSound, required this.onAudioTimingChanged, required this.onSeekStart, @@ -34,6 +35,7 @@ class VideoEditorBottomSection extends StatefulWidget { final VoidCallback onToggleOriginalAudio; final VoidCallback onToggleCustomAudio; final VoidCallback onAddSound; + final VoidCallback onAdjustSound; final VoidCallback onRemoveSound; final ValueChanged onAudioTimingChanged; final VoidCallback onSeekStart; @@ -184,6 +186,7 @@ class _VideoEditorBottomSectionState extends State { selectedLayer: selectedLayer, selection: _selection, onAddSound: widget.onAddSound, + onAdjustSound: widget.onAdjustSound, onRemoveSound: widget.onRemoveSound, onToggleOriginalAudio: widget.onToggleOriginalAudio, onToggleCustomAudio: widget.onToggleCustomAudio, diff --git a/lib/src/core/pro_video_editor/ui/widgets/layout/video_editor_regular_chrome.dart b/lib/src/core/pro_video_editor/ui/widgets/layout/video_editor_regular_chrome.dart index 0da15f28..38b9e1ed 100644 --- a/lib/src/core/pro_video_editor/ui/widgets/layout/video_editor_regular_chrome.dart +++ b/lib/src/core/pro_video_editor/ui/widgets/layout/video_editor_regular_chrome.dart @@ -20,6 +20,7 @@ class VideoEditorRegularChrome { required this.onToggleOriginalAudio, required this.onToggleCustomAudio, required this.onAddSound, + required this.onAdjustSound, required this.onRemoveSound, required this.onAudioTimingChanged, this.onTrimChanged, @@ -39,11 +40,14 @@ class VideoEditorRegularChrome { final VoidCallback onToggleOriginalAudio; final VoidCallback onToggleCustomAudio; final VoidCallback onAddSound; + final VoidCallback onAdjustSound; final VoidCallback onRemoveSound; final ValueChanged onAudioTimingChanged; final void Function(double start, double end)? onTrimChanged; final void Function(double start, double end, bool isStartHandle)? onTrimEnd; final VideoEditorRevealCoordinator reveal; + final _overlayActive = ValueNotifier(false); + double? _revealValueBeforeOverlay; Widget buildRemoveArea(Widget child) { return VideoEditorRevealRemoveArea(coordinator: reveal, child: child); @@ -54,25 +58,29 @@ class VideoEditorRegularChrome { required ProImageEditorState editor, required bool visible, }) { - return VideoEditorRevealBottomBar( + return ValueListenableBuilder( key: key, - coordinator: reveal, - visible: visible, - child: VideoEditorBottomSection( - editor: editor, - videoTimelineState: timelineState, - selectedLayerIdListenable: selectedLayerIdListenable, - onSeek: onSeek, - onSeekStart: onSeekStart, - onSeekEnd: onSeekEnd, - onTogglePlay: onTogglePlay, - onToggleOriginalAudio: onToggleOriginalAudio, - onToggleCustomAudio: onToggleCustomAudio, - onAddSound: onAddSound, - onRemoveSound: onRemoveSound, - onAudioTimingChanged: onAudioTimingChanged, - onTrimChanged: onTrimChanged, - onTrimEnd: onTrimEnd, + valueListenable: _overlayActive, + builder: (context, isOverlayActive, _) => VideoEditorRevealBottomBar( + coordinator: reveal, + visible: visible && !isOverlayActive, + child: VideoEditorBottomSection( + editor: editor, + videoTimelineState: timelineState, + selectedLayerIdListenable: selectedLayerIdListenable, + onSeek: onSeek, + onSeekStart: onSeekStart, + onSeekEnd: onSeekEnd, + onTogglePlay: onTogglePlay, + onToggleOriginalAudio: onToggleOriginalAudio, + onToggleCustomAudio: onToggleCustomAudio, + onAddSound: onAddSound, + onAdjustSound: onAdjustSound, + onRemoveSound: onRemoveSound, + onAudioTimingChanged: onAudioTimingChanged, + onTrimChanged: onTrimChanged, + onTrimEnd: onTrimEnd, + ), ), ); } @@ -88,12 +96,18 @@ class VideoEditorRegularChrome { timelineState: timelineState, selectedLayerIdListenable: selectedLayerIdListenable, onPreviewTap: onTogglePlay, - overlay: SafeArea( - bottom: false, - child: VideoEditorHeader( - onBack: editor.closeEditor, - onNext: editor.doneEditing, - ), + overlay: ValueListenableBuilder( + valueListenable: _overlayActive, + builder: (context, isOverlayActive, _) { + if (isOverlayActive) return const SizedBox.shrink(); + return SafeArea( + bottom: false, + child: VideoEditorHeader( + onBack: editor.closeEditor, + onNext: editor.doneEditing, + ), + ); + }, ), child: content, ); @@ -111,5 +125,21 @@ class VideoEditorRegularChrome { ); } - void dispose() => reveal.dispose(); + void setOverlayActive(bool isActive) { + if (_overlayActive.value == isActive) return; + _overlayActive.value = isActive; + if (isActive) { + _revealValueBeforeOverlay ??= reveal.value; + reveal.value = 0; + return; + } + final previousValue = _revealValueBeforeOverlay; + _revealValueBeforeOverlay = null; + if (previousValue != null) reveal.value = previousValue; + } + + void dispose() { + _overlayActive.dispose(); + reveal.dispose(); + } } diff --git a/lib/src/core/pro_video_editor/ui/widgets/layout/video_editor_toolbar.dart b/lib/src/core/pro_video_editor/ui/widgets/layout/video_editor_toolbar.dart index 0ee75046..512f84b6 100644 --- a/lib/src/core/pro_video_editor/ui/widgets/layout/video_editor_toolbar.dart +++ b/lib/src/core/pro_video_editor/ui/widgets/layout/video_editor_toolbar.dart @@ -12,6 +12,7 @@ class VideoEditorToolbar extends StatelessWidget { required this.selectedLayer, required this.selection, required this.onAddSound, + required this.onAdjustSound, required this.onRemoveSound, required this.onToggleOriginalAudio, required this.onToggleCustomAudio, @@ -24,6 +25,7 @@ class VideoEditorToolbar extends StatelessWidget { final Layer? selectedLayer; final TimelineSelection selection; final VoidCallback onAddSound; + final VoidCallback onAdjustSound; final VoidCallback onRemoveSound; final VoidCallback onToggleOriginalAudio; final VoidCallback onToggleCustomAudio; @@ -156,6 +158,12 @@ class VideoEditorToolbar extends StatelessWidget { List _audioTrackActions(BuildContext context) { final l10n = AppLocalizations.of(context); return [ + VideoToolbarAction( + id: 'adjust-audio', + icon: Icons.graphic_eq, + label: l10n.buttonAdjustClip, + onPressed: onAdjustSound, + ), VideoToolbarAction( id: 'replace-audio', icon: Icons.library_music, diff --git a/lib/src/core/pro_video_editor/ui/widgets/timeline/audio_timeline_track.dart b/lib/src/core/pro_video_editor/ui/widgets/timeline/audio_timeline_track.dart index 7317b681..0cd63b15 100644 --- a/lib/src/core/pro_video_editor/ui/widgets/timeline/audio_timeline_track.dart +++ b/lib/src/core/pro_video_editor/ui/widgets/timeline/audio_timeline_track.dart @@ -2,6 +2,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:pro_image_editor/pro_image_editor.dart'; import 'package:spark/src/core/design_system/tokens/colors.dart'; +import 'package:spark/src/core/pro_video_editor/ui/widgets/audio/audio_waveform.dart'; import 'package:spark/src/core/pro_video_editor/ui/widgets/timeline/timed_track_range.dart'; import 'package:spark/src/core/pro_video_editor/ui/widgets/timeline/timeline_subtrack_content.dart'; import 'package:spark/src/core/pro_video_editor/ui/widgets/timeline/video_timeline_state.dart'; @@ -87,11 +88,10 @@ class AudioTimelineTrack extends StatelessWidget { ), ), ), - child: CustomPaint( - painter: _AudioWaveformPainter( - waveformData: videoTimelineState.customWaveformData, - totalWidth: sourceWidth, - ), + child: AudioWaveform( + samples: videoTimelineState.customWaveformData, + color: AppColors.greyWhite.withAlpha(100), + presentation: AudioWaveformPresentation.timeline, ), ); } @@ -115,52 +115,3 @@ class AudioTimelineTrack extends StatelessWidget { ); } } - -class _AudioWaveformPainter extends CustomPainter { - _AudioWaveformPainter({required this.waveformData, required this.totalWidth}); - - final List waveformData; - final double totalWidth; - - @override - void paint(Canvas canvas, Size size) { - if (waveformData.isEmpty) return; - - final paint = Paint() - ..color = AppColors.greyWhite.withAlpha(100) - ..strokeWidth = 2 - ..strokeCap = StrokeCap.round; - - const barWidth = 2.0; - const barSpacing = 2.0; - const barStep = barWidth + barSpacing; - final barCount = (size.width / barStep).floor(); - final samplesPerBar = waveformData.length / barCount; - final centerY = size.height / 2; - - for (var i = 0; i < barCount; i++) { - final sampleIndex = (i * samplesPerBar).floor().clamp( - 0, - waveformData.length - 1, - ); - final amplitude = waveformData[sampleIndex]; - final barHeight = (amplitude * size.height * 0.7).clamp( - 2.0, - size.height - 4, - ); - final x = i * barStep + barWidth / 2; - - canvas.drawLine( - Offset(x, centerY - barHeight / 2), - Offset(x, centerY + barHeight / 2), - paint, - ); - } - } - - @override - bool shouldRepaint(covariant _AudioWaveformPainter oldDelegate) { - return oldDelegate.waveformData != waveformData || - oldDelegate.totalWidth != totalWidth; - } -} diff --git a/lib/src/features/posting/ui/pages/recording_page.dart b/lib/src/features/posting/ui/pages/recording_page.dart index 7fd658cc..81fdfe3e 100644 --- a/lib/src/features/posting/ui/pages/recording_page.dart +++ b/lib/src/features/posting/ui/pages/recording_page.dart @@ -1111,8 +1111,6 @@ class _RecordingSoundPickerSheetState child: SoundPickerSheetScaffold( title: l10n.titleSelectSound, child: AudioTrackListSection( - configs: const ProImageEditorConfigs(), - videoDuration: const Duration(seconds: 9), selectedTrack: _selectedTrack, onTrackSelected: _handleTrackTap, ), diff --git a/lib/src/features/posting/ui/widgets/image_sound_selection_sheet.dart b/lib/src/features/posting/ui/widgets/image_sound_selection_sheet.dart index 0ef15ce7..5ce6af8c 100644 --- a/lib/src/features/posting/ui/widgets/image_sound_selection_sheet.dart +++ b/lib/src/features/posting/ui/widgets/image_sound_selection_sheet.dart @@ -20,8 +20,6 @@ class ImageSoundSelectionSheet extends StatefulWidget { } class _ImageSoundSelectionSheetState extends State { - static const _previewDuration = Duration(seconds: 9); - late final AudioPlayer _audioPlayer; AudioTrack? _selectedTrack; int _previewRequestId = 0; @@ -96,8 +94,6 @@ class _ImageSoundSelectionSheetState extends State { ), ), child: AudioTrackListSection( - configs: const ProImageEditorConfigs(), - videoDuration: _previewDuration, selectedTrack: _selectedTrack, onTrackSelected: _handleTrackSelected, ), diff --git a/pubspec.lock b/pubspec.lock index 7dfbfc63..84796577 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -89,14 +89,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" - audio_waveforms: - dependency: "direct main" - description: - name: audio_waveforms - sha256: "03b3430ecf430a2e90185518a228c02be3d26653c62dd931e50d671213a6dbc8" - url: "https://pub.dev" - source: hosted - version: "2.0.2" audioplayers: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 6296a0fa..a71b42bc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,7 +15,6 @@ dependencies: app_badge_plus: ^1.3.1 assets: path: assets - audio_waveforms: ^2.0.2 audioplayers: ^6.7.1 auto_route: ^11.1.0 better_player_plus: ^1.3.2 diff --git a/test/src/core/network/atproto/data/models/sound_models_test.dart b/test/src/core/network/atproto/data/models/sound_models_test.dart index 18b8eac4..f3174bcf 100644 --- a/test/src/core/network/atproto/data/models/sound_models_test.dart +++ b/test/src/core/network/atproto/data/models/sound_models_test.dart @@ -156,6 +156,58 @@ void main() { expect(decodeSoundTrackAudioMimeType(track.id), 'audio/mp4'); }); + test('normalizes legacy FLAC MIME types for Plyr playback', () { + final audio = AudioView.fromJson({ + 'uri': 'at://did:plc:plyr123/fm.plyr.track/flac-track', + 'cid': 'cid-flac', + 'author': {'did': 'did:plc:plyr123', 'handle': 'artist.plyr.fm'}, + 'record': { + r'$type': 'fm.plyr.track', + 'title': 'FLAC Track', + 'artist': 'Dame', + 'fileType': 'flac', + 'audioUrl': 'https://audio.plyr.fm/audio/track.flac', + 'audioBlob': _blobJson(mimeType: 'audio/x-flac'), + 'createdAt': '2026-05-01T12:00:00.000Z', + }, + 'title': 'FLAC Track', + 'coverArt': 'https://example.com/cover.jpg', + 'indexedAt': '2026-05-01T12:00:00.000Z', + 'audio': 'https://media.sprk.so/sound/did%3Aplc%3Aplyr123/cid', + }); + + final track = audioViewToAudioTrack(audio); + + expect(track, isNotNull); + expect(track!.audio.networkUrl, 'https://audio.plyr.fm/audio/track.flac'); + expect(decodeSoundTrackAudioFileExtension(track.id), 'flac'); + expect(decodeSoundTrackAudioMimeType(track.id), 'audio/flac'); + }); + + test('derives one FLAC format from a legacy Spark blob MIME', () { + final audio = AudioView.fromJson({ + 'uri': 'at://did:plc:test123/so.sprk.sound.audio/flac-spark', + 'cid': 'cid-spark-flac', + 'author': {'did': 'did:plc:test123', 'handle': 'test.sprk.so'}, + 'record': { + r'$type': 'so.sprk.sound.audio', + 'sound': _blobJson(mimeType: 'audio/x-flac'), + 'title': 'Spark FLAC', + 'createdAt': '2026-05-01T12:00:00.000Z', + }, + 'title': 'Spark FLAC', + 'coverArt': 'https://example.com/spark.jpg', + 'indexedAt': '2026-05-01T12:00:00.000Z', + 'audio': 'https://example.com/audio.flac', + }); + + final track = audioViewToAudioTrack(audio); + + expect(track, isNotNull); + expect(decodeSoundTrackAudioFileExtension(track!.id), 'flac'); + expect(decodeSoundTrackAudioMimeType(track.id), 'audio/flac'); + }); + test('maps Spark audio/mp4 blobs to m4a tracks', () { final audio = AudioView.fromJson({ 'uri': 'at://did:plc:test123/so.sprk.sound.audio/m4a-spark', diff --git a/test/src/core/pro_video_editor/services/audio_waveform_extractor_test.dart b/test/src/core/pro_video_editor/services/audio_waveform_extractor_test.dart new file mode 100644 index 00000000..0fdb38ec --- /dev/null +++ b/test/src/core/pro_video_editor/services/audio_waveform_extractor_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spark/src/core/pro_video_editor/services/audio_waveform_extractor.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('pro_video_editor'); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test('extracts and merges normalized waveform channels', () async { + MethodCall? receivedCall; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + receivedCall = call; + return { + 'leftChannel': [0.2, -0.8, 0.4], + 'rightChannel': [-0.9, 0.1, 0.3, 0.6], + 'sampleRate': 48000, + 'duration': 1000, + 'samplesPerSecond': 50, + }; + }); + + final samples = await AudioWaveformExtractor.instance.extractFromPath( + '/tmp/test-audio.mp3', + ); + + expect(receivedCall?.method, 'getWaveform'); + expect( + receivedCall?.arguments, + containsPair('inputPath', '/tmp/test-audio.mp3'), + ); + expect(receivedCall?.arguments, containsPair('extension', 'mpeg')); + expect(receivedCall?.arguments, containsPair('samplesPerSecond', 50)); + expect(samples, [ + 1.0, + closeTo(8 / 9, 0.0001), + closeTo(4 / 9, 0.0001), + closeTo(2 / 3, 0.0001), + ]); + }); +} diff --git a/test/src/core/pro_video_editor/ui/controllers/audio_audition_controller_test.dart b/test/src/core/pro_video_editor/ui/controllers/audio_audition_controller_test.dart new file mode 100644 index 00000000..d82de9d6 --- /dev/null +++ b/test/src/core/pro_video_editor/ui/controllers/audio_audition_controller_test.dart @@ -0,0 +1,481 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pro_image_editor/pro_image_editor.dart'; +import 'package:spark/src/core/pro_video_editor/ui/controllers/audio_audition_controller.dart'; +import 'package:spark/src/core/pro_video_editor/ui/controllers/audio_audition_playback.dart'; + +void main() { + test( + 'owns the picker, range, and commit phases as one transaction', + () async { + final playback = _FakeAudioPlayback(); + final waveform = Completer>(); + final commits = []; + final controller = AudioAuditionController( + playback, + (_) => waveform.future, + commits.add, + (_, _, _) {}, + ); + addTearDown(controller.dispose); + final hostSpan = _span(2, 12); + final track = _track('selected'); + + await controller.beginPicker(previousTrack: null, hostSpan: hostSpan); + expect(controller.state, isA()); + expect(await controller.selectPickerTrack(track), isTrue); + expect(controller.confirmPicker(), isTrue); + expect(controller.rangeState?.draft.audioStartTime, Duration.zero); + expect( + controller.rangeState?.draft.audioEndTime, + const Duration(seconds: 10), + ); + await Future.delayed(Duration.zero); + expect(playback.preparedSpans, [hostSpan]); + expect(playback.startCount, 1); + + waveform.complete([0.2, 0.8]); + await Future.delayed(Duration.zero); + expect(controller.rangeState?.waveform, [0.2, 0.8]); + + expect(controller.finish(const Duration(seconds: 4)), isTrue); + + expect(commits.single.track.audioStartTime, const Duration(seconds: 4)); + expect(commits.single.waveform, [0.2, 0.8]); + expect(controller.state, isNull); + }, + ); + + test('does not replace an active audition session', () async { + final playback = _FakeAudioPlayback(); + final waveforms = >>{}; + final controller = AudioAuditionController( + playback, + (track) => (waveforms[track.id] = Completer>()).future, + (_) {}, + (_, _, _) {}, + ); + addTearDown(controller.dispose); + final span = _span(0, 10); + + expect( + controller.beginAdjustment( + track: _track('first'), + hostSpan: span, + waveform: const [], + ), + isTrue, + ); + expect( + controller.beginAdjustment( + track: _track('second'), + hostSpan: span, + waveform: const [], + ), + isFalse, + ); + + expect(controller.rangeState?.draft.id, 'first'); + expect(waveforms, contains('first')); + expect(waveforms, isNot(contains('second'))); + + waveforms['first']!.complete([0.1]); + await Future.delayed(Duration.zero); + expect(controller.rangeState?.waveform, [0.1]); + }); + + test('does not start a second picker while the first is pausing', () async { + final pause = Completer(); + final playback = _FakeAudioPlayback(onPause: () => pause.future); + final controller = AudioAuditionController( + playback, + (_) async => [0.5], + (_) {}, + (_, _, _) {}, + ); + addTearDown(controller.dispose); + final first = controller.beginPicker( + previousTrack: null, + hostSpan: _span(0, 10), + ); + + expect( + await controller.beginPicker(previousTrack: null, hostSpan: _span(1, 11)), + isFalse, + ); + pause.complete(); + expect(await first, isTrue); + expect(controller.state?.hostSpan, _span(0, 10)); + }); + + test('keeps cancellation restoring until the prior track is ready', () async { + final restore = Completer(); + final playback = _FakeAudioPlayback(onRestore: () => restore.future); + final controller = AudioAuditionController( + playback, + (_) async => [0.5], + (_) {}, + (_, _, _) {}, + ); + addTearDown(controller.dispose); + final previousTrack = _track('previous'); + final hostSpan = _span(0, 12); + controller.beginAdjustment( + track: previousTrack, + hostSpan: hostSpan, + waveform: const [0.5], + ); + + final cancellation = controller.cancel(); + expect(controller.state, isA()); + expect(controller.state?.blocksHostInteraction, isTrue); + + restore.complete(); + await cancellation; + expect(controller.state, isNull); + expect(playback.restoredTracks, [same(previousTrack)]); + }); + + test( + 'stale preview preparation cannot start playback after cancel', + () async { + final prepare = Completer(); + final playback = _FakeAudioPlayback(onPrepare: () => prepare.future); + final controller = AudioAuditionController( + playback, + (_) async => [0.5], + (_) {}, + (_, _, _) {}, + ); + addTearDown(controller.dispose); + controller.beginAdjustment( + track: _track('selected'), + hostSpan: _span(0, 10), + waveform: const [0.5], + ); + await Future.delayed(Duration.zero); + + final cancellation = controller.cancel(); + prepare.complete(); + await cancellation; + await Future.delayed(Duration.zero); + + expect(playback.startCount, 0); + expect(controller.state, isNull); + }, + ); + + test('waveform completion does not cancel current preparation', () async { + final prepare = Completer(); + final waveform = Completer>(); + final playback = _FakeAudioPlayback(onPrepare: () => prepare.future); + final controller = AudioAuditionController( + playback, + (_) => waveform.future, + (_) {}, + (_, _, _) {}, + ); + addTearDown(controller.dispose); + controller.beginAdjustment( + track: _track('selected'), + hostSpan: _span(0, 10), + waveform: const [], + ); + await Future.delayed(Duration.zero); + + waveform.complete([0.4]); + await Future.delayed(Duration.zero); + prepare.complete(); + await Future.delayed(Duration.zero); + + expect(playback.startCount, 1); + expect(controller.rangeState?.waveform, [0.4]); + }); + + test('waveform completion keeps in-flight playback current', () async { + final play = Completer(); + final waveform = Completer>(); + final playback = _FakeAudioPlayback(onPlay: () => play.future); + final controller = AudioAuditionController( + playback, + (_) => waveform.future, + (_) {}, + (_, _, _) {}, + ); + addTearDown(controller.dispose); + controller.beginAdjustment( + track: _track('selected'), + hostSpan: _span(0, 10), + waveform: const [], + ); + await Future.delayed(Duration.zero); + waveform.complete([0.4]); + await Future.delayed(Duration.zero); + play.complete(); + await Future.delayed(Duration.zero); + + expect(playback.playWasCurrent, isTrue); + }); + + test('preview failure invalidates the failed transport request', () async { + final playback = _FakeAudioPlayback( + onPlay: () async => throw StateError('play failed'), + ); + final controller = AudioAuditionController( + playback, + (_) async => [0.5], + (_) {}, + (_, _, _) {}, + ); + addTearDown(controller.dispose); + + controller.beginAdjustment( + track: _track('selected'), + hostSpan: _span(0, 10), + waveform: const [0.5], + ); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect(controller.rangeState?.isScrubbing, isTrue); + expect(playback.lastPlayIsCurrent?.call(), isFalse); + }); + + test('picker phase ignores host playback snapshots', () async { + final playback = _FakeAudioPlayback(); + final controller = AudioAuditionController( + playback, + (_) async => [0.5], + (_) {}, + (_, _, _) {}, + ); + addTearDown(controller.dispose); + await controller.beginPicker(previousTrack: null, hostSpan: _span(0, 10)); + + expect( + controller.handlePlaybackSnapshot( + const AudioAuditionPlaybackSnapshot( + position: Duration.zero, + isPlaying: false, + isCompleted: false, + ), + ), + isNull, + ); + }); + + test('host snapshot updates progress and exposes the active range', () async { + final playback = _FakeAudioPlayback(); + final controller = AudioAuditionController( + playback, + (_) async => [0.5], + (_) {}, + (_, _, _) {}, + ); + addTearDown(controller.dispose); + controller.beginAdjustment( + track: _track('selected'), + hostSpan: _span(2, 10), + waveform: const [0.5], + ); + await Future.delayed(Duration.zero); + const snapshot = AudioAuditionPlaybackSnapshot( + position: Duration(seconds: 6), + isPlaying: true, + isCompleted: false, + ); + + expect( + controller.handlePlaybackSnapshot(snapshot), + same(controller.rangeState), + ); + + expect(controller.playbackProgress.value, 0.5); + }); + + test('range preview waits for an asynchronous scrub pause', () async { + final pause = Completer(); + final playback = _FakeAudioPlayback(onPause: () => pause.future); + final controller = AudioAuditionController( + playback, + (_) async => [0.5], + (_) {}, + (_, _, _) {}, + ); + addTearDown(controller.dispose); + controller.beginAdjustment( + track: _track('selected'), + hostSpan: _span(0, 10), + waveform: const [0.5], + ); + await Future.delayed(Duration.zero); + expect(playback.startCount, 1); + + final pauseFuture = controller.pauseForScrub(); + final previewFuture = controller.previewRange(const Duration(seconds: 5)); + await Future.delayed(Duration.zero); + expect(playback.startCount, 1); + + pause.complete(); + await Future.wait([pauseFuture, previewFuture]); + await Future.delayed(Duration.zero); + + expect(playback.startCount, 2); + expect( + controller.rangeState?.draft.audioStartTime, + const Duration(seconds: 5), + ); + }); + + test('only the latest overlapping scrub can restart playback', () async { + final pauses = [Completer(), Completer()]; + var pauseIndex = 0; + final playback = _FakeAudioPlayback( + onPause: () => pauses[pauseIndex++].future, + ); + final controller = AudioAuditionController( + playback, + (_) async => [0.5], + (_) {}, + (_, _, _) {}, + ); + addTearDown(controller.dispose); + controller.beginAdjustment( + track: _track('selected'), + hostSpan: _span(0, 10), + waveform: const [0.5], + ); + await Future.delayed(Duration.zero); + expect(playback.startCount, 1); + + final firstPause = controller.pauseForScrub(); + final firstPreview = controller.previewRange(const Duration(seconds: 3)); + final secondPause = controller.pauseForScrub(); + final secondPreview = controller.previewRange(const Duration(seconds: 5)); + + pauses.last.complete(); + await Future.delayed(Duration.zero); + expect(playback.startCount, 1); + + pauses.first.complete(); + await Future.wait([firstPause, firstPreview, secondPause, secondPreview]); + await Future.delayed(Duration.zero); + + expect(playback.startCount, 2); + expect( + controller.rangeState?.draft.audioStartTime, + const Duration(seconds: 5), + ); + }); + + test('range boundary restarts without exposing a stale range', () async { + final playback = _FakeAudioPlayback(); + final controller = AudioAuditionController( + playback, + (_) async => [0.5], + (_) {}, + (_, _, _) {}, + ); + addTearDown(controller.dispose); + controller.beginAdjustment( + track: _track('selected'), + hostSpan: _span(2, 10), + waveform: const [0.5], + ); + await Future.delayed(Duration.zero); + expect( + controller.handlePlaybackSnapshot( + const AudioAuditionPlaybackSnapshot( + position: Duration(seconds: 10), + isPlaying: false, + isCompleted: true, + ), + ), + isNull, + ); + await Future.delayed(Duration.zero); + + expect(playback.preparedSpans, hasLength(2)); + }); +} + +class _FakeAudioPlayback implements AudioAuditionPlayback { + _FakeAudioPlayback({ + this.onPause, + this.onPrepare, + this.onPlay, + this.onRestore, + }); + + final Future Function()? onPause; + final Future Function()? onPrepare; + final Future Function()? onPlay; + final Future Function()? onRestore; + final preparedSpans = []; + final restoredTracks = []; + var startCount = 0; + bool? playWasCurrent; + bool Function()? lastPlayIsCurrent; + + @override + Future pausePreview() async { + await onPause?.call(); + } + + @override + Future previewCandidate( + AudioTrack track, + TrimDurationSpan hostSpan, { + required bool Function() isCurrent, + }) async {} + + @override + Future prepareRangePreview( + AudioTrack track, + TrimDurationSpan playbackSpan, { + required bool Function() isCurrent, + }) async { + preparedSpans.add(playbackSpan); + await onPrepare?.call(); + } + + @override + Future startRangePreview( + AudioTrack track, + TrimDurationSpan playbackSpan, { + required bool Function() isCurrent, + }) async { + startCount++; + lastPlayIsCurrent = isCurrent; + await onPlay?.call(); + playWasCurrent = isCurrent(); + } + + @override + Future restorePrevious( + AudioTrack? track, + TrimDurationSpan hostSpan, { + required bool Function() isCurrent, + }) async { + restoredTracks.add(track); + await onRestore?.call(); + } +} + +AudioTrack _track(String id) { + return AudioTrack( + id: id, + title: 'Sound $id', + subtitle: 'artist.sprk.so', + duration: const Duration(seconds: 30), + audio: EditorAudio(networkUrl: 'https://example.com/$id.mp3'), + ); +} + +TrimDurationSpan _span(int startSeconds, int endSeconds) { + return TrimDurationSpan( + start: Duration(seconds: startSeconds), + end: Duration(seconds: endSeconds), + ); +} diff --git a/test/src/core/pro_video_editor/ui/controllers/video_editor_media_session_test.dart b/test/src/core/pro_video_editor/ui/controllers/video_editor_media_session_test.dart index 6c196765..1f4f4368 100644 --- a/test/src/core/pro_video_editor/ui/controllers/video_editor_media_session_test.dart +++ b/test/src/core/pro_video_editor/ui/controllers/video_editor_media_session_test.dart @@ -1,10 +1,158 @@ import 'dart:async'; +import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:pro_image_editor/pro_image_editor.dart'; +import 'package:spark/src/core/pro_video_editor/services/audio_helper_service.dart'; +import 'package:spark/src/core/pro_video_editor/ui/controllers/audio_audition_playback.dart'; import 'package:spark/src/core/pro_video_editor/ui/controllers/video_editor_media_session.dart'; import 'package:video_player/video_player.dart'; void main() { + group('VideoEditorAudioPlaybackCoordinator', () { + late _ControlledVideoPlayerController videoController; + late _FakeAudioHelperService audioService; + late VideoEditorMediaSession media; + late ProVideoController editorController; + late VideoEditorAudioPlaybackCoordinator playback; + late VideoEditorAudioAuditionPlayback auditionPlayback; + + setUp(() { + videoController = _ControlledVideoPlayerController(); + audioService = _FakeAudioHelperService(videoController: videoController); + media = VideoEditorMediaSession( + videoController: videoController, + videoDuration: const Duration(seconds: 20), + onSeekError: (_, _) {}, + audioService: audioService, + ); + editorController = ProVideoController( + videoPlayer: const SizedBox(), + videoDuration: const Duration(seconds: 20), + initialResolution: const Size(1080, 1920), + fileSize: 0, + ); + editorController.initialize( + callbacksAudioFunction: () => const AudioEditorCallbacks(), + callbacksFunction: VideoEditorCallbacks.new, + configsFunction: () => const VideoEditorConfigs(), + ); + playback = VideoEditorAudioPlaybackCoordinator(media, editorController); + auditionPlayback = VideoEditorAudioAuditionPlayback(playback); + }); + + tearDown(() => media.dispose()); + + test('forwards host spans for candidate preview and restoration', () async { + final track = _track(); + final hostSpan = _span(3, 13); + + await auditionPlayback.previewCandidate( + track, + hostSpan, + isCurrent: () => true, + ); + await auditionPlayback.restorePrevious( + track, + hostSpan, + isCurrent: () => true, + ); + + expect(audioService.playSpans, [hostSpan]); + expect(audioService.prepareSpans, [hostSpan]); + expect(audioService.audioModes, [true, true]); + }); + + test('maps neutral snapshots into editor audio synchronization', () async { + final track = _track(); + final playbackSpan = _span(2, 12); + const snapshot = AudioAuditionPlaybackSnapshot( + position: Duration(seconds: 7), + isPlaying: true, + isCompleted: false, + ); + + await playback.synchronize( + track, + playbackSpan, + snapshot, + isCurrent: () => true, + ); + + expect(audioService.synchronizedPositions, [snapshot.position]); + expect(audioService.synchronizedSpans, [playbackSpan]); + expect(audioService.synchronizedPlaying, [true]); + }); + + test('pauses audio before preparing a range preview', () async { + final preparation = auditionPlayback.prepareRangePreview( + _track(), + _span(2, 12), + isCurrent: () => true, + ); + await Future.delayed(Duration.zero); + + expect(audioService.operations, ['pause']); + expect(videoController.pauseCount, 1); + videoController.completeNextSeek(); + await preparation; + + expect(audioService.operations, ['pause', 'prepare', 'mode']); + }); + + test('starts only a current range preview with its exact span', () async { + final track = _track(); + final playbackSpan = _span(2, 12); + + await auditionPlayback.startRangePreview( + track, + playbackSpan, + isCurrent: () => false, + ); + expect(audioService.playSpans, isEmpty); + + videoController.value = const VideoPlayerValue( + duration: Duration(seconds: 20), + position: Duration(seconds: 2), + isPlaying: true, + ); + final start = auditionPlayback.startRangePreview( + track, + playbackSpan, + isCurrent: () => true, + ); + await Future.delayed(Duration.zero); + videoController.value = videoController.value.copyWith( + position: const Duration(seconds: 3), + ); + await start; + + expect(audioService.playSpans, [playbackSpan]); + expect(audioService.playForceSeeks, [isTrue]); + }); + + test('rejects host play while an audition owns playback', () { + editorController.isPlayingNotifier.value = true; + media.timelineState.setPlaying(isPlaying: true); + + expect(playback.rejectPlayRequest(auditionActive: true), isTrue); + + expect(editorController.isPlayingNotifier.value, isFalse); + expect(media.timelineState.isPlaying, isFalse); + }); + + test('leaves host play state alone without an active audition', () { + editorController.isPlayingNotifier.value = true; + media.timelineState.setPlaying(isPlaying: true); + + expect(playback.rejectPlayRequest(auditionActive: false), isFalse); + + expect(editorController.isPlayingNotifier.value, isTrue); + expect(media.timelineState.isPlaying, isTrue); + }); + }); + group('VideoEditorTimelineSeekCoordinator', () { test('latest seek supersedes a pending timeline seek safely', () async { final controller = _ControlledVideoPlayerController(); @@ -63,6 +211,13 @@ class _ControlledVideoPlayerController extends VideoPlayerController { final List targets = []; final List> _seeks = []; + var pauseCount = 0; + + @override + Future pause() async { + pauseCount++; + value = value.copyWith(isPlaying: false); + } @override Future seekTo(Duration position) { @@ -76,3 +231,97 @@ class _ControlledVideoPlayerController extends VideoPlayerController { void failNextSeek(Object error) => _seeks.removeAt(0).completeError(error); } + +class _FakeAudioHelperService extends AudioHelperService { + _FakeAudioHelperService({required super.videoController}) + : super(audioPlayer: _NoopAudioPlayer()); + + final playSpans = []; + final playForceSeeks = []; + final prepareSpans = []; + final audioModes = []; + final synchronizedPositions = []; + final synchronizedSpans = []; + final synchronizedPlaying = []; + final operations = []; + + @override + bool get useCustomAudio => false; + + @override + Future pause() async { + operations.add('pause'); + } + + @override + Future play( + AudioTrack track, { + Duration videoPosition = Duration.zero, + Duration videoStart = Duration.zero, + Duration? videoEnd, + bool forceSeek = false, + }) async { + playForceSeeks.add(forceSeek); + playSpans.add( + TrimDurationSpan(start: videoStart, end: videoEnd ?? Duration.zero), + ); + } + + @override + Future prepare( + AudioTrack track, { + Duration videoPosition = Duration.zero, + Duration videoStart = Duration.zero, + Duration? videoEnd, + }) async { + operations.add('prepare'); + prepareSpans.add( + TrimDurationSpan(start: videoStart, end: videoEnd ?? Duration.zero), + ); + } + + @override + Future setAudioMode({required bool useCustom}) async { + operations.add('mode'); + audioModes.add(useCustom); + } + + @override + Future synchronizePlayback( + AudioTrack track, { + required Duration videoPosition, + required Duration videoStart, + required Duration videoEnd, + required bool isVideoPlaying, + bool forceSeek = false, + }) async { + synchronizedPositions.add(videoPosition); + synchronizedSpans.add(TrimDurationSpan(start: videoStart, end: videoEnd)); + synchronizedPlaying.add(isVideoPlaying); + } + + @override + Future dispose() async {} +} + +class _NoopAudioPlayer implements AudioPlayer { + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +AudioTrack _track() { + return AudioTrack( + id: 'track', + title: 'Track', + subtitle: 'Artist', + duration: const Duration(seconds: 30), + audio: EditorAudio(networkUrl: 'https://example.com/track.mp3'), + ); +} + +TrimDurationSpan _span(int startSeconds, int endSeconds) { + return TrimDurationSpan( + start: Duration(seconds: startSeconds), + end: Duration(seconds: endSeconds), + ); +} diff --git a/test/src/core/pro_video_editor/ui/widgets/audio/audio_range_selection_overlay_test.dart b/test/src/core/pro_video_editor/ui/widgets/audio/audio_range_selection_overlay_test.dart new file mode 100644 index 00000000..c0dbbf8c --- /dev/null +++ b/test/src/core/pro_video_editor/ui/widgets/audio/audio_range_selection_overlay_test.dart @@ -0,0 +1,448 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pro_image_editor/pro_image_editor.dart'; +import 'package:spark/src/core/design_system/components/atoms/buttons/app_button.dart'; +import 'package:spark/src/core/design_system/tokens/colors.dart'; +import 'package:spark/src/core/l10n/app_localizations.dart'; +import 'package:spark/src/core/pro_video_editor/models/audio_audition_timing.dart'; +import 'package:spark/src/core/pro_video_editor/ui/widgets/audio/audio_range_selection_overlay.dart'; + +void main() { + test( + 'selection duration uses the window length without exceeding the sound', + () { + expect( + audioSelectionDuration( + audioDuration: const Duration(seconds: 30), + selectionWindowDuration: const Duration(seconds: 12), + ), + const Duration(seconds: 12), + ); + expect( + audioSelectionDuration( + audioDuration: const Duration(seconds: 8), + selectionWindowDuration: const Duration(seconds: 12), + ), + const Duration(seconds: 8), + ); + }, + ); + + test('audition range normalizes source bounds and short-track looping', () { + final span = TrimDurationSpan( + start: const Duration(seconds: 2), + end: const Duration(seconds: 12), + ); + final track = _testTrack(); + + final beforeStart = audioTrackForAuditionRange( + track, + playbackSpan: span, + sourceStart: const Duration(seconds: -2), + ); + expect(beforeStart.audioStartTime, Duration.zero); + expect(beforeStart.audioEndTime, const Duration(seconds: 10)); + + final afterEnd = audioTrackForAuditionRange( + track, + playbackSpan: span, + sourceStart: const Duration(seconds: 40), + ); + expect(afterEnd.audioStartTime, const Duration(seconds: 20)); + expect(afterEnd.audioEndTime, const Duration(seconds: 30)); + + final shortTrack = audioTrackForAuditionRange( + track.copyWith(duration: const Duration(seconds: 5)), + playbackSpan: span, + sourceStart: const Duration(seconds: 3), + ); + expect(shortTrack.audioStartTime, Duration.zero); + expect(shortTrack.audioEndTime, const Duration(seconds: 5)); + expect(shortTrack.loop, isTrue); + }); + + test('preview range follows the sound placement inside the host span', () { + final track = AudioTrack( + id: 'sound', + title: 'Summer Loop', + subtitle: 'artist.sprk.so', + duration: const Duration(seconds: 30), + audio: EditorAudio(networkUrl: 'https://example.com/sound.mp3'), + startTime: const Duration(seconds: 3), + endTime: const Duration(seconds: 8), + ); + + final placedRange = audioTrackPreviewRange( + track: track, + hostStart: Duration.zero, + hostEnd: const Duration(seconds: 10), + ); + expect(placedRange.start, const Duration(seconds: 3)); + expect(placedRange.end, const Duration(seconds: 8)); + + final trimmedRange = audioTrackPreviewRange( + track: track, + hostStart: const Duration(seconds: 4), + hostEnd: const Duration(seconds: 6), + ); + expect(trimmedRange.start, const Duration(seconds: 4)); + expect(trimmedRange.end, const Duration(seconds: 6)); + }); + + test('playback progress is relative to the active host range', () { + expect( + audioRangePlaybackProgress( + position: const Duration(seconds: 5), + rangeStart: const Duration(seconds: 5), + rangeEnd: const Duration(seconds: 15), + ), + 0, + ); + expect( + audioRangePlaybackProgress( + position: const Duration(seconds: 9), + rangeStart: const Duration(seconds: 5), + rangeEnd: const Duration(seconds: 15), + ), + 0.4, + ); + expect( + audioRangePlaybackProgress( + position: const Duration(seconds: 20), + rangeStart: const Duration(seconds: 5), + rangeEnd: const Duration(seconds: 15), + ), + 1, + ); + }); + + test('preview loops from the end to the start of its placed range', () { + final range = TrimDurationSpan( + start: const Duration(seconds: 3), + end: const Duration(seconds: 8), + ); + expect( + audioRangeLoopTarget( + isPlaybackArmed: true, + isPlaybackCompleted: false, + position: const Duration(seconds: 8), + range: range, + ), + const Duration(seconds: 3), + ); + expect( + audioRangeLoopTarget( + isPlaybackArmed: true, + isPlaybackCompleted: false, + position: const Duration(seconds: 7), + range: range, + ), + isNull, + ); + expect( + audioRangeLoopTarget( + isPlaybackArmed: false, + isPlaybackCompleted: false, + position: const Duration(seconds: 8), + range: range, + ), + isNull, + ); + }); + + test('completed playback loops to the start of a middle-to-end range', () { + final range = TrimDurationSpan( + start: const Duration(seconds: 5), + end: const Duration(seconds: 10), + ); + + expect( + audioRangeLoopTarget( + isPlaybackArmed: true, + isPlaybackCompleted: true, + position: Duration.zero, + range: range, + ), + const Duration(seconds: 5), + ); + expect( + audioRangeLoopTarget( + isPlaybackArmed: true, + isPlaybackCompleted: false, + position: Duration.zero, + range: range, + ), + const Duration(seconds: 5), + ); + }); + + testWidgets('dragging the waveform previews and confirms a fixed range', ( + tester, + ) async { + var scrubStartCount = 0; + Duration? previewedStart; + Duration? confirmedStart; + final playbackProgress = ValueNotifier(0.0); + addTearDown(playbackProgress.dispose); + final track = AudioTrack( + id: 'sound', + title: 'Summer Loop', + subtitle: 'artist.sprk.so', + duration: const Duration(seconds: 30), + audio: EditorAudio(networkUrl: 'https://example.com/sound.mp3'), + ); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: AudioRangeSelectionOverlay( + track: track, + selectionWindowDuration: const Duration(seconds: 10), + waveformData: List.generate( + 90, + (index) => (index % 10 + 1) / 10, + ), + isWaveformLoading: false, + playbackProgress: playbackProgress, + onScrubStarted: () => scrubStartCount++, + onPreviewRequested: (value) => previewedStart = value, + onCancel: () {}, + onDone: (value) => confirmedStart = value, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('audio-range-selection-frame')), + findsOneWidget, + ); + expect(find.text('Select sound clip'), findsOneWidget); + expect(find.text('Summer Loop'), findsOneWidget); + + final waveformStack = tester.widget( + find.byKey(const ValueKey('audio-range-waveform-stack')), + ); + expect(waveformStack.children.map((child) => child.key).take(3), const [ + ValueKey('audio-range-playback-layer'), + ValueKey('audio-range-waveform-layer'), + ValueKey('audio-range-selection-border-layer'), + ]); + expect( + tester + .widget( + find.byKey(const ValueKey('audio-range-playback-fill')), + ) + .color, + AppColors.primary500, + ); + + final cancelButton = tester.widget( + find.byKey(const ValueKey('audio-range-cancel')), + ); + final doneButton = tester.widget( + find.byKey(const ValueKey('audio-range-done')), + ); + expect(cancelButton.variant, AppButtonVariant.neutral); + expect(doneButton.variant, AppButtonVariant.primary); + expect(cancelButton.size, doneButton.size); + expect(cancelButton.minWidth, doneButton.minWidth); + expect(cancelButton.minHeight, doneButton.minHeight); + expect(cancelButton.padding, doneButton.padding); + + double playbackScale() => tester + .widget( + find.byKey(const ValueKey('audio-range-playback-progress')), + ) + .transform + .storage[0]; + + expect(playbackScale(), 0); + playbackProgress.value = 0.5; + await tester.pump(); + await tester.pump(const Duration(milliseconds: 80)); + expect(playbackScale(), allOf(greaterThan(0), lessThan(0.5))); + await tester.pumpAndSettle(); + expect(playbackScale(), closeTo(0.5, 0.001)); + + playbackProgress.value = 0; + await tester.pump(); + expect(playbackScale(), 0); + + await tester.drag( + find.byKey(const ValueKey('audio-range-waveform-scroller')), + const Offset(-240, 0), + ); + await tester.pumpAndSettle(); + + expect(scrubStartCount, 1); + expect(previewedStart, greaterThan(Duration.zero)); + + await tester.tap(find.byKey(const ValueKey('audio-range-done'))); + await tester.pump(); + + expect(confirmedStart, previewedStart); + }); + + testWidgets('cancel removes the range overlay', (tester) async { + final playbackProgress = ValueNotifier(0.0); + addTearDown(playbackProgress.dispose); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: _CancelHost(playbackProgress: playbackProgress), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('audio-range-cancel'))); + await tester.pump(); + + expect(find.byType(AudioRangeSelectionOverlay), findsNothing); + }); + + testWidgets('blocks blank-area input and underlying editor semantics', ( + tester, + ) async { + var backgroundTapCount = 0; + final playbackProgress = ValueNotifier(0.0); + addTearDown(playbackProgress.dispose); + + await tester.pumpWidget( + _overlayHarness( + track: _testTrack(), + playbackProgress: playbackProgress, + background: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => backgroundTapCount++, + child: Semantics( + label: 'Underlying video editor', + button: true, + child: const ColoredBox(color: Colors.black), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tapAt(const Offset(400, 300)); + await tester.pump(); + + expect(backgroundTapCount, 0); + expect(find.semantics.byLabel('Underlying video editor'), findsNothing); + }); + + testWidgets( + 'semantic scrolling re-previews without previewing initial positioning', + (tester) async { + var scrubStartCount = 0; + final previewedStarts = []; + final playbackProgress = ValueNotifier(0.0); + addTearDown(playbackProgress.dispose); + final track = _testTrack().copyWith( + audioStartTime: const Duration(seconds: 5), + audioEndTime: const Duration(seconds: 15), + ); + + await tester.pumpWidget( + _overlayHarness( + track: track, + playbackProgress: playbackProgress, + onScrubStarted: () => scrubStartCount++, + onPreviewRequested: previewedStarts.add, + ), + ); + await tester.pumpAndSettle(); + + expect(scrubStartCount, 0); + expect(previewedStarts, isEmpty); + + tester.semantics.scrollLeft(scrollable: find.semantics.scrollable()); + await tester.pumpAndSettle(); + + expect(scrubStartCount, 1); + expect(previewedStarts, hasLength(1)); + expect(previewedStarts.single, greaterThan(const Duration(seconds: 5))); + }, + ); +} + +AudioTrack _testTrack() { + return AudioTrack( + id: 'sound', + title: 'Summer Loop', + subtitle: 'artist.sprk.so', + duration: const Duration(seconds: 30), + audio: EditorAudio(networkUrl: 'https://example.com/sound.mp3'), + ); +} + +Widget _overlayHarness({ + required AudioTrack track, + required ValueListenable playbackProgress, + Widget? background, + VoidCallback? onScrubStarted, + ValueChanged? onPreviewRequested, +}) { + return MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Stack( + fit: StackFit.expand, + children: [ + background ?? const SizedBox.expand(), + AudioRangeSelectionOverlay( + track: track, + selectionWindowDuration: const Duration(seconds: 10), + waveformData: List.generate( + 90, + (index) => (index % 10 + 1) / 10, + ), + isWaveformLoading: false, + playbackProgress: playbackProgress, + onScrubStarted: onScrubStarted ?? () {}, + onPreviewRequested: onPreviewRequested ?? (_) {}, + onCancel: () {}, + onDone: (_) {}, + ), + ], + ), + ), + ); +} + +class _CancelHost extends StatefulWidget { + const _CancelHost({required this.playbackProgress}); + + final ValueListenable playbackProgress; + + @override + State<_CancelHost> createState() => _CancelHostState(); +} + +class _CancelHostState extends State<_CancelHost> { + bool _visible = true; + + @override + Widget build(BuildContext context) { + if (!_visible) return const SizedBox.shrink(); + return AudioRangeSelectionOverlay( + track: _testTrack(), + selectionWindowDuration: const Duration(seconds: 10), + waveformData: const [0.2, 0.8], + isWaveformLoading: false, + playbackProgress: widget.playbackProgress, + onScrubStarted: () {}, + onPreviewRequested: (_) {}, + onCancel: () => setState(() => _visible = false), + onDone: (_) {}, + ); + } +} diff --git a/test/src/core/pro_video_editor/ui/widgets/audio/audio_selection_bottom_sheet_test.dart b/test/src/core/pro_video_editor/ui/widgets/audio/audio_selection_bottom_sheet_test.dart new file mode 100644 index 00000000..ff792670 --- /dev/null +++ b/test/src/core/pro_video_editor/ui/widgets/audio/audio_selection_bottom_sheet_test.dart @@ -0,0 +1,320 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pro_image_editor/pro_image_editor.dart'; +import 'package:spark/src/core/design_system/components/atoms/buttons/app_button.dart'; +import 'package:spark/src/core/l10n/app_localizations.dart'; +import 'package:spark/src/core/pro_video_editor/providers/sound_picker_search_provider.dart'; +import 'package:spark/src/core/pro_video_editor/providers/sound_picker_search_state.dart'; +import 'package:spark/src/core/pro_video_editor/ui/controllers/audio_audition_controller.dart'; +import 'package:spark/src/core/pro_video_editor/ui/controllers/audio_audition_playback.dart'; +import 'package:spark/src/core/pro_video_editor/ui/widgets/audio/audio_selection_bottom_sheet.dart'; +import 'package:spark/src/core/pro_video_editor/ui/widgets/audio/audio_track_list_section.dart'; + +void main() { + testWidgets('cancel while opening does not present a stale picker', ( + tester, + ) async { + final pause = Completer(); + final playback = _FakeAudioPlayback(onPause: () => pause.future); + final controller = _controller(playback); + addTearDown(controller.dispose); + await _pumpFlowHost(tester, controller: controller, autoOpen: false); + + await tester.tap(find.text('Open sounds')); + await tester.pump(); + expect(controller.state, isA()); + + final cancellation = controller.cancel(); + pause.complete(); + await cancellation; + await tester.pumpAndSettle(); + + expect(find.byType(AudioSelectionBottomSheet), findsNothing); + expect(controller.state, isNull); + }); + + testWidgets('an active picker ignores overlapping open requests', ( + tester, + ) async { + final pause = Completer(); + final playback = _FakeAudioPlayback(onPause: () => pause.future); + final controller = _controller(playback); + addTearDown(controller.dispose); + await _pumpFlowHost(tester, controller: controller, autoOpen: false); + + await tester.tap(find.text('Open sounds')); + await tester.pump(); + await tester.tap(find.text('Open sounds')); + await tester.pump(); + + expect(playback.pauseCalls, 1); + pause.complete(); + await tester.pumpAndSettle(); + expect(find.byType(AudioSelectionBottomSheet), findsOneWidget); + + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + }); + + testWidgets('Continue stays disabled until the selected preview succeeds', ( + tester, + ) async { + final preview = Completer(); + final playback = _FakeAudioPlayback(onPreview: (_) => preview.future); + final controller = _controller(playback); + addTearDown(controller.dispose); + AudioTrack? result; + await _pumpFlowHost( + tester, + controller: controller, + onResult: (track) => result = track, + ); + final track = _track('first'); + + _selectTrack(tester, track); + await tester.pump(); + + expect(_continueButton(tester).onPressed, isNull); + preview.complete(); + await tester.pump(); + await tester.pump(); + + expect(_continueButton(tester).onPressed, isNotNull); + await tester.tap(find.byType(AppButton)); + await tester.pumpAndSettle(); + expect(result?.id, track.id); + expect(controller.state, isA()); + }); + + testWidgets('failed preview cannot be continued', (tester) async { + final preview = Completer(); + final reportedErrors = []; + final playback = _FakeAudioPlayback( + onPreview: (_) => preview.future, + failurePauseError: StateError('pause failed'), + ); + final controller = _controller(playback, errors: reportedErrors); + addTearDown(controller.dispose); + AudioTrack? result; + await _pumpFlowHost( + tester, + controller: controller, + onResult: (track) => result = track, + ); + + _selectTrack(tester, _track('failed')); + await tester.pump(); + preview.completeError(StateError('preview failed')); + await tester.pump(); + await tester.pump(); + + expect(_continueButton(tester).onPressed, isNull); + expect(find.byType(SnackBar), findsOneWidget); + expect(reportedErrors, hasLength(2)); + expect(result, isNull); + expect(tester.takeException(), isNull); + }); + + testWidgets('stale preview success cannot confirm the newer selection', ( + tester, + ) async { + final firstPreview = Completer(); + final secondPreview = Completer(); + final playback = _FakeAudioPlayback( + onPreview: (track) => switch (track.id) { + 'first' => firstPreview.future, + 'second' => secondPreview.future, + _ => throw StateError('Unexpected track ${track.id}'), + }, + ); + final controller = _controller(playback); + addTearDown(controller.dispose); + AudioTrack? result; + await _pumpFlowHost( + tester, + controller: controller, + onResult: (track) => result = track, + ); + final firstTrack = _track('first'); + final secondTrack = _track('second'); + + _selectTrack(tester, firstTrack); + await tester.pump(); + _selectTrack(tester, secondTrack); + await tester.pump(); + firstPreview.complete(); + await tester.pump(); + await tester.pump(); + + expect(_continueButton(tester).onPressed, isNull); + secondPreview.complete(); + await tester.pump(); + await tester.pump(); + expect(_continueButton(tester).onPressed, isNotNull); + await tester.tap(find.byType(AppButton)); + await tester.pumpAndSettle(); + expect(result?.id, secondTrack.id); + }); + + testWidgets('closing during a pending preview restores the prior track', ( + tester, + ) async { + final preview = Completer(); + final playback = _FakeAudioPlayback(onPreview: (_) => preview.future); + final controller = _controller(playback); + addTearDown(controller.dispose); + AudioTrack? result; + await _pumpFlowHost( + tester, + controller: controller, + onResult: (track) => result = track, + ); + + _selectTrack(tester, _track('pending')); + await tester.pump(); + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + + expect(result, isNull); + expect(playback.restoredTracks, [null]); + expect(controller.state, isNull); + + preview.complete(); + await tester.pump(); + expect(tester.takeException(), isNull); + }); +} + +AudioAuditionController _controller( + _FakeAudioPlayback playback, { + List? errors, +}) { + return AudioAuditionController( + playback, + (_) async => const [0.5], + (_) {}, + (_, error, _) => errors?.add(error), + ); +} + +Future _pumpFlowHost( + WidgetTester tester, { + required AudioAuditionController controller, + ValueChanged? onResult, + bool autoOpen = true, +}) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + soundPickerSearchProvider.overrideWithValue( + const SoundPickerSearchState(), + ), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Scaffold( + body: ElevatedButton( + onPressed: () async { + await showAudioSelectionFlow( + context: context, + initialTrack: null, + hostSpan: _span, + audition: controller, + isCurrent: () => true, + onError: (_, _, _) {}, + ); + onResult?.call(controller.rangeState?.draft); + }, + child: const Text('Open sounds'), + ), + ), + ), + ), + ), + ); + if (autoOpen) { + await tester.tap(find.text('Open sounds')); + await tester.pumpAndSettle(); + } +} + +void _selectTrack(WidgetTester tester, AudioTrack track) { + tester + .widget(find.byType(AudioTrackListSection)) + .onTrackSelected(track); +} + +AppButton _continueButton(WidgetTester tester) { + return tester.widget(find.byType(AppButton)); +} + +class _FakeAudioPlayback implements AudioAuditionPlayback { + _FakeAudioPlayback({this.onPreview, this.onPause, this.failurePauseError}); + + final Future Function(AudioTrack track)? onPreview; + final Future Function()? onPause; + final Object? failurePauseError; + final restoredTracks = []; + var pauseCalls = 0; + + @override + Future previewCandidate( + AudioTrack track, + TrimDurationSpan hostSpan, { + required bool Function() isCurrent, + }) { + return onPreview?.call(track) ?? Future.value(); + } + + @override + Future restorePrevious( + AudioTrack? track, + TrimDurationSpan hostSpan, { + required bool Function() isCurrent, + }) async { + restoredTracks.add(track); + } + + @override + Future pausePreview() async { + pauseCalls++; + await onPause?.call(); + if (pauseCalls > 1 && failurePauseError != null) { + throw failurePauseError!; + } + } + + @override + Future prepareRangePreview( + AudioTrack track, + TrimDurationSpan playbackSpan, { + required bool Function() isCurrent, + }) async {} + + @override + Future startRangePreview( + AudioTrack track, + TrimDurationSpan playbackSpan, { + required bool Function() isCurrent, + }) async {} +} + +AudioTrack _track(String id) { + return AudioTrack( + id: id, + title: 'Sound $id', + subtitle: 'artist.sprk.so', + duration: const Duration(seconds: 30), + audio: EditorAudio(networkUrl: 'https://example.com/$id.mp3'), + ); +} + +final _span = TrimDurationSpan( + start: Duration.zero, + end: const Duration(seconds: 10), +); diff --git a/test/src/core/pro_video_editor/ui/widgets/layout/video_editor_bottom_section_test.dart b/test/src/core/pro_video_editor/ui/widgets/layout/video_editor_bottom_section_test.dart index 58ca9a45..c268d8b9 100644 --- a/test/src/core/pro_video_editor/ui/widgets/layout/video_editor_bottom_section_test.dart +++ b/test/src/core/pro_video_editor/ui/widgets/layout/video_editor_bottom_section_test.dart @@ -35,6 +35,7 @@ void main() { onToggleOriginalAudio: () {}, onToggleCustomAudio: () {}, onAddSound: () {}, + onAdjustSound: () {}, onRemoveSound: () {}, onAudioTimingChanged: (_) {}, onSeekStart: () {}, @@ -138,6 +139,7 @@ Widget _testApp( onToggleOriginalAudio: () {}, onToggleCustomAudio: () {}, onAddSound: () {}, + onAdjustSound: () {}, onRemoveSound: () {}, onAudioTimingChanged: (_) {}, onSeekStart: () {}, diff --git a/test/src/core/pro_video_editor/ui/widgets/layout/video_editor_reveal_layout_test.dart b/test/src/core/pro_video_editor/ui/widgets/layout/video_editor_reveal_layout_test.dart index 1270d5f9..05a17218 100644 --- a/test/src/core/pro_video_editor/ui/widgets/layout/video_editor_reveal_layout_test.dart +++ b/test/src/core/pro_video_editor/ui/widgets/layout/video_editor_reveal_layout_test.dart @@ -2,10 +2,50 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pro_image_editor/pro_image_editor.dart'; import 'package:spark/src/core/design_system/tokens/recording_layout.dart'; +import 'package:spark/src/core/pro_video_editor/ui/widgets/layout/video_editor_regular_chrome.dart'; import 'package:spark/src/core/pro_video_editor/ui/widgets/layout/video_editor_reveal_layout.dart'; import 'package:spark/src/core/pro_video_editor/ui/widgets/timeline/video_timeline_state.dart'; void main() { + test( + 'regular chrome restores its reveal state after a fullscreen overlay', + () { + final selectedLayerId = ValueNotifier(null); + final timelineState = VideoTimelineState( + videoDuration: const Duration(seconds: 10), + ); + final chrome = VideoEditorRegularChrome( + vsync: const TestVSync(), + editorKey: GlobalKey(), + previewAspectRatio: 9 / 16, + timelineState: timelineState, + selectedLayerIdListenable: selectedLayerId, + onSeek: (_) {}, + onSeekStart: () {}, + onSeekEnd: () {}, + onTogglePlay: () {}, + onToggleOriginalAudio: () {}, + onToggleCustomAudio: () {}, + onAddSound: () {}, + onAdjustSound: () {}, + onRemoveSound: () {}, + onAudioTimingChanged: (_) {}, + ); + addTearDown(() { + chrome.dispose(); + timelineState.dispose(); + selectedLayerId.dispose(); + }); + chrome.reveal.value = 0.65; + + chrome.setOverlayActive(true); + expect(chrome.reveal.value, 0); + + chrome.setOverlayActive(false); + expect(chrome.reveal.value, 0.65); + }, + ); + test('timed layers only intercept swipes while visible', () { final layer = WidgetLayer( widget: const SizedBox(), diff --git a/test/src/core/pro_video_editor/ui/widgets/layout/video_editor_toolbar_test.dart b/test/src/core/pro_video_editor/ui/widgets/layout/video_editor_toolbar_test.dart index be6f8828..0c7f3ab6 100644 --- a/test/src/core/pro_video_editor/ui/widgets/layout/video_editor_toolbar_test.dart +++ b/test/src/core/pro_video_editor/ui/widgets/layout/video_editor_toolbar_test.dart @@ -51,6 +51,7 @@ void main() { ); addTearDown(timelineState.dispose); var muteCount = 0; + var adjustCount = 0; var removeCount = 0; var selectionClearCount = 0; @@ -76,19 +77,24 @@ void main() { editor: editor, timelineState: timelineState, selection: TimelineSelection.audio, + onAdjustSound: () => adjustCount++, onToggleCustomAudio: () => muteCount++, onRemoveSound: () => removeCount++, onClearSelection: () => selectionClearCount++, ), ); await tester.pumpAndSettle(); + expect(_action('adjust-audio'), findsOneWidget); + expect(find.text('Adjust clip'), findsOneWidget); expect(_action('replace-audio'), findsOneWidget); expect(_action('mute'), findsOneWidget); expect(_action('remove-audio'), findsOneWidget); expect(_action('crop'), findsNothing); + await tester.tap(_action('adjust-audio')); await tester.tap(_action('mute')); await tester.tap(_action('remove-audio')); + expect(adjustCount, 1); expect(muteCount, 2); expect(removeCount, 1); expect(selectionClearCount, 1); @@ -107,6 +113,7 @@ class _ToolbarTestApp extends StatelessWidget { this.selection = TimelineSelection.none, this.onToggleOriginalAudio, this.onToggleCustomAudio, + this.onAdjustSound, this.onRemoveSound, this.onClearSelection, }); @@ -117,6 +124,7 @@ class _ToolbarTestApp extends StatelessWidget { final TimelineSelection selection; final VoidCallback? onToggleOriginalAudio; final VoidCallback? onToggleCustomAudio; + final VoidCallback? onAdjustSound; final VoidCallback? onRemoveSound; final VoidCallback? onClearSelection; @@ -132,6 +140,7 @@ class _ToolbarTestApp extends StatelessWidget { selectedLayer: selectedLayer, selection: selection, onAddSound: () {}, + onAdjustSound: onAdjustSound ?? () {}, onRemoveSound: onRemoveSound ?? () {}, onToggleOriginalAudio: onToggleOriginalAudio ?? () {}, onToggleCustomAudio: onToggleCustomAudio ?? () {}, diff --git a/widgetbook/ios/Podfile.lock b/widgetbook/ios/Podfile.lock index f39fe134..6ad02786 100644 --- a/widgetbook/ios/Podfile.lock +++ b/widgetbook/ios/Podfile.lock @@ -1,83 +1,16 @@ PODS: - - audio_waveforms (0.0.1): - - Flutter - - better_player_plus (1.1.2): - - Cache (~> 6.0.0) - - Flutter - - GCDWebServer - - HLSCachingReverseProxyServer - - PINCache - - Cache (6.0.0) - Flutter (1.0.0) - - fvp (0.36.1): - - Flutter - - FlutterMacOS - - mdk (~> 0.36.0) - - GCDWebServer (3.5.4): - - GCDWebServer/Core (= 3.5.4) - - GCDWebServer/Core (3.5.4) - - HLSCachingReverseProxyServer (0.1.0): - - GCDWebServer (~> 3.5) - - PINCache (>= 3.0.1-beta.3) - - mdk (0.36.0) - - PINCache (3.0.4): - - PINCache/Arc-exception-safe (= 3.0.4) - - PINCache/Core (= 3.0.4) - - PINCache/Arc-exception-safe (3.0.4): - - PINCache/Core - - PINCache/Core (3.0.4): - - PINOperation (~> 1.2.3) - - PINOperation (1.2.3) - - pro_image_editor (12.0.8): - - Flutter - - pro_video_editor (0.0.1): - - Flutter DEPENDENCIES: - - audio_waveforms (from `.symlinks/plugins/audio_waveforms/ios`) - - better_player_plus (from `.symlinks/plugins/better_player_plus/ios`) - Flutter (from `Flutter`) - - fvp (from `.symlinks/plugins/fvp/darwin`) - - pro_image_editor (from `.symlinks/plugins/pro_image_editor/ios`) - - pro_video_editor (from `.symlinks/plugins/pro_video_editor/ios`) - -SPEC REPOS: - trunk: - - Cache - - GCDWebServer - - HLSCachingReverseProxyServer - - mdk - - PINCache - - PINOperation EXTERNAL SOURCES: - audio_waveforms: - :path: ".symlinks/plugins/audio_waveforms/ios" - better_player_plus: - :path: ".symlinks/plugins/better_player_plus/ios" Flutter: :path: Flutter - fvp: - :path: ".symlinks/plugins/fvp/darwin" - pro_image_editor: - :path: ".symlinks/plugins/pro_image_editor/ios" - pro_video_editor: - :path: ".symlinks/plugins/pro_video_editor/ios" SPEC CHECKSUMS: - audio_waveforms: a6dde7fe7c0ea05f06ffbdb0f7c1b2b2ba6cedcf - better_player_plus: 3d40145c650bb83dde08f0d593b21a144196769f - Cache: 4ca7e00363fca5455f26534e5607634c820ffc2d Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - fvp: f8b3b61cf8c696ecaa5608ae0d282ba710e65ae9 - GCDWebServer: 2c156a56c8226e2d5c0c3f208a3621ccffbe3ce4 - HLSCachingReverseProxyServer: 59935e1e0244ad7f3375d75b5ef46e8eb26ab181 - mdk: 3bfb53e0bcc9643180eaa864360051f281e4c17b - PINCache: d9a87a0ff397acffe9e2f0db972ac14680441158 - PINOperation: fb563bcc9c32c26d6c78aaff967d405aa2ee74a7 - pro_image_editor: 3dedac450f82a389877286fa9eb08852cefb04ea - pro_video_editor: 44ef9a6d48dbd757ed428cf35396dd05f35c7830 PODFILE CHECKSUM: bd29822c3d5baf6b44b726f00ea3293a19339ef2 -COCOAPODS: 1.16.2 +COCOAPODS: 1.17.0 diff --git a/widgetbook/ios/Runner.xcodeproj/project.pbxproj b/widgetbook/ios/Runner.xcodeproj/project.pbxproj index 7f2c60c5..bff446c3 100644 --- a/widgetbook/ios/Runner.xcodeproj/project.pbxproj +++ b/widgetbook/ios/Runner.xcodeproj/project.pbxproj @@ -12,11 +12,11 @@ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 5FEAB69BB8689796F43F5DAE /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C1F83B00BA082E6C55D3447E /* Pods_RunnerTests.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; A7B551FDDD3B1859739E2ADB /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6A09F273B323081C2429F5E3 /* Pods_Runner.framework */; }; - 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -52,6 +52,7 @@ 6A09F273B323081C2429F5E3 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 7E30407E72EF685B2ED96145 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 81F90E7337B42F1D05FD81CB /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; @@ -66,7 +67,6 @@ C1F83B00BA082E6C55D3447E /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; E66A3276723A81A2130301C3 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; EDC57EE11678391C675F78EC /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; - 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -192,9 +192,6 @@ productType = "com.apple.product-type.bundle.unit-test"; }; 97C146ED1CF9000F007C117D /* Runner */ = { - packageProductDependencies = ( - 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, - ); isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( @@ -205,13 +202,15 @@ 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - 3C4DB3EBE9E00FC5C43C6516 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); dependencies = ( ); name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; @@ -220,9 +219,6 @@ /* Begin PBXProject section */ 97C146E61CF9000F007C117D /* Project object */ = { - packageReferences = ( - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, - ); isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; @@ -248,6 +244,9 @@ Base, ); mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; @@ -318,23 +317,6 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; - 3C4DB3EBE9E00FC5C43C6516 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; 6B96FFA8679C39B024B1CBA7 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -733,12 +715,14 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + /* Begin XCLocalSwiftPackageReference section */ - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { isa = XCLocalSwiftPackageReference; relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; }; /* End XCLocalSwiftPackageReference section */ + /* Begin XCSwiftPackageProductDependency section */ 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { isa = XCSwiftPackageProductDependency; diff --git a/widgetbook/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/widgetbook/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5d8c4196..9aceec7e 100644 --- a/widgetbook/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/widgetbook/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -18,13 +18,22 @@ "version" : "11.2.0" } }, + { + "identity" : "cache", + "kind" : "remoteSourceControl", + "location" : "https://github.com/hyperoslo/Cache.git", + "state" : { + "revision" : "ad6abdf2a3a866288a7dad2c4e13379406002a81", + "version" : "6.2.0" + } + }, { "identity" : "firebase-ios-sdk", "kind" : "remoteSourceControl", "location" : "https://github.com/firebase/firebase-ios-sdk", "state" : { - "revision" : "d10045cace0b4c335c4efa8f7df7e9a9fc5a7c60", - "version" : "12.13.0" + "revision" : "8d5b4189f1f482df8d5c58c9985ea70491ef5382", + "version" : "12.14.0" } }, { @@ -32,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", "state" : { - "revision" : "19dffda9a9caf8d86570ff846535902d8509d7bf", - "version" : "3.5.0" + "revision" : "dc39082d8881109d35b94b1c122164c0e8d08a55", + "version" : "3.6.1" } }, { @@ -41,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleAppMeasurement.git", "state" : { - "revision" : "c2c76bebcfbb90d90ea10599f934f9af160e1604", - "version" : "12.13.0" + "revision" : "219e564a8510e983e675c94f77f7f7c50049f22d", + "version" : "12.14.0" } }, { @@ -113,8 +122,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/PostHog/posthog-ios", "state" : { - "revision" : "cf6155963a6532c7b88e0cfa2b1d07595d6b6db6", - "version" : "3.58.3" + "revision" : "19288ee2f0575606b4669b2abadda2c9db97c529", + "version" : "3.66.1" } }, { diff --git a/widgetbook/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/widgetbook/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5d8c4196..9aceec7e 100644 --- a/widgetbook/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/widgetbook/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -18,13 +18,22 @@ "version" : "11.2.0" } }, + { + "identity" : "cache", + "kind" : "remoteSourceControl", + "location" : "https://github.com/hyperoslo/Cache.git", + "state" : { + "revision" : "ad6abdf2a3a866288a7dad2c4e13379406002a81", + "version" : "6.2.0" + } + }, { "identity" : "firebase-ios-sdk", "kind" : "remoteSourceControl", "location" : "https://github.com/firebase/firebase-ios-sdk", "state" : { - "revision" : "d10045cace0b4c335c4efa8f7df7e9a9fc5a7c60", - "version" : "12.13.0" + "revision" : "8d5b4189f1f482df8d5c58c9985ea70491ef5382", + "version" : "12.14.0" } }, { @@ -32,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", "state" : { - "revision" : "19dffda9a9caf8d86570ff846535902d8509d7bf", - "version" : "3.5.0" + "revision" : "dc39082d8881109d35b94b1c122164c0e8d08a55", + "version" : "3.6.1" } }, { @@ -41,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleAppMeasurement.git", "state" : { - "revision" : "c2c76bebcfbb90d90ea10599f934f9af160e1604", - "version" : "12.13.0" + "revision" : "219e564a8510e983e675c94f77f7f7c50049f22d", + "version" : "12.14.0" } }, { @@ -113,8 +122,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/PostHog/posthog-ios", "state" : { - "revision" : "cf6155963a6532c7b88e0cfa2b1d07595d6b6db6", - "version" : "3.58.3" + "revision" : "19288ee2f0575606b4669b2abadda2c9db97c529", + "version" : "3.66.1" } }, {