1208 lines
47 KiB
Dart
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&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),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|