feat: add local image picking functionality (Beta) (closes: #548)

This commit is contained in:
Valeri
2025-04-27 13:06:09 +04:00
parent 19f2e2157c
commit 0a770583ad
23 changed files with 420 additions and 133 deletions

View File

@ -70,6 +70,7 @@
"offlineSongs": "Offline songs",
"originalRecommendations": "Original algorithm for recommendations",
"others": "آخرون",
"pickImageFromDevice": "Pick image from device",
"playNext": "Play next",
"playlist": "قائمة تشغيل",
"playlistAlreadyDownloaded": "Playlist already downloaded",

View File

@ -70,6 +70,7 @@
"offlineSongs": "Offline-Songs",
"originalRecommendations": "Originaler Algorithmus für Vorschläge",
"others": "Andere",
"pickImageFromDevice": "Pick image from device",
"playNext": "Play next",
"playlist": "Playlist",
"playlistAlreadyDownloaded": "Playlist already downloaded",

View File

@ -70,6 +70,7 @@
"offlineSongs": "Τραγούδια εκτός σύνδεσης",
"originalRecommendations": "Πρωτότυπος αλγόριθμος για συστάσεις",
"others": "Άλλα",
"pickImageFromDevice": "Pick image from device",
"playNext": "Play next",
"playlist": "Λίστα αναπαραγωγής",
"playlistAlreadyDownloaded": "Playlist already downloaded",

View File

@ -70,6 +70,7 @@
"offlineSongs": "Offline songs",
"originalRecommendations": "Original recommendation algorithm",
"others": "Others",
"pickImageFromDevice": "Pick image from device",
"playNext": "Play next",
"playlist": "Playlist",
"playlistAlreadyDownloaded": "Playlist already downloaded",

View File

@ -70,6 +70,7 @@
"offlineSongs": "Canciones sin conexión",
"originalRecommendations": "Algoritmo de recomendaciones original",
"others": "Otros",
"pickImageFromDevice": "Pick image from device",
"playNext": "Play next",
"playlist": "Lista de reproducción",
"playlistAlreadyDownloaded": "Playlist already downloaded",

View File

@ -70,6 +70,7 @@
"offlineSongs": "Titres hors ligne",
"originalRecommendations": "Algorithme original pour les recommandations",
"others": "Autres",
"pickImageFromDevice": "Pick image from device",
"playNext": "Play next",
"playlist": "Playlist",
"playlistAlreadyDownloaded": "Playlist already downloaded",

View File

@ -70,6 +70,7 @@
"offlineSongs": "ऑफ़लाइन गाने",
"originalRecommendations": "मूल अनुशंसा एल्गोरिथ्म",
"others": "अन्य",
"pickImageFromDevice": "Pick image from device",
"playNext": "Play next",
"playlist": "प्लेलिस्ट",
"playlistAlreadyDownloaded": "Playlist already downloaded",

View File

@ -70,6 +70,7 @@
"offlineSongs": "Lagu offline",
"originalRecommendations": "Algoritma rekomendasi asli",
"others": "Lainnya",
"pickImageFromDevice": "Pick image from device",
"playNext": "Putar berikutnya",
"playlist": "Playlist",
"playlistAlreadyDownloaded": "Playlist sudah diunduh",

View File

@ -70,6 +70,7 @@
"offlineSongs": "Brani offline",
"originalRecommendations": "Algoritmo originale per le raccomandazioni",
"others": "Altri",
"pickImageFromDevice": "Pick image from device",
"playNext": "Play next",
"playlist": "Playlist",
"playlistAlreadyDownloaded": "Playlist already downloaded",

View File

@ -70,6 +70,7 @@
"offlineSongs": "オフライン用の曲",
"originalRecommendations": "おすすめに独自アルゴリズムを使用",
"others": "ほか",
"pickImageFromDevice": "Pick image from device",
"playNext": "Play next",
"playlist": "再生リスト",
"playlistAlreadyDownloaded": "Playlist already downloaded",

View File

@ -70,6 +70,7 @@
"offlineSongs": "오프라인 노래",
"originalRecommendations": "추천을 위한 오리지널 알고리즘",
"others": "기타",
"pickImageFromDevice": "Pick image from device",
"playNext": "다음 재생",
"playlist": "재생목록",
"playlistAlreadyDownloaded": "재생목록이 이미 다운로드되었음",
@ -124,4 +125,4 @@
"undo": "실행 취소",
"userPlaylists": "사용자 재생목록",
"youtubePlaylistLinkOrId": "유튜브 재생목록 링크 또는 ID "
}
}

View File

