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/')
|
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,24 +210,42 @@ 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) {
|
n.metrics.maxScrollExtent - 800) {
|
||||||
final artist = page.items[i];
|
ref
|
||||||
return ArtistCard(
|
.read(_libraryArtistsProvider.notifier)
|
||||||
artist: artist,
|
.loadMore();
|
||||||
onTap: () => ctx.push('/artists/${artist.id}',
|
}
|
||||||
extra: artist),
|
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
|
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 {
|
||||||
|
ref.invalidate(_libraryAlbumsProvider);
|
||||||
|
await ref.read(_libraryAlbumsProvider.future);
|
||||||
|
},
|
||||||
// Same responsive 3-up grid as the artist detail
|
// Same responsive 3-up grid as the artist detail
|
||||||
// album list — LayoutBuilder + AlbumCard sized to the
|
// album list — LayoutBuilder + AlbumCard sized to the
|
||||||
// cell, mainAxisExtent matched to actual card height.
|
// cell, mainAxisExtent matched to actual card height.
|
||||||
@@ -185,25 +282,37 @@ class _AlbumsTab extends ConsumerWidget {
|
|||||||
// cover (cellW - 16) + gap (8) + 2-line title (~36)
|
// cover (cellW - 16) + gap (8) + 2-line title (~36)
|
||||||
// + artist line (~16) + small fudge.
|
// + artist line (~16) + small fudge.
|
||||||
final cellH = (cellW - 16) + 8 + 36 + 16 + 4;
|
final cellH = (cellW - 16) + 8 + 36 + 16 + 4;
|
||||||
return GridView.builder(
|
return NotificationListener<ScrollNotification>(
|
||||||
padding: const EdgeInsets.all(sidePad),
|
onNotification: (n) {
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
if (n.metrics.pixels >=
|
||||||
crossAxisCount: cols,
|
n.metrics.maxScrollExtent - 800) {
|
||||||
mainAxisExtent: cellH,
|
ref
|
||||||
mainAxisSpacing: gap,
|
.read(_libraryAlbumsProvider.notifier)
|
||||||
crossAxisSpacing: gap,
|
.loadMore();
|
||||||
),
|
}
|
||||||
itemCount: page.items.length,
|
return false;
|
||||||
itemBuilder: (ctx, i) {
|
|
||||||
final album = page.items[i];
|
|
||||||
return AlbumCard(
|
|
||||||
album: album,
|
|
||||||
width: cellW,
|
|
||||||
titleMaxLines: 2,
|
|
||||||
onTap: () => ctx.push('/albums/${album.id}',
|
|
||||||
extra: album),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
|
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