Skip to content

Commit cc9202c

Browse files
authored
feature: download podcasts (#344)
* feature: download podcasts Fixes #240 * Add remove/cancel * Localize and improve cancel * Add downloads only button * Change readme * Remove on unsub
1 parent 85e2ae7 commit cc9202c

18 files changed

+504
-85
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ Thank you @tomassasovsky for the [dart implementation of radiobrowser-api](https
5353
- [X] Play TV Stations found on radiobrowser
5454
- [ ] Chromecast Support ([#91](https://github.com/ubuntu-flutter-community/musicpod/issues/91))
5555
- [X] streaming provider agnostic sharing links
56-
- [ ] option to download podcasts (#[240](https://github.com/ubuntu-flutter-community/musicpod/issues/240))
56+
- [X] option to download podcasts (#[240](https://github.com/ubuntu-flutter-community/musicpod/issues/240))
5757
- [X] reduced memory allocation
5858
- [ ] WebDav support (#[248](https://github.com/ubuntu-flutter-community/musicpod/issues/248))
5959
- [ ] upnp/dlna support (#[248](https://github.com/ubuntu-flutter-community/musicpod/issues/247))

lib/constants.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ const kStarredStationsFileName = 'starredStations.json';
9393
const kSettingsFileName = 'settings.json';
9494
const kLastPositionsFileName = 'lastPositions.json';
9595
const kLocalAudioCacheFileName = 'localaudiocache.json';
96+
const kDownloads = 'downloads.json';
97+
const kFeedsWithDownloads = 'feedswithdownloads.json';
9698
const kLocalAudioCache = 'localAudioCache';
9799
const kUseLocalAudioCache = 'cacheSuggestionDisposed';
98100
const kCreateCacheLimit = 1000;

lib/src/app/master_items.dart

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -112,20 +112,18 @@ List<MasterItem> createMasterItems({
112112
podcast.value.firstOrNull?.title ??
113113
podcast.value.firstOrNull.toString(),
114114
),
115-
pageBuilder: (context) => isOnline
116-
? PodcastPage(
117-
pageId: podcast.key,
118-
title: podcast.value.firstOrNull?.album ??
119-
podcast.value.firstOrNull?.title ??
120-
podcast.value.firstOrNull.toString(),
121-
audios: podcast.value,
122-
onTextTap: onTextTap,
123-
addPodcast: addPodcast,
124-
removePodcast: removePodcast,
125-
imageUrl: podcast.value.firstOrNull?.albumArtUrl ??
126-
podcast.value.firstOrNull?.imageUrl,
127-
)
128-
: const OfflinePage(),
115+
pageBuilder: (context) => PodcastPage(
116+
pageId: podcast.key,
117+
title: podcast.value.firstOrNull?.album ??
118+
podcast.value.firstOrNull?.title ??
119+
podcast.value.firstOrNull.toString(),
120+
audios: podcast.value,
121+
onTextTap: onTextTap,
122+
addPodcast: addPodcast,
123+
removePodcast: removePodcast,
124+
imageUrl: podcast.value.firstOrNull?.albumArtUrl ??
125+
podcast.value.firstOrNull?.imageUrl,
126+
),
129127
iconBuilder: (context, selected) => PodcastPage.createIcon(
130128
context: context,
131129
imageUrl: podcast.value.firstOrNull?.albumArtUrl ??

lib/src/common/audio_page_body.dart

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import '../../common.dart';
77
import '../../data.dart';
88
import '../../player.dart';
99
import '../../podcasts.dart';
10+
import '../app/connectivity_notifier.dart';
1011
import '../library/library_model.dart';
1112

1213
class AudioPageBody extends StatefulWidget {
@@ -90,6 +91,7 @@ class _AudioPageBodyState extends State<AudioPageBody> {
9091

9192
@override
9293
Widget build(BuildContext context) {
94+
final isOnline = context.select((ConnectivityNotifier c) => c.isOnline);
9395
final isPlaying = context.select((PlayerModel m) => m.isPlaying);
9496

9597
final playerModel = context.read<PlayerModel>();
@@ -209,13 +211,16 @@ class _AudioPageBodyState extends State<AudioPageBody> {
209211
List.generate(sortedAudios.take(_amount).length, (index) {
210212
final audio = sortedAudios.elementAt(index);
211213
final audioSelected = currentAudio == audio;
214+
final download = libraryModel.getDownload(audio.url);
212215

213216
if (audio.audioType == AudioType.podcast) {
214217
return PodcastAudioTile(
215218
removeUpdate: () =>
216219
libraryModel.removePodcastUpdate(widget.pageId),
217220
isExpanded: audioSelected,
218-
audio: audio,
221+
audio: download != null
222+
? audio.copyWith(path: download)
223+
: audio,
219224
isPlayerPlaying: isPlaying,
220225
selected: audioSelected,
221226
pause: pause,
@@ -226,6 +231,7 @@ class _AudioPageBodyState extends State<AudioPageBody> {
226231
play: play,
227232
lastPosition: libraryModel.getLastPosition.call(audio.url),
228233
safeLastPosition: playerModel.safeLastPosition,
234+
isOnline: isOnline,
229235
);
230236
}
231237

lib/src/data/audio.dart

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -247,13 +247,20 @@ class Audio {
247247
return 'Audio(path: $path, url: $url, audioType: $audioType, imageUrl: $imageUrl, description: $description, website: $website, title: $title, durationMs: $durationMs, artist: $artist, album: $album, albumArtist: $albumArtist, trackNumber: $trackNumber, trackTotal: $trackTotal, discNumber: $discNumber, discTotal: $discTotal, year: $year, genre: $genre, pictureMimeType: $pictureMimeType, pictureData: $pictureData, fileSize: $fileSize, albumArtUrl: $albumArtUrl)';
248248
}
249249

250+
String toShortPath() {
251+
final now = DateTime.now().toUtc().toString();
252+
return '${artist ?? ''}${title ?? ''}${durationMs ?? ''}${year ?? ''})$now'
253+
.replaceAll(RegExp(r'[^a-zA-Z0-9]'), '');
254+
}
255+
250256
@override
251257
bool operator ==(Object other) {
252258
if (identical(this, other)) return true;
253259

254260
return other is Audio &&
255-
other.path == path &&
256-
other.url == url &&
261+
(other.path == path ||
262+
(other.url == url && other.path != null) ||
263+
other.url == url) &&
257264
other.audioType == audioType &&
258265
other.imageUrl == imageUrl &&
259266
other.description == description &&

lib/src/l10n/app_de.arb

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,5 +117,30 @@
117117
"collection": "Sammlung",
118118
"addToCollection": "Zur Sammlung hinzufügen",
119119
"removeFromCollection": "Aus Sammlung entfernen",
120-
"loadingPodcastFeed": "Lade Podcast-Feed..."
120+
"loadingPodcastFeed": "Lade Podcast-Feed...",
121+
"downloadStarted": "Herunterladen gestartet: {name}",
122+
"@downloadStarted": {
123+
"placeholders": {
124+
"name": {
125+
"type": "String"
126+
}
127+
}
128+
},
129+
"downloadCancelled": "Herunterladen abgebrochen: {name}",
130+
"@downloadCancelled": {
131+
"placeholders": {
132+
"name": {
133+
"type": "String"
134+
}
135+
}
136+
},
137+
"downloadFinished": "Herunterladen abgeschlossen: {name}",
138+
"@downloadFinished": {
139+
"placeholders": {
140+
"name": {
141+
"type": "String"
142+
}
143+
}
144+
},
145+
"downloadsOnly": "Nur Downloads"
121146
}

lib/src/l10n/app_en.arb

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,5 +117,30 @@
117117
"collection": "Collection",
118118
"addToCollection": "Add to collection",
119119
"removeFromCollection": "Remove from collection",
120-
"loadingPodcastFeed": "Loading podcast feed..."
120+
"loadingPodcastFeed": "Loading podcast feed...",
121+
"downloadStarted": "Download started: {name}",
122+
"@downloadStarted": {
123+
"placeholders": {
124+
"name": {
125+
"type": "String"
126+
}
127+
}
128+
},
129+
"downloadCancelled": "Download cancelled: {name}",
130+
"@downloadCancelled": {
131+
"placeholders": {
132+
"name": {
133+
"type": "String"
134+
}
135+
}
136+
},
137+
"downloadFinished": "Download finished: {name}",
138+
"@downloadFinished": {
139+
"placeholders": {
140+
"name": {
141+
"type": "String"
142+
}
143+
}
144+
},
145+
"downloadsOnly": "Downloads only"
121146
}

lib/src/library/library_model.dart

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class LibraryModel extends SafeChangeNotifier {
2222
StreamSubscription<bool>? _neverShowFailedImportsSub;
2323
StreamSubscription<bool>? _favTagsSub;
2424
StreamSubscription<bool>? _lastFavSub;
25+
StreamSubscription<bool>? _downloadsSub;
2526

2627
bool get neverShowFailedImports => _service.neverShowFailedImports;
2728
Future<void> setNeverShowLocalImports() async =>
@@ -56,6 +57,7 @@ class LibraryModel extends SafeChangeNotifier {
5657
_service.neverShowFailedImportsChanged.listen((_) => notifyListeners());
5758
_favTagsSub = _service.favTagsChanged.listen((_) => notifyListeners());
5859
_lastFavSub = _service.lastFavChanged.listen((_) => notifyListeners());
60+
_downloadsSub = _service.downloadsChanged.listen((_) => notifyListeners());
5961

6062
notifyListeners();
6163
}
@@ -79,6 +81,7 @@ class LibraryModel extends SafeChangeNotifier {
7981
_neverShowFailedImportsSub?.cancel();
8082
_favTagsSub?.cancel();
8183
_lastFavSub?.cancel();
84+
_downloadsSub?.cancel();
8285

8386
super.dispose();
8487
}
@@ -184,6 +187,16 @@ class LibraryModel extends SafeChangeNotifier {
184187
void removePodcastUpdate(String feedUrl) =>
185188
_service.removePodcastUpdate(feedUrl);
186189

190+
int get downloadsLength => _service.downloads.length;
191+
192+
String? getDownload(String? url) =>
193+
url == null ? null : _service.downloads[url];
194+
195+
bool feedHasDownload(String? feedUrl) =>
196+
feedUrl == null ? false : _service.feedHasDownloads(feedUrl);
197+
198+
int get feedsWithDownloadsLength => _service.feedsWithDownloadsLength;
199+
187200
//
188201
// Albums
189202
//

lib/src/library/library_service.dart

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import 'dart:async';
2+
import 'dart:io';
23

34
import 'package:collection/collection.dart';
5+
import 'package:dio/dio.dart';
46
import 'package:flutter/foundation.dart';
57

68
import '../../constants.dart';
@@ -227,7 +229,70 @@ class LibraryService {
227229
}
228230

229231
// Podcasts
232+
final dio = Dio();
233+
Map<String, String> _downloads = {};
234+
Map<String, String> get downloads => _downloads;
235+
String? getDownload(String? url) => downloads[url];
236+
237+
Set<String> _feedsWithDownloads = {};
238+
bool feedHasDownloads(String feedUrl) =>
239+
_feedsWithDownloads.contains(feedUrl);
240+
int get feedsWithDownloadsLength => _feedsWithDownloads.length;
241+
242+
final _downloadsController = StreamController<bool>.broadcast();
243+
Stream<bool> get downloadsChanged => _downloadsController.stream;
244+
void addDownload({
245+
required String url,
246+
required String path,
247+
required String feedUrl,
248+
}) {
249+
_downloads.putIfAbsent(url, () => path);
250+
_feedsWithDownloads.add(feedUrl);
251+
writeStringMap(_downloads, kDownloads)
252+
.then(
253+
(_) => writeStringSet(
254+
set: _feedsWithDownloads,
255+
filename: kFeedsWithDownloads,
256+
),
257+
)
258+
.then((_) => _downloadsController.add(true));
259+
}
260+
261+
void removeDownload({required String url, required String feedUrl}) {
262+
final path = _downloads[url];
263+
264+
if (path != null) {
265+
final file = File(path);
266+
if (file.existsSync()) {
267+
file.deleteSync();
268+
}
269+
}
270+
271+
if (_downloads.containsKey(url)) {
272+
_downloads.remove(url);
273+
_feedsWithDownloads.remove(feedUrl);
274+
275+
writeStringMap(_downloads, kDownloads)
276+
.then(
277+
(_) => writeStringSet(
278+
set: _feedsWithDownloads,
279+
filename: kFeedsWithDownloads,
280+
),
281+
)
282+
.then((_) => _downloadsController.add(true));
283+
}
284+
}
285+
286+
void _removeFeedWithDownload(String feedUrl) {
287+
_feedsWithDownloads.remove(feedUrl);
288+
writeStringSet(
289+
set: _feedsWithDownloads,
290+
filename: kFeedsWithDownloads,
291+
).then((_) => _downloadsController.add(true));
292+
}
230293

294+
String? _downloadsDir;
295+
String? get downloadsDir => _downloadsDir;
231296
Map<String, Set<Audio>> _podcasts = {};
232297
Map<String, Set<Audio>> get podcasts => _podcasts;
233298
int get podcastsLength => _podcasts.length;
@@ -274,7 +339,9 @@ class LibraryService {
274339
void removePodcast(String name) {
275340
_podcasts.remove(name);
276341
writeAudioMap(_podcasts, kPodcastsFileName)
277-
.then((_) => _podcastsController.add(true));
342+
.then((_) => _podcastsController.add(true))
343+
.then((_) => removePodcastUpdate(name))
344+
.then((_) => _removeFeedWithDownload(name));
278345
}
279346

280347
//
@@ -342,6 +409,9 @@ class LibraryService {
342409
(await readAudioMap(kLikedAudios)).entries.firstOrNull?.value ??
343410
<Audio>{};
344411
_favTags = (await readStringSet(filename: kTagFavsFileName));
412+
_downloadsDir = await getDownloadsDir();
413+
_downloads = await readStringMap(kDownloads);
414+
_feedsWithDownloads = await readStringSet(filename: kFeedsWithDownloads);
345415
_libraryInitialized = true;
346416
}
347417

@@ -399,6 +469,7 @@ class LibraryService {
399469
}
400470

401471
Future<void> dispose() async {
472+
dio.close();
402473
await safeStates();
403474
await _useLocalAudioCacheController.close();
404475
await _albumsController.close();
@@ -414,6 +485,7 @@ class LibraryService {
414485
await _neverShowFailedImportsController.close();
415486
await _lastFavController.close();
416487
await _updateController.close();
488+
await _downloadsController.close();
417489
}
418490

419491
Future<void> safeStates() async {

0 commit comments

Comments
 (0)