@ -70,6 +70,7 @@
"offlineSongs": "Utwory offline",
"originalRecommendations": "Oryginalny algorytm rekomendacji",
"others": "Inne",
"pickImageFromDevice": "Pick image from device",
"playNext": "Play next",
"playlist": "Playlista",
"playlistAlreadyDownloaded": "Playlist already downloaded",

View File

@ -70,6 +70,7 @@
"offlineSongs": "Músicas offline",
"originalRecommendations": "Recomendações originais",
"others": "Outros",
"pickImageFromDevice": "Pick image from device",
"playNext": "Play next",
"playlist": "Playlist",
"playlistAlreadyDownloaded": "Playlist already downloaded",

View File

@ -70,6 +70,7 @@
"offlineSongs": "Треки без интернета",
"originalRecommendations": "Оригинальный алгоритм рекомендаций",
"others": "Ещё",
"pickImageFromDevice": "Pick image from device",
"playNext": "Play next",
"playlist": "Плейлист",
"playlistAlreadyDownloaded": "Playlist already downloaded",

View File

@ -70,6 +70,7 @@
"offlineSongs": "Çevrimdışı Parçalar",
"originalRecommendations": "Asıl Öneri Algoritması",
"others": "Diğerleri",
"pickImageFromDevice": "Pick image from device",
"playNext": "Bundan Sonra Oynat",
"playlist": "Çalma Listeleri",
"playlistAlreadyDownloaded": "Çalma listesi hali hazırda indirildi",
@ -124,4 +125,4 @@
"undo": "Geri Al",
"userPlaylists": "Kullanıcı Listeleri",
"youtubePlaylistLinkOrId": "YouTube listesi bağlantısı veya ID'si"
}
}

View File

@ -70,6 +70,7 @@
"offlineSongs": "Offline songs",
"originalRecommendations": "Original algorithm for recommendations",
"others": "Інше",
"pickImageFromDevice": "Pick image from device",
"playNext": "Play next",
"playlist": "Плейлист",
"playlistAlreadyDownloaded": "Playlist already downloaded",

View File

@ -70,6 +70,7 @@
"offlineSongs": "已離線歌曲",
"originalRecommendations": "原始推薦算法",
"others": "其他",
"pickImageFromDevice": "Pick image from device",
"playNext": "Play next",
"playlist": "播放列表",
"playlistAlreadyDownloaded": "Playlist already downloaded",

View File

@ -70,6 +70,7 @@
"offlineSongs": "已离线歌曲",
"originalRecommendations": "原始推荐算法",
"others": "其他",
"pickImageFromDevice": "Pick image from device",
"playNext": "Play next",
"playlist": "播放列表",
"playlistAlreadyDownloaded": "Playlist already downloaded",

View File

