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:
parent
0b6f27d278
commit
811182b376
1 changed files with 3386 additions and 22 deletions
|
|
@ -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 {}
|
||||
}
|
||||
Loading…
Reference in a new issue