diff --git a/CHALLENGE_2/sleepysound/lib/main.dart b/CHALLENGE_2/sleepysound/lib/main.dart index 7563a1e..0ba6ad7 100644 --- a/CHALLENGE_2/sleepysound/lib/main.dart +++ b/CHALLENGE_2/sleepysound/lib/main.dart @@ -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 { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - title: Text(_getPageTitle()), - ), body: _getSelectedPage(), bottomNavigationBar: BottomNavigationBar( items: const [ @@ -110,17 +110,4 @@ class _MyHomePageState extends State { ), ); } - - String _getPageTitle() { - switch (_selectedIndex) { - case 0: - return 'Now Playing'; - case 1: - return 'Voting'; - case 2: - return 'Group'; - default: - return 'Now Playing'; - } - } } diff --git a/CHALLENGE_2/sleepysound/lib/pages/voting_page.dart b/CHALLENGE_2/sleepysound/lib/pages/voting_page.dart index 427570a..bbcbcc8 100644 --- a/CHALLENGE_2/sleepysound/lib/pages/voting_page.dart +++ b/CHALLENGE_2/sleepysound/lib/pages/voting_page.dart @@ -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 { Future _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(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 { final queueService = Provider.of(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 { } void _addToQueue(SpotifyTrack track) { + final spamService = Provider.of(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(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 { body: SafeArea( child: Column( children: [ + // User Activity Status + Consumer( + 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 { 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 { // Voting Buttons Column( children: [ - IconButton( - onPressed: () => queueService.upvote(index), - icon: const Icon( - Icons.keyboard_arrow_up, - color: Colors.green, - size: 28, - ), + Consumer( + 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 { fontWeight: FontWeight.bold, ), ), - IconButton( - onPressed: () => queueService.downvote(index), - icon: const Icon( - Icons.keyboard_arrow_down, - color: Colors.red, - size: 28, - ), + Consumer( + 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', + ); + } ), ], ), diff --git a/CHALLENGE_2/sleepysound/lib/services/audio_service.dart b/CHALLENGE_2/sleepysound/lib/services/audio_service.dart new file mode 100644 index 0000000..f8f43e9 --- /dev/null +++ b/CHALLENGE_2/sleepysound/lib/services/audio_service.dart @@ -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 _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 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 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 stop() async { + try { + await _audioPlayer.stop(); + } catch (e) { + print('Error stopping audio: $e'); + } + + _isPlaying = false; + _currentPosition = Duration.zero; + _currentTrack = null; + notifyListeners(); + } + + Future 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 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(); + } + } +} diff --git a/CHALLENGE_2/sleepysound/lib/services/genre_filter_service.dart b/CHALLENGE_2/sleepysound/lib/services/genre_filter_service.dart new file mode 100644 index 0000000..49025fb --- /dev/null +++ b/CHALLENGE_2/sleepysound/lib/services/genre_filter_service.dart @@ -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 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 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 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 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 filterSearchResults(List 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 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 = []; + 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(); + } +} diff --git a/CHALLENGE_2/sleepysound/lib/services/music_queue_service.dart b/CHALLENGE_2/sleepysound/lib/services/music_queue_service.dart index 2fd97ba..adcfc9a 100644 --- a/CHALLENGE_2/sleepysound/lib/services/music_queue_service.dart +++ b/CHALLENGE_2/sleepysound/lib/services/music_queue_service.dart @@ -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 _queue = []; @@ -46,9 +46,9 @@ class MusicQueueService extends ChangeNotifier { final List _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 get queue => List.unmodifiable(_queue); List get recentlyPlayed => List.unmodifiable(_recentlyPlayed); @@ -102,13 +102,14 @@ class MusicQueueService extends ChangeNotifier { }); } - // Playback simulation - void playNext() { + // Playback control + Future 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 togglePlayPause() async { + await _audioService.togglePlayPause(); notifyListeners(); } - void skipTrack() { - playNext(); + Future 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 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? 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, }; } } diff --git a/CHALLENGE_2/sleepysound/lib/services/spam_protection_service.dart b/CHALLENGE_2/sleepysound/lib/services/spam_protection_service.dart new file mode 100644 index 0000000..d6472a8 --- /dev/null +++ b/CHALLENGE_2/sleepysound/lib/services/spam_protection_service.dart @@ -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> _userVotes = {}; + final Map> _userSuggestions = {}; + final Map _lastVoteTime = {}; + final Map _lastSuggestionTime = {}; + final Map _consecutiveActions = {}; + + // Blocked users (temporary) + final Map _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 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 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> 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 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(); + } +} diff --git a/CHALLENGE_2/sleepysound/lib/widgets/user_activity_status.dart b/CHALLENGE_2/sleepysound/lib/widgets/user_activity_status.dart new file mode 100644 index 0000000..834c1d8 --- /dev/null +++ b/CHALLENGE_2/sleepysound/lib/widgets/user_activity_status.dart @@ -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( + 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( + suggestionsUsed > maxSuggestions * 0.9 ? Colors.red : Colors.orange, + ), + ), + ], + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/CHALLENGE_2/sleepysound/linux/flutter/generated_plugin_registrant.cc b/CHALLENGE_2/sleepysound/linux/flutter/generated_plugin_registrant.cc index f6f23bf..cc10c4d 100644 --- a/CHALLENGE_2/sleepysound/linux/flutter/generated_plugin_registrant.cc +++ b/CHALLENGE_2/sleepysound/linux/flutter/generated_plugin_registrant.cc @@ -6,9 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin"); + audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/CHALLENGE_2/sleepysound/linux/flutter/generated_plugins.cmake b/CHALLENGE_2/sleepysound/linux/flutter/generated_plugins.cmake index f16b4c3..8e2a190 100644 --- a/CHALLENGE_2/sleepysound/linux/flutter/generated_plugins.cmake +++ b/CHALLENGE_2/sleepysound/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_linux url_launcher_linux ) diff --git a/CHALLENGE_2/sleepysound/macos/Flutter/GeneratedPluginRegistrant.swift b/CHALLENGE_2/sleepysound/macos/Flutter/GeneratedPluginRegistrant.swift index 8346026..a320557 100644 --- a/CHALLENGE_2/sleepysound/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/CHALLENGE_2/sleepysound/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,15 +5,19 @@ import FlutterMacOS import Foundation +import audioplayers_darwin import connectivity_plus import network_info_plus +import path_provider_foundation import shared_preferences_foundation import url_launcher_macos import webview_flutter_wkwebview func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) NetworkInfoPlusPlugin.register(with: registry.registrar(forPlugin: "NetworkInfoPlusPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) diff --git a/CHALLENGE_2/sleepysound/pubspec.lock b/CHALLENGE_2/sleepysound/pubspec.lock index 734b970..3cce59a 100644 --- a/CHALLENGE_2/sleepysound/pubspec.lock +++ b/CHALLENGE_2/sleepysound/pubspec.lock @@ -33,6 +33,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.12.0" + audioplayers: + dependency: "direct main" + description: + name: audioplayers + sha256: e653f162ddfcec1da2040ba2d8553fff1662b5c2a5c636f4c21a3b11bee497de + url: "https://pub.dev" + source: hosted + version: "6.5.0" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605" + url: "https://pub.dev" + source: hosted + version: "5.2.1" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: "0811d6924904ca13f9ef90d19081e4a87f7297ddc19fc3d31f60af1aaafee333" + url: "https://pub.dev" + source: hosted + version: "6.3.0" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001 + url: "https://pub.dev" + source: hosted + version: "4.2.1" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656" + url: "https://pub.dev" + source: hosted + version: "7.1.1" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: "1c0f17cec68455556775f1e50ca85c40c05c714a99c5eb1d2d57cc17ba5522d7" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: "4048797865105b26d47628e6abb49231ea5de84884160229251f37dfcbe52fd7" + url: "https://pub.dev" + source: hosted + version: "4.2.1" boolean_selector: dependency: transitive description: @@ -464,6 +520,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 + url: "https://pub.dev" + source: hosted + version: "2.2.17" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + url: "https://pub.dev" + source: hosted + version: "2.4.1" path_provider_linux: dependency: transitive description: @@ -645,6 +725,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.1" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" stack_trace: dependency: transitive description: @@ -677,6 +765,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "0669c70faae6270521ee4f05bffd2919892d42d1276e6c495be80174b6bc0ef6" + url: "https://pub.dev" + source: hosted + version: "3.3.1" term_glyph: dependency: transitive description: @@ -773,6 +869,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.4" + uuid: + dependency: transitive + description: + name: uuid + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + url: "https://pub.dev" + source: hosted + version: "4.5.1" vector_math: dependency: transitive description: diff --git a/CHALLENGE_2/sleepysound/pubspec.yaml b/CHALLENGE_2/sleepysound/pubspec.yaml index e4da814..d56c97c 100644 --- a/CHALLENGE_2/sleepysound/pubspec.yaml +++ b/CHALLENGE_2/sleepysound/pubspec.yaml @@ -59,6 +59,9 @@ dependencies: # Local network discovery multicast_dns: ^0.3.2+4 + + # Audio playback + audioplayers: ^6.0.0 dev_dependencies: flutter_test: @@ -87,7 +90,8 @@ flutter: uses-material-design: true # To add assets to your application, add an assets section, like this: - # assets: + assets: + - assets/audio/ # - images/a_dot_burr.jpeg # - images/a_dot_ham.jpeg diff --git a/CHALLENGE_2/sleepysound/windows/flutter/generated_plugin_registrant.cc b/CHALLENGE_2/sleepysound/windows/flutter/generated_plugin_registrant.cc index 5777988..d836cf4 100644 --- a/CHALLENGE_2/sleepysound/windows/flutter/generated_plugin_registrant.cc +++ b/CHALLENGE_2/sleepysound/windows/flutter/generated_plugin_registrant.cc @@ -6,10 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + AudioplayersWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin")); ConnectivityPlusWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/CHALLENGE_2/sleepysound/windows/flutter/generated_plugins.cmake b/CHALLENGE_2/sleepysound/windows/flutter/generated_plugins.cmake index 3103206..5ac8838 100644 --- a/CHALLENGE_2/sleepysound/windows/flutter/generated_plugins.cmake +++ b/CHALLENGE_2/sleepysound/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_windows connectivity_plus url_launcher_windows )