mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-13 07:55:18 +00:00
Merge branch 'KRTirtho:master' into feature_duration_matching
This commit is contained in:
commit
7ae453b81d
BIN
assets/album-placeholder.png
Normal file
BIN
assets/album-placeholder.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 94 KiB |
BIN
assets/user-placeholder.png
Normal file
BIN
assets/user-placeholder.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 47 KiB |
@ -148,20 +148,38 @@ class Sidebar extends HookConsumerWidget {
|
||||
child: CircularProgressIndicator(),
|
||||
)
|
||||
else if (data != null)
|
||||
Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundImage:
|
||||
CachedNetworkImageProvider(avatarImg),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
data.displayName ?? "Guest",
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
Flexible(
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundImage:
|
||||
CachedNetworkImageProvider(avatarImg),
|
||||
onBackgroundImageError:
|
||||
(exception, stackTrace) =>
|
||||
Image.asset(
|
||||
"assets/user-placeholder.png",
|
||||
height: 16,
|
||||
width: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(
|
||||
width: 10,
|
||||
),
|
||||
Flexible(
|
||||
child: Text(
|
||||
data.displayName ?? "Guest",
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.fade,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
@ -176,6 +194,12 @@ class Sidebar extends HookConsumerWidget {
|
||||
child: CircleAvatar(
|
||||
backgroundImage:
|
||||
CachedNetworkImageProvider(avatarImg),
|
||||
onBackgroundImageError: (exception, stackTrace) =>
|
||||
Image.asset(
|
||||
"assets/user-placeholder.png",
|
||||
height: 16,
|
||||
width: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@ -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>[],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@ -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()),
|
||||
]),
|
||||
),
|
||||
),
|
||||
|
@ -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",
|
||||
@ -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 metadata = await tagProcessor.getTagsFromByteArray(bytes);
|
||||
return {
|
||||
"metadata": metadata,
|
||||
"file": f,
|
||||
"art": imageFile?.path,
|
||||
"duration": duration,
|
||||
};
|
||||
},
|
||||
),
|
||||
));
|
||||
|
||||
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();
|
||||
|
||||
final tracks = filesWithMetadata
|
||||
.map(
|
||||
(fileWithMetadata) => TypeConversionUtils.localTrack_X_Track(
|
||||
fileWithMetadata["metadata"] as Metadata,
|
||||
fileWithMetadata["file"] as File),
|
||||
)
|
||||
.toList();
|
||||
|
||||
localTracks.value = tracks;
|
||||
})();
|
||||
|
||||
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()),
|
||||
)
|
||||
],
|
||||
);
|
||||
;
|
||||
}
|
||||
}
|
||||
|
@ -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';
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
|
@ -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],
|
||||
);
|
||||
|
||||
|
@ -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';
|
||||
@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
@ -10,6 +10,7 @@ import 'package:spotube/components/Player/PlayerActions.dart';
|
||||
import 'package:spotube/components/Player/PlayerControls.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/useBreakpoints.dart';
|
||||
import 'package:spotube/hooks/useCustomStatusBarColor.dart';
|
||||
import 'package:spotube/hooks/usePaletteColor.dart';
|
||||
@ -57,10 +58,7 @@ class PlayerView extends HookConsumerWidget {
|
||||
body: Container(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: CachedNetworkImageProvider(
|
||||
albumArt,
|
||||
cacheKey: albumArt,
|
||||
),
|
||||
image: UniversalImage.imageProvider(albumArt),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
@ -121,10 +119,8 @@ class PlayerView extends HookConsumerWidget {
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: CircleAvatar(
|
||||
backgroundImage: CachedNetworkImageProvider(
|
||||
albumArt,
|
||||
cacheKey: albumArt,
|
||||
),
|
||||
backgroundImage:
|
||||
UniversalImage.imageProvider(albumArt),
|
||||
radius: MediaQuery.of(context).size.width *
|
||||
(breakpoint.isSm ? 0.4 : 0.3),
|
||||
),
|
||||
|
@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/components/Library/UserLocalTracks.dart';
|
||||
import 'package:spotube/models/SpotubeTrack.dart';
|
||||
import 'package:spotube/provider/Playback.dart';
|
||||
import 'package:spotube/provider/UserPreferences.dart';
|
||||
@ -89,6 +90,7 @@ class DownloadTrackButton extends HookConsumerWidget {
|
||||
},
|
||||
onDone: () async {
|
||||
status.value = TrackStatus.done;
|
||||
ref.refresh(localTracksProvider);
|
||||
await Future.delayed(
|
||||
const Duration(seconds: 3),
|
||||
() {
|
||||
@ -187,7 +189,11 @@ class DownloadTrackButton extends HookConsumerWidget {
|
||||
icon: Icon(
|
||||
outputFileExists ? Icons.download_done_rounded : Icons.download_rounded,
|
||||
),
|
||||
onPressed: track != null && track is SpotubeTrack ? _downloadTrack : null,
|
||||
onPressed: track != null &&
|
||||
track is SpotubeTrack &&
|
||||
playback.playlist?.isLocal != true
|
||||
? _downloadTrack
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,11 +1,11 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart' hide Action;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotify/spotify.dart' hide Image;
|
||||
import 'package:spotube/components/Shared/AdaptivePopupMenuButton.dart';
|
||||
import 'package:spotube/components/Shared/LinkText.dart';
|
||||
import 'package:spotube/components/Shared/UniversalImage.dart';
|
||||
import 'package:spotube/hooks/useBreakpoints.dart';
|
||||
import 'package:spotube/hooks/useForceUpdate.dart';
|
||||
import 'package:spotube/models/Logger.dart';
|
||||
@ -32,6 +32,8 @@ class TrackTile extends HookConsumerWidget {
|
||||
|
||||
final bool isChecked;
|
||||
final bool showCheck;
|
||||
|
||||
final bool isLocal;
|
||||
final void Function(bool?)? onCheckChange;
|
||||
|
||||
TrackTile(
|
||||
@ -46,12 +48,17 @@ class TrackTile extends HookConsumerWidget {
|
||||
this.showAlbum = true,
|
||||
this.isChecked = false,
|
||||
this.showCheck = false,
|
||||
this.isLocal = false,
|
||||
this.onCheckChange,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final isReallyLocal = isLocal ||
|
||||
ref.watch(
|
||||
playbackProvider.select((s) => s.playlist?.isLocal == true),
|
||||
);
|
||||
final breakpoint = useBreakpoints();
|
||||
final auth = ref.watch(authProvider);
|
||||
final spotify = ref.watch(spotifyProvider);
|
||||
@ -60,7 +67,7 @@ class TrackTile extends HookConsumerWidget {
|
||||
final savedTracksSnapshot = ref.watch(currentUserSavedTracksQuery);
|
||||
|
||||
final isSaved = savedTracksSnapshot.asData?.value.any(
|
||||
(e) => track.value.id! == e.id,
|
||||
(e) => track.value.id == e.id,
|
||||
) ??
|
||||
false;
|
||||
|
||||
@ -210,17 +217,17 @@ class TrackTile extends HookConsumerWidget {
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(5)),
|
||||
child: CachedNetworkImage(
|
||||
child: UniversalImage(
|
||||
path: thumbnailUrl!,
|
||||
height: 40,
|
||||
width: 40,
|
||||
placeholder: (context, url) {
|
||||
return Container(
|
||||
return Image.asset(
|
||||
"assets/album-placeholder.png",
|
||||
height: 40,
|
||||
width: 40,
|
||||
color: Theme.of(context).primaryColor,
|
||||
);
|
||||
},
|
||||
imageUrl: thumbnailUrl!,
|
||||
maxHeightDiskCache: 40,
|
||||
maxWidthDiskCache: 40,
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -248,61 +255,70 @@ class TrackTile extends HookConsumerWidget {
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
TypeConversionUtils.artists_X_ClickableArtists(
|
||||
track.value.artists ?? [],
|
||||
textStyle: TextStyle(
|
||||
fontSize:
|
||||
breakpoint.isLessThan(Breakpoints.lg) ? 12 : 14)),
|
||||
isReallyLocal
|
||||
? Text(
|
||||
TypeConversionUtils.artists_X_String<Artist>(
|
||||
track.value.artists ?? []),
|
||||
)
|
||||
: TypeConversionUtils.artists_X_ClickableArtists(
|
||||
track.value.artists ?? [],
|
||||
textStyle: TextStyle(
|
||||
fontSize: breakpoint.isLessThan(Breakpoints.lg)
|
||||
? 12
|
||||
: 14)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (breakpoint.isMoreThan(Breakpoints.md) && showAlbum)
|
||||
Expanded(
|
||||
child: LinkText(
|
||||
track.value.album!.name!,
|
||||
"/album/${track.value.album?.id}",
|
||||
extra: track.value.album,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
child: isReallyLocal
|
||||
? Text(track.value.album?.name ?? "")
|
||||
: LinkText(
|
||||
track.value.album!.name!,
|
||||
"/album/${track.value.album?.id}",
|
||||
extra: track.value.album,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (!breakpoint.isSm) ...[
|
||||
const SizedBox(width: 10),
|
||||
Text(duration),
|
||||
],
|
||||
const SizedBox(width: 10),
|
||||
AdaptiveActions(
|
||||
actions: [
|
||||
if (auth.isLoggedIn)
|
||||
if (!isReallyLocal)
|
||||
AdaptiveActions(
|
||||
actions: [
|
||||
if (auth.isLoggedIn)
|
||||
Action(
|
||||
icon: Icon(isSaved
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_border_rounded),
|
||||
text: const Text("Save as favorite"),
|
||||
onPressed: () {
|
||||
actionFavorite(isSaved);
|
||||
},
|
||||
),
|
||||
if (auth.isLoggedIn)
|
||||
Action(
|
||||
icon: const Icon(Icons.add_box_rounded),
|
||||
text: const Text("Add To playlist"),
|
||||
onPressed: actionAddToPlaylist,
|
||||
),
|
||||
if (userPlaylist && auth.isLoggedIn)
|
||||
Action(
|
||||
icon: const Icon(Icons.remove_circle_outline_rounded),
|
||||
text: const Text("Remove from playlist"),
|
||||
onPressed: actionRemoveFromPlaylist,
|
||||
),
|
||||
Action(
|
||||
icon: Icon(isSaved
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_border_rounded),
|
||||
text: const Text("Save as favorite"),
|
||||
icon: const Icon(Icons.share_rounded),
|
||||
text: const Text("Share"),
|
||||
onPressed: () {
|
||||
actionFavorite(isSaved);
|
||||
actionShare(track.value);
|
||||
},
|
||||
),
|
||||
if (auth.isLoggedIn)
|
||||
Action(
|
||||
icon: const Icon(Icons.add_box_rounded),
|
||||
text: const Text("Add To playlist"),
|
||||
onPressed: actionAddToPlaylist,
|
||||
),
|
||||
if (userPlaylist && auth.isLoggedIn)
|
||||
Action(
|
||||
icon: const Icon(Icons.remove_circle_outline_rounded),
|
||||
text: const Text("Remove from playlist"),
|
||||
onPressed: actionRemoveFromPlaylist,
|
||||
),
|
||||
Action(
|
||||
icon: const Icon(Icons.share_rounded),
|
||||
text: const Text("Share"),
|
||||
onPressed: () {
|
||||
actionShare(track.value);
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
@ -17,6 +17,7 @@ class TracksTableView extends HookConsumerWidget {
|
||||
final bool userPlaylist;
|
||||
final String? playlistId;
|
||||
final bool bottomSpace;
|
||||
final bool isSliver;
|
||||
|
||||
final Widget? heading;
|
||||
const TracksTableView(
|
||||
@ -27,6 +28,7 @@ class TracksTableView extends HookConsumerWidget {
|
||||
this.playlistId,
|
||||
this.heading,
|
||||
this.bottomSpace = false,
|
||||
this.isSliver = true,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
@ -48,156 +50,153 @@ class TracksTableView extends HookConsumerWidget {
|
||||
[tracks],
|
||||
);
|
||||
|
||||
return SliverList(
|
||||
delegate: SliverChildListDelegate(
|
||||
[
|
||||
if (heading != null) heading!,
|
||||
Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: selected.value.length == tracks.length,
|
||||
onChanged: (checked) {
|
||||
if (!showCheck.value) showCheck.value = true;
|
||||
if (checked == true) {
|
||||
selected.value = tracks.map((s) => s.id!).toList();
|
||||
} else {
|
||||
selected.value = [];
|
||||
showCheck.value = false;
|
||||
}
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
"#",
|
||||
textAlign: TextAlign.center,
|
||||
final children = [
|
||||
if (heading != null) heading!,
|
||||
Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: selected.value.length == tracks.length,
|
||||
onChanged: (checked) {
|
||||
if (!showCheck.value) showCheck.value = true;
|
||||
if (checked == true) {
|
||||
selected.value = tracks.map((s) => s.id!).toList();
|
||||
} else {
|
||||
selected.value = [];
|
||||
showCheck.value = false;
|
||||
}
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
"#",
|
||||
textAlign: TextAlign.center,
|
||||
style: tableHeadStyle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
"Title",
|
||||
style: tableHeadStyle,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// used alignment of this table-head
|
||||
if (breakpoint.isMoreThan(Breakpoints.md)) ...[
|
||||
const SizedBox(width: 100),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
"Album",
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: tableHeadStyle,
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
"Title",
|
||||
style: tableHeadStyle,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// used alignment of this table-head
|
||||
if (breakpoint.isMoreThan(Breakpoints.md)) ...[
|
||||
const SizedBox(width: 100),
|
||||
Expanded(
|
||||
)
|
||||
],
|
||||
if (!breakpoint.isSm) ...[
|
||||
const SizedBox(width: 10),
|
||||
Text("Time", style: tableHeadStyle),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
PopupMenuButton(
|
||||
itemBuilder: (context) {
|
||||
return [
|
||||
PopupMenuItem(
|
||||
enabled: selected.value.isNotEmpty,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.file_download_outlined),
|
||||
Text(
|
||||
"Album",
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: tableHeadStyle,
|
||||
"Download ${selectedTracks.isNotEmpty ? "(${selectedTracks.length})" : ""}",
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
if (!breakpoint.isSm) ...[
|
||||
const SizedBox(width: 10),
|
||||
Text("Time", style: tableHeadStyle),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
PopupMenuButton(
|
||||
itemBuilder: (context) {
|
||||
return [
|
||||
PopupMenuItem(
|
||||
enabled: selected.value.isNotEmpty,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.file_download_outlined),
|
||||
Text(
|
||||
"Download ${selectedTracks.isNotEmpty ? "(${selectedTracks.length})" : ""}",
|
||||
),
|
||||
],
|
||||
),
|
||||
value: "download",
|
||||
),
|
||||
];
|
||||
},
|
||||
onSelected: (action) async {
|
||||
switch (action) {
|
||||
case "download":
|
||||
{
|
||||
final isConfirmed = await showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return const DownloadConfirmationDialog();
|
||||
});
|
||||
if (isConfirmed != true) return;
|
||||
for (final selectedTrack in selectedTracks) {
|
||||
downloader.addToQueue(selectedTrack);
|
||||
}
|
||||
selected.value = [];
|
||||
showCheck.value = false;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
value: "download",
|
||||
),
|
||||
];
|
||||
},
|
||||
onSelected: (action) async {
|
||||
switch (action) {
|
||||
case "download":
|
||||
{
|
||||
final isConfirmed = await showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return const DownloadConfirmationDialog();
|
||||
});
|
||||
if (isConfirmed != true) return;
|
||||
for (final selectedTrack in selectedTracks) {
|
||||
downloader.addToQueue(selectedTrack);
|
||||
}
|
||||
selected.value = [];
|
||||
showCheck.value = false;
|
||||
break;
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
default:
|
||||
}
|
||||
},
|
||||
),
|
||||
...tracks.asMap().entries.map((track) {
|
||||
String? thumbnailUrl = TypeConversionUtils.image_X_UrlString(
|
||||
track.value.album?.images,
|
||||
index: (track.value.album?.images?.length ?? 1) - 1,
|
||||
);
|
||||
String duration =
|
||||
"${track.value.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}";
|
||||
return InkWell(
|
||||
onLongPress: () {
|
||||
showCheck.value = true;
|
||||
selected.value = [...selected.value, track.value.id!];
|
||||
},
|
||||
onTap: () {
|
||||
if (showCheck.value) {
|
||||
final alreadyChecked =
|
||||
selected.value.contains(track.value.id);
|
||||
if (alreadyChecked) {
|
||||
selected.value = selected.value
|
||||
.where((id) => id != track.value.id)
|
||||
.toList();
|
||||
} else {
|
||||
selected.value = [...selected.value, track.value.id!];
|
||||
}
|
||||
} else {
|
||||
onTrackPlayButtonPressed?.call(track.value);
|
||||
}
|
||||
},
|
||||
child: TrackTile(
|
||||
playback,
|
||||
playlistId: playlistId,
|
||||
track: track,
|
||||
duration: duration,
|
||||
thumbnailUrl: thumbnailUrl,
|
||||
userPlaylist: userPlaylist,
|
||||
isActive: playback.track?.id == track.value.id,
|
||||
onTrackPlayButtonPressed: onTrackPlayButtonPressed,
|
||||
isChecked: selected.value.contains(track.value.id),
|
||||
showCheck: showCheck.value,
|
||||
onCheckChange: (checked) {
|
||||
if (checked == true) {
|
||||
selected.value = [...selected.value, track.value.id!];
|
||||
} else {
|
||||
selected.value = selected.value
|
||||
.where((id) => id != track.value.id)
|
||||
.toList();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
if (bottomSpace) const SizedBox(height: 70),
|
||||
],
|
||||
),
|
||||
);
|
||||
...tracks.asMap().entries.map((track) {
|
||||
String? thumbnailUrl = TypeConversionUtils.image_X_UrlString(
|
||||
track.value.album?.images,
|
||||
index: (track.value.album?.images?.length ?? 1) - 1,
|
||||
);
|
||||
String duration =
|
||||
"${track.value.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}";
|
||||
return InkWell(
|
||||
onLongPress: () {
|
||||
showCheck.value = true;
|
||||
selected.value = [...selected.value, track.value.id!];
|
||||
},
|
||||
onTap: () {
|
||||
if (showCheck.value) {
|
||||
final alreadyChecked = selected.value.contains(track.value.id);
|
||||
if (alreadyChecked) {
|
||||
selected.value =
|
||||
selected.value.where((id) => id != track.value.id).toList();
|
||||
} else {
|
||||
selected.value = [...selected.value, track.value.id!];
|
||||
}
|
||||
} else {
|
||||
onTrackPlayButtonPressed?.call(track.value);
|
||||
}
|
||||
},
|
||||
child: TrackTile(
|
||||
playback,
|
||||
playlistId: playlistId,
|
||||
track: track,
|
||||
duration: duration,
|
||||
thumbnailUrl: thumbnailUrl,
|
||||
userPlaylist: userPlaylist,
|
||||
isActive: playback.track?.id == track.value.id,
|
||||
onTrackPlayButtonPressed: onTrackPlayButtonPressed,
|
||||
isChecked: selected.value.contains(track.value.id),
|
||||
showCheck: showCheck.value,
|
||||
onCheckChange: (checked) {
|
||||
if (checked == true) {
|
||||
selected.value = [...selected.value, track.value.id!];
|
||||
} else {
|
||||
selected.value =
|
||||
selected.value.where((id) => id != track.value.id).toList();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
if (bottomSpace) const SizedBox(height: 70),
|
||||
];
|
||||
if (isSliver) {
|
||||
return SliverList(delegate: SliverChildListDelegate(children));
|
||||
}
|
||||
return ListView(children: children);
|
||||
}
|
||||
}
|
||||
|
98
lib/components/Shared/UniversalImage.dart
Normal file
98
lib/components/Shared/UniversalImage.dart
Normal file
@ -0,0 +1,98 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
||||
class UniversalImage extends HookWidget {
|
||||
final String path;
|
||||
final double? height;
|
||||
final double? width;
|
||||
final double scale;
|
||||
final PlaceholderWidgetBuilder? placeholder;
|
||||
const UniversalImage({
|
||||
required this.path,
|
||||
this.height,
|
||||
this.width,
|
||||
this.placeholder,
|
||||
this.scale = 1,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
static ImageProvider imageProvider(
|
||||
String path, {
|
||||
final double? height,
|
||||
final double? width,
|
||||
final double scale = 1,
|
||||
}) {
|
||||
if (path.startsWith("http")) {
|
||||
return CachedNetworkImageProvider(
|
||||
path,
|
||||
maxHeight: height?.toInt(),
|
||||
maxWidth: width?.toInt(),
|
||||
cacheKey: path,
|
||||
scale: scale,
|
||||
);
|
||||
} else if (Uri.tryParse(path) != null) {
|
||||
return FileImage(File(path), scale: scale);
|
||||
}
|
||||
return MemoryImage(base64Decode(path), scale: scale);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (path.startsWith("http")) {
|
||||
return CachedNetworkImage(
|
||||
imageUrl: path,
|
||||
height: height,
|
||||
width: width,
|
||||
maxWidthDiskCache: width?.toInt(),
|
||||
maxHeightDiskCache: height?.toInt(),
|
||||
memCacheHeight: height?.toInt(),
|
||||
memCacheWidth: width?.toInt(),
|
||||
placeholder: placeholder,
|
||||
cacheKey: path,
|
||||
);
|
||||
} else if (Uri.tryParse(path) != null) {
|
||||
return Image.file(
|
||||
File(path),
|
||||
width: width,
|
||||
height: height,
|
||||
cacheHeight: height?.toInt(),
|
||||
cacheWidth: width?.toInt(),
|
||||
scale: scale,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return placeholder?.call(context, error.toString()) ??
|
||||
Image.asset(
|
||||
"assets/placeholder.png",
|
||||
width: width,
|
||||
height: height,
|
||||
cacheHeight: height?.toInt(),
|
||||
cacheWidth: width?.toInt(),
|
||||
scale: scale,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
return Image.memory(
|
||||
base64Decode(path),
|
||||
width: width,
|
||||
height: height,
|
||||
cacheHeight: height?.toInt(),
|
||||
cacheWidth: width?.toInt(),
|
||||
scale: scale,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return placeholder?.call(context, error.toString()) ??
|
||||
Image.asset(
|
||||
"assets/placeholder.png",
|
||||
width: width,
|
||||
height: height,
|
||||
cacheHeight: height?.toInt(),
|
||||
cacheWidth: width?.toInt(),
|
||||
scale: scale,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:palette_generator/palette_generator.dart';
|
||||
import 'package:spotube/components/Shared/UniversalImage.dart';
|
||||
|
||||
final _paletteColorState = StateProvider<PaletteColor>(
|
||||
(ref) {
|
||||
@ -18,11 +19,10 @@ PaletteColor usePaletteColor(String imageUrl, WidgetRef ref) {
|
||||
useEffect(() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((timeStamp) async {
|
||||
final palette = await PaletteGenerator.fromImageProvider(
|
||||
CachedNetworkImageProvider(
|
||||
UniversalImage.imageProvider(
|
||||
imageUrl,
|
||||
cacheKey: imageUrl,
|
||||
maxHeight: 50,
|
||||
maxWidth: 50,
|
||||
height: 50,
|
||||
width: 50,
|
||||
),
|
||||
);
|
||||
if (!mounted()) return;
|
||||
@ -49,11 +49,10 @@ PaletteGenerator usePaletteGenerator(
|
||||
useEffect(() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((timeStamp) async {
|
||||
final newPalette = await PaletteGenerator.fromImageProvider(
|
||||
CachedNetworkImageProvider(
|
||||
UniversalImage.imageProvider(
|
||||
imageUrl,
|
||||
cacheKey: imageUrl,
|
||||
maxHeight: 50,
|
||||
maxWidth: 50,
|
||||
height: 50,
|
||||
width: 50,
|
||||
),
|
||||
);
|
||||
if (!mounted()) return;
|
||||
|
@ -61,12 +61,14 @@ class CurrentPlaylist {
|
||||
String id;
|
||||
String name;
|
||||
String thumbnail;
|
||||
bool isLocal;
|
||||
|
||||
CurrentPlaylist({
|
||||
required this.tracks,
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.thumbnail,
|
||||
this.isLocal = false,
|
||||
});
|
||||
|
||||
static CurrentPlaylist fromJson(Map<String, dynamic> map) {
|
||||
@ -76,6 +78,7 @@ class CurrentPlaylist {
|
||||
map["tracks"].map((track) => Track.fromJson(track)).toList()),
|
||||
name: map["name"],
|
||||
thumbnail: map["thumbnail"],
|
||||
isLocal: map["isLocal"],
|
||||
);
|
||||
}
|
||||
|
||||
@ -107,6 +110,7 @@ class CurrentPlaylist {
|
||||
"name": name,
|
||||
"tracks": tracks.map((track) => track.toJson()).toList(),
|
||||
"thumbnail": thumbnail,
|
||||
"isLocal": isLocal,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
143
lib/models/Id3Tags.dart
Normal file
143
lib/models/Id3Tags.dart
Normal file
@ -0,0 +1,143 @@
|
||||
import 'package:dart_tags/dart_tags.dart';
|
||||
|
||||
class Id3Tags {
|
||||
Id3Tags({
|
||||
this.tsse,
|
||||
this.title,
|
||||
this.album,
|
||||
this.tpe2,
|
||||
this.comment,
|
||||
this.tcop,
|
||||
this.tdrc,
|
||||
this.genre,
|
||||
this.picture,
|
||||
});
|
||||
|
||||
String? tsse;
|
||||
String? title;
|
||||
String? album;
|
||||
String? tpe2;
|
||||
Comment? comment;
|
||||
String? tcop;
|
||||
String? tdrc;
|
||||
String? genre;
|
||||
AttachedPicture? picture;
|
||||
|
||||
factory Id3Tags.fromJson(Map<String, dynamic> json) => Id3Tags(
|
||||
tsse: json["TSSE"],
|
||||
title: json["title"],
|
||||
album: json["album"],
|
||||
tpe2: json["TPE2"],
|
||||
comment: json["comment"]?["eng:"] is Comment
|
||||
? json["comment"]["eng:"]
|
||||
: CommentJson.fromJson(Map.from(
|
||||
json["comment"]?["eng:"] ?? {},
|
||||
)),
|
||||
tcop: json["TCOP"],
|
||||
tdrc: json["TDRC"],
|
||||
genre: json["genre"],
|
||||
picture: json["picture"]?["Cover (front)"] is AttachedPicture
|
||||
? json["picture"]["Cover (front)"]
|
||||
: AttachedPictureJson.fromJson(Map.from(
|
||||
json["picture"]?["Cover (front)"] ?? {},
|
||||
)),
|
||||
);
|
||||
|
||||
factory Id3Tags.fromId3v1Tags(Id3v1Tags v1tags) => Id3Tags(
|
||||
album: v1tags.album,
|
||||
comment: Comment("", "", v1tags.comment ?? ""),
|
||||
genre: v1tags.genre,
|
||||
title: v1tags.title,
|
||||
tcop: v1tags.year,
|
||||
tdrc: v1tags.year,
|
||||
tpe2: v1tags.artist,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"TSSE": tsse,
|
||||
"title": title,
|
||||
"album": album,
|
||||
"TPE2": tpe2,
|
||||
"comment": comment,
|
||||
"TCOP": tcop,
|
||||
"TDRC": tdrc,
|
||||
"genre": genre,
|
||||
"picture": picture,
|
||||
};
|
||||
}
|
||||
|
||||
extension CommentJson on Comment {
|
||||
static fromJson(Map<String, dynamic> json) => Comment(
|
||||
json["lang"] ?? "",
|
||||
json["description"] ?? "",
|
||||
json["comment"] ?? "",
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"comment": comment,
|
||||
"description": description,
|
||||
"key": key,
|
||||
"lang": lang,
|
||||
};
|
||||
}
|
||||
|
||||
extension AttachedPictureJson on AttachedPicture {
|
||||
static fromJson(Map<String, dynamic> json) => AttachedPicture(
|
||||
json["mime"] ?? "",
|
||||
json["imageTypeCode"] ?? 0,
|
||||
json["description"] ?? "",
|
||||
List<int>.from(json["imageData"] ?? []),
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"description": description,
|
||||
"imageData": imageData,
|
||||
"imageData64": imageData64,
|
||||
"imageType": imageType,
|
||||
"imageTypeCode": imageTypeCode,
|
||||
"key": key,
|
||||
"mime": mime,
|
||||
};
|
||||
}
|
||||
|
||||
class Id3v1Tags {
|
||||
String? title;
|
||||
String? artist;
|
||||
String? album;
|
||||
String? year;
|
||||
String? comment;
|
||||
String? track;
|
||||
String? genre;
|
||||
|
||||
Id3v1Tags({
|
||||
this.title,
|
||||
this.artist,
|
||||
this.album,
|
||||
this.year,
|
||||
this.comment,
|
||||
this.track,
|
||||
this.genre,
|
||||
});
|
||||
|
||||
Id3v1Tags.fromJson(Map<String, dynamic> json) {
|
||||
title = json['title'];
|
||||
artist = json['artist'];
|
||||
album = json['album'];
|
||||
year = json['year'];
|
||||
comment = json['comment'];
|
||||
track = json['track'];
|
||||
genre = json['genre'];
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'title': title,
|
||||
'artist': artist,
|
||||
'album': album,
|
||||
'year': year,
|
||||
'comment': comment,
|
||||
'track': track,
|
||||
'genre': genre,
|
||||
};
|
||||
}
|
||||
}
|
@ -1,17 +1,23 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dart_tags/dart_tags.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:queue/queue.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/components/Library/UserLocalTracks.dart';
|
||||
import 'package:spotube/models/Id3Tags.dart';
|
||||
import 'package:spotube/models/Logger.dart';
|
||||
import 'package:spotube/models/SpotubeTrack.dart';
|
||||
import 'package:spotube/provider/Playback.dart';
|
||||
import 'package:spotube/provider/UserPreferences.dart';
|
||||
import 'package:spotube/provider/YouTube.dart';
|
||||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
import 'package:youtube_explode_dart/youtube_explode_dart.dart' hide Comment;
|
||||
|
||||
Queue queueInstance = Queue(delay: const Duration(seconds: 5));
|
||||
Queue grabberQueue = Queue(delay: const Duration(seconds: 5));
|
||||
@ -89,6 +95,54 @@ class Downloader with ChangeNotifier {
|
||||
logger.v(
|
||||
"[addToQueue] Download of ${file.path} is done successfully",
|
||||
);
|
||||
|
||||
final response = await get(
|
||||
Uri.parse(
|
||||
TypeConversionUtils.image_X_UrlString(
|
||||
track.album?.images ?? [],
|
||||
),
|
||||
),
|
||||
);
|
||||
final picture = AttachedPicture.base64(
|
||||
response.headers["Content-Type"] ?? "image/jpeg",
|
||||
3,
|
||||
track.name!,
|
||||
base64Encode(response.bodyBytes),
|
||||
);
|
||||
// write id3 metadata
|
||||
final tag = Id3Tags(
|
||||
album: track.album?.name,
|
||||
picture: picture,
|
||||
title: track.name,
|
||||
genre: "Spotube",
|
||||
tcop: track.ytTrack.uploadDate?.year.toString(),
|
||||
tdrc: track.ytTrack.uploadDate?.year.toString(),
|
||||
tpe2: TypeConversionUtils.artists_X_String<Artist>(
|
||||
track.artists ?? [],
|
||||
),
|
||||
tsse: "",
|
||||
comment: Comment(
|
||||
"eng",
|
||||
track.ytTrack.description,
|
||||
track.ytTrack.title,
|
||||
),
|
||||
);
|
||||
|
||||
logger.v("[addToQueue] Writing metadata to ${file.path}");
|
||||
|
||||
final taggedMp3 = await tagProcessor.putTagsToByteArray(
|
||||
file.readAsBytes(),
|
||||
[
|
||||
Tag()
|
||||
..type = "ID3"
|
||||
..version = "2.4.0"
|
||||
..tags = tag.toJson()
|
||||
],
|
||||
);
|
||||
await file.writeAsBytes(taggedMp3);
|
||||
logger.v(
|
||||
"[addToQueue] Writing metadata to ${file.path} is successful",
|
||||
);
|
||||
} catch (e, stack) {
|
||||
logger.e(
|
||||
"[addToQueue] Failed download of ${file.path}",
|
||||
|
@ -208,7 +208,10 @@ class Playback extends PersistedChangeNotifier {
|
||||
artist: TypeConversionUtils.artists_X_String(
|
||||
track.artists ?? <ArtistSimple>[]),
|
||||
artUri: Uri.parse(
|
||||
TypeConversionUtils.image_X_UrlString(track.album?.images)),
|
||||
TypeConversionUtils.image_X_UrlString(
|
||||
track.album?.images,
|
||||
),
|
||||
),
|
||||
duration: track.ytTrack.duration,
|
||||
);
|
||||
mobileAudioService?.addItem(tag);
|
||||
@ -216,7 +219,11 @@ class Playback extends PersistedChangeNotifier {
|
||||
this.track = track;
|
||||
notifyListeners();
|
||||
updatePersistence();
|
||||
await player.play(UrlSource(track.ytUri));
|
||||
await player.play(
|
||||
track.ytUri.startsWith("http")
|
||||
? UrlSource(track.ytUri)
|
||||
: DeviceFileSource(track.ytUri),
|
||||
);
|
||||
status = PlaybackStatus.playing;
|
||||
notifyListeners();
|
||||
} catch (e, stack) {
|
||||
|
@ -2,11 +2,16 @@
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dart_tags/dart_tags.dart';
|
||||
import 'package:flutter/widgets.dart' hide Image;
|
||||
import 'package:flutter_media_metadata/flutter_media_metadata.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:spotube/components/Shared/LinkText.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/models/Id3Tags.dart';
|
||||
import 'package:spotube/models/SpotubeTrack.dart';
|
||||
import 'package:spotube/utils/primitive_utils.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||
|
||||
abstract class TypeConversionUtils {
|
||||
static String image_X_UrlString(List<Image>? images, {int index = 0}) {
|
||||
@ -85,31 +90,61 @@ abstract class TypeConversionUtils {
|
||||
return track;
|
||||
}
|
||||
|
||||
static Track localTrack_X_Track(Metadata metadata, File file) {
|
||||
final track = Track();
|
||||
static SpotubeTrack localTrack_X_Track(
|
||||
List<Tag> metadatas,
|
||||
File file,
|
||||
Duration duration,
|
||||
String? art,
|
||||
) {
|
||||
final v2Tags =
|
||||
metadatas.firstWhereOrNull((s) => s.version == "2.4.0")?.tags;
|
||||
final v1Tags =
|
||||
metadatas.firstWhereOrNull((s) => s.version != "2.4.0")?.tags;
|
||||
final metadata = v2Tags != null
|
||||
? Id3Tags.fromJson(v2Tags)
|
||||
: Id3Tags.fromId3v1Tags(Id3v1Tags.fromJson(v1Tags ?? {}));
|
||||
final track = SpotubeTrack(
|
||||
Video(
|
||||
VideoId("dQw4w9WgXcQ"),
|
||||
basenameWithoutExtension(file.path),
|
||||
metadata.tpe2 ?? "",
|
||||
ChannelId(
|
||||
"https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw",
|
||||
),
|
||||
DateTime.now(),
|
||||
DateTime.now(),
|
||||
"",
|
||||
duration,
|
||||
ThumbnailSet(metadata.title ?? ""),
|
||||
[],
|
||||
const Engagement(0, 0, 0),
|
||||
false,
|
||||
),
|
||||
file.path,
|
||||
[],
|
||||
);
|
||||
track.album = Album()
|
||||
..name = metadata.albumName
|
||||
..name = metadata.album ?? "Spotube"
|
||||
..images = [if (art != null) Image()..url = art]
|
||||
..genres = [if (metadata.genre != null) metadata.genre!]
|
||||
..artists = [
|
||||
Artist()
|
||||
..name = metadata.albumArtistName
|
||||
..id = metadata.albumArtistName
|
||||
..name = metadata.tpe2 ?? "Spotube"
|
||||
..id = metadata.tpe2 ?? "Spotube"
|
||||
..type = "artist",
|
||||
]
|
||||
..id = "${metadata.albumName}${metadata.albumLength}";
|
||||
track.artists = metadata.trackArtistNames
|
||||
?.map((name) => Artist()
|
||||
..name = name
|
||||
..id = name)
|
||||
.toList();
|
||||
..id = metadata.album;
|
||||
track.artists = [
|
||||
Artist()
|
||||
..name = metadata.tpe2 ?? "Spotube"
|
||||
..id = metadata.tpe2 ?? "Spotube"
|
||||
];
|
||||
|
||||
track.discNumber = metadata.discNumber;
|
||||
track.durationMs = metadata.trackDuration;
|
||||
track.id = "${metadata.trackName}${metadata.trackDuration}";
|
||||
track.name = metadata.trackName;
|
||||
track.trackNumber = metadata.trackNumber;
|
||||
track.id = metadata.title ?? basenameWithoutExtension(file.path);
|
||||
track.name = metadata.title ?? basenameWithoutExtension(file.path);
|
||||
track.type = "track";
|
||||
track.uri = file.path;
|
||||
track.durationMs = duration.inMilliseconds;
|
||||
|
||||
return track;
|
||||
}
|
||||
|
@ -8,7 +8,6 @@
|
||||
|
||||
#include <audioplayers_linux/audioplayers_linux_plugin.h>
|
||||
#include <bitsdojo_window_linux/bitsdojo_window_plugin.h>
|
||||
#include <flutter_media_metadata/flutter_media_metadata_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
@ -18,9 +17,6 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) bitsdojo_window_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "BitsdojoWindowPlugin");
|
||||
bitsdojo_window_plugin_register_with_registrar(bitsdojo_window_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) flutter_media_metadata_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterMediaMetadataPlugin");
|
||||
flutter_media_metadata_plugin_register_with_registrar(flutter_media_metadata_registrar);
|
||||
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||
|
@ -5,7 +5,6 @@
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
audioplayers_linux
|
||||
bitsdojo_window_linux
|
||||
flutter_media_metadata
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
|
@ -9,7 +9,6 @@ import audio_service
|
||||
import audio_session
|
||||
import audioplayers_darwin
|
||||
import bitsdojo_window_macos
|
||||
import flutter_media_metadata
|
||||
import package_info_plus_macos
|
||||
import path_provider_macos
|
||||
import shared_preferences_macos
|
||||
@ -21,7 +20,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
|
||||
AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin"))
|
||||
BitsdojoWindowPlugin.register(with: registry.registrar(forPlugin: "BitsdojoWindowPlugin"))
|
||||
FlutterMediaMetadataPlugin.register(with: registry.registrar(forPlugin: "FlutterMediaMetadataPlugin"))
|
||||
FLTPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FLTPackageInfoPlusPlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
|
73
pubspec.lock
73
pubspec.lock
@ -140,7 +140,7 @@ packages:
|
||||
name: async
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.8.2"
|
||||
version: "2.9.0"
|
||||
audio_service:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -357,14 +357,7 @@ packages:
|
||||
name: characters
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
charcode:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: charcode
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.3.1"
|
||||
version: "1.2.1"
|
||||
checked_yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -378,7 +371,7 @@ packages:
|
||||
name: clock
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
version: "1.1.1"
|
||||
code_builder:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -442,6 +435,13 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.2.3"
|
||||
dart_tags:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dart_tags
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.4.0"
|
||||
dbus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -463,6 +463,13 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
eztags:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: eztags
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
fading_edge_scrollview:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -476,7 +483,7 @@ packages:
|
||||
name: fake_async
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
version: "1.3.1"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -573,13 +580,6 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
flutter_media_metadata:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "../flutter_media_metadata"
|
||||
relative: true
|
||||
source: path
|
||||
version: "1.0.0"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -702,6 +702,13 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.0.1"
|
||||
id3:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: id3
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
image:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -778,21 +785,21 @@ packages:
|
||||
name: matcher
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.12.11"
|
||||
version: "0.12.12"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.1.4"
|
||||
version: "0.1.5"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.7.0"
|
||||
version: "1.8.0"
|
||||
mime:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -800,6 +807,13 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
mp3_info:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: mp3_info
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.2.0"
|
||||
msix:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@ -890,7 +904,7 @@ packages:
|
||||
name: path
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.8.1"
|
||||
version: "1.8.2"
|
||||
path_provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -1175,7 +1189,7 @@ packages:
|
||||
name: source_span
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.8.2"
|
||||
version: "1.9.0"
|
||||
spotify:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -1233,7 +1247,7 @@ packages:
|
||||
name: string_scanner
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
version: "1.1.1"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1247,14 +1261,14 @@ packages:
|
||||
name: term_glyph
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
version: "1.2.1"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.4.9"
|
||||
version: "0.4.12"
|
||||
timing:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1325,6 +1339,13 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
utf_convert:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: utf_convert
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.10.0+1"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -71,8 +71,9 @@ dependencies:
|
||||
auto_size_text: ^3.0.0
|
||||
badges: ^2.0.3
|
||||
mime: ^1.0.2
|
||||
flutter_media_metadata:
|
||||
path: ../flutter_media_metadata
|
||||
dart_tags: ^0.4.0
|
||||
id3: ^1.0.2
|
||||
mp3_info: ^0.2.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
@ -8,7 +8,6 @@
|
||||
|
||||
#include <audioplayers_windows/audioplayers_windows_plugin.h>
|
||||
#include <bitsdojo_window_windows/bitsdojo_window_plugin.h>
|
||||
#include <flutter_media_metadata/flutter_media_metadata_plugin.h>
|
||||
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
@ -17,8 +16,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin"));
|
||||
BitsdojoWindowPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("BitsdojoWindowPlugin"));
|
||||
FlutterMediaMetadataPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterMediaMetadataPlugin"));
|
||||
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
|
@ -5,7 +5,6 @@
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
audioplayers_windows
|
||||
bitsdojo_window_windows
|
||||
flutter_media_metadata
|
||||
permission_handler_windows
|
||||
url_launcher_windows
|
||||
)
|
||||
|
Loading…
Reference in New Issue
Block a user