Skip to content

Commit

Permalink
feat(local-tracks): complete support for local tracks
Browse files Browse the repository at this point in the history
Downloaded tracks are saved with metadata. Only MP3 file metadata support is available in local track player for now
  • Loading branch information
KRTirtho committed Sep 3, 2022
1 parent c3bf511 commit e206f16
Show file tree
Hide file tree
Showing 26 changed files with 855 additions and 324 deletions.
Binary file added assets/album-placeholder.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions lib/components/Home/Sidebar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,12 @@ class Sidebar extends HookConsumerWidget {
CircleAvatar(
backgroundImage:
CachedNetworkImageProvider(avatarImg),
onBackgroundImageError:
(exception, stackTrace) => Container(
height: 16,
width: 16,
color: Theme.of(context).cardColor,
),
),
const SizedBox(width: 10),
Text(
Expand All @@ -176,6 +182,12 @@ class Sidebar extends HookConsumerWidget {
child: CircleAvatar(
backgroundImage:
CachedNetworkImageProvider(avatarImg),
onBackgroundImageError: (exception, stackTrace) =>
Container(
height: 16,
width: 16,
color: Theme.of(context).cardColor,
),
),
),
);
Expand Down
4 changes: 2 additions & 2 deletions lib/components/Library/UserDownloads.dart
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ class UserDownloads extends HookConsumerWidget {
),
horizontalTitleGap: 5,
subtitle: Text(
TypeConversionUtils.artists_X_String<Artist>(
track.artists ?? [],
TypeConversionUtils.artists_X_String(
track.artists ?? <Artist>[],
),
),
);
Expand Down
8 changes: 4 additions & 4 deletions lib/components/Library/UserLibrary.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,18 @@ class UserLibrary extends ConsumerWidget {
isScrollable: true,
tabs: [
Tab(text: "Playlist"),
Tab(text: "Artists"),
Tab(text: "Album"),
Tab(text: "Downloads"),
Tab(text: "Local"),
Tab(text: "Artists"),
Tab(text: "Album"),
],
),
body: TabBarView(children: [
const AnonymousFallback(child: UserPlaylists()),
AnonymousFallback(child: UserArtists()),
const AnonymousFallback(child: UserAlbums()),
const UserDownloads(),
const UserLocalTracks(),
AnonymousFallback(child: UserArtists()),
const AnonymousFallback(child: UserAlbums()),
]),
),
),
Expand Down
228 changes: 190 additions & 38 deletions lib/components/Library/UserLocalTracks.dart
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
import 'dart:convert';
import 'dart:io';

import 'package:dart_tags/dart_tags.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_media_metadata/flutter_media_metadata.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:mime/mime.dart';
import 'package:mp3_info/mp3_info.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/LoaderShimmers/ShimmerTrackTile.dart';
import 'package:spotube/components/Shared/TrackTile.dart';
import 'package:spotube/models/CurrentPlaylist.dart';
import 'package:spotube/models/Logger.dart';
import 'package:spotube/provider/Playback.dart';
import 'package:spotube/provider/UserPreferences.dart';
import 'package:spotube/utils/primitive_utils.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
import 'package:id3/id3.dart';

final tagProcessor = TagProcessor();

