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 =