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:
@@ -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.
|
||||
@@ -8,7 +8,8 @@
|
||||
},
|
||||
"theme": {
|
||||
"tileSet": "spring",
|
||||
"background": "background.webp"
|
||||
"background": "background.webp",
|
||||
"bgm": "season_001"
|
||||
},
|
||||
"stages": [
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
},
|
||||
"theme": {
|
||||
"tileSet": "spring",
|
||||
"background": "background.webp"
|
||||
"background": "background.webp",
|
||||
"bgm": "season_001"
|
||||
},
|
||||
"stages": [
|
||||
{
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
4280179302
|
||||
],
|
||||
"accentColor": 4285517301,
|
||||
"particleType": "petals"
|
||||
"particleType": "petals",
|
||||
"bgm": "season_002"
|
||||
},
|
||||
"stages": [
|
||||
{
|
||||
|
||||
+24
-1
@@ -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,
|
||||
|
||||
@@ -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},
|
||||
}),
|
||||
|
||||
@@ -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
@@ -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
@@ -32,5 +32,6 @@
|
||||
"restorePurchases": "구매 복원",
|
||||
"adsRemovedThanks": "광고가 제거되었습니다 — 감사합니다!",
|
||||
"purchaseUnavailable": "지금은 구매를 사용할 수 없습니다.",
|
||||
"soundAndVibration": "소리 및 진동"
|
||||
"soundAndVibration": "소리 및 진동",
|
||||
"music": "음악"
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user