const supportedAudioTypes = [
"audio/webm",
Expand All @@ -18,54 +31,193 @@ const supportedAudioTypes = [
"audio/aac",
];

List<Track> usePullLocalTracks(WidgetRef ref) {
const imgMimeToExt = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/webp": ".webp",
"image/gif": ".gif",
};

final localTracksProvider = FutureProvider<List<Track>>((ref) async {
final downloadDir = Directory(
ref.watch(userPreferencesProvider.select((s) => s.downloadLocation)),
);
final localTracks = useState<List<Track>>([]);
if (!await downloadDir.exists()) {
await downloadDir.create(recursive: true);
return [];
}
final entities = downloadDir.listSync(recursive: true);
final filesWithMetadata = (await Future.wait(
entities.map((e) => File(e.path)).where((file) {
final mimetype = lookupMimeType(file.path);
return mimetype != null && supportedAudioTypes.contains(mimetype);
}).map(
(f) async {
final bytes = f.readAsBytes();
final mp3Instance = MP3Instance(await bytes);

useEffect(() {
(() async {
if (!await downloadDir.exists()) {
await downloadDir.create(recursive: true);
return;
}
final entities = downloadDir.listSync(recursive: true);
final filesWithMetadata = (await Future.wait(
entities.map((e) => File(e.path)).where((file) {
final mimetype = lookupMimeType(file.path);
return mimetype != null && supportedAudioTypes.contains(mimetype);
}).map(
(f) async => {
"metadata": await MetadataRetriever.fromFile(f),
"file": f,
},
),
));
final imageFile = mp3Instance.parseTagsSync()
? File(join(
(await getTemporaryDirectory()).path,
"spotube",
basenameWithoutExtension(f.path) +
imgMimeToExt[
mp3Instance.metaTags["APIC"]?["mime"] ?? "image/jpeg"]!,
))
: null;
if (imageFile != null &&
!await imageFile.exists() &&
mp3Instance.metaTags["APIC"]?["base64"] != null) {
await imageFile.create(recursive: true);
await imageFile.writeAsBytes(
base64Decode(
mp3Instance.metaTags["APIC"]["base64"],
),
mode: FileMode.writeOnly,
);
}
Duration duration;
try {
duration = MP3Processor.fromBytes(await bytes).duration;
} catch (e, stack) {
getLogger(MP3Processor).e("[Parsing Mp3]", e, stack);
duration = Duration.zero;
}

final tracks = filesWithMetadata
.map(
(fileWithMetadata) => TypeConversionUtils.localTrack_X_Track(
fileWithMetadata["metadata"] as Metadata,
fileWithMetadata["file"] as File),
)
.toList();
final metadata = await tagProcessor.getTagsFromByteArray(bytes);
return {
"metadata": metadata,
"file": f,
"art": imageFile?.path,
"duration": duration,
};
},
),
));

localTracks.value = tracks;
})();
final tracks = filesWithMetadata
.map(
(fileWithMetadata) => TypeConversionUtils.localTrack_X_Track(
fileWithMetadata["metadata"] as List<Tag>,
fileWithMetadata["file"] as File,
fileWithMetadata["duration"] as Duration,
fileWithMetadata["art"] as String?,
),
)
.toList();

return;
}, [downloadDir]);

return localTracks.value;
}
return tracks;
});

