From 8947221b2763b7f2594f1959c57630b97876ea45 Mon Sep 17 00:00:00 2001 From: airkjw Date: Sun, 14 Jun 2026 09:31:10 +0900 Subject: [PATCH] feat(audio): looping per-season background music system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MusicService (looping audioplayers player, independent of SFX) driven by the active season theme's new 'bgm' key; switches track on season change, pauses on app background, all failures swallowed. Separate Music on/off toggle in Settings (persisted, independent of SFX). Season packs carry bgm keys (menu/season_001/ season_002), manifest regenerated. Assets slot assets/audio/bgm/ ready — drop in menu.mp3/season_001.mp3/season_002.mp3 (CC0) and it plays; silent until then. 180 tests green, analyze clean. Co-Authored-By: Claude Fable 5 --- assets/audio/bgm/README.txt | 7 +++ assets/seasons/season_001/pack.json | 3 +- content/manifest.json | 4 +- content/season_001/pack.json | 3 +- content/season_002/pack.json | 3 +- lib/app.dart | 25 ++++++++- lib/data/save_repository.dart | 11 ++++ lib/game/models/season.dart | 7 +++ lib/l10n/app_en.arb | 3 +- lib/l10n/app_ko.arb | 3 +- lib/services/music_service.dart | 78 +++++++++++++++++++++++++++++ lib/state/music_notifier.dart | 19 +++++++ lib/state/providers.dart | 12 +++++ lib/ui/screens/settings_screen.dart | 7 +++ pubspec.yaml | 1 + test/state/music_test.dart | 49 ++++++++++++++++++ 16 files changed, 227 insertions(+), 8 deletions(-) create mode 100644 assets/audio/bgm/README.txt create mode 100644 lib/services/music_service.dart create mode 100644 lib/state/music_notifier.dart create mode 100644 test/state/music_test.dart diff --git a/assets/audio/bgm/README.txt b/assets/audio/bgm/README.txt new file mode 100644 index 0000000..8cfe281 --- /dev/null +++ b/assets/audio/bgm/README.txt @@ -0,0 +1,7 @@ +Drop background-music tracks here as MP3, named by theme key: + menu.mp3 — home/menu (SeasonTheme.fallback, bgm="menu") + season_001.mp3 — Season 1 "First Bloom" + season_002.mp3 — Season 2 "Summer Tide" + +Use CC0 / royalty-free, commercial-safe tracks (see docs). The app plays +whatever is present and stays silent (no error) for any missing track. diff --git a/assets/seasons/season_001/pack.json b/assets/seasons/season_001/pack.json index 9d24586..96c63ae 100644 --- a/assets/seasons/season_001/pack.json +++ b/assets/seasons/season_001/pack.json @@ -8,7 +8,8 @@ }, "theme": { "tileSet": "spring", - "background": "background.webp" + "background": "background.webp", + "bgm": "season_001" }, "stages": [ { diff --git a/content/manifest.json b/content/manifest.json index 6779deb..430af49 100644 --- a/content/manifest.json +++ b/content/manifest.json @@ -7,13 +7,13 @@ "seasonId": "season_001", "version": 1, "packUrl": "seasons/season_001/pack.json", - "sha256": "5b20b88251931838563aaaa7729f48e5a35f09dbf80c576b9bc2ec944050fc0a" + "sha256": "6018fc20187e5835e1a5bc5a3626479dcd448e3a9f35c708b89722587a881468" }, { "seasonId": "season_002", "version": 1, "packUrl": "seasons/season_002/pack.json", - "sha256": "47cc115f9982ade7df686b28aa95a82edcc1e8a4aae5f13319e7131477855de3" + "sha256": "7be1d0082d9fa81b25938c340801baf9cc0deecdbbd0cdc2d75af443e9fb8552" } ] } diff --git a/content/season_001/pack.json b/content/season_001/pack.json index 9d24586..96c63ae 100644 --- a/content/season_001/pack.json +++ b/content/season_001/pack.json @@ -8,7 +8,8 @@ }, "theme": { "tileSet": "spring", - "background": "background.webp" + "background": "background.webp", + "bgm": "season_001" }, "stages": [ { diff --git a/content/season_002/pack.json b/content/season_002/pack.json index a0f1cf0..1c573f6 100644 --- a/content/season_002/pack.json +++ b/content/season_002/pack.json @@ -15,7 +15,8 @@ 4280179302 ], "accentColor": 4285517301, - "particleType": "petals" + "particleType": "petals", + "bgm": "season_002" }, "stages": [ { diff --git a/lib/app.dart b/lib/app.dart index c7dbcf7..9bcb083 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -13,21 +13,44 @@ class BlockSeasonsApp extends ConsumerStatefulWidget { ConsumerState createState() => _BlockSeasonsAppState(); } -class _BlockSeasonsAppState extends ConsumerState { +class _BlockSeasonsAppState extends ConsumerState + with WidgetsBindingObserver { @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addPostFrameCallback((_) { ref.read(consentServiceProvider).ensureConsentAndInitialize(); // Eagerly start the IAP service so its purchase stream is live for the // whole session — restores and interrupted/deferred transactions are // delivered (and completed) even if the player never opens Settings. ref.read(iapServiceProvider); + // Start background music for the current context (menu by default). + ref.read(musicServiceProvider).playKey(ref.read(activeThemeProvider).bgm); }); } + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + // Pause music while the app is not in the foreground. + ref + .read(musicServiceProvider) + .setBackgrounded(state != AppLifecycleState.resumed); + } + @override Widget build(BuildContext context) { + // Switch the looping track whenever the active season theme changes + // (home/menu -> a season -> back). playKey is a no-op if unchanged. + ref.listen(activeThemeProvider, (_, next) { + ref.read(musicServiceProvider).playKey(next.bgm); + }); return MaterialApp( onGenerateTitle: (context) => AppLocalizations.of(context)!.appTitle, debugShowCheckedModeBanner: false, diff --git a/lib/data/save_repository.dart b/lib/data/save_repository.dart index 2844337..a9a5bfb 100644 --- a/lib/data/save_repository.dart +++ b/lib/data/save_repository.dart @@ -45,6 +45,9 @@ class SaveRepository { _soundEnabled = (json['flags'] as Map?)?['soundEnabled'] as bool? ?? true; + _musicEnabled = + (json['flags'] as Map?)?['musicEnabled'] as bool? ?? + true; } } @@ -60,12 +63,14 @@ class SaveRepository { int _endlessBest = 0; bool _adsRemoved = false; bool _soundEnabled = true; + bool _musicEnabled = true; StreakState get streak => _streak; bool get tutorialDone => _tutorialDone; int get endlessBest => _endlessBest; bool get adsRemoved => _adsRemoved; bool get soundEnabled => _soundEnabled; + bool get musicEnabled => _musicEnabled; Future markTutorialDone() { _tutorialDone = true; @@ -82,6 +87,11 @@ class SaveRepository { return _flush(); } + Future setMusicEnabled(bool value) { + _musicEnabled = value; + return _flush(); + } + Future recordEndlessScore(int score) { if (score > _endlessBest) _endlessBest = score; return _flush(); @@ -154,6 +164,7 @@ class SaveRepository { 'tutorialDone': _tutorialDone, 'adsRemoved': _adsRemoved, 'soundEnabled': _soundEnabled, + 'musicEnabled': _musicEnabled, }, 'endless': {'best': _endlessBest}, }), diff --git a/lib/game/models/season.dart b/lib/game/models/season.dart index 2ce1657..8f77684 100644 --- a/lib/game/models/season.dart +++ b/lib/game/models/season.dart @@ -11,6 +11,7 @@ class SeasonTheme { this.particleType = 'petals', this.tilePalette, this.boardTint, + this.bgm = 'menu', }); factory SeasonTheme.fromJson(Map json) => SeasonTheme( @@ -25,6 +26,7 @@ class SeasonTheme { ? [for (final c in json['tilePalette'] as List) (c as num).toInt()] : null, boardTint: json['boardTint'] as int?, + bgm: (json['bgm'] as String?) ?? 'menu', ); /// Season 1 "First Bloom": deep navy dusk. @@ -48,6 +50,10 @@ class SeasonTheme { /// Optional board background override. final int? boardTint; + /// Looping background-music track key, resolved to `assets/audio/bgm/KEY.mp3`. + /// Remote seasons reuse bundled track keys (no remote audio download). + final String bgm; + Map toJson() => { 'tileSet': tileSet, 'background': background, @@ -56,6 +62,7 @@ class SeasonTheme { 'particleType': particleType, if (tilePalette != null) 'tilePalette': tilePalette, if (boardTint != null) 'boardTint': boardTint, + 'bgm': bgm, }; } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index eb00f48..7025ed6 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -60,5 +60,6 @@ "restorePurchases": "Restore purchases", "adsRemovedThanks": "Ads removed — thank you!", "purchaseUnavailable": "Purchases are unavailable right now.", - "soundAndVibration": "Sound & vibration" + "soundAndVibration": "Sound & vibration", + "music": "Music" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 47be69c..ef6ef61 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -32,5 +32,6 @@ "restorePurchases": "구매 복원", "adsRemovedThanks": "광고가 제거되었습니다 — 감사합니다!", "purchaseUnavailable": "지금은 구매를 사용할 수 없습니다.", - "soundAndVibration": "소리 및 진동" + "soundAndVibration": "소리 및 진동", + "music": "음악" } diff --git a/lib/services/music_service.dart b/lib/services/music_service.dart new file mode 100644 index 0000000..069fbe7 --- /dev/null +++ b/lib/services/music_service.dart @@ -0,0 +1,78 @@ +import 'package:audioplayers/audioplayers.dart'; + +/// Looping background music, independent of the SFX pool in [AudioService]. +/// One track plays at a time from `assets/audio/bgm/.mp3`. A track change +/// (e.g. switching seasons) restarts; toggling sound off or backgrounding the +/// app pauses/resumes the same track. Every player call is swallowed — music +/// must never break the app, and a missing track asset is silently ignored. +class MusicService { + MusicService({bool enabled = true}) : _enabled = enabled; + + /// Background music sits well under the SFX so it never competes. + static const double _volume = 0.45; + + final AudioPlayer _player = AudioPlayer(); + bool _enabled; + bool _backgrounded = false; + String? _currentKey; + + bool get enabled => _enabled; + bool get _shouldPlay => _enabled && !_backgrounded && _currentKey != null; + + /// Request a looping track by theme key (e.g. 'menu', 'season_001'). No-op + /// if it is already the current track (avoids restarting on every rebuild). + Future playKey(String key) async { + if (key == _currentKey) { + await _reconcile(); + return; + } + _currentKey = key; + await _restart(); + } + + set enabled(bool value) { + if (_enabled == value) return; + _enabled = value; + _reconcile(); + } + + /// App lifecycle: pause when backgrounded, resume on return. + void setBackgrounded(bool value) { + if (_backgrounded == value) return; + _backgrounded = value; + _reconcile(); + } + + Future _restart() async { + try { + await _player.stop(); + if (!_shouldPlay) return; + await _player.setReleaseMode(ReleaseMode.loop); + await _player.play( + AssetSource('audio/bgm/$_currentKey.mp3'), + volume: _volume, + ); + } catch (_) { + // Missing asset or platform hiccup: stay silent. + } + } + + /// Pause/resume the CURRENT track to match [_shouldPlay] without restarting. + Future _reconcile() async { + try { + if (_shouldPlay) { + if (_player.state == PlayerState.paused) { + await _player.resume(); + } else if (_player.state != PlayerState.playing) { + await _restart(); + } + } else if (_player.state == PlayerState.playing) { + await _player.pause(); + } + } catch (_) { + // Never throw from lifecycle/toggle changes. + } + } + + void dispose() => _player.dispose(); +} diff --git a/lib/state/music_notifier.dart b/lib/state/music_notifier.dart new file mode 100644 index 0000000..258f9b6 --- /dev/null +++ b/lib/state/music_notifier.dart @@ -0,0 +1,19 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'providers.dart'; + +/// Background-music on/off, seeded from the save repository. Independent of the +/// SFX/haptics ([soundEnabledProvider]) so players can keep one without the +/// other — the common "I want sound effects but no music" case. +class MusicEnabledNotifier extends Notifier { + @override + bool build() => ref.read(saveRepositoryProvider).musicEnabled; + + Future toggle() => set(!state); + + Future set(bool value) async { + if (state == value) return; + await ref.read(saveRepositoryProvider).setMusicEnabled(value); + state = value; + } +} diff --git a/lib/state/providers.dart b/lib/state/providers.dart index 4ec04d4..49ba835 100644 --- a/lib/state/providers.dart +++ b/lib/state/providers.dart @@ -9,8 +9,10 @@ import '../services/analytics_service.dart'; import '../services/audio_service.dart'; import '../services/consent_service.dart'; import '../services/iap_service.dart'; +import '../services/music_service.dart'; import 'ads_notifier.dart'; import 'endless_best_notifier.dart'; +import 'music_notifier.dart'; import 'sound_notifier.dart'; import 'game_session_notifier.dart'; import 'progress_notifier.dart'; @@ -33,6 +35,16 @@ final audioServiceProvider = Provider((ref) { return service; }); +final musicEnabledProvider = + NotifierProvider(MusicEnabledNotifier.new); + +final musicServiceProvider = Provider((ref) { + final service = MusicService(enabled: ref.read(musicEnabledProvider)); + ref.listen(musicEnabledProvider, (_, next) => service.enabled = next); + ref.onDispose(service.dispose); + return service; +}); + /// Overridden with the opened repository in main() (and in tests). final saveRepositoryProvider = Provider( (ref) => throw UnimplementedError('override with an opened SaveRepository'), diff --git a/lib/ui/screens/settings_screen.dart b/lib/ui/screens/settings_screen.dart index cff3545..5323ce0 100644 --- a/lib/ui/screens/settings_screen.dart +++ b/lib/ui/screens/settings_screen.dart @@ -15,6 +15,7 @@ class SettingsScreen extends ConsumerWidget { final l10n = AppLocalizations.of(context)!; final adsRemoved = ref.watch(adsRemovedProvider); final soundOn = ref.watch(soundEnabledProvider); + final musicOn = ref.watch(musicEnabledProvider); final iap = ref.read(iapServiceProvider); ref.listen(adsRemovedProvider, (prev, next) { @@ -44,6 +45,12 @@ class SettingsScreen extends ConsumerWidget { onChanged: (v) => ref.read(soundEnabledProvider.notifier).set(v), ), + SwitchListTile( + title: Text(l10n.music), + value: musicOn, + onChanged: (v) => + ref.read(musicEnabledProvider.notifier).set(v), + ), const Divider(), ListTile( title: Text(l10n.removeAds), diff --git a/pubspec.yaml b/pubspec.yaml index 04e906d..b152dfb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -74,6 +74,7 @@ flutter: assets: - assets/audio/ + - assets/audio/bgm/ - assets/seasons/season_001/ # To add assets to your application, add an assets section, like this: diff --git a/test/state/music_test.dart b/test/state/music_test.dart new file mode 100644 index 0000000..eeb20ef --- /dev/null +++ b/test/state/music_test.dart @@ -0,0 +1,49 @@ +import 'package:block_seasons/data/save_repository.dart'; +import 'package:block_seasons/game/models/season.dart'; +import 'package:block_seasons/state/providers.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + group('musicEnabled persistence', () { + setUp(() => SharedPreferences.setMockInitialValues({})); + + test('defaults true and persists across reopen', () async { + final repo = await SaveRepository.open(); + expect(repo.musicEnabled, isTrue); + await repo.setMusicEnabled(false); + final reopened = await SaveRepository.open(); + expect(reopened.musicEnabled, isFalse); + }); + + test('legacy save without the flag reads as true', () async { + SharedPreferences.setMockInitialValues({ + 'save_v1': '{"saveVersion":1,"progress":{},"flags":{"tutorialDone":true}}', + }); + final repo = await SaveRepository.open(); + expect(repo.musicEnabled, isTrue); + }); + + test('musicEnabledProvider toggles and persists', () async { + final repo = await SaveRepository.open(); + final c = ProviderContainer( + overrides: [saveRepositoryProvider.overrideWithValue(repo)], + ); + addTearDown(c.dispose); + expect(c.read(musicEnabledProvider), isTrue); + await c.read(musicEnabledProvider.notifier).toggle(); + expect(c.read(musicEnabledProvider), isFalse); + expect(repo.musicEnabled, isFalse); + }); + }); + + group('SeasonTheme.bgm', () { + test('defaults to menu and round-trips through json', () { + expect(const SeasonTheme().bgm, 'menu'); + final theme = SeasonTheme.fromJson(const {'bgm': 'season_001'}); + expect(theme.bgm, 'season_001'); + expect(SeasonTheme.fromJson(theme.toJson()).bgm, 'season_001'); + }); + }); +}