mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-02 11:05:38 +02:00
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:
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => '破棄';
|
||||
|
||||
|
||||
@@ -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 => '취소';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => 'Отменить';
|
||||
|
||||
|
||||
@@ -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ç';
|
||||
|
||||
|
||||
@@ -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 => 'Відхилити';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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,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(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user