import 'package:flutter/foundation.dart'; import 'package:halkyon_learn_flutter/src/core/video/media_players.dart'; import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_store.dart'; import 'package:halkyon_learn_flutter/src/feature/jellyfin/model/jellyfin.dart'; import 'package:material_ui/material_ui.dart'; /// The server of [FakeJellyfinGateway]. final fakeJellyfinServer = JellyfinServer( address: Uri.parse('https://jellyfin.local:8920'), id: 'server-1', name: 'Living room', version: '12.2.0', ); /// A [JellyfinGateway] in memory: [fakeJellyfinServer] at /// `jellyfin.local:8920`, the person `mika` with the password `secret`, /// the libraries and what each item holds ([tree], by parent id; the /// libraries under `''`), the videos' playback and subtitles ([vtt], by /// item id and track index). class FakeJellyfinGateway({ final Map> tree = const {}, final Map playbacks = const {}, final Map<(String, int), String> vtt = const {}, /// Each song's lyrics, by its id. final Map> lyricsOf = const {}, }) implements JellyfinGateway { /// When set, every request after signing in fails with it. JellyfinProblem? fail; /// Whether the Quick Connect code was approved. bool approved = false; /// The people the server shows on its login screen; those named in /// [withoutPassword] sign in with none. List shownUsers = const []; /// Names of accounts without a password, as a demo's. Set withoutPassword = const {}; /// What was reported of playbacks: their positions, in order. final reports = <({Duration position, bool started, bool stopped})>[]; /// The tokens signed out. final signedOut = []; void _check(JellyfinAccount account) { if (fail case final problem?) { throw JellyfinException(problem: problem); } if (signedOut.contains(account.token)) { throw JellyfinException(problem: JellyfinProblem.signedOut); } } JellyfinAccount _account( String token, { String userId = 'user-1', String userName = 'mika', }) => JellyfinAccount( server: fakeJellyfinServer, userId: userId, userName: userName, token: token, ); @override Future server(Uri address) async { if (address.host != fakeJellyfinServer.address.host || address.port != fakeJellyfinServer.address.port) { throw JellyfinException(problem: JellyfinProblem.unreachable); } return JellyfinServer( address: address, id: fakeJellyfinServer.id, name: fakeJellyfinServer.name, version: fakeJellyfinServer.version, ); } @override Future quickConnectEnabled(JellyfinServer server) async => true; @override Future> publicUsers(JellyfinServer server) async => shownUsers; @override Future signIn( JellyfinServer server, String user, String password, ) async { for (final public in shownUsers) { if (public.name == user && withoutPassword.contains(user) && password.isEmpty) { return _account('token-3', userId: public.id, userName: public.name); } } if (user != 'mika' || password != 'secret') { throw JellyfinException(problem: JellyfinProblem.wrongCredentials); } return _account('token-1'); } @override Future<({String code, String secret})> startQuickConnect( JellyfinServer server, ) async => (code: '123456', secret: 'secret-1'); @override Future quickConnectApproved( JellyfinServer server, String secret, ) async => approved; @override Future finishQuickConnect( JellyfinServer server, String secret, ) async => _account('token-2'); @override Future signOut(JellyfinAccount account) async => signedOut.add(account.token); @override Future> libraries(JellyfinAccount account) async { _check(account); return tree[''] ?? const []; } @override Future> children( JellyfinAccount account, String parentId, ) async { _check(account); return tree[parentId] ?? const []; } @override Future> search( JellyfinAccount account, String term, ) async { _check(account); return [ for (final items in tree.values) for (final item in items) if (item.kind != JellyfinItemKind.library && item.name.toLowerCase().contains(term.toLowerCase())) item, ]; } @override Future item(JellyfinAccount account, String id) async { _check(account); // A server answers 404 for an item it no longer has. return [ for (final items in tree.values) ...items, ].where((item) => item.id == id).firstOrNull ?? (throw JellyfinException(problem: JellyfinProblem.failed)); } @override Future playback( JellyfinAccount account, String itemId, ) async { _check(account); return playbacks[itemId] ?? (throw JellyfinException(problem: JellyfinProblem.failed)); } @override Uri streamOf(JellyfinAccount account, JellyfinPlayback playback) => fakeJellyfinServer.address.replace( path: '/Videos/${playback.itemId}/stream', ); @override Map headersOf(JellyfinAccount account) => { 'Authorization': 'MediaBrowser Token="${account.token}"', }; @override Future subtitles( JellyfinAccount account, JellyfinPlayback playback, JellyfinSubtitleTrack track, ) async { _check(account); return vtt[(playback.itemId, track.index)] ?? 'WEBVTT\n'; } @override Future> lyrics( JellyfinAccount account, String itemId, ) async { _check(account); return lyricsOf[itemId] ?? const []; } @override Uri imageOf(JellyfinAccount account, JellyfinItem item, {int height = 360}) => fakeJellyfinServer.address.replace(path: '/Items/${item.id}/Images'); @override Future report( JellyfinAccount account, JellyfinPlayback playback, Duration position, { bool paused = false, bool started = false, bool stopped = false, }) async => reports.add(( position: position, started: started, stopped: stopped, )); } /// A [JellyfinStore] in memory, with what [kept] holds to start with. class MemoryJellyfinStore({JellyfinState? kept}) implements JellyfinStore { JellyfinState? kept = kept; @override Future read() async => kept ?? const JellyfinState(deviceId: 'device-1'); @override Future write(JellyfinState state) async => kept = state; } /// [MediaPlayers] that play nothing: a playback stands where it is put /// ([FakeMediaPlayback.moveTo]), and its picture is [frame], or a dark box. class FakeMediaPlayers({ /// What the picture shows. final Widget? frame, /// The audio tracks of every video, as its player lists them. final List audio = const [], @override final bool available = true, }) implements MediaPlayers { /// The videos opened, in order. final opened = <({Uri url, Map headers, Duration start})>[]; /// The playback opened last. FakeMediaPlayback? last; @override Future open( Uri url, { Map headers = const {}, Duration start = Duration.zero, }) async { opened.add((url: url, headers: headers, start: start)); return last = FakeMediaPlayback(start: start, frame: frame, audio: audio); } } /// A [MediaPlayback] standing still until moved. class FakeMediaPlayback({ Duration start = Duration.zero, final Widget? frame, final List audio = const [], }) implements MediaPlayback { final ValueNotifier _state = ValueNotifier( MediaState(position: start, duration: const Duration(minutes: 24)), ); /// Whether it was disposed. bool disposed = false; /// The audio track chosen, if one was. MediaAudioTrack? selectedAudio; @override Future> audioTracks() async => audio; @override Future selectAudio(MediaAudioTrack track) async => selectedAudio = track; @override ValueListenable get state => _state; /// Puts it at [position], as playing would. void moveTo(Duration position) => _state.value = MediaState( position: position, duration: _state.value.duration, playing: _state.value.playing, ); @override Future play() async => _state.value = MediaState( position: _state.value.position, duration: _state.value.duration, playing: true, ); @override Future pause() async => _state.value = MediaState( position: _state.value.position, duration: _state.value.duration, ); @override Future seekTo(Duration position) async => moveTo(position); @override Widget view() => AspectRatio( aspectRatio: 16 / 9, child: frame ?? const ColoredBox(color: Color(0xFF101418)), ); @override Future dispose() async => disposed = true; }