♻️ Better current track metadata

This commit is contained in:
2025-12-20 00:25:48 +08:00
parent cb4cca2917
commit a86e8b1cab
8 changed files with 299 additions and 247 deletions

View File

@@ -1,7 +1,11 @@
import 'dart:typed_data';
import 'package:audio_service/audio_service.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:media_kit/media_kit.dart' as media_kit;
import 'package:groovybox/data/db.dart';
import 'package:groovybox/logic/metadata_service.dart';
import 'package:groovybox/providers/audio_provider.dart';
import 'package:groovybox/providers/theme_provider.dart';
import 'package:groovybox/providers/remote_provider.dart';
import 'package:groovybox/providers/db_provider.dart';
@@ -47,11 +51,13 @@ class AudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
_container = container;
}
// Update theme color based on current track's album art
// Update theme color based on current track's album art and set current metadata
void _updateThemeFromCurrentTrack(MediaItem mediaItem) async {
if (_container == null) return;
try {
TrackMetadata? metadata;
// For remote tracks, get metadata from database
final urlResolver = _container!.read(remoteUrlResolverProvider);
if (urlResolver.isProtocolUrl(mediaItem.id)) {
@@ -60,22 +66,59 @@ class AudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
database.tracks,
)..where((t) => t.path.equals(mediaItem.id))).getSingleOrNull();
if (track != null && track.artUri != null) {
if (track != null) {
// Fetch album art bytes for remote tracks
// TODO: Implement remote album art fetching for theme
Uint8List? artBytes;
if (track.artUri != null) {
try {
final response = await http.get(Uri.parse(track.artUri!));
if (response.statusCode == 200) {
artBytes = response.bodyBytes;
}
} catch (e) {
// Ignore art fetching errors
}
} else {
// For local tracks, use existing metadata service
// TODO: Get metadata service working
}
// Reset to default for now
metadata = TrackMetadata(
title: track.title,
artist: track.artist,
album: track.album,
artBytes: artBytes,
);
// Update theme from album art
final seedColorNotifier = _container!.read(
seedColorProvider.notifier,
);
seedColorNotifier.updateFromAlbumArtBytes(artBytes);
}
} else {
// For local tracks, use metadata service
final metadataService = MetadataService();
metadata = await metadataService.getMetadata(mediaItem.id);
// Update theme from album art
final seedColorNotifier = _container!.read(seedColorProvider.notifier);
seedColorNotifier.resetToDefault();
seedColorNotifier.updateFromAlbumArtBytes(metadata.artBytes);
}
// Set current track metadata
if (metadata != null) {
final metadataNotifier = _container!.read(
currentTrackMetadataProvider.notifier,
);
metadataNotifier.setMetadata(metadata);
}
} catch (e) {
// If metadata retrieval fails, reset to default color
// If metadata retrieval fails, reset to default color and clear metadata
final seedColorNotifier = _container!.read(seedColorProvider.notifier);
seedColorNotifier.resetToDefault();
final metadataNotifier = _container!.read(
currentTrackMetadataProvider.notifier,
);
metadataNotifier.clear();
}
}

View File

