feat(jellyfin): the player as players are, with the settings under a cog

The Watch screen's player takes the shape people know from players,
YouTube's first: the picture set in from every side with rounded
corners, so the glow of its colours shows all around it, and over it
play or pause in the middle with 10 seconds back and on beside it, and
along its foot where the video is (0:05 ──── 23:47) and a cog. The
controls fade out while it plays and the pointer rests, and come back as
it moves, on a tap with a finger, on a jump and while paused. The cog
opens a panel inside the picture, so nothing runs off the window: Pause
after each line, the subtitles, the translation and the sound, each
track named by its language in the app's words, and the keyboard
shortcuts. The buttons for the line before, again and the next sit
beside the subtitles they act on, no longer looking like a player's
skip to the next video. Keys are YouTube's (Space or K, J and L, the
arrows, 0 to 9, Home, End) with Shift and the arrows for lines, R, P and
Esc; ? lists them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-13 16:51:53 +02:00
commit 48a9564231
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
10 changed files with 1032 additions and 299 deletions

File diff suppressed because one or more lines are too long

View file

@ -1774,13 +1774,29 @@
"watchSubtitles": "Untertitel", "watchSubtitles": "Untertitel",
"watchTranslation": "Übersetzung", "watchTranslation": "Übersetzung",
"watchAudio": "Audio", "watchAudio": "Audio",
"watchTracks": "Audio und Untertitel", "watchBack10": "10 Sekunden zurück",
"watchForward10": "10 Sekunden vor",
"watchSettings": "Einstellungen",
"watchShortcuts": "Tastenkürzel",
"watchTrackForced": "erzwungen",
"watchTrackCaptions": "für Hörgeschädigte",
"watchKeyPlayPause": "Abspielen oder pausieren",
"watchKeyFive": "5 Sekunden zurück oder vor",
"watchKeyTen": "10 Sekunden zurück oder vor",
"watchKeyLines": "Vorige oder nächste Zeile",
"watchKeyJump": "Zu 0 %, 10 %, … 90 % des Videos",
"watchKeyStartEnd": "Zum Anfang oder zum Ende",
"watchKeyClose": "Wort oder Einstellungen schließen",
"watchKeyShortcuts": "Diese Tastenkürzel",
"watchKeySpace": "Leertaste",
"watchKeyShift": "Umschalt",
"watchKeyHome": "Pos1",
"watchKeyEnd": "Ende",
"watchNone": "Keine", "watchNone": "Keine",
"watchPlay": "Abspielen", "watchPlay": "Abspielen",
"watchPause": "Pause", "watchPause": "Pause",
"watchPreviousLine": "Vorherige Zeile", "watchPreviousLine": "Vorherige Zeile",
"watchNextLine": "Nächste Zeile", "watchNextLine": "Nächste Zeile",
"watchRepeatLine": "Zeile wiederholen",
"watchPauseAtLineEnd": "Nach jeder Zeile anhalten", "watchPauseAtLineEnd": "Nach jeder Zeile anhalten",
"watchWordsTitle": "Wörter in diesem Video", "watchWordsTitle": "Wörter in diesem Video",
"watchWordsNewOnly": "Nur neue", "watchWordsNewOnly": "Nur neue",

View file

@ -2087,13 +2087,29 @@
"watchSubtitles": "Subtitles", "watchSubtitles": "Subtitles",
"watchTranslation": "Translation", "watchTranslation": "Translation",
"watchAudio": "Audio", "watchAudio": "Audio",
"watchTracks": "Audio and subtitles", "watchBack10": "10 seconds back",
"watchForward10": "10 seconds on",
"watchSettings": "Settings",
"watchShortcuts": "Keyboard shortcuts",
"watchTrackForced": "forced",
"watchTrackCaptions": "for the hard of hearing",
"watchKeyPlayPause": "Play or pause",
"watchKeyFive": "5 seconds back or on",
"watchKeyTen": "10 seconds back or on",
"watchKeyLines": "Previous or next line",
"watchKeyJump": "To 0 %, 10 %, … 90 % of the video",
"watchKeyStartEnd": "To the start or the end",
"watchKeyClose": "Close the word or the settings",
"watchKeyShortcuts": "These shortcuts",
"watchKeySpace": "Space",
"watchKeyShift": "Shift",
"watchKeyHome": "Home",
"watchKeyEnd": "End",
"watchNone": "None", "watchNone": "None",
"watchPlay": "Play", "watchPlay": "Play",
"watchPause": "Pause", "watchPause": "Pause",
"watchPreviousLine": "Previous line", "watchPreviousLine": "Previous line",
"watchNextLine": "Next line", "watchNextLine": "Next line",
"watchRepeatLine": "Repeat line",
"watchPauseAtLineEnd": "Pause after each line", "watchPauseAtLineEnd": "Pause after each line",
"watchWordsTitle": "Words in this video", "watchWordsTitle": "Words in this video",
"watchWordsNewOnly": "New only", "watchWordsNewOnly": "New only",

View file

