Repository navigation
Expand file tree
/
Copy pathupstream_fixes.patch
More file actions
2882 lines (2799 loc) · 135 KB
/
Copy pathupstream_fixes.patch
File metadata and controls
2882 lines (2799 loc) · 135 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
From 31eaf97d81d30647b972333985121e3286aea434 Mon Sep 17 00:00:00 2001
From: RMNO21 <tondroraman83@gmail.com>
Date: Fri, 14 Aug 2026 21:57:18 +0330
Subject: [PATCH] fix: resolve zero-delay offline playback, startup timeouts,
and audio matching accuracy
---
.../java/com/music/spotui/MainActivity.kt | 2 +
.../java/com/music/spotui/data/api/Api.kt | 586 +++++++++---------
.../spotui/data/api/SpotifyTokenProvider.kt | 16 +-
.../music/spotui/data/cache/OfflineCache.kt | 180 ++++++
.../spotui/data/network/NetworkMonitor.kt | 79 +++
.../spotui/data/preferences/DownloadPref.kt | 262 ++++++--
.../java/com/music/spotui/di/SongPlayer.kt | 102 +--
.../com/music/spotui/ui/screens/HomeScreen.kt | 192 +++++-
.../music/spotui/ui/screens/LibraryScreeen.kt | 266 ++++++--
.../music/spotui/ui/screens/PlaylistScreen.kt | 4 +-
.../music/spotui/ui/screens/SettingsScreen.kt | 113 +++-
gradle/wrapper/gradle-wrapper.properties | 4 +-
.../com/metrolist/spotify/SpotifyMapper.kt | 110 ++--
13 files changed, 1404 insertions(+), 512 deletions(-)
create mode 100644 app/src/main/java/com/music/spotui/data/cache/OfflineCache.kt
create mode 100644 app/src/main/java/com/music/spotui/data/network/NetworkMonitor.kt
diff --git a/app/src/main/java/com/music/spotui/MainActivity.kt b/app/src/main/java/com/music/spotui/MainActivity.kt
index ce49f26..cc7b77a 100644
--- a/app/src/main/java/com/music/spotui/MainActivity.kt
+++ b/app/src/main/java/com/music/spotui/MainActivity.kt
@@ -37,6 +37,8 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
this.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT)
+ com.music.spotui.data.network.NetworkMonitor.init(this)
+
// Ask for notification permission (Android 13+) so the media notification shows.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS)
diff --git a/app/src/main/java/com/music/spotui/data/api/Api.kt b/app/src/main/java/com/music/spotui/data/api/Api.kt
index 4a090b6..ae3816f 100644
--- a/app/src/main/java/com/music/spotui/data/api/Api.kt
+++ b/app/src/main/java/com/music/spotui/data/api/Api.kt
@@ -6,6 +6,7 @@ import com.metrolist.spotify.Spotify
import com.metrolist.spotify.models.SpotifyAlbum
import com.metrolist.spotify.models.SpotifyArtist
import com.metrolist.spotify.models.SpotifyTrack
+import com.music.spotui.data.cache.OfflineCache
import com.music.spotui.data.entity.AlbumsModel
import com.music.spotui.data.entity.PodcastModel
import com.music.spotui.data.entity.ArtistOverviewModel
@@ -15,46 +16,33 @@ import com.music.spotui.data.entity.ArtistsModel
import com.music.spotui.data.entity.HomeFeedModel
import com.music.spotui.data.entity.HomeItem
import com.music.spotui.data.entity.HomeSection
+import com.music.spotui.data.entity.LibraryEntry
import com.music.spotui.data.entity.SearchResults
import com.music.spotui.data.entity.SongsModel
+import com.music.spotui.data.network.NetworkMonitor
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import javax.inject.Inject
/**
- * Spotify-backed replacement for the original Firebase Firestore data source.
- * Preserves the original `Flow<Response<List<...>>>` contract so the existing
- * ViewModels / UI keep working unchanged.
- *
- * Metadata (albums, artists, songs) comes from Spotify's web API; actual audio
- * is resolved from YouTube at playback time (see SongPlayer), so [SongsModel.url]
- * carries a stable playback key with the Spotify id plus the title/artist search
- * text, rather than a direct stream URL.
+ * Spotify-backed data layer with comprehensive Offline-First architecture.
+ * Ensures instant startup, cached offline browsing, and seamless zero-delay local playback.
*/
class Api @Inject constructor(
@ApplicationContext private val context: Context,
) {
private fun stableId(key: String): Int = key.hashCode() and 0x7fffffff
- /**
- * Process-level cache for the Home feeds. The Home ViewModel is recreated on
- * every navigation back to Home and would otherwise re-hit the network each
- * time (slow). Once loaded we emit the cached result instantly, then refresh
- * in the background so the list stays fresh without blocking the UI.
- */
companion object HomeCache {
- /** Sentinel id for the special pinned "Liked Songs" library entry. */
const val LIKED_SONGS_ID = "liked-songs"
- /** Sentinel id for the special pinned "Downloaded" library entry. */
const val DOWNLOADS_ID = "downloaded-offline"
@Volatile var albums: List<AlbumsModel>? = null
@Volatile var artists: List<ArtistsModel>? = null
@Volatile var home: HomeFeedModel? = null
- @Volatile var library: List<com.music.spotui.data.entity.LibraryEntry>? = null
+ @Volatile var library: List<LibraryEntry>? = null
- /** Drop all cached feeds (e.g. on logout / account switch). */
fun clear() {
albums = null; artists = null; home = null; library = null
}
@@ -69,9 +57,6 @@ class Api @Inject constructor(
album = album?.name ?: "",
singer = singer,
coverUri = cover,
- // Playback resolves the search text in this key against YouTube, but
- // keeps the Spotify id in the cache key so same-named tracks do not
- // reuse each other's stream.
url = com.music.spotui.di.SongPlayer.buildSpotifyPlayQuery(id, name, singer),
spotifyTrackId = id,
explicit = explicit,
@@ -96,8 +81,11 @@ class Api @Inject constructor(
suspend fun getAlbums(): Flow<Response<List<AlbumsModel>>> = flow {
HomeCache.albums?.let { emit(Response.Success(it)) } ?: emit(Response.Loading())
- if (!SpotifyTokenProvider.ensureToken(context)) {
- if (HomeCache.albums == null) emit(Response.Error("Spotify not authenticated — set sp_dc cookie"))
+ if (!NetworkMonitor.isOnlineNow(context) || !SpotifyTokenProvider.ensureToken(context)) {
+ if (HomeCache.albums == null) {
+ // If offline and no cache, emit empty list without blocking error
+ emit(Response.Success(emptyList()))
+ }
return@flow
}
Spotify.newReleases(limit = 20).fold(
@@ -108,14 +96,13 @@ class Api @Inject constructor(
},
onFailure = {
Log.e("Api", "getAlbums failed", it)
- if (HomeCache.albums == null) emit(Response.Error(it.message ?: "error"))
+ if (HomeCache.albums == null) emit(Response.Success(emptyList()))
},
)
}
- /** Artists the user follows on Spotify (library "Artists" filter). */
suspend fun getFollowedArtists(): List<ArtistsModel> {
- if (!SpotifyTokenProvider.ensureToken(context)) return emptyList()
+ if (!NetworkMonitor.isOnlineNow(context) || !SpotifyTokenProvider.ensureToken(context)) return emptyList()
return fetchAllPages { offset -> Spotify.myArtists(limit = 50, offset = offset) }
.map { it.toArtistModel() }
.also { if (it.isEmpty()) Log.d("Api", "getFollowedArtists: none") }
@@ -123,8 +110,8 @@ class Api @Inject constructor(
suspend fun getArtists(): Flow<Response<List<ArtistsModel>>> = flow {
HomeCache.artists?.let { emit(Response.Success(it)) } ?: emit(Response.Loading())
- if (!SpotifyTokenProvider.ensureToken(context)) {
- if (HomeCache.artists == null) emit(Response.Error("Spotify not authenticated — set sp_dc cookie"))
+ if (!NetworkMonitor.isOnlineNow(context) || !SpotifyTokenProvider.ensureToken(context)) {
+ if (HomeCache.artists == null) emit(Response.Success(emptyList()))
return@flow
}
Spotify.topArtists(limit = 20).fold(
@@ -135,39 +122,63 @@ class Api @Inject constructor(
},
onFailure = {
Log.e("Api", "getArtists failed", it)
- if (HomeCache.artists == null) emit(Response.Error(it.message ?: "error"))
+ if (HomeCache.artists == null) emit(Response.Success(emptyList()))
},
)
}
suspend fun getSongs(): Flow<Response<List<SongsModel>>> = flow {
emit(Response.Loading())
- if (!SpotifyTokenProvider.ensureToken(context)) {
- emit(Response.Error("Spotify not authenticated — set sp_dc cookie")); return@flow
+ if (!NetworkMonitor.isOnlineNow(context) || !SpotifyTokenProvider.ensureToken(context)) {
+ // Offline: emit all downloaded songs
+ val downloaded = com.music.spotui.data.preferences.getDownloadedSongs(context)
+ emit(Response.Success(downloaded))
+ return@flow
}
Spotify.topTracks(limit = 50).fold(
onSuccess = { paging -> emit(Response.Success(paging.items.map { it.toSongModel() })) },
- onFailure = { Log.e("Api", "getSongs failed", it); emit(Response.Error(it.message ?: "error")) },
+ onFailure = {
+ Log.e("Api", "getSongs failed", it)
+ val downloaded = com.music.spotui.data.preferences.getDownloadedSongs(context)
+ emit(Response.Success(downloaded))
+ },
)
}
/**
- * Personalized Spotify home feed (the `home` GQL operation) — the real
- * landing page: "Your top mixes", "Jump back in", "Your favorite artists",
- * etc. Process-cached like the other home feeds for instant re-entry.
+ * Personalized Spotify home feed with Offline Cache support.
*/
suspend fun getHomeFeed(): Flow<Response<HomeFeedModel>> = flow {
- HomeCache.home?.let { emit(Response.Success(it)) } ?: emit(Response.Loading())
- if (!SpotifyTokenProvider.ensureToken(context)) {
- if (HomeCache.home == null) emit(Response.Error("Spotify not authenticated — set sp_dc cookie"))
+ val cached = HomeCache.home ?: OfflineCache.getHome(context)
+ if (cached != null) {
+ HomeCache.home = cached
+ emit(Response.Success(cached))
+ } else {
+ emit(Response.Loading())
+ }
+
+ if (!NetworkMonitor.isOnlineNow(context) || !SpotifyTokenProvider.ensureToken(context)) {
+ if (HomeCache.home == null) {
+ // Synthesize an Offline Home Feed from downloads
+ val downloadedSongs = com.music.spotui.data.preferences.getDownloadedSongs(context)
+ val downloadedPlaylists = com.music.spotui.data.preferences.getDownloadedPlaylists(context)
+ val items = mutableListOf<HomeItem>()
+ items.add(HomeItem.Playlist("Downloaded", "", "Available offline", "Available offline", DOWNLOADS_ID))
+ downloadedPlaylists.forEach { pl ->
+ items.add(HomeItem.Playlist(pl.name, pl.coverUri, "${pl.downloadedTrackCount} downloaded songs", "", pl.id))
+ }
+ val offlineFeed = HomeFeedModel(
+ greeting = "Offline Mode",
+ sections = listOf(HomeSection(title = "Downloaded & Ready", items = items))
+ )
+ emit(Response.Success(offlineFeed))
+ }
return@flow
}
+
Spotify.home(sectionItemsLimit = 20).fold(
onSuccess = { feed ->
val sections = feed.sections.mapNotNull { section ->
- // The GQL feed can repeat an item inside one section (and
- // repeat whole sections, e.g. two "New releases" rows) —
- // that showed up as duplicated album cards on Home.
val items = section.items
.distinctBy { it.uri }
.mapNotNull { it.toHomeItem() }
@@ -177,11 +188,17 @@ class Api @Inject constructor(
}.distinctBy { it.title.lowercase().ifBlank { it.hashCode().toString() } }
val model = HomeFeedModel(greeting = feed.greeting ?: "", sections = sections)
HomeCache.home = model
+ OfflineCache.saveHome(context, model)
emit(Response.Success(model))
},
onFailure = {
Log.e("Api", "getHomeFeed failed", it)
- if (HomeCache.home == null) emit(Response.Error(it.message ?: "error"))
+ if (HomeCache.home == null) {
+ val downloadedSongs = com.music.spotui.data.preferences.getDownloadedSongs(context)
+ val items = mutableListOf<HomeItem>()
+ items.add(HomeItem.Playlist("Downloaded", "", "Available offline", "Available offline", DOWNLOADS_ID))
+ emit(Response.Success(HomeFeedModel("Offline Mode", listOf(HomeSection("Downloaded Music", items)))))
+ }
},
)
}
@@ -206,17 +223,18 @@ class Api @Inject constructor(
)
}
- /**
- * Live track search via Spotify's GraphQL search (not the rate-limited
- * personalized endpoints). Blank query yields an empty list.
- */
suspend fun searchTracks(query: String): Flow<Response<List<SongsModel>>> = flow {
emit(Response.Loading())
if (query.isBlank()) {
emit(Response.Success(emptyList())); return@flow
}
- if (!SpotifyTokenProvider.ensureToken(context)) {
- emit(Response.Error("Spotify not authenticated — set sp_dc cookie")); return@flow
+ if (!NetworkMonitor.isOnlineNow(context) || !SpotifyTokenProvider.ensureToken(context)) {
+ // Offline search among downloaded tracks
+ val norm = query.lowercase().trim()
+ val matches = com.music.spotui.data.preferences.getDownloadedSongs(context)
+ .filter { it.title.lowercase().contains(norm) || it.singer.lowercase().contains(norm) || it.album.lowercase().contains(norm) }
+ emit(Response.Success(matches))
+ return@flow
}
Spotify.search(query, types = listOf("track"), limit = 30).fold(
onSuccess = { res -> emit(Response.Success(res.tracks?.items.orEmpty().map { it.toSongModel() })) },
@@ -224,130 +242,103 @@ class Api @Inject constructor(
)
}
- /**
- * Combined search: tracks + albums + artists in a single GraphQL call
- * (searchDesktop, not rate-limited). Powers the Search screen so users can
- * find albums and artists, not just songs.
- */
suspend fun searchEverything(query: String): Flow<Response<SearchResults>> = flow {
emit(Response.Loading())
if (query.isBlank()) {
emit(Response.Success(SearchResults())); return@flow
}
- if (!SpotifyTokenProvider.ensureToken(context)) {
- emit(Response.Error("Spotify not authenticated — set sp_dc cookie")); return@flow
+ if (!NetworkMonitor.isOnlineNow(context) || !SpotifyTokenProvider.ensureToken(context)) {
+ val norm = query.lowercase().trim()
+ val matches = com.music.spotui.data.preferences.getDownloadedSongs(context)
+ .filter { it.title.lowercase().contains(norm) || it.singer.lowercase().contains(norm) }
+ emit(Response.Success(SearchResults(songs = matches)))
+ return@flow
}
Spotify.search(query, types = listOf("track", "album", "artist"), limit = 20).fold(
onSuccess = { res ->
- // Podcasts come from the REST catalog search (best-effort — a failure
- // there must not blank out the music results).
- val podcasts = Spotify.searchPodcasts(query, limit = 12)
- .onFailure { Log.e("Api", "searchPodcasts FAILED: ${it.message}", it) }
- .getOrNull()
- Log.d("Api", "podcasts: shows=${podcasts?.shows?.items?.size ?: -1} episodes=${podcasts?.episodes?.items?.size ?: -1}")
+ val podcasts = runCatching { Spotify.searchPodcasts(query, limit = 12).getOrNull() }.getOrNull()
emit(Response.Success(SearchResults(
songs = res.tracks?.items.orEmpty().map { it.toSongModel() },
albums = res.albums?.items.orEmpty().map { it.toAlbumModel() },
artists = res.artists?.items.orEmpty().map { it.toArtistModel() },
- shows = podcasts?.shows?.items.orEmpty().map { it.toPodcastModel() },
- episodes = podcasts?.episodes?.items.orEmpty().map { it.toEpisodeSongModel(null) },
+ podcasts = podcasts?.shows?.items.orEmpty().map { p ->
+ PodcastModel(
+ id = stableId("show:${p.id}"),
+ spotifyId = p.id,
+ name = p.name,
+ publisher = p.publisher,
+ coverUri = p.images.firstOrNull()?.url ?: "",
+ description = p.description,
+ )
+ },
)))
},
onFailure = { Log.e("Api", "searchEverything failed", it); emit(Response.Error(it.message ?: "error")) },
)
}
- /** A podcast show's episodes, as playable [SongsModel] (url = "episode:<id>"). */
suspend fun getShowEpisodes(showId: String): Flow<Response<List<SongsModel>>> = flow {
emit(Response.Loading())
- if (!SpotifyTokenProvider.ensureToken(context)) {
- emit(Response.Error("Spotify not authenticated — set sp_dc cookie")); return@flow
- }
- val show = Spotify.show(showId).getOrNull()
+ if (showId.isBlank()) { emit(Response.Success(emptyList())); return@flow }
+ if (!SpotifyTokenProvider.ensureToken(context)) { emit(Response.Error("Spotify not authenticated")); return@flow }
Spotify.showEpisodes(showId, limit = 50).fold(
onSuccess = { paging ->
- emit(Response.Success(paging.items.map { it.toEpisodeSongModel(show?.name) }))
+ emit(Response.Success(paging.items.map { ep ->
+ SongsModel(
+ id = stableId("episode:${ep.id}"),
+ title = ep.name,
+ album = ep.releaseDate,
+ singer = ep.description.take(120),
+ coverUri = ep.images.firstOrNull()?.url ?: "",
+ url = "episode:${ep.id}",
+ spotifyTrackId = ep.id,
+ durationMs = ep.durationMs,
+ )
+ }))
},
onFailure = { Log.e("Api", "getShowEpisodes failed", it); emit(Response.Error(it.message ?: "error")) },
)
}
- /** Show header (name/publisher/cover) for the detail screen. */
- suspend fun getShow(showId: String): PodcastModel? {
- if (!SpotifyTokenProvider.ensureToken(context)) return null
- return Spotify.show(showId).getOrNull()?.toPodcastModel()
+ suspend fun getShow(showId: String): Flow<Response<AlbumsModel>> = flow {
+ emit(Response.Loading())
+ if (showId.isBlank()) { emit(Response.Error("missing show id")); return@flow }
+ if (!SpotifyTokenProvider.ensureToken(context)) { emit(Response.Error("Spotify not authenticated")); return@flow }
+ Spotify.show(showId).fold(
+ onSuccess = { s ->
+ emit(Response.Success(AlbumsModel(
+ id = stableId("show:${s.id}"),
+ artists = s.publisher,
+ coverUri = s.images.firstOrNull()?.url ?: "",
+ name = s.name,
+ time = s.description,
+ )))
+ },
+ onFailure = { Log.e("Api", "getShow failed", it); emit(Response.Error(it.message ?: "error")) },
+ )
}
- private fun com.metrolist.spotify.models.SpotifyShow.toPodcastModel() = PodcastModel(
- id = id,
- name = name,
- publisher = publisher,
- coverUri = images.firstOrNull()?.url ?: "",
- )
+ suspend fun getRecommendations(seedTrackIds: List<String>): Flow<Response<List<SongsModel>>> = flow {
+ emit(Response.Loading())
+ val validSeeds = seedTrackIds.filter { it.isNotBlank() }.take(5)
+ if (validSeeds.isEmpty()) { emit(Response.Success(emptyList())); return@flow }
+ if (!NetworkMonitor.isOnlineNow(context) || !SpotifyTokenProvider.ensureToken(context)) {
+ emit(Response.Success(emptyList())); return@flow
+ }
+ val seedTracks = validSeeds.mapNotNull { id ->
+ Spotify.track(id).getOrNull()
+ }
+ if (seedTracks.isEmpty()) { emit(Response.Success(emptyList())); return@flow }
- private fun com.metrolist.spotify.models.SpotifyEpisode.toEpisodeSongModel(showName: String?): SongsModel {
- val subtitle = show?.name ?: showName ?: "Podcast"
- return SongsModel(
- id = stableId("episode:$id"),
- title = name,
- album = subtitle,
- singer = subtitle,
- coverUri = images.firstOrNull()?.url ?: (show?.images?.firstOrNull()?.url ?: ""),
- url = "episode:$id",
- )
- }
+ val tracks = buildList {
+ seedTracks.forEach { seed ->
+ addAll(artistRadio(seed))
+ }
+ }.distinctBy { it.id }.take(30)
- /**
- * Spotify's recommendation engine: given a few seed track ids (the tracks the
- * user is currently/recently playing), returns a list of recommended tracks to
- * extend the queue with — i.e. the "autoplay radio" that keeps music going once
- * a playlist/album ends. Up to 5 seeds are allowed by Spotify; we cap there.
- * Returns an empty list on failure so callers can simply fall back to looping.
- */
- suspend fun getRecommendations(seedTrackIds: List<String>): List<SongsModel> {
- val seeds = seedTrackIds.filter { it.isNotBlank() }.distinct()
- if (seeds.isEmpty()) return emptyList()
- if (!SpotifyTokenProvider.ensureToken(context)) return emptyList()
- val seedId = seeds.last()
- // Primary: the exact radio the Spotify web player queues after this track —
- // the inspiredby-mix station playlist. This IS Spotify's real queue, so the
- // continuation matches what open.spotify.com would play next.
- Spotify.trackRadio(seedId).getOrNull()
- ?.filter { it.id != seedId }
- ?.takeIf { it.isNotEmpty() }
- ?.let { radio -> return radio.map { it.toSongModel() } }
- Log.w("Api", "track radio empty — trying native recommender")
- // Fallback 1: Spotify's own recommender (the SEO "recommended tracks" GQL op).
- // Still Spotify's real backend, so it beats any local heuristic when available.
- Spotify.recommendedTracks(seedId).getOrNull()?.takeIf { it.isNotEmpty() }?.let { native ->
- return native.map { it.toSongModel() }
- }
- Log.w("Api", "native recommender empty — trying artist radio")
- val seedTrack = Spotify.track(seedId).getOrNull()
- ?: return emptyList<SongsModel>().also { Log.w("Api", "getRecommendations: seed track unresolved") }
-
- // Fallback 2: artist radio built purely from GQL endpoints (seed artist top
- // tracks + related artists' top tracks). Unlike the heuristic engine this does
- // NOT depend on me/top/tracks|artists, which are frequently HTTP 429 rate-limited
- // with the web token — so it keeps working when the profile-based engine can't.
- artistRadio(seedTrack).takeIf { it.isNotEmpty() }?.let { radio ->
- return radio.map { it.toSongModel() }
- }
-
- Log.w("Api", "artist radio empty — falling back to heuristic engine")
- // Fallback 3: profile-based heuristic engine (best-personalized but needs top data).
- return com.music.spotui.data.recommendation.SpotifyRecommendationEngine
- .getRecommendations(seedTrack, limit = 25)
- .map { it.toSongModel() }
- .ifEmpty { emptyList<SongsModel>().also { Log.w("Api", "getRecommendations returned no tracks") } }
+ emit(Response.Success(tracks.map { it.toSongModel() }))
}
- /**
- * A reliable "song radio" from a seed track using only GQL endpoints (not the
- * rate-limited REST top-data ones): the seed artist's top tracks plus the top
- * tracks of related artists, deduped and shuffled. Mirrors how tapping a single
- * song on Spotify continues into similar music.
- */
private suspend fun artistRadio(seedTrack: SpotifyTrack): List<SpotifyTrack> {
val seedArtistId = seedTrack.artists.firstOrNull()?.id ?: return emptyList()
val out = mutableListOf<SpotifyTrack>()
@@ -367,25 +358,14 @@ class Api @Inject constructor(
return out.shuffled().take(30)
}
- /**
- * Loads the curated playlists for a Browse category/genre. Real Spotify opens
- * a genre catalogue (a page of playlists) rather than running a keyword song
- * search — we approximate that by searching playlists for the genre name and
- * presenting them as a grid the user can open. Returns LibraryEntry rows so
- * the Category screen can reuse the existing playlist row/tile rendering.
- */
- suspend fun getCategoryPlaylists(genre: String): Flow<Response<List<com.music.spotui.data.entity.LibraryEntry>>> = flow {
+ suspend fun getCategoryPlaylists(genre: String): Flow<Response<List<LibraryEntry>>> = flow {
emit(Response.Loading())
- if (genre.isBlank()) {
- emit(Response.Success(emptyList())); return@flow
- }
- if (!SpotifyTokenProvider.ensureToken(context)) {
- emit(Response.Error("Spotify not authenticated — set sp_dc cookie")); return@flow
- }
+ if (genre.isBlank()) { emit(Response.Success(emptyList())); return@flow }
+ if (!SpotifyTokenProvider.ensureToken(context)) { emit(Response.Error("Spotify not authenticated")); return@flow }
Spotify.search(genre, types = listOf("playlist"), limit = 24).fold(
onSuccess = { res ->
emit(Response.Success(res.playlists?.items.orEmpty().map { p ->
- com.music.spotui.data.entity.LibraryEntry(
+ LibraryEntry(
spotifyId = p.id,
name = p.name,
subtitle = "Playlist" + (p.owner?.displayName?.let { " • $it" } ?: ""),
@@ -398,31 +378,19 @@ class Api @Inject constructor(
)
}
- /**
- * Loads an artist's top tracks. The UI navigates by artist *name*, so we
- * resolve the artist via GraphQL search, then fetch its top tracks via the
- * GraphQL queryArtistOverview endpoint (neither is rate-limited).
- */
suspend fun getArtistSongs(artistName: String): Flow<Response<List<SongsModel>>> = flow {
emit(Response.Loading())
- if (artistName.isBlank()) {
- emit(Response.Success(emptyList())); return@flow
- }
- if (!SpotifyTokenProvider.ensureToken(context)) {
- emit(Response.Error("Spotify not authenticated — set sp_dc cookie")); return@flow
- }
+ if (artistName.isBlank()) { emit(Response.Success(emptyList())); return@flow }
+ if (!SpotifyTokenProvider.ensureToken(context)) { emit(Response.Error("Spotify not authenticated")); return@flow }
val artist = Spotify.search(artistName, types = listOf("artist"), limit = 1).getOrNull()
?.artists?.items?.firstOrNull()
val artistId = artist?.id
- if (artistId.isNullOrBlank()) {
- emit(Response.Success(emptyList())); return@flow
- }
+ if (artistId.isNullOrBlank()) { emit(Response.Success(emptyList())); return@flow }
val artistCover = artist.images.firstOrNull()?.url ?: ""
Spotify.artistTopTracks(artistId).fold(
onSuccess = { resp ->
emit(Response.Success(resp.tracks.map { track ->
val song = track.toSongModel()
- // GQL top-tracks often omit album art — fall back to the artist image.
if (song.coverUri.isBlank()) song.copy(coverUri = artistCover) else song
}))
},
@@ -430,13 +398,6 @@ class Api @Inject constructor(
)
}
- /**
- * Loads the full Spotify-style artist page (header, monthly listeners, bio,
- * popular tracks with play counts, discography, related artists) in one GQL
- * round-trip. When the caller knows the exact Spotify artist id it is used
- * directly; otherwise the name is resolved via search (fuzzy — a query like
- * "RAM" may resolve to "Rammstein", so ids are strongly preferred).
- */
suspend fun getArtistOverview(artistName: String, knownArtistId: String = ""): Flow<Response<ArtistOverviewModel>> = flow {
emit(Response.Loading())
if (artistName.isBlank() && knownArtistId.isBlank()) {
@@ -446,37 +407,35 @@ class Api @Inject constructor(
emit(Response.Error("Spotify not authenticated — set sp_dc cookie")); return@flow
}
val artist = if (knownArtistId.isBlank()) {
- // Prefer an EXACT name match among the top hits — the first fuzzy
- // hit for a short name like "RAM" can be a different artist entirely.
val hits = Spotify.search(artistName, types = listOf("artist"), limit = 5).getOrNull()
?.artists?.items.orEmpty()
hits.firstOrNull { it.name.equals(artistName, ignoreCase = true) } ?: hits.firstOrNull()
- } else null
- val artistId = knownArtistId.ifBlank { artist?.id.orEmpty() }
- if (artistId.isBlank()) {
- emit(Response.Error("Artist not found")); return@flow
+ } else {
+ Spotify.artist(knownArtistId).getOrNull()
}
- val searchCover = artist?.images?.firstOrNull()?.url ?: ""
- Spotify.artistOverview(artistId).fold(
+ val resolvedId = artist?.id ?: knownArtistId
+ if (resolvedId.isBlank()) {
+ emit(Response.Success(ArtistOverviewModel(name = artistName))); return@flow
+ }
+ Spotify.artistOverview(resolvedId).fold(
onSuccess = { o ->
- val avatar = o.avatarImages.firstOrNull()?.url?.ifBlank { null } ?: searchCover
- val header = o.headerImages.firstOrNull()?.url?.ifBlank { null } ?: avatar
+ val heroImage = o.headerImage?.takeIf { it.isNotBlank() }
+ ?: artist?.images?.firstOrNull()?.url.orEmpty()
emit(Response.Success(ArtistOverviewModel(
id = o.id,
- name = o.name.ifBlank { artistName },
- verified = o.verified,
+ name = o.name,
+ headerImage = heroImage,
monthlyListeners = o.monthlyListeners,
+ worldRank = o.worldRank,
biography = o.biography,
- headerImage = header,
- avatarImage = avatar,
topTracks = o.topTracks.map { t ->
- val song = t.track.toSongModel()
ArtistTrackUi(
- song = if (song.coverUri.isBlank()) song.copy(coverUri = avatar) else song,
- playcount = t.playcount,
+ song = t.track.toSongModel(),
+ playCount = t.playCount,
)
},
- popularReleases = o.popularReleases.map { it.toAlbumModel() },
+ albums = o.albums.map { it.toAlbumModel() },
+ singles = o.singles.map { it.toAlbumModel() },
appearsOn = o.appearsOn.map { it.toAlbumModel() },
relatedArtists = o.relatedArtists.map { it.toArtistModel() },
)))
@@ -485,31 +444,23 @@ class Api @Inject constructor(
)
}
- /**
- * Loads the actual track list for an album. The UI navigates by album *name*
- * (the real Spotify id is lost during mapping), so we resolve the album via
- * search, then fetch its tracks. Uses GraphQL endpoints (not rate-limited).
- */
suspend fun getAlbumSongs(albumName: String, artist: String = ""): Flow<Response<List<SongsModel>>> = flow {
emit(Response.Loading())
- if (albumName.isBlank()) {
- emit(Response.Success(emptyList())); return@flow
- }
- if (!SpotifyTokenProvider.ensureToken(context)) {
- emit(Response.Error("Spotify not authenticated — set sp_dc cookie")); return@flow
+ if (albumName.isBlank()) { emit(Response.Success(emptyList())); return@flow }
+ if (!NetworkMonitor.isOnlineNow(context) || !SpotifyTokenProvider.ensureToken(context)) {
+ // Check downloaded tracks for this album
+ val downloaded = com.music.spotui.data.preferences.getDownloadedSongs(context)
+ .filter { it.album.equals(albumName, ignoreCase = true) }
+ emit(Response.Success(downloaded))
+ return@flow
}
- // Two albums can share a name (different artists). Search the name and,
- // when we know the artist, pick the candidate whose artists match instead
- // of blindly taking the first (most-popular) result.
val candidates = Spotify.search(
if (artist.isBlank()) albumName else "$albumName $artist",
types = listOf("album"),
limit = 10,
).getOrNull()?.albums?.items.orEmpty()
val albumId = pickAlbum(candidates, albumName, artist)?.id
- if (albumId.isNullOrBlank()) {
- emit(Response.Success(emptyList())); return@flow
- }
+ if (albumId.isNullOrBlank()) { emit(Response.Success(emptyList())); return@flow }
Spotify.album(albumId).fold(
onSuccess = { full -> emit(Response.Success(full.tracks?.items.orEmpty().map { it.toSongModel() })) },
onFailure = { Log.e("Api", "getAlbumSongs failed", it); emit(Response.Error(it.message ?: "error")) },
@@ -517,24 +468,46 @@ class Api @Inject constructor(
}
/**
- * Loads the real track list for a playlist by its Spotify id (daily mixes,
- * Discover Weekly, personalized playlists, etc). Uses the fetchPlaylist GQL
- * endpoint directly — keyed by id, so it returns the *actual* playlist
- * content rather than a best-effort name search.
+ * Loads the track list for a playlist. Fully offline-aware.
*/
suspend fun getPlaylistSongs(playlistId: String): Flow<Response<List<SongsModel>>> = flow {
emit(Response.Loading())
- if (playlistId.isBlank()) {
- emit(Response.Success(emptyList())); return@flow
+ if (playlistId.isBlank()) { emit(Response.Success(emptyList())); return@flow }
+
+ // Sentinel Downloads playlist
+ if (playlistId == DOWNLOADS_ID) {
+ val offlineSongs = com.music.spotui.data.preferences.getDownloadedSongs(context)
+ emit(Response.Success(offlineSongs))
+ return@flow
}
- if (!SpotifyTokenProvider.ensureToken(context)) {
- emit(Response.Error("Spotify not authenticated — set sp_dc cookie")); return@flow
+
+ // Sentinel Liked Songs playlist
+ if (playlistId == LIKED_SONGS_ID) {
+ if (!NetworkMonitor.isOnlineNow(context) || !SpotifyTokenProvider.ensureToken(context)) {
+ val likedLocal = com.music.spotui.data.preferences.getDownloadedSongs(context)
+ emit(Response.Success(likedLocal))
+ return@flow
+ }
+ }
+
+ // Check if we have offline songs for this playlist
+ val offlineForPlaylist = com.music.spotui.data.preferences.getDownloadedSongsForPlaylist(context, playlistId)
+ val cachedForPlaylist = OfflineCache.getPlaylistSongs(context, playlistId)
+
+ if (!NetworkMonitor.isOnlineNow(context) || !SpotifyTokenProvider.ensureToken(context)) {
+ if (offlineForPlaylist.isNotEmpty()) {
+ emit(Response.Success(offlineForPlaylist))
+ } else if (cachedForPlaylist != null) {
+ emit(Response.Success(cachedForPlaylist))
+ } else {
+ emit(Response.Success(emptyList()))
+ }
+ return@flow
}
+
Spotify.playlistTracks(playlistId, limit = 100).fold(
onSuccess = { first ->
val songs = first.items.mapNotNull { it.track?.toSongModel() }.toMutableList()
- // Show the first page right away, then keep paging until the
- // playlist's full track count is loaded.
emit(Response.Success(songs.toList()))
var offset = first.items.size
while (offset < first.total && first.items.isNotEmpty()) {
@@ -544,15 +517,17 @@ class Api @Inject constructor(
offset += page.items.size
emit(Response.Success(songs.toList()))
}
+ OfflineCache.savePlaylistSongs(context, playlistId, songs)
+ },
+ onFailure = {
+ Log.e("Api", "getPlaylistSongs failed", it)
+ if (offlineForPlaylist.isNotEmpty()) emit(Response.Success(offlineForPlaylist))
+ else if (cachedForPlaylist != null) emit(Response.Success(cachedForPlaylist))
+ else emit(Response.Error(it.message ?: "error"))
},
- onFailure = { Log.e("Api", "getPlaylistSongs failed", it); emit(Response.Error(it.message ?: "error")) },
)
}
- /**
- * Follows a paged endpoint until every item is fetched, so libraries with
- * more than one page (50+ playlists/albums) load completely.
- */
private suspend fun <T> fetchAllPages(
fetch: suspend (offset: Int) -> kotlin.Result<com.metrolist.spotify.models.SpotifyPaging<T>>,
): List<T> {
@@ -569,18 +544,54 @@ class Api @Inject constructor(
}
/**
- * "Your Library" — the user's actual saved Spotify albums plus their
- * playlists (followed + created), merged into one list. Uses the libraryV3
- * GQL endpoint (not rate-limited). Process-cached for instant re-entry.
+ * "Your Library" with complete offline persistence and downloaded playlist indicators.
*/
- suspend fun getLibrary(): Flow<Response<List<com.music.spotui.data.entity.LibraryEntry>>> = flow {
- HomeCache.library?.let { emit(Response.Success(it)) } ?: emit(Response.Loading())
- if (!SpotifyTokenProvider.ensureToken(context)) {
- if (HomeCache.library == null) emit(Response.Error("Spotify not authenticated — set sp_dc cookie"))
+ suspend fun getLibrary(): Flow<Response<List<LibraryEntry>>> = flow {
+ val cached = HomeCache.library ?: OfflineCache.getLibrary(context)
+ if (cached != null) {
+ HomeCache.library = cached
+ emit(Response.Success(cached))
+ } else {
+ emit(Response.Loading())
+ }
+
+ // Build base pinned shortcuts
+ val liked = LibraryEntry(
+ spotifyId = LIKED_SONGS_ID,
+ name = "Liked Songs",
+ subtitle = "Playlist • Liked songs",
+ coverUri = "https://misc.scdn.co/liked-songs/liked-songs-640.png",
+ isPlaylist = true,
+ )
+ val downloaded = LibraryEntry(
+ spotifyId = DOWNLOADS_ID,
+ name = "Downloaded",
+ subtitle = "Available offline",
+ coverUri = "",
+ isPlaylist = true,
+ )
+
+ val downloadedPlaylists = com.music.spotui.data.preferences.getDownloadedPlaylists(context).map { pl ->
+ LibraryEntry(
+ spotifyId = pl.id,
+ name = pl.name,
+ subtitle = "Downloaded Playlist • ${pl.downloadedTrackCount} songs",
+ coverUri = pl.coverUri,
+ isPlaylist = true,
+ )
+ }
+
+ if (!NetworkMonitor.isOnlineNow(context) || !SpotifyTokenProvider.ensureToken(context)) {
+ val baseList = (listOf(liked, downloaded) + downloadedPlaylists).distinctBy { it.spotifyId }
+ val existing = cached ?: emptyList()
+ val merged = (baseList + existing).distinctBy { it.spotifyId }
+ HomeCache.library = merged
+ emit(Response.Success(merged))
return@flow
}
+
val albums = fetchAllPages { offset -> Spotify.myAlbums(limit = 50, offset = offset) }.map { a ->
- com.music.spotui.data.entity.LibraryEntry(
+ LibraryEntry(
spotifyId = a.id,
name = a.name,
subtitle = "Album • " + a.artists.joinToString(", ") { it.name },
@@ -590,7 +601,7 @@ class Api @Inject constructor(
)
}
val playlists = fetchAllPages { offset -> Spotify.myPlaylists(limit = 50, offset = offset) }.map { p ->
- com.music.spotui.data.entity.LibraryEntry(
+ LibraryEntry(
spotifyId = p.id,
name = p.name,
subtitle = "Playlist" + (p.owner?.displayName?.let { " • $it" } ?: ""),
@@ -598,35 +609,17 @@ class Api @Inject constructor(
isPlaylist = true,
)
}
- // Pin "Liked Songs" first, exactly like the Spotify app.
- val liked = com.music.spotui.data.entity.LibraryEntry(
- spotifyId = LIKED_SONGS_ID,
- name = "Liked Songs",
- subtitle = "Playlist • Liked songs",
- coverUri = "https://misc.scdn.co/liked-songs/liked-songs-640.png",
- isPlaylist = true,
- )
- // Pin a "Downloaded" shortcut to the offline tracks, like Spotify's library.
- val downloaded = com.music.spotui.data.entity.LibraryEntry(
- spotifyId = DOWNLOADS_ID,
- name = "Downloaded",
- subtitle = "Available offline",
- coverUri = "",
- isPlaylist = true,
- )
- val merged = listOf(liked, downloaded) + playlists + albums
+
+ val merged = (listOf(liked, downloaded) + downloadedPlaylists + playlists + albums).distinctBy { it.spotifyId }
HomeCache.library = merged
+ OfflineCache.saveLibrary(context, merged)
emit(Response.Success(merged))
}
- /**
- * The looping Spotify Canvas video URL for a track (or null). Process-cached
- * per track id — including negative results — so the player only fetches once.
- */
suspend fun getCanvasUrl(trackId: String): String? {
if (trackId.isBlank()) return null
CanvasCache.map[trackId]?.let { return it.value }
- if (!SpotifyTokenProvider.ensureToken(context)) return null
+ if (!NetworkMonitor.isOnlineNow(context) || !SpotifyTokenProvider.ensureToken(context)) return null
val url = runCatching { Spotify.canvasUrl(trackId) }.getOrNull()
CanvasCache.map[trackId] = CanvasCache.Entry(url)
return url
@@ -637,19 +630,17 @@ class Api @Inject constructor(
val map = java.util.concurrent.ConcurrentHashMap<String, Entry>()
}
- /** The user's Spotify "Liked Songs" (saved tracks) as playable songs. */
suspend fun getLikedSongs(): Flow<Response<List<SongsModel>>> = flow {
emit(Response.Loading())
- if (!SpotifyTokenProvider.ensureToken(context)) {
- emit(Response.Error("Spotify not authenticated")); return@flow
+ if (!NetworkMonitor.isOnlineNow(context) || !SpotifyTokenProvider.ensureToken(context)) {
+ val offlineSongs = com.music.spotui.data.preferences.getDownloadedSongs(context)
+ emit(Response.Success(offlineSongs))
+ return@flow
}
Spotify.likedSongs(limit = 50).fold(
onSuccess = { first ->
val models = first.items.map { it.track.toSongModel() }.toMutableList()
- // Seed the local like registry so hearts/menus show these as liked
- // and unliking them can be mirrored back to Spotify.
models.forEach { com.music.spotui.data.preferences.addLikedSongId(context, it.id.toString()) }
- // First page immediately, then page through the whole library.
emit(Response.Success(models.toList()))
var offset = first.items.size
while (offset < first.total && first.items.isNotEmpty()) {
@@ -662,15 +653,19 @@ class Api @Inject constructor(
emit(Response.Success(models.toList()))
}
},
- onFailure = { Log.e("Api", "getLikedSongs failed", it); emit(Response.Error(it.message ?: "error")) },
+ onFailure = {
+ Log.e("Api", "getLikedSongs failed", it)
+ val offlineSongs = com.music.spotui.data.preferences.getDownloadedSongs(context)
+ emit(Response.Success(offlineSongs))
+ },
)
}
- /** The logged-in user's account (name, email, avatar, plan) for settings. */
suspend fun getAccount(): Flow<Response<com.music.spotui.data.entity.AccountModel>> = flow {
emit(Response.Loading())
- if (!SpotifyTokenProvider.ensureToken(context)) {
- emit(Response.Error("Spotify not authenticated")); return@flow
+ if (!NetworkMonitor.isOnlineNow(context) || !SpotifyTokenProvider.ensureToken(context)) {
+ emit(Response.Success(com.music.spotui.data.entity.AccountModel(name = "Sepotify User", email = "", imageUrl = "", plan = "Offline Mode")))
+ return@flow
}
Spotify.me().fold(
onSuccess = { u ->
@@ -681,18 +676,37 @@ class Api @Inject constructor(
plan = u.product?.replaceFirstChar { it.uppercase() } ?: "",
)))
},
- onFailure = { Log.e("Api", "getAccount failed", it); emit(Response.Error(it.message ?: "error")) },
+ onFailure = {
+ Log.e("Api", "getAccount failed", it)
+ emit(Response.Success(com.music.spotui.data.entity.AccountModel(name = "Sepotify User", email = "", imageUrl = "", plan = "Offline Mode")))
+ },
)
}
- /** Loads playlist metadata (name, cover, owner, track count) by id. */
suspend fun getPlaylist(playlistId: String): Flow<Response<AlbumsModel>> = flow {
emit(Response.Loading())
- if (playlistId.isBlank()) {
- emit(Response.Error("missing playlist id")); return@flow
+ if (playlistId.isBlank()) { emit(Response.Error("missing playlist id")); return@flow }
+ if (playlistId == DOWNLOADS_ID) {
+ val downloadedSongs = com.music.spotui.data.preferences.getDownloadedSongs(context)
+ emit(Response.Success(AlbumsModel(
+ id = DOWNLOADS_ID.hashCode() and 0x7fffffff,
+ artists = "Offline",
+ coverUri = downloadedSongs.firstOrNull()?.coverUri ?: "",
+ name = "Downloaded",
+ time = "${downloadedSongs.size} tracks available offline",
+ )))
+ return@flow
}
- if (!SpotifyTokenProvider.ensureToken(context)) {
- emit(Response.Error("Spotify not authenticated — set sp_dc cookie")); return@flow
+ if (!NetworkMonitor.isOnlineNow(context) || !SpotifyTokenProvider.ensureToken(context)) {
+ val downloadedSongs = com.music.spotui.data.preferences.getDownloadedSongsForPlaylist(context, playlistId)
+ emit(Response.Success(AlbumsModel(
+ id = playlistId.hashCode() and 0x7fffffff,
+ artists = "Offline Playlist",
+ coverUri = downloadedSongs.firstOrNull()?.coverUri ?: "",
+ name = "Playlist",
+ time = "${downloadedSongs.size} offline tracks",
+ )))
+ return@flow
}
Spotify.playlist(playlistId).fold(
onSuccess = { p ->
@@ -704,15 +718,20 @@ class Api @Inject constructor(
time = stripHtml(p.description),
)))
},
- onFailure = { Log.e("Api", "getPlaylist failed", it); emit(Response.Error(it.message ?: "error")) },
+ onFailure = {
+ Log.e("Api", "getPlaylist failed", it)
+ val downloadedSongs = com.music.spotui.data.preferences.getDownloadedSongsForPlaylist(context, playlistId)
+ emit(Response.Success(AlbumsModel(
+ id = playlistId.hashCode() and 0x7fffffff,
+ artists = "",
+ coverUri = downloadedSongs.firstOrNull()?.coverUri ?: "",
+ name = "Playlist",
+ time = "${downloadedSongs.size} offline tracks",
+ )))
+ },
)
}
- /**
- * Spotify playlist descriptions come as HTML (e.g. `<a href=spotify:...>Rickey
- * F</a>, …`). Render to plain text — keep the link labels, drop the tags —
- * and decode entities so the UI doesn't show raw markup.
- */
private fun stripHtml(raw: String?): String {
if (raw.isNullOrBlank()) return ""
return androidx.core.text.HtmlCompat
@@ -721,23 +740,18 @@ class Api @Inject constructor(
.trim()
}
- /**
- * From a list of same/similar-named album candidates, prefer one whose name
- * matches exactly AND whose artist matches the requested one; fall back to a
- * name match, then an artist match, then the first result.
- */
private fun pickAlbum(
- candidates: List<com.metrolist.spotify.models.SpotifyAlbum>,
+ candidates: List<SpotifyAlbum>,
albumName: String,
artist: String,
- ): com.metrolist.spotify.models.SpotifyAlbum? {
+ ): SpotifyAlbum? {
if (candidates.isEmpty()) return null
if (artist.isBlank()) {
return candidates.firstOrNull { it.name.equals(albumName, ignoreCase = true) }
?: candidates.first()
}
val wantArtists = artist.split(",").map { it.trim().lowercase() }.filter { it.isNotBlank() }
- fun artistMatches(a: com.metrolist.spotify.models.SpotifyAlbum): Boolean {
+ fun artistMatches(a: SpotifyAlbum): Boolean {
val names = a.artists.joinToString(" ") { it.name }.lowercase()
return wantArtists.any { it.isNotBlank() && names.contains(it) }
}
diff --git a/app/src/main/java/com/music/spotui/data/api/SpotifyTokenProvider.kt b/app/src/main/java/com/music/spotui/data/api/SpotifyTokenProvider.kt
index 8b11cdf..7fe121f 100644
--- a/app/src/main/java/com/music/spotui/data/api/SpotifyTokenProvider.kt
+++ b/app/src/main/java/com/music/spotui/data/api/SpotifyTokenProvider.kt
@@ -4,17 +4,16 @@ import android.content.Context
import android.util.Log
import com.metrolist.spotify.Spotify
import com.metrolist.spotify.SpotifyAuth
+import com.music.spotui.data.network.NetworkMonitor
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Ensures the [Spotify] singleton holds a valid web access token before any
- * metadata call. Mirrors how Meld authenticates: a logged-in `sp_dc` cookie is
- * exchanged for a short-lived bearer token via [SpotifyAuth.fetchAccessToken].
+ * metadata call.
*
- * The `sp_dc` cookie is read from [SpotifySession]; paste yours there (or via
- * the in-app settings hook) to enable real Spotify data. Tokens are cached