@@ -1,10 +1,9 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_media_metadata/flutter_media_metadata.dart';
import 'package:http/http.dart' as http;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:groovybox/providers/remote_provider.dart';
import 'package:groovybox/providers/db_provider.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
class TrackMetadata {
final String? title;
@@ -38,20 +37,16 @@ class MetadataService {
}
}
@Riverpod(keepAlive: true)
MetadataService metadataService(Ref ref) {
return MetadataService();
}
final trackMetadataProvider = FutureProvider.family<TrackMetadata, String>((
ref,
path,
) async {
try {
// Import the database provider directly
final db = ref.watch(databaseProvider);
@riverpod
Future<TrackMetadata> trackMetadata(Ref ref, String path) async {
// Check if this is a remote track (protocol URL)
final urlResolver = ref.watch(remoteUrlResolverProvider);
if (urlResolver.isProtocolUrl(path)) {
// For remote tracks, get metadata from database
final database = ref.watch(databaseProvider);
final track = await (database.select(
database.tracks,
final track = await (db.select(
db.tracks,
)..where((t) => t.path.equals(path))).getSingleOrNull();
if (track != null) {
@@ -75,11 +70,21 @@ Future<TrackMetadata> trackMetadata(Ref ref, String path) async {
album: track.album,
artBytes: artBytes,
);
}
return TrackMetadata();
} else {
// For local tracks, use file metadata
final service = MetadataService();
return service.getMetadata(path);
return TrackMetadata(
title: 'Unknown Title',
artist: 'Unknown Artist',
album: 'Unknown Album',
artBytes: null,
);
}
}
} catch (e) {
debugPrint('Error fetching metadata for $path: $e');
return TrackMetadata(
title: 'Unknown Title',
artist: 'Unknown Artist',
album: 'Unknown Album',
artBytes: null,
);
}
});

View File

@@ -1,4 +1,5 @@
import 'package:groovybox/logic/audio_handler.dart';
import 'package:groovybox/logic/metadata_service.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'audio_provider.g.dart';
@@ -15,3 +16,19 @@ AudioHandler audioHandler(Ref ref) {
void setAudioHandler(AudioHandler handler) {
_audioHandler = handler;
}
@Riverpod(keepAlive: true)
class CurrentTrackMetadataNotifier extends _$CurrentTrackMetadataNotifier {
@override
TrackMetadata? build() {
return null;
}
void setMetadata(TrackMetadata metadata) {
state = metadata;
}
void clear() {
state = null;
}
}

View File

@@ -49,3 +49,58 @@ final class AudioHandlerProvider
}
String _$audioHandlerHash() => r'65fbd92e049fe4f3a0763516f1e68e1614f7630f';
@ProviderFor(CurrentTrackMetadataNotifier)
const currentTrackMetadataProvider = CurrentTrackMetadataNotifierProvider._();
final class CurrentTrackMetadataNotifierProvider
extends $NotifierProvider<CurrentTrackMetadataNotifier, TrackMetadata?> {
const CurrentTrackMetadataNotifierProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'currentTrackMetadataProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$currentTrackMetadataNotifierHash();
@$internal
@override
CurrentTrackMetadataNotifier create() => CurrentTrackMetadataNotifier();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(TrackMetadata? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<TrackMetadata?>(value),
);
}
}
String _$currentTrackMetadataNotifierHash() =>
r'0a491bd4edda2b010ed3d6f7dd459f4ac8689a5f';
abstract class _$CurrentTrackMetadataNotifier
extends $Notifier<TrackMetadata?> {
TrackMetadata? build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<TrackMetadata?, TrackMetadata?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<TrackMetadata?, TrackMetadata?>,
TrackMetadata?,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -48,24 +48,19 @@ class PlayerScreen extends HookConsumerWidget {
}
final media = medias[index];
final path = Uri.decodeFull(Uri.parse(media.uri).path);
// For now, skip metadata loading to avoid provider issues
final AsyncValue<TrackMetadata> metadataAsync = AsyncValue.data(
TrackMetadata(),
);
final currentMetadata = ref.watch(currentTrackMetadataProvider);
// Build blurred background if cover art is available
Widget? background;
metadataAsync.when(
data: (meta) {
if (meta.artBytes != null) {
final artBytes = currentMetadata?.artBytes;
if (artBytes != null) {
background = Positioned.fill(
child: Stack(
children: [
Container(
decoration: BoxDecoration(
image: DecorationImage(
image: MemoryImage(meta.artBytes!),
image: MemoryImage(artBytes),
fit: BoxFit.cover,
),
),
@@ -84,10 +79,6 @@ class PlayerScreen extends HookConsumerWidget {
} else {
background = null;
}
},
loading: () => background = null,
error: (_, _) => background = null,
);
final devicePadding = MediaQuery.paddingOf(context);
@@ -116,7 +107,7 @@ class PlayerScreen extends HookConsumerWidget {
body: ClipRect(
child: Stack(
children: [
...background != null ? [background!] : [],
...background != null ? [background] : [],
// Main content (StreamBuilder)
Builder(
builder: (context) {
@@ -126,18 +117,16 @@ class PlayerScreen extends HookConsumerWidget {
child: _MobileLayout(
player: player,
viewMode: viewMode,
metadataAsync: metadataAsync,
media: media,
trackPath: path,
trackPath: media.uri,
),
);
} else {
return _DesktopLayout(
player: player,
viewMode: viewMode,
metadataAsync: metadataAsync,
media: media,
trackPath: path,
trackPath: media.uri,
);
}
},
@@ -164,30 +153,30 @@ class PlayerScreen extends HookConsumerWidget {
}
}
class _MobileLayout extends StatelessWidget {
class _MobileLayout extends HookConsumerWidget {
final Player player;
final ValueNotifier<ViewMode> viewMode;
final AsyncValue<TrackMetadata> metadataAsync;
final Media media;
final String trackPath;
const _MobileLayout({
required this.player,
required this.viewMode,
required this.metadataAsync,
required this.media,
required this.trackPath,
});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final currentMetadata = ref.watch(currentTrackMetadataProvider);
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: switch (viewMode.value) {
ViewMode.cover => _CoverView(
key: const ValueKey('cover'),
player: player,
metadataAsync: metadataAsync,
currentMetadata: currentMetadata,
media: media,
trackPath: trackPath,
).padding(bottom: MediaQuery.paddingOf(context).bottom),
@@ -207,13 +196,13 @@ class _MobileLayout extends StatelessWidget {
class _PlayerCoverControlsPanel extends StatelessWidget {
final Player player;
final AsyncValue<TrackMetadata> metadataAsync;
final TrackMetadata? currentMetadata;
final Media media;
final String trackPath;
const _PlayerCoverControlsPanel({
required this.player,
required this.metadataAsync,
required this.currentMetadata,
required this.media,
required this.trackPath,
});
@@ -235,7 +224,7 @@ class _PlayerCoverControlsPanel extends StatelessWidget {
constraints: const BoxConstraints(maxWidth: 400),
child: AspectRatio(
aspectRatio: 1,
child: _PlayerCoverArt(metadataAsync: metadataAsync),
child: _PlayerCoverArt(currentMetadata: currentMetadata),
),
),
),
@@ -243,7 +232,7 @@ class _PlayerCoverControlsPanel extends StatelessWidget {
),
_PlayerControls(
player: player,
metadataAsync: metadataAsync,
currentMetadata: currentMetadata,
media: media,
trackPath: trackPath,
),
@@ -254,23 +243,23 @@ class _PlayerCoverControlsPanel extends StatelessWidget {
}
}
class _DesktopLayout extends StatelessWidget {
class _DesktopLayout extends HookConsumerWidget {
final Player player;
final ValueNotifier<ViewMode> viewMode;
final AsyncValue<TrackMetadata> metadataAsync;
final Media media;
final String trackPath;
const _DesktopLayout({
required this.player,
required this.viewMode,
required this.metadataAsync,
required this.media,
required this.trackPath,
});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final currentMetadata = ref.watch(currentTrackMetadataProvider);
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: switch (viewMode.value) {
@@ -278,7 +267,7 @@ class _DesktopLayout extends StatelessWidget {
key: const ValueKey('cover'),
child: _PlayerCoverControlsPanel(
player: player,
metadataAsync: metadataAsync,
currentMetadata: currentMetadata,
media: media,
trackPath: trackPath,
),
@@ -294,7 +283,7 @@ class _DesktopLayout extends StatelessWidget {
child: Center(
child: _PlayerCoverControlsPanel(
player: player,
metadataAsync: metadataAsync,
currentMetadata: currentMetadata,
media: media,
trackPath: trackPath,
),
@@ -328,7 +317,7 @@ class _DesktopLayout extends StatelessWidget {
child: Center(
child: _PlayerCoverControlsPanel(
player: player,
metadataAsync: metadataAsync,
currentMetadata: currentMetadata,
media: media,
trackPath: trackPath,
),
@@ -355,14 +344,14 @@ class _DesktopLayout extends StatelessWidget {
class _CoverView extends StatelessWidget {
final Player player;
final AsyncValue<TrackMetadata> metadataAsync;
final TrackMetadata? currentMetadata;
final Media media;
final String trackPath;
const _CoverView({
super.key,
required this.player,
required this.metadataAsync,
required this.currentMetadata,
required this.media,
required this.trackPath,
});
@@ -377,13 +366,13 @@ class _CoverView extends StatelessWidget {
child: Padding(
padding: const EdgeInsets.only(bottom: 32),
child: Center(
child: _PlayerCoverArt(metadataAsync: metadataAsync),
child: _PlayerCoverArt(currentMetadata: currentMetadata),
),
),
),
_PlayerControls(
player: player,
metadataAsync: metadataAsync,
currentMetadata: currentMetadata,
media: media,
trackPath: trackPath,
),
@@ -417,14 +406,13 @@ class _LyricsView extends StatelessWidget {
}
class _PlayerCoverArt extends StatelessWidget {
final AsyncValue<TrackMetadata> metadataAsync;
final TrackMetadata? currentMetadata;
const _PlayerCoverArt({required this.metadataAsync});
const _PlayerCoverArt({required this.currentMetadata});
@override
Widget build(BuildContext context) {
return metadataAsync.when(
data: (meta) => Center(
return Center(
child: AspectRatio(
aspectRatio: 1,
child: Container(
@@ -440,14 +428,17 @@ class _PlayerCoverArt extends StatelessWidget {
offset: const Offset(0, 10),
),
],
image: meta.artBytes != null
image: () {
final artBytes = currentMetadata?.artBytes;
return artBytes != null
? DecorationImage(
image: MemoryImage(meta.artBytes!),
image: MemoryImage(artBytes),
fit: BoxFit.cover,
)
: null,
: null;
}(),
),
child: meta.artBytes == null
child: currentMetadata?.artBytes == null
? Center(
child: Icon(
Icons.music_note,
@@ -460,23 +451,6 @@ class _PlayerCoverArt extends StatelessWidget {
: null,
),
),
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, _) => Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(24),
),
child: Center(
child: Icon(
Icons.error_outline,
size: 80,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.7),
),
),
),
);
}
}
@@ -1161,7 +1135,7 @@ class _QueueView extends HookConsumerWidget {
itemBuilder: (context, index) {
final media = playlist.medias[index];
final isCurrent = index == playlist.index;
final trackPath = Uri.decodeFull(Uri.parse(media.uri).path);
final trackPath = media.uri;
final trackAsync = ref.watch(trackByPathProvider(trackPath));
return trackAsync.when(
@@ -1525,13 +1499,13 @@ class _TimedLyricsView extends HookConsumerWidget {
class _PlayerControls extends HookWidget {
final Player player;
final AsyncValue<TrackMetadata> metadataAsync;
final TrackMetadata? currentMetadata;
final Media media;
final String trackPath;
const _PlayerControls({
required this.player,
required this.metadataAsync,
required this.currentMetadata,
required this.media,
required this.trackPath,
});
@@ -1547,28 +1521,20 @@ class _PlayerControls extends HookWidget {
// Title & Artist
Column(
children: [
metadataAsync.when(
data: (meta) => Text(
meta.title ?? Uri.parse(media.uri).pathSegments.last,
Text(
currentMetadata?.title ?? Uri.parse(media.uri).pathSegments.last,
style: Theme.of(context).textTheme.headlineSmall,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
loading: () => const SizedBox(height: 32),
error: (_, _) => Text(Uri.parse(media.uri).pathSegments.last),
),
const SizedBox(height: 8),
metadataAsync.when(
data: (meta) => Text(
meta.artist ?? 'Unknown Artist',
Text(
currentMetadata?.artist ?? 'Unknown Artist',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
loading: () => const SizedBox(height: 24),
error: (_, _) => const SizedBox.shrink(),
),
],
),

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:groovybox/data/db.dart' as db;
import 'package:groovybox/logic/metadata_service.dart';
import 'package:groovybox/providers/audio_provider.dart';
import 'package:groovybox/ui/screens/player_screen.dart';
import 'package:groovybox/ui/widgets/track_tile.dart';
@@ -53,10 +53,7 @@ class _MobileMiniPlayer extends HookConsumerWidget {
final devicePadding = MediaQuery.paddingOf(context);
// For now, skip metadata loading to avoid provider issues
final AsyncValue<TrackMetadata> metadataAsync = AsyncValue.data(
TrackMetadata(),
);
final currentMetadata = ref.watch(currentTrackMetadataProvider);
Widget content = Container(
height: 72 + devicePadding.bottom,
@@ -132,9 +129,11 @@ class _MobileMiniPlayer extends HookConsumerWidget {
// Cover Art
AspectRatio(
aspectRatio: 1,
child: metadataAsync.when(
data: (meta) => meta.artBytes != null
? Image.memory(meta.artBytes!, fit: BoxFit.cover)
child: currentMetadata?.artBytes != null
? Image.memory(
currentMetadata!.artBytes!,
fit: BoxFit.cover,
)
: Container(
color: Colors.grey[800],
child: const Icon(
@@ -142,9 +141,6 @@ class _MobileMiniPlayer extends HookConsumerWidget {
color: Colors.white54,
),
),
loading: () => Container(color: Colors.grey[800]),
error: (_, _) => Container(color: Colors.grey[800]),
),
),
const Gap(8),
// Title & Artist
@@ -155,29 +151,20 @@ class _MobileMiniPlayer extends HookConsumerWidget {
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
metadataAsync.when(
data: (meta) => Text(
meta.title ??
Text(
currentMetadata?.title ??
Uri.parse(media.uri).pathSegments.last,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(fontWeight: FontWeight.bold),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
loading: () => const Text('Loading...'),
error: (_, _) =>
Text(Uri.parse(media.uri).pathSegments.last),
),
metadataAsync.when(
data: (meta) => Text(
meta.artist ?? 'Unknown Artist',
Text(
currentMetadata?.artist ?? 'Unknown Artist',
style: Theme.of(context).textTheme.bodySmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
loading: () => const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
),
],
),
),
@@ -272,10 +259,7 @@ class _DesktopMiniPlayer extends HookConsumerWidget {
final devicePadding = MediaQuery.paddingOf(context);
// For now, skip metadata loading to avoid provider issues
final AsyncValue<TrackMetadata> metadataAsync = AsyncValue.data(
TrackMetadata(),
);
final currentMetadata = ref.watch(currentTrackMetadataProvider);
Widget content = Container(
height: 72 + devicePadding.bottom,
@@ -356,10 +340,9 @@ class _DesktopMiniPlayer extends HookConsumerWidget {
// Cover Art
AspectRatio(
aspectRatio: 1,
child: metadataAsync.when(
data: (meta) => meta.artBytes != null
child: currentMetadata?.artBytes != null
? Image.memory(
meta.artBytes!,
currentMetadata!.artBytes!,
fit: BoxFit.cover,
)
: Container(
@@ -369,10 +352,6 @@ class _DesktopMiniPlayer extends HookConsumerWidget {
color: Colors.white54,
),
),
loading: () => Container(color: Colors.grey[800]),
error: (_, _) =>
Container(color: Colors.grey[800]),
),
),
const Gap(8),
// Title & Artist
@@ -384,34 +363,20 @@ class _DesktopMiniPlayer extends HookConsumerWidget {
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
metadataAsync.when(
data: (meta) => Text(
meta.title ??
Text(
currentMetadata?.title ??
Uri.parse(media.uri).pathSegments.last,
style: Theme.of(context)
.textTheme
.bodyMedium
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(fontWeight: FontWeight.bold),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
loading: () => const Text('Loading...'),
error: (_, _) => Text(
Uri.parse(media.uri).pathSegments.last,
),
),
metadataAsync.when(
data: (meta) => Text(
meta.artist ?? 'Unknown Artist',
style: Theme.of(
context,
).textTheme.bodySmall,
Text(
currentMetadata?.artist ?? 'Unknown Artist',
style: Theme.of(context).textTheme.bodySmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
loading: () => const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
),
],
),
),

View File

@@ -537,7 +537,7 @@ packages:
source: hosted
version: "0.15.6"
http:
dependency: transitive
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"

View File

@@ -51,6 +51,7 @@ dependencies:
styled_widget: ^0.4.1
super_sliver_list: ^0.4.1
dio: ^5.0.0
http: ^1.2.2
audio_service: ^0.18.18
palette_generator: ^0.3.3+4
watcher: ^1.2.0