feat(preview): play short track preview snippets in lists

Add a preview player provider and PreviewButton, a previewUrl field on Track (parsed from search and local redownload), preview buttons in search results, and stop the preview on navigation/tab change via per-tab navigator observers. Includes preview play/stop/unavailable localization strings.
This commit is contained in:
zarzet
2026-06-28 06:06:12 +07:00
parent ce813bc216
commit 2ab0350733
24 changed files with 541 additions and 0 deletions
+18
View File
@@ -1202,6 +1202,24 @@ abstract class AppLocalizations {
/// **'Download'**
String get dialogDownload;
/// Tooltip for the button that plays a short track preview snippet
///
/// In en, this message translates to:
/// **'Play preview'**
String get previewPlay;
/// Tooltip for the button that stops the playing track preview snippet
///
/// In en, this message translates to:
/// **'Stop preview'**
String get previewStop;
/// Snackbar shown when a track preview snippet cannot be played
///
/// In en, this message translates to:
/// **'Preview unavailable'**
String get previewUnavailable;
/// Dialog button - discard changes
///
/// In en, this message translates to:
+9
View File
@@ -603,6 +603,15 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get dialogDownload => 'Download';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Discard';
+9
View File
@@ -612,6 +612,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get dialogDownload => 'Herunterladen';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Verwerfen';
+9
View File
@@ -603,6 +603,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get dialogDownload => 'Download';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Discard';
+9
View File
@@ -603,6 +603,15 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get dialogDownload => 'Download';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Discard';
+9
View File
@@ -620,6 +620,15 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get dialogDownload => 'Télécharger';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Ignorer';
+9
View File
@@ -603,6 +603,15 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get dialogDownload => 'Download';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Discard';
+9
View File
@@ -604,6 +604,15 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get dialogDownload => 'Download';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Buang';
+9
View File
@@ -600,6 +600,15 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get dialogDownload => 'Download';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => '破棄';
+9
View File
@@ -593,6 +593,15 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get dialogDownload => 'Download';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => '취소';
+9
View File
@@ -603,6 +603,15 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get dialogDownload => 'Download';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Discard';
+9
View File
@@ -603,6 +603,15 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get dialogDownload => 'Download';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Discard';
+9
View File
@@ -609,6 +609,15 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get dialogDownload => 'Скачать';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Отменить';
+9
View File
@@ -610,6 +610,15 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get dialogDownload => 'İndir';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Vazgeç';
+9
View File
@@ -612,6 +612,15 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get dialogDownload => 'Завантажити';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Відхилити';
+9
View File
@@ -603,6 +603,15 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get dialogDownload => 'Download';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Discard';
+12
View File
@@ -772,6 +772,18 @@
"@dialogDownload": {
"description": "Confirm button in Download All dialog"
},
"previewPlay": "Play preview",
"@previewPlay": {
"description": "Tooltip for the button that plays a short track preview snippet"
},
"previewStop": "Stop preview",
"@previewStop": {
"description": "Tooltip for the button that stops the playing track preview snippet"
},
"previewUnavailable": "Preview unavailable",
"@previewUnavailable": {
"description": "Snackbar shown when a track preview snippet cannot be played"
},
"dialogDiscard": "Discard",
"@dialogDiscard": {
"description": "Dialog button - discard changes"
+4
View File
@@ -13,6 +13,7 @@ class Track {
final String? albumId;
final String? coverUrl;
final String? isrc;
final String? previewUrl;
final int duration;
final int? trackNumber;
final int? discNumber;
@@ -38,6 +39,7 @@ class Track {
this.albumId,
this.coverUrl,
this.isrc,
this.previewUrl,
required this.duration,
this.trackNumber,
this.discNumber,
@@ -81,6 +83,8 @@ class Track {
audioModes != null && audioModes!.contains('DOLBY_ATMOS');
bool get hasAudioQuality => audioQuality != null && audioQuality!.isNotEmpty;
bool get hasPreview => previewUrl != null && previewUrl!.isNotEmpty;
}
@JsonSerializable()
+2
View File
@@ -16,6 +16,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
albumId: json['albumId'] as String?,
coverUrl: json['coverUrl'] as String?,
isrc: json['isrc'] as String?,
previewUrl: json['previewUrl'] as String?,
duration: (json['duration'] as num).toInt(),
trackNumber: (json['trackNumber'] as num?)?.toInt(),
discNumber: (json['discNumber'] as num?)?.toInt(),
@@ -46,6 +47,7 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
'albumId': instance.albumId,
'coverUrl': instance.coverUrl,
'isrc': instance.isrc,
'previewUrl': instance.previewUrl,
'duration': instance.duration,
'trackNumber': instance.trackNumber,
'discNumber': instance.discNumber,
+237
View File
@@ -0,0 +1,237 @@
import 'dart:async';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('PreviewPlayer');
enum PreviewStatus { idle, loading, playing, paused }
class PreviewPlayerState {
final String? activeUrl;
final PreviewStatus status;
final Duration position;
final Duration duration;
const PreviewPlayerState({
this.activeUrl,
this.status = PreviewStatus.idle,
this.position = Duration.zero,
this.duration = Duration.zero,
});
bool get isActive => activeUrl != null && activeUrl!.isNotEmpty;
bool isActiveUrl(String? url) =>
url != null && url.isNotEmpty && url == activeUrl;
double get progress {
final total = duration.inMilliseconds;
if (total <= 0) return 0;
return (position.inMilliseconds / total).clamp(0.0, 1.0);
}
PreviewPlayerState copyWith({
String? activeUrl,
bool clearActiveUrl = false,
PreviewStatus? status,
Duration? position,
Duration? duration,
}) {
return PreviewPlayerState(
activeUrl: clearActiveUrl ? null : (activeUrl ?? this.activeUrl),
status: status ?? this.status,
position: position ?? this.position,
duration: duration ?? this.duration,
);
}
}
class PreviewPlayerController extends Notifier<PreviewPlayerState> {
AudioPlayer? _player;
final List<StreamSubscription<dynamic>> _subscriptions = [];
AppLifecycleListener? _lifecycleListener;
@override
PreviewPlayerState build() {
_lifecycleListener = AppLifecycleListener(
onStateChange: _handleAppLifecycleState,
);
ref.onDispose(_disposePlayer);
return const PreviewPlayerState();
}
void _handleAppLifecycleState(AppLifecycleState lifecycleState) {
if (lifecycleState == AppLifecycleState.paused ||
lifecycleState == AppLifecycleState.hidden ||
lifecycleState == AppLifecycleState.detached) {
if (state.isActive) {
unawaited(stop());
}
}
}
AudioPlayer _ensurePlayer() {
final existing = _player;
if (existing != null) return existing;
final player = AudioPlayer(playerId: 'preview-player');
player.setReleaseMode(ReleaseMode.stop);
_attachListeners(player);
_player = player;
return player;
}
void _attachListeners(AudioPlayer player) {
_subscriptions.add(
player.onPlayerStateChanged.listen(_handlePlayerStateChanged),
);
_subscriptions.add(
player.onPositionChanged.listen((position) {
if (state.status == PreviewStatus.playing ||
state.status == PreviewStatus.paused) {
state = state.copyWith(position: position);
}
}),
);
_subscriptions.add(
player.onDurationChanged.listen((duration) {
state = state.copyWith(duration: duration);
}),
);
_subscriptions.add(
player.onPlayerComplete.listen((_) {
_log.d('Preview playback completed');
state = const PreviewPlayerState();
}),
);
}
void _discardActivePlayer() {
for (final sub in _subscriptions) {
sub.cancel();
}
_subscriptions.clear();
final player = _player;
_player = null;
if (player != null) {
try {
player.dispose();
} catch (_) {}
}
}
void _handlePlayerStateChanged(PlayerState playerState) {
switch (playerState) {
case PlayerState.playing:
state = state.copyWith(status: PreviewStatus.playing);
break;
case PlayerState.paused:
if (state.isActive) {
state = state.copyWith(status: PreviewStatus.paused);
}
break;
case PlayerState.stopped:
case PlayerState.completed:
break;
case PlayerState.disposed:
break;
}
}
Future<void> toggle(String? url) async {
final trimmed = url?.trim() ?? '';
if (trimmed.isEmpty) return;
if (state.isActiveUrl(trimmed)) {
if (state.status == PreviewStatus.playing) {
await pause();
} else if (state.status == PreviewStatus.paused) {
await resume();
}
return;
}
await play(trimmed);
}
Future<void> play(String url) async {
final trimmed = url.trim();
if (trimmed.isEmpty) return;
state = PreviewPlayerState(
activeUrl: trimmed,
status: PreviewStatus.loading,
);
try {
_log.i('Starting preview playback');
await _playOnPlayer(_ensurePlayer(), trimmed);
} catch (e) {
_log.w('Preview playback failed, recreating player and retrying: $e');
_discardActivePlayer();
try {
await _playOnPlayer(_ensurePlayer(), trimmed);
} catch (retryError) {
_log.e('Preview playback failed after retry', retryError);
_discardActivePlayer();
state = const PreviewPlayerState();
rethrow;
}
}
}
Future<void> _playOnPlayer(AudioPlayer player, String url) async {
await player.stop();
await player.play(UrlSource(url));
}
Future<void> pause() async {
final player = _player;
if (player == null) return;
try {
await player.pause();
state = state.copyWith(status: PreviewStatus.paused);
} catch (e) {
_log.w('Failed to pause preview: $e');
}
}
Future<void> resume() async {
final player = _player;
if (player == null || !state.isActive) return;
try {
await player.resume();
state = state.copyWith(status: PreviewStatus.playing);
} catch (e) {
_log.w('Failed to resume preview: $e');
}
}
Future<void> stop() async {
final player = _player;
if (player == null) {
state = const PreviewPlayerState();
return;
}
try {
await player.stop();
} catch (e) {
_log.w('Failed to stop preview: $e');
}
state = const PreviewPlayerState();
}
void _disposePlayer() {
_lifecycleListener?.dispose();
_lifecycleListener = null;
_discardActivePlayer();
}
}
final previewPlayerProvider =
NotifierProvider<PreviewPlayerController, PreviewPlayerState>(
PreviewPlayerController.new,
);
+44
View File
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/store_provider.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/providers/preview_player_provider.dart';
import 'package:spotiflac_android/screens/home_tab.dart';
import 'package:spotiflac_android/screens/repo_tab.dart';
import 'package:spotiflac_android/screens/queue_tab.dart';
@@ -52,6 +53,10 @@ class _MainShellState extends ConsumerState<MainShell>
final GlobalKey<NavigatorState> _repoTabNavigatorKey =
ShellNavigationService.repoTabNavigatorKey;
late final _PreviewStopNavigatorObserver _homePreviewStopObserver;
late final _PreviewStopNavigatorObserver _libraryPreviewStopObserver;
late final _PreviewStopNavigatorObserver _repoPreviewStopObserver;
@override
void didChangeDependencies() {
super.didChangeDependencies();
@@ -61,6 +66,15 @@ class _MainShellState extends ConsumerState<MainShell>
@override
void initState() {
super.initState();
_homePreviewStopObserver = _PreviewStopNavigatorObserver(
() => ref.read(previewPlayerProvider.notifier).stop(),
);
_libraryPreviewStopObserver = _PreviewStopNavigatorObserver(
() => ref.read(previewPlayerProvider.notifier).stop(),
);
_repoPreviewStopObserver = _PreviewStopNavigatorObserver(
() => ref.read(previewPlayerProvider.notifier).stop(),
);
_pageController = PageController(initialPage: _currentIndex);
_tabJumpTransitionController = AnimationController(
vsync: this,
@@ -264,6 +278,7 @@ class _MainShellState extends ConsumerState<MainShell>
}
void _resetHomeToMain() {
ref.read(previewPlayerProvider.notifier).stop();
final showStore = ref.read(
settingsProvider.select((s) => s.showExtensionStore),
);
@@ -312,6 +327,7 @@ class _MainShellState extends ConsumerState<MainShell>
void _onPageChanged(int index) {
if (_currentIndex != index) {
ref.read(previewPlayerProvider.notifier).stop();
setState(() => _currentIndex = index);
final showStore = ref.read(
settingsProvider.select((s) => s.showExtensionStore),
@@ -369,6 +385,7 @@ class _MainShellState extends ConsumerState<MainShell>
'(hasSearchText=${trackState.hasSearchText}, hasContent=${trackState.hasContent})',
);
FocusManager.instance.primaryFocus?.unfocus();
ref.read(previewPlayerProvider.notifier).stop();
ref.read(trackProvider.notifier).clear();
_lastBackPress = null;
return;
@@ -392,6 +409,7 @@ class _MainShellState extends ConsumerState<MainShell>
// Unfocus BEFORE clear so _onTrackStateChanged can properly
// clear _urlController (it checks !_searchFocusNode.hasFocus)
FocusManager.instance.primaryFocus?.unfocus();
ref.read(previewPlayerProvider.notifier).stop();
ref.read(trackProvider.notifier).clear();
_lastBackPress = null;
return;
@@ -461,17 +479,20 @@ class _MainShellState extends ConsumerState<MainShell>
_TabNavigator(
key: const ValueKey('tab-home'),
navigatorKey: _homeTabNavigatorKey,
observers: [_homePreviewStopObserver],
child: const HomeTab(),
),
_TabNavigator(
key: const ValueKey('tab-library'),
navigatorKey: _libraryTabNavigatorKey,
observers: [_libraryPreviewStopObserver],
child: _LibraryTabRoot(parentPageController: _pageController),
),
if (showStore)
_TabNavigator(
key: const ValueKey('tab-repo'),
navigatorKey: _repoTabNavigatorKey,
observers: [_repoPreviewStopObserver],
child: const RepoTab(),
),
const SettingsTab(),
@@ -609,17 +630,20 @@ class _MainShellState extends ConsumerState<MainShell>
class _TabNavigator extends StatelessWidget {
final GlobalKey<NavigatorState> navigatorKey;
final Widget child;
final List<NavigatorObserver> observers;
const _TabNavigator({
super.key,
required this.navigatorKey,
required this.child,
this.observers = const [],
});
@override
Widget build(BuildContext context) {
return Navigator(
key: navigatorKey,
observers: observers,
onGenerateInitialRoutes: (_, _) => [
MaterialPageRoute<void>(builder: (_) => child),
],
@@ -627,6 +651,26 @@ class _TabNavigator extends StatelessWidget {
}
}
class _PreviewStopNavigatorObserver extends NavigatorObserver {
_PreviewStopNavigatorObserver(this._onNavigate);
final VoidCallback _onNavigate;
@override
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
super.didPush(route, previousRoute);
if (previousRoute != null) {
_onNavigate();
}
}
@override
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
super.didPop(route, previousRoute);
_onNavigate();
}
}
class _LibraryTabRoot extends ConsumerWidget {
final PageController parentPageController;
+4
View File
@@ -4,6 +4,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/providers/preview_player_provider.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
@@ -11,6 +12,7 @@ import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
import 'package:spotiflac_android/widgets/preview_button.dart';
class SearchScreen extends ConsumerStatefulWidget {
final String query;
@@ -37,6 +39,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
@override
void dispose() {
ref.read(previewPlayerProvider.notifier).stop();
_searchController.dispose();
super.dispose();
}
@@ -239,6 +242,7 @@ class _SearchTrackTile extends ConsumerWidget {
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
PreviewButton(track: track),
IconButton(
icon: const Icon(Icons.download_rounded),
tooltip: context.l10n.dialogDownload,
@@ -157,6 +157,7 @@ class LocalTrackRedownloadService {
source: data['source']?.toString() ?? data['provider_id']?.toString(),
albumType: data['album_type']?.toString(),
itemType: itemType,
previewUrl: data['preview_url']?.toString(),
);
}
+84
View File
@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/preview_player_provider.dart';
class PreviewButton extends ConsumerWidget {
final Track track;
final double size;
const PreviewButton({super.key, required this.track, this.size = 24});
Future<void> _onPressed(BuildContext context, WidgetRef ref) async {
try {
await ref.read(previewPlayerProvider.notifier).toggle(track.previewUrl);
} catch (_) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.previewUnavailable)),
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
if (!track.hasPreview) return const SizedBox.shrink();
final colorScheme = Theme.of(context).colorScheme;
final previewState = ref.watch(previewPlayerProvider);
final isActive = previewState.isActiveUrl(track.previewUrl);
final status = isActive ? previewState.status : PreviewStatus.idle;
final Widget icon;
final String tooltip;
switch (status) {
case PreviewStatus.loading:
icon = SizedBox(
width: size * 0.7,
height: size * 0.7,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorScheme.primary,
),
);
tooltip = context.l10n.previewStop;
break;
case PreviewStatus.playing:
icon = Icon(
Icons.pause_circle_filled_rounded,
color: colorScheme.primary,
);
tooltip = context.l10n.previewStop;
break;
case PreviewStatus.paused:
icon = Icon(
Icons.play_circle_fill_rounded,
color: colorScheme.primary,
);
tooltip = context.l10n.previewPlay;
break;
case PreviewStatus.idle:
icon = Icon(
Icons.play_circle_outline_rounded,
color: colorScheme.onSurfaceVariant,
);
tooltip = context.l10n.previewPlay;
break;
}
return Transform.translate(
offset: const Offset(18, 0),
child: IconButton(
iconSize: size,
padding: EdgeInsets.zero,
alignment: Alignment.centerRight,
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints(minWidth: 24, minHeight: 36),
icon: icon,
tooltip: tooltip,
onPressed: () => _onPressed(context, ref),
),
);
}
}