@ -10485,11 +10485,113 @@ abstract class AppLocalizations {
/// **'Audio'** /// **'Audio'**
String get watchAudio; String get watchAudio;
/// No description provided for @watchTracks. /// No description provided for @watchBack10.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Audio and subtitles'** /// **'10 seconds back'**
String get watchTracks; String get watchBack10;
/// No description provided for @watchForward10.
///
/// In en, this message translates to:
/// **'10 seconds on'**
String get watchForward10;
/// No description provided for @watchSettings.
///
/// In en, this message translates to:
/// **'Settings'**
String get watchSettings;
/// No description provided for @watchShortcuts.
///
/// In en, this message translates to:
/// **'Keyboard shortcuts'**
String get watchShortcuts;
/// No description provided for @watchTrackForced.
///
/// In en, this message translates to:
/// **'forced'**
String get watchTrackForced;
/// No description provided for @watchTrackCaptions.
///
/// In en, this message translates to:
/// **'for the hard of hearing'**
String get watchTrackCaptions;
/// No description provided for @watchKeyPlayPause.
///
/// In en, this message translates to:
/// **'Play or pause'**
String get watchKeyPlayPause;
/// No description provided for @watchKeyFive.
///
/// In en, this message translates to:
/// **'5 seconds back or on'**
String get watchKeyFive;
/// No description provided for @watchKeyTen.
///
/// In en, this message translates to:
/// **'10 seconds back or on'**
String get watchKeyTen;
/// No description provided for @watchKeyLines.
///
/// In en, this message translates to:
/// **'Previous or next line'**
String get watchKeyLines;
/// No description provided for @watchKeyJump.
///
/// In en, this message translates to:
/// **'To 0 %, 10 %, … 90 % of the video'**
String get watchKeyJump;
/// No description provided for @watchKeyStartEnd.
///
/// In en, this message translates to:
/// **'To the start or the end'**
String get watchKeyStartEnd;
/// No description provided for @watchKeyClose.
///
/// In en, this message translates to:
/// **'Close the word or the settings'**
String get watchKeyClose;
/// No description provided for @watchKeyShortcuts.
///
/// In en, this message translates to:
/// **'These shortcuts'**
String get watchKeyShortcuts;
/// No description provided for @watchKeySpace.
///
/// In en, this message translates to:
/// **'Space'**
String get watchKeySpace;
/// No description provided for @watchKeyShift.
///
/// In en, this message translates to:
/// **'Shift'**
String get watchKeyShift;
/// No description provided for @watchKeyHome.
///
/// In en, this message translates to:
/// **'Home'**
String get watchKeyHome;
/// No description provided for @watchKeyEnd.
///
/// In en, this message translates to:
/// **'End'**
String get watchKeyEnd;
/// No description provided for @watchNone. /// No description provided for @watchNone.
/// ///
@ -10521,12 +10623,6 @@ abstract class AppLocalizations {
/// **'Next line'** /// **'Next line'**
String get watchNextLine; String get watchNextLine;
/// No description provided for @watchRepeatLine.
///
/// In en, this message translates to:
/// **'Repeat line'**
String get watchRepeatLine;
/// No description provided for @watchPauseAtLineEnd. /// No description provided for @watchPauseAtLineEnd.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -6605,7 +6605,58 @@ class AppLocalizationsDe extends AppLocalizations {
String get watchAudio => 'Audio'; String get watchAudio => 'Audio';
@override @override
String get watchTracks => 'Audio und Untertitel'; String get watchBack10 => '10 Sekunden zurück';
@override
String get watchForward10 => '10 Sekunden vor';
@override
String get watchSettings => 'Einstellungen';
@override
String get watchShortcuts => 'Tastenkürzel';
@override
String get watchTrackForced => 'erzwungen';
@override
String get watchTrackCaptions => 'für Hörgeschädigte';
@override
String get watchKeyPlayPause => 'Abspielen oder pausieren';
@override
String get watchKeyFive => '5 Sekunden zurück oder vor';
@override
String get watchKeyTen => '10 Sekunden zurück oder vor';
@override
String get watchKeyLines => 'Vorige oder nächste Zeile';
@override
String get watchKeyJump => 'Zu 0 %, 10 %, … 90 % des Videos';
@override
String get watchKeyStartEnd => 'Zum Anfang oder zum Ende';
@override
String get watchKeyClose => 'Wort oder Einstellungen schließen';
@override
String get watchKeyShortcuts => 'Diese Tastenkürzel';
@override
String get watchKeySpace => 'Leertaste';
@override
String get watchKeyShift => 'Umschalt';
@override
String get watchKeyHome => 'Pos1';
@override
String get watchKeyEnd => 'Ende';
@override @override
String get watchNone => 'Keine'; String get watchNone => 'Keine';
@ -6622,9 +6673,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get watchNextLine => 'Nächste Zeile'; String get watchNextLine => 'Nächste Zeile';
@override
String get watchRepeatLine => 'Zeile wiederholen';
@override @override
String get watchPauseAtLineEnd => 'Nach jeder Zeile anhalten'; String get watchPauseAtLineEnd => 'Nach jeder Zeile anhalten';

View file

@ -6547,7 +6547,58 @@ class AppLocalizationsEn extends AppLocalizations {
String get watchAudio => 'Audio'; String get watchAudio => 'Audio';
@override @override
String get watchTracks => 'Audio and subtitles'; String get watchBack10 => '10 seconds back';
@override
String get watchForward10 => '10 seconds on';
@override
String get watchSettings => 'Settings';
@override
String get watchShortcuts => 'Keyboard shortcuts';
@override
String get watchTrackForced => 'forced';
@override
String get watchTrackCaptions => 'for the hard of hearing';
@override
String get watchKeyPlayPause => 'Play or pause';
@override
String get watchKeyFive => '5 seconds back or on';
@override
String get watchKeyTen => '10 seconds back or on';
@override
String get watchKeyLines => 'Previous or next line';
@override
String get watchKeyJump => 'To 0 %, 10 %, … 90 % of the video';
@override
String get watchKeyStartEnd => 'To the start or the end';
@override
String get watchKeyClose => 'Close the word or the settings';
@override
String get watchKeyShortcuts => 'These shortcuts';
@override
String get watchKeySpace => 'Space';
@override
String get watchKeyShift => 'Shift';
@override
String get watchKeyHome => 'Home';
@override
String get watchKeyEnd => 'End';
@override @override
String get watchNone => 'None'; String get watchNone => 'None';
@ -6564,9 +6615,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get watchNextLine => 'Next line'; String get watchNextLine => 'Next line';
@override
String get watchRepeatLine => 'Repeat line';
@override @override
String get watchPauseAtLineEnd => 'Pause after each line'; String get watchPauseAtLineEnd => 'Pause after each line';

View file

@ -465,13 +465,6 @@ class WatchCubit(
/// Plays the line after the one shown. /// Plays the line after the one shown.
Future<void> nextLine() => playLine((state.line ?? -1) + 1); Future<void> nextLine() => playLine((state.line ?? -1) + 1);
/// Plays the line shown again.
Future<void> repeatLine() async {
if (state.line case final line?) {
await playLine(line);
}
}
/// Plays, or pauses. /// Plays, or pauses.
Future<void> playOrPause() async { Future<void> playOrPause() async {
final playback = _playback; final playback = _playback;
@ -491,6 +484,30 @@ class WatchCubit(
await _playback?.seekTo(position); await _playback?.seekTo(position);
} }
/// Jumps [by] from where the video is (back when negative), within it.
Future<void> seekBy(Duration by) async {
final media = _playback?.state.value;
if (media == null) {
return;
}
final to = media.position + by;
await seekTo(
to < Duration.zero
? Duration.zero
: media.duration > Duration.zero && to > media.duration
? media.duration
: to,
);
}
/// Jumps to [share] of the video: 0 its start, 0.5 its middle.
Future<void> seekToShare(double share) async {
final duration = _playback?.state.value.duration;
if (duration != null) {
await seekTo(duration * share.clamp(0, 1));
}
}
/// Pauses after each line from now on, or no longer. /// Pauses after each line from now on, or no longer.
void pauseAtLineEnd({required bool on}) => void pauseAtLineEnd({required bool on}) =>
emit(state.copyWith(pauseAtLineEnd: on)); emit(state.copyWith(pauseAtLineEnd: on));

View file

@ -0,0 +1,565 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/gestures.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/l10n/content_languages.dart';
import 'package:halkyon_learn_flutter/src/core/theme/app_theme.dart';
import 'package:halkyon_learn_flutter/src/core/video/media_players.dart';
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/bloc/watch_cubit.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/model/jellyfin.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// What the settings' panel over the picture shows: its first page, or
/// the list of one kind of track.
enum WatchSettingsPage() {
main,
subtitles,
translation,
audio,
}
/// The player (decided 2026-10-13, as players are, YouTube's first): the
/// picture with rounded corners and, over it, play or pause in the middle
/// with 10 seconds back and on beside it, and along its foot where the
/// video is (0:05 ───── 23:47) and the settings' cog. The controls fade out
/// while the video plays and the pointer rests, and come back as it moves,
/// on a tap (with a finger: with a mouse a click plays or pauses), on a jump
/// and while paused. The cog opens a panel over the picture ([settings],
/// which the screen's Esc closes): Pause after each line, the subtitles
/// read, the translation, the sound, and the keyboard shortcuts. [frame] is
/// around the picture alone, for its colours to glow.
class const WatchPlayer({
required final GlobalKey frame,
required final ValueNotifier<WatchSettingsPage?> settings,
super.key,
}) extends StatefulWidget {
/// How long the controls stay without the pointer moving.
static const showFor = Duration(seconds: 3);
@override
State<WatchPlayer> createState() => _WatchPlayerState();
}
class _WatchPlayerState() extends State<WatchPlayer> {
var _shown = true;
Timer? _hide;
MediaPlayback? _following;
var _was = const MediaState();
/// Shows the controls, and hides them again unless the pointer moves.
void _poke() {
_hide?.cancel();
if (!_shown) {
setState(() => _shown = true);
}
_hide = Timer(WatchPlayer.showFor, () {
if (mounted && (_following?.state.value.playing ?? false)) {
setState(() => _shown = false);
}
});
}
/// Shows the controls while paused and on a jump.
void _follow() {
final now = _following?.state.value;
if (now == null) {
return;
}
final jumped =
(now.position - _was.position).abs() > const Duration(seconds: 2);
if (now.playing != _was.playing || jumped) {
if (now.playing) {
_poke();
} else {
_hide?.cancel();
if (!_shown) {
setState(() => _shown = true);
}
}
}
_was = now;
}
void _followNow(MediaPlayback? playback) {
if (identical(playback, _following)) {
return;
}
_following?.state.removeListener(_follow);
_following = playback;
if (playback != null) {
_was = playback.state.value;
playback.state.addListener(_follow);
}
}
@override
void dispose() {
_hide?.cancel();
_following?.state.removeListener(_follow);
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final state = context.watch<WatchCubit>().state;
final playback = context.select<WatchCubit, MediaPlayback?>(
(cubit) => cubit.playback,
);
_followNow(playback);
final message = state.playerMissing
? Platform.isLinux
? l10n.jellyfinNoPlayerLinux
: l10n.jellyfinNoPlayer
: state.playerFailed
? l10n.watchFailed
: null;
if (message != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(message, textAlign: TextAlign.center),
),
);
}
if (playback == null) {
return state.status == WatchStatus.failed
? const SizedBox()
: const Center(child: AppLoadingIndicator());
}
// The controls are light on the picture in either theme, as players'.
return Theme(
data: appTheme(Brightness.dark),
child: ValueListenableBuilder<MediaState>(
valueListenable: playback.state,
builder: (context, media, _) => Center(
child: AspectRatio(
aspectRatio: media.aspectRatio,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: ValueListenableBuilder(
valueListenable: widget.settings,
builder: (context, page, _) {
final shown = _shown || page != null || !media.playing;
return MouseRegion(
cursor: shown ? MouseCursor.defer : SystemMouseCursors.none,
onHover: (_) => _poke(),
child: Stack(
fit: StackFit.expand,
children: [
GestureDetector(
onTapUp: (details) {
if (details.kind == PointerDeviceKind.touch) {
_hide?.cancel();
setState(() => _shown = !shown);
} else {
unawaited(
context.read<WatchCubit>().playOrPause(),
);
}
},
child: RepaintBoundary(
key: widget.frame,
child: playback.view(),
),
),
IgnorePointer(
ignoring: !shown,
child: AnimatedOpacity(
opacity: shown ? 1 : 0,
duration: const Duration(milliseconds: 200),
child: _Controls(
media: media,
onSettings: () => widget.settings.value =
page == null ? WatchSettingsPage.main : null,
),
),
),
if (page != null) ...[
// A tap beside the panel closes it.
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => widget.settings.value = null,
),
Positioned(
right: 8,
bottom: 56,
top: 8,
child: Align(
alignment: Alignment.bottomRight,
child: _Settings(
page: page,
go: (to) => widget.settings.value = to,
),
),
),
],
],
),
);
},
),
),
),
),
),
);
}
}
/// The controls over the picture: 10 seconds back, play or pause, 10
/// seconds on; along the foot, on a shade so they read on any picture,
/// where the video is and the cog.
class const _Controls({
required final MediaState media,
required final VoidCallback onSettings,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
WatchCubit cubit() => context.read<WatchCubit>();
final total = media.duration.inMilliseconds;
final times = Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.white,
fontFeatures: const [FontFeature.tabularFigures()],
);
Widget round(
String tooltip,
Widget icon,
VoidCallback onPressed, {
double size = 48,
}) => IconButton.filled(
tooltip: tooltip,
iconSize: size / 2,
style: IconButton.styleFrom(
backgroundColor: Colors.black.withValues(alpha: 0.45),
foregroundColor: Colors.white,
fixedSize: Size.square(size),
),
icon: icon,
onPressed: onPressed,
);
return Stack(
fit: StackFit.expand,
children: [
const Align(
alignment: Alignment.bottomCenter,
child: SizedBox(
height: 80,
width: double.infinity,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [Color(0xA6000000), Color(0x00000000)],
),
),
),
),
),
Center(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
round(
l10n.watchBack10,
const _TenSeconds(back: true),
() => cubit().seekBy(const Duration(seconds: -10)),
),
const SizedBox(width: 24),
round(
media.playing ? l10n.watchPause : l10n.watchPlay,
Icon(media.playing ? LucideIcons.pause : LucideIcons.play),
() => cubit().playOrPause(),
size: 64,
),
const SizedBox(width: 24),
round(
l10n.watchForward10,
const _TenSeconds(back: false),
() => cubit().seekBy(const Duration(seconds: 10)),
),
],
),
),
),
Positioned(
left: 12,
right: 4,
bottom: 4,
child: Row(
children: [
Text(watchTime(media.position), style: times),
Expanded(
child: Slider(
value: total <= 0
? 0
: media.position.inMilliseconds.clamp(0, total) / total,
onChanged: total <= 0
? null
: (value) => cubit().seekTo(
Duration(milliseconds: (value * total).round()),
),
),
),
Text(watchTime(media.duration), style: times),
IconButton(
tooltip: l10n.watchSettings,
color: Colors.white,
icon: const Icon(LucideIcons.settings),
onPressed: onSettings,
),
],
),
),
],
);
}
}
/// 10 seconds back or on: a turning arrow with the 10 in it, as players
/// show it (Lucide has no such icon).
class const _TenSeconds({required final bool back}) extends StatelessWidget {
@override
Widget build(BuildContext context) => Stack(
alignment: Alignment.center,
children: [
Icon(back ? LucideIcons.rotateCcw : LucideIcons.rotateCw),
const Text(
'10',
style: TextStyle(fontSize: 8, fontWeight: FontWeight.w700),
),
],
);
}
/// The settings' panel: its first page, or a list of tracks to choose
/// from, each with the way back.
class const _Settings({
required final WatchSettingsPage page,
required final ValueChanged<WatchSettingsPage?> go,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final state = context.watch<WatchCubit>().state;
WatchCubit cubit() => context.read<WatchCubit>();
final subtitles = trackNames(l10n, state.tracks);
final audio = audioNames(l10n, state.audioTracks);
Widget back(String title) => ListTile(
leading: const Icon(LucideIcons.chevronLeft),
title: Text(title),
onTap: () => go(WatchSettingsPage.main),
);
Widget choice(
String name, {
required bool chosen,
required VoidCallback onTap,
}) => ListTile(
leading: Icon(
LucideIcons.check,
color: chosen ? null : Colors.transparent,
),
title: Text(name),
onTap: () {
onTap();
go(WatchSettingsPage.main);
},
);
Widget opens(String title, String? value, WatchSettingsPage to) => ListTile(
title: Text(title),
subtitle: Text(value ?? l10n.watchNone),
trailing: const Icon(LucideIcons.chevronRight),
onTap: () => go(to),
);
final children = switch (page) {
WatchSettingsPage.main => [
SwitchListTile(
title: Text(l10n.watchPauseAtLineEnd),
value: state.pauseAtLineEnd,
onChanged: (on) => cubit().pauseAtLineEnd(on: on),
),
if (state.tracks.isNotEmpty) ...[
opens(
l10n.watchSubtitles,
subtitles[state.studyTrack],
WatchSettingsPage.subtitles,
),
opens(
l10n.watchTranslation,
subtitles[state.translationTrack],
WatchSettingsPage.translation,
),
],
if (state.audioTracks.length > 1)
opens(
l10n.watchAudio,
audio[state.audioTrack],
WatchSettingsPage.audio,
),
ListTile(
title: Text(l10n.watchShortcuts),
trailing: const Icon(LucideIcons.keyboard),
onTap: () {
go(null);
unawaited(showWatchShortcuts(context));
},
),
],
WatchSettingsPage.subtitles => [
back(l10n.watchSubtitles),
const Divider(height: 1),
for (final MapEntry(key: track, value: name) in subtitles.entries)
choice(
name,
chosen: track == state.studyTrack,
onTap: () => cubit().chooseStudyTrack(track),
),
choice(
l10n.watchNone,
chosen: state.studyTrack == null,
onTap: () => cubit().chooseStudyTrack(null),
),
],
WatchSettingsPage.translation => [
back(l10n.watchTranslation),
const Divider(height: 1),
for (final MapEntry(key: track, value: name) in subtitles.entries)
choice(
name,
chosen: track == state.translationTrack,
onTap: () => cubit().chooseTranslationTrack(track),
),
choice(
l10n.watchNone,
chosen: state.translationTrack == null,
onTap: () => cubit().chooseTranslationTrack(null),
),
],
WatchSettingsPage.audio => [
back(l10n.watchAudio),
const Divider(height: 1),
for (final MapEntry(key: track, value: name) in audio.entries)
choice(
name,
chosen: track == state.audioTrack,
onTap: () => cubit().chooseAudioTrack(track),
),
],
};
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 320),
child: Material(
color: const Color(0xEB1F2024),
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(mainAxisSize: MainAxisSize.min, children: children),
),
),
);
}
}
/// Subtitle tracks named plainly: their language in the app's words, and
/// whether forced or for the hard of hearing, rather than Jellyfin's
/// "closedcaptions - Japanese - SUBRIP - External"; tracks that would read
/// the same keep their own titles beside.
Map<JellyfinSubtitleTrack, String> trackNames(
AppLocalizations l10n,
List<JellyfinSubtitleTrack> tracks,
) => _distinct({
for (final track in tracks)
track: (
[
_languageName(l10n, track.language) ?? track.title,
if (track.isHearingImpaired) l10n.watchTrackCaptions,
if (track.isForced) l10n.watchTrackForced,
].join(' · '),
track.title,
),
});
/// Audio tracks named plainly, as [trackNames].
Map<JellyfinAudioTrack, String> audioNames(
AppLocalizations l10n,
List<JellyfinAudioTrack> tracks,
) => _distinct({
for (final track in tracks)
track: (_languageName(l10n, track.language) ?? track.title, track.title),
});
Map<T, String> _distinct<T>(Map<T, (String, String)> named) {
final counts = <String, int>{};
for (final (name, _) in named.values) {
counts[name] = (counts[name] ?? 0) + 1;
}
return {
for (final MapEntry(key: track, value: (name, title)) in named.entries)
track: (counts[name] ?? 0) > 1 ? '$name ($title)' : name,
};
}
String? _languageName(AppLocalizations l10n, String? code) {
final tag = languageNamesOf(code).where((name) => name.length == 2);
return tag.isEmpty ? null : contentLanguageName(l10n, tag.first);
}
/// A time of the video: 3:07, or 1:02:09 past an hour.
String watchTime(Duration time) {
final seconds = (time.inSeconds % 60).toString().padLeft(2, '0');
final minutes = time.inMinutes % 60;
return time.inHours > 0
? '${time.inHours}:${minutes.toString().padLeft(2, '0')}:$seconds'
: '$minutes:$seconds';
}
/// The Watch screen's keyboard shortcuts, listed as YouTube's are (? shows
/// them there too).
Future<void> showWatchShortcuts(BuildContext context) =>
showAdaptiveSheet<void>(
context,
builder: (context) {
final l10n = AppLocalizations.of(context);
final rows = [
([l10n.watchKeySpace, 'K'], l10n.watchKeyPlayPause),
(['J', 'L'], l10n.watchKeyTen),
(['←', '→'], l10n.watchKeyFive),
(
['${l10n.watchKeyShift} + ←', '${l10n.watchKeyShift} + →'],
l10n.watchKeyLines,
),
(['P'], l10n.watchPauseAtLineEnd),
(['0 … 9'], l10n.watchKeyJump),
([l10n.watchKeyHome, l10n.watchKeyEnd], l10n.watchKeyStartEnd),
(['Esc'], l10n.watchKeyClose),
(['?'], l10n.watchKeyShortcuts),
];
return SheetScaffold(
title: l10n.watchShortcuts,
body: Column(
children: [
for (final (keys, what) in rows)
ListTile(
title: Text(what),
trailing: Wrap(
spacing: 4,
children: [
for (final key in keys) Chip(label: Text(key)),
],
),
),
],
),
);
},
);

