diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d90da09e9..abcad9126 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -44,7 +44,6 @@ android:launchMode="singleTop" android:taskAffinity="" android:theme="@style/LaunchTheme" - android:screenOrientation="portrait" android:resizeableActivity="true" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" diff --git a/android/app/src/main/kotlin/com/mossapps/flick/MainActivity.kt b/android/app/src/main/kotlin/com/mossapps/flick/MainActivity.kt index dab2cdcb7..a8b697208 100644 --- a/android/app/src/main/kotlin/com/mossapps/flick/MainActivity.kt +++ b/android/app/src/main/kotlin/com/mossapps/flick/MainActivity.kt @@ -36,7 +36,6 @@ import android.content.ContentValues import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.content.pm.ActivityInfo import android.content.pm.PackageManager import android.hardware.usb.UsbConstants import android.hardware.usb.UsbDevice @@ -87,6 +86,7 @@ import java.io.ByteArrayOutputStream import java.io.File import java.io.FileInputStream import java.io.InputStream +import java.io.OutputStream import java.nio.ByteBuffer import java.nio.ByteOrder import java.security.MessageDigest @@ -185,7 +185,6 @@ class MainActivity: FlutterActivity() { } override fun onCreate(savedInstanceState: Bundle?) { - requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT super.onCreate(savedInstanceState) if (!nativeInitRustAndroidContext(applicationContext)) { @@ -605,6 +604,34 @@ class MainActivity: FlutterActivity() { result.error("INVALID_ARGUMENT", "folderTreeUri, filePath, and tempFilePath are required", null) } } + "stageContentUriForEdit" -> { + val uri = call.argument("uri") + val extensionHint = call.argument("extensionHint") + if (uri != null) { + mainScope.launch { + val staged = withContext(Dispatchers.IO) { + stageContentUriForEdit(uri, extensionHint) + } + result.success(staged) + } + } else { + result.error("INVALID_ARGUMENT", "uri is required", null) + } + } + "writeBytesToContentUri" -> { + val uri = call.argument("uri") + val tempFilePath = call.argument("tempFilePath") + if (uri != null && tempFilePath != null) { + mainScope.launch { + val written = withContext(Dispatchers.IO) { + writeBytesToContentUri(uri, tempFilePath) + } + result.success(written) + } + } else { + result.error("INVALID_ARGUMENT", "uri and tempFilePath are required", null) + } + } "queryMediaStoreAudio" -> { val folderPaths = call.argument>("folderPaths") val volumeName = call.argument("volumeName") @@ -1880,6 +1907,124 @@ class MainActivity: FlutterActivity() { } } + /** + * Copies a content:// document into a private, uniquely named cache file so + * the Rust tagger can open it by path. The shared playback staging cache is + * deliberately not reused: editing mutates this copy, and a cached playback + * file must never be modified in place. + */ + private fun stageContentUriForEdit(uriString: String, extensionHint: String?): Map { + val uri = Uri.parse(uriString) + val stagingDir = File(cacheDir, "metadata_edits") + val stagedFile = try { + stagingDir.mkdirs() + pruneMetadataEditStaging(stagingDir) + val extension = resolveStagedExtension(uri, extensionHint) + File(stagingDir, "${md5(uriString)}-${UUID.randomUUID()}.$extension") + } catch (e: Exception) { + Log.w("MainActivity", "stageContentUriForEdit: could not prepare staging: ${e.message}", e) + return mapOf("path" to null, "reason" to "failed") + } + + return try { + val input = contentResolver.openInputStream(uri) + if (input == null) { + mapOf("path" to null, "reason" to "not_found") + } else { + input.use { source -> + stagedFile.outputStream().use { target -> + copyStream(source, target) + } + } + if (stagedFile.length() <= 0L) { + stagedFile.delete() + mapOf("path" to null, "reason" to "failed") + } else { + mapOf("path" to stagedFile.absolutePath, "reason" to null) + } + } + } catch (e: java.io.FileNotFoundException) { + stagedFile.delete() + Log.w("MainActivity", "stageContentUriForEdit: not found: $uriString (${e.message})") + mapOf("path" to null, "reason" to "not_found") + } catch (e: SecurityException) { + stagedFile.delete() + Log.w("MainActivity", "stageContentUriForEdit: no access: $uriString (${e.message})") + mapOf("path" to null, "reason" to "no_access") + } catch (e: Exception) { + stagedFile.delete() + Log.w("MainActivity", "stageContentUriForEdit failed: ${e.message}", e) + mapOf("path" to null, "reason" to "failed") + } + } + + /** Streams an edited cache copy back over the original document URI. */ + private fun writeBytesToContentUri(uriString: String, tempFilePath: String): Map { + val tempFile = File(tempFilePath) + if (!tempFile.exists()) { + return mapOf("success" to false, "reason" to "failed") + } + val uri = Uri.parse(uriString) + + return try { + val output = openContentUriOutputStream(uri) + if (output == null) { + mapOf("success" to false, "reason" to "failed") + } else { + output.use { target -> + tempFile.inputStream().use { source -> + copyStream(source, target) + } + } + mapOf("success" to true, "reason" to "ok") + } + } catch (e: java.io.FileNotFoundException) { + Log.w("MainActivity", "writeBytesToContentUri: not found: $uriString (${e.message})") + mapOf("success" to false, "reason" to "not_found") + } catch (e: SecurityException) { + Log.w("MainActivity", "writeBytesToContentUri: no access: $uriString (${e.message})") + mapOf("success" to false, "reason" to "no_access") + } catch (e: Exception) { + Log.w("MainActivity", "writeBytesToContentUri failed: ${e.message}", e) + mapOf("success" to false, "reason" to "failed") + } + } + + /** + * "wt" truncates before writing, but a few document providers only accept + * "w". A genuinely missing document fails again on the retry and is + * reported as not_found by the caller. + */ + private fun openContentUriOutputStream(uri: Uri): OutputStream? { + return try { + contentResolver.openOutputStream(uri, "wt") + } catch (e: IllegalArgumentException) { + contentResolver.openOutputStream(uri, "w") + } catch (e: java.io.FileNotFoundException) { + contentResolver.openOutputStream(uri, "w") + } + } + + private fun copyStream(source: InputStream, target: OutputStream) { + val buffer = ByteArray(1024 * 1024) + while (true) { + val read = source.read(buffer) + if (read < 0) break + target.write(buffer, 0, read) + } + target.flush() + } + + /** Clear edit copies left behind by crashes; 24h is far beyond one save. */ + private fun pruneMetadataEditStaging(stagingDir: File) { + val cutoff = System.currentTimeMillis() - 24 * 60 * 60 * 1000L + stagingDir.listFiles()?.forEach { file -> + if (file.isFile && file.lastModified() < cutoff) { + file.delete() + } + } + } + private fun isIgnoringBatteryOptimizations(): Boolean { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { return true diff --git a/docs/BAYBAYIN_REVIEW.md b/docs/BAYBAYIN_REVIEW.md index 080c0035c..de44b1667 100644 --- a/docs/BAYBAYIN_REVIEW.md +++ b/docs/BAYBAYIN_REVIEW.md @@ -63,6 +63,9 @@ subsequently turning them off survives a restart. - The standalone Player Layout settings screen is English in Taglish too, including both layout names and descriptions, layout controls, quick-action pickers and album-color options. English and Chinese keep their own copy. +- The UI Customization settings screen is English in Taglish for Track + Thumbnails, Progress Bar and Album Colors, including option names and + descriptions. English and Chinese keep their own copy. - The album, artist, playlist and smart-mix three-dot More sheets use English, including their **More** header. - Album completeness uses English in Taglish: header and grid badges, missing @@ -289,7 +292,9 @@ Player Layout has two entry points, and both use the English role in Taglish: `model_labels.dart` gained caller-catalog `descriptionFor` methods for player screen modes and album-color modes, so plain extension getters elsewhere still follow the active locale. The action picker uses the same English catalog as -the sheet. +the sheet. The same pattern covers `ProgressBarStyle` and +`SongTileThumbnailMode`; the UI Customization screen passes its English role +catalog so those option descriptions do not fall back to Taglish. `sort_filter_bottom_sheet.dart` also uses the English shim. Scoped copy roles in `songs_screen.dart` keep the album menu, album-options sheet and multiselect @@ -353,6 +358,8 @@ Before removing the experimental/draft notices, the Filipino reviewer should: the fully English full-player three-dot sheet, inline/online lyrics and its playlist/share/metadata/speed/pitch/sleep/layout sub-interfaces, the English Player Layout settings screen and action picker, + the English UI Customization Track Thumbnails, Progress Bar and Album + Colors sections, the fully English Lyrics Sync Studio, the English Milestones surface and playlist cover editor, the English playlist detail header information line/lossless indicator, all-English diff --git a/lib/app/app.dart b/lib/app/app.dart index 49466d9f7..74866a1a7 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:flick/widgets/common/flick_snackbar.dart'; import 'package:flick/widgets/common/flick_dialog.dart'; @@ -23,6 +24,7 @@ import 'package:flick/features/favorites/screens/favorites_screen.dart'; import 'package:flick/features/search/screens/search_screen.dart'; import 'package:flick/core/navigation/nav_bar_visibility_observer.dart'; import 'package:flick/core/navigation/root_navigator.dart'; +import 'package:flick/core/utils/adaptive_layout.dart'; import 'package:flick/core/utils/navigation_helper.dart'; import 'package:flick/core/utils/app_haptics.dart'; import 'package:flick/core/utils/app_log.dart'; @@ -35,6 +37,7 @@ import 'package:flick/features/onboarding/screens/onboarding_screen.dart'; import 'package:flick/features/onboarding/tutorial_targets.dart'; import 'package:flick/features/onboarding/widgets/tutorial_overlay.dart'; import 'package:flick/widgets/common/embedded_mini_player.dart'; +import 'package:flick/widgets/common/landscape_side_mini_player.dart'; import 'package:flick/widgets/navigation/bottom_bar_geometry.dart'; import 'package:flick/providers/mini_player_config_provider.dart'; import 'package:flick/widgets/common/floating_mini_player.dart'; @@ -50,6 +53,7 @@ import 'package:flick/services/milestone_service.dart'; import 'package:flick/services/widget_sync_service.dart'; import 'package:flick/services/widget_intent_handler.dart'; import 'package:flick/models/nav_bar_config.dart'; +import 'package:flick/models/song_view_mode.dart'; import 'package:flick/features/milestone/widgets/milestone_card.dart'; import 'package:flick/features/milestone/widgets/streak_popup.dart'; import 'package:flick/features/whats_new/widgets/whats_new_bottom_sheet.dart'; @@ -354,10 +358,7 @@ class _MainShellState extends ConsumerState _onPlaybackDesyncChanged, ); - ref.listenManual>( - libraryWarmupProvider, - _onWarmupChanged, - ); + ref.listenManual>(libraryWarmupProvider, _onWarmupChanged); ref.listenManual(appPreferencesProvider, (previous, next) { if (!mounted) return; @@ -413,9 +414,9 @@ class _MainShellState extends ConsumerState supportLabel: l10n.englishForFilipino.supportFlick, dismissLabel: l10n.englishForFilipino.dismiss, onSupportTap: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const SupportFlickScreen()), - ); + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const SupportFlickScreen())); }, ), ); @@ -516,9 +517,7 @@ class _MainShellState extends ConsumerState messenger.showFlickSnackBar( SnackBar( - content: Text( - l10n.englishForFilipino.finishingMetadataForSongs(count), - ), + content: Text(l10n.englishForFilipino.finishingMetadataForSongs(count)), duration: const Duration(days: 1), action: SnackBarAction( label: l10n.englishForFilipino.speedUp, @@ -667,7 +666,9 @@ class _MainShellState extends ConsumerState } void _onNavBarVisibilityChanged(bool isVisible) { - final separated = ref.read(appPreferencesProvider).separateMiniPlayerFromNavBar; + final separated = ref + .read(appPreferencesProvider) + .separateMiniPlayerFromNavBar; if (separated) { if (_navBarAnimationController.isAnimating || _navBarAnimationController.value != 0) { @@ -730,7 +731,8 @@ class _MainShellState extends ConsumerState return false; } - if (notification is UserScrollNotification) { + if (notification is UserScrollNotification && + notification.metrics.axis == Axis.vertical) { final direction = notification.direction; final currentVisibility = ref.read(navBarVisibleProvider); @@ -779,8 +781,41 @@ class _MainShellState extends ConsumerState ), ); final extraClearance = BottomBarGeometry.extraClearance( - context, miniConfig, separated, + context, + miniConfig, + separated, + ); + final screenSize = MediaQuery.sizeOf(context); + final useSideNavigation = AdaptiveLayout.useSideNavigation(screenSize); + final railWidth = useSideNavigation ? FlickNavRail.width(context) : 0.0; + final sideMiniClearance = + BottomBarGeometry.miniPlayerHeight(context, miniConfig) + 20; + final contentSize = Size( + math.max(0.0, screenSize.width - railWidth), + screenSize.height, ); + final currentIndex = ref.watch(navigationIndexProvider); + final songsViewMode = ref.watch(songsViewModeProvider); + final sideMiniPlayerCandidate = + useSideNavigation && + AdaptiveLayout.useCompactLandscapeMiniPlayer(contentSize) && + currentIndex == NavBarButton.songs.pageIndex && + songsViewMode == SongViewMode.orbit; + // Only listen to the auto-disposed library provider while the orbit is on + // screen; the shell must not keep the whole song list alive at startup. + final songsSortOption = sideMiniPlayerCandidate + ? ref.watch(songsProvider.select((state) => state.value?.sortOption)) + : null; + final useOrbitSideMiniPlayer = + sideMiniPlayerCandidate && songsSortOption != SongSortOption.album; + final nestedBottomInset = useOrbitSideMiniPlayer + ? 0.0 + : useSideNavigation + ? sideMiniClearance + : extraClearance; + final bottomClearance = useSideNavigation + ? (useOrbitSideMiniPlayer ? 0.0 : sideMiniClearance) + : _kNestedBarClearance + extraClearance; return PopScope( canPop: false, @@ -793,196 +828,287 @@ class _MainShellState extends ConsumerState child: SnackBarTheme( data: Theme.of(context).snackBarTheme.copyWith( insetPadding: EdgeInsets.fromLTRB( - 16, 8, 16, _kNestedBarClearance + extraClearance + 12, + (useSideNavigation ? railWidth : 0.0) + 16, + 8, + 16, + bottomClearance + 12, ), ), child: Scaffold( - backgroundColor: AppColors.background, - extendBody: true, - body: NotificationListener( - onNotification: _handleScrollNotification, - child: Listener( - behavior: HitTestBehavior.translucent, - onPointerDown: (_) => _onUserInteraction(), - onPointerMove: (_) => _onUserInteraction(), - child: Stack( - children: [ - // Base Gradient - Container( - decoration: const BoxDecoration( - gradient: AppColors.backgroundGradient, - ), - ), + backgroundColor: AppColors.background, + extendBody: true, + body: NotificationListener( + onNotification: _handleScrollNotification, + child: Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (_) => _onUserInteraction(), + onPointerMove: (_) => _onUserInteraction(), + child: Stack( + children: [ + // Base Gradient + Container( + decoration: const BoxDecoration( + gradient: AppColors.backgroundGradient, + ), + ), - // Persistent Background - always on for all bottom-bar screens - Positioned.fill( - child: Consumer( - builder: (context, ref, _) { - final currentSong = ref.watch(currentSongProvider); - return AmbientBackground(song: currentSong); - }, - ), - ), + // Persistent Background - always on for all bottom-bar screens + Positioned.fill( + child: Consumer( + builder: (context, ref, _) { + final currentSong = ref.watch(currentSongProvider); + return AmbientBackground(song: currentSong); + }, + ), + ), - // Nested navigator: tabs + detail screens below the bar. - Positioned.fill( - child: Builder( - builder: (nestedContext) { - final mq = MediaQuery.of(nestedContext); - final inflated = mq.copyWith( - padding: mq.padding.copyWith( - bottom: mq.padding.bottom + _kNestedBarClearance + extraClearance, - ), - viewPadding: mq.viewPadding.copyWith( - bottom: - mq.viewPadding.bottom + _kNestedBarClearance + extraClearance, - ), - ); - return MediaQuery( - data: inflated, - child: Navigator( - key: nestedNavigatorKey, - initialRoute: '/', - observers: [_navBarVisibilityObserver], - onGenerateRoute: (settings) { - if (settings.name == '/') { - return PageRouteBuilder( - settings: settings, - pageBuilder: (routeContext, _, __) => - MediaQuery.removePadding( - context: routeContext, - removeBottom: true, - child: Consumer( - builder: (context, ref, _) { - final currentIdx = - ref.watch(navigationIndexProvider); - final cfg = - ref.watch(navBarConfigProvider); - final order = _getPageOrder(cfg); - return Padding( - padding: EdgeInsets.only(bottom: extraClearance), - child: PageView( - controller: _pageController, - physics: - const ClampingScrollPhysics(), - onPageChanged: (position) { - final currentConfig = ref.read( - navBarConfigProvider); - final currentOrder = - _getPageOrder(currentConfig); - if (position < 0 || - position >= - currentOrder.length) { - return; - } - if (_programmaticPageTarget == null && - position != _lastHapticPage) { - AppHaptics.selection(); - } - _lastHapticPage = position; - final enabledCount = currentConfig - .orderedButtons.length; - if (position >= enabledCount) { - if (_programmaticPageTarget == - position) { - _programmaticPageTarget = null; - final pageIndex = - currentOrder[position] - .pageIndex; - if (ref.read( - navigationIndexProvider) != - pageIndex) { - ref - .read( + // Nested navigator: tabs + detail screens below the bar. + // In rail mode the navigator is physically inset, so fixed + // elements inside screens (detail headers, FABs) cannot sit + // under the rail. The MediaQuery width is reduced too, so + // width-based grids measure the real content pane. + Positioned.fill( + left: useSideNavigation ? railWidth : 0.0, + child: Builder( + builder: (nestedContext) { + final mq = MediaQuery.of(nestedContext); + final inflated = mq.copyWith( + size: useSideNavigation + ? Size( + math.max(0.0, mq.size.width - railWidth), + mq.size.height, + ) + : mq.size, + padding: mq.padding.copyWith( + left: useSideNavigation ? 0.0 : mq.padding.left, + bottom: mq.padding.bottom + bottomClearance, + ), + viewPadding: mq.viewPadding.copyWith( + left: useSideNavigation + ? 0.0 + : mq.viewPadding.left, + bottom: mq.viewPadding.bottom + bottomClearance, + ), + ); + return MediaQuery( + data: inflated, + child: Navigator( + key: nestedNavigatorKey, + initialRoute: '/', + observers: [_navBarVisibilityObserver], + onGenerateRoute: (settings) { + if (settings.name == '/') { + return PageRouteBuilder( + settings: settings, + pageBuilder: (routeContext, _, __) => MediaQuery.removePadding( + context: routeContext, + removeBottom: true, + child: Consumer( + builder: (context, ref, _) { + final currentIdx = ref.watch( + navigationIndexProvider, + ); + final cfg = ref.watch( + navBarConfigProvider, + ); + final order = _getPageOrder(cfg); + return Padding( + padding: EdgeInsets.only( + bottom: nestedBottomInset, + ), + child: PageView( + controller: _pageController, + physics: + const ClampingScrollPhysics(), + onPageChanged: (position) { + final currentConfig = ref.read( + navBarConfigProvider, + ); + final currentOrder = + _getPageOrder( + currentConfig, + ); + if (position < 0 || + position >= + currentOrder.length) { + return; + } + if (_programmaticPageTarget == + null && + position != + _lastHapticPage) { + AppHaptics.selection(); + } + _lastHapticPage = position; + final enabledCount = + currentConfig + .orderedButtons + .length; + if (position >= enabledCount) { + if (_programmaticPageTarget == + position) { + _programmaticPageTarget = + null; + final pageIndex = + currentOrder[position] + .pageIndex; + if (ref.read( + navigationIndexProvider, + ) != + pageIndex) { + ref + .read( + navigationIndexProvider + .notifier, + ) + .setIndex(pageIndex); + } + return; + } + if (_programmaticPageTarget != + null) { + return; + } + final pageIndex = + currentOrder[position] + .pageIndex; + if (ref.read( + navigationIndexProvider, + ) != + pageIndex) { + ref + .read( + navigationIndexProvider + .notifier, + ) + .setIndex(pageIndex); + } + return; + } + if (_programmaticPageTarget == + position) { + _programmaticPageTarget = + null; + } + final pageIndex = + currentOrder[position] + .pageIndex; + if (ref.read( + navigationIndexProvider, + ) != + pageIndex) { + ref + .read( navigationIndexProvider - .notifier) - .setIndex(pageIndex); - } - return; - } - if (_programmaticPageTarget != - null) { - return; - } - final pageIndex = - currentOrder[position].pageIndex; - if (ref.read( - navigationIndexProvider) != - pageIndex) { - ref - .read(navigationIndexProvider - .notifier) - .setIndex(pageIndex); - } - return; - } - if (_programmaticPageTarget == - position) { - _programmaticPageTarget = null; - } - final pageIndex = - currentOrder[position].pageIndex; - if (ref.read( - navigationIndexProvider) != - pageIndex) { - ref - .read(navigationIndexProvider - .notifier) - .setIndex(pageIndex); - } - }, - children: order.map((button) { - return _buildTab( - tabIndex: button.pageIndex, - currentIndex: currentIdx, - child: _buildScreen(button), + .notifier, + ) + .setIndex(pageIndex); + } + }, + children: order.map((button) { + return _buildTab( + tabIndex: button.pageIndex, + currentIndex: currentIdx, + child: _buildScreen(button), + ); + }).toList(), + ), ); - }).toList(), - ), - ); - }, - ), - ), - transitionDuration: Duration.zero, - ); - } - return null; - }, + }, + ), + ), + transitionDuration: Duration.zero, + ); + } + return null; + }, + ), + ); + }, + ), + ), + + // Wide windows use a side rail; portrait keeps the bottom bar. + if (useSideNavigation) + Positioned( + left: 0, + top: 0, + bottom: 0, + child: TutorialTargetAnchor( + target: TutorialTarget.navBar, + child: FlickNavRail( + currentIndex: ref.watch(navigationIndexProvider), + config: ref.watch(navBarConfigProvider), + onTap: _handleNavigationTap, + onBottomBarSettings: _openBottomBarSettings, + ), ), - ); - }, - ), - ), + ) + else + Positioned( + left: 0, + right: 0, + bottom: 0, + child: RepaintBoundary( + child: SlideTransition( + position: _navBarSlideAnimation, + child: _buildUnifiedBottomBar(), + ), + ), + ), - // Unified Bottom Bar (Mini Player + Navigation) - Positioned( - left: 0, - right: 0, - bottom: 0, - child: RepaintBoundary( - child: SlideTransition( - position: _navBarSlideAnimation, - child: _buildUnifiedBottomBar(), - ), - ), - ), + // In rail mode the mini player owns the bottom of the content + // area, not the full window width. + if (useSideNavigation && !useOrbitSideMiniPlayer) + Positioned( + left: railWidth, + right: 0, + bottom: 0, + child: SafeArea( + top: false, + left: false, + child: TutorialTargetAnchor( + target: TutorialTarget.miniPlayer, + child: EmbeddedMiniPlayer( + collapsed: false, + separated: separated, + ), + ), + ), + ), + + // Compact landscape orbit moves the mini player to the right + // edge so the orbit stage keeps the full window height. + if (useOrbitSideMiniPlayer) + Positioned( + right: 6, + top: 0, + bottom: 0, + child: SafeArea( + left: false, + child: TutorialTargetAnchor( + target: TutorialTarget.miniPlayer, + child: const LandscapeSideMiniPlayer(), + ), + ), + ), - // Floating island mini-player (draggable pill) - const FloatingMiniPlayer(), + // Floating island mini-player (draggable pill) + const FloatingMiniPlayer(), - // Pinned scan/preload progress bubble (overlay minimized) - const Positioned.fill(child: FloatingScanProgress()), + // Pinned scan/preload progress bubble (overlay minimized) + const Positioned.fill(child: FloatingScanProgress()), - // Interactive tutorial overlay - const Positioned.fill(child: TutorialOverlay()), + // Interactive tutorial overlay + const Positioned.fill(child: TutorialOverlay()), - // USB DAC attach → auto bit-perfect switch (decline-aware) - const UsbBitPerfectPrompt(), - ], + // USB DAC attach → auto bit-perfect switch (decline-aware) + const UsbBitPerfectPrompt(), + ], + ), + ), ), ), - ), - ), ), ), ); @@ -1037,6 +1163,25 @@ class _MainShellState extends ConsumerState ); } + void _handleNavigationTap(int index) { + final nested = nestedNavigatorKey.currentState; + if (nested != null && nested.canPop()) { + nested.popUntil((route) => route.isFirst); + } + if (ref.read(navigationIndexProvider) != index) { + ref.read(navigationIndexProvider.notifier).setIndex(index); + } else if (nested != null && nested.canPop()) { + nested.popUntil((route) => route.isFirst); + } + } + + void _openBottomBarSettings() { + NavigationHelper.pushOnRoot( + context, + MaterialPageRoute(builder: (_) => const BottomBarSettingsScreen()), + ); + } + Widget _buildUnifiedBottomBar() { final currentIndex = ref.watch(navigationIndexProvider); final navBarConfig = ref.watch(navBarConfigProvider); @@ -1053,32 +1198,12 @@ class _MainShellState extends ConsumerState currentIndex: currentIndex, config: navBarConfig, collapsed: collapsed, - onTap: (index) { - final nested = nestedNavigatorKey.currentState; - if (nested != null && nested.canPop()) { - nested.popUntil((route) => route.isFirst); - } - if (ref.read(navigationIndexProvider) != index) { - ref.read(navigationIndexProvider.notifier).setIndex(index); - } else if (nested != null && nested.canPop()) { - nested.popUntil((route) => route.isFirst); - } - }, - onBottomBarSettings: () { - NavigationHelper.pushOnRoot( - context, - MaterialPageRoute( - builder: (_) => const BottomBarSettingsScreen(), - ), - ); - }, + onTap: _handleNavigationTap, + onBottomBarSettings: _openBottomBarSettings, showMiniPlayer: true, miniPlayerWidget: TutorialTargetAnchor( target: TutorialTarget.miniPlayer, - child: EmbeddedMiniPlayer( - collapsed: collapsed, - separated: separated, - ), + child: EmbeddedMiniPlayer(collapsed: collapsed, separated: separated), ), ), ); diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 08420778c..b43e6ce68 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -156,6 +156,12 @@ class AppTheme { // Text theme textTheme: _textTheme, + // Let the app's sheet surfaces use the available window, rather than + // Material 3's fixed 640dp modal-route cap on landscape/tablets. + bottomSheetTheme: const BottomSheetThemeData( + constraints: BoxConstraints(maxWidth: double.infinity), + ), + // Shared transient feedback: a quiet surface with native Material motion. snackBarTheme: SnackBarThemeData( behavior: SnackBarBehavior.floating, diff --git a/lib/core/utils/adaptive_layout.dart b/lib/core/utils/adaptive_layout.dart new file mode 100644 index 000000000..b668dc9de --- /dev/null +++ b/lib/core/utils/adaptive_layout.dart @@ -0,0 +1,49 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; + +/// Layout policy for the app window, not the physical device. +/// +/// Android windows can be resized, folded, and split while the device stays +/// in one orientation. Every decision here therefore comes from the current +/// constraints/size; nothing reads `MediaQuery.orientation`. +abstract final class AdaptiveLayout { + /// Below this width a two-pane composition starts to lose its right pane. + static const double sideBySideMinWidth = 520.0; + + /// Material's medium/expanded boundary, kept for wide library layouts. + static const double expandedMinWidth = 840.0; + + /// Short windows need vertically compact chrome even when they are wide. + static const double shortHeight = 480.0; + + /// Width of the compact navigation rail in wide windows. + static const double navigationRailWidth = 76.0; + + /// Narrow, vertical mini player used by the compact landscape song orbit. + static const double sideMiniPlayerWidth = 64.0; + static const double sideMiniPlayerContentInset = 78.0; + + static bool isWide(Size size) => size.width > size.height; + + /// Short landscape windows trade the bottom mini player for a side strip so + /// the orbit keeps its vertical stage. + static bool useCompactLandscapeMiniPlayer(Size size) => + size.width > size.height && size.height < shortHeight; + + /// True when the player should use its left-stage/right-controls layout. + static bool usePlayerSideLayout(Size size) => + size.width >= sideBySideMinWidth && size.width > size.height; + + /// Same decision using the space actually left for the player body. + static bool usePlayerSideLayoutFor(BoxConstraints constraints) => + constraints.maxWidth >= sideBySideMinWidth && + constraints.maxWidth > constraints.maxHeight; + + /// True when the shell should move navigation from the bottom to the side. + static bool useSideNavigation(Size size) => + size.width >= 520.0 && size.width > size.height; + + static double railWidth(BuildContext context) => + math.min(navigationRailWidth, MediaQuery.sizeOf(context).width * 0.16); +} diff --git a/lib/features/player/screens/full_player_screen.dart b/lib/features/player/screens/full_player_screen.dart index d2e12653e..91f8b934d 100644 --- a/lib/features/player/screens/full_player_screen.dart +++ b/lib/features/player/screens/full_player_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; 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/adaptive_layout.dart'; import 'package:flick/core/utils/duration_format.dart'; import 'package:flick/core/utils/responsive.dart'; import 'package:flick/data/repositories/song_repository.dart'; @@ -17,9 +18,12 @@ import 'package:flick/features/player/widgets/player_navigation.dart'; import 'package:flick/features/player/widgets/song_actions_sheet.dart'; import 'package:flick/features/player/widgets/song_stage.dart'; import 'package:flick/features/player/widgets/song_stage_carousel.dart'; +import 'package:flick/features/player/widgets/landscape_player_layout.dart'; import 'package:flick/features/player/widgets/lyrics_mode_waveform_strip.dart'; +import 'package:flick/features/player/widgets/inline_lyrics_panel.dart'; import 'package:flick/features/player/widgets/album_color_helpers.dart'; import 'package:flick/models/album_color_mode.dart'; +import 'package:flick/models/immersive_background_style.dart'; import 'package:flick/models/player_screen_mode.dart'; import 'package:flick/models/player_action_button.dart'; import 'package:flick/models/song.dart'; @@ -233,8 +237,9 @@ class _FullPlayerScreenState extends ConsumerState void _setVisualizationMode(bool value) { final nextLyricsMode = value ? false : _isLyricsMode; - if (_isVisualizationMode == value && _isLyricsMode == nextLyricsMode) + if (_isVisualizationMode == value && _isLyricsMode == nextLyricsMode) { return; + } setState(() { _isVisualizationMode = value; _isLyricsMode = nextLyricsMode; @@ -333,6 +338,7 @@ class _FullPlayerScreenState extends ConsumerState String visFreq, String visMove, bool animatedArtwork, + ImmersiveBackgroundStyle backgroundStyle, ) { final bgBlend = albumColorMode.backgroundBlend; final hasAlbumTint = albumColor != null && bgBlend > 0; @@ -356,6 +362,7 @@ class _FullPlayerScreenState extends ConsumerState visFreq, visMove, animatedArtwork, + backgroundStyle, ), ), Positioned.fill( @@ -384,9 +391,18 @@ class _FullPlayerScreenState extends ConsumerState String visFreq, String visMove, bool animatedArtwork, + ImmersiveBackgroundStyle backgroundStyle, ) { final bgBlend = albumColorMode.backgroundBlend; final hasAlbumTint = albumColor != null && bgBlend > 0; + final useBlurGradient = + backgroundStyle == ImmersiveBackgroundStyle.blurGradient; + // In the side-by-side immersive layout the controls own the right pane, so + // the scrim/blur needs to deepen toward that edge instead of the bottom. + final sideImmersiveLayout = + _playerScreenMode == PlayerScreenMode.immersive && + !_isImmersiveFullView && + AdaptiveLayout.usePlayerSideLayout(MediaQuery.sizeOf(context)); if (visualizationMode && _playerScreenMode != PlayerScreenMode.artworkCard) { @@ -407,19 +423,31 @@ class _FullPlayerScreenState extends ConsumerState Positioned.fill( child: Container( decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - overlayColor.withValues(alpha: 0.7), - overlayColor.withValues(alpha: 0.35), - Colors.transparent, - Colors.transparent, - overlayColor.withValues(alpha: 0.3), - overlayColor.withValues(alpha: 0.75), - ], - stops: const [0.0, 0.12, 0.28, 0.62, 0.82, 1.0], - ), + gradient: sideImmersiveLayout + ? LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + Colors.transparent, + Colors.transparent, + overlayColor.withValues(alpha: 0.35), + overlayColor.withValues(alpha: 0.75), + ], + stops: const [0.0, 0.42, 0.68, 1.0], + ) + : LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + overlayColor.withValues(alpha: 0.7), + overlayColor.withValues(alpha: 0.35), + Colors.transparent, + Colors.transparent, + overlayColor.withValues(alpha: 0.3), + overlayColor.withValues(alpha: 0.75), + ], + stops: const [0.0, 0.12, 0.28, 0.62, 0.82, 1.0], + ), ), ), ), @@ -487,6 +515,15 @@ class _FullPlayerScreenState extends ConsumerState gradientBase.withValues(alpha: 0.08), Colors.transparent, ] + : useBlurGradient + ? [ + gradientBase.withValues(alpha: 0.72), + gradientBase.withValues(alpha: 0.58), + gradientBase.withValues(alpha: 0.42), + gradientBase.withValues(alpha: 0.24), + gradientBase.withValues(alpha: 0.1), + Colors.transparent, + ] : [ gradientBase, gradientBase.withValues(alpha: 0.95), @@ -511,7 +548,8 @@ class _FullPlayerScreenState extends ConsumerState errorWidget: placeholder, albumName: song.album, artistName: - (song.albumArtist != null && song.albumArtist!.trim().isNotEmpty) + (song.albumArtist != null && + song.albumArtist!.trim().isNotEmpty) ? song.albumArtist : song.artist, representativeSongTitle: song.title, @@ -530,13 +568,48 @@ class _FullPlayerScreenState extends ConsumerState return Stack( children: [ Positioned.fill(child: artLayer), + if (useBlurGradient) + Positioned.fill( + child: IgnorePointer( + child: AnimatedOpacity( + duration: AppConstants.animationNormal, + opacity: _isImmersiveFullView ? 0.0 : 1.0, + child: ShaderMask( + shaderCallback: (bounds) => LinearGradient( + begin: sideImmersiveLayout + ? Alignment.centerLeft + : Alignment.topCenter, + end: sideImmersiveLayout + ? Alignment.centerRight + : Alignment.bottomCenter, + colors: const [ + Colors.transparent, + Colors.transparent, + Colors.white, + ], + stops: const [0.0, 0.42, 0.82], + ).createShader(bounds), + blendMode: BlendMode.dstIn, + child: AmbientBackground( + song: song, + imageOpacity: 1.0, + scrimOpacity: 0.0, + ), + ), + ), + ), + ), Positioned.fill( child: AnimatedContainer( duration: AppConstants.animationNormal, decoration: BoxDecoration( gradient: LinearGradient( - begin: Alignment.bottomCenter, - end: Alignment.topCenter, + begin: sideImmersiveLayout + ? Alignment.centerRight + : Alignment.bottomCenter, + end: sideImmersiveLayout + ? Alignment.centerLeft + : Alignment.topCenter, colors: gradientColors, stops: const [0.0, 0.2, 0.4, 0.6, 0.8, 1.0], ), @@ -606,7 +679,8 @@ class _FullPlayerScreenState extends ConsumerState BaybayinText( label: BaybayinLabel.playing, fallback: context.l10n.isFilipino - ? context.l10n.playingLabel : l10n.nowPlaying, + ? context.l10n.playingLabel + : l10n.nowPlaying, style: TextStyle( fontFamily: 'GoogleSansFlex', fontSize: context.responsive(12.0, 13.0, 14.0), @@ -829,8 +903,9 @@ class _FullPlayerScreenState extends ConsumerState Widget _buildPinnedWaveform( BuildContext context, Song song, - PlayerScreenMode mode, - ) { + PlayerScreenMode mode, { + double? horizontalPadding, + }) { final isImmersive = mode == PlayerScreenMode.immersive; // Lyrics uses the strip for immersive (waveform + dismiss arrow) but the // time labels are now unified as a single pinned default for BOTH @@ -841,9 +916,11 @@ class _FullPlayerScreenState extends ConsumerState positionNotifier: _throttledPositionNotifier, currentSong: song, formatDuration: formatDuration, - horizontalPadding: isImmersive - ? context.responsive(18.0, 24.0, 30.0) - : context.responsive(16.0, 20.0, 24.0), + horizontalPadding: + horizontalPadding ?? + (isImmersive + ? context.responsive(18.0, 24.0, 30.0) + : context.responsive(16.0, 20.0, 24.0)), onSwipeUp: () => _setLyricsMode(false), showTimeLabels: false, showArrow: _isLyricsMode && isImmersive, @@ -860,6 +937,9 @@ class _FullPlayerScreenState extends ConsumerState final visStyle = appPrefs.visualizerAnimationStyle; final visFreq = appPrefs.visualizerFrequencyMode; final visMove = appPrefs.visualizerMovementMode; + final immersiveBackgroundStyle = ImmersiveBackgroundStyleX.fromStorageValue( + appPrefs.immersiveBackgroundStyle, + ); final colorMode = ref.watch(albumColorModeProvider); final dominantColor = ref.watch(albumDominantColorSyncProvider); @@ -886,11 +966,9 @@ class _FullPlayerScreenState extends ConsumerState return const SizedBox.shrink(); } - final isShortHeight = MediaQuery.sizeOf(context).height < 620.0; final showImmersiveFullView = _isImmersiveFullView && - _playerScreenMode == PlayerScreenMode.immersive && - !isShortHeight; + _playerScreenMode == PlayerScreenMode.immersive; final showVisualizerOnly = _isVisualizationMode && appPrefs.visualizerEnabled && @@ -954,6 +1032,7 @@ class _FullPlayerScreenState extends ConsumerState visMove, showImmersiveFullView, showVisualizerOnly, + immersiveBackgroundStyle, ), ), ); @@ -974,14 +1053,21 @@ class _FullPlayerScreenState extends ConsumerState String visMove, bool showImmersiveFullView, bool showVisualizerOnly, + ImmersiveBackgroundStyle backgroundStyle, ) { final visualizationMode = _isVisualizationMode && appPrefs.visualizerEnabled; - // Shared file-info builder for stages; also drawn pinned (overlay) so the - // left/right buttons don't slide with the carousel. - Widget fileInfoBuilder(Song s, bool lm, PlayerScreenMode mode) { + // Shared action-row builder for stages and the pinned overlay. The + // landscape pane passes `compact: true` for a single short row. + Widget buildActionRow( + Song s, + bool lm, + PlayerScreenMode mode, { + bool compact = false, + }) { return PlayerActionButtonRow( + compact: compact, song: s, lyricsMode: lm, isVisualizationMode: _isVisualizationMode, @@ -1010,6 +1096,9 @@ class _FullPlayerScreenState extends ConsumerState ); } + Widget fileInfoBuilder(Song s, bool lm, PlayerScreenMode mode) => + buildActionRow(s, lm, mode); + // Overlay shows over the stage's reserved invisible row slot. final showPinnedActionRow = _playerScreenMode == PlayerScreenMode.immersive ? (appPrefs.immersiveShowFileInfo && !_isLyricsMode) @@ -1033,6 +1122,7 @@ class _FullPlayerScreenState extends ConsumerState visFreq, visMove, appPrefs.animatedAlbumArt && appPrefs.animationsEnabled, + backgroundStyle, ); if (showVisualizerOnly) { @@ -1134,6 +1224,33 @@ class _FullPlayerScreenState extends ConsumerState ); } + if (AdaptiveLayout.usePlayerSideLayout(MediaQuery.sizeOf(context))) { + return Stack( + children: [ + Positioned.fill(child: background), + SafeArea( + child: _buildLandscapeLayout( + context, + song, + appPrefs, + colorMode, + albumColor, + visStyle, + visFreq, + visMove, + visualizationMode, + (s) => buildActionRow( + s, + _isLyricsMode, + _playerScreenMode, + compact: true, + ), + ), + ), + ], + ); + } + // Default: pinned top chrome + sliding stage + pinned controls return Stack( children: [ @@ -1312,11 +1429,7 @@ class _FullPlayerScreenState extends ConsumerState mainAxisSize: MainAxisSize.min, children: [ SizedBox( - height: context.responsive( - 8.0, - 10.0, - 12.0, - ), + height: context.responsive(8.0, 10.0, 12.0), ), Padding( padding: EdgeInsets.symmetric( @@ -1329,19 +1442,23 @@ class _FullPlayerScreenState extends ConsumerState return ValueListenableBuilder( valueListenable: _playerService.durationNotifier, - builder: (context, engineDuration, _) { - final duration = - engineDuration.inMilliseconds > 0 - ? engineDuration - : (song.duration); - return PlaybackTimeLabels( - position: position, - duration: duration, - formatDuration: formatDuration, - horizontalPadding: - timeLabelsPadding, - ); - }, + builder: + (context, engineDuration, _) { + final duration = + engineDuration + .inMilliseconds > + 0 + ? engineDuration + : (song.duration); + return PlaybackTimeLabels( + position: position, + duration: duration, + formatDuration: + formatDuration, + horizontalPadding: + timeLabelsPadding, + ); + }, ); }, ), @@ -1397,4 +1514,235 @@ class _FullPlayerScreenState extends ConsumerState ], ); } + + /// Wide, short window: square stage on the left, pinned playback on the + /// right. Both player styles keep their own identity instead of being + /// squeezed into the portrait column. + Widget _buildLandscapeLayout( + BuildContext context, + Song song, + dynamic appPrefs, + AlbumColorMode colorMode, + Color? albumColor, + String visStyle, + String visFreq, + String visMove, + bool visualizationMode, + Widget Function(Song song) actionRowBuilder, + ) { + final isArtwork = _playerScreenMode == PlayerScreenMode.artworkCard; + final showActions = isArtwork + ? appPrefs.artworkCardShowFileInfo + : appPrefs.immersiveShowFileInfo; + + final leftPane = _isLyricsMode + ? Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 4, 0), + child: InlineLyricsPanel( + song: song, + playerService: _playerService, + lyricsService: _lyricsService, + albumColor: albumColor, + positionNotifier: _throttledPositionNotifier, + active: true, + ), + ) + : ValueListenableBuilder>( + valueListenable: _playerService.upNextNotifier, + builder: (context, upNext, _) { + return ValueListenableBuilder( + valueListenable: _playerService.currentIndexNotifier, + builder: (context, idx, _) { + final prev = _playerService.peekPrevious; + final next = _playerService.peekNext; + return SongStageCarousel( + key: _carouselKey, + currentSong: song, + prevSong: prev, + nextSong: next, + enabled: !_isVinylRotationActive, + onDragStateChanged: (v) => _isCarouselDragging = v, + onNext: _handleCarouselNext, + onPrevious: _handleCarouselPrevious, + stageBuilder: (s) { + final isCurrent = s.id == song.id; + return SongStage( + song: s, + pane: isArtwork + ? SongStagePane.artworkOnly + : SongStagePane.backdropOnly, + lyricsMode: _isLyricsMode, + visualizationMode: visualizationMode, + immersiveFullView: false, + playerScreenMode: _playerScreenMode, + albumColorMode: colorMode, + albumColor: albumColor, + playerService: _playerService, + lyricsService: _lyricsService, + positionNotifier: isCurrent + ? _throttledPositionNotifier + : _zeroPosition, + formatDuration: formatDuration, + onNavigateToArtistDetail: (s) => + _navigation.openArtistFromSong(context, s), + onNavigateToAlbumDetail: (s) => + _navigation.openAlbumFromSong(context, s), + buildFileInfoRow: (s, _, _) => actionRowBuilder(s), + fileInfoRowVisible: false, + visualizerAnimationStyle: visStyle, + visualizerFrequencyMode: visFreq, + visualizerMovementMode: visMove, + artworkCardArtworkScale: + appPrefs.artworkCardArtworkScale, + artworkCardTextScale: appPrefs.artworkCardTextScale, + artworkCardVerticalOffset: + appPrefs.artworkCardVerticalOffset, + artworkCardArtworkOffset: + appPrefs.artworkCardArtworkOffset, + artworkCardShowTitle: appPrefs.artworkCardShowTitle, + artworkCardShowArtist: appPrefs.artworkCardShowArtist, + artworkCardShowAlbum: appPrefs.artworkCardShowAlbum, + artworkCardShowFileInfo: + appPrefs.artworkCardShowFileInfo, + artworkCardShowFrame: appPrefs.artworkCardShowFrame, + immersiveTextScale: appPrefs.immersiveTextScale, + immersiveVerticalOffset: + appPrefs.immersiveVerticalOffset, + immersiveFullViewScale: appPrefs.immersiveFullViewScale, + immersiveShowTitle: appPrefs.immersiveShowTitle, + immersiveShowArtist: appPrefs.immersiveShowArtist, + immersiveShowFileInfo: appPrefs.immersiveShowFileInfo, + onRotationEnabledChanged: (enabled) { + _isVinylRotationActive = enabled; + }, + vinylMode: _isVinylMode, + onVinylChanged: (v) => _isVinylMode = v, + onToggleLyrics: () => _setLyricsMode(!_isLyricsMode), + showWaveform: false, + motionArtEnabled: isCurrent, + active: isCurrent, + ); + }, + ); + }, + ); + }, + ); + + final detailsPane = isArtwork + ? SongStagePane.artworkCardDetails + : SongStagePane.immersiveIdentity; + + return Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), + child: Column( + children: [ + const Uac2ErrorNotification(), + _buildTopChrome(context, song, albumColor, colorMode, appPrefs), + const SizedBox(height: 4), + Expanded( + child: LandscapePlayerLayout( + stage: leftPane, + artworkCard: isArtwork, + lyricsMode: _isLyricsMode, + identity: SongStage( + song: song, + pane: detailsPane, + lyricsMode: _isLyricsMode, + visualizationMode: visualizationMode, + immersiveFullView: false, + playerScreenMode: _playerScreenMode, + albumColorMode: colorMode, + albumColor: albumColor, + playerService: _playerService, + lyricsService: _lyricsService, + positionNotifier: _throttledPositionNotifier, + formatDuration: formatDuration, + onNavigateToArtistDetail: (s) => + _navigation.openArtistFromSong(context, s), + onNavigateToAlbumDetail: (s) => + _navigation.openAlbumFromSong(context, s), + buildFileInfoRow: (s, _, _) => actionRowBuilder(s), + fileInfoRowVisible: false, + visualizerAnimationStyle: visStyle, + visualizerFrequencyMode: visFreq, + visualizerMovementMode: visMove, + artworkCardArtworkScale: appPrefs.artworkCardArtworkScale, + artworkCardTextScale: appPrefs.artworkCardTextScale, + artworkCardVerticalOffset: appPrefs.artworkCardVerticalOffset, + artworkCardArtworkOffset: appPrefs.artworkCardArtworkOffset, + artworkCardShowTitle: appPrefs.artworkCardShowTitle, + artworkCardShowArtist: appPrefs.artworkCardShowArtist, + artworkCardShowAlbum: appPrefs.artworkCardShowAlbum, + artworkCardShowFileInfo: appPrefs.artworkCardShowFileInfo, + artworkCardShowFrame: appPrefs.artworkCardShowFrame, + immersiveTextScale: appPrefs.immersiveTextScale, + immersiveVerticalOffset: appPrefs.immersiveVerticalOffset, + immersiveFullViewScale: appPrefs.immersiveFullViewScale, + immersiveShowTitle: appPrefs.immersiveShowTitle, + immersiveShowArtist: appPrefs.immersiveShowArtist, + immersiveShowFileInfo: appPrefs.immersiveShowFileInfo, + onRotationEnabledChanged: (enabled) { + _isVinylRotationActive = enabled; + }, + vinylMode: _isVinylMode, + onVinylChanged: (v) => _isVinylMode = v, + onToggleLyrics: () => _setLyricsMode(!_isLyricsMode), + showWaveform: false, + motionArtEnabled: true, + active: true, + ), + actions: showActions ? actionRowBuilder(song) : null, + playback: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildPinnedWaveform( + context, + song, + _playerScreenMode, + horizontalPadding: 4, + ), + if (_isLyricsMode) ...[ + const SizedBox(height: 6), + ValueListenableBuilder( + valueListenable: _playerService.positionNotifier, + builder: (context, position, _) { + return ValueListenableBuilder( + valueListenable: _playerService.durationNotifier, + builder: (context, engineDuration, _) { + final duration = engineDuration.inMilliseconds > 0 + ? engineDuration + : song.duration; + return PlaybackTimeLabels( + position: position, + duration: duration, + formatDuration: formatDuration, + horizontalPadding: 4, + ); + }, + ); + }, + ), + ], + const SizedBox(height: 4), + PlayerControls( + playerService: _playerService, + formatDuration: formatDuration, + currentSong: song, + onPrevious: _animateToPreviousSong, + onNext: _animateToNextSong, + timelineHorizontalPadding: 4, + albumColorMode: colorMode, + albumColor: albumColor, + showTimeLabels: !_isLyricsMode, + compact: MediaQuery.sizeOf(context).height < 500, + ), + ], + ), + ), + ), + ], + ), + ); + } } diff --git a/lib/features/player/widgets/add_to_playlist_sheet.dart b/lib/features/player/widgets/add_to_playlist_sheet.dart index 703fee816..f11b2ae80 100644 --- a/lib/features/player/widgets/add_to_playlist_sheet.dart +++ b/lib/features/player/widgets/add_to_playlist_sheet.dart @@ -25,19 +25,20 @@ class AddToPlaylistSheet extends ConsumerWidget { useRootNavigator: true, context: context, backgroundColor: Colors.transparent, + isScrollControlled: true, builder: (context) => AddToPlaylistSheet(songs: songs), ); } @override Widget build(BuildContext context, WidgetRef ref) { - return Container( - decoration: AppBottomSheetSurface.decoration, - padding: EdgeInsets.fromLTRB( + return AppBottomSheetSurface( + scrollable: true, + padding: const EdgeInsets.fromLTRB( + 24, 24, 24, 24, - 24 + MediaQuery.paddingOf(context).bottom, ), child: Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/features/player/widgets/ambient_background.dart b/lib/features/player/widgets/ambient_background.dart index 90bb5cd74..54bdc84f4 100644 --- a/lib/features/player/widgets/ambient_background.dart +++ b/lib/features/player/widgets/ambient_background.dart @@ -50,7 +50,18 @@ class AmbientBackgroundScope extends InheritedWidget { class AmbientBackground extends StatefulWidget { final Song? song; - const AmbientBackground({super.key, this.song}); + /// Opacity of the pre-blurred artwork itself. + final double imageOpacity; + + /// Black scrim drawn over the blurred artwork. + final double scrimOpacity; + + const AmbientBackground({ + super.key, + this.song, + this.imageOpacity = 0.6, + this.scrimOpacity = 0.3, + }); @override State createState() => _AmbientBackgroundState(); @@ -265,10 +276,13 @@ class _AmbientBackgroundState extends State { RawImage( image: image, fit: BoxFit.cover, - opacity: const AlwaysStoppedAnimation(0.6), + opacity: AlwaysStoppedAnimation(widget.imageOpacity), ), // Dark scrim for readability - ColoredBox(color: Colors.black.withValues(alpha: 0.3)), + if (widget.scrimOpacity > 0) + ColoredBox( + color: Colors.black.withValues(alpha: widget.scrimOpacity), + ), ], ), ), diff --git a/lib/features/player/widgets/bit_perfect_indicator.dart b/lib/features/player/widgets/bit_perfect_indicator.dart index 0495b9760..31de10d0e 100644 --- a/lib/features/player/widgets/bit_perfect_indicator.dart +++ b/lib/features/player/widgets/bit_perfect_indicator.dart @@ -265,8 +265,9 @@ class _AudioInfoBottomSheetState extends ConsumerState<_AudioInfoBottomSheet> { ? dominantColor : null; - return Container( - decoration: AppBottomSheetSurface.decoration, + return AppBottomSheetSurface( + scrollable: true, + padding: EdgeInsets.zero, child: SafeArea( child: Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/features/player/widgets/landscape_player_layout.dart b/lib/features/player/widgets/landscape_player_layout.dart new file mode 100644 index 000000000..835b0f824 --- /dev/null +++ b/lib/features/player/widgets/landscape_player_layout.dart @@ -0,0 +1,94 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +/// A bounded listening composition, not two columns stretched to the window. +/// The identity and playback stay together; only the identity/actions scroll +/// when enlarged text or a short window leaves too little vertical space. +class LandscapePlayerLayout extends StatelessWidget { + final Widget stage; + final Widget identity; + final Widget? actions; + final Widget playback; + final bool artworkCard; + final bool lyricsMode; + + const LandscapePlayerLayout({ + super.key, + required this.stage, + required this.identity, + required this.playback, + this.actions, + this.artworkCard = false, + this.lyricsMode = false, + }); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final width = math.min(1080.0, constraints.maxWidth); + final gap = width >= 800 ? 40.0 : 20.0; + final controlsWidth = math.min( + 500.0, + math.max(248.0, width * (lyricsMode ? 0.48 : 0.52)), + ); + final stageWidth = math.max(0.0, width - controlsWidth - gap); + final artExtent = math.min(400.0, stageWidth); + // The artwork card's file row sits directly below the identity block; + // give that pairing more air than the immersive header needs. + final identityToActionsGap = artworkCard ? 24.0 : 12.0; + + return Center( + child: SizedBox( + width: width, + child: Row( + children: [ + SizedBox( + width: stageWidth, + child: Center( + child: artworkCard && !lyricsMode + ? SizedBox( + width: artExtent, + height: math.min(artExtent, constraints.maxHeight), + child: stage, + ) + : stage, + ), + ), + SizedBox(width: gap), + SizedBox( + width: controlsWidth, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: SingleChildScrollView( + key: const ValueKey('landscape-player-details'), + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + identity, + if (actions != null) ...[ + SizedBox(height: identityToActionsGap), + actions!, + ], + ], + ), + ), + ), + const SizedBox(height: 12), + playback, + ], + ), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/features/player/widgets/line_seek_bar.dart b/lib/features/player/widgets/line_seek_bar.dart index 18ded4d7a..7f44360c4 100644 --- a/lib/features/player/widgets/line_seek_bar.dart +++ b/lib/features/player/widgets/line_seek_bar.dart @@ -10,6 +10,10 @@ class LineSeekBar extends StatefulWidget { final ValueChanged? onChangeEnd; final double appearProgress; + /// Visible thickness of the progress line in logical pixels. The touch + /// area stays at least 48dp tall even when this is smaller. + final double thickness; + const LineSeekBar({ super.key, required this.position, @@ -17,6 +21,7 @@ class LineSeekBar extends StatefulWidget { required this.onChanged, this.onChangeEnd, this.appearProgress = 1.0, + this.thickness = 3, }); @override @@ -25,10 +30,12 @@ class LineSeekBar extends StatefulWidget { class _LineSeekBarState extends State with SingleTickerProviderStateMixin { - static const double _baseHeight = 40; - static const double _expandedHeight = 72; static const double _fineScrubZoom = 2.6; + double get _baseHeight => math.max(48.0, widget.thickness + 36.0); + double get _expandedHeight => math.max(72.0, widget.thickness + 36.0); + double get _finePaintHeight => math.max(10.0, widget.thickness); + bool _isDragging = false; bool _isFineScrubbing = false; Duration? _interactivePosition; @@ -184,15 +191,17 @@ class _LineSeekBarState extends State } double get _effectiveLineHeight { - if (_isFineScrubbing) return 10; - if (_isDragging) return 5; - return 3; + if (_isFineScrubbing) return math.max(10.0, widget.thickness); + if (_isDragging) return math.max(5.0, widget.thickness); + return widget.thickness; } + double get _restingDotRadius => math.max(3.5, widget.thickness / 2 + 2); + double get _effectiveDotRadius { - if (_isFineScrubbing) return 7; - if (_isDragging) return 5; - return 3.5; + if (_isFineScrubbing) return math.max(7.0, _restingDotRadius + 3.5); + if (_isDragging) return math.max(5.0, _restingDotRadius + 1.5); + return _restingDotRadius; } @override @@ -231,8 +240,13 @@ class _LineSeekBarState extends State : Colors.transparent, ), padding: _isFineScrubbing - ? EdgeInsets.symmetric(vertical: (_expandedHeight - 10) / 2) - : const EdgeInsets.only(top: 26, bottom: 10), + ? EdgeInsets.symmetric( + vertical: (_expandedHeight - _finePaintHeight) / 2, + ) + : EdgeInsets.only( + top: 26, + bottom: _baseHeight - 26 - _finePaintHeight, + ), child: RepaintBoundary( child: AnimatedBuilder( animation: _pulseAnimation, diff --git a/lib/features/player/widgets/pitch_bottom_sheet.dart b/lib/features/player/widgets/pitch_bottom_sheet.dart index ff8f00366..0d2cb6f4c 100644 --- a/lib/features/player/widgets/pitch_bottom_sheet.dart +++ b/lib/features/player/widgets/pitch_bottom_sheet.dart @@ -14,19 +14,20 @@ class PitchBottomSheet extends StatelessWidget { useRootNavigator: true, context: context, backgroundColor: Colors.transparent, + isScrollControlled: true, builder: (context) => PitchBottomSheet(playerService: playerService), ); } @override Widget build(BuildContext context) { - return Container( - decoration: AppBottomSheetSurface.decoration, - padding: EdgeInsets.fromLTRB( + return AppBottomSheetSurface( + scrollable: true, + padding: const EdgeInsets.fromLTRB( 20, 16, 20, - 24 + MediaQuery.paddingOf(context).bottom, + 24, ), child: Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/features/player/widgets/player_action_button_row.dart b/lib/features/player/widgets/player_action_button_row.dart index 871119d2e..9c352d5eb 100644 --- a/lib/features/player/widgets/player_action_button_row.dart +++ b/lib/features/player/widgets/player_action_button_row.dart @@ -43,6 +43,9 @@ class PlayerActionButtonRow extends ConsumerStatefulWidget { final GlobalKey usbVolumeButtonKey; final void Function(BuildContext) onShowUsbVolumePopup; + /// Horizontal, tighter arrangement used by the landscape player pane. + final bool compact; + const PlayerActionButtonRow({ super.key, required this.song, @@ -62,6 +65,7 @@ class PlayerActionButtonRow extends ConsumerStatefulWidget { required this.onOpenQueue, required this.usbVolumeButtonKey, required this.onShowUsbVolumePopup, + this.compact = false, }); @override @@ -82,120 +86,121 @@ class _PlayerActionButtonRowState extends ConsumerState { final rightTopAction = widget.rightTopAction; final rightAction = widget.rightAction; final immersiveActions = playerScreenMode == PlayerScreenMode.immersive; - final actionPadding = immersiveActions + final actionPadding = widget.compact + ? const EdgeInsets.all(15) + : immersiveActions ? EdgeInsets.all(context.responsive(8.0, 9.0, 10.0)) : EdgeInsets.all(context.responsive(6.0, 7.0, 8.0)); final actionRadius = immersiveActions ? 12.0 : 10.0; - final actionIconSize = context.responsive(18.0, 20.0, 22.0); + final actionIconSize = widget.compact + ? 18.0 + : context.responsive(18.0, 20.0, 22.0); final accentBlend = albumColorMode.accentBlend; final surfaceBlend = albumColorMode.surfaceBlend; final hasAlbumTint = albumColor != null && accentBlend > 0; final inactiveBg = hasAlbumTint - ? albumSurface( - albumColor, - surfaceBlend, - ).withValues(alpha: 0.15) + ? albumSurface(albumColor, surfaceBlend).withValues(alpha: 0.15) : Colors.white.withValues(alpha: 0.15); final inactiveBorder = hasAlbumTint - ? albumSurface( - albumColor, - surfaceBlend, - ).withValues(alpha: 0.08) + ? albumSurface(albumColor, surfaceBlend).withValues(alpha: 0.08) : Colors.white.withValues(alpha: 0.08); - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, + final leftButtons = _buildSideButtons( + topAction: leftTopAction, + bottomAction: leftAction, + context: context, + song: song, + actionPadding: actionPadding, + actionRadius: actionRadius, + actionIconSize: actionIconSize, + inactiveBg: inactiveBg, + inactiveBorder: inactiveBorder, + albumColor: albumColor, + accentBlend: accentBlend, + hasAlbumTint: hasAlbumTint, + immersiveActions: immersiveActions, + lyricsMode: lyricsMode, + compact: widget.compact, + ); + final fileInfo = Row( + mainAxisSize: MainAxisSize.min, children: [ - _buildSideButtons( - topAction: leftTopAction, - bottomAction: leftAction, - context: context, - song: song, - actionPadding: actionPadding, - actionRadius: actionRadius, - actionIconSize: actionIconSize, - inactiveBg: inactiveBg, - inactiveBorder: inactiveBorder, - albumColor: albumColor, - accentBlend: accentBlend, - hasAlbumTint: hasAlbumTint, - immersiveActions: immersiveActions, - lyricsMode: lyricsMode, - ), - Flexible( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _buildPlayerBadge(context, song.fileType.toUpperCase()), - if (song.isDsd && song.dsdRateLabel.isNotEmpty) ...[ - SizedBox(width: context.responsive(5.0, 6.0, 7.0)), - Flexible( - child: Text( - song.dsdRateLabel, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontFamily: 'GoogleSansFlex', - fontSize: context.responsive(9.0, 10.0, 11.0), - color: Colors.white.withValues(alpha: 0.7), - ), - ), - ), - ] else if (song.resolution != null) ...[ - SizedBox(width: context.responsive(5.0, 6.0, 7.0)), - Flexible( - child: Text( - song.resolution!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontFamily: 'GoogleSansFlex', - fontSize: context.responsive(9.0, 10.0, 11.0), - color: Colors.white.withValues(alpha: 0.7), - ), - ), - ), - ], - SizedBox(width: context.responsive(5.0, 6.0, 7.0)), - BitPerfectIndicator( - song: song, - playerService: widget.playerService, - onTap: () { - final diagnostics = ref.read(audioOutputDiagnosticsProvider); - final deviceStatus = ref.read(uac2DeviceStatusProvider); - BitPerfectIndicator.showInfoSheet( - context, - song: song, - diagnostics: diagnostics, - deviceStatus: deviceStatus, - playerService: widget.playerService, - ); - }, + _buildPlayerBadge(context, song.fileType.toUpperCase()), + if (song.isDsd && song.dsdRateLabel.isNotEmpty) ...[ + SizedBox(width: context.responsive(5.0, 6.0, 7.0)), + Flexible( + child: Text( + song.dsdRateLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontFamily: 'GoogleSansFlex', + fontSize: context.responsive(9.0, 10.0, 11.0), + color: Colors.white.withValues(alpha: 0.7), ), - ], + ), ), - ), - _buildSideButtons( - topAction: rightTopAction, - bottomAction: rightAction, - context: context, + ] else if (song.resolution != null) ...[ + SizedBox(width: context.responsive(5.0, 6.0, 7.0)), + Flexible( + child: Text( + song.resolution!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontFamily: 'GoogleSansFlex', + fontSize: context.responsive(9.0, 10.0, 11.0), + color: Colors.white.withValues(alpha: 0.7), + ), + ), + ), + ], + SizedBox(width: context.responsive(5.0, 6.0, 7.0)), + BitPerfectIndicator( song: song, - actionPadding: actionPadding, - actionRadius: actionRadius, - actionIconSize: actionIconSize, - inactiveBg: inactiveBg, - inactiveBorder: inactiveBorder, - albumColor: albumColor, - accentBlend: accentBlend, - hasAlbumTint: hasAlbumTint, - immersiveActions: immersiveActions, - lyricsMode: lyricsMode, + playerService: widget.playerService, + onTap: () { + final diagnostics = ref.read(audioOutputDiagnosticsProvider); + final deviceStatus = ref.read(uac2DeviceStatusProvider); + BitPerfectIndicator.showInfoSheet( + context, + song: song, + diagnostics: diagnostics, + deviceStatus: deviceStatus, + playerService: widget.playerService, + ); + }, ), ], ); + final rightButtons = _buildSideButtons( + topAction: rightTopAction, + bottomAction: rightAction, + context: context, + song: song, + actionPadding: actionPadding, + actionRadius: actionRadius, + actionIconSize: actionIconSize, + inactiveBg: inactiveBg, + inactiveBorder: inactiveBorder, + albumColor: albumColor, + accentBlend: accentBlend, + hasAlbumTint: hasAlbumTint, + immersiveActions: immersiveActions, + lyricsMode: lyricsMode, + compact: widget.compact, + ); + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + leftButtons, + Flexible(child: fileInfo), + rightButtons, + ], + ); } Widget _buildPlayerBadge(BuildContext context, String label) { @@ -219,6 +224,7 @@ class _PlayerActionButtonRowState extends ConsumerState { ), ); } + Widget _buildSideButtons({ required PlayerActionButton topAction, required PlayerActionButton bottomAction, @@ -234,12 +240,17 @@ class _PlayerActionButtonRowState extends ConsumerState { required bool hasAlbumTint, required bool immersiveActions, required bool lyricsMode, + required bool compact, }) { final buttons = []; for (final action in [topAction, bottomAction]) { if (action == PlayerActionButton.none) continue; if (buttons.isNotEmpty) { - buttons.add(SizedBox(height: context.responsive(6.0, 7.0, 8.0))); + buttons.add( + compact + ? const SizedBox(width: 6) + : SizedBox(height: context.responsive(6.0, 7.0, 8.0)), + ); } buttons.add( _buildActionButton( @@ -260,6 +271,9 @@ class _PlayerActionButtonRowState extends ConsumerState { ); } if (buttons.isEmpty) return const SizedBox.shrink(); + if (compact) { + return Row(mainAxisSize: MainAxisSize.min, children: buttons); + } return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, @@ -426,17 +440,14 @@ class _PlayerActionButtonRowState extends ConsumerState { required bool lyricsMode, }) { final lyricsActiveBg = hasAlbumTint - ? albumAccent( - albumColor!, - accentBlend, - ).withValues(alpha: 0.28) + ? albumAccent(albumColor!, accentBlend).withValues(alpha: 0.28) : AppColors.accent.withValues(alpha: 0.28); final lyricsActiveBorder = hasAlbumTint - ? albumAccent( - albumColor!, - accentBlend, - ).withValues(alpha: 0.45) + ? albumAccent(albumColor!, accentBlend).withValues(alpha: 0.45) : AppColors.accent.withValues(alpha: 0.45); + final lyricsActiveColor = hasAlbumTint + ? albumAccent(albumColor!, accentBlend) + : AppColors.accent; return Tooltip( message: lyricsMode ? l10n.hideLyrics : l10n.showLyrics, @@ -444,18 +455,22 @@ class _PlayerActionButtonRowState extends ConsumerState { onTap: () => widget.onToggleLyrics(), child: Container( padding: actionPadding, - decoration: BoxDecoration( - color: lyricsMode ? lyricsActiveBg : inactiveBg, - borderRadius: BorderRadius.circular(actionRadius), - border: Border.all( - color: lyricsMode ? lyricsActiveBorder : inactiveBorder, - ), - ), + decoration: widget.compact + ? null + : BoxDecoration( + color: lyricsMode ? lyricsActiveBg : inactiveBg, + borderRadius: BorderRadius.circular(actionRadius), + border: Border.all( + color: lyricsMode ? lyricsActiveBorder : inactiveBorder, + ), + ), child: Icon( lyricsMode ? Icons.keyboard_arrow_down_rounded : LucideIcons.fileText, - color: Colors.white.withValues(alpha: 0.96), + color: widget.compact && lyricsMode + ? lyricsActiveColor + : Colors.white.withValues(alpha: 0.96), size: actionIconSize, ), ), @@ -479,14 +494,18 @@ class _PlayerActionButtonRowState extends ConsumerState { final isFavorite = snapshot.data ?? false; return GestureDetector( onTap: () async { - final newState = await widget.favoritesService.toggleFavorite(song.id); + final newState = await widget.favoritesService.toggleFavorite( + song.id, + ); setState(() {}); widget.playerService.refreshNotificationState(); if (context.mounted) { ScaffoldMessenger.of(context).showFlickSnackBar( SnackBar( content: Text( - newState ? l10n.addedToFavorites : l10n.removedFromFavorites3, + newState + ? l10n.addedToFavorites + : l10n.removedFromFavorites3, ), duration: const Duration(seconds: 1), ), @@ -495,25 +514,24 @@ class _PlayerActionButtonRowState extends ConsumerState { }, child: Container( padding: actionPadding, - decoration: BoxDecoration( - color: isFavorite - ? (hasAlbumTint - ? albumAccent( - albumColor!, - accentBlend, - ).withValues(alpha: 0.25) - : Colors.red.withValues(alpha: 0.25)) - : inactiveBg, - borderRadius: BorderRadius.circular(actionRadius), - ), + decoration: widget.compact + ? null + : BoxDecoration( + color: isFavorite + ? (hasAlbumTint + ? albumAccent( + albumColor!, + accentBlend, + ).withValues(alpha: 0.25) + : Colors.red.withValues(alpha: 0.25)) + : inactiveBg, + borderRadius: BorderRadius.circular(actionRadius), + ), child: Icon( isFavorite ? Icons.favorite : Icons.favorite_border, color: isFavorite ? (hasAlbumTint - ? albumAccent( - albumColor!, - accentBlend, - ) + ? albumAccent(albumColor!, accentBlend) : Colors.red) : Colors.white.withValues(alpha: 0.9), size: actionIconSize, @@ -537,17 +555,14 @@ class _PlayerActionButtonRowState extends ConsumerState { }) { final isVisMode = widget.isVisualizationMode; final visActiveBg = hasAlbumTint - ? albumAccent( - albumColor!, - accentBlend, - ).withValues(alpha: 0.28) + ? albumAccent(albumColor!, accentBlend).withValues(alpha: 0.28) : AppColors.accent.withValues(alpha: 0.28); final visActiveBorder = hasAlbumTint - ? albumAccent( - albumColor!, - accentBlend, - ).withValues(alpha: 0.45) + ? albumAccent(albumColor!, accentBlend).withValues(alpha: 0.45) : AppColors.accent.withValues(alpha: 0.45); + final visActiveColor = hasAlbumTint + ? albumAccent(albumColor!, accentBlend) + : AppColors.accent; return Tooltip( message: isVisMode ? l10n.hideVisualizer : l10n.showVisualizer, @@ -555,16 +570,20 @@ class _PlayerActionButtonRowState extends ConsumerState { onTap: () => widget.onToggleVisualization(!isVisMode), child: Container( padding: actionPadding, - decoration: BoxDecoration( - color: isVisMode ? visActiveBg : inactiveBg, - borderRadius: BorderRadius.circular(actionRadius), - border: Border.all( - color: isVisMode ? visActiveBorder : inactiveBorder, - ), - ), + decoration: widget.compact + ? null + : BoxDecoration( + color: isVisMode ? visActiveBg : inactiveBg, + borderRadius: BorderRadius.circular(actionRadius), + border: Border.all( + color: isVisMode ? visActiveBorder : inactiveBorder, + ), + ), child: Icon( Icons.graphic_eq_rounded, - color: Colors.white.withValues(alpha: 0.96), + color: widget.compact && isVisMode + ? visActiveColor + : Colors.white.withValues(alpha: 0.96), size: actionIconSize, ), ), @@ -602,6 +621,7 @@ class _PlayerActionButtonRowState extends ConsumerState { accentBlend: accentBlend, inactiveBg: inactiveBg, inactiveBorder: inactiveBorder, + flat: widget.compact, ); } @@ -622,11 +642,13 @@ class _PlayerActionButtonRowState extends ConsumerState { onTap: () => widget.onOpenQueue(context), child: Container( padding: actionPadding, - decoration: BoxDecoration( - color: inactiveBg, - borderRadius: BorderRadius.circular(actionRadius), - border: Border.all(color: inactiveBorder), - ), + decoration: widget.compact + ? null + : BoxDecoration( + color: inactiveBg, + borderRadius: BorderRadius.circular(actionRadius), + border: Border.all(color: inactiveBorder), + ), child: Icon( LucideIcons.listMusic, color: Colors.white.withValues(alpha: 0.96), @@ -651,11 +673,13 @@ class _PlayerActionButtonRowState extends ConsumerState { onTap: () => SleepTimerBottomSheet.show(context, widget.playerService), child: Container( padding: actionPadding, - decoration: BoxDecoration( - color: inactiveBg, - borderRadius: BorderRadius.circular(actionRadius), - border: Border.all(color: inactiveBorder), - ), + decoration: widget.compact + ? null + : BoxDecoration( + color: inactiveBg, + borderRadius: BorderRadius.circular(actionRadius), + border: Border.all(color: inactiveBorder), + ), child: Icon( LucideIcons.moonStar, color: Colors.white.withValues(alpha: 0.96), @@ -679,7 +703,7 @@ class _PlayerActionButtonRowState extends ConsumerState { child: GestureDetector( onTap: () { showModalBottomSheet( - useRootNavigator: true, + useRootNavigator: true, context: context, backgroundColor: Colors.transparent, isScrollControlled: true, @@ -688,11 +712,13 @@ class _PlayerActionButtonRowState extends ConsumerState { }, child: Container( padding: actionPadding, - decoration: BoxDecoration( - color: inactiveBg, - borderRadius: BorderRadius.circular(actionRadius), - border: Border.all(color: inactiveBorder), - ), + decoration: widget.compact + ? null + : BoxDecoration( + color: inactiveBg, + borderRadius: BorderRadius.circular(actionRadius), + border: Border.all(color: inactiveBorder), + ), child: Icon( LucideIcons.share2, color: Colors.white.withValues(alpha: 0.96), @@ -731,11 +757,13 @@ class _PlayerActionButtonRowState extends ConsumerState { onTap: () => widget.onShowUsbVolumePopup(context), child: Container( padding: actionPadding, - decoration: BoxDecoration( - color: inactiveBg, - borderRadius: BorderRadius.circular(actionRadius), - border: Border.all(color: inactiveBorder), - ), + decoration: widget.compact + ? null + : BoxDecoration( + color: inactiveBg, + borderRadius: BorderRadius.circular(actionRadius), + border: Border.all(color: inactiveBorder), + ), child: Icon( LucideIcons.volume2, color: Colors.white.withValues(alpha: 0.96), @@ -788,11 +816,13 @@ class _PlayerActionButtonRowState extends ConsumerState { }, child: Container( padding: actionPadding, - decoration: BoxDecoration( - color: inactiveBg, - borderRadius: BorderRadius.circular(actionRadius), - border: Border.all(color: inactiveBorder), - ), + decoration: widget.compact + ? null + : BoxDecoration( + color: inactiveBg, + borderRadius: BorderRadius.circular(actionRadius), + border: Border.all(color: inactiveBorder), + ), child: Icon( LucideIcons.slidersHorizontal, color: Colors.white.withValues(alpha: 0.96), @@ -817,11 +847,13 @@ class _PlayerActionButtonRowState extends ConsumerState { onTap: () => VolumeBottomSheet.show(context, widget.playerService), child: Container( padding: actionPadding, - decoration: BoxDecoration( - color: inactiveBg, - borderRadius: BorderRadius.circular(actionRadius), - border: Border.all(color: inactiveBorder), - ), + decoration: widget.compact + ? null + : BoxDecoration( + color: inactiveBg, + borderRadius: BorderRadius.circular(actionRadius), + border: Border.all(color: inactiveBorder), + ), child: Icon( LucideIcons.volume, color: Colors.white.withValues(alpha: 0.96), @@ -845,23 +877,23 @@ class _PlayerActionButtonRowState extends ConsumerState { message: isCasting ? l10n.castingTapToManage : l10n.cast, child: GestureDetector( onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const CastingSettingsScreen(), - ), + MaterialPageRoute(builder: (_) => const CastingSettingsScreen()), ), child: Container( padding: actionPadding, - decoration: BoxDecoration( - color: isCasting - ? AppColors.accent.withValues(alpha: 0.28) - : inactiveBg, - borderRadius: BorderRadius.circular(actionRadius), - border: Border.all( - color: isCasting - ? AppColors.accent.withValues(alpha: 0.45) - : inactiveBorder, - ), - ), + decoration: widget.compact + ? null + : BoxDecoration( + color: isCasting + ? AppColors.accent.withValues(alpha: 0.28) + : inactiveBg, + borderRadius: BorderRadius.circular(actionRadius), + border: Border.all( + color: isCasting + ? AppColors.accent.withValues(alpha: 0.45) + : inactiveBorder, + ), + ), child: Icon( LucideIcons.cast, color: isCasting diff --git a/lib/features/player/widgets/player_controls.dart b/lib/features/player/widgets/player_controls.dart index b2b8995eb..a60027216 100644 --- a/lib/features/player/widgets/player_controls.dart +++ b/lib/features/player/widgets/player_controls.dart @@ -13,6 +13,7 @@ import 'package:flick/features/player/widgets/shuffle_mode_sheet.dart'; import 'package:flick/features/player/widgets/loop_mode_sheet.dart'; import 'package:flick/l10n/l10n.dart'; + class PlayerControls extends StatelessWidget { final PlayerService playerService; final String Function(Duration) formatDuration; @@ -24,7 +25,11 @@ class PlayerControls extends StatelessWidget { final Color? albumColor; final bool showTimeLabels; - const PlayerControls({super.key, + /// Tighter metrics for the right pane of the landscape player. + final bool compact; + + const PlayerControls({ + super.key, required this.playerService, required this.formatDuration, required this.currentSong, @@ -34,6 +39,7 @@ class PlayerControls extends StatelessWidget { this.albumColorMode = AlbumColorMode.off, this.albumColor, this.showTimeLabels = true, + this.compact = false, }); @override @@ -48,6 +54,9 @@ class PlayerControls extends StatelessWidget { final activeAccent = hasAlbumTint ? albumAccent(albumColor!, accentBlend) : AppColors.accent; + final sideSize = compact ? 44.0 : context.responsive(40.0, 44.0, 48.0); + final iconSize = compact ? 18.0 : context.responsive(18.0, 20.0, 22.0); + final gap = compact ? 4.0 : context.responsive(14.0, 18.0, 22.0); return RepaintBoundary( child: ValueListenableBuilder( @@ -79,8 +88,7 @@ class PlayerControls extends StatelessWidget { position: position, duration: duration, formatDuration: formatDuration, - horizontalPadding: - timelineHorizontalPadding, + horizontalPadding: timelineHorizontalPadding, ), const SizedBox(height: 12), ], @@ -88,9 +96,12 @@ class PlayerControls extends StatelessWidget { : const SizedBox.shrink(), ), ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ + FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ // Shuffle ValueListenableBuilder( valueListenable: playerService.shuffleModeNotifier, @@ -107,22 +118,25 @@ class PlayerControls extends StatelessWidget { playerService.toggleShuffle(); final next = playerService.shuffleModeNotifier.value; - ScaffoldMessenger.of(context) - .showFlickSnackBar( - SnackBar( - content: Text(l10n.englishForFilipino.shuffle2(next.label)), - behavior: SnackBarBehavior.floating, - duration: const Duration(seconds: 1), + ScaffoldMessenger.of(context).showFlickSnackBar( + SnackBar( + content: Text( + l10n.englishForFilipino.shuffle2( + next.label, + ), ), - ); + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 1), + ), + ); }, onLongPress: () { AppHaptics.tap(); ShuffleModeSheet.show(context, playerService); }, child: Container( - width: context.responsive(40.0, 44.0, 48.0), - height: context.responsive(40.0, 44.0, 48.0), + width: sideSize, + height: sideSize, decoration: BoxDecoration( color: isActive ? activeAccent.withValues(alpha: 0.25) @@ -139,7 +153,7 @@ class PlayerControls extends StatelessWidget { ), child: Icon( icon, - size: context.responsive(18.0, 20.0, 22.0), + size: iconSize, color: isActive ? activeAccent : Colors.white.withValues(alpha: 0.7), @@ -148,11 +162,11 @@ class PlayerControls extends StatelessWidget { ); }, ), - SizedBox(width: context.responsive(14.0, 18.0, 22.0)), + SizedBox(width: gap), // Previous Container( - width: context.responsive(40.0, 44.0, 48.0), - height: context.responsive(40.0, 44.0, 48.0), + width: sideSize, + height: sideSize, decoration: BoxDecoration( color: buttonSurface.withValues(alpha: 0.6), shape: BoxShape.circle, @@ -162,23 +176,24 @@ class PlayerControls extends StatelessWidget { AppHaptics.tap(); onPrevious(); }, - iconSize: context.responsive(18.0, 20.0, 22.0), + iconSize: iconSize, padding: EdgeInsets.zero, icon: Icon(LucideIcons.skipBack, color: Colors.white), ), ), - SizedBox(width: context.responsive(14.0, 18.0, 22.0)), + SizedBox(width: gap), // Play/Pause _PlayPauseButton( playerService: playerService, albumColorMode: albumColorMode, albumColor: albumColor, + compact: compact, ), - SizedBox(width: context.responsive(14.0, 18.0, 22.0)), + SizedBox(width: gap), // Next Container( - width: context.responsive(40.0, 44.0, 48.0), - height: context.responsive(40.0, 44.0, 48.0), + width: sideSize, + height: sideSize, decoration: BoxDecoration( color: buttonSurface.withValues(alpha: 0.6), shape: BoxShape.circle, @@ -188,7 +203,7 @@ class PlayerControls extends StatelessWidget { AppHaptics.tap(); onNext(); }, - iconSize: context.responsive(18.0, 20.0, 22.0), + iconSize: iconSize, padding: EdgeInsets.zero, icon: Icon( LucideIcons.skipForward, @@ -196,7 +211,7 @@ class PlayerControls extends StatelessWidget { ), ), ), - SizedBox(width: context.responsive(14.0, 18.0, 22.0)), + SizedBox(width: gap), // Repeat/Loop ValueListenableBuilder( valueListenable: playerService.loopModeNotifier, @@ -211,22 +226,23 @@ class PlayerControls extends StatelessWidget { AppHaptics.tap(); playerService.toggleLoopMode(); final next = playerService.loopModeNotifier.value; - ScaffoldMessenger.of(context) - .showFlickSnackBar( - SnackBar( - content: Text(l10n.englishForFilipino.repeat(next.label)), - behavior: SnackBarBehavior.floating, - duration: const Duration(seconds: 1), + ScaffoldMessenger.of(context).showFlickSnackBar( + SnackBar( + content: Text( + l10n.englishForFilipino.repeat(next.label), ), - ); + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 1), + ), + ); }, onLongPress: () { AppHaptics.tap(); LoopModeSheet.show(context, playerService); }, child: Container( - width: context.responsive(40.0, 44.0, 48.0), - height: context.responsive(40.0, 44.0, 48.0), + width: sideSize, + height: sideSize, decoration: BoxDecoration( color: buttonSurface.withValues(alpha: 0.6), shape: BoxShape.circle, @@ -234,11 +250,7 @@ class PlayerControls extends StatelessWidget { child: Stack( alignment: Alignment.center, children: [ - Icon( - icon, - size: context.responsive(18.0, 20.0, 22.0), - color: color, - ), + Icon(icon, size: iconSize, color: color), if (loopMode == LoopMode.all) Positioned( bottom: 6, @@ -257,7 +269,8 @@ class PlayerControls extends StatelessWidget { ); }, ), - ], + ], + ), ), ], ); @@ -275,7 +288,8 @@ class PlaybackTimeLabels extends StatelessWidget { final String Function(Duration) formatDuration; final double horizontalPadding; - const PlaybackTimeLabels({super.key, + const PlaybackTimeLabels({ + super.key, required this.position, required this.duration, required this.formatDuration, @@ -319,10 +333,13 @@ class _PlayPauseButton extends StatelessWidget { final AlbumColorMode albumColorMode; final Color? albumColor; + final bool compact; + const _PlayPauseButton({ required this.playerService, this.albumColorMode = AlbumColorMode.off, this.albumColor, + this.compact = false, }); @override @@ -342,8 +359,12 @@ class _PlayPauseButton extends StatelessWidget { child: ValueListenableBuilder( valueListenable: playerService.isPlayingNotifier, builder: (context, isPlaying, _) { - final buttonSize = context.responsive(58.0, 64.0, 68.0); - final iconSize = context.responsive(26.0, 28.0, 30.0); + final buttonSize = compact + ? 52.0 + : context.responsive(58.0, 64.0, 68.0); + final iconSize = compact + ? 24.0 + : context.responsive(26.0, 28.0, 30.0); return Container( width: buttonSize, diff --git a/lib/features/player/widgets/player_layout_sheet.dart b/lib/features/player/widgets/player_layout_sheet.dart index f0f657049..6ce01a1ab 100644 --- a/lib/features/player/widgets/player_layout_sheet.dart +++ b/lib/features/player/widgets/player_layout_sheet.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:math' as math; +import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flick/core/theme/app_colors.dart'; @@ -7,6 +8,7 @@ import 'package:flick/core/theme/adaptive_color_provider.dart'; import 'package:flick/core/constants/app_constants.dart'; import 'package:flick/services/player_service.dart'; import 'package:flick/models/song.dart'; +import 'package:flick/models/immersive_background_style.dart'; import 'package:flick/models/player_screen_mode.dart'; import 'package:flick/models/album_color_mode.dart'; import 'package:flick/models/player_action_button.dart'; @@ -63,401 +65,419 @@ class _PlayerLayoutSheetState extends ConsumerState { final colorMode = ref.watch(albumColorModeProvider); final appPrefs = ref.watch(appPreferencesProvider); final prefsNotifier = ref.read(appPreferencesProvider.notifier); - return SafeArea( - top: false, - bottom: false, - child: Container( - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * 0.9, - ), - decoration: AppBottomSheetSurface.decoration, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 0), - child: Row( + final immersiveBackgroundStyle = ImmersiveBackgroundStyleX.fromStorageValue( + appPrefs.immersiveBackgroundStyle, + ); + return AppBottomSheetSurface( + padding: EdgeInsets.zero, + child: LayoutBuilder( + builder: (context, constraints) { + final isWide = + MediaQuery.sizeOf(context).width > + MediaQuery.sizeOf(context).height; + final header = Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 0), + child: Row( + children: [ + Icon( + Icons.dashboard_customize_rounded, + size: 20, + color: context.adaptiveTextSecondary, + ), + const SizedBox(width: 10), + Text( + l10n.playerLayout, + style: TextStyle( + fontFamily: 'GoogleSansFlex', + fontSize: 18, + fontWeight: FontWeight.w600, + color: context.adaptiveTextPrimary, + ), + ), + ], + ), + ); + final preview = Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: GestureDetector( + onTap: () => _showFullScreenPreview(), + child: Stack( children: [ - Icon( - Icons.dashboard_customize_rounded, - size: 20, - color: context.adaptiveTextSecondary, + _PlayerLayoutPreview( + song: widget.playerService.currentSongNotifier.value, + mode: _mode, + artworkCardArtworkScale: appPrefs.artworkCardArtworkScale, + artworkCardTextScale: appPrefs.artworkCardTextScale, + artworkCardVerticalOffset: + appPrefs.artworkCardVerticalOffset, + artworkCardArtworkOffset: appPrefs.artworkCardArtworkOffset, + artworkCardShowTitle: appPrefs.artworkCardShowTitle, + artworkCardShowArtist: appPrefs.artworkCardShowArtist, + artworkCardShowAlbum: appPrefs.artworkCardShowAlbum, + artworkCardShowFrame: appPrefs.artworkCardShowFrame, + immersiveTextScale: appPrefs.immersiveTextScale, + immersiveVerticalOffset: appPrefs.immersiveVerticalOffset, + immersiveFullViewScale: appPrefs.immersiveFullViewScale, + immersiveShowTitle: appPrefs.immersiveShowTitle, + immersiveShowArtist: appPrefs.immersiveShowArtist, + immersiveBackgroundStyle: immersiveBackgroundStyle, ), - const SizedBox(width: 10), - Text( - l10n.playerLayout, - style: TextStyle( - fontFamily: 'GoogleSansFlex', - fontSize: 18, - fontWeight: FontWeight.w600, - color: context.adaptiveTextPrimary, + Positioned( + top: 8, + right: 8, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.48), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.open_in_full_rounded, + size: 13, + color: Colors.white.withValues(alpha: 0.86), + ), + const SizedBox(width: 4), + Text( + l10n.fullscreen, + style: TextStyle( + fontFamily: 'GoogleSansFlex', + fontSize: 11, + fontWeight: FontWeight.w500, + color: Colors.white.withValues(alpha: 0.86), + ), + ), + ], + ), ), ), ], ), ), - const SizedBox(height: 12), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: GestureDetector( - onTap: () => _showFullScreenPreview(), - child: Stack( + ); + final settings = SingleChildScrollView( + // Surface reaches the bottom edge; content clears the system + // gesture/navigation inset. + padding: EdgeInsets.fromLTRB(20, 12, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _PlayerLayoutOptionTile( + title: PlayerScreenMode.immersive.labelFor(l10n), + subtitle: PlayerScreenMode.immersive.descriptionFor(l10n), + icon: Icons.fit_screen_rounded, + isSelected: _mode == PlayerScreenMode.immersive, + onTap: () { + unawaited(widget.onModeChanged(PlayerScreenMode.immersive)); + setState(() => _mode = PlayerScreenMode.immersive); + }, + ), + const SizedBox(height: 12), + _PlayerLayoutOptionTile( + title: PlayerScreenMode.artworkCard.labelFor(l10n), + subtitle: PlayerScreenMode.artworkCard.descriptionFor(l10n), + icon: Icons.rounded_corner_rounded, + isSelected: _mode == PlayerScreenMode.artworkCard, + onTap: () { + unawaited( + widget.onModeChanged(PlayerScreenMode.artworkCard), + ); + setState(() => _mode = PlayerScreenMode.artworkCard); + }, + ), + const SizedBox(height: 20), + _PlayerCustomizationGroup( + title: l10n.artworkCard, + icon: Icons.rounded_corner_rounded, children: [ - _PlayerLayoutPreview( - song: widget.playerService.currentSongNotifier.value, - mode: _mode, - artworkCardArtworkScale: - appPrefs.artworkCardArtworkScale, - artworkCardTextScale: appPrefs.artworkCardTextScale, - artworkCardVerticalOffset: - appPrefs.artworkCardVerticalOffset, - artworkCardArtworkOffset: - appPrefs.artworkCardArtworkOffset, - artworkCardShowTitle: appPrefs.artworkCardShowTitle, - artworkCardShowArtist: appPrefs.artworkCardShowArtist, - artworkCardShowAlbum: appPrefs.artworkCardShowAlbum, - artworkCardShowFrame: appPrefs.artworkCardShowFrame, - immersiveTextScale: appPrefs.immersiveTextScale, - immersiveVerticalOffset: appPrefs.immersiveVerticalOffset, - immersiveFullViewScale: appPrefs.immersiveFullViewScale, - immersiveShowTitle: appPrefs.immersiveShowTitle, - immersiveShowArtist: appPrefs.immersiveShowArtist, + _PlayerCustomizationSlider( + title: l10n.artworkSize, + value: appPrefs.artworkCardArtworkScale, + min: 0.8, + max: 1.36, + divisions: 28, + valueLabel: + '${(appPrefs.artworkCardArtworkScale * 100).round()}%', + onChanged: prefsNotifier.setArtworkCardArtworkScale, ), - Positioned( - top: 8, - right: 8, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.48), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.open_in_full_rounded, - size: 13, - color: Colors.white.withValues(alpha: 0.86), - ), - const SizedBox(width: 4), - Text( - l10n.fullscreen, - style: TextStyle( - fontFamily: 'GoogleSansFlex', - fontSize: 11, - fontWeight: FontWeight.w500, - color: Colors.white.withValues(alpha: 0.86), - ), - ), - ], - ), + _PlayerCustomizationSlider( + title: l10n.textSize, + value: appPrefs.artworkCardTextScale, + min: 0.82, + max: 1.2, + divisions: 19, + valueLabel: + '${(appPrefs.artworkCardTextScale * 100).round()}%', + onChanged: prefsNotifier.setArtworkCardTextScale, + ), + _PlayerCustomizationSlider( + title: l10n.contentPlacement, + value: appPrefs.artworkCardVerticalOffset, + min: -36, + max: 36, + divisions: 12, + valueLabel: _placementLabel( + appPrefs.artworkCardVerticalOffset, ), + onChanged: prefsNotifier.setArtworkCardVerticalOffset, + ), + _PlayerCustomizationSlider( + title: l10n.artworkPlacement, + value: appPrefs.artworkCardArtworkOffset, + min: -48, + max: 48, + divisions: 16, + valueLabel: _placementLabel( + appPrefs.artworkCardArtworkOffset, + ), + onChanged: prefsNotifier.setArtworkCardArtworkOffset, + ), + const SizedBox(height: 6), + _PlayerCustomizationToggle( + title: l10n.showTitle, + value: appPrefs.artworkCardShowTitle, + onChanged: prefsNotifier.setArtworkCardShowTitle, + ), + _PlayerCustomizationToggle( + title: l10n.showArtist, + value: appPrefs.artworkCardShowArtist, + onChanged: prefsNotifier.setArtworkCardShowArtist, + ), + _PlayerCustomizationToggle( + title: l10n.showAlbum, + value: appPrefs.artworkCardShowAlbum, + onChanged: prefsNotifier.setArtworkCardShowAlbum, + ), + _PlayerCustomizationToggle( + title: l10n.showFileInfo, + value: appPrefs.artworkCardShowFileInfo, + onChanged: prefsNotifier.setArtworkCardShowFileInfo, + ), + _PlayerCustomizationToggle( + title: l10n.showFrame, + value: appPrefs.artworkCardShowFrame, + onChanged: prefsNotifier.setArtworkCardShowFrame, ), ], ), - ), - ), - const SizedBox(height: 12), - Container( - height: 1, - margin: const EdgeInsets.symmetric(horizontal: 20), - color: AppColors.glassBorder, - ), - Flexible( - child: SingleChildScrollView( - // Surface reaches the bottom edge; content clears the system - // gesture/navigation inset. - padding: EdgeInsets.fromLTRB( - 20, - 12, - 20, - 24 + MediaQuery.paddingOf(context).bottom, + const SizedBox(height: 16), + _PlayerCustomizationGroup( + title: l10n.immersive, + icon: Icons.fit_screen_rounded, + children: [ + _PlayerBackgroundStyleSelector( + currentValue: immersiveBackgroundStyle, + onChanged: (style) { + prefsNotifier.setImmersiveBackgroundStyle( + style.storageValue, + ); + }, + ), + _PlayerCustomizationSlider( + title: l10n.textSize, + value: appPrefs.immersiveTextScale, + min: 0.82, + max: 1.2, + divisions: 19, + valueLabel: + '${(appPrefs.immersiveTextScale * 100).round()}%', + onChanged: prefsNotifier.setImmersiveTextScale, + ), + _PlayerCustomizationSlider( + title: l10n.textPlacement, + value: appPrefs.immersiveVerticalOffset, + min: -36, + max: 36, + divisions: 12, + valueLabel: _placementLabel( + appPrefs.immersiveVerticalOffset, + ), + onChanged: prefsNotifier.setImmersiveVerticalOffset, + ), + _PlayerCustomizationSlider( + title: l10n.fullViewCardSize, + value: appPrefs.immersiveFullViewScale, + min: 0.82, + max: 1.18, + divisions: 18, + valueLabel: + '${(appPrefs.immersiveFullViewScale * 100).round()}%', + onChanged: prefsNotifier.setImmersiveFullViewScale, + ), + const SizedBox(height: 6), + _PlayerCustomizationToggle( + title: l10n.showTitle, + value: appPrefs.immersiveShowTitle, + onChanged: prefsNotifier.setImmersiveShowTitle, + ), + _PlayerCustomizationToggle( + title: l10n.showArtist, + value: appPrefs.immersiveShowArtist, + onChanged: prefsNotifier.setImmersiveShowArtist, + ), + _PlayerCustomizationToggle( + title: l10n.showFileInfo, + value: appPrefs.immersiveShowFileInfo, + onChanged: prefsNotifier.setImmersiveShowFileInfo, + ), + ], ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + const SizedBox(height: 16), + _PlayerCustomizationGroup( + title: l10n.quickActions, + icon: Icons.swap_horiz_rounded, children: [ - _PlayerLayoutOptionTile( - title: PlayerScreenMode.immersive.labelFor(l10n), - subtitle: PlayerScreenMode.immersive.descriptionFor(l10n), - icon: Icons.fit_screen_rounded, - isSelected: _mode == PlayerScreenMode.immersive, - onTap: () { - unawaited(widget.onModeChanged(PlayerScreenMode.immersive)); - setState(() => _mode = PlayerScreenMode.immersive); + _PlayerActionButtonSelector( + label: l10n.leftTop, + currentValue: PlayerActionButtonX.fromStorageValue( + appPrefs.leftTopActionButton, + ), + onChanged: (action) { + prefsNotifier.setLeftTopActionButton( + action.storageValue, + ); }, ), - const SizedBox(height: 12), - _PlayerLayoutOptionTile( - title: PlayerScreenMode.artworkCard.labelFor(l10n), - subtitle: PlayerScreenMode.artworkCard.descriptionFor(l10n), - icon: Icons.rounded_corner_rounded, - isSelected: _mode == PlayerScreenMode.artworkCard, - onTap: () { - unawaited(widget.onModeChanged(PlayerScreenMode.artworkCard)); - setState(() => _mode = PlayerScreenMode.artworkCard); + const SizedBox(height: 8), + _PlayerActionButtonSelector( + label: l10n.leftBottom, + currentValue: PlayerActionButtonX.fromStorageValue( + appPrefs.leftActionButton, + ), + onChanged: (action) { + prefsNotifier.setLeftActionButton(action.storageValue); }, ), - const SizedBox(height: 20), - _PlayerCustomizationGroup( - title: l10n.artworkCard, - icon: Icons.rounded_corner_rounded, - children: [ - _PlayerCustomizationSlider( - title: l10n.artworkSize, - value: appPrefs.artworkCardArtworkScale, - min: 0.8, - max: 1.36, - divisions: 28, - valueLabel: - '${(appPrefs.artworkCardArtworkScale * 100).round()}%', - onChanged: prefsNotifier.setArtworkCardArtworkScale, - ), - _PlayerCustomizationSlider( - title: l10n.textSize, - value: appPrefs.artworkCardTextScale, - min: 0.82, - max: 1.2, - divisions: 19, - valueLabel: - '${(appPrefs.artworkCardTextScale * 100).round()}%', - onChanged: prefsNotifier.setArtworkCardTextScale, - ), - _PlayerCustomizationSlider( - title: l10n.contentPlacement, - value: appPrefs.artworkCardVerticalOffset, - min: -36, - max: 36, - divisions: 12, - valueLabel: _placementLabel( - appPrefs.artworkCardVerticalOffset, - ), - onChanged: prefsNotifier.setArtworkCardVerticalOffset, - ), - _PlayerCustomizationSlider( - title: l10n.artworkPlacement, - value: appPrefs.artworkCardArtworkOffset, - min: -48, - max: 48, - divisions: 16, - valueLabel: _placementLabel( - appPrefs.artworkCardArtworkOffset, - ), - onChanged: prefsNotifier.setArtworkCardArtworkOffset, - ), - const SizedBox(height: 6), - _PlayerCustomizationToggle( - title: l10n.showTitle, - value: appPrefs.artworkCardShowTitle, - onChanged: prefsNotifier.setArtworkCardShowTitle, - ), - _PlayerCustomizationToggle( - title: l10n.showArtist, - value: appPrefs.artworkCardShowArtist, - onChanged: prefsNotifier.setArtworkCardShowArtist, - ), - _PlayerCustomizationToggle( - title: l10n.showAlbum, - value: appPrefs.artworkCardShowAlbum, - onChanged: prefsNotifier.setArtworkCardShowAlbum, - ), - _PlayerCustomizationToggle( - title: l10n.showFileInfo, - value: appPrefs.artworkCardShowFileInfo, - onChanged: prefsNotifier.setArtworkCardShowFileInfo, - ), - _PlayerCustomizationToggle( - title: l10n.showFrame, - value: appPrefs.artworkCardShowFrame, - onChanged: prefsNotifier.setArtworkCardShowFrame, - ), - ], + const SizedBox(height: 8), + _PlayerActionButtonSelector( + label: l10n.rightTop, + currentValue: PlayerActionButtonX.fromStorageValue( + appPrefs.rightTopActionButton, + ), + onChanged: (action) { + prefsNotifier.setRightTopActionButton( + action.storageValue, + ); + }, ), - const SizedBox(height: 16), - _PlayerCustomizationGroup( - title: l10n.immersive, - icon: Icons.fit_screen_rounded, - children: [ - _PlayerCustomizationSlider( - title: l10n.textSize, - value: appPrefs.immersiveTextScale, - min: 0.82, - max: 1.2, - divisions: 19, - valueLabel: - '${(appPrefs.immersiveTextScale * 100).round()}%', - onChanged: prefsNotifier.setImmersiveTextScale, - ), - _PlayerCustomizationSlider( - title: l10n.textPlacement, - value: appPrefs.immersiveVerticalOffset, - min: -36, - max: 36, - divisions: 12, - valueLabel: _placementLabel( - appPrefs.immersiveVerticalOffset, - ), - onChanged: prefsNotifier.setImmersiveVerticalOffset, - ), - _PlayerCustomizationSlider( - title: l10n.fullViewCardSize, - value: appPrefs.immersiveFullViewScale, - min: 0.82, - max: 1.18, - divisions: 18, - valueLabel: - '${(appPrefs.immersiveFullViewScale * 100).round()}%', - onChanged: prefsNotifier.setImmersiveFullViewScale, - ), - const SizedBox(height: 6), - _PlayerCustomizationToggle( - title: l10n.showTitle, - value: appPrefs.immersiveShowTitle, - onChanged: prefsNotifier.setImmersiveShowTitle, - ), - _PlayerCustomizationToggle( - title: l10n.showArtist, - value: appPrefs.immersiveShowArtist, - onChanged: prefsNotifier.setImmersiveShowArtist, - ), - _PlayerCustomizationToggle( - title: l10n.showFileInfo, - value: appPrefs.immersiveShowFileInfo, - onChanged: prefsNotifier.setImmersiveShowFileInfo, - ), - ], + const SizedBox(height: 8), + _PlayerActionButtonSelector( + label: l10n.rightBottom, + currentValue: PlayerActionButtonX.fromStorageValue( + appPrefs.rightActionButton, + ), + onChanged: (action) { + prefsNotifier.setRightActionButton(action.storageValue); + }, ), - const SizedBox(height: 16), - _PlayerCustomizationGroup( - title: l10n.quickActions, - icon: Icons.swap_horiz_rounded, - children: [ - _PlayerActionButtonSelector( - label: l10n.leftTop, - currentValue: PlayerActionButtonX.fromStorageValue( - appPrefs.leftTopActionButton, - ), - onChanged: (action) { - prefsNotifier.setLeftTopActionButton( - action.storageValue, - ); - }, - ), - const SizedBox(height: 8), - _PlayerActionButtonSelector( - label: l10n.leftBottom, - currentValue: PlayerActionButtonX.fromStorageValue( - appPrefs.leftActionButton, - ), - onChanged: (action) { - prefsNotifier.setLeftActionButton( - action.storageValue, - ); - }, + ], + ), + const SizedBox(height: 20), + Row( + children: [ + Icon( + Icons.palette_outlined, + size: 20, + color: context.adaptiveTextSecondary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + l10n.albumColors, + style: TextStyle( + fontFamily: 'GoogleSansFlex', + fontSize: 18, + fontWeight: FontWeight.w600, + color: context.adaptiveTextPrimary, ), - const SizedBox(height: 8), - _PlayerActionButtonSelector( - label: l10n.rightTop, - currentValue: PlayerActionButtonX.fromStorageValue( - appPrefs.rightTopActionButton, - ), - onChanged: (action) { - prefsNotifier.setRightTopActionButton( - action.storageValue, - ); - }, + ), + ), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: AlbumColorMode.values.map((mode) { + final isSelected = colorMode == mode; + return GestureDetector( + onTap: () { + ref.read(albumColorModeProvider.notifier).setMode(mode); + }, + child: AnimatedContainer( + duration: AppConstants.animationFast, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, ), - const SizedBox(height: 8), - _PlayerActionButtonSelector( - label: l10n.rightBottom, - currentValue: PlayerActionButtonX.fromStorageValue( - appPrefs.rightActionButton, + decoration: BoxDecoration( + color: isSelected + ? AppColors.accent.withValues(alpha: 0.14) + : AppColors.glassBackground, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected + ? AppColors.accent.withValues(alpha: 0.6) + : AppColors.glassBorder, + width: 1, ), - onChanged: (action) { - prefsNotifier.setRightActionButton( - action.storageValue, - ); - }, - ), - ], - ), - const SizedBox(height: 20), - Row( - children: [ - Icon( - Icons.palette_outlined, - size: 20, - color: context.adaptiveTextSecondary, ), - const SizedBox(width: 10), - Text( - l10n.albumColors, + child: Text( + mode.labelFor(l10n), style: TextStyle( fontFamily: 'GoogleSansFlex', - fontSize: 18, - fontWeight: FontWeight.w600, - color: context.adaptiveTextPrimary, + fontSize: 14, + fontWeight: isSelected + ? FontWeight.bold + : FontWeight.normal, + color: isSelected + ? AppColors.textPrimary + : context.adaptiveTextSecondary, ), ), - ], - ), - const SizedBox(height: 12), - Wrap( - spacing: 8, - runSpacing: 8, - children: AlbumColorMode.values.map((mode) { - final isSelected = colorMode == mode; - return GestureDetector( - onTap: () { - ref - .read(albumColorModeProvider.notifier) - .setMode(mode); - }, - child: AnimatedContainer( - duration: AppConstants.animationFast, - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 10, - ), - decoration: BoxDecoration( - color: isSelected - ? AppColors.accent.withValues(alpha: 0.14) - : AppColors.glassBackground, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isSelected - ? AppColors.accent.withValues(alpha: 0.6) - : AppColors.glassBorder, - width: 1, - ), - ), - child: Text( - mode.labelFor(l10n), - style: TextStyle( - fontFamily: 'GoogleSansFlex', - fontSize: 14, - fontWeight: isSelected - ? FontWeight.bold - : FontWeight.normal, - color: isSelected - ? AppColors.textPrimary - : context.adaptiveTextSecondary, - ), - ), - ), - ); - }).toList(), - ), - ], + ), + ); + }).toList(), ), - ), + ], ), - ], - ), + ); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + header, + const SizedBox(height: 12), + Flexible( + child: isWide + ? Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: math.min(340.0, constraints.maxWidth * 0.38), + child: SingleChildScrollView(child: preview), + ), + Expanded(child: settings), + ], + ) + : Column( + mainAxisSize: MainAxisSize.min, + children: [ + preview, + const SizedBox(height: 12), + Divider(height: 1, color: AppColors.glassBorder), + Flexible(child: settings), + ], + ), + ), + ], + ); + }, ), ); } @@ -501,6 +521,10 @@ class _PlayerLayoutSheetState extends ConsumerState { immersiveShowTitle: appPrefs.immersiveShowTitle, immersiveShowArtist: appPrefs.immersiveShowArtist, immersiveShowFileInfo: appPrefs.immersiveShowFileInfo, + immersiveBackgroundStyle: + ImmersiveBackgroundStyleX.fromStorageValue( + appPrefs.immersiveBackgroundStyle, + ), ), ), Positioned( @@ -689,13 +713,15 @@ class _PlayerCustomizationGroup extends StatelessWidget { children: [ Icon(icon, size: 18, color: context.adaptiveTextSecondary), const SizedBox(width: 8), - Text( - title, - style: TextStyle( - fontFamily: 'GoogleSansFlex', - fontSize: 15, - fontWeight: FontWeight.w600, - color: context.adaptiveTextPrimary, + Expanded( + child: Text( + title, + style: TextStyle( + fontFamily: 'GoogleSansFlex', + fontSize: 15, + fontWeight: FontWeight.w600, + color: context.adaptiveTextPrimary, + ), ), ), ], @@ -726,13 +752,15 @@ class _PlayerCustomizationToggle extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - title, - style: TextStyle( - fontFamily: 'GoogleSansFlex', - fontSize: 13, - fontWeight: FontWeight.w600, - color: context.adaptiveTextPrimary, + Expanded( + child: Text( + title, + style: TextStyle( + fontFamily: 'GoogleSansFlex', + fontSize: 13, + fontWeight: FontWeight.w600, + color: context.adaptiveTextPrimary, + ), ), ), Switch( @@ -775,15 +803,18 @@ class _PlayerCustomizationSlider extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - title, - style: TextStyle( - fontFamily: 'GoogleSansFlex', - fontSize: 13, - fontWeight: FontWeight.w600, - color: context.adaptiveTextPrimary, + Expanded( + child: Text( + title, + style: TextStyle( + fontFamily: 'GoogleSansFlex', + fontSize: 13, + fontWeight: FontWeight.w600, + color: context.adaptiveTextPrimary, + ), ), ), + const SizedBox(width: 12), Text( valueLabel, style: TextStyle( @@ -815,6 +846,94 @@ class _PlayerCustomizationSlider extends StatelessWidget { } } +class _PlayerBackgroundStyleSelector extends StatelessWidget { + final ImmersiveBackgroundStyle currentValue; + final ValueChanged onChanged; + + const _PlayerBackgroundStyleSelector({ + required this.currentValue, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.background, + style: TextStyle( + fontFamily: 'GoogleSansFlex', + fontSize: 13, + fontWeight: FontWeight.w600, + color: context.adaptiveTextPrimary, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: ImmersiveBackgroundStyle.values.map((style) { + final isSelected = style == currentValue; + return GestureDetector( + onTap: () => onChanged(style), + child: AnimatedContainer( + duration: AppConstants.animationFast, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: isSelected + ? AppColors.accent.withValues(alpha: 0.18) + : AppColors.glassBackground, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isSelected + ? AppColors.accent.withValues(alpha: 0.6) + : AppColors.glassBorder, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + style == ImmersiveBackgroundStyle.colorGradient + ? Icons.gradient_rounded + : Icons.blur_on_rounded, + size: 14, + color: isSelected + ? AppColors.accent + : context.adaptiveTextSecondary, + ), + const SizedBox(width: 4), + Flexible( + child: Text( + style.labelFor(l10n), + style: TextStyle( + fontFamily: 'GoogleSansFlex', + fontSize: 12, + fontWeight: isSelected + ? FontWeight.bold + : FontWeight.normal, + color: isSelected + ? AppColors.textPrimary + : context.adaptiveTextSecondary, + ), + ), + ), + ], + ), + ), + ); + }).toList(), + ), + const SizedBox(height: 6), + ], + ); + } +} + class _PlayerLayoutPreview extends StatelessWidget { final Song? song; final PlayerScreenMode mode; @@ -831,6 +950,7 @@ class _PlayerLayoutPreview extends StatelessWidget { final double immersiveFullViewScale; final bool immersiveShowTitle; final bool immersiveShowArtist; + final ImmersiveBackgroundStyle immersiveBackgroundStyle; const _PlayerLayoutPreview({ required this.song, @@ -848,6 +968,7 @@ class _PlayerLayoutPreview extends StatelessWidget { required this.immersiveFullViewScale, this.immersiveShowTitle = true, this.immersiveShowArtist = true, + this.immersiveBackgroundStyle = ImmersiveBackgroundStyle.colorGradient, }); @override @@ -951,13 +1072,11 @@ class _PlayerLayoutPreview extends StatelessWidget { return Stack( children: [ Positioned.fill( - child: Opacity( - opacity: 0.28, - child: _PreviewAlbumArt( - song: song, - size: double.infinity, - radius: 0, - ), + child: _ImmersivePreviewBackground( + song: song, + backgroundStyle: immersiveBackgroundStyle, + artOpacity: 0.28, + blurSigma: 6, ), ), Positioned( @@ -1028,6 +1147,58 @@ class _PlayerLayoutPreview extends StatelessWidget { } } +class _ImmersivePreviewBackground extends StatelessWidget { + final Song? song; + final ImmersiveBackgroundStyle backgroundStyle; + final double artOpacity; + final double blurSigma; + + const _ImmersivePreviewBackground({ + required this.song, + required this.backgroundStyle, + required this.artOpacity, + required this.blurSigma, + }); + + @override + Widget build(BuildContext context) { + final useBlur = backgroundStyle == ImmersiveBackgroundStyle.blurGradient; + return Stack( + fit: StackFit.expand, + children: [ + Opacity( + opacity: useBlur ? math.min(1.0, artOpacity + 0.18) : artOpacity, + child: _PreviewAlbumArt(song: song, size: double.infinity, radius: 0), + ), + if (useBlur) + ShaderMask( + shaderCallback: (bounds) => const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.transparent, Colors.white], + stops: [0.0, 0.42, 0.82], + ).createShader(bounds), + blendMode: BlendMode.dstIn, + child: ImageFiltered( + imageFilter: ui.ImageFilter.blur( + sigmaX: blurSigma, + sigmaY: blurSigma, + ), + child: Opacity( + opacity: 0.86, + child: _PreviewAlbumArt( + song: song, + size: double.infinity, + radius: 0, + ), + ), + ), + ), + ], + ); + } +} + class _PreviewAlbumArt extends StatelessWidget { final Song? song; final double size; @@ -1094,6 +1265,7 @@ class _FullScreenPreview extends StatelessWidget { final bool immersiveShowTitle; final bool immersiveShowArtist; final bool immersiveShowFileInfo; + final ImmersiveBackgroundStyle immersiveBackgroundStyle; const _FullScreenPreview({ required this.song, @@ -1113,6 +1285,7 @@ class _FullScreenPreview extends StatelessWidget { this.immersiveShowTitle = true, this.immersiveShowArtist = true, this.immersiveShowFileInfo = true, + this.immersiveBackgroundStyle = ImmersiveBackgroundStyle.colorGradient, }); @override @@ -1222,13 +1395,11 @@ class _FullScreenPreview extends StatelessWidget { return Stack( children: [ Positioned.fill( - child: Opacity( - opacity: 0.32, - child: _PreviewAlbumArt( - song: song, - size: double.infinity, - radius: 0, - ), + child: _ImmersivePreviewBackground( + song: song, + backgroundStyle: immersiveBackgroundStyle, + artOpacity: 0.32, + blurSigma: 18, ), ), Positioned( diff --git a/lib/features/player/widgets/rating_button.dart b/lib/features/player/widgets/rating_button.dart index eda875e4c..15dd2bc77 100644 --- a/lib/features/player/widgets/rating_button.dart +++ b/lib/features/player/widgets/rating_button.dart @@ -13,6 +13,9 @@ class RatingButton extends StatefulWidget { final Color inactiveBg; final Color inactiveBorder; + /// Draws the icon without a chip, used by the landscape action row. + final bool flat; + const RatingButton({ super.key, required this.currentRating, @@ -24,6 +27,7 @@ class RatingButton extends StatefulWidget { this.accentBlend = 0, required this.inactiveBg, required this.inactiveBorder, + this.flat = false, }); @override @@ -94,8 +98,9 @@ class _RatingButtonState extends State Widget _buildIconButton() { final rating = widget.currentRating; final active = rating > 0; + final activeColor = _activeColor(); final bgColor = active - ? _activeColor().withValues(alpha: 0.28) + ? activeColor.withValues(alpha: 0.28) : widget.inactiveBg; final icon = active ? Icons.star : LucideIcons.star; @@ -110,17 +115,25 @@ class _RatingButtonState extends State }, child: Container( padding: widget.padding, - decoration: BoxDecoration( - color: bgColor, - borderRadius: BorderRadius.circular(widget.borderRadius), - border: active - ? Border.all(color: _activeColor().withValues(alpha: 0.45)) - : Border.all(color: widget.inactiveBorder), - ), + decoration: widget.flat + ? null + : BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(widget.borderRadius), + border: active + ? Border.all(color: activeColor.withValues(alpha: 0.45)) + : Border.all(color: widget.inactiveBorder), + ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, color: Colors.white.withValues(alpha: 0.96), size: widget.iconSize), + Icon( + icon, + color: widget.flat && active + ? activeColor + : Colors.white.withValues(alpha: 0.96), + size: widget.iconSize, + ), if (rating > 0) ...[ SizedBox(width: widget.iconSize * 0.2), Text( @@ -162,9 +175,13 @@ class _RatingButtonState extends State children: List.generate(5, (i) { final starIndex = i + 1; final delay = i * 0.08; - final starProgress = (_starsController.value - delay) - .clamp(0.0, 1.0); - final easedProgress = Curves.easeOutBack.transform(starProgress).clamp(0.001, double.infinity); + final starProgress = (_starsController.value - delay).clamp( + 0.0, + 1.0, + ); + final easedProgress = Curves.easeOutBack + .transform(starProgress) + .clamp(0.001, double.infinity); final isFilled = starIndex <= (widget.currentRating); final isHovered = _hoveredStar > 0 && starIndex <= _hoveredStar; @@ -186,7 +203,10 @@ class _RatingButtonState extends State final box = context.findRenderObject() as RenderBox; final localPos = box.globalToLocal(details.globalPosition); final starWidth = box.size.width / 5; - final hovered = ((localPos.dx / starWidth).ceil()).clamp(0, 5); + final hovered = ((localPos.dx / starWidth).ceil()).clamp( + 0, + 5, + ); if (hovered != _hoveredStar) { setState(() => _hoveredStar = hovered); } @@ -195,7 +215,10 @@ class _RatingButtonState extends State final box = context.findRenderObject() as RenderBox; final localPos = box.globalToLocal(details.globalPosition); final starWidth = box.size.width / 5; - final hovered = ((localPos.dx / starWidth).ceil()).clamp(0, 5); + final hovered = ((localPos.dx / starWidth).ceil()).clamp( + 0, + 5, + ); if (hovered != _hoveredStar) { setState(() => _hoveredStar = hovered); } @@ -207,7 +230,9 @@ class _RatingButtonState extends State horizontal: widget.iconSize * 0.08, ), child: Icon( - (isFilled || isHovered) ? Icons.star : Icons.star_border, + (isFilled || isHovered) + ? Icons.star + : Icons.star_border, color: (isFilled || isHovered) ? Colors.amber.shade400 : Colors.white.withValues(alpha: 0.4), @@ -223,4 +248,4 @@ class _RatingButtonState extends State ), ); } -} \ No newline at end of file +} diff --git a/lib/features/player/widgets/share/share_bottom_sheet.dart b/lib/features/player/widgets/share/share_bottom_sheet.dart index a49a74fa5..a7da7a43b 100644 --- a/lib/features/player/widgets/share/share_bottom_sheet.dart +++ b/lib/features/player/widgets/share/share_bottom_sheet.dart @@ -221,13 +221,13 @@ class _ShareBottomSheetState extends ConsumerState { Widget build(BuildContext context) { final templates = ShareTemplate.values; - return Container( - decoration: AppBottomSheetSurface.decoration, - padding: EdgeInsets.fromLTRB( + return AppBottomSheetSurface( + scrollable: true, + padding: const EdgeInsets.fromLTRB( 20, 16, 20, - 24 + MediaQuery.paddingOf(context).bottom, + 24, ), child: Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/features/player/widgets/sleep_timer_bottom_sheet.dart b/lib/features/player/widgets/sleep_timer_bottom_sheet.dart index 82531568b..0b95d71da 100644 --- a/lib/features/player/widgets/sleep_timer_bottom_sheet.dart +++ b/lib/features/player/widgets/sleep_timer_bottom_sheet.dart @@ -15,6 +15,7 @@ class SleepTimerBottomSheet extends StatefulWidget { useRootNavigator: true, context: context, backgroundColor: Colors.transparent, + isScrollControlled: true, builder: (context) => SleepTimerBottomSheet(playerService: playerService), ); } @@ -31,13 +32,13 @@ class _SleepTimerBottomSheetState extends State { @override Widget build(BuildContext context) { - return Container( - decoration: AppBottomSheetSurface.decoration, - padding: EdgeInsets.fromLTRB( + return AppBottomSheetSurface( + scrollable: true, + padding: const EdgeInsets.fromLTRB( 20, 16, 20, - 24 + MediaQuery.paddingOf(context).bottom, + 24, ), child: Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/features/player/widgets/song_actions_sheet.dart b/lib/features/player/widgets/song_actions_sheet.dart index 6bbd90a11..8581ab15e 100644 --- a/lib/features/player/widgets/song_actions_sheet.dart +++ b/lib/features/player/widgets/song_actions_sheet.dart @@ -76,319 +76,309 @@ class SongActionsSheet extends ConsumerWidget { @override Widget build(BuildContext sheetContext, WidgetRef ref) { final context = parentContext; - return ValueListenableBuilder( - valueListenable: playerService.currentSongNotifier, - builder: (sheetContext, currentSong, _) { - final activeSong = currentSong ?? song; - return SafeArea( - top: false, - bottom: false, - child: Container( - constraints: BoxConstraints( - maxHeight: MediaQuery.of(sheetContext).size.height * 0.5, - ), - decoration: AppBottomSheetSurface.decoration, - padding: EdgeInsets.fromLTRB( - context.responsive(16.0, 18.0, 20.0), - context.responsive(10.0, 11.0, 12.0), - context.responsive(16.0, 18.0, 20.0), - // Surface reaches the bottom edge; content clears the system - // gesture/navigation inset. - context.responsive(20.0, 22.0, 24.0) + - MediaQuery.paddingOf(sheetContext).bottom, + return ValueListenableBuilder( + valueListenable: playerService.currentSongNotifier, + builder: (sheetContext, currentSong, _) { + final activeSong = currentSong ?? song; + return AppBottomSheetSurface( + padding: EdgeInsets.fromLTRB( + context.responsive(16.0, 18.0, 20.0), + context.responsive(10.0, 11.0, 12.0), + context.responsive(16.0, 18.0, 20.0), + context.responsive(20.0, 22.0, 24.0), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppColors.glassBorderStrong, + borderRadius: BorderRadius.circular(4), + ), ), - child: Column( - mainAxisSize: MainAxisSize.min, + const SizedBox(height: 16), + Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: AppColors.glassBorderStrong, - borderRadius: BorderRadius.circular(4), + ClipRRect( + borderRadius: BorderRadius.circular( + context.responsive(10.0, 11.0, 12.0), ), - ), - const SizedBox(height: 16), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular( - context.responsive(10.0, 11.0, 12.0), + child: SizedBox( + width: context.responsive(56.0, 62.0, 68.0), + height: context.responsive(56.0, 62.0, 68.0), + child: CachedImageWidget( + imagePath: activeSong.albumArt, + audioSourcePath: activeSong.filePath, + fit: BoxFit.cover, + useThumbnail: true, + thumbnailWidth: 136, + thumbnailHeight: 136, + placeholder: Container( + color: AppColors.surfaceLight, + child: const FlickArtworkPlaceholder( + size: 28, + opacity: 0.9, + ), ), - child: SizedBox( - width: context.responsive(56.0, 62.0, 68.0), - height: context.responsive(56.0, 62.0, 68.0), - child: CachedImageWidget( - imagePath: activeSong.albumArt, - audioSourcePath: activeSong.filePath, - fit: BoxFit.cover, - useThumbnail: true, - thumbnailWidth: 136, - thumbnailHeight: 136, - placeholder: Container( - color: AppColors.surfaceLight, - child: const FlickArtworkPlaceholder( - size: 28, - opacity: 0.9, - ), - ), - errorWidget: Container( - color: AppColors.surfaceLight, - child: const FlickArtworkPlaceholder( - size: 28, - opacity: 0.9, - ), - ), + errorWidget: Container( + color: AppColors.surfaceLight, + child: const FlickArtworkPlaceholder( + size: 28, + opacity: 0.9, ), ), ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + activeSong.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontFamily: 'GoogleSansFlex', + fontSize: context.responsive(16.0, 17.0, 18.0), + fontWeight: FontWeight.w600, + color: sheetContext.adaptiveTextPrimary, + ), + ), + const SizedBox(height: 4), + Text( + activeSong.artist, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontFamily: 'GoogleSansFlex', + fontSize: context.responsive(12.0, 13.0, 14.0), + color: sheetContext.adaptiveTextSecondary, + ), + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, children: [ - Text( - activeSong.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontFamily: 'GoogleSansFlex', - fontSize: context.responsive(16.0, 17.0, 18.0), - fontWeight: FontWeight.w600, - color: sheetContext.adaptiveTextPrimary, - ), - ), - const SizedBox(height: 4), - Text( - activeSong.artist, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontFamily: 'GoogleSansFlex', - fontSize: context.responsive(12.0, 13.0, 14.0), - color: sheetContext.adaptiveTextSecondary, - ), + _buildSongInfoChip( + sheetContext, + activeSong.formattedDuration, ), - const SizedBox(height: 10), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _buildSongInfoChip( - sheetContext, - activeSong.formattedDuration, - ), - _buildSongInfoChip( - sheetContext, - activeSong.fileType.toUpperCase(), - ), - ], + _buildSongInfoChip( + sheetContext, + activeSong.fileType.toUpperCase(), ), ], ), - ), - ], + ], + ), ), - const SizedBox(height: 16), - Flexible( - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (!activeSong.isFromLocker) - _buildSongActionTile( - context: sheetContext, - icon: LucideIcons.listPlus, - label: l10n.playerMenuAddToQueue, - onTap: () async { - Navigator.pop(sheetContext); - await navigation.queueSong(context, activeSong); - }, - ), - _buildSongActionTile( - context: sheetContext, - icon: LucideIcons.listMusic, - label: l10n.playerMenuAddToPlaylist, - onTap: () { - Navigator.pop(sheetContext); - AddToPlaylistSheet.show(context, activeSong); - }, - ), - _buildSongActionTile( - context: sheetContext, - icon: LucideIcons.image, - label: l10n.playerMenuSetAlbumArt, - onTap: () { - Navigator.pop(sheetContext); - Future.delayed(Duration.zero, () async { - final changed = - await AlbumArtPickerBottomSheet.show( - context, - activeSong, - ); - if (changed && context.mounted) { - ref.invalidate(songsProvider); - } - }); - }, - ), - _buildSongActionTile( - context: sheetContext, - icon: LucideIcons.refreshCw, - label: l10n.playerMenuRefreshAlbumArt, - onTap: () async { - Navigator.pop(sheetContext); - final albumArtist = activeSong.albumArtist; - await AnimatedArtworkService.instance - .refreshAlbumArtwork( - artist: - (albumArtist != null && - albumArtist.trim().isNotEmpty) - ? albumArtist.trim() - : activeSong.artist, - albumName: activeSong.album, - songTitle: activeSong.title, - ); - if (context.mounted) { - ScaffoldMessenger.of(context).showFlickSnackBar( - SnackBar( - content: Text(l10n.englishForFilipino.motionArtRefreshed), - duration: Duration(seconds: 2), - ), + ], + ), + const SizedBox(height: 16), + Flexible( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!activeSong.isFromLocker) + _buildSongActionTile( + context: sheetContext, + icon: LucideIcons.listPlus, + label: l10n.playerMenuAddToQueue, + onTap: () async { + Navigator.pop(sheetContext); + await navigation.queueSong(context, activeSong); + }, + ), + _buildSongActionTile( + context: sheetContext, + icon: LucideIcons.listMusic, + label: l10n.playerMenuAddToPlaylist, + onTap: () { + Navigator.pop(sheetContext); + AddToPlaylistSheet.show(context, activeSong); + }, + ), + _buildSongActionTile( + context: sheetContext, + icon: LucideIcons.image, + label: l10n.playerMenuSetAlbumArt, + onTap: () { + Navigator.pop(sheetContext); + Future.delayed(Duration.zero, () async { + final changed = + await AlbumArtPickerBottomSheet.show( + context, + activeSong, ); - } - }, - ), - if (activeSong.filePath != null && - activeSong.startOffsetMs == null && - !activeSong.isExternal) - _buildSongActionTile( - context: sheetContext, - icon: LucideIcons.pencil, - label: l10n.playerMenuEditMetadata, - onTap: () { - Navigator.pop(sheetContext); - Future.delayed(Duration.zero, () async { - final saved = - await MetadataEditorBottomSheet.show( + if (changed && context.mounted) { + ref.invalidate(songsProvider); + } + }); + }, + ), + _buildSongActionTile( + context: sheetContext, + icon: LucideIcons.refreshCw, + label: l10n.playerMenuRefreshAlbumArt, + onTap: () async { + Navigator.pop(sheetContext); + final albumArtist = activeSong.albumArtist; + await AnimatedArtworkService.instance + .refreshAlbumArtwork( + artist: + (albumArtist != null && + albumArtist.trim().isNotEmpty) + ? albumArtist.trim() + : activeSong.artist, + albumName: activeSong.album, + songTitle: activeSong.title, + ); + if (context.mounted) { + ScaffoldMessenger.of(context).showFlickSnackBar( + SnackBar( + content: Text( + l10n.englishForFilipino.motionArtRefreshed, + ), + duration: Duration(seconds: 2), + ), + ); + } + }, + ), + if (activeSong.filePath != null && + activeSong.startOffsetMs == null && + !activeSong.isExternal) + _buildSongActionTile( + context: sheetContext, + icon: LucideIcons.pencil, + label: l10n.playerMenuEditMetadata, + onTap: () { + Navigator.pop(sheetContext); + Future.delayed(Duration.zero, () async { + final saved = + await MetadataEditorBottomSheet.show( context, activeSong, ); - if (saved && context.mounted) { - ref.invalidate(songsProvider); - } - }); - }, - ), - _buildSongActionTile( - context: sheetContext, - icon: LucideIcons.info, - label: l10n.viewMetadata, - onTap: () { - Navigator.pop(sheetContext); - SongMetadataSheet.show(context, activeSong); - }, - ), - _buildSongActionTile( - context: sheetContext, - icon: LucideIcons.fileText, - label: l10n.lyrics, - onTap: () { - Navigator.pop(sheetContext); - onShowLyrics(); - }, - ), - _buildSongActionTile( - context: sheetContext, - icon: Icons.graphic_eq_rounded, - label: isVisualizationMode - ? l10n.hideVisualizer2 - : l10n.visualizer, - onTap: () { - Navigator.pop(sheetContext); - onToggleVisualization(!isVisualizationMode); - }, - ), - _buildSongActionTile( - context: sheetContext, - icon: LucideIcons.user, - label: l10n.goToArtist, - onTap: () { - Navigator.pop(sheetContext); - navigation.openArtistFromSong(context, activeSong); - }, - ), - _buildSongActionTile( - context: sheetContext, - icon: LucideIcons.disc, - label: l10n.goToAlbum, - onTap: () { - Navigator.pop(sheetContext); - navigation.openAlbumFromSong(context, activeSong); - }, - ), - _buildSongActionTile( - context: sheetContext, - icon: Icons.dashboard_customize_rounded, - label: l10n.playerLayout, - onTap: () { - Navigator.pop(sheetContext); - onShowPlayerLayout(context); - }, - ), - _buildSongActionTile( - context: sheetContext, - icon: LucideIcons.gauge, - label: l10n.playbackSpeed, - onTap: () { - Navigator.pop(sheetContext); - SpeedBottomSheet.show(context, playerService); - }, - ), - _buildSongActionTile( - context: sheetContext, - icon: LucideIcons.music, - label: l10n.pitch, - onTap: () { - Navigator.pop(sheetContext); - PitchBottomSheet.show(context, playerService); - }, - ), - _buildSongActionTile( - context: sheetContext, - icon: LucideIcons.moonStar, - label: l10n.playerMenuSleepTimer, - onTap: () { - Navigator.pop(sheetContext); - SleepTimerBottomSheet.show(context, playerService); - }, - ), - _buildSongActionTile( - context: sheetContext, - icon: LucideIcons.share2, - label: l10n.playerMenuShare, - onTap: () { - Navigator.pop(sheetContext); - showModalBottomSheet( - useRootNavigator: true, - context: context, - backgroundColor: Colors.transparent, - isScrollControlled: true, - builder: (_) => - ShareBottomSheet(song: activeSong), - ); - }, - ), - ], + if (saved && context.mounted) { + ref.invalidate(songsProvider); + } + }); + }, + ), + _buildSongActionTile( + context: sheetContext, + icon: LucideIcons.info, + label: l10n.viewMetadata, + onTap: () { + Navigator.pop(sheetContext); + SongMetadataSheet.show(context, activeSong); + }, ), - ), + _buildSongActionTile( + context: sheetContext, + icon: LucideIcons.fileText, + label: l10n.lyrics, + onTap: () { + Navigator.pop(sheetContext); + onShowLyrics(); + }, + ), + _buildSongActionTile( + context: sheetContext, + icon: Icons.graphic_eq_rounded, + label: isVisualizationMode + ? l10n.hideVisualizer2 + : l10n.visualizer, + onTap: () { + Navigator.pop(sheetContext); + onToggleVisualization(!isVisualizationMode); + }, + ), + _buildSongActionTile( + context: sheetContext, + icon: LucideIcons.user, + label: l10n.goToArtist, + onTap: () { + Navigator.pop(sheetContext); + navigation.openArtistFromSong(context, activeSong); + }, + ), + _buildSongActionTile( + context: sheetContext, + icon: LucideIcons.disc, + label: l10n.goToAlbum, + onTap: () { + Navigator.pop(sheetContext); + navigation.openAlbumFromSong(context, activeSong); + }, + ), + _buildSongActionTile( + context: sheetContext, + icon: Icons.dashboard_customize_rounded, + label: l10n.playerLayout, + onTap: () { + Navigator.pop(sheetContext); + onShowPlayerLayout(context); + }, + ), + _buildSongActionTile( + context: sheetContext, + icon: LucideIcons.gauge, + label: l10n.playbackSpeed, + onTap: () { + Navigator.pop(sheetContext); + SpeedBottomSheet.show(context, playerService); + }, + ), + _buildSongActionTile( + context: sheetContext, + icon: LucideIcons.music, + label: l10n.pitch, + onTap: () { + Navigator.pop(sheetContext); + PitchBottomSheet.show(context, playerService); + }, + ), + _buildSongActionTile( + context: sheetContext, + icon: LucideIcons.moonStar, + label: l10n.playerMenuSleepTimer, + onTap: () { + Navigator.pop(sheetContext); + SleepTimerBottomSheet.show(context, playerService); + }, + ), + _buildSongActionTile( + context: sheetContext, + icon: LucideIcons.share2, + label: l10n.playerMenuShare, + onTap: () { + Navigator.pop(sheetContext); + showModalBottomSheet( + useRootNavigator: true, + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (_) => ShareBottomSheet(song: activeSong), + ); + }, + ), + ], ), - ], + ), ), - ), - ); - }, + ], + ), + ); + }, ); } diff --git a/lib/features/player/widgets/song_metadata_sheet.dart b/lib/features/player/widgets/song_metadata_sheet.dart index 0d6dd7a61..f5be87191 100644 --- a/lib/features/player/widgets/song_metadata_sheet.dart +++ b/lib/features/player/widgets/song_metadata_sheet.dart @@ -21,13 +21,13 @@ class SongMetadataSheet extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - decoration: AppBottomSheetSurface.decoration, - padding: EdgeInsets.fromLTRB( + return AppBottomSheetSurface( + scrollable: true, + padding: const EdgeInsets.fromLTRB( 20, 16, 20, - 24 + MediaQuery.paddingOf(context).bottom, + 24, ), child: Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/features/player/widgets/song_stage.dart b/lib/features/player/widgets/song_stage.dart index cb2d1a04a..ff4487136 100644 --- a/lib/features/player/widgets/song_stage.dart +++ b/lib/features/player/widgets/song_stage.dart @@ -20,6 +20,19 @@ import 'package:flick/features/player/widgets/waveform_layer.dart'; import 'package:flick/widgets/common/cached_image_widget.dart'; import 'package:flick/widgets/common/flick_artwork_placeholder.dart'; +/// Which part of the stage a layout needs. +/// +/// The portrait player always uses [full]. The landscape player splits the +/// same stage across two panes so the artwork keeps its square aspect ratio +/// while playback controls stay pinned beside it. +enum SongStagePane { + full, + artworkOnly, + backdropOnly, + immersiveIdentity, + artworkCardDetails, +} + /// The sliding portion of the player: album art + title/artist/album + /// file-info row + waveform. Background, top chrome and [PlayerControls] /// stay pinned — only this stage translates during a swipe. @@ -27,8 +40,6 @@ import 'package:flick/widgets/common/flick_artwork_placeholder.dart'; /// The file-info row keeps its layout slot even when hidden; the row is /// then drawn pinned by the parent screen ([fileInfoRowVisible]). class SongStage extends ConsumerWidget { - static const double _shortHeightThreshold = 620.0; - final Song song; final bool lyricsMode; final bool visualizationMode; @@ -71,6 +82,9 @@ class SongStage extends ConsumerWidget { final VoidCallback onToggleLyrics; final bool showWaveform; + /// Selects the full portrait stage or one pane of the landscape layout. + final SongStagePane pane; + /// Gates Motion Art on the album art box (e.g. only the settled carousel /// page keeps video running). final bool motionArtEnabled; @@ -119,6 +133,7 @@ class SongStage extends ConsumerWidget { this.onVinylChanged, required this.onToggleLyrics, this.showWaveform = true, + this.pane = SongStagePane.full, this.motionArtEnabled = true, this.active = true, }); @@ -127,12 +142,11 @@ class SongStage extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { // Rebuild the capsule when verification settles, not on every diagnostic tick. ref.watch(uac2BitPerfectIndicatorProvider); - final isShortHeight = - MediaQuery.sizeOf(context).height < _shortHeightThreshold; + if (pane != SongStagePane.full) { + return _buildPane(context); + } final showImmersiveFullView = - immersiveFullView && - playerScreenMode == PlayerScreenMode.immersive && - !isShortHeight; + immersiveFullView && playerScreenMode == PlayerScreenMode.immersive; final showVisualizerOnly = visualizationMode && showImmersiveFullView; // Inner switcher: immersive full-view vs default vs lyrics — keep the @@ -184,6 +198,73 @@ class SongStage extends ConsumerWidget { ); } + // --------------------------------------------------------------------------- + // Landscape panes + // --------------------------------------------------------------------------- + + Widget _buildPane(BuildContext context) { + switch (pane) { + case SongStagePane.full: + case SongStagePane.backdropOnly: + return const SizedBox.shrink(); + case SongStagePane.artworkOnly: + return LayoutBuilder( + builder: (context, constraints) { + final extent = math.min( + constraints.maxWidth, + constraints.maxHeight, + ); + if (extent < 80) return const SizedBox.shrink(); + final artworkSize = math + .max(80.0, extent * 0.92 * artworkCardArtworkScale) + .clamp(80.0, extent) + .toDouble(); + return Center( + child: SizedBox( + width: artworkSize, + height: artworkSize, + child: visualizationMode + ? VisualizerArtBox( + playerService: playerService, + size: artworkSize, + animationStyle: visualizerAnimationStyle, + frequencyMode: visualizerFrequencyMode, + movementMode: visualizerMovementMode, + albumColor: albumColor, + showFrame: artworkCardShowFrame, + ) + : AlbumArtBox( + song: song, + size: artworkSize, + playerService: playerService, + onRotationEnabledChanged: onRotationEnabledChanged, + initialVinyl: vinylMode, + onVinylChanged: onVinylChanged, + showFrame: artworkCardShowFrame, + motionArtEnabled: motionArtEnabled, + ), + ), + ); + }, + ); + case SongStagePane.immersiveIdentity: + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Transform.translate( + offset: Offset(0, immersiveVerticalOffset.clamp(-40.0, 40.0)), + child: _buildImmersiveSongHeader(context, landscape: true), + ), + ), + ); + case SongStagePane.artworkCardDetails: + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: _buildSongIdentity(context, compact: true, landscape: true), + ); + } + } + // --------------------------------------------------------------------------- // Immersive // --------------------------------------------------------------------------- @@ -381,98 +462,107 @@ class SongStage extends ConsumerWidget { context.responsive(16.0, 20.0, 24.0), context.responsive(20.0, 24.0, 28.0), ), - child: Container( - padding: EdgeInsets.all( - context.responsive(10.0, 12.0, 14.0) * immersiveFullViewScale, - ), - decoration: BoxDecoration( - color: const Color(0xFF121212).withValues(alpha: 0.72), - borderRadius: BorderRadius.circular(24), - border: Border.all(color: Colors.white.withValues(alpha: 0.08)), - ), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular( - 18 * immersiveFullViewScale, - ), - child: SizedBox( - width: artworkSize, - height: artworkSize, - child: CachedImageWidget( - imagePath: song.albumArt, - audioSourcePath: song.filePath, - fit: BoxFit.cover, - placeholder: Container( - color: Colors.white.withValues(alpha: 0.05), - child: FlickArtworkPlaceholder( - size: artworkSize * 0.44, - opacity: 0.92, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: Container( + padding: EdgeInsets.all( + context.responsive(10.0, 12.0, 14.0) * immersiveFullViewScale, + ), + decoration: BoxDecoration( + color: const Color(0xFF121212).withValues(alpha: 0.72), + borderRadius: BorderRadius.circular(24), + border: Border.all(color: Colors.white.withValues(alpha: 0.08)), + ), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular( + 18 * immersiveFullViewScale, + ), + child: SizedBox( + width: artworkSize, + height: artworkSize, + child: CachedImageWidget( + imagePath: song.albumArt, + audioSourcePath: song.filePath, + fit: BoxFit.cover, + placeholder: Container( + color: Colors.white.withValues(alpha: 0.05), + child: FlickArtworkPlaceholder( + size: artworkSize * 0.44, + opacity: 0.92, + ), ), - ), - errorWidget: Container( - color: Colors.white.withValues(alpha: 0.05), - child: FlickArtworkPlaceholder( - size: artworkSize * 0.44, - opacity: 0.92, + errorWidget: Container( + color: Colors.white.withValues(alpha: 0.05), + child: FlickArtworkPlaceholder( + size: artworkSize * 0.44, + opacity: 0.92, + ), ), ), ), ), - ), - SizedBox(width: context.responsive(12.0, 14.0, 16.0)), - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (immersiveShowTitle) - Text( - song.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontFamily: 'GoogleSansFlex', - fontSize: context.responsiveText( - context.responsive(18.0, 19.0, 21.0) * - immersiveTextScale, - ), - fontWeight: FontWeight.w700, - color: Colors.white, - height: 1.12, - ), - ), - if (immersiveShowTitle && immersiveShowArtist) - SizedBox(height: context.responsive(6.0, 7.0, 8.0)), - if (immersiveShowArtist) - GestureDetector( - onTap: () => onNavigateToArtistDetail(song), - child: Text( - song.artist, - maxLines: 1, + SizedBox(width: context.responsive(12.0, 14.0, 16.0)), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (immersiveShowTitle) + Text( + song.title, + maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( fontFamily: 'GoogleSansFlex', fontSize: context.responsiveText( - context.responsive(13.0, 14.0, 15.0) * + context.responsive(18.0, 19.0, 21.0) * immersiveTextScale, ), - fontWeight: FontWeight.w500, - color: Colors.white.withValues(alpha: 0.78), + fontWeight: FontWeight.w700, + color: Colors.white, + height: 1.12, ), ), - ), - ], + if (immersiveShowTitle && immersiveShowArtist) + SizedBox(height: context.responsive(6.0, 7.0, 8.0)), + if (immersiveShowArtist) + GestureDetector( + onTap: () => onNavigateToArtistDetail(song), + child: Text( + song.artist, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontFamily: 'GoogleSansFlex', + fontSize: context.responsiveText( + context.responsive(13.0, 14.0, 15.0) * + immersiveTextScale, + ), + fontWeight: FontWeight.w500, + color: Colors.white.withValues(alpha: 0.78), + ), + ), + ), + ], + ), ), - ), - ], + ], + ), ), ), ), ); } - Widget _buildImmersiveSongHeader(BuildContext context) { + Widget _buildImmersiveSongHeader( + BuildContext context, { + bool landscape = false, + }) { + final titleSize = landscape ? 24.0 : context.responsive(22.0, 24.0, 28.0); + final artistSize = landscape ? 14.0 : context.responsive(13.0, 14.0, 16.0); + final titleGap = landscape ? 8.0 : context.responsive(10.0, 12.0, 14.0); return Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ @@ -489,7 +579,7 @@ class SongStage extends ConsumerWidget { style: TextStyle( fontFamily: 'GoogleSansFlex', fontSize: context.responsiveText( - context.responsive(22.0, 24.0, 28.0) * immersiveTextScale, + titleSize * immersiveTextScale, ), fontWeight: FontWeight.w700, color: Colors.white, @@ -497,7 +587,7 @@ class SongStage extends ConsumerWidget { ), ), if (immersiveShowTitle && immersiveShowArtist) - SizedBox(height: context.responsive(10.0, 12.0, 14.0)), + SizedBox(height: titleGap), if (immersiveShowArtist) GestureDetector( onTap: () => onNavigateToArtistDetail(song), @@ -508,8 +598,7 @@ class SongStage extends ConsumerWidget { style: TextStyle( fontFamily: 'GoogleSansFlex', fontSize: context.responsiveText( - context.responsive(13.0, 14.0, 16.0) * - immersiveTextScale, + artistSize * immersiveTextScale, ), fontWeight: FontWeight.w500, color: Colors.white.withValues(alpha: 0.82), @@ -517,7 +606,7 @@ class SongStage extends ConsumerWidget { ), ), if (immersiveShowTitle || immersiveShowArtist) - SizedBox(height: context.responsive(10.0, 12.0, 14.0)), + SizedBox(height: landscape ? 8.0 : titleGap), ], ), ), @@ -738,16 +827,21 @@ class SongStage extends ConsumerWidget { BuildContext context, { bool compact = false, bool veryCompact = false, + bool landscape = false, }) { final titleSize = - (veryCompact + (landscape + ? 22.0 + : veryCompact ? context.responsive(20.0, 22.0, 25.0) : compact ? context.responsive(22.0, 25.0, 28.0) : context.responsive(25.0, 27.0, 30.0)) * artworkCardTextScale; final artistSize = - (veryCompact + (landscape + ? 14.0 + : veryCompact ? context.responsive(13.0, 14.0, 15.0) : compact ? context.responsive(14.0, 15.0, 16.0) @@ -788,11 +882,14 @@ class SongStage extends ConsumerWidget { final hasAlbum = song.album != null && song.album!.trim().isNotEmpty; return Column( + crossAxisAlignment: landscape + ? CrossAxisAlignment.start + : CrossAxisAlignment.center, children: [ if (artworkCardShowTitle) Text( song.title, - textAlign: TextAlign.center, + textAlign: landscape ? TextAlign.start : TextAlign.center, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( @@ -810,7 +907,7 @@ class SongStage extends ConsumerWidget { onTap: () => onNavigateToArtistDetail(song), child: Text( song.artist, - textAlign: TextAlign.center, + textAlign: landscape ? TextAlign.start : TextAlign.center, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( @@ -827,7 +924,7 @@ class SongStage extends ConsumerWidget { SizedBox(height: artistToAlbumSpacing), if (diagnostics != null && appPrefs.replaceAlbumWithBitPerfectCapsule) Stack( - alignment: Alignment.center, + alignment: landscape ? Alignment.centerLeft : Alignment.center, children: [ if (hasAlbum) AnimatedOpacity( @@ -849,6 +946,9 @@ class SongStage extends ConsumerWidget { onTap: () => onNavigateToAlbumDetail(song), child: Text( song.album!, + textAlign: landscape + ? TextAlign.start + : TextAlign.center, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( @@ -897,6 +997,7 @@ class SongStage extends ConsumerWidget { ), child: Text( song.album!, + textAlign: landscape ? TextAlign.start : TextAlign.center, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( diff --git a/lib/features/player/widgets/song_stage_carousel.dart b/lib/features/player/widgets/song_stage_carousel.dart index 9eda30dd7..dc65e2c31 100644 --- a/lib/features/player/widgets/song_stage_carousel.dart +++ b/lib/features/player/widgets/song_stage_carousel.dart @@ -59,7 +59,8 @@ class SongStageCarouselState extends State bool _isDragging = false; bool _isAnimating = false; double _dragStartX = double.infinity; - double _dragStartY = double.infinity; + bool _dragStartedNearSystemBottom = false; + Size? _lastWindowSize; // Frozen stages — prevents the glimpse bug where upNext updates to C // (via _setCurrentIndex) before currentSongNotifier flips from A->B, @@ -110,6 +111,26 @@ class SongStageCarouselState extends State } } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final nextSize = MediaQuery.sizeOf(context); + if (_lastWindowSize != null && + nextSize != _lastWindowSize && + (_isDragging || _dragOffset != 0)) { + // Rotation or resize landed mid-swipe. Settle on the current song + // instead of carrying a half-finished gesture into the new geometry. + _controller.stop(); + _isAnimating = false; + _isDragging = false; + _dragOffset = 0; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) widget.onDragStateChanged?.call(false); + }); + } + _lastWindowSize = nextSize; + } + @override void dispose() { _controller.dispose(); @@ -136,7 +157,10 @@ class SongStageCarouselState extends State Future _animateToTargetAndCommit({required bool isNext}) async { final width = context.size?.width ?? MediaQuery.sizeOf(context).width; final target = isNext ? -width : width; - await _animateOffsetTo(target, commitDuration: const Duration(milliseconds: 320)); + await _animateOffsetTo( + target, + commitDuration: const Duration(milliseconds: 320), + ); // Service changes song; our didUpdateWidget will snap offset. HapticFeedback.lightImpact(); if (isNext) { @@ -149,16 +173,18 @@ class SongStageCarouselState extends State } } - Future _animateOffsetTo(double target, - {required Duration commitDuration}) async { + Future _animateOffsetTo( + double target, { + required Duration commitDuration, + }) async { if (_dragOffset == target) return; _isAnimating = true; _animStartOffset = _dragOffset; _animTargetOffset = target; - _animation = Tween(begin: _animStartOffset, end: _animTargetOffset) - .animate( - CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic), - ); + _animation = Tween( + begin: _animStartOffset, + end: _animTargetOffset, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic)); _controller.duration = commitDuration; _controller.value = 0; await _controller.forward(); @@ -167,8 +193,12 @@ class SongStageCarouselState extends State void _handleDragStart(DragStartDetails details) { if (!widget.enabled || _isAnimating) return; - _dragStartX = details.globalPosition.dx; - _dragStartY = details.globalPosition.dy; + // Carousel-local coordinates; the stage may be a pane inside landscape, + // so global x can never be compared with the local width. + _dragStartX = details.localPosition.dx; + _dragStartedNearSystemBottom = + details.globalPosition.dy >= + MediaQuery.sizeOf(context).height - _edgeExclusion; _controller.stop(); _isAnimating = false; _isDragging = true; @@ -212,15 +242,18 @@ class SongStageCarouselState extends State _isDragging = false; widget.onDragStateChanged?.call(false); - // Edge exclusion: if drag started near screen edge, don't navigate. + // Edge exclusion: left/right use carousel-local coordinates; the bottom + // check uses the real system edge captured at drag start. final width = context.size?.width ?? MediaQuery.sizeOf(context).width; - final height = MediaQuery.sizeOf(context).height; final nearEdge = _dragStartX <= _edgeExclusion || _dragStartX >= width - _edgeExclusion || - _dragStartY >= height - _edgeExclusion; + _dragStartedNearSystemBottom; if (nearEdge) { - await _animateOffsetTo(0, commitDuration: const Duration(milliseconds: 220)); + await _animateOffsetTo( + 0, + commitDuration: const Duration(milliseconds: 220), + ); return; } @@ -236,20 +269,26 @@ class SongStageCarouselState extends State if (shouldNext) { HapticFeedback.mediumImpact(); - await _animateOffsetTo(-width, - commitDuration: const Duration(milliseconds: 320)); + await _animateOffsetTo( + -width, + commitDuration: const Duration(milliseconds: 320), + ); await widget.onNext(); if (mounted) setState(() => _dragOffset = 0.0); } else if (shouldPrev) { HapticFeedback.mediumImpact(); - await _animateOffsetTo(width, - commitDuration: const Duration(milliseconds: 320)); + await _animateOffsetTo( + width, + commitDuration: const Duration(milliseconds: 320), + ); await widget.onPrevious(); if (mounted) setState(() => _dragOffset = 0.0); } else { if (_dragOffset.abs() > 8) AppHaptics.tap(); - await _animateOffsetTo(0, - commitDuration: const Duration(milliseconds: 260)); + await _animateOffsetTo( + 0, + commitDuration: const Duration(milliseconds: 260), + ); } } diff --git a/lib/features/player/widgets/speed_bottom_sheet.dart b/lib/features/player/widgets/speed_bottom_sheet.dart index eba181399..5fce63779 100644 --- a/lib/features/player/widgets/speed_bottom_sheet.dart +++ b/lib/features/player/widgets/speed_bottom_sheet.dart @@ -14,6 +14,7 @@ class SpeedBottomSheet extends StatelessWidget { useRootNavigator: true, context: context, backgroundColor: Colors.transparent, + isScrollControlled: true, builder: (context) => SpeedBottomSheet(playerService: playerService), ); } @@ -23,13 +24,13 @@ class SpeedBottomSheet extends StatelessWidget { const min = 0.5; const max = 2.0; const step = 0.25; - return Container( - decoration: AppBottomSheetSurface.decoration, - padding: EdgeInsets.fromLTRB( + return AppBottomSheetSurface( + scrollable: true, + padding: const EdgeInsets.fromLTRB( 20, 16, 20, - 24 + MediaQuery.paddingOf(context).bottom, + 24, ), child: Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/features/player/widgets/volume_bottom_sheet.dart b/lib/features/player/widgets/volume_bottom_sheet.dart index 3b58261c2..91f425bdf 100644 --- a/lib/features/player/widgets/volume_bottom_sheet.dart +++ b/lib/features/player/widgets/volume_bottom_sheet.dart @@ -14,6 +14,7 @@ class VolumeBottomSheet extends StatelessWidget { useRootNavigator: true, context: context, backgroundColor: Colors.transparent, + isScrollControlled: true, builder: (context) => VolumeBottomSheet(playerService: playerService), ); } @@ -40,13 +41,13 @@ class VolumeBottomSheet extends StatelessWidget { double currentVolume = initialVolume; return StatefulBuilder( builder: (context, setSheetState) { - return Container( - decoration: AppBottomSheetSurface.decoration, - padding: EdgeInsets.fromLTRB( + return AppBottomSheetSurface( + scrollable: true, + padding: const EdgeInsets.fromLTRB( 20, 16, 20, - 24 + MediaQuery.paddingOf(context).bottom, + 24, ), child: Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/features/player/widgets/waveform_layer.dart b/lib/features/player/widgets/waveform_layer.dart index 24ff080da..68d933281 100644 --- a/lib/features/player/widgets/waveform_layer.dart +++ b/lib/features/player/widgets/waveform_layer.dart @@ -86,6 +86,7 @@ class _WaveformLayerState extends State return Consumer( builder: (context, ref, _) { final style = ref.watch(progressBarStyleProvider); + final sizes = ref.watch(progressBarSizeProvider); return AnimatedBuilder( animation: _appearAnimation, builder: (context, _) { @@ -109,6 +110,7 @@ class _WaveformLayerState extends State position: position, duration: duration, appearProgress: t, + thickness: sizes.lineThickness, onChanged: (newPos) { widget.positionNotifier.value = newPos; unawaited(widget.playerService.seek(newPos)); @@ -119,6 +121,7 @@ class _WaveformLayerState extends State position: position, duration: duration, appearProgress: t, + height: sizes.waveformHeight, cachedPeaks: _cachedPeaks, onChanged: (newPos) { widget.positionNotifier.value = newPos; diff --git a/lib/features/player/widgets/waveform_seek_bar.dart b/lib/features/player/widgets/waveform_seek_bar.dart index f427c50e8..7986c5fce 100644 --- a/lib/features/player/widgets/waveform_seek_bar.dart +++ b/lib/features/player/widgets/waveform_seek_bar.dart @@ -20,20 +20,26 @@ class WaveformSeekBar extends StatefulWidget { this.barCount = 60, this.appearProgress = 1.0, this.cachedPeaks, + this.height = 60, }); final int barCount; + /// Visible height of the waveform bars in logical pixels. The touch area + /// stays at least 48dp tall even when this is smaller. + final double height; + @override State createState() => _WaveformSeekBarState(); } class _WaveformSeekBarState extends State { - static const double _baseHeight = 60; - static const double _expandedHeight = 96; static const double _fineScrubZoom = 2.6; static const int _cachedSampleCount = 180; + double get _baseHeight => max(48.0, widget.height); + double get _expandedHeight => max(72.0, widget.height + 36.0); + // Cache the waveform data so it doesn't jitter on rebuilds late List _waveformData; bool _isFineScrubbing = false; @@ -245,7 +251,9 @@ class _WaveformSeekBarState extends State { ), padding: EdgeInsets.symmetric( horizontal: 2, - vertical: _isFineScrubbing ? 6 : 0, + vertical: _isFineScrubbing + ? 6 + : (_baseHeight - widget.height) / 2, ), child: RepaintBoundary( child: CustomPaint( diff --git a/lib/features/settings/data/settings_search_catalog.dart b/lib/features/settings/data/settings_search_catalog.dart index 5e1b15b10..0002b099d 100644 --- a/lib/features/settings/data/settings_search_catalog.dart +++ b/lib/features/settings/data/settings_search_catalog.dart @@ -390,7 +390,10 @@ List buildSettingsSearchCatalog() { _section('progressBar', l10n.progressBar, [ ('waveform', l10n.waveform), ('line', l10n.line), - ], keywords: 'seekbar'), + ('waveformHeight', l10n.waveformHeight), + ('lineThickness', l10n.lineThickness), + ('resetToDefaults', l10n.resetToDefaults), + ], keywords: 'seekbar height thickness size'), _section('albumColors', l10n.albumColors, [ ('off', l10n.off), ('subtle', l10n.subtle), diff --git a/lib/features/settings/screens/equalizer_screen.dart b/lib/features/settings/screens/equalizer_screen.dart index cf739a88c..63fb3da52 100644 --- a/lib/features/settings/screens/equalizer_screen.dart +++ b/lib/features/settings/screens/equalizer_screen.dart @@ -79,6 +79,19 @@ class _EqualizerScreenState extends ConsumerState { Widget build(BuildContext context) { final activePresetName = ref.watch(eqActivePresetNameProvider); final currentSong = ref.watch(currentSongProvider); + final size = MediaQuery.sizeOf(context); + final isShortWide = size.width > size.height && size.height < 560.0; + + final header = _Header( + title: l10n.englishForFilipino.eqDynamics, + subtitle: activePresetName != null ? l10n.preset(activePresetName) : null, + onBack: () => Navigator.of(context).pop(), + onPresets: _showPresetsBottomSheet, + ); + final tabs = _EffectsTabBar( + selectedIndex: _currentPage, + onSelected: _switchToPage, + ); return Stack( children: [ @@ -90,78 +103,72 @@ class _EqualizerScreenState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _Header( - title: l10n.englishForFilipino.eqDynamics, - subtitle: activePresetName != null - ? l10n.preset(activePresetName) - : null, - onBack: () => Navigator.of(context).pop(), - onPresets: _showPresetsBottomSheet, - ), - Expanded( - child: Column( + if (isShortWide) + Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - _EffectsTabBar( - selectedIndex: _currentPage, - onSelected: _switchToPage, + Expanded(child: header), + SizedBox( + width: math.min(340.0, size.width * 0.42), + child: tabs, ), - const SizedBox(height: AppConstants.spacingMd), - Expanded( - child: GestureDetector( - onHorizontalDragEnd: (details) { - final velocity = details.primaryVelocity ?? 0; - if (velocity < -250) { - _switchToPage(_currentPage + 1); - } else if (velocity > 250) { - _switchToPage(_currentPage - 1); - } - }, - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, - layoutBuilder: - (currentChild, previousChildren) => Stack( - alignment: Alignment.topCenter, - children: [ - ...previousChildren, - ?currentChild, - ], - ), - transitionBuilder: (child, animation) { - final incoming = - (child.key as ValueKey).value == - _currentPage; - final beginX = - (incoming ? _pageDirection : -_pageDirection) - .toDouble(); - return SlideTransition( - position: Tween( - begin: Offset(beginX, 0), - end: Offset.zero, - ).animate(animation), - child: FadeTransition( - opacity: animation, - child: child, - ), - ); - }, - child: KeyedSubtree( - key: ValueKey(_currentPage), - child: switch (_currentPage) { - 0 => _EqPage( - scrollController: _scrollController, - graphicGraphKey: _graphicGraphKey, - parametricGraphKey: _parametricGraphKey, - ), - 1 => const _DynamicsPage(), - _ => const _FxPage(), - }, - ), + const SizedBox(width: AppConstants.spacingMd), + ], + ) + else ...[ + header, + tabs, + const SizedBox(height: AppConstants.spacingMd), + ], + Expanded( + child: GestureDetector( + onHorizontalDragEnd: (details) { + final velocity = details.primaryVelocity ?? 0; + if (velocity < -250) { + _switchToPage(_currentPage + 1); + } else if (velocity > 250) { + _switchToPage(_currentPage - 1); + } + }, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + layoutBuilder: (currentChild, previousChildren) => Stack( + alignment: Alignment.topCenter, + children: [...previousChildren, ?currentChild], + ), + transitionBuilder: (child, animation) { + final incoming = + (child.key as ValueKey).value == _currentPage; + final beginX = + (incoming ? _pageDirection : -_pageDirection) + .toDouble(); + return SlideTransition( + position: Tween( + begin: Offset(beginX, 0), + end: Offset.zero, + ).animate(animation), + child: FadeTransition( + opacity: animation, + child: child, ), - ), + ); + }, + child: KeyedSubtree( + key: ValueKey(_currentPage), + child: switch (_currentPage) { + 0 => _EqPage( + scrollController: _scrollController, + graphicGraphKey: _graphicGraphKey, + parametricGraphKey: _parametricGraphKey, + compact: isShortWide, + ), + 1 => const _DynamicsPage(), + _ => const _FxPage(), + }, ), - ], + ), ), ), ], @@ -233,11 +240,13 @@ class _EqPage extends ConsumerWidget { final ScrollController scrollController; final GlobalKey graphicGraphKey; final GlobalKey parametricGraphKey; + final bool compact; const _EqPage({ required this.scrollController, required this.graphicGraphKey, required this.parametricGraphKey, + this.compact = false, }); @override @@ -245,45 +254,47 @@ class _EqPage extends ConsumerWidget { final enabled = ref.watch(eqEnabledProvider); final mode = ref.watch(eqModeProvider); - return NotificationListener( - onNotification: (_) => true, - child: SingleChildScrollView( - controller: scrollController, - padding: const EdgeInsets.symmetric(horizontal: AppConstants.spacingMd), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _GlassCard( - child: Column( - children: [ - _TopControlsRow(enabled: enabled), - _Divider(), - _PreampRow(enabled: enabled), - _Divider(), - _ModeAndActionsRow(mode: mode), - ], + return NotificationListener( + onNotification: (_) => true, + child: SingleChildScrollView( + controller: scrollController, + padding: const EdgeInsets.symmetric(horizontal: AppConstants.spacingMd), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _GlassCard( + child: Column( + children: [ + _TopControlsRow(enabled: enabled), + _Divider(), + _PreampRow(enabled: enabled), + _Divider(), + _ModeAndActionsRow(mode: mode), + ], + ), ), - ), - const SizedBox(height: AppConstants.spacingLg), - _BmtKnobsRow(enabled: enabled), - const SizedBox(height: AppConstants.spacingLg), - AnimatedSwitcher( - duration: AppConstants.animationNormal, - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, - child: mode == EqMode.graphic - ? _GraphicEqView( - key: const ValueKey('graphic'), - graphKey: graphicGraphKey, - ) - : _ParametricEqView( - key: const ValueKey('param'), - graphKey: parametricGraphKey, - ), + const SizedBox(height: AppConstants.spacingLg), + _BmtKnobsRow(enabled: enabled), + const SizedBox(height: AppConstants.spacingLg), + AnimatedSwitcher( + duration: AppConstants.animationNormal, + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + child: mode == EqMode.graphic + ? _GraphicEqView( + key: const ValueKey('graphic'), + graphKey: graphicGraphKey, + compact: compact, + ) + : _ParametricEqView( + key: const ValueKey('param'), + graphKey: parametricGraphKey, + compact: compact, + ), ), - SizedBox(height: AppConstants.navBarHeight + 120), - ], - ), + SizedBox(height: _bottomSpacer(context)), + ], + ), ), ); } @@ -302,7 +313,7 @@ class _DynamicsPage extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ const _DynamicsSection(), - SizedBox(height: AppConstants.navBarHeight + 120), + SizedBox(height: _bottomSpacer(context)), ], ), ), @@ -323,7 +334,7 @@ class _FxPage extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ const _CreativeFxSection(), - SizedBox(height: AppConstants.navBarHeight + 120), + SizedBox(height: _bottomSpacer(context)), ], ), ), @@ -1718,9 +1729,21 @@ class _BmtKnobsRow extends ConsumerWidget { } } +double _bottomSpacer(BuildContext context) { + final size = MediaQuery.sizeOf(context); + return size.width > size.height && size.height < 560 + ? 72.0 + : AppConstants.navBarHeight + 120; +} + class _GraphicEqView extends ConsumerWidget { final GlobalKey? graphKey; - const _GraphicEqView({super.key, this.graphKey}); + final bool compact; + const _GraphicEqView({ + super.key, + this.graphKey, + this.compact = false, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -1803,9 +1826,9 @@ class _GraphicEqView extends ConsumerWidget { RepaintBoundary( child: Opacity( opacity: enabled ? 1.0 : 0.5, - child: const SizedBox( - height: 220, - child: GraphicEqGraph(), + child: SizedBox( + height: compact ? 160.0 : 220.0, + child: const GraphicEqGraph(), ), ), ), @@ -1828,7 +1851,7 @@ class _GraphicEqView extends ConsumerWidget { ), const SizedBox(height: AppConstants.spacingMd), SizedBox( - height: 332, + height: compact ? 260.0 : 332.0, child: RepaintBoundary( child: SingleChildScrollView( scrollDirection: Axis.horizontal, @@ -2108,7 +2131,12 @@ class _AnimatedSliderState extends State<_AnimatedSlider> class _ParametricEqView extends ConsumerStatefulWidget { final GlobalKey? graphKey; - const _ParametricEqView({super.key, this.graphKey}); + final bool compact; + const _ParametricEqView({ + super.key, + this.graphKey, + this.compact = false, + }); @override ConsumerState<_ParametricEqView> createState() => _ParametricEqViewState(); @@ -2218,9 +2246,9 @@ class _ParametricEqViewState extends ConsumerState<_ParametricEqView> { RepaintBoundary( child: Opacity( opacity: enabled ? 1.0 : 0.5, - child: const SizedBox( - height: 220, - child: ParametricEqGraph(), + child: SizedBox( + height: widget.compact ? 160.0 : 220.0, + child: const ParametricEqGraph(), ), ), ), @@ -2246,7 +2274,7 @@ class _ParametricEqViewState extends ConsumerState<_ParametricEqView> { ), const SizedBox(height: AppConstants.spacingMd), SizedBox( - height: 300, + height: widget.compact ? 260.0 : 300.0, child: ListView.separated( controller: _bandScrollController, scrollDirection: Axis.horizontal, diff --git a/lib/features/settings/screens/ui_customization_settings_screen.dart b/lib/features/settings/screens/ui_customization_settings_screen.dart index 8e055fd9e..0b2a80c7c 100644 --- a/lib/features/settings/screens/ui_customization_settings_screen.dart +++ b/lib/features/settings/screens/ui_customization_settings_screen.dart @@ -3,9 +3,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flick/core/constants/app_constants.dart'; import 'package:flick/models/album_color_mode.dart'; +import 'package:flick/models/progress_bar_size.dart'; import 'package:flick/models/progress_bar_style.dart'; import 'package:flick/models/song_tile_thumbnail_mode.dart'; import 'package:flick/providers/providers.dart'; +import 'package:flick/features/settings/widgets/progress_bar_preview.dart'; import 'package:flick/features/settings/widgets/settings_widgets.dart'; import 'package:flick/l10n/settings_l10n.dart'; @@ -17,6 +19,22 @@ class UiCustomizationSettingsScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final appPreferences = ref.watch(appPreferencesProvider); + final progressBarStyle = ref.watch(progressBarStyleProvider); + final progressBarSizes = ref.watch(progressBarSizeProvider); + final isWaveform = progressBarStyle == ProgressBarStyle.waveform; + final progressBarSize = isWaveform + ? progressBarSizes.waveformHeight + : progressBarSizes.lineThickness; + final progressBarMin = isWaveform + ? ProgressBarSizes.minWaveformHeight + : ProgressBarSizes.minLineThickness; + final progressBarMax = isWaveform + ? ProgressBarSizes.maxWaveformHeight + : ProgressBarSizes.maxLineThickness; + final progressBarDivisions = isWaveform + ? ((progressBarMax - progressBarMin) / ProgressBarSizes.waveformHeightStep) + .round() + : (progressBarMax - progressBarMin).round(); return SettingsScaffold( title: l10n.englishForFilipino.uiCustomization, @@ -221,7 +239,7 @@ class UiCustomizationSettingsScreen extends ConsumerWidget { SelectionSetting( icon: icon, title: mode.labelFor(l10n.englishForFilipino), - subtitle: mode.description, + subtitle: mode.descriptionFor(l10n), selected: isSelected, onTap: () { ref @@ -240,9 +258,8 @@ class UiCustomizationSettingsScreen extends ConsumerWidget { SelectionSetting( icon: LucideIcons.audioWaveform, title: ProgressBarStyle.waveform.labelFor(l10n.englishForFilipino), - subtitle: ProgressBarStyle.waveform.description, - selected: ref.watch(progressBarStyleProvider) == - ProgressBarStyle.waveform, + subtitle: ProgressBarStyle.waveform.descriptionFor(l10n), + selected: progressBarStyle == ProgressBarStyle.waveform, onTap: () { ref .read(progressBarStyleProvider.notifier) @@ -253,9 +270,8 @@ class UiCustomizationSettingsScreen extends ConsumerWidget { SelectionSetting( icon: LucideIcons.minus, title: ProgressBarStyle.line.labelFor(l10n.englishForFilipino), - subtitle: ProgressBarStyle.line.description, - selected: ref.watch(progressBarStyleProvider) == - ProgressBarStyle.line, + subtitle: ProgressBarStyle.line.descriptionFor(l10n), + selected: progressBarStyle == ProgressBarStyle.line, onTap: () { ref .read(progressBarStyleProvider.notifier) @@ -264,6 +280,61 @@ class UiCustomizationSettingsScreen extends ConsumerWidget { ), ], ), + const SizedBox(height: AppConstants.spacingMd), + SettingsCard( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppConstants.spacingLg, + vertical: AppConstants.spacingMd, + ), + child: const ProgressBarPreview(), + ), + ], + ), + const SizedBox(height: AppConstants.spacingMd), + SettingsCard( + children: [ + SliderSetting( + icon: isWaveform ? LucideIcons.audioWaveform : LucideIcons.minus, + title: isWaveform + ? l10n.englishForFilipino.waveformHeight + : l10n.englishForFilipino.lineThickness, + subtitle: isWaveform + ? l10n.englishForFilipino.waveformHeightDescription + : l10n.englishForFilipino.lineThicknessDescription, + value: progressBarSize, + displayValue: l10n.englishForFilipino.dp( + progressBarSize.round(), + ), + min: progressBarMin, + max: progressBarMax, + divisions: progressBarDivisions, + onChanged: (value) { + final notifier = ref.read(progressBarSizeProvider.notifier); + if (isWaveform) { + notifier.setWaveformHeight(value); + } else { + notifier.setLineThickness(value); + } + }, + ), + const SettingsDivider(), + ActionButton( + icon: LucideIcons.rotateCcw, + title: l10n.englishForFilipino.resetToDefaults, + subtitle: l10n.englishForFilipino.restoreDefaultProgressBarSize, + onTap: () { + final notifier = ref.read(progressBarSizeProvider.notifier); + if (isWaveform) { + notifier.resetWaveformHeight(); + } else { + notifier.resetLineThickness(); + } + }, + ), + ], + ), const SizedBox(height: AppConstants.spacingLg), SettingsSectionHeader(l10n.englishForFilipino.albumColors, sectionId: 'albumColors'), SettingsCard( @@ -277,7 +348,7 @@ class UiCustomizationSettingsScreen extends ConsumerWidget { SelectionSetting( icon: LucideIcons.palette, title: mode.labelFor(l10n.englishForFilipino), - subtitle: mode.description, + subtitle: mode.descriptionFor(l10n), selected: isSelected, onTap: () { ref diff --git a/lib/features/settings/widgets/progress_bar_preview.dart b/lib/features/settings/widgets/progress_bar_preview.dart new file mode 100644 index 000000000..ccb3a97c0 --- /dev/null +++ b/lib/features/settings/widgets/progress_bar_preview.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:flick/features/player/widgets/line_seek_bar.dart'; +import 'package:flick/features/player/widgets/waveform_seek_bar.dart'; +import 'package:flick/models/progress_bar_style.dart'; +import 'package:flick/providers/providers.dart'; + +/// Non-interactive preview of the player progress bar at its current size. +class ProgressBarPreview extends ConsumerWidget { + const ProgressBarPreview({super.key}); + + static const _position = Duration(minutes: 1, seconds: 23); + static const _duration = Duration(minutes: 3, seconds: 20); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final style = ref.watch(progressBarStyleProvider); + final sizes = ref.watch(progressBarSizeProvider); + + return ExcludeSemantics( + child: IgnorePointer( + child: switch (style) { + ProgressBarStyle.line => LineSeekBar( + position: _position, + duration: _duration, + onChanged: (_) {}, + thickness: sizes.lineThickness, + ), + ProgressBarStyle.waveform => WaveformSeekBar( + barCount: 60, + position: _position, + duration: _duration, + onChanged: (_) {}, + height: sizes.waveformHeight, + ), + }, + ), + ); + } +} diff --git a/lib/features/songs/screens/songs_screen.dart b/lib/features/songs/screens/songs_screen.dart index ef4fc3011..778956ef2 100644 --- a/lib/features/songs/screens/songs_screen.dart +++ b/lib/features/songs/screens/songs_screen.dart @@ -12,6 +12,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:flick/core/theme/app_colors.dart'; import 'package:flick/core/theme/adaptive_color_provider.dart'; import 'package:flick/core/constants/app_constants.dart'; +import 'package:flick/core/utils/adaptive_layout.dart'; import 'package:flick/core/utils/app_haptics.dart'; import 'package:flick/core/utils/responsive.dart'; import 'package:flick/core/utils/navigation_helper.dart'; @@ -135,6 +136,12 @@ class _SongsScreenState extends ConsumerState final songsAsync = ref.watch(songsProvider); final viewMode = ref.watch(songsViewModeProvider); final navBarVisible = ref.watch(navBarVisibleProvider); + final reserveSideMiniPlayer = + viewMode != SongViewMode.list && + songsAsync.value?.sortOption != SongSortOption.album && + AdaptiveLayout.useCompactLandscapeMiniPlayer( + MediaQuery.sizeOf(context), + ); final swipeActionsEnabled = ref .watch(appPreferencesProvider) .swipeActionsEnabled; @@ -143,209 +150,200 @@ class _SongsScreenState extends ConsumerState NavBarButton.search, ); - final sortOption = - songsAsync.value?.sortOption ?? SongSortOption.albumArtist; - final isAlbumMode = sortOption == SongSortOption.album; - final shouldReserveOrbitBottomSpace = - viewMode != SongViewMode.list && navBarVisible && !isAlbumMode; - return Stack( clipBehavior: Clip.none, children: [ SafeArea( bottom: false, - 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), - ), - ), + 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), + ), + ), - // 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), + // 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), + ), ], ), - borderRadius: BorderRadius.circular( - AppConstants.radiusXl, - ), - border: Border.all( - color: AppColors.glassBorder, - width: 1, + child: GlassSearchBar( + controller: _searchController, + hintText: l10n.searchSongsArtists, + showBackground: false, + onChanged: (value) { + setState(() { + _searchQuery = value.toLowerCase(); + _selectedIndex = 0; + _lastSyncedSong = null; + }); + }, ), - 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; - }); - }, ), ), ), - ), - const SizedBox(height: AppConstants.spacingMd), - ], + const SizedBox(height: AppConstants.spacingMd), + ], - // Content based on async state - Expanded( - 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(); + // Content based on async state + Expanded( + 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 (songs.isEmpty && _searchQuery.isEmpty) { - return _buildEmptyState(); - } + 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.isNotEmpty) { - return _buildNoSearchResultsState(); - } + if (songs.isEmpty && _searchQuery.isEmpty) { + return _buildEmptyState(); + } - if (isAlbumMode) { - if (!_wasInAlbumMode) { - _wasInAlbumMode = true; - _visibleAlbumCount = _getAlbumPageSize(); - _albumPaginationSignature = ''; + if (songs.isEmpty && _searchQuery.isNotEmpty) { + return _buildNoSearchResultsState(); } - 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 (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 (filteredGroups.isEmpty) { - return _buildNoSearchResultsState(); + if (albumGroups.isEmpty) { + return _buildEmptyState(); } - return _buildAlbumGridView(filteredGroups); + return _buildAlbumGridView(albumGroups); } - if (albumGroups.isEmpty) { - return _buildEmptyState(); - } - return _buildAlbumGridView(albumGroups); - } - _alignCurrentSongAfterSongsLoad(songs); + _alignCurrentSongAfterSongsLoad(songs); - if (_selectedIndex >= songs.length) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - setState(() => _selectedIndex = 0); - } - }); - } + if (_selectedIndex >= songs.length) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() => _selectedIndex = 0); + } + }); + } - _syncSelectedTokenForIndex(songs, _selectedIndex); + _syncSelectedTokenForIndex(songs, _selectedIndex); - final tokenToIndexMap = _buildFastIndexMap(songs); - final appPrefs = ref.watch(appPreferencesProvider); + final tokenToIndexMap = _buildFastIndexMap(songs); + final appPrefs = ref.watch(appPreferencesProvider); - return _buildSongsView( - songs, - viewMode, - tokenToIndexMap, - navBarVisible, - swipeActionsEnabled, - appPrefs.fastIndexEnabled, - ); - }, + return _buildSongsView( + songs, + viewMode, + tokenToIndexMap, + navBarVisible, + swipeActionsEnabled, + appPrefs.fastIndexEnabled, + ); + }, + ), ), - ), - - // Reserve space only when needed for orbit view. - // List view handles its own bottom padding. - AnimatedContainer( - duration: AppConstants.animationNormal, - curve: Curves.easeOutCubic, - height: shouldReserveOrbitBottomSpace - ? AppConstants.navBarHeight + AppConstants.spacingLg - : 0, - ), - ], + ], + ), ), ), ], @@ -446,6 +444,10 @@ class _SongsScreenState extends ConsumerState } }, child: OrbitScroll( + scrollDirection: + MediaQuery.sizeOf(context).width > MediaQuery.sizeOf(context).height + ? Axis.horizontal + : Axis.vertical, controller: _orbitScrollController, songs: songs, selectedIndex: _selectedIndex.clamp(0, songs.length - 1).toInt(), @@ -577,9 +579,7 @@ class _SongsScreenState extends ConsumerState final sorted = List.from(albums); switch (_albumSortOption) { case _AlbumGridSortOption.name: - sorted.sort( - (a, b) => compareCaseInsensitive(a.albumName, b.albumName), - ); + sorted.sort((a, b) => compareCaseInsensitive(a.albumName, b.albumName)); case _AlbumGridSortOption.artist: sorted.sort((a, b) { final c = compareCaseInsensitive(a.albumArtist, b.albumArtist); @@ -1860,7 +1860,10 @@ class _SongsScreenState extends ConsumerState content: Text(message), action: onUndo == null ? null - : SnackBarAction(label: l10n.englishForFilipino.undo, onPressed: onUndo), + : SnackBarAction( + label: l10n.englishForFilipino.undo, + onPressed: onUndo, + ), duration: const Duration(seconds: 2), ), ); @@ -2727,7 +2730,9 @@ class _AlbumCardState extends State<_AlbumCard> fit: BoxFit.cover, useThumbnail: true, thumbnailWidth: artworkTargetWidth, - thumbnailHeight: widget.stretchArtwork ? artworkTargetWidth : null, + thumbnailHeight: widget.stretchArtwork + ? artworkTargetWidth + : null, placeholder: _buildPlaceholder(context), errorWidget: _buildPlaceholder(context), ), @@ -2757,7 +2762,9 @@ class _AlbumCardState extends State<_AlbumCard> decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.55), borderRadius: BorderRadius.circular(999), - border: Border.all(color: Colors.white.withValues(alpha: 0.12)), + border: Border.all( + color: Colors.white.withValues(alpha: 0.12), + ), ), child: Text( l10n.tracks6(widget.album.songs.length), @@ -2791,9 +2798,9 @@ class _AlbumCardState extends State<_AlbumCard> const SizedBox(height: _albumCardLineGap), Text( widget.album.albumArtist, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: context.adaptiveTextSecondary, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: context.adaptiveTextSecondary), maxLines: 1, overflow: TextOverflow.ellipsis, ), diff --git a/lib/features/songs/widgets/orbit_scroll.dart b/lib/features/songs/widgets/orbit_scroll.dart index e65de111e..009ba9dbd 100644 --- a/lib/features/songs/widgets/orbit_scroll.dart +++ b/lib/features/songs/widgets/orbit_scroll.dart @@ -58,6 +58,10 @@ class OrbitScroll extends StatefulWidget { /// Controller for external jump-to-index actions. final OrbitScrollController? controller; + /// Landscape owns horizontal drags only within its music viewport. The + /// surrounding header and side gutters remain available to tab navigation. + final Axis scrollDirection; + final double radiusRatio; final double centerOffsetRatio; final double centerYRatio; @@ -83,6 +87,7 @@ class OrbitScroll extends StatefulWidget { this.isSelectionMode = false, this.selectedIds = const {}, this.controller, + this.scrollDirection = Axis.vertical, this.radiusRatio = 1.0, this.centerOffsetRatio = -0.5, this.centerYRatio = 0.42, @@ -118,6 +123,9 @@ class _OrbitScrollState extends State bool _artworkGateHeld = false; DateTime _lastScrollTime = DateTime.now(); int _lastReportedIndex = 0; + Size? _layoutSize; + double _dragExtent = 90.0; + int _animationGeneration = 0; // Cache for transform calculations (bounded) final Map _positionCache = {}; @@ -129,10 +137,7 @@ class _OrbitScrollState extends State // user-tunable count takes effect. Upgrade to a memo if this ever shows in a profile. List get _orderedIndices { final visibleRange = widget.visibleItems ~/ 2; - return List.generate( - visibleRange * 2 + 1, - (i) => i - visibleRange, - ) + return List.generate(visibleRange * 2 + 1, (i) => i - visibleRange) ..sort((a, b) => b.abs().compareTo(a.abs())); } @@ -167,14 +172,26 @@ class _OrbitScrollState extends State oldWidget.centerOffsetRatio != widget.centerOffsetRatio || oldWidget.centerYRatio != widget.centerYRatio || oldWidget.cardArtSize != widget.cardArtSize || - oldWidget.cardWidthRatio != widget.cardWidthRatio) { + oldWidget.cardWidthRatio != widget.cardWidthRatio || + oldWidget.scrollDirection != widget.scrollDirection) { _transformCache.clear(); _positionCache.clear(); } + if (oldWidget.scrollDirection != widget.scrollDirection || + oldWidget.songs.length != widget.songs.length) { + _animationGeneration++; + _controller.stop(); + _isScrolling = false; + _scrollOffset.value = _scrollOffset.value + .round() + .clamp(0, math.max(0, widget.songs.length - 1)) + .toDouble(); + } if (widget.selectedIndex != oldWidget.selectedIndex) { _lastReportedIndex = widget.selectedIndex; // If the index changed externally, snap/spring to it - if ((widget.selectedIndex.toDouble() - _scrollOffset.value).abs() > 0.05) { + if ((widget.selectedIndex.toDouble() - _scrollOffset.value).abs() > + 0.05) { _animateTo(widget.selectedIndex.toDouble()); } } @@ -182,6 +199,7 @@ class _OrbitScrollState extends State @override void dispose() { + _animationGeneration++; widget.controller?._detach(); _controller.dispose(); _scrollOffset.dispose(); @@ -256,11 +274,13 @@ class _OrbitScrollState extends State }); } - void _onVerticalDragStart(DragStartDetails details) { + void _onDragStart(DragStartDetails details) { + _animationGeneration++; _controller.stop(); } - void _onVerticalDragUpdate(DragUpdateDetails details) { + void _onDragUpdate(DragUpdateDetails details) { + if (widget.songs.isEmpty) return; final delta = details.primaryDelta ?? 0.0; if (delta == 0) return; @@ -274,15 +294,17 @@ class _OrbitScrollState extends State maxScrollExtent: widget.songs.length.toDouble(), pixels: _scrollOffset.value, viewportDimension: 100, - axisDirection: AxisDirection.down, + axisDirection: widget.scrollDirection == Axis.horizontal + ? AxisDirection.right + : AxisDirection.down, devicePixelRatio: 1.0, ), context: context, direction: direction, ).dispatch(context); - const itemHeight = 90.0; - var itemDelta = -(delta / itemHeight); + var itemDelta = -(delta / _dragExtent); + _markScrollActive(); double newOffset = _scrollOffset.value + itemDelta; if (newOffset < -0.5 || newOffset > widget.songs.length - 0.5) { @@ -303,7 +325,8 @@ class _OrbitScrollState extends State } } - void _onVerticalDragEnd(DragEndDetails details) { + void _onDragEnd(DragEndDetails details) { + if (widget.songs.isEmpty) return; final velocity = details.primaryVelocity ?? 0.0; // Dispatch end notification (idle) @@ -313,7 +336,9 @@ class _OrbitScrollState extends State maxScrollExtent: widget.songs.length.toDouble(), pixels: _scrollOffset.value, viewportDimension: 100, - axisDirection: AxisDirection.down, + axisDirection: widget.scrollDirection == Axis.horizontal + ? AxisDirection.right + : AxisDirection.down, devicePixelRatio: 1.0, ), context: context, @@ -322,8 +347,7 @@ class _OrbitScrollState extends State // Pixels per second // Convert to items per second - const itemHeight = 90.0; - final velocityItemsPerSec = -velocity / itemHeight; + final velocityItemsPerSec = -velocity / _dragExtent; // 1. Predict landing point // We use a FrictionSimulation to see where it WOULD land. @@ -347,6 +371,11 @@ class _OrbitScrollState extends State } void _animateTo(double target, {double velocity = 0.0}) { + final generation = ++_animationGeneration; + if (MediaQuery.disableAnimationsOf(context)) { + _jumpToIndex(target.round(), false); + return; + } // Create a spring simulation from current => target final description = SpringDescription.withDampingRatio( mass: 1.0, @@ -362,6 +391,7 @@ class _OrbitScrollState extends State ); _controller.animateWith(simulation).whenComplete(() { + if (!mounted || generation != _animationGeneration) return; // Ensure we explicitly set the final state to avoid micro-drifts _scrollOffset.value = target; setState(() { @@ -388,54 +418,102 @@ class _OrbitScrollState extends State @override Widget build(BuildContext context) { - final size = MediaQuery.of(context).size; - - // Calculate orbit parameters - final orbitRadius = size.width * widget.radiusRatio; - final orbitCenterX = size.width * widget.centerOffsetRatio; - final orbitCenterY = size.height * widget.centerYRatio; - - return GestureDetector( - onVerticalDragStart: _onVerticalDragStart, - onVerticalDragUpdate: _onVerticalDragUpdate, - onVerticalDragEnd: _onVerticalDragEnd, - behavior: HitTestBehavior.opaque, - child: Container( - width: double.infinity, - height: double.infinity, - color: Colors.transparent, - child: Stack( - clipBehavior: Clip.none, - children: [ - // Background glow - if (widget.showGlow) - _buildSelectionGlow(orbitCenterX, orbitCenterY, orbitRadius), - - // Path - if (widget.showPath) - _buildOrbitPath(orbitCenterX, orbitCenterY, orbitRadius), - - // Songs — only rebuilds when scroll offset changes - ValueListenableBuilder( - valueListenable: _scrollOffset, - builder: (context, _, __) { - return SizedBox.expand( - child: Stack( - clipBehavior: Clip.none, - children: _buildSongItems(orbitCenterX, orbitCenterY, orbitRadius), + final horizontal = widget.scrollDirection == Axis.horizontal; + return Padding( + padding: EdgeInsets.symmetric(horizontal: horizontal ? 28 : 0), + child: LayoutBuilder( + builder: (context, constraints) { + final size = constraints.biggest; + if (_layoutSize != size) { + _layoutSize = size; + _positionCache.clear(); + _transformCache.clear(); + } + // Phone landscape leaves a much shorter stage than a tablet. Keep + // the same orbit metaphor, but lift and shrink it so the flanking + // cards are not pushed off the bottom of the window. + final compactHorizontal = horizontal && size.height < 420; + final orbitRadius = horizontal + ? size.width * widget.radiusRatio.clamp(0.6, 1.4) + : size.width * widget.radiusRatio; + final orbitCenterX = horizontal + ? size.width / 2 + : size.width * widget.centerOffsetRatio; + final orbitCenterY = horizontal + ? size.height * (compactHorizontal ? 0.44 : 0.48) + orbitRadius + : size.height * widget.centerYRatio; + _dragExtent = horizontal + ? (size.width * 0.28).clamp(140.0, 280.0) + : 90; + + final viewport = GestureDetector( + onVerticalDragStart: horizontal ? null : _onDragStart, + onVerticalDragUpdate: horizontal ? null : _onDragUpdate, + onVerticalDragEnd: horizontal ? null : _onDragEnd, + onVerticalDragCancel: horizontal ? null : _onDragCancel, + onHorizontalDragStart: horizontal ? _onDragStart : null, + onHorizontalDragUpdate: horizontal ? _onDragUpdate : null, + onHorizontalDragEnd: horizontal ? _onDragEnd : null, + onHorizontalDragCancel: horizontal ? _onDragCancel : null, + behavior: HitTestBehavior.opaque, + child: Container( + width: double.infinity, + height: double.infinity, + color: Colors.transparent, + child: Stack( + clipBehavior: Clip.hardEdge, + children: [ + // Background glow + if (widget.showGlow) + _buildSelectionGlow( + orbitCenterX, + orbitCenterY, + orbitRadius, + ), + + // Path + if (widget.showPath) + _buildOrbitPath(orbitCenterX, orbitCenterY, orbitRadius), + + // Songs — only rebuilds when scroll offset changes + ValueListenableBuilder( + valueListenable: _scrollOffset, + builder: (context, _, __) { + return SizedBox.expand( + child: Stack( + clipBehavior: Clip.none, + children: _buildSongItems( + orbitCenterX, + orbitCenterY, + orbitRadius, + ), + ), + ); + }, ), - ); - }, + ], + ), ), - ], - ), + ); + // Inner recognizers win Flutter's gesture arena before the ancestor + // PageView. No global physics toggle is needed (or left stuck on cancel). + return viewport; + }, ), ); } + void _onDragCancel() { + if (widget.songs.isEmpty) return; + _animateTo( + _scrollOffset.value.round().clamp(0, widget.songs.length - 1).toDouble(), + ); + } + Widget _buildSelectionGlow(double centerX, double centerY, double radius) { - final x = centerX + radius; - final y = centerY; + final horizontal = widget.scrollDirection == Axis.horizontal; + final x = horizontal ? centerX : centerX + radius; + final y = horizontal ? centerY - radius : centerY; return Positioned( left: x - 120, // Slightly larger glow top: y - 120, @@ -466,6 +544,7 @@ class _OrbitScrollState extends State centerX: centerX, centerY: centerY, radius: radius, + horizontal: widget.scrollDirection == Axis.horizontal, ), ), ); @@ -474,6 +553,25 @@ class _OrbitScrollState extends State List _buildSongItems(double centerX, double centerY, double radius) { final List items = []; + final horizontal = widget.scrollDirection == Axis.horizontal; + final compactHorizontal = horizontal && _layoutSize!.height < 420; + final cardScale = math.max(1.0, widget.selectedScale); + final viewportCardWidth = + (_layoutSize!.width * widget.cardWidthRatio * 0.5) / cardScale; + final compactCardHeight = _layoutSize!.height * 0.72 / cardScale; + final cardWidth = horizontal + ? compactHorizontal + ? math + .min(viewportCardWidth, compactCardHeight * 1.05) + .clamp(88.0, 280.0) + : viewportCardWidth.clamp(160.0, 280.0) + : null; + final cardHeight = horizontal + ? compactHorizontal + ? compactCardHeight + : math.min(380.0, _layoutSize!.height * 0.85) / cardScale + : null; + final centerIndex = _scrollOffset.value.round(); // ponytail: adjacent/distant shrink derived from one Depth slider. @@ -498,16 +596,15 @@ class _OrbitScrollState extends State double scale; if (distanceFromCenter < 1.0) { - scale = widget.selectedScale - - (widget.selectedScale - adjacentScale) * - distanceFromCenter; + scale = + widget.selectedScale - + (widget.selectedScale - adjacentScale) * distanceFromCenter; } else if (distanceFromCenter < 2.0) { - scale = adjacentScale - - (adjacentScale - distantScale) * - (distanceFromCenter - 1.0); + scale = + adjacentScale - + (adjacentScale - distantScale) * (distanceFromCenter - 1.0); } else { - scale = distantScale - - (distanceFromCenter - 2.0) * 0.12; + scale = distantScale - (distanceFromCenter - 2.0) * 0.12; } scale = scale.clamp(0.0, 1.25); @@ -533,16 +630,26 @@ class _OrbitScrollState extends State child: FractionalTranslation( translation: const Offset(-0.5, -0.5), child: SongCard( + key: ValueKey(widget.songs[actualIndex].id), song: widget.songs[actualIndex], scale: transform.scale, opacity: transform.opacity, isSelected: transform.isSelected, - swipeActionsEnabled: widget.swipeActionsEnabled, + // Horizontal drags browse songs here, never queue/favorite. + // Those actions remain in the existing long-press song menu. + swipeActionsEnabled: + widget.swipeActionsEnabled && + widget.scrollDirection == Axis.vertical, isSelectionMode: widget.isSelectionMode, - isMultiSelected: widget.selectedIds.contains(widget.songs[actualIndex].id), + isMultiSelected: widget.selectedIds.contains( + widget.songs[actualIndex].id, + ), artSizeBase: widget.cardArtSize, artSizeLarge: widget.cardArtSize * 1.5625, cardWidthRatio: widget.cardWidthRatio, + artworkAbove: horizontal, + width: cardWidth, + height: cardHeight, artResolutionMultiplier: widget.artResolutionMultiplier, onTap: () { AppHaptics.tap(); @@ -581,8 +688,13 @@ class _OrbitScrollState extends State relativeIndex.sign * splitAmount * math.min(relativeIndex.abs(), 1.0); final angle = adjustedIndex * widget.itemSpacing; - final x = centerX + radius * math.cos(angle); - final y = centerY + radius * math.sin(angle); + final horizontal = widget.scrollDirection == Axis.horizontal; + final x = horizontal + ? centerX + radius * math.sin(angle) + : centerX + radius * math.cos(angle); + final y = horizontal + ? centerY - radius * math.cos(angle) + : centerY + radius * math.sin(angle); final position = _Position(x, y); @@ -616,11 +728,13 @@ class _OrbitPathPainter extends CustomPainter { final double centerX; final double centerY; final double radius; + final bool horizontal; _OrbitPathPainter({ required this.centerX, required this.centerY, required this.radius, + this.horizontal = false, }); @override @@ -635,13 +749,20 @@ class _OrbitPathPainter extends CustomPainter { radius: radius, ); - canvas.drawArc(rect, -math.pi / 2.5, 2 * math.pi / 2.5, false, paint); + canvas.drawArc( + rect, + horizontal ? -math.pi / 2 - math.pi / 2.5 : -math.pi / 2.5, + 2 * math.pi / 2.5, + false, + paint, + ); } @override bool shouldRepaint(covariant _OrbitPathPainter oldDelegate) { return centerX != oldDelegate.centerX || centerY != oldDelegate.centerY || - radius != oldDelegate.radius; + radius != oldDelegate.radius || + horizontal != oldDelegate.horizontal; } } diff --git a/lib/features/songs/widgets/song_card.dart b/lib/features/songs/widgets/song_card.dart index 80f2ea298..9f96d3f65 100644 --- a/lib/features/songs/widgets/song_card.dart +++ b/lib/features/songs/widgets/song_card.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flick/core/theme/app_colors.dart'; @@ -52,6 +53,9 @@ class SongCard extends StatefulWidget { /// Decode-resolution multiplier applied to art size (higher = sharper, heavier). final double artResolutionMultiplier; + final double? width; + final double? height; + final bool artworkAbove; const SongCard({ super.key, @@ -69,6 +73,9 @@ class SongCard extends StatefulWidget { this.artSizeLarge = AppConstants.songCardArtSizeLarge, this.cardWidthRatio = 0.68, this.artResolutionMultiplier = 2.0, + this.width, + this.height, + this.artworkAbove = false, }); @override @@ -85,8 +92,9 @@ class _SongCardState extends State { final artSize = widget.isSelected ? widget.artSizeLarge : widget.artSizeBase; - final cardWidth = MediaQuery.of(context).size.width * widget.cardWidthRatio; - const cardHeight = 130.0; + final cardWidth = widget.width ?? + MediaQuery.sizeOf(context).width * widget.cardWidthRatio; + final cardHeight = widget.height ?? 130.0; final isSwiping = _dragDx.abs() > 0.001; final isFlashing = _queuedFlash || _favoriteFlash; @@ -183,6 +191,7 @@ class _SongCardState extends State { } Widget _buildCardFace(double artSize) { + if (widget.artworkAbove) return _buildArtworkCard(artSize); return ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(AppConstants.radiusLg)), child: Stack( @@ -235,6 +244,131 @@ class _SongCardState extends State { ); } + Widget _buildArtworkCard(double artSize) { + return Semantics( + selected: widget.isSelected || widget.isMultiSelected, + child: ClipRRect( + borderRadius: BorderRadius.circular(AppConstants.radiusLg), + child: ColoredBox( + color: AppColors.surface, + child: LayoutBuilder( + builder: (context, constraints) { + final enlargedText = + MediaQuery.textScalerOf(context).scale(14) > 19; + final showArtwork = + constraints.maxHeight > (enlargedText ? 150 : 100); + final text = Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.song.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: 'GoogleSansFlex', + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + widget.song.artist, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: 'GoogleSansFlex', + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ); + if (!showArtwork) { + // A short window still shows the music, not a row of text-only + // cards. Overlay the identity instead of squeezing out the art. + return Stack( + fit: StackFit.expand, + children: [ + _buildAlbumWithGradient(artSize), + const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x33000000), Color(0xE6000000)], + ), + ), + ), + Align( + alignment: Alignment.bottomLeft, + child: SingleChildScrollView(child: text), + ), + if (widget.isSelectionMode) + Positioned( + top: 8, + right: 8, + child: Icon( + widget.isMultiSelected + ? Icons.check_circle_rounded + : Icons.radio_button_unchecked_rounded, + color: AppColors.textPrimary, + ), + ), + ], + ); + } + return Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (showArtwork) + Expanded( + child: _buildRawImage( + widget.song.albumArt ?? '', + audioSourcePath: widget.song.filePath, + artSize: math.max(artSize, constraints.maxWidth), + ), + ), + if (showArtwork) + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: constraints.maxHeight * 0.55, + ), + child: SingleChildScrollView(child: text), + ) + else + Expanded(child: SingleChildScrollView(child: text)), + ], + ), + if (widget.isSelectionMode) + Positioned( + top: 8, + right: 8, + child: Icon( + widget.isMultiSelected + ? Icons.check_circle_rounded + : Icons.radio_button_unchecked_rounded, + color: AppColors.textPrimary, + ), + ), + ], + ); + }, + ), + ), + ), + ); + } + Widget _buildSwipeStack(double cardWidth, double artSize) { final queueProgress = (-_dragDx / 110).clamp(0.0, 1.0); final favoriteProgress = (_dragDx / 110).clamp(0.0, 1.0); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c43f4cccc..b2b580a1f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1113,6 +1113,8 @@ "@background": { "description": "lib/features/settings/screens/widget_settings_screen.dart:155" }, + "blurGradient": "Blur gradient", + "colorGradient": "Color gradient", "backgroundOpacity": "Background Opacity", "@backgroundOpacity": { "description": "lib/features/settings/widgets/mini_player_customization.dart:197" @@ -4348,6 +4350,10 @@ "@fileInformation": { "description": "lib/features/songs/screens/metadata_editor_screen.dart:444" }, + "fileIsNoLongerAvailable": "This file is no longer available. It may have been moved or deleted. Rescan the library, then try again.", + "@fileIsNoLongerAvailable": { + "description": "lib/services/metadata_editor_service.dart: user-visible service error" + }, "filePath": "File Path", "@filePath": { "description": "lib/features/player/widgets/song_metadata_sheet.dart:82" @@ -4492,6 +4498,10 @@ "@flickLogs": { "description": "lib/features/settings/screens/logs_screen.dart:110" }, + "flickNoLongerHasPermissionToEditThisFile": "Flick no longer has permission to edit this file. Re-add its folder in Settings to grant edit access, then try again.", + "@flickNoLongerHasPermissionToEditThisFile": { + "description": "lib/services/metadata_editor_service.dart: user-visible service error" + }, "flickPlayer": "Flick Player", "@flickPlayer": { "description": "lib/features/settings/screens/app_info_settings_screen.dart:444" @@ -5796,6 +5806,14 @@ } } }, + "lineThickness": "Line Thickness", + "@lineThickness": { + "description": "lib/features/settings/screens/ui_customization_settings_screen.dart" + }, + "lineThicknessDescription": "Adjust how thick the progress line appears.", + "@lineThicknessDescription": { + "description": "lib/features/settings/screens/ui_customization_settings_screen.dart" + }, "linear": "Linear", "@linear": { "description": "lib/features/player/widgets/bit_perfect_indicator.dart:919" @@ -8338,6 +8356,10 @@ "@restartingFlick": { "description": "lib/features/menu/screens/restarting_screen.dart:126" }, + "restoreDefaultProgressBarSize": "Restore the selected progress bar style to its default size.", + "@restoreDefaultProgressBarSize": { + "description": "lib/features/settings/screens/ui_customization_settings_screen.dart" + }, "restoreTheOriginalOrbitalLayout": "Restore the original orbital layout", "@restoreTheOriginalOrbitalLayout": { "description": "lib/features/settings/screens/orbit_settings_screen.dart:200" @@ -11479,6 +11501,14 @@ "@waveform": { "description": "lib/models/progress_bar_style.dart:16" }, + "waveformHeight": "Waveform Height", + "@waveformHeight": { + "description": "lib/features/settings/screens/ui_customization_settings_screen.dart" + }, + "waveformHeightDescription": "Adjust how tall the waveform bars appear.", + "@waveformHeightDescription": { + "description": "lib/features/settings/screens/ui_customization_settings_screen.dart" + }, "waveformSeekbar": "Waveform seekbar", "@waveformSeekbar": { "description": "lib/features/manual/data/manual_data.dart" diff --git a/lib/l10n/app_tl.arb b/lib/l10n/app_tl.arb index 44c778ff2..52e376796 100644 --- a/lib/l10n/app_tl.arb +++ b/lib/l10n/app_tl.arb @@ -228,6 +228,8 @@ "backToReplay": "Bumalik sa Replay", "backend": "Backend", "background": "Likuran", + "blurGradient": "Gradient ng blur", + "colorGradient": "Gradient ng kulay", "backgroundOpacity": "Pagkalabo ng likuran", "backgroundPlaybackAnchor": "Pangpanatili ng pagtugtog sa background", "balance": "Balanse", @@ -795,6 +797,7 @@ "feedback": "Puna", "file": "File", "fileInformation": "Impormasyon ng file", + "fileIsNoLongerAvailable": "Hindi na available ang file na ito. Maaaring nailipat o natanggal ito. I-scan muli ang library at subukan ulit.", "filePath": "Path ng file", "files": "{done} / {total} file", "files2": "{count, plural, =1{{count} file} other{{count} file}}", @@ -824,6 +827,7 @@ "flickChecksFlickPlayerSiteFor": "Sinusuri ng Flick ang flick-player.site para sa update kapag may koneksiyon ka", "flickIsBuiltAndMaintainedBy": "Iisang tao ang gumagawa at nagpapanatili ng Flick nang walang badyet. Walang koponan, pondo, o patalastas.", "flickLogs": "Mga log ng Flick", + "flickNoLongerHasPermissionToEditThisFile": "Wala nang pahintulot ang Flick na i-edit ang file na ito. Alisin at idagdag muli ang folder nito sa Mga pagsasaayos upang payagang baguhin ito, saka subukan muli.", "flickPlayer": "Flick Player", "flickPlayerDoesNotCollectStore": "Hindi nangongolekta, nag-iimbak, o nagpapadala ng personal na datos ang Flick Player. Walang kinukuhang personal na impormasyon, analytics ng paggamit, ulat ng pag-crash, o advertising identifier. Walang datos na ibinabahagi sa mga ikatlong partido.", "flickPreview": "Paunang tingin ng Flick", @@ -1093,6 +1097,8 @@ "line": "Linya", "lineOf": "Linya {arg1} sa {arg2}", "lineOfWords": "Linya {arg1} sa {arg2} · {captured}/{segmentCount} salita", + "lineThickness": "Line Thickness", + "lineThicknessDescription": "Adjust how thick the progress line appears.", "linear": "Linear", "lines": "Mga linya", "lines2": "{arg1} linya", @@ -1637,6 +1643,7 @@ "restartTheAppToApplyPlayback": "Simulan muli ang app upang mailapat ang mga pagbabago sa pagtugtog.", "restartYourDeviceToApplyOutput": "Simulan muli ang aparato upang mailapat ang mga pagbabago sa format ng output.", "restartingFlick": "Muling sinisimulan ang Flick", + "restoreDefaultProgressBarSize": "Restore the selected progress bar style to its default size.", "restoreTheOriginalOrbitalLayout": "Ibalik ang orihinal na ayos ng Orbital", "results": "Mga resulta ({arg1})", "resumeAFamiliarFavoriteOrShuffle": "Balikan ang pamilyar na paborito o ihalo ang isa sa mga halong binuo mula sa iyong pakikinig.", @@ -2314,6 +2321,8 @@ "was": "dati: {arg1}", "wave": "Alon", "waveform": "Waveform", + "waveformHeight": "Waveform Height", + "waveformHeightDescription": "Adjust how tall the waveform bars appear.", "weLlKeepTheBestQuality": "Ititira namin ang pinakamagandang bersiyon sa bawat pangkat (inuuna ang larawan ng album, saka ang pinakamataas na bitrate) at aalisin ang {count, plural, =1{{count} kopya} other{{count} kopya}}. Hindi mabubura ang mga file sa imbakan.", "weLlKeepYourCheckedVersion": "Ititira namin ang {kept, plural, =1{{kept} bersiyong nilagyan mo ng tsek} other{{kept} bersiyong nilagyan mo ng tsek}} at aalisin ang {removed, plural, =1{{removed} iba pang kopya} other{{removed} iba pang kopya}} sa iyong aklatan. Hindi mabubura ang mga audio file sa imbakan.", "webdav": "WebDAV", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 8b2406b55..706ffb761 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -237,6 +237,8 @@ "backToReplay": "返回回顾", "backend": "后端", "background": "背景", + "blurGradient": "模糊渐变", + "colorGradient": "色彩渐变", "backgroundOpacity": "背景不透明度", "backgroundPlaybackAnchor": "后台播放锚点", "balance": "平衡", @@ -948,6 +950,7 @@ "feedback": "反馈", "file": "文件", "fileInformation": "文件信息", + "fileIsNoLongerAvailable": "此文件已不可用,可能已被移动或删除。请重新扫描媒体库后重试。", "filePath": "文件路径", "files": "{done} / {total} 个文件", "files2": "{count} 个文件", @@ -977,6 +980,7 @@ "flickChecksFlickPlayerSiteFor": "联网时 Flick 会检查 flick-player.site 的更新", "flickIsBuiltAndMaintainedBy": "Flick 由一个人零预算开发与维护。没有团队,没有资金,没有广告。", "flickLogs": "Flick 日志", + "flickNoLongerHasPermissionToEditThisFile": "Flick 已没有编辑此文件的权限。请在设置中移除并重新添加该文件夹以授予编辑权限,然后重试。", "flickPlayer": "Flick 音乐播放器", "flickPlayerDoesNotCollectStore": "Flick Player 不收集、不存储、不传输任何个人数据。不会采集个人信息、使用分析、崩溃报告或广告标识符。不会与第三方共享任何数据。", "flickPreview": "Flick 预览", @@ -1245,6 +1249,8 @@ "line": "线条", "lineOf": "第 {arg1} 行 / 共 {arg2} 行", "lineOfWords": "第 {arg1} 行 / 共 {arg2} 行 · {captured}/{segmentCount} 个词", + "lineThickness": "线条粗细", + "lineThicknessDescription": "调整进度线条的粗细。", "linear": "线性", "lines": "歌词行", "lines2": "{arg1} 行", @@ -1788,6 +1794,7 @@ "restartTheAppToApplyPlayback": "重启应用以应用播放设置更改。", "restartYourDeviceToApplyOutput": "重启设备以应用输出格式更改。", "restartingFlick": "正在重启 Flick", + "restoreDefaultProgressBarSize": "将所选进度条样式恢复为默认大小。", "restoreTheOriginalOrbitalLayout": "恢复原始轨道布局", "results": "结果({arg1})", "resumeAFamiliarFavoriteOrShuffle": "继续播放熟悉的收藏,或随机播放由你的收听生成的混音。", @@ -2463,6 +2470,8 @@ "was": "原为:{arg1}", "wave": "波形", "waveform": "波形", + "waveformHeight": "波形高度", + "waveformHeightDescription": "调整波形柱的高度。", "waveformSeekbar": "波形进度条", "weLlKeepTheBestQuality": "每组保留质量最好的版本(优先有专辑封面,其次码率最高),并移除 {count} 个。磁盘上的文件不会被删除。", "weLlKeepYourCheckedVersion": "将保留你勾选的 {kept} 个版本,并从音乐库中移除另外 {removed} 个。磁盘上的音频文件不会被删除。", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 86801d3dc..98d1a9a2c 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -1529,6 +1529,18 @@ abstract class AppLocalizations { /// **'Background'** String get background; + /// No description provided for @blurGradient. + /// + /// In en, this message translates to: + /// **'Blur gradient'** + String get blurGradient; + + /// No description provided for @colorGradient. + /// + /// In en, this message translates to: + /// **'Color gradient'** + String get colorGradient; + /// lib/features/settings/widgets/mini_player_customization.dart:197 /// /// In en, this message translates to: @@ -5741,6 +5753,12 @@ abstract class AppLocalizations { /// **'File Information'** String get fileInformation; + /// lib/services/metadata_editor_service.dart: user-visible service error + /// + /// In en, this message translates to: + /// **'This file is no longer available. It may have been moved or deleted. Rescan the library, then try again.'** + String get fileIsNoLongerAvailable; + /// lib/features/player/widgets/song_metadata_sheet.dart:82 /// /// In en, this message translates to: @@ -5915,6 +5933,12 @@ abstract class AppLocalizations { /// **'Flick logs'** String get flickLogs; + /// lib/services/metadata_editor_service.dart: user-visible service error + /// + /// In en, this message translates to: + /// **'Flick no longer has permission to edit this file. Re-add its folder in Settings to grant edit access, then try again.'** + String get flickNoLongerHasPermissionToEditThisFile; + /// lib/features/settings/screens/app_info_settings_screen.dart:444 /// /// In en, this message translates to: @@ -7528,6 +7552,18 @@ abstract class AppLocalizations { Object segmentCount, ); + /// lib/features/settings/screens/ui_customization_settings_screen.dart + /// + /// In en, this message translates to: + /// **'Line Thickness'** + String get lineThickness; + + /// lib/features/settings/screens/ui_customization_settings_screen.dart + /// + /// In en, this message translates to: + /// **'Adjust how thick the progress line appears.'** + String get lineThicknessDescription; + /// lib/features/player/widgets/bit_perfect_indicator.dart:919 /// /// In en, this message translates to: @@ -10780,6 +10816,12 @@ abstract class AppLocalizations { /// **'Restarting Flick'** String get restartingFlick; + /// lib/features/settings/screens/ui_customization_settings_screen.dart + /// + /// In en, this message translates to: + /// **'Restore the selected progress bar style to its default size.'** + String get restoreDefaultProgressBarSize; + /// lib/features/settings/screens/orbit_settings_screen.dart:200 /// /// In en, this message translates to: @@ -14834,6 +14876,18 @@ abstract class AppLocalizations { /// **'Waveform'** String get waveform; + /// lib/features/settings/screens/ui_customization_settings_screen.dart + /// + /// In en, this message translates to: + /// **'Waveform Height'** + String get waveformHeight; + + /// lib/features/settings/screens/ui_customization_settings_screen.dart + /// + /// In en, this message translates to: + /// **'Adjust how tall the waveform bars appear.'** + String get waveformHeightDescription; + /// lib/features/manual/data/manual_data.dart /// /// 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 9bf4cd8ff..e38e5aebd 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -860,6 +860,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get background => 'Background'; + @override + String get blurGradient => 'Blur gradient'; + + @override + String get colorGradient => 'Color gradient'; + @override String get backgroundOpacity => 'Background Opacity'; @@ -3345,6 +3351,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get fileInformation => 'File Information'; + @override + String get fileIsNoLongerAvailable => + 'This file is no longer available. It may have been moved or deleted. Rescan the library, then try again.'; + @override String get filePath => 'File Path'; @@ -3461,6 +3471,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get flickLogs => 'Flick logs'; + @override + String get flickNoLongerHasPermissionToEditThisFile => + 'Flick no longer has permission to edit this file. Re-add its folder in Settings to grant edit access, then try again.'; + @override String get flickPlayer => 'Flick Player'; @@ -4419,6 +4433,13 @@ class AppLocalizationsEn extends AppLocalizations { return 'Line $arg1 of $arg2 · $captured/$segmentCount words'; } + @override + String get lineThickness => 'Line Thickness'; + + @override + String get lineThicknessDescription => + 'Adjust how thick the progress line appears.'; + @override String get linear => 'Linear'; @@ -6316,6 +6337,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get restartingFlick => 'Restarting Flick'; + @override + String get restoreDefaultProgressBarSize => + 'Restore the selected progress bar style to its default size.'; + @override String get restoreTheOriginalOrbitalLayout => 'Restore the original orbital layout'; @@ -8772,6 +8797,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get waveform => 'Waveform'; + @override + String get waveformHeight => 'Waveform Height'; + + @override + String get waveformHeightDescription => + 'Adjust how tall the waveform bars appear.'; + @override String get waveformSeekbar => 'Waveform seekbar'; diff --git a/lib/l10n/generated/app_localizations_tl.dart b/lib/l10n/generated/app_localizations_tl.dart index 60f0af8e6..df08a03c8 100644 --- a/lib/l10n/generated/app_localizations_tl.dart +++ b/lib/l10n/generated/app_localizations_tl.dart @@ -873,6 +873,12 @@ class AppLocalizationsTl extends AppLocalizations { @override String get background => 'Likuran'; + @override + String get blurGradient => 'Gradient ng blur'; + + @override + String get colorGradient => 'Gradient ng kulay'; + @override String get backgroundOpacity => 'Pagkalabo ng likuran'; @@ -3388,6 +3394,10 @@ class AppLocalizationsTl extends AppLocalizations { @override String get fileInformation => 'Impormasyon ng file'; + @override + String get fileIsNoLongerAvailable => + 'Hindi na available ang file na ito. Maaaring nailipat o natanggal ito. I-scan muli ang library at subukan ulit.'; + @override String get filePath => 'Path ng file'; @@ -3507,6 +3517,10 @@ class AppLocalizationsTl extends AppLocalizations { @override String get flickLogs => 'Mga log ng Flick'; + @override + String get flickNoLongerHasPermissionToEditThisFile => + 'Wala nang pahintulot ang Flick na i-edit ang file na ito. Alisin at idagdag muli ang folder nito sa Mga pagsasaayos upang payagang baguhin ito, saka subukan muli.'; + @override String get flickPlayer => 'Flick Player'; @@ -4471,6 +4485,13 @@ class AppLocalizationsTl extends AppLocalizations { return 'Linya $arg1 sa $arg2 · $captured/$segmentCount salita'; } + @override + String get lineThickness => 'Line Thickness'; + + @override + String get lineThicknessDescription => + 'Adjust how thick the progress line appears.'; + @override String get linear => 'Linear'; @@ -6386,6 +6407,10 @@ class AppLocalizationsTl extends AppLocalizations { @override String get restartingFlick => 'Muling sinisimulan ang Flick'; + @override + String get restoreDefaultProgressBarSize => + 'Restore the selected progress bar style to its default size.'; + @override String get restoreTheOriginalOrbitalLayout => 'Ibalik ang orihinal na ayos ng Orbital'; @@ -8873,6 +8898,13 @@ class AppLocalizationsTl extends AppLocalizations { @override String get waveform => 'Waveform'; + @override + String get waveformHeight => 'Waveform Height'; + + @override + String get waveformHeightDescription => + 'Adjust how tall the waveform bars appear.'; + @override String get waveformSeekbar => 'Waveform seekbar'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 5e63da335..e2d85c925 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -792,6 +792,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get background => '背景'; + @override + String get blurGradient => '模糊渐变'; + + @override + String get colorGradient => '色彩渐变'; + @override String get backgroundOpacity => '背景不透明度'; @@ -3116,6 +3122,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get fileInformation => '文件信息'; + @override + String get fileIsNoLongerAvailable => '此文件已不可用,可能已被移动或删除。请重新扫描媒体库后重试。'; + @override String get filePath => '文件路径'; @@ -3217,6 +3226,10 @@ class AppLocalizationsZh extends AppLocalizations { @override String get flickLogs => 'Flick 日志'; + @override + String get flickNoLongerHasPermissionToEditThisFile => + 'Flick 已没有编辑此文件的权限。请在设置中移除并重新添加该文件夹以授予编辑权限,然后重试。'; + @override String get flickPlayer => 'Flick 音乐播放器'; @@ -4106,6 +4119,12 @@ class AppLocalizationsZh extends AppLocalizations { return '第 $arg1 行 / 共 $arg2 行 · $captured/$segmentCount 个词'; } + @override + String get lineThickness => '线条粗细'; + + @override + String get lineThicknessDescription => '调整进度线条的粗细。'; + @override String get linear => '线性'; @@ -5888,6 +5907,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get restartingFlick => '正在重启 Flick'; + @override + String get restoreDefaultProgressBarSize => '将所选进度条样式恢复为默认大小。'; + @override String get restoreTheOriginalOrbitalLayout => '恢复原始轨道布局'; @@ -8130,6 +8152,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get waveform => '波形'; + @override + String get waveformHeight => '波形高度'; + + @override + String get waveformHeightDescription => '调整波形柱的高度。'; + @override String get waveformSeekbar => '波形进度条'; diff --git a/lib/l10n/model_labels.dart b/lib/l10n/model_labels.dart index d9628f459..2dfb8b309 100644 --- a/lib/l10n/model_labels.dart +++ b/lib/l10n/model_labels.dart @@ -25,6 +25,7 @@ import 'package:flick/l10n/l10n.dart'; import 'package:flick/models/advance_list_order.dart'; import 'package:flick/models/album_color_mode.dart'; import 'package:flick/models/audio_engine_type.dart'; +import 'package:flick/models/immersive_background_style.dart'; import 'package:flick/models/nav_bar_config.dart'; import 'package:flick/models/playback_context.dart'; import 'package:flick/models/player_action_button.dart'; @@ -161,6 +162,16 @@ extension PlayerScreenModeL10n on PlayerScreenMode { }; } +// --------------------------------------------------------------------------- +// 沉浸式播放器背景 +// --------------------------------------------------------------------------- +extension ImmersiveBackgroundStyleL10n on ImmersiveBackgroundStyle { + String labelFor(AppLocalizations l10n) => switch (this) { + ImmersiveBackgroundStyle.colorGradient => l10n.colorGradient, + ImmersiveBackgroundStyle.blurGradient => l10n.blurGradient, + }; +} + // --------------------------------------------------------------------------- // 进度条样式 // --------------------------------------------------------------------------- @@ -172,7 +183,9 @@ extension ProgressBarStyleL10n on ProgressBarStyle { ProgressBarStyle.line => l10n.line, }; - String get description => switch (this) { + String get description => descriptionFor(l10n); + + String descriptionFor(AppLocalizations l10n) => switch (this) { ProgressBarStyle.waveform => l10n.verticalBarsThatAnimateAcrossThe, ProgressBarStyle.line => l10n.cleanStraightLineWithPrecisionScrubbing, }; @@ -211,7 +224,9 @@ extension SongTileThumbnailModeL10n on SongTileThumbnailMode { SongTileThumbnailMode.trackNumberOnArt => l10n.numberOnArt, }; - String get description => switch (this) { + String get description => descriptionFor(l10n); + + String descriptionFor(AppLocalizations l10n) => switch (this) { SongTileThumbnailMode.artwork => l10n.showAlbumArtwork, SongTileThumbnailMode.trackNumber => l10n.showTheTrackNumber, SongTileThumbnailMode.trackNumberOnArt => l10n.showTheNumberOverBlurredArt, diff --git a/lib/main.dart b/lib/main.dart index 565cf8cdc..15d238017 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flick/src/rust/frb_generated.dart'; import 'package:flick/app/app.dart'; @@ -29,10 +28,6 @@ Future main() async { // store: new installations start on the adaptive refresh-rate default. await AppPreferencesService.captureInstallFreshness(); - await SystemChrome.setPreferredOrientations([ - DeviceOrientation.portraitUp, - ]); - await AppLog.instance.initializePersistence(); await Uac2PreferencesService().initializeDeveloperModeCache(); FrameTimingsMonitor.instance.start(); diff --git a/lib/models/immersive_background_style.dart b/lib/models/immersive_background_style.dart new file mode 100644 index 000000000..54124fbc2 --- /dev/null +++ b/lib/models/immersive_background_style.dart @@ -0,0 +1,23 @@ +/// Background treatments for the immersive full player. +enum ImmersiveBackgroundStyle { colorGradient, blurGradient } + +extension ImmersiveBackgroundStyleX on ImmersiveBackgroundStyle { + String get storageValue { + switch (this) { + case ImmersiveBackgroundStyle.colorGradient: + return 'color_gradient'; + case ImmersiveBackgroundStyle.blurGradient: + return 'blur_gradient'; + } + } + + static ImmersiveBackgroundStyle fromStorageValue(String? value) { + switch (value) { + case 'blur_gradient': + return ImmersiveBackgroundStyle.blurGradient; + case 'color_gradient': + default: + return ImmersiveBackgroundStyle.colorGradient; + } + } +} diff --git a/lib/models/progress_bar_size.dart b/lib/models/progress_bar_size.dart new file mode 100644 index 000000000..27f820d11 --- /dev/null +++ b/lib/models/progress_bar_size.dart @@ -0,0 +1,53 @@ +/// User-selectable visible sizes for the player progress bar. +class ProgressBarSizes { + static const double defaultWaveformHeight = 60; + static const double minWaveformHeight = 24; + static const double maxWaveformHeight = 96; + static const double waveformHeightStep = 4; + + static const double defaultLineThickness = 3; + static const double minLineThickness = 1; + static const double maxLineThickness = 12; + + /// The seekbars keep this much vertical room for touch even when the + /// visible waveform bars or line stroke are smaller. + static const double minimumTouchTarget = 48; + + static const ProgressBarSizes defaults = ProgressBarSizes( + waveformHeight: defaultWaveformHeight, + lineThickness: defaultLineThickness, + ); + + const ProgressBarSizes({ + required this.waveformHeight, + required this.lineThickness, + }); + + final double waveformHeight; + final double lineThickness; + + static double clampWaveformHeight(double value) { + return value.clamp(minWaveformHeight, maxWaveformHeight).toDouble(); + } + + static double clampLineThickness(double value) { + return value.clamp(minLineThickness, maxLineThickness).toDouble(); + } + + ProgressBarSizes copyWith({double? waveformHeight, double? lineThickness}) { + return ProgressBarSizes( + waveformHeight: waveformHeight ?? this.waveformHeight, + lineThickness: lineThickness ?? this.lineThickness, + ); + } + + @override + bool operator ==(Object other) { + return other is ProgressBarSizes && + other.waveformHeight == waveformHeight && + other.lineThickness == lineThickness; + } + + @override + int get hashCode => Object.hash(waveformHeight, lineThickness); +} diff --git a/lib/providers/app_preferences_provider.dart b/lib/providers/app_preferences_provider.dart index baea1dd0a..83d6fb5de 100644 --- a/lib/providers/app_preferences_provider.dart +++ b/lib/providers/app_preferences_provider.dart @@ -207,6 +207,14 @@ class AppPreferencesNotifier extends Notifier { .setImmersiveAutoFullViewSeconds(value); } + Future setImmersiveBackgroundStyle(String value) async { + if (state.immersiveBackgroundStyle == value) return; + state = state.copyWith(immersiveBackgroundStyle: value); + await ref + .read(appPreferencesServiceProvider) + .setImmersiveBackgroundStyle(value); + } + Future setVisualizerAnimationStyle(String value) async { if (state.visualizerAnimationStyle == value) return; state = state.copyWith(visualizerAnimationStyle: value); diff --git a/lib/providers/progress_bar_size_provider.dart b/lib/providers/progress_bar_size_provider.dart new file mode 100644 index 000000000..f839082f2 --- /dev/null +++ b/lib/providers/progress_bar_size_provider.dart @@ -0,0 +1,77 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:flick/models/progress_bar_size.dart'; +import 'package:flick/services/progress_bar_size_preference_service.dart'; + +final progressBarSizePreferenceServiceProvider = + Provider((ref) { + return ProgressBarSizePreferenceService(); + }); + +class ProgressBarSizeNotifier extends Notifier { + late Future _ready; + Future _writes = Future.value(); + bool _loaded = false; + final _pending = []; + + @override + ProgressBarSizes build() { + _loaded = false; + _pending.clear(); + _ready = Future.microtask(_loadFromPreferences); + return ProgressBarSizes.defaults; + } + + Future _loadFromPreferences() async { + var sizes = await ref + .read(progressBarSizePreferenceServiceProvider) + .getSizes(); + if (!ref.mounted) return; + for (final change in _pending) { + sizes = change(sizes); + } + _pending.clear(); + _loaded = true; + state = sizes; + } + + Future setWaveformHeight(double height) { + final clamped = ProgressBarSizes.clampWaveformHeight(height); + return _update((sizes) => sizes.copyWith(waveformHeight: clamped)); + } + + Future setLineThickness(double thickness) { + final clamped = ProgressBarSizes.clampLineThickness(thickness); + return _update((sizes) => sizes.copyWith(lineThickness: clamped)); + } + + Future resetWaveformHeight() { + return setWaveformHeight(ProgressBarSizes.defaultWaveformHeight); + } + + Future resetLineThickness() { + return setLineThickness(ProgressBarSizes.defaultLineThickness); + } + + Future _update( + ProgressBarSizes Function(ProgressBarSizes) change, + ) async { + if (!_loaded) _pending.add(change); + state = change(state); + await _ready; + if (!ref.mounted) return; + + final snapshot = state; + final write = _writes.then( + (_) => + ref.read(progressBarSizePreferenceServiceProvider).setSizes(snapshot), + ); + _writes = write.catchError((Object _) {}); + await write; + } +} + +final progressBarSizeProvider = + NotifierProvider( + ProgressBarSizeNotifier.new, + ); diff --git a/lib/providers/providers.dart b/lib/providers/providers.dart index 146228f6b..92a4b77cb 100644 --- a/lib/providers/providers.dart +++ b/lib/providers/providers.dart @@ -20,6 +20,7 @@ export 'duplicate_cleaner_provider.dart'; export 'onboarding_provider.dart'; export 'app_preferences_provider.dart'; export 'progress_bar_style_provider.dart'; +export 'progress_bar_size_provider.dart'; export 'album_color_provider.dart'; export 'nav_bar_config_provider.dart'; export 'player_screen_mode_provider.dart'; diff --git a/lib/services/app_preferences_service.dart b/lib/services/app_preferences_service.dart index ac9165951..2dddb6b27 100644 --- a/lib/services/app_preferences_service.dart +++ b/lib/services/app_preferences_service.dart @@ -22,6 +22,7 @@ class AppPreferences { final bool fastIndexEnabled; final int fastIndexTimeoutSeconds; final int immersiveAutoFullViewSeconds; + final String immersiveBackgroundStyle; final String visualizerAnimationStyle; final String visualizerFrequencyMode; final String visualizerMovementMode; @@ -141,6 +142,7 @@ class AppPreferences { this.fastIndexEnabled = true, this.fastIndexTimeoutSeconds = 3, this.immersiveAutoFullViewSeconds = 0, + this.immersiveBackgroundStyle = 'color_gradient', this.visualizerAnimationStyle = 'bars', this.visualizerFrequencyMode = 'full', this.visualizerMovementMode = 'bouncy', @@ -261,6 +263,7 @@ class AppPreferences { bool? fastIndexEnabled, int? fastIndexTimeoutSeconds, int? immersiveAutoFullViewSeconds, + String? immersiveBackgroundStyle, String? visualizerAnimationStyle, String? visualizerFrequencyMode, String? visualizerMovementMode, @@ -386,6 +389,8 @@ class AppPreferences { fastIndexTimeoutSeconds ?? this.fastIndexTimeoutSeconds, immersiveAutoFullViewSeconds: immersiveAutoFullViewSeconds ?? this.immersiveAutoFullViewSeconds, + immersiveBackgroundStyle: + immersiveBackgroundStyle ?? this.immersiveBackgroundStyle, visualizerAnimationStyle: visualizerAnimationStyle ?? this.visualizerAnimationStyle, visualizerFrequencyMode: @@ -555,6 +560,7 @@ class AppPreferencesService { static const _fastIndexEnabledKey = 'fast_index_enabled'; static const _fastIndexTimeoutKey = 'fast_index_timeout_seconds'; static const _immersiveAutoFullViewKey = 'immersive_auto_full_view_seconds'; + static const _immersiveBackgroundStyleKey = 'immersive_background_style'; static const _visualizerAnimationStyleKey = 'visualizer_animation_style'; static const _visualizerFrequencyModeKey = 'visualizer_frequency_mode'; static const _visualizerMovementModeKey = 'visualizer_movement_mode'; @@ -696,6 +702,8 @@ class AppPreferencesService { fastIndexTimeoutSeconds: prefs.getInt(_fastIndexTimeoutKey) ?? 3, immersiveAutoFullViewSeconds: prefs.getInt(_immersiveAutoFullViewKey) ?? 0, + immersiveBackgroundStyle: + prefs.getString(_immersiveBackgroundStyleKey) ?? 'color_gradient', visualizerAnimationStyle: prefs.getString(_visualizerAnimationStyleKey) ?? 'bars', visualizerFrequencyMode: @@ -1108,6 +1116,16 @@ class AppPreferencesService { await prefs.setInt(_immersiveAutoFullViewKey, value); } + Future getImmersiveBackgroundStyle() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_immersiveBackgroundStyleKey) ?? 'color_gradient'; + } + + Future setImmersiveBackgroundStyle(String value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_immersiveBackgroundStyleKey, value); + } + Future getVisualizerAnimationStyle() async { final prefs = await SharedPreferences.getInstance(); return prefs.getString(_visualizerAnimationStyleKey) ?? 'bars'; diff --git a/lib/services/metadata_editor_service.dart b/lib/services/metadata_editor_service.dart index 26b66ae6d..275ca6d33 100644 --- a/lib/services/metadata_editor_service.dart +++ b/lib/services/metadata_editor_service.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:path_provider/path_provider.dart'; @@ -31,15 +32,136 @@ class MetadataWriteResult { bool get verified => outcome == MetadataWriteOutcome.verified; } +typedef MetadataTagReader = + Future Function(String path); +typedef MetadataTagWriter = + Future Function( + String path, + rust_metadata.TagEditFields fields, + ); +typedef MetadataTempTagWriter = + Future Function( + String path, + rust_metadata.TagEditFields fields, + String tempDir, + ); +typedef MetadataPersist = + Future Function(String path, rust_metadata.TagEditFields fields); +typedef MetadataTempDirectory = Future Function(); + +class _StagedContentUri { + const _StagedContentUri.ok(this.path) : reason = null; + const _StagedContentUri.failed(this.reason) : path = null; + + final String? path; + final String? reason; +} + +class _ContentUriWrite { + const _ContentUriWrite(this.success, this.reason); + + final bool success; + final String? reason; +} + class MetadataEditorService { - MetadataEditorService._(); + MetadataEditorService._({ + MetadataTagReader? readTagFile, + MetadataTagWriter? writeTagFile, + MetadataTempTagWriter? writeTagFileToTemp, + MetadataPersist? persistMetadata, + MetadataTempDirectory? temporaryDirectory, + }) : _readTagFile = readTagFile ?? _readTagsFromRust, + _writeTagFile = writeTagFile ?? _writeTagsWithRust, + _writeTagFileToTemp = writeTagFileToTemp ?? _writeTagsToTempWithRust, + _persistMetadata = persistMetadata ?? _persistToRepository, + _temporaryDirectory = temporaryDirectory ?? getTemporaryDirectory; + static final MetadataEditorService instance = MetadataEditorService._(); + /// Test seam: inject the Rust tagger, DB write, and temp directory so the + /// content:// orchestration can be exercised in `flutter test`. + @visibleForTesting + factory MetadataEditorService.forTesting({ + MetadataTagReader? readTagFile, + MetadataTagWriter? writeTagFile, + MetadataTempTagWriter? writeTagFileToTemp, + MetadataPersist? persistMetadata, + MetadataTempDirectory? temporaryDirectory, + }) { + return MetadataEditorService._( + readTagFile: readTagFile, + writeTagFile: writeTagFile, + writeTagFileToTemp: writeTagFileToTemp, + persistMetadata: persistMetadata, + temporaryDirectory: temporaryDirectory, + ); + } + + final MetadataTagReader _readTagFile; + final MetadataTagWriter _writeTagFile; + final MetadataTempTagWriter _writeTagFileToTemp; + final MetadataPersist _persistMetadata; + final MetadataTempDirectory _temporaryDirectory; + static const _channel = MethodChannel('com.mossapps.flick/storage'); + static Future _readTagsFromRust(String path) => + rust_metadata.readTags(path: path); + + static Future _writeTagsWithRust( + String path, + rust_metadata.TagEditFields fields, + ) => rust_metadata.writeTags(path: path, fields: fields); + + static Future _writeTagsToTempWithRust( + String path, + rust_metadata.TagEditFields fields, + String tempDir, + ) => rust_metadata.writeTagsToTemp( + path: path, + fields: fields, + tempDir: tempDir, + ); + + static Future _persistToRepository( + String path, + rust_metadata.TagEditFields fields, + ) { + return SongRepository().updateSongMetadata( + path, + title: fields.title, + artist: fields.artist, + album: fields.album, + albumArtist: fields.albumArtist, + trackNumber: fields.trackNumber, + discNumber: fields.discNumber, + year: fields.year, + genre: fields.genre, + ); + } + + /// Reads tags from a local path or an Android `content://` document. + /// + /// The Rust tagger can only open filesystem paths, so content URIs are first + /// copied to a private cache file and that copy is removed afterwards. Future readTags(String filePath) async { + final path = _plainPath(filePath); + if (path == null) { + final staged = await _stageContentUri(filePath); + final stagedPath = staged.path; + if (stagedPath == null) return null; + try { + return await _readTagFile(stagedPath); + } catch (_) { + return null; + } finally { + await _deleteQuietly(stagedPath); + } + } + try { - return await rust_metadata.readTags(path: filePath); + return await _readTagFile(path); } catch (_) { return null; } @@ -47,11 +169,13 @@ class MetadataEditorService { /// Persists [fields] to the actual audio file and verifies them. /// - /// Android Scoped Storage blocks direct writes to most user files, so this - /// tries the fast in-place write first and falls back to the SAF pipeline - /// (write tags to an app-cache temp copy, then copy the bytes back through - /// the folder's document tree grant) when that fails. Either way, the result - /// is confirmed by re-reading the tags before the local DB is touched. + /// Android Scoped Storage blocks direct writes to most user files. Plain + /// filesystem paths try the fast in-place write first and fall back to the + /// SAF pipeline (write tags to an app-cache temp copy, then copy the bytes + /// back through the folder's document tree grant). Content URIs are staged, + /// edited in cache, then streamed back to the original document. Either way, + /// the result is confirmed by re-reading the tags before the local DB is + /// touched. Future writeTags( Song song, rust_metadata.TagEditFields fields, @@ -75,32 +199,97 @@ class MetadataEditorService { ); } - final path = song.filePath!; + final source = song.filePath!; + final path = _plainPath(source); + + // SAF-scanned songs keep a content:// document URI as their filePath. The + // Rust tagger cannot open that URI, so route it through Android staging. + if (path == null) { + return _writeViaContentUri(source, fields); + } + final rust_metadata.TagWriteResult direct; try { // Fast path: direct in-place write. Works for app-owned files and any // location the OS lets us write to directly. - final direct = await rust_metadata.writeTags(path: path, fields: fields); - if (direct.success) { - return await _verifyAndPersist(path, fields); + direct = await _writeTagFile(path, fields); + } catch (e) { + // A thrown direct write must not skip the SAF fallback. + final safResult = await _tryWriteViaSaf(song, source, path, fields); + if (safResult != null) return safResult; + + return MetadataWriteResult( + MetadataWriteOutcome.failed, + message: _unexpectedWriteFailure(e), + ); + } + + if (direct.success) { + try { + return await _verifyAndPersist(source, fields); + } catch (e) { + return MetadataWriteResult( + MetadataWriteOutcome.failed, + message: _unexpectedWriteFailure(e), + ); } + } - // Direct write failed (typically Scoped Storage EACCES). Try the SAF - // copy-back pipeline if we have a folder tree grant to write through. - final safResult = await _writeViaSaf(song, path, fields); - if (safResult != null) return safResult; + // Direct write failed (typically Scoped Storage EACCES). Try the SAF + // copy-back pipeline if we have a folder tree grant to write through. + final safResult = await _tryWriteViaSaf(song, source, path, fields); + if (safResult != null) return safResult; + + // No SAF route available — surface a message that tells the user what + // to do instead of the raw "os error 2" from the tagger. + return MetadataWriteResult( + MetadataWriteOutcome.failed, + message: _writeFailureMessage(direct.error), + ); + } - // No SAF route available — surface the original failure. + /// Stages the document, edits the cache copy with Rust, streams it back to + /// the original URI, then verifies by re-staging the original. + Future _writeViaContentUri( + String uri, + rust_metadata.TagEditFields fields, + ) async { + final staging = await _stageContentUri(uri); + final stagedPath = staging.path; + if (stagedPath == null) { return MetadataWriteResult( MetadataWriteOutcome.failed, - message: - direct.error ?? l10n.englishForFilipino.failedToWriteTagsToTheFile, + message: _stageFailureMessage(staging.reason), ); + } + + try { + final edited = await _writeTagFile(stagedPath, fields); + if (!edited.success) { + return MetadataWriteResult( + MetadataWriteOutcome.failed, + message: l10n.englishForFilipino.couldNotPrepareTheEditedFile( + edited.error ?? l10n.englishForFilipino.unknown, + ), + ); + } + + final written = await _writeBytesToContentUri(uri, stagedPath); + if (!written.success) { + return MetadataWriteResult( + MetadataWriteOutcome.failed, + message: _contentWriteFailureMessage(written.reason), + ); + } + + return await _verifyAndPersist(uri, fields); } catch (e) { return MetadataWriteResult( MetadataWriteOutcome.failed, - message: l10n.englishForFilipino.failedToSaveMetadata(e), + message: _unexpectedWriteFailure(e), ); + } finally { + await _deleteQuietly(stagedPath); } } @@ -108,23 +297,13 @@ class MetadataEditorService { /// result into the local DB. The read-back is the integrity guard: it proves /// the on-disk file actually holds what we asked for. Future _verifyAndPersist( - String path, + String source, rust_metadata.TagEditFields fields, ) async { - final readBack = await readTags(path); + final readBack = await readTags(source); final verified = _verifyReadback(readBack, fields); - await SongRepository().updateSongMetadata( - path, - title: fields.title, - artist: fields.artist, - album: fields.album, - albumArtist: fields.albumArtist, - trackNumber: fields.trackNumber, - discNumber: fields.discNumber, - year: fields.year, - genre: fields.genre, - ); + await _persistMetadata(source, fields); return verified ? const MetadataWriteResult(MetadataWriteOutcome.verified) @@ -134,22 +313,39 @@ class MetadataEditorService { ); } + Future _tryWriteViaSaf( + Song song, + String source, + String path, + rust_metadata.TagEditFields fields, + ) async { + try { + return await _writeViaSaf(song, source, path, fields); + } catch (e) { + return MetadataWriteResult( + MetadataWriteOutcome.failed, + message: _unexpectedWriteFailure(e), + ); + } + } + /// SAF copy-back pipeline. Returns `null` when SAF isn't applicable (no - /// folder tree grant, or a content:// URI the Rust layer can't open); a - /// non-null result (which may be a failure) when SAF was attempted. + /// folder tree grant); a non-null result (which may be a failure) when SAF + /// was attempted. Future _writeViaSaf( Song song, + String source, String path, rust_metadata.TagEditFields fields, ) async { final treeUri = song.folderUri; if (treeUri == null || treeUri.isEmpty) return null; - // The Rust tagger opens the file by path; it can't open a content:// URI. + // Defensive: content URIs are handled by _writeViaContentUri. if (path.startsWith('content://')) return null; final Directory cache; try { - cache = await getTemporaryDirectory(); + cache = await _temporaryDirectory(); } catch (_) { return null; } @@ -157,11 +353,7 @@ class MetadataEditorService { await tempDir.create(recursive: true); // Stage the edited file in app cache (always writable). - final temp = await rust_metadata.writeTagsToTemp( - path: path, - fields: fields, - tempDir: tempDir.path, - ); + final temp = await _writeTagFileToTemp(path, fields, tempDir.path); if (!temp.success || temp.tempPath == null) { return MetadataWriteResult( MetadataWriteOutcome.failed, @@ -186,7 +378,7 @@ class MetadataEditorService { ); } - return _verifyAndPersist(path, fields); + return _verifyAndPersist(source, fields); } Future _writeFileBytesViaSaf( @@ -206,6 +398,124 @@ class MetadataEditorService { } } + Future<_StagedContentUri> _stageContentUri(String uri) async { + try { + final res = await _channel.invokeMapMethod( + 'stageContentUriForEdit', + {'uri': uri, 'extensionHint': _extensionOf(uri)}, + ); + final path = res?['path']; + if (path is String && path.isNotEmpty) { + return _StagedContentUri.ok(path); + } + final reason = res?['reason']; + return _StagedContentUri.failed(reason is String ? reason : 'failed'); + } catch (_) { + return const _StagedContentUri.failed('failed'); + } + } + + Future<_ContentUriWrite> _writeBytesToContentUri( + String uri, + String tempFilePath, + ) async { + try { + final res = await _channel.invokeMapMethod( + 'writeBytesToContentUri', + {'uri': uri, 'tempFilePath': tempFilePath}, + ); + final success = res?['success'] == true; + final reason = res?['reason']; + return _ContentUriWrite(success, reason is String ? reason : null); + } catch (_) { + return const _ContentUriWrite(false, 'failed'); + } + } + + /// Maps a `file://` URI to a filesystem path, passes plain paths through, + /// and returns null for `content://` documents (which Rust cannot open). + String? _plainPath(String source) { + if (source.startsWith('content://')) return null; + if (source.startsWith('file://')) { + try { + return Uri.parse(source).toFilePath(); + } catch (_) { + return source; + } + } + return source; + } + + /// Best-effort extension hint for the staged cache file. Android resolves + /// the provider's display name when this is null (e.g. MediaStore URIs). + String? _extensionOf(String source) { + final path = Uri.tryParse(source)?.path ?? source; + final dot = path.lastIndexOf('.'); + if (dot < 0 || dot == path.length - 1) return null; + final extension = path.substring(dot + 1).toLowerCase(); + if (extension.length > 5 || extension.contains('/')) return null; + return extension; + } + + Future _deleteQuietly(String path) async { + try { + await File(path).delete(); + } catch (_) {} + } + + String _stageFailureMessage(String? reason) { + if (reason == 'no_access') { + return l10n.englishForFilipino.flickNoLongerHasPermissionToEditThisFile; + } + return l10n.englishForFilipino.fileIsNoLongerAvailable; + } + + String _contentWriteFailureMessage(String? reason) { + if (reason == 'no_access') { + return l10n.englishForFilipino.flickNoLongerHasPermissionToEditThisFile; + } + if (reason == 'not_found') { + return l10n.englishForFilipino.fileIsNoLongerAvailable; + } + return l10n.englishForFilipino.androidBlockedWritingToThisFile; + } + + String _writeFailureMessage(String? error) { + if (_isNoSuchFile(error)) { + return l10n.englishForFilipino.fileIsNoLongerAvailable; + } + if (_isPermissionDenied(error)) { + return l10n.englishForFilipino.flickNoLongerHasPermissionToEditThisFile; + } + return error ?? l10n.englishForFilipino.failedToWriteTagsToTheFile; + } + + String _unexpectedWriteFailure(Object error) { + final text = error.toString(); + if (_isNoSuchFile(text)) { + return l10n.englishForFilipino.fileIsNoLongerAvailable; + } + if (_isPermissionDenied(text)) { + return l10n.englishForFilipino.flickNoLongerHasPermissionToEditThisFile; + } + return l10n.englishForFilipino.failedToSaveMetadata(error); + } + + bool _isNoSuchFile(String? error) { + final text = error?.toLowerCase() ?? ''; + return text.contains('os error 2') || + text.contains('no such file or directory') || + text.contains('enoent'); + } + + bool _isPermissionDenied(String? error) { + final text = error?.toLowerCase() ?? ''; + return text.contains('os error 13') || + text.contains('permission denied') || + text.contains('operation not permitted') || + text.contains('eacces'); + } + /// Compares the freshly read tags against the fields we intended to set. /// Only fields with a concrete intent (non-null) are checked; null fields /// mean "leave untouched" and are skipped. String compares are normalized so diff --git a/lib/services/player_service.dart b/lib/services/player_service.dart index 0612b8f1b..7fee7aba5 100644 --- a/lib/services/player_service.dart +++ b/lib/services/player_service.dart @@ -2896,6 +2896,7 @@ class PlayerService { final directUsbState = _mapValue(rustAudioState?['direct_usb']); final dsdCallbackState = _mapValue(rustAudioState?['dsd_callback']); final managedOutputState = _mapValue(rustAudioState?['managed_output']); + final pcmCallbackState = _mapValue(managedOutputState?['pcm_callback']); final deviceProfile = _mapValue(rustAudioState?['device_profile']); final detectedDap = _isDetectedDapProfile(deviceProfile); final detectedDapBrand = _detectedDapBrand(deviceProfile); @@ -3313,6 +3314,14 @@ class PlayerService { reportedOutputSampleRate == 48000 ? 'app-stream-may-match-link' : 'app-stream-resampled-by-bt-mixer'; + final pcmSkippedStages = pcmCallbackState == null + ? -1 + : (_intValue(pcmCallbackState['skipped_pitch']) ?? 0) + + (_intValue(pcmCallbackState['skipped_eq']) ?? 0) + + (_intValue(pcmCallbackState['skipped_crossfeed']) ?? 0) + + (_intValue(pcmCallbackState['skipped_fx']) ?? 0) + + (_intValue(pcmCallbackState['skipped_convolver']) ?? 0) + + (_intValue(pcmCallbackState['skipped_dynamics']) ?? 0); _debugLog( '[Diagnostics] $reason: mode=${mode.logLabel}, ' @@ -3338,6 +3347,24 @@ class PlayerService { 'callbackFramesMax=${_intValue(managedOutputState?['burst_frames_max']) ?? -1}, ' 'callbackChunks=${_intValue(managedOutputState?['burst_chunked_count']) ?? -1}, ' 'callbackScratchFrames=${_intValue(managedOutputState?['scratch_frames']) ?? -1}, ' + 'callbackCount=${_intValue(managedOutputState?['callback_count']) ?? -1}, ' + 'callbackFramesTotal=${_intValue(managedOutputState?['callback_frames_total']) ?? -1}, ' + 'callbackDurationUsMax=${_intValue(managedOutputState?['callback_duration_us_max']) ?? -1}, ' + 'callbackOverruns=${_intValue(managedOutputState?['callback_overruns']) ?? -1}, ' + 'xrunSupported=${managedOutputState?['xrun_count_supported'] ?? 'unknown'}, ' + 'xrunCount=${_intValue(managedOutputState?['xrun_count']) ?? -1}, ' + 'framesPerBurst=${_intValue(managedOutputState?['frames_per_burst']) ?? -1}, ' + 'bufferFrames=${_intValue(managedOutputState?['buffer_size_frames']) ?? -1}/' + '${_intValue(managedOutputState?['buffer_capacity_frames']) ?? -1}, ' + 'pcmSourceLockMisses=${_intValue(pcmCallbackState?['source_lock_misses']) ?? -1}, ' + 'pcmCrossfadeLockMisses=${_intValue(pcmCallbackState?['crossfader_lock_misses']) ?? -1}, ' + 'pcmMixLockMisses=${_intValue(pcmCallbackState?['mix_buffer_lock_misses']) ?? -1}, ' + 'pcmSpeedLockMisses=${_intValue(pcmCallbackState?['speed_lock_misses']) ?? -1}, ' + 'pcmStarvedSamples=${_intValue(pcmCallbackState?['starved_samples']) ?? -1}, ' + 'pcmSkippedDspStages=$pcmSkippedStages, ' + 'pcmPeak=${pcmCallbackState?['peak'] ?? -1}, ' + 'pcmClippedSamples=${_intValue(pcmCallbackState?['clipped_samples']) ?? -1}, ' + 'pcmNonFiniteSamples=${_intValue(pcmCallbackState?['non_finite_samples']) ?? -1}, ' 'producerFrames=${producerFrames ?? -1}, consumerFrames=${consumerFrames ?? -1}, ' 'driftMs=${driftMsFromTarget ?? -999}, ' 'resampler=$resamplerActive, passthrough=$passthroughAllowed, ' diff --git a/lib/services/progress_bar_size_preference_service.dart b/lib/services/progress_bar_size_preference_service.dart new file mode 100644 index 000000000..3e43a812e --- /dev/null +++ b/lib/services/progress_bar_size_preference_service.dart @@ -0,0 +1,34 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:flick/models/progress_bar_size.dart'; + +class ProgressBarSizePreferenceService { + static const waveformHeightKey = 'progress_bar_waveform_height'; + static const lineThicknessKey = 'progress_bar_line_thickness'; + + Future getSizes() async { + final prefs = await SharedPreferences.getInstance(); + return ProgressBarSizes( + waveformHeight: ProgressBarSizes.clampWaveformHeight( + prefs.getDouble(waveformHeightKey) ?? + ProgressBarSizes.defaultWaveformHeight, + ), + lineThickness: ProgressBarSizes.clampLineThickness( + prefs.getDouble(lineThicknessKey) ?? + ProgressBarSizes.defaultLineThickness, + ), + ); + } + + Future setSizes(ProgressBarSizes sizes) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble( + waveformHeightKey, + ProgressBarSizes.clampWaveformHeight(sizes.waveformHeight), + ); + await prefs.setDouble( + lineThicknessKey, + ProgressBarSizes.clampLineThickness(sizes.lineThickness), + ); + } +} diff --git a/lib/widgets/common/detail_header.dart b/lib/widgets/common/detail_header.dart index 70720a98f..cfe1a46bb 100644 --- a/lib/widgets/common/detail_header.dart +++ b/lib/widgets/common/detail_header.dart @@ -55,8 +55,17 @@ class DetailHeader extends StatelessWidget { }); double _resolveHeight(BuildContext context) { - if (!expanded) return 300; - return (MediaQuery.of(context).size.height * 0.55).clamp(360.0, 560.0); + final size = MediaQuery.sizeOf(context); + final isWide = size.width > size.height; + if (!expanded) { + return isWide ? (size.height * 0.55).clamp(150.0, 220.0) : 300; + } + if (isWide) { + // Keep enough room for the tracklist below instead of letting the + // cover consume almost the whole short landscape viewport. + return (size.height * 0.62).clamp(180.0, 280.0); + } + return (size.height * 0.55).clamp(360.0, 560.0); } @override diff --git a/lib/widgets/common/flick_dialog.dart b/lib/widgets/common/flick_dialog.dart index bf21f25b6..47cb9d1e0 100644 --- a/lib/widgets/common/flick_dialog.dart +++ b/lib/widgets/common/flick_dialog.dart @@ -16,8 +16,7 @@ Future showFlickDialog({ context: context, barrierDismissible: barrierDismissible, barrierLabel: barrierLabel, - barrierColor: - barrierColor ?? Colors.black.withValues(alpha: 0.5), + barrierColor: barrierColor ?? Colors.black.withValues(alpha: 0.5), transitionDuration: AppConstants.animationDialog, pageBuilder: (context, animation, secondaryAnimation) => const SizedBox.shrink(), @@ -140,9 +139,9 @@ class FlickTextField extends StatelessWidget { keyboardType: keyboardType, textCapitalization: textCapitalization, maxLength: maxLength, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: AppColors.textPrimary, - ), + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: AppColors.textPrimary), decoration: InputDecoration( isDense: true, hintText: hint, @@ -230,7 +229,10 @@ class FlickDialog extends StatelessWidget { @override Widget build(BuildContext context) { - final maxHeight = MediaQuery.of(context).size.height * 0.8; + final mediaQuery = MediaQuery.of(context); + final availableHeight = + mediaQuery.size.height - mediaQuery.viewInsets.bottom; + final maxHeight = availableHeight * 0.8; return Dialog( backgroundColor: Colors.transparent, @@ -310,7 +312,8 @@ class FlickDialog extends StatelessWidget { ); } - Color get _iconColor => destructive ? AppColors.error : AppColors.textSecondary; + Color get _iconColor => + destructive ? AppColors.error : AppColors.textSecondary; } /// One-call helpers for the two most common dialog shapes. diff --git a/lib/widgets/common/floating_mini_player.dart b/lib/widgets/common/floating_mini_player.dart index a86f28337..a97a65093 100644 --- a/lib/widgets/common/floating_mini_player.dart +++ b/lib/widgets/common/floating_mini_player.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flick/core/theme/adaptive_color_provider.dart'; @@ -63,7 +64,27 @@ class _FloatingMiniPlayerState extends ConsumerState @override void didChangeMetrics() { - setState(() => _offset = Offset.zero); + if (!mounted) return; + + // Keep the island where the user left it, re-clamped to the new window. + // Resetting to zero on every metrics change made it jump to center after + // rotation and could leave a dragged island outside a landscape viewport. + final screenSize = MediaQuery.sizeOf(context); + final safeArea = MediaQuery.paddingOf(context); + final t = _expandAnimation.value; + final width = 96.0 + t * 184.0; + final maxDx = math.max(0.0, (screenSize.width - width) / 2 - 8); + final bottomLimit = math.max( + 0.0, + screenSize.height - 36.0 - safeArea.bottom - safeArea.top - 16, + ); + + setState(() { + _offset = Offset( + _offset.dx.clamp(-maxDx, maxDx), + _offset.dy.clamp(0.0, bottomLimit), + ); + }); } void _onTap() { diff --git a/lib/widgets/common/glass_bottom_sheet.dart b/lib/widgets/common/glass_bottom_sheet.dart index 856542523..5aeb91347 100644 --- a/lib/widgets/common/glass_bottom_sheet.dart +++ b/lib/widgets/common/glass_bottom_sheet.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flick/core/constants/app_constants.dart'; import 'package:flick/core/theme/app_colors.dart'; import 'package:flutter/material.dart'; @@ -6,12 +8,14 @@ class AppBottomSheetSurface extends StatelessWidget { final Widget child; final double? maxHeightRatio; final EdgeInsetsGeometry? padding; + final bool scrollable; const AppBottomSheetSurface({ super.key, required this.child, this.maxHeightRatio, this.padding, + this.scrollable = false, }); static final BoxDecoration decoration = BoxDecoration( @@ -39,9 +43,18 @@ class AppBottomSheetSurface extends StatelessWidget { @override Widget build(BuildContext context) { final mediaQuery = MediaQuery.of(context); - final maxHeight = maxHeightRatio != null - ? mediaQuery.size.height * maxHeightRatio! - : mediaQuery.size.height * 0.85; + final availableHeight = + (mediaQuery.size.height - mediaQuery.viewInsets.bottom).clamp( + 0.0, + mediaQuery.size.height, + ); + final isWide = mediaQuery.size.width > mediaQuery.size.height; + final maxHeight = availableHeight * + (maxHeightRatio ?? (isWide ? 0.96 : 0.9)); + final sheetWidth = math.min( + mediaQuery.size.width, + isWide ? 1120.0 : 760.0, + ); // The sheet surface paints all the way to the bottom edge of the screen. // The system gesture/navigation inset is handled by bottom padding so @@ -69,10 +82,15 @@ class AppBottomSheetSurface extends StatelessWidget { onTap: () {}, child: ConstrainedBox( constraints: BoxConstraints(maxHeight: maxHeight), - child: Container( - decoration: decoration, - padding: effectivePadding, - child: child, + child: SizedBox( + width: sheetWidth, + child: Container( + decoration: decoration, + padding: effectivePadding, + child: scrollable + ? SingleChildScrollView(child: child) + : child, + ), ), ), ), @@ -154,8 +172,11 @@ class GlassBottomSheet extends StatelessWidget { isDismissible: isDismissible, enableDrag: enableDrag, backgroundColor: Colors.transparent, + constraints: const BoxConstraints(maxWidth: double.infinity), barrierColor: Colors.black.withValues(alpha: 0.5), - builder: (context) => GlassBottomSheet( + builder: (context) => Padding( + padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), + child: GlassBottomSheet( title: title, content: content, actions: actions, @@ -164,6 +185,7 @@ class GlassBottomSheet extends StatelessWidget { isScrollControlled: isScrollControlled, maxHeightRatio: maxHeightRatio, contentPadding: contentPadding, + ), ), ); } diff --git a/lib/widgets/common/landscape_side_mini_player.dart b/lib/widgets/common/landscape_side_mini_player.dart new file mode 100644 index 000000000..7ba014ceb --- /dev/null +++ b/lib/widgets/common/landscape_side_mini_player.dart @@ -0,0 +1,327 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:flick/core/constants/app_constants.dart'; +import 'package:flick/core/theme/adaptive_color_provider.dart'; +import 'package:flick/core/theme/app_colors.dart'; +import 'package:flick/core/utils/adaptive_layout.dart'; +import 'package:flick/core/utils/navigation_helper.dart'; +import 'package:flick/features/player/widgets/audio_visualizer.dart'; +import 'package:flick/l10n/l10n.dart'; +import 'package:flick/models/mini_player_config.dart'; +import 'package:flick/models/song.dart'; +import 'package:flick/providers/mini_player_config_provider.dart'; +import 'package:flick/services/app_preferences_service.dart'; +import 'package:flick/providers/providers.dart'; +import 'package:flick/widgets/common/cached_image_widget.dart'; +import 'package:flick/widgets/common/flick_artwork_placeholder.dart'; + +/// The compact landscape song orbit needs the stage height that the regular +/// horizontal mini player would otherwise take. This strip keeps the artwork, +/// play/pause and a vertical visualizer while moving the player to the side. +class LandscapeSideMiniPlayer extends ConsumerStatefulWidget { + const LandscapeSideMiniPlayer({super.key}); + + static const String heroTag = 'mini_player_art'; + + @override + ConsumerState createState() => + _LandscapeSideMiniPlayerState(); +} + +class _LandscapeSideMiniPlayerState + extends ConsumerState { + int _songChangeDirection = 0; + + void _next() { + _songChangeDirection = 1; + ref.read(playerProvider.notifier).next(); + } + + void _previous() { + _songChangeDirection = -1; + ref.read(playerProvider.notifier).previous(allowRestart: false); + } + + void _onVerticalDragEnd(DragEndDetails details) { + final velocity = details.primaryVelocity ?? 0; + if (velocity.abs() <= 300) return; + if (velocity < 0) { + _next(); + } else { + _previous(); + } + } + + Future _openPlayer() async { + final result = await NavigationHelper.navigateToFullPlayer( + context, + heroTag: LandscapeSideMiniPlayer.heroTag, + ); + if (result != null && mounted) { + ref.read(navigationIndexProvider.notifier).setIndex(result); + } + } + + @override + Widget build(BuildContext context) { + final song = ref.watch(currentSongProvider); + if (song == null) return const SizedBox.shrink(); + + final prefs = ref.watch(appPreferencesProvider); + final config = ref.watch(miniPlayerConfigProvider); + final isPlaying = ref.watch(isPlayingProvider); + final progress = ref.watch(progressProvider); + final reducedMotion = MediaQuery.disableAnimationsOf(context); + final radius = math.min(config.cornerRadius, 28.0); + final duration = reducedMotion + ? Duration.zero + : AppConstants.animationNormal; + final visualizerEnabled = + prefs.visualizerEnabled && !MediaQuery.disableAnimationsOf(context); + + return SizedBox( + width: AdaptiveLayout.sideMiniPlayerWidth, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: AppConstants.spacingXs), + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + AppColors.surfaceLight.withValues( + alpha: config.backgroundOpacity * (0.86 / 0.94), + ), + AppColors.surface.withValues(alpha: config.backgroundOpacity), + ], + ), + borderRadius: BorderRadius.circular(radius), + boxShadow: _shadow(config), + ), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onVerticalDragEnd: _onVerticalDragEnd, + child: ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: Stack( + fit: StackFit.expand, + children: [ + Column( + children: [ + Expanded( + child: Semantics( + button: true, + label: l10n.openPlayer, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _openPlayer, + child: Column( + // The rotated painter has no intrinsic width; + // give it tight bounds on both axes. + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (config.showArtwork) + _buildArtwork(song, duration) + else + const SizedBox( + height: AppConstants.spacingXs, + ), + const SizedBox(height: AppConstants.spacingXxs), + Expanded( + child: _buildVisualizer( + prefs: prefs, + visualizerEnabled: visualizerEnabled, + ), + ), + ], + ), + ), + ), + ), + Container( + key: const ValueKey('side_mini_player_divider'), + height: 1, + margin: const EdgeInsets.symmetric( + horizontal: AppConstants.spacingSm, + ), + color: Colors.white.withValues(alpha: 0.14), + ), + SizedBox( + height: 52, + child: Center( + child: IconButton( + tooltip: isPlaying ? l10n.pause : l10n.play, + onPressed: () => ref + .read(playerProvider.notifier) + .togglePlayPause(), + icon: Icon( + isPlaying ? LucideIcons.pause : LucideIcons.play, + size: 22, + color: AppColors.accent, + ), + ), + ), + ), + ], + ), + if (config.showProgress) + Positioned( + left: 0, + top: 0, + bottom: 0, + child: _VerticalProgress(progress: progress), + ), + if (config.showBorder) + Positioned.fill( + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(radius), + border: Border.all( + color: Colors.white.withValues(alpha: 0.22), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildArtwork(Song song, Duration duration) { + final placeholder = ColoredBox( + color: AppColors.surface, + child: const FlickArtworkPlaceholder(size: 26, opacity: 0.9), + ); + final artwork = song.albumArt != null + ? CachedImageWidget( + imagePath: song.albumArt!, + audioSourcePath: song.filePath, + fit: BoxFit.cover, + useThumbnail: true, + thumbnailWidth: 128, + thumbnailHeight: 128, + placeholder: placeholder, + errorWidget: placeholder, + ) + : placeholder; + + return SizedBox( + height: AdaptiveLayout.sideMiniPlayerWidth, + width: double.infinity, + child: Hero( + tag: LandscapeSideMiniPlayer.heroTag, + child: AnimatedSwitcher( + duration: duration, + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: SlideTransition( + position: Tween( + begin: Offset(0, _songChangeDirection * 0.25), + end: Offset.zero, + ).animate(animation), + child: child, + ), + ), + child: KeyedSubtree(key: ValueKey(song.id), child: artwork), + ), + ), + ); + } + + Widget _buildVisualizer({ + required AppPreferences prefs, + required bool visualizerEnabled, + }) { + if (!visualizerEnabled) { + return Center( + child: Icon( + LucideIcons.audioLines, + size: 18, + color: context.adaptiveTextSecondary.withValues(alpha: 0.35), + ), + ); + } + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppConstants.spacingXs, + vertical: AppConstants.spacingXxs, + ), + child: RotatedBox( + key: const ValueKey('side_mini_player_visualizer'), + quarterTurns: 3, + child: AudioVisualizer( + playerService: ref.read(playerServiceProvider), + animationStyle: prefs.visualizerAnimationStyle, + frequencyMode: prefs.visualizerFrequencyMode, + movementMode: prefs.visualizerMovementMode, + albumColor: ref.watch(albumDominantColorSyncProvider), + enabled: true, + ), + ), + ); + } + + List? _shadow(MiniPlayerConfig config) { + return switch (config.shadow) { + MiniPlayerShadow.off => null, + MiniPlayerShadow.subtle => [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.22), + blurRadius: 14, + offset: const Offset(-4, 0), + ), + ], + MiniPlayerShadow.strong => [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.35), + blurRadius: 24, + offset: const Offset(-8, 0), + ), + ], + }; + } +} + +class _VerticalProgress extends StatelessWidget { + final double progress; + + const _VerticalProgress({required this.progress}); + + @override + Widget build(BuildContext context) { + final value = progress.isFinite ? progress.clamp(0.0, 1.0) : 0.0; + return SizedBox( + width: 2, + child: Stack( + fit: StackFit.expand, + children: [ + ColoredBox(color: Colors.white.withValues(alpha: 0.08)), + if (value > 0) + Align( + alignment: Alignment.bottomCenter, + child: FractionallySizedBox( + heightFactor: value, + widthFactor: 1, + child: ColoredBox( + key: const ValueKey('side_mini_player_progress'), + color: AppColors.accent, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/navigation/flick_nav_bar.dart b/lib/widgets/navigation/flick_nav_bar.dart index 78a3b2c78..40de26350 100644 --- a/lib/widgets/navigation/flick_nav_bar.dart +++ b/lib/widgets/navigation/flick_nav_bar.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flick/core/theme/app_colors.dart'; import 'package:flick/core/constants/app_constants.dart'; +import 'package:flick/core/utils/adaptive_layout.dart'; import 'package:flick/core/utils/app_haptics.dart'; import 'package:flick/core/utils/responsive.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -187,6 +188,274 @@ class _FlickNavBarState extends State { } } +/// Compact, scrollable navigation for wide windows. It reuses the same +/// destination order and item widgets as [FlickNavBar], so user +/// customisation is preserved instead of being re-interpreted. +class FlickNavRail extends StatelessWidget { + final int currentIndex; + final ValueChanged onTap; + final VoidCallback? onBottomBarSettings; + final NavBarConfig config; + + const FlickNavRail({ + super.key, + required this.currentIndex, + required this.onTap, + this.onBottomBarSettings, + required this.config, + }); + + static double width(BuildContext context) => + AdaptiveLayout.railWidth(context) + MediaQuery.paddingOf(context).left; + + @override + Widget build(BuildContext context) { + final mq = MediaQuery.of(context); + final railWidth = AdaptiveLayout.railWidth(context); + final buttons = config.orderedButtons; + final missingEssentials = config.missingEssentials; + final isOnMissingEssential = missingEssentials.any( + (button) => button.pageIndex == currentIndex, + ); + + return Container( + width: railWidth + mq.padding.left, + padding: EdgeInsets.only( + left: mq.padding.left + 6, + right: 6, + top: mq.padding.top + 10, + bottom: mq.padding.bottom + 10, + ), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + AppColors.surfaceLight.withValues(alpha: 0.94), + AppColors.surface.withValues(alpha: 0.98), + ], + ), + border: const Border(right: BorderSide(color: AppColors.glassBorder)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.28), + blurRadius: 20, + offset: const Offset(6, 0), + ), + ], + ), + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + for (final button in buttons) + _RailItem( + button: button, + isSelected: currentIndex == button.pageIndex, + config: config, + onTap: () => onTap(button.pageIndex), + onLongPress: onBottomBarSettings, + ), + if (missingEssentials.isNotEmpty) + _RailOverflowItem( + isSelected: isOnMissingEssential, + config: config, + onTap: () => + _showOverflowSheet(context, missingEssentials), + onLongPress: onBottomBarSettings, + ), + ], + ), + ), + ), + if (onBottomBarSettings != null) ...[ + Divider( + height: 17, + color: AppColors.glassBorder.withValues(alpha: 0.7), + ), + IconButton( + onPressed: AppHaptics.wrap(onBottomBarSettings!), + tooltip: l10n.customizeBottomBar, + icon: Icon( + LucideIcons.settings, + color: AppColors.inactiveState, + size: context.responsiveIcon(AppConstants.iconSizeSm), + ), + ), + ], + ], + ), + ); + } + + void _showOverflowSheet( + BuildContext context, + List missingButtons, + ) { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (sheetContext) { + return SafeArea( + child: Container( + margin: const EdgeInsets.all(12), + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(sheetContext).height * 0.7, + ), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + AppColors.surfaceLight.withValues(alpha: 0.97), + AppColors.surface.withValues(alpha: 0.99), + ], + ), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: AppColors.glassBorder), + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final button in missingButtons) + ListTile( + leading: Icon( + button.icon, + size: 20, + color: AppColors.textSecondary, + ), + title: BaybayinText( + label: baybayinNavigationLabel(button), + fallback: button.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onTap: () { + Navigator.of(sheetContext).pop(); + onTap(button.pageIndex); + }, + ), + if (onBottomBarSettings != null) ...[ + Divider( + height: 1, + color: AppColors.glassBorder, + indent: 12, + endIndent: 12, + ), + ListTile( + leading: Icon( + LucideIcons.settings, + size: 20, + color: AppColors.accent.withValues(alpha: 0.85), + ), + title: Text(l10n.customizeBottomBar), + onTap: () { + Navigator.of(sheetContext).pop(); + onBottomBarSettings!(); + }, + ), + ], + ], + ), + ), + ), + ); + }, + ); + } +} + +class _RailItem extends StatelessWidget { + final NavBarButton button; + final bool isSelected; + final NavBarConfig config; + final VoidCallback onTap; + final VoidCallback? onLongPress; + + const _RailItem({ + required this.button, + required this.isSelected, + required this.config, + required this.onTap, + this.onLongPress, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onLongPress: onLongPress, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 56), + child: _FlickNavItem( + button: button, + isSelected: isSelected, + config: config, + onTap: onTap, + ), + ), + ); + } +} + +class _RailOverflowItem extends StatelessWidget { + final bool isSelected; + final NavBarConfig config; + final VoidCallback onTap; + final VoidCallback? onLongPress; + + const _RailOverflowItem({ + required this.isSelected, + required this.config, + required this.onTap, + this.onLongPress, + }); + + @override + Widget build(BuildContext context) { + final color = isSelected ? AppColors.activeState : AppColors.inactiveState; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + onLongPress: onLongPress, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 56), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + LucideIcons.ellipsis, + color: color, + size: + context.responsiveIcon(AppConstants.iconSizeSm) * + config.iconSizeFactor, + ), + if (config.showLabels) ...[ + const SizedBox(height: 2), + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + l10n.more, + maxLines: 1, + style: TextStyle( + fontFamily: 'GoogleSansFlex', + fontSize: + context.responsiveText(8.0) * config.barSizeFactor, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + color: color, + ), + ), + ), + ], + ], + ), + ), + ); + } +} + class _OverflowNavItem extends StatelessWidget { final bool isSelected; final NavBarConfig config; diff --git a/rust/src/api/audio_api.rs b/rust/src/api/audio_api.rs index d593e8e93..b9523b601 100644 --- a/rust/src/api/audio_api.rs +++ b/rust/src/api/audio_api.rs @@ -1186,11 +1186,37 @@ pub(crate) fn managed_output_debug_json() -> serde_json::Value { #[cfg(not(target_os = "android"))] let scratch_frames = 0usize; - let stats = read_audio_engine(|handle| handle.managed_output_stats()); + let managed = read_audio_engine(|handle| handle.managed_output_stats()); + let pcm = read_audio_engine(|handle| handle.pcm_callback_stats()); serde_json::json!({ - "burst_frames_max": stats.map(|(frames, _)| frames), - "burst_chunked_count": stats.map(|(_, chunks)| chunks), + "burst_frames_max": managed.map(|stats| stats.burst_frames_max), + "burst_chunked_count": managed.map(|stats| stats.burst_chunked_count), + "callback_count": managed.map(|stats| stats.callback_count), + "callback_frames_total": managed.map(|stats| stats.callback_frames_total), + "callback_duration_us_max": managed.map(|stats| stats.callback_duration_us_max), + "callback_overruns": managed.map(|stats| stats.callback_overruns), + "xrun_count_supported": managed.map(|stats| stats.xrun_count_supported), + "xrun_count": managed.and_then(|stats| stats.xrun_count), + "frames_per_burst": managed.map(|stats| stats.frames_per_burst), + "buffer_size_frames": managed.map(|stats| stats.buffer_size_frames), + "buffer_capacity_frames": managed.map(|stats| stats.buffer_capacity_frames), "scratch_frames": scratch_frames, + "pcm_callback": { + "source_lock_misses": pcm.map(|stats| stats.source_lock_misses), + "crossfader_lock_misses": pcm.map(|stats| stats.crossfader_lock_misses), + "mix_buffer_lock_misses": pcm.map(|stats| stats.mix_buffer_lock_misses), + "speed_lock_misses": pcm.map(|stats| stats.speed_lock_misses), + "starved_samples": pcm.map(|stats| stats.starved_samples), + "skipped_pitch": pcm.map(|stats| stats.skipped_pitch), + "skipped_eq": pcm.map(|stats| stats.skipped_eq), + "skipped_crossfeed": pcm.map(|stats| stats.skipped_crossfeed), + "skipped_fx": pcm.map(|stats| stats.skipped_fx), + "skipped_convolver": pcm.map(|stats| stats.skipped_convolver), + "skipped_dynamics": pcm.map(|stats| stats.skipped_dynamics), + "peak": pcm.map(|stats| stats.peak), + "clipped_samples": pcm.map(|stats| stats.clipped_samples), + "non_finite_samples": pcm.map(|stats| stats.non_finite_samples), + }, }) } diff --git a/rust/src/audio/engine.rs b/rust/src/audio/engine.rs index b3c542648..41a7b7830 100644 --- a/rust/src/audio/engine.rs +++ b/rust/src/audio/engine.rs @@ -15,7 +15,7 @@ use crate::audio::device::current_device_profile; use crate::audio::dsd_engine::DsdDecoderThread; use crate::audio::dynamics::DynamicsChain; use crate::audio::crossfeed::{Crossfeed, CrossfeedLevel}; -use crate::audio::equalizer::{EqBandSpec, Equalizer}; +use crate::audio::equalizer::{EqBandSpec, EqParams, Equalizer}; use crate::audio::fx::SpatialFx; use crate::audio::pitch_shifter::PitchShifter; use crate::audio::source::{ @@ -152,10 +152,50 @@ impl From for EngineDsdMode { } } +/// Cumulative counters from the real-time PCM/DSP callback. Published through +/// atomics so diagnostics can inspect the live path without logging from (or +/// taking locks in) the audio callback. +#[allow(dead_code)] // fields are consumed by Android diagnostics and tests +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct PcmCallbackStats { + pub source_lock_misses: u64, + pub crossfader_lock_misses: u64, + pub mix_buffer_lock_misses: u64, + pub speed_lock_misses: u64, + pub starved_samples: u64, + pub skipped_pitch: u64, + pub skipped_eq: u64, + pub skipped_crossfeed: u64, + pub skipped_fx: u64, + pub skipped_convolver: u64, + pub skipped_dynamics: u64, + pub peak: f32, + pub clipped_samples: u64, + pub non_finite_samples: u64, +} + +/// Managed Oboe stream configuration and xrun counters, sampled from the +/// control thread (never from the audio callback). +#[allow(dead_code)] // fields are consumed by Android diagnostics and tests +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct ManagedOutputStats { + pub callback_count: u64, + pub callback_frames_total: u64, + pub callback_duration_us_max: u64, + pub callback_overruns: u64, + pub burst_frames_max: u32, + pub burst_chunked_count: u64, + pub xrun_count_supported: bool, + pub xrun_count: Option, + pub frames_per_burst: u32, + pub buffer_size_frames: u32, + pub buffer_capacity_frames: u32, +} + /// Audio callback data shared between engine and audio thread. /// -/// This struct contains only lock-free or atomic data to ensure -/// the audio callback never blocks. +/// Progress and diagnostics use atomics. Mutable render state is protected by +/// non-blocking callback locks; control-side contention must be kept out of it. pub struct AudioCallbackData { /// Volume level (0.0 to 1.0) volume: std::sync::atomic::AtomicU32, // Using AtomicU32 for f32 bit pattern @@ -181,8 +221,12 @@ pub struct AudioCallbackData { speed_buffer: Mutex>, /// Fractional sample position for speed interpolation speed_frac_pos: Mutex, - /// Graphic EQ (10 bands). try_lock in callback to avoid blocking. + /// Live EQ filter state, mutated only on the render side. equalizer: Mutex, + /// Latest precomputed EQ update. A busy mailbox defers the update, not the + /// active EQ processing. Coalescing avoids unbounded slider-update queues. + pending_equalizer: Mutex>, + equalizer_update_pending: AtomicBool, /// Creative spatial/time FX. fx: Mutex, /// BS2B (Bauer stereophonic-to-binaural) crossfeed. Reduces headphone ear @@ -210,6 +254,14 @@ pub struct AudioCallbackData { /// passthrough so the BS2B filters run even on a verified bit-perfect USB /// output (same trick as EQ). crossfeed_forces_dsp: AtomicBool, + /// Lock-free mirrors of effects that are enabled but have no dedicated + /// passthrough override. The callback uses these to avoid contending on + /// inactive effect mutexes and to distinguish an active stage skipped by + /// lock contention from an effect that is simply bypassed. + fx_enabled: AtomicBool, + convolver_enabled: AtomicBool, + compressor_enabled: AtomicBool, + limiter_enabled: AtomicBool, /// Effective ReplayGain for the current track (dB, f32 bits). New sources /// are stamped with this value at spawn; [AudioCommand::SetReplayGain] /// updates it live. ReplayGain != 0 dB needs the DSP path, so this also @@ -234,6 +286,10 @@ pub struct AudioCallbackData { /// `try_lock` never collides mid-fade (a collision drops the fade gains and /// emits the outgoing track at full volume = audible chopping). crossfade_active: AtomicBool, + /// Lock-free mirror of `crossfader.is_enabled()`. Lets the command loop + /// skip disabled-crossfade checks without taking the source/crossfader + /// locks that the real-time callback needs. + crossfade_configured: AtomicBool, /// Latches a reported [AudioEvent::NextTrackReady] for the queued /// successor. Starts `true` ("nothing to report"); queueing arms it, and /// the command loop reports only once the successor actually holds audio. @@ -261,6 +317,49 @@ pub struct AudioCallbackData { dsd_starved_samples: AtomicU64, /// Telemetry: callbacks silenced by `sources` lock contention. dsd_lock_misses: AtomicU64, + /// Telemetry: PCM callbacks whose entire block was silenced because the + /// source provider lock was busy. This is the DSP-path counterpart of + /// `dsd_lock_misses`. + pcm_source_lock_misses: AtomicU64, + /// Telemetry: PCM callbacks that could not acquire crossfader state. + pcm_crossfader_lock_misses: AtomicU64, + /// Telemetry: PCM callbacks that could not acquire the crossfade mix or + /// speed scratch buffers. + pcm_mix_buffer_lock_misses: AtomicU64, + pcm_speed_lock_misses: AtomicU64, + /// Telemetry: samples zero-filled because a live PCM source ran short. + pcm_starved_samples: AtomicU64, + /// Telemetry: DSP blocks where an active effect was bypassed because its + /// control lock was busy. + dsp_skipped_pitch: AtomicU64, + dsp_skipped_eq: AtomicU64, + dsp_skipped_crossfeed: AtomicU64, + dsp_skipped_fx: AtomicU64, + dsp_skipped_convolver: AtomicU64, + dsp_skipped_dynamics: AtomicU64, + /// Telemetry: highest absolute output sample after the final volume stage + /// (f32 bits), and the number of samples outside [-1, 1] or non-finite. + output_peak_bits: AtomicU32, + output_clipped_samples: AtomicU64, + output_nonfinite_samples: AtomicU64, + /// Telemetry sampled from the managed Oboe stream control path. + managed_callback_count: AtomicU64, + managed_callback_frames_total: AtomicU64, + managed_callback_duration_us_max: AtomicU64, + managed_callback_overruns: AtomicU64, + managed_xrun_count_supported: AtomicBool, + managed_xrun_count_available: AtomicBool, + managed_xrun_count: AtomicU32, + managed_frames_per_burst: AtomicU32, + managed_buffer_size_frames: AtomicU32, + managed_buffer_capacity_frames: AtomicU32, + /// Lock-free playback progress snapshot. `progress_seq` is a seqlock + /// generation: odd while the callback publishes, even when stable. + progress_seq: AtomicU64, + progress_position_secs_bits: AtomicU64, + progress_duration_secs_bits: AtomicU64, + progress_buffer_level_bits: AtomicU32, + progress_has_source: AtomicBool, /// Latches the first persistent-silence report (zero channels, undersized /// mix buffers) so the RT callback emits a single "output lost" event /// instead of spamming the channel every callback. @@ -314,6 +413,8 @@ impl AudioCallbackData { speed_buffer: Mutex::new(vec![0.0; speed_buffer_size]), speed_frac_pos: Mutex::new(0.0), equalizer: Mutex::new(Equalizer::new()), + pending_equalizer: Mutex::new(None), + equalizer_update_pending: AtomicBool::new(false), fx: Mutex::new(SpatialFx::new(sample_rate)), crossfeed: Mutex::new(Crossfeed::new(sample_rate)), convolver: Mutex::new(Convolver::new(sample_rate)), @@ -322,12 +423,17 @@ impl AudioCallbackData { pitch_shift_forces_dsp: AtomicBool::new(false), eq_forces_dsp: AtomicBool::new(false), crossfeed_forces_dsp: AtomicBool::new(false), + fx_enabled: AtomicBool::new(false), + convolver_enabled: AtomicBool::new(false), + compressor_enabled: AtomicBool::new(false), + limiter_enabled: AtomicBool::new(false), replaygain_db: AtomicU32::new(0.0f32.to_bits()), replaygain_forces_dsp: AtomicBool::new(false), finished_tracks, tuning_432hz_enabled: AtomicBool::new(tuning_enabled), crossfade_forces_dsp: AtomicBool::new(false), crossfade_active: AtomicBool::new(false), + crossfade_configured: AtomicBool::new(false), next_ready_reported: AtomicBool::new(true), stream_needs_restart: AtomicBool::new(false), engine_dsd_mode: AtomicU8::new(engine_dsd_mode as u8), @@ -336,6 +442,35 @@ impl AudioCallbackData { dop_marker_phase: AtomicBool::new(false), dsd_starved_samples: AtomicU64::new(0), dsd_lock_misses: AtomicU64::new(0), + pcm_source_lock_misses: AtomicU64::new(0), + pcm_crossfader_lock_misses: AtomicU64::new(0), + pcm_mix_buffer_lock_misses: AtomicU64::new(0), + pcm_speed_lock_misses: AtomicU64::new(0), + pcm_starved_samples: AtomicU64::new(0), + dsp_skipped_pitch: AtomicU64::new(0), + dsp_skipped_eq: AtomicU64::new(0), + dsp_skipped_crossfeed: AtomicU64::new(0), + dsp_skipped_fx: AtomicU64::new(0), + dsp_skipped_convolver: AtomicU64::new(0), + dsp_skipped_dynamics: AtomicU64::new(0), + output_peak_bits: AtomicU32::new(0.0f32.to_bits()), + output_clipped_samples: AtomicU64::new(0), + output_nonfinite_samples: AtomicU64::new(0), + managed_callback_count: AtomicU64::new(0), + managed_callback_frames_total: AtomicU64::new(0), + managed_callback_duration_us_max: AtomicU64::new(0), + managed_callback_overruns: AtomicU64::new(0), + managed_xrun_count_supported: AtomicBool::new(false), + managed_xrun_count_available: AtomicBool::new(false), + managed_xrun_count: AtomicU32::new(0), + managed_frames_per_burst: AtomicU32::new(0), + managed_buffer_size_frames: AtomicU32::new(0), + managed_buffer_capacity_frames: AtomicU32::new(0), + progress_seq: AtomicU64::new(0), + progress_position_secs_bits: AtomicU64::new(0.0f64.to_bits()), + progress_duration_secs_bits: AtomicU64::new(0.0f64.to_bits()), + progress_buffer_level_bits: AtomicU32::new(0.0f32.to_bits()), + progress_has_source: AtomicBool::new(false), output_loss_reported: AtomicBool::new(false), oboe_burst_frames_max: AtomicU32::new(0), oboe_burst_chunked_count: AtomicU64::new(0), @@ -424,6 +559,200 @@ impl AudioCallbackData { ) } + /// Publish the current source's UI progress through the lock-free snapshot. + /// Callers must hold the `sources` lock so the source cannot be replaced + /// between selection and publication. + pub(crate) fn publish_progress_snapshot(&self, sources: &SourceProvider) { + let source = progress_source( + self.crossfade_active.load(Ordering::Relaxed), + sources, + ); + + self.progress_seq.fetch_add(1, Ordering::Relaxed); + // Publish the odd generation before any payload stores. The reader's + // acquire fence before rechecking the generation pairs with this fence. + std::sync::atomic::fence(Ordering::Release); + match source { + Some(source) => { + self.progress_position_secs_bits + .store(source.position_secs().to_bits(), Ordering::Relaxed); + self.progress_duration_secs_bits + .store(source.info.duration_secs.to_bits(), Ordering::Relaxed); + self.progress_buffer_level_bits + .store(source.buffer_level().to_bits(), Ordering::Relaxed); + self.progress_has_source.store(true, Ordering::Relaxed); + } + None => { + self.progress_position_secs_bits + .store(0.0f64.to_bits(), Ordering::Relaxed); + self.progress_duration_secs_bits + .store(0.0f64.to_bits(), Ordering::Relaxed); + self.progress_buffer_level_bits + .store(0.0f32.to_bits(), Ordering::Relaxed); + self.progress_has_source.store(false, Ordering::Relaxed); + } + } + self.progress_seq.fetch_add(1, Ordering::Release); + } + + /// Clear the progress snapshot when playback is explicitly stopped or the + /// engine is shutting down. Callers must hold the `sources` lock so this + /// cannot interleave with a callback publishing a newer snapshot. + pub(crate) fn clear_progress_snapshot(&self) { + self.progress_seq.fetch_add(1, Ordering::Relaxed); + std::sync::atomic::fence(Ordering::Release); + self.progress_position_secs_bits + .store(0.0f64.to_bits(), Ordering::Relaxed); + self.progress_duration_secs_bits + .store(0.0f64.to_bits(), Ordering::Relaxed); + self.progress_buffer_level_bits + .store(0.0f32.to_bits(), Ordering::Relaxed); + self.progress_has_source.store(false, Ordering::Relaxed); + self.progress_seq.fetch_add(1, Ordering::Release); + } + + /// Read the lock-free progress snapshot. Never takes the sources lock. + pub(crate) fn progress_snapshot(&self) -> Option { + for _ in 0..4 { + let seq_before = self.progress_seq.load(Ordering::Acquire); + if seq_before & 1 != 0 { + continue; + } + let has_source = self.progress_has_source.load(Ordering::Relaxed); + let position_bits = self.progress_position_secs_bits.load(Ordering::Relaxed); + let duration_bits = self.progress_duration_secs_bits.load(Ordering::Relaxed); + let buffer_level_bits = self.progress_buffer_level_bits.load(Ordering::Relaxed); + std::sync::atomic::fence(Ordering::Acquire); + let seq_after = self.progress_seq.load(Ordering::Relaxed); + if seq_before != seq_after { + continue; + } + if !has_source { + return None; + } + return Some(PlaybackProgress { + position_secs: f64::from_bits(position_bits), + duration_secs: Some(f64::from_bits(duration_bits)), + buffer_level: f32::from_bits(buffer_level_bits), + }); + } + None + } + + /// Cumulative PCM/DSP callback path counters. + pub(crate) fn pcm_callback_stats(&self) -> PcmCallbackStats { + PcmCallbackStats { + source_lock_misses: self.pcm_source_lock_misses.load(Ordering::Relaxed), + crossfader_lock_misses: self.pcm_crossfader_lock_misses.load(Ordering::Relaxed), + mix_buffer_lock_misses: self.pcm_mix_buffer_lock_misses.load(Ordering::Relaxed), + speed_lock_misses: self.pcm_speed_lock_misses.load(Ordering::Relaxed), + starved_samples: self.pcm_starved_samples.load(Ordering::Relaxed), + skipped_pitch: self.dsp_skipped_pitch.load(Ordering::Relaxed), + skipped_eq: self.dsp_skipped_eq.load(Ordering::Relaxed), + skipped_crossfeed: self.dsp_skipped_crossfeed.load(Ordering::Relaxed), + skipped_fx: self.dsp_skipped_fx.load(Ordering::Relaxed), + skipped_convolver: self.dsp_skipped_convolver.load(Ordering::Relaxed), + skipped_dynamics: self.dsp_skipped_dynamics.load(Ordering::Relaxed), + peak: f32::from_bits(self.output_peak_bits.load(Ordering::Relaxed)), + clipped_samples: self.output_clipped_samples.load(Ordering::Relaxed), + non_finite_samples: self.output_nonfinite_samples.load(Ordering::Relaxed), + } + } + + /// Record the output peak and clipping/non-finite sample counts after the + /// final volume stage. RT-safe: only positive-float bit comparisons and + /// atomic updates. + #[inline] + pub(crate) fn note_output_peak(&self, peak: f32, clipped: u64, non_finite: u64) { + if peak > 0.0 { + self.output_peak_bits + .fetch_max(peak.to_bits(), Ordering::Relaxed); + } + if clipped > 0 { + self.output_clipped_samples + .fetch_add(clipped, Ordering::Relaxed); + } + if non_finite > 0 { + self.output_nonfinite_samples + .fetch_add(non_finite, Ordering::Relaxed); + } + } + + /// Telemetry: execution time for one whole device burst (including bounded + /// rendering and output conversion). An overrun is not an OS xrun count. + #[inline] + pub(crate) fn note_managed_callback( + &self, + frames: usize, + elapsed: std::time::Duration, + sample_rate: u32, + ) { + self.managed_callback_count.fetch_add(1, Ordering::Relaxed); + self.managed_callback_frames_total + .fetch_add(frames as u64, Ordering::Relaxed); + self.managed_callback_duration_us_max.fetch_max( + elapsed.as_micros().min(u64::MAX as u128) as u64, + Ordering::Relaxed, + ); + if sample_rate > 0 + && frames > 0 + && elapsed.as_nanos() > frames as u128 * 1_000_000_000 / sample_rate as u128 + { + self.managed_callback_overruns.fetch_add(1, Ordering::Relaxed); + } + } + + /// Publish managed-stream configuration and xrun counters sampled from the + /// control thread. An unavailable reading must not look like zero underruns + /// or a stale reading from a previously opened stream. + #[allow(clippy::too_many_arguments)] + pub(crate) fn note_managed_telemetry( + &self, + xrun_supported: bool, + xrun_count: Option, + frames_per_burst: u32, + buffer_size_frames: u32, + buffer_capacity_frames: u32, + ) { + self.managed_xrun_count_supported + .store(xrun_supported, Ordering::Relaxed); + if let Some(count) = xrun_count { + self.managed_xrun_count.store(count, Ordering::Relaxed); + } + self.managed_xrun_count_available + .store(xrun_supported && xrun_count.is_some(), Ordering::Relaxed); + self.managed_frames_per_burst + .store(frames_per_burst, Ordering::Relaxed); + self.managed_buffer_size_frames + .store(buffer_size_frames, Ordering::Relaxed); + self.managed_buffer_capacity_frames + .store(buffer_capacity_frames, Ordering::Relaxed); + } + + /// Snapshot of managed-stream and burst telemetry. + pub(crate) fn managed_output_stats(&self) -> ManagedOutputStats { + ManagedOutputStats { + callback_count: self.managed_callback_count.load(Ordering::Relaxed), + callback_frames_total: self.managed_callback_frames_total.load(Ordering::Relaxed), + callback_duration_us_max: self + .managed_callback_duration_us_max + .load(Ordering::Relaxed), + callback_overruns: self.managed_callback_overruns.load(Ordering::Relaxed), + burst_frames_max: self.oboe_burst_frames_max.load(Ordering::Relaxed), + burst_chunked_count: self.oboe_burst_chunked_count.load(Ordering::Relaxed), + xrun_count_supported: self.managed_xrun_count_supported.load(Ordering::Relaxed), + xrun_count: self + .managed_xrun_count_available + .load(Ordering::Relaxed) + .then(|| self.managed_xrun_count.load(Ordering::Relaxed)), + frames_per_burst: self.managed_frames_per_burst.load(Ordering::Relaxed), + buffer_size_frames: self.managed_buffer_size_frames.load(Ordering::Relaxed), + buffer_capacity_frames: self + .managed_buffer_capacity_frames + .load(Ordering::Relaxed), + } + } + /// Telemetry: record a managed-output device burst and the number of /// bounded chunks it was rendered in (see `render_managed_burst`). #[allow(dead_code)] // consumed by the Android-only managed output callbacks @@ -450,6 +779,39 @@ impl AudioCallbackData { self.dsp_burst_frames_cap.load(Ordering::Relaxed) } + /// Publish already prepared coefficients without touching the live EQ. + /// Replacing an unconsumed update is intentional: the latest slider value wins. + fn queue_equalizer_params(&self, params: EqParams) { + let mut pending = self.pending_equalizer.lock(); + *pending = Some(params); + self.equalizer_update_pending.store(true, Ordering::Release); + } + + /// Apply the latest EQ settings at a render block boundary. If the producer + /// is publishing, leave the old settings active and retry next block. Neither + /// a mailbox collision nor coefficient computation can bypass an active EQ. + fn apply_pending_equalizer(&self) { + if !self.equalizer_update_pending.load(Ordering::Acquire) { + return; + } + let Some(mut pending) = self.pending_equalizer.try_lock() else { + return; + }; + let Some(mut eq) = self.equalizer.try_lock() else { + return; + }; + if let Some(params) = pending.take() { + eq.apply_params(params); + self.eq_forces_dsp.store( + params.enabled + && (params.active_bands > 0 + || (params.preamp_linear - 1.0).abs() > f32::EPSILON), + Ordering::Relaxed, + ); + } + self.equalizer_update_pending.store(false, Ordering::Release); + } + #[inline] pub fn channels(&self) -> usize { self.channels @@ -592,7 +954,11 @@ impl AudioCallbackData { .store((buffer_size / self.channels.max(1)).max(1), Ordering::Relaxed); self.crossfader.lock().rebind_sample_rate(sample_rate); self.crossfade_active.store(false, Ordering::Relaxed); - *self.sources.lock() = SourceProvider::new(sample_rate, self.channels); + { + let mut sources = self.sources.lock(); + *sources = SourceProvider::new(sample_rate, self.channels); + self.clear_progress_snapshot(); + } *self.mix_buffer_a.lock() = vec![0.0; buffer_size]; *self.mix_buffer_b.lock() = vec![0.0; buffer_size]; *self.speed_buffer.lock() = vec![0.0; speed_buffer_size]; @@ -601,6 +967,8 @@ impl AudioCallbackData { self.crossfeed.lock().rebind_sample_rate(sample_rate); self.convolver.lock().reconfigure_sample_rate(sample_rate); *self.dynamics.lock() = DynamicsChain::new(sample_rate); + self.compressor_enabled.store(false, Ordering::Relaxed); + self.limiter_enabled.store(false, Ordering::Relaxed); let semitones = self.pitch_shifter.lock().semitones(); let mut shifter = PitchShifter::new(sample_rate, self.channels); shifter.set_semitones(semitones); @@ -931,18 +1299,11 @@ impl AudioEngineHandle { } } - /// Get current progress. + /// Get current progress from the callback-published snapshot. Progress + /// polling runs every 50 ms; taking the source lock here raced the audio + /// callback and could silence a block on a `try_lock` miss. pub fn get_progress(&self) -> Option { - let sources = self.callback_data.sources.lock(); - progress_source( - self.callback_data.crossfade_active.load(Ordering::Relaxed), - &sources, - ) - .map(|source| PlaybackProgress { - position_secs: source.position_secs(), - duration_secs: Some(source.info.duration_secs), - buffer_level: source.buffer_level(), - }) + self.callback_data.progress_snapshot() } /// Get the current track path. @@ -988,8 +1349,12 @@ impl AudioEngineHandle { self.callback_data.dsd_starve_stats() } - pub(crate) fn managed_output_stats(&self) -> (u32, u64) { - self.callback_data.oboe_burst_stats() + pub(crate) fn managed_output_stats(&self) -> ManagedOutputStats { + self.callback_data.managed_output_stats() + } + + pub(crate) fn pcm_callback_stats(&self) -> PcmCallbackStats { + self.callback_data.pcm_callback_stats() } pub fn audio_api_pref(&self) -> AudioApiPreference { @@ -1404,8 +1769,50 @@ struct AndroidManagedStream { exclusive_error: Option, } +#[cfg(target_os = "android")] +fn read_managed_stream_telemetry( + stream: &mut T, +) -> (bool, Option, u32, u32, u32) { + let xrun_supported = stream.is_xrun_count_supported(); + let xrun_count = if xrun_supported { + stream + .get_xrun_count() + .ok() + .filter(|count| *count >= 0) + .map(|count| count as u32) + } else { + None + }; + ( + xrun_supported, + xrun_count, + stream.get_frames_per_burst().max(0) as u32, + stream.get_buffer_size_in_frames().max(0) as u32, + stream.get_buffer_capacity_in_frames().max(0) as u32, + ) +} + #[cfg(target_os = "android")] impl AndroidManagedStream { + /// Sample control-thread telemetry from the live stream. Oboe's xrun count + /// is only meaningful while the stream is running, so the supervisor + /// refreshes it alongside its restart polling. + fn note_telemetry(&mut self, callback_data: &AudioCallbackData) { + let (supported, xruns, frames_per_burst, buffer_size, capacity) = match &mut self.kind { + AndroidManagedStreamKind::F32(s) => read_managed_stream_telemetry(s), + AndroidManagedStreamKind::I32(s) => read_managed_stream_telemetry(s), + AndroidManagedStreamKind::I32Pcm(s) => read_managed_stream_telemetry(s), + AndroidManagedStreamKind::I16(s) => read_managed_stream_telemetry(s), + }; + callback_data.note_managed_telemetry( + supported, + xruns, + frames_per_burst, + buffer_size, + capacity, + ); + } + fn start(&mut self) -> Result<(), oboe::Error> { match &mut self.kind { AndroidManagedStreamKind::F32(s) => s.start(), @@ -1484,6 +1891,9 @@ impl ManagedStreamSupervisor { /// parameters. The source position is untouched, so the new stream /// continues from the interrupted sample. fn poll_restart(&mut self, shutdown: &AtomicBool) { + if let Some(stream) = self.stream.as_mut() { + stream.note_telemetry(&self.callback_data); + } if !self.callback_data.stream_restart_requested() { return; } @@ -2655,6 +3065,7 @@ impl AudioOutputCallback for AndroidOutputCallbackF32 { _audio_stream: &mut dyn AudioOutputStreamSafe, audio_data: &mut [(f32, f32)], ) -> DataCallbackResult { + let callback_started = std::time::Instant::now(); let total_frames = audio_data.len(); let callback_data = &self.callback_data; let event_tx = &self.event_tx; @@ -2672,6 +3083,11 @@ impl AudioOutputCallback for AndroidOutputCallbackF32 { } }, ); + callback_data.note_managed_callback( + total_frames, + callback_started.elapsed(), + _audio_stream.get_sample_rate().max(0) as u32, + ); callback_data.note_oboe_burst(total_frames, chunks); if panicked { @@ -2754,6 +3170,7 @@ impl AudioOutputCallback for AndroidOutputCallbackI32 { _audio_stream: &mut dyn AudioOutputStreamSafe, audio_data: &mut [(i32, i32)], ) -> DataCallbackResult { + let callback_started = std::time::Instant::now(); let total_frames = audio_data.len(); let callback_data = &self.callback_data; let event_tx = &self.event_tx; @@ -2774,6 +3191,11 @@ impl AudioOutputCallback for AndroidOutputCallbackI32 { } }, ); + callback_data.note_managed_callback( + total_frames, + callback_started.elapsed(), + _audio_stream.get_sample_rate().max(0) as u32, + ); callback_data.note_oboe_burst(total_frames, chunks); if panicked { @@ -2864,6 +3286,7 @@ impl AudioOutputCallback for AndroidOutputCallbackI32Pcm { _audio_stream: &mut dyn AudioOutputStreamSafe, audio_data: &mut [(i32, i32)], ) -> DataCallbackResult { + let callback_started = std::time::Instant::now(); let total_frames = audio_data.len(); let callback_data = &self.callback_data; let event_tx = &self.event_tx; @@ -2884,6 +3307,11 @@ impl AudioOutputCallback for AndroidOutputCallbackI32Pcm { } }, ); + callback_data.note_managed_callback( + total_frames, + callback_started.elapsed(), + _audio_stream.get_sample_rate().max(0) as u32, + ); callback_data.note_oboe_burst(total_frames, chunks); if panicked { @@ -2968,6 +3396,7 @@ impl AudioOutputCallback for AndroidOutputCallbackI16 { _audio_stream: &mut dyn AudioOutputStreamSafe, audio_data: &mut [(i16, i16)], ) -> DataCallbackResult { + let callback_started = std::time::Instant::now(); let total_frames = audio_data.len(); let callback_data = &self.callback_data; let event_tx = &self.event_tx; @@ -2988,6 +3417,11 @@ impl AudioOutputCallback for AndroidOutputCallbackI16 { } }, ); + callback_data.note_managed_callback( + total_frames, + callback_started.elapsed(), + _audio_stream.get_sample_rate().max(0) as u32, + ); callback_data.note_oboe_burst(total_frames, chunks); if panicked { @@ -3360,14 +3794,16 @@ fn open_android_output_stream( } else { (!exclusive_notes.is_empty()).then(|| exclusive_notes.join(" | ")) }; - return Ok(AndroidManagedStream { + let mut stream = AndroidManagedStream { kind, actual_sample_rate: actual_rate.max(1) as u32, active_audio_api: audio_api_label(actual_api), active_sharing: sharing_label(actual_sharing), sample_format, exclusive_error, - }); + }; + stream.note_telemetry(&callback_data); + return Ok(stream); } Err(error) => { if sharing_mode == SharingMode::Exclusive { @@ -3382,14 +3818,16 @@ fn open_android_output_stream( } if let Some(kind) = fallback_kind { - return Ok(AndroidManagedStream { + let mut stream = AndroidManagedStream { kind, actual_sample_rate: fallback_rate.max(1), active_audio_api: fallback_api.unwrap_or("Unspecified"), active_sharing: fallback_sharing.unwrap_or("Unspecified"), sample_format: fallback_format.unwrap_or("f32"), exclusive_error: (!exclusive_notes.is_empty()).then(|| exclusive_notes.join(" | ")), - }); + }; + stream.note_telemetry(&callback_data); + return Ok(stream); } Err(last_error.unwrap_or_else(|| { @@ -3543,6 +3981,86 @@ fn volume_to_gain(volume: f32) -> f32 { } } +/// Run the DSP effects that are actually active and finish with the master +/// volume stage. Inactive effects are never locked, which keeps their control +/// mutexes out of the real-time callback entirely. An active effect whose lock +/// is busy is counted as skipped instead of being silently bypassed. +#[inline] +fn process_dsp_effects(data: &AudioCallbackData, output: &mut [f32], channels: usize, volume: f32) { + if data.pitch_shift_forces_dsp.load(Ordering::Relaxed) { + match data.pitch_shifter.try_lock() { + Some(mut pitch) => pitch.process(output, channels), + None => { + data.dsp_skipped_pitch.fetch_add(1, Ordering::Relaxed); + } + } + } + if data.eq_forces_dsp.load(Ordering::Relaxed) { + match data.equalizer.try_lock() { + Some(mut eq) => eq.process(output, channels), + None => { + data.dsp_skipped_eq.fetch_add(1, Ordering::Relaxed); + } + } + } + if data.crossfeed_forces_dsp.load(Ordering::Relaxed) { + match data.crossfeed.try_lock() { + Some(mut crossfeed) => crossfeed.process(output, channels), + None => { + data.dsp_skipped_crossfeed.fetch_add(1, Ordering::Relaxed); + } + } + } + if data.fx_enabled.load(Ordering::Relaxed) { + match data.fx.try_lock() { + Some(mut fx) => fx.process(output, channels), + None => { + data.dsp_skipped_fx.fetch_add(1, Ordering::Relaxed); + } + } + } + if data.convolver_enabled.load(Ordering::Relaxed) { + match data.convolver.try_lock() { + Some(mut convolver) => convolver.process(output, channels), + None => { + data.dsp_skipped_convolver.fetch_add(1, Ordering::Relaxed); + } + } + } + if data.compressor_enabled.load(Ordering::Relaxed) + || data.limiter_enabled.load(Ordering::Relaxed) + { + match data.dynamics.try_lock() { + Some(mut dynamics) => dynamics.process(output, channels), + None => { + data.dsp_skipped_dynamics.fetch_add(1, Ordering::Relaxed); + } + } + } + + // Volume is always applied last, after all DSP processing. This is also the + // single point where the callback measures the final signal level without + // logging or allocating on the audio thread. + let mut peak = 0.0f32; + let mut clipped = 0u64; + let mut non_finite = 0u64; + for sample in output.iter_mut() { + *sample *= volume; + if sample.is_finite() { + let magnitude = sample.abs(); + if magnitude > peak { + peak = magnitude; + } + if magnitude > 1.0 { + clipped += 1; + } + } else { + non_finite += 1; + } + } + data.note_output_peak(peak, clipped, non_finite); +} + /// The real-time audio callback. /// /// This function MUST NOT: @@ -3560,6 +4078,8 @@ pub(crate) fn audio_callback( return; } + data.apply_pending_equalizer(); + // Passthrough path: raw samples from decoder straight to output. if data.pipeline_mode.load(Ordering::Relaxed) == PipelineMode::Dop as u8 { let mut marker_is_fa = data.dop_marker_phase.load(Ordering::Relaxed); @@ -3584,6 +4104,7 @@ pub(crate) fn audio_callback( crate::audio::dsd_engine::dsd::dop::frame_dop_f32(output, data.channels, &mut marker_is_fa); data.dop_marker_phase.store(marker_is_fa, Ordering::Relaxed); } + data.publish_progress_snapshot(&sources); return; } @@ -3610,6 +4131,7 @@ pub(crate) fn audio_callback( .fetch_add((output.len() - read) as u64, Ordering::Relaxed); data.fill_silence(&mut output[read..]); } + data.publish_progress_snapshot(&sources); return; } @@ -3631,61 +4153,48 @@ pub(crate) fn audio_callback( let mut sources = match data.sources.try_lock() { Some(s) => s, None => { + data.pcm_source_lock_misses.fetch_add(1, Ordering::Relaxed); output.fill(0.0); return; } }; - let mut crossfader = match data.crossfader.try_lock() { - Some(c) => c, - None => { - // Never take the ordinary gapless read path during a fade: it - // drops both fade gains and can advance at EOF behind the - // crossfader's back. Stay non-blocking and preserve both sources - // until the next callback can acquire the fade state. - if data.crossfade_active.load(Ordering::Relaxed) { - output.fill(0.0); - return; - } - let (read, old_source) = sources.read(output); - - if let Some(source) = old_source { - let _ = data.finished_tracks.try_send(source); - } - - if read < output.len() { - output[read..].fill(0.0); - } - if let Some(mut pitch) = data.pitch_shifter.try_lock() { - pitch.process(output, channels); - } - if let Some(mut eq) = data.equalizer.try_lock() { - eq.process(output, channels); - } - if let Some(mut crossfeed) = data.crossfeed.try_lock() { - crossfeed.process(output, channels); - } - if let Some(mut fx) = data.fx.try_lock() { - fx.process(output, channels); - } - if let Some(mut convolver) = data.convolver.try_lock() { - convolver.process(output, channels); - } - if let Some(mut dynamics) = data.dynamics.try_lock() { - dynamics.process(output, channels); - } - // Volume is always applied last, after all DSP processing. - for sample in output.iter_mut() { - *sample *= volume; + // Only touch crossfader state when a fade can exist. A disabled crossfade + // must not contend with the command loop's auto-fade check on every block. + let mut crossfader = None; + if data.crossfade_configured.load(Ordering::Relaxed) + || data.crossfade_active.load(Ordering::Relaxed) + { + match data.crossfader.try_lock() { + Some(c) => crossfader = Some(c), + None => { + data.pcm_crossfader_lock_misses + .fetch_add(1, Ordering::Relaxed); + // Never take the ordinary gapless read path during a fade: it + // drops both fade gains and can advance at EOF behind the + // crossfader's back. Stay non-blocking and preserve both sources + // until the next callback can acquire the fade state. + if data.crossfade_active.load(Ordering::Relaxed) { + output.fill(0.0); + return; + } } - return; } - }; + } + + let fading = crossfader + .as_ref() + .map(|c| c.is_active()) + .unwrap_or(false) + && sources.next().is_some(); - if crossfader.is_active() && sources.next_mut().is_some() { + if fading { + let crossfader = crossfader.as_mut().expect("fade requires crossfader state"); let mut buf_a = match data.mix_buffer_a.try_lock() { Some(b) => b, None => { + data.pcm_mix_buffer_lock_misses + .fetch_add(1, Ordering::Relaxed); output.fill(0.0); return; } @@ -3693,6 +4202,8 @@ pub(crate) fn audio_callback( let mut buf_b = match data.mix_buffer_b.try_lock() { Some(b) => b, None => { + data.pcm_mix_buffer_lock_misses + .fetch_add(1, Ordering::Relaxed); output.fill(0.0); return; } @@ -3711,6 +4222,8 @@ pub(crate) fn audio_callback( return; } + let had_current = sources.current().is_some(); + let had_next = sources.next().is_some(); let read_a = sources .current_mut() .map(|s| s.read(&mut buf_a[..needed])) @@ -3722,22 +4235,31 @@ pub(crate) fn audio_callback( if read_a < needed { buf_a[read_a..needed].fill(0.0); + if had_current && sources.current().is_some_and(|s| !s.is_finished()) { + data.pcm_starved_samples + .fetch_add((needed - read_a) as u64, Ordering::Relaxed); + } } if read_b < needed { buf_b[read_b..needed].fill(0.0); + if had_next && sources.next().is_some_and(|s| !s.is_finished()) { + data.pcm_starved_samples + .fetch_add((needed - read_b) as u64, Ordering::Relaxed); + } } let _ = crossfader.mix(&buf_a[..needed], &buf_b[..needed], output, channels); if !crossfader.is_active() { data.crossfade_active.store(false, Ordering::Relaxed); - drop(crossfader); if let Some(source) = sources.advance_to_next() { let _ = data.finished_tracks.try_send(source); } } + data.publish_progress_snapshot(&sources); } else { if (speed - 1.0).abs() < 0.001 { + let had_current = sources.current().is_some(); let (read, old_source) = sources.read(output); if let Some(source) = old_source { @@ -3746,11 +4268,17 @@ pub(crate) fn audio_callback( if read < output.len() { output[read..].fill(0.0); + if had_current && sources.current().is_some_and(|s| !s.is_finished()) { + data.pcm_starved_samples + .fetch_add((output.len() - read) as u64, Ordering::Relaxed); + } } + data.publish_progress_snapshot(&sources); } else { let mut speed_buf = match data.speed_buffer.try_lock() { Some(b) => b, None => { + data.pcm_speed_lock_misses.fetch_add(1, Ordering::Relaxed); output.fill(0.0); return; } @@ -3758,6 +4286,7 @@ pub(crate) fn audio_callback( let mut frac_pos = match data.speed_frac_pos.try_lock() { Some(p) => p, None => { + data.pcm_speed_lock_misses.fetch_add(1, Ordering::Relaxed); output.fill(0.0); return; } @@ -3772,12 +4301,22 @@ pub(crate) fn audio_callback( return; } + let had_current = sources.current().is_some(); let (read, old_source) = sources.read(&mut speed_buf[..input_samples_needed]); if let Some(source) = old_source { let _ = data.finished_tracks.try_send(source); } + if had_current + && sources.current().is_some_and(|s| !s.is_finished()) + && read < input_samples_needed + { + data.pcm_starved_samples + .fetch_add((input_samples_needed - read) as u64, Ordering::Relaxed); + } + data.publish_progress_snapshot(&sources); + if read < channels { output.fill(0.0); return; @@ -3810,29 +4349,11 @@ pub(crate) fn audio_callback( } } - if let Some(mut pitch) = data.pitch_shifter.try_lock() { - pitch.process(output, channels); - } - if let Some(mut eq) = data.equalizer.try_lock() { - eq.process(output, channels); - } - if let Some(mut crossfeed) = data.crossfeed.try_lock() { - crossfeed.process(output, channels); - } - if let Some(mut fx) = data.fx.try_lock() { - fx.process(output, channels); - } - if let Some(mut convolver) = data.convolver.try_lock() { - convolver.process(output, channels); - } - if let Some(mut dynamics) = data.dynamics.try_lock() { - dynamics.process(output, channels); - } - - // Volume is always applied last, after all DSP processing. - for sample in output.iter_mut() { - *sample *= volume; - } + // Release playback state before the effect chain: command handling must not + // wait on the source/crossfade locks while the callback runs effects. + drop(crossfader); + drop(sources); + process_dsp_effects(data, output, channels, volume); } /// Command processing loop running in the audio thread. @@ -3966,7 +4487,11 @@ fn command_processing_loop( let _ = event_tx.try_send(AudioEvent::StateChanged(PlaybackState::Playing)); } AudioCommand::Stop => { - callback_data.sources.lock().stop(); + { + let mut sources = callback_data.sources.lock(); + sources.stop(); + callback_data.clear_progress_snapshot(); + } callback_data.crossfader.lock().reset(); callback_data.pitch_shifter.lock().reset(); callback_data.crossfade_active.store(false, Ordering::Relaxed); @@ -4027,6 +4552,9 @@ fn command_processing_loop( callback_data .crossfade_forces_dsp .store(enabled, Ordering::Relaxed); + callback_data + .crossfade_configured + .store(enabled, Ordering::Relaxed); log::info!( "[crossfade] set enabled={} duration={:.1}s forces_dsp={}", enabled, @@ -4046,17 +4574,14 @@ fn command_processing_loop( preamp_db, specs, } => { - if let Some(mut eq) = callback_data.equalizer.try_lock() { - eq.set(enabled, preamp_db, &specs, sample_rate); - } - // EQ needs the DSP path (see crossfade). Force out of - // passthrough when active so the biquads run even on a - // verified bit-perfect USB output. Preamp-only also - // needs the gain stage. - callback_data.eq_forces_dsp.store( - enabled && (!specs.is_empty() || preamp_db.abs() > f32::EPSILON), - Ordering::Relaxed, - ); + // Biquad computation belongs on the control thread, not + // behind the live filter-state lock or in the callback. + let params = if enabled { + EqParams::from_specs(&specs, preamp_db, sample_rate) + } else { + EqParams::disabled() + }; + callback_data.queue_equalizer_params(params); } AudioCommand::SetCrossfeed { level } => { callback_data.crossfeed.lock().set_level(level); @@ -4096,6 +4621,9 @@ fn command_processing_loop( release_ms, makeup_gain_db, ); + callback_data + .compressor_enabled + .store(enabled, Ordering::Relaxed); } AudioCommand::SetLimiter { enabled, @@ -4109,6 +4637,9 @@ fn command_processing_loop( ceiling_db, release_ms, ); + callback_data + .limiter_enabled + .store(enabled, Ordering::Relaxed); } AudioCommand::SetPipelineMode { passthrough } => { let mode = if passthrough { @@ -4155,22 +4686,33 @@ fn command_processing_loop( enabled, balance, tempo, damp, filter_hz, delay_ms, size, mix, feedback, width, ); + callback_data.fx_enabled.store(enabled, Ordering::Relaxed); } AudioCommand::SetConvolver { enabled, mix } => { callback_data.convolver.lock().set(enabled, mix); + callback_data + .convolver_enabled + .store(enabled, Ordering::Relaxed); } AudioCommand::SetConvolverIr { coeffs } => { callback_data.convolver.lock().load_ir(coeffs); } AudioCommand::ClearConvolverIr => { callback_data.convolver.lock().clear_ir(); + callback_data + .convolver_enabled + .store(false, Ordering::Relaxed); } AudioCommand::CrossfadeToNext | AudioCommand::SkipToNext => { handle_skip_to_next(&callback_data, &state, &event_tx); } AudioCommand::Shutdown => { // Stop everything and exit - callback_data.sources.lock().stop(); + { + let mut sources = callback_data.sources.lock(); + sources.stop(); + callback_data.clear_progress_snapshot(); + } for decoder in decoders.lock().drain(..) { decoder.stop(); } @@ -4442,7 +4984,11 @@ fn start_playback_source( source.set_ready(); source.set_playing(); - callback_data.sources.lock().set_current(source); + { + let mut sources = callback_data.sources.lock(); + sources.set_current(source); + callback_data.publish_progress_snapshot(&sources); + } callback_data.set_paused(false); decoders.lock().push(decoder_handle); @@ -4533,6 +5079,12 @@ fn maybe_start_auto_crossfade( return false; } + // Disabled crossfade is the common case. Check the lock-free mirror before + // taking the source/crossfader locks that the real-time callback needs. + if !callback_data.crossfade_configured.load(Ordering::Relaxed) { + return false; + } + let sources = callback_data.sources.lock(); let mut crossfader = callback_data.crossfader.lock(); let configured = crossfader.configured_duration_secs(); @@ -4887,7 +5439,11 @@ fn handle_seek( source.set_playing(); } - callback_data.sources.lock().set_current(source); + { + let mut sources = callback_data.sources.lock(); + sources.set_current(source); + callback_data.publish_progress_snapshot(&sources); + } callback_data.set_paused(was_paused); decoders.lock().push(handle); @@ -5333,6 +5889,7 @@ mod tests { fade.set_enabled(true); fade.set_duration(0.5); } + data.crossfade_configured.store(true, Ordering::Relaxed); let state = Arc::new(AtomicU8::new(PlaybackState::Playing as u8)); let mut near_end_logged = false; @@ -5376,6 +5933,7 @@ mod tests { fade.set_enabled(true); fade.set_duration(0.5); } + data.crossfade_configured.store(true, Ordering::Relaxed); let state = Arc::new(AtomicU8::new(PlaybackState::Playing as u8)); let mut near_end_logged = false; @@ -6233,4 +6791,271 @@ mod tests { assert_eq!(chunks, 1); assert_eq!(spans, vec![(0, 1_000)]); } + + #[test] + fn pcm_callback_counts_source_lock_miss_and_silences_block() { + let data = build_callback_data(48_000, 2); + let _sources = data.sources.lock(); + + let output = run_callback(&data, 16); + + assert!(output.iter().all(|sample| *sample == 0.0)); + assert_eq!(data.pcm_callback_stats().source_lock_misses, 1); + } + + #[test] + fn pcm_callback_counts_starved_samples_on_short_read() { + let data = build_callback_data(48_000, 2); + let (mut source, _producer) = + AudioSource::new(queued_source_info("empty-current.flac", 48_000, 2)); + source.set_ready(); + source.set_playing(); + data.sources.lock().set_current(source); + + let output = run_callback(&data, 16); + + assert!(output.iter().all(|sample| *sample == 0.0)); + let stats = data.pcm_callback_stats(); + assert_eq!(stats.starved_samples, 16); + assert_eq!(stats.source_lock_misses, 0); + } + + #[test] + fn pcm_callback_does_not_count_natural_eof_as_starvation() { + let data = build_callback_data(48_000, 2); + data.sources + .lock() + .set_current(build_source(&[0.5, 0.5, -0.5, -0.5], 48_000, 2)); + + let output = run_callback(&data, 16); + + assert_eq!(output[..4], [0.5, 0.5, -0.5, -0.5]); + assert!(output[4..].iter().all(|sample| *sample == 0.0)); + assert_eq!(data.pcm_callback_stats().starved_samples, 0); + } + + #[test] + fn pcm_callback_records_peak_and_clipping_after_volume() { + let data = build_callback_data(48_000, 2); + data.set_volume(2.0); + data.sources.lock().set_current(build_source( + &[0.75, -0.75, 0.125, -0.125], + 48_000, + 2, + )); + + let output = run_callback(&data, 4); + + assert_eq!(output, vec![1.5, -1.5, 0.25, -0.25]); + let stats = data.pcm_callback_stats(); + assert_eq!(stats.peak, 1.5); + assert_eq!(stats.clipped_samples, 2); + assert_eq!(stats.non_finite_samples, 0); + } + + #[test] + fn active_effect_lock_miss_is_counted_as_skipped() { + let data = build_callback_data(48_000, 2); + data.sources + .lock() + .set_current(build_source(&[0.1; 8], 48_000, 2)); + data.eq_forces_dsp.store(true, Ordering::Relaxed); + let _eq = data.equalizer.lock(); + + let output = run_callback(&data, 8); + + assert_eq!(output, vec![0.1; 8]); + assert_eq!(data.pcm_callback_stats().skipped_eq, 1); + } + + #[test] + fn progress_snapshot_is_readable_while_sources_are_locked() { + let data = build_callback_data(48_000, 2); + let source = build_source(&[0.25; 96_000], 48_000, 2); + source.set_position_secs(0.75); + + let mut sources = data.sources.lock(); + sources.set_current(source); + data.publish_progress_snapshot(&sources); + + let progress = data + .progress_snapshot() + .expect("published progress must survive a held source lock"); + assert_eq!(progress.position_secs, 0.75); + assert!(progress.duration_secs.is_some()); + } + + #[test] + fn auto_crossfade_does_not_start_when_configuration_mirror_is_disabled() { + let data = build_callback_data(48_000, 2); + let (event_tx, _event_rx) = bounded::(8); + { + let mut sources = data.sources.lock(); + let outgoing = build_source(&[0.25; 96_000], 48_000, 2); + outgoing.set_position_secs(0.75); + sources.set_current(outgoing); + sources.queue_next(build_source(&[0.5; 96_000], 48_000, 2)); + } + { + let mut fade = data.crossfader.lock(); + fade.set_enabled(true); + fade.set_duration(0.5); + } + let state = Arc::new(AtomicU8::new(PlaybackState::Playing as u8)); + let mut near_end_logged = false; + + assert!(!maybe_start_auto_crossfade( + &data, + &state, + &event_tx, + &mut near_end_logged + )); + assert!(!data.crossfade_active.load(Ordering::Relaxed)); + } + + #[test] + fn managed_output_telemetry_accumulates() { + let data = build_callback_data(48_000, 2); + data.note_managed_callback(1_922, std::time::Duration::from_micros(1_000), 96_000); + data.note_managed_callback(480, std::time::Duration::from_millis(11), 48_000); + data.note_managed_telemetry(true, Some(3), 192, 960, 9_600); + + let stats = data.managed_output_stats(); + assert_eq!(stats.callback_count, 2); + assert_eq!(stats.callback_frames_total, 2_402); + assert!(stats.xrun_count_supported); + assert_eq!(stats.xrun_count, Some(3)); + assert_eq!(stats.callback_duration_us_max, 11_000); + assert_eq!(stats.callback_overruns, 1); + assert_eq!(stats.frames_per_burst, 192); + assert_eq!(stats.buffer_size_frames, 960); + assert_eq!(stats.buffer_capacity_frames, 9_600); + } + + #[test] + fn managed_output_does_not_report_unavailable_xruns_as_zero_or_stale() { + let data = build_callback_data(48_000, 2); + assert_eq!(data.managed_output_stats().xrun_count, None); + data.note_managed_telemetry(true, Some(7), 192, 960, 9_600); + assert_eq!(data.managed_output_stats().xrun_count, Some(7)); + + data.note_managed_telemetry(true, None, 192, 960, 9_600); + assert!(data.managed_output_stats().xrun_count_supported); + assert_eq!(data.managed_output_stats().xrun_count, None); + + data.note_managed_telemetry(false, None, 0, 0, 0); + let stats = data.managed_output_stats(); + assert!(!stats.xrun_count_supported); + assert_eq!(stats.xrun_count, None); + assert_eq!(stats.frames_per_burst, 0); + assert_eq!(stats.buffer_size_frames, 0); + assert_eq!(stats.buffer_capacity_frames, 0); + } + + #[test] + fn eq_mailbox_contention_keeps_previous_processing_until_next_block() { + let data = build_callback_data(48_000, 2); + data.sources + .lock() + .set_current(build_source(&[0.5; 64], 48_000, 2)); + let previous = EqParams::from_specs(&[], -6.0, 48_000); + data.queue_equalizer_params(previous); + let expected = 0.5 * previous.preamp_linear; + assert!(run_callback(&data, 8) + .iter() + .all(|sample| (*sample - expected).abs() < 1e-6)); + + data.queue_equalizer_params(EqParams::disabled()); + let pending = data.pending_equalizer.lock(); + let during_update = run_callback(&data, 8); + assert!(during_update + .iter() + .all(|sample| (*sample - expected).abs() < 1e-6)); + assert_eq!(data.pcm_callback_stats().skipped_eq, 0); + assert!(data.equalizer_update_pending.load(Ordering::Acquire)); + drop(pending); + + assert_eq!(run_callback(&data, 8), vec![0.5; 8]); + assert!(!data.eq_forces_dsp.load(Ordering::Relaxed)); + assert!(!data.equalizer_update_pending.load(Ordering::Acquire)); + } + + #[test] + fn eq_updates_never_lock_live_state_and_latest_pending_value_wins() { + let data = build_callback_data(48_000, 2); + data.sources + .lock() + .set_current(build_source(&[0.5; 64], 48_000, 2)); + let live = data.equalizer.lock(); + data.queue_equalizer_params(EqParams::from_specs(&[], 6.0, 48_000)); + let latest = EqParams::from_specs(&[], -12.0, 48_000); + data.queue_equalizer_params(latest); + drop(live); + + let expected = 0.5 * latest.preamp_linear; + assert!(run_callback(&data, 8) + .iter() + .all(|sample| (*sample - expected).abs() < 1e-6)); + assert_eq!(data.pcm_callback_stats().skipped_eq, 0); + assert_eq!(data.pcm_callback_stats().source_lock_misses, 0); + } + + #[test] + fn eq_enable_and_disable_take_effect_at_passthrough_block_boundaries() { + let data = build_passthrough_callback_data(96_000, 2); + data.sources + .lock() + .set_current(build_source(&[0.5; 64], 96_000, 2)); + let params = EqParams::from_specs(&[], -6.0, 96_000); + data.queue_equalizer_params(params); + assert!(run_callback(&data, 8) + .iter() + .all(|sample| (*sample - 0.5 * params.preamp_linear).abs() < 1e-6)); + assert!(!data.is_passthrough()); + + data.queue_equalizer_params(EqParams::disabled()); + assert_eq!(run_callback(&data, 8), vec![0.5; 8]); + assert!(data.is_passthrough()); + } + + #[test] + fn progress_polling_during_pcm_render_does_not_contend_with_sources() { + for sample_rate in [44_100, 48_000, 96_000] { + let data = Arc::new(build_callback_data(sample_rate, 2)); + data.sources.lock().set_current(build_source( + &vec![0.25; 80_000], + sample_rate, + 2, + )); + let polling_data = Arc::clone(&data); + let polling = thread::spawn(move || { + for _ in 0..10_000 { + let _ = polling_data.progress_snapshot(); + } + }); + for _ in 0..20 { + // The reporter's largest device burst was 1,922 frames. + assert_eq!(run_callback(&data, 1_922 * 2), vec![0.25; 1_922 * 2]); + } + polling.join().unwrap(); + assert_eq!(data.pcm_callback_stats().source_lock_misses, 0); + assert_eq!(data.pcm_callback_stats().starved_samples, 0); + assert!(data.progress_snapshot().unwrap().position_secs > 0.0); + } + } + + #[test] + fn reconfigure_sample_rate_clears_progress_snapshot() { + let data = build_callback_data(48_000, 2); + { + let mut sources = data.sources.lock(); + sources.set_current(build_source(&[0.25; 64], 48_000, 2)); + data.publish_progress_snapshot(&sources); + } + assert!(data.progress_snapshot().is_some()); + + data.reconfigure_sample_rate(96_000); + + assert!(data.progress_snapshot().is_none()); + } } diff --git a/rust/src/audio/equalizer.rs b/rust/src/audio/equalizer.rs index 3060b2ee4..737337f04 100644 --- a/rust/src/audio/equalizer.rs +++ b/rust/src/audio/equalizer.rs @@ -192,7 +192,8 @@ impl Equalizer { } } - /// Called from command thread. sample_rate must match engine. + /// Prepare parameters at the engine sample rate, then apply them. The + /// engine prepares these off-thread and uses `apply_params` at a block boundary. pub fn set( &mut self, enabled: bool, @@ -205,11 +206,17 @@ impl Equalizer { } else { EqParams::disabled() }; + self.apply_params(next); + } + + /// Apply precomputed coefficients without allocating or resetting active + /// filter history. Only the render side mutates the live filter state. + pub(crate) fn apply_params(&mut self, next: EqParams) { let idx = self.index.load(Ordering::Relaxed); self.params[1 - idx as usize] = next; self.index.store(1 - idx, Ordering::Release); // Reset state when disabling to avoid artifacts when re-enabling - if !enabled { + if !next.enabled { for ch_state in &mut self.state { for band_state in ch_state.iter_mut() { band_state.fill(0.0); diff --git a/test/core/adaptive_layout_test.dart b/test/core/adaptive_layout_test.dart new file mode 100644 index 000000000..53fe2657b --- /dev/null +++ b/test/core/adaptive_layout_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flick/core/utils/adaptive_layout.dart'; + +void main() { + group('AdaptiveLayout window decisions', () { + test('portrait phones keep the stacked player and bottom navigation', () { + for (final size in const [ + Size(320, 568), + Size(360, 640), + Size(411, 891), + ]) { + expect(AdaptiveLayout.isWide(size), isFalse); + expect(AdaptiveLayout.usePlayerSideLayout(size), isFalse); + expect(AdaptiveLayout.useSideNavigation(size), isFalse); + } + }); + + test('landscape phones get the side player layout and rail', () { + for (final size in const [ + Size(520, 320), + Size(568, 320), + Size(640, 360), + Size(891, 411), + ]) { + expect(AdaptiveLayout.isWide(size), isTrue); + expect(AdaptiveLayout.usePlayerSideLayout(size), isTrue); + expect(AdaptiveLayout.useSideNavigation(size), isTrue); + } + }); + + test('tablets follow window shape, not hardware class', () { + expect( + AdaptiveLayout.usePlayerSideLayout(const Size(800, 1280)), + isFalse, + ); + expect(AdaptiveLayout.usePlayerSideLayout(const Size(1280, 800)), isTrue); + expect(AdaptiveLayout.useSideNavigation(const Size(800, 1280)), isFalse); + expect(AdaptiveLayout.useSideNavigation(const Size(1280, 800)), isTrue); + }); + + test( + 'compact side mini player only belongs to short landscape windows', + () { + for (final size in const [ + Size(520, 320), + Size(640, 360), + Size(891, 411), + ]) { + expect(AdaptiveLayout.useCompactLandscapeMiniPlayer(size), isTrue); + } + for (final size in const [ + Size(360, 640), + Size(640, 480), + Size(1280, 650), + ]) { + expect(AdaptiveLayout.useCompactLandscapeMiniPlayer(size), isFalse); + } + }, + ); + + test('a narrow landscape window is not treated as wide', () { + expect(AdaptiveLayout.usePlayerSideLayout(const Size(520, 600)), isFalse); + expect(AdaptiveLayout.useSideNavigation(const Size(520, 600)), isFalse); + expect(AdaptiveLayout.useSideNavigation(const Size(519, 320)), isFalse); + }); + + test('constraints equivalents agree with window size', () { + const constraints = BoxConstraints.tightFor(width: 640, height: 360); + expect( + AdaptiveLayout.usePlayerSideLayoutFor(constraints), + AdaptiveLayout.usePlayerSideLayout(const Size(640, 360)), + ); + expect( + AdaptiveLayout.usePlayerSideLayoutFor( + const BoxConstraints.tightFor(width: 360, height: 640), + ), + isFalse, + ); + }); + }); +} diff --git a/test/features/songs/orbit_scroll_test.dart b/test/features/songs/orbit_scroll_test.dart new file mode 100644 index 000000000..abc2851b2 --- /dev/null +++ b/test/features/songs/orbit_scroll_test.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flick/features/songs/widgets/orbit_scroll.dart'; +import 'package:flick/features/songs/widgets/song_card.dart'; +import 'package:flick/models/song.dart'; + +final _songs = List.generate( + 40, + (i) => Song( + id: 'orbit-$i', + title: 'Song $i with a longer title', + artist: 'Artist $i', + duration: const Duration(minutes: 4), + fileType: 'FLAC', + ), +); + +void main() { + void setView(WidgetTester tester, Size size) { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + } + + Widget host( + PageController pages, { + int selected = 15, + Axis axis = Axis.horizontal, + OrbitScrollController? orbit, + ValueChanged? onChanged, + ValueChanged? onAction, + double textScale = 1, + }) => MaterialApp( + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(textScale)), + child: child!, + ), + home: Scaffold( + body: PageView( + controller: pages, + children: [ + Column( + children: [ + Container( + key: const ValueKey('header'), + height: 64, + color: Colors.black, + ), + Expanded( + child: OrbitScroll( + key: const ValueKey('orbit'), + songs: _songs, + selectedIndex: selected, + scrollDirection: axis, + controller: orbit, + swipeActionsEnabled: true, + onSelectedIndexChanged: onChanged, + onSongSwipedLeft: onAction, + onSongSwipedRight: onAction, + ), + ), + ], + ), + const Center(child: Text('Next destination')), + ], + ), + ), + ); + + testWidgets('horizontal orbit wins over navigation, including on a card', ( + tester, + ) async { + setView(tester, const Size(900, 411)); + final pages = PageController(); + addTearDown(pages.dispose); + var selected = 15; + var actions = 0; + await tester.pumpWidget( + host(pages, onChanged: (i) => selected = i, onAction: (_) => actions++), + ); + final card = find.byWidgetPredicate((w) => w is SongCard && w.isSelected); + await tester.dragFrom(tester.getCenter(card), const Offset(-180, 0)); + await tester.pumpAndSettle(); + expect(selected, greaterThan(15)); + expect(pages.page, 0); + expect(actions, 0); + expect(tester.takeException(), isNull); + }); + + for (final edge in [0, 39]) { + testWidgets('orbit boundary $edge never leaks a fling into navigation', ( + tester, + ) async { + setView(tester, const Size(900, 411)); + final pages = PageController(); + addTearDown(pages.dispose); + await tester.pumpWidget(host(pages, selected: edge)); + await tester.flingFrom( + const Offset(450, 220), + Offset(edge == 0 ? 250 : -250, 0), + 900, + ); + await tester.pumpAndSettle(); + expect(pages.page, 0); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('header and side gutters retain navigation swipes', ( + tester, + ) async { + setView(tester, const Size(900, 411)); + final pages = PageController(); + addTearDown(pages.dispose); + await tester.pumpWidget(host(pages)); + await tester.dragFrom(const Offset(700, 32), const Offset(-600, 0)); + await tester.pumpAndSettle(); + expect(pages.page, 1); + pages.jumpToPage(0); + await tester.pumpAndSettle(); + await tester.flingFrom(const Offset(16, 220), const Offset(-220, 0), 1000); + await tester.pumpAndSettle(); + expect(pages.page, 1); + }); + + testWidgets('cancel and rotation retain selection and external jumps', ( + tester, + ) async { + setView(tester, const Size(900, 411)); + final pages = PageController(); + final orbit = OrbitScrollController(); + addTearDown(pages.dispose); + var selected = 15; + await tester.pumpWidget( + host(pages, orbit: orbit, onChanged: (i) => selected = i), + ); + final gesture = await tester.startGesture(const Offset(450, 220)); + await gesture.moveBy(const Offset(-80, 0)); + await gesture.cancel(); + await tester.pumpAndSettle(); + expect(pages.page, 0); + orbit.jumpToIndex(24, animate: false); + await tester.pump(); + expect(selected, 24); + tester.view.physicalSize = const Size(411, 900); + await tester.pumpWidget( + host(pages, orbit: orbit, axis: Axis.vertical, selected: selected), + ); + await tester.pumpAndSettle(); + final card = tester.widget( + find.byWidgetPredicate((w) => w is SongCard && w.isSelected), + ); + expect(card.song.id, 'orbit-24'); + expect(card.artworkAbove, isFalse); + expect(tester.takeException(), isNull); + }); + + testWidgets('short landscape cards fit with enlarged text', (tester) async { + setView(tester, const Size(640, 240)); + final pages = PageController(); + addTearDown(pages.dispose); + await tester.pumpWidget(host(pages, textScale: 2)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/l10n/ui_customization_locale_test.dart b/test/l10n/ui_customization_locale_test.dart new file mode 100644 index 000000000..2c96f1c11 --- /dev/null +++ b/test/l10n/ui_customization_locale_test.dart @@ -0,0 +1,166 @@ +// UI Customization is an English surface in Taglish. The option labels already +// used the English copy role, but the model descriptions resolved the active +// catalog directly, so these sections fell back to Tagalog. +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:flick/core/theme/app_theme.dart'; +import 'package:flick/features/settings/screens/ui_customization_settings_screen.dart'; +import 'package:flick/l10n/l10n.dart'; +import 'package:flick/providers/player_provider.dart'; + +Widget _host(Widget child, {Locale locale = kFilipinoLocale}) => ProviderScope( + overrides: [currentSongProvider.overrideWithValue(null)], + child: MaterialApp( + theme: AppTheme.darkTheme, + locale: locale, + supportedLocales: kSupportedLocales, + localizationsDelegates: kAppLocalizationsDelegates, + localeResolutionCallback: resolveAppLocale, + builder: (context, child) { + LocaleController.instance.bind(AppLocalizations.of(context)); + return child!; + }, + home: Scaffold(body: child), + ), +); + +void _setTallView(WidgetTester tester) { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(800, 3000); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + tearDown(() => LocaleController.instance.bindLocale(kFallbackLocale)); + + testWidgets('Taglish UI customization options are English', (tester) async { + _setTallView(tester); + final en = lookupFlickLocalizations(kFallbackLocale); + final tl = lookupFlickLocalizations(kFilipinoLocale); + await tester.pumpWidget(_host(const UiCustomizationSettingsScreen())); + await tester.pumpAndSettle(); + + for (final copy in [ + en.uiCustomization, + en.trackThumbnails.toUpperCase(), + en.progressBar.toUpperCase(), + en.albumColors.toUpperCase(), + ]) { + expect(find.text(copy), findsOneWidget); + } + + for (final copy in [ + en.albumArt, + en.trackNumber, + en.numberOnArt, + en.waveform, + en.line, + en.off, + en.subtle, + en.moderate, + en.vibrant, + ]) { + expect(find.text(copy), findsWidgets); + } + + for (final copy in [ + en.showAlbumArtwork, + en.showTheTrackNumber, + en.showTheNumberOverBlurredArt, + en.verticalBarsThatAnimateAcrossThe, + en.cleanStraightLineWithPrecisionScrubbing, + en.useTheDefaultMonochromeTheme, + en.faintHueShiftFromAlbumArt, + en.noticeableTintingFromAlbumArt, + en.boldSaturatedColorsFromAlbumArt, + ]) { + expect(find.text(copy), findsOneWidget); + } + + for (final copy in [ + tl.albumArt, + tl.trackNumber, + tl.numberOnArt, + tl.line, + tl.off, + tl.subtle, + tl.moderate, + tl.vibrant, + tl.showAlbumArtwork, + tl.showTheTrackNumber, + tl.showTheNumberOverBlurredArt, + tl.verticalBarsThatAnimateAcrossThe, + tl.cleanStraightLineWithPrecisionScrubbing, + tl.useTheDefaultMonochromeTheme, + tl.faintHueShiftFromAlbumArt, + tl.noticeableTintingFromAlbumArt, + tl.boldSaturatedColorsFromAlbumArt, + ]) { + expect(find.text(copy), findsNothing); + } + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('Chinese UI customization options stay Chinese', (tester) async { + _setTallView(tester); + final en = lookupFlickLocalizations(kFallbackLocale); + final zh = lookupFlickLocalizations(const Locale('zh', 'CN')); + await tester.pumpWidget( + _host( + const UiCustomizationSettingsScreen(), + locale: const Locale('zh', 'CN'), + ), + ); + await tester.pumpAndSettle(); + + for (final copy in [ + zh.uiCustomization, + zh.trackThumbnails.toUpperCase(), + zh.progressBar.toUpperCase(), + zh.albumColors.toUpperCase(), + zh.albumArt, + zh.trackNumber, + zh.numberOnArt, + zh.waveform, + zh.line, + zh.off, + zh.subtle, + zh.moderate, + zh.vibrant, + zh.showAlbumArtwork, + zh.showTheTrackNumber, + zh.showTheNumberOverBlurredArt, + zh.verticalBarsThatAnimateAcrossThe, + zh.cleanStraightLineWithPrecisionScrubbing, + zh.useTheDefaultMonochromeTheme, + zh.faintHueShiftFromAlbumArt, + zh.noticeableTintingFromAlbumArt, + zh.boldSaturatedColorsFromAlbumArt, + ]) { + expect(find.text(copy), findsWidgets); + } + + for (final copy in [ + en.showAlbumArtwork, + en.showTheTrackNumber, + en.showTheNumberOverBlurredArt, + en.verticalBarsThatAnimateAcrossThe, + en.cleanStraightLineWithPrecisionScrubbing, + en.useTheDefaultMonochromeTheme, + en.faintHueShiftFromAlbumArt, + en.noticeableTintingFromAlbumArt, + en.boldSaturatedColorsFromAlbumArt, + ]) { + expect(find.text(copy), findsNothing); + } + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); +} diff --git a/test/models/immersive_background_style_test.dart b/test/models/immersive_background_style_test.dart new file mode 100644 index 000000000..b4feb03ec --- /dev/null +++ b/test/models/immersive_background_style_test.dart @@ -0,0 +1,25 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:flick/models/immersive_background_style.dart'; + +void main() { + test('storage values round-trip', () { + for (final style in ImmersiveBackgroundStyle.values) { + expect( + ImmersiveBackgroundStyleX.fromStorageValue(style.storageValue), + style, + ); + } + }); + + test('unknown or missing values fall back to the color gradient', () { + expect( + ImmersiveBackgroundStyleX.fromStorageValue(null), + ImmersiveBackgroundStyle.colorGradient, + ); + expect( + ImmersiveBackgroundStyleX.fromStorageValue('unknown'), + ImmersiveBackgroundStyle.colorGradient, + ); + }); +} diff --git a/test/providers/app_preferences_provider_test.dart b/test/providers/app_preferences_provider_test.dart index 346270d07..198b2cf97 100644 --- a/test/providers/app_preferences_provider_test.dart +++ b/test/providers/app_preferences_provider_test.dart @@ -207,4 +207,44 @@ void main() { expect(prefs.getBool('onboarding_completed'), isTrue); }); } + + test('immersive background defaults to the color gradient', () async { + final container = ProviderContainer(); + addTearDown(container.dispose); + container.read(appPreferencesProvider); + await pumpEventQueue(); + expect( + container.read(appPreferencesProvider).immersiveBackgroundStyle, + 'color_gradient', + ); + }); + + test( + 'immersive blur gradient survives reopening the preferences', + () async { + final container = ProviderContainer(); + addTearDown(container.dispose); + container.read(appPreferencesProvider); + await pumpEventQueue(); + await container + .read(appPreferencesProvider.notifier) + .setImmersiveBackgroundStyle('blur_gradient'); + expect( + container.read(appPreferencesProvider).immersiveBackgroundStyle, + 'blur_gradient', + ); + + final restored = ProviderContainer(); + addTearDown(restored.dispose); + restored.read(appPreferencesProvider); + await pumpEventQueue(); + expect( + restored.read(appPreferencesProvider).immersiveBackgroundStyle, + 'blur_gradient', + ); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('immersive_background_style'), 'blur_gradient'); + }, + ); } diff --git a/test/providers/progress_bar_size_provider_test.dart b/test/providers/progress_bar_size_provider_test.dart new file mode 100644 index 000000000..ae9a304ca --- /dev/null +++ b/test/providers/progress_bar_size_provider_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:flick/models/progress_bar_size.dart'; +import 'package:flick/providers/progress_bar_size_provider.dart'; +import 'package:flick/services/progress_bar_size_preference_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('defaults are used when nothing is saved', () async { + SharedPreferences.setMockInitialValues({}); + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(progressBarSizeProvider); + await pumpEventQueue(); + + expect(container.read(progressBarSizeProvider), ProgressBarSizes.defaults); + }); + + test('waveform height and line thickness persist independently', () async { + SharedPreferences.setMockInitialValues({}); + final container = ProviderContainer(); + final notifier = container.read(progressBarSizeProvider.notifier); + + await notifier.setWaveformHeight(80); + await notifier.setLineThickness(10); + container.dispose(); + + final reloaded = ProviderContainer(); + addTearDown(reloaded.dispose); + reloaded.read(progressBarSizeProvider); + await pumpEventQueue(); + + final sizes = reloaded.read(progressBarSizeProvider); + expect(sizes.waveformHeight, 80); + expect(sizes.lineThickness, 10); + }); + + test('reset only restores the selected style', () async { + SharedPreferences.setMockInitialValues({ + ProgressBarSizePreferenceService.waveformHeightKey: 80.0, + ProgressBarSizePreferenceService.lineThicknessKey: 10.0, + }); + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(progressBarSizeProvider); + await pumpEventQueue(); + await container + .read(progressBarSizeProvider.notifier) + .resetWaveformHeight(); + + final sizes = container.read(progressBarSizeProvider); + expect(sizes.waveformHeight, ProgressBarSizes.defaultWaveformHeight); + expect(sizes.lineThickness, 10); + }); + + test('saved values outside the supported range are clamped', () async { + SharedPreferences.setMockInitialValues({ + ProgressBarSizePreferenceService.waveformHeightKey: 500.0, + ProgressBarSizePreferenceService.lineThicknessKey: -3.0, + }); + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(progressBarSizeProvider); + await pumpEventQueue(); + + final sizes = container.read(progressBarSizeProvider); + expect(sizes.waveformHeight, ProgressBarSizes.maxWaveformHeight); + expect(sizes.lineThickness, ProgressBarSizes.minLineThickness); + }); + + test('rapid edits persist the final state', () async { + SharedPreferences.setMockInitialValues({}); + final container = ProviderContainer(); + addTearDown(container.dispose); + final notifier = container.read(progressBarSizeProvider.notifier); + + await Future.wait([ + notifier.setWaveformHeight(32), + notifier.setWaveformHeight(48), + notifier.setLineThickness(7), + notifier.setLineThickness(9), + ]); + + final prefs = await SharedPreferences.getInstance(); + expect( + prefs.getDouble(ProgressBarSizePreferenceService.waveformHeightKey), + 48, + ); + expect( + prefs.getDouble(ProgressBarSizePreferenceService.lineThicknessKey), + 9, + ); + }); +} diff --git a/test/services/metadata_editor_service_test.dart b/test/services/metadata_editor_service_test.dart new file mode 100644 index 000000000..ca98fffc4 --- /dev/null +++ b/test/services/metadata_editor_service_test.dart @@ -0,0 +1,381 @@ +import 'dart:io'; +import 'dart:ui' show Locale; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:flick/l10n/l10n.dart'; +import 'package:flick/models/song.dart'; +import 'package:flick/services/metadata_editor_service.dart'; +import 'package:flick/src/rust/api/metadata_editor.dart' as rust_metadata; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('com.mossapps.flick/storage'); + const contentUri = + 'content://com.android.externalstorage.documents/tree/primary%3AMusic/' + 'document/primary%3AMusic%2Ftrack.flac'; + const treeUri = + 'content://com.android.externalstorage.documents/tree/primary%3AMusic'; + + final en = lookupAppLocalizations(const Locale('en')); + + late Directory tempDir; + late List channelCalls; + late Future Function(MethodCall call) onChannelCall; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('flick-metadata-editor-'); + channelCalls = []; + onChannelCall = (call) async { + fail('Unexpected storage channel call: ${call.method}'); + }; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + channelCalls.add(call); + return onChannelCall(call); + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + if (tempDir.existsSync()) { + tempDir.deleteSync(recursive: true); + } + }); + + File stagedFile(String name) { + final file = File('${tempDir.path}/$name'); + file.writeAsBytesSync([1, 2, 3]); + return file; + } + + Song songFor(String filePath, {String? folderUri}) { + return Song( + id: '1', + title: 'Blank Space', + artist: 'Taylor Swift', + duration: const Duration(minutes: 4), + fileType: 'FLAC', + filePath: filePath, + folderUri: folderUri, + ); + } + + const fields = rust_metadata.TagEditFields( + title: 'New Title', + artist: 'New Artist', + album: 'New Album', + albumArtist: 'New Album Artist', + genre: 'Rock', + year: 2026, + trackNumber: 1, + discNumber: 1, + ); + + const readBack = rust_metadata.TagReadResult( + title: 'New Title', + artist: 'New Artist', + album: 'New Album', + albumArtist: 'New Album Artist', + genre: 'Rock', + year: 2026, + trackNumber: 1, + discNumber: 1, + ); + + test( + 'edits a content URI through staging and writes the bytes back', + () async { + final editStage = stagedFile('edit.flac'); + final verifyStage = stagedFile('verify.flac'); + var stageCalls = 0; + + onChannelCall = (call) async { + switch (call.method) { + case 'stageContentUriForEdit': + final args = call.arguments as Map; + expect(args['uri'], contentUri); + expect(args['extensionHint'], 'flac'); + return stageCalls++ == 0 + ? {'path': editStage.path, 'reason': null} + : {'path': verifyStage.path, 'reason': null}; + case 'writeBytesToContentUri': + final args = call.arguments as Map; + expect(args['uri'], contentUri); + expect(args['tempFilePath'], editStage.path); + return {'success': true, 'reason': 'ok'}; + default: + fail('Unexpected storage channel call: ${call.method}'); + } + }; + + final writtenTo = []; + final persisted = []; + final service = MetadataEditorService.forTesting( + readTagFile: (path) async { + expect(path, verifyStage.path); + return readBack; + }, + writeTagFile: (path, editFields) async { + expect(editFields, fields); + writtenTo.add(path); + return const rust_metadata.TagWriteResult(success: true); + }, + persistMetadata: (path, editFields) async => persisted.add(path), + ); + + final result = await service.writeTags( + songFor(contentUri, folderUri: treeUri), + fields, + ); + + expect(result.outcome, MetadataWriteOutcome.verified); + expect(writtenTo, [editStage.path]); + expect(persisted, [contentUri]); + expect(stageCalls, 2); + expect(editStage.existsSync(), isFalse); + expect(verifyStage.existsSync(), isFalse); + expect(channelCalls.map((call) => call.method), [ + 'stageContentUriForEdit', + 'writeBytesToContentUri', + 'stageContentUriForEdit', + ]); + }, + ); + + test( + 'write-back without permission keeps the DB untouched and says why', + () async { + final editStage = stagedFile('edit.flac'); + var stageCalls = 0; + + onChannelCall = (call) async { + switch (call.method) { + case 'stageContentUriForEdit': + stageCalls++; + return {'path': editStage.path, 'reason': null}; + case 'writeBytesToContentUri': + return {'success': false, 'reason': 'no_access'}; + default: + fail('Unexpected storage channel call: ${call.method}'); + } + }; + + var readCalls = 0; + final persisted = []; + final service = MetadataEditorService.forTesting( + readTagFile: (path) async { + readCalls++; + return readBack; + }, + writeTagFile: (path, editFields) async => + const rust_metadata.TagWriteResult(success: true), + persistMetadata: (path, editFields) async => persisted.add(path), + ); + + final result = await service.writeTags( + songFor(contentUri, folderUri: treeUri), + fields, + ); + + expect(result.outcome, MetadataWriteOutcome.failed); + expect(result.message, en.flickNoLongerHasPermissionToEditThisFile); + expect(stageCalls, 1); + expect(readCalls, 0); + expect(persisted, isEmpty); + expect(editStage.existsSync(), isFalse); + }, + ); + + test('a missing content URI reports a moved-or-deleted file', () async { + onChannelCall = (call) async { + expect(call.method, 'stageContentUriForEdit'); + return {'path': null, 'reason': 'not_found'}; + }; + + var writeCalls = 0; + final service = MetadataEditorService.forTesting( + readTagFile: (path) async => readBack, + writeTagFile: (path, editFields) async { + writeCalls++; + return const rust_metadata.TagWriteResult(success: true); + }, + persistMetadata: (path, editFields) async {}, + ); + + final result = await service.writeTags( + songFor(contentUri, folderUri: treeUri), + fields, + ); + + expect(result.outcome, MetadataWriteOutcome.failed); + expect(result.message, en.fileIsNoLongerAvailable); + expect(writeCalls, 0); + }); + + test( + 'file:// paths are normalized before reaching the Rust tagger', + () async { + const fileUri = 'file:///storage/emulated/0/Music/track.flac'; + final writtenTo = []; + final persisted = []; + + final service = MetadataEditorService.forTesting( + readTagFile: (path) async => readBack, + writeTagFile: (path, editFields) async { + writtenTo.add(path); + return const rust_metadata.TagWriteResult(success: true); + }, + persistMetadata: (path, editFields) async => persisted.add(path), + ); + + final result = await service.writeTags(songFor(fileUri), fields); + + expect(result.outcome, MetadataWriteOutcome.verified); + expect(writtenTo, ['/storage/emulated/0/Music/track.flac']); + // The DB row is keyed by the original URI, not the normalized path. + expect(persisted, [fileUri]); + expect(channelCalls, isEmpty); + }, + ); + + test( + 'a direct ENOENT failure gets a clear message instead of os error 2', + () async { + final service = MetadataEditorService.forTesting( + readTagFile: (path) async => readBack, + writeTagFile: (path, editFields) async => + const rust_metadata.TagWriteResult( + success: false, + error: + 'Failed to open file: No such file or directory (os error 2)', + ), + persistMetadata: (path, editFields) async {}, + ); + + final result = await service.writeTags( + songFor('/storage/emulated/0/Music/missing.flac'), + fields, + ); + + expect(result.outcome, MetadataWriteOutcome.failed); + expect(result.message, en.fileIsNoLongerAvailable); + expect(result.message, isNot(contains('os error 2'))); + expect(channelCalls, isEmpty); + }, + ); + + test( + 'a thrown direct write still attempts the SAF fallback and keeps the URI key', + () async { + const path = '/storage/emulated/0/Music/track.flac'; + const fileUri = 'file://$path'; + final editStage = stagedFile('saf-edit.flac'); + final persisted = []; + + onChannelCall = (call) async { + expect(call.method, 'writeFileBytesViaSaf'); + final args = call.arguments as Map; + expect(args['folderTreeUri'], treeUri); + expect(args['filePath'], path); + expect(args['tempFilePath'], editStage.path); + return true; + }; + + final service = MetadataEditorService.forTesting( + readTagFile: (readPath) async { + expect(readPath, path); + return readBack; + }, + writeTagFile: (path, editFields) async { + throw Exception('direct write denied'); + }, + writeTagFileToTemp: (path, editFields, tempDirPath) async { + expect(path, '/storage/emulated/0/Music/track.flac'); + expect(tempDirPath, '${tempDir.path}/metadata_edits'); + return rust_metadata.TagWriteResult( + success: true, + tempPath: editStage.path, + ); + }, + persistMetadata: (path, editFields) async => persisted.add(path), + temporaryDirectory: () async => tempDir, + ); + + final result = await service.writeTags( + songFor(fileUri, folderUri: treeUri), + fields, + ); + + expect(result.outcome, MetadataWriteOutcome.verified); + expect(persisted, [fileUri]); + expect(editStage.existsSync(), isFalse); + expect(channelCalls.single.method, 'writeFileBytesViaSaf'); + }, + ); + + test('a direct permission failure falls back to SAF', () async { + const path = '/storage/emulated/0/Music/track.flac'; + final editStage = stagedFile('saf-denied.flac'); + final persisted = []; + + onChannelCall = (call) async { + expect(call.method, 'writeFileBytesViaSaf'); + final args = call.arguments as Map; + expect(args['filePath'], path); + expect(args['tempFilePath'], editStage.path); + return true; + }; + + final service = MetadataEditorService.forTesting( + readTagFile: (readPath) async => readBack, + writeTagFile: (path, editFields) async => + const rust_metadata.TagWriteResult( + success: false, + error: 'Failed to open file: Permission denied (os error 13)', + ), + writeTagFileToTemp: (path, editFields, tempDirPath) async => + rust_metadata.TagWriteResult(success: true, tempPath: editStage.path), + persistMetadata: (path, editFields) async => persisted.add(path), + temporaryDirectory: () async => tempDir, + ); + + final result = await service.writeTags( + songFor(path, folderUri: treeUri), + fields, + ); + + expect(result.outcome, MetadataWriteOutcome.verified); + expect(persisted, [path]); + expect(editStage.existsSync(), isFalse); + expect(channelCalls.single.method, 'writeFileBytesViaSaf'); + }); + + test( + 'readTags stages a content URI into cache and removes the copy', + () async { + final readStage = stagedFile('read.flac'); + + onChannelCall = (call) async { + expect(call.method, 'stageContentUriForEdit'); + return {'path': readStage.path, 'reason': null}; + }; + + final service = MetadataEditorService.forTesting( + readTagFile: (path) async { + expect(path, readStage.path); + return readBack; + }, + ); + + final result = await service.readTags(contentUri); + + expect(result, readBack); + expect(readStage.existsSync(), isFalse); + expect(channelCalls.single.method, 'stageContentUriForEdit'); + }, + ); +} diff --git a/test/widgets/adaptive_bottom_sheet_test.dart b/test/widgets/adaptive_bottom_sheet_test.dart new file mode 100644 index 000000000..62c738db8 --- /dev/null +++ b/test/widgets/adaptive_bottom_sheet_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flick/core/theme/app_theme.dart'; +import 'package:flick/widgets/common/glass_bottom_sheet.dart'; + +void main() { + for (final size in const [Size(891, 411), Size(1280, 800), Size(375, 800)]) { + testWidgets('modal uses usable width and height at $size', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.darkTheme, + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => GlassBottomSheet.show( + context: context, + title: 'Options', + content: Column( + children: List.generate( + 80, + (i) => ListTile(title: Text('Option $i')), + ), + ), + ), + child: const Text('Open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + final surface = find.byWidgetPredicate( + (w) => + w is Container && w.decoration == AppBottomSheetSurface.decoration, + ); + final rect = tester.getRect(surface); + expect(rect.width, greaterThan(size.width * 0.85)); + expect(rect.height, greaterThan(size.height * 0.85)); + expect(rect.bottom, lessThanOrEqualTo(size.height)); + await tester.drag( + find.byType(SingleChildScrollView), + const Offset(0, -5000), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('keyboard reduces and lifts the sheet without hiding actions', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(891, 411); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.darkTheme, + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(viewInsets: const EdgeInsets.only(bottom: 180)), + child: child!, + ), + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => GlassBottomSheet.show( + context: context, + title: 'Options', + content: const SizedBox(height: 500, child: Text('Content')), + actions: [ + TextButton(onPressed: () {}, child: const Text('Save')), + ], + ), + child: const Text('Open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + expect(tester.getRect(find.text('Save')).bottom, lessThanOrEqualTo(231)); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/widgets/flick_nav_rail_test.dart b/test/widgets/flick_nav_rail_test.dart new file mode 100644 index 000000000..f84a095f0 --- /dev/null +++ b/test/widgets/flick_nav_rail_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flick/l10n/l10n.dart'; +import 'package:flick/models/nav_bar_config.dart'; +import 'package:flick/widgets/navigation/flick_nav_bar.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +Widget _host(Widget child, {double textScale = 1}) { + return MaterialApp( + locale: kFallbackLocale, + supportedLocales: kSupportedLocales, + localizationsDelegates: kAppLocalizationsDelegates, + localeResolutionCallback: resolveAppLocale, + home: MediaQuery( + data: MediaQueryData( + size: const Size(568, 320), + textScaler: TextScaler.linear(textScale), + disableAnimations: true, + ), + child: Scaffold( + body: Align( + alignment: Alignment.centerLeft, + child: SizedBox(height: 320, child: child), + ), + ), + ), + ); +} + +void main() { + testWidgets('rail scrolls every configured destination in a short window', ( + tester, + ) async { + final config = NavBarConfig(enabledButtons: NavBarButton.values); + + for (final scale in [1.0, 1.5, 2.0]) { + await tester.pumpWidget( + _host( + FlickNavRail( + currentIndex: NavBarButton.menu.pageIndex, + config: config, + onTap: (_) {}, + onBottomBarSettings: () {}, + ), + textScale: scale, + ), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull, reason: 'textScale=$scale'); + expect(find.byIcon(LucideIcons.search), findsOneWidget); + + await tester.ensureVisible(find.byIcon(LucideIcons.search)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull, reason: 'scrolled scale=$scale'); + } + }); + + testWidgets('missing essential destinations stay reachable from the rail', ( + tester, + ) async { + const config = NavBarConfig( + enabledButtons: [NavBarButton.albums, NavBarButton.favorites], + ); + + await tester.pumpWidget( + _host( + FlickNavRail( + currentIndex: NavBarButton.albums.pageIndex, + config: config, + onTap: (_) {}, + onBottomBarSettings: () {}, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.byIcon(LucideIcons.ellipsis), findsOneWidget); + + await tester.tap(find.byIcon(LucideIcons.ellipsis)); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + // Menu, Songs and Settings are supplied by the overflow sheet. + expect(find.byIcon(LucideIcons.layoutGrid), findsOneWidget); + expect(find.byIcon(LucideIcons.music), findsOneWidget); + expect(find.byIcon(LucideIcons.settings), findsWidgets); + }); +} diff --git a/test/widgets/landscape_player_layout_test.dart b/test/widgets/landscape_player_layout_test.dart new file mode 100644 index 000000000..a171f4040 --- /dev/null +++ b/test/widgets/landscape_player_layout_test.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flick/features/player/widgets/landscape_player_layout.dart'; + +void main() { + for (final size in const [Size(496, 210), Size(850, 300), Size(1280, 650)]) { + for (final artwork in [false, true]) { + testWidgets( + '$size artwork=$artwork keeps identity and playback together', + (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: LandscapePlayerLayout( + artworkCard: artwork, + stage: const ColoredBox( + key: ValueKey('stage'), + color: Colors.blue, + ), + identity: const SizedBox( + key: ValueKey('identity'), + height: 56, + child: Text('Title and artist'), + ), + actions: const SizedBox(height: 48, child: Text('Actions')), + playback: const SizedBox( + key: ValueKey('playback'), + height: 80, + child: Text('Playback'), + ), + ), + ), + ), + ); + final identity = tester.getRect( + find.byKey(const ValueKey('identity')), + ); + final playback = tester.getRect( + find.byKey(const ValueKey('playback')), + ); + // Artwork-card details keep a little extra breathing room above + // playback, so allow a slightly larger gap than immersive mode. + expect(playback.top - identity.bottom, lessThanOrEqualTo(100)); + expect(playback.bottom, lessThanOrEqualTo(size.height)); + if (artwork) { + expect( + tester.getSize(find.byKey(const ValueKey('stage'))).width, + lessThanOrEqualTo(400), + ); + } + expect(tester.takeException(), isNull); + }, + ); + } + } + + testWidgets('large details scroll without displacing playback', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(640, 240); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: LandscapePlayerLayout( + lyricsMode: true, + stage: const Text('Lyrics'), + identity: Text(List.filled(30, 'A long song title').join('\n')), + playback: const SizedBox( + key: ValueKey('playback'), + height: 100, + child: Text('Playback'), + ), + ), + ), + ), + ); + expect( + tester.getRect(find.byKey(const ValueKey('playback'))).bottom, + lessThanOrEqualTo(240), + ); + await tester.drag( + find.byKey(const ValueKey('landscape-player-details')), + const Offset(0, -200), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/widgets/landscape_side_mini_player_test.dart b/test/widgets/landscape_side_mini_player_test.dart new file mode 100644 index 000000000..1231845d2 --- /dev/null +++ b/test/widgets/landscape_side_mini_player_test.dart @@ -0,0 +1,283 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:flick/features/player/widgets/audio_visualizer.dart'; +import 'package:flick/l10n/l10n.dart'; +import 'package:flick/models/song.dart'; +import 'package:flick/providers/mini_player_config_provider.dart'; +import 'package:flick/providers/providers.dart'; +import 'package:flick/services/player_service.dart'; +import 'package:flick/widgets/common/landscape_side_mini_player.dart'; + +const _song = Song( + id: 'side-player-track', + title: 'A title that should not appear', + artist: 'An artist that should not appear', + duration: Duration(minutes: 4), + fileType: 'FLAC', +); + +class _Player extends Fake implements PlayerService { + @override + final isPlayingNotifier = ValueNotifier(true); + @override + final positionNotifier = ValueNotifier(const Duration(seconds: 48)); + @override + final currentSongNotifier = ValueNotifier(_song); + @override + final usingRustBackendNotifier = ValueNotifier(false); + + @override + void dispose() { + isPlayingNotifier.dispose(); + positionNotifier.dispose(); + currentSongNotifier.dispose(); + usingRustBackendNotifier.dispose(); + } +} + +Widget _host( + ProviderContainer container, { + required bool animationsEnabled, + double height = 360, +}) => UncontrolledProviderScope( + container: container, + child: MaterialApp( + locale: kFallbackLocale, + supportedLocales: kSupportedLocales, + localizationsDelegates: kAppLocalizationsDelegates, + localeResolutionCallback: resolveAppLocale, + home: MediaQuery( + data: MediaQueryData( + size: Size(640, height), + disableAnimations: !animationsEnabled, + ), + child: Scaffold( + body: Align( + alignment: Alignment.centerRight, + child: SizedBox( + height: height, + child: TickerMode( + enabled: animationsEnabled, + child: const LandscapeSideMiniPlayer(), + ), + ), + ), + ), + ), + ), +); + +ProviderContainer _container(_Player player) { + final container = ProviderContainer( + overrides: [ + currentSongProvider.overrideWithValue(_song), + isPlayingProvider.overrideWithValue(true), + progressProvider.overrideWithValue(0.42), + playerServiceProvider.overrideWithValue(player), + albumDominantColorSyncProvider.overrideWithValue(null), + ], + ); + addTearDown(container.dispose); + return container; +} + +Future _visibleVisualizerPixels(WidgetTester tester) async { + final boundary = tester.renderObject( + find.descendant( + of: find.byType(AudioVisualizer), + matching: find.byType(RepaintBoundary), + ), + ); + return (await tester.runAsync(() async { + final image = await boundary.toImage(); + final data = (await image.toByteData(format: ui.ImageByteFormat.rawRgba))!; + image.dispose(); + var visible = 0; + for (var offset = 3; offset < data.lengthInBytes; offset += 4) { + if (data.getUint8(offset) > 30) visible++; + } + return visible; + }))!; +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('keeps the compact orbit controls in one vertical strip', ( + tester, + ) async { + final player = _Player(); + addTearDown(player.dispose); + + await tester.pumpWidget( + _host(_container(player), animationsEnabled: false), + ); + await tester.pump(); + + expect(find.byType(LandscapeSideMiniPlayer), findsOneWidget); + expect(find.text(_song.title), findsNothing); + expect(find.text(_song.artist), findsNothing); + expect(find.byType(AudioVisualizer), findsNothing); + expect( + find.byKey(const ValueKey('side_mini_player_divider')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('side_mini_player_progress')), + findsOneWidget, + ); + expect(find.byTooltip(l10n.pause), findsOneWidget); + + final strip = tester.getRect(find.byType(LandscapeSideMiniPlayer)); + final artwork = tester.getRect(find.byType(Hero)); + final divider = tester.getRect( + find.byKey(const ValueKey('side_mini_player_divider')), + ); + final progress = tester.getRect( + find.byKey(const ValueKey('side_mini_player_progress')), + ); + final play = tester.getRect(find.byTooltip(l10n.pause)); + + expect(strip.width, 64); + expect(artwork.top, lessThan(divider.top)); + expect(divider.bottom, lessThanOrEqualTo(play.top)); + expect(progress.left, strip.left); + expect(progress.right, lessThan(play.center.dx)); + expect(progress.bottom, closeTo(strip.bottom - 8, 0.1)); + }); + + testWidgets('rotates the visualizer so it grows from the right edge', ( + tester, + ) async { + final player = _Player(); + addTearDown(player.dispose); + + await tester.pumpWidget(_host(_container(player), animationsEnabled: true)); + await tester.pump(); + + final visualizerFinder = find.byKey( + const ValueKey('side_mini_player_visualizer'), + ); + final visualizer = tester.widget(visualizerFinder); + expect(visualizer.quarterTurns, 3); + final painter = tester.renderObject( + find.descendant( + of: find.byType(AudioVisualizer), + matching: find.byType(CustomPaint), + ), + ); + expect(painter.size.width, greaterThan(0)); + expect(painter.size.height, 48); + + final bounds = tester.getRect(visualizerFinder); + final lowFrequencyBase = painter.localToGlobal( + Offset(0, painter.size.height), + ); + final highFrequencyBase = painter.localToGlobal( + Offset(painter.size.width, painter.size.height), + ); + expect(lowFrequencyBase.dx, closeTo(bounds.right, 0.01)); + expect(lowFrequencyBase.dy, closeTo(bounds.bottom, 0.01)); + expect(highFrequencyBase.dx, closeTo(bounds.right, 0.01)); + expect(highFrequencyBase.dy, closeTo(bounds.top, 0.01)); + }); + + testWidgets('paints every style with and without artwork in short windows', ( + tester, + ) async { + final player = _Player(); + addTearDown(player.dispose); + final container = _container(player); + await tester.pumpWidget(_host(container, animationsEnabled: true)); + await tester.pump(); + + for (final showArtwork in [true, false]) { + await container + .read(miniPlayerConfigProvider.notifier) + .update((config) => config.copyWith(showArtwork: showArtwork)); + for (final style in [ + 'bars', + 'wave', + 'curved_wave', + 'mirrored', + 'dots', + 'blocks', + ]) { + await container + .read(appPreferencesProvider.notifier) + .setVisualizerAnimationStyle(style); + await tester.pumpWidget( + _host( + container, + animationsEnabled: true, + height: showArtwork ? 320 : 411, + ), + ); + for (var frame = 0; frame < 20; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + } + + final bounds = tester.getRect( + find.byKey(const ValueKey('side_mini_player_visualizer')), + ); + final strip = tester.getRect(find.byType(LandscapeSideMiniPlayer)); + final divider = tester.getRect( + find.byKey(const ValueKey('side_mini_player_divider')), + ); + expect(bounds.width, 48); + expect(bounds.height, greaterThan(100)); + expect(bounds.left, greaterThan(strip.left)); + expect(bounds.right, lessThan(strip.right)); + expect(bounds.bottom, lessThan(divider.top)); + if (showArtwork) { + expect( + bounds.top, + greaterThan(tester.getRect(find.byType(Hero)).bottom), + ); + } + expect( + await _visibleVisualizerPixels(tester), + greaterThan(20), + reason: '$style, artwork: $showArtwork', + ); + expect(tester.takeException(), isNull); + } + } + await tester.pumpWidget(const SizedBox.shrink()); + }); + + testWidgets('visualizer visibility still follows the preference', ( + tester, + ) async { + final player = _Player(); + addTearDown(player.dispose); + final container = _container(player); + await tester.pumpWidget(_host(container, animationsEnabled: true)); + await tester.pump(); + + await container + .read(appPreferencesProvider.notifier) + .setVisualizerEnabled(false); + await tester.pump(); + expect(find.byType(AudioVisualizer), findsNothing); + + await container + .read(appPreferencesProvider.notifier) + .setVisualizerEnabled(true); + await tester.pump(); + expect(find.byType(AudioVisualizer), findsOneWidget); + expect( + tester + .getSize(find.byKey(const ValueKey('side_mini_player_visualizer'))) + .width, + 48, + ); + await tester.pumpWidget(const SizedBox.shrink()); + }); +} diff --git a/test/widgets/player_landscape_sheets_test.dart b/test/widgets/player_landscape_sheets_test.dart new file mode 100644 index 000000000..c501809aa --- /dev/null +++ b/test/widgets/player_landscape_sheets_test.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:flick/core/theme/app_theme.dart'; +import 'package:flick/features/player/widgets/player_layout_sheet.dart'; +import 'package:flick/features/player/widgets/song_metadata_sheet.dart'; +import 'package:flick/features/player/widgets/speed_bottom_sheet.dart'; +import 'package:flick/features/player/widgets/pitch_bottom_sheet.dart'; +import 'package:flick/features/player/widgets/sleep_timer_bottom_sheet.dart'; +import 'package:flick/l10n/l10n.dart'; +import 'package:flick/models/player_screen_mode.dart'; +import 'package:flick/models/song.dart'; +import 'package:flick/providers/player_provider.dart'; +import 'package:flick/services/player_service.dart'; + +class _Player extends Fake implements PlayerService { + @override + final currentSongNotifier = ValueNotifier(null); + @override + final playbackSpeedNotifier = ValueNotifier(1.0); + @override + final pitchSemitonesNotifier = ValueNotifier(0.0); + @override + final sleepTimerRemainingNotifier = ValueNotifier(null); + + @override + Future setPlaybackSpeed(double value) async => + playbackSpeedNotifier.value = value; + @override + Future setPitchSemitones(double value) async => + pitchSemitonesNotifier.value = value; + @override + void setSleepTimer(Duration duration) => + sleepTimerRemainingNotifier.value = duration; + @override + void cancelSleepTimer() => sleepTimerRemainingNotifier.value = null; +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + tearDown(() => LocaleController.instance.bindLocale(kFallbackLocale)); + + Widget host(Widget child, Locale locale, double scale) => ProviderScope( + overrides: [currentSongProvider.overrideWithValue(null)], + child: MaterialApp( + theme: AppTheme.darkTheme, + locale: locale, + supportedLocales: kSupportedLocales, + localizationsDelegates: kAppLocalizationsDelegates, + builder: (context, child) { + LocaleController.instance.bind(AppLocalizations.of(context)); + return MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(scale)), + child: child!, + ); + }, + home: Scaffold(body: child), + ), + ); + + for (final locale in kSupportedLocales) { + for (final size in const [Size(640, 320), Size(1280, 800)]) { + testWidgets('layout editor uses landscape columns at $size $locale', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + host( + PlayerLayoutSheet( + playerService: _Player(), + initialMode: PlayerScreenMode.immersive, + onModeChanged: (_) async {}, + song: null, + ), + locale, + 2, + ), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + final text = lookupFlickLocalizations( + locale == kFilipinoLocale ? kFallbackLocale : locale, + ); + final artwork = tester.getRect(find.text(text.artworkCard).first); + final fullscreen = tester.getRect(find.text(text.fullscreen).first); + expect(artwork.left, greaterThan(fullscreen.right)); + }); + } + } + + testWidgets('short landscape utility sheets remain scrollable at 2x text', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(640, 320); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final service = _Player(); + for (final child in [ + SpeedBottomSheet(playerService: service), + PitchBottomSheet(playerService: service), + SleepTimerBottomSheet(playerService: service), + const SongMetadataSheet( + song: Song( + id: 'test', + title: 'A long song title', + artist: 'A long artist name', + album: 'A long album name', + duration: Duration(minutes: 4), + fileType: 'FLAC', + ), + ), + ]) { + await tester.pumpWidget(host(child, kFallbackLocale, 2)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull, reason: '${child.runtimeType}'); + // Do not drag across a slider: its end callback deliberately closes the + // modal in production, while this fixture hosts each surface directly. + final scroll = tester.state(find.byType(Scrollable).first); + scroll.position.jumpTo(scroll.position.maxScrollExtent); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + } + }); +} diff --git a/test/widgets/progress_bar_size_test.dart b/test/widgets/progress_bar_size_test.dart new file mode 100644 index 000000000..21613d57d --- /dev/null +++ b/test/widgets/progress_bar_size_test.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flick/features/player/widgets/line_seek_bar.dart'; +import 'package:flick/features/player/widgets/waveform_seek_bar.dart'; +import 'package:flick/models/progress_bar_size.dart'; + +void main() { + const position = Duration(minutes: 1); + const duration = Duration(minutes: 3); + + Future pumpBar(WidgetTester tester, Widget bar) { + return tester.pumpWidget( + MaterialApp( + theme: ThemeData.dark(), + home: Scaffold( + body: Center(child: SizedBox(width: 320, child: bar)), + ), + ), + ); + } + + for (final thickness in [ + ProgressBarSizes.minLineThickness, + ProgressBarSizes.maxLineThickness, + ]) { + testWidgets('line style keeps a 48dp touch target at ${thickness}dp', ( + tester, + ) async { + await pumpBar( + tester, + LineSeekBar( + position: position, + duration: duration, + onChanged: (_) {}, + thickness: thickness, + ), + ); + + expect(tester.getSize(find.byType(LineSeekBar)).height, 48); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('waveform height below the touch target stays tappable', ( + tester, + ) async { + await pumpBar( + tester, + WaveformSeekBar( + position: position, + duration: duration, + onChanged: (_) {}, + height: ProgressBarSizes.minWaveformHeight, + ), + ); + + expect(tester.getSize(find.byType(WaveformSeekBar)).height, 48); + expect(tester.takeException(), isNull); + }); + + testWidgets('waveform uses the selected height above the touch target', ( + tester, + ) async { + await pumpBar( + tester, + WaveformSeekBar( + position: position, + duration: duration, + onChanged: (_) {}, + height: ProgressBarSizes.maxWaveformHeight, + ), + ); + + expect( + tester.getSize(find.byType(WaveformSeekBar)).height, + ProgressBarSizes.maxWaveformHeight, + ); + expect(tester.takeException(), isNull); + }); +}