290 lines
8.2 KiB
Dart
290 lines
8.2 KiB
Dart
|
|
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<String, List<JellyfinItem>> tree = const {},
|
||
|
|
final Map<String, JellyfinPlayback> playbacks = const {},
|
||
|
|
final Map<(String, int), String> vtt = 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;
|
||
|
|
|
||
|
|
/// What was reported of playbacks: their positions, in order.
|
||
|
|
final reports = <({Duration position, bool started, bool stopped})>[];
|
||
|
|
|
||
|
|
/// The tokens signed out.
|
||
|
|
final signedOut = <String>[];
|
||
|
|
|
||
|
|
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) => JellyfinAccount(
|
||
|
|
server: fakeJellyfinServer,
|
||
|
|
userId: 'user-1',
|
||
|
|
userName: 'mika',
|
||
|
|
token: token,
|
||
|
|
);
|
||
|
|
|
||
|
|
@override
|
||
|
|
Future<JellyfinServer> 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<bool> quickConnectEnabled(JellyfinServer server) async => true;
|
||
|
|
|
||
|
|
@override
|
||
|
|
Future<JellyfinAccount> signIn(
|
||
|
|
JellyfinServer server,
|
||
|
|
String user,
|
||
|
|
String password,
|
||
|
|
) async {
|
||
|
|
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<bool> quickConnectApproved(
|
||
|
|
JellyfinServer server,
|
||
|
|
String secret,
|
||
|
|
) async => approved;
|
||
|
|
|
||
|
|
@override
|
||
|
|
Future<JellyfinAccount> finishQuickConnect(
|
||
|
|
JellyfinServer server,
|
||
|
|
String secret,
|
||
|
|
) async => _account('token-2');
|
||
|
|
|
||
|
|
@override
|
||
|
|
Future<void> signOut(JellyfinAccount account) async =>
|
||
|
|
signedOut.add(account.token);
|
||
|
|
|
||
|
|
@override
|
||
|
|
Future<List<JellyfinItem>> libraries(JellyfinAccount account) async {
|
||
|
|
_check(account);
|
||
|
|
return tree[''] ?? const [];
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
Future<List<JellyfinItem>> children(
|
||
|
|
JellyfinAccount account,
|
||
|
|
String parentId,
|
||
|
|
) async {
|
||
|
|
_check(account);
|
||
|
|
return tree[parentId] ?? const [];
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
Future<List<JellyfinItem>> 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<JellyfinItem> 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<JellyfinPlayback> 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<String, String> headersOf(JellyfinAccount account) => {
|
||
|
|
'Authorization': 'MediaBrowser Token="${account.token}"',
|
||
|
|
};
|
||
|
|
|
||
|
|
@override
|
||
|
|
Future<String> subtitles(
|
||
|
|
JellyfinAccount account,
|
||
|
|
JellyfinPlayback playback,
|
||
|
|
JellyfinSubtitleTrack track,
|
||
|
|
) async {
|
||
|
|
_check(account);
|
||
|
|
return vtt[(playback.itemId, track.index)] ?? 'WEBVTT\n';
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
Uri imageOf(JellyfinAccount account, JellyfinItem item, {int height = 360}) =>
|
||
|
|
fakeJellyfinServer.address.replace(path: '/Items/${item.id}/Images');
|
||
|
|
|
||
|
|
@override
|
||
|
|
Future<void> 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<JellyfinState> read() async =>
|
||
|
|
kept ?? const JellyfinState(deviceId: 'device-1');
|
||
|
|
|
||
|
|
@override
|
||
|
|
Future<void> 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<MediaAudioTrack> audio = const [],
|
||
|
|
@override final bool available = true,
|
||
|
|
}) implements MediaPlayers {
|
||
|
|
/// The videos opened, in order.
|
||
|
|
final opened = <({Uri url, Map<String, String> headers, Duration start})>[];
|
||
|
|
|
||
|
|
/// The playback opened last.
|
||
|
|
FakeMediaPlayback? last;
|
||
|
|
|
||
|
|
@override
|
||
|
|
Future<MediaPlayback> open(
|
||
|
|
Uri url, {
|
||
|
|
Map<String, String> 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<MediaAudioTrack> audio = const [],
|
||
|
|
}) implements MediaPlayback {
|
||
|
|
final ValueNotifier<MediaState> _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<List<MediaAudioTrack>> audioTracks() async => audio;
|
||
|
|
|
||
|
|
@override
|
||
|
|
Future<void> selectAudio(MediaAudioTrack track) async =>
|
||
|
|
selectedAudio = track;
|
||
|
|
|
||
|
|
@override
|
||
|
|
ValueListenable<MediaState> 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<void> play() async => _state.value = MediaState(
|
||
|
|
position: _state.value.position,
|
||
|
|
duration: _state.value.duration,
|
||
|
|
playing: true,
|
||
|
|
);
|
||
|
|
|
||
|
|
@override
|
||
|
|
Future<void> pause() async => _state.value = MediaState(
|
||
|
|
position: _state.value.position,
|
||
|
|
duration: _state.value.duration,
|
||
|
|
);
|
||
|
|
|
||
|
|
@override
|
||
|
|
Future<void> seekTo(Duration position) async => moveTo(position);
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget view() => AspectRatio(
|
||
|
|
aspectRatio: 16 / 9,
|
||
|
|
child: frame ?? const ColoredBox(color: Color(0xFF101418)),
|
||
|
|
);
|
||
|
|
|
||
|
|
@override
|
||
|
|
Future<void> dispose() async => disposed = true;
|
||
|
|
}
|