Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e610948307 | |||
| 8c00ee21c7 | |||
| 2500914498 |
@@ -72,6 +72,25 @@ jobs:
|
||||
if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')
|
||||
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
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
# 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
|
||||
# think the user is behind because pubspec.yaml's static
|
||||
# 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
|
||||
env:
|
||||
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: |
|
||||
TAG="${GITHUB_REF#refs/tags/v}"
|
||||
flutter build apk --release --build-name="${TAG}"
|
||||
|
||||
@@ -30,11 +30,38 @@ android {
|
||||
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 {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
signingConfig = if (keystorePath != null && keystorePath.isNotEmpty()) {
|
||||
signingConfigs.getByName("release")
|
||||
} else {
|
||||
// Local-only fallback so `flutter run --release` works
|
||||
// without the keystore. CI must export ANDROID_KEYSTORE_PATH.
|
||||
signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,13 +37,89 @@ final _meApiProvider = FutureProvider<MeApi>((ref) async {
|
||||
return MeApi(await ref.watch(dioProvider.future));
|
||||
});
|
||||
|
||||
final _libraryArtistsProvider = FutureProvider<wire.Paged<ArtistRef>>((ref) async {
|
||||
return (await ref.watch(_libraryListsApiProvider.future)).listArtists();
|
||||
});
|
||||
/// Paginated artist list. AsyncNotifier so the screen can call
|
||||
/// `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 {
|
||||
return (await ref.watch(_libraryListsApiProvider.future)).listAlbums();
|
||||
});
|
||||
@override
|
||||
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 {
|
||||
return (await ref.watch(_meApiProvider.future)).history();
|
||||
@@ -134,24 +210,42 @@ class _ArtistsTab extends ConsumerWidget {
|
||||
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))
|
||||
: RefreshIndicator(
|
||||
onRefresh: () async => ref.refresh(_libraryArtistsProvider.future),
|
||||
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),
|
||||
);
|
||||
onRefresh: () async {
|
||||
ref.invalidate(_libraryArtistsProvider);
|
||||
await ref.read(_libraryArtistsProvider.future);
|
||||
},
|
||||
// Listen for scroll-near-bottom and ask the notifier
|
||||
// to fetch the next page. 800px lookahead so the next
|
||||
// page lands before the user reaches the visible end.
|
||||
child: NotificationListener<ScrollNotification>(
|
||||
onNotification: (n) {
|
||||
if (n.metrics.pixels >=
|
||||
n.metrics.maxScrollExtent - 800) {
|
||||
ref
|
||||
.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),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -170,7 +264,10 @@ class _AlbumsTab extends ConsumerWidget {
|
||||
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))
|
||||
: RefreshIndicator(
|
||||
onRefresh: () async => ref.refresh(_libraryAlbumsProvider.future),
|
||||
onRefresh: () async {
|
||||
ref.invalidate(_libraryAlbumsProvider);
|
||||
await ref.read(_libraryAlbumsProvider.future);
|
||||
},
|
||||
// Same responsive 3-up grid as the artist detail
|
||||
// album list — LayoutBuilder + AlbumCard sized to the
|
||||
// cell, mainAxisExtent matched to actual card height.
|
||||
@@ -185,25 +282,37 @@ class _AlbumsTab extends ConsumerWidget {
|
||||
// cover (cellW - 16) + gap (8) + 2-line title (~36)
|
||||
// + artist line (~16) + small fudge.
|
||||
final cellH = (cellW - 16) + 8 + 36 + 16 + 4;
|
||||
return 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),
|
||||
);
|
||||
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),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user