diff --git a/CHANGELOG.md b/CHANGELOG.md index 81551db..4e6d1d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,26 @@ +# v1.7.0+18 + +## Material 3 Refresh + Artist Profiles + Search Reliability + +### NEW FEATURES + +- **Material 3 UI Refresh:** Migrated major screens/components to Material 3 with responsive spacing and layout behavior. +- **Dynamic Theme Colors:** Removed legacy glass mode and added seed-color based app theming. +- **Artist Profile Screen:** Added artist profile page with artist art, bio (more/less), monthly audience, Top Songs, Albums, and Singles & EPs. +- **Multi-Artist Picker:** Tapping artist name in full player now supports multi-artist tracks using a tap-only picker sheet. +- **Search Albums Section:** Search now shows a dedicated Albums section under song results with open-to-playlist flow. + +### BUG FIXES / IMPROVEMENTS + +- **Navigation + Mini-player:** Stabilized right-side quick switcher layout and floating mini-player spacing across screen sizes. +- **Album Filtering:** Limited search album results to album-only intent and reduced EP/Episode/podcast noise. +- **YouTube Search Resilience:** Added strategy fallback and persisted cache refresh behavior for more stable results under API/network variance. + +### UPCOMING (work in progress) + +- Better artist identity matching for edge-case names/collaborations +- Further recommendation relevance tuning and queue quality improvements + # v1.6.0+17 ## Lyrics + Distribution Flavors + Android Widgets diff --git a/README.md b/README.md index 7d9093a..22541bd 100644 --- a/README.md +++ b/README.md @@ -77,13 +77,18 @@ Hongeet is a music player that supports **offline local audio playback** and **o - + + + + + + diff --git a/SECURITY.md b/SECURITY.md index b5122ed..4d04c46 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,7 +6,8 @@ Security fixes are provided only for the latest stable release line. | Version | Supported | | --------- | --------- | -| 1.6.x | ✅ | +| 1.7.x | ✅ | +| 1.6.x | ❌ | | 1.5.x | ❌ | | 1.4.x | ❌ | | 1.3.x | ❌ | diff --git a/assets/screenshots/01.jpg b/assets/screenshots/01.jpg index 625c124..3d5fbde 100644 Binary files a/assets/screenshots/01.jpg and b/assets/screenshots/01.jpg differ diff --git a/assets/screenshots/02.jpg b/assets/screenshots/02.jpg index 5c96db7..879fa46 100644 Binary files a/assets/screenshots/02.jpg and b/assets/screenshots/02.jpg differ diff --git a/assets/screenshots/03.jpg b/assets/screenshots/03.jpg index 920829a..fc5a76f 100644 Binary files a/assets/screenshots/03.jpg and b/assets/screenshots/03.jpg differ diff --git a/assets/screenshots/04.jpg b/assets/screenshots/04.jpg index b4f0fbd..035b99c 100644 Binary files a/assets/screenshots/04.jpg and b/assets/screenshots/04.jpg differ diff --git a/assets/screenshots/05.jpg b/assets/screenshots/05.jpg index 49e1af9..3e478fe 100644 Binary files a/assets/screenshots/05.jpg and b/assets/screenshots/05.jpg differ diff --git a/assets/screenshots/06.jpg b/assets/screenshots/06.jpg index c266295..e9117b1 100644 Binary files a/assets/screenshots/06.jpg and b/assets/screenshots/06.jpg differ diff --git a/assets/screenshots/07.jpg b/assets/screenshots/07.jpg index 6239391..cb768e7 100644 Binary files a/assets/screenshots/07.jpg and b/assets/screenshots/07.jpg differ diff --git a/assets/screenshots/08.jpg b/assets/screenshots/08.jpg index 416c89d..87dca2a 100644 Binary files a/assets/screenshots/08.jpg and b/assets/screenshots/08.jpg differ diff --git a/assets/screenshots/09.jpg b/assets/screenshots/09.jpg index 21f72ba..2288585 100644 Binary files a/assets/screenshots/09.jpg and b/assets/screenshots/09.jpg differ diff --git a/assets/screenshots/10.jpg b/assets/screenshots/10.jpg deleted file mode 100644 index efc8da0..0000000 Binary files a/assets/screenshots/10.jpg and /dev/null differ diff --git a/assets/screenshots/11.png b/assets/screenshots/11.png deleted file mode 100644 index ddc9948..0000000 Binary files a/assets/screenshots/11.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/changelogs/18.txt b/fastlane/metadata/android/en-US/changelogs/18.txt new file mode 100644 index 0000000..a984f53 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/18.txt @@ -0,0 +1,7 @@ +v1.7.0+18 +- Material 3 UI refresh with improved spacing and responsive layouts +- Dynamic seed-color theming; removed legacy glass mode +- Added artist profile with bio, audience, top songs, albums, singles +- Search now includes Albums section +- Improved navigation and mini-player spacing +- Improved YouTube search reliability \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/01.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/01.jpg index 625c124..3d5fbde 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/01.jpg and b/fastlane/metadata/android/en-US/images/phoneScreenshots/01.jpg differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/02.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/02.jpg index 5c96db7..879fa46 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/02.jpg and b/fastlane/metadata/android/en-US/images/phoneScreenshots/02.jpg differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/03.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/03.jpg index 920829a..fc5a76f 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/03.jpg and b/fastlane/metadata/android/en-US/images/phoneScreenshots/03.jpg differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/04.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/04.jpg index b4f0fbd..035b99c 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/04.jpg and b/fastlane/metadata/android/en-US/images/phoneScreenshots/04.jpg differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/05.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/05.jpg index 49e1af9..3e478fe 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/05.jpg and b/fastlane/metadata/android/en-US/images/phoneScreenshots/05.jpg differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/06.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/06.jpg index c266295..e9117b1 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/06.jpg and b/fastlane/metadata/android/en-US/images/phoneScreenshots/06.jpg differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/07.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/07.jpg index 6239391..cb768e7 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/07.jpg and b/fastlane/metadata/android/en-US/images/phoneScreenshots/07.jpg differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/08.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/08.jpg index 416c89d..87dca2a 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/08.jpg and b/fastlane/metadata/android/en-US/images/phoneScreenshots/08.jpg differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/09.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/09.jpg index 21f72ba..2288585 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/09.jpg and b/fastlane/metadata/android/en-US/images/phoneScreenshots/09.jpg differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/10.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/10.png deleted file mode 100644 index ddc9948..0000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/10.png and /dev/null differ diff --git a/ghweb/version.json b/ghweb/version.json index 3858988..43fb390 100644 --- a/ghweb/version.json +++ b/ghweb/version.json @@ -1,6 +1,6 @@ { - "latest": "v1.6.0+17", - "min_supported": "v1.5.1+16", + "latest": "v1.7.0+18", + "min_supported": "v1.6.0+17", "apk_url": "https://sourceforge.net/projects/hongeet/files/latest/download", - "notes": "Added lyrics support (synced and unsynced). Added distribution flavors: GitHub/SF keeps first-start update checks while Izzy disables them for policy compliance. Added 2x2 and 4x2 Android music widgets with controls, artwork, and progress. Fixed 3-button navigation bottom-bar sizing/mini-player overlap." + "notes": "Material 3 UI refresh with improved spacing and responsive layouts. Dynamic seed-color theming; removed legacy glass mode. Added artist profile with bio, audience, top songs, albums, singles. Search now includes Albums section. Improved navigation and mini-player spacing. Improved YouTube search reliability." } diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index a1494e3..232b2f3 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -2,65 +2,190 @@ import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../utils/data_saver_settings.dart'; -enum ProgressBarStyle { defaultStyle, snake, glass } +enum ProgressBarStyle { defaultStyle, snake } enum UiPerformanceMode { auto, smooth, full } class AppTheme { - static ThemeData glassTheme = ThemeData( - brightness: Brightness.dark, - scaffoldBackgroundColor: Colors.transparent, - primaryColor: const Color(0xFF1DB954), - textTheme: ThemeData.dark().textTheme.apply(fontFamily: 'Inter'), - appBarTheme: const AppBarTheme( - backgroundColor: Colors.transparent, - elevation: 0, - ), - bottomNavigationBarTheme: const BottomNavigationBarThemeData( - backgroundColor: Color(0xFF111111), - selectedItemColor: Color(0xFF1DB954), - unselectedItemColor: Colors.grey, - showUnselectedLabels: true, - type: BottomNavigationBarType.fixed, - ), - ); - - static ThemeData simpleDarkTheme = ThemeData( - brightness: Brightness.dark, - scaffoldBackgroundColor: const Color(0xFF0D0D0D), - primaryColor: const Color(0xFF1DB954), - textTheme: ThemeData.dark().textTheme.apply(fontFamily: 'Inter'), - appBarTheme: const AppBarTheme( - backgroundColor: Colors.transparent, - elevation: 0, - ), - bottomNavigationBarTheme: const BottomNavigationBarThemeData( - backgroundColor: Color(0xFF111111), - selectedItemColor: Color(0xFF1DB954), - unselectedItemColor: Colors.grey, - showUnselectedLabels: true, - type: BottomNavigationBarType.fixed, - ), - ); + static const Color defaultSeedColor = Color(0xFF28C76F); + + static ThemeData buildTheme({required Color seedColor}) { + final scheme = ColorScheme.fromSeed( + seedColor: seedColor, + brightness: Brightness.dark, + ); + + final textTheme = Typography.material2021( + platform: TargetPlatform.android, + ).white.apply(fontFamily: 'Inter'); + + final base = ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + colorScheme: scheme, + fontFamily: 'Inter', + textTheme: textTheme, + scaffoldBackgroundColor: scheme.surface, + appBarTheme: AppBarTheme( + elevation: 0, + centerTitle: false, + scrolledUnderElevation: 0, + backgroundColor: scheme.surface, + foregroundColor: scheme.onSurface, + surfaceTintColor: Colors.transparent, + ), + cardTheme: CardThemeData( + elevation: 0, + margin: EdgeInsets.zero, + color: scheme.surfaceContainerHigh, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + ), + navigationBarTheme: NavigationBarThemeData( + backgroundColor: scheme.surfaceContainer, + surfaceTintColor: Colors.transparent, + elevation: 0, + indicatorColor: scheme.secondaryContainer, + labelTextStyle: WidgetStateProperty.resolveWith((states) { + final isSelected = states.contains(WidgetState.selected); + return textTheme.labelMedium?.copyWith( + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, + color: isSelected ? scheme.onSecondaryContainer : scheme.onSurface, + ); + }), + ), + navigationRailTheme: NavigationRailThemeData( + backgroundColor: scheme.surfaceContainerLow, + selectedIconTheme: IconThemeData(color: scheme.onSecondaryContainer), + unselectedIconTheme: IconThemeData( + color: scheme.onSurface.withValues(alpha: 0.7), + ), + selectedLabelTextStyle: textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + unselectedLabelTextStyle: textTheme.labelMedium, + indicatorColor: scheme.secondaryContainer, + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: scheme.surfaceContainerLow, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide( + color: scheme.outlineVariant.withValues(alpha: 0.45), + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide( + color: scheme.outlineVariant.withValues(alpha: 0.45), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide(color: scheme.primary, width: 1.3), + ), + hintStyle: textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant.withValues(alpha: 0.86), + ), + ), + searchBarTheme: SearchBarThemeData( + elevation: const WidgetStatePropertyAll(0), + backgroundColor: WidgetStatePropertyAll(scheme.surfaceContainerLow), + side: WidgetStatePropertyAll( + BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.45)), + ), + shape: WidgetStatePropertyAll( + RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)), + ), + hintStyle: WidgetStatePropertyAll( + textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant.withValues(alpha: 0.86), + ), + ), + ), + listTileTheme: ListTileThemeData( + iconColor: scheme.primary, + titleTextStyle: textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: scheme.onSurface, + ), + subtitleTextStyle: textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + snackBarTheme: SnackBarThemeData( + backgroundColor: scheme.inverseSurface, + contentTextStyle: textTheme.bodyMedium?.copyWith( + color: scheme.onInverseSurface, + ), + behavior: SnackBarBehavior.floating, + ), + bottomSheetTheme: BottomSheetThemeData( + showDragHandle: true, + elevation: 0, + surfaceTintColor: Colors.transparent, + modalBackgroundColor: scheme.surfaceContainerHigh, + backgroundColor: scheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + ), + dividerTheme: DividerThemeData( + color: scheme.outlineVariant.withValues(alpha: 0.4), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + minimumSize: const Size(0, 44), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, 44), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + side: BorderSide(color: scheme.outlineVariant), + ), + ), + switchTheme: SwitchThemeData( + thumbColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return scheme.onPrimary; + } + return scheme.outline; + }), + trackColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return scheme.primary; + } + return scheme.surfaceContainerHighest; + }), + ), + ); + + return base; + } } class ThemeProvider with ChangeNotifier { - static const _useGlassThemeKey = 'use_glass_theme'; + static const _seedColorKey = 'theme_seed_color'; + static const _legacyUseGlassThemeKey = 'use_glass_theme'; static const _progressBarStyleKey = 'progress_bar_style'; static const _uiPerformanceModeKey = 'ui_performance_mode'; static const _dataSaverKey = DataSaverSettings.prefKey; - bool _useGlassTheme = false; - bool get useGlassTheme => _useGlassTheme; + // Kept only for backwards compatibility with older UI branches + bool get useGlassTheme => false; + + Color _seedColor = AppTheme.defaultSeedColor; + Color get seedColor => _seedColor; ProgressBarStyle _progressBarStyle = ProgressBarStyle.defaultStyle; ProgressBarStyle get progressBarStyle => _progressBarStyle; - ProgressBarStyle get effectiveProgressBarStyle { - if (!_useGlassTheme && _progressBarStyle == ProgressBarStyle.glass) { - return ProgressBarStyle.defaultStyle; - } - return _progressBarStyle; - } + ProgressBarStyle get effectiveProgressBarStyle => _progressBarStyle; UiPerformanceMode _uiPerformanceMode = UiPerformanceMode.auto; UiPerformanceMode get uiPerformanceMode => _uiPerformanceMode; @@ -68,8 +193,7 @@ class ThemeProvider with ChangeNotifier { bool _dataSaverEnabled = false; bool get dataSaverEnabled => _dataSaverEnabled; - ThemeData get currentTheme => - _useGlassTheme ? AppTheme.glassTheme : AppTheme.simpleDarkTheme; + ThemeData get currentTheme => AppTheme.buildTheme(seedColor: _seedColor); ThemeProvider() { _loadTheme(); @@ -77,7 +201,12 @@ class ThemeProvider with ChangeNotifier { Future _loadTheme() async { final prefs = await SharedPreferences.getInstance(); - _useGlassTheme = prefs.getBool(_useGlassThemeKey) ?? false; + await prefs.remove(_legacyUseGlassThemeKey); + final storedSeed = prefs.getInt(_seedColorKey); + _seedColor = storedSeed == null + ? AppTheme.defaultSeedColor + : Color(storedSeed); + final progressRaw = prefs.getString(_progressBarStyleKey) ?? ProgressBarStyle.defaultStyle.name; @@ -93,38 +222,31 @@ class ThemeProvider with ChangeNotifier { ); _dataSaverEnabled = prefs.getBool(_dataSaverKey) ?? false; DataSaverSettings.setInMemory(_dataSaverEnabled); + notifyListeners(); + } - if (!_useGlassTheme && _progressBarStyle == ProgressBarStyle.glass) { - _progressBarStyle = ProgressBarStyle.defaultStyle; - await prefs.setString(_progressBarStyleKey, _progressBarStyle.name); - } + Future setSeedColor(Color color) async { + if (_seedColor.toARGB32() == color.toARGB32()) return; + _seedColor = color; + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_seedColorKey, color.toARGB32()); notifyListeners(); } + @Deprecated('Glass theme has been removed') Future setUseGlassTheme(bool enabled) async { - if (_useGlassTheme == enabled) return; - _useGlassTheme = enabled; final prefs = await SharedPreferences.getInstance(); - await prefs.setBool(_useGlassThemeKey, _useGlassTheme); - - if (!_useGlassTheme && _progressBarStyle == ProgressBarStyle.glass) { - _progressBarStyle = ProgressBarStyle.defaultStyle; - await prefs.setString(_progressBarStyleKey, _progressBarStyle.name); - } - notifyListeners(); + await prefs.remove(_legacyUseGlassThemeKey); } + @Deprecated('Glass theme has been removed') Future toggleTheme() async { - await setUseGlassTheme(!_useGlassTheme); + await setUseGlassTheme(false); } Future setProgressBarStyle(ProgressBarStyle style) async { - final next = (!_useGlassTheme && style == ProgressBarStyle.glass) - ? ProgressBarStyle.defaultStyle - : style; - if (_progressBarStyle == next) return; - - _progressBarStyle = next; + if (_progressBarStyle == style) return; + _progressBarStyle = style; final prefs = await SharedPreferences.getInstance(); await prefs.setString(_progressBarStyleKey, _progressBarStyle.name); notifyListeners(); @@ -159,11 +281,6 @@ class ThemeProvider with ChangeNotifier { } UiPerformanceMode resolvedUiPerformanceMode(BuildContext context) { - if (_useGlassTheme) { - // Glass mode always renders at full visual strength. - return UiPerformanceMode.full; - } - if (_uiPerformanceMode != UiPerformanceMode.auto) { return _uiPerformanceMode; } diff --git a/lib/core/theme/responsive.dart b/lib/core/theme/responsive.dart new file mode 100644 index 0000000..6352d1e --- /dev/null +++ b/lib/core/theme/responsive.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; + +class ResponsiveLayout { + static const double compactMaxWidth = 600; + static const double mediumMaxWidth = 1024; + static const double expandedMaxContentWidth = 1320; + + static bool isCompact(BuildContext context) => + MediaQuery.sizeOf(context).width < compactMaxWidth; + + static bool isMedium(BuildContext context) { + final width = MediaQuery.sizeOf(context).width; + return width >= compactMaxWidth && width < mediumMaxWidth; + } + + static bool isExpanded(BuildContext context) => + MediaQuery.sizeOf(context).width >= mediumMaxWidth; + + static EdgeInsets pagePadding(BuildContext context) { + final width = MediaQuery.sizeOf(context).width; + if (width >= 1280) { + return const EdgeInsets.symmetric(horizontal: 32, vertical: 22); + } + if (width >= mediumMaxWidth) { + return const EdgeInsets.symmetric(horizontal: 24, vertical: 20); + } + if (width >= compactMaxWidth) { + return const EdgeInsets.symmetric(horizontal: 20, vertical: 18); + } + return const EdgeInsets.symmetric(horizontal: 16, vertical: 16); + } + + static double maxContentWidth(BuildContext context) { + if (isExpanded(context)) { + return expandedMaxContentWidth; + } + return double.infinity; + } + + static int adaptiveGridColumns( + BuildContext context, { + double minCardWidth = 190, + int minColumns = 2, + int maxColumns = 6, + }) { + final width = MediaQuery.sizeOf(context).width; + final columns = (width / minCardWidth).floor(); + return columns.clamp(minColumns, maxColumns); + } +} diff --git a/lib/core/utils/app_messenger.dart b/lib/core/utils/app_messenger.dart index 587f675..64f4550 100644 --- a/lib/core/utils/app_messenger.dart +++ b/lib/core/utils/app_messenger.dart @@ -17,18 +17,22 @@ class AppMessenger { _entry?.remove(); _entry = OverlayEntry( - builder: (_) => SafeArea( - child: Align( - alignment: Alignment.bottomCenter, - child: Padding( - padding: const EdgeInsets.only(bottom: 90, left: 16, right: 16), - child: _Toast( - message: message, - color: color ?? Colors.black.withValues(alpha: 0.85), + builder: (context) { + final scheme = Theme.of(context).colorScheme; + return SafeArea( + child: Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.only(bottom: 90, left: 16, right: 16), + child: _Toast( + message: message, + color: color ?? scheme.inverseSurface, + textColor: scheme.onInverseSurface, + ), ), ), - ), - ), + ); + }, ); overlay.insert(_entry!); @@ -43,8 +47,13 @@ class AppMessenger { class _Toast extends StatelessWidget { final String message; final Color color; + final Color textColor; - const _Toast({required this.message, required this.color}); + const _Toast({ + required this.message, + required this.color, + required this.textColor, + }); @override Widget build(BuildContext context) { @@ -58,7 +67,7 @@ class _Toast extends StatelessWidget { ), child: Text( message, - style: const TextStyle(color: Colors.white), + style: TextStyle(color: textColor), textAlign: TextAlign.center, ), ), diff --git a/lib/core/utils/audio_player_service.dart b/lib/core/utils/audio_player_service.dart index 483efb9..123376c 100644 --- a/lib/core/utils/audio_player_service.dart +++ b/lib/core/utils/audio_player_service.dart @@ -119,7 +119,7 @@ class AudioPlayerService { 'Accept': '*/*', 'Accept-Language': 'en-US,en;q=0.9', }; - static const Duration _loadWatchdogTimeout = Duration(seconds: 26); + static const Duration _loadWatchdogTimeout = Duration(seconds: 15); static const int _upNextTargetCount = 10; Future<_ResolvedStream> _resolveUrl(String id) async { @@ -239,14 +239,28 @@ class AudioPlayerService { } } - void _showAutoSkipNotice() { + void _showAutoSkipNotice({String? reason}) { final now = DateTime.now(); final last = _lastAutoSkipNoticeAt; if (last != null && now.difference(last) < const Duration(seconds: 2)) { return; } _lastAutoSkipNoticeAt = now; - AppMessenger.show('Skipping song: server/load error'); + + String message; + if (reason != null && reason.toLowerCase().contains('403')) { + message = 'Skipping: YouTube rate limited (403)'; + } else if (reason != null && reason.toLowerCase().contains('timeout')) { + message = 'Skipping: Connection timeout'; + } else if (reason != null && reason.toLowerCase().contains('network')) { + message = 'Skipping: Network error'; + } else if (reason != null && reason.toLowerCase().contains('watchdog')) { + message = 'Skipping: Load timeout'; + } else { + message = 'Skipping song: server/load error'; + } + + AppMessenger.show(message); } void setSleepTimer(Duration duration) { @@ -422,7 +436,7 @@ class AudioPlayerService { if (retried || token != _playToken) return; if (_currentIndex + 1 < _queue.length) { - _showAutoSkipNotice(); + _showAutoSkipNotice(reason: 'watchdog timeout'); await Future.delayed(const Duration(milliseconds: 500)); await skipNext(); } @@ -437,7 +451,7 @@ class AudioPlayerService { if (!_isTransientLoadError(errorText)) return false; final attempts = (_transientRetryCount[song.id] ?? 0) + 1; - const maxAttempts = 2; + const maxAttempts = 3; if (attempts > maxAttempts) { _transientRetryCount.remove(song.id); return false; @@ -558,7 +572,7 @@ class AudioPlayerService { _transientRetryCount.remove(song.id); AppLogger.warning('YouTube 403 persists after retries, skipping track'); if (_currentIndex + 1 < _queue.length) { - _showAutoSkipNotice(); + _showAutoSkipNotice(reason: '403'); await Future.delayed(const Duration(milliseconds: 500)); await skipNext(); } diff --git a/lib/core/utils/glass_container.dart b/lib/core/utils/glass_container.dart deleted file mode 100644 index 34af481..0000000 --- a/lib/core/utils/glass_container.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'dart:ui'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import '../theme/app_theme.dart'; - -class GlassContainer extends StatelessWidget { - final Widget child; - final double blur; - final double opacity; - final BorderRadius borderRadius; - - const GlassContainer({ - super.key, - required this.child, - this.blur = 25, - this.opacity = 0.12, - this.borderRadius = const BorderRadius.all(Radius.circular(20)), - }); - - @override - Widget build(BuildContext context) { - final themeProvider = Provider.of(context); - - if (themeProvider.useGlassTheme) { - final clampedOpacity = opacity.clamp(0.0, 1.0).toDouble(); - final effectiveBlur = blur.clamp(0.0, 60.0).toDouble(); - if (effectiveBlur <= 0.1) { - return _buildGlassTint(clampedOpacity); - } - return _buildBlurredGlass(effectiveBlur, clampedOpacity); - } else { - return Container( - decoration: BoxDecoration( - color: const Color(0xFF1A1A1A), - borderRadius: borderRadius, - ), - child: child, - ); - } - } - - Widget _buildGlassTint(double clampedOpacity) { - return Container( - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: clampedOpacity), - borderRadius: borderRadius, - border: Border.all(color: Colors.white.withValues(alpha: 0.2)), - ), - child: child, - ); - } - - Widget _buildBlurredGlass(double effectiveBlur, double clampedOpacity) { - return ClipRRect( - borderRadius: borderRadius, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: effectiveBlur, sigmaY: effectiveBlur), - child: _buildGlassTint(clampedOpacity), - ), - ); - } -} diff --git a/lib/core/utils/glass_page.dart b/lib/core/utils/glass_page.dart deleted file mode 100644 index de0f47f..0000000 --- a/lib/core/utils/glass_page.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import '../theme/app_theme.dart'; - -class GlassPage extends StatelessWidget { - final Widget child; - final EdgeInsets padding; - - const GlassPage({ - super.key, - required this.child, - this.padding = const EdgeInsets.all(16), - }); - - @override - Widget build(BuildContext context) { - final themeProvider = Provider.of(context); - - return Scaffold( - body: themeProvider.useGlassTheme - ? Stack( - children: [ - Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [Color(0xFF0A0A0A), Color(0xFF000000)], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - ), - ), - ), - SafeArea( - child: Padding( - padding: padding, - child: child, - ), - ), - ], - ) - : SafeArea( - child: Padding( - padding: padding, - child: child, - ), - ), - ); - } -} diff --git a/lib/core/utils/themed_container.dart b/lib/core/utils/themed_container.dart new file mode 100644 index 0000000..5112a49 --- /dev/null +++ b/lib/core/utils/themed_container.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +class ThemedContainer extends StatelessWidget { + final Widget child; + final double blur; + final double opacity; + final BorderRadius borderRadius; + + const ThemedContainer({ + super.key, + required this.child, + this.blur = 25, + this.opacity = 0.12, + this.borderRadius = const BorderRadius.all(Radius.circular(20)), + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + + return Material( + color: scheme.surfaceContainerHigh, + borderRadius: borderRadius, + clipBehavior: Clip.antiAlias, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: borderRadius, + border: Border.all( + color: scheme.outlineVariant.withValues(alpha: 0.4), + ), + ), + child: child, + ), + ); + } +} diff --git a/lib/core/utils/themed_page.dart b/lib/core/utils/themed_page.dart new file mode 100644 index 0000000..de787fe --- /dev/null +++ b/lib/core/utils/themed_page.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import '../theme/responsive.dart'; + +class ThemedPage extends StatelessWidget { + final Widget child; + final EdgeInsets padding; + + const ThemedPage({ + super.key, + required this.child, + this.padding = const EdgeInsets.all(16), + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final usesDefaultPadding = padding == const EdgeInsets.all(16); + final effectivePadding = usesDefaultPadding + ? ResponsiveLayout.pagePadding(context) + : padding; + final maxWidth = ResponsiveLayout.maxContentWidth(context); + + return Scaffold( + body: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [scheme.surface, scheme.surfaceContainerLowest], + ), + ), + child: SafeArea( + child: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: Padding(padding: effectivePadding, child: child), + ), + ), + ), + ), + ); + } +} diff --git a/lib/core/widgets/sleep_timer_overlay_screen.dart b/lib/core/widgets/sleep_timer_overlay_screen.dart index 084d0cc..81af820 100644 --- a/lib/core/widgets/sleep_timer_overlay_screen.dart +++ b/lib/core/widgets/sleep_timer_overlay_screen.dart @@ -7,8 +7,12 @@ class SleepTimerOverlayScreen extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final textTheme = theme.textTheme; + return Scaffold( - backgroundColor: Colors.black, + backgroundColor: scheme.scrim, body: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => Navigator.of(context).maybePop(), @@ -19,19 +23,18 @@ class SleepTimerOverlayScreen extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon( + Icon( Icons.nightlight_round, size: 150, - color: Colors.white, + color: scheme.onSurface, ), const SizedBox(height: 22), - const Text( + Text( 'Sleep Timer Active', textAlign: TextAlign.center, - style: TextStyle( - fontSize: 30, + style: textTheme.displaySmall?.copyWith( fontWeight: FontWeight.w700, - color: Colors.white, + color: scheme.onSurface, ), ), const SizedBox(height: 10), @@ -40,13 +43,17 @@ class SleepTimerOverlayScreen extends StatelessWidget { ? 'Playback stopped after current song.' : 'Playback stopped by sleep timer.', textAlign: TextAlign.center, - style: const TextStyle(fontSize: 15, color: Colors.white70), + style: textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), ), const SizedBox(height: 24), - const Text( + Text( 'Tap anywhere to continue', textAlign: TextAlign.center, - style: TextStyle(fontSize: 13, color: Colors.white54), + style: textTheme.labelLarge?.copyWith( + color: scheme.onSurfaceVariant, + ), ), ], ), diff --git a/lib/data/api/youtube_api.dart b/lib/data/api/youtube_api.dart index 1771d84..c6430bb 100644 --- a/lib/data/api/youtube_api.dart +++ b/lib/data/api/youtube_api.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:youtube_explode_dart/youtube_explode_dart.dart'; import '../../core/utils/youtube_thumbnail_utils.dart'; @@ -33,6 +34,14 @@ class YoutubeApi { static const int _maxYtmPages = 3; static const int _maxChartArtworkFallbackLookups = 10; static const Duration _trendingAlbumsCacheTtl = Duration(minutes: 20); + static const Duration _searchMemoryTtl = Duration(minutes: 2); + static const Duration _searchPersistFreshTtl = Duration(minutes: 8); + static const Duration _searchPersistStaleTtl = Duration(hours: 24); + static const String _searchPersistKey = 'yt_search_cache_v1'; + static const int _searchPersistMaxEntries = 90; + static const String _strategyYtmStrict = 'ytm_strict'; + static const String _strategyYtmRelaxed = 'ytm_relaxed'; + static const String _strategyYtExplode = 'yt_explode'; static final Map _searchCache = {}; static final Map _relatedCache = {}; @@ -41,7 +50,10 @@ class YoutubeApi { static final Map _albumsCache = {}; static final Map _artistsCache = {}; static final Map _artistSongsCache = {}; + static final Map _artistProfileCache = {}; static final Map _sessionSongArtworkOverrides = {}; + static final Map _searchStrategyHealth = {}; + static Future _searchPersistWriteQueue = Future.value(); static const int _maxSessionSongArtworkOverrides = 2000; static _YtmBootstrapCache? _ytmBootstrapCache; static Future<_YtmBootstrapCache>? _ytmBootstrapInFlight; @@ -61,58 +73,684 @@ class YoutubeApi { if (!forceRefresh) { final cached = _searchCache[cacheKey]; - if (cached != null && !cached.isExpired(const Duration(minutes: 2))) { + if (cached != null && !cached.isExpired(_searchMemoryTtl)) { return cached.songs; } } + final persisted = await _readPersistedSearchEntry(cacheKey); + final persistedSongs = + persisted?.limitedSongs(safeTake) ?? const []; + final hasPersistedSongs = persistedSongs.isNotEmpty; + final persistedIsUsableStale = + persisted != null && persisted.age <= _searchPersistStaleTtl; + + if (!forceRefresh && hasPersistedSongs && persisted != null) { + _searchCache[cacheKey] = _TimedSongsCache(persistedSongs); + + if (persisted.age <= _searchPersistFreshTtl) { + if (persisted.age > _searchMemoryTtl) { + unawaited( + _refreshSearchCacheInBackground( + cacheKey: cacheKey, + query: normalized, + effectiveQuery: effectiveQuery, + artistQuery: artistQuery, + safeTake: safeTake, + ), + ); + } + return persistedSongs; + } + + if (persistedIsUsableStale) { + unawaited( + _refreshSearchCacheInBackground( + cacheKey: cacheKey, + query: normalized, + effectiveQuery: effectiveQuery, + artistQuery: artistQuery, + safeTake: safeTake, + ), + ); + return persistedSongs; + } + } + + try { + final songs = await _searchSongsFromNetwork( + query: normalized, + effectiveQuery: effectiveQuery, + artistQuery: artistQuery, + safeTake: safeTake, + ); + final immutable = List.unmodifiable(songs); + _searchCache[cacheKey] = _TimedSongsCache(immutable); + _trimCache(_searchCache, maxEntries: 60); + await _writePersistedSearchEntry(cacheKey, immutable); + return immutable; + } catch (_) { + if (hasPersistedSongs && persistedIsUsableStale) { + _searchCache[cacheKey] = _TimedSongsCache(persistedSongs); + return persistedSongs; + } + rethrow; + } + } + + static Future> _searchSongsFromNetwork({ + required String query, + required String effectiveQuery, + required bool artistQuery, + required int safeTake, + }) async { + final plan = _buildSearchStrategyPlan(); List ytmSongs = const []; - Object? ytmError; + List fallbackSongs = const []; + Object? lastError; + + for (final strategy in plan) { + if (strategy == _strategyYtmStrict && ytmSongs.isEmpty) { + try { + final songs = await _searchViaYtm(query: query, take: safeTake); + if (songs.isNotEmpty) { + _recordSearchStrategySuccess(strategy); + ytmSongs = songs; + break; + } + _recordSearchStrategyFailure(strategy, StateError('empty result')); + } catch (e) { + _recordSearchStrategyFailure(strategy, e); + lastError ??= e; + } + } else if (strategy == _strategyYtmRelaxed && ytmSongs.isEmpty) { + try { + final songs = await _searchViaYtm( + query: query, + take: safeTake, + useSongsParams: false, + requireSongsShelf: false, + ); + if (songs.isNotEmpty) { + _recordSearchStrategySuccess(strategy); + ytmSongs = songs; + break; + } + _recordSearchStrategyFailure(strategy, StateError('empty result')); + } catch (e) { + _recordSearchStrategyFailure(strategy, e); + lastError ??= e; + } + } else if (strategy == _strategyYtExplode && ytmSongs.isEmpty) { + try { + fallbackSongs = await _searchViaYoutubeExplodeWithFallback( + query: effectiveQuery, + originalQuery: query, + artistQuery: artistQuery, + take: safeTake, + ); + if (fallbackSongs.isNotEmpty) { + _recordSearchStrategySuccess(strategy); + break; + } + _recordSearchStrategyFailure(strategy, StateError('empty result')); + } catch (e) { + _recordSearchStrategyFailure(strategy, e); + lastError ??= e; + } + } + } + + final songs = _mergeWithDedup(ytmSongs, fallbackSongs, safeTake); + if (songs.isEmpty && lastError != null) { + throw lastError; + } + return songs; + } + + static Future> searchAlbums( + String query, { + int take = 10, + bool forceRefresh = false, + }) async { + final normalized = query.trim(); + if (normalized.isEmpty) return const []; + + final safeTake = take.clamp(1, 20); + final cacheKey = + 'ytm_search_albums::${normalized.toLowerCase()}::$safeTake'; + + if (!forceRefresh) { + final cached = _albumsCache[cacheKey]; + if (cached != null && !cached.isExpired(const Duration(minutes: 15))) { + return cached.albums; + } + } + + _YtmBootstrapCache? bootstrap; try { - ytmSongs = await _searchViaYtm(query: normalized, take: safeTake); - } catch (e) { - ytmError = e; + bootstrap = await _getYtmBootstrap(); + } catch (_) { + bootstrap = null; } + if (bootstrap == null) return const []; - // Secondary YTM pass without fixed songs params so still prefer YTM before falling back to generic YouTube search. - if (ytmSongs.isEmpty) { + final out = []; + final seen = {}; + void append(List albums) { + for (final album in albums) { + final key = album.browseId.toLowerCase(); + if (!seen.add(key)) continue; + out.add(album); + if (out.length >= safeTake) break; + } + } + + final queries = [ + normalized, + '$normalized album', + '$normalized full album', + '$normalized soundtrack', + ]; + final seenQueries = {}; + for (final q in queries) { + if (out.length >= safeTake) break; + final trimmed = q.trim(); + if (trimmed.isEmpty) continue; + if (!seenQueries.add(trimmed.toLowerCase())) continue; try { - ytmSongs = await _searchViaYtm( - query: normalized, + final batch = await _searchAlbumsViaYtm( + bootstrap: bootstrap, + query: trimmed, take: safeTake, - useSongsParams: false, - requireSongsShelf: false, ); - } catch (e) { - ytmError ??= e; + append(batch); + } catch (_) { + continue; } } - List fallbackSongs = const []; - if (ytmSongs.isEmpty) { + if (out.length < safeTake) { try { - fallbackSongs = await _searchViaYoutubeExplodeWithFallback( - query: effectiveQuery, - originalQuery: normalized, - artistQuery: artistQuery, - take: safeTake, + append( + await _searchAlbumsViaYoutubePlaylists( + query: normalized, + take: safeTake, + ), ); } catch (_) { - if (ytmSongs.isEmpty && ytmError != null) { - rethrow; + // Keep YTM-only results if playlist fallback fails. + } + } + + final immutable = List.unmodifiable( + out.take(safeTake).toList(growable: false), + ); + if (immutable.isNotEmpty) { + _albumsCache[cacheKey] = _TimedAlbumsCache(immutable); + _trimAlbumsCache(maxEntries: 140); + } else { + _albumsCache.remove(cacheKey); + } + return immutable; + } + + static String extractArtistBrowseId(String browseIdOrUrl) { + final raw = browseIdOrUrl.trim(); + if (raw.isEmpty) return ''; + + final uri = Uri.tryParse(raw); + if (uri != null && + (uri.scheme == 'http' || uri.scheme == 'https') && + uri.pathSegments.isNotEmpty) { + final segments = uri.pathSegments; + if (segments.length >= 2 && segments.first.toLowerCase() == 'channel') { + return segments[1].trim(); + } + if (segments.length >= 2 && segments.first.toLowerCase() == 'browse') { + return segments[1].trim(); + } + if (segments.isNotEmpty) { + return segments.last.trim(); + } + } + + return raw; + } + + static Future> searchArtists( + String query, { + int take = 8, + bool forceRefresh = false, + }) async { + final normalized = query.trim(); + if (normalized.isEmpty) return const []; + + final safeTake = take.clamp(1, 30); + final cacheKey = + 'ytm_search_artists::${normalized.toLowerCase()}::$safeTake'; + + if (!forceRefresh) { + final cached = _artistsCache[cacheKey]; + if (cached != null && !cached.isExpired(const Duration(minutes: 15))) { + return cached.artists; + } + } + + final out = []; + final seen = {}; + void append(List artists) { + for (final artist in artists) { + final key = artist.browseId.toLowerCase(); + if (!seen.add(key)) continue; + out.add(artist); + if (out.length >= safeTake) break; + } + } + + _YtmBootstrapCache? bootstrap; + try { + bootstrap = await _getYtmBootstrap(); + } catch (_) { + bootstrap = null; + } + + if (bootstrap != null) { + final queries = [ + normalized, + '$normalized artist', + '$normalized official artist', + '$normalized topic', + ]; + final seenQueries = {}; + for (final q in queries) { + if (out.length >= safeTake) break; + final trimmed = q.trim(); + if (trimmed.isEmpty) continue; + if (!seenQueries.add(trimmed.toLowerCase())) continue; + + try { + final payload = await _postYtmSearch( + bootstrap: bootstrap, + query: trimmed, + useSongsParams: false, + timeout: _ytmSearchTimeout, + ); + final parsed = _parseYtmArtistsFromSearchResults( + payload, + take: safeTake * 2, + ); + append(parsed); + } catch (_) { + continue; } } } - final songs = _mergeWithDedup(ytmSongs, fallbackSongs, safeTake); - if (songs.isEmpty && ytmError != null) { - throw ytmError; + if (out.length < safeTake) { + try { + append( + await _searchArtistsViaYoutubeExplode( + query: normalized, + take: safeTake, + ), + ); + } catch (_) { + // Keep current list if fallback fails. + } } - final normalizedSongs = List.unmodifiable(songs); - _searchCache[cacheKey] = _TimedSongsCache(normalizedSongs); - _trimCache(_searchCache, maxEntries: 60); - return normalizedSongs; + final immutable = List.unmodifiable( + out.take(safeTake).toList(growable: false), + ); + if (immutable.isNotEmpty) { + _artistsCache[cacheKey] = _TimedArtistsCache(immutable); + _trimArtistsCache(maxEntries: 140); + } else { + _artistsCache.remove(cacheKey); + } + return immutable; + } + + static Future artistProfile( + String artistBrowseIdOrUrl, { + String? artistName, + int topSongsTake = 24, + int releasesTake = 20, + bool forceRefresh = false, + }) async { + var browseId = extractArtistBrowseId(artistBrowseIdOrUrl); + var resolvedArtistName = artistName?.trim() ?? ''; + + if (browseId.isEmpty && resolvedArtistName.isEmpty) { + throw StateError('Missing artist identity'); + } + + if (browseId.isEmpty && resolvedArtistName.isNotEmpty) { + final candidates = await searchArtists( + resolvedArtistName, + take: 6, + forceRefresh: forceRefresh, + ); + if (candidates.isNotEmpty) { + final best = _pickBestArtistCandidate(candidates, resolvedArtistName); + browseId = best.browseId; + resolvedArtistName = best.name; + } + } + + if (browseId.isEmpty) { + if (resolvedArtistName.isNotEmpty) { + return _fallbackArtistProfileByName( + resolvedArtistName, + take: safeClamp(topSongsTake, 6, 60), + forceRefresh: forceRefresh, + ); + } + throw StateError('Artist profile not found'); + } + + final safeSongsTake = safeClamp(topSongsTake, 6, 60); + final safeReleasesTake = releasesTake.clamp(6, 60); + final cacheKey = + '${browseId.toLowerCase()}::$safeSongsTake::$safeReleasesTake'; + + if (!forceRefresh) { + final cached = _artistProfileCache[cacheKey]; + if (cached != null && !cached.isExpired(const Duration(minutes: 20))) { + return cached.profile; + } + } + + Map? payload; + try { + final bootstrap = await _getYtmBootstrap(); + payload = await _postYtmBrowse( + bootstrap: bootstrap, + browseId: browseId, + timeout: _chartSongsTimeout, + ); + } catch (_) { + payload = null; + } + + final header = payload == null + ? _ArtistHeaderResult( + name: resolvedArtistName, + imageUrl: '', + bio: '', + monthlyAudience: '', + ) + : _parseArtistHeader(payload, fallbackName: resolvedArtistName); + + List topSongs = payload == null + ? const [] + : _extractSongsFromPayload(payload, take: safeSongsTake); + if (topSongs.isEmpty) { + try { + topSongs = await artistSongs( + browseId, + artistName: header.name.isNotEmpty ? header.name : resolvedArtistName, + take: safeSongsTake, + forceRefresh: forceRefresh, + ); + } catch (_) { + topSongs = const []; + } + } + + final albums = payload == null + ? const [] + : _parseArtistReleasesFromShelves( + payload, + take: safeReleasesTake, + section: _ArtistReleaseSection.albums, + ); + final singlesAndEps = payload == null + ? const [] + : _parseArtistReleasesFromShelves( + payload, + take: safeReleasesTake, + section: _ArtistReleaseSection.singlesAndEps, + ); + + final profile = YtmArtistProfile( + browseId: browseId, + name: header.name.isNotEmpty + ? header.name + : (resolvedArtistName.isNotEmpty ? resolvedArtistName : browseId), + imageUrl: header.imageUrl, + bio: header.bio, + monthlyAudience: header.monthlyAudience, + topSongs: List.unmodifiable(topSongs), + albums: List.unmodifiable(albums), + singlesAndEps: List.unmodifiable(singlesAndEps), + ); + + _artistProfileCache[cacheKey] = _TimedArtistProfileCache(profile); + _trimArtistProfileCache(maxEntries: 80); + return profile; + } + + static int safeClamp(int value, int min, int max) { + return value < min ? min : (value > max ? max : value); + } + + static Future _fallbackArtistProfileByName( + String artistName, { + required int take, + required bool forceRefresh, + }) async { + List songs = const []; + try { + songs = await searchSongs( + artistName, + take: take, + forceRefresh: forceRefresh, + ); + } catch (_) { + songs = const []; + } + + var imageUrl = ''; + if (songs.isNotEmpty) { + imageUrl = songs.first.imageUrl; + } + + return YtmArtistProfile( + browseId: '', + name: artistName, + imageUrl: imageUrl, + bio: '', + monthlyAudience: '', + topSongs: List.unmodifiable(songs), + albums: const [], + singlesAndEps: const [], + ); + } + + static Future _refreshSearchCacheInBackground({ + required String cacheKey, + required String query, + required String effectiveQuery, + required bool artistQuery, + required int safeTake, + }) async { + try { + final songs = await _searchSongsFromNetwork( + query: query, + effectiveQuery: effectiveQuery, + artistQuery: artistQuery, + safeTake: safeTake, + ); + if (songs.isEmpty) return; + + final immutable = List.unmodifiable(songs); + _searchCache[cacheKey] = _TimedSongsCache(immutable); + _trimCache(_searchCache, maxEntries: 60); + await _writePersistedSearchEntry(cacheKey, immutable); + } catch (_) { + // Keep stale cache in place when refresh fails. + } + } + + static List _buildSearchStrategyPlan() { + final ytm = []; + if (_isSearchStrategyAvailable(_strategyYtmStrict)) { + ytm.add(_strategyYtmStrict); + } + if (_isSearchStrategyAvailable(_strategyYtmRelaxed)) { + ytm.add(_strategyYtmRelaxed); + } + + if (ytm.length == 2) { + final strictScore = _searchStrategyScore(_strategyYtmStrict); + final relaxedScore = _searchStrategyScore(_strategyYtmRelaxed); + if (relaxedScore > strictScore + 0.1) { + ytm + ..clear() + ..add(_strategyYtmRelaxed) + ..add(_strategyYtmStrict); + } + } + + final plan = [...ytm]; + final explodeAvailable = _isSearchStrategyAvailable(_strategyYtExplode); + if (explodeAvailable || plan.isNotEmpty) { + plan.add(_strategyYtExplode); + } + + if (plan.isEmpty) { + return const [ + _strategyYtmStrict, + _strategyYtmRelaxed, + _strategyYtExplode, + ]; + } + return plan; + } + + static bool _isSearchStrategyAvailable(String strategy) { + final health = _searchStrategyHealth[strategy]; + if (health == null) return true; + return !health.isCoolingDown; + } + + static double _searchStrategyScore(String strategy) { + final health = _searchStrategyHealth[strategy]; + if (health == null) return 0.65; + return health.score; + } + + static void _recordSearchStrategySuccess(String strategy) { + final health = _searchStrategyHealth.putIfAbsent( + strategy, + _StrategyHealth.new, + ); + health.recordSuccess(); + } + + static void _recordSearchStrategyFailure(String strategy, Object error) { + final health = _searchStrategyHealth.putIfAbsent( + strategy, + _StrategyHealth.new, + ); + health.recordFailure(retryable: _isRetryableStrategyError(error)); + } + + static bool _isRetryableStrategyError(Object error) { + if (error is TimeoutException) return true; + final lower = error.toString().toLowerCase(); + if (lower.isEmpty) return false; + + const retryableTokens = [ + 'timeout', + 'timed out', + 'socketexception', + 'failed host lookup', + 'connection reset', + 'connection aborted', + '429', + 'too many requests', + '503', + 'temporarily unavailable', + 'network', + ]; + return retryableTokens.any(lower.contains); + } + + static Future<_PersistedSearchEntry?> _readPersistedSearchEntry( + String cacheKey, + ) async { + try { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_searchPersistKey); + if (raw == null || raw.trim().isEmpty) return null; + + final decoded = jsonDecode(raw); + if (decoded is! List) return null; + + for (final item in decoded) { + final map = _asMap(item); + if (map == null) continue; + final entry = _PersistedSearchEntry.fromJson(map); + if (entry == null) continue; + if (entry.key == cacheKey) return entry; + } + } catch (_) { + return null; + } + return null; + } + + static Future _writePersistedSearchEntry( + String cacheKey, + List songs, + ) async { + if (songs.isEmpty) return; + + _searchPersistWriteQueue = _searchPersistWriteQueue.then((_) async { + try { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_searchPersistKey); + + final entries = {}; + if (raw != null && raw.trim().isNotEmpty) { + final decoded = jsonDecode(raw); + if (decoded is List) { + for (final item in decoded) { + final map = _asMap(item); + if (map == null) continue; + final entry = _PersistedSearchEntry.fromJson(map); + if (entry == null) continue; + if (entry.age > const Duration(days: 3)) continue; + entries[entry.key] = entry; + } + } + } + + entries[cacheKey] = _PersistedSearchEntry( + key: cacheKey, + timestamp: DateTime.now(), + songs: songs.take(50).toList(growable: false), + ); + + final sorted = entries.values.toList(growable: false) + ..sort((a, b) => b.timestamp.compareTo(a.timestamp)); + final trimmed = sorted + .take(_searchPersistMaxEntries) + .toList(growable: false); + + final encoded = jsonEncode( + trimmed.map((e) => e.toJson()).toList(growable: false), + ); + await prefs.setString(_searchPersistKey, encoded); + } catch (_) { + // Ignore persistence errors. + } + }); + + return _searchPersistWriteQueue; } static Future> _searchViaYtm({ @@ -514,7 +1152,7 @@ class YoutubeApi { if (runMap == null) continue; final text = (runMap['text'] ?? '').toString().trim(); - if (text.isEmpty || text == '•' || _looksLikeDurationText(text)) { + if (text.isEmpty || text == '•' || _looksLikeDurationText(text)) { continue; } @@ -541,7 +1179,7 @@ class YoutubeApi { final fallbackFromRuns = {}; for (final run in runs) { final text = (_asMap(run)?['text'] ?? '').toString().trim(); - if (text.isEmpty || text == '•' || _looksLikeDurationText(text)) { + if (text.isEmpty || text == '•' || _looksLikeDurationText(text)) { continue; } if (_isLikelyNonArtistMetaText(text)) continue; @@ -551,7 +1189,7 @@ class YoutubeApi { if (rawLine.isNotEmpty) { final pieces = rawLine - .split(RegExp(r'\s*[•\u2022\|]\s*')) + .split(RegExp(r'\s*[•\u2022\|]\s*')) .map((s) => s.trim()) .where((s) => s.isNotEmpty) .where((s) => !_looksLikeDurationText(s)) @@ -562,7 +1200,7 @@ class YoutubeApi { for (final run in runs) { final text = (_asMap(run)?['text'] ?? '').toString().trim(); - if (text.isEmpty || text == '•' || _looksLikeDurationText(text)) { + if (text.isEmpty || text == '•' || _looksLikeDurationText(text)) { continue; } return text; @@ -893,17 +1531,115 @@ class YoutubeApi { if (out.length >= take) return out; } - for (final renderer in responsiveRows) { - final mapped = _mapResponsiveRendererToAlbum(renderer); - if (mapped == null) continue; - if (!seen.add(mapped.browseId.toLowerCase())) continue; - out.add(mapped); - if (out.length >= take) break; + for (final renderer in responsiveRows) { + final mapped = _mapResponsiveRendererToAlbum(renderer); + if (mapped == null) continue; + if (!seen.add(mapped.browseId.toLowerCase())) continue; + out.add(mapped); + if (out.length >= take) break; + } + + return out.take(take).toList(growable: false); + } + + static Future> _searchAlbumsViaYoutubePlaylists({ + required String query, + required int take, + }) async { + final out = []; + final seen = {}; + + final queries = [ + query, + '$query album', + '$query full album', + '$query soundtrack', + ]; + final seenQueries = {}; + + for (final q in queries) { + if (out.length >= take) break; + final normalizedQuery = q.trim(); + if (normalizedQuery.isEmpty) continue; + if (!seenQueries.add(normalizedQuery.toLowerCase())) continue; + + SearchList? page; + try { + page = await _yt.search + .searchContent(normalizedQuery, filter: TypeFilters.playlist) + .timeout(_searchTimeout); + } catch (_) { + continue; + } + + var pageGuard = 0; + while (page != null && pageGuard < 3 && out.length < take) { + for (final item in page) { + if (item is! SearchPlaylist) continue; + + final playlistId = item.id.value.trim(); + if (playlistId.isEmpty) continue; + + final browseId = _toYtmBrowseId(playlistId); + final subtitle = item.videoCount > 0 + ? '${item.videoCount} songs' + : 'Album - YouTube Music'; + + if (!_isLikelyAlbumResult( + pageType: '', + browseId: browseId, + playlistId: playlistId, + title: item.title.trim(), + subtitle: subtitle, + )) { + continue; + } + + final dedupKey = browseId.toLowerCase(); + if (!seen.add(dedupKey)) continue; + + final title = item.title.trim(); + if (title.isEmpty) continue; + + out.add( + YtmAlbum( + browseId: browseId, + title: title, + subtitle: subtitle, + imageUrl: _extractSearchPlaylistArtwork(item.thumbnails), + ), + ); + if (out.length >= take) break; + } + + if (out.length >= take) break; + try { + page = await page.nextPage().timeout(_searchFallbackTimeout); + } catch (_) { + break; + } + pageGuard++; + } } return out.take(take).toList(growable: false); } + static String _extractSearchPlaylistArtwork(List thumbnails) { + if (thumbnails.isEmpty) return ''; + + Thumbnail? best; + var bestArea = -1; + for (final thumb in thumbnails) { + final area = thumb.width * thumb.height; + if (area > bestArea) { + bestArea = area; + best = thumb; + } + } + return best?.url.toString() ?? ''; + } + static YtmAlbum? _mapTwoRowRendererToAlbum(Map renderer) { final title = _textFromRuns(_asMap(renderer['title'])).trim(); if (title.isEmpty) return null; @@ -911,21 +1647,20 @@ class YoutubeApi { final endpoint = _extractAlbumNavigationEndpoint(renderer) ?? _asMap(renderer['navigationEndpoint']); - final browse = _asMap(endpoint?['browseEndpoint']); - final browseId = (browse?['browseId'] ?? '').toString().trim(); + final endpointInfo = _extractAlbumEndpointInfo(endpoint); + final browseId = endpointInfo.browseId; if (browseId.isEmpty) return null; - final pageType = - _asMap( - _asMap( - browse?['browseEndpointContextSupportedConfigs'], - )?['browseEndpointContextMusicConfig'], - )?['pageType']?.toString().toUpperCase() ?? - ''; final subtitle = _textFromRuns(_asMap(renderer['subtitle'])).trim(); - final looksLikeAlbum = - pageType.contains('ALBUM') || subtitle.toLowerCase().contains('album'); - if (!looksLikeAlbum) return null; + if (!_isLikelyAlbumResult( + pageType: endpointInfo.pageType, + browseId: browseId, + playlistId: endpointInfo.playlistId, + title: title, + subtitle: subtitle, + )) { + return null; + } final artworkUrl = _extractPreferredAlbumThumbnail( @@ -962,24 +1697,16 @@ class YoutubeApi { final title = _textFromRuns(firstText).trim(); if (title.isEmpty) return null; - String browseId = ''; - String pageType = ''; - for (final run in _asList(firstText?['runs'])) { - final browse = _asMap( - _asMap(_asMap(run)?['navigationEndpoint'])?['browseEndpoint'], - ); - final candidate = (browse?['browseId'] ?? '').toString().trim(); - if (candidate.isEmpty) continue; - browseId = candidate; - pageType = - _asMap( - _asMap( - browse?['browseEndpointContextSupportedConfigs'], - )?['browseEndpointContextMusicConfig'], - )?['pageType']?.toString().toUpperCase() ?? - ''; - break; + Map? endpoint = _asMap(renderer['navigationEndpoint']); + if (endpoint == null) { + for (final run in _asList(firstText?['runs'])) { + endpoint = _asMap(_asMap(run)?['navigationEndpoint']); + if (endpoint != null) break; + } } + + final endpointInfo = _extractAlbumEndpointInfo(endpoint); + final browseId = endpointInfo.browseId; if (browseId.isEmpty) return null; final secondColumn = columns.length > 1 @@ -988,9 +1715,15 @@ class YoutubeApi { ) : null; final subtitle = _textFromRuns(_asMap(secondColumn?['text'])).trim(); - final looksLikeAlbum = - pageType.contains('ALBUM') || subtitle.toLowerCase().contains('album'); - if (!looksLikeAlbum) return null; + if (!_isLikelyAlbumResult( + pageType: endpointInfo.pageType, + browseId: browseId, + playlistId: endpointInfo.playlistId, + title: title, + subtitle: subtitle, + )) { + return null; + } final artworkUrl = _extractPreferredAlbumThumbnail( @@ -1111,27 +1844,350 @@ class YoutubeApi { return out.take(take).toList(growable: false); } - static YtmArtist? _mapTwoRowRendererToArtist(Map renderer) { - final name = _textFromRuns(_asMap(renderer['title'])).trim(); - if (name.isEmpty) return null; + static YtmArtist? _mapTwoRowRendererToArtist(Map renderer) { + final name = _textFromRuns(_asMap(renderer['title'])).trim(); + if (name.isEmpty) return null; + + final endpoint = + _asMap(renderer['navigationEndpoint']) ?? + _extractAlbumNavigationEndpoint(renderer); + final browse = _asMap(endpoint?['browseEndpoint']); + final browseId = (browse?['browseId'] ?? '').toString().trim(); + if (browseId.isEmpty) return null; + + final pageType = _extractBrowsePageType(browse); + final subtitle = _textFromRuns(_asMap(renderer['subtitle'])).trim(); + final looksLikeArtist = + pageType.contains('ARTIST') || + browseId.toUpperCase().startsWith('UC') || + subtitle.toLowerCase().contains('artist'); + if (!looksLikeArtist) return null; + + final imageUrl = + _extractPreferredArtistThumbnail( + _asList( + _asMap( + _asMap( + _asMap( + renderer['thumbnailRenderer'], + )?['musicThumbnailRenderer'], + )?['thumbnail'], + )?['thumbnails'], + ), + ) ?? + ''; + + return YtmArtist( + browseId: browseId, + name: name, + subtitle: subtitle.isEmpty ? 'Artist' : subtitle, + imageUrl: imageUrl, + ); + } + + static YtmArtist? _mapResponsiveRendererToArtist( + Map renderer, + ) { + final columns = _asList(renderer['flexColumns']); + if (columns.isEmpty) return null; + + final firstColumn = _asMap( + _asMap(columns.first)?['musicResponsiveListItemFlexColumnRenderer'], + ); + final firstText = _asMap(firstColumn?['text']); + final name = _textFromRuns(firstText).trim(); + if (name.isEmpty) return null; + + String browseId = ''; + String pageType = ''; + for (final run in _asList(firstText?['runs'])) { + final browse = _asMap( + _asMap(_asMap(run)?['navigationEndpoint'])?['browseEndpoint'], + ); + final candidate = (browse?['browseId'] ?? '').toString().trim(); + if (candidate.isEmpty) continue; + browseId = candidate; + pageType = _extractBrowsePageType(browse); + break; + } + if (browseId.isEmpty) return null; + + final secondColumn = columns.length > 1 + ? _asMap( + _asMap(columns[1])?['musicResponsiveListItemFlexColumnRenderer'], + ) + : null; + final subtitle = _textFromRuns(_asMap(secondColumn?['text'])).trim(); + final looksLikeArtist = + pageType.contains('ARTIST') || + browseId.toUpperCase().startsWith('UC') || + subtitle.toLowerCase().contains('artist'); + if (!looksLikeArtist) return null; + + final imageUrl = + _extractPreferredArtistThumbnail( + _asList( + _asMap( + _asMap( + _asMap(renderer['thumbnail'])?['musicThumbnailRenderer'], + )?['thumbnail'], + )?['thumbnails'], + ), + ) ?? + ''; + + return YtmArtist( + browseId: browseId, + name: name, + subtitle: subtitle.isEmpty ? 'Artist' : subtitle, + imageUrl: imageUrl, + ); + } + + static _ArtistHeaderResult _parseArtistHeader( + Map payload, { + String fallbackName = '', + }) { + final headerMaps = >[]; + _collectMapsByKey(payload, 'musicImmersiveHeaderRenderer', headerMaps); + _collectMapsByKey(payload, 'musicDetailHeaderRenderer', headerMaps); + + var name = ''; + var imageUrl = ''; + final monthlyCandidates = []; + final bioCandidates = []; + + void addCandidate(List target, String value) { + final normalized = value.trim(); + if (normalized.isEmpty) return; + target.add(normalized); + } + + for (final header in headerMaps) { + final title = _textFromRuns(_asMap(header['title'])).trim(); + final subtitle = _textFromRuns(_asMap(header['subtitle'])).trim(); + final strapline = _textFromRuns(_asMap(header['strapline'])).trim(); + final description = _textFromRuns(_asMap(header['description'])).trim(); + final secondSubtitle = _textFromRuns( + _asMap(header['secondSubtitle']), + ).trim(); + + if (name.isEmpty && title.isNotEmpty) name = title; + if (imageUrl.isEmpty) { + imageUrl = _extractArtistHeaderImage(header); + } + + addCandidate(monthlyCandidates, subtitle); + addCandidate(monthlyCandidates, strapline); + addCandidate(monthlyCandidates, secondSubtitle); + addCandidate(monthlyCandidates, description); + addCandidate(bioCandidates, description); + } + + final descriptionShelves = >[]; + _collectMapsByKey( + payload, + 'musicDescriptionShelfRenderer', + descriptionShelves, + ); + for (final shelf in descriptionShelves) { + addCandidate(bioCandidates, _textFromRuns(_asMap(shelf['description']))); + addCandidate( + monthlyCandidates, + _textFromRuns(_asMap(shelf['description'])), + ); + addCandidate( + monthlyCandidates, + _textFromRuns(_asMap(shelf['subheader'])), + ); + } + + final monthlyAudience = _extractMonthlyAudienceText(monthlyCandidates); + final bio = bioCandidates + .map((e) => e.replaceAll(RegExp(r'\s+'), ' ').trim()) + .where((e) => e.isNotEmpty) + .fold( + '', + (best, current) => current.length > best.length ? current : best, + ); + + if (name.isEmpty) { + name = fallbackName.trim(); + } + + return _ArtistHeaderResult( + name: name, + imageUrl: imageUrl, + bio: bio, + monthlyAudience: monthlyAudience, + ); + } + + static String _extractMonthlyAudienceText(List candidates) { + if (candidates.isEmpty) return ''; + + final monthlyPattern = RegExp( + r'([0-9][0-9\.,]*\s*[KMB]?)\s+monthly\s+(audience|listeners?)', + caseSensitive: false, + ); + for (final candidate in candidates) { + final match = monthlyPattern.firstMatch(candidate); + if (match == null) continue; + final value = match.group(1)?.trim() ?? ''; + final unit = match.group(2)?.trim().toLowerCase() ?? 'audience'; + if (value.isEmpty) continue; + return '$value monthly $unit'; + } + + for (final candidate in candidates) { + final normalized = candidate.toLowerCase(); + if (normalized.contains('monthly audience') || + normalized.contains('monthly listeners')) { + return candidate.trim(); + } + } + + return ''; + } + + static String _extractArtistHeaderImage(Map header) { + final thumbsCandidates = >[ + _asList( + _asMap( + _asMap( + _asMap(header['thumbnail'])?['musicThumbnailRenderer'], + )?['thumbnail'], + )?['thumbnails'], + ), + _asList(_asMap(header['thumbnail'])?['thumbnails']), + _asList( + _asMap( + _asMap( + _asMap(header['foregroundThumbnail'])?['musicThumbnailRenderer'], + )?['thumbnail'], + )?['thumbnails'], + ), + _asList( + _asMap( + _asMap( + _asMap(header['backgroundThumbnail'])?['musicThumbnailRenderer'], + )?['thumbnail'], + )?['thumbnails'], + ), + ]; + + for (final thumbs in thumbsCandidates) { + if (thumbs.isEmpty) continue; + final resolved = _extractPreferredArtistThumbnail(thumbs); + if (resolved != null && resolved.trim().isNotEmpty) { + return resolved.trim(); + } + } + return ''; + } + + static List _extractSongsFromPayload( + Map payload, { + required int take, + }) { + if (take <= 0) return const []; + + final renderers = >[]; + _collectMapsByKey(payload, 'musicResponsiveListItemRenderer', renderers); + + final songs = []; + final seen = {}; + for (final renderer in renderers) { + final mapped = _mapYtmRendererToSong(renderer); + if (mapped == null) continue; + if (!seen.add(mapped.id)) continue; + songs.add(mapped); + if (songs.length >= take) break; + } + return songs.take(take).toList(growable: false); + } + + static List _parseArtistReleasesFromShelves( + Map payload, { + required int take, + required _ArtistReleaseSection section, + }) { + if (take <= 0) return const []; + + final shelfRenderers = >[]; + _collectMapsByKey(payload, 'musicCarouselShelfRenderer', shelfRenderers); + + final out = []; + final seen = {}; + for (final shelf in shelfRenderers) { + final shelfTitle = _extractCarouselShelfTitle(shelf); + if (!_isMatchingArtistReleaseShelf(shelfTitle, section)) { + continue; + } + + final twoRows = >[]; + final responsiveRows = >[]; + _collectMapsByKey(shelf, 'musicTwoRowItemRenderer', twoRows); + _collectMapsByKey( + shelf, + 'musicResponsiveListItemRenderer', + responsiveRows, + ); + + for (final renderer in twoRows) { + final release = _mapTwoRowRendererToRelease(renderer); + if (release == null) continue; + if (!seen.add(release.browseId.toLowerCase())) continue; + out.add(release); + if (out.length >= take) return out.take(take).toList(growable: false); + } + + for (final renderer in responsiveRows) { + final release = _mapResponsiveRendererToRelease(renderer); + if (release == null) continue; + if (!seen.add(release.browseId.toLowerCase())) continue; + out.add(release); + if (out.length >= take) return out.take(take).toList(growable: false); + } + } + + return out.take(take).toList(growable: false); + } + + static bool _isMatchingArtistReleaseShelf( + String shelfTitle, + _ArtistReleaseSection section, + ) { + final normalized = _normalizeShelfTitleForMatching(shelfTitle); + if (normalized.isEmpty) return false; + + final hasAlbum = normalized.contains('album'); + final hasSingle = normalized.contains('single'); + final hasEp = RegExp(r'(^|\s)eps?($|\s)').hasMatch(normalized); + + if (section == _ArtistReleaseSection.albums) { + if (!hasAlbum) return false; + return !hasSingle && !hasEp; + } + + return hasSingle || hasEp; + } + + static YtmAlbum? _mapTwoRowRendererToRelease(Map renderer) { + final title = _textFromRuns(_asMap(renderer['title'])).trim(); + if (title.isEmpty) return null; final endpoint = - _asMap(renderer['navigationEndpoint']) ?? - _extractAlbumNavigationEndpoint(renderer); - final browse = _asMap(endpoint?['browseEndpoint']); - final browseId = (browse?['browseId'] ?? '').toString().trim(); + _extractAlbumNavigationEndpoint(renderer) ?? + _asMap(renderer['navigationEndpoint']); + final endpointInfo = _extractAlbumEndpointInfo(endpoint); + final browseId = endpointInfo.browseId.trim(); if (browseId.isEmpty) return null; + if (browseId.toUpperCase().startsWith('UC')) return null; - final pageType = _extractBrowsePageType(browse); final subtitle = _textFromRuns(_asMap(renderer['subtitle'])).trim(); - final looksLikeArtist = - pageType.contains('ARTIST') || - browseId.toUpperCase().startsWith('UC') || - subtitle.toLowerCase().contains('artist'); - if (!looksLikeArtist) return null; - - final imageUrl = - _extractPreferredArtistThumbnail( + final artworkUrl = + _extractPreferredAlbumThumbnail( _asList( _asMap( _asMap( @@ -1144,15 +2200,15 @@ class YoutubeApi { ) ?? ''; - return YtmArtist( + return YtmAlbum( browseId: browseId, - name: name, - subtitle: subtitle.isEmpty ? 'Artist' : subtitle, - imageUrl: imageUrl, + title: title, + subtitle: subtitle.isEmpty ? 'Release - YouTube Music' : subtitle, + imageUrl: artworkUrl, ); } - static YtmArtist? _mapResponsiveRendererToArtist( + static YtmAlbum? _mapResponsiveRendererToRelease( Map renderer, ) { final columns = _asList(renderer['flexColumns']); @@ -1162,22 +2218,21 @@ class YoutubeApi { _asMap(columns.first)?['musicResponsiveListItemFlexColumnRenderer'], ); final firstText = _asMap(firstColumn?['text']); - final name = _textFromRuns(firstText).trim(); - if (name.isEmpty) return null; + final title = _textFromRuns(firstText).trim(); + if (title.isEmpty) return null; - String browseId = ''; - String pageType = ''; - for (final run in _asList(firstText?['runs'])) { - final browse = _asMap( - _asMap(_asMap(run)?['navigationEndpoint'])?['browseEndpoint'], - ); - final candidate = (browse?['browseId'] ?? '').toString().trim(); - if (candidate.isEmpty) continue; - browseId = candidate; - pageType = _extractBrowsePageType(browse); - break; + Map? endpoint = _asMap(renderer['navigationEndpoint']); + if (endpoint == null) { + for (final run in _asList(firstText?['runs'])) { + endpoint = _asMap(_asMap(run)?['navigationEndpoint']); + if (endpoint != null) break; + } } + + final endpointInfo = _extractAlbumEndpointInfo(endpoint); + final browseId = endpointInfo.browseId.trim(); if (browseId.isEmpty) return null; + if (browseId.toUpperCase().startsWith('UC')) return null; final secondColumn = columns.length > 1 ? _asMap( @@ -1185,14 +2240,9 @@ class YoutubeApi { ) : null; final subtitle = _textFromRuns(_asMap(secondColumn?['text'])).trim(); - final looksLikeArtist = - pageType.contains('ARTIST') || - browseId.toUpperCase().startsWith('UC') || - subtitle.toLowerCase().contains('artist'); - if (!looksLikeArtist) return null; - final imageUrl = - _extractPreferredArtistThumbnail( + final artworkUrl = + _extractPreferredAlbumThumbnail( _asList( _asMap( _asMap( @@ -1203,11 +2253,11 @@ class YoutubeApi { ) ?? ''; - return YtmArtist( + return YtmAlbum( browseId: browseId, - name: name, - subtitle: subtitle.isEmpty ? 'Artist' : subtitle, - imageUrl: imageUrl, + title: title, + subtitle: subtitle.isEmpty ? 'Release - YouTube Music' : subtitle, + imageUrl: artworkUrl, ); } @@ -1304,6 +2354,89 @@ class YoutubeApi { return null; } + static ({String browseId, String playlistId, String pageType}) + _extractAlbumEndpointInfo(Map? endpoint) { + final browse = _asMap(endpoint?['browseEndpoint']); + final watch = _asMap(endpoint?['watchEndpoint']); + final watchPlaylist = _asMap(endpoint?['watchPlaylistEndpoint']); + + var browseId = (browse?['browseId'] ?? '').toString().trim(); + final playlistId = + (watch?['playlistId'] ?? watchPlaylist?['playlistId'] ?? '') + .toString() + .trim(); + final pageType = _extractBrowsePageType(browse); + + if (browseId.isEmpty && playlistId.isNotEmpty) { + browseId = _toYtmBrowseId(playlistId); + } + + return (browseId: browseId, playlistId: playlistId, pageType: pageType); + } + + static bool _isLikelyAlbumResult({ + required String pageType, + required String browseId, + required String playlistId, + required String title, + required String subtitle, + }) { + if (_isExcludedAlbumLikeResult(title) || + _isExcludedAlbumLikeResult(subtitle)) { + return false; + } + + final normalizedPageType = pageType.toUpperCase(); + if (normalizedPageType.contains('ARTIST') || + normalizedPageType.contains('CHANNEL')) { + return false; + } + if (normalizedPageType.contains('ALBUM')) return true; + + if (_looksLikeAlbumId(browseId) || _looksLikeAlbumId(playlistId)) { + return true; + } + + return _looksLikeAlbumSubtitle(subtitle); + } + + static bool _looksLikeAlbumId(String value) { + final raw = value.trim().toUpperCase(); + if (raw.isEmpty) return false; + return raw.startsWith('MPRE') || + raw.startsWith('VLMPR') || + raw.startsWith('OLAK5') || + raw.startsWith('VLOLAK5'); + } + + static bool _looksLikeAlbumSubtitle(String subtitle) { + final normalized = subtitle.toLowerCase().trim(); + if (normalized.isEmpty) return false; + if (normalized.contains('album')) { + return true; + } + + // Album rows often look like: "Artist • 2024". + final hasYear = RegExp(r'\b(19|20)\d{2}\b').hasMatch(normalized); + final hasBulletSeparator = + normalized.contains('\u2022') || normalized.contains('\u00b7'); + if (hasYear && hasBulletSeparator) return true; + + return false; + } + + static bool _isExcludedAlbumLikeResult(String text) { + final normalized = text.toLowerCase().trim(); + if (normalized.isEmpty) return false; + + if (RegExp(r'\bepisodes?\b').hasMatch(normalized)) return true; + if (RegExp(r'\bpodcast\b').hasMatch(normalized)) return true; + if (RegExp(r'\bsingles?\b').hasMatch(normalized)) return true; + if (RegExp(r'(^|[^\w])ep([^\w]|$)').hasMatch(normalized)) return true; + + return false; + } + static String _toYtmBrowseId(String idOrBrowseId) { final raw = idOrBrowseId.trim(); if (raw.isEmpty) return raw; @@ -1996,6 +3129,105 @@ class YoutubeApi { return false; } + static Future> _searchArtistsViaYoutubeExplode({ + required String query, + required int take, + }) async { + final normalized = query.trim(); + if (normalized.isEmpty) return const []; + + final out = []; + final seen = {}; + final queries = [ + normalized, + '$normalized artist', + '$normalized topic', + ]; + final seenQueries = {}; + + for (final q in queries) { + if (out.length >= take) break; + final effectiveQuery = q.trim(); + if (effectiveQuery.isEmpty) continue; + if (!seenQueries.add(effectiveQuery.toLowerCase())) continue; + + try { + final firstPage = await _yt.search + .searchContent(effectiveQuery, filter: TypeFilters.channel) + .timeout(_searchFallbackTimeout); + final channels = [ + ...firstPage.whereType(), + ]; + var currentPage = firstPage; + var pageGuard = 0; + + while (channels.length < take * 3 && pageGuard < 1) { + final nextPage = await currentPage.nextPage().timeout( + _searchFallbackTimeout, + ); + if (nextPage == null || nextPage.isEmpty) break; + channels.addAll(nextPage.whereType()); + currentPage = nextPage; + pageGuard++; + } + + for (final item in channels) { + final id = item.id.value.trim(); + final name = item.name.trim(); + if (id.isEmpty || name.isEmpty) continue; + if (!seen.add(id.toLowerCase())) continue; + + out.add( + YtmArtist( + browseId: id, + name: name, + subtitle: 'Artist', + imageUrl: _bestExplodeChannelThumbnail(item.thumbnails), + ), + ); + if (out.length >= take) break; + } + } catch (_) { + continue; + } + } + + return out.take(take).toList(growable: false); + } + + static YtmArtist _pickBestArtistCandidate( + List candidates, + String artistName, + ) { + if (candidates.isEmpty) { + return const YtmArtist( + browseId: '', + name: '', + subtitle: 'Artist', + imageUrl: '', + ); + } + + final target = artistName.toLowerCase().trim(); + if (target.isEmpty) return candidates.first; + + int score(YtmArtist artist) { + final name = artist.name.toLowerCase().trim(); + var value = 0; + if (name == target) value += 100; + if (name.contains(target)) value += 40; + if (target.contains(name) && name.isNotEmpty) value += 25; + if (name.startsWith(target)) value += 18; + if (artist.subtitle.toLowerCase().contains('artist')) value += 10; + if (artist.browseId.toUpperCase().startsWith('UC')) value += 6; + return value; + } + + final sorted = [...candidates] + ..sort((a, b) => score(b).compareTo(score(a))); + return sorted.first; + } + static Future> _trendingArtistsViaYoutubeExplode({ required int take, }) async { @@ -2010,7 +3242,7 @@ class YoutubeApi { for (final query in queries) { try { final firstPage = await _yt.search - .search(query, filter: TypeFilters.channel) + .searchContent(query, filter: TypeFilters.channel) .timeout(_searchFallbackTimeout); final channels = [ ...firstPage.whereType(), @@ -2810,6 +4042,15 @@ class YoutubeApi { _artistsCache.remove(keys[i]); } } + + static void _trimArtistProfileCache({required int maxEntries}) { + if (_artistProfileCache.length <= maxEntries) return; + final keys = _artistProfileCache.keys.toList(growable: false); + final removeCount = _artistProfileCache.length - maxEntries; + for (var i = 0; i < removeCount; i++) { + _artistProfileCache.remove(keys[i]); + } + } } class _TimedSongsCache { @@ -2856,6 +4097,17 @@ class _TimedArtistsCache { } } +class _TimedArtistProfileCache { + final DateTime timestamp; + final YtmArtistProfile profile; + + _TimedArtistProfileCache(this.profile) : timestamp = DateTime.now(); + + bool isExpired(Duration ttl) { + return DateTime.now().difference(timestamp) > ttl; + } +} + class _YtmSongsPage { final List songs; final String? continuation; @@ -2942,3 +4194,188 @@ class YtmArtist { required this.imageUrl, }); } + +class YtmArtistProfile { + final String browseId; + final String name; + final String imageUrl; + final String bio; + final String monthlyAudience; + final List topSongs; + final List albums; + final List singlesAndEps; + + const YtmArtistProfile({ + required this.browseId, + required this.name, + required this.imageUrl, + required this.bio, + required this.monthlyAudience, + required this.topSongs, + required this.albums, + required this.singlesAndEps, + }); +} + +class _ArtistHeaderResult { + final String name; + final String imageUrl; + final String bio; + final String monthlyAudience; + + const _ArtistHeaderResult({ + required this.name, + required this.imageUrl, + required this.bio, + required this.monthlyAudience, + }); +} + +enum _ArtistReleaseSection { albums, singlesAndEps } + +class _PersistedSearchEntry { + final String key; + final DateTime timestamp; + final List songs; + + const _PersistedSearchEntry({ + required this.key, + required this.timestamp, + required this.songs, + }); + + Duration get age => DateTime.now().difference(timestamp); + + List limitedSongs(int take) { + return songs.take(take).toList(growable: false); + } + + Map toJson() { + return { + 'key': key, + 'timestamp': timestamp.millisecondsSinceEpoch, + 'songs': songs.map(_songToJson).toList(growable: false), + }; + } + + static _PersistedSearchEntry? fromJson(Map json) { + final key = (json['key'] ?? '').toString().trim(); + if (key.isEmpty) return null; + + final timestampMs = (json['timestamp'] as num?)?.toInt(); + if (timestampMs == null || timestampMs <= 0) return null; + + final songsRaw = json['songs']; + if (songsRaw is! List) return null; + + final songs = []; + for (final item in songsRaw) { + final map = YoutubeApi._asMap(item); + if (map == null) continue; + final song = _songFromJson(map); + if (song != null) songs.add(song); + } + if (songs.isEmpty) return null; + + return _PersistedSearchEntry( + key: key, + timestamp: DateTime.fromMillisecondsSinceEpoch(timestampMs), + songs: List.unmodifiable(songs), + ); + } + + static Map _songToJson(SaavnSong song) { + return { + 'id': song.id, + 'name': song.name, + 'artists': song.artists, + 'imageUrl': song.imageUrl, + 'duration': song.duration, + 'downloadUrls': song.downloadUrls, + }; + } + + static SaavnSong? _songFromJson(Map json) { + final id = (json['id'] ?? '').toString().trim(); + final name = (json['name'] ?? '').toString().trim(); + final artists = (json['artists'] ?? '').toString().trim(); + if (id.isEmpty || name.isEmpty) return null; + + final imageUrl = (json['imageUrl'] ?? '').toString().trim(); + final duration = (json['duration'] as num?)?.toInt(); + + final downloadUrls = >[]; + final rawDownloadUrls = json['downloadUrls']; + if (rawDownloadUrls is List) { + for (final entry in rawDownloadUrls) { + final map = YoutubeApi._asMap(entry); + if (map == null) continue; + downloadUrls.add({ + 'quality': (map['quality'] ?? '').toString(), + 'url': (map['url'] ?? '').toString(), + }); + } + } + + return SaavnSong( + id: id, + name: name, + artists: artists.isEmpty ? 'Unknown' : artists, + imageUrl: imageUrl, + duration: duration, + downloadUrls: downloadUrls, + ); + } +} + +class _StrategyHealth { + static const int _maxCounter = 800; + + int _successCount = 0; + int _failureCount = 0; + int _consecutiveFailures = 0; + DateTime? _cooldownUntil; + + bool get isCoolingDown { + final until = _cooldownUntil; + if (until == null) return false; + return DateTime.now().isBefore(until); + } + + double get score { + final total = _successCount + _failureCount; + final base = total == 0 ? 0.65 : (_successCount / total); + final penalty = (_consecutiveFailures * 0.07).clamp(0.0, 0.42); + return (base - penalty).clamp(0.05, 1.0); + } + + void recordSuccess() { + _successCount = (_successCount + 1).clamp(0, _maxCounter); + _consecutiveFailures = 0; + _cooldownUntil = null; + _decayIfNeeded(); + } + + void recordFailure({required bool retryable}) { + _failureCount = (_failureCount + 1).clamp(0, _maxCounter); + _consecutiveFailures = (_consecutiveFailures + 1).clamp(0, 8); + + if (retryable && _consecutiveFailures >= 2) { + final seconds = switch (_consecutiveFailures) { + 2 => 20, + 3 => 45, + 4 => 90, + 5 => 180, + _ => 300, + }; + _cooldownUntil = DateTime.now().add(Duration(seconds: seconds)); + } + _decayIfNeeded(); + } + + void _decayIfNeeded() { + if (_successCount < _maxCounter && _failureCount < _maxCounter) return; + _successCount = (_successCount * 0.7).round(); + _failureCount = (_failureCount * 0.7).round(); + } +} diff --git a/lib/data/api/youtube_song_api.dart b/lib/data/api/youtube_song_api.dart index f080e5d..54cad9d 100644 --- a/lib/data/api/youtube_song_api.dart +++ b/lib/data/api/youtube_song_api.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:youtube_explode_dart/youtube_explode_dart.dart'; @@ -11,9 +12,16 @@ class YoutubeSongApi { static const Duration _primaryTimeout = Duration(seconds: 14); static const Duration _retryTimeout = Duration(seconds: 8); + static const Duration _streamMemoryTtl = Duration(hours: 1); + static const Duration _streamPersistFreshTtl = Duration(minutes: 30); + static const Duration _streamPersistStaleTtl = Duration(hours: 6); + static const String _streamPersistKey = 'yt_stream_cache_v1'; + static const int _streamPersistMaxEntries = 220; static final Map _streamCache = {}; static final Map> _inFlight = {}; + static final Map _attemptHealth = {}; + static Future _streamPersistWriteQueue = Future.value(); static const Map _defaultHeaders = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' @@ -33,16 +41,44 @@ class YoutubeSongApi { final cacheKey = _cacheKey(normalized, dataSaver: dataSaver); final cached = _streamCache[cacheKey]; - if (cached != null && !cached.isExpired(const Duration(hours: 1))) { + if (cached != null && !cached.isExpired(_streamMemoryTtl)) { return cached.stream; } + final persisted = await _readPersistedStreamEntry(cacheKey); + final hasPersistedFresh = + persisted != null && !persisted.isExpired(_streamPersistFreshTtl); + final hasPersistedStale = + persisted != null && !persisted.isExpired(_streamPersistStaleTtl); + final persistedStream = persisted?.stream; + + if (hasPersistedFresh && persistedStream != null) { + _streamCache[cacheKey] = _TimedStreamCache(persistedStream); + return persistedStream; + } + + if (hasPersistedStale && persistedStream != null) { + _streamCache[cacheKey] = _TimedStreamCache(persistedStream); + unawaited( + _refreshStreamInBackground( + normalized, + dataSaver: dataSaver, + cacheKey: cacheKey, + ), + ); + return persistedStream; + } + final inFlight = _inFlight[cacheKey]; if (inFlight != null) { return inFlight; } - final future = _fetchBestStreamInternal(normalized, dataSaver: dataSaver); + final future = _fetchBestStreamInternal( + normalized, + dataSaver: dataSaver, + fallbackStale: hasPersistedStale ? persistedStream : null, + ); _inFlight[cacheKey] = future; try { @@ -62,6 +98,7 @@ class YoutubeSongApi { static Future _fetchBestStreamInternal( String normalized, { required bool dataSaver, + YoutubeExtractedStream? fallbackStale, }) async { AppLogger.info('Extracting stream via youtube_explode for: $normalized'); const maxTransientAttempts = 3; @@ -70,10 +107,11 @@ class YoutubeSongApi { for (var attempt = 1; attempt <= maxTransientAttempts; attempt++) { try { final stream = await _extractBestStreamOnce(normalized, dataSaver); - - _streamCache[_cacheKey(normalized, dataSaver: dataSaver)] = - _TimedStreamCache(stream); - _trimCache(_streamCache, maxEntries: 250); + await _cacheResolvedStream( + normalized, + dataSaver: dataSaver, + stream: stream, + ); return stream; } catch (e, st) { lastError = e; @@ -93,6 +131,9 @@ class YoutubeSongApi { } } + if (fallbackStale != null) { + return fallbackStale; + } throw lastError ?? Exception('No playable stream URL returned'); } @@ -109,6 +150,36 @@ class YoutubeSongApi { return YoutubeExtractedStream(url, _defaultHeaders); } + static Future _refreshStreamInBackground( + String normalized, { + required bool dataSaver, + required String cacheKey, + }) async { + final inFlight = _inFlight[cacheKey]; + if (inFlight != null) return; + + try { + await _fetchBestStreamInternal( + normalized, + dataSaver: dataSaver, + fallbackStale: null, + ); + } catch (_) { + // Keep stale stream cache when refresh fails. + } + } + + static Future _cacheResolvedStream( + String normalized, { + required bool dataSaver, + required YoutubeExtractedStream stream, + }) async { + final cacheKey = _cacheKey(normalized, dataSaver: dataSaver); + _streamCache[cacheKey] = _TimedStreamCache(stream); + _trimCache(_streamCache, maxEntries: 250); + await _writePersistedStreamEntry(cacheKey, stream); + } + static Future _getManifestWithFallback(String videoId) async { final attempts = <_ManifestAttempt>[ const _ManifestAttempt( @@ -137,20 +208,24 @@ class YoutubeSongApi { ), ]; + final ordered = _orderAttemptsByHealth(attempts); Object? lastError; - for (var i = 0; i < attempts.length; i++) { - final attempt = attempts[i]; + for (var i = 0; i < ordered.length; i++) { + final attempt = ordered[i]; try { - return await _yt.videos.streamsClient + final manifest = await _yt.videos.streamsClient .getManifest( videoId, ytClients: attempt.ytClients, requireWatchPage: attempt.requireWatchPage, ) .timeout(attempt.timeout); + _recordAttemptSuccess(attempt.label); + return manifest; } catch (e) { lastError = e; - final hasNext = i < attempts.length - 1; + _recordAttemptFailure(attempt.label, e); + final hasNext = i < ordered.length - 1; if (!hasNext) break; AppLogger.warning( 'Extraction fallback after "${attempt.label}": $e', @@ -163,6 +238,51 @@ class YoutubeSongApi { throw lastError ?? Exception('No playable stream URL returned'); } + static List<_ManifestAttempt> _orderAttemptsByHealth( + List<_ManifestAttempt> attempts, + ) { + final available = attempts + .where((a) => _isAttemptAvailable(a.label)) + .toList(growable: false); + final base = available.isEmpty ? attempts : available; + final ordered = List<_ManifestAttempt>.from(base); + + ordered.sort((a, b) { + final scoreA = _attemptScore(a.label); + final scoreB = _attemptScore(b.label); + if ((scoreA - scoreB).abs() >= 0.08) { + return scoreB.compareTo(scoreA); + } + final indexA = attempts.indexOf(a); + final indexB = attempts.indexOf(b); + return indexA.compareTo(indexB); + }); + + return ordered; + } + + static bool _isAttemptAvailable(String label) { + final health = _attemptHealth[label]; + if (health == null) return true; + return !health.isCoolingDown; + } + + static double _attemptScore(String label) { + final health = _attemptHealth[label]; + if (health == null) return 0.65; + return health.score; + } + + static void _recordAttemptSuccess(String label) { + final health = _attemptHealth.putIfAbsent(label, _StrategyHealth.new); + health.recordSuccess(); + } + + static void _recordAttemptFailure(String label, Object error) { + final health = _attemptHealth.putIfAbsent(label, _StrategyHealth.new); + health.recordFailure(retryable: _isRetryableTransientExtractError(error)); + } + static AudioOnlyStreamInfo _selectAudioStream( StreamManifest manifest, { required bool dataSaver, @@ -239,6 +359,89 @@ class YoutubeSongApi { return enabled; } + static Future<_PersistedStreamEntry?> _readPersistedStreamEntry( + String cacheKey, + ) async { + try { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_streamPersistKey); + if (raw == null || raw.trim().isEmpty) return null; + final decoded = jsonDecode(raw); + if (decoded is! List) return null; + + for (final item in decoded) { + final map = _asMap(item); + if (map == null) continue; + final entry = _PersistedStreamEntry.fromJson(map); + if (entry == null) continue; + if (entry.key == cacheKey) return entry; + } + } catch (_) { + return null; + } + return null; + } + + static Future _writePersistedStreamEntry( + String cacheKey, + YoutubeExtractedStream stream, + ) async { + _streamPersistWriteQueue = _streamPersistWriteQueue.then((_) async { + try { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_streamPersistKey); + + final entries = {}; + if (raw != null && raw.trim().isNotEmpty) { + final decoded = jsonDecode(raw); + if (decoded is List) { + for (final item in decoded) { + final map = _asMap(item); + if (map == null) continue; + final entry = _PersistedStreamEntry.fromJson(map); + if (entry == null) continue; + if (entry.isExpired(const Duration(days: 1))) continue; + entries[entry.key] = entry; + } + } + } + + entries[cacheKey] = _PersistedStreamEntry( + key: cacheKey, + timestamp: DateTime.now(), + stream: stream, + ); + + final sorted = entries.values.toList(growable: false) + ..sort((a, b) => b.timestamp.compareTo(a.timestamp)); + final trimmed = sorted + .take(_streamPersistMaxEntries) + .toList(growable: false); + + final encoded = jsonEncode( + trimmed.map((e) => e.toJson()).toList(growable: false), + ); + await prefs.setString(_streamPersistKey, encoded); + } catch (_) { + // Ignore persistence errors. + } + }); + + return _streamPersistWriteQueue; + } + + static Map? _asMap(dynamic value) { + if (value is Map) return value; + if (value is Map) { + final casted = {}; + value.forEach((k, v) { + casted[k.toString()] = v; + }); + return casted; + } + return null; + } + static void _trimCache( Map cache, { required int maxEntries, @@ -283,3 +486,103 @@ class _ManifestAttempt { required this.timeout, }); } + +class _PersistedStreamEntry { + final String key; + final DateTime timestamp; + final YoutubeExtractedStream stream; + + const _PersistedStreamEntry({ + required this.key, + required this.timestamp, + required this.stream, + }); + + bool isExpired(Duration ttl) { + return DateTime.now().difference(timestamp) > ttl; + } + + Map toJson() { + return { + 'key': key, + 'timestamp': timestamp.millisecondsSinceEpoch, + 'url': stream.url, + 'headers': stream.headers, + }; + } + + static _PersistedStreamEntry? fromJson(Map json) { + final key = (json['key'] ?? '').toString().trim(); + final timestampMs = (json['timestamp'] as num?)?.toInt(); + final url = (json['url'] ?? '').toString().trim(); + if (key.isEmpty || timestampMs == null || timestampMs <= 0 || url.isEmpty) { + return null; + } + + final headers = {}; + final rawHeaders = YoutubeSongApi._asMap(json['headers']); + if (rawHeaders != null) { + rawHeaders.forEach((k, v) { + headers[k] = v.toString(); + }); + } + + return _PersistedStreamEntry( + key: key, + timestamp: DateTime.fromMillisecondsSinceEpoch(timestampMs), + stream: YoutubeExtractedStream(url, headers), + ); + } +} + +class _StrategyHealth { + static const int _maxCounter = 600; + + int _successCount = 0; + int _failureCount = 0; + int _consecutiveFailures = 0; + DateTime? _cooldownUntil; + + bool get isCoolingDown { + final until = _cooldownUntil; + if (until == null) return false; + return DateTime.now().isBefore(until); + } + + double get score { + final total = _successCount + _failureCount; + final base = total == 0 ? 0.65 : (_successCount / total); + final penalty = (_consecutiveFailures * 0.07).clamp(0.0, 0.42); + return (base - penalty).clamp(0.05, 1.0); + } + + void recordSuccess() { + _successCount = (_successCount + 1).clamp(0, _maxCounter); + _consecutiveFailures = 0; + _cooldownUntil = null; + _decayIfNeeded(); + } + + void recordFailure({required bool retryable}) { + _failureCount = (_failureCount + 1).clamp(0, _maxCounter); + _consecutiveFailures = (_consecutiveFailures + 1).clamp(0, 8); + + if (retryable && _consecutiveFailures >= 2) { + final seconds = switch (_consecutiveFailures) { + 2 => 20, + 3 => 45, + 4 => 90, + 5 => 180, + _ => 300, + }; + _cooldownUntil = DateTime.now().add(Duration(seconds: seconds)); + } + _decayIfNeeded(); + } + + void _decayIfNeeded() { + if (_successCount < _maxCounter && _failureCount < _maxCounter) return; + _successCount = (_successCount * 0.7).round(); + _failureCount = (_failureCount * 0.7).round(); + } +} diff --git a/lib/features/home/home_screen.dart b/lib/features/home/home_screen.dart index 593785e..6bf4f17 100644 --- a/lib/features/home/home_screen.dart +++ b/lib/features/home/home_screen.dart @@ -1,8 +1,6 @@ -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import '../../core/theme/app_theme.dart'; +import '../../core/theme/responsive.dart'; import '../../core/utils/app_distribution.dart'; import '../../core/utils/app_update_service.dart'; import '../library/library_screen.dart'; @@ -72,28 +70,133 @@ class _HomeScreenState extends State { }); } + void _onDestinationSelected(int nextIndex, {required bool isLocalMode}) { + if (nextIndex == _index) return; + if (nextIndex == 1 && isLocalMode) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Enable Saavn or YouTube in Settings to use Library'), + ), + ); + return; + } + setState(() => _index = nextIndex); + } + + String _labelForIndex(int index) { + switch (index) { + case 0: + return 'Search'; + case 1: + return 'Library'; + default: + return 'Settings'; + } + } + + IconData _iconForIndex(int index, {required bool selected}) { + switch (index) { + case 0: + return selected ? Icons.search : Icons.search_outlined; + case 1: + return selected ? Icons.library_music : Icons.library_music_outlined; + default: + return selected ? Icons.settings : Icons.settings_outlined; + } + } + + Widget _buildQuickTabSwitcher({ + required BuildContext context, + required int displayIndex, + required bool isLocalMode, + required double top, + required double bottom, + }) { + final scheme = Theme.of(context).colorScheme; + + Widget buildQuickIcon(int index) { + final selected = index == displayIndex; + final disabled = index == 1 && isLocalMode; + final icon = _iconForIndex(index, selected: selected); + final iconColor = disabled + ? scheme.onSurface.withValues(alpha: 0.35) + : selected + ? scheme.onSecondaryContainer + : scheme.onSurfaceVariant; + + return Tooltip( + message: _labelForIndex(index), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: disabled + ? null + : () => _onDestinationSelected(index, isLocalMode: isLocalMode), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + width: 42, + height: 42, + decoration: BoxDecoration( + color: selected + ? scheme.secondaryContainer.withValues(alpha: 0.95) + : Colors.transparent, + borderRadius: BorderRadius.circular(14), + ), + child: Icon(icon, size: 22, color: iconColor), + ), + ), + ), + ); + } + + return Positioned( + top: top, + right: 8, + bottom: bottom, + child: Align( + alignment: Alignment.centerRight, + child: Material( + color: scheme.surfaceContainerHighest.withValues(alpha: 0.9), + elevation: 4, + shadowColor: scheme.shadow.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(18), + child: Container( + width: 50, + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: scheme.outlineVariant.withValues(alpha: 0.4), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + buildQuickIcon(0), + const SizedBox(height: 16), + buildQuickIcon(1), + const SizedBox(height: 16), + buildQuickIcon(2), + ], + ), + ), + ), + ), + ); + } + @override Widget build(BuildContext context) { - final themeProvider = Provider.of(context); - final bottomInset = MediaQuery.viewPaddingOf(context).bottom; - final keyboardHeight = MediaQuery.viewInsetsOf(context).bottom; - const navBottomPadding = 12.0; - final isButtonNavigation = bottomInset >= 24.0; - final samsungSafeBaseGap = (12.0 - (bottomInset * 0.2)) - .clamp(4.0, 12.0) - .toDouble(); - final nonSamsungLiftBoost = isButtonNavigation ? 0.0 : 10.0; - final samsungDownAdjust = isButtonNavigation ? -2.0 : 0.0; - final adaptiveGap = - (samsungSafeBaseGap + nonSamsungLiftBoost + samsungDownAdjust) - .clamp(8.0, 24.0) - .toDouble(); - final miniGapAboveNav = isButtonNavigation ? 16.0 : adaptiveGap; - final miniPlayerBottom = - bottomInset + - navBottomPadding + - kBottomNavigationBarHeight + - miniGapAboveNav; + final media = MediaQuery.of(context); + final bottomInset = media.viewPadding.bottom; + final keyboardHeight = media.viewInsets.bottom; + final quickSwitcherTop = media.padding.top + 90; + final quickSwitcherBottom = keyboardHeight == 0 + ? (108 + bottomInset) + : 24.0; + final miniPlayerBottom = 12 + bottomInset; final isLocalMode = !_useYoutube && !_useSaavn; @@ -106,76 +209,47 @@ class _HomeScreenState extends State { int displayIndex = _index; if (displayIndex >= tabs.length) displayIndex = 0; + Widget buildIndexedContent() { + return IndexedStack( + index: displayIndex, + children: List.generate( + tabs.length, + (i) => RepaintBoundary( + child: TickerMode(enabled: i == displayIndex, child: tabs[i]), + ), + ), + ); + } + return Scaffold( - extendBody: true, body: Stack( children: [ - IndexedStack( - index: displayIndex, - children: List.generate( - tabs.length, - (i) => RepaintBoundary( - child: TickerMode(enabled: i == displayIndex, child: tabs[i]), - ), - ), - ), + Positioned.fill(child: buildIndexedContent()), if (keyboardHeight == 0) Positioned( - left: 0, - right: 0, + left: 16, + right: 16, bottom: miniPlayerBottom, - child: const MiniPlayer(), - ), - ], - ), - bottomNavigationBar: Padding( - padding: EdgeInsets.fromLTRB(12, 0, 12, navBottomPadding + bottomInset), - child: ClipRRect( - borderRadius: BorderRadius.circular(24), - child: Container( - color: Colors.white.withValues(alpha: 0.08), - child: MediaQuery.removePadding( - context: context, - removeBottom: true, - child: BottomNavigationBar( - backgroundColor: Colors.transparent, - elevation: 0, - currentIndex: displayIndex, - onTap: (i) { - if (i == displayIndex) return; - setState(() => _index = i); - }, - items: [ - BottomNavigationBarItem( - icon: Icon( - themeProvider.useGlassTheme - ? CupertinoIcons.search - : Icons.search, - ), - label: 'Search', + child: Align( + alignment: Alignment.bottomCenter, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: ResponsiveLayout.isExpanded(context) + ? 760 + : double.infinity, ), - BottomNavigationBarItem( - icon: Icon( - themeProvider.useGlassTheme - ? CupertinoIcons.music_albums - : Icons.library_music, - color: isLocalMode ? Colors.white24 : null, - ), - label: 'Library', - ), - BottomNavigationBarItem( - icon: Icon( - themeProvider.useGlassTheme - ? CupertinoIcons.settings - : Icons.settings, - ), - label: 'Settings', - ), - ], + child: const MiniPlayer(), + ), ), ), + _buildQuickTabSwitcher( + context: context, + displayIndex: displayIndex, + isLocalMode: isLocalMode, + top: quickSwitcherTop, + bottom: quickSwitcherBottom, ), - ), + ], ), ); } @@ -186,17 +260,22 @@ class _DisabledLibraryPlaceholder extends StatelessWidget { @override Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; return Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, - children: const [ - Icon(Icons.library_music, size: 56, color: Colors.white38), - SizedBox(height: 12), + children: [ + Icon( + Icons.library_music_outlined, + size: 56, + color: scheme.onSurfaceVariant.withValues(alpha: 0.7), + ), + const SizedBox(height: 12), Text( 'Library is disabled in Local-only mode', - style: TextStyle(color: Colors.white54), + style: TextStyle(color: scheme.onSurfaceVariant), textAlign: TextAlign.center, ), ], diff --git a/lib/features/library/downloaded_songs_screen.dart b/lib/features/library/downloaded_songs_screen.dart index 25a1f04..7ab3a5d 100644 --- a/lib/features/library/downloaded_songs_screen.dart +++ b/lib/features/library/downloaded_songs_screen.dart @@ -6,10 +6,11 @@ import 'package:marquee/marquee.dart'; import 'package:provider/provider.dart'; import '../../core/theme/app_theme.dart'; +import '../../core/theme/responsive.dart'; import '../../core/utils/app_messenger.dart'; import '../../core/utils/audio_player_service.dart'; -import '../../core/utils/glass_container.dart'; -import '../../core/utils/glass_page.dart'; +import '../../core/utils/themed_container.dart'; +import '../../core/utils/themed_page.dart'; import '../player/mini_player.dart'; import 'downloaded_songs_provider.dart'; @@ -60,9 +61,9 @@ class _DownloadedSongsScreenState extends State { ), TextButton( onPressed: () => Navigator.pop(ctx, true), - child: const Text( + child: Text( 'Delete', - style: TextStyle(color: Colors.redAccent), + style: TextStyle(color: Theme.of(ctx).colorScheme.error), ), ), ], @@ -90,10 +91,11 @@ class _DownloadedSongsScreenState extends State { } Widget _emptyState(String text) { + final scheme = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsets.all(24), child: Center( - child: Text(text, style: const TextStyle(color: Colors.white54)), + child: Text(text, style: TextStyle(color: scheme.onSurfaceVariant)), ), ); } @@ -141,8 +143,9 @@ class _DownloadedSongsScreenState extends State { Widget build(BuildContext context) { final themeProvider = Provider.of(context); final player = AudioPlayerService(); + final textTheme = Theme.of(context).textTheme; - return GlassPage( + return ThemedPage( child: Stack( children: [ RefreshIndicator( @@ -162,12 +165,11 @@ class _DownloadedSongsScreenState extends State { onPressed: () => Navigator.of(context).pop(), ), const SizedBox(width: 8), - const Expanded( + Expanded( child: Text( 'Downloaded Songs', - style: TextStyle( - fontSize: 26, - fontWeight: FontWeight.w600, + style: textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, ), ), ), @@ -192,7 +194,7 @@ class _DownloadedSongsScreenState extends State { final song = entry.value; return Padding( padding: const EdgeInsets.only(bottom: 12), - child: GlassContainer( + child: ThemedContainer( child: ListTile( leading: Icon( themeProvider.useGlassTheme @@ -228,7 +230,22 @@ class _DownloadedSongsScreenState extends State { ], ), ), - const Positioned(left: 0, right: 0, bottom: 0, child: MiniPlayer()), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Align( + alignment: Alignment.bottomCenter, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: ResponsiveLayout.isExpanded(context) + ? 760 + : double.infinity, + ), + child: const MiniPlayer(), + ), + ), + ), ], ), ); diff --git a/lib/features/library/library_screen.dart b/lib/features/library/library_screen.dart index 7a6faa3..3fb497f 100644 --- a/lib/features/library/library_screen.dart +++ b/lib/features/library/library_screen.dart @@ -1,10 +1,10 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:hongit/core/theme/app_theme.dart'; -import 'package:hongit/core/utils/glass_page.dart'; +import 'package:hongit/core/utils/themed_page.dart'; import 'package:provider/provider.dart'; import '../../core/utils/app_messenger.dart'; -import '../../core/utils/glass_container.dart'; +import '../../core/utils/themed_container.dart'; import '../../core/utils/audio_player_service.dart'; import '../../features/library/playlist_manager.dart'; import 'downloaded_songs_screen.dart'; @@ -27,107 +27,102 @@ class _LibraryScreenState extends State { if (playlistName == PlaylistManager.systemFavourites) { return; } + final uiTheme = Theme.of(context); + final scheme = uiTheme.colorScheme; + final textTheme = uiTheme.textTheme; showModalBottomSheet( context: context, - backgroundColor: Colors.transparent, - builder: (ctx) => Container( - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.9), - borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 12), - Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: Colors.white24, - borderRadius: BorderRadius.circular(2), - ), + useSafeArea: true, + backgroundColor: scheme.surfaceContainerHigh.withValues(alpha: 0.96), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (ctx) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 12), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: scheme.outlineVariant, + borderRadius: BorderRadius.circular(2), ), - const SizedBox(height: 20), - - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Text( - playlistName, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Text( + playlistName, + style: textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - - const SizedBox(height: 20), - const Divider(height: 1, color: Colors.white12), - - // Delete playlist option - ListTile( - leading: Icon( - theme.useGlassTheme - ? CupertinoIcons.trash - : Icons.delete_outline, - color: Colors.redAccent, - ), - title: const Text( - 'Delete Playlist', - style: TextStyle(color: Colors.redAccent), - ), - onTap: () async { - Navigator.pop(ctx); - - // Confirm deletion - final confirmDelete = await showDialog( - context: context, - builder: (ctx2) => AlertDialog( - title: const Text('Delete Playlist'), - content: Text( - 'Are you sure you want to delete "$playlistName"? This cannot be undone.', + ), + const SizedBox(height: 20), + Divider( + height: 1, + color: scheme.outlineVariant.withValues(alpha: 0.5), + ), + ListTile( + leading: Icon( + theme.useGlassTheme ? CupertinoIcons.trash : Icons.delete_outline, + color: scheme.error, + ), + title: Text( + 'Delete Playlist', + style: TextStyle(color: scheme.error), + ), + onTap: () async { + Navigator.pop(ctx); + + final confirmDelete = await showDialog( + context: context, + builder: (ctx2) => AlertDialog( + title: const Text('Delete Playlist'), + content: Text( + 'Are you sure you want to delete "$playlistName"? This cannot be undone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx2, false), + child: const Text('Cancel'), ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx2, false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.pop(ctx2, true), - child: const Text( - 'Delete', - style: TextStyle(color: Colors.redAccent), - ), + TextButton( + onPressed: () => Navigator.pop(ctx2, true), + child: Text( + 'Delete', + style: TextStyle(color: scheme.error), ), - ], - ), - ); - - if (confirmDelete == true) { - await PlaylistManager.deletePlaylist(playlistName); - AppMessenger.show('Playlist deleted'); - } - }, - ), - - const Divider(height: 1, color: Colors.white12), + ), + ], + ), + ); - // Cancel - ListTile( - leading: Icon( - theme.useGlassTheme - ? CupertinoIcons.xmark_circle - : Icons.cancel_outlined, - ), - title: const Text('Cancel'), - onTap: () => Navigator.pop(ctx), + if (confirmDelete == true) { + await PlaylistManager.deletePlaylist(playlistName); + AppMessenger.show('Playlist deleted'); + } + }, + ), + Divider( + height: 1, + color: scheme.outlineVariant.withValues(alpha: 0.5), + ), + ListTile( + leading: Icon( + theme.useGlassTheme + ? CupertinoIcons.xmark_circle + : Icons.cancel_outlined, ), - - SizedBox(height: MediaQuery.of(context).padding.bottom + 8), - ], - ), + title: const Text('Cancel'), + onTap: () => Navigator.pop(ctx), + ), + SizedBox(height: MediaQuery.of(context).padding.bottom + 8), + ], ), ); } @@ -157,25 +152,29 @@ class _LibraryScreenState extends State { Widget build(BuildContext context) { final player = AudioPlayerService(); final themeProvider = Provider.of(context); + final theme = Theme.of(context); + final textTheme = theme.textTheme; final perfMode = themeProvider.resolvedUiPerformanceMode(context); final animateListItems = perfMode == UiPerformanceMode.full; - return GlassPage( + return ThemedPage( child: ListView( children: [ - const Text( + Text( 'Library', - style: TextStyle(fontSize: 26, fontWeight: FontWeight.w600), + style: textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + ), ), const SizedBox(height: 24), - const Text( + Text( 'Quick Access', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500), + style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), ), const SizedBox(height: 12), - GlassContainer( + ThemedContainer( child: ListTile( leading: Icon( themeProvider.useGlassTheme @@ -201,7 +200,7 @@ class _LibraryScreenState extends State { const SizedBox(height: 12), - GlassContainer( + ThemedContainer( child: ListTile( leading: Icon( themeProvider.useGlassTheme @@ -251,7 +250,7 @@ class _LibraryScreenState extends State { const SizedBox(height: 12), - GlassContainer( + ThemedContainer( child: ListTile( leading: Icon( themeProvider.useGlassTheme @@ -275,9 +274,9 @@ class _LibraryScreenState extends State { const SizedBox(height: 32), - const Text( + Text( 'Playlists', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500), + style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), ), const SizedBox(height: 12), @@ -288,7 +287,9 @@ class _LibraryScreenState extends State { final userPlaylistNames = playlists.keys .where((name) => name != PlaylistManager.systemFavourites) .toList(); - if (userPlaylistNames.isEmpty) return _empty('No playlists'); + if (userPlaylistNames.isEmpty) { + return _empty(context, 'No playlists'); + } return Column( children: userPlaylistNames.asMap().entries.map((entry) { @@ -300,7 +301,7 @@ class _LibraryScreenState extends State { animate: animateListItems, child: Padding( padding: const EdgeInsets.only(bottom: 12), - child: GlassContainer( + child: ThemedContainer( child: ListTile( leading: Icon( themeProvider.useGlassTheme @@ -377,16 +378,16 @@ class _LibraryScreenState extends State { const SizedBox(height: 32), - const Text( + Text( 'Recently Played', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500), + style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), ), const SizedBox(height: 12), StreamBuilder>>( stream: player.recentlyPlayedStream, builder: (_, snap) { final items = (snap.data ?? []).take(20).toList(); - if (items.isEmpty) return _empty('Nothing played yet'); + if (items.isEmpty) return _empty(context, 'Nothing played yet'); return Column( children: items.asMap().entries.map((entry) { @@ -398,7 +399,7 @@ class _LibraryScreenState extends State { animate: animateListItems, child: Padding( padding: const EdgeInsets.only(bottom: 12), - child: GlassContainer( + child: ThemedContainer( child: ListTile( leading: Icon( themeProvider.useGlassTheme @@ -434,11 +435,12 @@ class _LibraryScreenState extends State { ); } - Widget _empty(String text) { + Widget _empty(BuildContext context, String text) { + final scheme = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsets.all(24), child: Center( - child: Text(text, style: const TextStyle(color: Colors.white54)), + child: Text(text, style: TextStyle(color: scheme.onSurfaceVariant)), ), ); } diff --git a/lib/features/library/local_audios_screen.dart b/lib/features/library/local_audios_screen.dart index 8e44cac..3efd845 100644 --- a/lib/features/library/local_audios_screen.dart +++ b/lib/features/library/local_audios_screen.dart @@ -7,10 +7,11 @@ import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; import '../../core/theme/app_theme.dart'; +import '../../core/theme/responsive.dart'; import '../../core/utils/app_messenger.dart'; import '../../core/utils/audio_player_service.dart'; -import '../../core/utils/glass_container.dart'; -import '../../core/utils/glass_page.dart'; +import '../../core/utils/themed_container.dart'; +import '../../core/utils/themed_page.dart'; import '../player/mini_player.dart'; import 'downloaded_songs_provider.dart'; import 'local_audio_provider.dart'; @@ -81,10 +82,11 @@ class _LocalAudiosScreenState extends State { } Widget _emptyState(String text) { + final scheme = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsets.all(24), child: Center( - child: Text(text, style: const TextStyle(color: Colors.white54)), + child: Text(text, style: TextStyle(color: scheme.onSurfaceVariant)), ), ); } @@ -93,8 +95,9 @@ class _LocalAudiosScreenState extends State { Widget build(BuildContext context) { final themeProvider = Provider.of(context); final player = AudioPlayerService(); + final textTheme = Theme.of(context).textTheme; - return GlassPage( + return ThemedPage( child: Stack( children: [ RefreshIndicator( @@ -114,12 +117,11 @@ class _LocalAudiosScreenState extends State { onPressed: () => Navigator.of(context).pop(), ), const SizedBox(width: 8), - const Expanded( + Expanded( child: Text( 'Local Audios', - style: TextStyle( - fontSize: 26, - fontWeight: FontWeight.w600, + style: textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, ), ), ), @@ -144,7 +146,7 @@ class _LocalAudiosScreenState extends State { final track = entry.value; return Padding( padding: const EdgeInsets.only(bottom: 12), - child: GlassContainer( + child: ThemedContainer( child: ListTile( leading: Icon( themeProvider.useGlassTheme @@ -181,7 +183,22 @@ class _LocalAudiosScreenState extends State { ], ), ), - const Positioned(left: 0, right: 0, bottom: 0, child: MiniPlayer()), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Align( + alignment: Alignment.bottomCenter, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: ResponsiveLayout.isExpanded(context) + ? 760 + : double.infinity, + ), + child: const MiniPlayer(), + ), + ), + ), ], ), ); diff --git a/lib/features/library/playlist_screen.dart b/lib/features/library/playlist_screen.dart index 0aedfd8..f2c16b1 100644 --- a/lib/features/library/playlist_screen.dart +++ b/lib/features/library/playlist_screen.dart @@ -6,9 +6,10 @@ import 'package:provider/provider.dart'; import '../../core/utils/audio_player_service.dart'; import '../../core/utils/youtube_thumbnail_utils.dart'; import '../../core/widgets/fallback_network_image.dart'; -import '../../core/utils/glass_container.dart'; -import '../../core/utils/glass_page.dart'; +import '../../core/utils/themed_container.dart'; +import '../../core/utils/themed_page.dart'; import '../../core/theme/app_theme.dart'; +import '../../core/theme/responsive.dart'; import '../../core/utils/app_messenger.dart'; import '../../features/library/playlist_manager.dart'; import '../player/mini_player.dart'; @@ -92,6 +93,9 @@ class _PlaylistScreenState extends State { Map song, ThemeProvider theme, ) { + final uiTheme = Theme.of(context); + final scheme = uiTheme.colorScheme; + final textTheme = uiTheme.textTheme; final perfMode = theme.resolvedUiPerformanceMode(context); final thumbFilterQuality = perfMode == UiPerformanceMode.full ? FilterQuality.medium @@ -106,122 +110,121 @@ class _PlaylistScreenState extends State { showModalBottomSheet( context: context, - backgroundColor: Colors.transparent, - builder: (ctx) => Container( - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.9), - borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 12), - Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: Colors.white24, - borderRadius: BorderRadius.circular(2), - ), + useSafeArea: true, + backgroundColor: scheme.surfaceContainerHigh.withValues(alpha: 0.96), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (ctx) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 12), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: scheme.outlineVariant, + borderRadius: BorderRadius.circular(2), ), - const SizedBox(height: 20), + ), + const SizedBox(height: 20), - // Song info - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: FallbackNetworkImage( - urls: imageCandidates, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: FallbackNetworkImage( + urls: imageCandidates, + width: 56, + height: 56, + fit: BoxFit.cover, + filterQuality: thumbFilterQuality, + fallback: Container( width: 56, height: 56, - fit: BoxFit.cover, - filterQuality: thumbFilterQuality, - fallback: Container( - width: 56, - height: 56, - color: Colors.white12, - child: const Icon(Icons.music_note), - ), + color: scheme.surfaceContainerHighest, + child: const Icon(Icons.music_note), ), ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - song['title'], - style: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 16, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + song['title'], + style: textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, ), - const SizedBox(height: 4), - Text( - song['artist'], - style: const TextStyle( - color: Colors.white70, - fontSize: 14, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + song['artist'], + style: textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, ), - ], - ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], ), - ], - ), + ), + ], ), + ), - const SizedBox(height: 20), - const Divider(height: 1, color: Colors.white12), + const SizedBox(height: 20), + Divider( + height: 1, + color: scheme.outlineVariant.withValues(alpha: 0.5), + ), - // Remove from playlist option - ListTile( - leading: Icon( - theme.useGlassTheme - ? CupertinoIcons.minus_circle - : Icons.remove_circle_outline, - color: Colors.redAccent, - ), - title: Text( - widget.name == PlaylistManager.systemFavourites - ? 'Remove from Favorites' - : 'Remove from Playlist', - style: const TextStyle(color: Colors.redAccent), - ), - onTap: () async { - Navigator.pop(ctx); - await PlaylistManager.removeSong(widget.name, song['id']); - AppMessenger.show( - widget.name == PlaylistManager.systemFavourites - ? 'Removed from favorites' - : 'Removed from playlist', - ); - }, + ListTile( + leading: Icon( + theme.useGlassTheme + ? CupertinoIcons.minus_circle + : Icons.remove_circle_outline, + color: scheme.error, + ), + title: Text( + widget.name == PlaylistManager.systemFavourites + ? 'Remove from Favorites' + : 'Remove from Playlist', + style: TextStyle(color: scheme.error), ), + onTap: () async { + Navigator.pop(ctx); + await PlaylistManager.removeSong(widget.name, song['id']); + AppMessenger.show( + widget.name == PlaylistManager.systemFavourites + ? 'Removed from favorites' + : 'Removed from playlist', + ); + }, + ), - const Divider(height: 1, color: Colors.white12), + Divider( + height: 1, + color: scheme.outlineVariant.withValues(alpha: 0.5), + ), - // Cancel - ListTile( - leading: Icon( - theme.useGlassTheme - ? CupertinoIcons.xmark_circle - : Icons.cancel_outlined, - ), - title: const Text('Cancel'), - onTap: () => Navigator.pop(ctx), + ListTile( + leading: Icon( + theme.useGlassTheme + ? CupertinoIcons.xmark_circle + : Icons.cancel_outlined, ), + title: const Text('Cancel'), + onTap: () => Navigator.pop(ctx), + ), - SizedBox(height: MediaQuery.of(context).padding.bottom + 8), - ], - ), + SizedBox(height: MediaQuery.of(context).padding.bottom + 8), + ], ), ); } @@ -230,13 +233,16 @@ class _PlaylistScreenState extends State { Widget build(BuildContext context) { final player = AudioPlayerService(); final theme = Provider.of(context); + final uiTheme = Theme.of(context); + final textTheme = uiTheme.textTheme; + final scheme = uiTheme.colorScheme; final perfMode = theme.resolvedUiPerformanceMode(context); final animateEntries = perfMode == UiPerformanceMode.full; final thumbFilterQuality = perfMode == UiPerformanceMode.full ? FilterQuality.medium : FilterQuality.low; - return GlassPage( + return ThemedPage( child: StreamBuilder>>>( stream: PlaylistManager.stream, builder: (context, snapshot) { @@ -266,9 +272,8 @@ class _PlaylistScreenState extends State { Expanded( child: Text( widget.name, - style: const TextStyle( - fontSize: 26, - fontWeight: FontWeight.w600, + style: textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -290,14 +295,15 @@ class _PlaylistScreenState extends State { ? CupertinoIcons.music_note_2 : Icons.music_note, size: 64, - color: Colors.white24, + color: scheme.onSurfaceVariant.withValues( + alpha: 0.7, + ), ), const SizedBox(height: 16), - const Text( + Text( 'No songs yet', - style: TextStyle( - color: Colors.white54, - fontSize: 16, + style: textTheme.bodyLarge?.copyWith( + color: scheme.onSurfaceVariant, ), ), ], @@ -315,7 +321,7 @@ class _PlaylistScreenState extends State { animate: animateEntries, child: Padding( padding: const EdgeInsets.only(bottom: 12), - child: GlassContainer( + child: ThemedContainer( child: ListTile( leading: ClipRRect( borderRadius: BorderRadius.circular(8), @@ -333,7 +339,7 @@ class _PlaylistScreenState extends State { fallback: Container( width: 48, height: 48, - color: Colors.white12, + color: scheme.surfaceContainerHighest, child: const Icon(Icons.music_note, size: 24), ), ), @@ -378,11 +384,21 @@ class _PlaylistScreenState extends State { ], ), - const Positioned( + Positioned( left: 0, right: 0, bottom: 0, - child: MiniPlayer(), + child: Align( + alignment: Alignment.bottomCenter, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: ResponsiveLayout.isExpanded(context) + ? 760 + : double.infinity, + ), + child: const MiniPlayer(), + ), + ), ), ], ); diff --git a/lib/features/player/full_player_sheet.dart b/lib/features/player/full_player_sheet.dart index 254272c..45c3a1b 100644 --- a/lib/features/player/full_player_sheet.dart +++ b/lib/features/player/full_player_sheet.dart @@ -11,15 +11,17 @@ import 'package:provider/provider.dart'; import 'package:permission_handler/permission_handler.dart'; import '../../core/utils/audio_player_service.dart'; -import '../../core/utils/glass_container.dart'; +import '../../core/utils/themed_container.dart'; import '../../core/utils/streaming_preferences.dart'; import '../../data/api/local_backend_api.dart'; import '../../data/api/lrclib_api.dart'; import '../../data/api/youtube_song_api.dart'; import '../../core/utils/app_messenger.dart'; import '../../core/theme/app_theme.dart'; +import '../../core/theme/responsive.dart'; import '../../core/utils/youtube_thumbnail_utils.dart'; import '../../core/widgets/fallback_network_image.dart'; +import '../search/artist_profile_screen.dart'; import 'widgets/player_progress_bar.dart'; import '../../features/library/downloaded_songs_provider.dart'; @@ -52,14 +54,153 @@ class FullPlayerSheet extends StatelessWidget { return mins > 0 ? '${mins}m ${secs}s' : '${remaining.inSeconds}s'; } + bool _isBlockedArtistName(String value) { + final normalized = value.trim().toLowerCase(); + if (normalized.isEmpty) return true; + return normalized == 'unknown' || + normalized == 'offline' || + normalized == 'local audio' || + normalized == 'artist' || + normalized == 'various artists'; + } + + List _extractArtistNames(String rawArtist) { + var text = rawArtist.trim(); + if (text.isEmpty) return const []; + + text = text.replaceAll(RegExp(r'\s+'), ' '); + text = text.replaceAll( + RegExp(r'\b(featuring|feat|ft)\.?\b', caseSensitive: false), + ',', + ); + text = text.replaceAll('&', ','); + text = text.replaceAll( + RegExp(r'\b(and|with)\b', caseSensitive: false), + ',', + ); + text = text.replaceAll(RegExp(r'\s+[xX]\s+'), ','); + + final parts = text + .split(RegExp(r'[,/;|]+')) + .map((e) => e.trim()) + .where((e) => e.isNotEmpty) + .map((e) => e.replaceAll(RegExp(r'^\(|\)$'), '').trim()) + .where((e) => e.isNotEmpty) + .toList(growable: false); + + final out = []; + final seen = {}; + for (final part in parts) { + if (_isBlockedArtistName(part)) continue; + final key = part.toLowerCase(); + if (!seen.add(key)) continue; + out.add(part); + } + + return out; + } + + void _pushArtistProfile(BuildContext context, String artist) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ArtistProfileScreen(artistName: artist), + ), + ); + } + + void _openArtistProfile(BuildContext context, String rawArtist) { + final artists = _extractArtistNames(rawArtist); + if (artists.isEmpty) { + AppMessenger.show('Artist profile not available'); + return; + } + + if (artists.length == 1) { + _pushArtistProfile(context, artists.first); + return; + } + + final uiTheme = Theme.of(context); + final scheme = uiTheme.colorScheme; + final textTheme = uiTheme.textTheme; + final stableContext = context; + + showModalBottomSheet( + context: context, + useSafeArea: true, + backgroundColor: scheme.surfaceContainerHigh.withValues(alpha: 0.96), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (sheetCtx) { + return SafeArea( + child: ListView( + shrinkWrap: true, + padding: EdgeInsets.fromLTRB( + 12, + 8, + 12, + 8 + MediaQuery.of(sheetCtx).viewPadding.bottom, + ), + children: [ + Center( + child: Container( + width: 42, + height: 4, + decoration: BoxDecoration( + color: scheme.outlineVariant, + borderRadius: BorderRadius.circular(999), + ), + ), + ), + const SizedBox(height: 10), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Text( + 'Choose Artist', + style: textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(height: 4), + ...artists.map( + (artist) => ListTile( + title: Text(artist), + trailing: Icon( + CupertinoIcons.chevron_right, + size: 18, + color: scheme.onSurfaceVariant, + ), + onTap: () { + Navigator.of(sheetCtx).pop(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!stableContext.mounted) return; + _pushArtistProfile(stableContext, artist); + }); + }, + ), + ), + ], + ), + ); + }, + ); + } + void _showSleepTimerSheet( BuildContext context, AudioPlayerService player, bool useGlassTheme, ) { + final uiTheme = Theme.of(context); + final scheme = uiTheme.colorScheme; + final textTheme = uiTheme.textTheme; + showModalBottomSheet( context: context, - backgroundColor: Colors.black.withValues(alpha: 0.88), + useSafeArea: true, + backgroundColor: scheme.surfaceContainerHigh.withValues(alpha: 0.96), shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), @@ -73,14 +214,16 @@ class FullPlayerSheet extends StatelessWidget { width: 42, height: 4, decoration: BoxDecoration( - color: Colors.white24, + color: scheme.outlineVariant, borderRadius: BorderRadius.circular(999), ), ), const SizedBox(height: 10), - const Text( + Text( 'Sleep Timer', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + style: textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), ), const SizedBox(height: 6), StreamBuilder( @@ -88,7 +231,9 @@ class FullPlayerSheet extends StatelessWidget { initialData: player.sleepTimerStatus, builder: (_, snap) => Text( 'Current: ${_sleepTimerLabel(snap.data ?? const SleepTimerStatus.off())}', - style: const TextStyle(color: Colors.white70, fontSize: 13), + style: textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), ), ), const SizedBox(height: 8), @@ -148,18 +293,12 @@ class FullPlayerSheet extends StatelessWidget { Future _downloadSong(QueuedSong song) async { await StreamingPreferences.reload(); if (!StreamingPreferences.isStreamingEnabled) { - AppMessenger.show( - 'Enable a streaming service in Settings to download.', - color: Colors.orange.shade700, - ); + AppMessenger.show('Enable a streaming service in Settings to download.'); return; } if (_queuedOrActiveSongIds.contains(song.id)) { - AppMessenger.show( - 'Already queued: ${song.meta.title}', - color: Colors.orange.shade700, - ); + AppMessenger.show('Already queued: ${song.meta.title}'); return; } @@ -170,13 +309,9 @@ class FullPlayerSheet extends StatelessWidget { if (pendingTotal > _queueFeedbackThreshold) { AppMessenger.show( 'Queued $pendingTotal downloads. Running $_maxConcurrentDownloads at a time.', - color: Colors.blueGrey.shade800, ); } else { - AppMessenger.show( - 'Added to queue: ${song.meta.title}', - color: Colors.blueGrey.shade800, - ); + AppMessenger.show('Added to queue: ${song.meta.title}'); } _pumpDownloadQueue(); @@ -194,10 +329,7 @@ class FullPlayerSheet extends StatelessWidget { void _runDownloadTask(_QueuedDownloadTask task) { () async { final song = task.song; - AppMessenger.show( - 'Downloading: ${song.meta.title}', - color: Colors.blueGrey.shade800, - ); + AppMessenger.show('Downloading: ${song.meta.title}'); try { if (song.id.startsWith('yt:')) { @@ -214,9 +346,9 @@ class FullPlayerSheet extends StatelessWidget { ); } - AppMessenger.show('Download complete', color: Colors.green.shade700); + AppMessenger.show('Download complete'); } catch (_) { - AppMessenger.show('Download failed', color: Colors.red.shade700); + AppMessenger.show('Download failed'); } finally { _queuedOrActiveSongIds.remove(song.id); if (_activeDownloadCount > 0) { @@ -244,6 +376,7 @@ class FullPlayerSheet extends StatelessWidget { QueuedSong song, AudioPlayerService player, ) async { + final errorColor = Theme.of(context).colorScheme.error; final confirm = await showDialog( context: context, builder: (ctx) => AlertDialog( @@ -256,10 +389,7 @@ class FullPlayerSheet extends StatelessWidget { ), TextButton( onPressed: () => Navigator.pop(ctx, true), - child: const Text( - 'Delete', - style: TextStyle(color: Colors.redAccent), - ), + child: Text('Delete', style: TextStyle(color: errorColor)), ), ], ), @@ -281,10 +411,7 @@ class FullPlayerSheet extends StatelessWidget { } } - AppMessenger.show( - 'Deleted ${song.meta.title}', - color: Colors.redAccent.shade700, - ); + AppMessenger.show('Deleted ${song.meta.title}', color: errorColor); if (context.mounted) { Navigator.of(context).maybePop(); } @@ -295,6 +422,7 @@ class FullPlayerSheet extends StatelessWidget { QueuedSong song, AudioPlayerService player, ) async { + final errorColor = Theme.of(context).colorScheme.error; final confirm = await showDialog( context: context, builder: (ctx) => AlertDialog( @@ -307,10 +435,7 @@ class FullPlayerSheet extends StatelessWidget { ), TextButton( onPressed: () => Navigator.pop(ctx, true), - child: const Text( - 'Delete', - style: TextStyle(color: Colors.redAccent), - ), + child: Text('Delete', style: TextStyle(color: errorColor)), ), ], ), @@ -325,7 +450,7 @@ class FullPlayerSheet extends StatelessWidget { if (status.isDenied) { AppMessenger.show( 'Permission denied. Cannot delete file.', - color: Colors.red.shade700, + color: errorColor, ); return; } @@ -333,7 +458,7 @@ class FullPlayerSheet extends StatelessWidget { if (status.isPermanentlyDenied) { AppMessenger.show( 'Storage permission permanently denied. Open app settings to enable it.', - color: Colors.red.shade700, + color: errorColor, ); openAppSettings(); return; @@ -364,25 +489,19 @@ class FullPlayerSheet extends StatelessWidget { LocalAudioProvider.notifyChanged(); - AppMessenger.show( - 'Deleted ${song.meta.title}', - color: Colors.redAccent.shade700, - ); + AppMessenger.show('Deleted ${song.meta.title}', color: errorColor); } catch (e) { - AppMessenger.show( - 'Failed to delete file', - color: Colors.red.shade700, - ); + AppMessenger.show('Failed to delete file', color: errorColor); } } else { - AppMessenger.show('File not found', color: Colors.orange.shade700); + AppMessenger.show('File not found'); } if (context.mounted) { Navigator.of(context).maybePop(); } } catch (e) { - AppMessenger.show('Error: ${e.toString()}', color: Colors.red.shade700); + AppMessenger.show('Error: ${e.toString()}', color: errorColor); } } @@ -390,12 +509,17 @@ class FullPlayerSheet extends StatelessWidget { Widget build(BuildContext context) { final player = AudioPlayerService(); final theme = Provider.of(context); + final uiTheme = Theme.of(context); + final scheme = uiTheme.colorScheme; final perfMode = theme.resolvedUiPerformanceMode(context); final fullVisuals = perfMode == UiPerformanceMode.full; final smoothVisuals = perfMode == UiPerformanceMode.smooth; final backdropBlur = fullVisuals ? 30.0 : 16.0; final mainArtCacheSize = smoothVisuals ? 512 : 768; final queueArtCacheSize = smoothVisuals ? 192 : 256; + final horizontalPadding = ResponsiveLayout.isExpanded(context) + ? 24.0 + : 16.0; return StreamBuilder( stream: player.nowPlayingStream, @@ -447,7 +571,7 @@ class FullPlayerSheet extends StatelessWidget { return Stack( children: [ if (smoothVisuals) - Container(color: Colors.black.withValues(alpha: 0.72)) + Container(color: scheme.scrim.withValues(alpha: 0.72)) else BackdropFilter( filter: ImageFilter.blur( @@ -455,7 +579,7 @@ class FullPlayerSheet extends StatelessWidget { sigmaY: backdropBlur, ), child: Container( - color: Colors.black.withValues(alpha: 0.65), + color: scheme.scrim.withValues(alpha: 0.66), ), ), @@ -467,7 +591,9 @@ class FullPlayerSheet extends StatelessWidget { return ListView( controller: controller, cacheExtent: 900, - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: EdgeInsets.symmetric( + horizontal: horizontalPadding, + ), children: [ const SizedBox(height: 16), @@ -476,7 +602,7 @@ class FullPlayerSheet extends StatelessWidget { song: currentSong, player: player, useGlassTheme: theme.useGlassTheme, - frontCard: GlassContainer( + frontCard: ThemedContainer( borderRadius: BorderRadius.circular(32), child: Padding( padding: const EdgeInsets.all(20), @@ -489,7 +615,7 @@ class FullPlayerSheet extends StatelessWidget { bottom: 16, ), decoration: BoxDecoration( - color: Colors.white30, + color: scheme.outlineVariant, borderRadius: BorderRadius.circular( 2, ), @@ -514,7 +640,8 @@ class FullPlayerSheet extends StatelessWidget { filterQuality: FilterQuality.medium, fallback: Container( - color: Colors.black26, + color: scheme + .surfaceContainerHighest, child: const Icon( Icons.music_note_rounded, size: 56, @@ -542,11 +669,26 @@ class FullPlayerSheet extends StatelessWidget { SizedBox( height: 20, - child: _AutoMarqueeText( - text: now.artist, - style: const TextStyle( - fontSize: 14, - color: Colors.white70, + child: GestureDetector( + behavior: + HitTestBehavior.translucent, + onTap: () => _openArtistProfile( + context, + now.artist, + ), + child: _AutoMarqueeText( + text: now.artist, + style: + uiTheme.textTheme.bodyMedium + ?.copyWith( + color: scheme + .onSurfaceVariant, + ) ?? + TextStyle( + fontSize: 14, + color: + scheme.onSurfaceVariant, + ), ), ), ), @@ -595,8 +737,6 @@ class FullPlayerSheet extends StatelessWidget { max: max, style: theme .effectiveProgressBarStyle, - useGlassTheme: theme - .useGlassTheme, onChanged: isTrackLoading ? (_) {} @@ -621,19 +761,37 @@ class FullPlayerSheet extends StatelessWidget { children: [ Text( _fmt(shownPos), - style: const TextStyle( - fontSize: 12, - color: Colors - .white70, - ), + style: + uiTheme + .textTheme + .labelMedium + ?.copyWith( + color: + scheme.onSurfaceVariant, + ) ?? + TextStyle( + fontSize: + 12, + color: scheme + .onSurfaceVariant, + ), ), Text( _fmt(shownDur), - style: const TextStyle( - fontSize: 12, - color: Colors - .white70, - ), + style: + uiTheme + .textTheme + .labelMedium + ?.copyWith( + color: + scheme.onSurfaceVariant, + ) ?? + TextStyle( + fontSize: + 12, + color: scheme + .onSurfaceVariant, + ), ), ], ), @@ -680,8 +838,10 @@ class FullPlayerSheet extends StatelessWidget { color: mode == LoopMode.off - ? Colors.white54 - : Colors.white, + ? scheme + .onSurfaceVariant + : scheme + .onSurface, ), onPressed: player .toggleLoopMode, @@ -749,7 +909,7 @@ class FullPlayerSheet extends StatelessWidget { AlwaysStoppedAnimation< Color >( - Colors.white, + scheme.primary, ), ), ), @@ -820,7 +980,7 @@ class FullPlayerSheet extends StatelessWidget { .trash : Icons .delete_outline, - color: Colors.redAccent, + color: scheme.error, ), onPressed: () => _deleteDownloadedSong( @@ -840,7 +1000,7 @@ class FullPlayerSheet extends StatelessWidget { .trash : Icons .delete_outline, - color: Colors.redAccent, + color: scheme.error, ), onPressed: () => _deleteLocalAudio( @@ -897,10 +1057,9 @@ class FullPlayerSheet extends StatelessWidget { : Icons .favorite_border), color: isFav - ? Colors - .redAccent - : Colors - .white70, + ? scheme.error + : scheme + .onSurfaceVariant, ), iconSize: 26, onPressed: () async => @@ -951,9 +1110,9 @@ class FullPlayerSheet extends StatelessWidget { color: timerStatus .isActive - ? Colors - .lightBlueAccent - : Colors.white70, + ? scheme.primary + : scheme + .onSurfaceVariant, ), iconSize: 26, onPressed: () { @@ -975,7 +1134,8 @@ class FullPlayerSheet extends StatelessWidget { .music_note_list : Icons .playlist_add, - color: Colors.white70, + color: scheme + .onSurfaceVariant, ), iconSize: 26, onPressed: () { @@ -990,12 +1150,16 @@ class FullPlayerSheet extends StatelessWidget { ), ], const SizedBox(height: 12), - const Text( + Text( 'Swipe right for lyrics', - style: TextStyle( - fontSize: 12, - color: Colors.white54, - ), + style: uiTheme + .textTheme + .labelMedium + ?.copyWith( + fontSize: 12, + color: + scheme.onSurfaceVariant, + ), textAlign: TextAlign.center, ), ], @@ -1021,7 +1185,7 @@ class FullPlayerSheet extends StatelessWidget { (upcomingSong) => RepaintBoundary( child: Padding( padding: const EdgeInsets.only(bottom: 10), - child: GlassContainer( + child: ThemedContainer( child: ListTile( leading: ClipRRect( clipBehavior: Clip.antiAlias, @@ -1060,7 +1224,8 @@ class FullPlayerSheet extends StatelessWidget { fallback: Container( width: 48, height: 48, - color: Colors.black26, + color: scheme + .surfaceContainerHighest, child: const Icon( Icons.music_note_rounded, size: 22, @@ -1098,7 +1263,7 @@ class FullPlayerSheet extends StatelessWidget { theme.useGlassTheme ? CupertinoIcons.trash : Icons.delete_outline, - color: Colors.redAccent, + color: scheme.error, ), onPressed: () async { if (_isDownloadedLocalTrack( @@ -1168,8 +1333,9 @@ class FullPlayerSheet extends StatelessWidget { : Icons .favorite_border), color: isFav - ? Colors.redAccent - : Colors.white70, + ? scheme.error + : scheme + .onSurfaceVariant, ), onPressed: () async => await PlaylistManager.toggleFavourite({ @@ -1211,7 +1377,8 @@ class FullPlayerSheet extends StatelessWidget { .music_note_list : Icons .playlist_add, - color: Colors.white70, + color: scheme + .onSurfaceVariant, ), onPressed: () => _showAddToPlaylistSheet( @@ -1256,6 +1423,9 @@ class _QueuedDownloadTask { void _showAddToPlaylistSheet(BuildContext context, QueuedSong song) { final stableContext = context; + final uiTheme = Theme.of(context); + final scheme = uiTheme.colorScheme; + final textTheme = uiTheme.textTheme; final useGlassTheme = Provider.of( context, listen: false, @@ -1278,7 +1448,7 @@ void _showAddToPlaylistSheet(BuildContext context, QueuedSong song) { context: context, isScrollControlled: true, useSafeArea: true, - backgroundColor: Colors.black.withValues(alpha: 0.85), + backgroundColor: scheme.surfaceContainerHigh.withValues(alpha: 0.96), shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), @@ -1308,20 +1478,21 @@ void _showAddToPlaylistSheet(BuildContext context, QueuedSong song) { child: Column( mainAxisSize: MainAxisSize.max, children: [ - const Text( + Text( 'Add to Playlist', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, + style: textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, ), ), const SizedBox(height: 12), Expanded( child: playlistNames.isEmpty - ? const Center( + ? Center( child: Text( 'No playlists yet', - style: TextStyle(color: Colors.white54), + style: TextStyle( + color: scheme.onSurfaceVariant, + ), ), ) : ListView( @@ -1342,16 +1513,13 @@ void _showAddToPlaylistSheet(BuildContext context, QueuedSong song) { return ListTile( leading: Icon( added ? addedIcon() : playlistIcon(), - color: added - ? Colors.green.shade400 - : null, + color: added ? scheme.primary : null, ), title: Text(name), onTap: () async { if (added) { AppMessenger.show( 'Already in "$name"', - color: Colors.orange.shade700, ); return; } @@ -1375,12 +1543,10 @@ void _showAddToPlaylistSheet(BuildContext context, QueuedSong song) { ); AppMessenger.show( 'Added to "$name"', - color: Colors.green.shade700, ); } else { AppMessenger.show( 'Already in "$name"', - color: Colors.orange.shade700, ); } }, @@ -1442,10 +1608,7 @@ void _showCreatePlaylistDialog(BuildContext context) { await PlaylistManager.create(name); if (!dialogContext.mounted) return; Navigator.of(dialogContext).pop(); - AppMessenger.show( - 'Playlist "$name" created', - color: Colors.green.shade700, - ); + AppMessenger.show('Playlist "$name" created'); }, child: const Text('Create'), ), @@ -1668,7 +1831,10 @@ class _LyricsBackCard extends StatelessWidget { @override Widget build(BuildContext context) { final activeSong = song; - return GlassContainer( + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final textTheme = theme.textTheme; + return ThemedContainer( borderRadius: BorderRadius.circular(32), child: Padding( padding: const EdgeInsets.all(20), @@ -1680,7 +1846,7 @@ class _LyricsBackCard extends StatelessWidget { height: 4, margin: const EdgeInsets.only(bottom: 16), decoration: BoxDecoration( - color: Colors.white30, + color: scheme.outlineVariant, borderRadius: BorderRadius.circular(2), ), ), @@ -1691,13 +1857,15 @@ class _LyricsBackCard extends StatelessWidget { ? CupertinoIcons.music_note_2 : Icons.lyrics_outlined, size: 20, - color: Colors.white70, + color: scheme.onSurfaceVariant, ), const SizedBox(width: 8), - const Expanded( + Expanded( child: Text( 'Lyrics', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + style: textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), ), ), FutureBuilder( @@ -1712,11 +1880,11 @@ class _LyricsBackCard extends StatelessWidget { ), decoration: BoxDecoration( color: isSynced - ? Colors.white.withValues(alpha: 0.15) - : Colors.white.withValues(alpha: 0.07), + ? scheme.primary.withValues(alpha: 0.2) + : scheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(20), border: Border.all( - color: Colors.white.withValues(alpha: 0.2), + color: scheme.outlineVariant.withValues(alpha: 0.6), ), ), child: Text( @@ -1725,7 +1893,9 @@ class _LyricsBackCard extends StatelessWidget { fontSize: 11, fontFamily: 'Helvetica', fontWeight: FontWeight.w700, - color: isSynced ? Colors.white : Colors.white54, + color: isSynced + ? scheme.onPrimaryContainer + : scheme.onSurfaceVariant, letterSpacing: 0.3, ), ), @@ -1736,23 +1906,23 @@ class _LyricsBackCard extends StatelessWidget { ), const SizedBox(height: 10), if (activeSong == null) - const SizedBox( + SizedBox( height: 360, child: Center( child: Text( 'No song playing', - style: TextStyle(color: Colors.white70), + style: TextStyle(color: scheme.onSurfaceVariant), ), ), ) else if (activeSong.isLocal) - const SizedBox( + SizedBox( height: 360, child: Center( child: Text( 'Lyrics are currently available for streaming tracks only.', textAlign: TextAlign.center, - style: TextStyle(color: Colors.white70), + style: TextStyle(color: scheme.onSurfaceVariant), ), ), ) @@ -1767,30 +1937,30 @@ class _LyricsBackCard extends StatelessWidget { } if (snapshot.hasError) { - return const Center( + return Center( child: Text( 'Lyrics failed to load.', - style: TextStyle(color: Colors.white70), + style: TextStyle(color: scheme.onSurfaceVariant), ), ); } final lyrics = snapshot.data; if (lyrics == null) { - return const Center( + return Center( child: Text( 'No lyrics found for this song.', textAlign: TextAlign.center, - style: TextStyle(color: Colors.white70), + style: TextStyle(color: scheme.onSurfaceVariant), ), ); } if (lyrics.instrumental) { - return const Center( + return Center( child: Text( 'Instrumental track.', - style: TextStyle(color: Colors.white70), + style: TextStyle(color: scheme.onSurfaceVariant), ), ); } @@ -1805,10 +1975,10 @@ class _LyricsBackCard extends StatelessWidget { final plain = lyrics.plainLyrics.trim(); if (plain.isEmpty) { - return const Center( + return Center( child: Text( 'No lyrics available.', - style: TextStyle(color: Colors.white70), + style: TextStyle(color: scheme.onSurfaceVariant), ), ); } @@ -1821,11 +1991,11 @@ class _LyricsBackCard extends StatelessWidget { child: Text( plain, textAlign: TextAlign.center, - style: const TextStyle( + style: textTheme.bodyLarge?.copyWith( fontSize: 17, fontFamily: 'Helvetica', fontWeight: FontWeight.w400, - color: Colors.white70, + color: scheme.onSurfaceVariant, height: 1.7, ), ), @@ -1834,9 +2004,12 @@ class _LyricsBackCard extends StatelessWidget { ), ), const SizedBox(height: 12), - const Text( + Text( 'Swipe left to return to player controls', - style: TextStyle(fontSize: 12, color: Colors.white54), + style: textTheme.labelMedium?.copyWith( + fontSize: 12, + color: scheme.onSurfaceVariant, + ), textAlign: TextAlign.center, ), ], @@ -1941,6 +2114,7 @@ class _SyncedLyricsViewState extends State<_SyncedLyricsView> @override Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; return LayoutBuilder( builder: (context, constraints) { final centerY = constraints.maxHeight / 2 - _lineHeight / 2; @@ -1998,7 +2172,7 @@ class _SyncedLyricsViewState extends State<_SyncedLyricsView> fontWeight: isActive ? FontWeight.w700 : FontWeight.w400, - color: Colors.white, + color: scheme.onSurface, ), child: Text( widget.lines[index].text, diff --git a/lib/features/player/mini_player.dart b/lib/features/player/mini_player.dart index 64d2643..26d7838 100644 --- a/lib/features/player/mini_player.dart +++ b/lib/features/player/mini_player.dart @@ -4,7 +4,7 @@ import 'package:hongit/core/theme/app_theme.dart'; import 'package:marquee/marquee.dart'; import 'package:provider/provider.dart'; import '../../core/utils/audio_player_service.dart'; -import '../../core/utils/glass_container.dart'; +import '../../core/utils/themed_container.dart'; import '../../core/utils/youtube_thumbnail_utils.dart'; import '../../core/widgets/fallback_network_image.dart'; import 'full_player_sheet.dart'; @@ -73,6 +73,7 @@ class _MiniPlayerContent extends StatelessWidget { @override Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; final animateMiniVisuals = perfMode == UiPerformanceMode.full; final imageScale = YoutubeThumbnailUtils.preferredArtworkScale( imageUrl: now.imageUrl, @@ -95,7 +96,7 @@ class _MiniPlayerContent extends StatelessWidget { builder: (_) => const FullPlayerSheet(), ); }, - child: GlassContainer( + child: ThemedContainer( borderRadius: BorderRadius.circular(24), child: Padding( padding: const EdgeInsets.all(12), @@ -154,9 +155,9 @@ class _MiniPlayerContent extends StatelessWidget { const SizedBox(height: 2), _AutoMarqueeText( text: now.artist, - style: const TextStyle( + style: TextStyle( fontSize: 12, - color: Colors.white70, + color: scheme.onSurfaceVariant, ), enableMarquee: animateMiniVisuals, ), @@ -204,7 +205,7 @@ class _MiniPlayerContent extends StatelessWidget { strokeWidth: 2.4, valueColor: AlwaysStoppedAnimation( - Colors.white, + scheme.primary, ), ), ), diff --git a/lib/features/player/widgets/player_progress_bar.dart b/lib/features/player/widgets/player_progress_bar.dart index ba3949d..44af261 100644 --- a/lib/features/player/widgets/player_progress_bar.dart +++ b/lib/features/player/widgets/player_progress_bar.dart @@ -9,7 +9,6 @@ class PlayerProgressBar extends StatelessWidget { final double max; final ValueChanged onChanged; final ProgressBarStyle style; - final bool useGlassTheme; const PlayerProgressBar({ super.key, @@ -17,7 +16,6 @@ class PlayerProgressBar extends StatelessWidget { required this.max, required this.onChanged, required this.style, - required this.useGlassTheme, }); double get _safeMax => max > 0 ? max : 1.0; @@ -26,27 +24,16 @@ class PlayerProgressBar extends StatelessWidget { @override Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + switch (style) { case ProgressBarStyle.snake: return _SnakeProgressBar( value: _safeValue, max: _safeMax, onChanged: onChanged, - activeColor: const Color(0xFF1DB954), - inactiveColor: Colors.white.withValues(alpha: 0.28), - ); - case ProgressBarStyle.glass: - return SliderTheme( - data: SliderTheme.of(context).copyWith( - trackHeight: 8, - thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7), - overlayShape: const RoundSliderOverlayShape(overlayRadius: 14), - activeTrackColor: Colors.white.withValues(alpha: 0.92), - inactiveTrackColor: Colors.white.withValues(alpha: 0.24), - thumbColor: const Color(0xFF1DB954), - overlayColor: Colors.white.withValues(alpha: 0.12), - ), - child: Slider(value: _safeValue, max: _safeMax, onChanged: onChanged), + activeColor: scheme.primary, + inactiveColor: scheme.outlineVariant.withValues(alpha: 0.6), ); case ProgressBarStyle.defaultStyle: return Slider(value: _safeValue, max: _safeMax, onChanged: onChanged); @@ -157,7 +144,7 @@ class _SnakeProgressPainter extends CustomPainter { final headOutline = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 1.8 - ..color = Colors.black.withValues(alpha: 0.35); + ..color = inactiveColor.withValues(alpha: 0.7); canvas.drawCircle(tangent.position, 8, headFill); canvas.drawCircle(tangent.position, 8, headOutline); diff --git a/lib/features/search/artist_profile_screen.dart b/lib/features/search/artist_profile_screen.dart new file mode 100644 index 0000000..9344bf2 --- /dev/null +++ b/lib/features/search/artist_profile_screen.dart @@ -0,0 +1,645 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../core/theme/app_theme.dart'; +import '../../core/theme/responsive.dart'; +import '../../core/utils/audio_player_service.dart'; +import '../../core/utils/themed_container.dart'; +import '../../core/utils/themed_page.dart'; +import '../../core/utils/youtube_thumbnail_utils.dart'; +import '../../core/widgets/fallback_network_image.dart'; +import '../../data/api/youtube_api.dart'; +import '../../data/models/saavn_song.dart'; +import '../player/mini_player.dart'; +import 'chart_songs_screen.dart'; + +class ArtistProfileScreen extends StatefulWidget { + final String artistName; + final String? artistBrowseId; + + const ArtistProfileScreen({ + super.key, + required this.artistName, + this.artistBrowseId, + }); + + @override + State createState() => _ArtistProfileScreenState(); +} + +class _ArtistProfileScreenState extends State { + Future? _profileFuture; + bool _bioExpanded = false; + + @override + void initState() { + super.initState(); + _loadProfile(); + } + + void _loadProfile({bool forceRefresh = false}) { + _profileFuture = _fetchProfile(forceRefresh: forceRefresh); + } + + String _primaryArtistName(String raw) { + final trimmed = raw.trim(); + if (trimmed.isEmpty) return ''; + final normalized = trimmed + .replaceAll(RegExp(r'\s+'), ' ') + .replaceAll(RegExp(r'\b(feat|ft)\.?\b.*$', caseSensitive: false), '') + .trim(); + final split = normalized.split(RegExp(r'\s*(?:,|&| x | and )\s*')); + return split.first.trim(); + } + + Future _fetchProfile({bool forceRefresh = false}) async { + final seedName = _primaryArtistName(widget.artistName); + final seedBrowseId = YoutubeApi.extractArtistBrowseId( + widget.artistBrowseId ?? '', + ); + + return YoutubeApi.artistProfile( + seedBrowseId, + artistName: seedName, + topSongsTake: 24, + releasesTake: 20, + forceRefresh: forceRefresh, + ); + } + + Future _refresh() async { + setState(() { + _loadProfile(forceRefresh: true); + }); + try { + await _profileFuture; + } catch (_) {} + } + + List _topSongsPreview(List songs) { + if (songs.isEmpty) return const []; + if (songs.length <= 8) return songs; + final half = (songs.length / 2).ceil(); + final count = half.clamp(8, 12); + return songs.take(count).toList(growable: false); + } + + String _resolvedReleaseSubtitle(String subtitle, String artistName) { + final raw = subtitle.trim(); + if (raw.isEmpty) return artistName.trim(); + + final normalized = raw.toLowerCase().trim(); + final isUnknownOnly = + normalized == 'unknown' || + normalized == 'unknown artist' || + normalized == 'artist'; + if (isUnknownOnly) return artistName.trim(); + return raw; + } + + Widget _buildReleasesSection( + BuildContext context, { + required String title, + required List releases, + required String headerTitle, + required String artistName, + }) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final scheme = theme.colorScheme; + final cardWidth = ResponsiveLayout.isExpanded(context) ? 210.0 : 172.0; + + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 12), + if (releases.isEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Text( + 'No $title available', + style: textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ) + else + SizedBox( + height: 238, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.only(right: 2, bottom: 8), + itemCount: releases.length, + separatorBuilder: (_, _) => const SizedBox(width: 14), + itemBuilder: (_, index) { + final release = releases[index]; + final resolvedSubtitle = _resolvedReleaseSubtitle( + release.subtitle, + artistName, + ); + final imageCandidates = YoutubeThumbnailUtils.candidateUrls( + imageUrl: release.imageUrl, + ); + final imageScale = + YoutubeThumbnailUtils.preferredArtworkScale( + imageUrl: release.imageUrl, + youtubeVideoScale: 1.08, + normalScale: 1.0, + ); + final chart = YtmChart( + playlistId: release.browseId, + browseId: release.browseId, + title: release.title, + subtitle: resolvedSubtitle, + imageUrl: release.imageUrl, + ); + + return SizedBox( + width: cardWidth, + child: RepaintBoundary( + child: ThemedContainer( + borderRadius: BorderRadius.circular(18), + child: InkWell( + borderRadius: BorderRadius.circular(18), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChartSongsScreen( + chart: chart, + headerTitle: headerTitle, + fallbackArtistName: artistName, + ), + ), + ); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: 1, + child: ClipRRect( + borderRadius: const BorderRadius.vertical( + top: Radius.circular(18), + ), + child: Transform.scale( + scale: imageScale, + child: FallbackNetworkImage( + urls: imageCandidates, + width: double.infinity, + height: double.infinity, + cacheWidth: 768, + cacheHeight: 768, + fit: BoxFit.cover, + alignment: Alignment.center, + filterQuality: FilterQuality.medium, + fallback: Container( + color: scheme.surfaceContainerHighest, + child: const Icon( + Icons.album_rounded, + size: 34, + ), + ), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB( + 10, + 8, + 10, + 8, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + release.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + resolvedSubtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + }, + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final themeProvider = Provider.of(context); + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final textTheme = theme.textTheme; + final useGlassTheme = themeProvider.useGlassTheme; + + return ThemedPage( + child: Stack( + children: [ + RefreshIndicator( + onRefresh: _refresh, + child: FutureBuilder( + future: _profileFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: const [ + SizedBox(height: 180), + Center(child: CircularProgressIndicator()), + ], + ); + } + + if (snapshot.hasError || !snapshot.hasData) { + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + const SizedBox(height: 120), + Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Unable to load artist profile', + style: textTheme.titleMedium, + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: _refresh, + child: const Text('Retry'), + ), + ], + ), + ), + ), + ], + ); + } + + final profile = snapshot.data!; + final previewSongs = _topSongsPreview(profile.topSongs); + final queuedSongs = profile.topSongs + .map( + (s) => QueuedSong( + id: s.id, + meta: NowPlaying( + title: s.name, + artist: s.artists, + imageUrl: s.imageUrl, + ), + ), + ) + .toList(growable: false); + final headerImageCandidates = + YoutubeThumbnailUtils.candidateUrls( + imageUrl: profile.imageUrl, + ); + final showBioToggle = profile.bio.trim().length > 220; + final topSongsCount = profile.topSongs.length; + final headerHeight = ResponsiveLayout.isExpanded(context) + ? 360.0 + : 310.0; + + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.only(bottom: 140), + children: [ + const SizedBox(height: 8), + Row( + children: [ + IconButton( + icon: Icon( + useGlassTheme + ? CupertinoIcons.back + : Icons.arrow_back, + ), + onPressed: () => Navigator.of(context).pop(), + ), + const SizedBox(width: 6), + Text( + 'Artist', + style: textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ], + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: ClipRRect( + borderRadius: BorderRadius.circular(24), + child: SizedBox( + height: headerHeight, + child: Stack( + fit: StackFit.expand, + children: [ + FallbackNetworkImage( + urls: headerImageCandidates, + fit: BoxFit.cover, + alignment: Alignment.center, + cacheWidth: 1200, + cacheHeight: 1200, + filterQuality: FilterQuality.medium, + fallback: Container( + color: scheme.surfaceContainerHighest, + child: const Icon( + Icons.person_rounded, + size: 72, + ), + ), + ), + Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0x22000000), + Color(0xB0000000), + Color(0xE0000000), + ], + ), + ), + ), + Positioned( + left: 16, + right: 16, + bottom: 16, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + profile.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.w800, + color: Colors.white, + ), + ), + if (profile.monthlyAudience + .trim() + .isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + profile.monthlyAudience, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.titleMedium + ?.copyWith( + color: Colors.white.withValues( + alpha: 0.92, + ), + ), + ), + ), + if (profile.bio.trim().isNotEmpty) ...[ + const SizedBox(height: 10), + Text( + profile.bio, + maxLines: _bioExpanded ? 10 : 4, + overflow: TextOverflow.ellipsis, + style: textTheme.bodyMedium?.copyWith( + color: Colors.white.withValues( + alpha: 0.93, + ), + ), + ), + if (showBioToggle) + Padding( + padding: const EdgeInsets.only( + top: 2, + ), + child: InkWell( + borderRadius: BorderRadius.circular( + 8, + ), + onTap: () => setState( + () => + _bioExpanded = !_bioExpanded, + ), + child: Padding( + padding: + const EdgeInsets.symmetric( + vertical: 4, + ), + child: Text( + _bioExpanded ? 'less' : 'more', + style: textTheme.labelLarge + ?.copyWith( + color: Colors.white, + fontWeight: + FontWeight.w700, + ), + ), + ), + ), + ), + ], + ], + ), + ), + ], + ), + ), + ), + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'Top Songs', + style: textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(height: 8), + if (previewSongs.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'No top songs available', + style: textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ) + else ...[ + ...previewSongs.asMap().entries.map((entry) { + final index = entry.key; + final song = entry.value; + final imageScale = + YoutubeThumbnailUtils.preferredArtworkScale( + songId: song.id, + imageUrl: song.imageUrl, + youtubeVideoScale: 1.0, + normalScale: 1.0, + ); + final imageCandidates = + YoutubeThumbnailUtils.candidateUrls( + songId: song.id, + imageUrl: song.imageUrl, + ); + + return Padding( + padding: const EdgeInsets.only( + left: 16, + right: 16, + bottom: 10, + ), + child: ThemedContainer( + child: ListTile( + leading: SizedBox( + width: 50, + height: 50, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Transform.scale( + scale: imageScale, + child: FallbackNetworkImage( + urls: imageCandidates, + width: 50, + height: 50, + cacheWidth: 320, + cacheHeight: 320, + fit: BoxFit.cover, + alignment: Alignment.center, + filterQuality: FilterQuality.medium, + fallback: Container( + color: scheme.surfaceContainerHighest, + child: const Icon( + Icons.music_note_rounded, + size: 20, + ), + ), + ), + ), + ), + ), + title: Text( + song.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + song.artists, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: Text( + '${index + 1}', + style: textTheme.labelLarge?.copyWith( + color: scheme.onSurfaceVariant, + fontWeight: FontWeight.w700, + ), + ), + onTap: () async { + if (index < 0 || index >= queuedSongs.length) { + return; + } + await AudioPlayerService().playFromList( + songs: queuedSongs, + startIndex: index, + ); + }, + ), + ), + ); + }), + if (topSongsCount > previewSongs.length) + Padding( + padding: const EdgeInsets.only( + left: 16, + right: 16, + bottom: 6, + ), + child: Text( + 'Showing ${previewSongs.length} of $topSongsCount', + style: textTheme.labelMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ), + ], + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: _buildReleasesSection( + context, + title: 'Albums', + releases: profile.albums, + headerTitle: 'Albums', + artistName: profile.name, + ), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: _buildReleasesSection( + context, + title: 'Singles & EPs', + releases: profile.singlesAndEps, + headerTitle: 'Singles & EPs', + artistName: profile.name, + ), + ), + ], + ); + }, + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Align( + alignment: Alignment.bottomCenter, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: ResponsiveLayout.isExpanded(context) + ? 760 + : double.infinity, + ), + child: const MiniPlayer(), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/search/chart_songs_screen.dart b/lib/features/search/chart_songs_screen.dart index 0d5828d..84783ff 100644 --- a/lib/features/search/chart_songs_screen.dart +++ b/lib/features/search/chart_songs_screen.dart @@ -3,10 +3,11 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../core/theme/app_theme.dart'; +import '../../core/theme/responsive.dart'; import '../../core/utils/audio_player_service.dart'; import '../../core/utils/app_messenger.dart'; -import '../../core/utils/glass_container.dart'; -import '../../core/utils/glass_page.dart'; +import '../../core/utils/themed_container.dart'; +import '../../core/utils/themed_page.dart'; import '../../core/utils/youtube_thumbnail_utils.dart'; import '../../core/widgets/fallback_network_image.dart'; import '../../data/api/youtube_api.dart'; @@ -17,11 +18,13 @@ import '../player/mini_player.dart'; class ChartSongsScreen extends StatefulWidget { final YtmChart chart; final String headerTitle; + final String? fallbackArtistName; const ChartSongsScreen({ super.key, required this.chart, this.headerTitle = 'Charts', + this.fallbackArtistName, }); @override @@ -84,6 +87,11 @@ class _ChartSongsScreenState extends State { } String _fallbackArtistFromChartSubtitle() { + final explicit = widget.fallbackArtistName?.trim() ?? ''; + if (explicit.isNotEmpty && !_isUnknownArtistValue(explicit)) { + return explicit; + } + final subtitle = widget.chart.subtitle.trim(); if (subtitle.isEmpty) return ''; final parts = subtitle @@ -290,11 +298,12 @@ class _ChartSongsScreenState extends State { } Future _toggleFavourite(SaavnSong song) async { + final scheme = Theme.of(context).colorScheme; final wasFavourite = PlaylistManager.isFavourite(song.id); await PlaylistManager.toggleFavourite(_songAsPlaylistEntry(song)); AppMessenger.show( wasFavourite ? 'Removed from favorites' : 'Added to favorites', - color: wasFavourite ? Colors.orange.shade700 : Colors.green.shade700, + color: wasFavourite ? scheme.secondary : scheme.primary, ); if (mounted) setState(() {}); } @@ -304,11 +313,15 @@ class _ChartSongsScreenState extends State { context, listen: false, ).useGlassTheme; + final uiTheme = Theme.of(context); + final scheme = uiTheme.colorScheme; + final textTheme = uiTheme.textTheme; final addedInSheet = {}; showModalBottomSheet( context: context, - backgroundColor: Colors.black.withValues(alpha: 0.85), + useSafeArea: true, + backgroundColor: scheme.surfaceContainerHigh.withValues(alpha: 0.96), shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), @@ -330,18 +343,17 @@ class _ChartSongsScreenState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Text( + Text( 'Add to Playlist', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, + style: textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, ), ), const SizedBox(height: 12), if (names.isEmpty) - const Text( + Text( 'No playlists yet', - style: TextStyle(color: Colors.white54), + style: TextStyle(color: scheme.onSurfaceVariant), ), ...names.map((name) { final playlistSongs = playlists[name] ?? const []; @@ -356,15 +368,12 @@ class _ChartSongsScreenState extends State { added ? _addedToPlaylistIcon(useGlassTheme) : _playlistIcon(useGlassTheme), - color: added ? Colors.green.shade400 : null, + color: added ? scheme.primary : null, ), title: Text(name), onTap: () async { if (added) { - AppMessenger.show( - 'Already in "$name"', - color: Colors.orange.shade700, - ); + AppMessenger.show('Already in "$name"'); return; } @@ -376,15 +385,9 @@ class _ChartSongsScreenState extends State { if (success) { setModalState(() => addedInSheet.add(name)); - AppMessenger.show( - 'Added to "$name"', - color: Colors.green.shade700, - ); + AppMessenger.show('Added to "$name"'); } else { - AppMessenger.show( - 'Already in "$name"', - color: Colors.orange.shade700, - ); + AppMessenger.show('Already in "$name"'); } }, ); @@ -403,11 +406,16 @@ class _ChartSongsScreenState extends State { @override Widget build(BuildContext context) { final theme = Provider.of(context); + final uiTheme = Theme.of(context); + final textTheme = uiTheme.textTheme; + final scheme = uiTheme.colorScheme; + final screenWidth = MediaQuery.sizeOf(context).width; + final headerArtSize = screenWidth >= 1024 ? 280.0 : 210.0; final artCandidates = YoutubeThumbnailUtils.candidateUrls( imageUrl: widget.chart.imageUrl, ); - return GlassPage( + return ThemedPage( child: Stack( children: [ RefreshIndicator( @@ -452,9 +460,8 @@ class _ChartSongsScreenState extends State { const SizedBox(width: 6), Text( widget.headerTitle, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.w600, + style: textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, ), ), ], @@ -472,16 +479,16 @@ class _ChartSongsScreenState extends State { borderRadius: BorderRadius.circular(20), child: FallbackNetworkImage( urls: artCandidates, - width: 210, - height: 210, + width: headerArtSize, + height: headerArtSize, cacheWidth: 1024, cacheHeight: 1024, fit: BoxFit.cover, filterQuality: FilterQuality.medium, fallback: Container( - width: 210, - height: 210, - color: Colors.black26, + width: headerArtSize, + height: headerArtSize, + color: scheme.surfaceContainerHighest, child: Icon( _chartFallbackIcon(theme.useGlassTheme), size: 48, @@ -495,9 +502,8 @@ class _ChartSongsScreenState extends State { textAlign: TextAlign.center, maxLines: 2, overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.w700, + style: textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w800, ), ), const SizedBox(height: 6), @@ -506,19 +512,17 @@ class _ChartSongsScreenState extends State { textAlign: TextAlign.center, maxLines: 2, overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 15, + style: textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w500, - color: Colors.white70, + color: scheme.onSurfaceVariant, ), ), const SizedBox(height: 8), Text( '$resolvedSongCount songs', textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 12, - color: Colors.white60, + style: textTheme.labelMedium?.copyWith( + color: scheme.onSurfaceVariant, ), ), ], @@ -533,9 +537,8 @@ class _ChartSongsScreenState extends State { _enhanceTargetCount > 0 ? 'Fetching songs arts... $_enhancedCount/$_enhanceTargetCount' : 'Fetching songs arts...', - style: const TextStyle( - fontSize: 12, - color: Colors.white60, + style: textTheme.labelMedium?.copyWith( + color: scheme.onSurfaceVariant, ), ), ), @@ -552,9 +555,11 @@ class _ChartSongsScreenState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Text( + Text( 'Failed to load chart songs', - style: TextStyle(color: Colors.white70), + style: TextStyle( + color: scheme.onSurfaceVariant, + ), ), const SizedBox(height: 10), OutlinedButton( @@ -566,12 +571,12 @@ class _ChartSongsScreenState extends State { ), ) else if (songs.isEmpty) - const Padding( + Padding( padding: EdgeInsets.symmetric(vertical: 36), child: Center( child: Text( 'No songs found in this chart', - style: TextStyle(color: Colors.white60), + style: TextStyle(color: scheme.onSurfaceVariant), ), ), ) @@ -601,7 +606,7 @@ class _ChartSongsScreenState extends State { return Padding( padding: const EdgeInsets.only(bottom: 10), - child: GlassContainer( + child: ThemedContainer( child: ListTile( leading: SizedBox( width: 50, @@ -619,7 +624,7 @@ class _ChartSongsScreenState extends State { fit: BoxFit.cover, filterQuality: FilterQuality.medium, fallback: Container( - color: Colors.black26, + color: scheme.surfaceContainerHighest, child: Icon( _songFallbackIcon( theme.useGlassTheme, @@ -662,7 +667,7 @@ class _ChartSongsScreenState extends State { ), color: PlaylistManager.isFavourite(song.id) - ? Colors.redAccent + ? scheme.error : null, ), onPressed: () => _toggleFavourite(song), @@ -694,7 +699,22 @@ class _ChartSongsScreenState extends State { }, ), ), - const Positioned(left: 0, right: 0, bottom: 0, child: MiniPlayer()), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Align( + alignment: Alignment.bottomCenter, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: ResponsiveLayout.isExpanded(context) + ? 760 + : double.infinity, + ), + child: const MiniPlayer(), + ), + ), + ), ], ), ); diff --git a/lib/features/search/search_screen.dart b/lib/features/search/search_screen.dart index 4d075ab..9a837a4 100644 --- a/lib/features/search/search_screen.dart +++ b/lib/features/search/search_screen.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:marquee/marquee.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:hongit/core/theme/app_theme.dart'; +import 'package:hongit/core/theme/responsive.dart'; import 'package:hongit/core/utils/audio_player_service.dart'; import 'package:hongit/data/api/saavn_api.dart'; import 'package:hongit/data/api/youtube_api.dart'; @@ -16,8 +17,8 @@ import 'package:hongit/features/library/local_audio_provider.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../../core/utils/app_logger.dart'; -import '../../core/utils/glass_container.dart'; -import '../../core/utils/glass_page.dart'; +import '../../core/utils/themed_container.dart'; +import '../../core/utils/themed_page.dart'; import '../../core/utils/youtube_thumbnail_utils.dart'; import '../../core/widgets/fallback_network_image.dart'; @@ -35,6 +36,7 @@ class _SearchScreenState extends State final ScrollController _chartsScrollController = ScrollController(); final ScrollController _albumsScrollController = ScrollController(); Future>? _searchFuture; + Future>? _albumSearchFuture; String _lastQuery = ''; Timer? _debounce; static final Map _sessionSearchCache = {}; @@ -127,6 +129,7 @@ class _SearchScreenState extends State _useYoutubeService = false; _useSaavnService = false; _homeSectionsFuture = null; + _albumSearchFuture = null; _localAudiosFuture = _loadLocalAudiosWithPermission(); }); _localAudiosFuture.then((tracks) { @@ -139,6 +142,7 @@ class _SearchScreenState extends State _useYoutubeService = useYoutube; _useSaavnService = useSaavn; _searchFuture = _performSearch(_quickPicksQuery); + _albumSearchFuture = null; _homeSectionsFuture = useYoutube ? _loadHomeSections() : null; }); } @@ -186,21 +190,27 @@ class _SearchScreenState extends State if (!useYoutube && !useSaavn) { _homeSectionsFuture = null; _searchFuture = null; + _albumSearchFuture = null; _localAudiosFuture = _loadLocalAudiosWithPermission(); localAudiosFuture = _localAudiosFuture; } else { _searchFuture = _performSearch(_quickPicksQuery, forceRefresh: true); + _albumSearchFuture = null; _homeSectionsFuture = useYoutube ? _loadHomeSections(forceRefresh: true) : null; } } else if (query.length < minSearchLength) { _searchFuture = null; + _albumSearchFuture = null; } else { _lastQuery = query; _searchFuture = (!useYoutube && !useSaavn) ? _searchLocalAudios(query) : _performSearch(query, forceRefresh: true); + _albumSearchFuture = useYoutube + ? _performAlbumSearch(query, forceRefresh: true) + : null; } }); @@ -212,6 +222,9 @@ class _SearchScreenState extends State if (useYoutube || useSaavn) { await _searchFuture?.catchError((_) => []); + if (useYoutube && query.length >= minSearchLength) { + await _albumSearchFuture?.catchError((_) => []); + } } } @@ -233,6 +246,30 @@ class _SearchScreenState extends State return results; } + Future> _performAlbumSearch( + String query, { + bool forceRefresh = false, + }) async { + final normalizedQuery = query.trim(); + if (normalizedQuery.isEmpty || normalizedQuery.length < minSearchLength) { + return const []; + } + + final prefs = await SharedPreferences.getInstance(); + final useYoutube = prefs.getBool('use_youtube_service') ?? false; + if (!useYoutube) return const []; + + try { + return await YoutubeApi.searchAlbums( + normalizedQuery, + take: _albumsTargetCount, + forceRefresh: forceRefresh, + ); + } catch (_) { + return const []; + } + } + Future> _performSearch( String query, { bool forceRefresh = false, @@ -891,6 +928,7 @@ class _SearchScreenState extends State if (!useYoutube && !useSaavn) { _homeSectionsFuture = null; _searchFuture = null; + _albumSearchFuture = null; _localAudiosFuture = _loadLocalAudiosWithPermission(); _localAudiosFuture.then((tracks) { if (!mounted) return; @@ -898,6 +936,7 @@ class _SearchScreenState extends State }); } else { _searchFuture = _performSearch(_quickPicksQuery); + _albumSearchFuture = null; _homeSectionsFuture = useYoutube ? _loadHomeSections() : null; } }); @@ -909,6 +948,7 @@ class _SearchScreenState extends State setState(() { _lastQuery = ''; _searchFuture = null; + _albumSearchFuture = null; }); return; } @@ -927,8 +967,12 @@ class _SearchScreenState extends State _lastQuery = trimmed; if (!useYoutube && !useSaavn) { _searchFuture = _searchLocalAudios(trimmed); + _albumSearchFuture = null; } else { _searchFuture = _performSearch(trimmed); + _albumSearchFuture = useYoutube + ? _performAlbumSearch(trimmed) + : null; } }); }); @@ -952,12 +996,16 @@ class _SearchScreenState extends State Widget build(BuildContext context) { super.build(context); final themeProvider = Provider.of(context); + final theme = Theme.of(context); + final textTheme = theme.textTheme; final perfMode = themeProvider.resolvedUiPerformanceMode(context); final smoothMode = perfMode == UiPerformanceMode.smooth; final animateSectionHeader = perfMode == UiPerformanceMode.full; if (!_servicesReady) { - return const GlassPage(child: Center(child: CircularProgressIndicator())); + return const ThemedPage( + child: Center(child: CircularProgressIndicator()), + ); } final useYoutube = _useYoutubeService; @@ -965,7 +1013,7 @@ class _SearchScreenState extends State final isLocalMode = !useYoutube && !useSaavn; final headerText = isSearching ? 'Search Results' : 'Quick Picks'; - return GlassPage( + return ThemedPage( child: RefreshIndicator( onRefresh: _refreshSearch, child: CustomScrollView( @@ -974,50 +1022,34 @@ class _SearchScreenState extends State physics: const AlwaysScrollableScrollPhysics(), cacheExtent: smoothMode ? 420 : 720, slivers: [ - const SliverToBoxAdapter( + SliverToBoxAdapter( child: Text( 'Welcome to\nHongeet', - style: TextStyle(fontSize: 26, fontWeight: FontWeight.w600), + style: textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + ), ), ), const SliverToBoxAdapter(child: SizedBox(height: 20)), SliverToBoxAdapter( - child: GlassContainer( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: TextField( - controller: _controller, - onChanged: _onSearchChanged, - style: const TextStyle(color: Colors.white), - decoration: InputDecoration( - icon: Icon( - themeProvider.useGlassTheme - ? CupertinoIcons.search - : Icons.search, - color: Colors.white70, - ), - hintText: isLocalMode - ? 'Search local audio...' - : 'Search songs, artists...', - hintStyle: const TextStyle(color: Colors.white54), - border: InputBorder.none, - suffixIcon: _controller.text.isNotEmpty - ? IconButton( - icon: Icon( - themeProvider.useGlassTheme - ? CupertinoIcons.clear_circled_solid - : Icons.close, - color: Colors.white70, - ), - onPressed: () { - _controller.clear(); - _onSearchChanged(''); - }, - ) - : null, - ), - ), - ), + child: SearchBar( + controller: _controller, + hintText: isLocalMode + ? 'Search local audio...' + : 'Search songs, artists...', + leading: const Icon(Icons.search), + onChanged: _onSearchChanged, + trailing: _controller.text.isEmpty + ? null + : [ + IconButton( + icon: const Icon(Icons.close), + onPressed: () { + _controller.clear(); + _onSearchChanged(''); + }, + ), + ], ), ), const SliverToBoxAdapter(child: SizedBox(height: 28)), @@ -1030,17 +1062,15 @@ class _SearchScreenState extends State child: Text( headerText, key: ValueKey(headerText), - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.w500, + style: textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, ), ), ) : Text( headerText, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.w500, + style: textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, ), ), ), @@ -1067,6 +1097,9 @@ class _SearchScreenState extends State Widget _buildHomeSectionsSliver(BuildContext context) { _homeSectionsFuture ??= _loadHomeSections(); final themeProvider = Provider.of(context, listen: false); + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final textTheme = theme.textTheme; return FutureBuilder<_HomeSectionsData>( future: _homeSectionsFuture, @@ -1098,7 +1131,7 @@ class _SearchScreenState extends State if (snapshot.hasError) { return SliverToBoxAdapter( - child: GlassContainer( + child: ThemedContainer( child: Padding( padding: const EdgeInsets.symmetric( horizontal: 14, @@ -1110,13 +1143,15 @@ class _SearchScreenState extends State themeProvider.useGlassTheme ? CupertinoIcons.exclamationmark_triangle : Icons.error_outline, - color: Colors.white70, + color: scheme.onSurfaceVariant, ), const SizedBox(width: 10), - const Expanded( + Expanded( child: Text( 'Failed to load home sections', - style: TextStyle(color: Colors.white70), + style: textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), ), ), OutlinedButton( @@ -1154,6 +1189,7 @@ class _SearchScreenState extends State required double? height, required double topPadding, }) { + final textTheme = Theme.of(context).textTheme; final perfMode = Provider.of( context, listen: false, @@ -1171,7 +1207,9 @@ class _SearchScreenState extends State child: Text( title, textAlign: fullMode ? TextAlign.center : TextAlign.start, - style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w500), + style: textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), ), ), const SizedBox(height: 16), @@ -1192,9 +1230,13 @@ class _SearchScreenState extends State Widget _buildChartsSection(BuildContext context, List charts) { final themeProvider = Provider.of(context, listen: false); + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final textTheme = theme.textTheme; final perfMode = themeProvider.resolvedUiPerformanceMode(context); final smoothMode = perfMode == UiPerformanceMode.smooth; final fullMode = perfMode == UiPerformanceMode.full; + final cardWidth = ResponsiveLayout.isExpanded(context) ? 210.0 : 170.0; return Column( crossAxisAlignment: fullMode @@ -1206,26 +1248,30 @@ class _SearchScreenState extends State child: fullMode ? AnimatedSwitcher( duration: const Duration(milliseconds: 200), - child: const Text( + child: Text( 'Charts', - key: ValueKey('charts_full'), + key: const ValueKey('charts_full'), textAlign: TextAlign.center, - style: TextStyle(fontSize: 20, fontWeight: FontWeight.w500), + style: textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), ), ) - : const Text( + : Text( 'Charts', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.w500), + style: textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), ), ), const SizedBox(height: 16), SizedBox( height: _chartsSectionBodyHeight, child: charts.isEmpty - ? const Center( + ? Center( child: Text( 'No charts available right now', - style: TextStyle(color: Colors.white60), + style: TextStyle(color: scheme.onSurfaceVariant), ), ) : ListView.separated( @@ -1250,9 +1296,9 @@ class _SearchScreenState extends State ); return SizedBox( - width: 170, + width: cardWidth, child: RepaintBoundary( - child: GlassContainer( + child: ThemedContainer( borderRadius: BorderRadius.circular(18), child: InkWell( borderRadius: BorderRadius.circular(18), @@ -1283,7 +1329,7 @@ class _SearchScreenState extends State fit: BoxFit.cover, filterQuality: FilterQuality.medium, fallback: Container( - color: Colors.black26, + color: scheme.surfaceContainerHighest, child: Icon( themeProvider.useGlassTheme ? CupertinoIcons.waveform @@ -1311,10 +1357,14 @@ class _SearchScreenState extends State height: 20, child: _AutoMarqueeText( text: chart.title, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - ), + style: + textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ) ?? + const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + ), ), ), const SizedBox(height: 2), @@ -1322,9 +1372,8 @@ class _SearchScreenState extends State chart.subtitle, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 12, - color: Colors.white70, + style: textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, ), ), ], @@ -1349,9 +1398,13 @@ class _SearchScreenState extends State List albums, ) { final themeProvider = Provider.of(context, listen: false); + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final textTheme = theme.textTheme; final perfMode = themeProvider.resolvedUiPerformanceMode(context); final smoothMode = perfMode == UiPerformanceMode.smooth; final fullMode = perfMode == UiPerformanceMode.full; + final cardWidth = ResponsiveLayout.isExpanded(context) ? 210.0 : 170.0; return Padding( padding: const EdgeInsets.only(top: 24), @@ -1371,23 +1424,25 @@ class _SearchScreenState extends State textAlign: TextAlign.center, style: TextStyle( fontSize: 20, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w700, ), ), ) - : const Text( + : Text( 'Trending Albums', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.w500), + style: textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), ), ), const SizedBox(height: 16), SizedBox( height: _albumsSectionBodyHeight, child: albums.isEmpty - ? const Center( + ? Center( child: Text( 'No trending albums right now', - style: TextStyle(color: Colors.white60), + style: TextStyle(color: scheme.onSurfaceVariant), ), ) : ListView.separated( @@ -1441,9 +1496,9 @@ class _SearchScreenState extends State ); return SizedBox( - width: 170, + width: cardWidth, child: RepaintBoundary( - child: GlassContainer( + child: ThemedContainer( borderRadius: BorderRadius.circular(18), child: InkWell( borderRadius: BorderRadius.circular(18), @@ -1479,7 +1534,8 @@ class _SearchScreenState extends State alignment: Alignment.center, filterQuality: FilterQuality.medium, fallback: Container( - color: Colors.black26, + color: + scheme.surfaceContainerHighest, child: Icon( themeProvider.useGlassTheme ? CupertinoIcons.music_albums @@ -1508,10 +1564,16 @@ class _SearchScreenState extends State height: 20, child: _AutoMarqueeText( text: album.title, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - ), + style: + textTheme.titleSmall + ?.copyWith( + fontWeight: + FontWeight.w700, + ) ?? + const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + ), ), ), const SizedBox(height: 2), @@ -1519,10 +1581,11 @@ class _SearchScreenState extends State album.subtitle, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 12, - color: Colors.white70, - ), + style: textTheme.bodySmall + ?.copyWith( + color: + scheme.onSurfaceVariant, + ), ), ], ), @@ -1547,6 +1610,9 @@ class _SearchScreenState extends State List songs, ) { final themeProvider = Provider.of(context, listen: false); + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final textTheme = theme.textTheme; final perfMode = themeProvider.resolvedUiPerformanceMode(context); final fullMode = perfMode == UiPerformanceMode.full; final queuedSongs = songs @@ -1581,21 +1647,23 @@ class _SearchScreenState extends State textAlign: TextAlign.center, style: TextStyle( fontSize: 20, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w700, ), ), ) - : const Text( + : Text( 'Trending Songs', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.w500), + style: textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), ), ), const SizedBox(height: 16), if (songs.isEmpty) - const Center( + Center( child: Text( 'No trending songs right now', - style: TextStyle(color: Colors.white60), + style: TextStyle(color: scheme.onSurfaceVariant), ), ) else @@ -1618,7 +1686,7 @@ class _SearchScreenState extends State bottom: index == songs.length - 1 ? 0 : 10, ), child: RepaintBoundary( - child: GlassContainer( + child: ThemedContainer( borderRadius: BorderRadius.circular(14), child: InkWell( borderRadius: BorderRadius.circular(14), @@ -1655,7 +1723,7 @@ class _SearchScreenState extends State fallback: Container( width: 56, height: 56, - color: Colors.black26, + color: scheme.surfaceContainerHighest, child: Icon( themeProvider.useGlassTheme ? CupertinoIcons.music_note_2 @@ -1676,9 +1744,8 @@ class _SearchScreenState extends State song.name, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, + style: textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, ), ), const SizedBox(height: 4), @@ -1686,9 +1753,8 @@ class _SearchScreenState extends State song.artists, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 13, - color: Colors.white70, + style: textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, ), ), ], @@ -1709,6 +1775,9 @@ class _SearchScreenState extends State } Widget _buildLocalSearchResults(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final textTheme = theme.textTheme; final query = _controller.text.trim(); if (query.isNotEmpty && query.length < minSearchLength) { return Center( @@ -1716,7 +1785,9 @@ class _SearchScreenState extends State padding: const EdgeInsets.all(32), child: Text( 'Type at least $minSearchLength characters to search', - style: const TextStyle(color: Colors.white54, fontSize: 14), + style: textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), textAlign: TextAlign.center, ), ), @@ -1743,7 +1814,9 @@ class _SearchScreenState extends State padding: const EdgeInsets.all(32), child: Text( 'No local audio files found on your device', - style: const TextStyle(color: Colors.white54, fontSize: 14), + style: textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), textAlign: TextAlign.center, ), ), @@ -1758,7 +1831,7 @@ class _SearchScreenState extends State final song = entry.value; return Padding( padding: const EdgeInsets.only(bottom: 12), - child: GlassContainer( + child: ThemedContainer( child: InkWell( borderRadius: BorderRadius.circular(14), onTap: () async { @@ -1781,9 +1854,8 @@ class _SearchScreenState extends State song.name, maxLines: 2, overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, + style: textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, ), ), const SizedBox(height: 4), @@ -1791,9 +1863,8 @@ class _SearchScreenState extends State 'Local Audio', maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 12, - color: Colors.white54, + style: textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, ), ), ], @@ -1821,7 +1892,7 @@ class _SearchScreenState extends State return Center( child: Text( 'Error loading local audio: ${snapshot.error}', - style: const TextStyle(color: Colors.red), + style: TextStyle(color: scheme.error), ), ); } @@ -1833,7 +1904,9 @@ class _SearchScreenState extends State padding: const EdgeInsets.all(32), child: Text( 'No matches found', - style: const TextStyle(color: Colors.white54, fontSize: 14), + style: textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), textAlign: TextAlign.center, ), ), @@ -1848,7 +1921,7 @@ class _SearchScreenState extends State final song = entry.value; return Padding( padding: const EdgeInsets.only(bottom: 12), - child: GlassContainer( + child: ThemedContainer( child: InkWell( borderRadius: BorderRadius.circular(14), onTap: () async { @@ -1871,9 +1944,8 @@ class _SearchScreenState extends State song.name, maxLines: 2, overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, + style: textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, ), ), const SizedBox(height: 4), @@ -1881,9 +1953,8 @@ class _SearchScreenState extends State 'Local Audio', maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 12, - color: Colors.white54, + style: textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, ), ), ], @@ -1902,8 +1973,184 @@ class _SearchScreenState extends State ); } + Widget _buildSearchAlbumsSection( + BuildContext context, { + required List albums, + required bool loading, + }) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final textTheme = theme.textTheme; + final cardWidth = ResponsiveLayout.isExpanded(context) ? 210.0 : 172.0; + + if (loading) { + return SizedBox( + height: _albumsSectionBodyHeight, + child: const Center(child: CircularProgressIndicator()), + ); + } + + if (albums.isEmpty) { + return Padding( + padding: const EdgeInsets.only(top: 4, bottom: 12), + child: Text( + 'No related albums found', + style: textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant), + ), + ); + } + + return SizedBox( + height: _albumsSectionBodyHeight, + child: ListView.separated( + key: const PageStorageKey('search_screen_albums_query_row'), + scrollDirection: Axis.horizontal, + controller: _albumsScrollController, + padding: const EdgeInsets.only(right: 2, bottom: 8), + itemCount: albums.length, + separatorBuilder: (_, _) => const SizedBox(width: 14), + itemBuilder: (_, index) { + final album = albums[index]; + final allImageCandidates = YoutubeThumbnailUtils.candidateUrls( + imageUrl: album.imageUrl, + ); + final ytmOnlyCandidates = allImageCandidates + .where(YoutubeThumbnailUtils.isYtmArtworkUrl) + .toList(growable: false); + final imageCandidates = ytmOnlyCandidates.isNotEmpty + ? ytmOnlyCandidates + : allImageCandidates; + final baseImageScale = YoutubeThumbnailUtils.preferredArtworkScale( + imageUrl: album.imageUrl, + youtubeVideoScale: 2.0, + normalScale: 1.0, + ); + final imageScale = ytmOnlyCandidates.isNotEmpty + ? (baseImageScale < 1.04 ? 1.04 : baseImageScale) + : (baseImageScale < 1.12 ? 1.12 : baseImageScale); + + final albumAsChart = YtmChart( + playlistId: album.browseId, + browseId: album.browseId, + title: album.title, + subtitle: album.subtitle, + imageUrl: album.imageUrl, + ); + + return SizedBox( + width: cardWidth, + child: RepaintBoundary( + child: ThemedContainer( + borderRadius: BorderRadius.circular(18), + child: InkWell( + borderRadius: BorderRadius.circular(18), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChartSongsScreen( + chart: albumAsChart, + headerTitle: 'Albums', + ), + ), + ); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: 1, + child: ClipRRect( + clipBehavior: Clip.antiAlias, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(18), + ), + child: Transform.scale( + scale: imageScale, + child: FallbackNetworkImage( + urls: imageCandidates, + width: double.infinity, + height: double.infinity, + cacheWidth: 768, + cacheHeight: 768, + fit: BoxFit.cover, + alignment: Alignment.center, + filterQuality: FilterQuality.medium, + fallback: Container( + color: scheme.surfaceContainerHighest, + child: const Icon( + Icons.album_rounded, + size: 34, + ), + ), + ), + ), + ), + ), + Flexible( + child: Padding( + padding: const EdgeInsets.fromLTRB(10, 8, 10, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 20, + child: _AutoMarqueeText( + text: album.title, + style: + textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ) ?? + const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(height: 2), + Text( + album.subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ); + }, + ), + ); + } + Widget _buildSearchResultsSliver(BuildContext context) { final query = _controller.text.trim(); + final theme = Theme.of(context); + final columns = ResponsiveLayout.adaptiveGridColumns( + context, + minCardWidth: 415, + minColumns: 2, + maxColumns: 6, + ); + final gridGap = columns >= 4 ? 18.0 : 14.0; + final aspectRatio = switch (columns) { + >= 5 => 0.78, + 4 => 0.75, + 3 => 0.72, + _ => 0.68, + }; + final showAlbumsSection = + _useYoutubeService && query.length >= minSearchLength; + final showSongsSectionTitle = query.isNotEmpty; + if (query.isNotEmpty && query.length < minSearchLength) { return SliverToBoxAdapter( child: Center( @@ -1911,7 +2158,9 @@ class _SearchScreenState extends State padding: const EdgeInsets.all(32), child: Text( 'Type at least $minSearchLength characters to search', - style: const TextStyle(color: Colors.white54, fontSize: 14), + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), textAlign: TextAlign.center, ), ), @@ -1927,8 +2176,8 @@ class _SearchScreenState extends State return FutureBuilder>( future: _searchFuture, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { + builder: (context, songSnapshot) { + if (songSnapshot.connectionState == ConnectionState.waiting) { return const SliverToBoxAdapter( child: Padding( padding: EdgeInsets.symmetric(vertical: 24), @@ -1937,7 +2186,7 @@ class _SearchScreenState extends State ); } - if (snapshot.hasError) { + if (songSnapshot.hasError) { return SliverToBoxAdapter( child: Center( child: Padding( @@ -1950,21 +2199,22 @@ class _SearchScreenState extends State ? CupertinoIcons.exclamationmark_triangle : Icons.error_outline, size: 48, - color: Colors.red.shade300, + color: theme.colorScheme.error, ), const SizedBox(height: 16), - const Text( + Text( 'Failed to load songs', - style: TextStyle( - color: Colors.white70, - fontSize: 16, + style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w500, + color: theme.colorScheme.onSurface, ), ), const SizedBox(height: 8), - const Text( + Text( 'API might be down or network issue', - style: TextStyle(color: Colors.white54, fontSize: 12), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), textAlign: TextAlign.center, ), const SizedBox(height: 14), @@ -1979,24 +2229,9 @@ class _SearchScreenState extends State ); } - if (!snapshot.hasData || snapshot.data!.isEmpty) { - return const SliverToBoxAdapter( - child: Center( - child: Padding( - padding: EdgeInsets.all(32), - child: Text( - 'No results', - style: TextStyle(color: Colors.white70), - ), - ), - ), - ); - } - - final songs = List.from(snapshot.data!); - if (songs.length >= 2 && songs.length.isOdd) { - songs.removeLast(); - } + final songs = List.from( + songSnapshot.data ?? const [], + ); final queuedSongs = songs .map( @@ -2014,38 +2249,122 @@ class _SearchScreenState extends State ? _buildDynamicSeedQueries(songs) : const []; - return SliverPadding( - padding: const EdgeInsets.only(bottom: 16), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - mainAxisSpacing: 16, - crossAxisSpacing: 16, - childAspectRatio: 0.68, - ), - delegate: SliverChildBuilderDelegate( - (_, i) { - final song = songs[i]; - - return RepaintBoundary( - child: SongCard( - song: song, - onTap: () async { - if (i < 0 || i >= queuedSongs.length) return; - - await AudioPlayerService().playFromList( - songs: queuedSongs, - startIndex: i, - autoExtendQueue: true, - dynamicSeedQueries: dynamicSeedQueries, + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showSongsSectionTitle) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'Songs', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(height: 12), + ], + if (songs.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Text( + query.isEmpty + ? 'No quick picks found' + : 'No songs found', + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ) + else + GridView.builder( + padding: EdgeInsets.zero, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + cacheExtent: smoothMode ? 420 : 720, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columns, + mainAxisSpacing: gridGap, + crossAxisSpacing: gridGap, + childAspectRatio: aspectRatio, + ), + itemCount: songs.length, + itemBuilder: (_, i) { + final song = songs[i]; + return RepaintBoundary( + child: SongCard( + song: song, + onTap: () async { + if (i < 0 || i >= queuedSongs.length) return; + + await AudioPlayerService().playFromList( + songs: queuedSongs, + startIndex: i, + autoExtendQueue: true, + dynamicSeedQueries: dynamicSeedQueries, + ); + }, + ), ); }, ), - ); - }, - childCount: songs.length, - addAutomaticKeepAlives: !smoothMode, - addRepaintBoundaries: true, + if (showAlbumsSection) ...[ + const SizedBox(height: 26), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'Albums', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: FutureBuilder>( + future: _albumSearchFuture, + builder: (context, albumSnapshot) { + if (albumSnapshot.connectionState == + ConnectionState.waiting) { + return _buildSearchAlbumsSection( + context, + albums: const [], + loading: true, + ); + } + + if (albumSnapshot.hasError) { + return Padding( + padding: const EdgeInsets.only(top: 4, bottom: 12), + child: Text( + 'Unable to load albums', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.error, + ), + ), + ); + } + + final albums = albumSnapshot.data ?? const []; + return _buildSearchAlbumsSection( + context, + albums: albums, + loading: false, + ); + }, + ), + ), + ], + ], ), ), ); diff --git a/lib/features/search/widgets/song_card.dart b/lib/features/search/widgets/song_card.dart index fb7ebd6..1f9678b 100644 --- a/lib/features/search/widgets/song_card.dart +++ b/lib/features/search/widgets/song_card.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import '../../../core/widgets/fallback_network_image.dart'; import '../../../core/utils/youtube_thumbnail_utils.dart'; -import '../../../core/utils/glass_container.dart'; +import '../../../core/utils/themed_container.dart'; import '../../../data/models/saavn_song.dart'; class SongCard extends StatelessWidget { @@ -12,6 +12,8 @@ class SongCard extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; final imageUrl = song.imageUrl.trim(); final imageScale = YoutubeThumbnailUtils.preferredArtworkScale( songId: song.id, @@ -24,7 +26,7 @@ class SongCard extends StatelessWidget { imageUrl: imageUrl, ); - return GlassContainer( + return ThemedContainer( borderRadius: BorderRadius.circular(18), child: InkWell( borderRadius: BorderRadius.circular(18), @@ -53,7 +55,7 @@ class SongCard extends StatelessWidget { alignment: Alignment.center, filterQuality: FilterQuality.medium, fallback: Container( - color: Colors.black26, + color: scheme.surfaceContainerHighest, child: const Icon( Icons.music_note_rounded, size: 40, @@ -62,7 +64,7 @@ class SongCard extends StatelessWidget { ), ) : Container( - color: Colors.black26, + color: scheme.surfaceContainerHighest, child: const Icon(Icons.music_note_rounded, size: 40), ), ), @@ -80,9 +82,8 @@ class SongCard extends StatelessWidget { song.name, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 14, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, ), ), const SizedBox(height: 2), @@ -90,9 +91,8 @@ class SongCard extends StatelessWidget { song.artists, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 12, - color: Colors.white70, + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, ), ), ], diff --git a/lib/features/settings/about_screen.dart b/lib/features/settings/about_screen.dart index fab6f1f..282cf1f 100644 --- a/lib/features/settings/about_screen.dart +++ b/lib/features/settings/about_screen.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; -import '../../core/utils/glass_page.dart'; +import '../../core/utils/themed_page.dart'; class AboutScreen extends StatelessWidget { const AboutScreen({super.key}); @@ -9,84 +9,120 @@ class AboutScreen extends StatelessWidget { Widget build(BuildContext context) { final siteUri = Uri.parse('https://greenbugx.github.io/Hongeet/'); final githubUri = Uri.parse('https://github.com/greenbugx/Hongeet'); + final theme = Theme.of(context); + final textTheme = theme.textTheme; final logoSize = (MediaQuery.sizeOf(context).width * 0.42).clamp( 140.0, 220.0, ); - return GlassPage( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => Navigator.of(context).pop(), - ), - Center( - child: Column( - children: [ - Image.asset( - 'assets/app/icon_fg.webp', - width: logoSize.toDouble(), - height: logoSize.toDouble(), - fit: BoxFit.contain, - errorBuilder: (context, error, stackTrace) => Image.asset( - 'assets/icon/icon_fg.png', + return ThemedPage( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Align( + alignment: Alignment.centerLeft, + child: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.of(context).pop(), + ), + ), + const SizedBox(height: 8), + Center( + child: Column( + children: [ + Image.asset( + 'assets/app/icon_fg.webp', width: logoSize.toDouble(), height: logoSize.toDouble(), fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) => Image.asset( + 'assets/icon/icon_fg.png', + width: logoSize.toDouble(), + height: logoSize.toDouble(), + fit: BoxFit.contain, + ), ), - ), - const SizedBox(height: 8), - const Text( - 'HONGEET', - style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 16), - const Text('Dev: Dxku', style: TextStyle(fontSize: 22)), - const SizedBox(height: 14), - Wrap( - alignment: WrapAlignment.center, - spacing: 10, - children: [ - FilledButton.icon( - onPressed: () async { - await launchUrl( - siteUri, - mode: LaunchMode.externalApplication, - ); - }, - icon: const Icon(Icons.open_in_new), - label: const Text('Visit Website'), + const SizedBox(height: 8), + Text( + 'HONGEET', + style: textTheme.displaySmall?.copyWith( + fontWeight: FontWeight.w800, + letterSpacing: 1.2, ), - FilledButton.icon( - onPressed: () async { - await launchUrl( - githubUri, - mode: LaunchMode.externalApplication, - ); - }, - icon: const Icon(Icons.code), - label: const Text('View Source'), + ), + const SizedBox(height: 12), + Text( + 'Dev: Dxku', + style: textTheme.titleLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, ), - ], + ), + ], + ), + ), + const SizedBox(height: 18), + Wrap( + alignment: WrapAlignment.center, + spacing: 10, + runSpacing: 10, + children: [ + FilledButton.icon( + onPressed: () async { + await launchUrl( + siteUri, + mode: LaunchMode.externalApplication, + ); + }, + icon: const Icon(Icons.open_in_new), + label: const Text('Visit Website'), ), - const SizedBox(height: 8), - const Text('v1.6.0+17', style: TextStyle(fontSize: 16)), - const SizedBox(height: 10), - const Text( + OutlinedButton.icon( + onPressed: () async { + await launchUrl( + githubUri, + mode: LaunchMode.externalApplication, + ); + }, + icon: const Icon(Icons.code), + label: const Text('View Source'), + ), + ], + ), + const SizedBox(height: 12), + Center( + child: Text( + 'v1.7.0+18', + style: textTheme.titleMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(height: 18), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( 'A simple yet powerful music player designed for seamless streaming of your favorite songs. Enjoy a smooth, distraction-free listening experience with no ads, no interruptions, and a clean interface built for music lovers.', - style: TextStyle(fontSize: 14), + style: textTheme.bodyMedium, ), - const SizedBox(height: 16), - const Text( + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( 'This app is open source and available on Github and licensed under the GNU-AGPLv3.0-or-later', - style: TextStyle(fontSize: 12), + style: textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), ), - ], + ), ), - ), - ], + ], + ), ), ); } diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index d37ece4..01b5cda 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -1,13 +1,12 @@ -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:hongit/features/settings/about_screen.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../../core/theme/app_theme.dart'; -import '../../core/utils/glass_container.dart'; +import '../../core/utils/themed_container.dart'; import '../../core/utils/streaming_preferences.dart'; -import '../../core/utils/glass_page.dart'; +import '../../core/utils/themed_page.dart'; import '../../core/utils/audio_player_service.dart'; import '../../core/utils/app_update_service.dart'; import '../../core/utils/battery_optimization_handler.dart'; @@ -33,6 +32,17 @@ class _SettingsScreenState extends State static const _lastPromptKey = 'battery_prompt_time'; static const _firstSeenKey = 'battery_first_seen'; + static const List _presetThemeColors = [ + Color(0xFF28C76F), + Color(0xFF00BFA6), + Color(0xFF1E88E5), + Color(0xFF3F51B5), + Color(0xFF8E24AA), + Color(0xFFD81B60), + Color(0xFFFB8C00), + Color(0xFFFDD835), + ]; + @override void initState() { super.initState(); @@ -115,11 +125,9 @@ class _SettingsScreenState extends State Future _setYoutubeServicePreference(bool enabled) async { final prefs = await SharedPreferences.getInstance(); - // When enabling YouTube, ensure Saavn is disabled to keep services mutually exclusive. await prefs.setBool('use_youtube_service', enabled); if (enabled) { await prefs.setBool('use_saavn_service', false); - // If in local mode and enabling YouTube, switch to YTM mode final currentMode = prefs.getString('app_mode'); if (currentMode == 'local') { await prefs.setString('app_mode', 'ytm'); @@ -136,11 +144,9 @@ class _SettingsScreenState extends State Future _setSaavnServicePreference(bool enabled) async { final prefs = await SharedPreferences.getInstance(); - // When enabling Saavn, ensure YTM is disabled to keep services mutually exclusive await prefs.setBool('use_saavn_service', enabled); if (enabled) { await prefs.setBool('use_youtube_service', false); - // If in local mode and enabling Saavn, switch to Saavn mode final currentMode = prefs.getString('app_mode'); if (currentMode == 'local') { await prefs.setString('app_mode', 'saavn'); @@ -197,18 +203,12 @@ class _SettingsScreenState extends State Future.delayed(const Duration(milliseconds: 600), _checkBattery); } - bool _canUseGlassTheme(BuildContext context) { - return !ThemeProvider.isLowEndLikely(context); - } - String _progressBarStyleLabel(ProgressBarStyle style) { switch (style) { case ProgressBarStyle.defaultStyle: return 'Default'; case ProgressBarStyle.snake: return 'Snake'; - case ProgressBarStyle.glass: - return 'Glass'; } } @@ -218,8 +218,6 @@ class _SettingsScreenState extends State return 'Standard seek bar'; case ProgressBarStyle.snake: return 'Curved static track with moving head'; - case ProgressBarStyle.glass: - return 'Glass-styled seek bar'; } } @@ -235,10 +233,6 @@ class _SettingsScreenState extends State } String _uiPerformanceHint(ThemeProvider themeProvider, BuildContext context) { - if (themeProvider.useGlassTheme) { - return 'This setting makes no change when Glass Theme is enabled.'; - } - final resolved = themeProvider.resolvedUiPerformanceMode(context); switch (themeProvider.uiPerformanceMode) { case UiPerformanceMode.auto: @@ -250,13 +244,6 @@ class _SettingsScreenState extends State } } - List _availableProgressStyles(ThemeProvider themeProvider) { - if (themeProvider.useGlassTheme) { - return ProgressBarStyle.values; - } - return const [ProgressBarStyle.defaultStyle, ProgressBarStyle.snake]; - } - String _dataSaverDescription(bool enabled) { return enabled ? 'Enabled: streams use up to ~120 kbps and artwork uses lower-medium quality to reduce data usage.' @@ -286,24 +273,199 @@ class _SettingsScreenState extends State } } + Future _showColorPickerDialog(ThemeProvider themeProvider) async { + final seedArgb = themeProvider.seedColor.toARGB32(); + int red = (seedArgb >> 16) & 0xFF; + int green = (seedArgb >> 8) & 0xFF; + int blue = seedArgb & 0xFF; + + await showDialog( + context: context, + builder: (dialogContext) { + return StatefulBuilder( + builder: (context, setStateDialog) { + final preview = Color.fromARGB(255, red, green, blue); + + Widget buildSlider({ + required String label, + required int value, + required ValueChanged onChanged, + required Color activeColor, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('$label: $value'), + Slider( + min: 0, + max: 255, + value: value.toDouble(), + activeColor: activeColor, + onChanged: onChanged, + ), + ], + ); + } + + return AlertDialog( + title: const Text('Custom Theme Color'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: double.infinity, + height: 64, + decoration: BoxDecoration( + color: preview, + borderRadius: BorderRadius.circular(12), + ), + ), + const SizedBox(height: 16), + buildSlider( + label: 'Red', + value: red, + activeColor: Colors.red, + onChanged: (v) => + setStateDialog(() => red = v.round().clamp(0, 255)), + ), + buildSlider( + label: 'Green', + value: green, + activeColor: Colors.green, + onChanged: (v) => + setStateDialog(() => green = v.round().clamp(0, 255)), + ), + buildSlider( + label: 'Blue', + value: blue, + activeColor: Colors.blue, + onChanged: (v) => + setStateDialog(() => blue = v.round().clamp(0, 255)), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () async { + await themeProvider.setSeedColor(preview); + if (!dialogContext.mounted) return; + Navigator.pop(dialogContext); + }, + child: const Text('Apply'), + ), + ], + ); + }, + ); + }, + ); + } + + Widget _buildThemeColorSection( + BuildContext context, + ThemeProvider themeProvider, + ) { + final scheme = Theme.of(context).colorScheme; + final selected = themeProvider.seedColor.toARGB32(); + + return ThemedContainer( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.palette_outlined, color: scheme.primary), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Theme color', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: themeProvider.seedColor, + shape: BoxShape.circle, + border: Border.all( + color: scheme.outlineVariant.withValues(alpha: 0.65), + ), + ), + ), + ], + ), + const SizedBox(height: 14), + Wrap( + spacing: 10, + runSpacing: 10, + children: _presetThemeColors.map((color) { + final isSelected = color.toARGB32() == selected; + return InkWell( + borderRadius: BorderRadius.circular(999), + onTap: () => themeProvider.setSeedColor(color), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + width: isSelected ? 38 : 34, + height: isSelected ? 38 : 34, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color, + border: Border.all( + color: isSelected + ? scheme.onSurface + : scheme.outlineVariant.withValues(alpha: 0.55), + width: isSelected ? 2 : 1, + ), + ), + ), + ); + }).toList(), + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: () => _showColorPickerDialog(themeProvider), + icon: const Icon(Icons.tune), + label: const Text('Custom color'), + ), + ], + ), + ), + ); + } + @override Widget build(BuildContext context) { final themeProvider = Provider.of(context); - final canUseGlassTheme = _canUseGlassTheme(context); + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final textTheme = theme.textTheme; - return GlassPage( + return ThemedPage( child: ListView( children: [ - const Text( + Text( 'Settings', - style: TextStyle(fontSize: 26, fontWeight: FontWeight.w600), + style: textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + ), ), const SizedBox(height: 20), if (showBatteryWarning) ...[ - GlassContainer( + ThemedContainer( child: ListTile( - leading: const Icon(Icons.battery_alert, color: Colors.orange), + leading: Icon(Icons.battery_alert, color: scheme.tertiary), title: const Text('Background playback may stop'), subtitle: Text( '$manufacturer devices aggressively limit background apps. ' @@ -318,13 +480,9 @@ class _SettingsScreenState extends State const SizedBox(height: 20), ], - GlassContainer( + ThemedContainer( child: ListTile( - leading: Icon( - themeProvider.useGlassTheme - ? CupertinoIcons.arrow_down_circle - : Icons.system_update_alt, - ), + leading: const Icon(Icons.system_update_alt), title: const Text('Check for updates'), subtitle: const Text('Check latest version and update now'), onTap: _checkForUpdatesManually, @@ -333,57 +491,24 @@ class _SettingsScreenState extends State const SizedBox(height: 12), - GlassContainer( - child: SwitchListTile( - value: themeProvider.useGlassTheme, - onChanged: (enabled) { - if (enabled && !canUseGlassTheme) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Glass theme may feel laggy on this device.', - ), - ), - ); - } - themeProvider.setUseGlassTheme(enabled); - }, - secondary: Icon( - themeProvider.useGlassTheme - ? CupertinoIcons.settings - : Icons.blur_on, - ), - title: const Text('Glass UI Theme'), - subtitle: Text( - canUseGlassTheme - ? 'Use iOS 26 glass UI Theme.' - : 'May lag on low-end devices.', - ), - ), - ), + _buildThemeColorSection(context, themeProvider), const SizedBox(height: 12), - GlassContainer( + ThemedContainer( child: ListTile( - leading: Icon( - themeProvider.useGlassTheme - ? CupertinoIcons.speedometer - : Icons.speed, - ), + leading: const Icon(Icons.speed), title: const Text('UI performance'), subtitle: Text(_uiPerformanceHint(themeProvider, context)), trailing: DropdownButtonHideUnderline( child: DropdownButton( value: themeProvider.uiPerformanceMode, isDense: true, - onChanged: themeProvider.useGlassTheme - ? null - : (mode) { - if (mode != null) { - themeProvider.setUiPerformanceMode(mode); - } - }, + onChanged: (mode) { + if (mode != null) { + themeProvider.setUiPerformanceMode(mode); + } + }, items: UiPerformanceMode.values .map( (mode) => DropdownMenuItem( @@ -399,13 +524,9 @@ class _SettingsScreenState extends State const SizedBox(height: 12), - GlassContainer( + ThemedContainer( child: ListTile( - leading: Icon( - themeProvider.useGlassTheme - ? CupertinoIcons.waveform_path_ecg - : Icons.multitrack_audio, - ), + leading: const Icon(Icons.multitrack_audio), title: const Text('Progress bar style'), subtitle: Text( _progressBarStyleHint(themeProvider.effectiveProgressBarStyle), @@ -419,7 +540,7 @@ class _SettingsScreenState extends State themeProvider.setProgressBarStyle(style); } }, - items: _availableProgressStyles(themeProvider) + items: ProgressBarStyle.values .map( (style) => DropdownMenuItem( value: style, @@ -434,17 +555,13 @@ class _SettingsScreenState extends State const SizedBox(height: 12), - GlassContainer( + ThemedContainer( child: SwitchListTile( value: themeProvider.dataSaverEnabled, onChanged: (enabled) { themeProvider.setDataSaverEnabled(enabled); }, - secondary: Icon( - themeProvider.useGlassTheme - ? CupertinoIcons.antenna_radiowaves_left_right - : Icons.data_saver_on, - ), + secondary: const Icon(Icons.data_saver_on), title: const Text('Data Saver'), subtitle: Text( _dataSaverDescription(themeProvider.dataSaverEnabled), @@ -454,17 +571,13 @@ class _SettingsScreenState extends State const SizedBox(height: 12), - GlassContainer( + ThemedContainer( child: SwitchListTile( value: _useSaavnService, onChanged: (v) { _setSaavnServicePreference(v); }, - secondary: Icon( - themeProvider.useGlassTheme - ? CupertinoIcons.music_albums - : Icons.library_music, - ), + secondary: const Icon(Icons.library_music), title: const Text('Saavn Service'), subtitle: const Text('Use Saavn as the music Service'), ), @@ -472,17 +585,13 @@ class _SettingsScreenState extends State const SizedBox(height: 12), - GlassContainer( + ThemedContainer( child: SwitchListTile( value: _useYoutubeService, onChanged: (v) { _setYoutubeServicePreference(v); }, - secondary: Icon( - themeProvider.useGlassTheme - ? CupertinoIcons.play_circle - : Icons.smart_display, - ), + secondary: const Icon(Icons.smart_display), title: const Text('Youtube Service'), subtitle: const Text('Use Youtube as the music Service'), ), @@ -490,7 +599,7 @@ class _SettingsScreenState extends State const SizedBox(height: 12), - GlassContainer( + ThemedContainer( child: ListTile( leading: const Icon(Icons.cached), title: const Text('Clear stream cache'), @@ -514,7 +623,7 @@ class _SettingsScreenState extends State const SizedBox(height: 12), - GlassContainer( + ThemedContainer( child: ListTile( leading: const Icon(Icons.history), title: const Text('Clear recently played'), @@ -531,20 +640,12 @@ class _SettingsScreenState extends State const SizedBox(height: 12), - GlassContainer( + ThemedContainer( child: ListTile( - leading: Icon( - themeProvider.useGlassTheme - ? CupertinoIcons.info_circle - : Icons.info_outline, - ), + leading: const Icon(Icons.info_outline), title: const Text('About'), subtitle: const Text('Version, license'), - trailing: Icon( - themeProvider.useGlassTheme - ? CupertinoIcons.right_chevron - : Icons.chevron_right, - ), + trailing: const Icon(Icons.chevron_right), onTap: () { Navigator.push( context, diff --git a/pubspec.yaml b/pubspec.yaml index 3858ab8..e609d36 100644 --- a/pubspec.yaml +++ b/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.6.0+17 +version: 1.7.0+18 environment: sdk: ^3.10.7 diff --git a/test/widget_test.dart b/test/widget_test.dart index db0df8c..594392d 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -9,13 +9,15 @@ void main() { setUp(() { SharedPreferences.setMockInitialValues({ - 'use_glass_theme': false, + 'theme_seed_color': AppTheme.defaultSeedColor.toARGB32(), 'progress_bar_style': 'defaultStyle', 'ui_performance_mode': 'auto', }); }); - testWidgets('ThemeProvider loads default dark theme', (WidgetTester tester) async { + testWidgets('ThemeProvider loads default dark theme', ( + WidgetTester tester, + ) async { await tester.pumpWidget( ChangeNotifierProvider( create: (_) => ThemeProvider(), @@ -39,14 +41,17 @@ void main() { expect(find.text('ready'), findsOneWidget); }); - testWidgets('ThemeProvider can switch to glass theme', (WidgetTester tester) async { + testWidgets('ThemeProvider can update Material 3 seed color', ( + WidgetTester tester, + ) async { SharedPreferences.setMockInitialValues({ - 'use_glass_theme': false, + 'theme_seed_color': AppTheme.defaultSeedColor.toARGB32(), 'progress_bar_style': 'defaultStyle', 'ui_performance_mode': 'auto', }); final provider = ThemeProvider(); + const updatedSeed = Color(0xFFE57373); await tester.pumpWidget( ChangeNotifierProvider.value( value: provider, @@ -56,9 +61,7 @@ void main() { return MaterialApp( theme: active.currentTheme, home: Scaffold( - body: Text( - active.useGlassTheme ? 'glass' : 'simple', - ), + body: Text(active.seedColor.toARGB32().toString()), ), ); }, @@ -66,14 +69,17 @@ void main() { ), ); await tester.pumpAndSettle(); - expect(find.text('simple'), findsOneWidget); + final defaultSeedText = AppTheme.defaultSeedColor.toARGB32().toString(); + expect(find.text(defaultSeedText), findsOneWidget); - await provider.setUseGlassTheme(true); + await provider.setSeedColor(updatedSeed); await tester.pumpAndSettle(); - expect(find.text('glass'), findsOneWidget); - final context = tester.element(find.text('glass')); + final updatedSeedText = updatedSeed.toARGB32().toString(); + expect(find.text(updatedSeedText), findsOneWidget); + final context = tester.element(find.text(updatedSeedText)); final theme = Theme.of(context); - expect(theme.scaffoldBackgroundColor, Colors.transparent); + expect(provider.seedColor.toARGB32(), updatedSeed.toARGB32()); + expect(theme.brightness, Brightness.dark); }); }