View file

@ -1,12 +1,10 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart'; import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart'; import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart'; import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/video/media_players.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart'; import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_adder.dart'; import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_adder.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart'; import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
@ -19,6 +17,7 @@ import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_gateway
import 'package:halkyon_learn_flutter/src/feature/jellyfin/model/jellyfin.dart'; import 'package:halkyon_learn_flutter/src/feature/jellyfin/model/jellyfin.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/widget/ambient_glow.dart'; import 'package:halkyon_learn_flutter/src/feature/jellyfin/widget/ambient_glow.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/widget/jellyfin_connect.dart'; import 'package:halkyon_learn_flutter/src/feature/jellyfin/widget/jellyfin_connect.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/widget/watch_player.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_language_scope.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_language_scope.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
@ -72,26 +71,76 @@ class const _Watch({
} }
class const _WatchView({required final Dictionary? dictionary}) class const _WatchView({required final Dictionary? dictionary})
extends StatelessWidget { extends StatefulWidget {
@override
State<_WatchView> createState() => _WatchViewState();
}
class _WatchViewState() extends State<_WatchView> {
/// The page of the player's settings shown, if open.
final _settings = ValueNotifier<WatchSettingsPage?>(null);
@override
void dispose() {
_settings.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final dictionary = widget.dictionary;
WatchCubit cubit() => context.read<WatchCubit>(); WatchCubit cubit() => context.read<WatchCubit>();
final item = context.select<WatchCubit, JellyfinItem?>( final item = context.select<WatchCubit, JellyfinItem?>(
(cubit) => cubit.state.item, (cubit) => cubit.state.item,
); );
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
return CallbackShortcuts( return CallbackShortcuts(
// YouTube's keys, and keys for lines (decided 2026-10-13); ? lists
// them, as there.
bindings: { bindings: {
const SingleActivator(LogicalKeyboardKey.space): () => const SingleActivator(LogicalKeyboardKey.space): () =>
cubit().playOrPause(), cubit().playOrPause(),
const SingleActivator(LogicalKeyboardKey.keyK): () =>
cubit().playOrPause(),
const SingleActivator(LogicalKeyboardKey.keyJ): () =>
cubit().seekBy(const Duration(seconds: -10)),
const SingleActivator(LogicalKeyboardKey.keyL): () =>
cubit().seekBy(const Duration(seconds: 10)),
const SingleActivator(LogicalKeyboardKey.arrowLeft): () => const SingleActivator(LogicalKeyboardKey.arrowLeft): () =>
cubit().previousLine(), cubit().seekBy(const Duration(seconds: -5)),
const SingleActivator(LogicalKeyboardKey.arrowRight): () => const SingleActivator(LogicalKeyboardKey.arrowRight): () =>
cubit().seekBy(const Duration(seconds: 5)),
const SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true): () =>
cubit().previousLine(),
const SingleActivator(LogicalKeyboardKey.arrowRight, shift: true): () =>
cubit().nextLine(), cubit().nextLine(),
const SingleActivator(LogicalKeyboardKey.keyR): () => const SingleActivator(LogicalKeyboardKey.keyP): () =>
cubit().repeatLine(), cubit().pauseAtLineEnd(on: !cubit().state.pauseAtLineEnd),
const SingleActivator(LogicalKeyboardKey.escape): () => for (final (n, key) in const [
cubit().unselect(), LogicalKeyboardKey.digit0,
LogicalKeyboardKey.digit1,
LogicalKeyboardKey.digit2,
LogicalKeyboardKey.digit3,
LogicalKeyboardKey.digit4,
LogicalKeyboardKey.digit5,
LogicalKeyboardKey.digit6,
LogicalKeyboardKey.digit7,
LogicalKeyboardKey.digit8,
LogicalKeyboardKey.digit9,
].indexed)
SingleActivator(key): () => cubit().seekToShare(n / 10),
const SingleActivator(LogicalKeyboardKey.home): () =>
cubit().seekTo(Duration.zero),
const SingleActivator(LogicalKeyboardKey.end): () =>
cubit().seekToShare(1),
const SingleActivator(LogicalKeyboardKey.escape): () {
if (_settings.value != null) {
_settings.value = null;
} else {
cubit().unselect();
}
},
const CharacterActivator('?'): () => showWatchShortcuts(context),
}, },
child: Focus( child: Focus(
autofocus: true, autofocus: true,
@ -124,14 +173,15 @@ class const _WatchView({required final Dictionary? dictionary})
), ),
], ],
), ),
actions: const [_TracksMenu()],
), ),
body: LayoutBuilder( body: LayoutBuilder(
builder: (context, constraints) => constraints.maxWidth >= 900 builder: (context, constraints) => constraints.maxWidth >= 900
? Row( ? Row(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const Expanded(child: _Stage(wide: true)), Expanded(
child: _Stage(wide: true, settings: _settings),
),
const VerticalDivider(width: 1), const VerticalDivider(width: 1),
SizedBox( SizedBox(
width: 380, width: 380,
@ -142,7 +192,7 @@ class const _WatchView({required final Dictionary? dictionary})
: Column( : Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const _Stage(wide: false), _Stage(wide: false, settings: _settings),
const Divider(height: 1), const Divider(height: 1),
Expanded(child: _Panel(dictionary: dictionary)), Expanded(child: _Panel(dictionary: dictionary)),
], ],
@ -154,12 +204,20 @@ class const _WatchView({required final Dictionary? dictionary})
} }
} }
/// The video, its controls and its line of subtitles. The line has a box of /// The player and, beneath it, its line of subtitles: the picture set in
/// its own height (decided 2026-10-13): two rows of words with their /// from every side (decided 2026-10-13, as YouTube's), so the glow of its
/// readings and two lines of translation, across the whole width, longer /// colours ([AmbientGlow], in the dark theme) shows all around it. The line
/// ones scaled down to fit, so the video's size never moves with the lines. /// has a box of its own height: two rows of words with their readings and
/// In the dark theme the video's colours glow around it ([AmbientGlow]). /// two lines of translation, across the whole width, longer ones scaled
class const _Stage({required final bool wide}) extends StatefulWidget { /// down to fit, so the video's size never moves with the lines. The line's
/// buttons sit on either side of it: the one before (or the one shown from
/// its start, once under way, as a music player's previous does) and the
/// next. No button for again (decided 2026-10-13): the one before does it,
/// and a repeat sign means playing on in a loop in players.
class const _Stage({
required final bool wide,
required final ValueNotifier<WatchSettingsPage?> settings,
}) extends StatefulWidget {
@override @override
State<_Stage> createState() => _StageState(); State<_Stage> createState() => _StageState();
} }
@ -170,13 +228,14 @@ class _StageState() extends State<_Stage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
WatchCubit cubit() => context.read<WatchCubit>();
final wide = widget.wide; final wide = widget.wide;
final fontSize = wide ? 30.0 : 24.0; final fontSize = wide ? 30.0 : 24.0;
final dark = Theme.of(context).brightness == Brightness.dark; final inset = wide ? 24.0 : 12.0;
final video = ColoredBox( final player = Padding(
// In the dark theme the glow shows beside the picture. padding: EdgeInsets.fromLTRB(inset, inset, inset, 0),
color: dark ? Colors.transparent : Colors.black, child: WatchPlayer(frame: _picture, settings: widget.settings),
child: Center(child: _Picture(frame: _picture)),
); );
return AmbientGlow( return AmbientGlow(
source: _picture, source: _picture,
@ -184,23 +243,38 @@ class _StageState() extends State<_Stage> {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
if (wide) if (wide)
Expanded(child: video) Expanded(child: player)
else else
AspectRatio(aspectRatio: 16 / 9, child: video), AspectRatio(aspectRatio: 16 / 9, child: player),
_Controls(wide: wide),
Padding( Padding(
padding: EdgeInsets.fromLTRB(16, 8, 16, wide ? 24 : 12), padding: EdgeInsets.fromLTRB(4, 8, 4, wide ? 24 : 12),
child: SizedBox( child: Row(
height: _lineBoxHeight(fontSize), children: [
child: LayoutBuilder( IconButton(
builder: (context, box) => FittedBox( tooltip: l10n.watchPreviousLine,
fit: BoxFit.scaleDown, icon: const Icon(LucideIcons.chevronLeft),
onPressed: () => cubit().previousLine(),
),
Expanded(
child: SizedBox( child: SizedBox(
width: box.maxWidth, height: _lineBoxHeight(fontSize),
child: _Line(fontSize: fontSize), child: LayoutBuilder(
builder: (context, box) => FittedBox(
fit: BoxFit.scaleDown,
child: SizedBox(
width: box.maxWidth,
child: _Line(fontSize: fontSize),
),
),
),
), ),
), ),
), IconButton(
tooltip: l10n.watchNextLine,
icon: const Icon(LucideIcons.chevronRight),
onPressed: () => cubit().nextLine(),
),
],
), ),
), ),
], ],
@ -214,139 +288,6 @@ class _StageState() extends State<_Stage> {
double _lineBoxHeight(double fontSize) => double _lineBoxHeight(double fontSize) =>
2 * (fontSize * 1.4 + fontSize * 0.5 * 1.4) + 8 + 2 * 24; 2 * (fontSize * 1.4 + fontSize * 0.5 * 1.4) + 8 + 2 * 24;
class const _Picture({required final GlobalKey frame}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final state = context.watch<WatchCubit>().state;
final playback = context.select<WatchCubit, MediaPlayback?>(
(cubit) => cubit.playback,
);
final message = state.playerMissing
? Platform.isLinux
? l10n.jellyfinNoPlayerLinux
: l10n.jellyfinNoPlayer
: state.playerFailed
? l10n.watchFailed
: null;
if (message != null) {
return Padding(
padding: const EdgeInsets.all(24),
child: Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
);
}
if (playback == null) {
return state.status == WatchStatus.failed
? const SizedBox()
: const AppLoadingIndicator(color: Colors.white70);
}
return GestureDetector(
onTap: () => context.read<WatchCubit>().playOrPause(),
child: RepaintBoundary(key: frame, child: playback.view()),
);
}
}
/// Play and pause, the lines before, again and after, and where the video
/// is.
class const _Controls({required final bool wide}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
WatchCubit cubit() => context.read<WatchCubit>();
final playback = context.select<WatchCubit, MediaPlayback?>(
(cubit) => cubit.playback,
);
if (playback == null) {
return SizedBox(height: wide ? 48 : 96);
}
return ValueListenableBuilder<MediaState>(
valueListenable: playback.state,
builder: (context, media, _) {
final total = media.duration.inMilliseconds;
final buttons = [
IconButton(
tooltip: l10n.watchPreviousLine,
icon: const Icon(LucideIcons.skipBack),
onPressed: () => cubit().previousLine(),
),
IconButton.filledTonal(
tooltip: media.playing ? l10n.watchPause : l10n.watchPlay,
icon: Icon(
media.playing ? LucideIcons.pause : LucideIcons.play,
),
onPressed: () => cubit().playOrPause(),
),
IconButton(
tooltip: l10n.watchRepeatLine,
icon: const Icon(LucideIcons.rotateCcw),
onPressed: () => cubit().repeatLine(),
),
IconButton(
tooltip: l10n.watchNextLine,
icon: const Icon(LucideIcons.skipForward),
onPressed: () => cubit().nextLine(),
),
const SizedBox(width: 8),
const _PauseAtLineEnd(),
];
final where = [
Expanded(
child: Slider(
value: total <= 0
? 0
: media.position.inMilliseconds.clamp(0, total) / total,
onChanged: total <= 0
? null
: (value) => cubit().seekTo(
Duration(milliseconds: (value * total).round()),
),
),
),
Text(
'${_time(media.position)} / ${_time(media.duration)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontFeatures: const [FontFeature.tabularFigures()],
),
),
const SizedBox(width: 8),
];
// On a phone where it is gets a row of its own, above the buttons.
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: wide
? Row(children: [...buttons, ...where])
: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(children: where),
// The chip goes to a line of its own where the buttons
// leave it no room.
Wrap(
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
children: buttons,
),
],
),
);
},
);
}
}
String _time(Duration time) {
final seconds = (time.inSeconds % 60).toString().padLeft(2, '0');
final minutes = time.inMinutes % 60;
return time.inHours > 0
? '${time.inHours}:${minutes.toString().padLeft(2, '0')}:$seconds'
: '$minutes:$seconds';
}
/// The line shown, its words tappable with their readings above, those the /// The line shown, its words tappable with their readings above, those the
/// studyset holds underlined; the translation beneath. /// studyset holds underlined; the translation beneath.
class const _Line({required final double fontSize}) extends StatelessWidget { class const _Line({required final double fontSize}) extends StatelessWidget {
@ -824,102 +765,3 @@ class const _VideoWords({required final Dictionary dictionary})
); );
} }
} }
/// Pausing after each line, on or off: a chip with its name, ticked while
/// on, as the words' New only (decided 2026-10-13: the icon button's
/// colour alone hardly showed it).
class const _PauseAtLineEnd() extends StatelessWidget {
@override
Widget build(BuildContext context) {
final on = context.select<WatchCubit, bool>(
(cubit) => cubit.state.pauseAtLineEnd,
);
return FilterChip(
label: Text(AppLocalizations.of(context).watchPauseAtLineEnd),
selected: on,
onSelected: (on) => context.read<WatchCubit>().pauseAtLineEnd(on: on),
);
}
}
/// The subtitle tracks to read and to translate with, and the sound.
class const _TracksMenu() extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
WatchCubit cubit() => context.read<WatchCubit>();
final state = context.watch<WatchCubit>().state;
if (state.tracks.isEmpty && state.audioTracks.length < 2) {
return const SizedBox();
}
Widget choice(
String label,
bool chosen,
Future<void> Function() choose,
) => MenuItemButton(
leadingIcon: Icon(
LucideIcons.check,
color: chosen ? null : Colors.transparent,
),
onPressed: choose,
child: Text(label),
);
return MenuAnchor(
menuChildren: [
SubmenuButton(
leadingIcon: const Icon(LucideIcons.captions),
menuChildren: [
for (final track in state.tracks)
choice(
track.title,
track == state.studyTrack,
() => cubit().chooseStudyTrack(track),
),
choice(
l10n.watchNone,
state.studyTrack == null,
() => cubit().chooseStudyTrack(null),
),
],
child: Text(l10n.watchSubtitles),
),
SubmenuButton(
leadingIcon: const Icon(LucideIcons.languages),
menuChildren: [
for (final track in state.tracks)
choice(
track.title,
track == state.translationTrack,
() => cubit().chooseTranslationTrack(track),
),
choice(
l10n.watchNone,
state.translationTrack == null,
() => cubit().chooseTranslationTrack(null),
),
],
child: Text(l10n.watchTranslation),
),
if (state.audioTracks.length > 1)
SubmenuButton(
leadingIcon: const Icon(LucideIcons.audioLines),
menuChildren: [
for (final track in state.audioTracks)
choice(
track.title,
track == state.audioTrack,
() => cubit().chooseAudioTrack(track),
),
],
child: Text(l10n.watchAudio),
),
],
builder: (context, controller, _) => IconButton(
tooltip: l10n.watchTracks,
icon: const Icon(LucideIcons.captions),
onPressed: () =>
controller.isOpen ? controller.close() : controller.open(),
),
);
}
}

