mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 09:08:35 +02:00
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:
@@ -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
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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\" добавлен в очередь';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user