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/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 2bcf40e4..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'; @@ -17,9 +18,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 +355,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 +383,7 @@ class _PreviewOrbit extends StatelessWidget { required this.songs, required this.selectedIndex, required this.onSelectedIndexChanged, + required this.paintOverflowTop, }); final bool landscape; @@ -391,10 +391,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, @@ -422,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, @@ -469,19 +474,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 +727,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; +} diff --git a/lib/features/songs/screens/songs_screen.dart b/lib/features/songs/screens/songs_screen.dart index 72694abb..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'; @@ -168,215 +169,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( @@ -510,10 +503,14 @@ 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. pathOverflowBottom: widget.pathOverflowBottom, + paintOverflowTop: MediaQuery.sizeOf(context).width * 2, horizontalPaintOverflow: sideOverflow, onSelectedIndexChanged: (index) { if (!mounted) return; @@ -3479,3 +3476,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_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/features/songs/widgets/orbit_scroll.dart b/lib/features/songs/widgets/orbit_scroll.dart index 77e1c9c3..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,11 +77,17 @@ 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. 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. @@ -109,7 +118,9 @@ 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, }); @@ -139,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 = {}; @@ -213,6 +226,7 @@ class _OrbitScrollState extends State @override void dispose() { _animationGeneration++; + _blurPreloader.dispose(); widget.controller?._detach(); _controller.dispose(); _scrollOffset.dispose(); @@ -283,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(); } }); } @@ -514,13 +531,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: [ @@ -577,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; @@ -608,26 +681,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), ); } @@ -681,6 +757,8 @@ class _OrbitScrollState extends State }) { final List items = []; + _scheduleBlurPreload(); + final horizontal = widget.scrollDirection == Axis.horizontal; final centerIndex = _scrollOffset.value.round(); @@ -766,6 +844,7 @@ class _OrbitScrollState extends State width: cardWidth, height: cardHeight, artResolutionMultiplier: widget.artResolutionMultiplier, + backgroundStyle: widget.cardBackgroundStyle, onTap: () { AppHaptics.tap(); _animateTo(actualIndex.toDouble()); @@ -822,20 +901,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 { 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], + ), + ), + ), ], ); } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2445409f..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": { @@ -7203,13 +7209,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..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": { @@ -7203,13 +7209,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..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", @@ -1427,8 +1433,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..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": "后台播放锚点", @@ -1580,8 +1586,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..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: @@ -9534,16 +9570,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..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'; @@ -5568,10 +5588,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..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'; @@ -5638,11 +5659,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..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'; @@ -5627,11 +5648,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..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 => '色彩渐变'; @@ -5206,10 +5224,10 @@ class AppLocalizationsZh extends AppLocalizations { @override String get orbitHeaderBoundaryBody => - '轨道卡片和轨道路径始终位于媒体库标题与搜索栏下方。向上移动轨道时,会在此边界处被裁切。'; + '轨道卡片和轨道路径可以穿过媒体库标题与搜索栏后方。它们只保留在“歌曲”屏幕内,并在屏幕边缘被裁切。'; @override - String get orbitHeaderBoundaryTitle => '轨道止于媒体库顶栏'; + String get orbitHeaderBoundaryTitle => '轨道从媒体库顶栏后方穿过'; @override String get orbitLandscapeHint => '当应用窗口宽大于高时使用。'; 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; + } + } +} 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 { 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/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/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 { 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); }, ); 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']); + }, + ); +}