icon things

This commit is contained in:
Leon Astner 2025-08-02 07:51:04 +02:00
parent a91654df03
commit 8bc45ad6fd
50 changed files with 592 additions and 213 deletions

View file

@ -8,7 +8,7 @@ plugins {
android { android {
namespace = "com.example.sleepysound" namespace = "com.example.sleepysound"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion ndkVersion = "27.0.12077973"
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 5.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 2.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 9.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Before After
Before After

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#121212</color>
</resources>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View file

@ -427,7 +427,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++"; CLANG_CXX_LIBRARY = "libc++";
@ -484,7 +484,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++"; CLANG_CXX_LIBRARY = "libc++";

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 780 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 4.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 3.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 7.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 6.7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 6.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Before After
Before After

View file

@ -26,7 +26,7 @@ class MyApp extends StatelessWidget {
ChangeNotifierProvider(create: (context) => AudioService()), ChangeNotifierProvider(create: (context) => AudioService()),
], ],
child: MaterialApp( child: MaterialApp(
title: 'SleepySound', title: 'LidoSound',
theme: ThemeData( theme: ThemeData(
useMaterial3: true, useMaterial3: true,
brightness: Brightness.dark, brightness: Brightness.dark,

View file

@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../services/network_group_service.dart'; import '../services/network_group_service.dart';
import '../widgets/network_demo_widget.dart';
class GroupPage extends StatefulWidget { class GroupPage extends StatefulWidget {
const GroupPage({super.key}); const GroupPage({super.key});
@ -172,9 +171,6 @@ class _GroupPageState extends State<GroupPage> {
const SizedBox(height: 25), const SizedBox(height: 25),
// Demo Widget
NetworkDemoWidget(networkService: networkService),
// Network Users Section // Network Users Section
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
@ -330,13 +326,34 @@ class _GroupPageState extends State<GroupPage> {
const SizedBox(height: 4), const SizedBox(height: 4),
Row( Row(
children: [ children: [
Text( if (user.isListening && user.currentTrackName != null) ...[
'Joined ${_formatDuration(DateTime.now().difference(user.joinedAt))} ago', const Icon(
style: const TextStyle( Icons.music_note,
color: Colors.grey, color: Color(0xFF6366F1),
fontSize: 12, size: 14,
), ),
), const SizedBox(width: 4),
Expanded(
child: Text(
'Listening to "${user.currentTrackName}" by ${user.currentArtist}',
style: const TextStyle(
color: Color(0xFF6366F1),
fontSize: 12,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
] else ...[
Text(
'Joined ${_formatDuration(DateTime.now().difference(user.joinedAt))} ago',
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
),
),
],
if (!user.isOnline) ...[ if (!user.isOnline) ...[
const Text( const Text(
'', '',
@ -366,20 +383,54 @@ class _GroupPageState extends State<GroupPage> {
], ],
), ),
), ),
Container( Column(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), children: [
decoration: BoxDecoration( if (user.isListening && !isCurrentUser) ...[
color: const Color(0xFF6366F1).withOpacity(0.2), // Join Listening Session Button
borderRadius: BorderRadius.circular(10), IconButton(
), onPressed: () async {
child: Text( final success = await networkService.joinListeningSession(user);
'${user.votes} votes', if (mounted) {
style: const TextStyle( ScaffoldMessenger.of(context).showSnackBar(
color: Color(0xFF6366F1), SnackBar(
fontSize: 11, content: Text(
fontWeight: FontWeight.bold, success
? 'Joined ${user.name}\'s listening session! 🎵'
: 'Failed to join listening session',
),
backgroundColor: success
? const Color(0xFF22C55E)
: const Color(0xFFEF4444),
duration: const Duration(seconds: 3),
),
);
}
},
icon: const Icon(
Icons.headphones,
color: Color(0xFF6366F1),
size: 20,
),
tooltip: 'Join listening session',
),
const SizedBox(height: 4),
],
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF6366F1).withOpacity(0.2),
borderRadius: BorderRadius.circular(10),
),
child: Text(
'${user.votes} votes',
style: const TextStyle(
color: Color(0xFF6366F1),
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
), ),
), ],
), ),
], ],
), ),

View file

@ -15,14 +15,188 @@ class VotingPage extends StatefulWidget {
class _VotingPageState extends State<VotingPage> { class _VotingPageState extends State<VotingPage> {
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final FocusNode _searchFocusNode = FocusNode();
List<SpotifyTrack> _searchResults = []; List<SpotifyTrack> _searchResults = [];
bool _isLoading = false; bool _isLoading = false;
String _statusMessage = ''; String _statusMessage = '';
final LayerLink _layerLink = LayerLink();
OverlayEntry? _overlayEntry;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadInitialQueue(); _loadInitialQueue();
_searchFocusNode.addListener(_onSearchFocusChange);
}
@override
void dispose() {
_hideSearchOverlay();
_searchController.dispose();
_searchFocusNode.dispose();
super.dispose();
}
void _onSearchFocusChange() {
if (_searchFocusNode.hasFocus && _searchResults.isNotEmpty) {
_showSearchOverlay();
} else if (!_searchFocusNode.hasFocus) {
// Delay hiding to allow for taps on results
Future.delayed(const Duration(milliseconds: 150), () {
_hideSearchOverlay();
});
}
}
void _showSearchOverlay() {
if (_overlayEntry != null) return;
_overlayEntry = _createOverlayEntry();
Overlay.of(context).insert(_overlayEntry!);
}
void _hideSearchOverlay() {
_overlayEntry?.remove();
_overlayEntry = null;
}
OverlayEntry _createOverlayEntry() {
RenderBox renderBox = context.findRenderObject() as RenderBox;
var size = renderBox.size;
var offset = renderBox.localToGlobal(Offset.zero);
return OverlayEntry(
builder: (context) => Positioned(
left: offset.dx + 20,
top: offset.dy + 200, // Adjust based on search field position
width: size.width - 40,
child: CompositedTransformFollower(
link: _layerLink,
showWhenUnlinked: false,
child: Material(
elevation: 8,
borderRadius: BorderRadius.circular(12),
color: const Color(0xFF1E1E1E),
child: Container(
constraints: const BoxConstraints(maxHeight: 300),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF6366F1).withOpacity(0.3)),
),
child: _buildSearchResultsOverlay(),
),
),
),
),
);
}
Widget _buildSearchResultsOverlay() {
if (_searchResults.isEmpty) {
return Container(
padding: const EdgeInsets.all(20),
child: const Text(
'No results found',
style: TextStyle(color: Colors.grey),
textAlign: TextAlign.center,
),
);
}
return ListView.builder(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: _searchResults.length,
itemBuilder: (context, index) {
final track = _searchResults[index];
return _buildSearchResultItem(track, index);
},
);
}
Widget _buildSearchResultItem(SpotifyTrack track, int index) {
return InkWell(
onTap: () {
_addToQueue(track);
_hideSearchOverlay();
_searchController.clear();
_searchFocusNode.unfocus();
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
// Album Art
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
color: const Color(0xFF2A2A2A),
),
child: track.album.images.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.network(
track.album.images.first.url,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return const Icon(
Icons.music_note,
color: Colors.grey,
size: 16,
);
},
),
)
: const Icon(
Icons.music_note,
color: Colors.grey,
size: 16,
),
),
const SizedBox(width: 12),
// Track Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
track.name,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
track.artists.map((a) => a.name).join(', '),
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
// Add Icon
const Icon(
Icons.add_circle_outline,
color: Color(0xFF6366F1),
size: 20,
),
],
),
),
);
} }
Future<void> _loadInitialQueue() async { Future<void> _loadInitialQueue() async {
@ -31,7 +205,14 @@ class _VotingPageState extends State<VotingPage> {
} }
Future<void> _searchSpotify(String query) async { Future<void> _searchSpotify(String query) async {
if (query.isEmpty) return; if (query.isEmpty) {
setState(() {
_searchResults = [];
_statusMessage = '';
});
_hideSearchOverlay();
return;
}
// Check if search query is appropriate // Check if search query is appropriate
if (!GenreFilterService.isSearchQueryAppropriate(query)) { if (!GenreFilterService.isSearchQueryAppropriate(query)) {
@ -41,6 +222,7 @@ class _VotingPageState extends State<VotingPage> {
_statusMessage = 'Search term not suitable for the peaceful Lido atmosphere. Try: ${suggestions.join(', ')}'; _statusMessage = 'Search term not suitable for the peaceful Lido atmosphere. Try: ${suggestions.join(', ')}';
_searchResults = []; _searchResults = [];
}); });
_hideSearchOverlay();
return; return;
} }
@ -57,6 +239,7 @@ class _VotingPageState extends State<VotingPage> {
_statusMessage = blockMessage ?? 'Please wait $cooldown seconds before searching again.'; _statusMessage = blockMessage ?? 'Please wait $cooldown seconds before searching again.';
_searchResults = []; _searchResults = [];
}); });
_hideSearchOverlay();
return; return;
} }
@ -69,31 +252,35 @@ class _VotingPageState extends State<VotingPage> {
final queueService = Provider.of<MusicQueueService>(context, listen: false); final queueService = Provider.of<MusicQueueService>(context, listen: false);
final results = await queueService.searchTracks(query); final results = await queueService.searchTracks(query);
// Filter results based on genre appropriateness // No filtering on search results - let users see all tracks
final filteredResults = GenreFilterService.filterSearchResults(results); // Filtering only happens when adding to queue to maintain atmosphere
// Record the suggestion attempt // Record the suggestion attempt
spamService.recordSuggestion(userId); spamService.recordSuggestion(userId);
setState(() { setState(() {
_searchResults = filteredResults; _searchResults = results;
_isLoading = false; _isLoading = false;
if (filteredResults.isEmpty && results.isNotEmpty) { if (results.isEmpty) {
_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"'; _statusMessage = 'No tracks found for "$query"';
} else { } else {
final filtered = results.length - filteredResults.length; _statusMessage = 'Found ${results.length} tracks';
_statusMessage = 'Found ${filteredResults.length} tracks' +
(filtered > 0 ? ' ($filtered filtered for atmosphere)' : '');
} }
}); });
// Show overlay if we have results and search field is focused
if (results.isNotEmpty && _searchFocusNode.hasFocus) {
_showSearchOverlay();
} else {
_hideSearchOverlay();
}
} catch (e) { } catch (e) {
setState(() { setState(() {
_isLoading = false; _isLoading = false;
_statusMessage = 'Search failed: ${e.toString()}'; _statusMessage = 'Search failed: ${e.toString()}';
_searchResults = []; _searchResults = [];
}); });
_hideSearchOverlay();
} }
} }
@ -202,34 +389,74 @@ class _VotingPageState extends State<VotingPage> {
const SizedBox(height: 20), const SizedBox(height: 20),
// Search Bar // Search Bar
TextField( CompositedTransformTarget(
controller: _searchController, link: _layerLink,
style: const TextStyle(color: Colors.white), child: TextField(
decoration: InputDecoration( controller: _searchController,
hintText: 'Search for songs, artists, albums...', focusNode: _searchFocusNode,
hintStyle: const TextStyle(color: Colors.grey), style: const TextStyle(color: Colors.white),
prefixIcon: const Icon(Icons.search, color: Colors.grey), decoration: InputDecoration(
suffixIcon: _isLoading hintText: 'Search for songs, artists, albums...',
? const Padding( hintStyle: const TextStyle(color: Colors.grey),
padding: EdgeInsets.all(12), prefixIcon: const Icon(Icons.search, color: Colors.grey),
child: SizedBox( suffixIcon: Row(
width: 20, mainAxisSize: MainAxisSize.min,
height: 20, children: [
child: CircularProgressIndicator( if (_isLoading)
strokeWidth: 2, const Padding(
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF6366F1)), padding: EdgeInsets.all(12),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF6366F1)),
),
), ),
), ),
) if (_searchController.text.isNotEmpty)
: null, IconButton(
filled: true, icon: const Icon(Icons.clear, color: Colors.grey),
fillColor: const Color(0xFF1E1E1E), onPressed: () {
border: OutlineInputBorder( _searchController.clear();
borderRadius: BorderRadius.circular(12), _hideSearchOverlay();
borderSide: BorderSide.none, setState(() {
_searchResults = [];
_statusMessage = '';
});
},
),
],
),
filled: true,
fillColor: const Color(0xFF1E1E1E),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFF6366F1), width: 2),
),
), ),
onChanged: (value) {
// Search as user types (with debounce)
if (value.length >= 3) {
Future.delayed(const Duration(milliseconds: 500), () {
if (_searchController.text == value) {
_searchSpotify(value);
}
});
} else if (value.isEmpty) {
setState(() {
_searchResults = [];
_statusMessage = '';
});
_hideSearchOverlay();
}
},
onSubmitted: _searchSpotify,
), ),
onSubmitted: _searchSpotify,
), ),
// Atmosphere Info // Atmosphere Info
@ -323,33 +550,51 @@ class _VotingPageState extends State<VotingPage> {
), ),
), ),
// Search Results and Queue // Queue Section
Expanded( Expanded(
child: DefaultTabController( child: Column(
length: 2, crossAxisAlignment: CrossAxisAlignment.start,
child: Column( children: [
children: [ Padding(
const TabBar( padding: const EdgeInsets.symmetric(horizontal: 20),
labelColor: Color(0xFF6366F1), child: Row(
unselectedLabelColor: Colors.grey, mainAxisAlignment: MainAxisAlignment.spaceBetween,
indicatorColor: Color(0xFF6366F1), children: [
tabs: [ const Text(
Tab(text: 'Search Results'), 'Music Queue',
Tab(text: 'Queue'), style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFF6366F1).withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: const Color(0xFF6366F1),
width: 1,
),
),
child: Text(
'${queueService.queue.length} songs',
style: const TextStyle(
color: Color(0xFF6366F1),
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
], ],
), ),
Expanded( ),
child: TabBarView( const SizedBox(height: 16),
children: [ Expanded(
// Search Results Tab child: _buildQueueView(queueService),
_buildSearchResults(), ),
// Queue Tab ],
_buildQueueView(queueService),
],
),
),
],
),
), ),
), ),
], ],
@ -360,40 +605,6 @@ class _VotingPageState extends State<VotingPage> {
); );
} }
Widget _buildSearchResults() {
if (_searchResults.isEmpty && !_isLoading) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search,
size: 80,
color: Colors.grey,
),
SizedBox(height: 20),
Text(
'Search for songs to add to the queue',
style: TextStyle(
color: Colors.grey,
fontSize: 16,
),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.all(20),
itemCount: _searchResults.length,
itemBuilder: (context, index) {
final track = _searchResults[index];
return _buildTrackCard(track);
},
);
}
Widget _buildQueueView(MusicQueueService queueService) { Widget _buildQueueView(MusicQueueService queueService) {
final queue = queueService.queue; final queue = queueService.queue;
@ -439,97 +650,6 @@ class _VotingPageState extends State<VotingPage> {
); );
} }
Widget _buildTrackCard(SpotifyTrack track) {
return Card(
color: const Color(0xFF1E1E1E),
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
// Album Art
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: const Color(0xFF2A2A2A),
),
child: track.album.images.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
track.album.images.first.url,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return const Icon(
Icons.music_note,
color: Colors.grey,
);
},
),
)
: const Icon(
Icons.music_note,
color: Colors.grey,
),
),
const SizedBox(width: 12),
// Track Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
track.name,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
track.artists.map((a) => a.name).join(', '),
style: const TextStyle(
color: Colors.grey,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
track.album.name,
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
// Add Button
IconButton(
onPressed: () => _addToQueue(track),
icon: const Icon(
Icons.add_circle,
color: Color(0xFF6366F1),
size: 32,
),
),
],
),
),
);
}
Widget _buildQueueItemCard(QueueItem queueItem, int index, MusicQueueService queueService) { Widget _buildQueueItemCard(QueueItem queueItem, int index, MusicQueueService queueService) {
return Card( return Card(
color: const Color(0xFF1E1E1E), color: const Color(0xFF1E1E1E),

View file

@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart';
import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:network_info_plus/network_info_plus.dart'; import 'package:network_info_plus/network_info_plus.dart';
import 'package:multicast_dns/multicast_dns.dart'; import 'package:multicast_dns/multicast_dns.dart';
import 'music_queue_service.dart';
class NetworkUser { class NetworkUser {
final String id; final String id;
@ -15,6 +16,11 @@ class NetworkUser {
final int votes; final int votes;
bool isOnline; bool isOnline;
DateTime lastSeen; DateTime lastSeen;
String? currentTrackId;
String? currentTrackName;
String? currentArtist;
String? currentTrackImage;
bool isListening;
NetworkUser({ NetworkUser({
required this.id, required this.id,
@ -24,6 +30,11 @@ class NetworkUser {
this.votes = 0, this.votes = 0,
this.isOnline = true, this.isOnline = true,
DateTime? lastSeen, DateTime? lastSeen,
this.currentTrackId,
this.currentTrackName,
this.currentArtist,
this.currentTrackImage,
this.isListening = false,
}) : lastSeen = lastSeen ?? DateTime.now(); }) : lastSeen = lastSeen ?? DateTime.now();
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
@ -34,6 +45,11 @@ class NetworkUser {
'votes': votes, 'votes': votes,
'isOnline': isOnline, 'isOnline': isOnline,
'lastSeen': lastSeen.toIso8601String(), 'lastSeen': lastSeen.toIso8601String(),
'currentTrackId': currentTrackId,
'currentTrackName': currentTrackName,
'currentArtist': currentArtist,
'currentTrackImage': currentTrackImage,
'isListening': isListening,
}; };
factory NetworkUser.fromJson(Map<String, dynamic> json) => NetworkUser( factory NetworkUser.fromJson(Map<String, dynamic> json) => NetworkUser(
@ -44,6 +60,11 @@ class NetworkUser {
votes: json['votes'] ?? 0, votes: json['votes'] ?? 0,
isOnline: json['isOnline'] ?? true, isOnline: json['isOnline'] ?? true,
lastSeen: DateTime.parse(json['lastSeen']), lastSeen: DateTime.parse(json['lastSeen']),
currentTrackId: json['currentTrackId'],
currentTrackName: json['currentTrackName'],
currentArtist: json['currentArtist'],
currentTrackImage: json['currentTrackImage'],
isListening: json['isListening'] ?? false,
); );
} }
@ -66,6 +87,7 @@ class NetworkGroupService extends ChangeNotifier {
final Map<String, NetworkUser> _networkUsers = {}; final Map<String, NetworkUser> _networkUsers = {};
late NetworkUser _currentUser; late NetworkUser _currentUser;
MusicQueueService? _musicService;
// Getters // Getters
bool get isConnectedToWifi => _isConnectedToWifi; bool get isConnectedToWifi => _isConnectedToWifi;
@ -79,6 +101,8 @@ class NetworkGroupService extends ChangeNotifier {
NetworkGroupService() { NetworkGroupService() {
_initializeCurrentUser(); _initializeCurrentUser();
_startNetworkMonitoring(); _startNetworkMonitoring();
// Initialize music service reference
_musicService = MusicQueueService();
} }
void _initializeCurrentUser() { void _initializeCurrentUser() {
@ -232,6 +256,8 @@ class NetworkGroupService extends ChangeNotifier {
response.headers.set('Access-Control-Allow-Origin', '*'); response.headers.set('Access-Control-Allow-Origin', '*');
if (request.method == 'GET' && request.uri.path == '/user') { if (request.method == 'GET' && request.uri.path == '/user') {
// Update current user with latest listening info before sending
await _updateCurrentUserListeningInfo();
// Return current user info // Return current user info
response.write(jsonEncode(_currentUser.toJson())); response.write(jsonEncode(_currentUser.toJson()));
} else if (request.method == 'POST' && request.uri.path == '/heartbeat') { } else if (request.method == 'POST' && request.uri.path == '/heartbeat') {
@ -244,6 +270,41 @@ class NetworkGroupService extends ChangeNotifier {
notifyListeners(); notifyListeners();
response.write(jsonEncode({'status': 'ok'})); response.write(jsonEncode({'status': 'ok'}));
} else if (request.method == 'POST' && request.uri.path == '/join-session') {
// Handle request to join this user's listening session
final body = await utf8.decoder.bind(request).join();
// Parse request data for future use (logging, analytics, etc.)
jsonDecode(body);
// Get current track info to send back
final currentTrackInfo = _musicService?.currentTrackInfo;
if (currentTrackInfo != null) {
response.write(jsonEncode({
'status': 'ok',
'trackInfo': currentTrackInfo,
'message': 'Successfully joined listening session'
}));
} else {
response.write(jsonEncode({
'status': 'no_track',
'message': 'No track currently playing'
}));
}
} else if (request.method == 'GET' && request.uri.path == '/current-track') {
// Get current track info without joining
await _updateCurrentUserListeningInfo();
final currentTrackInfo = _musicService?.currentTrackInfo;
if (currentTrackInfo != null) {
response.write(jsonEncode({
'status': 'ok',
'trackInfo': currentTrackInfo
}));
} else {
response.write(jsonEncode({
'status': 'no_track',
'message': 'No track currently playing'
}));
}
} else { } else {
response.statusCode = 404; response.statusCode = 404;
response.write(jsonEncode({'error': 'Not found'})); response.write(jsonEncode({'error': 'Not found'}));
@ -395,6 +456,39 @@ class NetworkGroupService extends ChangeNotifier {
} }
} }
Future<void> _updateCurrentUserListeningInfo() async {
final currentTrackInfo = _musicService?.currentTrackInfo;
final currentTrack = _musicService?.currentTrack;
if (currentTrack != null && currentTrackInfo != null) {
_currentUser = NetworkUser(
id: _currentUser.id,
name: _currentUser.name,
ipAddress: _currentUser.ipAddress,
joinedAt: _currentUser.joinedAt,
votes: _currentUser.votes,
isOnline: true,
currentTrackId: currentTrack.id,
currentTrackName: currentTrack.name,
currentArtist: currentTrack.artistNames,
currentTrackImage: currentTrack.imageUrl,
isListening: currentTrackInfo['isPlaying'] ?? false,
);
} else {
_currentUser = NetworkUser(
id: _currentUser.id,
name: _currentUser.name,
ipAddress: _currentUser.ipAddress,
joinedAt: _currentUser.joinedAt,
votes: _currentUser.votes,
isOnline: true,
isListening: false,
);
}
_networkUsers[_currentUser.id] = _currentUser;
}
// Public methods for UI interaction // Public methods for UI interaction
Future<void> refreshNetwork() async { Future<void> refreshNetwork() async {
await _checkConnectivity(); await _checkConnectivity();
@ -426,6 +520,45 @@ class NetworkGroupService extends ChangeNotifier {
return 'Connected to $_currentNetworkName'; return 'Connected to $_currentNetworkName';
} }
// Join another user's listening session
Future<bool> joinListeningSession(NetworkUser user) async {
if (!user.isListening || user.currentTrackId == null) {
return false;
}
try {
final client = HttpClient();
client.connectionTimeout = const Duration(seconds: 5);
final request = await client.postUrl(
Uri.parse('http://${user.ipAddress}:$_discoveryPort/join-session')
);
request.headers.set('Content-Type', 'application/json');
request.write(jsonEncode({'userId': _currentUser.id}));
final response = await request.close().timeout(const Duration(seconds: 5));
if (response.statusCode == 200) {
final body = await utf8.decoder.bind(response).join();
final responseData = jsonDecode(body);
if (responseData['status'] == 'ok' && responseData['trackInfo'] != null) {
// Here you could sync the track with your local player
// For now, we'll just return success
return true;
}
}
client.close();
return false;
} catch (e) {
if (kDebugMode) {
print('Error joining listening session: $e');
}
return false;
}
}
// Demo methods for testing // Demo methods for testing
void simulateNetworkConnection() { void simulateNetworkConnection() {
_isConnectedToWifi = true; _isConnectedToWifi = true;

View file

@ -34,7 +34,13 @@ class SpamProtectionService extends ChangeNotifier {
return false; return false;
} }
// Check cooldown // Allow first vote without cooldown for smooth user experience
final votes = _userVotes[userId] ?? [];
if (votes.isEmpty) {
return true; // First vote is always allowed
}
// Check cooldown for subsequent votes
if (_isOnCooldown(userId, _lastVoteTime, voteCooldown)) { if (_isOnCooldown(userId, _lastVoteTime, voteCooldown)) {
return false; return false;
} }
@ -54,7 +60,13 @@ class SpamProtectionService extends ChangeNotifier {
return false; return false;
} }
// Check cooldown // Allow first suggestion without cooldown for smooth user experience
final suggestions = _userSuggestions[userId] ?? [];
if (suggestions.isEmpty) {
return true; // First suggestion is always allowed
}
// Check cooldown for subsequent suggestions
if (_isOnCooldown(userId, _lastSuggestionTime, suggestionCooldown)) { if (_isOnCooldown(userId, _lastSuggestionTime, suggestionCooldown)) {
return false; return false;
} }

View file

@ -17,6 +17,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.7.1" version: "7.7.1"
archive:
dependency: transitive
description:
name: archive
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
url: "https://pub.dev"
source: hosted
version: "4.0.7"
args: args:
dependency: transitive dependency: transitive
description: description:
@ -177,6 +185,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.3" version: "2.0.3"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.2"
clock: clock:
dependency: transitive dependency: transitive
description: description:
@ -294,6 +310,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_launcher_icons:
dependency: "direct dev"
description:
name: flutter_launcher_icons
sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea"
url: "https://pub.dev"
source: hosted
version: "0.13.1"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@ -360,6 +384,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928"
url: "https://pub.dev"
source: hosted
version: "4.5.4"
io: io:
dependency: transitive dependency: transitive
description: description:
@ -600,6 +632,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.5.1" version: "1.5.1"
posix:
dependency: transitive
description:
name: posix
sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61"
url: "https://pub.dev"
source: hosted
version: "6.0.3"
provider: provider:
dependency: "direct main" dependency: "direct main"
description: description:

View file

@ -77,6 +77,9 @@ dev_dependencies:
# JSON serialization # JSON serialization
json_serializable: ^6.7.1 json_serializable: ^6.7.1
build_runner: ^2.4.7 build_runner: ^2.4.7
# App icon generator
flutter_launcher_icons: ^0.13.1
# For information on the generic Dart part of this file, see the # For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec # following page: https://dart.dev/tools/pub/pubspec
@ -92,6 +95,7 @@ flutter:
# To add assets to your application, add an assets section, like this: # To add assets to your application, add an assets section, like this:
assets: assets:
- assets/audio/ - assets/audio/
- assets/icons/
# - images/a_dot_burr.jpeg # - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg # - images/a_dot_ham.jpeg
@ -120,3 +124,13 @@ flutter:
# #
# For details regarding fonts from package dependencies, # For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package # see https://flutter.dev/to/font-from-package
flutter_launcher_icons:
android: true
ios: true
web:
generate: true
image_path: "assets/icons/app_icon.png"
adaptive_icon_background: "#121212"
adaptive_icon_foreground: "assets/icons/app_icon_foreground.png"
remove_alpha_ios: true

Binary file not shown.

Before

Width:  |  Height:  |  Size: 917 B

After

Width:  |  Height:  |  Size: 581 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 312 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 312 KiB

Before After
Before After

View file

@ -32,4 +32,4 @@
"purpose": "maskable" "purpose": "maskable"
} }
] ]
} }