Skip to content

Commit 0fc4043

Browse files
committed
feat: download animated covers and backgrounds
1 parent a1d7dba commit 0fc4043

File tree

9 files changed

+695
-231
lines changed

9 files changed

+695
-231
lines changed

lib/components/PlayerScreen/player_screen_album_image.dart

Lines changed: 141 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import 'dart:async';
2-
import 'dart:typed_data';
32

43
import 'package:finamp/components/PlayerScreen/queue_source_helper.dart';
54
import 'package:finamp/components/animated_album_cover.dart';
65
import 'package:finamp/l10n/app_localizations.dart';
76
import 'package:finamp/models/finamp_models.dart';
87
import 'package:finamp/models/jellyfin_models.dart';
98
import 'package:finamp/services/animated_music_service.dart';
9+
import 'package:finamp/services/current_track_metadata_provider.dart';
1010
import 'package:finamp/services/feedback_helper.dart';
1111
import 'package:finamp/services/finamp_settings_helper.dart';
1212
import 'package:finamp/services/music_player_background_task.dart';
@@ -29,50 +29,15 @@ class PlayerScreenAlbumImage extends ConsumerStatefulWidget {
2929
}
3030

3131
class _PlayerScreenAlbumImageState extends ConsumerState<PlayerScreenAlbumImage> {
32-
late final AnimatedMusicService _animatedMusicService;
33-
String? _animatedCoverUri;
32+
String? _currentAnimatedCoverSource;
3433
String? _lastTrackId;
35-
bool _isLoadingAnimatedCover = false;
36-
37-
@override
38-
void initState() {
39-
super.initState();
40-
// Service is already registered in main.dart - just get the instance
41-
_animatedMusicService = GetIt.instance<AnimatedMusicService>();
42-
}
43-
44-
Future<void> _loadAnimatedCover(String trackId) async {
45-
if (_isLoadingAnimatedCover || _lastTrackId == trackId) {
46-
return;
47-
}
48-
49-
_isLoadingAnimatedCover = true;
50-
_lastTrackId = trackId;
51-
52-
try {
53-
final animatedUri = await _animatedMusicService.getAnimatedCoverForTrack(BaseItemId(trackId));
54-
55-
if (mounted && _lastTrackId == trackId) {
56-
setState(() {
57-
_animatedCoverUri = animatedUri;
58-
});
59-
}
60-
} catch (e) {
61-
// Silently fail and fallback to static image
62-
if (mounted) {
63-
setState(() {
64-
_animatedCoverUri = null;
65-
});
66-
}
67-
} finally {
68-
_isLoadingAnimatedCover = false;
69-
}
70-
}
7134

7235
@override
7336
Widget build(BuildContext context) {
7437
final queueService = GetIt.instance<QueueService>();
7538
final audioService = GetIt.instance<MusicPlayerBackgroundTask>();
39+
final animatedMusicService = GetIt.instance<AnimatedMusicService>();
40+
7641
return StreamBuilder<FinampQueueInfo?>(
7742
stream: queueService.getQueueStream(),
7843
builder: (context, snapshot) {
@@ -85,86 +50,157 @@ class _PlayerScreenAlbumImageState extends ConsumerState<PlayerScreenAlbumImage>
8550
final currentTrack = queueInfo.currentTrack;
8651
final currentTrackId = currentTrack?.baseItemId.raw;
8752

88-
// Load animated cover if we have an track ID
89-
if (currentTrackId != null && currentTrackId != _lastTrackId) {
90-
WidgetsBinding.instance.addPostFrameCallback((_) {
91-
_loadAnimatedCover(currentTrackId);
92-
});
53+
// Only proceed if we have a current track
54+
if (currentTrackId == null) {
55+
if (_currentAnimatedCoverSource != null) {
56+
_currentAnimatedCoverSource = null;
57+
if (mounted) setState(() {});
58+
}
59+
return _buildStaticCover(snapshot, audioService);
9360
}
9461

95-
return Semantics(
96-
label: AppLocalizations.of(
97-
context,
98-
)!.playerAlbumArtworkTooltip(currentTrack?.item.title ?? AppLocalizations.of(context)!.unknownName),
99-
excludeSemantics: true, // replace child semantics with custom semantics
100-
container: true,
101-
child: GestureDetector(
102-
onSecondaryTapDown: (_) async {
103-
var queueItem = snapshot.data!.currentTrack;
104-
if (queueItem?.baseItem != null) {
105-
var inPlaylist = queueItemInPlaylist(queueItem);
106-
await showModalTrackMenu(
107-
context: context,
108-
item: queueItem!.baseItem!,
109-
showPlaybackControls: true,
110-
// show controls on player screen
111-
parentItem: inPlaylist ? queueItem.source.item : null,
112-
isInPlaylist: inPlaylist,
113-
);
114-
}
115-
},
116-
child: SimpleGestureDetector(
117-
//TODO replace with PageView, this is just a placeholder
118-
onTap: () {
119-
unawaited(audioService.togglePlayback());
120-
FeedbackHelper.feedback(FeedbackType.selection);
121-
},
122-
onDoubleTap: () {
123-
final currentTrack = queueService.getCurrentTrack();
124-
if (currentTrack?.baseItem != null && !FinampSettingsHelper.finampSettings.isOffline) {
125-
ref.read(isFavoriteProvider(currentTrack!.baseItem).notifier).toggleFavorite();
126-
}
127-
},
128-
onHorizontalSwipe: (direction) {
129-
if (direction == SwipeDirection.left) {
130-
if (!FinampSettingsHelper.finampSettings.disableGesture) {
131-
queueService.skipByOffset(1);
132-
FeedbackHelper.feedback(FeedbackType.selection);
133-
}
134-
} else if (direction == SwipeDirection.right) {
135-
if (!FinampSettingsHelper.finampSettings.disableGesture) {
136-
queueService.skipByOffset(-1);
137-
FeedbackHelper.feedback(FeedbackType.selection);
62+
// Watch the metadata provider for the current track
63+
final metadataAsyncValue = ref.watch(currentTrackMetadataProvider);
64+
65+
// Handle metadata loading/error states
66+
final metadata = metadataAsyncValue.unwrapPrevious();
67+
final metadataValue = metadata.valueOrNull;
68+
69+
// Check if track changed
70+
if (currentTrackId != _lastTrackId) {
71+
_lastTrackId = currentTrackId;
72+
73+
String? animatedCoverSource;
74+
75+
// First, try to use locally cached file
76+
if (metadataValue?.animatedCoverFile != null && metadataValue!.animatedCoverFile!.existsSync()) {
77+
animatedCoverSource = metadataValue.animatedCoverFile!.path;
78+
}
79+
// Fallback to online streaming if not in offline mode and no local file
80+
else if (!FinampSettingsHelper.finampSettings.isOffline) {
81+
// Use the animated music service to check if animated cover exists
82+
WidgetsBinding.instance.addPostFrameCallback((_) async {
83+
try {
84+
final hasAnimatedCover = await animatedMusicService.hasAnimatedCover(BaseItemId(currentTrackId));
85+
if (mounted && hasAnimatedCover && _lastTrackId == currentTrackId) {
86+
final onlineUrl = await animatedMusicService.getAnimatedCoverForTrack(BaseItemId(currentTrackId));
87+
if (mounted && onlineUrl != null && _lastTrackId == currentTrackId) {
88+
setState(() {
89+
_currentAnimatedCoverSource = onlineUrl;
90+
});
13891
}
92+
} else if (mounted && !hasAnimatedCover) {
93+
// No animated cover available
94+
setState(() {
95+
_currentAnimatedCoverSource = null;
96+
});
13997
}
140-
},
141-
child: LayoutBuilder(
142-
builder: (context, constraints) {
143-
final minPadding = ref.watch(finampSettingsProvider.playerScreenCoverMinimumPadding);
144-
final horizontalPadding = constraints.maxWidth * (minPadding / 100.0);
145-
final verticalPadding = constraints.maxHeight * (minPadding / 100.0);
146-
147-
return Padding(
148-
padding: EdgeInsets.symmetric(horizontal: horizontalPadding, vertical: verticalPadding),
149-
child: _buildCoverWidget(),
150-
);
151-
},
152-
),
153-
),
154-
),
155-
);
98+
} catch (e) {
99+
// Silently fail - no animated cover available for streaming
100+
if (mounted) {
101+
setState(() {
102+
_currentAnimatedCoverSource = null;
103+
});
104+
}
105+
}
106+
});
107+
} else {
108+
// No animated cover available
109+
animatedCoverSource = null;
110+
}
111+
112+
// Update animated cover source if we have a local file
113+
if (animatedCoverSource != _currentAnimatedCoverSource) {
114+
_currentAnimatedCoverSource = animatedCoverSource;
115+
if (mounted) setState(() {});
116+
}
117+
}
118+
119+
return _buildCoverWithGestures(snapshot, audioService);
156120
},
157121
);
158122
}
159123

124+
Widget _buildStaticCover(AsyncSnapshot<FinampQueueInfo?> snapshot, MusicPlayerBackgroundTask audioService) {
125+
return _buildCoverWithGestures(snapshot, audioService);
126+
}
127+
128+
Widget _buildCoverWithGestures(AsyncSnapshot<FinampQueueInfo?> snapshot, MusicPlayerBackgroundTask audioService) {
129+
final currentTrack = snapshot.data?.currentTrack;
130+
131+
return Semantics(
132+
label: AppLocalizations.of(
133+
context,
134+
)!.playerAlbumArtworkTooltip(currentTrack?.item.title ?? AppLocalizations.of(context)!.unknownName),
135+
excludeSemantics: true, // replace child semantics with custom semantics
136+
container: true,
137+
child: GestureDetector(
138+
onSecondaryTapDown: (_) async {
139+
var queueItem = snapshot.data!.currentTrack;
140+
if (queueItem?.baseItem != null) {
141+
var inPlaylist = queueItemInPlaylist(queueItem);
142+
await showModalTrackMenu(
143+
context: context,
144+
item: queueItem!.baseItem!,
145+
showPlaybackControls: true,
146+
// show controls on player screen
147+
parentItem: inPlaylist ? queueItem.source.item : null,
148+
isInPlaylist: inPlaylist,
149+
);
150+
}
151+
},
152+
child: SimpleGestureDetector(
153+
//TODO replace with PageView, this is just a placeholder
154+
onTap: () {
155+
unawaited(audioService.togglePlayback());
156+
FeedbackHelper.feedback(FeedbackType.selection);
157+
},
158+
onDoubleTap: () {
159+
final queueService = GetIt.instance<QueueService>();
160+
final currentTrack = queueService.getCurrentTrack();
161+
if (currentTrack?.baseItem != null && !FinampSettingsHelper.finampSettings.isOffline) {
162+
ref.read(isFavoriteProvider(currentTrack!.baseItem).notifier).toggleFavorite();
163+
}
164+
},
165+
onHorizontalSwipe: (direction) {
166+
final queueService = GetIt.instance<QueueService>();
167+
if (direction == SwipeDirection.left) {
168+
if (!FinampSettingsHelper.finampSettings.disableGesture) {
169+
queueService.skipByOffset(1);
170+
FeedbackHelper.feedback(FeedbackType.selection);
171+
}
172+
} else if (direction == SwipeDirection.right) {
173+
if (!FinampSettingsHelper.finampSettings.disableGesture) {
174+
queueService.skipByOffset(-1);
175+
FeedbackHelper.feedback(FeedbackType.selection);
176+
}
177+
}
178+
},
179+
child: LayoutBuilder(
180+
builder: (context, constraints) {
181+
final minPadding = ref.watch(finampSettingsProvider.playerScreenCoverMinimumPadding);
182+
final horizontalPadding = constraints.maxWidth * (minPadding / 100.0);
183+
final verticalPadding = constraints.maxHeight * (minPadding / 100.0);
184+
185+
return Padding(
186+
padding: EdgeInsets.symmetric(horizontal: horizontalPadding, vertical: verticalPadding),
187+
child: _buildCoverWidget(),
188+
);
189+
},
190+
),
191+
),
192+
),
193+
);
194+
}
195+
160196
Widget _buildCoverWidget() {
161197
final borderRadius = BorderRadius.circular(8.0);
162198
final decoration = BoxDecoration(
163199
boxShadow: [BoxShadow(blurRadius: 24, offset: const Offset(0, 4), color: Colors.black.withOpacity(0.3))],
164200
);
165201

166202
// Show animated cover if available
167-
if (_animatedCoverUri != null) {
203+
if (_currentAnimatedCoverSource != null) {
168204
return Stack(
169205
children: [
170206
// Fallback static image
@@ -175,7 +211,7 @@ class _PlayerScreenAlbumImageState extends ConsumerState<PlayerScreenAlbumImage>
175211
decoration: decoration,
176212
),
177213
// Animated cover overlay
178-
AnimatedAlbumCover(animatedCoverUri: _animatedCoverUri!, borderRadius: borderRadius),
214+
AnimatedAlbumCover(animatedCoverUri: _currentAnimatedCoverSource!, borderRadius: borderRadius),
179215
],
180216
);
181217
}

lib/models/finamp_models.dart

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1147,6 +1147,10 @@ class DownloadStub {
11471147
BaseItemDtoType.fromItem(baseItem!) == baseItemType;
11481148
case DownloadItemType.image:
11491149
return baseItem != null;
1150+
case DownloadItemType.animatedCover:
1151+
return baseItem != null;
1152+
case DownloadItemType.verticalBackgroundVideo:
1153+
return baseItem != null;
11501154
case DownloadItemType.finampCollection:
11511155
return baseItem == null && baseItemType == BaseItemDtoType.noItem && finampCollection != null;
11521156
case DownloadItemType.anchor:
@@ -1459,7 +1463,9 @@ enum DownloadItemType {
14591463
track("song", true, true),
14601464
image("image", true, true),
14611465
anchor("anchor", false, false),
1462-
finampCollection("finampCollection", false, false);
1466+
finampCollection("finampCollection", false, false),
1467+
animatedCover("animatedCover", true, true),
1468+
verticalBackgroundVideo("verticalBackgroundVideo", true, true);
14631469

14641470
const DownloadItemType(this.isarType, this.requiresItem, this.hasFiles);
14651471

lib/models/finamp_models.g.dart

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)