From f64834c82e31087d6e491ebacaec0f5d7626c22a Mon Sep 17 00:00:00 2001 From: "BLACKHAT\\kenil" Date: Fri, 2 Oct 2026 14:34:47 +0530 Subject: [PATCH 1/3] release v1.0.2: AI recommendation engine, seamless mini-player playback, and UI/UX enhancements --- ember_flutter/.gitignore | 3 + .../android/app/src/main/AndroidManifest.xml | 1 + ember_flutter/lib/blocs/audio/audio_bloc.dart | 4 + .../lib/blocs/audio/audio_state.dart | 6 ++ ember_flutter/lib/main.dart | 2 + .../lib/screens/artist/artist_screen.dart | 3 +- .../screens/download/bloc/download_bloc.dart | 3 +- .../lib/screens/history/history_screen.dart | 17 +--- .../lib/screens/home/bloc/home_bloc.dart | 83 ++++++++--------- .../lib/screens/home/bloc/home_state.dart | 8 ++ .../lib/screens/home/home_screen.dart | 88 ++++++++++++++++++- .../lib/screens/library/library_screen.dart | 5 +- .../screens/player/full_player_screen.dart | 2 - .../lib/screens/playlist/playlist_screen.dart | 25 ------ .../lib/screens/profile/profile_screen.dart | 2 + .../lib/screens/settings/settings_screen.dart | 24 ++++- .../lib/services/python_service.dart | 22 +++++ .../lib/services/recommender_service.dart | 2 +- ember_flutter/lib/utils/result.dart | 7 ++ ember_flutter/lib/utils/search_algorithm.dart | 3 +- .../lib/widgets/equalizer_sheet.dart | 3 +- ember_flutter/lib/widgets/mini_player.dart | 2 +- ember_flutter/lib/widgets/playlist_sheet.dart | 9 +- ember_flutter/lib/widgets/shared_ui.dart | 13 --- ember_flutter/pubspec.yaml | 4 +- 25 files changed, 225 insertions(+), 116 deletions(-) diff --git a/ember_flutter/.gitignore b/ember_flutter/.gitignore index 79f7eca..97ab62c 100644 --- a/ember_flutter/.gitignore +++ b/ember_flutter/.gitignore @@ -46,3 +46,6 @@ app.*.map.json # Widget Preview related .widget_preview/ + +# Secrets +.env diff --git a/ember_flutter/android/app/src/main/AndroidManifest.xml b/ember_flutter/android/app/src/main/AndroidManifest.xml index d49893f..351a0d4 100644 --- a/ember_flutter/android/app/src/main/AndroidManifest.xml +++ b/ember_flutter/android/app/src/main/AndroidManifest.xml @@ -45,6 +45,7 @@ android:name="com.ryanheise.audioservice.AudioService" android:foregroundServiceType="mediaPlayback" android:exported="true" + android:stopWithTask="true" tools:ignore="Instantiatable"> diff --git a/ember_flutter/lib/blocs/audio/audio_bloc.dart b/ember_flutter/lib/blocs/audio/audio_bloc.dart index 0ae3707..64df81b 100644 --- a/ember_flutter/lib/blocs/audio/audio_bloc.dart +++ b/ember_flutter/lib/blocs/audio/audio_bloc.dart @@ -157,6 +157,7 @@ class AudioBloc extends Bloc { queue: queue, nativeIndexOffset: offset, currentTrack: currentTrack, + isFetchingStream: true, ), ); @@ -170,6 +171,9 @@ class AudioBloc extends Bloc { await player.setAudioSource(_playlist); final success = await _addTrackToPlaylist(offset, queue); + + emit(state.copyWith(isFetchingStream: false)); + if (success) { if (state.resumePositionMs != null && state.resumePositionMs! > 0) { await player.seek(Duration(milliseconds: state.resumePositionMs!)); diff --git a/ember_flutter/lib/blocs/audio/audio_state.dart b/ember_flutter/lib/blocs/audio/audio_state.dart index 517b66d..0b07f4f 100644 --- a/ember_flutter/lib/blocs/audio/audio_state.dart +++ b/ember_flutter/lib/blocs/audio/audio_state.dart @@ -9,6 +9,7 @@ class AudioState extends Equatable { final Color? dominantColor; final dynamic playerState; // just_audio PlayerState final int? resumePositionMs; + final bool isFetchingStream; const AudioState({ this.queue = const [], @@ -17,6 +18,7 @@ class AudioState extends Equatable { this.dominantColor, this.playerState, this.resumePositionMs, + this.isFetchingStream = false, }); AudioState copyWith({ @@ -26,6 +28,7 @@ class AudioState extends Equatable { Color? dominantColor, dynamic playerState, int? resumePositionMs, + bool? isFetchingStream, }) { return AudioState( queue: queue ?? this.queue, @@ -34,6 +37,7 @@ class AudioState extends Equatable { dominantColor: dominantColor ?? this.dominantColor, playerState: playerState ?? this.playerState, resumePositionMs: resumePositionMs ?? this.resumePositionMs, + isFetchingStream: isFetchingStream ?? this.isFetchingStream, ); } @@ -48,6 +52,7 @@ class AudioState extends Equatable { playerState: playerState, currentTrack: currentTrack ? null : this.currentTrack, dominantColor: dominantColor ? null : this.dominantColor, + isFetchingStream: isFetchingStream, ); } @@ -59,5 +64,6 @@ class AudioState extends Equatable { dominantColor, playerState, resumePositionMs, + isFetchingStream, ]; } diff --git a/ember_flutter/lib/main.dart b/ember_flutter/lib/main.dart index dac2237..ccc60e1 100644 --- a/ember_flutter/lib/main.dart +++ b/ember_flutter/lib/main.dart @@ -20,9 +20,11 @@ import 'package:ember_flutter/widgets/mini_player.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:just_audio_background/just_audio_background.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + await dotenv.load(fileName: ".env"); await JustAudioBackground.init( androidNotificationChannelId: 'com.example.ember.channel.audio', diff --git a/ember_flutter/lib/screens/artist/artist_screen.dart b/ember_flutter/lib/screens/artist/artist_screen.dart index 9687e28..00e85eb 100644 --- a/ember_flutter/lib/screens/artist/artist_screen.dart +++ b/ember_flutter/lib/screens/artist/artist_screen.dart @@ -31,11 +31,12 @@ class _ArtistScreenState extends State { Future _load() async { final browseId = widget.artistInfo['videoId'] ?? widget.artistInfo['browseId']; if (browseId == null || browseId.isEmpty) { - if (mounted) + if (mounted) { setState(() { _loading = false; _error = "Invalid artist link."; }); + } return; } diff --git a/ember_flutter/lib/screens/download/bloc/download_bloc.dart b/ember_flutter/lib/screens/download/bloc/download_bloc.dart index 8ae3187..2176ff9 100644 --- a/ember_flutter/lib/screens/download/bloc/download_bloc.dart +++ b/ember_flutter/lib/screens/download/bloc/download_bloc.dart @@ -143,7 +143,7 @@ class DownloadBloc extends Bloc { final downloads = await DatabaseService.instance.getAllDownloads(); // Ensure we invoke emit safely - if (!isClosed) + if (!isClosed) { emit( state.copyWith( progress: finalProgress, @@ -151,6 +151,7 @@ class DownloadBloc extends Bloc { downloadedTracks: downloads, ), ); + } } catch (e) { _failDownload(videoId); } diff --git a/ember_flutter/lib/screens/history/history_screen.dart b/ember_flutter/lib/screens/history/history_screen.dart index 1d3786b..b018eae 100644 --- a/ember_flutter/lib/screens/history/history_screen.dart +++ b/ember_flutter/lib/screens/history/history_screen.dart @@ -46,19 +46,6 @@ class _HistoryScreenState extends State { void _playSong(BuildContext context, Map track) { context.read().add(AudioPlayQueue([track], startIndex: 0)); - - Navigator.push( - context, - PageRouteBuilder( - pageBuilder: (context, animation, secondaryAnimation) => const FullPlayerScreen(), - transitionsBuilder: (context, animation, secondaryAnimation, child) { - const begin = Offset(0.0, 1.0); - const end = Offset.zero; - var tween = Tween(begin: begin, end: end).chain(CurveTween(curve: Curves.fastOutSlowIn)); - return SlideTransition(position: animation.drive(tween), child: child); - }, - ), - ); } Widget _buildListeningStats() { @@ -111,7 +98,7 @@ class _HistoryScreenState extends State { Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), decoration: BoxDecoration( - color: YTColors.primary.withOpacity(0.2), + color: YTColors.primary.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(12), ), child: Text( @@ -122,7 +109,7 @@ class _HistoryScreenState extends State { ], ), ); - }).toList(), + }), ], ), ); diff --git a/ember_flutter/lib/screens/home/bloc/home_bloc.dart b/ember_flutter/lib/screens/home/bloc/home_bloc.dart index be22c32..a4974b1 100644 --- a/ember_flutter/lib/screens/home/bloc/home_bloc.dart +++ b/ember_flutter/lib/screens/home/bloc/home_bloc.dart @@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:ember_flutter/services/python_service.dart'; import 'package:ember_flutter/services/database_service.dart'; import 'package:ember_flutter/services/recommender_service.dart'; +import 'package:ember_flutter/services/ai_recommendation_service.dart'; import 'package:ember_flutter/utils/result.dart'; import 'package:ember_flutter/screens/home/bloc/home_event.dart'; import 'package:ember_flutter/screens/home/bloc/home_state.dart'; @@ -20,58 +21,46 @@ class HomeBloc extends Bloc { Emitter emit, ) async { if (state is! HomeLoaded) { - emit(HomeLoading()); + if (event.mood != null) { + emit(HomeAILoading(event.mood!)); + } else { + emit(HomeLoading()); + } + } else { + if (event.mood != null) { + emit(HomeAILoading(event.mood!)); + } } try { if (event.mood != null) { - // Query YT for the top mood hits - final result = await PythonService.search("${event.mood} hits", filterType: "songs"); - switch (result) { - case Success(): - if (result.data.isEmpty) { - emit(const HomeError("No tracks found for this mood.")); - } else { - List rawHits = result.data; - List curatedMix = []; - - try { - // Grab the absolute top hits from this mood and seed them into our Recommender - final seedIds = rawHits.take(2).map((e) => (e as Map)['videoId']?.toString() ?? '').where((id) => id.isNotEmpty).toList(); - if (seedIds.isNotEmpty) { - final curatedRecs = await RecommenderService.instance.getRecommendations(seedIds); - if (curatedRecs.isNotEmpty) { - curatedMix = curatedRecs; // Highly personalized mix! - } - } - } catch (_) {} - - final sections = >[]; - - if (curatedMix.isNotEmpty) { - sections.add({ - "title": "Your ${event.mood} Mix", - "tracks": curatedMix.take(16).toList(), // Limit to 4 pages of 4 items - }); - } - - if (rawHits.isNotEmpty) { - sections.add({ - "title": "Top ${event.mood} Hits", - "tracks": rawHits.take(16).toList(), // Limit to 4 pages of 4 items - }); - } + // Query AI for the ultimate mood mix + final aiResult = await AiRecommendationService.getAiMix(event.mood!); + final sections = >[]; + + if (aiResult.songs.isNotEmpty) { + sections.add({ + "title": "DJ AI: ${event.mood} Mix", + "subtitle": "\u2728 ${aiResult.reason}", + "tracks": aiResult.songs.take(16).toList(), + }); + } - if (sections.isEmpty) { - emit(const HomeError("No tracks found for this mood.")); - return; - } + // Backup plan: fetch standard hits as well + final result = await PythonService.search("${event.mood} hits", filterType: "songs"); + if (result.isSuccess && result.data != null && (result.data as List).isNotEmpty) { + sections.add({ + "title": "Top ${event.mood} Hits", + "tracks": (result.data as List).take(16).toList(), + }); + } - emit(HomeLoaded(sections, activeMood: event.mood)); - } - case Failure(): - emit(HomeError(result.message)); + if (sections.isEmpty) { + emit(const HomeError("No tracks found for this mood.")); + return; } + + emit(HomeLoaded(sections, activeMood: event.mood)); } else { if (!event.forceRefresh && _cachedBaseSections != null) { // Instantly restore base feed if available in memory @@ -141,8 +130,8 @@ class HomeBloc extends Bloc { final validArtists = >[]; for (var res in artistResults) { - if (res is Success) { - final data = (res as Success).data; + if (res.isSuccess) { + final data = res.data; if (data is List && data.isNotEmpty) { final items = data.cast>(); if (items.isNotEmpty) { diff --git a/ember_flutter/lib/screens/home/bloc/home_state.dart b/ember_flutter/lib/screens/home/bloc/home_state.dart index 003af7d..fd8bbac 100644 --- a/ember_flutter/lib/screens/home/bloc/home_state.dart +++ b/ember_flutter/lib/screens/home/bloc/home_state.dart @@ -9,6 +9,14 @@ abstract class HomeState extends Equatable { class HomeLoading extends HomeState {} +class HomeAILoading extends HomeState { + final String targetMood; + const HomeAILoading(this.targetMood); + + @override + List get props => [targetMood]; +} + class HomeLoaded extends HomeState { final List> sections; final String? activeMood; diff --git a/ember_flutter/lib/screens/home/home_screen.dart b/ember_flutter/lib/screens/home/home_screen.dart index 04c2b09..71e708a 100644 --- a/ember_flutter/lib/screens/home/home_screen.dart +++ b/ember_flutter/lib/screens/home/home_screen.dart @@ -1,5 +1,6 @@ import 'package:ember_flutter/widgets/shared_ui.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:shimmer/shimmer.dart'; @@ -20,6 +21,10 @@ class HomeScreen extends StatelessWidget { return _buildHomeSkeleton(); } + if (state is HomeAILoading) { + return _buildAILoadingSkeleton(state.targetMood); + } + if (state is HomeError) { return Center( child: Column( @@ -133,6 +138,7 @@ class HomeScreen extends StatelessWidget { if (state.isColdStart && state.activeMood == null) _buildWelcomeBanner(), ...sections.map((section) { final title = section['title'] as String; + final subtitle = section['subtitle'] as String?; final rawTracks = section['tracks'] as List; final items = rawTracks .map( @@ -142,10 +148,33 @@ class HomeScreen extends StatelessWidget { ) .toList(); - return Column( + final isVIP = title.toLowerCase().contains("dj ai"); + + Widget sectionContent = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SharedUI.buildSectionTitle(title), + if (subtitle != null && subtitle.isNotEmpty) + Padding( + padding: const EdgeInsets.only(left: 16.0, right: 16.0, bottom: 12.0), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: YTColors.primary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: YTColors.primary.withValues(alpha: 0.3)), + ), + child: Text( + subtitle, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.9), + fontSize: 14, + height: 1.4, + fontStyle: FontStyle.italic, + ), + ), + ), + ), // Only use premium grid for Quick Picks, everything else uses horizontal carousel if (title.toLowerCase().contains('quick picks')) SharedUI.buildQuickPicksGrid(context, items) @@ -161,6 +190,29 @@ class HomeScreen extends StatelessWidget { const SizedBox(height: 8), ], ); + + if (isVIP) { + return Container( + margin: const EdgeInsets.only(bottom: 24), + padding: const EdgeInsets.symmetric(vertical: 16), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Colors.deepPurpleAccent.withValues(alpha: 0.15), + YTColors.primary.withValues(alpha: 0.05), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + border: Border.symmetric( + horizontal: BorderSide(color: Colors.deepPurpleAccent.withValues(alpha: 0.2)), + ) + ), + child: sectionContent, + ); + } + + return sectionContent; }), ], ), @@ -190,6 +242,7 @@ class HomeScreen extends StatelessWidget { color: Colors.transparent, child: InkWell( onTap: () { + HapticFeedback.lightImpact(); if (isSelected) { context.read().add(const HomeLoadRequested()); } else { @@ -340,4 +393,37 @@ class HomeScreen extends StatelessWidget { ), ); } + + Widget _buildAILoadingSkeleton(String mood) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Shimmer.fromColors( + baseColor: YTColors.primary.withValues(alpha: 0.8), + highlightColor: Colors.deepPurpleAccent, + child: const Icon( + Icons.auto_awesome, + size: 72, + ), + ), + const SizedBox(height: 24), + Shimmer.fromColors( + baseColor: Colors.white60, + highlightColor: Colors.white, + child: Text( + "DJ AI is carefully crafting your\n$mood mix...", + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + fontStyle: FontStyle.italic, + height: 1.4, + ), + ), + ), + ], + ), + ); + } } diff --git a/ember_flutter/lib/screens/library/library_screen.dart b/ember_flutter/lib/screens/library/library_screen.dart index ec66f42..6bceaa1 100644 --- a/ember_flutter/lib/screens/library/library_screen.dart +++ b/ember_flutter/lib/screens/library/library_screen.dart @@ -57,12 +57,12 @@ class _LibraryScreenState extends State { void _showImportPlaylistDialog(BuildContext context) { final TextEditingController urlController = TextEditingController(); + bool isImporting = false; showDialog( context: context, builder: (context) { return StatefulBuilder( builder: (context, setDialogState) { - bool isImporting = false; return AlertDialog( backgroundColor: YTColors.surface, shape: RoundedRectangleBorder( @@ -131,6 +131,7 @@ class _LibraryScreenState extends State { await DatabaseService.instance.addToPlaylist(playlistName, trackMap); } } + if (!context.mounted) return; context.read().add(StorageLoadAll()); Navigator.pop(context); ScaffoldMessenger.of(context).showSnackBar( @@ -142,6 +143,7 @@ class _LibraryScreenState extends State { ), ); } else { + if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('No tracks could be found in this playlist.'), @@ -150,6 +152,7 @@ class _LibraryScreenState extends State { ); } } else if (res is Failure) { + if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text((res as Failure).message), diff --git a/ember_flutter/lib/screens/player/full_player_screen.dart b/ember_flutter/lib/screens/player/full_player_screen.dart index 1ef0331..281cec0 100644 --- a/ember_flutter/lib/screens/player/full_player_screen.dart +++ b/ember_flutter/lib/screens/player/full_player_screen.dart @@ -29,7 +29,6 @@ class FullPlayerScreen extends StatefulWidget { class _FullPlayerScreenState extends State { final PageController _pageController = PageController(initialPage: 0); - int _currentPage = 0; Future>? _lyricsFuture; String? _lastLyricsVideoId; @@ -766,7 +765,6 @@ class _FullPlayerScreenState extends State { stream: audioBloc.player.durationStream, builder: (context, durSnap) { final pos = posSnap.data ?? Duration.zero; - final dur = durSnap.data ?? Duration.zero; int activeIndex = -1; if (hasTimestamps) { diff --git a/ember_flutter/lib/screens/playlist/playlist_screen.dart b/ember_flutter/lib/screens/playlist/playlist_screen.dart index 4724e15..b0414f2 100644 --- a/ember_flutter/lib/screens/playlist/playlist_screen.dart +++ b/ember_flutter/lib/screens/playlist/playlist_screen.dart @@ -63,19 +63,6 @@ class _PlaylistScreenState extends State { context.read().add(AudioPlayQueue(queue, startIndex: 0)); context.read().add(StorageAddPlayHistory(queue.first)); - - Navigator.push( - context, - PageRouteBuilder( - pageBuilder: (context, animation, secondaryAnimation) => const FullPlayerScreen(), - transitionsBuilder: (context, animation, secondaryAnimation, child) { - const begin = Offset(0.0, 1.0); - const end = Offset.zero; - var tween = Tween(begin: begin, end: end).chain(CurveTween(curve: Curves.fastOutSlowIn)); - return SlideTransition(position: animation.drive(tween), child: child); - }, - ), - ); } @override @@ -401,18 +388,6 @@ class _PlaylistScreenState extends State { onTap: () { context.read().add(AudioPlayQueue(tracks, startIndex: index)); context.read().add(StorageAddPlayHistory(track)); - - Navigator.push( - context, - PageRouteBuilder( - pageBuilder: (context, animation, secondaryAnimation) => const FullPlayerScreen(), - transitionsBuilder: (context, animation, secondaryAnimation, child) { - var tween = - Tween(begin: const Offset(0.0, 1.0), end: Offset.zero).chain(CurveTween(curve: Curves.fastOutSlowIn)); - return SlideTransition(position: animation.drive(tween), child: child); - }, - ), - ); }, ), ); diff --git a/ember_flutter/lib/screens/profile/profile_screen.dart b/ember_flutter/lib/screens/profile/profile_screen.dart index 5b50fdd..8004b2e 100644 --- a/ember_flutter/lib/screens/profile/profile_screen.dart +++ b/ember_flutter/lib/screens/profile/profile_screen.dart @@ -435,7 +435,9 @@ class ProfileScreen extends StatelessWidget { controller.text.trim(), ); setState(() => isLoading = false); + if (!ctx.mounted) return; Navigator.pop(ctx); + if (!context.mounted) return; if (res is Success>) { final data = res.data; final title = data['title'] as String; diff --git a/ember_flutter/lib/screens/settings/settings_screen.dart b/ember_flutter/lib/screens/settings/settings_screen.dart index bde9c4e..e738d49 100644 --- a/ember_flutter/lib/screens/settings/settings_screen.dart +++ b/ember_flutter/lib/screens/settings/settings_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:shorebird_code_push/shorebird_code_push.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import '../../services/update_service.dart'; import '../about/about_screen.dart'; @@ -251,7 +252,13 @@ class SettingsScreen extends StatelessWidget { child: Icon(Icons.info_outline_rounded, color: activeTheme.primary, size: 20), ), title: const Text('About Ember', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), - subtitle: const Text('v1.0.0 • Mayank & Kenil', style: TextStyle(color: YTColors.secondary, fontSize: 12)), + subtitle: FutureBuilder( + future: PackageInfo.fromPlatform(), + builder: (context, snapshot) { + final version = snapshot.hasData ? snapshot.data!.version : '1.0.1'; + return Text('v$version • Mayank & Kenil', style: const TextStyle(color: YTColors.secondary, fontSize: 12)); + }, + ), trailing: const Icon(Icons.chevron_right_rounded, color: Colors.white38), onTap: () { Navigator.pop(context); @@ -284,11 +291,24 @@ class _UpdateSectionState extends State<_UpdateSection> { int? _patchNumber; UpdateStatus? _status; String? _feedback; + String _version = '1.0.1'; @override void initState() { super.initState(); _loadPatchNumber(); + _loadVersion(); + } + + Future _loadVersion() async { + try { + final info = await PackageInfo.fromPlatform(); + if (mounted) { + setState(() { + _version = info.version; + }); + } + } catch (_) {} } Future _loadPatchNumber() async { @@ -357,7 +377,7 @@ class _UpdateSectionState extends State<_UpdateSection> { Widget build(BuildContext context) { final isOutdated = _status == UpdateStatus.outdated; final isRestartRequired = _status == UpdateStatus.restartRequired; - final versionDisplay = _patchNumber != null ? 'Version 1.0.0 • Patch $_patchNumber' : 'Version 1.0.0'; + final versionDisplay = _patchNumber != null ? 'Version $_version • Patch $_patchNumber' : 'Version $_version'; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), diff --git a/ember_flutter/lib/services/python_service.dart b/ember_flutter/lib/services/python_service.dart index 3f741b6..539e41a 100644 --- a/ember_flutter/lib/services/python_service.dart +++ b/ember_flutter/lib/services/python_service.dart @@ -74,6 +74,22 @@ class PythonService { String videoId, ) async { try { + final db = DatabaseService.instance; + final cacheKey = 'stream_url_$videoId'; + + final cachedString = await db.getCache(cacheKey); + if (cachedString != null) { + try { + final decoded = jsonDecode(cachedString); + final timestamp = decoded['timestamp'] ?? 0; + // 5 hour expiration for streaming links (5 * 60 * 60 * 1000 = 18000000 ms) + if (DateTime.now().millisecondsSinceEpoch - timestamp < 18000000) { + final data = (decoded['data'] as Map).cast(); + return Success(data); + } + } catch (_) {} + } + final Map result = await _channel.invokeMethod( 'get_stream_url', {'videoId': videoId}, @@ -86,6 +102,12 @@ class PythonService { data['error']!.isNotEmpty) { return Failure(data['error'] ?? "Unknown python error"); } + + await db.setCache(cacheKey, jsonEncode({ + 'timestamp': DateTime.now().millisecondsSinceEpoch, + 'data': data + })); + return Success(data); } catch (e) { debugPrint("Error in getStreamUrl: $e"); diff --git a/ember_flutter/lib/services/recommender_service.dart b/ember_flutter/lib/services/recommender_service.dart index 4bf228f..d31e9ee 100644 --- a/ember_flutter/lib/services/recommender_service.dart +++ b/ember_flutter/lib/services/recommender_service.dart @@ -8,7 +8,7 @@ class RecommenderService { static final RecommenderService instance = RecommenderService._init(); RecommenderService._init(); - Map _artistAffinityCache = {}; + final Map _artistAffinityCache = {}; Map> _affinityGraph = {}; bool _isInitialized = false; diff --git a/ember_flutter/lib/utils/result.dart b/ember_flutter/lib/utils/result.dart index f4065e3..e374aab 100644 --- a/ember_flutter/lib/utils/result.dart +++ b/ember_flutter/lib/utils/result.dart @@ -1,8 +1,15 @@ sealed class Result { const Result(); + + bool get isSuccess => this is Success; + bool get isFailure => this is Failure; + + T? get data => this is Success ? (this as Success).data : null; + String? get error => this is Failure ? (this as Failure).message : null; } class Success extends Result { + @override final T data; const Success(this.data); } diff --git a/ember_flutter/lib/utils/search_algorithm.dart b/ember_flutter/lib/utils/search_algorithm.dart index 2453f6c..28de6a6 100644 --- a/ember_flutter/lib/utils/search_algorithm.dart +++ b/ember_flutter/lib/utils/search_algorithm.dart @@ -131,8 +131,9 @@ class SearchAlgorithm { // If it is a standard list item for a track, YTMusic style shows just "Artist • length" if (!isTopCard && (type == 'song' || type == 'video')) { - if (topArtist.isNotEmpty && duration.isNotEmpty) + if (topArtist.isNotEmpty && duration.isNotEmpty) { return '$topArtist • $duration'; + } if (topArtist.isNotEmpty) return topArtist; return 'Unknown'; } diff --git a/ember_flutter/lib/widgets/equalizer_sheet.dart b/ember_flutter/lib/widgets/equalizer_sheet.dart index 6563d6d..a9095b1 100644 --- a/ember_flutter/lib/widgets/equalizer_sheet.dart +++ b/ember_flutter/lib/widgets/equalizer_sheet.dart @@ -44,8 +44,9 @@ void showEqualizerSheet(BuildContext context) { FutureBuilder( future: equalizer.parameters, builder: (context, snapshot) { - if (!snapshot.hasData) + if (!snapshot.hasData) { return CircularProgressIndicator(color: YTColors.primary); + } final params = snapshot.data!; return StreamBuilder( diff --git a/ember_flutter/lib/widgets/mini_player.dart b/ember_flutter/lib/widgets/mini_player.dart index 8e756b5..bcc2682 100644 --- a/ember_flutter/lib/widgets/mini_player.dart +++ b/ember_flutter/lib/widgets/mini_player.dart @@ -152,7 +152,7 @@ class MiniPlayer extends StatelessWidget { Builder( builder: (context) { Widget playPauseBtn; - if (processingState == ProcessingState.loading || processingState == ProcessingState.buffering) { + if (state.isFetchingStream || processingState == ProcessingState.loading || processingState == ProcessingState.buffering) { playPauseBtn = Padding( key: const ValueKey('loading'), padding: const EdgeInsets.all(12.0), diff --git a/ember_flutter/lib/widgets/playlist_sheet.dart b/ember_flutter/lib/widgets/playlist_sheet.dart index af23532..515cf4c 100644 --- a/ember_flutter/lib/widgets/playlist_sheet.dart +++ b/ember_flutter/lib/widgets/playlist_sheet.dart @@ -61,13 +61,14 @@ void showPlaylistSheet(BuildContext context, {Map? track}) { onSubmitted: (name) { if (name.trim().isNotEmpty) { storageBloc.add(StorageCreatePlaylist(name.trim())); - if (track != null) + if (track != null) { storageBloc.add( StorageAddToPlaylist( name: name.trim(), track: track, ), ); + } ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( @@ -93,10 +94,11 @@ void showPlaylistSheet(BuildContext context, {Map? track}) { final name = ctrl.text.trim(); if (name.isNotEmpty) { storageBloc.add(StorageCreatePlaylist(name)); - if (track != null) + if (track != null) { storageBloc.add( StorageAddToPlaylist(name: name, track: track), ); + } ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( @@ -116,8 +118,9 @@ void showPlaylistSheet(BuildContext context, {Map? track}) { BlocBuilder( builder: (context, state) { - if (state.playlists.isNotEmpty) + if (state.playlists.isNotEmpty) { const Divider(color: YTColors.divider, height: 1); + } if (state.playlists.isEmpty) { return const Padding( padding: EdgeInsets.all(16.0), diff --git a/ember_flutter/lib/widgets/shared_ui.dart b/ember_flutter/lib/widgets/shared_ui.dart index 46200e3..76a54c3 100644 --- a/ember_flutter/lib/widgets/shared_ui.dart +++ b/ember_flutter/lib/widgets/shared_ui.dart @@ -229,19 +229,6 @@ class SharedUI { static void playFromList(BuildContext context, List> list, int index) { context.read().add(AudioPlayQueue(list, startIndex: index)); context.read().add(StorageAddPlayHistory(list[index])); - - Navigator.push( - context, - PageRouteBuilder( - pageBuilder: (context, animation, secondaryAnimation) => const FullPlayerScreen(), - transitionsBuilder: (context, animation, secondaryAnimation, child) { - const begin = Offset(0.0, 1.0); - const end = Offset.zero; - var tween = Tween(begin: begin, end: end).chain(CurveTween(curve: Curves.fastOutSlowIn)); - return SlideTransition(position: animation.drive(tween), child: child); - }, - ), - ); } static Widget buildHorizontalList(List> items, {String listId = 'default'}) { diff --git a/ember_flutter/pubspec.yaml b/ember_flutter/pubspec.yaml index 9ee5f40..6b79a98 100644 --- a/ember_flutter/pubspec.yaml +++ b/ember_flutter/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.1+2 +version: 1.0.2+3 environment: sdk: ^3.0.0 @@ -56,6 +56,7 @@ dependencies: package_info_plus: ^10.2.1 http: ^1.6.0 shorebird_code_push: ^2.0.7 + flutter_dotenv: ^5.1.0 dev_dependencies: flutter_test: @@ -82,6 +83,7 @@ flutter: assets: - shorebird.yaml + - .env # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images From dc4768616e5fa0b0df7eb4dbf87d55fab48cf36c Mon Sep 17 00:00:00 2001 From: "BLACKHAT\\kenil" Date: Fri, 2 Oct 2026 15:09:59 +0530 Subject: [PATCH 2/3] fix: add missing AI recommendation service files to track --- docs/current_recommendation_system.md | 37 ++++ docs/music_recommendation_research.md | 28 +++ ember/recommendation_engine.py | 97 ++++++++++ .../services/ai_recommendation_service.dart | 170 ++++++++++++++++++ 4 files changed, 332 insertions(+) create mode 100644 docs/current_recommendation_system.md create mode 100644 docs/music_recommendation_research.md create mode 100644 ember/recommendation_engine.py create mode 100644 ember_flutter/lib/services/ai_recommendation_service.dart diff --git a/docs/current_recommendation_system.md b/docs/current_recommendation_system.md new file mode 100644 index 0000000..64233cf --- /dev/null +++ b/docs/current_recommendation_system.md @@ -0,0 +1,37 @@ +# Ember Current Recommendation System + +The Ember app currently utilizes a hybrid approach for its music recommendations, combining external collaborative filtering via YouTube Music's API with local heuristic-based scoring. + +## Architecture + +The recommendation engine spans both the Dart backend services (`RecommenderService`) and Python microservices (`PythonService` communicating with `catalog.py`). + +### Stage 1: Candidate Generation (Retrieval) +When a song finishes or the user skips tracks, the system must choose a new track to queue. It compiles a "candidate pool" from two primary sources: + +1. **YT Music Similar Graph:** + * The app sends the `videoId` of the currently playing track to the Python backend (`android_api.py` -> `catalog.py`). + * The Python backend fetches the "watch playlist" from YouTube Music, effectively acting as an external collaborative filtering system that knows what other users listened to alongside that track. +2. **Local Favorites:** + * To inject user preference directly, the system randomly samples up to 5 tracks from the user's local database of favorite songs. + +### Stage 2: Ranking Pipeline +Once candidates are pooled, they pass through a lightweight, synchronous ranking algorithm running in Dart: + +1. **Base Scoring:** + * Songs are scored initially based on their order in the candidate list. Higher-ranked candidates from YT Music start with a slightly higher base score (`100.0 - (index * 1.5)`). + * Tracks that appear in both candidates (e.g., in favorites and similar) get a `+1.0` fusion bonus. +2. **Artist Affinity Multiplier:** + * The most significant step is personalization based on the `_artistAffinityCache`. + * The app dynamically calculates an "affinity score" for each artist based on: + * **Play History:** `+1` point every time a song by that artist is played. + * **Favorites:** `+3` points for each favorited song by that artist. + * **Cold Start:** `+10` points for artists selected during the initial app onboarding. + * If a candidate track belongs to an artist with a recorded affinity, its final score significantly boosts (`score += affinity * 2.0`). +3. **Fatigue Penalty:** + * To prevent endless loops, the engine checks the 20 most recently played `videoId`s. If a candidate was recently played, it receives a `-200.0` penalty, ensuring it sinks to the bottom of the recommendations. + +### Limitations of Current Model +- Lacks semantic understanding of music (like tempo, genre, or mood). +- Heavily relies on internet connectivity for the initial "similar graph" generation. +- The scoring model only uses artists, completely ignoring genres or collaborative clustering based on the user's explicit listening sessions locally. diff --git a/docs/music_recommendation_research.md b/docs/music_recommendation_research.md new file mode 100644 index 0000000..7d2464e --- /dev/null +++ b/docs/music_recommendation_research.md @@ -0,0 +1,28 @@ +# Modern Music Recommendation Algorithms + +This document summarizes state-of-the-art approaches to building music recommendation systems, moving beyond simple popularity metrics to highly personalized, context-aware models. + +## 1. Collaborative Filtering (CF) +Collaborative filtering predicts what a user will like based on the preferences of similar users or the relationships between items. +- **User-User CF:** Finds users with similar tastes and recommends tracks they have enjoyed. +- **Item-Item CF:** Analyzes co-occurrence of songs in listening sessions (e.g., if Song A and Song B are often played together, recommend B when A is playing). +- **Matrix Factorization (MF):** A mathematical technique that decomposes user-item interactions into lower-dimensional latent features, allowing the system to uncover hidden affinities even when data is sparse. + +## 2. Content-Based Filtering +This approach analyzes the actual properties of the items to recommend similar tracks. It is essential for solving the "cold start" problem (new tracks with no listening history). +- **Metadata Analysis:** Using TF-IDF or text embeddings on track titles, artist names, genres, and lyrics. +- **Audio Feature Extraction:** Analyzing acoustic features like BPM (tempo), danceability, energy, and key using neural networks. + +## 3. Deep Learning & Sequence Modeling +Modern streaming giants (like Spotify and YouTube Music) utilize deep learning to understand complex user patterns. +- **Sequence Modeling:** Since music is consumed in sequential sessions, models like Recurrent Neural Networks (RNNs), Transformers (like BERT4Rec), and Markov Chains analyze the *order* of songs to predict the perfect "next track". +- **Embeddings:** Utilizing models like Word2Vec to map tracks and users into a high-dimensional vector space. The distance between vectors represents similarity. + +## 4. Context-Aware Recommendations +Recommendations shouldn't just depend on historical taste, but also the current situation. +- **Temporal Context:** Adjusting weights based on the time of day (e.g., energetic tracks in the morning, lo-fi beats at night). +- **Recent Fatigue:** Applying heavy negative weights to songs heard in the last 24 hours to prevent repetitive loops. + +## How to Optimize with Python & Dart in Hybrid Apps +- **Python's Role:** Python excels at vector mathematics and machine learning. Libraries like `scikit-learn` or `numpy` can be used to locally train Matrix Factorization models or compute TF-IDF similarities on user databases as a background task. +- **Dart's Role:** Dart (Flutter) excels at immediate, stateful UI updates and lightweight heuristics. Dart should handle the final recommendation ranking loop (applying immediate context, fatigue penalties, and cache lookups) to ensure near-zero latency when skipping tracks. diff --git a/ember/recommendation_engine.py b/ember/recommendation_engine.py new file mode 100644 index 0000000..99e9db3 --- /dev/null +++ b/ember/recommendation_engine.py @@ -0,0 +1,97 @@ +import json +import logging +import math +from collections import defaultdict +from typing import Any, Dict, List + +log = logging.getLogger("recommendation_engine") + +def build_cooccurrence_matrix(history: List[Dict[str, Any]], session_threshold_ms: int = 30 * 60 * 1000) -> Dict[str, Dict[str, float]]: + """ + Builds an item-item co-occurrence matrix from play history based on sessions. + Args: + history: List of play history track definitions containing 'videoId', 'artist', 'timestamp' + session_threshold_ms: Time difference in ms to consider tracks as part of the same session + Returns: + JSON serializable dict mapping artist to related artists and their affinity scores. + """ + if not history: + return {} + + # Sort history by timestamp just in case + def get_ts(x: Dict[str, Any]) -> int: + try: + return int(x.get("timestamp", 0)) + except (ValueError, TypeError): + return 0 + + sorted_history = sorted(history, key=get_ts) + + sessions = [] + current_session = [] + last_ts = 0 + + for track in sorted_history: + ts = get_ts(track) + artist = track.get("artist") + if not artist or artist.lower() == "unknown artist": + continue + + if not current_session: + current_session.append(artist) + last_ts = ts + else: + if ts - last_ts <= session_threshold_ms: + current_session.append(artist) + else: + sessions.append(current_session) + current_session = [artist] + last_ts = ts + + if current_session: + sessions.append(current_session) + + # Build co-occurrence map + co_occurrence = defaultdict(lambda: defaultdict(int)) + artist_counts = defaultdict(int) + + for session in sessions: + # Unique artists in this session to avoid self-loop bias inside a single long playlist + unique_artists = list(set(session)) + for i in range(len(unique_artists)): + artist_a = unique_artists[i] + artist_counts[artist_a] += 1 + for j in range(i + 1, len(unique_artists)): + artist_b = unique_artists[j] + co_occurrence[artist_a][artist_b] += 1 + co_occurrence[artist_b][artist_a] += 1 + + # Normalize into a graph with similarities based on Jaccard-like index + affinity_graph = {} + for artist_a, related in co_occurrence.items(): + edges = {} + for artist_b, count in related.items(): + # Standard association rule metric: count / (freq(a) + freq(b) - count) + # We scale it up so it's a usable integer/float multiplier for Dart + score = (count / float(artist_counts[artist_a] + artist_counts[artist_b] - count)) * 100.0 + if score > 0: + edges[artist_b] = round(score, 2) + if edges: + affinity_graph[artist_a] = edges + + return affinity_graph + +def analyze_history(history_json: str) -> str: + """ + Parses the JSON play history string from Dart and builds the graph. + Returns stringified JSON. + """ + try: + data = json.loads(history_json) + if not isinstance(data, list): + return "{}" + graph = build_cooccurrence_matrix(data) + return json.dumps(graph) + except Exception as exc: + log.error("Failed to build affinity graph: %s", exc) + return "{}" diff --git a/ember_flutter/lib/services/ai_recommendation_service.dart b/ember_flutter/lib/services/ai_recommendation_service.dart new file mode 100644 index 0000000..d3c6ee9 --- /dev/null +++ b/ember_flutter/lib/services/ai_recommendation_service.dart @@ -0,0 +1,170 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:flutter/foundation.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:ember_flutter/services/database_service.dart'; +import 'package:ember_flutter/services/recommender_service.dart'; +import 'package:ember_flutter/services/python_service.dart'; + +class AiRecommendationResponse { + final List> songs; + final String reason; + + AiRecommendationResponse({required this.songs, required this.reason}); +} + +class AiRecommendationService { + // Directly using Groq API for 100% free serverless architecture + static const String endpoint = "https://api.groq.com/openai/v1/chat/completions"; + static String get apiKey => dotenv.env['GROQ_KEY'] ?? 'YOUR_GROQ_API_KEY'; + + static final Map> _activeRequests = {}; + + /// Gets an AI Recommendation Mix, falling back to local SQLite if it fails + static Future getAiMix(String mood) async { + // 1. Debounce and Lock (Prevent users from spamming the button) + if (_activeRequests.containsKey(mood)) { + return _activeRequests[mood]!; + } + + final future = _getAiMixInternal(mood); + _activeRequests[mood] = future; + + try { + return await future; + } finally { + _activeRequests.remove(mood); + } + } + + static Future _getAiMixInternal(String mood) async { + try { + final db = DatabaseService.instance; + + // 2. Cache Layer (Your local 'Redis') + final cacheKey = 'ai_mix_$mood'; + final cachedString = await db.getCache(cacheKey); + if (cachedString != null) { + try { + final decoded = jsonDecode(cachedString); + final timestamp = decoded['timestamp'] ?? 0; + // 12 hour cache expiration (12 * 60 * 60 * 1000 = 43200000 ms) + if (DateTime.now().millisecondsSinceEpoch - timestamp < 43200000) { + final List> cachedSongs = (decoded['songs'] as List).map((e) => Map.from(e)).toList(); + return AiRecommendationResponse(songs: cachedSongs, reason: decoded['reason']); + } + } catch (_) {} + } + + final playHistory = await db.getPlayHistory(); + + // Get unique recent artists + final Set artistsSet = {}; + for (var track in playHistory.take(20)) { + if (track['artist'] != null && track['artist'] != 'Unknown Artist') { + artistsSet.add(track['artist']!); + } + } + final recentArtists = artistsSet.take(5).toList(); + + String contextLine = ""; + if (recentArtists.isNotEmpty) { + contextLine = "The user recently listened to artists: ${recentArtists.join(", ")}.\nRecommend exactly 3 new songs they might like (do not repeat their recent artists)."; + } else { + contextLine = "The user is brand new to the music app and has no listening history.\nRecommend exactly 3 universally loved, extremely popular songs that perfectly fit their mood."; + } + + final prompt = ''' +You are an expert DJ AI for the Ember music app. +$contextLine +Their current context/mood is: $mood. +Explain WHY you recommended these songs in one short engaging sentence. + +Format your response exactly as JSON like this: +{ + "recommendations": [ + {"song_title": "Title 1", "artist": "Artist 1"}, + {"song_title": "Title 2", "artist": "Artist 2"} + ], + "reason": "Because you've been listening to X, here is some Y." +} +'''; + + // 2. Make request directly to Groq (No Cloudflare Middleman) + final response = await http.post( + Uri.parse(endpoint), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $apiKey', + }, + body: jsonEncode({ + "model": "llama-3.1-8b-instant", + "messages": [ + { "role": "system", "content": "You are a helpful JSON music recommendation API. Only output valid JSON without markdown wrapping." }, + { "role": "user", "content": prompt } + ], + "response_format": { "type": "json_object" } + }), + ).timeout(const Duration(seconds: 15)); // Increased slightly to give Groq breathing room + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + final aiString = data['choices']?[0]?['message']?['content'] ?? ""; + + // Try parsing JSON out of the AI response + try { + final start = aiString.indexOf('{'); + final end = aiString.lastIndexOf('}'); + if (start != -1 && end != -1) { + final cleanJson = aiString.substring(start, end + 1); + final aiContent = jsonDecode(cleanJson); + + final List> resolvedSongs = []; + final List recommendations = aiContent['recommendations'] ?? []; + + // Convert AI response into actual YouTube Playable IDs via native python + for (var rec in recommendations) { + final title = rec['song_title'] ?? ''; + final artist = rec['artist'] ?? ''; + if (title.isNotEmpty && artist.isNotEmpty) { + final searchRes = await PythonService.search("$title $artist", filterType: "songs"); + if (searchRes.isSuccess && searchRes.data != null && (searchRes.data as List).isNotEmpty) { + final item = (searchRes.data as List).first; + resolvedSongs.add(Map.from(item.map((key, value) => MapEntry(key.toString(), value?.toString() ?? '')))); + } + } + } + + if (resolvedSongs.isNotEmpty) { + final responseObj = AiRecommendationResponse(songs: resolvedSongs, reason: aiContent['reason'] ?? "Here is your custom mix!"); + // Save to Cache + await db.setCache(cacheKey, jsonEncode({ + 'timestamp': DateTime.now().millisecondsSinceEpoch, + 'songs': resolvedSongs, + 'reason': responseObj.reason + })); + return responseObj; + } + } + } catch (e) { + debugPrint("Failed to parse AI JSON: $e"); + } + } else { + debugPrint("Groq request failed: ${response.statusCode}"); + } + } catch (e) { + debugPrint("AI Request Failed or Timed out. Falling back... $e"); + } + + // 3. Fallback to Local Recommendations + return _getLocalFallback(mood); + } + + static Future _getLocalFallback(String mood) async { + final localRecs = await RecommenderService.instance.getRecommendations(['default']); + return AiRecommendationResponse( + songs: localRecs.take(5).toList(), + reason: "Groq is taking a break! Here are some local tracks picked from your favorites." + ); + } +} From 0c642dbc0fd1a49a4bd3e778b0d1384992764b59 Mon Sep 17 00:00:00 2001 From: "BLACKHAT\\kenil" Date: Fri, 2 Oct 2026 15:22:54 +0530 Subject: [PATCH 3/3] release v1.0.2: AI recommendation engine, seamless mini-player playback, and UI/UX enhancements --- .../lib/screens/about/about_screen.dart | 48 +++++++++---------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/ember_flutter/lib/screens/about/about_screen.dart b/ember_flutter/lib/screens/about/about_screen.dart index f19c6ad..c1c8396 100644 --- a/ember_flutter/lib/screens/about/about_screen.dart +++ b/ember_flutter/lib/screens/about/about_screen.dart @@ -1,10 +1,9 @@ +import 'package:ember_flutter/screens/settings/settings_screen.dart'; +import 'package:ember_flutter/theme.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:url_launcher/url_launcher.dart'; - -import 'package:ember_flutter/theme.dart'; -import 'package:ember_flutter/screens/settings/settings_screen.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import 'package:url_launcher/url_launcher.dart'; class AboutScreen extends StatelessWidget { const AboutScreen({super.key}); @@ -179,28 +178,27 @@ class AboutScreen extends StatelessWidget { ), const SizedBox(height: 8), FutureBuilder( - future: PackageInfo.fromPlatform(), - builder: (context, snapshot) { - final version = snapshot.hasData ? "v${snapshot.data!.version}" : "v1.0.1"; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(20), - border: Border.all(color: Colors.white.withValues(alpha: 0.05)), - ), - child: Text( - version, - style: const TextStyle( - fontSize: 12, - color: Colors.white70, - fontWeight: FontWeight.w600, - letterSpacing: 0.5, + future: PackageInfo.fromPlatform(), + builder: (context, snapshot) { + final version = snapshot.hasData ? "v${snapshot.data!.version}" : "v1.0.1"; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: Colors.white.withValues(alpha: 0.05)), ), - ), - ); - } - ), + child: Text( + version, + style: const TextStyle( + fontSize: 12, + color: Colors.white70, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + ); + }), ], ), ],