feat(meditate): the instance's library in the app: Explore, its player, courses, keeping offline

Explore shows the instance's library below Meditate's practices, where it
has one: its courses and items by category (Meditate's eleven, named in
English and German, and the operator's own) and by the same search; an
instance without one shows nothing more. An item's page: its recordings
to choose, Play, a favourite (kept in the sealed settings under
library/<id>), keeping it on the device and removing it, and for a sound,
a silent sitting with it looped; its credit and licence. The player
plays, pauses and seeks; played to its end it is kept as a sitting, and
as its course's lesson done (lesson_done, sealed and synced). Settings
lists the library's credits beside About; Today shows its favourites.

The last catalogue is kept in the person's folder for offline; files are
fetched with the catalogue's access, asked for again shortly before it
ends. ADR 0038, D16.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-03 16:47:28 +01:00
commit 811182b376
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 3386 additions and 22 deletions

View file

@ -7,6 +7,8 @@ import 'package:halkyon_meditate_flutter/src/app/meditate_dependencies.dart';
import 'package:halkyon_meditate_flutter/src/core/privacy/meditate_keys.dart';
import 'package:halkyon_meditate_flutter/src/feature/journal/data/dream_repository.dart';
import 'package:halkyon_meditate_flutter/src/feature/journal/model/dream.dart';
import 'package:halkyon_meditate_flutter/src/feature/library/data/lesson_repository.dart';
import 'package:halkyon_meditate_flutter/src/feature/library/data/library_repository.dart';
import 'package:halkyon_meditate_flutter/src/feature/settings/data/settings_repository.dart';
import 'package:halkyon_meditate_flutter/src/feature/settings/model/meditate_settings.dart';
import 'package:halkyon_meditate_flutter/src/feature/sitting/data/sitting_repository.dart';
@ -14,8 +16,10 @@ import 'package:halkyon_meditate_flutter/src/feature/sitting/model/sitting.dart'
import 'package:halkyon_meditate_flutter/src/feature/sitting/widget/sitting_screen.dart';
import 'package:halkyon_meditate_flutter/src/tabs/journal_tab.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
import '../support/fake_library.dart';
import '../support/memory_content_key_gateway.dart';
import '../support/memory_sealed_row_gateway.dart';
import '../support/recording_device.dart';
@ -32,6 +36,10 @@ void main() {
late MemorySealedRowGateway sittingRows;
late MemorySealedRowGateway dreamRows;
late FakeSpeechInput speech;
late FakeLibraryGateway library;
late MemoryLibraryDownloads downloads;
late MemorySealedRowGateway lessonRows;
late RecordingGuidePlayer guide;
late RecordingBells bells;
late RecordingScreen screen;
@ -56,6 +64,10 @@ void main() {
sittingRows = MemorySealedRowGateway();
dreamRows = MemorySealedRowGateway();
speech = FakeSpeechInput();
library = FakeLibraryGateway();
downloads = MemoryLibraryDownloads();
lessonRows = MemorySealedRowGateway();
guide = RecordingGuidePlayer();
bells = RecordingBells();
screen = RecordingScreen();
store = MemoryInstanceStore();
@ -150,9 +162,16 @@ void main() {
settings: settings(),
sittings: sittings(),
dreams: dreams(),
library: LibraryRepository(
library,
store: MemoryCatalogueStore(),
downloads: downloads,
),
lessons: LessonRepository(lessonRows, keys),
bells: bells,
screen: screen,
speech: speech,
guide: guide,
);
},
),
@ -1141,4 +1160,215 @@ void main() {
findsOneWidget,
);
});
group("the instance's library (ADR 0038, D16)", () {
/// Opens Explore on an instance with the sample library.
Future<void> exploreLibrary(WidgetTester tester) async {
library.answer = sampleCatalogue();
await start(tester, usedBefore: true);
await goTab(tester, 'Explore');
}
/// Opens the library item [title] from Explore.
Future<void> openItem(WidgetTester tester, String title) async {
await exploreLibrary(tester);
await scrollTo(tester, find.text(title));
await tester.tap(find.text(title));
await tester.pumpAndSettle();
}
testWidgets('an instance without a library shows nothing of one: no '
'section in Explore, no credits in Settings', (tester) async {
await start(tester, usedBefore: true);
await goTab(tester, 'Explore');
await scrollTo(tester, find.text('Kind wishes'));
expect(find.text('From meditate.example.org'), findsNothing);
await openSettings(tester);
expect(find.text('Library credits'), findsNothing);
});
testWidgets("Explore shows the instance's courses and items below the "
'practices, by category and by the search', (tester) async {
await exploreLibrary(tester);
await scrollTo(tester, find.text('From meditate.example.org'));
expect(find.text('A calm week'), findsOneWidget);
expect(find.text('Course · 2 lessons'), findsOneWidget);
await scrollTo(tester, find.text('Box breathing'));
expect(find.text('Soft rain'), findsOneWidget);
// The operator's own category, named as the manifest names it.
expect(find.text('Breathwork'), findsWidgets);
await scrollTo(tester, find.text('Sounds'), up: true);
await tester.tap(find.text('Sounds'));
await tester.pumpAndSettle();
await scrollTo(tester, find.text('Soft rain'));
expect(find.text('Evening rest'), findsNothing);
expect(find.text('A calm week'), findsNothing);
await scrollTo(tester, find.byType(TextField), up: true);
await tester.enterText(find.byType(TextField), 'evening');
await tester.pumpAndSettle();
await scrollTo(tester, find.text('All').last);
await tester.tap(find.text('All').last);
await tester.pumpAndSettle();
await scrollTo(tester, find.text('Evening rest'));
expect(find.text('Soft rain'), findsNothing);
});
testWidgets('an item plays its recording chosen; played to its end it '
'counts as a sitting', (tester) async {
await openItem(tester, 'Evening rest');
expect(find.text('With Sample Speaker'), findsOneWidget);
expect(
find.text('Credit: Sample Speaker, for the example instance'),
findsOneWidget,
);
expect(find.text('Licence: CC BY 4.0'), findsOneWidget);
await tester.tap(find.text('Long'));
await tester.pumpAndSettle();
await tester.tap(find.text('Play · 20 min'));
await tester.pumpAndSettle();
const long =
'https://meditate.example.org/library/'
'bbbbbbbbbbbbbbbbbbbbbbbb.mp3?access=signed-access';
expect(guide.calls, ['play $long']);
expect(find.text('Pause'), findsOneWidget);
await tester.tap(find.text('Pause'));
await tester.pumpAndSettle();
expect(guide.calls.last, 'pause');
await tester.tap(find.text('Resume'));
await tester.pumpAndSettle();
guide.finish();
await tester.pumpAndSettle();
expect(find.text('Well sat'), findsOneWidget);
final kept = await sittings().all();
expect(kept.single.sitting.practiceId, 'library/evening-rest');
expect(kept.single.sitting.satSeconds, 1200);
});
testWidgets('stopped before its end, nothing is kept', (tester) async {
await openItem(tester, 'Evening rest');
await tester.tap(find.text('Play · 5 min'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Stop'));
await tester.pumpAndSettle();
expect(guide.calls.last, 'stop');
expect(await sittings().all(), isEmpty);
});
testWidgets("a course's lessons, each ticked once played to its end, "
'its done ones counted', (tester) async {
await exploreLibrary(tester);
await scrollTo(tester, find.text('A calm week'));
await tester.tap(find.text('A calm week'));
await tester.pumpAndSettle();
expect(find.text('Meditation · 0 of 2 done'), findsOneWidget);
expect(find.text('Evening one'), findsOneWidget);
await tester.tap(find.text('Evening one'));
await tester.pumpAndSettle();
await tester.tap(find.text('Play · 5 min'));
await tester.pumpAndSettle();
guide.finish();
await tester.pumpAndSettle();
await tester.tap(find.text('Done'));
await tester.pumpAndSettle();
await tester.pageBack();
await tester.pumpAndSettle();
expect(find.text('Meditation · 1 of 2 done'), findsOneWidget);
expect(find.byIcon(LucideIcons.check), findsOneWidget);
expect(
await LessonRepository(lessonRows, keys).done(),
{(course: 'calm-week', item: 'evening-rest')},
);
});
testWidgets('a favourite of the library shows on Today', (tester) async {
await openItem(tester, 'Evening rest');
await tester.tap(find.byTooltip('Add to favourites'));
await tester.pumpAndSettle();
expect(
(await settings().read()).favourites,
['library/evening-rest'],
);
await goTab(tester, 'Today');
await scrollTo(tester, find.text('Evening rest'));
expect(find.text('Evening rest'), findsOneWidget);
});
testWidgets('kept on the device on asking, then played from there, and '
'removed again', (tester) async {
await openItem(tester, 'Soft rain');
await tester.tap(find.text('Keep on this device'));
await tester.pumpAndSettle();
expect(downloads.fetched.keys, ['cccccccccccccccccccccccc.ogg']);
expect(find.text('Remove from this device'), findsOneWidget);
await tester.tap(find.text('Play · 30 min'));
await tester.pumpAndSettle();
expect(
guide.calls.single,
'play /device/library/cccccccccccccccccccccccc.ogg',
);
await tester.tap(find.byTooltip('Stop'));
await tester.pumpAndSettle();
await tester.tap(find.text('Remove from this device'));
await tester.pumpAndSettle();
expect(downloads.fetched, isEmpty);
expect(find.text('Keep on this device'), findsOneWidget);
});
testWidgets("one that can't be fetched says so", (tester) async {
await openItem(tester, 'Soft rain');
downloads.fails = true;
await tester.tap(find.text('Keep on this device'));
await tester.pumpAndSettle();
expect(
find.text("It couldn't be fetched. Try again when you're online."),
findsOneWidget,
);
expect(find.text('Keep on this device'), findsOneWidget);
});
testWidgets('a sound plays looped under a silent sitting', (tester) async {
await openItem(tester, 'Soft rain');
expect(find.text('Sit in silence with this sound'), findsOneWidget);
await tester.tap(find.text('Sit in silence with this sound'));
await sit(tester, const Duration(seconds: 2));
expect(find.byType(SittingScreen), findsOneWidget);
const rain =
'https://meditate.example.org/library/'
'cccccccccccccccccccccccc.ogg?access=signed-access';
expect(guide.calls, ['play $rain loop']);
});
testWidgets("Settings lists the library's credits and licences", (
tester,
) async {
library.answer = sampleCatalogue();
await start(tester, usedBefore: true);
await openSettings(tester);
await tapInSettings(tester, 'Library credits');
expect(find.textContaining('Credit: Sample Recordist'), findsOneWidget);
expect(find.textContaining('Licence: CC0 1.0'), findsOneWidget);
});
});
}

View file

@ -0,0 +1,296 @@
import 'package:clock/clock.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_meditate_flutter/src/core/privacy/meditate_keys.dart';
import 'package:halkyon_meditate_flutter/src/core/sealed/json_payload.dart';
import 'package:halkyon_meditate_flutter/src/core/sound/guide_player.dart';
import 'package:halkyon_meditate_flutter/src/feature/library/bloc/listening_cubit.dart';
import 'package:halkyon_meditate_flutter/src/feature/library/data/lesson_repository.dart';
import 'package:halkyon_meditate_flutter/src/feature/library/data/library_repository.dart';
import 'package:halkyon_meditate_flutter/src/feature/library/model/library.dart';
import 'package:halkyon_meditate_flutter/src/feature/settings/model/meditate_settings.dart';
import 'package:halkyon_meditate_flutter/src/feature/sitting/bloc/sitting_cubit.dart';
import 'package:halkyon_meditate_flutter/src/feature/sitting/model/sitting.dart';
import '../../support/fake_library.dart';
import '../../support/memory_content_key_gateway.dart';
import '../../support/memory_sealed_row_gateway.dart';
import '../../support/recording_device.dart';
void main() {
late FakeLibraryGateway gateway;
late MemoryCatalogueStore store;
late MemoryLibraryDownloads downloads;
late LibraryRepository library;
setUp(() {
gateway = FakeLibraryGateway()..answer = sampleCatalogue();
store = MemoryCatalogueStore();
downloads = MemoryLibraryDownloads();
library = LibraryRepository(gateway, store: store, downloads: downloads);
});
group("the instance's library on the device (ADR 0038, D16)", () {
test('reads the instance’s catalogue and keeps a copy; offline, the '
'copy; with neither, none', () async {
expect((await library.read()).items, hasLength(3));
expect(store.kept?.items, hasLength(3));
gateway.fails = true;
final offline = LibraryRepository(
gateway,
store: store,
downloads: downloads,
);
expect((await offline.read()).items, hasLength(3));
final fresh = LibraryRepository(
gateway,
store: MemoryCatalogueStore(),
downloads: downloads,
);
expect((await fresh.read()).items, isEmpty);
});
test('a file is fetched with the access the catalogue brought, asked for '
'again shortly before it ends', () async {
await library.read();
expect(
'${await library.urlOf('aaaaaaaaaaaaaaaaaaaaaaaa.mp3')}',
'https://meditate.example.org/library/'
'aaaaaaaaaaaaaaaaaaaaaaaa.mp3?access=signed-access',
);
expect(gateway.calls, 1);
gateway.answer = sampleCatalogue(
accessUntil: clock.now().add(const Duration(minutes: 10)),
);
await library.read();
await library.urlOf('aaaaaaaaaaaaaaaaaaaaaaaa.mp3');
expect(gateway.calls, 3, reason: 'ten minutes left: asked again');
});
test('plays from the device what the person kept there, else from the '
'instance, and keeps and removes on asking', () async {
await library.read();
const served = 'aaaaaaaaaaaaaaaaaaaaaaaa.mp3';
expect(await library.sourceOf(served), isA<GuideUrl>());
await library.keep(served);
expect(
downloads.fetched[served]?.queryParameters['access'],
'signed-access',
);
expect(await library.kept(), {served});
expect(
await library.sourceOf(served),
isA<GuideFile>().having((file) => file.path, 'path', endsWith(served)),
);
await library.delete(served);
expect(await library.kept(), isEmpty);
});
test('an instance without a library gives no file at all', () async {
gateway.answer = sampleCatalogue()
..filesUrl = null
..access = null;
await library.read();
expect(await library.sourceOf('aaaaaaaaaaaaaaaaaaaaaaaa.mp3'), isNull);
await expectLater(
library.keep('aaaaaaaaaaaaaaaaaaaaaaaa.mp3'),
throwsStateError,
);
});
test('favourites and sittings of the library are kept under library/, '
'apart from Meditate’s own practices', () {
expect(libraryPracticeId('evening-rest'), 'library/evening-rest');
expect(libraryItemIdOf('library/evening-rest'), 'evening-rest');
expect(libraryItemIdOf('breath'), isNull);
expect(libraryItemIdOf(null), isNull);
const settings = MeditateSettings(
favourites: ['breath', 'library/evening-rest'],
);
expect(settings.libraryFavourites, ['evening-rest']);
});
test('categories used come in Meditate’s order, then the operator’s', () {
expect(sampleCatalogue().categoriesUsed, [
'meditation',
'sound',
'breathwork',
]);
});
});
group('lessons done (ADR 0038, D16)', () {
late MeditateKeys keys;
late MemorySealedRowGateway rows;
setUp(() async {
keys = MeditateKeys(
MemoryContentKeyGateway(),
FakeContentCipher(),
() async => FakeAccountCrypto().keysNumbered(1),
);
await keys.refresh();
rows = MemorySealedRowGateway();
});
tearDown(() => keys.dispose());
test('are sealed, one row a lesson however often and on however many '
'devices it is done', () async {
final here = LessonRepository(rows, keys);
final there = LessonRepository(rows, keys);
final at = DateTime.utc(2026, 11, 3, 20);
await here.markDone((course: 'calm-week', item: 'evening-rest'), at);
await there.markDone((course: 'calm-week', item: 'evening-rest'), at);
await there.markDone((course: 'calm-week', item: 'soft-rain'), at);
expect(rows.byId, hasLength(2));
expect(await here.done(), {
(course: 'calm-week', item: 'evening-rest'),
(course: 'calm-week', item: 'soft-rain'),
});
for (final envelope in rows.byId.values) {
expect(envelope.keyId, isNotNull, reason: 'sealed');
}
});
test('a payload reads back as written, and one of a newer schema not '
'at all', () {
final done = (
lesson: (course: 'calm-week', item: 'evening-rest'),
at: DateTime.utc(2026, 11, 3, 20),
);
expect(decodeLesson(encodeLesson(done)), done);
expect(
decodeLesson(
encodeJson(lessonSchemaVersion + 1, {
'course': 'calm-week',
'item': 'evening-rest',
'doneAt': '2026-11-03T20:00:00.000Z',
}),
),
isNull,
);
});
});
group('listening (ADR 0038, D16)', () {
late RecordingGuidePlayer player;
late List<Sitting> kept;
late List<DoneLesson> lessons;
setUp(() {
player = RecordingGuidePlayer();
kept = [];
lessons = [];
});
ListeningCubit listen({String? course, GuideSource? source}) {
final catalogue = sampleCatalogue();
final item = catalogue.items.first;
return ListeningCubit(
ListeningPlan(item: item, audio: item.audio.first, courseId: course),
player: player,
source: () async =>
source ?? GuideUrl(Uri.parse('https://example.org/a.mp3')),
keep: (sitting) async => kept.add(sitting),
lessonDone: (lesson) async => lessons.add(lesson),
);
}
test('played to its end, it is kept as a sitting of its whole length, '
'and as its course’s lesson done', () async {
final cubit = listen(course: 'calm-week');
await cubit.start();
expect(cubit.state.phase, ListeningPhase.playing);
expect(player.calls, ['play https://example.org/a.mp3']);
player.finish();
await pumpEventQueue();
expect(cubit.state.phase, ListeningPhase.done);
expect(kept.single.practiceId, 'library/evening-rest');
expect(kept.single.plannedSeconds, 300);
expect(kept.single.satSeconds, 300);
expect(lessons, [(course: 'calm-week', item: 'evening-rest')]);
await cubit.close();
expect(player.calls, isNot(contains('stop')));
});
test(
'stopped before its end, nothing is kept and the player stops',
() async {
final cubit = listen();
await cubit.start();
await cubit.pause();
expect(cubit.state.phase, ListeningPhase.paused);
await cubit.resume();
await cubit.seek(const Duration(seconds: 30));
await cubit.close();
expect(player.calls, [
'play https://example.org/a.mp3',
'pause',
'resume',
'seek 30000',
'stop',
]);
expect(kept, isEmpty);
expect(lessons, isEmpty);
},
);
test('one that can’t play says so', () async {
player.fails = true;
final cubit = listen();
await cubit.start();
expect(cubit.state.phase, ListeningPhase.failed);
await cubit.close();
});
});
group('a sound under a silent sitting (ADR 0038, D16)', () {
test(
'loops while the sitting runs, pauses with it and stops at its end',
() async {
final player = RecordingGuidePlayer();
final cubit = SittingCubit(
const SittingPlan(
minutes: 10,
sound: (item: 'soft-rain', file: 'c.ogg'),
),
settings: const MeditateSettings(
startBell: false,
keepScreenOn: false,
),
bells: RecordingBells(),
screen: RecordingScreen(),
keep: (sitting) async {},
sound: player,
soundSource: () async => const GuideFile('/device/c.ogg'),
)..start();
await pumpEventQueue();
expect(player.calls, ['play /device/c.ogg loop']);
cubit
..pause()
..resume();
await cubit.end(keep: false);
expect(player.calls, [
'play /device/c.ogg loop',
'pause',
'resume',
'stop',
]);
await cubit.close();
},
);
});
}

View file

@ -0,0 +1,207 @@
import 'dart:async';
import 'package:clock/clock.dart';
import 'package:halkyon_meditate_client/halkyon_meditate_client.dart';
import 'package:halkyon_meditate_flutter/src/core/sound/guide_player.dart';
import 'package:halkyon_meditate_flutter/src/feature/library/data/library_gateway.dart';
/// A library of made-up, neutral entries (ADR 0038, D16), with an access
/// until [accessUntil]: a meditation in two lengths, a sound, a sleep story
/// in an operator's own category, and a course of the first two.
LibraryCatalogue sampleCatalogue({DateTime? accessUntil}) => LibraryCatalogue(
items: [
LibraryItem(
id: 'evening-rest',
title: 'Evening rest',
category: 'meditation',
description: 'A short sitting before sleep.',
speaker: 'Sample Speaker',
language: 'en',
audio: [
LibraryAudio(
file: 'aaaaaaaaaaaaaaaaaaaaaaaa.mp3',
label: 'Short',
durationSeconds: 300,
size: 1000,
),
LibraryAudio(
file: 'bbbbbbbbbbbbbbbbbbbbbbbb.mp3',
label: 'Long',
durationSeconds: 1200,
size: 4000,
),
],
licence: 'CC BY 4.0',
credit: 'Sample Speaker, for the example instance',
),
LibraryItem(
id: 'soft-rain',
title: 'Soft rain',
category: 'sound',
language: 'en',
audio: [
LibraryAudio(
file: 'cccccccccccccccccccccccc.ogg',
durationSeconds: 1800,
size: 9000,
),
],
licence: 'CC0 1.0',
credit: 'Sample Recordist',
),
LibraryItem(
id: 'box-breathing',
title: 'Box breathing',
category: 'breathwork',
language: 'en',
audio: [
LibraryAudio(
file: 'dddddddddddddddddddddddd.mp3',
durationSeconds: 330,
size: 2000,
),
],
licence: 'CC BY 4.0',
credit: 'Sample Speaker',
),
],
courses: [
LibraryCourse(
id: 'calm-week',
title: 'A calm week',
description: 'Evenings to begin with.',
category: 'meditation',
language: 'en',
lessons: [
LibraryLesson(item: 'evening-rest', title: 'Evening one'),
LibraryLesson(item: 'soft-rain'),
],
),
],
categories: [
LibraryCategory(
key: 'breathwork',
english: 'Breathwork',
german: 'Atemarbeit',
),
],
filesUrl: 'https://meditate.example.org/library',
access: 'signed-access',
accessUntil: accessUntil ?? clock.now().add(const Duration(hours: 12)),
);
/// [LibraryGateway] answering with [answer] (none: no library), or failing
/// where [fails], counting the calls.
class FakeLibraryGateway() implements LibraryGateway {
LibraryCatalogue answer = LibraryCatalogue(
items: [],
courses: [],
categories: [],
);
/// Whether the instance can't be reached.
bool fails = false;
/// How often the catalogue was asked for.
int calls = 0;
@override
Future<LibraryCatalogue> catalogue() async {
calls++;
if (fails) {
throw StateError('offline');
}
return answer;
}
}
/// [CatalogueStore] in memory.
class MemoryCatalogueStore() implements CatalogueStore {
LibraryCatalogue? kept;
@override
Future<LibraryCatalogue?> read() async => kept;
@override
Future<void> write(LibraryCatalogue catalogue) async => kept = catalogue;
}
/// [LibraryDownloads] in memory, noting what was fetched from where.
class MemoryLibraryDownloads() implements LibraryDownloads {
final fetched = <String, Uri>{};
/// Whether fetching fails, as offline.
bool fails = false;
@override
Future<Set<String>> kept() async => fetched.keys.toSet();
@override
Future<String?> pathOf(String served) async =>
fetched.containsKey(served) ? '/device/library/$served' : null;
@override
Future<void> keep(String served, Uri url) async {
if (fails) {
throw StateError('offline');
}
fetched[served] = url;
}
@override
Future<void> delete(String served) async => fetched.remove(served);
}
/// [GuidePlayer] noting what it was asked, playing nothing; a test moves it
/// on and ends what plays.
class RecordingGuidePlayer() implements GuidePlayer {
/// Every call, in order: `play <source> loop`, `pause`, `resume`,
/// `seek <ms>`, `stop`.
final calls = <String>[];
/// Whether playing fails.
bool fails = false;
final _positions = StreamController<Duration>.broadcast();
final _finished = StreamController<void>.broadcast();
/// As the recording moved to [position].
void moveTo(Duration position) => _positions.add(position);
/// As the recording played to its end.
void finish() => _finished.add(null);
@override
Stream<Duration> get positions => _positions.stream;
@override
Stream<void> get finished => _finished.stream;
@override
Future<void> play(GuideSource source, {bool loop = false}) async {
if (fails) {
throw StateError('no sound');
}
final from = switch (source) {
GuideUrl(:final url) => '$url',
GuideFile(:final path) => path,
};
calls.add('play $from${loop ? ' loop' : ''}');
}
@override
Future<void> pause() async => calls.add('pause');
@override
Future<void> resume() async => calls.add('resume');
@override
Future<void> seek(Duration position) async =>
calls.add('seek ${position.inMilliseconds}');
@override
Future<void> stop() async => calls.add('stop');
@override
Future<void> dispose() async {}
}