Files
jamshalat-diary/lib/features/quran/presentation/quran_murattal_screen.dart
2026-03-18 00:07:10 +07:00

1208 lines
47 KiB
Dart

import 'dart:async';
import 'dart:math';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:audio_service/audio_service.dart';
import 'package:just_audio/just_audio.dart';
import 'package:go_router/go_router.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../app/icons/app_icons.dart';
import '../../../app/theme/app_colors.dart';
import '../../../core/services/app_audio_player.dart';
import '../../../data/services/muslim_api_service.dart';
import '../../../data/services/unsplash_service.dart';
/// Quran Murattal (audio player) screen.
/// Implements full Surah playback using just_audio.
class QuranMurattalScreen extends ConsumerStatefulWidget {
final String surahId;
final String? initialQariId;
final bool autoPlay;
final bool isSimpleModeTab;
const QuranMurattalScreen({
super.key,
required this.surahId,
this.initialQariId,
this.autoPlay = false,
this.isSimpleModeTab = false,
});
@override
ConsumerState<QuranMurattalScreen> createState() =>
_QuranMurattalScreenState();
}
class _QuranMurattalScreenState extends ConsumerState<QuranMurattalScreen>
with SingleTickerProviderStateMixin {
final AudioPlayer _audioPlayer = AppAudioPlayer.instance;
Map<String, dynamic>? _surahData;
bool _isLoading = true;
// Audio State Variables
bool _isPlaying = false;
bool _isBuffering = false;
Duration _position = Duration.zero;
Duration _duration = Duration.zero;
StreamSubscription? _positionSub;
StreamSubscription? _durationSub;
StreamSubscription? _playerStateSub;
// Qari State
late String _selectedQariId;
// Shuffle State
bool _isShuffleEnabled = false;
// Unsplash Background
Map<String, String>? _unsplashPhoto;
late final AnimationController _goldRingController;
@override
void initState() {
super.initState();
_selectedQariId =
widget.initialQariId ?? '05'; // Default to Misyari Rasyid Al-Afasi
_goldRingController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 5000),
)..repeat();
_initDataAndPlayer();
_loadUnsplashPhoto();
}
Future<void> _loadUnsplashPhoto() async {
final photo = await UnsplashService.instance.getIslamicPhoto();
if (mounted && photo != null) {
setState(() => _unsplashPhoto = photo);
}
}
Future<void> _initDataAndPlayer() async {
final surahNum = int.tryParse(widget.surahId) ?? 1;
final data = await MuslimApiService.instance.getSurah(surahNum);
if (data != null && mounted) {
setState(() {
_surahData = data;
_isLoading = false;
});
_setupAudioStreamListeners();
_loadAudioSource();
} else if (mounted) {
setState(() => _isLoading = false);
}
}
void _setupAudioStreamListeners() {
_positionSub = _audioPlayer.positionStream.listen((pos) {
if (mounted) setState(() => _position = pos);
});
_durationSub = _audioPlayer.durationStream.listen((dur) {
if (mounted && dur != null) setState(() => _duration = dur);
});
_playerStateSub = _audioPlayer.playerStateStream.listen((state) {
if (!mounted) return;
setState(() {
_isPlaying = state.playing;
_isBuffering = state.processingState == ProcessingState.buffering ||
state.processingState == ProcessingState.loading;
// Auto pause and reset to 0 when finished
if (state.processingState == ProcessingState.completed) {
_audioPlayer.pause();
_audioPlayer.seek(Duration.zero);
// Auto-play next surah
final currentSurah = int.tryParse(widget.surahId) ?? 1;
if (_isShuffleEnabled) {
final random = Random();
int nextSurah = random.nextInt(114) + 1;
while (nextSurah == currentSurah) {
nextSurah = random.nextInt(114) + 1;
}
_navigateToSurahNumber(nextSurah, autoplay: true);
} else if (currentSurah < 114) {
_navigateToSurah(1);
}
}
});
});
}
Future<void> _loadAudioSource() async {
if (_surahData == null) return;
final audioUrls = _surahData!['audioFull'];
if (audioUrls != null && audioUrls[_selectedQariId] != null) {
try {
final surahName = _surahData?['namaLatin'] ?? 'Surah ${widget.surahId}';
final qariName = MuslimApiService.qariNames[_selectedQariId] ?? 'Qari';
final imageUrl = _unsplashPhoto?['imageUrl'];
await _audioPlayer.setAudioSource(
AudioSource.uri(
Uri.parse(audioUrls[_selectedQariId] as String),
tag: MediaItem(
id: 'murattal_${widget.surahId}_$_selectedQariId',
album: "Al-Qur'an Murattal",
title: 'Surah $surahName',
artist: qariName,
artUri: imageUrl != null ? Uri.tryParse(imageUrl) : null,
),
),
);
if (widget.autoPlay) {
_audioPlayer.play();
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Gagal memuat audio murattal')),
);
}
}
}
}
@override
void dispose() {
_positionSub?.cancel();
_durationSub?.cancel();
_playerStateSub?.cancel();
_goldRingController.dispose();
super.dispose();
}
void _syncGoldRingAnimation({required bool reducedMotion}) {
if (reducedMotion) {
if (_goldRingController.isAnimating) {
_goldRingController.stop(canceled: false);
}
return;
}
if (!_goldRingController.isAnimating) {
_goldRingController.repeat();
}
}
String _formatDuration(Duration d) {
final minutes = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = d.inSeconds.remainder(60).toString().padLeft(2, '0');
if (d.inHours > 0) {
return '${d.inHours}:$minutes:$seconds';
}
return '$minutes:$seconds';
}
void _seekRelative(int seconds) {
final newPosition = _position + Duration(seconds: seconds);
if (newPosition < Duration.zero) {
_audioPlayer.seek(Duration.zero);
} else if (newPosition > _duration) {
_audioPlayer.seek(_duration);
} else {
_audioPlayer.seek(newPosition);
}
}
void _navigateToSurah(int direction) {
final currentSurah = int.tryParse(widget.surahId) ?? 1;
final nextSurah = currentSurah + direction;
_navigateToSurahNumber(nextSurah, autoplay: true);
}
void _navigateToSurahNumber(int surahNum, {bool autoplay = false}) {
if (surahNum >= 1 && surahNum <= 114) {
final base = widget.isSimpleModeTab ? '/quran' : '/tools/quran';
context.pushReplacement(
'$base/$surahNum/murattal?qariId=$_selectedQariId&autoplay=$autoplay',
);
}
}
void _navigateToQuranReading() {
final base = widget.isSimpleModeTab ? '/quran' : '/tools/quran';
context.push('$base/${widget.surahId}');
}
void _showQariSelector() {
showModalBottomSheet(
context: context,
useSafeArea: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 12),
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 16),
const Text(
'Pilih Qari',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
...MuslimApiService.qariNames.entries.map((entry) {
final isSelected = entry.key == _selectedQariId;
return ListTile(
leading: AppIcon(
glyph: isSelected ? AppIcons.checkCircle : AppIcons.circle,
color: isSelected ? AppColors.primary : Colors.grey,
),
title: Text(
entry.value,
style: TextStyle(
fontWeight:
isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? AppColors.primary : null,
),
),
onTap: () {
Navigator.pop(context);
if (!isSelected) {
setState(() => _selectedQariId = entry.key);
_loadAudioSource();
}
},
);
}),
const SizedBox(height: 16),
],
),
);
},
);
}
void _showSurahPlaylist() {
final currentSurah = int.tryParse(widget.surahId) ?? 1;
showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) {
return DraggableScrollableSheet(
initialChildSize: 0.7,
minChildSize: 0.4,
maxChildSize: 0.9,
expand: false,
builder: (context, scrollController) {
return Column(
children: [
const SizedBox(height: 12),
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 16),
const Text(
'Playlist Surah',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Expanded(
child: FutureBuilder<List<Map<String, dynamic>>>(
future: MuslimApiService.instance.getAllSurahs(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final surahs = snapshot.data!;
return ListView.builder(
controller: scrollController,
itemCount: surahs.length,
itemBuilder: (context, i) {
final surah = surahs[i];
final surahNum = surah['nomor'] ?? (i + 1);
final isCurrentSurah = surahNum == currentSurah;
return ListTile(
leading: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: isCurrentSurah
? AppColors.primary
: AppColors.primary.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Center(
child: Text(
'$surahNum',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: isCurrentSurah
? Colors.white
: AppColors.primary,
),
),
),
),
title: Text(
surah['namaLatin'] ?? 'Surah $surahNum',
style: TextStyle(
fontWeight: isCurrentSurah
? FontWeight.bold
: FontWeight.normal,
color:
isCurrentSurah ? AppColors.primary : null,
),
),
subtitle: Text(
'${surah['arti'] ?? ''}${surah['jumlahAyat'] ?? 0} Ayat',
style: const TextStyle(fontSize: 12),
),
trailing: isCurrentSurah
? const AppIcon(
glyph: AppIcons.musicNote,
color: AppColors.primary,
size: 20,
)
: null,
onTap: () {
Navigator.pop(context);
if (!isCurrentSurah) {
context.pushReplacement(
'${widget.isSimpleModeTab ? '/quran' : '/tools/quran'}/$surahNum/murattal?qariId=$_selectedQariId',
);
}
},
);
},
);
},
),
),
],
);
},
);
},
);
}
@override
Widget build(BuildContext context) {
final media = MediaQuery.maybeOf(context);
final reducedMotion = (media?.disableAnimations ?? false) ||
(media?.accessibleNavigation ?? false);
_syncGoldRingAnimation(reducedMotion: reducedMotion);
final isDark = Theme.of(context).brightness == Brightness.dark;
final surahName = _surahData?['namaLatin'] ?? 'Surah ${widget.surahId}';
final systemBottomInset = media?.viewPadding.bottom ?? 0.0;
final playerBottomPadding = 32 + systemBottomInset;
final playerReservedBottom = 280 + systemBottomInset;
final hasPhoto = _unsplashPhoto != null;
return Scaffold(
extendBodyBehindAppBar: hasPhoto,
appBar: AppBar(
leading: IconButton(
icon: AppIcon(
glyph: AppIcons.backArrow,
color: hasPhoto ? Colors.white : null,
),
onPressed: () {
if (widget.isSimpleModeTab) {
context.go('/');
} else {
context.pop();
}
},
),
actions: [
IconButton(
icon: AppIcon(
glyph: AppIcons.quran,
color: hasPhoto ? Colors.white : null,
),
tooltip: 'Buka Surah',
onPressed: _navigateToQuranReading,
),
],
backgroundColor: hasPhoto ? Colors.transparent : null,
elevation: hasPhoto ? 0 : null,
iconTheme: hasPhoto ? const IconThemeData(color: Colors.white) : null,
flexibleSpace: hasPhoto
? ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
color: Colors.black.withValues(alpha: 0.2),
),
),
)
: null,
title: Column(
children: [
Text(
'Surah $surahName',
style: TextStyle(
color: hasPhoto ? Colors.white : null,
),
),
Text(
'MURATTAL',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 1.5,
color: hasPhoto ? Colors.white70 : AppColors.primary,
),
),
],
),
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: Stack(
fit: StackFit.expand,
children: [
// === FULL-BLEED BACKGROUND ===
if (_unsplashPhoto != null)
CachedNetworkImage(
imageUrl: _unsplashPhoto!['imageUrl'] ?? '',
fit: BoxFit.cover,
placeholder: (context, url) => Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.primary.withValues(alpha: 0.1),
AppColors.primary.withValues(alpha: 0.05),
],
),
),
),
errorWidget: (context, url, error) => Container(
color: isDark ? Colors.black : Colors.grey.shade100,
),
)
else
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.primary.withValues(alpha: 0.1),
AppColors.primary.withValues(alpha: 0.03),
],
),
),
),
// Dark overlay
if (_unsplashPhoto != null)
Container(
color: Colors.black.withValues(alpha: 0.35),
),
// === CENTER CONTENT (Equalizer + Text) ===
Positioned(
top: 0,
left: 0,
right: 0,
bottom: playerReservedBottom, // leave room for player
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Equalizer circle
Container(
width: 220,
height: 220,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: _unsplashPhoto != null
? [
Colors.white.withValues(alpha: 0.15),
Colors.white.withValues(alpha: 0.05),
]
: [
AppColors.primary.withValues(alpha: 0.2),
AppColors.primary.withValues(alpha: 0.05),
],
),
),
child: Center(
child: Container(
width: 140,
height: 140,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _unsplashPhoto != null
? Colors.white.withValues(alpha: 0.1)
: AppColors.primary.withValues(alpha: 0.12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: List.generate(7, (i) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 2),
child: _EqualizerBar(
isPlaying: _isPlaying,
index: i,
color: _unsplashPhoto != null
? Colors.white
: AppColors.primary,
),
);
}),
),
),
),
),
const SizedBox(height: 32),
// Qari name
Text(
MuslimApiService.qariNames[_selectedQariId] ??
'Memuat...',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: _unsplashPhoto != null ? Colors.white : null,
),
),
const SizedBox(height: 4),
Text(
'Memutar Surat $surahName',
style: TextStyle(
fontSize: 14,
color: _unsplashPhoto != null
? Colors.white70
: AppColors.primary,
),
),
],
),
),
),
// === FROSTED GLASS PLAYER CONTROLS ===
Positioned(
bottom: 0,
left: 0,
right: 0,
child: ClipRRect(
borderRadius:
const BorderRadius.vertical(top: Radius.circular(24)),
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: _unsplashPhoto != null ? 20 : 14,
sigmaY: _unsplashPhoto != null ? 20 : 14,
),
child: Container(
padding: EdgeInsets.fromLTRB(
24, 16, 24, playerBottomPadding),
decoration: BoxDecoration(
color: AppColors.primary.withValues(
alpha: _unsplashPhoto != null
? 0.22
: (isDark ? 0.18 : 0.14),
),
borderRadius: const BorderRadius.vertical(
top: Radius.circular(24)),
border: Border(
top: BorderSide(
color: _unsplashPhoto != null
? Colors.white.withValues(alpha: 0.24)
: AppColors.primary.withValues(alpha: 0.32),
width: 0.7,
),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Progress slider
SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 3,
thumbShape: const RoundSliderThumbShape(
enabledThumbRadius: 6),
),
child: Slider(
value: _position.inMilliseconds.toDouble(),
max: _duration.inMilliseconds > 0
? _duration.inMilliseconds.toDouble()
: 1.0,
onChanged: (v) {
_audioPlayer
.seek(Duration(milliseconds: v.round()));
},
activeColor: _unsplashPhoto != null
? Colors.white
: AppColors.primary,
inactiveColor: _unsplashPhoto != null
? Colors.white.withValues(alpha: 0.2)
: AppColors.primary.withValues(alpha: 0.15),
),
),
// Time labels
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
_formatDuration(_position),
style: TextStyle(
fontSize: 12,
color: _unsplashPhoto != null
? Colors.white70
: (isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight),
),
),
Text(
_formatDuration(_duration),
style: TextStyle(
fontSize: 12,
color: _unsplashPhoto != null
? Colors.white70
: (isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight),
),
),
],
),
),
const SizedBox(height: 16),
// Playback controls — Spotify-style 5-button row
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
// Shuffle
IconButton(
onPressed: () => setState(() =>
_isShuffleEnabled = !_isShuffleEnabled),
icon: AppIcon(
glyph: AppIcons.shuffle,
size: 24,
color: _isShuffleEnabled
? (_unsplashPhoto != null
? Colors.white
: AppColors.primary)
: (_unsplashPhoto != null
? Colors.white54
: (isDark
? AppColors.textSecondaryDark
: AppColors
.textSecondaryLight)),
),
),
// Previous Surah
IconButton(
onPressed:
(int.tryParse(widget.surahId) ?? 1) > 1
? () => _navigateToSurah(-1)
: null,
icon: AppIcon(
glyph: AppIcons.previousTrack,
size: 36,
color: (int.tryParse(widget.surahId) ?? 1) >
1
? (_unsplashPhoto != null
? Colors.white
: (isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight))
: Colors.grey.withValues(alpha: 0.2),
),
),
// Play/Pause
GestureDetector(
onTap: () {
if (_isPlaying) {
_audioPlayer.pause();
} else {
_audioPlayer.play();
}
},
child: SizedBox(
width: 68,
height: 68,
child: RepaintBoundary(
child: AnimatedBuilder(
animation: _goldRingController,
builder: (_, __) {
final ringProgress = reducedMotion
? 0.18
: _goldRingController.value;
return CustomPaint(
painter: _MurattalGoldRingPainter(
progress: ringProgress,
reducedMotion: reducedMotion,
isDark: isDark,
),
child: Padding(
padding: const EdgeInsets.all(3),
child: DecoratedBox(
decoration: BoxDecoration(
color: _unsplashPhoto != null
? AppColors.brandTeal900
.withValues(
alpha: 0.88)
: AppColors.primary,
shape: BoxShape.circle,
),
child: _isBuffering
? Padding(
padding:
const EdgeInsets
.all(18.0),
child:
CircularProgressIndicator(
color: Colors.white,
strokeWidth: 3,
),
)
: Padding(
padding:
const EdgeInsets
.all(18),
child: AppIcon(
glyph: _isPlaying
? AppIcons.pause
: AppIcons
.murattal,
size: 18,
color: AppColors
.onPrimary,
),
),
),
),
);
},
),
),
),
),
// Next Surah
IconButton(
onPressed:
(int.tryParse(widget.surahId) ?? 1) < 114
? () => _navigateToSurah(1)
: null,
icon: AppIcon(
glyph: AppIcons.nextTrack,
size: 36,
color: (int.tryParse(widget.surahId) ?? 1) <
114
? (_unsplashPhoto != null
? Colors.white
: (isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight))
: Colors.grey.withValues(alpha: 0.2),
),
),
// Playlist
IconButton(
onPressed: _showSurahPlaylist,
icon: AppIcon(
glyph: AppIcons.playlist,
size: 28,
color: _unsplashPhoto != null
? Colors.white70
: (isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight),
),
),
],
),
const SizedBox(height: 16),
// Qari selector trigger
GestureDetector(
onTap: _showQariSelector,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: _unsplashPhoto != null
? Colors.white.withValues(alpha: 0.15)
: AppColors.primary
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(50),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
AppIcon(
glyph: AppIcons.user,
size: 16,
color: _unsplashPhoto != null
? Colors.white
: AppColors.primary,
),
const SizedBox(width: 8),
Text(
MuslimApiService
.qariNames[_selectedQariId] ??
'Ganti Qari',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: _unsplashPhoto != null
? Colors.white
: AppColors.primary,
),
),
const SizedBox(width: 4),
AppIcon(
glyph: AppIcons.arrowDown,
size: 16,
color: _unsplashPhoto != null
? Colors.white
: AppColors.primary,
),
],
),
),
),
],
),
),
),
),
),
// === ATTRIBUTION ===
if (_unsplashPhoto != null)
Positioned(
bottom: playerReservedBottom,
left: 0,
right: 0,
child: GestureDetector(
onTap: () {
final url = _unsplashPhoto!['photographerUrl'];
if (url != null && url.isNotEmpty) {
launchUrl(Uri.parse(
'$url?utm_source=jamshalat_diary&utm_medium=referral'));
}
},
child: Text(
'📷 ${_unsplashPhoto!['photographerName']} / Unsplash',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 10,
color: Colors.white.withValues(alpha: 0.6),
fontWeight: FontWeight.w500,
),
),
),
),
],
),
);
}
}
class _MurattalGoldRingPainter extends CustomPainter {
const _MurattalGoldRingPainter({
required this.progress,
required this.reducedMotion,
required this.isDark,
});
final double progress;
final bool reducedMotion;
final bool isDark;
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final outerRadius = (size.shortestSide / 2) - 0.8;
final ringRadius = (size.shortestSide / 2) - 2.0;
final innerRadius = (size.shortestSide / 2) - 3.8;
final rotation = reducedMotion ? pi * 0.63 : progress * pi * 2;
void drawEmboss({
required double radius,
required Offset shadowOffset,
required Offset highlightOffset,
required Color shadowColor,
required Color highlightColor,
required double shadowBlur,
required double highlightBlur,
required double strokeWidth,
}) {
final shadowPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..color = shadowColor
..maskFilter = MaskFilter.blur(BlurStyle.normal, shadowBlur);
final highlightPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..color = highlightColor
..maskFilter = MaskFilter.blur(BlurStyle.normal, highlightBlur);
canvas.save();
canvas.translate(shadowOffset.dx, shadowOffset.dy);
canvas.drawCircle(center, radius, shadowPaint);
canvas.restore();
canvas.save();
canvas.translate(highlightOffset.dx, highlightOffset.dy);
canvas.drawCircle(center, radius, highlightPaint);
canvas.restore();
}
drawEmboss(
radius: outerRadius,
shadowOffset: const Offset(0.8, 1.1),
highlightOffset: const Offset(-0.65, -0.75),
shadowColor: isDark
? const Color(0xC4000000)
: AppColors.navEmbossShadow.withValues(alpha: 0.72),
highlightColor: isDark
? Colors.white.withValues(alpha: 0.2)
: Colors.white.withValues(alpha: 0.88),
shadowBlur: isDark ? 2.6 : 1.9,
highlightBlur: isDark ? 1.7 : 1.2,
strokeWidth: 1.12,
);
drawEmboss(
radius: innerRadius,
shadowOffset: const Offset(-0.4, -0.4),
highlightOffset: const Offset(0.45, 0.55),
shadowColor: isDark
? Colors.black.withValues(alpha: 0.58)
: AppColors.navEmbossShadow.withValues(alpha: 0.58),
highlightColor: isDark
? Colors.white.withValues(alpha: 0.14)
: Colors.white.withValues(alpha: 0.76),
shadowBlur: isDark ? 1.5 : 1.2,
highlightBlur: isDark ? 1.1 : 0.9,
strokeWidth: 0.96,
);
final ringRect = Rect.fromCircle(center: center, radius: ringRadius);
final metallic = SweepGradient(
startAngle: rotation,
endAngle: rotation + pi * 2,
colors: const [
AppColors.navActiveGoldDeep,
AppColors.navActiveGold,
AppColors.navActiveGoldBright,
AppColors.navActiveGoldPale,
AppColors.navActiveGoldBright,
AppColors.navActiveGoldDeep,
],
stops: const [0.0, 0.16, 0.34, 0.5, 0.68, 1.0],
);
final metallicPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2.8
..shader = metallic.createShader(ringRect)
..isAntiAlias = true;
canvas.drawCircle(center, ringRadius, metallicPaint);
final chromaStrength = isDark ? 0.92 : 0.74;
final chromaSweep = SweepGradient(
startAngle: (rotation * 1.3) + 0.42,
endAngle: (rotation * 1.3) + 0.42 + pi * 2,
colors: [
Colors.transparent,
Colors.transparent,
AppColors.navActiveGold.withValues(alpha: 0.52 * chromaStrength),
Colors.white.withValues(alpha: 0.92 * chromaStrength),
AppColors.navActiveGoldPale.withValues(alpha: 0.66 * chromaStrength),
Colors.transparent,
Colors.transparent,
AppColors.navActiveGold.withValues(alpha: 0.44 * chromaStrength),
Colors.white.withValues(alpha: 0.84 * chromaStrength),
AppColors.navActiveGoldPale.withValues(alpha: 0.6 * chromaStrength),
Colors.transparent,
Colors.transparent,
],
stops: const [
0.0,
0.09,
0.112,
0.126,
0.14,
0.175,
0.45,
0.468,
0.484,
0.5,
0.528,
1.0,
],
);
final chromaPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.55
..shader = chromaSweep.createShader(ringRect)
..blendMode = BlendMode.screen;
canvas.drawCircle(center, ringRadius, chromaPaint);
final ambient = Paint()
..style = PaintingStyle.stroke
..strokeWidth = isDark ? 1.0 : 0.86
..color = isDark
? AppColors.navActiveGold.withValues(alpha: 0.2)
: AppColors.navActiveGoldDeep.withValues(alpha: 0.16)
..maskFilter = MaskFilter.blur(
BlurStyle.normal,
isDark ? 2.8 : 1.3,
);
canvas.drawCircle(center, ringRadius, ambient);
final innerEdge = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 0.8
..color = isDark
? Colors.white.withValues(alpha: 0.12)
: const Color(0x330F172A);
canvas.drawCircle(center, innerRadius, innerEdge);
}
@override
bool shouldRepaint(covariant _MurattalGoldRingPainter oldDelegate) {
return oldDelegate.progress != progress ||
oldDelegate.reducedMotion != reducedMotion ||
oldDelegate.isDark != isDark;
}
}
/// Animated equalizer bar widget for the Murattal player.
class _EqualizerBar extends StatefulWidget {
final bool isPlaying;
final int index;
final Color color;
const _EqualizerBar({
required this.isPlaying,
required this.index,
required this.color,
});
@override
State<_EqualizerBar> createState() => _EqualizerBarState();
}
class _EqualizerBarState extends State<_EqualizerBar>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
// Each bar has a unique height range and speed for variety
static const _barConfigs = [
[0.3, 0.9, 600],
[0.2, 1.0, 500],
[0.4, 0.8, 700],
[0.1, 1.0, 450],
[0.3, 0.9, 550],
[0.2, 0.85, 650],
[0.35, 0.95, 480],
];
@override
void initState() {
super.initState();
final config = _barConfigs[widget.index % _barConfigs.length];
final minHeight = config[0] as double;
final maxHeight = config[1] as double;
final durationMs = (config[2] as num).toInt();
_controller = AnimationController(
vsync: this,
duration: Duration(milliseconds: durationMs),
);
_animation = Tween<double>(
begin: minHeight,
end: maxHeight,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
));
if (widget.isPlaying) {
_controller.repeat(reverse: true);
}
}
@override
void didUpdateWidget(covariant _EqualizerBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isPlaying && !oldWidget.isPlaying) {
_controller.repeat(reverse: true);
} else if (!widget.isPlaying && oldWidget.isPlaying) {
_controller.animateTo(0.0, duration: const Duration(milliseconds: 300));
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Container(
width: 6,
height: 50 * _animation.value,
decoration: BoxDecoration(
color:
widget.color.withValues(alpha: 0.6 + (_animation.value * 0.4)),
borderRadius: BorderRadius.circular(3),
),
);
},
);
}
}