Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e610948307 | |||
| 8c00ee21c7 | |||
| 2500914498 | |||
| fb811804d2 | |||
| 0134281b8c | |||
| 4dbb3190ff | |||
| 2299824ad9 | |||
| a77d4ceac0 | |||
| 11c40c6aca |
@@ -72,6 +72,25 @@ jobs:
|
|||||||
if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')
|
if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')
|
||||||
run: flutter build apk --debug
|
run: flutter build apk --debug
|
||||||
|
|
||||||
|
- name: Decode signing keystore
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
# Reconstructs the release keystore from a base64-encoded
|
||||||
|
# CI secret so every tagged build is signed with the same
|
||||||
|
# key. Without this, each CI runner would generate its own
|
||||||
|
# debug keystore and Android would refuse to upgrade an
|
||||||
|
# existing install (signature mismatch).
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }}
|
||||||
|
run: |
|
||||||
|
if [ -z "${ANDROID_KEYSTORE_B64}" ]; then
|
||||||
|
echo "::error::ANDROID_KEYSTORE_B64 secret is missing — release builds need a consistent signing key"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
KEYSTORE_PATH="${RUNNER_TEMP}/minstrel-release.keystore"
|
||||||
|
echo "${ANDROID_KEYSTORE_B64}" | base64 -d > "${KEYSTORE_PATH}"
|
||||||
|
echo "ANDROID_KEYSTORE_PATH=${KEYSTORE_PATH}" >> "${GITHUB_ENV}"
|
||||||
|
|
||||||
- name: Build release APK
|
- name: Build release APK
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
# Inject the tag (sans leading 'v') as the build's --build-name
|
# Inject the tag (sans leading 'v') as the build's --build-name
|
||||||
@@ -79,7 +98,15 @@ jobs:
|
|||||||
# reports — otherwise the in-app update banner would always
|
# reports — otherwise the in-app update banner would always
|
||||||
# think the user is behind because pubspec.yaml's static
|
# think the user is behind because pubspec.yaml's static
|
||||||
# version drifts from the actual release tag.
|
# version drifts from the actual release tag.
|
||||||
|
#
|
||||||
|
# ANDROID_KEYSTORE_PATH is set by the previous step;
|
||||||
|
# build.gradle.kts picks it up and signs the release APK with
|
||||||
|
# the operator's persistent keystore.
|
||||||
shell: bash
|
shell: bash
|
||||||
|
env:
|
||||||
|
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
|
||||||
|
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
|
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
run: |
|
run: |
|
||||||
TAG="${GITHUB_REF#refs/tags/v}"
|
TAG="${GITHUB_REF#refs/tags/v}"
|
||||||
flutter build apk --release --build-name="${TAG}"
|
flutter build apk --release --build-name="${TAG}"
|
||||||
|
|||||||
@@ -30,11 +30,38 @@ android {
|
|||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Real release signing config — populated only when CI exports
|
||||||
|
// ANDROID_KEYSTORE_PATH (decoded from a base64 secret) plus the
|
||||||
|
// matching password/alias env vars. Without these (local
|
||||||
|
// `flutter build apk --release` runs), the release build falls
|
||||||
|
// through to the debug keystore so local builds still work.
|
||||||
|
//
|
||||||
|
// Why this matters: Flutter's default `flutter run --release` and
|
||||||
|
// CI's earlier setup both signed with the per-machine debug
|
||||||
|
// keystore (~/.android/debug.keystore). Every CI runner generated
|
||||||
|
// its own debug key, so consecutive release APKs had different
|
||||||
|
// signatures and Android refused to upgrade an existing install.
|
||||||
|
val keystorePath: String? = System.getenv("ANDROID_KEYSTORE_PATH")
|
||||||
|
if (keystorePath != null && keystorePath.isNotEmpty()) {
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
storeFile = file(keystorePath)
|
||||||
|
storePassword = System.getenv("ANDROID_STORE_PASSWORD")
|
||||||
|
keyAlias = System.getenv("ANDROID_KEY_ALIAS")
|
||||||
|
keyPassword = System.getenv("ANDROID_KEY_PASSWORD")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
signingConfig = if (keystorePath != null && keystorePath.isNotEmpty()) {
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
signingConfigs.getByName("release")
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
} else {
|
||||||
|
// Local-only fallback so `flutter run --release` works
|
||||||
|
// without the keystore. CI must export ANDROID_KEYSTORE_PATH.
|
||||||
|
signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,16 @@ class LibraryListsApi {
|
|||||||
final Dio _dio;
|
final Dio _dio;
|
||||||
|
|
||||||
Future<Paged<ArtistRef>> listArtists({int limit = 50, int offset = 0}) async {
|
Future<Paged<ArtistRef>> listArtists({int limit = 50, int offset = 0}) async {
|
||||||
|
// Server mounts the artists list at /api/artists (handleListArtists),
|
||||||
|
// not /api/library/artists. Albums use /api/library/albums for
|
||||||
|
// historical reasons; the paths aren't symmetric.
|
||||||
final r = await _dio.get<Map<String, dynamic>>(
|
final r = await _dio.get<Map<String, dynamic>>(
|
||||||
'/api/library/artists',
|
'/api/artists',
|
||||||
queryParameters: {'limit': limit, 'offset': offset},
|
queryParameters: {
|
||||||
|
'limit': limit,
|
||||||
|
'offset': offset,
|
||||||
|
'sort': 'alpha',
|
||||||
|
},
|
||||||
);
|
);
|
||||||
return Paged.fromJson(r.data ?? const {}, ArtistRef.fromJson);
|
return Paged.fromJson(r.data ?? const {}, ArtistRef.fromJson);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../../models/track.dart';
|
||||||
|
|
||||||
|
/// `GET /api/radio?seed_track=<uuid>&limit=<int>`. Returns the seed
|
||||||
|
/// at index 0 followed by up to `limit-1` weighted-shuffle picks
|
||||||
|
/// scored by the server's recommendation engine. The shape matches
|
||||||
|
/// what `playerActions.playTracks` expects, so a radio start is just
|
||||||
|
/// one fetch + one playTracks call.
|
||||||
|
class RadioApi {
|
||||||
|
RadioApi(this._dio);
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
Future<List<TrackRef>> seedTrack(String trackId, {int? limit}) async {
|
||||||
|
final r = await _dio.get<Map<String, dynamic>>(
|
||||||
|
'/api/radio',
|
||||||
|
queryParameters: {
|
||||||
|
'seed_track': trackId,
|
||||||
|
if (limit != null) 'limit': limit,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final raw = (r.data?['tracks'] as List?) ?? const [];
|
||||||
|
return raw
|
||||||
|
.map((e) => TrackRef.fromJson((e as Map).cast<String, dynamic>()))
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../api/endpoints/likes.dart';
|
import '../api/endpoints/likes.dart';
|
||||||
|
import '../models/album.dart';
|
||||||
import '../cache/audio_cache_manager.dart';
|
import '../cache/audio_cache_manager.dart';
|
||||||
import '../cache/db.dart';
|
import '../cache/db.dart';
|
||||||
import '../likes/like_button.dart';
|
import '../likes/like_button.dart';
|
||||||
@@ -12,18 +13,78 @@ import 'library_providers.dart';
|
|||||||
import 'widgets/track_row.dart';
|
import 'widgets/track_row.dart';
|
||||||
|
|
||||||
class AlbumDetailScreen extends ConsumerWidget {
|
class AlbumDetailScreen extends ConsumerWidget {
|
||||||
const AlbumDetailScreen({required this.id, super.key});
|
const AlbumDetailScreen({required this.id, this.seed, super.key});
|
||||||
final String id;
|
final String id;
|
||||||
|
|
||||||
|
/// Optional album reference passed by the caller (typically the
|
||||||
|
/// tile they tapped) so the header can render immediately while
|
||||||
|
/// the full provider loads tracks. Hydration only — provider data
|
||||||
|
/// still wins once it arrives.
|
||||||
|
final AlbumRef? seed;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
final live = ref.watch(albumProvider(id));
|
||||||
|
|
||||||
|
// Pick the best header info available: live data > seed > nothing.
|
||||||
|
final headerTitle = live.value?.album.title.isNotEmpty == true
|
||||||
|
? live.value!.album.title
|
||||||
|
: seed?.title ?? '';
|
||||||
|
final headerArtist = live.value?.album.artistName.isNotEmpty == true
|
||||||
|
? live.value!.album.artistName
|
||||||
|
: seed?.artistName ?? '';
|
||||||
|
|
||||||
|
Widget header() => Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 96,
|
||||||
|
height: 96,
|
||||||
|
child: ServerImage(
|
||||||
|
url: '/api/albums/$id/cover',
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
fallback: Container(color: fs.slate),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
headerTitle,
|
||||||
|
style: TextStyle(
|
||||||
|
color: fs.parchment,
|
||||||
|
fontFamily: fs.display.fontFamily,
|
||||||
|
fontSize: 22,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (headerArtist.isNotEmpty)
|
||||||
|
Text(headerArtist, style: TextStyle(color: fs.ash)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(),
|
appBar: AppBar(),
|
||||||
backgroundColor: fs.obsidian,
|
backgroundColor: fs.obsidian,
|
||||||
body: ref.watch(albumProvider(id)).when(
|
body: live.when(
|
||||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => seed != null
|
||||||
|
? ListView(children: [
|
||||||
|
header(),
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 24),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
: const Center(child: CircularProgressIndicator()),
|
||||||
data: (r) => ListView(children: [
|
data: (r) => ListView(children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
import '../api/endpoints/likes.dart';
|
import '../api/endpoints/likes.dart';
|
||||||
import '../likes/like_button.dart';
|
import '../likes/like_button.dart';
|
||||||
import '../models/album.dart';
|
import '../models/album.dart';
|
||||||
|
import '../models/artist.dart';
|
||||||
import '../player/player_provider.dart';
|
import '../player/player_provider.dart';
|
||||||
import '../shared/widgets/server_image.dart';
|
import '../shared/widgets/server_image.dart';
|
||||||
import '../theme/theme_extension.dart';
|
import '../theme/theme_extension.dart';
|
||||||
@@ -12,22 +13,49 @@ import 'library_providers.dart';
|
|||||||
import 'widgets/album_card.dart';
|
import 'widgets/album_card.dart';
|
||||||
|
|
||||||
class ArtistDetailScreen extends ConsumerWidget {
|
class ArtistDetailScreen extends ConsumerWidget {
|
||||||
const ArtistDetailScreen({required this.id, super.key});
|
const ArtistDetailScreen({required this.id, this.seed, super.key});
|
||||||
final String id;
|
final String id;
|
||||||
|
|
||||||
|
/// Optional artist reference from the caller. Lets the screen render
|
||||||
|
/// the name + avatar immediately while albums load.
|
||||||
|
final ArtistRef? seed;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
final artist = ref.watch(artistProvider(id));
|
final artist = ref.watch(artistProvider(id));
|
||||||
final albums = ref.watch(artistAlbumsProvider(id));
|
final albums = ref.watch(artistAlbumsProvider(id));
|
||||||
|
|
||||||
|
// Resolve which artist info to render in the header. Live wins
|
||||||
|
// when present and populated; seed fills the gap during the
|
||||||
|
// first frame after navigation.
|
||||||
|
final liveArtist = artist.value;
|
||||||
|
final hasLiveName = liveArtist != null && liveArtist.name.isNotEmpty;
|
||||||
|
final effective = hasLiveName ? liveArtist : (seed ?? liveArtist);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(),
|
appBar: AppBar(),
|
||||||
backgroundColor: fs.obsidian,
|
backgroundColor: fs.obsidian,
|
||||||
body: artist.when(
|
body: artist.when(
|
||||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
// While loading: render header from seed if available so the
|
||||||
data: (a) => ListView(children: [
|
// page isn't blank.
|
||||||
|
loading: () => seed == null
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: _artistBody(context, ref, seed!, albums, fs),
|
||||||
|
data: (_) => _artistBody(context, ref, effective ?? seed!, albums, fs),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _artistBody(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
ArtistRef a,
|
||||||
|
AsyncValue<List<AlbumRef>> albums,
|
||||||
|
FabledSwordTheme fs,
|
||||||
|
) =>
|
||||||
|
ListView(children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Row(children: [
|
child: Row(children: [
|
||||||
@@ -111,16 +139,14 @@ class ArtistDetailScreen extends ConsumerWidget {
|
|||||||
width: cellW,
|
width: cellW,
|
||||||
titleMaxLines: 2,
|
titleMaxLines: 2,
|
||||||
showArtist: false,
|
showArtist: false,
|
||||||
onTap: () => context.push('/albums/${album.id}'),
|
onTap: () =>
|
||||||
|
context.push('/albums/${album.id}', extra: album),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
]),
|
]);
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Renders the artist's avatar. Server-emitted coverUrl wins when
|
/// Renders the artist's avatar. Server-emitted coverUrl wins when
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import '../models/track.dart';
|
|||||||
import '../playlists/playlists_provider.dart';
|
import '../playlists/playlists_provider.dart';
|
||||||
import '../playlists/widgets/playlist_card.dart';
|
import '../playlists/widgets/playlist_card.dart';
|
||||||
import '../playlists/widgets/playlist_placeholder_card.dart';
|
import '../playlists/widgets/playlist_placeholder_card.dart';
|
||||||
import '../shared/delayed_loading.dart';
|
|
||||||
import '../shared/widgets/connection_error_banner.dart';
|
import '../shared/widgets/connection_error_banner.dart';
|
||||||
import '../shared/widgets/main_app_bar_actions.dart';
|
import '../shared/widgets/main_app_bar_actions.dart';
|
||||||
import '../theme/theme_extension.dart';
|
import '../theme/theme_extension.dart';
|
||||||
@@ -50,12 +49,7 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
return Center(child: Text('$e', style: TextStyle(color: fs.error)));
|
return Center(child: Text('$e', style: TextStyle(color: fs.error)));
|
||||||
},
|
},
|
||||||
loading: () => const DelayedLoading(
|
loading: () => _HomeSkeleton(fs: fs),
|
||||||
isLoading: true,
|
|
||||||
whenReady: SizedBox.shrink(),
|
|
||||||
whileDelayed:
|
|
||||||
Center(child: CircularProgressIndicator()),
|
|
||||||
),
|
|
||||||
data: (h) => RefreshIndicator(
|
data: (h) => RefreshIndicator(
|
||||||
onRefresh: () async => ref.refresh(homeProvider.future),
|
onRefresh: () async => ref.refresh(homeProvider.future),
|
||||||
child: ListView(
|
child: ListView(
|
||||||
@@ -190,7 +184,7 @@ class _RecentlyAddedSection extends StatelessWidget {
|
|||||||
.map((a) => AlbumCard(
|
.map((a) => AlbumCard(
|
||||||
album: a,
|
album: a,
|
||||||
onTap: () =>
|
onTap: () =>
|
||||||
_push(context, '/albums/${a.id}'),
|
context.push('/albums/${a.id}', extra: a),
|
||||||
))
|
))
|
||||||
.toList(),
|
.toList(),
|
||||||
),
|
),
|
||||||
@@ -220,7 +214,7 @@ class _RediscoverSection extends StatelessWidget {
|
|||||||
.map((a) => AlbumCard(
|
.map((a) => AlbumCard(
|
||||||
album: a,
|
album: a,
|
||||||
onTap: () =>
|
onTap: () =>
|
||||||
_push(context, '/albums/${a.id}'),
|
context.push('/albums/${a.id}', extra: a),
|
||||||
))
|
))
|
||||||
.toList(),
|
.toList(),
|
||||||
),
|
),
|
||||||
@@ -232,7 +226,7 @@ class _RediscoverSection extends StatelessWidget {
|
|||||||
.map((ar) => ArtistCard(
|
.map((ar) => ArtistCard(
|
||||||
artist: ar,
|
artist: ar,
|
||||||
onTap: () =>
|
onTap: () =>
|
||||||
_push(context, '/artists/${ar.id}'),
|
context.push('/artists/${ar.id}', extra: ar),
|
||||||
))
|
))
|
||||||
.toList(),
|
.toList(),
|
||||||
),
|
),
|
||||||
@@ -291,7 +285,8 @@ class _LastPlayedSection extends StatelessWidget {
|
|||||||
children: artists
|
children: artists
|
||||||
.map((ar) => ArtistCard(
|
.map((ar) => ArtistCard(
|
||||||
artist: ar,
|
artist: ar,
|
||||||
onTap: () => _push(context, '/artists/${ar.id}'),
|
onTap: () =>
|
||||||
|
context.push('/artists/${ar.id}', extra: ar),
|
||||||
))
|
))
|
||||||
.toList(),
|
.toList(),
|
||||||
);
|
);
|
||||||
@@ -324,8 +319,87 @@ class _EmptySection extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _push(BuildContext context, String path) {
|
/// Cold-start skeleton. Renders the same shape as the real home (a few
|
||||||
context.push(path);
|
/// section titles + card-sized placeholders) so the page feels alive
|
||||||
|
/// while /api/home is in flight, instead of a 30-second blank spinner.
|
||||||
|
/// Each section drops in independently as data arrives via the
|
||||||
|
/// individual providers.
|
||||||
|
class _HomeSkeleton extends StatelessWidget {
|
||||||
|
const _HomeSkeleton({required this.fs});
|
||||||
|
final FabledSwordTheme fs;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListView(
|
||||||
|
physics: const ClampingScrollPhysics(),
|
||||||
|
children: [
|
||||||
|
_SkeletonSection(fs: fs, title: 'Playlists', cardWidth: 176),
|
||||||
|
_SkeletonSection(fs: fs, title: 'Recently added', cardWidth: 140),
|
||||||
|
_SkeletonSection(fs: fs, title: 'Rediscover', cardWidth: 140),
|
||||||
|
_SkeletonSection(fs: fs, title: 'Most played', cardWidth: 140),
|
||||||
|
_SkeletonSection(fs: fs, title: 'Last played', cardWidth: 140),
|
||||||
|
const SizedBox(height: 140),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SkeletonSection extends StatelessWidget {
|
||||||
|
const _SkeletonSection({
|
||||||
|
required this.fs,
|
||||||
|
required this.title,
|
||||||
|
required this.cardWidth,
|
||||||
|
});
|
||||||
|
final FabledSwordTheme fs;
|
||||||
|
final String title;
|
||||||
|
final double cardWidth;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final coverSize = cardWidth - 16;
|
||||||
|
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: fs.display.fontFamily,
|
||||||
|
fontSize: 18,
|
||||||
|
color: fs.parchment,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
height: coverSize + 40,
|
||||||
|
child: ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
itemCount: 6,
|
||||||
|
itemBuilder: (_, __) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: coverSize,
|
||||||
|
height: coverSize,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: fs.slate,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(width: coverSize * 0.7, height: 12, color: fs.slate),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Container(width: coverSize * 0.4, height: 10, color: fs.iron),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
List<List<T>> _chunk<T>(List<T> items, int size) {
|
List<List<T>> _chunk<T>(List<T> items, int size) {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:drift/drift.dart' as drift;
|
import 'package:drift/drift.dart' as drift;
|
||||||
import 'package:flutter/foundation.dart' show debugPrint;
|
import 'package:flutter/foundation.dart' show debugPrint;
|
||||||
@@ -64,6 +66,9 @@ final artistProvider =
|
|||||||
isOnline: () async => (await ref
|
isOnline: () async => (await ref
|
||||||
.read(connectivityProvider.future)
|
.read(connectivityProvider.future)
|
||||||
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||||
|
// SWR: yield cache instantly on hit, refresh in the background so
|
||||||
|
// the row catches up to server state without making the user wait.
|
||||||
|
alwaysRefresh: true,
|
||||||
tag: 'artist($id)',
|
tag: 'artist($id)',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -95,6 +100,7 @@ final artistAlbumsProvider =
|
|||||||
isOnline: () async => (await ref
|
isOnline: () async => (await ref
|
||||||
.read(connectivityProvider.future)
|
.read(connectivityProvider.future)
|
||||||
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||||
|
alwaysRefresh: true,
|
||||||
tag: 'artistAlbums($artistId)',
|
tag: 'artistAlbums($artistId)',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -128,8 +134,11 @@ final artistTracksProvider =
|
|||||||
albumTitle: album?.title ?? '',
|
albumTitle: album?.title ?? '',
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
isOnline: () async =>
|
isOnline: () async => (await ref
|
||||||
(await ref.read(connectivityProvider.future)),
|
.read(connectivityProvider.future)
|
||||||
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||||
|
alwaysRefresh: true,
|
||||||
|
tag: 'artistTracks($artistId)',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -160,6 +169,10 @@ final albumProvider = StreamProvider.family<
|
|||||||
// Once-per-subscription guard so we don't re-fetch in a loop if the
|
// Once-per-subscription guard so we don't re-fetch in a loop if the
|
||||||
// server genuinely returns zero tracks (or if the fetch fails).
|
// server genuinely returns zero tracks (or if the fetch fails).
|
||||||
var fetchAttempted = false;
|
var fetchAttempted = false;
|
||||||
|
// SWR revalidation guard — fire one background refresh per
|
||||||
|
// subscription on the first complete cache hit, so the displayed
|
||||||
|
// data catches up to server state without making the user wait.
|
||||||
|
var revalidated = false;
|
||||||
|
|
||||||
Future<bool> isOnline() async {
|
Future<bool> isOnline() async {
|
||||||
try {
|
try {
|
||||||
@@ -290,6 +303,16 @@ final albumProvider = StreamProvider.family<
|
|||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
|
// SWR: first complete cache hit triggers one background refresh
|
||||||
|
// so we don't show stale data indefinitely. Drift watch re-emits
|
||||||
|
// on success and the next iteration yields the fresh data.
|
||||||
|
if (!revalidated && trackRows.isNotEmpty) {
|
||||||
|
revalidated = true;
|
||||||
|
if (await isOnline()) {
|
||||||
|
unawaited(fetchAndPopulate());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
yield (album: album, tracks: tracks);
|
yield (album: album, tracks: tracks);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,13 +37,89 @@ final _meApiProvider = FutureProvider<MeApi>((ref) async {
|
|||||||
return MeApi(await ref.watch(dioProvider.future));
|
return MeApi(await ref.watch(dioProvider.future));
|
||||||
});
|
});
|
||||||
|
|
||||||
final _libraryArtistsProvider = FutureProvider<wire.Paged<ArtistRef>>((ref) async {
|
/// Paginated artist list. AsyncNotifier so the screen can call
|
||||||
return (await ref.watch(_libraryListsApiProvider.future)).listArtists();
|
/// `loadMore()` when the scroll approaches the bottom; subsequent
|
||||||
});
|
/// pages append to the existing items rather than replacing them.
|
||||||
|
class _LibraryArtistsNotifier extends AsyncNotifier<wire.Paged<ArtistRef>> {
|
||||||
|
static const _pageSize = 50;
|
||||||
|
bool _loadingMore = false;
|
||||||
|
|
||||||
final _libraryAlbumsProvider = FutureProvider<wire.Paged<AlbumRef>>((ref) async {
|
@override
|
||||||
return (await ref.watch(_libraryListsApiProvider.future)).listAlbums();
|
Future<wire.Paged<ArtistRef>> build() async {
|
||||||
});
|
final api = await ref.watch(_libraryListsApiProvider.future);
|
||||||
|
return api.listArtists(limit: _pageSize, offset: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadMore() async {
|
||||||
|
if (_loadingMore) return;
|
||||||
|
final cur = state.value;
|
||||||
|
if (cur == null) return;
|
||||||
|
if (cur.items.length >= cur.total) return;
|
||||||
|
_loadingMore = true;
|
||||||
|
try {
|
||||||
|
final api = await ref.read(_libraryListsApiProvider.future);
|
||||||
|
final next = await api.listArtists(
|
||||||
|
limit: _pageSize,
|
||||||
|
offset: cur.items.length,
|
||||||
|
);
|
||||||
|
state = AsyncData(wire.Paged(
|
||||||
|
items: [...cur.items, ...next.items],
|
||||||
|
total: next.total,
|
||||||
|
limit: next.limit,
|
||||||
|
offset: 0,
|
||||||
|
));
|
||||||
|
} catch (_) {
|
||||||
|
// Swallow — the next near-bottom event will retry. The current
|
||||||
|
// partial list stays visible.
|
||||||
|
} finally {
|
||||||
|
_loadingMore = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final _libraryArtistsProvider = AsyncNotifierProvider<
|
||||||
|
_LibraryArtistsNotifier,
|
||||||
|
wire.Paged<ArtistRef>>(_LibraryArtistsNotifier.new);
|
||||||
|
|
||||||
|
class _LibraryAlbumsNotifier extends AsyncNotifier<wire.Paged<AlbumRef>> {
|
||||||
|
static const _pageSize = 50;
|
||||||
|
bool _loadingMore = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<wire.Paged<AlbumRef>> build() async {
|
||||||
|
final api = await ref.watch(_libraryListsApiProvider.future);
|
||||||
|
return api.listAlbums(limit: _pageSize, offset: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadMore() async {
|
||||||
|
if (_loadingMore) return;
|
||||||
|
final cur = state.value;
|
||||||
|
if (cur == null) return;
|
||||||
|
if (cur.items.length >= cur.total) return;
|
||||||
|
_loadingMore = true;
|
||||||
|
try {
|
||||||
|
final api = await ref.read(_libraryListsApiProvider.future);
|
||||||
|
final next = await api.listAlbums(
|
||||||
|
limit: _pageSize,
|
||||||
|
offset: cur.items.length,
|
||||||
|
);
|
||||||
|
state = AsyncData(wire.Paged(
|
||||||
|
items: [...cur.items, ...next.items],
|
||||||
|
total: next.total,
|
||||||
|
limit: next.limit,
|
||||||
|
offset: 0,
|
||||||
|
));
|
||||||
|
} catch (_) {
|
||||||
|
// Swallow — next scroll event will retry.
|
||||||
|
} finally {
|
||||||
|
_loadingMore = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final _libraryAlbumsProvider = AsyncNotifierProvider<
|
||||||
|
_LibraryAlbumsNotifier,
|
||||||
|
wire.Paged<AlbumRef>>(_LibraryAlbumsNotifier.new);
|
||||||
|
|
||||||
final _historyProvider = FutureProvider<HistoryPage>((ref) async {
|
final _historyProvider = FutureProvider<HistoryPage>((ref) async {
|
||||||
return (await ref.watch(_meApiProvider.future)).history();
|
return (await ref.watch(_meApiProvider.future)).history();
|
||||||
@@ -134,19 +210,41 @@ class _ArtistsTab extends ConsumerWidget {
|
|||||||
data: (page) => page.items.isEmpty
|
data: (page) => page.items.isEmpty
|
||||||
? Center(child: Text("No artists yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
|
? Center(child: Text("No artists yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
|
||||||
: RefreshIndicator(
|
: RefreshIndicator(
|
||||||
onRefresh: () async => ref.refresh(_libraryArtistsProvider.future),
|
onRefresh: () async {
|
||||||
child: GridView.builder(
|
ref.invalidate(_libraryArtistsProvider);
|
||||||
padding: const EdgeInsets.all(8),
|
await ref.read(_libraryArtistsProvider.future);
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
},
|
||||||
crossAxisCount: 3,
|
// Listen for scroll-near-bottom and ask the notifier
|
||||||
mainAxisSpacing: 8,
|
// to fetch the next page. 800px lookahead so the next
|
||||||
crossAxisSpacing: 8,
|
// page lands before the user reaches the visible end.
|
||||||
childAspectRatio: 0.78,
|
child: NotificationListener<ScrollNotification>(
|
||||||
),
|
onNotification: (n) {
|
||||||
itemCount: page.items.length,
|
if (n.metrics.pixels >=
|
||||||
itemBuilder: (ctx, i) => ArtistCard(
|
n.metrics.maxScrollExtent - 800) {
|
||||||
artist: page.items[i],
|
ref
|
||||||
onTap: () => ctx.push('/artists/${page.items[i].id}'),
|
.read(_libraryArtistsProvider.notifier)
|
||||||
|
.loadMore();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
child: GridView.builder(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
gridDelegate:
|
||||||
|
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 3,
|
||||||
|
mainAxisSpacing: 8,
|
||||||
|
crossAxisSpacing: 8,
|
||||||
|
childAspectRatio: 0.78,
|
||||||
|
),
|
||||||
|
itemCount: page.items.length,
|
||||||
|
itemBuilder: (ctx, i) {
|
||||||
|
final artist = page.items[i];
|
||||||
|
return ArtistCard(
|
||||||
|
artist: artist,
|
||||||
|
onTap: () => ctx.push('/artists/${artist.id}',
|
||||||
|
extra: artist),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -166,21 +264,57 @@ class _AlbumsTab extends ConsumerWidget {
|
|||||||
data: (page) => page.items.isEmpty
|
data: (page) => page.items.isEmpty
|
||||||
? Center(child: Text("No albums yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
|
? Center(child: Text("No albums yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
|
||||||
: RefreshIndicator(
|
: RefreshIndicator(
|
||||||
onRefresh: () async => ref.refresh(_libraryAlbumsProvider.future),
|
onRefresh: () async {
|
||||||
child: GridView.builder(
|
ref.invalidate(_libraryAlbumsProvider);
|
||||||
padding: const EdgeInsets.all(8),
|
await ref.read(_libraryAlbumsProvider.future);
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
},
|
||||||
crossAxisCount: 2,
|
// Same responsive 3-up grid as the artist detail
|
||||||
mainAxisSpacing: 8,
|
// album list — LayoutBuilder + AlbumCard sized to the
|
||||||
crossAxisSpacing: 8,
|
// cell, mainAxisExtent matched to actual card height.
|
||||||
childAspectRatio: 0.78,
|
child: LayoutBuilder(builder: (ctx, constraints) {
|
||||||
),
|
const cols = 3;
|
||||||
itemCount: page.items.length,
|
const sidePad = 8.0;
|
||||||
itemBuilder: (ctx, i) => AlbumCard(
|
const gap = 8.0;
|
||||||
album: page.items[i],
|
final cellW = (constraints.maxWidth -
|
||||||
onTap: () => ctx.push('/albums/${page.items[i].id}'),
|
sidePad * 2 -
|
||||||
),
|
gap * (cols - 1)) /
|
||||||
),
|
cols;
|
||||||
|
// cover (cellW - 16) + gap (8) + 2-line title (~36)
|
||||||
|
// + artist line (~16) + small fudge.
|
||||||
|
final cellH = (cellW - 16) + 8 + 36 + 16 + 4;
|
||||||
|
return NotificationListener<ScrollNotification>(
|
||||||
|
onNotification: (n) {
|
||||||
|
if (n.metrics.pixels >=
|
||||||
|
n.metrics.maxScrollExtent - 800) {
|
||||||
|
ref
|
||||||
|
.read(_libraryAlbumsProvider.notifier)
|
||||||
|
.loadMore();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
child: GridView.builder(
|
||||||
|
padding: const EdgeInsets.all(sidePad),
|
||||||
|
gridDelegate:
|
||||||
|
SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: cols,
|
||||||
|
mainAxisExtent: cellH,
|
||||||
|
mainAxisSpacing: gap,
|
||||||
|
crossAxisSpacing: gap,
|
||||||
|
),
|
||||||
|
itemCount: page.items.length,
|
||||||
|
itemBuilder: (ctx, i) {
|
||||||
|
final album = page.items[i];
|
||||||
|
return AlbumCard(
|
||||||
|
album: album,
|
||||||
|
width: cellW,
|
||||||
|
titleMaxLines: 2,
|
||||||
|
onTap: () => ctx.push('/albums/${album.id}',
|
||||||
|
extra: album),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -260,10 +394,14 @@ class _LikedTab extends ConsumerWidget {
|
|||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
itemCount: ar.items.length,
|
itemCount: ar.items.length,
|
||||||
itemBuilder: (ctx, i) => ArtistCard(
|
itemBuilder: (ctx, i) {
|
||||||
artist: ar.items[i],
|
final artist = ar.items[i];
|
||||||
onTap: () => ctx.push('/artists/${ar.items[i].id}'),
|
return ArtistCard(
|
||||||
),
|
artist: artist,
|
||||||
|
onTap: () =>
|
||||||
|
ctx.push('/artists/${artist.id}', extra: artist),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -275,10 +413,14 @@ class _LikedTab extends ConsumerWidget {
|
|||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
itemCount: al.items.length,
|
itemCount: al.items.length,
|
||||||
itemBuilder: (ctx, i) => AlbumCard(
|
itemBuilder: (ctx, i) {
|
||||||
album: al.items[i],
|
final album = al.items[i];
|
||||||
onTap: () => ctx.push('/albums/${al.items[i].id}'),
|
return AlbumCard(
|
||||||
),
|
album: album,
|
||||||
|
onTap: () =>
|
||||||
|
ctx.push('/albums/${album.id}', extra: album),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -165,19 +165,36 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
queue.add([...queue.value, item]);
|
queue.add([...queue.value, item]);
|
||||||
}
|
}
|
||||||
|
|
||||||
MediaItem _toMediaItem(TrackRef t) => MediaItem(
|
MediaItem _toMediaItem(TrackRef t) {
|
||||||
id: t.id,
|
// Stash album_id + artist_id in extras so widgets reconstructing
|
||||||
title: t.title,
|
// a TrackRef from the MediaItem (player kebab → "Go to artist",
|
||||||
artist: t.artistName,
|
// "Go to album") have the IDs they need to navigate. Earlier code
|
||||||
album: t.albumTitle,
|
// only carried album_id which left "Go to artist" pushing
|
||||||
duration: Duration(seconds: t.durationSec),
|
// /artists/ (empty id, route 404).
|
||||||
// Stash album_id in extras so _loadArtForCurrentItem can pull it
|
final extras = <String, dynamic>{};
|
||||||
// back without re-walking the track list.
|
if (t.albumId.isNotEmpty) extras['album_id'] = t.albumId;
|
||||||
extras: t.albumId.isEmpty ? null : {'album_id': t.albumId},
|
if (t.artistId.isNotEmpty) extras['artist_id'] = t.artistId;
|
||||||
);
|
return MediaItem(
|
||||||
|
id: t.id,
|
||||||
|
title: t.title,
|
||||||
|
artist: t.artistName,
|
||||||
|
album: t.albumTitle,
|
||||||
|
duration: Duration(seconds: t.durationSec),
|
||||||
|
extras: extras.isEmpty ? null : extras,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _onCurrentIndexChanged(int? idx) {
|
void _onCurrentIndexChanged(int? idx) {
|
||||||
if (idx == null) return;
|
if (idx == null) return;
|
||||||
|
// Push the new track's MediaItem onto the mediaItem stream so
|
||||||
|
// the player UI rebuilds with the new title/artist/album/cover.
|
||||||
|
// Without this, the bar and full player stayed pinned to whichever
|
||||||
|
// track was passed via setQueueFromTracks(initialIndex:) regardless
|
||||||
|
// of skip/auto-advance.
|
||||||
|
final items = queue.value;
|
||||||
|
if (idx >= 0 && idx < items.length) {
|
||||||
|
mediaItem.add(items[idx]);
|
||||||
|
}
|
||||||
unawaited(_loadArtForCurrentItem());
|
unawaited(_loadArtForCurrentItem());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -219,30 +219,49 @@ class _TitleRow extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Row(children: [
|
// Stack: title centered absolutely in the row; like + kebab pinned
|
||||||
Expanded(
|
// to the right edge. Padding on the title equals the actions' width
|
||||||
child: Text(
|
// so it stays optically centered without colliding with them.
|
||||||
media.title,
|
return SizedBox(
|
||||||
style: TextStyle(color: fs.parchment, fontSize: 22),
|
height: 32,
|
||||||
maxLines: 1,
|
child: Stack(
|
||||||
overflow: TextOverflow.ellipsis,
|
alignment: Alignment.center,
|
||||||
),
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 64),
|
||||||
|
child: Text(
|
||||||
|
media.title,
|
||||||
|
style: TextStyle(color: fs.parchment, fontSize: 22),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
right: 0,
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
LikeButton(kind: LikeKind.track, id: media.id, size: 22),
|
||||||
|
TrackActionsButton(
|
||||||
|
track: TrackRef(
|
||||||
|
id: media.id,
|
||||||
|
title: media.title,
|
||||||
|
albumId: (media.extras?['album_id'] as String?) ?? '',
|
||||||
|
albumTitle: media.album ?? '',
|
||||||
|
artistId: (media.extras?['artist_id'] as String?) ?? '',
|
||||||
|
artistName: media.artist ?? '',
|
||||||
|
durationSec: media.duration?.inSeconds ?? 0,
|
||||||
|
streamUrl: '',
|
||||||
|
),
|
||||||
|
hideQueueActions: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
LikeButton(kind: LikeKind.track, id: media.id, size: 22),
|
);
|
||||||
TrackActionsButton(
|
|
||||||
track: TrackRef(
|
|
||||||
id: media.id,
|
|
||||||
title: media.title,
|
|
||||||
albumId: (media.extras?['album_id'] as String?) ?? '',
|
|
||||||
albumTitle: media.album ?? '',
|
|
||||||
artistId: '',
|
|
||||||
artistName: media.artist ?? '',
|
|
||||||
durationSec: media.duration?.inSeconds ?? 0,
|
|
||||||
streamUrl: '',
|
|
||||||
),
|
|
||||||
hideQueueActions: true,
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -286,7 +286,9 @@ TrackRef _trackRefFromMediaItem(MediaItem media) => TrackRef(
|
|||||||
title: media.title,
|
title: media.title,
|
||||||
albumId: (media.extras?['album_id'] as String?) ?? '',
|
albumId: (media.extras?['album_id'] as String?) ?? '',
|
||||||
albumTitle: media.album ?? '',
|
albumTitle: media.album ?? '',
|
||||||
artistId: '',
|
// artist_id is stashed in extras by audio_handler — without it
|
||||||
|
// the kebab's "Go to artist" pushes /artists/ (empty id) and 404s.
|
||||||
|
artistId: (media.extras?['artist_id'] as String?) ?? '',
|
||||||
artistName: media.artist ?? '',
|
artistName: media.artist ?? '',
|
||||||
durationSec: media.duration?.inSeconds ?? 0,
|
durationSec: media.duration?.inSeconds ?? 0,
|
||||||
streamUrl: '',
|
streamUrl: '',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:audio_service/audio_service.dart';
|
import 'package:audio_service/audio_service.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../api/endpoints/radio.dart';
|
||||||
import '../auth/auth_provider.dart';
|
import '../auth/auth_provider.dart';
|
||||||
import '../cache/audio_cache_manager.dart';
|
import '../cache/audio_cache_manager.dart';
|
||||||
import '../library/library_providers.dart' show dioProvider;
|
import '../library/library_providers.dart' show dioProvider;
|
||||||
@@ -102,6 +103,15 @@ class PlayerActions {
|
|||||||
Future<void> setVolume(double v) async {
|
Future<void> setVolume(double v) async {
|
||||||
await _ref.read(audioHandlerProvider).setVolume(v);
|
await _ref.read(audioHandlerProvider).setVolume(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetches `/api/radio?seed_track=<id>` and starts playing the
|
||||||
|
/// returned track list (seed at index 0 + recommended picks).
|
||||||
|
Future<void> startRadio(String trackId) async {
|
||||||
|
final dio = await _ref.read(dioProvider.future);
|
||||||
|
final tracks = await RadioApi(dio).seedTrack(trackId);
|
||||||
|
if (tracks.isEmpty) return;
|
||||||
|
await playTracks(tracks);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final playerActionsProvider = Provider<PlayerActions>((ref) => PlayerActions(ref));
|
final playerActionsProvider = Provider<PlayerActions>((ref) => PlayerActions(ref));
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:drift/drift.dart' as drift;
|
import 'package:drift/drift.dart' as drift;
|
||||||
import 'package:flutter/foundation.dart' show debugPrint;
|
import 'package:flutter/foundation.dart' show debugPrint;
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@@ -123,41 +125,144 @@ final playlistDetailProvider =
|
|||||||
tracks: const [],
|
tracks: const [],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Future<bool> isOnline() async {
|
||||||
|
try {
|
||||||
|
return await ref
|
||||||
|
.read(connectivityProvider.future)
|
||||||
|
.timeout(const Duration(seconds: 3), onTimeout: () => true);
|
||||||
|
} catch (_) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> fetchAndPopulate() async {
|
||||||
|
try {
|
||||||
|
final api = await ref.read(playlistsApiProvider.future);
|
||||||
|
debugPrint('playlistDetailProvider($id): calling get');
|
||||||
|
final fresh = await api.get(id).timeout(const Duration(seconds: 10));
|
||||||
|
debugPrint(
|
||||||
|
'playlistDetailProvider($id): got ${fresh.tracks.length} tracks, writing');
|
||||||
|
|
||||||
|
// Collect the artist + album rows referenced by these tracks so
|
||||||
|
// the JOINs that build artistName/albumTitle in the row view
|
||||||
|
// have something to bind to. Without this, system-playlist tracks
|
||||||
|
// surface with empty artist/album columns.
|
||||||
|
final artists = <String, ArtistRefRow>{};
|
||||||
|
final albums = <String, AlbumRefRow>{};
|
||||||
|
for (final t in fresh.tracks) {
|
||||||
|
final aId = t.artistId;
|
||||||
|
final aName = t.artistName;
|
||||||
|
if (aId != null && aId.isNotEmpty && aName.isNotEmpty) {
|
||||||
|
artists.putIfAbsent(aId, () => ArtistRefRow(aId, aName));
|
||||||
|
}
|
||||||
|
final lbId = t.albumId;
|
||||||
|
final lbTitle = t.albumTitle;
|
||||||
|
if (lbId != null && lbId.isNotEmpty && lbTitle.isNotEmpty) {
|
||||||
|
albums.putIfAbsent(lbId, () => AlbumRefRow(lbId, lbTitle, aId ?? ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.batch((b) {
|
||||||
|
b.insert(db.cachedPlaylists, fresh.playlist.toDrift(),
|
||||||
|
mode: drift.InsertMode.insertOrReplace);
|
||||||
|
|
||||||
|
// Wipe + re-insert this playlist's track positions so deletions
|
||||||
|
// propagate. Without the wipe, removed tracks would linger in
|
||||||
|
// drift after the playlist mutated server-side.
|
||||||
|
b.deleteWhere(
|
||||||
|
db.cachedPlaylistTracks, (t) => t.playlistId.equals(id));
|
||||||
|
|
||||||
|
for (var i = 0; i < fresh.tracks.length; i++) {
|
||||||
|
final t = fresh.tracks[i];
|
||||||
|
if (t.trackId == null) continue;
|
||||||
|
b.insert(
|
||||||
|
db.cachedPlaylistTracks,
|
||||||
|
CachedPlaylistTracksCompanion.insert(
|
||||||
|
playlistId: id,
|
||||||
|
trackId: t.trackId!,
|
||||||
|
position: drift.Value(i),
|
||||||
|
),
|
||||||
|
mode: drift.InsertMode.insertOrReplace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (artists.isNotEmpty) {
|
||||||
|
b.insertAllOnConflictUpdate(
|
||||||
|
db.cachedArtists,
|
||||||
|
artists.values
|
||||||
|
.map((a) => CachedArtistsCompanion.insert(
|
||||||
|
id: a.id,
|
||||||
|
name: a.name,
|
||||||
|
sortName: a.name,
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (albums.isNotEmpty) {
|
||||||
|
b.insertAllOnConflictUpdate(
|
||||||
|
db.cachedAlbums,
|
||||||
|
albums.values
|
||||||
|
.map((a) => CachedAlbumsCompanion.insert(
|
||||||
|
id: a.id,
|
||||||
|
artistId: a.artistId,
|
||||||
|
title: a.title,
|
||||||
|
sortTitle: a.title,
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
debugPrint(
|
||||||
|
'playlistDetailProvider($id): drift write done; awaiting watch re-emit');
|
||||||
|
return true;
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('playlistDetailProvider($id): fetch failed: $e\n$st');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once-per-subscription guard so an empty server response doesn't
|
||||||
|
// cause repeated re-fetches.
|
||||||
|
var fetchAttempted = false;
|
||||||
|
var revalidated = false;
|
||||||
|
|
||||||
await for (final playlistRows in playlistQuery.watch()) {
|
await for (final playlistRows in playlistQuery.watch()) {
|
||||||
if (playlistRows.isEmpty) {
|
if (playlistRows.isEmpty) {
|
||||||
if (await ref.read(connectivityProvider.future)) {
|
debugPrint('playlistDetailProvider($id): drift miss, cold-fetching');
|
||||||
try {
|
if (fetchAttempted) {
|
||||||
final api = await ref.read(playlistsApiProvider.future);
|
|
||||||
final fresh = await api.get(id);
|
|
||||||
await db.batch((b) {
|
|
||||||
b.insert(db.cachedPlaylists, fresh.playlist.toDrift(),
|
|
||||||
mode: drift.InsertMode.insertOrReplace);
|
|
||||||
for (var i = 0; i < fresh.tracks.length; i++) {
|
|
||||||
final t = fresh.tracks[i];
|
|
||||||
if (t.trackId == null) continue;
|
|
||||||
b.insert(
|
|
||||||
db.cachedPlaylistTracks,
|
|
||||||
CachedPlaylistTracksCompanion.insert(
|
|
||||||
playlistId: id,
|
|
||||||
trackId: t.trackId!,
|
|
||||||
position: drift.Value(i),
|
|
||||||
),
|
|
||||||
mode: drift.InsertMode.insertOrReplace,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// watch() re-emits next iteration with populated row.
|
|
||||||
} catch (_) {
|
|
||||||
yield emptyDetail();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
yield emptyDetail();
|
yield emptyDetail();
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
fetchAttempted = true;
|
||||||
|
if (!await isOnline()) {
|
||||||
|
yield emptyDetail();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final ok = await fetchAndPopulate();
|
||||||
|
if (!ok) yield emptyDetail();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
debugPrint(
|
||||||
|
'playlistDetailProvider($id): drift hit (${playlistRows.length} rows)');
|
||||||
|
|
||||||
final playlist = playlistRows.first.toRef();
|
final playlist = playlistRows.first.toRef();
|
||||||
final trackRows = await tracksQuery.get();
|
final trackRows = await tracksQuery.get();
|
||||||
|
|
||||||
|
// Same pattern as albumProvider: playlist row exists but no tracks
|
||||||
|
// (e.g., playlistsListProvider wrote the row, sync hasn't carried
|
||||||
|
// tracks for system playlists). Trigger the same fetch, drift
|
||||||
|
// watch re-emits with populated tracks.
|
||||||
|
if (trackRows.isEmpty && !fetchAttempted) {
|
||||||
|
debugPrint(
|
||||||
|
'playlistDetailProvider($id): playlist hit but no tracks; fetching');
|
||||||
|
fetchAttempted = true;
|
||||||
|
if (await isOnline()) {
|
||||||
|
final ok = await fetchAndPopulate();
|
||||||
|
if (ok) continue; // wait for watch re-emit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final tracks = trackRows.asMap().entries.map((e) {
|
final tracks = trackRows.asMap().entries.map((e) {
|
||||||
final r = e.value;
|
final r = e.value;
|
||||||
final track = r.readTableOrNull(db.cachedTracks);
|
final track = r.readTableOrNull(db.cachedTracks);
|
||||||
@@ -177,9 +282,33 @@ final playlistDetailProvider =
|
|||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
yield PlaylistDetail(playlist: playlist, tracks: tracks);
|
yield PlaylistDetail(playlist: playlist, tracks: tracks);
|
||||||
|
|
||||||
|
// SWR: first complete cache hit kicks one background refresh.
|
||||||
|
if (!revalidated && trackRows.isNotEmpty) {
|
||||||
|
revalidated = true;
|
||||||
|
if (await isOnline()) {
|
||||||
|
unawaited(fetchAndPopulate());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Lightweight tuples used inside fetchAndPopulate to dedupe artist
|
||||||
|
/// and album rows referenced by playlist tracks before we batch-write
|
||||||
|
/// them to drift.
|
||||||
|
class ArtistRefRow {
|
||||||
|
ArtistRefRow(this.id, this.name);
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AlbumRefRow {
|
||||||
|
AlbumRefRow(this.id, this.title, this.artistId);
|
||||||
|
final String id;
|
||||||
|
final String title;
|
||||||
|
final String artistId;
|
||||||
|
}
|
||||||
|
|
||||||
final systemPlaylistsStatusProvider =
|
final systemPlaylistsStatusProvider =
|
||||||
FutureProvider<SystemPlaylistsStatus>((ref) async {
|
FutureProvider<SystemPlaylistsStatus>((ref) async {
|
||||||
final dio = await ref.watch(dioProvider.future);
|
final dio = await ref.watch(dioProvider.future);
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import '../auth/login_screen.dart';
|
|||||||
import '../auth/server_url_screen.dart';
|
import '../auth/server_url_screen.dart';
|
||||||
import '../library/album_detail_screen.dart';
|
import '../library/album_detail_screen.dart';
|
||||||
import '../library/artist_detail_screen.dart';
|
import '../library/artist_detail_screen.dart';
|
||||||
|
import '../models/album.dart';
|
||||||
|
import '../models/artist.dart';
|
||||||
import '../discover/discover_screen.dart';
|
import '../discover/discover_screen.dart';
|
||||||
import '../library/home_screen.dart';
|
import '../library/home_screen.dart';
|
||||||
import '../library/library_screen.dart';
|
import '../library/library_screen.dart';
|
||||||
@@ -89,11 +91,19 @@ GoRouter buildRouter(Ref ref) {
|
|||||||
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
|
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/artists/:id',
|
path: '/artists/:id',
|
||||||
builder: (_, s) => ArtistDetailScreen(id: s.pathParameters['id']!),
|
// `extra` carries an optional ArtistRef so the detail
|
||||||
|
// header renders immediately while tracks/albums load.
|
||||||
|
builder: (_, s) => ArtistDetailScreen(
|
||||||
|
id: s.pathParameters['id']!,
|
||||||
|
seed: s.extra is ArtistRef ? s.extra as ArtistRef : null,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/albums/:id',
|
path: '/albums/:id',
|
||||||
builder: (_, s) => AlbumDetailScreen(id: s.pathParameters['id']!),
|
builder: (_, s) => AlbumDetailScreen(
|
||||||
|
id: s.pathParameters['id']!,
|
||||||
|
seed: s.extra is AlbumRef ? s.extra as AlbumRef : null,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(path: '/queue', builder: (_, __) => const QueueScreen()),
|
GoRoute(path: '/queue', builder: (_, __) => const QueueScreen()),
|
||||||
GoRoute(path: '/search', builder: (_, __) => const SearchScreen()),
|
GoRoute(path: '/search', builder: (_, __) => const SearchScreen()),
|
||||||
|
|||||||
@@ -117,6 +117,23 @@ class TrackActionsSheet extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
_MenuItem(
|
||||||
|
key: const Key('track_actions_start_radio'),
|
||||||
|
icon: Icons.radio,
|
||||||
|
label: 'Start radio',
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
try {
|
||||||
|
await ref.read(playerActionsProvider).startRadio(track.id);
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text("Couldn't start radio: $e")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
const _Divider(),
|
const _Divider(),
|
||||||
_MenuItem(
|
_MenuItem(
|
||||||
key: const Key('track_actions_go_to_album'),
|
key: const Key('track_actions_go_to_album'),
|
||||||
|
|||||||
Reference in New Issue
Block a user