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,
),
),
],
),
),
],
),
],
),
);
}
}

View file

@ -6,9 +6,13 @@
#include "generated_plugin_registrant.h"
#include <audioplayers_linux/audioplayers_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
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);

View file

@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_linux
url_launcher_linux
)

View file

@ -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"))

View file

@ -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:

View file

@ -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

View file

@ -6,10 +6,13 @@
#include "generated_plugin_registrant.h"
#include <audioplayers_windows/audioplayers_windows_plugin.h>
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
AudioplayersWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin"));
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(

View file

@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_windows
connectivity_plus
url_launcher_windows
)