From 356ef445d0f15855a500e3d9448e988b39d760b9 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Tue, 10 Nov 2026 12:56:57 +0800 Subject: [PATCH 01/12] Paint orbit preview behind library header overlay Lay out the preview orbit in a separate content slot so it can paint upward behind the header while the header stays readable, and clip the fullscreen stage so the overflow never escapes the route. --- .../settings/widgets/orbit_preview.dart | 114 +++++++++++++----- 1 file changed, 83 insertions(+), 31 deletions(-) diff --git a/lib/features/settings/widgets/orbit_preview.dart b/lib/features/settings/widgets/orbit_preview.dart index 2bcf40e4..cf14a6ac 100644 --- a/lib/features/settings/widgets/orbit_preview.dart +++ b/lib/features/settings/widgets/orbit_preview.dart @@ -17,9 +17,10 @@ import 'package:flick/services/app_preferences_service.dart'; /// Live, browse-only preview of the orbit settings. /// -/// The preview paints the library header above the orbit so the top boundary -/// is visible while the controls below change. Tapping cards only moves the -/// preview's focus; it never starts playback or changes the queue. +/// The preview paints the library header above the orbit so the overlap and +/// screen-edge clipping are visible while the controls below change. Tapping +/// cards only moves the preview's focus; it never starts playback or changes +/// the queue. class OrbitPreview extends ConsumerStatefulWidget { const OrbitPreview({super.key, required this.landscape}); @@ -353,22 +354,19 @@ class _VirtualLibraryPreview extends StatelessWidget { // around it still honors the user's text scale. textScaler: TextScaler.noScaling, ), - child: Column( - children: [ - _PreviewLibraryHeader( - songCount: songs.length, - compact: landscape, - ), - Expanded( - child: _PreviewOrbit( - landscape: landscape, - prefs: prefs, - songs: songs, - selectedIndex: selectedIndex, - onSelectedIndexChanged: onSelectedIndexChanged, - ), - ), - ], + child: _PreviewHeaderOverlay( + header: _PreviewLibraryHeader( + songCount: songs.length, + compact: landscape, + ), + content: _PreviewOrbit( + landscape: landscape, + prefs: prefs, + songs: songs, + selectedIndex: selectedIndex, + onSelectedIndexChanged: onSelectedIndexChanged, + paintOverflowTop: size.width * 2, + ), ), ), ), @@ -384,6 +382,7 @@ class _PreviewOrbit extends StatelessWidget { required this.songs, required this.selectedIndex, required this.onSelectedIndexChanged, + required this.paintOverflowTop, }); final bool landscape; @@ -391,10 +390,12 @@ class _PreviewOrbit extends StatelessWidget { final List songs; final int selectedIndex; final ValueChanged onSelectedIndexChanged; + final double paintOverflowTop; @override Widget build(BuildContext context) { return OrbitScroll( + paintOverflowTop: paintOverflowTop, songs: songs, selectedIndex: selectedIndex, scrollDirection: landscape ? Axis.horizontal : Axis.vertical, @@ -469,19 +470,26 @@ class _FullScreenOrbitPreviewState extends State<_FullScreenOrbitPreview> { child: Column( children: [ _FullScreenPreviewHeader(landscape: widget.landscape), - _PreviewLibraryHeader( - songCount: widget.songs.length, - compact: MediaQuery.sizeOf(context).height < 500, - ), Expanded( - child: SizedBox.expand( - key: const Key('orbit_preview_fullscreen'), - child: _PreviewOrbit( - landscape: widget.landscape, - prefs: widget.prefs, - songs: widget.songs, - selectedIndex: _selectedIndex, - onSelectedIndexChanged: _select, + // Keep the orbit's upward paint inside the preview stage so it + // never crosses the preview chrome or escapes the route. + child: ClipRect( + child: _PreviewHeaderOverlay( + header: _PreviewLibraryHeader( + songCount: widget.songs.length, + compact: MediaQuery.sizeOf(context).height < 500, + ), + content: SizedBox.expand( + key: const Key('orbit_preview_fullscreen'), + child: _PreviewOrbit( + landscape: widget.landscape, + prefs: widget.prefs, + songs: widget.songs, + selectedIndex: _selectedIndex, + onSelectedIndexChanged: _select, + paintOverflowTop: MediaQuery.sizeOf(context).width * 2, + ), + ), ), ), ), @@ -715,3 +723,47 @@ class _PreviewHeaderButton extends StatelessWidget { ); } } + +enum _PreviewLayoutSlot { content, header } + +/// Lays out the miniature library below its header but paints the orbit first, +/// so the header stays readable while the orbit passes behind it. +class _PreviewHeaderOverlay extends StatelessWidget { + const _PreviewHeaderOverlay({required this.header, required this.content}); + + final Widget header; + final Widget content; + + @override + Widget build(BuildContext context) { + return CustomMultiChildLayout( + delegate: _PreviewHeaderOverlayDelegate(), + children: [ + LayoutId(id: _PreviewLayoutSlot.content, child: content), + LayoutId(id: _PreviewLayoutSlot.header, child: header), + ], + ); + } +} + +class _PreviewHeaderOverlayDelegate extends MultiChildLayoutDelegate { + @override + void performLayout(Size size) { + final headerSize = layoutChild( + _PreviewLayoutSlot.header, + BoxConstraints(minWidth: size.width, maxWidth: size.width), + ); + positionChild(_PreviewLayoutSlot.header, Offset.zero); + + final contentHeight = math.max(0.0, size.height - headerSize.height); + layoutChild( + _PreviewLayoutSlot.content, + BoxConstraints.tightFor(width: size.width, height: contentHeight), + ); + positionChild(_PreviewLayoutSlot.content, Offset(0, headerSize.height)); + } + + @override + bool shouldRelayout(covariant _PreviewHeaderOverlayDelegate oldDelegate) => + false; +} From 583ee3eb6d4bf015bae30238c02b4fb0b2ccbe0c Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Tue, 10 Nov 2026 12:57:02 +0800 Subject: [PATCH 02/12] Refactor songs screen layout into clipped header overlay Extract header and content into separate slots so the orbit can paint above the stage behind the header, and clip the tab at screen bounds via a custom clipper. Add a paint-only top overflow to OrbitScroll for the ribbon and cards. --- lib/features/songs/screens/songs_screen.dart | 458 +++++++++++-------- lib/features/songs/widgets/orbit_scroll.dart | 32 +- 2 files changed, 282 insertions(+), 208 deletions(-) diff --git a/lib/features/songs/screens/songs_screen.dart b/lib/features/songs/screens/songs_screen.dart index 72694abb..463bf73b 100644 --- a/lib/features/songs/screens/songs_screen.dart +++ b/lib/features/songs/screens/songs_screen.dart @@ -168,215 +168,207 @@ class _SongsScreenState extends ConsumerState NavBarButton.search, ); - return Stack( - clipBehavior: Clip.none, + final header = Column( + mainAxisSize: MainAxisSize.min, children: [ - SafeArea( - bottom: false, - child: Padding( - padding: EdgeInsets.only( - right: reserveSideMiniPlayer - ? AdaptiveLayout.sideMiniPlayerContentInset - : 0, - ), - child: Column( - children: [ - // Header with sort option - AnimatedSwitcher( - duration: AppConstants.animationNormal, - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, - transitionBuilder: (child, animation) => - FadeTransition(opacity: animation, child: child), - child: _selectionMode - ? KeyedSubtree( - key: const ValueKey('selection_header'), - child: _buildSelectionHeader(), - ) - : KeyedSubtree( - key: const ValueKey('normal_header'), - child: _buildHeader(songsAsync), - ), + // Header with sort option + AnimatedSwitcher( + duration: AppConstants.animationNormal, + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) => + FadeTransition(opacity: animation, child: child), + child: _selectionMode + ? KeyedSubtree( + key: const ValueKey('selection_header'), + child: _buildSelectionHeader(), + ) + : KeyedSubtree( + key: const ValueKey('normal_header'), + child: _buildHeader(songsAsync), ), + ), - // Search Bar (hidden when Search tab is in the nav bar) - if (!searchInNavBar) ...[ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppConstants.spacingLg, - ), - child: TutorialTargetAnchor( - target: TutorialTarget.songsSearchBar, - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - AppColors.surfaceLight.withValues(alpha: 0.75), - AppColors.surface.withValues(alpha: 0.85), - ], - ), - borderRadius: BorderRadius.circular( - AppConstants.radiusXl, - ), - border: Border.all( - color: AppColors.glassBorder, - width: 1, - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.2), - blurRadius: 16, - spreadRadius: 1, - offset: const Offset(0, 4), - ), - ], - ), - child: GlassSearchBar( - controller: _searchController, - hintText: l10n.searchSongsArtists, - showBackground: false, - onChanged: (value) { - setState(() { - _searchQuery = value.toLowerCase(); - _selectedIndex = 0; - _lastSyncedSong = null; - }); - if (value.trim().isNotEmpty) { - ref - .read(tutorialProvider.notifier) - .onAction(TutorialAction.searchUsed); - } - }, - ), - ), - ), + // Search Bar (hidden when Search tab is in the nav bar) + if (!searchInNavBar) ...[ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppConstants.spacingLg, + ), + child: TutorialTargetAnchor( + target: TutorialTarget.songsSearchBar, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + AppColors.surfaceLight.withValues(alpha: 0.75), + AppColors.surface.withValues(alpha: 0.85), + ], ), - const SizedBox(height: AppConstants.spacingMd), - ], - - // Content based on async state - Expanded( - child: TutorialTargetAnchor( - target: TutorialTarget.songLibrary, - child: songsAsync.when( - loading: () => _buildLoadingState(), - error: (error, stack) => _buildErrorState(error), - data: (songsState) { - final isAlbumMode = - songsState.sortOption == SongSortOption.album; - final allSongs = songsState.sortedSongs; - var songs = allSongs; - if (_cachedSongs != songsState.songs) { - _cachedSongs = songsState.songs; - _lastSyncedSong = null; - } - - if (_searchQuery.isNotEmpty) { - songs = songs.where((song) { - return song.title.toLowerCase().contains( - _searchQuery, - ) || - song.artist.toLowerCase().contains( - _searchQuery, - ); - }).toList(); - } - _cachedDisplaySongs = songs; - - if (!isAlbumMode) { - _wasInAlbumMode = false; - final sig = - '${songs.length}:${songs.isNotEmpty ? songs.first.id : ''}'; - if (sig != _listPaginationSignature) { - _listPaginationSignature = sig; - _resetListPagination(); - } - } - - if (songs.isEmpty && _searchQuery.isEmpty) { - return _buildEmptyState(); - } - - if (songs.isEmpty && _searchQuery.isNotEmpty) { - return _buildNoSearchResultsState(); - } - - if (isAlbumMode) { - if (!_wasInAlbumMode) { - _wasInAlbumMode = true; - _visibleAlbumCount = _getAlbumPageSize(); - _albumPaginationSignature = ''; - } - final albumGroups = songsState.albumGroups; - if (_searchQuery.isNotEmpty) { - final filteredGroups = []; - for (final group in albumGroups) { - final filtered = group.songs.where((song) { - return song.title.toLowerCase().contains( - _searchQuery, - ) || - song.artist.toLowerCase().contains( - _searchQuery, - ); - }).toList(); - if (filtered.isNotEmpty) { - filteredGroups.add( - AlbumGroup( - key: group.key, - albumName: group.albumName, - albumArtist: group.albumArtist, - songs: filtered, - ), - ); - } - } - if (filteredGroups.isEmpty) { - return _buildNoSearchResultsState(); - } - return _buildAlbumGridView(filteredGroups); - } - if (albumGroups.isEmpty) { - return _buildEmptyState(); - } - return _buildAlbumGridView(albumGroups); - } - - _alignCurrentSongAfterSongsLoad(songs); - - if (_selectedIndex >= songs.length) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - setState(() => _selectedIndex = 0); - } - }); - } - - _syncSelectedTokenForIndex(songs, _selectedIndex); - - final tokenToIndexMap = _buildFastIndexMap(songs); - final appPrefs = ref.watch(appPreferencesProvider); - - return _buildSongsView( - songs, - viewMode, - tokenToIndexMap, - navBarVisible, - swipeActionsEnabled, - appPrefs.fastIndexEnabled, - orbitSideOverflow, - ); - }, + borderRadius: BorderRadius.circular(AppConstants.radiusXl), + border: Border.all(color: AppColors.glassBorder, width: 1), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 16, + spreadRadius: 1, + offset: const Offset(0, 4), ), - ), + ], ), - ], + child: GlassSearchBar( + controller: _searchController, + hintText: l10n.searchSongsArtists, + showBackground: false, + onChanged: (value) { + setState(() { + _searchQuery = value.toLowerCase(); + _selectedIndex = 0; + _lastSyncedSong = null; + }); + if (value.trim().isNotEmpty) { + ref + .read(tutorialProvider.notifier) + .onAction(TutorialAction.searchUsed); + } + }, + ), + ), ), ), - ), + const SizedBox(height: AppConstants.spacingMd), + ], ], ); + + final content = TutorialTargetAnchor( + target: TutorialTarget.songLibrary, + child: songsAsync.when( + loading: () => _buildLoadingState(), + error: (error, stack) => _buildErrorState(error), + data: (songsState) { + final isAlbumMode = songsState.sortOption == SongSortOption.album; + final allSongs = songsState.sortedSongs; + var songs = allSongs; + if (_cachedSongs != songsState.songs) { + _cachedSongs = songsState.songs; + _lastSyncedSong = null; + } + + if (_searchQuery.isNotEmpty) { + songs = songs.where((song) { + return song.title.toLowerCase().contains(_searchQuery) || + song.artist.toLowerCase().contains(_searchQuery); + }).toList(); + } + _cachedDisplaySongs = songs; + + if (!isAlbumMode) { + _wasInAlbumMode = false; + final sig = + '${songs.length}:${songs.isNotEmpty ? songs.first.id : ''}'; + if (sig != _listPaginationSignature) { + _listPaginationSignature = sig; + _resetListPagination(); + } + } + + if (songs.isEmpty && _searchQuery.isEmpty) { + return _buildEmptyState(); + } + + if (songs.isEmpty && _searchQuery.isNotEmpty) { + return _buildNoSearchResultsState(); + } + + if (isAlbumMode) { + if (!_wasInAlbumMode) { + _wasInAlbumMode = true; + _visibleAlbumCount = _getAlbumPageSize(); + _albumPaginationSignature = ''; + } + final albumGroups = songsState.albumGroups; + if (_searchQuery.isNotEmpty) { + final filteredGroups = []; + for (final group in albumGroups) { + final filtered = group.songs.where((song) { + return song.title.toLowerCase().contains(_searchQuery) || + song.artist.toLowerCase().contains(_searchQuery); + }).toList(); + if (filtered.isNotEmpty) { + filteredGroups.add( + AlbumGroup( + key: group.key, + albumName: group.albumName, + albumArtist: group.albumArtist, + songs: filtered, + ), + ); + } + } + if (filteredGroups.isEmpty) { + return _buildNoSearchResultsState(); + } + return _buildAlbumGridView(filteredGroups); + } + if (albumGroups.isEmpty) { + return _buildEmptyState(); + } + return _buildAlbumGridView(albumGroups); + } + + _alignCurrentSongAfterSongsLoad(songs); + + if (_selectedIndex >= songs.length) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() => _selectedIndex = 0); + } + }); + } + + _syncSelectedTokenForIndex(songs, _selectedIndex); + + final tokenToIndexMap = _buildFastIndexMap(songs); + final appPrefs = ref.watch(appPreferencesProvider); + + return _buildSongsView( + songs, + viewMode, + tokenToIndexMap, + navBarVisible, + swipeActionsEnabled, + appPrefs.fastIndexEnabled, + orbitSideOverflow, + ); + }, + ), + ); + + return ClipRect( + clipper: _SongsTabClipper( + top: safePadding.top, + bottomOverflow: widget.pathOverflowBottom, + ), + child: Stack( + clipBehavior: Clip.none, + children: [ + SafeArea( + bottom: false, + child: Padding( + padding: EdgeInsets.only( + right: reserveSideMiniPlayer + ? AdaptiveLayout.sideMiniPlayerContentInset + : 0, + ), + child: _SongsHeaderOverlay(header: header, content: content), + ), + ), + ], + ), + ); } Widget _buildSongsView( @@ -514,6 +506,7 @@ class _SongsScreenState extends ConsumerState // The ribbon may continue through that reserved strip; the card stage // stays above it. pathOverflowBottom: widget.pathOverflowBottom, + paintOverflowTop: MediaQuery.sizeOf(context).width * 2, horizontalPaintOverflow: sideOverflow, onSelectedIndexChanged: (index) { if (!mounted) return; @@ -3479,3 +3472,70 @@ class _PinchGestureRecognizer extends OneSequenceGestureRecognizer { @override String get debugDescription => '_PinchGestureRecognizer'; } + +enum _SongsLayoutSlot { content, header } + +/// Lays the library content below the header while painting the content first. +/// That lets the orbit paint up behind the header, with the header on top. +class _SongsHeaderOverlay extends StatelessWidget { + const _SongsHeaderOverlay({required this.header, required this.content}); + + final Widget header; + final Widget content; + + @override + Widget build(BuildContext context) { + return CustomMultiChildLayout( + delegate: _SongsHeaderOverlayDelegate(), + children: [ + LayoutId(id: _SongsLayoutSlot.content, child: content), + LayoutId(id: _SongsLayoutSlot.header, child: header), + ], + ); + } +} + +class _SongsHeaderOverlayDelegate extends MultiChildLayoutDelegate { + + @override + void performLayout(Size size) { + final headerSize = layoutChild( + _SongsLayoutSlot.header, + BoxConstraints(minWidth: size.width, maxWidth: size.width), + ); + positionChild(_SongsLayoutSlot.header, Offset.zero); + + final contentHeight = max(0.0, size.height - headerSize.height); + layoutChild( + _SongsLayoutSlot.content, + BoxConstraints.tightFor(width: size.width, height: contentHeight), + ); + positionChild(_SongsLayoutSlot.content, Offset(0, headerSize.height)); + } + + @override + bool shouldRelayout(covariant _SongsHeaderOverlayDelegate oldDelegate) => + false; +} + +/// Clips orbit overflow to the Songs page. The top inset keeps paint out of +/// the status bar; the bottom allowance preserves the ribbon's continuation +/// behind the shell's mini player and navigation bars. +class _SongsTabClipper extends CustomClipper { + const _SongsTabClipper({required this.top, required this.bottomOverflow}); + + final double top; + final double bottomOverflow; + + @override + Rect getClip(Size size) => Rect.fromLTRB( + 0, + top, + size.width, + size.height + max(0.0, bottomOverflow), + ); + + @override + bool shouldReclip(covariant _SongsTabClipper oldClipper) => + top != oldClipper.top || bottomOverflow != oldClipper.bottomOverflow; +} diff --git a/lib/features/songs/widgets/orbit_scroll.dart b/lib/features/songs/widgets/orbit_scroll.dart index 77e1c9c3..61929627 100644 --- a/lib/features/songs/widgets/orbit_scroll.dart +++ b/lib/features/songs/widgets/orbit_scroll.dart @@ -79,6 +79,11 @@ class OrbitScroll extends StatefulWidget { /// card stage or gesture surface move into the bottom bars. final double pathOverflowBottom; + /// Paint-only room above the stage for the path and cards. The orbit keeps + /// its geometry and gesture surface; the parent is responsible for clipping + /// this overflow at the screen boundary. + final double paintOverflowTop; + /// Side space reserved by the parent for safe areas and the mini player. /// Landscape cards and ribbon paint through it, but gestures stay in the /// inset music viewport so navigation and player controls keep their targets. @@ -110,6 +115,7 @@ class OrbitScroll extends StatefulWidget { this.showPath = true, this.showGlow = true, this.pathOverflowBottom = 0.0, + this.paintOverflowTop = 0.0, this.horizontalPaintOverflow = EdgeInsets.zero, }); @@ -514,13 +520,17 @@ class _OrbitScrollState extends State orbitCenterY, orbitRadius, sideOverflow, + widget.paintOverflowTop, ), // Cards reach the viewport's side edges while keeping the // original top/bottom limits and inset gesture surface. Positioned.fill( child: ClipRect( - clipper: _OrbitStageClipper(sideOverflow), + clipper: _OrbitStageClipper( + sideOverflow, + widget.paintOverflowTop, + ), child: Stack( clipBehavior: Clip.none, children: [ @@ -608,26 +618,29 @@ class _OrbitScrollState extends State double centerY, double radius, EdgeInsets sideOverflow, + double topOverflow, ) { + final bottomOverflow = math.max(0.0, widget.pathOverflowBottom); + final top = math.max(0.0, topOverflow); final path = RepaintBoundary( child: CustomPaint( size: Size.infinite, painter: _OrbitPathPainter( centerX: centerX + sideOverflow.left, - centerY: centerY, + centerY: centerY + top, radius: radius, horizontal: widget.scrollDirection == Axis.horizontal, ), ), ); - final overflow = math.max(0.0, widget.pathOverflowBottom); // Give the cached path the full paint width, not the padded gesture width. - // Its top still starts below the header/search; only its bottom overflows. + // It can continue past the stage on every side; the parent clips it at the + // screen boundary. return Positioned( left: -sideOverflow.left, - top: 0, + top: -top, right: -sideOverflow.right, - bottom: -overflow, + bottom: -bottomOverflow, child: ClipRect(child: path), ); } @@ -822,20 +835,21 @@ class _OrbitScrollState extends State /// hit testing in the inset music viewport. class _OrbitStageClipper extends CustomClipper { final EdgeInsets overflow; + final double topOverflow; - const _OrbitStageClipper(this.overflow); + const _OrbitStageClipper(this.overflow, this.topOverflow); @override Rect getClip(Size size) => Rect.fromLTRB( -overflow.left, - 0, + -math.max(0.0, topOverflow), size.width + overflow.right, size.height, ); @override bool shouldReclip(covariant _OrbitStageClipper oldClipper) => - overflow != oldClipper.overflow; + overflow != oldClipper.overflow || topOverflow != oldClipper.topOverflow; } class _Position { From 18068889f6d0beb7c440cc741758886758f34d7e Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Tue, 10 Nov 2026 12:57:06 +0800 Subject: [PATCH 03/12] Add portrait orbit header overlap test Verify the orbit path paints behind the library header by comparing gallery output with the path enabled and disabled, and tighten the rail clipping assertion to require zero ribbon bleed outside the Songs page. --- test/features/songs/orbit_viewport_test.dart | 76 +++++++++++++++++++- 1 file changed, 73 insertions(+), 3 deletions(-) diff --git a/test/features/songs/orbit_viewport_test.dart b/test/features/songs/orbit_viewport_test.dart index a8d8850e..9923fc16 100644 --- a/test/features/songs/orbit_viewport_test.dart +++ b/test/features/songs/orbit_viewport_test.dart @@ -149,6 +149,27 @@ int _paintedPixels(Uint8List pixels, int width, Rect area) { return count; } +int _changedPixels( + Uint8List before, + Uint8List after, + int width, + Rect area, +) { + var count = 0; + for (var y = area.top.ceil(); y < area.bottom.floor(); y++) { + for (var x = area.left.ceil(); x < area.right.floor(); x++) { + final index = (y * width + x) * 4; + for (var channel = 0; channel < 3; channel++) { + if ((before[index + channel] - after[index + channel]).abs() > 2) { + count++; + break; + } + } + } + } + return count; +} + void main() { setUp(() => SharedPreferences.setMockInitialValues({})); @@ -207,9 +228,19 @@ void main() { expect(changed, greaterThan(3), reason: 'ribbon must reach x=$x'); } expect( - withPath.sublist(0, stage.top.floor() * width * 4), - withoutPath.sublist(0, stage.top.floor() * width * 4), - reason: 'the wider ribbon must not bleed into the header/search', + _changedPixels( + withoutPath, + withPath, + width, + Rect.fromLTRB( + 0, + 0, + AdaptiveLayout.navigationRailWidth, + height.toDouble(), + ), + ), + 0, + reason: 'the ribbon must stay inside the Songs page', ); expect( tester @@ -224,6 +255,45 @@ void main() { }); } + testWidgets('portrait orbit paints behind the library header', (tester) async { + final container = await _pumpScreen( + tester, + const Size(411, 900), + railWidth: 0, + ); + final prefs = + container.read(appPreferencesProvider.notifier) as _OrbitPreferences; + prefs.update(path: true); + await tester.pumpAndSettle(); + + final stage = tester.getRect(find.byType(OrbitScroll)); + final cardTops = [ + for (var i = 0; i < find.byType(SongCard).evaluate().length; i++) + tester.getRect(find.byType(SongCard).at(i)).top, + ]; + expect( + cardTops.any((top) => top < stage.top), + isTrue, + reason: 'the top orbit card should paint into the header area', + ); + + final withPath = await _pixels(tester, 'portrait_overlap'); + prefs.update(path: false); + await tester.pumpAndSettle(); + final withoutPath = await _pixels(tester, 'portrait_overlap_without_path'); + expect( + _changedPixels( + withoutPath, + withPath, + 411, + Rect.fromLTRB(0, 0, 411, stage.top), + ), + greaterThan(100), + reason: 'the orbit path should be visible behind the header/search', + ); + expect(tester.takeException(), isNull); + }); + testWidgets('portrait ribbon still paints through the shell bottom inset', ( tester, ) async { From bef8bc3cf93c749f1652620ff1a5f89cbe399aa1 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Tue, 10 Nov 2026 12:58:05 +0800 Subject: [PATCH 04/12] Reword orbit header boundary notice Update the notice copy in en, es, tl, and zh to describe the orbit passing behind the library header and search bar while staying clipped to the Songs screen edges, matching the new overlay behavior. Regenerate the localizations. --- lib/l10n/app_en.arb | 8 ++++---- lib/l10n/app_es.arb | 8 ++++---- lib/l10n/app_tl.arb | 4 ++-- lib/l10n/app_zh.arb | 4 ++-- lib/l10n/generated/app_localizations.dart | 8 ++++---- lib/l10n/generated/app_localizations_en.dart | 5 +++-- lib/l10n/generated/app_localizations_es.dart | 4 ++-- lib/l10n/generated/app_localizations_tl.dart | 4 ++-- lib/l10n/generated/app_localizations_zh.dart | 4 ++-- 9 files changed, 25 insertions(+), 24 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2445409f..0f1aaa97 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -7203,13 +7203,13 @@ "@orbital": { "description": "lib/models/song_view_mode.dart:17" }, - "orbitHeaderBoundaryBody": "Orbital cards and the orbit path stay below the Your Library heading and search bar. Raising the orbit clips it at this boundary.", + "orbitHeaderBoundaryBody": "Orbit cards and the path can pass behind the Your Library heading and search bar. They stay inside the Songs screen and are clipped at its edges.", "@orbitHeaderBoundaryBody": { - "description": "Explains that orbit cards and the path stay below the library header and search bar." + "description": "Explains that orbit cards and the path can pass behind the library header and search bar, but stay inside the Songs screen." }, - "orbitHeaderBoundaryTitle": "Orbit stops at the library header", + "orbitHeaderBoundaryTitle": "Orbit passes behind the library header", "@orbitHeaderBoundaryTitle": { - "description": "Title of the notice about the orbit's top boundary." + "description": "Title of the notice about the orbit passing behind the library header." }, "orbitLandscapeHint": "Used when the app window is wider than it is tall.", "@orbitLandscapeHint": { diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index f085eb00..2cf65924 100755 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -7203,13 +7203,13 @@ "@orbital": { "description": "lib/models/song_view_mode.dart:17" }, - "orbitHeaderBoundaryBody": "Las tarjetas orbitales y la trayectoria de la órbita permanecen debajo del encabezado Tu biblioteca y de la barra de búsqueda. Si elevas la órbita, se recorta en este límite.", + "orbitHeaderBoundaryBody": "Las tarjetas orbitales y la trayectoria pueden pasar detrás del encabezado Tu biblioteca y de la barra de búsqueda. Permanecen dentro de la pantalla de Canciones y se recortan en sus bordes.", "@orbitHeaderBoundaryBody": { - "description": "Explains that orbit cards and the path stay below the library header and search bar." + "description": "Explains that orbit cards and the path can pass behind the library header and search bar, but stay inside the Songs screen." }, - "orbitHeaderBoundaryTitle": "La órbita se detiene en la cabecera de la biblioteca", + "orbitHeaderBoundaryTitle": "La órbita pasa detrás de la cabecera de la biblioteca", "@orbitHeaderBoundaryTitle": { - "description": "Title of the notice about the orbit's top boundary." + "description": "Title of the notice about the orbit passing behind the library header." }, "orbitLandscapeHint": "Se usa cuando la ventana de la app es más ancha que alta.", "@orbitLandscapeHint": { diff --git a/lib/l10n/app_tl.arb b/lib/l10n/app_tl.arb index ebd7bf05..029e4558 100644 --- a/lib/l10n/app_tl.arb +++ b/lib/l10n/app_tl.arb @@ -1427,8 +1427,8 @@ "openslEsLegacy": "OpenSL ES (luma)", "orRemoveAllUsingRecommendedPicks": "O alisin lahat gamit ang mga inirekomendang piliin", "orbital": "Orbital", - "orbitHeaderBoundaryBody": "Ang mga orbital card at orbit path ay nananatili sa ilalim ng Your Library heading at search bar. Kapag itinaas ang orbit, napuputol ito sa hangganang ito.", - "orbitHeaderBoundaryTitle": "Humihinto ang orbit sa library header", + "orbitHeaderBoundaryBody": "Maaaring dumaan ang mga orbital card at ang path sa likod ng Your Library heading at search bar. Nananatili sila sa loob ng Songs screen at naka-clip sa mga gilid nito.", + "orbitHeaderBoundaryTitle": "Dumadaan ang orbit sa likod ng library header", "orbitLandscapeHint": "Ginagamit kapag mas malapad kaysa matangkad ang window ng app.", "orbitLayoutProfile": "Profile ng layout", "orbitPortraitHint": "Ginagamit kapag mas matangkad kaysa lapad ang window ng app.", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 2270d90a..8029bc7a 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1580,8 +1580,8 @@ "openslEsLegacy": "OpenSL ES(旧版)", "orRemoveAllUsingRecommendedPicks": "或使用推荐选择移除全部", "orbital": "Orbital", - "orbitHeaderBoundaryBody": "轨道卡片和轨道路径始终位于媒体库标题与搜索栏下方。向上移动轨道时,会在此边界处被裁切。", - "orbitHeaderBoundaryTitle": "轨道止于媒体库顶栏", + "orbitHeaderBoundaryBody": "轨道卡片和轨道路径可以穿过媒体库标题与搜索栏后方。它们只保留在“歌曲”屏幕内,并在屏幕边缘被裁切。", + "orbitHeaderBoundaryTitle": "轨道从媒体库顶栏后方穿过", "orbitLandscapeHint": "当应用窗口宽大于高时使用。", "orbitLayoutProfile": "布局配置", "orbitPortraitHint": "当应用窗口高大于宽时使用。", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 98a8f13f..958a5217 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -9534,16 +9534,16 @@ abstract class AppLocalizations { /// **'Orbital'** String get orbital; - /// Explains that orbit cards and the path stay below the library header and search bar. + /// Explains that orbit cards and the path can pass behind the library header and search bar, but stay inside the Songs screen. /// /// In en, this message translates to: - /// **'Orbital cards and the orbit path stay below the Your Library heading and search bar. Raising the orbit clips it at this boundary.'** + /// **'Orbit cards and the path can pass behind the Your Library heading and search bar. They stay inside the Songs screen and are clipped at its edges.'** String get orbitHeaderBoundaryBody; - /// Title of the notice about the orbit's top boundary. + /// Title of the notice about the orbit passing behind the library header. /// /// In en, this message translates to: - /// **'Orbit stops at the library header'** + /// **'Orbit passes behind the library header'** String get orbitHeaderBoundaryTitle; /// Explains when the landscape orbit profile is applied. diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 97886808..a109bb0e 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -5568,10 +5568,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String get orbitHeaderBoundaryBody => - 'Orbital cards and the orbit path stay below the Your Library heading and search bar. Raising the orbit clips it at this boundary.'; + 'Orbit cards and the path can pass behind the Your Library heading and search bar. They stay inside the Songs screen and are clipped at its edges.'; @override - String get orbitHeaderBoundaryTitle => 'Orbit stops at the library header'; + String get orbitHeaderBoundaryTitle => + 'Orbit passes behind the library header'; @override String get orbitLandscapeHint => diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 7bb5ee7d..cbd14fb1 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -5638,11 +5638,11 @@ class AppLocalizationsEs extends AppLocalizations { @override String get orbitHeaderBoundaryBody => - 'Las tarjetas orbitales y la trayectoria de la órbita permanecen debajo del encabezado Tu biblioteca y de la barra de búsqueda. Si elevas la órbita, se recorta en este límite.'; + 'Las tarjetas orbitales y la trayectoria pueden pasar detrás del encabezado Tu biblioteca y de la barra de búsqueda. Permanecen dentro de la pantalla de Canciones y se recortan en sus bordes.'; @override String get orbitHeaderBoundaryTitle => - 'La órbita se detiene en la cabecera de la biblioteca'; + 'La órbita pasa detrás de la cabecera de la biblioteca'; @override String get orbitLandscapeHint => diff --git a/lib/l10n/generated/app_localizations_tl.dart b/lib/l10n/generated/app_localizations_tl.dart index c5ced6a9..ee0bb698 100644 --- a/lib/l10n/generated/app_localizations_tl.dart +++ b/lib/l10n/generated/app_localizations_tl.dart @@ -5627,11 +5627,11 @@ class AppLocalizationsTl extends AppLocalizations { @override String get orbitHeaderBoundaryBody => - 'Ang mga orbital card at orbit path ay nananatili sa ilalim ng Your Library heading at search bar. Kapag itinaas ang orbit, napuputol ito sa hangganang ito.'; + 'Maaaring dumaan ang mga orbital card at ang path sa likod ng Your Library heading at search bar. Nananatili sila sa loob ng Songs screen at naka-clip sa mga gilid nito.'; @override String get orbitHeaderBoundaryTitle => - 'Humihinto ang orbit sa library header'; + 'Dumadaan ang orbit sa likod ng library header'; @override String get orbitLandscapeHint => diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 7fc55335..9cae7a26 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -5206,10 +5206,10 @@ class AppLocalizationsZh extends AppLocalizations { @override String get orbitHeaderBoundaryBody => - '轨道卡片和轨道路径始终位于媒体库标题与搜索栏下方。向上移动轨道时,会在此边界处被裁切。'; + '轨道卡片和轨道路径可以穿过媒体库标题与搜索栏后方。它们只保留在“歌曲”屏幕内,并在屏幕边缘被裁切。'; @override - String get orbitHeaderBoundaryTitle => '轨道止于媒体库顶栏'; + String get orbitHeaderBoundaryTitle => '轨道从媒体库顶栏后方穿过'; @override String get orbitLandscapeHint => '当应用窗口宽大于高时使用。'; From 54833f1d30dd212178b693480a5e6bea1439c106 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Tue, 10 Nov 2026 16:01:26 +0800 Subject: [PATCH 05/12] Add orbit card background style setting Add a portrait-only Card Background section to the orbit settings with blur, black, and album-art color gradient options, and pass the selected style through to the orbit preview. Deep links to the cardBackground section now force the portrait profile. --- .../screens/orbit_settings_screen.dart | 59 ++++++++++++++++++- .../settings/widgets/orbit_preview.dart | 4 ++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/lib/features/settings/screens/orbit_settings_screen.dart b/lib/features/settings/screens/orbit_settings_screen.dart index 3a397e4c..90c40cd5 100644 --- a/lib/features/settings/screens/orbit_settings_screen.dart +++ b/lib/features/settings/screens/orbit_settings_screen.dart @@ -8,6 +8,7 @@ import 'package:flick/providers/app_preferences_provider.dart'; import 'package:flick/features/settings/widgets/orbit_preview.dart'; import 'package:flick/features/settings/widgets/settings_widgets.dart'; import 'package:flick/l10n/settings_l10n.dart'; +import 'package:flick/models/orbit_card_background_style.dart'; enum _OrbitLayoutProfile { portrait, landscape } @@ -32,9 +33,11 @@ class _OrbitSettingsScreenState extends ConsumerState { super.didChangeDependencies(); if (_profileInitialized) return; _profileInitialized = true; - _profile = AdaptiveLayout.isWide(MediaQuery.sizeOf(context)) - ? _OrbitLayoutProfile.landscape - : _OrbitLayoutProfile.portrait; + _profile = + widget.initialSection == 'cardBackground' || + !AdaptiveLayout.isWide(MediaQuery.sizeOf(context)) + ? _OrbitLayoutProfile.portrait + : _OrbitLayoutProfile.landscape; } @override @@ -61,6 +64,9 @@ class _OrbitSettingsScreenState extends ConsumerState { final cardWidthRatio = _landscape ? prefs.orbitLandscapeCardWidthRatio : prefs.orbitCardWidthRatio; + final cardBackgroundStyle = OrbitCardBackgroundStyleX.fromStorageValue( + prefs.orbitCardBackgroundStyle, + ); return SettingsScaffold( title: l10n.englishForFilipino.customizeOrbital, @@ -250,6 +256,53 @@ class _OrbitSettingsScreenState extends ConsumerState { ], ), const SizedBox(height: AppConstants.spacingLg), + if (!_landscape) ...[ + SettingsSectionHeader( + l10n.englishForFilipino.cardBackground, + tag: l10n.englishForFilipino.portrait, + sectionId: 'cardBackground', + ), + SettingsCard( + children: [ + SelectionSetting( + icon: LucideIcons.aperture, + title: l10n.englishForFilipino.blurGradient, + subtitle: l10n.englishForFilipino.blurGradientHint, + selected: + cardBackgroundStyle == + OrbitCardBackgroundStyle.blurGradient, + onTap: () => notifier.setOrbitCardBackgroundStyle( + OrbitCardBackgroundStyle.blurGradient.storageValue, + ), + ), + const SettingsDivider(), + SelectionSetting( + icon: LucideIcons.circle, + title: l10n.englishForFilipino.blackGradient, + subtitle: l10n.englishForFilipino.blackGradientHint, + selected: + cardBackgroundStyle == + OrbitCardBackgroundStyle.blackGradient, + onTap: () => notifier.setOrbitCardBackgroundStyle( + OrbitCardBackgroundStyle.blackGradient.storageValue, + ), + ), + const SettingsDivider(), + SelectionSetting( + icon: LucideIcons.palette, + title: l10n.englishForFilipino.albumArtColorGradient, + subtitle: l10n.englishForFilipino.albumArtColorGradientHint, + selected: + cardBackgroundStyle == + OrbitCardBackgroundStyle.albumColorGradient, + onTap: () => notifier.setOrbitCardBackgroundStyle( + OrbitCardBackgroundStyle.albumColorGradient.storageValue, + ), + ), + ], + ), + ], + const SizedBox(height: AppConstants.spacingLg), SettingsSectionHeader( l10n.englishForFilipino.artResolution, tag: l10n.englishForFilipino.orbitSharedVisuals, diff --git a/lib/features/settings/widgets/orbit_preview.dart b/lib/features/settings/widgets/orbit_preview.dart index cf14a6ac..2a4c5ca1 100644 --- a/lib/features/settings/widgets/orbit_preview.dart +++ b/lib/features/settings/widgets/orbit_preview.dart @@ -10,6 +10,7 @@ import 'package:flick/core/theme/app_colors.dart'; import 'package:flick/features/settings/widgets/settings_widgets.dart'; import 'package:flick/features/songs/widgets/orbit_scroll.dart'; import 'package:flick/l10n/settings_l10n.dart'; +import 'package:flick/models/orbit_card_background_style.dart'; import 'package:flick/models/song.dart'; import 'package:flick/providers/app_preferences_provider.dart'; import 'package:flick/providers/songs_provider.dart'; @@ -423,6 +424,9 @@ class _PreviewOrbit extends StatelessWidget { artResolutionMultiplier: prefs.orbitArtResolutionMultiplier, showPath: prefs.orbitShowPath, showGlow: prefs.orbitShowGlow, + cardBackgroundStyle: OrbitCardBackgroundStyleX.fromStorageValue( + prefs.orbitCardBackgroundStyle, + ), swipeActionsEnabled: false, isSelectionMode: false, onSelectedIndexChanged: onSelectedIndexChanged, From d8d5d4d0b1b3e4c0f0833c49edcae6b4d3c18509 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Tue, 10 Nov 2026 16:01:47 +0800 Subject: [PATCH 06/12] Render orbit card background styles Portrait cards now honor the card background setting: blackGradient keeps the legacy two-pass fade, blurGradient draws a pre-baked blurred cover with a lighter fade, and albumColorGradient tints the fade from the extracted dominant color. OrbitScroll preloads blurred artwork for the visible window so motion draws a cached texture instead of a live filter, and SongCard resolves album colors off the layout path. --- lib/features/songs/widgets/orbit_scroll.dart | 66 +++++++ lib/features/songs/widgets/song_card.dart | 176 +++++++++++++++++-- 2 files changed, 225 insertions(+), 17 deletions(-) diff --git a/lib/features/songs/widgets/orbit_scroll.dart b/lib/features/songs/widgets/orbit_scroll.dart index 61929627..255ac43c 100644 --- a/lib/features/songs/widgets/orbit_scroll.dart +++ b/lib/features/songs/widgets/orbit_scroll.dart @@ -6,8 +6,11 @@ import 'package:flutter/physics.dart'; import 'package:flutter/rendering.dart'; import 'package:flick/core/theme/app_colors.dart'; import 'package:flick/core/utils/app_haptics.dart'; +import 'package:flick/models/orbit_card_background_style.dart'; import 'package:flick/models/song.dart'; import 'package:flick/features/songs/widgets/song_card.dart'; +import 'package:flick/services/orbit_card_blur_image.dart'; +import 'package:flick/services/orbit_card_blur_preloader.dart'; import 'package:flick/widgets/common/cached_image_widget.dart'; class OrbitScrollController { @@ -74,6 +77,7 @@ class OrbitScroll extends StatefulWidget { final double artResolutionMultiplier; final bool showPath; final bool showGlow; + final OrbitCardBackgroundStyle cardBackgroundStyle; /// Extra bottom room the path ribbon may paint through without letting the /// card stage or gesture surface move into the bottom bars. @@ -114,6 +118,7 @@ class OrbitScroll extends StatefulWidget { this.artResolutionMultiplier = 2.0, this.showPath = true, this.showGlow = true, + this.cardBackgroundStyle = OrbitCardBackgroundStyle.blackGradient, this.pathOverflowBottom = 0.0, this.paintOverflowTop = 0.0, this.horizontalPaintOverflow = EdgeInsets.zero, @@ -145,6 +150,8 @@ class _OrbitScrollState extends State Size? _layoutSize; double _dragExtent = 90.0; int _animationGeneration = 0; + late final _blurPreloader = OrbitCardBlurPreloader(preload: _precacheBlur); + bool _blurPreloadScheduled = false; // Cache for transform calculations (bounded) final Map _positionCache = {}; @@ -219,6 +226,7 @@ class _OrbitScrollState extends State @override void dispose() { _animationGeneration++; + _blurPreloader.dispose(); widget.controller?._detach(); _controller.dispose(); _scrollOffset.dispose(); @@ -289,6 +297,9 @@ class _OrbitScrollState extends State if (_artworkGateHeld) { _artworkGateHeld = false; pauseArtworkExtraction(false); + // Retry lookahead covers that needed extraction during the fling. + _blurPreloader.clear(); + _scheduleBlurPreload(); } }); } @@ -587,6 +598,58 @@ class _OrbitScrollState extends State ); } + void _scheduleBlurPreload() { + if (_blurPreloadScheduled) return; + _blurPreloadScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _blurPreloadScheduled = false; + if (!mounted) return; + if (widget.scrollDirection != Axis.vertical || + widget.cardBackgroundStyle != OrbitCardBackgroundStyle.blurGradient) { + _blurPreloader.clear(); + return; + } + // Match SongCard's unscaled portrait bounds so both consumers resolve to + // the same ImageCache key; sharp-art resolution remains independent. + unawaited( + _blurPreloader.update( + songs: widget.songs, + centerIndex: _scrollOffset.value.round(), + visibleItems: widget.visibleItems, + cardSize: Size( + MediaQuery.sizeOf(context).width * widget.cardWidthRatio, + 130, + ), + ), + ); + }); + // The settle timer can request a retry after the final animation frame. + WidgetsBinding.instance.ensureVisualUpdate(); + } + + Future _precacheBlur(Song song, Size cardSize) async { + var path = await OrbitCardBlurArtwork.resolveLocalPath( + imagePath: song.albumArt, + audioSourcePath: song.filePath, + ); + if (!mounted) return; + if (path == null && !artworkExtractionPaused) { + path = await OrbitCardBlurArtwork.resolvePath( + imagePath: song.albumArt, + audioSourcePath: song.filePath, + ); + } + if (!mounted || path == null) return; + await precacheImage( + OrbitCardBlurImage(path: path, cardSize: cardSize), + context, + onError: (_, _) => OrbitCardBlurArtwork.forget( + imagePath: song.albumArt, + audioSourcePath: song.filePath, + ), + ); + } + Widget _buildSelectionGlow(double centerX, double centerY, double radius) { final horizontal = widget.scrollDirection == Axis.horizontal; final x = horizontal ? centerX : centerX + radius; @@ -694,6 +757,8 @@ class _OrbitScrollState extends State }) { final List items = []; + _scheduleBlurPreload(); + final horizontal = widget.scrollDirection == Axis.horizontal; final centerIndex = _scrollOffset.value.round(); @@ -779,6 +844,7 @@ class _OrbitScrollState extends State width: cardWidth, height: cardHeight, artResolutionMultiplier: widget.artResolutionMultiplier, + backgroundStyle: widget.cardBackgroundStyle, onTap: () { AppHaptics.tap(); _animateTo(actualIndex.toDouble()); diff --git a/lib/features/songs/widgets/song_card.dart b/lib/features/songs/widgets/song_card.dart index 7962054d..99b5a9c1 100644 --- a/lib/features/songs/widgets/song_card.dart +++ b/lib/features/songs/widgets/song_card.dart @@ -6,7 +6,12 @@ import 'package:flick/core/theme/app_colors.dart'; import 'package:flick/core/theme/app_theme.dart'; import 'package:flick/core/constants/app_constants.dart'; import 'package:flick/core/utils/app_haptics.dart'; +import 'package:flick/models/orbit_card_background_style.dart'; import 'package:flick/models/song.dart'; +import 'package:flick/features/player/widgets/album_color_helpers.dart'; +import 'package:flick/features/songs/widgets/orbit_card_blur.dart'; +import 'package:flick/services/album_art_service.dart'; +import 'package:flick/services/color_extraction_service.dart'; import 'package:flick/widgets/common/cached_image_widget.dart'; import 'package:flick/widgets/common/flick_artwork_placeholder.dart'; import 'package:flick/l10n/l10n.dart'; @@ -58,6 +63,9 @@ class SongCard extends StatefulWidget { final double? height; final bool artworkAbove; + /// Background treatment used by the portrait card face. + final OrbitCardBackgroundStyle backgroundStyle; + const SongCard({ super.key, required this.song, @@ -77,6 +85,7 @@ class SongCard extends StatefulWidget { this.width, this.height, this.artworkAbove = false, + this.backgroundStyle = OrbitCardBackgroundStyle.blackGradient, }); @override @@ -93,6 +102,17 @@ class _SongCardState extends State { /// artwork, gradients and text on every frame. Widget? _cardFace; + /// Resolves the per-card tint for [OrbitCardBackgroundStyle.albumColorGradient]. + /// The future is rebuilt only when the song or background style changes; the + /// service caches extracted colors, so later resolutions are instant. + late Future _albumColorFuture; + + @override + void initState() { + super.initState(); + _albumColorFuture = _resolveAlbumColor(); + } + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -105,16 +125,41 @@ class _SongCardState extends State { super.didUpdateWidget(oldWidget); // Song equality compares only the id, so use identity here: metadata or // artwork for the same id must refresh the retained face. - if (!identical(oldWidget.song, widget.song) || + final songChanged = !identical(oldWidget.song, widget.song); + if (songChanged || oldWidget.isSelected != widget.isSelected || oldWidget.isSelectionMode != widget.isSelectionMode || oldWidget.isMultiSelected != widget.isMultiSelected || oldWidget.artSizeBase != widget.artSizeBase || oldWidget.artSizeLarge != widget.artSizeLarge || oldWidget.artResolutionMultiplier != widget.artResolutionMultiplier || - oldWidget.artworkAbove != widget.artworkAbove) { + oldWidget.artworkAbove != widget.artworkAbove || + oldWidget.backgroundStyle != widget.backgroundStyle) { _cardFace = null; } + if (songChanged || + oldWidget.artworkAbove != widget.artworkAbove || + oldWidget.backgroundStyle != widget.backgroundStyle) { + _albumColorFuture = _resolveAlbumColor(); + } + } + + Future _resolveAlbumColor() { + if (widget.artworkAbove || + widget.backgroundStyle != OrbitCardBackgroundStyle.albumColorGradient) { + return Future.value(null); + } + final song = widget.song; + return () async { + // Resolve embedded or folder artwork before sampling; the service keeps + // the expensive extraction off the layout path and caches the result. + final path = await AlbumArtService.instance.resolveArtworkPath( + existingPath: song.albumArt, + audioSourcePath: song.filePath ?? '', + ); + if (path == null) return null; + return ColorExtractionService().extractDominantColor(path); + }(); } @override @@ -229,21 +274,9 @@ class _SongCardState extends State { borderRadius: const BorderRadius.all(Radius.circular(AppConstants.radiusLg)), child: Stack( children: [ - Positioned.fill(child: _buildAlbumWithGradient(artSize)), + Positioned.fill(child: _buildPortraitBackground(artSize)), Positioned.fill( - child: Container( - decoration: const BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(AppConstants.radiusLg)), - gradient: LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [ - Colors.transparent, - Color(0xD9000000), - ], - stops: [0.25, 0.70], - ), - ), + child: Padding( padding: const EdgeInsets.all(AppConstants.spacingMd), child: Row( children: [ @@ -483,7 +516,23 @@ class _SongCardState extends State { ); } - Widget _buildAlbumWithGradient(double size) { + Widget _buildPortraitBackground(double size) { + switch (widget.backgroundStyle) { + case OrbitCardBackgroundStyle.blurGradient: + return _buildBlurPortraitBackground(size); + case OrbitCardBackgroundStyle.albumColorGradient: + return FutureBuilder( + future: _albumColorFuture, + builder: (context, snapshot) { + return _buildAlbumColorPortraitBackground(size, snapshot.data); + }, + ); + case OrbitCardBackgroundStyle.blackGradient: + return _buildBlackPortraitBackground(size); + } + } + + Widget _buildBlackPortraitBackground(double size) { return Stack( fit: StackFit.expand, children: [ @@ -493,6 +542,7 @@ class _SongCardState extends State { fit: BoxFit.cover, artSize: size, ), + // Keep the original two-pass black fade for the legacy style. Container( decoration: BoxDecoration( gradient: LinearGradient( @@ -503,6 +553,98 @@ class _SongCardState extends State { ), ), ), + Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [Colors.transparent, Color(0xD9000000)], + stops: [0.25, 0.70], + ), + ), + ), + ], + ); + } + + Widget _buildBlurPortraitBackground(double size) { + return Stack( + fit: StackFit.expand, + children: [ + _buildRawImage( + widget.song.albumArt ?? '', + audioSourcePath: widget.song.filePath, + fit: BoxFit.cover, + artSize: size, + ), + // The blur and fade are baked once into a small cached image, so orbit + // motion draws a plain texture instead of rerunning a live filter. The + // sharp artwork above keeps its independent decode resolution. + OrbitCardBlur( + imagePath: widget.song.albumArt, + audioSourcePath: widget.song.filePath, + ), + // Lighter than the black style so the blurred cover stays visible. + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: 0.56), + Colors.black.withValues(alpha: 0.8), + ], + stops: const [0.22, 0.58, 0.9], + ), + ), + ), + ], + ); + } + + Widget _buildAlbumColorPortraitBackground(double size, Color? albumColor) { + if (albumColor == null) return _buildBlackPortraitBackground(size); + + final tint = albumSurface(albumColor, 0.5); + return Stack( + fit: StackFit.expand, + children: [ + _buildRawImage( + widget.song.albumArt ?? '', + audioSourcePath: widget.song.filePath, + fit: BoxFit.cover, + artSize: size, + ), + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + Colors.transparent, + tint.withValues(alpha: 0.55), + tint.withValues(alpha: 0.95), + ], + stops: const [0.2, 0.55, 0.88], + ), + ), + ), + // Darken the tint just enough for white metadata on bright covers. + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: 0.12), + Colors.black.withValues(alpha: 0.3), + ], + stops: const [0.45, 0.7, 1.0], + ), + ), + ), ], ); } From 79d5ea134068d8432de202b6f84744a22c742a4a Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Tue, 10 Nov 2026 16:01:54 +0800 Subject: [PATCH 07/12] Add orbit card background to settings search Wire the new cardBackground section into the settings search catalog and pass the selected background style through to the orbit stage from the Songs screen. --- lib/features/settings/data/settings_search_catalog.dart | 5 +++++ lib/features/songs/screens/songs_screen.dart | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/lib/features/settings/data/settings_search_catalog.dart b/lib/features/settings/data/settings_search_catalog.dart index d6cc7b29..ec4d1128 100644 --- a/lib/features/settings/data/settings_search_catalog.dart +++ b/lib/features/settings/data/settings_search_catalog.dart @@ -600,6 +600,11 @@ List buildSettingsSearchCatalog() { ('selectedSize', l10n.selectedSize), ('depth', l10n.depth), ]), + _section('cardBackground', l10n.cardBackground, [ + ('blurGradient', l10n.blurGradient), + ('blackGradient', l10n.blackGradient), + ('albumArtColorGradient', l10n.albumArtColorGradient), + ]), _section('artResolution', l10n.artResolution, [ ('low', l10n.low), ('medium', l10n.medium), diff --git a/lib/features/songs/screens/songs_screen.dart b/lib/features/songs/screens/songs_screen.dart index 463bf73b..883a4839 100644 --- a/lib/features/songs/screens/songs_screen.dart +++ b/lib/features/songs/screens/songs_screen.dart @@ -31,6 +31,7 @@ import 'package:flick/providers/providers.dart'; import 'package:flick/services/music_folder_service.dart'; import 'package:flick/services/player_service.dart'; import 'package:flick/models/nav_bar_config.dart'; +import 'package:flick/models/orbit_card_background_style.dart'; import 'package:flick/widgets/common/glass_search_bar.dart'; import 'package:flick/widgets/common/cached_image_widget.dart'; import 'package:flick/widgets/common/flick_artwork_placeholder.dart'; @@ -502,6 +503,9 @@ class _SongsScreenState extends ConsumerState artResolutionMultiplier: orbit.orbitArtResolutionMultiplier, showPath: orbit.orbitShowPath, showGlow: orbit.orbitShowGlow, + cardBackgroundStyle: OrbitCardBackgroundStyleX.fromStorageValue( + orbit.orbitCardBackgroundStyle, + ), // The shell overlays the mini player and navigation bar on the stage. // The ribbon may continue through that reserved strip; the card stage // stays above it. From 984a140147f97e4863436d0df08928952993c62b Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Tue, 10 Nov 2026 16:02:02 +0800 Subject: [PATCH 08/12] Persist orbit card background style preference Add the provider setter and preference plumbing for the card background style so the selection survives restarts and is cleared with the portrait layout reset. --- lib/providers/app_preferences_provider.dart | 10 ++++++++++ lib/services/app_preferences_service.dart | 17 +++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/lib/providers/app_preferences_provider.dart b/lib/providers/app_preferences_provider.dart index 48c06697..40708184 100644 --- a/lib/providers/app_preferences_provider.dart +++ b/lib/providers/app_preferences_provider.dart @@ -813,6 +813,14 @@ class AppPreferencesNotifier extends Notifier { await ref.read(appPreferencesServiceProvider).setOrbitShowGlow(value); } + Future setOrbitCardBackgroundStyle(String value) async { + if (state.orbitCardBackgroundStyle == value) return; + state = state.copyWith(orbitCardBackgroundStyle: value); + await ref + .read(appPreferencesServiceProvider) + .setOrbitCardBackgroundStyle(value); + } + Future setOrbitLandscapeRadiusRatio(double value) async { if (state.orbitLandscapeRadiusRatio == value) return; state = state.copyWith(orbitLandscapeRadiusRatio: value); @@ -933,6 +941,7 @@ class AppPreferencesNotifier extends Notifier { orbitVisibleItems: 5, orbitCardArtSize: 64.0, orbitCardWidthRatio: 0.68, + orbitCardBackgroundStyle: 'black_gradient', ); await ref.read(appPreferencesServiceProvider).clearOrbitPortraitLayout(); } @@ -951,6 +960,7 @@ class AppPreferencesNotifier extends Notifier { orbitArtResolutionMultiplier: 2.0, orbitShowPath: true, orbitShowGlow: true, + orbitCardBackgroundStyle: 'black_gradient', orbitLandscapeRadiusRatio: 1.0, orbitLandscapeCenterYRatio: 0.48, orbitLandscapeItemSpacing: 0.28, diff --git a/lib/services/app_preferences_service.dart b/lib/services/app_preferences_service.dart index c76eec49..ecaac2e2 100644 --- a/lib/services/app_preferences_service.dart +++ b/lib/services/app_preferences_service.dart @@ -107,6 +107,10 @@ class AppPreferences { final double orbitArtResolutionMultiplier; final bool orbitShowPath; final bool orbitShowGlow; + + /// One of 'blur_gradient', 'black_gradient', or 'album_color_gradient'. + final String orbitCardBackgroundStyle; + final double orbitLandscapeRadiusRatio; final double orbitLandscapeCenterYRatio; final double orbitLandscapeItemSpacing; @@ -235,6 +239,7 @@ class AppPreferences { this.orbitArtResolutionMultiplier = 2.0, this.orbitShowPath = true, this.orbitShowGlow = true, + this.orbitCardBackgroundStyle = 'black_gradient', this.orbitLandscapeRadiusRatio = 1.0, this.orbitLandscapeCenterYRatio = 0.48, this.orbitLandscapeItemSpacing = 0.28, @@ -363,6 +368,7 @@ class AppPreferences { double? orbitArtResolutionMultiplier, bool? orbitShowPath, bool? orbitShowGlow, + String? orbitCardBackgroundStyle, double? orbitLandscapeRadiusRatio, double? orbitLandscapeCenterYRatio, double? orbitLandscapeItemSpacing, @@ -532,6 +538,8 @@ class AppPreferences { orbitArtResolutionMultiplier ?? this.orbitArtResolutionMultiplier, orbitShowPath: orbitShowPath ?? this.orbitShowPath, orbitShowGlow: orbitShowGlow ?? this.orbitShowGlow, + orbitCardBackgroundStyle: + orbitCardBackgroundStyle ?? this.orbitCardBackgroundStyle, orbitLandscapeRadiusRatio: orbitLandscapeRadiusRatio ?? this.orbitLandscapeRadiusRatio, orbitLandscapeCenterYRatio: @@ -685,6 +693,7 @@ class AppPreferencesService { 'orbit_art_resolution_multiplier'; static const _orbitShowPathKey = 'orbit_show_path'; static const _orbitShowGlowKey = 'orbit_show_glow'; + static const _orbitCardBackgroundStyleKey = 'orbit_card_background_style'; static const _orbitLandscapeRadiusRatioKey = 'orbit_landscape_radius_ratio'; static const _orbitLandscapeCenterYRatioKey = 'orbit_landscape_center_y_ratio'; @@ -858,6 +867,8 @@ class AppPreferencesService { prefs.getDouble(_orbitArtResolutionMultiplierKey) ?? 2.0, orbitShowPath: prefs.getBool(_orbitShowPathKey) ?? true, orbitShowGlow: prefs.getBool(_orbitShowGlowKey) ?? true, + orbitCardBackgroundStyle: + prefs.getString(_orbitCardBackgroundStyleKey) ?? 'black_gradient', orbitLandscapeRadiusRatio: prefs.getDouble(_orbitLandscapeRadiusRatioKey) ?? 1.0, orbitLandscapeCenterYRatio: @@ -1978,6 +1989,11 @@ class AppPreferencesService { await prefs.setBool(_orbitShowGlowKey, value); } + Future setOrbitCardBackgroundStyle(String value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_orbitCardBackgroundStyleKey, value); + } + Future setOrbitLandscapeRadiusRatio(double value) async { final prefs = await SharedPreferences.getInstance(); await prefs.setDouble(_orbitLandscapeRadiusRatioKey, value); @@ -2084,6 +2100,7 @@ class AppPreferencesService { await prefs.remove(_orbitVisibleItemsKey); await prefs.remove(_orbitCardArtSizeKey); await prefs.remove(_orbitCardWidthRatioKey); + await prefs.remove(_orbitCardBackgroundStyleKey); } Future clearOrbitLandscapeLayout() async { From 344fbf4e3d103b1548d4a1d3af1072792994b6d9 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Tue, 10 Nov 2026 16:02:09 +0800 Subject: [PATCH 09/12] Add tests for orbit card background style Cover the selector in portrait settings, preference persistence and reset for the background style, and update card image lookups for the pre-baked blurred cover. --- .../settings/orbit_settings_screen_test.dart | 38 +++++++++++++++++++ test/features/songs/orbit_scroll_test.dart | 14 ++++++- test/features/songs/song_card_test.dart | 6 +-- .../orbit_layout_preferences_test.dart | 23 +++++++++++ 4 files changed, 76 insertions(+), 5 deletions(-) diff --git a/test/features/settings/orbit_settings_screen_test.dart b/test/features/settings/orbit_settings_screen_test.dart index ee698124..8eaffccd 100644 --- a/test/features/settings/orbit_settings_screen_test.dart +++ b/test/features/settings/orbit_settings_screen_test.dart @@ -8,6 +8,7 @@ import 'package:flick/features/settings/screens/orbit_settings_screen.dart'; import 'package:flick/features/songs/widgets/orbit_scroll.dart'; import 'package:flick/features/songs/widgets/song_card.dart'; import 'package:flick/l10n/l10n.dart'; +import 'package:flick/models/orbit_card_background_style.dart'; import 'package:flick/models/song.dart'; import 'package:flick/providers/providers.dart'; import 'package:flick/services/app_preferences_service.dart'; @@ -102,6 +103,43 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('portrait card background selector follows preference', ( + tester, + ) async { + await _pumpScreen(tester); + final copy = lookupFlickLocalizations(kFallbackLocale); + + expect( + tester + .widget(find.byType(OrbitScroll)) + .cardBackgroundStyle, + OrbitCardBackgroundStyle.blackGradient, + ); + expect(find.text(copy.cardBackground.toUpperCase()), findsOneWidget); + expect(find.text(copy.blackGradient), findsOneWidget); + expect(find.text(copy.albumArtColorGradient), findsOneWidget); + + await tester.ensureVisible(find.text(copy.albumArtColorGradient)); + await tester.pumpAndSettle(); + await tester.tap(find.text(copy.albumArtColorGradient)); + await tester.pumpAndSettle(); + + expect( + tester + .widget(find.byType(OrbitScroll)) + .cardBackgroundStyle, + OrbitCardBackgroundStyle.albumColorGradient, + ); + + await tester.ensureVisible(find.text(copy.landscape)); + await tester.pumpAndSettle(); + await tester.tap(find.text(copy.landscape)); + await tester.pumpAndSettle(); + + expect(find.text(copy.albumArtColorGradient), findsNothing); + expect(tester.takeException(), isNull); + }); + testWidgets('preview updates with preferences and can collapse', ( tester, ) async { diff --git a/test/features/songs/orbit_scroll_test.dart b/test/features/songs/orbit_scroll_test.dart index f53417c1..fe8fdda3 100644 --- a/test/features/songs/orbit_scroll_test.dart +++ b/test/features/songs/orbit_scroll_test.dart @@ -189,7 +189,12 @@ void main() { final finder = find.byWidget(card); cardStates[card.song.id] = tester.state(finder); imageStates[card.song.id] = tester.state( - find.descendant(of: finder, matching: find.byType(CachedImageWidget)), + find + .descendant( + of: finder, + matching: find.byType(CachedImageWidget), + ) + .first, ); } @@ -203,7 +208,12 @@ void main() { expect(tester.state(finder), same(cardStates[card.song.id])); expect( tester.state( - find.descendant(of: finder, matching: find.byType(CachedImageWidget)), + find + .descendant( + of: finder, + matching: find.byType(CachedImageWidget), + ) + .first, ), same(imageStates[card.song.id]), ); diff --git a/test/features/songs/song_card_test.dart b/test/features/songs/song_card_test.dart index 88904859..e320b763 100644 --- a/test/features/songs/song_card_test.dart +++ b/test/features/songs/song_card_test.dart @@ -53,7 +53,7 @@ void main() { tester, ) async { await tester.pumpWidget(_host(scale: 0.9, opacity: 0.8)); - final imageFinder = find.byType(CachedImageWidget); + final imageFinder = find.byType(CachedImageWidget).first; final image = tester.widget(imageFinder); final imageState = tester.state(imageFinder); final boundaryFinder = find.descendant( @@ -83,7 +83,7 @@ void main() { 'same-ID metadata and artwork updates refresh the retained face', (tester) async { await tester.pumpWidget(_host()); - final imageFinder = find.byType(CachedImageWidget); + final imageFinder = find.byType(CachedImageWidget).first; final originalImage = tester.widget(imageFinder); final imageState = tester.state(imageFinder); final edited = _song.copyWith( @@ -141,7 +141,7 @@ void main() { tester, ) async { await tester.pumpWidget(_host()); - final imageFinder = find.byType(CachedImageWidget); + final imageFinder = find.byType(CachedImageWidget).first; final imageState = tester.state(imageFinder); expect(tester.widget(imageFinder).thumbnailWidth, 105); diff --git a/test/providers/orbit_layout_preferences_test.dart b/test/providers/orbit_layout_preferences_test.dart index 6f452d53..1cd8f450 100644 --- a/test/providers/orbit_layout_preferences_test.dart +++ b/test/providers/orbit_layout_preferences_test.dart @@ -29,9 +29,26 @@ void main() { expect(prefs.orbitLandscapeDepth, 0.75); expect(prefs.orbitLandscapeVisibleItems, 5); expect(prefs.orbitLandscapeCardWidthRatio, 0.68); + expect(prefs.orbitCardBackgroundStyle, 'black_gradient'); }, ); + test('an explicitly chosen background survives the default change', () async { + SharedPreferences.setMockInitialValues({ + 'orbit_card_background_style': 'blur_gradient', + }); + + final container = await newContainer(); + expect( + container.read(appPreferencesProvider).orbitCardBackgroundStyle, + 'blur_gradient', + ); + + // Loading must not rewrite a stored choice to the new default. + final stored = await SharedPreferences.getInstance(); + expect(stored.getString('orbit_card_background_style'), 'blur_gradient'); + }); + test('portrait and landscape layouts persist independently', () async { final container = await newContainer(); final notifier = container.read(appPreferencesProvider.notifier); @@ -43,6 +60,7 @@ void main() { await notifier.setOrbitDepth(0.5); await notifier.setOrbitVisibleItems(7); await notifier.setOrbitCardWidthRatio(0.58); + await notifier.setOrbitCardBackgroundStyle('album_color_gradient'); await notifier.setOrbitLandscapeRadiusRatio(1.36); await notifier.setOrbitLandscapeCenterYRatio(0.56); @@ -61,6 +79,7 @@ void main() { expect(prefs.orbitDepth, 0.5); expect(prefs.orbitVisibleItems, 7); expect(prefs.orbitCardWidthRatio, 0.58); + expect(prefs.orbitCardBackgroundStyle, 'album_color_gradient'); expect(prefs.orbitLandscapeRadiusRatio, 1.36); expect(prefs.orbitLandscapeCenterYRatio, 0.56); expect(prefs.orbitLandscapeItemSpacing, 0.41); @@ -107,6 +126,7 @@ void main() { await notifier.setOrbitLandscapeRadiusRatio(1.36); await notifier.setOrbitLandscapeCenterYRatio(0.56); await notifier.setOrbitLandscapeVisibleItems(9); + await notifier.setOrbitCardBackgroundStyle('black_gradient'); await notifier.setOrbitArtResolutionMultiplier(3.0); await notifier.setOrbitShowPath(false); await notifier.setOrbitShowGlow(false); @@ -123,6 +143,7 @@ void main() { expect(prefs.orbitLandscapeCardWidthRatio, 0.68); expect(prefs.orbitRadiusRatio, 0.82); expect(prefs.orbitVisibleItems, 7); + expect(prefs.orbitCardBackgroundStyle, 'black_gradient'); expect(prefs.orbitArtResolutionMultiplier, 3.0); expect(prefs.orbitShowPath, isFalse); expect(prefs.orbitShowGlow, isFalse); @@ -145,12 +166,14 @@ void main() { expect(prefs.orbitVisibleItems, 5); expect(prefs.orbitCardArtSize, 64.0); expect(prefs.orbitCardWidthRatio, 0.68); + expect(prefs.orbitCardBackgroundStyle, 'black_gradient'); expect(prefs.orbitLandscapeRadiusRatio, 1.0); expect(prefs.orbitArtResolutionMultiplier, 3.0); stored = await SharedPreferences.getInstance(); expect(stored.getDouble('orbit_radius_ratio'), isNull); expect(stored.getDouble('orbit_landscape_radius_ratio'), isNull); + expect(stored.getString('orbit_card_background_style'), isNull); expect(stored.getDouble('orbit_art_resolution_multiplier'), 3.0); }, ); From 9873132ba12e14090a5da6a787f3c2318a31df85 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Tue, 10 Nov 2026 16:02:20 +0800 Subject: [PATCH 10/12] Add pre-blurred orbit card artwork pipeline Introduce an ImageProvider that rasterizes a blurred, gradient-masked portrait overlay at a bounded size, sharing resolved local covers and image-cache entries across cards. Add a preloader that warms a small moving lookahead window, one cover at a time, replacing queued work on jumps or direction changes. Cover the raster sizing, cache behavior during animation and scrolling, and preloader queueing, reversal, disposal, and error handling with tests. --- lib/services/orbit_card_blur_image.dart | 210 ++++++++++++++++++ lib/services/orbit_card_blur_preloader.dart | 77 +++++++ test/services/orbit_card_blur_image_test.dart | 132 +++++++++++ .../orbit_card_blur_preloader_test.dart | 140 ++++++++++++ 4 files changed, 559 insertions(+) create mode 100644 lib/services/orbit_card_blur_image.dart create mode 100644 lib/services/orbit_card_blur_preloader.dart create mode 100644 test/services/orbit_card_blur_image_test.dart create mode 100644 test/services/orbit_card_blur_preloader_test.dart diff --git a/lib/services/orbit_card_blur_image.dart b/lib/services/orbit_card_blur_image.dart new file mode 100644 index 00000000..4e62bcd7 --- /dev/null +++ b/lib/services/orbit_card_blur_image.dart @@ -0,0 +1,210 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; +import 'package:flick/services/album_art_service.dart'; + +/// Shares resolved local covers between lookahead loading and visible cards. +/// Only successful paths are retained; missing artwork can be retried later. +class OrbitCardBlurArtwork { + static final Map<(String?, String?), String> _paths = {}; + static const _maxPaths = 256; + + static String? cachedPath({String? imagePath, String? audioSourcePath}) { + final key = (imagePath, audioSourcePath); + final path = _paths.remove(key); + if (path != null) _paths[key] = path; + return path; + } + + static Future resolveLocalPath({ + String? imagePath, + String? audioSourcePath, + }) async { + final cached = cachedPath( + imagePath: imagePath, + audioSourcePath: audioSourcePath, + ); + if (cached != null) return cached; + if (imagePath == null || + imagePath.isEmpty || + imagePath.contains('://') || + !await File(imagePath).exists()) { + return null; + } + _remember(imagePath, audioSourcePath, imagePath); + return imagePath; + } + + static Future resolvePath({ + String? imagePath, + String? audioSourcePath, + }) async { + final local = await resolveLocalPath( + imagePath: imagePath, + audioSourcePath: audioSourcePath, + ); + if (local != null) return local; + if (audioSourcePath == null || audioSourcePath.isEmpty) return null; + final path = await AlbumArtService.instance.resolveArtworkPath( + existingPath: imagePath, + audioSourcePath: audioSourcePath, + ); + if (path == null || path.contains('://')) return null; + _remember(imagePath, audioSourcePath, path); + return path; + } + + static void forget({String? imagePath, String? audioSourcePath}) { + _paths.remove((imagePath, audioSourcePath)); + } + + static void _remember( + String? imagePath, + String? audioSourcePath, + String path, + ) { + final key = (imagePath, audioSourcePath); + _paths.remove(key); + _paths[key] = path; + while (_paths.length > _maxPaths) { + _paths.remove(_paths.keys.first); + } + } +} + +/// A low-resolution, pre-blurred and gradient-masked portrait card overlay. +/// +/// Flutter's bounded [ImageCache] shares both pending work and completed images +/// across cards with the same artwork and size, and owns their disposal. Unlike +/// a live ImageFilter/ShaderMask, displaying the result needs only an image draw. +@immutable +class OrbitCardBlurImage extends ImageProvider { + const OrbitCardBlurImage({required this.path, required this.cardSize}); + + final String path; + final Size cardSize; + + // The overlay is deliberately soft; the sharp artwork still uses the user's + // art-resolution preference. Keep temporary rasters small even on tablets. + static const int _maxDimension = 192; + static const double _blurSigma = 16; + + @override + Future obtainKey(ImageConfiguration configuration) => + SynchronousFuture(this); + + @override + ImageStreamCompleter loadImage( + OrbitCardBlurImage key, + ImageDecoderCallback decode, + ) => OneFrameImageStreamCompleter(key._render()); + + Future _render() async { + try { + final image = await renderBlurredCardImage( + path: path, + cardSize: cardSize, + ); + return ImageInfo(image: image); + } catch (_) { + // Do not retain a failed entry: artwork can be extracted/repaired later. + scheduleMicrotask(() => PaintingBinding.instance.imageCache.evict(this)); + rethrow; + } + } + + /// Draws the cover blurred and fade-masked into a small raster. + /// + /// Exposed separately so the raster pass can be tested without driving the + /// image cache. + @visibleForTesting + static Future renderBlurredCardImage({ + required String path, + required Size cardSize, + }) async { + if (!cardSize.isFinite || cardSize.isEmpty) { + throw ArgumentError.value( + cardSize, + 'cardSize', + 'must be finite and non-empty', + ); + } + + ui.Codec? codec; + ui.Image? source; + ui.Picture? picture; + try { + // Decode straight from the file so the source bytes never enter the Dart + // heap; the small target size keeps this cheap for full-resolution covers. + final buffer = await ui.ImmutableBuffer.fromFilePath(path); + codec = await ui.instantiateImageCodecWithSize( + buffer, + getTargetSize: (width, height) { + final scale = math.min(1.0, _maxDimension / math.max(width, height)); + return ui.TargetImageSize( + width: math.max(1, (width * scale).round()), + height: math.max(1, (height * scale).round()), + ); + }, + ); + source = (await codec.getNextFrame()).image; + + final scale = _maxDimension / math.max(cardSize.width, cardSize.height); + final width = math.max(1, (cardSize.width * scale).round()); + final height = math.max(1, (cardSize.height * scale).round()); + final bounds = Offset.zero & Size(width.toDouble(), height.toDouble()); + final sourceSize = Size( + source.width.toDouble(), + source.height.toDouble(), + ); + final fitted = applyBoxFit(BoxFit.cover, sourceSize, bounds.size); + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + // Bake the horizontal fade into the alpha channel too, so moving cards + // never need a ShaderMask saveLayer on the live rendering path. + canvas.saveLayer(bounds, Paint()); + canvas.drawImageRect( + source, + Alignment.center.inscribe(fitted.source, Offset.zero & sourceSize), + bounds, + Paint() + ..imageFilter = ui.ImageFilter.blur( + sigmaX: _blurSigma * scale, + sigmaY: _blurSigma * scale, + tileMode: TileMode.clamp, + ), + ); + canvas.drawRect( + bounds, + Paint() + ..blendMode = BlendMode.dstIn + ..shader = const LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [Color(0x00FFFFFF), Color(0x00FFFFFF), Color(0xFFFFFFFF)], + stops: [0.0, 0.26, 0.68], + ).createShader(bounds), + ); + canvas.restore(); + picture = recorder.endRecording(); + return await picture.toImage(width, height); + } finally { + picture?.dispose(); + source?.dispose(); + codec?.dispose(); + } + } + + @override + bool operator ==(Object other) => + other is OrbitCardBlurImage && + other.path == path && + other.cardSize == cardSize; + + @override + int get hashCode => Object.hash(path, cardSize); +} diff --git a/lib/services/orbit_card_blur_preloader.dart b/lib/services/orbit_card_blur_preloader.dart new file mode 100644 index 00000000..110e83da --- /dev/null +++ b/lib/services/orbit_card_blur_preloader.dart @@ -0,0 +1,77 @@ +import 'dart:math' as math; + +import 'package:flutter/painting.dart'; +import 'package:flick/models/song.dart'; + +/// Warms a small moving window, one cover at a time. A jump or direction change +/// replaces queued work rather than building a backlog for songs already passed. +class OrbitCardBlurPreloader { + OrbitCardBlurPreloader({required this.preload}); + + final Future Function(Song song, Size cardSize) preload; + static const _lookahead = 4; + + List? _songs; + int? _center; + int? _visibleItems; + Size? _cardSize; + List<(Song, Size)> _pending = []; + Future? _work; + bool _disposed = false; + + Future update({ + required List songs, + required int centerIndex, + required int visibleItems, + required Size cardSize, + }) { + if (_disposed) return Future.value(); + if (identical(songs, _songs) && + centerIndex == _center && + visibleItems == _visibleItems && + cardSize == _cardSize) { + return _work ?? Future.value(); + } + final direction = _center == null || centerIndex >= _center! ? 1 : -1; + _songs = songs; + _center = centerIndex; + _visibleItems = visibleItems; + _cardSize = cardSize; + final radius = math.max(0, visibleItems ~/ 2) + _lookahead; + _pending = [ + for (var distance = 0; distance <= radius; distance++) + for (final index in [ + centerIndex + distance * direction, + if (distance > 0) centerIndex - distance * direction, + ]) + if (index >= 0 && index < songs.length) (songs[index], cardSize), + ]; + return _work ??= Future.microtask(_drain); + } + + Future _drain() async { + try { + while (!_disposed && _pending.isNotEmpty) { + final (song, size) = _pending.removeAt(0); + try { + await preload(song, size); + } catch (_) { + // One unavailable cover must not block the rest of the window. + } + } + } finally { + _work = null; + } + } + + void clear() { + _pending.clear(); + _songs = null; + _center = null; + } + + void dispose() { + _disposed = true; + clear(); + } +} diff --git a/test/services/orbit_card_blur_image_test.dart b/test/services/orbit_card_blur_image_test.dart new file mode 100644 index 00000000..5e11c6ec --- /dev/null +++ b/test/services/orbit_card_blur_image_test.dart @@ -0,0 +1,132 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flick/features/songs/widgets/orbit_card_blur.dart'; +import 'package:flick/services/artwork_gate.dart'; +import 'package:flick/services/orbit_card_blur_image.dart'; + +Future _writeCover(String directory) async { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + canvas.drawRect( + const Rect.fromLTWH(0, 0, 64, 32), + Paint()..color = const Color(0xFF3366CC), + ); + final picture = recorder.endRecording(); + final image = await picture.toImage(64, 32); + final data = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + picture.dispose(); + final file = File('$directory/cover.png'); + await file.writeAsBytes(data!.buffer.asUint8List()); + return file.path; +} + +Future _loadImage(OrbitCardBlurImage provider) async { + final stream = provider.resolve(ImageConfiguration.empty); + final loaded = Completer(); + final listener = ImageStreamListener( + (image, _) { + image.dispose(); + loaded.complete(); + }, + onError: (Object error, StackTrace? stack) => + loaded.completeError(error, stack), + ); + stream.addListener(listener); + try { + await loaded.future.timeout(const Duration(seconds: 5)); + } finally { + stream.removeListener(listener); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Future cover(WidgetTester tester) async { + final directory = await tester.runAsync( + () => Directory.systemTemp.createTemp('flick-blur-image'), + ); + addTearDown(() => directory!.delete(recursive: true)); + return (await tester.runAsync(() => _writeCover(directory!.path)))!; + } + + testWidgets('rasterizes at the bounded size with the card aspect', ( + tester, + ) async { + final path = await cover(tester); + + final image = await tester.runAsync( + () => OrbitCardBlurImage.renderBlurredCardImage( + path: path, + cardSize: const Size(320, 130), + ), + ); + + // 192 px on the long edge preserves the 320:130 card aspect. + expect(image!.width, 192); + expect(image.height, 78); + image.dispose(); + }); + + testWidgets( + 'a cold blur loads without waiting for active animation to stop', + (tester) async { + final path = await cover(tester); + final animation = AnimationController( + vsync: tester, + duration: const Duration(seconds: 1), + )..repeat(); + try { + expect(tester.binding.transientCallbackCount, greaterThan(0)); + final provider = OrbitCardBlurImage( + path: path, + cardSize: const Size(320, 130), + ); + await tester.runAsync(() => _loadImage(provider)); + expect(animation.isAnimating, isTrue); + expect( + PaintingBinding.instance.imageCache.statusForKey(provider).keepAlive, + isTrue, + ); + } finally { + animation.dispose(); + } + }, + ); + + testWidgets( + 'a prewarmed overlay paints on its first frame during scrolling', + (tester) async { + final path = await cover(tester); + const size = Size(320, 130); + await tester.runAsync(() async { + await OrbitCardBlurArtwork.resolveLocalPath(imagePath: path); + await _loadImage(OrbitCardBlurImage(path: path, cardSize: size)); + }); + + pauseArtworkExtraction(true); + addTearDown(() => pauseArtworkExtraction(false)); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox.fromSize( + size: size, + child: OrbitCardBlur(imagePath: path), + ), + ), + ), + ); + final image = tester.widget(find.byType(RawImage)); + expect(image.image, isNotNull); + expect(find.byType(ImageFiltered), findsNothing); + expect(find.byType(ShaderMask), findsNothing); + expect(artworkExtractionPaused, isTrue); + }, + ); +} diff --git a/test/services/orbit_card_blur_preloader_test.dart b/test/services/orbit_card_blur_preloader_test.dart new file mode 100644 index 00000000..e828f598 --- /dev/null +++ b/test/services/orbit_card_blur_preloader_test.dart @@ -0,0 +1,140 @@ +import 'dart:async'; + +import 'package:flutter/painting.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flick/models/song.dart'; +import 'package:flick/services/orbit_card_blur_preloader.dart'; + +final _songs = List.generate( + 60, + (index) => Song( + id: '$index', + title: 'Song $index', + artist: 'Artist', + duration: const Duration(minutes: 4), + fileType: 'FLAC', + ), +); +const _size = Size(320, 130); + +void main() { + test('warms the focused cover and four beyond the visible range', () async { + final seen = []; + final preloader = OrbitCardBlurPreloader( + preload: (song, size) async { + expect(size, _size); + seen.add(int.parse(song.id)); + }, + ); + addTearDown(preloader.dispose); + + Future update() => preloader.update( + songs: _songs, + centerIndex: 20, + visibleItems: 5, + cardSize: _size, + ); + await update(); + expect(seen, [20, 21, 19, 22, 18, 23, 17, 24, 16, 25, 15, 26, 14]); + await update(); + expect(seen, hasLength(13), reason: 'Unchanged frames do not repeat work.'); + }); + + test( + 'a reversal replaces stale queued covers without parallel work', + () async { + final seen = []; + final started = Completer(); + final release = Completer(); + var active = 0; + final preloader = OrbitCardBlurPreloader( + preload: (song, _) async { + expect(++active, 1); + final index = int.parse(song.id); + seen.add(index); + if (index == 40) { + started.complete(); + await release.future; + } + active--; + }, + ); + addTearDown(preloader.dispose); + final work = preloader.update( + songs: _songs, + centerIndex: 40, + visibleItems: 5, + cardSize: _size, + ); + await started.future; + final latest = preloader.update( + songs: _songs, + centerIndex: 20, + visibleItems: 5, + cardSize: _size, + ); + release.complete(); + await Future.wait([work, latest]); + expect(seen.take(4), [40, 20, 19, 21]); + expect(seen, hasLength(14)); + expect(seen.skip(1), everyElement(inInclusiveRange(14, 26))); + }, + ); + + test('disposal stops queued work and ignores later updates', () async { + final started = Completer(); + final release = Completer(); + final seen = []; + final preloader = OrbitCardBlurPreloader( + preload: (song, _) async { + seen.add(song.id); + started.complete(); + await release.future; + }, + ); + final work = preloader.update( + songs: _songs, + centerIndex: 20, + visibleItems: 5, + cardSize: _size, + ); + await started.future; + preloader.dispose(); + release.complete(); + await work; + await preloader.update( + songs: _songs, + centerIndex: 30, + visibleItems: 5, + cardSize: _size, + ); + expect(seen, ['20']); + }); + + test( + 'empty lists, boundaries and a failed cover do not wedge loading', + () async { + final seen = []; + final preloader = OrbitCardBlurPreloader( + preload: (song, _) async { + seen.add(song.id); + if (song.id == '0') throw StateError('Unavailable cover'); + }, + ); + addTearDown(preloader.dispose); + await preloader.update( + songs: [], + centerIndex: 0, + visibleItems: 5, + cardSize: _size, + ); + await preloader.update( + songs: _songs, + centerIndex: 0, + visibleItems: 5, + cardSize: _size, + ); + expect(seen, ['0', '1', '2', '3', '4', '5', '6']); + }, + ); +} From 503224dd03afccd263ea636a150cb462f74bbceb Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Tue, 10 Nov 2026 16:02:24 +0800 Subject: [PATCH 11/12] Add orbit card background style widget and model Introduce an OrbitCardBlur widget that renders the cached blur overlay without a live filter or gradient mask, reusing prewarmed textures on the first frame and deferring only missing artwork behind the extraction gate. Add the OrbitCardBackgroundStyle enum with storage serialization for the blur, black, and album color gradient options. --- .../songs/widgets/orbit_card_blur.dart | 113 ++++++++++++++++++ lib/models/orbit_card_background_style.dart | 32 +++++ 2 files changed, 145 insertions(+) create mode 100644 lib/features/songs/widgets/orbit_card_blur.dart create mode 100644 lib/models/orbit_card_background_style.dart diff --git a/lib/features/songs/widgets/orbit_card_blur.dart b/lib/features/songs/widgets/orbit_card_blur.dart new file mode 100644 index 00000000..b3febc1a --- /dev/null +++ b/lib/features/songs/widgets/orbit_card_blur.dart @@ -0,0 +1,113 @@ +import 'package:flutter/widgets.dart'; +import 'package:flick/services/artwork_gate.dart'; +import 'package:flick/services/orbit_card_blur_image.dart'; + +/// Displays a cached blur overlay, without a live filter or gradient mask. +class OrbitCardBlur extends StatefulWidget { + const OrbitCardBlur({super.key, this.imagePath, this.audioSourcePath}); + + final String? imagePath; + final String? audioSourcePath; + + @override + State createState() => _OrbitCardBlurState(); +} + +class _OrbitCardBlurState extends State { + String? _resolvedPath; + int _request = 0; + bool _deferred = false; + + @override + void initState() { + super.initState(); + _loadArtwork(); + } + + @override + void didUpdateWidget(covariant OrbitCardBlur oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.imagePath != widget.imagePath || + oldWidget.audioSourcePath != widget.audioSourcePath) { + _request++; + _loadArtwork(); + } + } + + void _loadArtwork() { + // A prewarmed card can use its cached texture on its very first frame, + // including while the extraction gate is held during a fling. + _resolvedPath = OrbitCardBlurArtwork.cachedPath( + imagePath: widget.imagePath, + audioSourcePath: widget.audioSourcePath, + ); + if (_resolvedPath == null) _resolveArtwork(); + } + + Future _resolveArtwork() async { + final request = ++_request; + final path = widget.imagePath; + final audioSourcePath = widget.audioSourcePath; + try { + var resolved = await OrbitCardBlurArtwork.resolveLocalPath( + imagePath: path, + audioSourcePath: audioSourcePath, + ); + if (!mounted || request != _request) return; + // Existing covers never wait for scrolling to stop. Only genuinely + // missing artwork still uses the shared, expensive-extraction gate. + if (resolved == null) { + if (audioSourcePath == null || audioSourcePath.isEmpty) return; + if (artworkExtractionPaused) { + if (!_deferred) { + _deferred = true; + enqueueArtworkResolver(() { + _deferred = false; + if (mounted) _resolveArtwork(); + }); + } + return; + } + resolved = await OrbitCardBlurArtwork.resolvePath( + imagePath: path, + audioSourcePath: audioSourcePath, + ); + } + if (!mounted || request != _request || resolved == _resolvedPath) return; + setState(() => _resolvedPath = resolved); + } catch (_) { + // The sharp artwork and readability scrim remain usable on any failure. + } + } + + @override + Widget build(BuildContext context) { + final path = _resolvedPath; + // Baking supports local files only. Network covers normally resolve to a + // disk-cached file; if a remote URL reaches here, keep the sharp artwork + // and scrim rather than attempting a file decode every frame. + if (path == null || path.startsWith('http')) { + return const SizedBox.expand(); + } + return LayoutBuilder( + builder: (context, constraints) { + final cardSize = constraints.biggest; + if (!cardSize.isFinite || cardSize.isEmpty) { + return const SizedBox.expand(); + } + return Image( + image: OrbitCardBlurImage(path: path, cardSize: cardSize), + fit: BoxFit.fill, + excludeFromSemantics: true, + errorBuilder: (context, error, stackTrace) { + OrbitCardBlurArtwork.forget( + imagePath: widget.imagePath, + audioSourcePath: widget.audioSourcePath, + ); + return const SizedBox.expand(); + }, + ); + }, + ); + } +} diff --git a/lib/models/orbit_card_background_style.dart b/lib/models/orbit_card_background_style.dart new file mode 100644 index 00000000..e4cf0b05 --- /dev/null +++ b/lib/models/orbit_card_background_style.dart @@ -0,0 +1,32 @@ +/// Background treatment for portrait orbit song cards. +enum OrbitCardBackgroundStyle { + blurGradient, + blackGradient, + albumColorGradient, +} + +extension OrbitCardBackgroundStyleX on OrbitCardBackgroundStyle { + String get storageValue { + switch (this) { + case OrbitCardBackgroundStyle.blurGradient: + return 'blur_gradient'; + case OrbitCardBackgroundStyle.blackGradient: + return 'black_gradient'; + case OrbitCardBackgroundStyle.albumColorGradient: + return 'album_color_gradient'; + } + } + + static OrbitCardBackgroundStyle fromStorageValue(String? value) { + switch (value) { + case 'black_gradient': + return OrbitCardBackgroundStyle.blackGradient; + case 'album_color_gradient': + return OrbitCardBackgroundStyle.albumColorGradient; + case 'blur_gradient': + return OrbitCardBackgroundStyle.blurGradient; + default: + return OrbitCardBackgroundStyle.blackGradient; + } + } +} From be5786d795f9289dbca69749d42e84e5931dc2d6 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Tue, 10 Nov 2026 16:02:29 +0800 Subject: [PATCH 12/12] Add localized strings for card background options Add labels and hints for the card background style selector in English, Spanish, Filipino, and Chinese. --- lib/l10n/app_en.arb | 6 ++++ lib/l10n/app_es.arb | 6 ++++ lib/l10n/app_tl.arb | 6 ++++ lib/l10n/app_zh.arb | 6 ++++ lib/l10n/generated/app_localizations.dart | 36 ++++++++++++++++++++ lib/l10n/generated/app_localizations_en.dart | 20 +++++++++++ lib/l10n/generated/app_localizations_es.dart | 21 ++++++++++++ lib/l10n/generated/app_localizations_tl.dart | 21 ++++++++++++ lib/l10n/generated/app_localizations_zh.dart | 18 ++++++++++ 9 files changed, 140 insertions(+) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 0f1aaa97..39891c11 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1154,6 +1154,12 @@ "description": "lib/features/settings/screens/widget_settings_screen.dart:155" }, "blurGradient": "Blur gradient", + "blurGradientHint": "Blurred artwork fades toward the song details.", + "blackGradient": "Black gradient", + "blackGradientHint": "A deep black scrim keeps the classic look.", + "albumArtColorGradient": "Album art color", + "albumArtColorGradientHint": "Tint the card with colors sampled from the cover.", + "cardBackground": "Card background", "colorGradient": "Color gradient", "backgroundOpacity": "Background Opacity", "@backgroundOpacity": { diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 2cf65924..44a16c37 100755 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1154,6 +1154,12 @@ "description": "lib/features/settings/screens/widget_settings_screen.dart:155" }, "blurGradient": "Degradado difuminado", + "blurGradientHint": "La portada difuminada se funde hacia los detalles de la canción.", + "blackGradient": "Degradado negro", + "blackGradientHint": "Un velo negro profundo mantiene el aspecto clásico.", + "albumArtColorGradient": "Color de la portada", + "albumArtColorGradientHint": "Tiñe la tarjeta con colores tomados de la portada.", + "cardBackground": "Fondo de la tarjeta", "colorGradient": "Degradado de color", "backgroundOpacity": "Opacidad del fondo", "@backgroundOpacity": { diff --git a/lib/l10n/app_tl.arb b/lib/l10n/app_tl.arb index 029e4558..9f37e966 100644 --- a/lib/l10n/app_tl.arb +++ b/lib/l10n/app_tl.arb @@ -269,6 +269,12 @@ "backend": "Backend", "background": "Likuran", "blurGradient": "Gradient ng blur", + "blurGradientHint": "Ang blurred na artwork ay dahan-dahang lumalapit sa detalye ng kanta.", + "blackGradient": "Black gradient", + "blackGradientHint": "Malalim na black scrim para sa classic na itsura.", + "albumArtColorGradient": "Kulay ng album art", + "albumArtColorGradientHint": "Kulayan ang card gamit ang mga kulay mula sa cover.", + "cardBackground": "Background ng card", "colorGradient": "Gradient ng kulay", "backgroundOpacity": "Pagkalabo ng likuran", "backgroundPlaybackAnchor": "Pangpanatili ng pagtugtog sa background", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 8029bc7a..a1c165d5 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -278,6 +278,12 @@ "backend": "后端", "background": "背景", "blurGradient": "模糊渐变", + "blurGradientHint": "模糊的封面渐渐过渡到歌曲信息。", + "blackGradient": "黑色渐变", + "blackGradientHint": "深黑色遮罩,保留经典外观。", + "albumArtColorGradient": "封面颜色", + "albumArtColorGradientHint": "用封面提取的颜色为卡片着色。", + "cardBackground": "卡片背景", "colorGradient": "色彩渐变", "backgroundOpacity": "背景不透明度", "backgroundPlaybackAnchor": "后台播放锚点", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 958a5217..0202e95b 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -1777,6 +1777,42 @@ abstract class AppLocalizations { /// **'Blur gradient'** String get blurGradient; + /// No description provided for @blurGradientHint. + /// + /// In en, this message translates to: + /// **'Blurred artwork fades toward the song details.'** + String get blurGradientHint; + + /// No description provided for @blackGradient. + /// + /// In en, this message translates to: + /// **'Black gradient'** + String get blackGradient; + + /// No description provided for @blackGradientHint. + /// + /// In en, this message translates to: + /// **'A deep black scrim keeps the classic look.'** + String get blackGradientHint; + + /// No description provided for @albumArtColorGradient. + /// + /// In en, this message translates to: + /// **'Album art color'** + String get albumArtColorGradient; + + /// No description provided for @albumArtColorGradientHint. + /// + /// In en, this message translates to: + /// **'Tint the card with colors sampled from the cover.'** + String get albumArtColorGradientHint; + + /// No description provided for @cardBackground. + /// + /// In en, this message translates to: + /// **'Card background'** + String get cardBackground; + /// No description provided for @colorGradient. /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index a109bb0e..0ae0cb6f 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -1001,6 +1001,26 @@ class AppLocalizationsEn extends AppLocalizations { @override String get blurGradient => 'Blur gradient'; + @override + String get blurGradientHint => + 'Blurred artwork fades toward the song details.'; + + @override + String get blackGradient => 'Black gradient'; + + @override + String get blackGradientHint => 'A deep black scrim keeps the classic look.'; + + @override + String get albumArtColorGradient => 'Album art color'; + + @override + String get albumArtColorGradientHint => + 'Tint the card with colors sampled from the cover.'; + + @override + String get cardBackground => 'Card background'; + @override String get colorGradient => 'Color gradient'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index cbd14fb1..4cf13dbe 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -1014,6 +1014,27 @@ class AppLocalizationsEs extends AppLocalizations { @override String get blurGradient => 'Degradado difuminado'; + @override + String get blurGradientHint => + 'La portada difuminada se funde hacia los detalles de la canción.'; + + @override + String get blackGradient => 'Degradado negro'; + + @override + String get blackGradientHint => + 'Un velo negro profundo mantiene el aspecto clásico.'; + + @override + String get albumArtColorGradient => 'Color de la portada'; + + @override + String get albumArtColorGradientHint => + 'Tiñe la tarjeta con colores tomados de la portada.'; + + @override + String get cardBackground => 'Fondo de la tarjeta'; + @override String get colorGradient => 'Degradado de color'; diff --git a/lib/l10n/generated/app_localizations_tl.dart b/lib/l10n/generated/app_localizations_tl.dart index ee0bb698..199f4d4d 100644 --- a/lib/l10n/generated/app_localizations_tl.dart +++ b/lib/l10n/generated/app_localizations_tl.dart @@ -1014,6 +1014,27 @@ class AppLocalizationsTl extends AppLocalizations { @override String get blurGradient => 'Gradient ng blur'; + @override + String get blurGradientHint => + 'Ang blurred na artwork ay dahan-dahang lumalapit sa detalye ng kanta.'; + + @override + String get blackGradient => 'Black gradient'; + + @override + String get blackGradientHint => + 'Malalim na black scrim para sa classic na itsura.'; + + @override + String get albumArtColorGradient => 'Kulay ng album art'; + + @override + String get albumArtColorGradientHint => + 'Kulayan ang card gamit ang mga kulay mula sa cover.'; + + @override + String get cardBackground => 'Background ng card'; + @override String get colorGradient => 'Gradient ng kulay'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 9cae7a26..d7ef785f 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -927,6 +927,24 @@ class AppLocalizationsZh extends AppLocalizations { @override String get blurGradient => '模糊渐变'; + @override + String get blurGradientHint => '模糊的封面渐渐过渡到歌曲信息。'; + + @override + String get blackGradient => '黑色渐变'; + + @override + String get blackGradientHint => '深黑色遮罩,保留经典外观。'; + + @override + String get albumArtColorGradient => '封面颜色'; + + @override + String get albumArtColorGradientHint => '用封面提取的颜色为卡片着色。'; + + @override + String get cardBackground => '卡片背景'; + @override String get colorGradient => '色彩渐变';