feat(audio): looping per-season background music system

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 09:31:10 +09:00
parent 2310aabdb9
commit 8947221b27
16 changed files with 227 additions and 8 deletions
+7
View File
@@ -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.
+2 -1
View File
@@ -8,7 +8,8 @@
},
"theme": {
"tileSet": "spring",
"background": "background.webp"
"background": "background.webp",
"bgm": "season_001"
},
"stages": [
{
+2 -2
View File
@@ -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"
}
]
}
+2 -1
View File
@@ -8,7 +8,8 @@
},
"theme": {
"tileSet": "spring",
"background": "background.webp"
"background": "background.webp",
"bgm": "season_001"
},
"stages": [
{
+2 -1
View File
@@ -15,7 +15,8 @@
4280179302
],
"accentColor": 4285517301,
"particleType": "petals"
"particleType": "petals",
"bgm": "season_002"
},
"stages": [
{
+24 -1
View File
@@ -13,21 +13,44 @@ class BlockSeasonsApp extends ConsumerStatefulWidget {
ConsumerState<BlockSeasonsApp> createState() => _BlockSeasonsAppState();
}
class _BlockSeasonsAppState extends ConsumerState<BlockSeasonsApp> {
class _BlockSeasonsAppState extends ConsumerState<BlockSeasonsApp>
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,
+11
View File
@@ -45,6 +45,9 @@ class SaveRepository {
_soundEnabled =
(json['flags'] as Map<String, dynamic>?)?['soundEnabled'] as bool? ??
true;
_musicEnabled =
(json['flags'] as Map<String, dynamic>?)?['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<void> markTutorialDone() {
_tutorialDone = true;
@@ -82,6 +87,11 @@ class SaveRepository {
return _flush();
}
Future<void> setMusicEnabled(bool value) {
_musicEnabled = value;
return _flush();
}
Future<void> 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},
}),
+7
View File
@@ -11,6 +11,7 @@ class SeasonTheme {
this.particleType = 'petals',
this.tilePalette,
this.boardTint,
this.bgm = 'menu',
});
factory SeasonTheme.fromJson(Map<String, dynamic> 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<String, dynamic> 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,
};
}
+2 -1
View File
@@ -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"
}
+2 -1
View File
@@ -32,5 +32,6 @@
"restorePurchases": "구매 복원",
"adsRemovedThanks": "광고가 제거되었습니다 — 감사합니다!",
"purchaseUnavailable": "지금은 구매를 사용할 수 없습니다.",
"soundAndVibration": "소리 및 진동"
"soundAndVibration": "소리 및 진동",
"music": "음악"
}
+78
View File
@@ -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/<key>.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<void> 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<void> _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<void> _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();
}
+19
View File
@@ -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<bool> {
@override
bool build() => ref.read(saveRepositoryProvider).musicEnabled;
Future<void> toggle() => set(!state);
Future<void> set(bool value) async {
if (state == value) return;
await ref.read(saveRepositoryProvider).setMusicEnabled(value);
state = value;
}
}
+12
View File
@@ -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<AudioService>((ref) {
return service;
});
final musicEnabledProvider =
NotifierProvider<MusicEnabledNotifier, bool>(MusicEnabledNotifier.new);
final musicServiceProvider = Provider<MusicService>((ref) {
final service = MusicService(enabled: ref.read(musicEnabledProvider));
ref.listen<bool>(musicEnabledProvider, (_, next) => service.enabled = next);
ref.onDispose(service.dispose);
return service;
});
/// Overridden with the opened repository in main() (and in tests).
final saveRepositoryProvider = Provider<SaveRepository>(
(ref) => throw UnimplementedError('override with an opened SaveRepository'),
+7
View File
@@ -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<bool>(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),
+1
View File
@@ -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:
+49
View File
@@ -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');
});
});
}