mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 21:02:09 +02:00
feat(mornye): cache motion covers and refine player and search
Save album motion videos in app storage during downloads and prefer offline playback with an aspect-aware layout. Add cache management for saved videos. Center active lyrics on opening, expand lyrics during manual scrolling, and adjust landscape header spacing. Group search rows and skeletons with dividers and remove redundant result icons. Validation: Flutter analysis clean; 723 tests passed, 4 skipped; iOS simulator build installed.
This commit is contained in:
@@ -869,7 +869,8 @@ class HomeSearchSkeleton extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
const SkeletonBox(width: 20, height: 20, borderRadius: 10),
|
||||
if (!mornye)
|
||||
const SkeletonBox(width: 20, height: 20, borderRadius: 10),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -893,24 +894,19 @@ class HomeSearchSkeleton extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (mornye)
|
||||
for (var index = 0; index < rows.length; index++)
|
||||
Container(
|
||||
margin: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
0,
|
||||
16,
|
||||
index == rows.length - 1 ? 0 : 8,
|
||||
),
|
||||
decoration: decoration,
|
||||
child: rows[index],
|
||||
)
|
||||
else
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: decoration,
|
||||
child: Column(children: rows),
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: decoration,
|
||||
child: Column(
|
||||
children: [
|
||||
for (var index = 0; index < rows.length; index++) ...[
|
||||
if (mornye && index > 0)
|
||||
const Divider(height: 1, indent: 80, endIndent: 12),
|
||||
rows[index],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,6 +123,8 @@ class MornyeGlassPanel extends ConsumerWidget {
|
||||
super.key,
|
||||
required this.child,
|
||||
this.radius = 32,
|
||||
this.firstInGroup = true,
|
||||
this.lastInGroup = true,
|
||||
this.strongTint = false,
|
||||
this.tintOpacity,
|
||||
});
|
||||
@@ -132,17 +134,23 @@ class MornyeGlassPanel extends ConsumerWidget {
|
||||
super.key,
|
||||
required this.child,
|
||||
this.radius = 32,
|
||||
this.firstInGroup = true,
|
||||
this.lastInGroup = true,
|
||||
this.tintOpacity = 0.78,
|
||||
}) : strongTint = false;
|
||||
|
||||
final Widget child;
|
||||
final double radius;
|
||||
final bool firstInGroup;
|
||||
final bool lastInGroup;
|
||||
final bool strongTint;
|
||||
final double? tintOpacity;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) => MornyeGlass.navigation(
|
||||
radius: radius,
|
||||
firstInGroup: firstInGroup,
|
||||
lastInGroup: lastInGroup,
|
||||
strongTint: strongTint,
|
||||
tintOpacity: tintOpacity,
|
||||
blurEnabled:
|
||||
|
||||
@@ -83,7 +83,7 @@ class _MornyeLandscapePlayerState extends State<MornyeLandscapePlayer> {
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: _reveal,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: widget.artwork),
|
||||
@@ -91,7 +91,7 @@ class _MornyeLandscapePlayerState extends State<MornyeLandscapePlayer> {
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
|
||||
child: widget.header,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
@@ -8,20 +8,30 @@ class MornyePlayerArtwork extends StatelessWidget {
|
||||
super.key,
|
||||
required this.mediaItem,
|
||||
this.videoUrl,
|
||||
this.onAspectRatioChanged,
|
||||
this.onError,
|
||||
});
|
||||
|
||||
final MediaItem mediaItem;
|
||||
final String? videoUrl;
|
||||
final ValueChanged<double>? onAspectRatioChanged;
|
||||
final VoidCallback? onError;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fallback = PlayerArtwork(
|
||||
artUri: mediaItem.artUri?.toString(),
|
||||
colorScheme: Theme.of(context).colorScheme,
|
||||
cacheWidth:
|
||||
(MediaQuery.sizeOf(context).width *
|
||||
MediaQuery.devicePixelRatioOf(context))
|
||||
.round(),
|
||||
final fallback = Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: PlayerArtwork(
|
||||
artUri: mediaItem.artUri?.toString(),
|
||||
colorScheme: Theme.of(context).colorScheme,
|
||||
cacheWidth:
|
||||
(MediaQuery.sizeOf(context).width *
|
||||
MediaQuery.devicePixelRatioOf(context))
|
||||
.round(),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (MediaQuery.disableAnimationsOf(context)) return fallback;
|
||||
return videoUrl == null
|
||||
@@ -30,6 +40,8 @@ class MornyePlayerArtwork extends StatelessWidget {
|
||||
key: ValueKey(videoUrl),
|
||||
videoUrl: videoUrl!,
|
||||
fallback: fallback,
|
||||
onAspectRatioChanged: onAspectRatioChanged,
|
||||
onError: onError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,13 @@ class MornyePlayerBackground extends StatelessWidget {
|
||||
required this.artUri,
|
||||
this.artwork,
|
||||
this.squareArtwork = true,
|
||||
this.artworkAspectRatio,
|
||||
});
|
||||
|
||||
final Uri? artUri;
|
||||
final Widget? artwork;
|
||||
final bool squareArtwork;
|
||||
final double? artworkAspectRatio;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -56,11 +58,19 @@ class MornyePlayerBackground extends StatelessWidget {
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Positioned(
|
||||
AnimatedPositioned(
|
||||
duration: motion,
|
||||
curve: Curves.easeInOutCubic,
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
height: squareArtwork
|
||||
height: artworkAspectRatio != null && artworkAspectRatio! > 0
|
||||
? (MediaQuery.sizeOf(context).width / artworkAspectRatio!)
|
||||
.clamp(
|
||||
0.0,
|
||||
MediaQuery.sizeOf(context).height * 0.75,
|
||||
)
|
||||
: squareArtwork
|
||||
? MediaQuery.sizeOf(context).width
|
||||
: MediaQuery.sizeOf(context).height * 0.66,
|
||||
child: ShaderMask(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart' show ValueListenable;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
@@ -10,6 +12,8 @@ class MotionHeaderBanner extends StatefulWidget {
|
||||
final Widget fallback;
|
||||
final BoxFit fit;
|
||||
final Alignment alignment;
|
||||
final ValueChanged<double>? onAspectRatioChanged;
|
||||
final VoidCallback? onError;
|
||||
|
||||
const MotionHeaderBanner({
|
||||
super.key,
|
||||
@@ -17,6 +21,8 @@ class MotionHeaderBanner extends StatefulWidget {
|
||||
required this.fallback,
|
||||
this.fit = BoxFit.cover,
|
||||
this.alignment = Alignment.topCenter,
|
||||
this.onAspectRatioChanged,
|
||||
this.onError,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -95,28 +101,35 @@ class _MotionHeaderBannerState extends State<MotionHeaderBanner>
|
||||
return;
|
||||
}
|
||||
|
||||
final controller = VideoPlayerController.networkUrl(
|
||||
Uri.parse(url),
|
||||
formatHint: Uri.parse(url).path.toLowerCase().endsWith('.m3u8')
|
||||
? VideoFormat.hls
|
||||
: null,
|
||||
videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true),
|
||||
);
|
||||
final uri = Uri.tryParse(url);
|
||||
final options = VideoPlayerOptions(mixWithOthers: true);
|
||||
final controller = uri?.scheme == 'file' || url.startsWith('/')
|
||||
? VideoPlayerController.file(
|
||||
uri?.scheme == 'file' ? File.fromUri(uri!) : File(url),
|
||||
videoPlayerOptions: options,
|
||||
)
|
||||
: VideoPlayerController.networkUrl(
|
||||
Uri.parse(url),
|
||||
formatHint: uri?.path.toLowerCase().endsWith('.m3u8') == true
|
||||
? VideoFormat.hls
|
||||
: null,
|
||||
videoPlayerOptions: options,
|
||||
);
|
||||
_controller = controller;
|
||||
try {
|
||||
await controller.initialize();
|
||||
if (!mounted) {
|
||||
await controller.dispose();
|
||||
return;
|
||||
}
|
||||
if (!mounted || !identical(controller, _controller)) return;
|
||||
await controller.setVolume(0);
|
||||
await controller.setLooping(true);
|
||||
if (!mounted || !identical(controller, _controller)) return;
|
||||
setState(() => _ready = true);
|
||||
widget.onAspectRatioChanged?.call(controller.value.aspectRatio);
|
||||
_syncPlayback();
|
||||
} catch (e) {
|
||||
_log.w('Failed to play motion banner: $e');
|
||||
if (!mounted) return;
|
||||
if (!mounted || !identical(controller, _controller)) return;
|
||||
setState(() => _failed = true);
|
||||
widget.onError?.call();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user