Files
jamshalat-diary/lib/features/quran/presentation/quran_murattal_screen.dart
2026-03-16 00:30:32 +07:00

886 lines
33 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:just_audio/just_audio.dart';
import 'package:go_router/go_router.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../app/theme/app_colors.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> {
final AudioPlayer _audioPlayer = AudioPlayer();
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;
@override
void initState() {
super.initState();
_selectedQariId = widget.initialQariId ?? '05'; // Default to Misyari Rasyid Al-Afasi
_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 {
await _audioPlayer.setUrl(audioUrls[_selectedQariId]);
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();
_audioPlayer.dispose();
super.dispose();
}
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 _showQariSelector() {
showModalBottomSheet(
context: context,
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: Icon(
isSelected ? LucideIcons.checkCircle2 : LucideIcons.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,
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
? Icon(LucideIcons.music, 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 isDark = Theme.of(context).brightness == Brightness.dark;
final surahName = _surahData?['namaLatin'] ?? 'Surah ${widget.surahId}';
final hasPhoto = _unsplashPhoto != null;
return Scaffold(
extendBodyBehindAppBar: hasPhoto,
appBar: AppBar(
leading: IconButton(
icon: Icon(Icons.arrow_back,
color: hasPhoto ? Colors.white : null),
onPressed: () {
if (widget.isSimpleModeTab) {
context.go('/');
} else {
context.pop();
}
},
),
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: 280, // leave room for the 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: _unsplashPhoto != null
? ImageFilter.blur(sigmaX: 20, sigmaY: 20)
: ImageFilter.blur(sigmaX: 0, sigmaY: 0),
child: Container(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 48),
decoration: BoxDecoration(
color: _unsplashPhoto != null
? Colors.white.withValues(alpha: 0.15)
: (isDark ? AppColors.surfaceDark : AppColors.surfaceLight),
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
border: _unsplashPhoto != null
? Border(
top: BorderSide(
color: Colors.white.withValues(alpha: 0.2),
width: 0.5,
),
)
: null,
),
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: Icon(
LucideIcons.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: Icon(
LucideIcons.skipBack,
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: Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: _unsplashPhoto != null
? Colors.white
: AppColors.primary,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: (_unsplashPhoto != null
? Colors.white
: AppColors.primary)
.withValues(alpha: 0.3),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: _isBuffering
? Padding(
padding: const EdgeInsets.all(18.0),
child: CircularProgressIndicator(
color: _unsplashPhoto != null
? Colors.black87
: Colors.white,
strokeWidth: 3,
),
)
: Icon(
_isPlaying
? LucideIcons.pause
: LucideIcons.play,
size: 36,
color: _unsplashPhoto != null
? Colors.black87
: AppColors.onPrimary,
),
),
),
// Next Surah
IconButton(
onPressed: (int.tryParse(widget.surahId) ?? 1) < 114
? () => _navigateToSurah(1)
: null,
icon: Icon(
LucideIcons.skipForward,
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: Icon(
LucideIcons.listMusic,
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: [
Icon(LucideIcons.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),
Icon(LucideIcons.chevronDown,
size: 16,
color: _unsplashPhoto != null ? Colors.white : AppColors.primary),
],
),
),
),
],
),
),
),
),
),
// === ATTRIBUTION ===
if (_unsplashPhoto != null)
Positioned(
bottom: 280,
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,
),
),
),
),
],
),
);
}
}
/// 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),
),
);
},
);
}
}