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:
parent
96cbd912de
commit
48a9564231
10 changed files with 1032 additions and 299 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -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(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue