mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-28 23:08:59 +02:00
perf(player): isolate high-frequency playback updates
This commit is contained in:
@@ -12,6 +12,22 @@ final playbackStateProvider = StreamProvider<PlaybackState>((ref) {
|
||||
return musicPlayerPlaybackStateEvents();
|
||||
});
|
||||
|
||||
/// Small derived providers keep position ticks from rebuilding widgets that
|
||||
/// only care about transport state (and vice versa).
|
||||
final playbackPositionProvider = Provider<Duration>((ref) {
|
||||
return ref.watch(
|
||||
playbackStateProvider.select(
|
||||
(state) => state.value?.position ?? Duration.zero,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
final playbackPlayingProvider = Provider<bool>((ref) {
|
||||
return ref.watch(
|
||||
playbackStateProvider.select((state) => state.value?.playing ?? false),
|
||||
);
|
||||
});
|
||||
|
||||
final playQueueProvider = StreamProvider<List<MediaItem>>((ref) {
|
||||
return musicPlayerQueueEvents();
|
||||
});
|
||||
|
||||
+207
-135
@@ -172,6 +172,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
Map<String, dynamic>? _metadata;
|
||||
ParsedLyrics _lyrics = ParsedLyrics.empty;
|
||||
bool _loadingMeta = false;
|
||||
int _currentPage = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -377,9 +378,14 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
Expanded(
|
||||
child: PageView(
|
||||
controller: _pageController,
|
||||
onPageChanged: (page) {
|
||||
if (_currentPage != page) {
|
||||
setState(() => _currentPage = page);
|
||||
}
|
||||
},
|
||||
children: [
|
||||
_playerPage(mediaItem, controller, colorScheme),
|
||||
_lyricsSection(colorScheme),
|
||||
_lyricsSection(colorScheme, isActive: _currentPage == 1),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -419,17 +425,6 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
MusicPlayerController controller,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
final playback = ref.watch(playbackStateProvider).value;
|
||||
final isPlaying = playback?.playing ?? false;
|
||||
final position = playback?.position ?? Duration.zero;
|
||||
final duration = mediaItem.duration ?? Duration.zero;
|
||||
final maxMs = duration.inMilliseconds > 0
|
||||
? duration.inMilliseconds.toDouble()
|
||||
: 1.0;
|
||||
final posMs = position.inMilliseconds
|
||||
.clamp(0, duration.inMilliseconds > 0 ? duration.inMilliseconds : 0)
|
||||
.toDouble();
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
Widget artworkAt(double artSize) => Center(
|
||||
@@ -478,11 +473,6 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
mediaItem,
|
||||
controller,
|
||||
colorScheme,
|
||||
isPlaying: isPlaying,
|
||||
position: position,
|
||||
duration: duration,
|
||||
posMs: posMs,
|
||||
maxMs: maxMs,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -502,16 +492,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
children: [
|
||||
artworkAt(artSize),
|
||||
const SizedBox(height: 32),
|
||||
..._metadataAndControls(
|
||||
mediaItem,
|
||||
controller,
|
||||
colorScheme,
|
||||
isPlaying: isPlaying,
|
||||
position: position,
|
||||
duration: duration,
|
||||
posMs: posMs,
|
||||
maxMs: maxMs,
|
||||
),
|
||||
..._metadataAndControls(mediaItem, controller, colorScheme),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -525,13 +506,8 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
List<Widget> _metadataAndControls(
|
||||
MediaItem mediaItem,
|
||||
MusicPlayerController controller,
|
||||
ColorScheme colorScheme, {
|
||||
required bool isPlaying,
|
||||
required Duration position,
|
||||
required Duration duration,
|
||||
required double posMs,
|
||||
required double maxMs,
|
||||
}) {
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28),
|
||||
@@ -561,95 +537,16 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
_PlaybackControls(
|
||||
duration: mediaItem.duration ?? Duration.zero,
|
||||
controller: controller,
|
||||
colorScheme: colorScheme,
|
||||
qualityLabel: _qualityLabel(),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _lyricsSection(ColorScheme colorScheme) {
|
||||
Widget _lyricsSection(ColorScheme colorScheme, {required bool isActive}) {
|
||||
if (_loadingMeta) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
@@ -675,7 +572,11 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
);
|
||||
}
|
||||
if (_lyrics.synced) {
|
||||
return _SyncedLyricsView(lyrics: _lyrics, colorScheme: colorScheme);
|
||||
return _SyncedLyricsView(
|
||||
lyrics: _lyrics,
|
||||
colorScheme: colorScheme,
|
||||
isActive: isActive,
|
||||
);
|
||||
}
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
|
||||
@@ -928,11 +829,137 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
class _PlaybackControls extends ConsumerWidget {
|
||||
final Duration duration;
|
||||
final MusicPlayerController controller;
|
||||
final ColorScheme colorScheme;
|
||||
final String? qualityLabel;
|
||||
|
||||
const _PlaybackControls({
|
||||
required this.duration,
|
||||
required this.controller,
|
||||
required this.colorScheme,
|
||||
required this.qualityLabel,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final position = ref.watch(playbackPositionProvider);
|
||||
final isPlaying = ref.watch(playbackPlayingProvider);
|
||||
final maxMs = duration.inMilliseconds > 0
|
||||
? duration.inMilliseconds.toDouble()
|
||||
: 1.0;
|
||||
final posMs = position.inMilliseconds
|
||||
.clamp(0, duration.inMilliseconds > 0 ? duration.inMilliseconds : 0)
|
||||
.toDouble();
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SyncedLyricsView extends ConsumerStatefulWidget {
|
||||
final ParsedLyrics lyrics;
|
||||
final ColorScheme colorScheme;
|
||||
final bool isActive;
|
||||
|
||||
const _SyncedLyricsView({required this.lyrics, required this.colorScheme});
|
||||
const _SyncedLyricsView({
|
||||
required this.lyrics,
|
||||
required this.colorScheme,
|
||||
required this.isActive,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<_SyncedLyricsView> createState() => _SyncedLyricsViewState();
|
||||
@@ -940,12 +967,51 @@ class _SyncedLyricsView extends ConsumerStatefulWidget {
|
||||
|
||||
class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
|
||||
final ScrollController _scroll = ScrollController();
|
||||
ProviderSubscription<Duration>? _positionSubscription;
|
||||
int _active = -1;
|
||||
bool _userScrolling = false;
|
||||
static const double _estimatedLyricExtent = 64;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_syncPositionSubscription();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _SyncedLyricsView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.isActive != widget.isActive ||
|
||||
oldWidget.lyrics != widget.lyrics) {
|
||||
_syncPositionSubscription();
|
||||
}
|
||||
}
|
||||
|
||||
void _syncPositionSubscription() {
|
||||
_positionSubscription?.close();
|
||||
_positionSubscription = null;
|
||||
if (!widget.isActive) return;
|
||||
|
||||
_active = LyricsParser.activeIndex(
|
||||
widget.lyrics.lines,
|
||||
ref.read(playbackPositionProvider),
|
||||
);
|
||||
_positionSubscription = ref.listenManual<Duration>(
|
||||
playbackPositionProvider,
|
||||
(previous, next) {
|
||||
final active = LyricsParser.activeIndex(widget.lyrics.lines, next);
|
||||
if (active == _active || !mounted) return;
|
||||
setState(() => _active = active);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _maybeAutoScroll(active);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_positionSubscription?.close();
|
||||
_scroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -970,17 +1036,8 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final position =
|
||||
ref.watch(playbackStateProvider).value?.position ?? Duration.zero;
|
||||
final lines = widget.lyrics.lines;
|
||||
final active = LyricsParser.activeIndex(lines, position);
|
||||
|
||||
if (active != _active) {
|
||||
_active = active;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _maybeAutoScroll(active);
|
||||
});
|
||||
}
|
||||
final active = _active;
|
||||
|
||||
return NotificationListener<UserScrollNotification>(
|
||||
onNotification: (notification) {
|
||||
@@ -1013,7 +1070,10 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
|
||||
|
||||
Widget content;
|
||||
if (isActive && line.hasWordTiming) {
|
||||
content = _wordHighlightedLine(line, position);
|
||||
content = _WordHighlightedLyricLine(
|
||||
line: line,
|
||||
colorScheme: widget.colorScheme,
|
||||
);
|
||||
} else {
|
||||
content = Text(
|
||||
text,
|
||||
@@ -1054,8 +1114,20 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _wordHighlightedLine(LyricLine line, Duration position) {
|
||||
class _WordHighlightedLyricLine extends ConsumerWidget {
|
||||
final LyricLine line;
|
||||
final ColorScheme colorScheme;
|
||||
|
||||
const _WordHighlightedLyricLine({
|
||||
required this.line,
|
||||
required this.colorScheme,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final position = ref.watch(playbackPositionProvider);
|
||||
final spans = <TextSpan>[];
|
||||
for (final word in line.words) {
|
||||
final sung = position >= word.time;
|
||||
@@ -1064,8 +1136,8 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
|
||||
text: word.text,
|
||||
style: TextStyle(
|
||||
color: sung
|
||||
? widget.colorScheme.onSurface
|
||||
: widget.colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
|
||||
? colorScheme.onSurface
|
||||
: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -23,8 +23,7 @@ class _MiniPlayerState extends ConsumerState<MiniPlayer> {
|
||||
final mediaItem = ref.watch(currentMediaItemProvider).value;
|
||||
if (mediaItem == null) return const SizedBox.shrink();
|
||||
|
||||
final playback = ref.watch(playbackStateProvider).value;
|
||||
final isPlaying = playback?.playing ?? false;
|
||||
final isPlaying = ref.watch(playbackPlayingProvider);
|
||||
if (mediaItem.id == _dismissedItemId && !isPlaying) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
@@ -32,10 +31,6 @@ class _MiniPlayerState extends ConsumerState<MiniPlayer> {
|
||||
final controller = ref.read(musicPlayerControllerProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final duration = mediaItem.duration?.inMilliseconds ?? 0;
|
||||
final position = playback?.position.inMilliseconds ?? 0;
|
||||
final progress = duration > 0 ? (position / duration).clamp(0.0, 1.0) : 0.0;
|
||||
|
||||
return Dismissible(
|
||||
key: ValueKey('mini-player-${mediaItem.id}'),
|
||||
direction: DismissDirection.horizontal,
|
||||
@@ -64,9 +59,8 @@ class _MiniPlayerState extends ConsumerState<MiniPlayer> {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
LinearProgressIndicator(
|
||||
value: progress,
|
||||
minHeight: 2,
|
||||
_MiniPlayerProgress(
|
||||
duration: mediaItem.duration ?? Duration.zero,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
Padding(
|
||||
@@ -136,3 +130,27 @@ class _MiniPlayerState extends ConsumerState<MiniPlayer> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MiniPlayerProgress extends ConsumerWidget {
|
||||
final Duration duration;
|
||||
final Color backgroundColor;
|
||||
|
||||
const _MiniPlayerProgress({
|
||||
required this.duration,
|
||||
required this.backgroundColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final durationMs = duration.inMilliseconds;
|
||||
final positionMs = ref.watch(playbackPositionProvider).inMilliseconds;
|
||||
final progress = durationMs > 0
|
||||
? (positionMs / durationMs).clamp(0.0, 1.0)
|
||||
: 0.0;
|
||||
return LinearProgressIndicator(
|
||||
value: progress,
|
||||
minHeight: 2,
|
||||
backgroundColor: backgroundColor,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,9 +17,9 @@ class PreviewButton extends ConsumerWidget {
|
||||
await ref.read(previewPlayerProvider.notifier).toggle(track.previewUrl);
|
||||
} catch (_) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.previewUnavailable)),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(context.l10n.previewUnavailable)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,8 +41,7 @@ class PreviewButton extends ConsumerWidget {
|
||||
// it (consistent with the mini player) rather than the preview snippet.
|
||||
final mainItem = ref.watch(currentMediaItemProvider).value;
|
||||
if (_isCurrentMainTrack(mainItem)) {
|
||||
final isPlaying =
|
||||
ref.watch(playbackStateProvider).value?.playing ?? false;
|
||||
final isPlaying = ref.watch(playbackPlayingProvider);
|
||||
return Transform.translate(
|
||||
offset: const Offset(18, 0),
|
||||
child: IconButton(
|
||||
@@ -57,9 +56,12 @@ class PreviewButton extends ConsumerWidget {
|
||||
: Icons.play_circle_fill_rounded,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
tooltip: isPlaying ? context.l10n.previewStop : context.l10n.previewPlay,
|
||||
onPressed: () =>
|
||||
ref.read(musicPlayerControllerProvider).togglePlayPause(isPlaying),
|
||||
tooltip: isPlaying
|
||||
? context.l10n.previewStop
|
||||
: context.l10n.previewPlay,
|
||||
onPressed: () => ref
|
||||
.read(musicPlayerControllerProvider)
|
||||
.togglePlayPause(isPlaying),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -92,10 +94,7 @@ class PreviewButton extends ConsumerWidget {
|
||||
tooltip = context.l10n.previewStop;
|
||||
break;
|
||||
case PreviewStatus.paused:
|
||||
icon = Icon(
|
||||
Icons.play_circle_fill_rounded,
|
||||
color: colorScheme.primary,
|
||||
);
|
||||
icon = Icon(Icons.play_circle_fill_rounded, color: colorScheme.primary);
|
||||
tooltip = context.l10n.previewPlay;
|
||||
break;
|
||||
case PreviewStatus.idle:
|
||||
|
||||
Reference in New Issue
Block a user