added audio output for demo songs since spotify api doesnt support audio streaming

This commit is contained in:
Leon Astner 2025-08-02 05:41:50 +02:00
parent 6b93f1206d
commit a91654df03
14 changed files with 1261 additions and 77 deletions

View file

@ -5,6 +5,8 @@ import 'pages/voting_page.dart';
import 'pages/group_page.dart';
import 'services/music_queue_service.dart';
import 'services/network_group_service.dart';
import 'services/spam_protection_service.dart';
import 'services/audio_service.dart';
void main() {
runApp(const MyApp());
@ -20,6 +22,8 @@ class MyApp extends StatelessWidget {
providers: [
ChangeNotifierProvider(create: (context) => MusicQueueService()),
ChangeNotifierProvider(create: (context) => NetworkGroupService()),
ChangeNotifierProvider(create: (context) => SpamProtectionService()),
ChangeNotifierProvider(create: (context) => AudioService()),
],
child: MaterialApp(
title: 'SleepySound',
@ -81,10 +85,6 @@ class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(_getPageTitle()),
),
body: _getSelectedPage(),
bottomNavigationBar: BottomNavigationBar(
items: const <BottomNavigationBarItem>[
@ -110,17 +110,4 @@ class _MyHomePageState extends State<MyHomePage> {
),
);
}
String _getPageTitle() {
switch (_selectedIndex) {
case 0:
return 'Now Playing';
case 1:
return 'Voting';
case 2:
return 'Group';
default:
return 'Now Playing';
}
}
}

View file

@ -1,7 +1,10 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/music_queue_service.dart';
import '../services/spam_protection_service.dart';
import '../services/genre_filter_service.dart';
import '../models/spotify_track.dart';
import '../widgets/user_activity_status.dart';
class VotingPage extends StatefulWidget {
const VotingPage({super.key});
@ -30,6 +33,33 @@ class _VotingPageState extends State<VotingPage> {
Future<void> _searchSpotify(String query) async {
if (query.isEmpty) return;
// Check if search query is appropriate
if (!GenreFilterService.isSearchQueryAppropriate(query)) {
final suggestions = GenreFilterService.getAlternativeSearchSuggestions(query);
setState(() {
_isLoading = false;
_statusMessage = 'Search term not suitable for the peaceful Lido atmosphere. Try: ${suggestions.join(', ')}';
_searchResults = [];
});
return;
}
final spamService = Provider.of<SpamProtectionService>(context, listen: false);
final userId = spamService.getCurrentUserId();
// Check spam protection for suggestions
if (!spamService.canSuggest(userId)) {
final cooldown = spamService.getSuggestionCooldownRemaining(userId);
final blockMessage = spamService.getBlockMessage(userId);
setState(() {
_isLoading = false;
_statusMessage = blockMessage ?? 'Please wait $cooldown seconds before searching again.';
_searchResults = [];
});
return;
}
setState(() {
_isLoading = true;
_statusMessage = 'Searching for "$query"...';
@ -39,12 +69,24 @@ class _VotingPageState extends State<VotingPage> {
final queueService = Provider.of<MusicQueueService>(context, listen: false);
final results = await queueService.searchTracks(query);
// Filter results based on genre appropriateness
final filteredResults = GenreFilterService.filterSearchResults(results);
// Record the suggestion attempt
spamService.recordSuggestion(userId);
setState(() {
_searchResults = results;
_searchResults = filteredResults;
_isLoading = false;
_statusMessage = results.isEmpty
? 'No tracks found for "$query"'
: 'Found ${results.length} tracks';
if (filteredResults.isEmpty && results.isNotEmpty) {
_statusMessage = 'No tracks found that match the peaceful Lido atmosphere. Try searching for chill, ambient, or relaxing music.';
} else if (filteredResults.isEmpty) {
_statusMessage = 'No tracks found for "$query"';
} else {
final filtered = results.length - filteredResults.length;
_statusMessage = 'Found ${filteredResults.length} tracks' +
(filtered > 0 ? ' ($filtered filtered for atmosphere)' : '');
}
});
} catch (e) {
setState(() {
@ -56,9 +98,43 @@ class _VotingPageState extends State<VotingPage> {
}
void _addToQueue(SpotifyTrack track) {
final spamService = Provider.of<SpamProtectionService>(context, listen: false);
final userId = spamService.getCurrentUserId();
// Check if user can suggest (add to queue)
if (!spamService.canSuggest(userId)) {
final cooldown = spamService.getSuggestionCooldownRemaining(userId);
final blockMessage = spamService.getBlockMessage(userId);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(blockMessage ?? 'Please wait $cooldown seconds before adding another song.'),
duration: const Duration(seconds: 3),
backgroundColor: Colors.orange,
),
);
return;
}
// Check if track is appropriate for atmosphere
final rejectionReason = GenreFilterService.getRejectionReason(track);
if (rejectionReason != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(rejectionReason),
duration: const Duration(seconds: 4),
backgroundColor: Colors.orange,
),
);
return;
}
final queueService = Provider.of<MusicQueueService>(context, listen: false);
queueService.addToQueue(track);
// Record the suggestion
spamService.recordSuggestion(userId);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Added "${track.name}" to queue'),
@ -77,6 +153,13 @@ class _VotingPageState extends State<VotingPage> {
body: SafeArea(
child: Column(
children: [
// User Activity Status
Consumer<SpamProtectionService>(
builder: (context, spamService, child) {
return UserActivityStatus(spamService: spamService);
},
),
// Header with Search
Container(
padding: const EdgeInsets.all(20),
@ -149,6 +232,81 @@ class _VotingPageState extends State<VotingPage> {
onSubmitted: _searchSpotify,
),
// Atmosphere Info
Container(
margin: const EdgeInsets.only(top: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF6366F1).withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF6366F1).withOpacity(0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.spa, color: Color(0xFF6366F1), size: 20),
SizedBox(width: 8),
Text(
'Lido Atmosphere',
style: TextStyle(
color: Color(0xFF6366F1),
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
],
),
const SizedBox(height: 8),
Text(
GenreFilterService.getAtmosphereDescription(),
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
),
),
const SizedBox(height: 12),
const Text(
'Try these searches:',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
fontSize: 12,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 4,
children: GenreFilterService.getSuggestedSearchTerms()
.take(8)
.map((term) => InkWell(
onTap: () {
_searchController.text = term;
_searchSpotify(term);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF6366F1).withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: Text(
term,
style: const TextStyle(
color: Color(0xFF6366F1),
fontSize: 11,
),
),
),
))
.toList(),
),
],
),
),
// Status Message
if (_statusMessage.isNotEmpty)
Padding(
@ -458,13 +616,25 @@ class _VotingPageState extends State<VotingPage> {
// Voting Buttons
Column(
children: [
IconButton(
onPressed: () => queueService.upvote(index),
icon: const Icon(
Icons.keyboard_arrow_up,
color: Colors.green,
size: 28,
),
Consumer<SpamProtectionService>(
builder: (context, spamService, child) {
final userId = spamService.getCurrentUserId();
final canVote = spamService.canVote(userId);
final cooldown = spamService.getVoteCooldownRemaining(userId);
return IconButton(
onPressed: canVote ? () {
queueService.upvote(index);
spamService.recordVote(userId);
} : null,
icon: Icon(
Icons.keyboard_arrow_up,
color: canVote ? Colors.green : Colors.grey,
size: 28,
),
tooltip: canVote ? 'Upvote' : 'Wait $cooldown seconds',
);
}
),
Text(
'${queueItem.votes}',
@ -473,13 +643,25 @@ class _VotingPageState extends State<VotingPage> {
fontWeight: FontWeight.bold,
),
),
IconButton(
onPressed: () => queueService.downvote(index),
icon: const Icon(
Icons.keyboard_arrow_down,
color: Colors.red,
size: 28,
),
Consumer<SpamProtectionService>(
builder: (context, spamService, child) {
final userId = spamService.getCurrentUserId();
final canVote = spamService.canVote(userId);
final cooldown = spamService.getVoteCooldownRemaining(userId);
return IconButton(
onPressed: canVote ? () {
queueService.downvote(index);
spamService.recordVote(userId);
} : null,
icon: Icon(
Icons.keyboard_arrow_down,
color: canVote ? Colors.red : Colors.grey,
size: 28,
),
tooltip: canVote ? 'Downvote' : 'Wait $cooldown seconds',
);
}
),
],
),

View file

@ -0,0 +1,242 @@
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/foundation.dart';
import '../models/spotify_track.dart';
class AudioService extends ChangeNotifier {
static final AudioService _instance = AudioService._internal();
factory AudioService() => _instance;
AudioService._internal() {
_initializePlayer();
}
final AudioPlayer _audioPlayer = AudioPlayer();
// Current track state
SpotifyTrack? _currentTrack;
bool _isPlaying = false;
bool _isLoading = false;
Duration _currentPosition = Duration.zero;
Duration _totalDuration = Duration.zero;
// Getters
SpotifyTrack? get currentTrack => _currentTrack;
bool get isPlaying => _isPlaying;
bool get isLoading => _isLoading;
Duration get currentPosition => _currentPosition;
Duration get totalDuration => _totalDuration;
double get progress => _totalDuration.inMilliseconds > 0
? _currentPosition.inMilliseconds / _totalDuration.inMilliseconds
: 0.0;
// Free audio sources for demo purposes
// Using royalty-free music from reliable sources
final Map<String, String> _demoAudioUrls = {
// Peaceful, lido-appropriate tracks
'pop1': 'https://www.bensound.com/bensound-music/bensound-relaxing.mp3',
'pop2': 'https://www.bensound.com/bensound-music/bensound-sunny.mp3',
'pop3': 'https://www.bensound.com/bensound-music/bensound-jazzcomedy.mp3',
'pop4': 'https://www.bensound.com/bensound-music/bensound-acousticbreeze.mp3',
'1': 'https://www.bensound.com/bensound-music/bensound-creativeminds.mp3',
'2': 'https://www.bensound.com/bensound-music/bensound-happyrock.mp3',
'3': 'https://www.bensound.com/bensound-music/bensound-ukulele.mp3',
'4': 'https://www.bensound.com/bensound-music/bensound-summer.mp3',
'5': 'https://www.bensound.com/bensound-music/bensound-happiness.mp3',
};
void _initializePlayer() {
// Listen to player state changes
_audioPlayer.onPlayerStateChanged.listen((PlayerState state) {
_isPlaying = state == PlayerState.playing;
_isLoading = state == PlayerState.stopped && _currentTrack != null;
notifyListeners();
});
// Listen to position changes
_audioPlayer.onPositionChanged.listen((Duration position) {
_currentPosition = position;
notifyListeners();
});
// Listen to duration changes
_audioPlayer.onDurationChanged.listen((Duration duration) {
_totalDuration = duration;
notifyListeners();
});
// Listen for track completion
_audioPlayer.onPlayerComplete.listen((_) {
_onTrackComplete();
});
}
Future<void> playTrack(SpotifyTrack track) async {
try {
_isLoading = true;
_currentTrack = track;
notifyListeners();
// Check if we have a demo URL for this track
String? audioUrl = _demoAudioUrls[track.id];
if (audioUrl != null) {
// Play the demo audio
await _audioPlayer.play(UrlSource(audioUrl));
print('Playing audio for: ${track.name} by ${track.artistNames}');
} else {
// For tracks without demo URLs, simulate playback
print('Simulating playback for: ${track.name} by ${track.artistNames}');
_simulateTrackPlayback(track);
}
_isLoading = false;
notifyListeners();
} catch (e) {
print('Error playing track: $e');
_isLoading = false;
// Fallback to simulation
_simulateTrackPlayback(track);
notifyListeners();
}
}
void _simulateTrackPlayback(SpotifyTrack track) {
// Set simulated duration
_totalDuration = Duration(milliseconds: track.durationMs);
_currentPosition = Duration.zero;
_isPlaying = true;
// Simulate playback progress
_startSimulatedProgress();
}
void _startSimulatedProgress() {
if (_isPlaying && _currentTrack != null) {
Future.delayed(const Duration(seconds: 1), () {
if (_isPlaying && _currentTrack != null) {
_currentPosition = _currentPosition + const Duration(seconds: 1);
if (_currentPosition >= _totalDuration) {
_onTrackComplete();
} else {
notifyListeners();
_startSimulatedProgress();
}
}
});
}
}
Future<void> togglePlayPause() async {
try {
if (_isPlaying) {
await _audioPlayer.pause();
} else {
if (_currentTrack != null) {
// Check if we have a real audio URL
String? audioUrl = _demoAudioUrls[_currentTrack!.id];
if (audioUrl != null) {
await _audioPlayer.resume();
} else {
// Resume simulation
_isPlaying = true;
_startSimulatedProgress();
}
}
}
notifyListeners();
} catch (e) {
print('Error toggling play/pause: $e');
// Fallback to simulation toggle
_isPlaying = !_isPlaying;
if (_isPlaying) {
_startSimulatedProgress();
}
notifyListeners();
}
}
Future<void> stop() async {
try {
await _audioPlayer.stop();
} catch (e) {
print('Error stopping audio: $e');
}
_isPlaying = false;
_currentPosition = Duration.zero;
_currentTrack = null;
notifyListeners();
}
Future<void> seekTo(Duration position) async {
try {
// Check if we have a real audio URL
if (_currentTrack != null && _demoAudioUrls.containsKey(_currentTrack!.id)) {
await _audioPlayer.seek(position);
} else {
// Simulate seeking
_currentPosition = position;
notifyListeners();
}
} catch (e) {
print('Error seeking: $e');
// Fallback to simulation
_currentPosition = position;
notifyListeners();
}
}
void _onTrackComplete() {
_isPlaying = false;
_currentPosition = Duration.zero;
notifyListeners();
// Notify that track is complete (for queue management)
onTrackComplete?.call();
}
// Callback for when a track completes
Function()? onTrackComplete;
@override
void dispose() {
_audioPlayer.dispose();
super.dispose();
}
// Get formatted time strings
String get currentPositionString => _formatDuration(_currentPosition);
String get totalDurationString => _formatDuration(_totalDuration);
String _formatDuration(Duration duration) {
String twoDigits(int n) => n.toString().padLeft(2, '0');
String twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60));
String twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60));
return '$twoDigitMinutes:$twoDigitSeconds';
}
// Add better demo audio URLs (using royalty-free sources)
void addDemoAudioUrl(String trackId, String audioUrl) {
_demoAudioUrls[trackId] = audioUrl;
}
// Add local asset support
Future<void> playAsset(SpotifyTrack track, String assetPath) async {
try {
_isLoading = true;
_currentTrack = track;
notifyListeners();
await _audioPlayer.play(AssetSource(assetPath));
print('Playing asset: $assetPath for ${track.name}');
_isLoading = false;
notifyListeners();
} catch (e) {
print('Error playing asset: $e');
_isLoading = false;
_simulateTrackPlayback(track);
notifyListeners();
}
}
}

View file

@ -0,0 +1,288 @@
import 'package:flutter/foundation.dart';
import '../models/spotify_track.dart';
class GenreFilterService {
// Allowed genres for the Lido atmosphere (chill, ambient, relaxing)
static const List<String> allowedGenres = [
// Chill and Ambient
'chill',
'chillout',
'ambient',
'new age',
'meditation',
'nature sounds',
'spa',
'yoga',
// Smooth genres
'smooth jazz',
'neo soul',
'downtempo',
'trip hop',
'lo-fi',
'lo-fi hip hop',
'chillwave',
'synthwave',
// Acoustic and Folk
'acoustic',
'folk',
'indie folk',
'singer-songwriter',
'soft rock',
'alternative',
'indie',
// World and Cultural
'world music',
'bossa nova',
'latin',
'reggae',
'dub',
'tropical',
'caribbean',
// Electronic (chill variants)
'house',
'deep house',
'minimal techno',
'ambient techno',
'electronica',
'minimal',
// Classical and Instrumental
'classical',
'instrumental',
'piano',
'string quartet',
'chamber music',
'contemporary classical',
];
// Explicitly blocked genres (too energetic/aggressive for Lido)
static const List<String> blockedGenres = [
'metal',
'death metal',
'black metal',
'hardcore',
'punk',
'hardcore punk',
'grindcore',
'screamo',
'dubstep',
'drum and bass',
'breakcore',
'speedcore',
'gabber',
'hardstyle',
'hard trance',
'psytrance',
'hard rock',
'thrash',
'noise',
'industrial',
'aggressive',
'rap',
'hip hop',
'trap',
'drill',
'grime',
'gangsta rap',
];
// Keywords that suggest inappropriate content
static const List<String> blockedKeywords = [
'explicit',
'party',
'club',
'rave',
'aggressive',
'angry',
'violent',
'loud',
'hardcore',
'extreme',
'intense',
'heavy',
'wild',
'crazy',
'insane',
'brutal',
'savage',
'beast',
'fire',
'lit',
'banger',
'drop',
'bass drop',
'festival',
'mosh',
'headbang',
];
// Check if a track is appropriate for the Lido atmosphere
static bool isTrackAllowed(SpotifyTrack track) {
final trackName = track.name.toLowerCase();
final artistNames = track.artists.map((a) => a.name.toLowerCase()).join(' ');
final albumName = track.album.name.toLowerCase();
// Check for blocked keywords in track, artist, or album names
for (final keyword in blockedKeywords) {
if (trackName.contains(keyword) ||
artistNames.contains(keyword) ||
albumName.contains(keyword)) {
if (kDebugMode) {
print('Track blocked due to keyword: $keyword');
}
return false;
}
}
// For now, we'll allow tracks unless they contain blocked keywords
// In a real implementation, you'd check against Spotify's genre data
return true;
}
// Check if a genre is allowed
static bool isGenreAllowed(String genre) {
final lowerGenre = genre.toLowerCase();
// Check if explicitly blocked
if (blockedGenres.contains(lowerGenre)) {
return false;
}
// Check if explicitly allowed
if (allowedGenres.contains(lowerGenre)) {
return true;
}
// Check for partial matches in allowed genres
for (final allowedGenre in allowedGenres) {
if (lowerGenre.contains(allowedGenre) || allowedGenre.contains(lowerGenre)) {
return true;
}
}
// Check for partial matches in blocked genres
for (final blockedGenre in blockedGenres) {
if (lowerGenre.contains(blockedGenre) || blockedGenre.contains(lowerGenre)) {
return false;
}
}
// Default to allowed if not explicitly blocked
return true;
}
// Get suggested search terms for the atmosphere
static List<String> getSuggestedSearchTerms() {
return [
'chill',
'ambient',
'acoustic',
'coffee shop',
'study music',
'relaxing',
'peaceful',
'smooth',
'sunset',
'ocean',
'nature',
'meditation',
'spa music',
'lo-fi',
'bossa nova',
'jazz',
'instrumental',
'piano',
'guitar',
'folk',
'indie',
'world music',
'downtempo',
'chillout',
'lounge',
'soft rock',
];
}
// Get genre description for users
static String getAtmosphereDescription() {
return 'To maintain the peaceful Lido atmosphere, we feature chill, ambient, and relaxing music. Think coffee shop vibes, sunset sounds, and music that enhances tranquility.';
}
// Filter search results based on allowed genres
static List<SpotifyTrack> filterSearchResults(List<SpotifyTrack> tracks) {
return tracks.where((track) => isTrackAllowed(track)).toList();
}
// Get reason why a track might be rejected
static String? getRejectionReason(SpotifyTrack track) {
final trackName = track.name.toLowerCase();
final artistNames = track.artists.map((a) => a.name.toLowerCase()).join(' ');
final albumName = track.album.name.toLowerCase();
// Check for blocked keywords
for (final keyword in blockedKeywords) {
if (trackName.contains(keyword) ||
artistNames.contains(keyword) ||
albumName.contains(keyword)) {
return 'This track contains content that might disturb the peaceful Lido atmosphere. Try searching for more chill or ambient music.';
}
}
return null;
}
// Check if search query suggests inappropriate content
static bool isSearchQueryAppropriate(String query) {
final lowerQuery = query.toLowerCase();
for (final keyword in blockedKeywords) {
if (lowerQuery.contains(keyword)) {
return false;
}
}
for (final genre in blockedGenres) {
if (lowerQuery.contains(genre)) {
return false;
}
}
return true;
}
// Get alternative search suggestions for inappropriate queries
static List<String> getAlternativeSearchSuggestions(String inappropriateQuery) {
// Map inappropriate terms to chill alternatives
final alternatives = {
'party': ['chill', 'lounge', 'relaxing'],
'club': ['ambient', 'downtempo', 'smooth'],
'rave': ['meditation', 'spa music', 'nature sounds'],
'metal': ['acoustic', 'folk', 'classical'],
'punk': ['indie', 'alternative', 'soft rock'],
'hardcore': ['peaceful', 'calming', 'serene'],
'aggressive': ['gentle', 'soothing', 'mellow'],
'loud': ['quiet', 'soft', 'whisper'],
'heavy': ['light', 'airy', 'floating'],
'intense': ['relaxed', 'easy', 'laid-back'],
};
final suggestions = <String>[];
final lowerQuery = inappropriateQuery.toLowerCase();
for (final entry in alternatives.entries) {
if (lowerQuery.contains(entry.key)) {
suggestions.addAll(entry.value);
}
}
if (suggestions.isEmpty) {
suggestions.addAll(['chill', 'ambient', 'relaxing', 'peaceful']);
}
return suggestions.take(3).toList();
}
}

View file

@ -1,6 +1,7 @@
import 'package:flutter/foundation.dart';
import '../models/spotify_track.dart';
import '../services/spotify_service.dart';
import '../services/audio_service.dart';
class QueueItem {
final SpotifyTrack track;
@ -33,11 +34,10 @@ class MusicQueueService extends ChangeNotifier {
MusicQueueService._internal();
final SpotifyService _spotifyService = SpotifyService();
final AudioService _audioService = AudioService();
// Current playing track
SpotifyTrack? _currentTrack;
bool _isPlaying = false;
double _progress = 0.0;
// Queue management
final List<QueueItem> _queue = [];
@ -46,9 +46,9 @@ class MusicQueueService extends ChangeNotifier {
final List<SpotifyTrack> _recentlyPlayed = [];
// Getters
SpotifyTrack? get currentTrack => _currentTrack;
bool get isPlaying => _isPlaying;
double get progress => _progress;
SpotifyTrack? get currentTrack => _audioService.currentTrack ?? _currentTrack;
bool get isPlaying => _audioService.isPlaying;
double get progress => _audioService.progress;
List<QueueItem> get queue => List.unmodifiable(_queue);
List<SpotifyTrack> get recentlyPlayed => List.unmodifiable(_recentlyPlayed);
@ -102,13 +102,14 @@ class MusicQueueService extends ChangeNotifier {
});
}
// Playback simulation
void playNext() {
// Playback control
Future<void> playNext() async {
if (_queue.isNotEmpty) {
final nextItem = _queue.removeAt(0);
_currentTrack = nextItem.track;
_isPlaying = true;
_progress = 0.0;
// Use audio service to actually play the track
await _audioService.playTrack(nextItem.track);
// Add to recently played
_recentlyPlayed.insert(0, nextItem.track);
@ -118,37 +119,25 @@ class MusicQueueService extends ChangeNotifier {
notifyListeners();
print('Now playing: ${_currentTrack!.name} by ${_currentTrack!.artistNames}');
// Simulate track progress
_simulatePlayback();
}
}
void togglePlayPause() {
_isPlaying = !_isPlaying;
Future<void> togglePlayPause() async {
await _audioService.togglePlayPause();
notifyListeners();
}
void skipTrack() {
playNext();
Future<void> skipTrack() async {
await playNext();
}
void _simulatePlayback() {
if (_currentTrack == null) return;
// Simulate track progress over time
Future.delayed(const Duration(seconds: 1), () {
if (_isPlaying && _currentTrack != null) {
_progress += 1.0 / (_currentTrack!.durationMs / 1000);
if (_progress >= 1.0) {
// Track finished, play next
playNext();
} else {
notifyListeners();
_simulatePlayback();
}
}
});
Future<void> seekTo(double position) async {
if (_audioService.totalDuration != Duration.zero) {
final seekPosition = Duration(
milliseconds: (position * _audioService.totalDuration.inMilliseconds).round(),
);
await _audioService.seekTo(seekPosition);
}
}
// Initialize with some popular tracks
@ -165,9 +154,14 @@ class MusicQueueService extends ChangeNotifier {
_queue.add(queueItem);
}
// Set up audio service callback for track completion
_audioService.onTrackComplete = () {
playNext();
};
// Start playing the first track
if (_queue.isNotEmpty) {
playNext();
await playNext();
}
notifyListeners();
@ -189,16 +183,18 @@ class MusicQueueService extends ChangeNotifier {
// Get current track info for display
Map<String, dynamic>? get currentTrackInfo {
if (_currentTrack == null) return null;
final track = currentTrack;
if (track == null) return null;
return {
'title': _currentTrack!.name,
'artist': _currentTrack!.artistNames,
'album': _currentTrack!.album.name,
'imageUrl': _currentTrack!.imageUrl,
'duration': _currentTrack!.duration,
'progress': _progress,
'isPlaying': _isPlaying,
'title': track.name,
'artist': track.artistNames,
'album': track.album.name,
'imageUrl': track.imageUrl,
'duration': _audioService.totalDurationString,
'currentTime': _audioService.currentPositionString,
'progress': progress,
'isPlaying': isPlaying,
};
}
}

View file

@ -0,0 +1,243 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
class SpamProtectionService extends ChangeNotifier {
// Vote limits per user
static const int maxVotesPerHour = 20;
static const int maxVotesPerMinute = 5;
static const int maxSuggestionsPerHour = 10;
static const int maxSuggestionsPerMinute = 3;
// Cooldown periods (in seconds)
static const int voteCooldown = 2;
static const int suggestionCooldown = 10;
// User activity tracking
final Map<String, List<DateTime>> _userVotes = {};
final Map<String, List<DateTime>> _userSuggestions = {};
final Map<String, DateTime> _lastVoteTime = {};
final Map<String, DateTime> _lastSuggestionTime = {};
final Map<String, int> _consecutiveActions = {};
// Blocked users (temporary)
final Map<String, DateTime> _blockedUsers = {};
String getCurrentUserId() {
// In a real app, this would come from authentication
return 'current_user_${DateTime.now().millisecondsSinceEpoch ~/ 1000000}';
}
// Check if user can vote
bool canVote(String userId) {
// Check if user is temporarily blocked
if (_isUserBlocked(userId)) {
return false;
}
// Check cooldown
if (_isOnCooldown(userId, _lastVoteTime, voteCooldown)) {
return false;
}
// Check rate limits
if (!_checkRateLimit(userId, _userVotes, maxVotesPerMinute, maxVotesPerHour)) {
return false;
}
return true;
}
// Check if user can suggest songs
bool canSuggest(String userId) {
// Check if user is temporarily blocked
if (_isUserBlocked(userId)) {
return false;
}
// Check cooldown
if (_isOnCooldown(userId, _lastSuggestionTime, suggestionCooldown)) {
return false;
}
// Check rate limits
if (!_checkRateLimit(userId, _userSuggestions, maxSuggestionsPerMinute, maxSuggestionsPerHour)) {
return false;
}
return true;
}
// Record a vote action
void recordVote(String userId) {
final now = DateTime.now();
// Add to vote history
_userVotes.putIfAbsent(userId, () => []).add(now);
_lastVoteTime[userId] = now;
// Track consecutive actions for spam detection
_incrementConsecutiveActions(userId);
// Clean old entries
_cleanOldEntries(_userVotes[userId]!);
// Check for suspicious behavior
_checkForSpam(userId);
notifyListeners();
}
// Record a suggestion action
void recordSuggestion(String userId) {
final now = DateTime.now();
// Add to suggestion history
_userSuggestions.putIfAbsent(userId, () => []).add(now);
_lastSuggestionTime[userId] = now;
// Track consecutive actions for spam detection
_incrementConsecutiveActions(userId);
// Clean old entries
_cleanOldEntries(_userSuggestions[userId]!);
// Check for suspicious behavior
_checkForSpam(userId);
notifyListeners();
}
// Get remaining cooldown time in seconds
int getVoteCooldownRemaining(String userId) {
final lastVote = _lastVoteTime[userId];
if (lastVote == null) return 0;
final elapsed = DateTime.now().difference(lastVote).inSeconds;
return (voteCooldown - elapsed).clamp(0, voteCooldown);
}
int getSuggestionCooldownRemaining(String userId) {
final lastSuggestion = _lastSuggestionTime[userId];
if (lastSuggestion == null) return 0;
final elapsed = DateTime.now().difference(lastSuggestion).inSeconds;
return (suggestionCooldown - elapsed).clamp(0, suggestionCooldown);
}
// Get user activity stats
Map<String, int> getUserStats(String userId) {
final now = DateTime.now();
final hourAgo = now.subtract(const Duration(hours: 1));
final minuteAgo = now.subtract(const Duration(minutes: 1));
final votesThisHour = _userVotes[userId]?.where((time) => time.isAfter(hourAgo)).length ?? 0;
final votesThisMinute = _userVotes[userId]?.where((time) => time.isAfter(minuteAgo)).length ?? 0;
final suggestionsThisHour = _userSuggestions[userId]?.where((time) => time.isAfter(hourAgo)).length ?? 0;
final suggestionsThisMinute = _userSuggestions[userId]?.where((time) => time.isAfter(minuteAgo)).length ?? 0;
return {
'votesThisHour': votesThisHour,
'votesThisMinute': votesThisMinute,
'suggestionsThisHour': suggestionsThisHour,
'suggestionsThisMinute': suggestionsThisMinute,
'maxVotesPerHour': maxVotesPerHour,
'maxVotesPerMinute': maxVotesPerMinute,
'maxSuggestionsPerHour': maxSuggestionsPerHour,
'maxSuggestionsPerMinute': maxSuggestionsPerMinute,
};
}
// Check if user is blocked
bool _isUserBlocked(String userId) {
final blockTime = _blockedUsers[userId];
if (blockTime == null) return false;
// Unblock after 5 minutes
if (DateTime.now().difference(blockTime).inMinutes >= 5) {
_blockedUsers.remove(userId);
return false;
}
return true;
}
// Check cooldown
bool _isOnCooldown(String userId, Map<String, DateTime> lastActionTime, int cooldownSeconds) {
final lastAction = lastActionTime[userId];
if (lastAction == null) return false;
return DateTime.now().difference(lastAction).inSeconds < cooldownSeconds;
}
// Check rate limits
bool _checkRateLimit(String userId, Map<String, List<DateTime>> userActions, int maxPerMinute, int maxPerHour) {
final actions = userActions[userId] ?? [];
final now = DateTime.now();
// Count actions in the last minute
final actionsLastMinute = actions.where((time) =>
now.difference(time).inMinutes < 1).length;
if (actionsLastMinute >= maxPerMinute) return false;
// Count actions in the last hour
final actionsLastHour = actions.where((time) =>
now.difference(time).inHours < 1).length;
if (actionsLastHour >= maxPerHour) return false;
return true;
}
// Clean old entries (older than 1 hour)
void _cleanOldEntries(List<DateTime> entries) {
final oneHourAgo = DateTime.now().subtract(const Duration(hours: 1));
entries.removeWhere((time) => time.isBefore(oneHourAgo));
}
// Track consecutive actions for spam detection
void _incrementConsecutiveActions(String userId) {
_consecutiveActions[userId] = (_consecutiveActions[userId] ?? 0) + 1;
// Reset after some time of inactivity
Timer(const Duration(seconds: 30), () {
_consecutiveActions[userId] = 0;
});
}
// Check for spam behavior and block if necessary
void _checkForSpam(String userId) {
final consecutive = _consecutiveActions[userId] ?? 0;
// Block user if too many consecutive actions
if (consecutive > 15) {
_blockedUsers[userId] = DateTime.now();
if (kDebugMode) {
print('User $userId temporarily blocked for spam behavior');
}
}
}
// Get block status message
String? getBlockMessage(String userId) {
final blockTime = _blockedUsers[userId];
if (blockTime == null) return null;
final remaining = 5 - DateTime.now().difference(blockTime).inMinutes;
if (remaining <= 0) {
_blockedUsers.remove(userId);
return null;
}
return 'You are temporarily blocked for $remaining more minutes due to excessive activity.';
}
// Clear all data (for testing)
void clearAllData() {
_userVotes.clear();
_userSuggestions.clear();
_lastVoteTime.clear();
_lastSuggestionTime.clear();
_consecutiveActions.clear();
_blockedUsers.clear();
notifyListeners();
}
}

View file

@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import '../services/spam_protection_service.dart';
class UserActivityStatus extends StatelessWidget {
final SpamProtectionService spamService;
const UserActivityStatus({
super.key,
required this.spamService,
});
@override
Widget build(BuildContext context) {
final userId = spamService.getCurrentUserId();
final stats = spamService.getUserStats(userId);
final blockMessage = spamService.getBlockMessage(userId);
if (blockMessage != null) {
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.red.withOpacity(0.3)),
),
child: Row(
children: [
const Icon(Icons.block, color: Colors.red),
const SizedBox(width: 12),
Expanded(
child: Text(
blockMessage,
style: const TextStyle(color: Colors.red, fontSize: 14),
),
),
],
),
);
}
// Show activity limits if user is getting close
final votesUsed = stats['votesThisHour']!;
final suggestionsUsed = stats['suggestionsThisHour']!;
final maxVotes = stats['maxVotesPerHour']!;
final maxSuggestions = stats['maxSuggestionsPerHour']!;
final showWarning = votesUsed > maxVotes * 0.8 || suggestionsUsed > maxSuggestions * 0.8;
if (!showWarning) {
return const SizedBox.shrink();
}
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.orange.withOpacity(0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.warning, color: Colors.orange, size: 16),
SizedBox(width: 8),
Text(
'Activity Limits',
style: TextStyle(
color: Colors.orange,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Votes: $votesUsed/$maxVotes',
style: const TextStyle(color: Colors.white, fontSize: 11),
),
LinearProgressIndicator(
value: votesUsed / maxVotes,
backgroundColor: Colors.grey.withOpacity(0.3),
valueColor: AlwaysStoppedAnimation<Color>(
votesUsed > maxVotes * 0.9 ? Colors.red : Colors.orange,
),
),
],
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Suggestions: $suggestionsUsed/$maxSuggestions',
style: const TextStyle(color: Colors.white, fontSize: 11),
),
LinearProgressIndicator(
value: suggestionsUsed / maxSuggestions,
backgroundColor: Colors.grey.withOpacity(0.3),
valueColor: AlwaysStoppedAnimation<Color>(
suggestionsUsed > maxSuggestions * 0.9 ? Colors.red : Colors.orange,
),
),
],
),
),
],
),
],
),
);
}
}