class UserLocalTracks extends HookConsumerWidget {
const UserLocalTracks({Key? key}) : super(key: key);

void playLocalTracks(Playback playback, List<Track> tracks,
{Track? currentTrack}) async {
currentTrack ??= tracks.first;
final isPlaylistPlaying = playback.playlist?.id == "local";
if (!isPlaylistPlaying) {
await playback.playPlaylist(
CurrentPlaylist(
tracks: tracks,
id: "local",
name: "Local Tracks",
thumbnail: TypeConversionUtils.image_X_UrlString(null),
isLocal: true,
),
tracks.indexWhere((s) => s.id == currentTrack?.id),
);
} else if (isPlaylistPlaying &&
currentTrack.id != null &&
currentTrack.id != playback.track?.id) {
await playback.play(currentTrack);
}
}

@override
Widget build(BuildContext context, ref) {
final tracks = usePullLocalTracks(ref);
return Column();
final playback = ref.watch(playbackProvider);
final isPlaylistPlaying = playback.playlist?.id == "local";
final trackSnapshot = ref.watch(localTracksProvider);
return Column(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
const SizedBox(width: 10),
ElevatedButton.icon(
label: const Text("Play"),
icon: Icon(
isPlaylistPlaying
? Icons.stop_rounded
: Icons.play_arrow_rounded,
),
onPressed: trackSnapshot.value != null
? () {
if (trackSnapshot.value?.isNotEmpty == true) {
if (!isPlaylistPlaying) {
playLocalTracks(playback, trackSnapshot.value!);
} else {
playback.stop();
}
}
}
: null,
),
const Spacer(),
ElevatedButton(
child: const Icon(Icons.refresh_rounded),
onPressed: () {
ref.refresh(localTracksProvider);
},
)
],
),
),
trackSnapshot.when(
data: (tracks) {
return Expanded(
child: ListView.builder(
itemCount: tracks.length,
itemBuilder: (context, index) {
final track = tracks[index];
return TrackTile(
playback,
duration:
"${track.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.duration?.inSeconds.remainder(60) ?? 0)}",
track: MapEntry(index, track),
isActive: playback.track?.id == track.id,
isChecked: false,
showCheck: false,
thumbnailUrl: track.album?.images?.isNotEmpty == true
? track.album?.images?.single.url
: "assets/album-placeholder.png",
isLocal: true,
onTrackPlayButtonPressed: (currentTrack) {
if (tracks.isNotEmpty) {
if (!isPlaylistPlaying) {
playLocalTracks(
playback,
tracks,
currentTrack: track,
);
} else {
playback.stop();
}
}
},
);
},
),
);
},
loading: () => const ShimmerTrackTile(noSliver: true),
error: (error, stackTrace) =>
Text(error.toString() + stackTrace.toString()),
)
],
);
;
}
}
6 changes: 2 additions & 4 deletions lib/components/Lyrics/SyncedLyrics.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import 'package:spotube/components/Lyrics/LyricDelayAdjustDialog.dart';
import 'package:spotube/components/Lyrics/Lyrics.dart';
import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
import 'package:spotube/components/Shared/SpotubeMarqueeText.dart';
import 'package:spotube/components/Shared/UniversalImage.dart';
import 'package:spotube/hooks/useAutoScrollController.dart';
import 'package:spotube/hooks/useBreakpoints.dart';
import 'package:spotube/hooks/useCustomStatusBarColor.dart';
Expand Down Expand Up @@ -132,10 +133,7 @@ class SyncedLyrics extends HookConsumerWidget {
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
image: DecorationImage(
image: CachedNetworkImageProvider(
albumArt,
cacheKey: albumArt,
),
image: UniversalImage.imageProvider(albumArt),
fit: BoxFit.cover,
),
),
Expand Down
10 changes: 6 additions & 4 deletions lib/components/Player/Player.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@ class Player extends HookConsumerWidget {
final breakpoint = useBreakpoints();

String albumArt = useMemoized(
() => TypeConversionUtils.image_X_UrlString(
playback.track?.album?.images,
index: (playback.track?.album?.images?.length ?? 1) - 1,
),
() => playback.track?.album?.images?.isNotEmpty == true
? TypeConversionUtils.image_X_UrlString(
playback.track?.album?.images,
index: (playback.track?.album?.images?.length ?? 1) - 1,
)
: "assets/album-placeholder.png",
[playback.track?.album?.images],
);

Expand Down
15 changes: 7 additions & 8 deletions lib/components/Player/PlayerTrackDetails.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/components/Shared/UniversalImage.dart';
import 'package:spotube/hooks/useBreakpoints.dart';
import 'package:spotube/provider/Playback.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
Expand All @@ -21,16 +21,15 @@ class PlayerTrackDetails extends HookConsumerWidget {
if (albumArt != null)
Padding(
padding: const EdgeInsets.all(5.0),
child: CachedNetworkImage(
imageUrl: albumArt!,
maxHeightDiskCache: 50,
maxWidthDiskCache: 50,
cacheKey: albumArt,
child: UniversalImage(
path: albumArt!,
height: 50,
width: 50,
placeholder: (context, url) {
return Container(
return Image.asset(
"assets/album-placeholder.png",
height: 50,
width: 50,
color: Theme.of(context).primaryColor,
);
},
),
Expand Down
Loading

0 comments on commit e206f16

Please sign in to comment.