feat(tablet): navigation rail, landscape now-playing, clamped track lists

- shell switches to a NavigationRail at >=600dp (same destinations,
  badges included); the bottom bar and its blur stay phone-only while
  the mini player remains anchored bottom in both modes
- now-playing gains a two-pane landscape layout (artwork left,
  metadata/controls right) built from the same widget pieces
- track lists on album/playlist/library-folder screens and search
  result sections center at 720dp via wideListInset instead of
  stretching across the full tablet width; explore carousels stay
  full-bleed intentionally
This commit is contained in:
zarzet
2026-07-14 09:10:00 +07:00
parent 8f7ede4730
commit f5b5af6eea
7 changed files with 424 additions and 303 deletions
+30 -26
View File
@@ -14,6 +14,7 @@ import 'package:spotiflac_android/utils/cover_art_utils.dart';
import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart';
import 'package:spotiflac_android/widgets/error_card.dart';
import 'package:spotiflac_android/widgets/album_detail_header.dart';
import 'package:spotiflac_android/utils/adaptive_layout.dart';
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
import 'package:spotiflac_android/utils/provider_resource_ids.dart';
import 'package:spotiflac_android/utils/ttl_cache.dart';
@@ -536,37 +537,40 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
),
)
.maybeWhen(data: (keys) => keys, orElse: () => const <String>{});
return SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final track = tracks[index];
final isInHistory = existingHistoryKeys.contains(
historyLookups[index].lookupKey,
);
return KeyedSubtree(
key: ValueKey(track.id),
child: StaggeredListItem(
index: index,
child: TrackListTile(
track: track,
isInHistory: isInHistory,
onDownload: () => _downloadTrack(context, track),
clickableArtist: true,
leading: SizedBox(
width: 32,
child: Center(
child: Text(
'${track.trackNumber ?? 0}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
return SliverPadding(
padding: EdgeInsets.symmetric(horizontal: wideListInset(context)),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final track = tracks[index];
final isInHistory = existingHistoryKeys.contains(
historyLookups[index].lookupKey,
);
return KeyedSubtree(
key: ValueKey(track.id),
child: StaggeredListItem(
index: index,
child: TrackListTile(
track: track,
isInHistory: isInHistory,
onDownload: () => _downloadTrack(context, track),
clickableArtist: true,
leading: SizedBox(
width: 32,
child: Center(
child: Text(
'${track.trackNumber ?? 0}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
),
),
),
),
),
);
}, childCount: tracks.length),
);
}, childCount: tracks.length),
),
);
}
+29 -22
View File
@@ -24,6 +24,7 @@ import 'package:spotiflac_android/services/csv_import_service.dart';
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/utils/adaptive_layout.dart';
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
@@ -3168,7 +3169,10 @@ class _HomeTabState extends ConsumerState<HomeTab>
return [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
// Aligned with the clamped result list below on wide screens.
padding:
EdgeInsets.fromLTRB(16, 8, 8, 8) +
EdgeInsets.symmetric(horizontal: wideListInset(context)),
child: Row(
children: [
Expanded(
@@ -3212,29 +3216,32 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
),
),
SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final isFirst = index == 0;
final isLast = index == itemCount - 1;
return StaggeredListItem(
index: index,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: sectionColor,
borderRadius: BorderRadius.vertical(
top: isFirst ? const Radius.circular(20) : Radius.zero,
bottom: isLast ? const Radius.circular(20) : Radius.zero,
SliverPadding(
padding: EdgeInsets.symmetric(horizontal: wideListInset(context)),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final isFirst = index == 0;
final isLast = index == itemCount - 1;
return StaggeredListItem(
index: index,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: sectionColor,
borderRadius: BorderRadius.vertical(
top: isFirst ? const Radius.circular(20) : Radius.zero,
bottom: isLast ? const Radius.circular(20) : Radius.zero,
),
),
clipBehavior: Clip.antiAlias,
child: Material(
color: Colors.transparent,
child: itemBuilder(index, !isLast),
),
),
clipBehavior: Clip.antiAlias,
child: Material(
color: Colors.transparent,
child: itemBuilder(index, !isLast),
),
),
);
}, childCount: itemCount),
);
}, childCount: itemCount),
),
),
];
}
+34 -28
View File
@@ -13,6 +13,7 @@ import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/utils/adaptive_layout.dart';
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
import 'package:spotiflac_android/utils/cover_art_utils.dart';
import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart';
@@ -260,35 +261,40 @@ class _LibraryTracksFolderScreenState
),
)
else
SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final entry = entries[index];
final isSelected = selectedIds.contains(entry.key);
final isInHistory = existingHistoryKeys.contains(
historyLookups[index].lookupKey,
);
return KeyedSubtree(
key: ValueKey(entry.key),
child: StaggeredListItem(
index: index,
child: _CollectionTrackTile(
entry: entry,
mode: widget.mode,
playlistId: widget.playlistId,
folderTracks: folderTracks,
isInHistory: isInHistory,
isSelectionMode: isSelectionMode,
isSelected: isSelected,
onTap: isSelectionMode
? () => toggleSelection(entry.key)
: null,
onLongPress: isSelectionMode
? null
: () => enterSelectionMode(entry.key),
SliverPadding(
padding: EdgeInsets.symmetric(
horizontal: wideListInset(context),
),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final entry = entries[index];
final isSelected = selectedIds.contains(entry.key);
final isInHistory = existingHistoryKeys.contains(
historyLookups[index].lookupKey,
);
return KeyedSubtree(
key: ValueKey(entry.key),
child: StaggeredListItem(
index: index,
child: _CollectionTrackTile(
entry: entry,
mode: widget.mode,
playlistId: widget.playlistId,
folderTracks: folderTracks,
isInHistory: isInHistory,
isSelectionMode: isSelectionMode,
isSelected: isSelected,
onTap: isSelectionMode
? () => toggleSelection(entry.key)
: null,
onLongPress: isSelectionMode
? null
: () => enterSelectionMode(entry.key),
),
),
),
);
}, childCount: entries.length),
);
}, childCount: entries.length),
),
),
SliverToBoxAdapter(
child: SizedBox(height: isSelectionMode ? 200 : 32),
+82 -48
View File
@@ -584,6 +584,39 @@ class _MainShellState extends ConsumerState<MainShell>
});
}
// Material breakpoint: rail navigation on tablet/landscape widths, the
// bottom NavigationBar on phones.
final useNavigationRail = MediaQuery.sizeOf(context).width >= 600;
final pageView = AnimatedBuilder(
animation: _tabJumpTransitionController,
child: PageView.builder(
controller: _pageController,
itemCount: tabs.length,
onPageChanged: _onPageChanged,
physics: const NeverScrollableScrollPhysics(),
// TickerMode mutes animations and lets visibility-aware widgets
// (e.g. MotionHeaderBanner) pause when their tab is hidden —
// kept-alive pages otherwise keep running offscreen.
itemBuilder: (context, index) => _KeepAliveTabPage(
key: ValueKey('page-$index'),
child: TickerMode(
enabled: index == _currentIndex,
child: tabs[index],
),
),
),
builder: (context, child) {
final t = Curves.easeOutCubic.transform(
_tabJumpTransitionController.value,
);
return Opacity(
opacity: t,
child: Transform.scale(scale: 0.985 + (0.015 * t), child: child),
);
},
);
return BackButtonListener(
onBackButtonPressed: () async {
await _handleBackPress();
@@ -591,34 +624,34 @@ class _MainShellState extends ConsumerState<MainShell>
},
child: Scaffold(
extendBody: true,
body: AnimatedBuilder(
animation: _tabJumpTransitionController,
child: PageView.builder(
controller: _pageController,
itemCount: tabs.length,
onPageChanged: _onPageChanged,
physics: const NeverScrollableScrollPhysics(),
// TickerMode mutes animations and lets visibility-aware widgets
// (e.g. MotionHeaderBanner) pause when their tab is hidden —
// kept-alive pages otherwise keep running offscreen.
itemBuilder: (context, index) => _KeepAliveTabPage(
key: ValueKey('page-$index'),
child: TickerMode(
enabled: index == _currentIndex,
child: tabs[index],
),
),
),
builder: (context, child) {
final t = Curves.easeOutCubic.transform(
_tabJumpTransitionController.value,
);
return Opacity(
opacity: t,
child: Transform.scale(scale: 0.985 + (0.015 * t), child: child),
);
},
),
body: useNavigationRail
? Row(
children: [
SafeArea(
right: false,
bottom: false,
child: NavigationRail(
selectedIndex: _currentIndex.clamp(0, maxIndex),
onDestinationSelected: _onNavTap,
labelType: NavigationRailLabelType.all,
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainer,
destinations: [
for (final destination in destinations)
NavigationRailDestination(
icon: destination.icon,
selectedIcon: destination.selectedIcon,
label: Text(destination.label),
),
],
),
),
const VerticalDivider(width: 1),
Expanded(child: pageView),
],
)
: pageView,
bottomNavigationBar: ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18),
@@ -626,29 +659,30 @@ class _MainShellState extends ConsumerState<MainShell>
mainAxisSize: MainAxisSize.min,
children: [
const MiniPlayer(),
DecoratedBox(
position: DecorationPosition.foreground,
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: Theme.of(
context,
).colorScheme.outlineVariant.withValues(alpha: 0.5),
if (!useNavigationRail)
DecoratedBox(
position: DecorationPosition.foreground,
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: Theme.of(
context,
).colorScheme.outlineVariant.withValues(alpha: 0.5),
),
),
),
child: NavigationBar(
selectedIndex: _currentIndex.clamp(0, maxIndex),
onDestinationSelected: _onNavTap,
animationDuration: const Duration(milliseconds: 500),
elevation: 0,
height: 64,
backgroundColor: settingsGroupColor(
context,
).withValues(alpha: 0.72),
destinations: destinations,
),
),
child: NavigationBar(
selectedIndex: _currentIndex.clamp(0, maxIndex),
onDestinationSelected: _onNavTap,
animationDuration: const Duration(milliseconds: 500),
elevation: 0,
height: 64,
backgroundColor: settingsGroupColor(
context,
).withValues(alpha: 0.72),
destinations: destinations,
),
),
],
),
),
+199 -142
View File
@@ -417,6 +417,66 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
return LayoutBuilder(
builder: (context, constraints) {
Widget artworkAt(double artSize) => Center(
child: _artworkDragRegion(
context,
Hero(
tag: kNowPlayingArtworkHeroTag,
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: SizedBox(
width: artSize,
height: artSize,
child: PlayerArtwork(
artUri: mediaItem.artUri?.toString(),
colorScheme: colorScheme,
cacheWidth:
(artSize * MediaQuery.devicePixelRatioOf(context))
.round(),
),
),
),
),
),
);
// Tablet/landscape: artwork pane left, metadata and controls right,
// instead of one narrow column in a sea of empty space.
final twoPane =
constraints.maxWidth >= 720 &&
constraints.maxWidth > constraints.maxHeight;
if (twoPane) {
final artSize = (constraints.maxHeight - 96).clamp(0.0, 420.0);
return Row(
children: [
Expanded(child: artworkAt(artSize)),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 16),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight - 32,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: _metadataAndControls(
mediaItem,
controller,
colorScheme,
isPlaying: isPlaying,
position: position,
duration: duration,
posMs: posMs,
maxMs: maxMs,
),
),
),
),
),
],
);
}
final artSize = (constraints.maxWidth - 64).clamp(0.0, 360.0);
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 16),
@@ -425,149 +485,17 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Center(
child: _artworkDragRegion(
context,
Hero(
tag: kNowPlayingArtworkHeroTag,
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: SizedBox(
width: artSize,
height: artSize,
child: PlayerArtwork(
artUri: mediaItem.artUri?.toString(),
colorScheme: colorScheme,
cacheWidth:
(artSize *
MediaQuery.devicePixelRatioOf(context))
.round(),
),
),
),
),
),
),
artworkAt(artSize),
const SizedBox(height: 32),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 28),
child: Column(
children: [
Text(
mediaItem.title,
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 6),
Text(
mediaItem.artist ?? '',
style: Theme.of(context).textTheme.titleMedium
?.copyWith(color: colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
SliderTheme(
data: SliderThemeData(
trackHeight: 4,
activeTrackColor: colorScheme.primary,
inactiveTrackColor: colorScheme.onSurface.withValues(
alpha: 0.18,
),
thumbColor: colorScheme.primary,
thumbShape: const RoundSliderThumbShape(
enabledThumbRadius: 7,
),
overlayShape: const RoundSliderOverlayShape(
overlayRadius: 16,
),
),
child: Slider(
value: posMs.clamp(0, maxMs),
max: maxMs,
onChanged: duration.inMilliseconds > 0
? (value) => controller.seek(
Duration(milliseconds: value.round()),
)
: null,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
Text(
formatClock(position.inSeconds),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
Expanded(
child: Center(
child: _QualityBadge(
label: _qualityLabel(),
colorScheme: colorScheme,
),
),
),
Text(
formatClock(duration.inSeconds),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
iconSize: 44,
icon: const Icon(Icons.skip_previous),
onPressed: controller.previous,
),
const SizedBox(width: 20),
Container(
decoration: BoxDecoration(
color: colorScheme.primary,
shape: BoxShape.circle,
),
child: IconButton(
iconSize: 44,
padding: const EdgeInsets.all(12),
color: colorScheme.onPrimary,
icon: Icon(isPlaying ? Icons.pause : Icons.play_arrow),
onPressed: () => controller.togglePlayPause(isPlaying),
),
),
const SizedBox(width: 20),
IconButton(
iconSize: 44,
icon: const Icon(Icons.skip_next),
onPressed: controller.next,
),
],
..._metadataAndControls(
mediaItem,
controller,
colorScheme,
isPlaying: isPlaying,
position: position,
duration: duration,
posMs: posMs,
maxMs: maxMs,
),
],
),
@@ -577,6 +505,135 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
);
}
/// Title/artist, seek slider, and transport buttons — shared by the
/// portrait column and the landscape right pane.
List<Widget> _metadataAndControls(
MediaItem mediaItem,
MusicPlayerController controller,
ColorScheme colorScheme, {
required bool isPlaying,
required Duration position,
required Duration duration,
required double posMs,
required double maxMs,
}) {
return [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 28),
child: Column(
children: [
Text(
mediaItem.title,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 6),
Text(
mediaItem.artist ?? '',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
SliderTheme(
data: SliderThemeData(
trackHeight: 4,
activeTrackColor: colorScheme.primary,
inactiveTrackColor: colorScheme.onSurface.withValues(
alpha: 0.18,
),
thumbColor: colorScheme.primary,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 16),
),
child: Slider(
value: posMs.clamp(0, maxMs),
max: maxMs,
onChanged: duration.inMilliseconds > 0
? (value) =>
controller.seek(Duration(milliseconds: value.round()))
: null,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
Text(
formatClock(position.inSeconds),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
Expanded(
child: Center(
child: _QualityBadge(
label: _qualityLabel(),
colorScheme: colorScheme,
),
),
),
Text(
formatClock(duration.inSeconds),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
iconSize: 44,
icon: const Icon(Icons.skip_previous),
onPressed: controller.previous,
),
const SizedBox(width: 20),
Container(
decoration: BoxDecoration(
color: colorScheme.primary,
shape: BoxShape.circle,
),
child: IconButton(
iconSize: 44,
padding: const EdgeInsets.all(12),
color: colorScheme.onPrimary,
icon: Icon(isPlaying ? Icons.pause : Icons.play_arrow),
onPressed: () => controller.togglePlayPause(isPlaying),
),
),
const SizedBox(width: 20),
IconButton(
iconSize: 44,
icon: const Icon(Icons.skip_next),
onPressed: controller.next,
),
],
),
];
}
Widget _lyricsSection(ColorScheme colorScheme) {
if (_loadingMeta) {
return const Center(child: CircularProgressIndicator());
+41 -37
View File
@@ -9,6 +9,7 @@ import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/utils/image_cache_utils.dart';
import 'package:spotiflac_android/utils/cover_art_utils.dart';
import 'package:spotiflac_android/utils/adaptive_layout.dart';
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
import 'package:spotiflac_android/utils/provider_resource_ids.dart';
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
@@ -307,46 +308,49 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen>
),
)
.maybeWhen(data: (keys) => keys, orElse: () => const <String>{});
return SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final track = _tracks[index];
final isInHistory = existingHistoryKeys.contains(
historyLookups[index].lookupKey,
);
return KeyedSubtree(
key: ValueKey(track.id),
child: StaggeredListItem(
index: index,
child: TrackListTile(
track: track,
isInHistory: isInHistory,
onDownload: () =>
_downloadTrack(context, track, playlistPosition: index + 1),
leading: track.coverUrl != null
? CachedCoverImage(
imageUrl: track.coverUrl!,
width: 48,
height: 48,
borderRadius: BorderRadius.circular(8),
)
: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
return SliverPadding(
padding: EdgeInsets.symmetric(horizontal: wideListInset(context)),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final track = _tracks[index];
final isInHistory = existingHistoryKeys.contains(
historyLookups[index].lookupKey,
);
return KeyedSubtree(
key: ValueKey(track.id),
child: StaggeredListItem(
index: index,
child: TrackListTile(
track: track,
isInHistory: isInHistory,
onDownload: () =>
_downloadTrack(context, track, playlistPosition: index + 1),
leading: track.coverUrl != null
? CachedCoverImage(
imageUrl: track.coverUrl!,
width: 48,
height: 48,
borderRadius: BorderRadius.circular(8),
)
: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.music_note,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
child: Icon(
Icons.music_note,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
),
),
);
}, childCount: _tracks.length),
);
}, childCount: _tracks.length),
),
);
}
+9
View File
@@ -0,0 +1,9 @@
import 'package:flutter/widgets.dart';
/// Horizontal inset that centers full-width list content at [contentMaxWidth]
/// on tablets/landscape; zero on phones, so rows stop stretching across the
/// whole screen.
double wideListInset(BuildContext context, {double contentMaxWidth = 720}) {
final width = MediaQuery.sizeOf(context).width;
return width > contentMaxWidth ? (width - contentMaxWidth) / 2 : 0;
}