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

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 {}
}