Merge branch 'feature_organized_settings' of https://github.com/Demizo/spotube into feature_organized_settings

This commit is contained in:
Demizo 2022-09-02 23:33:02 -05:00
commit 1263a0cfcf
27 changed files with 857 additions and 325 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

BIN
assets/user-placeholder.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

@ -156,6 +156,13 @@ class Sidebar extends HookConsumerWidget {
CircleAvatar(
backgroundImage:
CachedNetworkImageProvider(avatarImg),
onBackgroundImageError:
(exception, stackTrace) =>
Image.asset(
"assets/user-placeholder.png",
height: 16,
width: 16,
),
),
const SizedBox(
width: 10,
@ -187,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,
),
),
),
);

View File

@ -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>[],
),
),
);

View File

@ -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()),
]),
),
),

View File

@ -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()),
)
],
);
;
}
}

View File

@ -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,
),
),

View File

@ -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],
);

View File

@ -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,
);
},
),

View File

@ -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),
),

View File

@ -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,
);
}
}

View File

@ -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);
},
)
],
),
)
],
),
],
),
),

View File

@ -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);
}
}

View 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,
);
},
);
}
}

View File

@ -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;

View File

@ -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
View 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,
};
}
}

View File

@ -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}",

View File

@ -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) {

View File

@ -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;
}

View File

@ -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);

View File

@ -5,7 +5,6 @@
list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_linux
bitsdojo_window_linux
flutter_media_metadata
url_launcher_linux
)

View File

@ -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"))

View File

@ -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:

View File

@ -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:

View File

@ -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(

View File

@ -5,7 +5,6 @@
list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_windows
bitsdojo_window_windows
flutter_media_metadata
permission_handler_windows
url_launcher_windows
)