diff --git a/.forgejo/workflows/flutter.yml b/.forgejo/workflows/flutter.yml index 0ff1066f..b677f62b 100644 --- a/.forgejo/workflows/flutter.yml +++ b/.forgejo/workflows/flutter.yml @@ -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}" diff --git a/flutter_client/android/app/build.gradle.kts b/flutter_client/android/app/build.gradle.kts index afad756c..54f8d478 100644 --- a/flutter_client/android/app/build.gradle.kts +++ b/flutter_client/android/app/build.gradle.kts @@ -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") + } } } } diff --git a/flutter_client/lib/library/library_screen.dart b/flutter_client/lib/library/library_screen.dart index c198fb7a..79f47e6e 100644 --- a/flutter_client/lib/library/library_screen.dart +++ b/flutter_client/lib/library/library_screen.dart @@ -37,13 +37,89 @@ final _meApiProvider = FutureProvider((ref) async { return MeApi(await ref.watch(dioProvider.future)); }); -final _libraryArtistsProvider = FutureProvider>((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> { + static const _pageSize = 50; + bool _loadingMore = false; -final _libraryAlbumsProvider = FutureProvider>((ref) async { - return (await ref.watch(_libraryListsApiProvider.future)).listAlbums(); -}); + @override + Future> build() async { + final api = await ref.watch(_libraryListsApiProvider.future); + return api.listArtists(limit: _pageSize, offset: 0); + } + + Future 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>(_LibraryArtistsNotifier.new); + +class _LibraryAlbumsNotifier extends AsyncNotifier> { + static const _pageSize = 50; + bool _loadingMore = false; + + @override + Future> build() async { + final api = await ref.watch(_libraryListsApiProvider.future); + return api.listAlbums(limit: _pageSize, offset: 0); + } + + Future 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>(_LibraryAlbumsNotifier.new); final _historyProvider = FutureProvider((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( + 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( + 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), + ); + }, + ), ); }), ),