diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 351e449c..e8faad8b 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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: diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index bc211990..e4c8d6e0 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -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'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index d44e244e..7e633828 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -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'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index e910796f..1c00659c 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 58970ae0..96886123 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -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'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index b1152bba..1538dca5 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -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'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index aeaa068c..d5ebc42e 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -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'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 1a9c13ff..930600fd 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -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'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index a65e96fd..45e247ba 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -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 => '破棄'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 5fb48c1c..ba841dc1 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -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 => '취소'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index f4371f31..9d607dc3 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -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'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index e3329f2f..9ea7ef24 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -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'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 4fc97040..7a0d7f76 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -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 => 'Отменить'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 8e700d96..c870fc1c 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -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ç'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index b0fc16a8..19856819 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -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 => 'Відхилити'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 8c0e70e5..705fa178 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -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'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index ca8e2f01..e3c2bdf9 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -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" diff --git a/lib/models/track.dart b/lib/models/track.dart index 2e8b8e53..ba08120c 100644 --- a/lib/models/track.dart +++ b/lib/models/track.dart @@ -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() diff --git a/lib/models/track.g.dart b/lib/models/track.g.dart index 901175bb..e71bce48 100644 --- a/lib/models/track.g.dart +++ b/lib/models/track.g.dart @@ -16,6 +16,7 @@ Track _$TrackFromJson(Map 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 _$TrackToJson(Track instance) => { 'albumId': instance.albumId, 'coverUrl': instance.coverUrl, 'isrc': instance.isrc, + 'previewUrl': instance.previewUrl, 'duration': instance.duration, 'trackNumber': instance.trackNumber, 'discNumber': instance.discNumber, diff --git a/lib/providers/preview_player_provider.dart b/lib/providers/preview_player_provider.dart new file mode 100644 index 00000000..9106e6c0 --- /dev/null +++ b/lib/providers/preview_player_provider.dart @@ -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 { + AudioPlayer? _player; + final List> _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 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 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 _playOnPlayer(AudioPlayer player, String url) async { + await player.stop(); + await player.play(UrlSource(url)); + } + + Future 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 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 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.new, + ); diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 2093b3e0..29cb72fa 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -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 final GlobalKey _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 @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 } void _resetHomeToMain() { + ref.read(previewPlayerProvider.notifier).stop(); final showStore = ref.read( settingsProvider.select((s) => s.showExtensionStore), ); @@ -312,6 +327,7 @@ class _MainShellState extends ConsumerState 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 '(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 // 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 _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 class _TabNavigator extends StatelessWidget { final GlobalKey navigatorKey; final Widget child; + final List 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(builder: (_) => child), ], @@ -627,6 +651,26 @@ class _TabNavigator extends StatelessWidget { } } +class _PreviewStopNavigatorObserver extends NavigatorObserver { + _PreviewStopNavigatorObserver(this._onNavigate); + + final VoidCallback _onNavigate; + + @override + void didPush(Route route, Route? previousRoute) { + super.didPush(route, previousRoute); + if (previousRoute != null) { + _onNavigate(); + } + } + + @override + void didPop(Route route, Route? previousRoute) { + super.didPop(route, previousRoute); + _onNavigate(); + } +} + class _LibraryTabRoot extends ConsumerWidget { final PageController parentPageController; diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 17730b9d..bd36b1b5 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -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 { @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, diff --git a/lib/services/local_track_redownload_service.dart b/lib/services/local_track_redownload_service.dart index c75a26f6..d6f88d67 100644 --- a/lib/services/local_track_redownload_service.dart +++ b/lib/services/local_track_redownload_service.dart @@ -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(), ); } diff --git a/lib/widgets/preview_button.dart b/lib/widgets/preview_button.dart new file mode 100644 index 00000000..926b319d --- /dev/null +++ b/lib/widgets/preview_button.dart @@ -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 _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), + ), + ); + } +}