feat(library): M3U8 playlist import and export

The playlist import picker now also accepts .m3u/.m3u8; EXTINF entries
feed the same Deezer enrichment pipeline as CSV. Collection screens
(wishlist, loved, playlists) gain an export action that resolves each
track to its local file, writes SAF entries relative to the download
tree root so external players and DAP copies resolve them, skips
tracks without a file, and shares the .m3u8 via the system sheet.
This commit is contained in:
zarzet
2026-07-26 17:56:38 +07:00
parent 54dcdb92dd
commit c497449549
30 changed files with 533 additions and 71842 deletions
+27 -40
View File
@@ -5,21 +5,16 @@ import 'package:flutter/widgets.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:intl/intl.dart' as intl;
import 'app_localizations_ar.dart';
import 'app_localizations_de.dart';
import 'app_localizations_en.dart';
import 'app_localizations_es.dart';
import 'app_localizations_fr.dart';
import 'app_localizations_hi.dart';
import 'app_localizations_id.dart';
import 'app_localizations_ja.dart';
import 'app_localizations_ko.dart';
import 'app_localizations_nl.dart';
import 'app_localizations_pt.dart';
import 'app_localizations_ru.dart';
import 'app_localizations_tr.dart';
import 'app_localizations_uk.dart';
import 'app_localizations_zh.dart';
// ignore_for_file: type=lint
@@ -107,25 +102,18 @@ abstract class AppLocalizations {
/// A list of this localizations delegate's supported locales.
static const List<Locale> supportedLocales = <Locale>[
Locale('ar'),
Locale('de'),
Locale('en'),
Locale('es'),
Locale('es', 'ES'),
Locale('fr'),
Locale('hi'),
Locale('id'),
Locale('ja'),
Locale('ko'),
Locale('nl'),
Locale('pt'),
Locale('pt', 'PT'),
Locale('ru'),
Locale('tr'),
Locale('uk'),
Locale('zh'),
Locale('zh', 'CN'),
Locale('zh', 'TW'),
];
/// App name - DO NOT TRANSLATE
@@ -1235,7 +1223,7 @@ abstract class AppLocalizations {
/// Dialog message - import playlist confirmation
///
/// In en, this message translates to:
/// **'Found {count} tracks in CSV. Add them to download queue?'**
/// **'Found {count} tracks in the playlist file. Add them to download queue?'**
String dialogImportPlaylistMessage(int count);
/// Label shown in quality picker for CSV import
@@ -1244,6 +1232,30 @@ abstract class AppLocalizations {
/// **'{count} tracks from CSV'**
String csvImportTracks(int count);
/// Tooltip on the collection screen action that exports the track list as an M3U8 playlist file
///
/// In en, this message translates to:
/// **'Export as M3U8'**
String get collectionExportM3u;
/// Snackbar after M3U8 export; tracks without a local file are not exported
///
/// In en, this message translates to:
/// **'Exported {exported} of {total} tracks'**
String collectionExportM3uDone(int exported, int total);
/// Snackbar when an M3U8 export finds no local files
///
/// In en, this message translates to:
/// **'No downloaded files to export'**
String get collectionExportM3uNone;
/// Snackbar when writing or sharing the M3U8 export fails
///
/// In en, this message translates to:
/// **'Export failed'**
String get collectionExportM3uFailed;
/// Snackbar - track added to download queue
///
/// In en, this message translates to:
@@ -6106,10 +6118,10 @@ abstract class AppLocalizations {
/// **'Search with {providerName}...'**
String homeSearchHintProvider(String providerName);
/// Tooltip for importing a CSV file into Home search
/// Tooltip for importing a CSV or M3U playlist file into Home search
///
/// In en, this message translates to:
/// **'Import CSV'**
/// **'Import playlist (CSV, M3U)'**
String get homeImportCsvTooltip;
/// Tooltip for the Home search provider picker
@@ -7664,21 +7676,16 @@ class _AppLocalizationsDelegate
@override
bool isSupported(Locale locale) => <String>[
'ar',
'de',
'en',
'es',
'fr',
'hi',
'id',
'ja',
'ko',
'nl',
'pt',
'ru',
'tr',
'uk',
'zh',
].contains(locale.languageCode);
@override
@@ -7704,22 +7711,10 @@ AppLocalizations lookupAppLocalizations(Locale locale) {
}
break;
}
case 'zh':
{
switch (locale.countryCode) {
case 'CN':
return AppLocalizationsZhCn();
case 'TW':
return AppLocalizationsZhTw();
}
break;
}
}
// Lookup logic when only language code is specified.
switch (locale.languageCode) {
case 'ar':
return AppLocalizationsAr();
case 'de':
return AppLocalizationsDe();
case 'en':
@@ -7728,16 +7723,10 @@ AppLocalizations lookupAppLocalizations(Locale locale) {
return AppLocalizationsEs();
case 'fr':
return AppLocalizationsFr();
case 'hi':
return AppLocalizationsHi();
case 'id':
return AppLocalizationsId();
case 'ja':
return AppLocalizationsJa();
case 'ko':
return AppLocalizationsKo();
case 'nl':
return AppLocalizationsNl();
case 'pt':
return AppLocalizationsPt();
case 'ru':
@@ -7746,8 +7735,6 @@ AppLocalizations lookupAppLocalizations(Locale locale) {
return AppLocalizationsTr();
case 'uk':
return AppLocalizationsUk();
case 'zh':
return AppLocalizationsZh();
}
throw FlutterError(
File diff suppressed because it is too large Load Diff
+14
View File
@@ -639,6 +639,20 @@ class AppLocalizationsDe extends AppLocalizations {
return '$count Titel aus CSV';
}
@override
String get collectionExportM3u => 'Export as M3U8';
@override
String collectionExportM3uDone(int exported, int total) {
return 'Exported $exported of $total tracks';
}
@override
String get collectionExportM3uNone => 'No downloaded files to export';
@override
String get collectionExportM3uFailed => 'Export failed';
@override
String snackbarAddedToQueue(String trackName) {
return '\"$trackName\" hinzugefügt';
+16 -2
View File
@@ -623,7 +623,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String dialogImportPlaylistMessage(int count) {
return 'Found $count tracks in CSV. Add them to download queue?';
return 'Found $count tracks in the playlist file. Add them to download queue?';
}
@override
@@ -631,6 +631,20 @@ class AppLocalizationsEn extends AppLocalizations {
return '$count tracks from CSV';
}
@override
String get collectionExportM3u => 'Export as M3U8';
@override
String collectionExportM3uDone(int exported, int total) {
return 'Exported $exported of $total tracks';
}
@override
String get collectionExportM3uNone => 'No downloaded files to export';
@override
String get collectionExportM3uFailed => 'Export failed';
@override
String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue';
@@ -3680,7 +3694,7 @@ class AppLocalizationsEn extends AppLocalizations {
}
@override
String get homeImportCsvTooltip => 'Import CSV';
String get homeImportCsvTooltip => 'Import playlist (CSV, M3U)';
@override
String get homeChangeSearchProviderTooltip => 'Change search provider';
+15 -1
View File
@@ -631,6 +631,20 @@ class AppLocalizationsEs extends AppLocalizations {
return '$count tracks from CSV';
}
@override
String get collectionExportM3u => 'Export as M3U8';
@override
String collectionExportM3uDone(int exported, int total) {
return 'Exported $exported of $total tracks';
}
@override
String get collectionExportM3uNone => 'No downloaded files to export';
@override
String get collectionExportM3uFailed => 'Export failed';
@override
String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue';
@@ -3674,7 +3688,7 @@ class AppLocalizationsEs extends AppLocalizations {
}
@override
String get homeImportCsvTooltip => 'Import CSV';
String get homeImportCsvTooltip => 'Import playlist (CSV, M3U)';
@override
String get homeChangeSearchProviderTooltip => 'Change search provider';
+14
View File
@@ -647,6 +647,20 @@ class AppLocalizationsFr extends AppLocalizations {
return '$count pistes issues d\'un fichier CSV';
}
@override
String get collectionExportM3u => 'Export as M3U8';
@override
String collectionExportM3uDone(int exported, int total) {
return 'Exported $exported of $total tracks';
}
@override
String get collectionExportM3uNone => 'No downloaded files to export';
@override
String get collectionExportM3uFailed => 'Export failed';
@override
String snackbarAddedToQueue(String trackName) {
return 'Ajout de « $trackName » à la file d\'attente';
File diff suppressed because it is too large Load Diff
+14
View File
@@ -633,6 +633,20 @@ class AppLocalizationsId extends AppLocalizations {
return '$count trek dari CSV';
}
@override
String get collectionExportM3u => 'Export as M3U8';
@override
String collectionExportM3uDone(int exported, int total) {
return 'Exported $exported of $total tracks';
}
@override
String get collectionExportM3uNone => 'No downloaded files to export';
@override
String get collectionExportM3uFailed => 'Export failed';
@override
String snackbarAddedToQueue(String trackName) {
return 'Menambahkan \"$trackName\" ke antrian';
+14
View File
@@ -628,6 +628,20 @@ class AppLocalizationsJa extends AppLocalizations {
return '$count tracks from CSV';
}
@override
String get collectionExportM3u => 'Export as M3U8';
@override
String collectionExportM3uDone(int exported, int total) {
return 'Exported $exported of $total tracks';
}
@override
String get collectionExportM3uNone => 'No downloaded files to export';
@override
String get collectionExportM3uFailed => 'Export failed';
@override
String snackbarAddedToQueue(String trackName) {
return '$trackName」をキューに追加しました';
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+15 -1
View File
@@ -631,6 +631,20 @@ class AppLocalizationsPt extends AppLocalizations {
return '$count tracks from CSV';
}
@override
String get collectionExportM3u => 'Export as M3U8';
@override
String collectionExportM3uDone(int exported, int total) {
return 'Exported $exported of $total tracks';
}
@override
String get collectionExportM3uNone => 'No downloaded files to export';
@override
String get collectionExportM3uFailed => 'Export failed';
@override
String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue';
@@ -3674,7 +3688,7 @@ class AppLocalizationsPt extends AppLocalizations {
}
@override
String get homeImportCsvTooltip => 'Import CSV';
String get homeImportCsvTooltip => 'Import playlist (CSV, M3U)';
@override
String get homeChangeSearchProviderTooltip => 'Change search provider';
+14
View File
@@ -635,6 +635,20 @@ class AppLocalizationsRu extends AppLocalizations {
return '$count трек(-ов) из CSV';
}
@override
String get collectionExportM3u => 'Export as M3U8';
@override
String collectionExportM3uDone(int exported, int total) {
return 'Exported $exported of $total tracks';
}
@override
String get collectionExportM3uNone => 'No downloaded files to export';
@override
String get collectionExportM3uFailed => 'Export failed';
@override
String snackbarAddedToQueue(String trackName) {
return '\"$trackName\" добавлен в очередь';
+14
View File
@@ -637,6 +637,20 @@ class AppLocalizationsTr extends AppLocalizations {
return 'CSV\'den $count şarkı';
}
@override
String get collectionExportM3u => 'Export as M3U8';
@override
String collectionExportM3uDone(int exported, int total) {
return 'Exported $exported of $total tracks';
}
@override
String get collectionExportM3uNone => 'No downloaded files to export';
@override
String get collectionExportM3uFailed => 'Export failed';
@override
String snackbarAddedToQueue(String trackName) {
return '\"$trackName\" kuyruğa eklendi';
+14
View File
@@ -638,6 +638,20 @@ class AppLocalizationsUk extends AppLocalizations {
return '$count треків з CSV';
}
@override
String get collectionExportM3u => 'Export as M3U8';
@override
String collectionExportM3uDone(int exported, int total) {
return 'Exported $exported of $total tracks';
}
@override
String get collectionExportM3uNone => 'No downloaded files to export';
@override
String get collectionExportM3uFailed => 'Export failed';
@override
String snackbarAddedToQueue(String trackName) {
return 'Додано \"$trackName\" до черги';
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+27 -3
View File
@@ -792,7 +792,7 @@
"@dialogImportPlaylistTitle": {
"description": "Dialog title - import CSV playlist"
},
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?",
"dialogImportPlaylistMessage": "Found {count} tracks in the playlist file. Add them to download queue?",
"csvImportTracks": "{count} tracks from CSV",
"@csvImportTracks": {
"description": "Label shown in quality picker for CSV import",
@@ -802,6 +802,30 @@
}
}
},
"collectionExportM3u": "Export as M3U8",
"@collectionExportM3u": {
"description": "Tooltip on the collection screen action that exports the track list as an M3U8 playlist file"
},
"collectionExportM3uDone": "Exported {exported} of {total} tracks",
"@collectionExportM3uDone": {
"description": "Snackbar after M3U8 export; tracks without a local file are not exported",
"placeholders": {
"exported": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"collectionExportM3uNone": "No downloaded files to export",
"@collectionExportM3uNone": {
"description": "Snackbar when an M3U8 export finds no local files"
},
"collectionExportM3uFailed": "Export failed",
"@collectionExportM3uFailed": {
"description": "Snackbar when writing or sharing the M3U8 export fails"
},
"@dialogImportPlaylistMessage": {
"description": "Dialog message - import playlist confirmation",
"placeholders": {
@@ -4769,9 +4793,9 @@
}
}
},
"homeImportCsvTooltip": "Import CSV",
"homeImportCsvTooltip": "Import playlist (CSV, M3U)",
"@homeImportCsvTooltip": {
"description": "Tooltip for importing a CSV file into Home search"
"description": "Tooltip for importing a CSV or M3U playlist file into Home search"
},
"homeChangeSearchProviderTooltip": "Change search provider",
"@homeChangeSearchProviderTooltip": {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -15,26 +15,26 @@ const int translationThreshold = 70;
const List<Locale> filteredSupportedLocales = <Locale>[
Locale('en'),
Locale('fr'),
Locale('de'),
Locale('es', 'ES'),
Locale('de'),
Locale('uk'),
Locale('ru'),
Locale('tr'),
Locale('id'),
Locale('ja'),
Locale('pt', 'PT'),
Locale('ja'),
];
/// Set of locale codes for quick lookup.
const Set<String> filteredLocaleCodes = <String>{
'en',
'fr',
'de',
'es_ES',
'de',
'uk',
'ru',
'tr',
'id',
'ja',
'pt_PT',
'ja',
};
+5
View File
@@ -214,6 +214,11 @@ class PlaybackController extends Notifier<PlaybackState> {
);
}
/// Public wrapper so non-playback features (e.g. M3U export) reuse the
/// same library+history file resolution.
Future<List<String?>> resolveTrackFilePaths(List<Track> tracks) =>
_resolveTrackPaths(tracks);
List<Track> _orderedTracksFromStartIndex(List<Track> tracks, int startIndex) {
final safeStart = startIndex.clamp(0, tracks.length - 1);
if (safeStart == 0) {
@@ -2,11 +2,14 @@ import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus, XFile;
import 'package:spotiflac_android/widgets/album_detail_header.dart';
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/services/m3u_playlist_service.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
@@ -71,6 +74,59 @@ class _LibraryTracksFolderScreenState
}
}
/// Exports the collection as an .m3u8 shared via the system sheet. Tracks
/// without a resolvable local file are skipped; SAF entries are written
/// relative to the download tree root.
Future<void> _exportAsM3u8(
BuildContext context,
String title,
List<CollectionTrackEntry> entries,
) async {
final l10n = context.l10n;
final messenger = ScaffoldMessenger.of(context);
try {
final tracks = entries.map((e) => e.track).toList(growable: false);
final paths = await ref
.read(playbackProvider.notifier)
.resolveTrackFilePaths(tracks);
final exportEntries = <M3uExportEntry>[];
for (var i = 0; i < tracks.length; i++) {
final path = paths[i];
if (path == null || isCueVirtualPath(path)) continue;
final entryPath = M3uPlaylistService.exportPathFor(path);
if (entryPath == null) continue;
exportEntries.add(M3uExportEntry(track: tracks[i], path: entryPath));
}
if (exportEntries.isEmpty) {
messenger.showSnackBar(
SnackBar(content: Text(l10n.collectionExportM3uNone)),
);
return;
}
final file = await M3uPlaylistService.writeExportFile(
title,
M3uPlaylistService.buildM3u8Content(exportEntries),
);
messenger.showSnackBar(
SnackBar(
content: Text(
l10n.collectionExportM3uDone(exportEntries.length, tracks.length),
),
),
);
await SharePlus.instance.share(
ShareParams(files: [XFile(file.path)], text: title),
);
} catch (_) {
messenger.showSnackBar(
SnackBar(content: Text(l10n.collectionExportM3uFailed)),
);
}
}
IconData _modeIcon() {
return switch (widget.mode) {
LibraryTracksFolderMode.wishlist => Icons.bookmark,
@@ -568,6 +624,23 @@ class _LibraryTracksFolderScreenState
)
: null,
appBarActions: [
if (!isSelectionMode && entries.isNotEmpty)
IconButton(
tooltip: context.l10n.collectionExportM3u,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.4),
shape: BoxShape.circle,
),
child: const Icon(
Icons.ios_share,
color: Colors.white,
size: 20,
),
),
onPressed: () => _exportAsM3u8(context, title, entries),
),
if (isPlaylistMode && !isSelectionMode) ...[
IconButton(
tooltip: context.l10n.collectionRenamePlaylist,
+8 -3
View File
@@ -1,6 +1,8 @@
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:path/path.dart' as p;
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/services/m3u_playlist_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
@@ -14,13 +16,16 @@ class CsvImportService {
try {
final FilePickerResult? result = await FilePicker.pickFiles(
type: FileType.custom,
allowedExtensions: ['csv'],
allowedExtensions: ['csv', 'm3u', 'm3u8'],
);
if (result != null && result.files.single.path != null) {
final file = File(result.files.single.path!);
final content = await file.readAsString();
final tracks = _parseCsv(content);
final extension = p.extension(file.path).toLowerCase();
final tracks = (extension == '.m3u' || extension == '.m3u8')
? M3uPlaylistService.parseM3u(content)
: _parseCsv(content);
if (tracks.isNotEmpty) {
return await _enrichTracksMetadata(tracks, onProgress: onProgress);
@@ -28,7 +33,7 @@ class CsvImportService {
return tracks;
}
} catch (e) {
_log.e('Error picking/parsing CSV: $e');
_log.e('Error picking/parsing playlist file: $e');
}
return [];
}
+149
View File
@@ -0,0 +1,149 @@
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('M3uPlaylist');
class M3uExportEntry {
final Track track;
final String path;
const M3uExportEntry({required this.track, required this.path});
}
class M3uPlaylistService {
/// Parses M3U/M3U8 content into tracks for the import pipeline. EXTINF
/// display text is split on the first " - " into artist and title; path
/// lines without EXTINF fall back to the file name stem.
static List<Track> parseM3u(String content) {
final tracks = <Track>[];
final lines = content.split(RegExp(r'\r\n|\r|\n'));
final baseId = DateTime.now().millisecondsSinceEpoch;
int? pendingDuration;
String? pendingDisplay;
for (final rawLine in lines) {
final line = rawLine.trim();
if (line.isEmpty) continue;
if (line.startsWith('#EXTINF:')) {
final body = line.substring('#EXTINF:'.length);
final commaIdx = body.indexOf(',');
if (commaIdx >= 0) {
final durationRaw = body.substring(0, commaIdx).split(' ').first;
pendingDuration = double.tryParse(durationRaw)?.round();
pendingDisplay = body.substring(commaIdx + 1).trim();
}
continue;
}
if (line.startsWith('#')) continue;
var display = pendingDisplay;
if (display == null || display.isEmpty) {
display = p.basenameWithoutExtension(line).trim();
}
String artist = '';
String title = display;
final splitIdx = display.indexOf(' - ');
if (splitIdx > 0) {
artist = display.substring(0, splitIdx).trim();
title = display.substring(splitIdx + 3).trim();
}
if (title.isNotEmpty) {
final duration = pendingDuration ?? 0;
tracks.add(
Track(
id: 'm3u_${baseId}_${tracks.length}',
name: title,
artistName: artist.isEmpty ? 'Unknown Artist' : artist,
albumName: 'Unknown Album',
duration: duration > 0 ? duration : 0,
coverUrl: null,
),
);
}
pendingDuration = null;
pendingDisplay = null;
}
_log.i('Parsed ${tracks.length} tracks from M3U');
return tracks;
}
/// Builds `#EXTM3U` content with one EXTINF line per entry.
static String buildM3u8Content(List<M3uExportEntry> entries) {
final buffer = StringBuffer('#EXTM3U\n');
for (final entry in entries) {
final track = entry.track;
final duration = track.duration > 0 ? track.duration : -1;
final artist = track.artistName.trim();
final display = artist.isEmpty
? track.name
: '$artist - ${track.name}';
buffer
..write('#EXTINF:')
..write(duration)
..write(',')
..writeln(display)
..writeln(entry.path);
}
return buffer.toString();
}
/// Converts a resolved library file path into an M3U entry path. Plain
/// paths pass through unchanged; SAF document URIs become paths relative
/// to their tree root (so the .m3u8 resolves when placed in the download
/// folder). Returns null when a content URI carries no usable path.
static String? exportPathFor(String rawPath) {
final trimmed = rawPath.trim();
if (trimmed.isEmpty) return null;
if (!trimmed.startsWith('content://')) return trimmed;
try {
// Uri.pathSegments already percent-decodes each segment once.
final segments = Uri.parse(trimmed).pathSegments;
final docIdx = segments.indexOf('document');
if (docIdx < 0 || docIdx + 1 >= segments.length) return null;
final docPath = segments[docIdx + 1];
final treeIdx = segments.indexOf('tree');
if (treeIdx >= 0 && treeIdx + 1 < segments.length) {
final treeId = segments[treeIdx + 1];
if (treeId.isNotEmpty && docPath.startsWith(treeId)) {
var relative = docPath.substring(treeId.length);
if (relative.startsWith('/')) relative = relative.substring(1);
if (relative.isNotEmpty) return relative;
}
}
// No tree context: strip the storage prefix
// ("primary:Music/A/b.flac" -> "Music/A/b.flac").
final colonIdx = docPath.indexOf(':');
if (colonIdx >= 0 && colonIdx + 1 < docPath.length) {
return docPath.substring(colonIdx + 1);
}
return docPath.isEmpty ? null : docPath;
} catch (_) {
return null;
}
}
/// Writes the playlist to a shareable temp file named after the playlist.
static Future<File> writeExportFile(String playlistName, String content) async {
final dir = await getTemporaryDirectory();
final safeName = playlistName
.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_')
.trim();
final file = File(
p.join(dir.path, '${safeName.isEmpty ? 'playlist' : safeName}.m3u8'),
);
await file.writeAsString(content);
return file;
}
}
+96
View File
@@ -0,0 +1,96 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/services/m3u_playlist_service.dart';
void main() {
group('parseM3u', () {
test('reads EXTINF duration, artist, and title', () {
final tracks = M3uPlaylistService.parseM3u('''
#EXTM3U
#EXTINF:245,Daft Punk - Harder Better Faster Stronger
Daft Punk/Discovery/04 Harder Better Faster Stronger.flac
#EXTINF:-1,Solo Title Only
music/solo.flac
''');
expect(tracks, hasLength(2));
expect(tracks[0].name, 'Harder Better Faster Stronger');
expect(tracks[0].artistName, 'Daft Punk');
expect(tracks[0].duration, 245);
expect(tracks[1].name, 'Solo Title Only');
expect(tracks[1].artistName, 'Unknown Artist');
expect(tracks[1].duration, 0);
});
test('falls back to the file name stem without EXTINF', () {
final tracks = M3uPlaylistService.parseM3u('''
# a comment
Some Artist - Some Song.mp3
''');
expect(tracks, hasLength(1));
expect(tracks[0].artistName, 'Some Artist');
expect(tracks[0].name, 'Some Song');
});
});
group('buildM3u8Content', () {
test('writes EXTM3U header and EXTINF entries', () {
final content = M3uPlaylistService.buildM3u8Content([
M3uExportEntry(
track: Track(
id: 't1',
name: 'Song',
artistName: 'Artist',
albumName: 'Album',
duration: 200,
coverUrl: null,
),
path: 'Artist/Album/Song.flac',
),
]);
expect(
content,
'#EXTM3U\n#EXTINF:200,Artist - Song\nArtist/Album/Song.flac\n',
);
});
});
group('exportPathFor', () {
test('passes plain paths through', () {
expect(
M3uPlaylistService.exportPathFor('/storage/emulated/0/Music/a.flac'),
'/storage/emulated/0/Music/a.flac',
);
});
test('makes SAF document URIs relative to their tree root', () {
expect(
M3uPlaylistService.exportPathFor(
'content://com.android.externalstorage.documents/tree/'
'primary%3AMusic/document/'
'primary%3AMusic%2FArtist%2FAlbum%2Fsong.flac',
),
'Artist/Album/song.flac',
);
});
test('strips the storage prefix without tree context', () {
expect(
M3uPlaylistService.exportPathFor(
'content://com.android.externalstorage.documents/document/'
'primary%3AMusic%2Fsong.flac',
),
'Music/song.flac',
);
});
test('returns null for content URIs without a document path', () {
expect(
M3uPlaylistService.exportPathFor('content://media/external/audio/1'),
isNull,
);
});
});
}