View file

@ -1,3 +1,4 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart'; import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
@ -187,8 +188,9 @@ void main() {
expect(find.text('A form of 待つ'), findsOneWidget); expect(find.text('A form of 待つ'), findsOneWidget);
}); });
testWidgets('the next line, the one before and the same again play from ' testWidgets('the next line and the one before play from their start, the '
'their start; a line can end with a pause', (tester) async { 'one shown from its start once under way; a line can end with a '
'pause', (tester) async {
await watch(tester); await watch(tester);
await playTo(tester, const Duration(seconds: 7)); await playTo(tester, const Duration(seconds: 7));
@ -197,7 +199,9 @@ void main() {
expect(players.last!.state.value.position, const Duration(seconds: 11)); expect(players.last!.state.value.position, const Duration(seconds: 11));
expect(players.last!.state.value.playing, isTrue); expect(players.last!.state.value.playing, isTrue);
await tester.tap(find.byTooltip('Repeat line')); // Under way: the one shown again, from its start.
await playTo(tester, const Duration(seconds: 14));
await tester.tap(find.byTooltip('Previous line'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(players.last!.state.value.position, const Duration(seconds: 11)); expect(players.last!.state.value.position, const Duration(seconds: 11));
@ -205,6 +209,8 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(players.last!.state.value.position, const Duration(seconds: 6)); expect(players.last!.state.value.position, const Duration(seconds: 6));
await tester.tap(find.byTooltip('Settings'));
await tester.pumpAndSettle();
await tester.tap(find.text('Pause after each line')); await tester.tap(find.text('Pause after each line'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await playTo(tester, const Duration(milliseconds: 9900)); await playTo(tester, const Duration(milliseconds: 9900));
@ -277,8 +283,10 @@ void main() {
); );
expect(players.last!.selectedAudio?.id, 'c'); expect(players.last!.selectedAudio?.id, 'c');
await tester.tap(find.byTooltip('Audio and subtitles')); await tester.tap(find.byTooltip('Settings'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Tracks are named by their language, plainly.
expect(find.text('Japanese'), findsWidgets);
await tester.tap(find.text('Audio')); await tester.tap(find.text('Audio'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('German')); await tester.tap(find.text('German'));
@ -364,6 +372,83 @@ void main() {
expect(playing.state.value.position, const Duration(minutes: 2)); expect(playing.state.value.position, const Duration(minutes: 2));
}); });
testWidgets('the player has play or pause with 10 seconds back and on '
'beside it, and where it is from 0:00 to its length', (tester) async {
await watch(tester);
await playTo(tester, const Duration(minutes: 1));
expect(find.text('1:00'), findsOneWidget);
expect(find.text('24:00'), findsOneWidget);
await tester.tap(find.byTooltip('10 seconds on'));
await tester.pumpAndSettle();
expect(players.last!.state.value.position, const Duration(seconds: 70));
await tester.tap(find.byTooltip('10 seconds back'));
await tester.tap(find.byTooltip('10 seconds back'));
await tester.pumpAndSettle();
expect(players.last!.state.value.position, const Duration(seconds: 50));
});
testWidgets("YouTube's keys work, the lines' too, and ? lists them", (
tester,
) async {
await watch(tester);
await playTo(tester, const Duration(minutes: 1));
Duration at() => players.last!.state.value.position;
await tester.sendKeyEvent(LogicalKeyboardKey.keyL);
expect(at(), const Duration(seconds: 70));
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
expect(at(), const Duration(seconds: 55));
await tester.sendKeyEvent(LogicalKeyboardKey.digit5);
expect(at(), const Duration(minutes: 12));
await tester.sendKeyEvent(LogicalKeyboardKey.home);
expect(at(), Duration.zero);
await playTo(tester, const Duration(seconds: 7));
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
await tester.pumpAndSettle();
expect(at(), const Duration(seconds: 11));
await tester.sendKeyEvent(LogicalKeyboardKey.keyP);
await tester.pumpAndSettle();
await playTo(tester, const Duration(milliseconds: 14900));
expect(players.last!.state.value.playing, isFalse);
await tester.sendKeyDownEvent(LogicalKeyboardKey.slash, character: '?');
await tester.sendKeyUpEvent(LogicalKeyboardKey.slash);
await tester.pumpAndSettle();
expect(find.text('Keyboard shortcuts'), findsOneWidget);
expect(find.text('Previous or next line'), findsOneWidget);
});
testWidgets('the controls fade out while it plays and the pointer rests, '
'and come back while paused', (tester) async {
await watch(tester);
double shown() => tester
.widget<AnimatedOpacity>(
find
.ancestor(
of: find.byTooltip('10 seconds on'),
matching: find.byType(AnimatedOpacity),
)
.first,
)
.opacity;
expect(shown(), 1);
await tester.tap(find.byTooltip('Play'));
await tester.pump();
await tester.pump(const Duration(seconds: 4));
expect(shown(), 0);
await tester.sendKeyEvent(LogicalKeyboardKey.space);
await tester.pump();
expect(shown(), 1);
});
testWidgets('on a phone the words are below the video', (tester) async { testWidgets('on a phone the words are below the video', (tester) async {
await watch(tester, size: const Size(412, 915)); await watch(tester, size: const Size(412, 915));
await playTo(tester, const Duration(seconds: 2)); await playTo(tester, const Duration(seconds: 2));