@ -19,6 +19,9 @@
* please visit: https://github.com/gokadzev/Musify
*/
import 'dart:convert';
import 'package:file_picker/file_picker.dart';
import 'package:fluentui_system_icons/fluentui_system_icons.dart';
import 'package:flutter/material.dart';
import 'package:musify/API/musify.dart';
@ -232,6 +235,7 @@ class _LibraryPageState extends State<LibraryPage> {
var customPlaylistName = '';
var isYouTubeMode = true;
String? imageUrl;
String? imageBase64;
return StatefulBuilder(
builder: (context, setState) {
@ -240,6 +244,76 @@ class _LibraryPageState extends State<LibraryPage> {
final inactiveButtonBackground = theme.colorScheme.secondaryContainer;
final dialogBackgroundColor = theme.dialogTheme.backgroundColor;
Future<void> _pickImage() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.image,
withData: true,
);
if (result != null && result.files.single.bytes != null) {
final file = result.files.single;
String? mimeType;
if (file.extension != null) {
switch (file.extension!.toLowerCase()) {
case 'jpg':
case 'jpeg':
mimeType = 'image/jpeg';
break;
case 'png':
mimeType = 'image/png';
break;
case 'gif':
mimeType = 'image/gif';
break;
case 'bmp':
mimeType = 'image/bmp';
break;
case 'webp':
mimeType = 'image/webp';
break;
default:
mimeType = 'application/octet-stream';
}
} else {
mimeType = 'application/octet-stream';
}
setState(() {
imageBase64 =
'data:$mimeType;base64,${base64Encode(file.bytes!)}';
imageUrl = null;
});
}
}
Widget _imagePreview() {
if (imageBase64 != null) {
final base64Data =
imageBase64!.contains(',')
? imageBase64!.split(',').last
: imageBase64!;
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Image.memory(
base64Decode(base64Data),
width: 80,
height: 80,
fit: BoxFit.cover,
),
);
} else if (imageUrl != null && imageUrl!.isNotEmpty) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Image.network(
imageUrl!,
width: 80,
height: 80,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const Icon(Icons.broken_image),
),
);
}
return const SizedBox.shrink();
}
return AlertDialog(
backgroundColor: dialogBackgroundColor,
content: SingleChildScrollView(
@ -256,6 +330,7 @@ class _LibraryPageState extends State<LibraryPage> {
id = '';
customPlaylistName = '';
imageUrl = null;
imageBase64 = null;
});
},
style: ElevatedButton.styleFrom(
@ -274,6 +349,7 @@ class _LibraryPageState extends State<LibraryPage> {
id = '';
customPlaylistName = '';
imageUrl = null;
imageBase64 = null;
});
},
style: ElevatedButton.styleFrom(
@ -305,15 +381,40 @@ class _LibraryPageState extends State<LibraryPage> {
customPlaylistName = value;
},
),
const SizedBox(height: 7),
TextField(
decoration: InputDecoration(
labelText: context.l10n!.customPlaylistImgUrl,
if (imageBase64 == null) ...[
const SizedBox(height: 7),
TextField(
decoration: InputDecoration(
labelText: context.l10n!.customPlaylistImgUrl,
),
onChanged: (value) {
imageUrl = value;
imageBase64 = null;
setState(() {});
},
),
onChanged: (value) {
imageUrl = value;
},
),
],
const SizedBox(height: 7),
if (imageUrl == null) ...[
Row(
children: [
ElevatedButton.icon(
onPressed: _pickImage,
icon: const Icon(Icons.image),
label: Text(context.l10n!.pickImageFromDevice),
),
if (imageBase64 != null)
Padding(
padding: const EdgeInsets.only(left: 8),
child: Icon(
Icons.check_circle,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
_imagePreview(),
],
],
],
),
@ -329,7 +430,7 @@ class _LibraryPageState extends State<LibraryPage> {
context,
createCustomPlaylist(
customPlaylistName,
imageUrl,
imageBase64 ?? imageUrl,
context,
),
);

View File

@ -19,8 +19,10 @@
* please visit: https://github.com/gokadzev/Musify
*/
import 'dart:convert';
import 'dart:math';
import 'package:file_picker/file_picker.dart';
import 'package:fluentui_system_icons/fluentui_system_icons.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@ -291,73 +293,184 @@ class _PlaylistPageState extends State<PlaylistPage> {
() => showDialog(
context: context,
builder: (BuildContext context) {
var customPlaylistName = _playlist['title'];
var imageUrl = _playlist['image'];
String customPlaylistName = _playlist['title'];
String? imageUrl = _playlist['image'];
var imageBase64 =
(imageUrl != null && imageUrl.startsWith('data:'))
? imageUrl
: null;
if (imageBase64 != null) imageUrl = null;
return AlertDialog(
content: SingleChildScrollView(
child: Column(
children: <Widget>[
const SizedBox(height: 7),
TextField(
controller: TextEditingController(
text: customPlaylistName,
return StatefulBuilder(
builder: (context, setState) {
Future<void> _pickImage() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.image,
withData: true,
);
if (result != null && result.files.single.bytes != null) {
final file = result.files.single;
String? mimeType;
if (file.extension != null) {
switch (file.extension!.toLowerCase()) {
case 'jpg':
case 'jpeg':
mimeType = 'image/jpeg';
break;
case 'png':
mimeType = 'image/png';
break;
case 'gif':
mimeType = 'image/gif';
break;
case 'bmp':
mimeType = 'image/bmp';
break;
case 'webp':
mimeType = 'image/webp';
break;
default:
mimeType = 'application/octet-stream';
}
} else {
mimeType = 'application/octet-stream';
}
setState(() {
imageBase64 =
'data:$mimeType;base64,${base64Encode(file.bytes!)}';
imageUrl = null;
});
}
}
Widget _imagePreview() {
if (imageBase64 != null) {
final base64Data =
imageBase64!.contains(',')
? imageBase64!.split(',').last
: imageBase64!;
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Image.memory(
base64Decode(base64Data),
width: 80,
height: 80,
fit: BoxFit.cover,
),
decoration: InputDecoration(
labelText: context.l10n!.customPlaylistName,
);
} else if (imageUrl != null && imageUrl!.isNotEmpty) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Image.network(
imageUrl!,
width: 80,
height: 80,
fit: BoxFit.cover,
errorBuilder:
(_, __, ___) => const Icon(Icons.broken_image),
),
onChanged: (value) {
customPlaylistName = value;
},
);
}
return const SizedBox.shrink();
}
return AlertDialog(
content: SingleChildScrollView(
child: Column(
children: <Widget>[
const SizedBox(height: 7),
TextField(
controller: TextEditingController(
text: customPlaylistName,
),
decoration: InputDecoration(
labelText: context.l10n!.customPlaylistName,
),
onChanged: (value) {
customPlaylistName = value;
},
),
if (imageBase64 == null) ...[
const SizedBox(height: 7),
TextField(
controller: TextEditingController(text: imageUrl),
decoration: InputDecoration(
labelText: context.l10n!.customPlaylistImgUrl,
),
onChanged: (value) {
imageUrl = value;
imageBase64 = null;
setState(() {});
},
),
],
const SizedBox(height: 7),
if (imageUrl == null) ...[
Row(
children: [
ElevatedButton.icon(
onPressed: _pickImage,
icon: const Icon(Icons.image),
label: Text(
context.l10n!.pickImageFromDevice,
),
),
if (imageBase64 != null)
Padding(
padding: const EdgeInsets.only(left: 8),
child: Icon(
Icons.check_circle,
color:
Theme.of(context).colorScheme.primary,
),
),
],
),
_imagePreview(),
],
],
),
const SizedBox(height: 7),
TextField(
controller: TextEditingController(text: imageUrl),
decoration: InputDecoration(
labelText: context.l10n!.customPlaylistImgUrl,
),
onChanged: (value) {
imageUrl = value;
),
actions: <Widget>[
TextButton(
child: Text(context.l10n!.add.toUpperCase()),
onPressed: () {
setState(() {
final index = userCustomPlaylists.value.indexOf(
widget.playlistData,
);
if (index != -1) {
final newPlaylist = {
'title': customPlaylistName,
'source': 'user-created',
if (imageBase64 != null)
'image': imageBase64
else if (imageUrl != null)
'image': imageUrl,
'list': widget.playlistData['list'],
};
final updatedPlaylists = List<Map>.from(
userCustomPlaylists.value,
);
updatedPlaylists[index] = newPlaylist;
userCustomPlaylists.value = updatedPlaylists;
addOrUpdateData(
'user',
'customPlaylists',
userCustomPlaylists.value,
);
_playlist = newPlaylist;
showToast(context, context.l10n!.playlistUpdated);
}
Navigator.pop(context);
});
},
),
],
),
),
actions: <Widget>[
TextButton(
child: Text(context.l10n!.add.toUpperCase()),
onPressed: () {
setState(() {
final index = userCustomPlaylists.value.indexOf(
widget.playlistData,
);
if (index != -1) {
final newPlaylist = {
'title': customPlaylistName,
'source': 'user-created',
if (imageUrl != null) 'image': imageUrl,
'list': widget.playlistData['list'],
};
final updatedPlaylists = List<Map>.from(
userCustomPlaylists.value,
);
updatedPlaylists[index] = newPlaylist;
userCustomPlaylists.value = updatedPlaylists;
addOrUpdateData(
'user',
'customPlaylists',
userCustomPlaylists,
);
_playlist = newPlaylist;
showToast(context, context.l10n!.playlistUpdated);
}
Navigator.pop(context);
});
},
),
],
);
},
);
},
),

View File

@ -0,0 +1,104 @@
/*
* Copyright (C) 2025 Valeri Gokadze
*
* Musify is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Musify is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
*
* For more information about Musify, including how to contribute,
* please visit: https://github.com/gokadzev/Musify
*/
import 'dart:convert';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:fluentui_system_icons/fluentui_system_icons.dart';
import 'package:flutter/material.dart';
import 'package:musify/utilities/common_variables.dart';
import 'package:musify/widgets/no_artwork_cube.dart';
class PlaylistArtwork extends StatelessWidget {
const PlaylistArtwork({
super.key,
required this.playlistArtwork,
this.playlistTitle,
this.cubeIcon = FluentIcons.music_note_1_24_regular,
this.iconSize = 30,
this.size = 220,
});
final String? playlistArtwork;
final String? playlistTitle;
final IconData cubeIcon;
final double iconSize;
final double size;
Widget _nullArtwork() => NullArtworkWidget(
icon: cubeIcon,
iconSize: iconSize,
size: size,
title: playlistTitle,
);
@override
Widget build(BuildContext context) {
final image = playlistArtwork;
if (image == null) return _nullArtwork();
if (image.startsWith('data:image')) {
final commaIdx = image.indexOf(',');
if (commaIdx == -1) return _nullArtwork();
try {
final bytes = base64Decode(image.substring(commaIdx + 1));
return SizedBox(
width: size,
height: size,
child: ClipRRect(
borderRadius: commonBarRadius,
child: Image.memory(
bytes,
height: size,
width: size,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _nullArtwork(),
),
),
);
} catch (_) {
return _nullArtwork();
}
}
if (image.startsWith('http')) {
return CachedNetworkImage(
key: Key(image),
height: size,
width: size,
imageUrl: image,
fit: BoxFit.cover,
imageBuilder:
(_, imageProvider) => SizedBox(
width: size,
height: size,
child: ClipRRect(
borderRadius: commonBarRadius,
child: Image(image: imageProvider),
),
),
errorWidget: (_, __, ___) => _nullArtwork(),
);
}
return _nullArtwork();
}
}

View File

@ -19,14 +19,13 @@
* please visit: https://github.com/gokadzev/Musify
*/
import 'package:cached_network_image/cached_network_image.dart';
import 'package:fluentui_system_icons/fluentui_system_icons.dart';
import 'package:flutter/material.dart';
import 'package:musify/API/musify.dart';
import 'package:musify/extensions/l10n.dart';
import 'package:musify/screens/playlist_page.dart';
import 'package:musify/utilities/common_variables.dart';
import 'package:musify/widgets/no_artwork_cube.dart';
import 'package:musify/widgets/playlist_artwork.dart';
class PlaylistBar extends StatelessWidget {
PlaylistBar(
@ -100,7 +99,12 @@ class PlaylistBar extends StatelessWidget {
padding: commonBarContentPadding,
child: Row(
children: [
_buildAlbumArt(),
PlaylistArtwork(
playlistArtwork: playlistArtwork,
size: artworkSize,
iconSize: iconSize,
cubeIcon: cubeIcon,
),
const SizedBox(width: 8),
Expanded(
child: Column(
@ -126,37 +130,6 @@ class PlaylistBar extends StatelessWidget {
);
}
Widget _buildAlbumArt() {
return playlistArtwork != null
? CachedNetworkImage(
key: Key(playlistArtwork.toString()),
height: artworkSize,
width: artworkSize,
imageUrl: playlistArtwork.toString(),
fit: BoxFit.cover,
imageBuilder:
(context, imageProvider) => SizedBox(
width: artworkSize,
height: artworkSize,
child: ClipRRect(
borderRadius: commonBarRadius,
child: Image(image: imageProvider),
),
),
errorWidget:
(context, url, error) => NullArtworkWidget(
icon: cubeIcon,
iconSize: iconSize,
size: artworkSize,
),
)
: NullArtworkWidget(
icon: cubeIcon,
iconSize: iconSize,
size: artworkSize,
);
}
Widget _buildActionButtons(BuildContext context, Color primaryColor) {
return PopupMenuButton<String>(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),

View File

@ -19,12 +19,11 @@
* please visit: https://github.com/gokadzev/Musify
*/
import 'package:cached_network_image/cached_network_image.dart';
import 'package:fluentui_system_icons/fluentui_system_icons.dart';
import 'package:flutter/material.dart';
import 'package:musify/API/musify.dart';
import 'package:musify/extensions/l10n.dart';
import 'package:musify/widgets/no_artwork_cube.dart';
import 'package:musify/widgets/playlist_artwork.dart';
class PlaylistCube extends StatelessWidget {
PlaylistCube(
@ -46,7 +45,6 @@ class PlaylistCube extends StatelessWidget {
static const double paddingValue = 4;
static const double typeLabelOffset = 10;
static const double iconSize = 30;
final ValueNotifier<bool> playlistLikeStatus;
@ -63,7 +61,11 @@ class PlaylistCube extends StatelessWidget {
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
_buildImage(context),
PlaylistArtwork(
playlistArtwork: playlist['image'],
size: size,
cubeIcon: cubeIcon,
),
if (borderRadius == 13 && playlist['image'] != null)
Positioned(
top: typeLabelOffset,
@ -75,30 +77,6 @@ class PlaylistCube extends StatelessWidget {
);
}
Widget _buildImage(BuildContext context) {
return playlist['image'] != null
? CachedNetworkImage(
key: ValueKey(playlist['image'].toString()),
imageUrl: playlist['image'].toString(),
height: size,
width: size,
fit: BoxFit.cover,
errorWidget:
(context, url, error) => NullArtworkWidget(
icon: cubeIcon,
iconSize: iconSize,
size: size,
title: playlist['title'],
),
)
: NullArtworkWidget(
icon: cubeIcon,
iconSize: iconSize,
size: size,
title: playlist['title'],
);
}
Widget _buildLabel(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(