Compare commits
13
Commits
v1.2.0
..
a1b270fd4e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1b270fd4e | ||
|
|
7e98ebd1db | ||
|
|
0b0c67acb7 | ||
|
|
622df58295 | ||
|
|
df6f6ef893 | ||
|
|
ea607c1a52 | ||
|
|
e537f9d5f8 | ||
|
|
cf59251449 | ||
|
|
ef562342e2 | ||
|
|
3d81342725 | ||
|
|
b949951ef9 | ||
|
|
fd8064611a | ||
|
|
959eed38a4 |
No files matched your search
@@ -43,6 +43,9 @@ jobs:
|
|||||||
# The updater's Windows apply path (running exe parked as .old) only
|
# The updater's Windows apply path (running exe parked as .old) only
|
||||||
# means something on real NTFS. Runner notes: see release.yml.
|
# means something on real NTFS. Runner notes: see release.yml.
|
||||||
updater-windows:
|
updater-windows:
|
||||||
|
# Not for a "Release v…" commit: release.yml builds it on the same
|
||||||
|
# (only) Windows runner, which this job would hold up.
|
||||||
|
if: ${{ !startsWith(github.event.head_commit.message, 'Release v') }}
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
|
|||||||
@@ -32,10 +32,12 @@ From the Releases page of this repo:
|
|||||||
`watch_together.exe`. mpv is bundled. New and not yet tried on a real
|
`watch_together.exe`. mpv is bundled. New and not yet tried on a real
|
||||||
Windows desktop; reports welcome.
|
Windows desktop; reports welcome.
|
||||||
|
|
||||||
Update from the app: when a newer version exists, an update indicator
|
Update from the app: the app looks for a new version at start and every
|
||||||
appears in the strip at the top of the window and opens the update card. "Update and restart" downloads,
|
few hours, and when one exists a band across the top of the window says so
|
||||||
verifies, applies in place and restarts; the install folder must be writable
|
(never over a playing video). "Update and restart" downloads,
|
||||||
by you. Settings has Check now and an auto-check toggle. From a terminal:
|
verifies, applies in place and restarts; "Later" hides it until the next
|
||||||
|
check; the install folder must be writable by you. The version you run is
|
||||||
|
under the Settings tab. Settings has Check now and an auto-check toggle. From a terminal:
|
||||||
`./watch-together-server --upgrade` (no display needed, so it works on a
|
`./watch-together-server --upgrade` (no display needed, so it works on a
|
||||||
server box too), `watch_together --upgrade`, or the shipped `update.sh` /
|
server box too), `watch_together --upgrade`, or the shipped `update.sh` /
|
||||||
`update.bat` (which run the first). Each updates the whole folder, app and
|
`update.bat` (which run the first). Each updates the whole folder, app and
|
||||||
@@ -93,8 +95,8 @@ right-click for Copy message and, with a link in it, Copy link / Open in
|
|||||||
browser), and its "Activity" toggle adds
|
browser), and its "Activity" toggle adds
|
||||||
joins, ready, seeks and the like between them. Joins, leaves, chat and
|
joins, ready, seeks and the like between them. Joins, leaves, chat and
|
||||||
other news also pop up bottom left with the person's avatar; click a message to open the chat
|
other news also pop up bottom left with the person's avatar; click a message to open the chat
|
||||||
(Settings → Notifications → Chat messages: Only when paused by default,
|
(Settings → Notifications → Chat messages: All by default, or Only when
|
||||||
or All, or Off; messages always reach the chat). Unread chat shows as a count on the chat
|
paused, or Off; messages always reach the chat, with their sound). Unread chat shows as a count on the chat
|
||||||
buttons and, in fullscreen with the controls hidden and the video paused,
|
buttons and, in fullscreen with the controls hidden and the video paused,
|
||||||
as a small chip top right.
|
as a small chip top right.
|
||||||
|
|
||||||
@@ -182,7 +184,7 @@ everyone has its start before it goes up; whoever added it keeps the
|
|||||||
app open for that. Everyone shares one Up next and anyone can change it; what
|
app open for that. Everyone shares one Up next and anyone can change it; what
|
||||||
you added stays when you leave.
|
you added stays when you leave.
|
||||||
|
|
||||||
In the player, Up next sits in the top bar next to the chat button: the
|
In the player, Up next sits in the top bar, right of the title: the
|
||||||
next item's picture and title (or the next episode's). Rest the mouse on
|
next item's picture and title (or the next episode's). Rest the mouse on
|
||||||
it for a larger preview (who added it, its length, whether it is ready
|
it for a larger preview (who added it, its length, whether it is ready
|
||||||
to start); ⏭ plays it now, a click opens the queue: drag rows to reorder, hover a
|
to start); ⏭ plays it now, a click opens the queue: drag rows to reorder, hover a
|
||||||
@@ -282,16 +284,22 @@ integrations (linked Jellyfin servers, below, the AnimeFlow connection:
|
|||||||
"connected as <name>" with what you may do, or why not, and playing the
|
"connected as <name>" with what you may do, or why not, and playing the
|
||||||
next episode automatically); Notifications & sounds (what pops up; the sound
|
next episode automatically); Notifications & sounds (what pops up; the sound
|
||||||
volume and sound set, and every sound with a preview, its own set and a switch); Updates; Keyboard shortcuts (read-only); About
|
volume and sound set, and every sound with a preview, its own set and a switch); Updates; Keyboard shortcuts (read-only); About
|
||||||
(the version; resting the mouse on the Settings tab shows it too).
|
(the version; resting the mouse on the Settings tab shows it too; and the
|
||||||
|
credits, e.g. that the Minimal player design is inspired by uosc).
|
||||||
|
|
||||||
Player settings live in the player: ⚙ at the right end of its controls
|
Player settings live in the player too: ⚙ → Player settings, at the
|
||||||
(both designs) opens them, and Settings → Player has the same ones (plus
|
right of its controls, and Settings → Player has the same ones (plus the
|
||||||
the custom shader files). Player design: Standard or uosc (below);
|
controls' layout and the custom shader files). Player design: Standard or
|
||||||
progress line when hidden (on by default: a 2 px line at the bottom of
|
Minimal (below); progress line when hidden (on by default: a thin line at
|
||||||
the video while the controls are hidden); upscaling shader (see below);
|
the bottom of the video while the controls are hidden); upscaling shader
|
||||||
hardware decoding (auto, off or custom; the env var `SYNC_RELAY_HWDEC`
|
(see below); hardware decoding (auto, off or custom; auto is mpv's
|
||||||
overrides it); in the player also the subtitle and audio track. Every
|
`auto-copy` on Windows, `auto` elsewhere; the env var
|
||||||
change applies right away.
|
`SYNC_RELAY_HWDEC` overrides it); in the player also the subtitle and
|
||||||
|
audio track. Settings → Player → Controls: time display (elapsed / total
|
||||||
|
or remaining), controls size (normal or large), hide the controls after
|
||||||
|
2, 3 or 5 s, and which optional buttons sit in the bar (chapter buttons,
|
||||||
|
subtitles, audio, chat; hidden ones are still in ⚙). Every change
|
||||||
|
applies right away.
|
||||||
|
|
||||||
Anywhere in a server (unless you are typing): Space = ready / unready,
|
Anywhere in a server (unless you are typing): Space = ready / unready,
|
||||||
Tab = chat & activity (Tab never moves between buttons). Anywhere:
|
Tab = chat & activity (Tab never moves between buttons). Anywhere:
|
||||||
@@ -304,28 +312,37 @@ mini it asks to leave, Ctrl+R = reload video,
|
|||||||
Ctrl+1..6 = Anime4K mode A, B, C, A+A, B+B, C+A, Ctrl+0 = shaders off.
|
Ctrl+1..6 = Anime4K mode A, B, C, A+A, B+B, C+A, Ctrl+0 = shaders off.
|
||||||
Every seek, skip and chapter jump moves everyone.
|
Every seek, skip and chapter jump moves everyone.
|
||||||
|
|
||||||
Controls: the seek bar marks chapter starts with a diamond; hovering it
|
Controls: both designs have the same buttons in the same places, only
|
||||||
shows the time and the chapter title. The chapters button lists them
|
the look differs. Bottom left: play / pause first (it is your ready, like
|
||||||
(current one checked; click to jump). "1:30" skips ahead 1:30. Audio and
|
Space: ✓ while you are ready and the others are not), previous / next
|
||||||
Subtitles buttons (with the track count) list tracks as "Japanese · E-AC3
|
episode (when there is one; next plays Up next first), previous / next
|
||||||
2.0 · title"; subtitles have Off. Volume (scroll anywhere on the video,
|
chapter (when the video has chapters), volume and the time (click it to
|
||||||
hover the speaker, or Up/Down) goes to 150 % like mpv: past the tick at
|
switch between elapsed / total and the time left). Bottom right:
|
||||||
100 % the bar turns amber and mpv amplifies; a scroll or key shows the
|
Subtitles and Audio (with the track count; tracks listed as "Japanese ·
|
||||||
volume for a moment. Lists and menus under the mouse scroll as usual.
|
E-AC3 2.0 · title", subtitles with Off), ⚙, chat and fullscreen last.
|
||||||
|
Everything else is in ⚙: chapters (current one checked; click to jump),
|
||||||
|
Skip 1:30, upscaling shader, change video, copy link, reload video,
|
||||||
|
mark everyone ready, mark a problem, player settings and leave. The top
|
||||||
|
bar has the title, Up next and back to the library (in fullscreen: exit
|
||||||
|
fullscreen and the window buttons). Volume (scroll anywhere on the
|
||||||
|
video, hover the speaker, or Up/Down) goes to 150 % like mpv: past the
|
||||||
|
tick at 100 % the bar turns amber and mpv amplifies; a scroll or key
|
||||||
|
shows the volume for a moment. Lists and menus under the mouse scroll
|
||||||
|
as usual.
|
||||||
|
|
||||||
The uosc player design (⚙ → Player design, Standard by default,
|
Standard (the default): a seek bar that marks chapter starts with a
|
||||||
switches right away): video controls modeled on mpv's uosc theme. A
|
diamond (hovering it shows the time and the chapter title) over a row
|
||||||
translucent timeline across the bottom marks the position with a thin
|
of round buttons. Minimal (⚙ → Player settings → Player design, switches
|
||||||
line, hatches the part not downloaded yet, shows chapter starts as
|
right away): a translucent timeline across the bottom tints what has
|
||||||
diamonds (click one to jump), the elapsed time left, the time remaining
|
played, stripes the part not downloaded yet, marks chapter starts with
|
||||||
right and, while less than a minute is buffered, the buffered seconds
|
dots (click one to jump), shows the elapsed time left and the total or
|
||||||
next to the position; hovering it shows the time there and the chapter
|
the time left right (click it to switch) and, while less than a minute
|
||||||
title. With the controls hidden it stays as a thin line (unless
|
is buffered, the buffered seconds next to the position; hovering it shows
|
||||||
"Progress line when hidden" is off). Above it flat buttons: ≡ menu, Subtitles and Audio (with the
|
the time there and the chapter title. With the controls hidden it stays
|
||||||
track count), the shader badge (the active Anime4K mode, e.g. "A HQ"),
|
as a thin line (unless "Progress line when hidden" is off). Its buttons
|
||||||
ready, chapters, 1:30, previous / next episode, change video, volume, ⚙
|
sit in two glass capsules above it. Minimal's timeline and controls are
|
||||||
and fullscreen; the title top left, Up next, chat and back-to-library top right. Keys and skip buttons work
|
inspired by [uosc](https://github.com/tomasklaen/uosc) by tomasklaen
|
||||||
the same.
|
(LGPL-2.1).
|
||||||
|
|
||||||
Skipping: during the opening a "Skip OP" button shows bottom right, just
|
Skipping: during the opening a "Skip OP" button shows bottom right, just
|
||||||
above the controls (it
|
above the controls (it
|
||||||
@@ -395,7 +412,7 @@ Watching:
|
|||||||
"No Jellyfin account here for ..." with a button to link one; it plays as
|
"No Jellyfin account here for ..." with a button to link one; it plays as
|
||||||
soon as you are signed in.
|
soon as you are signed in.
|
||||||
|
|
||||||
Episodes: the ⏮ / ⏭ buttons next to the controls (or `<` / `>`,
|
Episodes: the ⏮ / ⏭ buttons next to play (or `<` / `>`,
|
||||||
Shift+P / Shift+N) put up the previous or next episode for everyone. From
|
Shift+P / Shift+N) put up the previous or next episode for everyone. From
|
||||||
the ending (when it is known, see Skipping above; else in the last two
|
the ending (when it is known, see Skipping above; else in the last two
|
||||||
minutes) a "Next episode" button floats in the corner while the controls
|
minutes) a "Next episode" button floats in the corner while the controls
|
||||||
@@ -411,7 +428,7 @@ Your choice is remembered per series (and as a default) and the closest
|
|||||||
match (language, then title, then codec and channels) is selected on the
|
match (language, then title, then codec and channels) is selected on the
|
||||||
next episode. This is per person; nobody else is affected.
|
next episode. This is per person; nobody else is affected.
|
||||||
|
|
||||||
"Change video" in the player's controls opens the Library tab (the player
|
⚙ → Change video in the player opens the Library tab (the player
|
||||||
goes mini): the library starts on the series you are watching with the
|
goes mini): the library starts on the series you are watching with the
|
||||||
current season, else where you left it.
|
current season, else where you left it.
|
||||||
|
|
||||||
@@ -467,9 +484,9 @@ affected. The app ships [Anime4K](https://github.com/bloc97/Anime4K) v4.0.1
|
|||||||
C (480p), A+A, B+B, C+A, each as HQ (strong GPUs, GTX 1080 and up) or Fast
|
C (480p), A+A, B+B, C+A, each as HQ (strong GPUs, GTX 1080 and up) or Fast
|
||||||
(the guide's low-end set). "Custom shaders" runs your own `.glsl` files.
|
(the guide's low-end set). "Custom shaders" runs your own `.glsl` files.
|
||||||
|
|
||||||
Pick one with the ✨ button in the player's control bar (also in
|
Pick one with ⚙ → Upscaling shader in the player (also in
|
||||||
fullscreen) or Ctrl+0..6; the change applies live and is saved, so the next
|
fullscreen) or Ctrl+0..6; the change applies live and is saved, so the next
|
||||||
session starts with it. The player's ⚙ and Settings → Player set it too,
|
session starts with it. ⚙ → Player settings and Settings → Player set it too,
|
||||||
with HQ or Fast (used by Ctrl+1..6); Settings → Player holds the custom
|
with HQ or Fast (used by Ctrl+1..6); Settings → Player holds the custom
|
||||||
files. Shaders cost GPU time
|
files. Shaders cost GPU time
|
||||||
every frame and need hardware video rendering: on media_kit's software
|
every frame and need hardware video rendering: on media_kit's software
|
||||||
|
|||||||
+20
-44
@@ -5,17 +5,16 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:hive_ce_flutter/hive_flutter.dart';
|
import 'package:hive_ce_flutter/hive_flutter.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
import 'package:media_kit/media_kit.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
|
||||||
|
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
import 'src/playback/yt_dlp_app.dart';
|
|
||||||
import 'src/platform/app_log.dart';
|
import 'src/platform/app_log.dart';
|
||||||
|
import 'src/platform/app_shutdown.dart';
|
||||||
import 'src/platform/desktop_entry.dart';
|
import 'src/platform/desktop_entry.dart';
|
||||||
import 'src/platform/window_memory.dart';
|
|
||||||
import 'src/screens/home/ui.dart';
|
import 'src/screens/home/ui.dart';
|
||||||
|
import 'src/session/app_events.dart' show UpdateAvailable;
|
||||||
import 'src/state/session_state.dart';
|
import 'src/state/session_state.dart';
|
||||||
import 'src/state/settings.dart';
|
import 'src/state/settings.dart';
|
||||||
import 'src/update/update_controller.dart' show launchArgs;
|
import 'src/update/update_controller.dart' show UpdateController, UpdatePhase, launchArgs;
|
||||||
|
|
||||||
Future<void> main(List<String> args) async {
|
Future<void> main(List<String> args) async {
|
||||||
launchArgs = List.unmodifiable(args);
|
launchArgs = List.unmodifiable(args);
|
||||||
@@ -45,23 +44,11 @@ Future<void> main(List<String> args) async {
|
|||||||
await startAppLogging();
|
await startAppLogging();
|
||||||
await Hive.initFlutter();
|
await Hive.initFlutter();
|
||||||
await AppSettings.instance.ensureLoaded();
|
await AppSettings.instance.ensureLoaded();
|
||||||
await windowManager.ensureInitialized();
|
// Close button / Alt+F4: the orderly close ([shutDownApp]), so others
|
||||||
await windowManager.setPreventClose(true);
|
// see "left", a hosted server shuts down and no player outlives the
|
||||||
final memory = WindowMemory(Hive.isBoxOpen(settingsBoxName) ? Hive.box(settingsBoxName) : null);
|
// engine.
|
||||||
windowManager.addListener(_CloseGuard(memory));
|
final ports = SessionState.instance.ports;
|
||||||
final windowOptions = WindowOptions(
|
await ports.shell.start(title: appName, onCloseRequest: shutDownApp);
|
||||||
size: memory.size,
|
|
||||||
minimumSize: const Size(160, 90),
|
|
||||||
center: !memory.hasPosition,
|
|
||||||
backgroundColor: const Color(0xFF000000),
|
|
||||||
titleBarStyle: TitleBarStyle.hidden,
|
|
||||||
title: appName,
|
|
||||||
);
|
|
||||||
windowManager.waitUntilReadyToShow(windowOptions, () async {
|
|
||||||
await memory.restore();
|
|
||||||
await windowManager.show();
|
|
||||||
await windowManager.focus();
|
|
||||||
});
|
|
||||||
try {
|
try {
|
||||||
await SessionState.instance.loadForm();
|
await SessionState.instance.loadForm();
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
@@ -69,30 +56,19 @@ Future<void> main(List<String> args) async {
|
|||||||
}
|
}
|
||||||
MediaKit.ensureInitialized();
|
MediaKit.ensureInitialized();
|
||||||
// yt-dlp for web page links: downloaded on first run, kept current.
|
// yt-dlp for web page links: downloaded on first run, kept current.
|
||||||
AppYtDlp.instance.warmUp();
|
ports.links.warmUp();
|
||||||
// Linux: the launcher entry and icon Wayland needs (release installs).
|
// Linux: the launcher entry and icon Wayland needs (release installs).
|
||||||
unawaited(installDesktopEntry());
|
unawaited(installDesktopEntry());
|
||||||
runApp(const WatchTogetherApp());
|
// A found update is an event too (the band shows it).
|
||||||
}
|
final updates = UpdateController.instance;
|
||||||
|
updates.status.addListener(() {
|
||||||
/// Close button / Alt+F4: leave gracefully first (others see "left", a
|
final s = updates.status.value;
|
||||||
/// hosted server shuts down), bounded so a hung socket can't keep the
|
final version = s.manifest?.version;
|
||||||
/// window open.
|
if (s.phase == UpdatePhase.available && version != null) {
|
||||||
class _CloseGuard with WindowListener {
|
SessionState.instance.notifier.notify(UpdateAvailable(version));
|
||||||
final WindowMemory memory;
|
|
||||||
bool _closing = false;
|
|
||||||
_CloseGuard(this.memory);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> onWindowClose() async {
|
|
||||||
if (_closing) return;
|
|
||||||
_closing = true;
|
|
||||||
await memory.save();
|
|
||||||
try {
|
|
||||||
await SessionState.instance.leave().timeout(const Duration(seconds: 2));
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('leave on close failed: $e');
|
|
||||||
}
|
}
|
||||||
await windowManager.destroy();
|
});
|
||||||
}
|
runApp(const WatchTogetherApp());
|
||||||
|
// The sound device opens once the window is up, not on the first sound.
|
||||||
|
Future.delayed(const Duration(seconds: 2), SessionState.instance.sfx.warmUp);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
|
||||||
|
|
||||||
|
import '../jellyfin/track_prefs.dart' show MpvTrack;
|
||||||
|
import '../playback/media_marks.dart';
|
||||||
|
import '../shaders/shader_menu.dart';
|
||||||
|
import '../shell/mark_problem.dart';
|
||||||
|
import '../state/settings.dart';
|
||||||
|
import '../widgets/app_menu.dart';
|
||||||
|
import '../widgets/copy_open.dart';
|
||||||
|
import 'frame.dart';
|
||||||
|
import 'model/controls_layout.dart';
|
||||||
|
import 'model/controls_model.dart';
|
||||||
|
import 'player_settings.dart';
|
||||||
|
|
||||||
|
// The controls' menus, shared by both skins: the track and chapter lists
|
||||||
|
// and the ⚙ menu, all on the app's menu (`showAppMenu`), anchored on the
|
||||||
|
// button that opens them. Each keeps the controls up while open.
|
||||||
|
|
||||||
|
/// How many tracks of [type] there are.
|
||||||
|
int trackCount(List<MpvTrack> tracks, JellyfinStreamType type) => tracks.where((t) => t.type == type).length;
|
||||||
|
|
||||||
|
/// The audio or subtitle tracks ("Japanese · E-AC3 2.0 · title"), mpv's
|
||||||
|
/// selection checked; subtitles start with Off.
|
||||||
|
List<AppMenuItem<TrackPick>> trackMenuItems(JellyfinStreamType type, List<MpvTrack> tracks) {
|
||||||
|
final audio = type == JellyfinStreamType.audio;
|
||||||
|
final list = tracks.where((t) => t.type == type).toList();
|
||||||
|
return [
|
||||||
|
if (!audio)
|
||||||
|
AppMenuItem(
|
||||||
|
key: const ValueKey('track-off'),
|
||||||
|
value: (aid: null, sid: null, subtitlesOff: true),
|
||||||
|
label: 'Off',
|
||||||
|
checked: !list.any((t) => t.selected),
|
||||||
|
),
|
||||||
|
if (audio && list.isEmpty)
|
||||||
|
const AppMenuItem(value: (aid: null, sid: null, subtitlesOff: false), label: 'None', enabled: false),
|
||||||
|
for (final t in list)
|
||||||
|
AppMenuItem(
|
||||||
|
key: ValueKey('track-${t.type.name}-${t.id}'),
|
||||||
|
value: audio ? (aid: t.id, sid: null, subtitlesOff: false) : (aid: null, sid: t.id, subtitlesOff: false),
|
||||||
|
label: t.label,
|
||||||
|
checked: t.selected,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chapters with their start times, the playing one checked.
|
||||||
|
List<AppMenuItem<MediaChapter>> chapterMenuItems(List<MediaChapter> chapters, int? current) => [
|
||||||
|
for (final c in chapters)
|
||||||
|
AppMenuItem(
|
||||||
|
key: ValueKey('chapter-${c.index}'),
|
||||||
|
value: c,
|
||||||
|
label: c.title.isEmpty ? 'Chapter ${c.index + 1}' : c.title,
|
||||||
|
hint: formatClock(c.start),
|
||||||
|
checked: c.index == current,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
Future<T?> _menu<T>(BuildContext context, PlayerControlsModel model, List<AppMenuItem<T>> items) async {
|
||||||
|
model.menusOpen++;
|
||||||
|
try {
|
||||||
|
return await showAppMenu<T>(context, items: items);
|
||||||
|
} finally {
|
||||||
|
model.menusOpen--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The audio or subtitle list; a pick is applied (and remembered).
|
||||||
|
Future<void> openTrackMenu(BuildContext context, PlayerControlsModel model, JellyfinStreamType type) async {
|
||||||
|
final pick = await _menu(context, model, trackMenuItems(type, model.tracks.peek()));
|
||||||
|
if (pick != null) model.pickTrack(pick);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The chapter list; a pick jumps there for everyone.
|
||||||
|
Future<void> openChapterMenu(BuildContext context, PlayerControlsModel model) async {
|
||||||
|
final pick = await _menu(context, model, chapterMenuItems(model.chapters.peek(), model.currentChapter.peek()));
|
||||||
|
if (pick != null) model.pickChapter(pick);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ⚙ menu: every action that is not a bar button.
|
||||||
|
Future<void> openControlsMenu(BuildContext context, PlayerControlsModel model) async {
|
||||||
|
final s = AppSettings.instance;
|
||||||
|
final shader = shaderShortLabel(s.shaderPreset.peek(), s.shaderQuality.peek()) ?? 'Off';
|
||||||
|
final pick = await _menu(context, model, [
|
||||||
|
for (final a in model.menu())
|
||||||
|
AppMenuItem(
|
||||||
|
key: ValueKey('controls-menu-${a.name}'),
|
||||||
|
value: a,
|
||||||
|
label: a.label,
|
||||||
|
hint: a == ControlAction.shaders ? shader : a.hint,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
if (pick != null && context.mounted) await runControlAction(context, model, pick);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs a ⚙ menu action; [context] anchors what it opens next.
|
||||||
|
Future<void> runControlAction(BuildContext context, PlayerControlsModel model, ControlAction action) async {
|
||||||
|
switch (action) {
|
||||||
|
case ControlAction.chapters:
|
||||||
|
await openChapterMenu(context, model);
|
||||||
|
case ControlAction.subtitles:
|
||||||
|
await openTrackMenu(context, model, JellyfinStreamType.subtitle);
|
||||||
|
case ControlAction.audio:
|
||||||
|
await openTrackMenu(context, model, JellyfinStreamType.audio);
|
||||||
|
case ControlAction.skipForward:
|
||||||
|
model.skipForward();
|
||||||
|
case ControlAction.shaders:
|
||||||
|
model.menusOpen++;
|
||||||
|
try {
|
||||||
|
await ControlsFrame.holdWhile(
|
||||||
|
context,
|
||||||
|
() => showShaderMenu(context, renderMode: model.renderMode, onPick: model.pickShader),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
model.menusOpen--;
|
||||||
|
}
|
||||||
|
case ControlAction.chat:
|
||||||
|
model.toggleChat();
|
||||||
|
case ControlAction.changeVideo:
|
||||||
|
model.changeVideo();
|
||||||
|
case ControlAction.copyLink:
|
||||||
|
final link = model.link();
|
||||||
|
if (link != null) await copyToClipboard(link, done: 'Link copied');
|
||||||
|
case ControlAction.reload:
|
||||||
|
model.reload();
|
||||||
|
case ControlAction.markAllReady:
|
||||||
|
model.markAllReady();
|
||||||
|
case ControlAction.markProblem:
|
||||||
|
await ControlsFrame.holdWhile(context, () => showMarkProblem(context));
|
||||||
|
case ControlAction.playerSettings:
|
||||||
|
await showPlayerSettings(context, renderMode: model.renderMode);
|
||||||
|
case ControlAction.leave:
|
||||||
|
model.leave();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,7 @@ import 'package:flutter/material.dart';
|
|||||||
/// mouse on a floating element over the picture (a pill, a card, the
|
/// mouse on a floating element over the picture (a pill, a card, the
|
||||||
/// status pill: [ControlsHoldRegion]) or an open popover. While anything
|
/// status pill: [ControlsHoldRegion]) or an open popover. While anything
|
||||||
/// holds, the controls stay up; on release they hide on their usual
|
/// holds, the controls stay up; on release they hide on their usual
|
||||||
/// countdown. Both designs follow it ([ControlsHoverProbe] with
|
/// countdown. Both skins follow it (through `ControlsFrame`).
|
||||||
/// media_kit's controls, `UoscControls`).
|
|
||||||
class ControlsHold extends ChangeNotifier {
|
class ControlsHold extends ChangeNotifier {
|
||||||
final _by = <Object>{};
|
final _by = <Object>{};
|
||||||
bool _told = false;
|
bool _told = false;
|
||||||
@@ -74,109 +73,3 @@ class _ControlsHoldRegionState extends State<ControlsHoldRegion> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Follows whether media_kit's desktop controls around [child] are up,
|
|
||||||
/// by the same rule they use: up on mount and on any mouse movement,
|
|
||||||
/// down [hideAfter] later or when the mouse leaves. media_kit does not
|
|
||||||
/// expose its visibility; this lets the player draw a progress line while
|
|
||||||
/// they are down. With [hold] held it keeps them (and itself) up. Never
|
|
||||||
/// takes input.
|
|
||||||
class ControlsHoverProbe extends StatefulWidget {
|
|
||||||
final ValueNotifier<bool> shown;
|
|
||||||
final Duration hideAfter;
|
|
||||||
final ControlsHold? hold;
|
|
||||||
final Widget child;
|
|
||||||
const ControlsHoverProbe(
|
|
||||||
{super.key, required this.shown, required this.hideAfter, this.hold, required this.child});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<ControlsHoverProbe> createState() => _ControlsHoverProbeState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ControlsHoverProbeState extends State<ControlsHoverProbe> {
|
|
||||||
Timer? _timer;
|
|
||||||
// While held: media_kit's own countdown is restarted before it ends.
|
|
||||||
Timer? _keep;
|
|
||||||
|
|
||||||
bool get _held => widget.hold?.held ?? false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
widget.hold?.addListener(_holdChanged);
|
|
||||||
// Not during this build: the line listening to [shown] may be built.
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (mounted) _wake();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didUpdateWidget(ControlsHoverProbe oldWidget) {
|
|
||||||
super.didUpdateWidget(oldWidget);
|
|
||||||
if (oldWidget.hold != widget.hold) {
|
|
||||||
oldWidget.hold?.removeListener(_holdChanged);
|
|
||||||
widget.hold?.addListener(_holdChanged);
|
|
||||||
_holdChanged();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
widget.hold?.removeListener(_holdChanged);
|
|
||||||
_timer?.cancel();
|
|
||||||
_keep?.cancel();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _holdChanged() {
|
|
||||||
if (!mounted) return;
|
|
||||||
_keep?.cancel();
|
|
||||||
_keep = null;
|
|
||||||
if (_held) {
|
|
||||||
_keep = Timer.periodic(widget.hideAfter * 0.5, (_) => _pokeControls());
|
|
||||||
}
|
|
||||||
_pokeControls();
|
|
||||||
_wake();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// media_kit's controls state is private: a guarded dynamic call to its
|
|
||||||
/// hover handler, as a mouse movement on it would make.
|
|
||||||
void _pokeControls() {
|
|
||||||
if (!mounted) return;
|
|
||||||
void visit(Element e) {
|
|
||||||
if (e is StatefulElement && e.state.runtimeType.toString() == '_MaterialDesktopVideoControlsState') {
|
|
||||||
try {
|
|
||||||
(e.state as dynamic).onHover();
|
|
||||||
} catch (_) {}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
e.visitChildElements(visit);
|
|
||||||
}
|
|
||||||
|
|
||||||
context.visitChildElements(visit);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _wake() {
|
|
||||||
widget.shown.value = true;
|
|
||||||
_timer?.cancel();
|
|
||||||
_timer = Timer(widget.hideAfter, _hide);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _hide() {
|
|
||||||
_timer?.cancel();
|
|
||||||
if (!mounted || _held) return;
|
|
||||||
widget.shown.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) => MouseRegion(
|
|
||||||
opaque: false,
|
|
||||||
hitTestBehavior: HitTestBehavior.translucent,
|
|
||||||
onEnter: (_) => _wake(),
|
|
||||||
onHover: (_) => _wake(),
|
|
||||||
// Held (the mouse went onto a pill or the popover): media_kit's
|
|
||||||
// controls just hid on their own exit; bring them straight back.
|
|
||||||
onExit: (_) => _held ? _pokeControls() : _hide(),
|
|
||||||
child: widget.child,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/gestures.dart' show kPrimaryMouseButton;
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'controls_probe.dart' show ControlsHold;
|
||||||
|
|
||||||
|
// The frame both control skins live in: when the controls are up, and the
|
||||||
|
// double-click on the picture. Mouse movement shows them; [hideAfter]
|
||||||
|
// without any (or the mouse leaving) hides them and the cursor. Holds keep
|
||||||
|
// them up: a seek drag (no hover events arrive), an open menu (a route
|
||||||
|
// takes the mouse), and the session's [ControlsHold] (the mouse on a
|
||||||
|
// floating pill or card, the Up next popover).
|
||||||
|
|
||||||
|
class ControlsFrame extends StatefulWidget {
|
||||||
|
/// Draws the controls; [visible] false while they are hidden.
|
||||||
|
final Widget Function(BuildContext context, bool visible) builder;
|
||||||
|
final VoidCallback? onDoubleClick;
|
||||||
|
|
||||||
|
/// Bands at the top and bottom where a double-click is the bars', not
|
||||||
|
/// the picture's.
|
||||||
|
final double barTop;
|
||||||
|
final double barBottom;
|
||||||
|
final Duration hideAfter;
|
||||||
|
|
||||||
|
/// Follows whether the controls are up (for overlays outside them).
|
||||||
|
final ValueNotifier<bool>? shown;
|
||||||
|
|
||||||
|
/// Keeps them up while held.
|
||||||
|
final ControlsHold? holdBy;
|
||||||
|
const ControlsFrame({
|
||||||
|
super.key,
|
||||||
|
required this.builder,
|
||||||
|
this.onDoubleClick,
|
||||||
|
this.barTop = 0,
|
||||||
|
this.barBottom = 0,
|
||||||
|
this.hideAfter = const Duration(seconds: 3),
|
||||||
|
this.shown,
|
||||||
|
this.holdBy,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The frame around [context], for widgets that keep it up (a drag, a
|
||||||
|
/// menu).
|
||||||
|
static ControlsFrameState? maybeOf(BuildContext context) => context.findAncestorStateOfType<ControlsFrameState>();
|
||||||
|
|
||||||
|
/// Runs [open] (a menu, a popover) with the controls around [context]
|
||||||
|
/// held up, if there are any.
|
||||||
|
static Future<T> holdWhile<T>(BuildContext context, Future<T> Function() open) {
|
||||||
|
final frame = maybeOf(context);
|
||||||
|
return frame == null ? open() : frame.holdDuring(open);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fade of the controls.
|
||||||
|
static const fade = Duration(milliseconds: 150);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ControlsFrame> createState() => ControlsFrameState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class ControlsFrameState extends State<ControlsFrame> {
|
||||||
|
bool _visible = true;
|
||||||
|
int _holds = 0;
|
||||||
|
bool _heldBy = false;
|
||||||
|
Timer? _timer;
|
||||||
|
|
||||||
|
bool get visible => _visible;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
widget.holdBy?.addListener(_holdByChanged);
|
||||||
|
// Not during this build: an overlay listening to [shown] may be built.
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) widget.shown?.value = true;
|
||||||
|
});
|
||||||
|
_hideLater();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(ControlsFrame oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.holdBy != widget.holdBy) {
|
||||||
|
oldWidget.holdBy?.removeListener(_holdByChanged);
|
||||||
|
widget.holdBy?.addListener(_holdByChanged);
|
||||||
|
_holdByChanged();
|
||||||
|
}
|
||||||
|
if (oldWidget.hideAfter != widget.hideAfter && _visible) _hideLater();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
widget.holdBy?.removeListener(_holdByChanged);
|
||||||
|
_timer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _holdByChanged() {
|
||||||
|
final on = widget.holdBy?.held ?? false;
|
||||||
|
if (on == _heldBy || !mounted) return;
|
||||||
|
_heldBy = on;
|
||||||
|
hold(on);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show the controls and restart the hide countdown.
|
||||||
|
void wake() {
|
||||||
|
if (!_visible && mounted) {
|
||||||
|
setState(() => _visible = true);
|
||||||
|
widget.shown?.value = true;
|
||||||
|
}
|
||||||
|
_hideLater();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep the controls up while [on] (paired calls).
|
||||||
|
void hold(bool on) {
|
||||||
|
_holds = on ? _holds + 1 : (_holds - 1).clamp(0, 1 << 20);
|
||||||
|
wake();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs [open] (a menu) with the controls held up.
|
||||||
|
Future<T> holdDuring<T>(Future<T> Function() open) async {
|
||||||
|
hold(true);
|
||||||
|
try {
|
||||||
|
return await open();
|
||||||
|
} finally {
|
||||||
|
if (mounted) hold(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _hideLater() {
|
||||||
|
_timer?.cancel();
|
||||||
|
_timer = Timer(widget.hideAfter, _hide);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _hide() {
|
||||||
|
if (!mounted || _holds > 0 || !_visible) return;
|
||||||
|
setState(() => _visible = false);
|
||||||
|
widget.shown?.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MouseRegion(
|
||||||
|
cursor: _visible ? SystemMouseCursors.basic : SystemMouseCursors.none,
|
||||||
|
onEnter: (_) => wake(),
|
||||||
|
onHover: (_) => wake(),
|
||||||
|
onExit: (_) {
|
||||||
|
_timer?.cancel();
|
||||||
|
_hide();
|
||||||
|
},
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
if (widget.onDoubleClick != null)
|
||||||
|
Positioned.fill(
|
||||||
|
child: DoubleClickListener(
|
||||||
|
onDoubleClick: widget.onDoubleClick!,
|
||||||
|
skipTop: _visible ? widget.barTop : 0,
|
||||||
|
skipBottom: _visible ? widget.barBottom : 0,
|
||||||
|
child: const ColoredBox(color: Color(0x00000000), child: SizedBox.expand()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
widget.builder(context, _visible),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Double-click on the picture (not on the bars) without joining the
|
||||||
|
/// gesture arena, so single clicks on buttons stay instant.
|
||||||
|
class DoubleClickListener extends StatefulWidget {
|
||||||
|
final VoidCallback onDoubleClick;
|
||||||
|
final double skipTop, skipBottom;
|
||||||
|
final Widget child;
|
||||||
|
const DoubleClickListener({
|
||||||
|
super.key,
|
||||||
|
required this.onDoubleClick,
|
||||||
|
this.skipTop = 0,
|
||||||
|
this.skipBottom = 0,
|
||||||
|
required this.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DoubleClickListener> createState() => _DoubleClickListenerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DoubleClickListenerState extends State<DoubleClickListener> {
|
||||||
|
DateTime? _last;
|
||||||
|
Offset? _lastAt;
|
||||||
|
|
||||||
|
void _down(PointerDownEvent e) {
|
||||||
|
if (e.buttons != kPrimaryMouseButton) return;
|
||||||
|
final h = context.size?.height ?? 0;
|
||||||
|
final y = e.localPosition.dy;
|
||||||
|
if (y < widget.skipTop || y > h - widget.skipBottom) {
|
||||||
|
_last = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final now = DateTime.now();
|
||||||
|
final last = _last, at = _lastAt;
|
||||||
|
if (last != null &&
|
||||||
|
at != null &&
|
||||||
|
now.difference(last) < const Duration(milliseconds: 400) &&
|
||||||
|
(e.localPosition - at).distance < 24) {
|
||||||
|
_last = null;
|
||||||
|
widget.onDoubleClick();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_last = now;
|
||||||
|
_lastAt = e.localPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) =>
|
||||||
|
Listener(behavior: HitTestBehavior.translucent, onPointerDown: _down, child: widget.child);
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
|
||||||
|
|
||||||
|
import '../../queue/queue_ui.dart';
|
||||||
|
import '../../shell/now_playing_title.dart';
|
||||||
|
import '../../state/settings.dart';
|
||||||
|
import '../control_menus.dart';
|
||||||
|
import '../controls_probe.dart' show ControlsHold;
|
||||||
|
import '../frame.dart';
|
||||||
|
import '../model/controls_layout.dart';
|
||||||
|
import '../model/controls_model.dart';
|
||||||
|
import '../model/controls_prefs.dart';
|
||||||
|
import '../volume.dart';
|
||||||
|
import 'style.dart';
|
||||||
|
import 'timeline.dart';
|
||||||
|
|
||||||
|
// The Minimal skin (inspired by mpv's uosc): a timeline band across the
|
||||||
|
// bottom that carries the clock, and above it the buttons in two glass
|
||||||
|
// capsules, in the layout's order. Top: what plays, Up next and the window
|
||||||
|
// controls. Hidden, the band shrinks to a thin progress line (unless
|
||||||
|
// switched off).
|
||||||
|
|
||||||
|
class MinimalControls extends StatelessWidget {
|
||||||
|
final PlayerControlsModel model;
|
||||||
|
final bool visible;
|
||||||
|
final double scale;
|
||||||
|
final ControlsHold? hold;
|
||||||
|
final Widget? windowButtons;
|
||||||
|
const MinimalControls({
|
||||||
|
super.key,
|
||||||
|
required this.model,
|
||||||
|
required this.visible,
|
||||||
|
this.scale = 1,
|
||||||
|
this.hold,
|
||||||
|
this.windowButtons,
|
||||||
|
});
|
||||||
|
|
||||||
|
static const _rowGap = 8.0;
|
||||||
|
|
||||||
|
/// The button row's height at [scale] (a capsule).
|
||||||
|
static double rowHeight(double scale) => MinimalStyle.button * scale + 2 * MinimalStyle.capsulePad;
|
||||||
|
|
||||||
|
/// Buttons and timeline at [scale].
|
||||||
|
static double bottomHeight(double scale) => rowHeight(scale) + _rowGap + MinimalTimeline.heightFor(scale);
|
||||||
|
|
||||||
|
/// The top bar's height.
|
||||||
|
static const topHeight = 72.0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
const fade = ControlsFrame.fade;
|
||||||
|
Widget shown(Widget child, {Key? key}) => IgnorePointer(
|
||||||
|
key: key,
|
||||||
|
ignoring: !visible,
|
||||||
|
child: AnimatedOpacity(duration: fade, opacity: visible ? 1 : 0, child: child),
|
||||||
|
);
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
child: shown(
|
||||||
|
key: const ValueKey('minimal-top'),
|
||||||
|
DecoratedBox(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [Color(0x99000000), Color(0x00000000)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SizedBox(
|
||||||
|
height: topHeight + 24,
|
||||||
|
child: Padding(padding: const EdgeInsets.fromLTRB(16, 0, 10, 24), child: _top()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
shown(
|
||||||
|
key: const ValueKey('minimal-buttons'),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, _rowGap),
|
||||||
|
child: SizedBox(
|
||||||
|
height: rowHeight(scale),
|
||||||
|
child: SignalBuilder(
|
||||||
|
builder: (context) {
|
||||||
|
final s = model.snapshot.value;
|
||||||
|
final layout = model.bar.value;
|
||||||
|
// The clock lives on the timeline.
|
||||||
|
List<Widget> cluster(List<BarControl> cs) => [
|
||||||
|
for (final c in cs)
|
||||||
|
if (c != BarControl.time) _control(context, c, s),
|
||||||
|
];
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
MinimalCapsule(scale: scale, children: cluster(layout.left)),
|
||||||
|
const Spacer(),
|
||||||
|
MinimalCapsule(scale: scale, children: cluster(layout.right)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SignalBuilder(
|
||||||
|
builder: (context) {
|
||||||
|
final line = AppSettings.instance.progressLine.value;
|
||||||
|
return AnimatedOpacity(
|
||||||
|
key: const ValueKey('minimal-timeline-wrap'),
|
||||||
|
duration: fade,
|
||||||
|
opacity: visible || line ? 1 : 0,
|
||||||
|
child: MinimalTimeline(
|
||||||
|
position: model.position.value,
|
||||||
|
duration: model.duration.value,
|
||||||
|
buffered: model.buffered.value,
|
||||||
|
chapters: model.chapters.value,
|
||||||
|
expanded: visible,
|
||||||
|
remaining: model.settings.timeDisplay.value == TimeDisplay.remaining,
|
||||||
|
scale: scale,
|
||||||
|
onSeek: model.seek,
|
||||||
|
onToggleTime: model.toggleTimeDisplay,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _top() {
|
||||||
|
final m = model;
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
const Expanded(
|
||||||
|
child: Align(alignment: AlignmentDirectional.centerStart, child: NowPlayingTitle()),
|
||||||
|
),
|
||||||
|
UpNextButton(queue: m.queue, onPlayNextEpisode: m.playNextEpisode, look: UpNextLook.minimal, hold: hold),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
SignalBuilder(
|
||||||
|
builder: (context) {
|
||||||
|
final full = m.fullscreen.value;
|
||||||
|
return MinimalButton(
|
||||||
|
key: const ValueKey('minimal-collapse'),
|
||||||
|
icon: full ? Icons.fullscreen_exit_rounded : Icons.close_fullscreen_rounded,
|
||||||
|
tooltip: full ? 'Exit fullscreen (Esc)' : 'Back to the library (keeps playing)',
|
||||||
|
onPressed: full ? m.toggleFullscreen : m.collapse,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
?windowButtons,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _control(BuildContext context, BarControl c, ControlsSnapshot s) {
|
||||||
|
final m = model;
|
||||||
|
final key = ValueKey('control-${c.name}');
|
||||||
|
final enabled = controlEnabled(c, s);
|
||||||
|
Widget button(VoidCallback? onPressed, {int? badge, bool accent = false}) => MinimalButton(
|
||||||
|
key: key,
|
||||||
|
icon: controlIcon(c, s),
|
||||||
|
tooltip: controlTooltip(c, s),
|
||||||
|
badge: badge,
|
||||||
|
accent: accent,
|
||||||
|
scale: scale,
|
||||||
|
onPressed: enabled ? onPressed : null,
|
||||||
|
);
|
||||||
|
switch (c) {
|
||||||
|
case BarControl.playPause:
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(right: 4 * scale),
|
||||||
|
child: button(m.togglePlay, accent: true),
|
||||||
|
);
|
||||||
|
case BarControl.previous:
|
||||||
|
return button(m.previous);
|
||||||
|
case BarControl.next:
|
||||||
|
return button(m.next);
|
||||||
|
case BarControl.chapterPrevious:
|
||||||
|
return button(() => m.stepChapter(-1));
|
||||||
|
case BarControl.chapterNext:
|
||||||
|
return button(() => m.stepChapter(1));
|
||||||
|
case BarControl.volume:
|
||||||
|
return SignalBuilder(
|
||||||
|
key: key,
|
||||||
|
builder: (context) => VolumeControl(
|
||||||
|
volume: m.volume.value,
|
||||||
|
onChanged: m.setVolume,
|
||||||
|
onToggleMute: m.toggleMute,
|
||||||
|
sliderColor: Theme.of(context).colorScheme.primary,
|
||||||
|
trackColor: MinimalStyle.dim.withValues(alpha: 0.4),
|
||||||
|
button: (context, i, color, tip, onPressed) =>
|
||||||
|
MinimalButton(icon: i, tooltip: tip, color: color, scale: scale, onPressed: onPressed),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
case BarControl.time:
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
case BarControl.subtitles || BarControl.audio:
|
||||||
|
final type = c == BarControl.audio ? JellyfinStreamType.audio : JellyfinStreamType.subtitle;
|
||||||
|
return SignalBuilder(
|
||||||
|
key: key,
|
||||||
|
builder: (context) => Builder(
|
||||||
|
builder: (context) => MinimalButton(
|
||||||
|
icon: controlIcon(c, s),
|
||||||
|
tooltip: controlTooltip(c, s),
|
||||||
|
badge: trackCount(m.tracks.value, type),
|
||||||
|
scale: scale,
|
||||||
|
onPressed: () => openTrackMenu(context, m, type),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
case BarControl.settings:
|
||||||
|
return Builder(
|
||||||
|
key: key,
|
||||||
|
builder: (context) => MinimalButton(
|
||||||
|
icon: controlIcon(c, s),
|
||||||
|
tooltip: controlTooltip(c, s),
|
||||||
|
scale: scale,
|
||||||
|
onPressed: () => openControlsMenu(context, m),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
case BarControl.chat:
|
||||||
|
return SignalBuilder(
|
||||||
|
key: key,
|
||||||
|
builder: (context) => MinimalButton(
|
||||||
|
icon: controlIcon(c, s),
|
||||||
|
tooltip: controlTooltip(c, s),
|
||||||
|
badge: m.unread.value,
|
||||||
|
scale: scale,
|
||||||
|
onPressed: m.toggleChat,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
case BarControl.fullscreen:
|
||||||
|
return button(m.toggleFullscreen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../widgets/app_menu.dart' show AppMenuStyle;
|
||||||
|
|
||||||
|
// The Minimal skin's look: the app's glass and accent (the theme's primary)
|
||||||
|
// on a thick timeline band, with soft rounded buttons in two glass capsules.
|
||||||
|
|
||||||
|
abstract final class MinimalStyle {
|
||||||
|
/// The timeline band (no blur: it spans the width over the picture).
|
||||||
|
static const band = Color(0xCC0E0E12);
|
||||||
|
|
||||||
|
/// The capsules holding the buttons: the player pills' glass.
|
||||||
|
static const capsule = AppMenuStyle.pillGlass;
|
||||||
|
static const hover = Color(0x1FFFFFFF);
|
||||||
|
static const text = Colors.white;
|
||||||
|
static const dim = Color(0xB3FFFFFF);
|
||||||
|
static const radius = 10.0;
|
||||||
|
static const fontSize = 13.0;
|
||||||
|
|
||||||
|
/// The tooltips and the timeline's hover tip.
|
||||||
|
static const tip = Color(0xEB18181C);
|
||||||
|
|
||||||
|
/// A button's box at scale 1.
|
||||||
|
static const button = 38.0;
|
||||||
|
|
||||||
|
/// The capsules' padding around the buttons.
|
||||||
|
static const capsulePad = 4.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Minimal skin's tooltip: the app's dark glass colour, above.
|
||||||
|
class MinimalTooltip extends StatelessWidget {
|
||||||
|
final String message;
|
||||||
|
final Widget child;
|
||||||
|
const MinimalTooltip({super.key, required this.message, required this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (message.isEmpty) return child;
|
||||||
|
return Tooltip(
|
||||||
|
message: message,
|
||||||
|
preferBelow: false,
|
||||||
|
verticalOffset: 28,
|
||||||
|
waitDuration: const Duration(milliseconds: 300),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: MinimalStyle.tip,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: AppMenuStyle.border, width: 0.5),
|
||||||
|
),
|
||||||
|
textStyle: const TextStyle(color: MinimalStyle.text, fontSize: 12),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Minimal button: a white icon on a rounded square that lights on hover
|
||||||
|
/// ([accent]: filled with the theme's primary, the play button), an
|
||||||
|
/// optional [label] and a count [badge]. Never takes focus.
|
||||||
|
class MinimalButton extends StatefulWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String tooltip;
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
final String? label;
|
||||||
|
final int? badge;
|
||||||
|
final bool accent;
|
||||||
|
final Color? color;
|
||||||
|
final double scale;
|
||||||
|
const MinimalButton({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.tooltip,
|
||||||
|
required this.onPressed,
|
||||||
|
this.label,
|
||||||
|
this.badge,
|
||||||
|
this.accent = false,
|
||||||
|
this.color,
|
||||||
|
this.scale = 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MinimalButton> createState() => _MinimalButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MinimalButtonState extends State<MinimalButton> {
|
||||||
|
bool _hover = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final w = widget;
|
||||||
|
final primary = Theme.of(context).colorScheme.primary;
|
||||||
|
final onPrimary = Theme.of(context).colorScheme.onPrimary;
|
||||||
|
final enabled = w.onPressed != null;
|
||||||
|
final size = MinimalStyle.button * w.scale;
|
||||||
|
final fg = !enabled
|
||||||
|
? MinimalStyle.dim.withValues(alpha: 0.35)
|
||||||
|
: w.accent
|
||||||
|
? onPrimary
|
||||||
|
: (w.color ?? MinimalStyle.text);
|
||||||
|
final bg = w.accent
|
||||||
|
? (_hover ? Color.lerp(primary, Colors.white, 0.18)! : primary)
|
||||||
|
: (_hover && enabled ? MinimalStyle.hover : Colors.transparent);
|
||||||
|
final badge = w.badge;
|
||||||
|
return MinimalTooltip(
|
||||||
|
message: w.tooltip,
|
||||||
|
child: MouseRegion(
|
||||||
|
cursor: enabled ? SystemMouseCursors.click : SystemMouseCursors.basic,
|
||||||
|
onEnter: (_) => setState(() => _hover = true),
|
||||||
|
onExit: (_) => setState(() => _hover = false),
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: w.onPressed,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 120),
|
||||||
|
height: size,
|
||||||
|
constraints: BoxConstraints(minWidth: size),
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: w.label == null ? 0 : 10 * w.scale),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: bg,
|
||||||
|
borderRadius: BorderRadius.circular(w.accent ? size / 2 : MinimalStyle.radius * w.scale),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
Icon(w.icon, color: fg, size: (w.accent ? 26 : 22) * w.scale),
|
||||||
|
if (badge != null && badge > 0)
|
||||||
|
Positioned(
|
||||||
|
right: -7,
|
||||||
|
top: -5,
|
||||||
|
child: Container(
|
||||||
|
key: const ValueKey('minimal-badge'),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
decoration: BoxDecoration(color: primary, borderRadius: BorderRadius.circular(999)),
|
||||||
|
child: Text(
|
||||||
|
'$badge',
|
||||||
|
style: TextStyle(color: onPrimary, fontSize: 9, height: 1.35, fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (w.label != null) ...[
|
||||||
|
SizedBox(width: 6 * w.scale),
|
||||||
|
Text(
|
||||||
|
w.label!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: fg,
|
||||||
|
fontSize: MinimalStyle.fontSize * w.scale,
|
||||||
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A glass capsule around a row of Minimal buttons.
|
||||||
|
class MinimalCapsule extends StatelessWidget {
|
||||||
|
final List<Widget> children;
|
||||||
|
final double scale;
|
||||||
|
const MinimalCapsule({super.key, required this.children, this.scale = 1});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final r = BorderRadius.circular(MinimalStyle.radius * scale + MinimalStyle.capsulePad);
|
||||||
|
return DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: MinimalStyle.capsule,
|
||||||
|
borderRadius: r,
|
||||||
|
border: Border.all(color: AppMenuStyle.border, width: 0.5),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(MinimalStyle.capsulePad),
|
||||||
|
child: Row(mainAxisSize: MainAxisSize.min, spacing: 2, children: children),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,578 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../playback/media_marks.dart';
|
||||||
|
import '../frame.dart';
|
||||||
|
import 'style.dart';
|
||||||
|
|
||||||
|
// The Minimal skin's timeline: a band across the bottom with the played
|
||||||
|
// part tinted in the accent and the position as a line, the part not
|
||||||
|
// downloaded yet striped, chapter starts as dots on the top edge (click one
|
||||||
|
// to jump), the elapsed time left and the total or the time left right,
|
||||||
|
// the buffered seconds by the position while under a minute, and a tip
|
||||||
|
// with the time (and the chapter title) under the mouse. Hidden, it shrinks
|
||||||
|
// to a thin progress line.
|
||||||
|
//
|
||||||
|
// Inspired by the timeline of uosc (github.com/tomasklaen/uosc, LGPL-2.1,
|
||||||
|
// by tomasklaen): the striped unbuffered part, the remaining time and the
|
||||||
|
// thin line when hidden come from there. The drawing, geometry and values
|
||||||
|
// here are our own.
|
||||||
|
|
||||||
|
/// Sizes at scale 1.
|
||||||
|
abstract final class MinimalTimelineSpec {
|
||||||
|
/// The band's height while the controls are up.
|
||||||
|
static const size = 36.0;
|
||||||
|
|
||||||
|
/// What is left of it while they are hidden.
|
||||||
|
static const lineSize = 3.0;
|
||||||
|
|
||||||
|
/// The position line.
|
||||||
|
static const lineWidth = 2.0;
|
||||||
|
static const fontSize = 15.0;
|
||||||
|
|
||||||
|
/// The times' inset from the ends.
|
||||||
|
static const inset = 12.0;
|
||||||
|
|
||||||
|
/// A chapter dot's radius (doubled-ish while hovered).
|
||||||
|
static const dot = 3.5;
|
||||||
|
|
||||||
|
/// The cache label shows while less than this is buffered ahead.
|
||||||
|
static const bufferedThreshold = Duration(seconds: 60);
|
||||||
|
|
||||||
|
/// Collapse / grow.
|
||||||
|
static const animation = Duration(milliseconds: 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fraction of [total] at [d], 0..1 (0 without a duration).
|
||||||
|
double timelineFraction(Duration d, Duration total) {
|
||||||
|
final t = total.inMicroseconds;
|
||||||
|
if (t <= 0) return 0;
|
||||||
|
return (d.inMicroseconds / t).clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the part not downloaded yet starts (it runs to the end), as a
|
||||||
|
/// fraction; null when nothing known is missing. We know only the end of
|
||||||
|
/// mpv's cache ahead ([buffered]); a cache within half a second of the end
|
||||||
|
/// leaves nothing.
|
||||||
|
double? uncachedFrom({required Duration position, required Duration buffered, required Duration duration}) {
|
||||||
|
if (duration <= Duration.zero || buffered <= Duration.zero) return null;
|
||||||
|
final from = buffered > position ? buffered : position;
|
||||||
|
if (duration - from <= const Duration(milliseconds: 500)) return null;
|
||||||
|
return timelineFraction(from, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cache label by the position ("12s"): the seconds buffered ahead,
|
||||||
|
/// while under [MinimalTimelineSpec.bufferedThreshold] and the cache does
|
||||||
|
/// not reach the end.
|
||||||
|
String? cacheLabel({required Duration position, required Duration buffered, required Duration duration}) {
|
||||||
|
if (duration <= Duration.zero || buffered <= Duration.zero) return null;
|
||||||
|
if (duration - buffered <= const Duration(milliseconds: 500)) return null;
|
||||||
|
final ahead = buffered > position ? buffered - position : Duration.zero;
|
||||||
|
if (ahead >= MinimalTimelineSpec.bufferedThreshold) return null;
|
||||||
|
return '${(ahead.inMilliseconds / 1000).round()}s';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The band's height while it animates between hidden (0) and shown (1).
|
||||||
|
double minimalBandSize(double visibility, double scale) =>
|
||||||
|
MinimalTimelineSpec.lineSize +
|
||||||
|
((MinimalTimelineSpec.size * scale - MinimalTimelineSpec.lineSize) * visibility).roundToDouble();
|
||||||
|
|
||||||
|
/// The Minimal timeline. [expanded] false (the controls hidden) shrinks it,
|
||||||
|
/// animated, to the progress line. Seeks ([onSeek]) on press, on every drag
|
||||||
|
/// move and on release; a press on a hovered chapter dot jumps to that
|
||||||
|
/// chapter, one on the right-hand time switches it ([onToggleTime]).
|
||||||
|
class MinimalTimeline extends StatefulWidget {
|
||||||
|
final Duration position;
|
||||||
|
final Duration duration;
|
||||||
|
|
||||||
|
/// End of the cached range.
|
||||||
|
final Duration buffered;
|
||||||
|
final List<MediaChapter> chapters;
|
||||||
|
final bool expanded;
|
||||||
|
|
||||||
|
/// The right-hand time: the time left instead of the total.
|
||||||
|
final bool remaining;
|
||||||
|
final double scale;
|
||||||
|
final ValueChanged<Duration> onSeek;
|
||||||
|
final VoidCallback? onToggleTime;
|
||||||
|
const MinimalTimeline({
|
||||||
|
super.key,
|
||||||
|
required this.position,
|
||||||
|
required this.duration,
|
||||||
|
this.buffered = Duration.zero,
|
||||||
|
this.chapters = const [],
|
||||||
|
this.expanded = true,
|
||||||
|
this.remaining = true,
|
||||||
|
this.scale = 1,
|
||||||
|
required this.onSeek,
|
||||||
|
this.onToggleTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
static double heightFor(double scale) => MinimalTimelineSpec.size * scale;
|
||||||
|
static const collapsedHeight = MinimalTimelineSpec.lineSize;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MinimalTimeline> createState() => _MinimalTimelineState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MinimalTimelineState extends State<MinimalTimeline> {
|
||||||
|
Offset? _hover;
|
||||||
|
bool _dragging = false;
|
||||||
|
double _dragFraction = 0;
|
||||||
|
Duration? _lastSent;
|
||||||
|
// A released seek, shown until the position catches up (else the line
|
||||||
|
// jumps back to the old position for a moment).
|
||||||
|
double? _pending;
|
||||||
|
Timer? _pendingTimer;
|
||||||
|
|
||||||
|
double get _scale => widget.scale;
|
||||||
|
Duration _at(double fraction) => widget.duration * fraction.clamp(0.0, 1.0);
|
||||||
|
|
||||||
|
void _send(Duration d) {
|
||||||
|
if (d == _lastSent) return;
|
||||||
|
_lastSent = d;
|
||||||
|
widget.onSeek(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _drag(bool on) {
|
||||||
|
if (_dragging == on) return;
|
||||||
|
setState(() => _dragging = on);
|
||||||
|
ControlsFrame.maybeOf(context)?.hold(on);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _settle(double fraction) {
|
||||||
|
_pending = fraction;
|
||||||
|
_pendingTimer?.cancel();
|
||||||
|
_pendingTimer = Timer(const Duration(seconds: 2), () {
|
||||||
|
if (mounted) setState(() => _pending = null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(MinimalTimeline old) {
|
||||||
|
super.didUpdateWidget(old);
|
||||||
|
if (!widget.expanded && old.expanded) _hover = null;
|
||||||
|
final p = _pending;
|
||||||
|
if (p != null && widget.duration > Duration.zero) {
|
||||||
|
final off = (widget.position - _at(p)).abs();
|
||||||
|
if (off < const Duration(milliseconds: 1500)) {
|
||||||
|
_pending = null;
|
||||||
|
_pendingTimer?.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_pendingTimer?.cancel();
|
||||||
|
// A drag cut short by a rebuild must not keep the controls up.
|
||||||
|
if (_dragging) ControlsFrame.maybeOf(context)?.hold(false);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
double _dotRadius(bool hovered) => MinimalTimelineSpec.dot * _scale * (hovered ? 1.7 : 1);
|
||||||
|
|
||||||
|
/// The chapter whose dot is within reach of [at].
|
||||||
|
int? _hoveredChapter(double width, List<MediaChapter> marks, Offset? at) {
|
||||||
|
if (at == null || _dragging) return null;
|
||||||
|
final reach = _dotRadius(false) * 2.5;
|
||||||
|
int? best;
|
||||||
|
var bestD = double.infinity;
|
||||||
|
for (var i = 0; i < marks.length; i++) {
|
||||||
|
final cx = width * timelineFraction(marks[i].start, widget.duration);
|
||||||
|
final d = (Offset(cx, 0) - at).distance;
|
||||||
|
if (d <= reach && d < bestD) {
|
||||||
|
best = i;
|
||||||
|
bestD = d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _rightText(Duration shown) {
|
||||||
|
final d = widget.duration;
|
||||||
|
if (d <= Duration.zero) return null;
|
||||||
|
if (!widget.remaining) return formatClock(d);
|
||||||
|
final left = d - shown;
|
||||||
|
return '-${formatClock(left.isNegative ? Duration.zero : left)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
// The painter's text takes the app's font (a TextPainter does not
|
||||||
|
// inherit it).
|
||||||
|
String? _font;
|
||||||
|
|
||||||
|
TextStyle get _timeStyle => TextStyle(
|
||||||
|
fontFamily: _font,
|
||||||
|
color: MinimalStyle.text,
|
||||||
|
fontSize: MinimalTimelineSpec.fontSize * _scale,
|
||||||
|
height: 1,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Where the right-hand time sits (a press there switches it).
|
||||||
|
bool _onRightTime(Offset at, double width, String? text) {
|
||||||
|
if (text == null || widget.onToggleTime == null) return false;
|
||||||
|
final tp = TextPainter(
|
||||||
|
text: TextSpan(text: text, style: _timeStyle),
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
)..layout();
|
||||||
|
return at.dx >= width - MinimalTimelineSpec.inset * _scale - tp.width - 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
_font = DefaultTextStyle.of(context).style.fontFamily ?? Theme.of(context).textTheme.bodyMedium?.fontFamily;
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) => TweenAnimationBuilder<double>(
|
||||||
|
tween: Tween(end: widget.expanded ? 1 : 0),
|
||||||
|
duration: MinimalTimelineSpec.animation,
|
||||||
|
builder: (context, visibility, _) => _band(context, constraints.maxWidth, visibility),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _band(BuildContext context, double width, double visibility) {
|
||||||
|
final expanded = widget.expanded;
|
||||||
|
final duration = widget.duration;
|
||||||
|
final size = minimalBandSize(visibility, _scale);
|
||||||
|
final progress = _dragging ? _dragFraction : (_pending ?? timelineFraction(widget.position, duration));
|
||||||
|
final shown = _at(progress);
|
||||||
|
final marks = duration > Duration.zero ? widget.chapters : const <MediaChapter>[];
|
||||||
|
final hover = expanded ? _hover : null;
|
||||||
|
final hovered = _hoveredChapter(width, marks, hover);
|
||||||
|
final hoverFraction = hovered != null
|
||||||
|
? timelineFraction(marks[hovered].start, duration)
|
||||||
|
: (hover == null || duration <= Duration.zero || width <= 0 ? null : (hover.dx / width).clamp(0.0, 1.0));
|
||||||
|
final hoverAt = hoverFraction == null ? null : _at(hoverFraction);
|
||||||
|
final cursorX = hoverFraction == null ? null : width * hoverFraction;
|
||||||
|
final hoverChapter = hoverAt == null ? null : chapterIndexAt(widget.chapters, hoverAt);
|
||||||
|
final hoverTitle = hoverChapter == null ? '' : widget.chapters[hoverChapter].title;
|
||||||
|
// Text fades out before it would overflow the shrinking band.
|
||||||
|
final minText = MinimalTimelineSpec.fontSize * _scale;
|
||||||
|
final textOpacity = ((size - minText) / (minText / 2)).clamp(0.0, 1.0);
|
||||||
|
final right = _rightText(shown);
|
||||||
|
final primary = Theme.of(context).colorScheme.primary;
|
||||||
|
final painter = MinimalTimelinePainter(
|
||||||
|
progress: progress,
|
||||||
|
visibility: visibility,
|
||||||
|
accent: primary,
|
||||||
|
scale: _scale,
|
||||||
|
uncached: uncachedFrom(position: widget.position, buffered: widget.buffered, duration: duration),
|
||||||
|
chapters: [for (final c in marks) timelineFraction(c.start, duration)],
|
||||||
|
hoveredChapter: hovered,
|
||||||
|
hoverX: hovered == null ? cursorX : null,
|
||||||
|
textOpacity: textOpacity,
|
||||||
|
elapsed: duration > Duration.zero ? formatClock(shown) : null,
|
||||||
|
right: right,
|
||||||
|
cache: cacheLabel(position: widget.position, buffered: widget.buffered, duration: duration),
|
||||||
|
timeStyle: _timeStyle,
|
||||||
|
);
|
||||||
|
final bar = SizedBox(
|
||||||
|
key: const ValueKey('minimal-timeline'),
|
||||||
|
width: width,
|
||||||
|
height: size,
|
||||||
|
child: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
child: RepaintBoundary(child: CustomPaint(painter: painter)),
|
||||||
|
),
|
||||||
|
if (hoverAt != null && cursorX != null)
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: size + 8,
|
||||||
|
height: 120,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: CustomMultiChildLayout(
|
||||||
|
delegate: _TipLayout(x: cursorX),
|
||||||
|
children: [
|
||||||
|
LayoutId(
|
||||||
|
id: #tip,
|
||||||
|
child: _HoverTip(time: formatClock(hoverAt), chapter: hoverTitle, scale: _scale),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!expanded) return IgnorePointer(child: bar);
|
||||||
|
Offset clampTo(Offset p) => Offset(p.dx.clamp(0.0, width), p.dy);
|
||||||
|
return MouseRegion(
|
||||||
|
cursor: SystemMouseCursors.click,
|
||||||
|
onEnter: (e) => setState(() => _hover = clampTo(e.localPosition)),
|
||||||
|
onHover: (e) => setState(() => _hover = clampTo(e.localPosition)),
|
||||||
|
onExit: (_) {
|
||||||
|
if (!_dragging) setState(() => _hover = null);
|
||||||
|
},
|
||||||
|
child: Listener(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onPointerDown: (e) {
|
||||||
|
if (width <= 0) return;
|
||||||
|
final at = clampTo(e.localPosition);
|
||||||
|
if (_onRightTime(at, width, right)) {
|
||||||
|
widget.onToggleTime?.call();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (duration <= Duration.zero) return;
|
||||||
|
final chapter = _hoveredChapter(width, marks, at);
|
||||||
|
_lastSent = null;
|
||||||
|
if (chapter != null) {
|
||||||
|
_settle(timelineFraction(marks[chapter].start, duration));
|
||||||
|
_send(marks[chapter].start);
|
||||||
|
setState(() {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_pending = null;
|
||||||
|
_dragFraction = (at.dx / width).clamp(0.0, 1.0);
|
||||||
|
_drag(true);
|
||||||
|
_send(_at(_dragFraction));
|
||||||
|
},
|
||||||
|
onPointerMove: (e) {
|
||||||
|
if (!_dragging) return;
|
||||||
|
final at = clampTo(e.localPosition);
|
||||||
|
setState(() {
|
||||||
|
_hover = at;
|
||||||
|
_dragFraction = (at.dx / width).clamp(0.0, 1.0);
|
||||||
|
});
|
||||||
|
_send(_at(_dragFraction));
|
||||||
|
},
|
||||||
|
onPointerUp: (e) {
|
||||||
|
if (!_dragging) return;
|
||||||
|
_send(_at(_dragFraction));
|
||||||
|
_settle(_dragFraction);
|
||||||
|
final p = e.localPosition;
|
||||||
|
if (p.dx < 0 || p.dx > width || p.dy < 0 || p.dy > size) _hover = null;
|
||||||
|
_drag(false);
|
||||||
|
},
|
||||||
|
onPointerCancel: (_) => _drag(false),
|
||||||
|
child: bar,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HoverTip extends StatelessWidget {
|
||||||
|
final String time;
|
||||||
|
final String chapter;
|
||||||
|
final double scale;
|
||||||
|
const _HoverTip({required this.time, required this.chapter, required this.scale});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 360),
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 10 * scale, vertical: 5 * scale),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: MinimalStyle.tip,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: const Color(0x1FFFFFFF), width: 0.5),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (chapter.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
chapter,
|
||||||
|
key: const ValueKey('minimal-hover-chapter'),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(color: MinimalStyle.text, fontSize: 13 * scale, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
time,
|
||||||
|
key: const ValueKey('minimal-hover-time'),
|
||||||
|
style: TextStyle(
|
||||||
|
color: chapter.isEmpty ? MinimalStyle.text : MinimalStyle.dim,
|
||||||
|
fontSize: 13 * scale,
|
||||||
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hover tip centred on [x], kept inside the band's ends.
|
||||||
|
class _TipLayout extends MultiChildLayoutDelegate {
|
||||||
|
final double x;
|
||||||
|
_TipLayout({required this.x});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void performLayout(Size size) {
|
||||||
|
const gap = 4.0;
|
||||||
|
final tip = layoutChild(#tip, BoxConstraints.loose(size));
|
||||||
|
final left = (x - tip.width / 2).clamp(gap, math.max(gap, size.width - gap - tip.width)).toDouble();
|
||||||
|
positionChild(#tip, Offset(left, size.height - tip.height));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRelayout(_TipLayout old) => old.x != x;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paints the band: background, the played tint and the (white) position
|
||||||
|
/// line, the stripes over the part not downloaded, chapter marks, the
|
||||||
|
/// times, the hover line.
|
||||||
|
class MinimalTimelinePainter extends CustomPainter {
|
||||||
|
/// The playing position, 0..1.
|
||||||
|
final double progress;
|
||||||
|
|
||||||
|
/// 0 hidden .. 1 shown.
|
||||||
|
final double visibility;
|
||||||
|
final Color accent;
|
||||||
|
final double scale;
|
||||||
|
|
||||||
|
/// Start of the part not downloaded (to the end), as a fraction.
|
||||||
|
final double? uncached;
|
||||||
|
|
||||||
|
/// Chapter starts, as fractions.
|
||||||
|
final List<double> chapters;
|
||||||
|
final int? hoveredChapter;
|
||||||
|
final double? hoverX;
|
||||||
|
final double textOpacity;
|
||||||
|
final String? elapsed;
|
||||||
|
final String? right;
|
||||||
|
final String? cache;
|
||||||
|
final TextStyle timeStyle;
|
||||||
|
const MinimalTimelinePainter({
|
||||||
|
required this.progress,
|
||||||
|
this.visibility = 1,
|
||||||
|
this.accent = Colors.white,
|
||||||
|
this.scale = 1,
|
||||||
|
this.uncached,
|
||||||
|
this.chapters = const [],
|
||||||
|
this.hoveredChapter,
|
||||||
|
this.hoverX,
|
||||||
|
this.textOpacity = 1,
|
||||||
|
this.elapsed,
|
||||||
|
this.right,
|
||||||
|
this.cache,
|
||||||
|
this.timeStyle = const TextStyle(fontSize: MinimalTimelineSpec.fontSize),
|
||||||
|
});
|
||||||
|
|
||||||
|
/// x of the position line's centre.
|
||||||
|
double lineX(double width) {
|
||||||
|
final lw = MinimalTimelineSpec.lineWidth * scale;
|
||||||
|
return (width * progress).clamp(lw / 2, math.max(lw / 2, width - lw / 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final w = size.width, h = size.height;
|
||||||
|
final x = lineX(w);
|
||||||
|
final band = Offset.zero & size;
|
||||||
|
// The band, darker as it grows; hidden, a faint track.
|
||||||
|
canvas.drawRect(band, Paint()..color = Color.lerp(const Color(0x40FFFFFF), MinimalStyle.band, visibility)!);
|
||||||
|
// Played: an accent tint, solid once the band is a line.
|
||||||
|
canvas.drawRect(
|
||||||
|
Rect.fromLTRB(0, 0, x, h),
|
||||||
|
Paint()..color = accent.withValues(alpha: 0.28 + 0.72 * (1 - visibility)),
|
||||||
|
);
|
||||||
|
_stripes(canvas, w, h);
|
||||||
|
_chapters(canvas, w, h);
|
||||||
|
if (visibility > 0) {
|
||||||
|
final lw = MinimalTimelineSpec.lineWidth * scale;
|
||||||
|
canvas.drawRect(
|
||||||
|
Rect.fromLTRB(x - lw / 2, 0, x + lw / 2, h),
|
||||||
|
Paint()..color = Colors.white.withValues(alpha: visibility),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (textOpacity > 0) _times(canvas, w, h, x);
|
||||||
|
final hx = hoverX;
|
||||||
|
if (hx != null) {
|
||||||
|
canvas.drawRect(Rect.fromLTRB(hx - 0.5, 0, hx + 0.5, h), Paint()..color = Colors.white.withValues(alpha: 0.45));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Thin 45° stripes over the part not downloaded yet.
|
||||||
|
void _stripes(Canvas canvas, double w, double h) {
|
||||||
|
final from = uncached;
|
||||||
|
if (from == null) return;
|
||||||
|
final ax = (w * from).floorToDouble();
|
||||||
|
if (ax >= w) return;
|
||||||
|
final period = 7.0 * scale;
|
||||||
|
canvas.save();
|
||||||
|
canvas.clipRect(Rect.fromLTRB(ax, 0, w, h));
|
||||||
|
final paint = Paint()
|
||||||
|
..color = Colors.white.withValues(alpha: 0.10 + 0.08 * (1 - visibility))
|
||||||
|
..strokeWidth = 1.5 * scale;
|
||||||
|
for (var c = (ax / period).floorToDouble() * period - h; c <= w; c += period) {
|
||||||
|
canvas.drawLine(Offset(c, h), Offset(c + h, 0), paint);
|
||||||
|
}
|
||||||
|
canvas.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _chapters(Canvas canvas, double w, double h) {
|
||||||
|
final cut = Paint()..color = Colors.black.withValues(alpha: 0.55);
|
||||||
|
for (final f in chapters) {
|
||||||
|
if (f <= 0) continue;
|
||||||
|
final cx = w * f;
|
||||||
|
canvas.drawRect(Rect.fromLTRB(cx - 0.5, 0, cx + 0.5, h), cut);
|
||||||
|
}
|
||||||
|
if (visibility <= 0) return;
|
||||||
|
final r = MinimalTimelineSpec.dot * scale * visibility;
|
||||||
|
void dot(double f, double radius, Color fill) {
|
||||||
|
final c = Offset(w * f, 0);
|
||||||
|
canvas.drawCircle(c, radius + 1, Paint()..color = Colors.black.withValues(alpha: 0.7));
|
||||||
|
canvas.drawCircle(c, radius, Paint()..color = fill);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not at 0: the band's start says that already.
|
||||||
|
for (var i = 0; i < chapters.length; i++) {
|
||||||
|
if (i != hoveredChapter && chapters[i] > 0) dot(chapters[i], r, Colors.white.withValues(alpha: 0.9));
|
||||||
|
}
|
||||||
|
final hv = hoveredChapter;
|
||||||
|
if (hv != null && hv < chapters.length) dot(chapters[hv], r * 1.7, accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
TextPainter _text(String s, TextStyle style) => TextPainter(
|
||||||
|
text: TextSpan(text: s, style: style),
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
)..layout();
|
||||||
|
|
||||||
|
/// White text with a soft shadow, so it reads over the accent tint too.
|
||||||
|
void _label(Canvas canvas, String s, TextStyle style, Offset at) {
|
||||||
|
final o = textOpacity;
|
||||||
|
_text(
|
||||||
|
s,
|
||||||
|
style.copyWith(
|
||||||
|
color: (style.color ?? Colors.white).withValues(alpha: o),
|
||||||
|
shadows: [Shadow(color: Colors.black.withValues(alpha: 0.8 * o), blurRadius: 3)],
|
||||||
|
),
|
||||||
|
).paint(canvas, at);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _times(Canvas canvas, double w, double h, double lineX) {
|
||||||
|
final inset = MinimalTimelineSpec.inset * scale;
|
||||||
|
final fs = timeStyle.fontSize ?? MinimalTimelineSpec.fontSize;
|
||||||
|
final cy = h / 2;
|
||||||
|
final e = elapsed, r = right;
|
||||||
|
final ew = e == null ? 0.0 : _text(e, timeStyle).width;
|
||||||
|
final rw = r == null ? 0.0 : _text(r, timeStyle).width;
|
||||||
|
final c = cache;
|
||||||
|
if (c != null) {
|
||||||
|
final cs = timeStyle.copyWith(fontSize: fs * 0.8, color: MinimalStyle.dim);
|
||||||
|
final cw = _text(c, cs).width;
|
||||||
|
const margin = 6.0;
|
||||||
|
var x = lineX + margin;
|
||||||
|
final minX = inset + margin + ew, maxX = w - inset - margin - rw;
|
||||||
|
if (x < minX) {
|
||||||
|
x = minX;
|
||||||
|
} else if (x + cw > maxX) {
|
||||||
|
x = maxX - cw;
|
||||||
|
}
|
||||||
|
_label(canvas, c, cs, Offset(x, cy - fs * 0.4));
|
||||||
|
}
|
||||||
|
if (e != null) _label(canvas, e, timeStyle, Offset(inset, cy - fs / 2));
|
||||||
|
if (r != null) _label(canvas, r, timeStyle, Offset(w - inset - rw, cy - fs / 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(MinimalTimelinePainter old) => true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import 'package:flutter/material.dart' show IconData, Icons;
|
||||||
|
|
||||||
|
import '../../playback/media_marks.dart' show formatClock;
|
||||||
|
import 'controls_prefs.dart';
|
||||||
|
|
||||||
|
// The controls layout, defined once for both skins: which controls the
|
||||||
|
// bottom bar has, in which order, which actions sit in the ⚙ menu instead,
|
||||||
|
// and their icons, tooltips and labels. Pure: everything here is a
|
||||||
|
// function of a [ControlsSnapshot] and the user's choices, so tests cover
|
||||||
|
// it without a player.
|
||||||
|
|
||||||
|
/// What playback does as this viewer sees it. In a server "play" is being
|
||||||
|
/// ready: the video plays once everyone is.
|
||||||
|
enum PlayState {
|
||||||
|
/// The video plays (the button pauses: you go unready).
|
||||||
|
playing,
|
||||||
|
|
||||||
|
/// You are ready; the others are not yet (the button unreadies you).
|
||||||
|
waiting,
|
||||||
|
|
||||||
|
/// You are not ready (the button readies you).
|
||||||
|
paused,
|
||||||
|
|
||||||
|
/// The media ended.
|
||||||
|
finished,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A control in the bottom bar.
|
||||||
|
enum BarControl {
|
||||||
|
playPause,
|
||||||
|
|
||||||
|
/// Previous episode.
|
||||||
|
previous,
|
||||||
|
|
||||||
|
/// Up next's first item, else the next episode.
|
||||||
|
next,
|
||||||
|
chapterPrevious,
|
||||||
|
chapterNext,
|
||||||
|
volume,
|
||||||
|
time,
|
||||||
|
subtitles,
|
||||||
|
audio,
|
||||||
|
|
||||||
|
/// ⚙: everything that is not a bar button.
|
||||||
|
settings,
|
||||||
|
chat,
|
||||||
|
fullscreen,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An action in the ⚙ menu.
|
||||||
|
enum ControlAction {
|
||||||
|
chapters('Chapters…', null),
|
||||||
|
subtitles('Subtitles…', null),
|
||||||
|
audio('Audio…', null),
|
||||||
|
skipForward('Skip 1:30', 'Shift+Right'),
|
||||||
|
shaders('Upscaling shader…', 'Ctrl+0…6'),
|
||||||
|
chat('Chat & activity', 'Tab'),
|
||||||
|
changeVideo('Change video', null),
|
||||||
|
copyLink('Copy link', null),
|
||||||
|
reload('Reload video', 'Ctrl+R'),
|
||||||
|
markAllReady('Mark everyone ready', null),
|
||||||
|
markProblem('Mark a problem', 'Ctrl+Shift+B'),
|
||||||
|
playerSettings('Player settings…', null),
|
||||||
|
leave('Leave server', null);
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final String? hint;
|
||||||
|
const ControlAction(this.label, this.hint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bar's order, left cluster then right cluster. The play button is
|
||||||
|
/// always first and fullscreen always last; controls that do not apply
|
||||||
|
/// (no episode, no chapters) or that the user hid are left out, the rest
|
||||||
|
/// keep their order.
|
||||||
|
abstract final class ControlsLayout {
|
||||||
|
static const left = [
|
||||||
|
BarControl.playPause,
|
||||||
|
BarControl.previous,
|
||||||
|
BarControl.next,
|
||||||
|
BarControl.chapterPrevious,
|
||||||
|
BarControl.chapterNext,
|
||||||
|
BarControl.volume,
|
||||||
|
BarControl.time,
|
||||||
|
];
|
||||||
|
static const right = [
|
||||||
|
BarControl.subtitles,
|
||||||
|
BarControl.audio,
|
||||||
|
BarControl.settings,
|
||||||
|
BarControl.chat,
|
||||||
|
BarControl.fullscreen,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The ⚙ menu's order.
|
||||||
|
static const menu = ControlAction.values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the layout depends on, at one moment.
|
||||||
|
class ControlsSnapshot {
|
||||||
|
final PlayState play;
|
||||||
|
|
||||||
|
/// The previous episode's label; null: none.
|
||||||
|
final String? previousLabel;
|
||||||
|
|
||||||
|
/// Up next's first item's title, else the next episode's label; null:
|
||||||
|
/// nothing next.
|
||||||
|
final String? nextLabel;
|
||||||
|
|
||||||
|
/// [nextLabel] is Up next (not an episode).
|
||||||
|
final bool nextIsUpNext;
|
||||||
|
final bool hasChapters;
|
||||||
|
|
||||||
|
/// The media's length is known (seeks and skips make sense).
|
||||||
|
final bool hasDuration;
|
||||||
|
|
||||||
|
/// Watching alone (no one to mark ready).
|
||||||
|
final bool solo;
|
||||||
|
|
||||||
|
/// A link to copy (`SessionCoordinator.currentLink`).
|
||||||
|
final bool hasLink;
|
||||||
|
final bool fullscreen;
|
||||||
|
final bool chatOpen;
|
||||||
|
const ControlsSnapshot({
|
||||||
|
this.play = PlayState.paused,
|
||||||
|
this.previousLabel,
|
||||||
|
this.nextLabel,
|
||||||
|
this.nextIsUpNext = false,
|
||||||
|
this.hasChapters = false,
|
||||||
|
this.hasDuration = false,
|
||||||
|
this.solo = false,
|
||||||
|
this.hasLink = false,
|
||||||
|
this.fullscreen = false,
|
||||||
|
this.chatOpen = false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bar's two clusters for [s], in [ControlsLayout] order.
|
||||||
|
({List<BarControl> left, List<BarControl> right}) barControls(ControlsSnapshot s, Set<OptionalControl> hidden) {
|
||||||
|
bool on(BarControl c) => switch (c) {
|
||||||
|
BarControl.previous => s.previousLabel != null,
|
||||||
|
BarControl.next => s.nextLabel != null,
|
||||||
|
BarControl.chapterPrevious || BarControl.chapterNext => s.hasChapters && !hidden.contains(OptionalControl.chapters),
|
||||||
|
BarControl.subtitles => !hidden.contains(OptionalControl.subtitles),
|
||||||
|
BarControl.audio => !hidden.contains(OptionalControl.audio),
|
||||||
|
BarControl.chat => !hidden.contains(OptionalControl.chat),
|
||||||
|
_ => true,
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
left: [
|
||||||
|
for (final c in ControlsLayout.left)
|
||||||
|
if (on(c)) c,
|
||||||
|
],
|
||||||
|
right: [
|
||||||
|
for (final c in ControlsLayout.right)
|
||||||
|
if (on(c)) c,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ⚙ menu for [s]: every action that applies, minus those the bar
|
||||||
|
/// already shows.
|
||||||
|
List<ControlAction> menuActions(ControlsSnapshot s, Set<OptionalControl> hidden) => [
|
||||||
|
for (final a in ControlsLayout.menu)
|
||||||
|
if (switch (a) {
|
||||||
|
ControlAction.chapters => s.hasChapters,
|
||||||
|
ControlAction.subtitles => hidden.contains(OptionalControl.subtitles),
|
||||||
|
ControlAction.audio => hidden.contains(OptionalControl.audio),
|
||||||
|
ControlAction.skipForward => s.hasDuration,
|
||||||
|
ControlAction.chat => hidden.contains(OptionalControl.chat),
|
||||||
|
ControlAction.copyLink => s.hasLink,
|
||||||
|
ControlAction.markAllReady => !s.solo,
|
||||||
|
_ => true,
|
||||||
|
})
|
||||||
|
a,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Whether a bar control does something now (a skin dims it otherwise).
|
||||||
|
bool controlEnabled(BarControl c, ControlsSnapshot s) => switch (c) {
|
||||||
|
BarControl.chapterPrevious || BarControl.chapterNext => s.hasChapters && s.hasDuration,
|
||||||
|
BarControl.previous => s.previousLabel != null,
|
||||||
|
BarControl.next => s.nextLabel != null,
|
||||||
|
_ => true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The icon of a bar control (volume and the clock draw their own).
|
||||||
|
IconData controlIcon(BarControl c, ControlsSnapshot s) => switch (c) {
|
||||||
|
BarControl.playPause => switch (s.play) {
|
||||||
|
PlayState.playing => Icons.pause_rounded,
|
||||||
|
// Ready: the check says so; a click unreadies.
|
||||||
|
PlayState.waiting => Icons.check_rounded,
|
||||||
|
PlayState.finished => Icons.replay_rounded,
|
||||||
|
PlayState.paused => Icons.play_arrow_rounded,
|
||||||
|
},
|
||||||
|
BarControl.previous => Icons.skip_previous_rounded,
|
||||||
|
BarControl.next => Icons.skip_next_rounded,
|
||||||
|
BarControl.chapterPrevious => Icons.chevron_left_rounded,
|
||||||
|
BarControl.chapterNext => Icons.chevron_right_rounded,
|
||||||
|
BarControl.volume => Icons.volume_up_rounded,
|
||||||
|
BarControl.time => Icons.schedule_rounded,
|
||||||
|
BarControl.subtitles => Icons.subtitles_outlined,
|
||||||
|
BarControl.audio => Icons.graphic_eq_rounded,
|
||||||
|
BarControl.settings => Icons.settings_outlined,
|
||||||
|
BarControl.chat => s.chatOpen ? Icons.chat_bubble_rounded : Icons.chat_bubble_outline_rounded,
|
||||||
|
BarControl.fullscreen => s.fullscreen ? Icons.fullscreen_exit_rounded : Icons.fullscreen_rounded,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The tooltip of a bar control, with its key.
|
||||||
|
String controlTooltip(BarControl c, ControlsSnapshot s) => switch (c) {
|
||||||
|
BarControl.playPause => switch (s.play) {
|
||||||
|
PlayState.playing => 'Pause for everyone (Space)',
|
||||||
|
PlayState.waiting => 'Ready, waiting for the others (Space: not ready)',
|
||||||
|
PlayState.paused => s.solo ? 'Play (Space)' : 'Ready: play when everyone is (Space)',
|
||||||
|
PlayState.finished => 'Ready (Space)',
|
||||||
|
},
|
||||||
|
BarControl.previous => 'Previous: ${s.previousLabel ?? ''} (< or Shift+P)',
|
||||||
|
BarControl.next => '${s.nextIsUpNext ? 'Up next' : 'Next'}: ${s.nextLabel ?? ''} (> or Shift+N)',
|
||||||
|
BarControl.chapterPrevious => 'Previous chapter (PgUp)',
|
||||||
|
BarControl.chapterNext => 'Next chapter (PgDn)',
|
||||||
|
BarControl.volume => 'Volume (scroll, ↑ / ↓)',
|
||||||
|
BarControl.time => 'Elapsed / remaining (click to switch)',
|
||||||
|
BarControl.subtitles => 'Subtitles',
|
||||||
|
BarControl.audio => 'Audio',
|
||||||
|
BarControl.settings => 'Settings and more',
|
||||||
|
BarControl.chat => 'Chat & activity (Tab)',
|
||||||
|
BarControl.fullscreen => s.fullscreen ? 'Exit fullscreen (F, Esc)' : 'Fullscreen (F)',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The bar's clock: "6:00 / 24:00", or the time left ("-18:00"); "0:00"
|
||||||
|
/// parts while the length is unknown.
|
||||||
|
String clockText(Duration position, Duration duration, TimeDisplay display) {
|
||||||
|
if (display == TimeDisplay.remaining && duration > Duration.zero) {
|
||||||
|
final left = duration - position;
|
||||||
|
return '-${formatClock(left.isNegative ? Duration.zero : left)}';
|
||||||
|
}
|
||||||
|
return '${formatClock(position)} / ${formatClock(duration)}';
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
|
import '../../jellyfin/track_prefs.dart' show MpvTrack;
|
||||||
|
import '../../playback/media_marks.dart';
|
||||||
|
import '../../playback/playback_controller.dart';
|
||||||
|
import '../../queue/queue_controller.dart';
|
||||||
|
import '../../session/session_coordinator.dart';
|
||||||
|
import '../../shaders/shader_presets.dart';
|
||||||
|
import '../../state/session_state.dart';
|
||||||
|
import '../../state/settings.dart';
|
||||||
|
import '../player_settings.dart' show applyShaderChoice;
|
||||||
|
import '../volume.dart' as vol;
|
||||||
|
import 'controls_layout.dart';
|
||||||
|
|
||||||
|
/// A track pick from a track menu: audio id, subtitle id, or off.
|
||||||
|
typedef TrackPick = ({int? aid, int? sid, bool subtitlesOff});
|
||||||
|
|
||||||
|
/// The player controls, independent of how they look: what the controls
|
||||||
|
/// show (state, as signals) and what they do (actions), wired to the
|
||||||
|
/// session once. Both skins only draw this and call its actions, so a
|
||||||
|
/// control added here shows in both. Built by the session player per
|
||||||
|
/// mount; [fullscreen], [chatOpen] and [unread] are the shell's, passed in.
|
||||||
|
class PlayerControlsModel {
|
||||||
|
final SessionCoordinator coord;
|
||||||
|
final ReadonlySignal<bool> fullscreen;
|
||||||
|
final ReadonlySignal<bool> chatOpen;
|
||||||
|
final ReadonlySignal<int> unread;
|
||||||
|
final VoidCallback _toggleFullscreen;
|
||||||
|
final VoidCallback _toggleChat;
|
||||||
|
final VoidCallback _changeVideo;
|
||||||
|
final VoidCallback _collapse;
|
||||||
|
final VoidCallback _leave;
|
||||||
|
|
||||||
|
PlayerControlsModel({
|
||||||
|
required this.coord,
|
||||||
|
required this.fullscreen,
|
||||||
|
required this.chatOpen,
|
||||||
|
required this.unread,
|
||||||
|
required VoidCallback onToggleFullscreen,
|
||||||
|
required VoidCallback onToggleChat,
|
||||||
|
required VoidCallback onChangeVideo,
|
||||||
|
required VoidCallback onCollapse,
|
||||||
|
required VoidCallback onLeave,
|
||||||
|
}) : _toggleFullscreen = onToggleFullscreen,
|
||||||
|
_toggleChat = onToggleChat,
|
||||||
|
_changeVideo = onChangeVideo,
|
||||||
|
_collapse = onCollapse,
|
||||||
|
_leave = onLeave;
|
||||||
|
|
||||||
|
PlaybackController get playback => coord.playback;
|
||||||
|
QueueController get queue => coord.queue;
|
||||||
|
AppSettings get settings => AppSettings.instance;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- state
|
||||||
|
|
||||||
|
ReadonlySignal<Duration> get position => playback.position;
|
||||||
|
ReadonlySignal<Duration> get duration => playback.duration;
|
||||||
|
|
||||||
|
/// End of the cached range ahead of the position.
|
||||||
|
ReadonlySignal<Duration> get buffered => playback.buffered;
|
||||||
|
ReadonlySignal<List<MediaChapter>> get chapters => playback.chapters;
|
||||||
|
ReadonlySignal<int?> get currentChapter => playback.currentChapter;
|
||||||
|
ReadonlySignal<List<MpvTrack>> get tracks => playback.tracks;
|
||||||
|
|
||||||
|
/// 0..`maxVolume`.
|
||||||
|
ReadonlySignal<double> get volume => playback.volume;
|
||||||
|
ReadonlySignal<SkipOffer?> get skipOffer => coord.skipOffer;
|
||||||
|
ReadonlySignal<RenderMode> get renderMode => playback.renderMode;
|
||||||
|
|
||||||
|
/// Play / pause as the session sees it ([PlayState]).
|
||||||
|
late final Computed<PlayState> play = computed(() {
|
||||||
|
final r = coord.serverState.value;
|
||||||
|
final self = coord.selfMember.value;
|
||||||
|
return switch (r?.transport) {
|
||||||
|
'playing' => PlayState.playing,
|
||||||
|
'finished' => PlayState.finished,
|
||||||
|
_ => (self?.ready ?? false) && (self?.inSet ?? false) ? PlayState.waiting : PlayState.paused,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Everything the layout depends on (not the position: that changes
|
||||||
|
/// four times a second and only the timeline needs it).
|
||||||
|
late final Computed<ControlsSnapshot> snapshot = computed(() {
|
||||||
|
final items = queue.items.value;
|
||||||
|
final next = coord.nextEpisode.value;
|
||||||
|
return ControlsSnapshot(
|
||||||
|
play: play.value,
|
||||||
|
previousLabel: coord.previousEpisode.value?.label,
|
||||||
|
nextLabel: items.isNotEmpty ? items.first.title : next?.label,
|
||||||
|
nextIsUpNext: items.isNotEmpty,
|
||||||
|
hasChapters: chapters.value.isNotEmpty,
|
||||||
|
hasDuration: duration.value > Duration.zero,
|
||||||
|
solo: SessionState.instance.solo.value,
|
||||||
|
// Not a signal: read again whenever the state changes.
|
||||||
|
hasLink: coord.serverState.value != null && coord.currentLink() != null,
|
||||||
|
fullscreen: fullscreen.value,
|
||||||
|
chatOpen: chatOpen.value,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The bar's clusters, in the layout's order.
|
||||||
|
late final Computed<({List<BarControl> left, List<BarControl> right})> bar = computed(
|
||||||
|
() => barControls(snapshot.value, settings.hiddenControls.value),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// What the ⚙ menu lists now.
|
||||||
|
List<ControlAction> menu() => menuActions(snapshot.peek(), settings.hiddenControls.peek());
|
||||||
|
|
||||||
|
/// Ticks on each wheel / key volume step: the skins flash the volume.
|
||||||
|
final volumeFlash = ValueNotifier(0);
|
||||||
|
|
||||||
|
/// A menu of the controls is open (the player's Esc closes it).
|
||||||
|
int menusOpen = 0;
|
||||||
|
|
||||||
|
void dispose() => volumeFlash.dispose();
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- actions
|
||||||
|
|
||||||
|
/// Space: ready / not ready (being ready is "play" in a server).
|
||||||
|
void togglePlay() => coord.toggleReady();
|
||||||
|
|
||||||
|
/// A seek bar's press or drag; through `player.seek`, the user-seek
|
||||||
|
/// path, so everyone follows.
|
||||||
|
void seek(Duration to) => playback.player.seek(to);
|
||||||
|
|
||||||
|
/// Arrow keys: ±[secs], relative to a seek still in flight.
|
||||||
|
void nudge(double secs) => coord.nudge(secs);
|
||||||
|
|
||||||
|
/// +1:30 for everyone.
|
||||||
|
void skipForward() => coord.skipForward();
|
||||||
|
|
||||||
|
/// A skip pill (Skip OP / ED / 1:30).
|
||||||
|
void skip(SkipOffer offer) => coord.skip(offer);
|
||||||
|
|
||||||
|
/// PgDn (+1) / PgUp (-1). False when there is no chapter to go to.
|
||||||
|
bool stepChapter(int delta) => coord.stepChapter(delta);
|
||||||
|
|
||||||
|
void pickChapter(MediaChapter c) => coord.seekTo(c.start.inMicroseconds / 1e6);
|
||||||
|
|
||||||
|
/// Up next's first item, else the next episode. False when neither.
|
||||||
|
bool next() {
|
||||||
|
if (queue.items.peek().isNotEmpty) {
|
||||||
|
queue.skip();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (coord.nextEpisode.peek() == null) return false;
|
||||||
|
coord.playNextEpisode();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The previous episode. False without one.
|
||||||
|
bool previous() {
|
||||||
|
if (coord.previousEpisode.peek() == null) return false;
|
||||||
|
coord.playPreviousEpisode();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void playNextEpisode() => coord.playNextEpisode();
|
||||||
|
|
||||||
|
void setVolume(double v) => playback.player.setVolume(v.clamp(0.0, vol.maxVolume));
|
||||||
|
|
||||||
|
/// One volume step up (+1) or down (-1) from the wheel or ↑ / ↓;
|
||||||
|
/// flashes the volume readout.
|
||||||
|
void stepVolume(int step) {
|
||||||
|
setVolume(vol.stepVolume(volume.peek(), step * vol.volumeStep));
|
||||||
|
volumeFlash.value++;
|
||||||
|
}
|
||||||
|
|
||||||
|
double _beforeMute = 100;
|
||||||
|
|
||||||
|
/// The speaker: mute, or back to the volume before.
|
||||||
|
void toggleMute() {
|
||||||
|
final v = volume.peek();
|
||||||
|
if (v > 0) {
|
||||||
|
_beforeMute = v;
|
||||||
|
setVolume(0);
|
||||||
|
} else {
|
||||||
|
setVolume(_beforeMute <= 0 ? 100 : _beforeMute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void pickTrack(TrackPick p) => coord.selectTrack(aid: p.aid, sid: p.sid, subtitlesOff: p.subtitlesOff);
|
||||||
|
|
||||||
|
/// Saved as the choice and applied live; a null quality keeps the
|
||||||
|
/// saved one.
|
||||||
|
void pickShader(ShaderPreset preset, ShaderQuality? quality) => applyShaderChoice(preset, quality);
|
||||||
|
|
||||||
|
void toggleTimeDisplay() => settings.setTimeDisplay(settings.timeDisplay.peek().toggled);
|
||||||
|
|
||||||
|
void toggleFullscreen() => _toggleFullscreen();
|
||||||
|
|
||||||
|
void toggleChat() {
|
||||||
|
coord.markInput();
|
||||||
|
_toggleChat();
|
||||||
|
}
|
||||||
|
|
||||||
|
void changeVideo() => _changeVideo();
|
||||||
|
|
||||||
|
/// Back to the library (keeps playing in the mini player).
|
||||||
|
void collapse() => _collapse();
|
||||||
|
|
||||||
|
/// Asks before leaving the server.
|
||||||
|
void leave() => _leave();
|
||||||
|
|
||||||
|
void reload() {
|
||||||
|
coord.markInput();
|
||||||
|
coord.reloadVideo();
|
||||||
|
}
|
||||||
|
|
||||||
|
void markAllReady() => coord.forcePlayAll();
|
||||||
|
|
||||||
|
String? link() => coord.currentLink();
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// The player controls' user choices (Settings → Player, stored in
|
||||||
|
// `AppSettings`). Plain values: the layout spec and both skins read them.
|
||||||
|
|
||||||
|
/// The controls' look. Both skins draw the same controls model in the same
|
||||||
|
/// order ([ControlsLayout]); only the look differs.
|
||||||
|
enum ControlsSkin {
|
||||||
|
/// Material-style bar: a seek bar over a row of round icon buttons.
|
||||||
|
standard('Standard'),
|
||||||
|
|
||||||
|
/// The app's own timeline skin (inspired by mpv's uosc): a thick band
|
||||||
|
/// with the buffered part, chapters and times, flat glass buttons.
|
||||||
|
minimal('Minimal');
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
const ControlsSkin(this.label);
|
||||||
|
|
||||||
|
/// A stored name; the old `mpvControls` flag (true: the uosc design)
|
||||||
|
/// when there is none.
|
||||||
|
static ControlsSkin fromStored(Object? name, {Object? legacyMpvControls}) {
|
||||||
|
for (final s in values) {
|
||||||
|
if (s.name == name) return s;
|
||||||
|
}
|
||||||
|
return legacyMpvControls == true ? minimal : standard;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bar's clock: "6:00 / 24:00" or the time left ("-18:00").
|
||||||
|
enum TimeDisplay {
|
||||||
|
elapsed('Elapsed / total'),
|
||||||
|
remaining('Remaining');
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
const TimeDisplay(this.label);
|
||||||
|
|
||||||
|
TimeDisplay get toggled => this == elapsed ? remaining : elapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bar and button size.
|
||||||
|
enum ControlsSize {
|
||||||
|
normal('Normal', 1.0),
|
||||||
|
large('Large', 1.25);
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final double scale;
|
||||||
|
const ControlsSize(this.label, this.scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bar buttons the user may hide; hidden ones are still in ⚙.
|
||||||
|
enum OptionalControl {
|
||||||
|
chapters('Chapter buttons', 'Previous / next chapter (PgUp / PgDn)'),
|
||||||
|
subtitles('Subtitles', 'The subtitle track list'),
|
||||||
|
audio('Audio', 'The audio track list'),
|
||||||
|
chat('Chat', 'Chat & activity (Tab)');
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final String help;
|
||||||
|
const OptionalControl(this.label, this.help);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The auto-hide delays offered (seconds).
|
||||||
|
const controlsHideChoices = [2, 3, 5];
|
||||||
|
|
||||||
|
/// The default auto-hide delay (seconds).
|
||||||
|
const defaultControlsHideSecs = 3;
|
||||||
@@ -12,11 +12,11 @@ import '../shell/mark_problem.dart';
|
|||||||
import '../shell/motion.dart';
|
import '../shell/motion.dart';
|
||||||
import '../state/settings.dart';
|
import '../state/settings.dart';
|
||||||
import '../widgets/app_menu.dart';
|
import '../widgets/app_menu.dart';
|
||||||
import 'uosc/buttons.dart';
|
import 'frame.dart';
|
||||||
import 'uosc/controls.dart';
|
import 'model/controls_prefs.dart';
|
||||||
|
|
||||||
// Everything about the player, defined once: the rows of the ⚙ card in
|
// Everything about the player, defined once: the rows of the player
|
||||||
// both control designs and of Settings → Player. Each row reads and
|
// settings card (⚙ → Player settings, both skins) and of Settings → Player. Each row reads and
|
||||||
// writes [AppSettings] and applies to a running player right away.
|
// writes [AppSettings] and applies to a running player right away.
|
||||||
|
|
||||||
/// One setting: a label, one line of help under it, the control at the
|
/// One setting: a label, one line of help under it, the control at the
|
||||||
@@ -67,7 +67,7 @@ AppSelect<T> _dropdown<T>({
|
|||||||
}) =>
|
}) =>
|
||||||
AppSelect<T>(key: key, value: value, items: items, onChanged: onChanged, expanded: expanded);
|
AppSelect<T>(key: key, value: value, items: items, onChanged: onChanged, expanded: expanded);
|
||||||
|
|
||||||
/// Standard (our bars over media_kit's controls) or uosc. Live.
|
/// The controls' skin: Standard or Minimal. Live.
|
||||||
class PlayerDesignSetting extends StatelessWidget {
|
class PlayerDesignSetting extends StatelessWidget {
|
||||||
const PlayerDesignSetting({super.key});
|
const PlayerDesignSetting({super.key});
|
||||||
|
|
||||||
@@ -77,12 +77,100 @@ class PlayerDesignSetting extends StatelessWidget {
|
|||||||
return SignalBuilder(
|
return SignalBuilder(
|
||||||
builder: (context) => SettingRow(
|
builder: (context) => SettingRow(
|
||||||
label: 'Player design',
|
label: 'Player design',
|
||||||
help: 'uosc is modeled on mpv: a thick timeline with chapters and what is cached',
|
help: 'Minimal has a thick timeline with chapters and what is downloaded yet',
|
||||||
control: _dropdown<bool>(
|
control: _dropdown<ControlsSkin>(
|
||||||
key: const ValueKey('player-design'),
|
key: const ValueKey('player-design'),
|
||||||
value: s.mpvControls.value,
|
value: s.controlsSkin.value,
|
||||||
items: const [(false, 'Standard'), (true, 'uosc')],
|
items: [for (final k in ControlsSkin.values) (k, k.label)],
|
||||||
onChanged: s.setMpvControls,
|
onChanged: s.setControlsSkin,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bar's clock: elapsed / total or the time left. Live.
|
||||||
|
class TimeDisplaySetting extends StatelessWidget {
|
||||||
|
const TimeDisplaySetting({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final s = AppSettings.instance;
|
||||||
|
return SignalBuilder(
|
||||||
|
builder: (context) => SettingRow(
|
||||||
|
label: 'Time display',
|
||||||
|
help: 'Also switched by a click on the time',
|
||||||
|
control: _dropdown<TimeDisplay>(
|
||||||
|
key: const ValueKey('time-display'),
|
||||||
|
value: s.timeDisplay.value,
|
||||||
|
items: [for (final t in TimeDisplay.values) (t, t.label)],
|
||||||
|
onChanged: s.setTimeDisplay,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normal or large bar and buttons. Live.
|
||||||
|
class ControlsSizeSetting extends StatelessWidget {
|
||||||
|
const ControlsSizeSetting({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final s = AppSettings.instance;
|
||||||
|
return SignalBuilder(
|
||||||
|
builder: (context) => SettingRow(
|
||||||
|
label: 'Controls size',
|
||||||
|
help: 'Large makes the bar and its buttons a quarter bigger',
|
||||||
|
control: _dropdown<ControlsSize>(
|
||||||
|
key: const ValueKey('controls-size'),
|
||||||
|
value: s.controlsSize.value,
|
||||||
|
items: [for (final z in ControlsSize.values) (z, z.label)],
|
||||||
|
onChanged: s.setControlsSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long the controls stay up without mouse movement. Live.
|
||||||
|
class HideDelaySetting extends StatelessWidget {
|
||||||
|
const HideDelaySetting({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final s = AppSettings.instance;
|
||||||
|
return SignalBuilder(
|
||||||
|
builder: (context) => SettingRow(
|
||||||
|
label: 'Hide the controls after',
|
||||||
|
help: 'Without mouse movement over the video',
|
||||||
|
control: _dropdown<int>(
|
||||||
|
key: const ValueKey('controls-hide'),
|
||||||
|
value: s.controlsHideSecs.value,
|
||||||
|
items: [for (final secs in controlsHideChoices) (secs, '$secs s')],
|
||||||
|
onChanged: s.setControlsHideSecs,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One optional bar button on or off (off: it is still in ⚙). Live.
|
||||||
|
class BarButtonSetting extends StatelessWidget {
|
||||||
|
final OptionalControl control;
|
||||||
|
const BarButtonSetting({super.key, required this.control});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final s = AppSettings.instance;
|
||||||
|
return SignalBuilder(
|
||||||
|
builder: (context) => SettingRow(
|
||||||
|
label: control.label,
|
||||||
|
help: control.help,
|
||||||
|
control: Switch(
|
||||||
|
key: ValueKey('bar-${control.name}'),
|
||||||
|
value: !s.hiddenControls.value.contains(control),
|
||||||
|
onChanged: (on) => s.setControlShown(control, on),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -296,7 +384,20 @@ class TrackSetting extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The player rows of Settings → Player and the ⚙ card, in order.
|
/// The controls' own choices (Settings → Player → Controls).
|
||||||
|
List<Widget> controlsSettingRows() => [
|
||||||
|
const TimeDisplaySetting(),
|
||||||
|
const ControlsSizeSetting(),
|
||||||
|
const HideDelaySetting(),
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(top: 10, bottom: 2),
|
||||||
|
child: Text('Buttons in the bar (hidden ones stay in ⚙)', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
),
|
||||||
|
for (final c in OptionalControl.values) BarButtonSetting(control: c),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The player rows of Settings → Player and the player settings card, in
|
||||||
|
/// order.
|
||||||
List<Widget> playerSettingRows({ReadonlySignal<RenderMode>? renderMode}) => [
|
List<Widget> playerSettingRows({ReadonlySignal<RenderMode>? renderMode}) => [
|
||||||
const PlayerDesignSetting(),
|
const PlayerDesignSetting(),
|
||||||
const ProgressLineSetting(),
|
const ProgressLineSetting(),
|
||||||
@@ -304,7 +405,8 @@ List<Widget> playerSettingRows({ReadonlySignal<RenderMode>? renderMode}) => [
|
|||||||
const HwdecSetting(),
|
const HwdecSetting(),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// The ⚙ card: every player setting, plus the running player's tracks.
|
/// The player settings card: every player setting, plus the running
|
||||||
|
/// player's tracks.
|
||||||
class PlayerSettingsCard extends StatelessWidget {
|
class PlayerSettingsCard extends StatelessWidget {
|
||||||
final ReadonlySignal<RenderMode>? renderMode;
|
final ReadonlySignal<RenderMode>? renderMode;
|
||||||
/// Show the audio / subtitle pickers (a player is running).
|
/// Show the audio / subtitle pickers (a player is running).
|
||||||
@@ -337,7 +439,7 @@ class PlayerSettingsCard extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Custom shader files: Settings → Player',
|
'Controls layout and custom shaders: Settings → Player',
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12),
|
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -352,51 +454,14 @@ class PlayerSettingsCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens the ⚙ card over the player, near the controls' right end. The
|
/// Opens the player settings card over the player, near the controls'
|
||||||
/// uosc controls stay up while it is open.
|
/// right end; the controls stay up while it is open.
|
||||||
Future<void> showPlayerSettings(BuildContext context, {ReadonlySignal<RenderMode>? renderMode}) {
|
Future<void> showPlayerSettings(BuildContext context, {ReadonlySignal<RenderMode>? renderMode}) =>
|
||||||
Future<void> open() => showPopover<void>(
|
ControlsFrame.holdWhile(
|
||||||
|
context,
|
||||||
|
() => showPopover<void>(
|
||||||
context,
|
context,
|
||||||
at: const Alignment(0.96, 0.5),
|
at: const Alignment(0.96, 0.5),
|
||||||
child: PlayerSettingsCard(renderMode: renderMode),
|
child: PlayerSettingsCard(renderMode: renderMode),
|
||||||
);
|
),
|
||||||
final controls = UoscControls.maybeOf(context);
|
|
||||||
return controls == null ? open() : controls.holdDuring(open);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// ⚙ in the standard controls' bottom bar.
|
|
||||||
class PlayerSettingsButton extends StatelessWidget {
|
|
||||||
final FocusNode? focusNode;
|
|
||||||
final ReadonlySignal<RenderMode>? renderMode;
|
|
||||||
const PlayerSettingsButton({super.key, this.focusNode, this.renderMode});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return IconButton(
|
|
||||||
key: const ValueKey('player-settings'),
|
|
||||||
focusNode: focusNode,
|
|
||||||
tooltip: 'Player settings',
|
|
||||||
color: Colors.white,
|
|
||||||
iconSize: 20,
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
icon: const Icon(Icons.tune),
|
|
||||||
onPressed: () => showPlayerSettings(context, renderMode: renderMode),
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// ⚙ in the uosc controls' button row.
|
|
||||||
class UoscPlayerSettingsButton extends StatelessWidget {
|
|
||||||
final ReadonlySignal<RenderMode>? renderMode;
|
|
||||||
const UoscPlayerSettingsButton({super.key, this.renderMode});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return UoscButton(
|
|
||||||
key: const ValueKey('uosc-player-settings'),
|
|
||||||
icon: Icons.tune_rounded,
|
|
||||||
tooltip: 'Player settings',
|
|
||||||
onPressed: () => showPlayerSettings(context, renderMode: renderMode),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
|
import '../state/settings.dart';
|
||||||
|
import 'controls_probe.dart' show ControlsHold;
|
||||||
|
import 'frame.dart';
|
||||||
|
import 'minimal/minimal_controls.dart';
|
||||||
|
import 'model/controls_model.dart';
|
||||||
|
import 'model/controls_prefs.dart';
|
||||||
|
import 'standard/standard_controls.dart';
|
||||||
|
|
||||||
|
// The session's controls: one [ControlsFrame] (when they are up) around the
|
||||||
|
// skin the settings pick, both drawing the same [PlayerControlsModel].
|
||||||
|
// Skin, size and the hide delay follow the settings live.
|
||||||
|
|
||||||
|
/// The bottom controls' height in [skin] at [size] (the player's floating
|
||||||
|
/// pills sit just above it).
|
||||||
|
double controlsBottomHeight(ControlsSkin skin, ControlsSize size) => switch (skin) {
|
||||||
|
ControlsSkin.standard => StandardControls.bottomHeight(size.scale),
|
||||||
|
ControlsSkin.minimal => MinimalControls.bottomHeight(size.scale),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The top bar's height in [skin].
|
||||||
|
double controlsTopHeight(ControlsSkin skin) => switch (skin) {
|
||||||
|
ControlsSkin.standard => StandardControls.topHeight,
|
||||||
|
ControlsSkin.minimal => MinimalControls.topHeight,
|
||||||
|
};
|
||||||
|
|
||||||
|
class SessionControls extends StatelessWidget {
|
||||||
|
final PlayerControlsModel model;
|
||||||
|
|
||||||
|
/// Follows whether the controls are up.
|
||||||
|
final ValueNotifier<bool> shown;
|
||||||
|
final ControlsHold hold;
|
||||||
|
final VoidCallback onDoubleClick;
|
||||||
|
final Widget? windowButtons;
|
||||||
|
const SessionControls({
|
||||||
|
super.key,
|
||||||
|
required this.model,
|
||||||
|
required this.shown,
|
||||||
|
required this.hold,
|
||||||
|
required this.onDoubleClick,
|
||||||
|
this.windowButtons,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final s = AppSettings.instance;
|
||||||
|
return SignalBuilder(
|
||||||
|
builder: (context) {
|
||||||
|
final skin = s.controlsSkin.value;
|
||||||
|
final size = s.controlsSize.value;
|
||||||
|
final scale = size.scale;
|
||||||
|
return ControlsFrame(
|
||||||
|
// A new frame per skin: each starts up, like a fresh mount.
|
||||||
|
key: ValueKey(skin),
|
||||||
|
shown: shown,
|
||||||
|
holdBy: hold,
|
||||||
|
hideAfter: Duration(seconds: s.controlsHideSecs.value),
|
||||||
|
onDoubleClick: onDoubleClick,
|
||||||
|
barTop: controlsTopHeight(skin),
|
||||||
|
barBottom: controlsBottomHeight(skin, size),
|
||||||
|
builder: (context, visible) => switch (skin) {
|
||||||
|
ControlsSkin.standard => StandardControls(
|
||||||
|
model: model,
|
||||||
|
visible: visible,
|
||||||
|
scale: scale,
|
||||||
|
hold: hold,
|
||||||
|
windowButtons: windowButtons,
|
||||||
|
),
|
||||||
|
ControlsSkin.minimal => MinimalControls(
|
||||||
|
model: model,
|
||||||
|
visible: visible,
|
||||||
|
scale: scale,
|
||||||
|
hold: hold,
|
||||||
|
windowButtons: windowButtons,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../playback/media_marks.dart';
|
||||||
|
import '../frame.dart';
|
||||||
|
|
||||||
|
// The Standard skin's seek bar: a Material-style track with the played and
|
||||||
|
// buffered parts, chapter marks and a hover tip.
|
||||||
|
|
||||||
|
/// The seek bar: played / buffered track, a diamond at each chapter start
|
||||||
|
/// (not the one at 0), and on hover the time with the chapter title above
|
||||||
|
/// it. Like media_kit's bar it reports a seek on every drag move and on
|
||||||
|
/// release ([onSeek]); the session routes those through the user-seek path
|
||||||
|
/// (debounced, everyone follows).
|
||||||
|
class ChapterSeekBar extends StatefulWidget {
|
||||||
|
final Duration position;
|
||||||
|
final Duration duration;
|
||||||
|
final Duration buffer;
|
||||||
|
final List<MediaChapter> chapters;
|
||||||
|
final ValueChanged<Duration> onSeek;
|
||||||
|
|
||||||
|
/// The bar's height (its hit area); the track is centred in it.
|
||||||
|
final double height;
|
||||||
|
const ChapterSeekBar({
|
||||||
|
super.key,
|
||||||
|
required this.position,
|
||||||
|
required this.duration,
|
||||||
|
this.buffer = Duration.zero,
|
||||||
|
this.chapters = const [],
|
||||||
|
required this.onSeek,
|
||||||
|
this.height = ChapterSeekBar.defaultHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
static const defaultHeight = 28.0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ChapterSeekBar> createState() => _ChapterSeekBarState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChapterSeekBarState extends State<ChapterSeekBar> {
|
||||||
|
double? _hoverX;
|
||||||
|
bool _dragging = false;
|
||||||
|
double _dragFraction = 0;
|
||||||
|
|
||||||
|
double _fraction(Duration d) {
|
||||||
|
final total = widget.duration.inMicroseconds;
|
||||||
|
if (total <= 0) return 0;
|
||||||
|
return (d.inMicroseconds / total).clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
Duration _at(double fraction) => widget.duration * fraction.clamp(0.0, 1.0);
|
||||||
|
|
||||||
|
// A drag sends no hover events: the controls are held up through it.
|
||||||
|
void _drag(bool on) {
|
||||||
|
if (_dragging == on) return;
|
||||||
|
setState(() => _dragging = on);
|
||||||
|
ControlsFrame.maybeOf(context)?.hold(on);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
if (_dragging) ControlsFrame.maybeOf(context)?.hold(false);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _seekTo(double x, double width) {
|
||||||
|
if (widget.duration <= Duration.zero || width <= 0) return;
|
||||||
|
_dragFraction = (x / width).clamp(0.0, 1.0);
|
||||||
|
widget.onSeek(_at(_dragFraction));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final primary = Theme.of(context).colorScheme.primary;
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final width = constraints.maxWidth;
|
||||||
|
final active = _hoverX != null || _dragging;
|
||||||
|
final barHeight = active ? 5.0 : 3.0;
|
||||||
|
final played = _dragging ? _dragFraction : _fraction(widget.position);
|
||||||
|
final mid = widget.height / 2;
|
||||||
|
final hoverX = _hoverX;
|
||||||
|
final hoverAt = hoverX == null || width <= 0 ? null : _at(hoverX / width);
|
||||||
|
final hoverChapter = hoverAt == null ? null : chapterIndexAt(widget.chapters, hoverAt);
|
||||||
|
final hoverTitle = hoverChapter == null ? '' : widget.chapters[hoverChapter].title;
|
||||||
|
return MouseRegion(
|
||||||
|
cursor: SystemMouseCursors.click,
|
||||||
|
onEnter: (e) => setState(() => _hoverX = e.localPosition.dx.clamp(0, width)),
|
||||||
|
onHover: (e) => setState(() => _hoverX = e.localPosition.dx.clamp(0, width)),
|
||||||
|
onExit: (_) => setState(() => _hoverX = null),
|
||||||
|
child: Listener(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onPointerDown: (e) {
|
||||||
|
_dragFraction = width <= 0 ? 0 : (e.localPosition.dx / width).clamp(0.0, 1.0);
|
||||||
|
_drag(true);
|
||||||
|
},
|
||||||
|
onPointerMove: (e) {
|
||||||
|
setState(() => _hoverX = e.localPosition.dx.clamp(0, width));
|
||||||
|
_seekTo(e.localPosition.dx, width);
|
||||||
|
},
|
||||||
|
onPointerUp: (e) {
|
||||||
|
_seekTo(e.localPosition.dx, width);
|
||||||
|
_drag(false);
|
||||||
|
},
|
||||||
|
onPointerCancel: (_) => _drag(false),
|
||||||
|
child: SizedBox(
|
||||||
|
key: const ValueKey('seek-bar'),
|
||||||
|
width: width,
|
||||||
|
height: widget.height,
|
||||||
|
child: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: mid - barHeight / 2,
|
||||||
|
height: barHeight,
|
||||||
|
child: Container(color: Colors.white24),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
top: mid - barHeight / 2,
|
||||||
|
height: barHeight,
|
||||||
|
width: width * _fraction(widget.buffer),
|
||||||
|
child: Container(color: Colors.white24),
|
||||||
|
),
|
||||||
|
if (hoverX != null && !_dragging)
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
top: mid - barHeight / 2,
|
||||||
|
height: barHeight,
|
||||||
|
width: hoverX,
|
||||||
|
child: Container(color: Colors.white24),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
top: mid - barHeight / 2,
|
||||||
|
height: barHeight,
|
||||||
|
width: width * played,
|
||||||
|
child: Container(color: primary),
|
||||||
|
),
|
||||||
|
for (final c in widget.chapters)
|
||||||
|
if (c.start > Duration.zero && widget.duration > Duration.zero)
|
||||||
|
Positioned(
|
||||||
|
key: ValueKey('chapter-mark-${c.index}'),
|
||||||
|
left: width * _fraction(c.start) - 4,
|
||||||
|
top: mid - 4,
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: Transform.rotate(
|
||||||
|
angle: math.pi / 4,
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.all(1),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
border: Border.all(color: Colors.black54, width: 0.8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (active)
|
||||||
|
Positioned(
|
||||||
|
left: width * played - 6,
|
||||||
|
top: mid - 6,
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(color: primary, shape: BoxShape.circle),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (hoverAt != null)
|
||||||
|
Positioned(
|
||||||
|
// Kept clear of the window edges (the tip is centred).
|
||||||
|
left: hoverX!.clamp(math.min(60.0, width / 2), math.max(width - 60, width / 2)),
|
||||||
|
bottom: widget.height - 2,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: FractionalTranslation(
|
||||||
|
translation: const Offset(-0.5, 0),
|
||||||
|
child: _HoverTip(title: hoverTitle, time: formatClock(hoverAt)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HoverTip extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String time;
|
||||||
|
const _HoverTip({required this.title, required this.time});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 320),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.8), borderRadius: BorderRadius.circular(6)),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (title.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
key: const ValueKey('seek-hover-chapter'),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
time,
|
||||||
|
key: const ValueKey('seek-hover-time'),
|
||||||
|
style: const TextStyle(color: Colors.white70, fontSize: 11),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
|
||||||
|
|
||||||
|
import '../../queue/queue_ui.dart';
|
||||||
|
import '../../shell/now_playing_title.dart';
|
||||||
|
import '../../state/settings.dart';
|
||||||
|
import '../control_menus.dart';
|
||||||
|
import '../controls_probe.dart' show ControlsHold;
|
||||||
|
import '../frame.dart';
|
||||||
|
import '../model/controls_layout.dart';
|
||||||
|
import '../model/controls_model.dart';
|
||||||
|
import '../volume.dart';
|
||||||
|
import 'seek_bar.dart';
|
||||||
|
|
||||||
|
// The Standard skin: Material-style bars over the picture. Top: what plays,
|
||||||
|
// Up next and the window controls; bottom: the seek bar (chapter marks,
|
||||||
|
// hover time) over a row of round icon buttons in the layout's order.
|
||||||
|
// Hidden, a thin progress line stays (unless switched off).
|
||||||
|
|
||||||
|
class StandardControls extends StatelessWidget {
|
||||||
|
final PlayerControlsModel model;
|
||||||
|
final bool visible;
|
||||||
|
final double scale;
|
||||||
|
final ControlsHold? hold;
|
||||||
|
|
||||||
|
/// Right of the top bar's own buttons (the window buttons in fullscreen).
|
||||||
|
final Widget? windowButtons;
|
||||||
|
const StandardControls({
|
||||||
|
super.key,
|
||||||
|
required this.model,
|
||||||
|
required this.visible,
|
||||||
|
this.scale = 1,
|
||||||
|
this.hold,
|
||||||
|
this.windowButtons,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The top bar: room for the cover (2:3, 60 px tall).
|
||||||
|
static const topHeight = 72.0;
|
||||||
|
static const _rowHeight = 56.0;
|
||||||
|
|
||||||
|
/// The bottom bar (seek bar and buttons) at [scale].
|
||||||
|
static double bottomHeight(double scale) => (_rowHeight + ChapterSeekBar.defaultHeight) * scale;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
const fade = ControlsFrame.fade;
|
||||||
|
Widget bar(Widget child) => IgnorePointer(
|
||||||
|
ignoring: !visible,
|
||||||
|
child: AnimatedOpacity(duration: fade, opacity: visible ? 1 : 0, child: child),
|
||||||
|
);
|
||||||
|
// No button here takes focus (Space is the ready key); the top bar's
|
||||||
|
// Up next keeps its own (its popover has a text field).
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
child: bar(
|
||||||
|
DecoratedBox(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [Color(0x99000000), Color(0x00000000)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SizedBox(
|
||||||
|
key: const ValueKey('standard-top'),
|
||||||
|
height: topHeight,
|
||||||
|
child: Padding(padding: const EdgeInsets.only(left: 16, right: 8), child: _top()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: bar(
|
||||||
|
DecoratedBox(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [Color(0x00000000), Color(0xB3000000)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(top: 24 * scale),
|
||||||
|
child: SizedBox(
|
||||||
|
key: const ValueKey('standard-bottom'),
|
||||||
|
height: bottomHeight(scale),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: SignalBuilder(
|
||||||
|
builder: (context) => ChapterSeekBar(
|
||||||
|
position: model.position.value,
|
||||||
|
duration: model.duration.value,
|
||||||
|
buffer: model.buffered.value,
|
||||||
|
chapters: model.chapters.value,
|
||||||
|
height: ChapterSeekBar.defaultHeight * scale,
|
||||||
|
onSeek: model.seek,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: ExcludeFocus(
|
||||||
|
child: SignalBuilder(
|
||||||
|
builder: (context) {
|
||||||
|
final s = model.snapshot.value;
|
||||||
|
final layout = model.bar.value;
|
||||||
|
return Row(
|
||||||
|
key: const ValueKey('standard-buttons'),
|
||||||
|
children: [
|
||||||
|
for (final c in layout.left) _control(context, c, s),
|
||||||
|
const Spacer(),
|
||||||
|
for (final c in layout.right) _control(context, c, s),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Hidden: the thin progress line.
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: SignalBuilder(
|
||||||
|
builder: (context) {
|
||||||
|
if (visible || !AppSettings.instance.progressLine.value) return const SizedBox.shrink();
|
||||||
|
final d = model.duration.value, p = model.position.value;
|
||||||
|
final v = d.inMilliseconds == 0 ? 0.0 : (p.inMilliseconds / d.inMilliseconds).clamp(0.0, 1.0);
|
||||||
|
return LinearProgressIndicator(
|
||||||
|
key: const ValueKey('progress-line-standard'),
|
||||||
|
value: v,
|
||||||
|
minHeight: 2,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _top() {
|
||||||
|
final m = model;
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
const Expanded(child: NowPlayingTitle()),
|
||||||
|
UpNextButton(queue: m.queue, onPlayNextEpisode: m.playNextEpisode, look: UpNextLook.standard, hold: hold),
|
||||||
|
SignalBuilder(
|
||||||
|
builder: (context) {
|
||||||
|
final full = m.fullscreen.value;
|
||||||
|
return ExcludeFocus(
|
||||||
|
child: _IconButton(
|
||||||
|
key: const ValueKey('standard-collapse'),
|
||||||
|
icon: full ? Icons.fullscreen_exit_rounded : Icons.close_fullscreen_rounded,
|
||||||
|
tooltip: full ? 'Exit fullscreen (Esc)' : 'Back to the library (keeps playing)',
|
||||||
|
onPressed: full ? m.toggleFullscreen : m.collapse,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
?windowButtons,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _control(BuildContext context, BarControl c, ControlsSnapshot s) {
|
||||||
|
final m = model;
|
||||||
|
final key = ValueKey('control-${c.name}');
|
||||||
|
final enabled = controlEnabled(c, s);
|
||||||
|
Widget icon(VoidCallback? onPressed, {int badge = 0, double size = 24, Color? color}) => _IconButton(
|
||||||
|
key: key,
|
||||||
|
icon: controlIcon(c, s),
|
||||||
|
tooltip: controlTooltip(c, s),
|
||||||
|
badge: badge,
|
||||||
|
size: size * scale,
|
||||||
|
color: color,
|
||||||
|
onPressed: enabled ? onPressed : null,
|
||||||
|
);
|
||||||
|
switch (c) {
|
||||||
|
case BarControl.playPause:
|
||||||
|
final waiting = s.play == PlayState.waiting;
|
||||||
|
return icon(m.togglePlay, size: 30, color: waiting ? Theme.of(context).colorScheme.primary : null);
|
||||||
|
case BarControl.previous:
|
||||||
|
return icon(m.previous);
|
||||||
|
case BarControl.next:
|
||||||
|
return icon(m.next);
|
||||||
|
case BarControl.chapterPrevious:
|
||||||
|
return icon(() => m.stepChapter(-1));
|
||||||
|
case BarControl.chapterNext:
|
||||||
|
return icon(() => m.stepChapter(1));
|
||||||
|
case BarControl.volume:
|
||||||
|
return SignalBuilder(
|
||||||
|
key: key,
|
||||||
|
builder: (context) => VolumeControl(
|
||||||
|
volume: m.volume.value,
|
||||||
|
onChanged: m.setVolume,
|
||||||
|
onToggleMute: m.toggleMute,
|
||||||
|
button: (context, i, color, tip, onPressed) =>
|
||||||
|
_IconButton(icon: i, tooltip: tip, color: color, size: 24 * scale, onPressed: onPressed),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
case BarControl.time:
|
||||||
|
return SignalBuilder(
|
||||||
|
key: key,
|
||||||
|
builder: (context) => _Clock(
|
||||||
|
text: clockText(m.position.value, m.duration.value, m.settings.timeDisplay.value),
|
||||||
|
tooltip: controlTooltip(c, s),
|
||||||
|
scale: scale,
|
||||||
|
onPressed: m.toggleTimeDisplay,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
case BarControl.subtitles || BarControl.audio:
|
||||||
|
final type = c == BarControl.audio ? JellyfinStreamType.audio : JellyfinStreamType.subtitle;
|
||||||
|
return SignalBuilder(
|
||||||
|
key: key,
|
||||||
|
builder: (context) => Builder(
|
||||||
|
builder: (context) => _IconButton(
|
||||||
|
icon: controlIcon(c, s),
|
||||||
|
tooltip: controlTooltip(c, s),
|
||||||
|
badge: trackCount(m.tracks.value, type),
|
||||||
|
size: 22 * scale,
|
||||||
|
onPressed: () => openTrackMenu(context, m, type),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
case BarControl.settings:
|
||||||
|
return Builder(
|
||||||
|
key: key,
|
||||||
|
builder: (context) => _IconButton(
|
||||||
|
icon: controlIcon(c, s),
|
||||||
|
tooltip: controlTooltip(c, s),
|
||||||
|
size: 22 * scale,
|
||||||
|
onPressed: () => openControlsMenu(context, m),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
case BarControl.chat:
|
||||||
|
return SignalBuilder(
|
||||||
|
key: key,
|
||||||
|
builder: (context) => _IconButton(
|
||||||
|
icon: controlIcon(c, s),
|
||||||
|
tooltip: controlTooltip(c, s),
|
||||||
|
badge: m.unread.value,
|
||||||
|
size: 22 * scale,
|
||||||
|
onPressed: m.toggleChat,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
case BarControl.fullscreen:
|
||||||
|
return icon(m.toggleFullscreen, size: 26);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A round white icon button with an optional count badge (the Material
|
||||||
|
/// look of media_kit's bar).
|
||||||
|
class _IconButton extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String tooltip;
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
final int badge;
|
||||||
|
final double size;
|
||||||
|
final Color? color;
|
||||||
|
const _IconButton({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.tooltip,
|
||||||
|
required this.onPressed,
|
||||||
|
this.badge = 0,
|
||||||
|
this.size = 24,
|
||||||
|
this.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
return IconButton(
|
||||||
|
tooltip: tooltip,
|
||||||
|
iconSize: size,
|
||||||
|
color: color ?? Colors.white,
|
||||||
|
disabledColor: Colors.white38,
|
||||||
|
onPressed: onPressed,
|
||||||
|
icon: Badge(
|
||||||
|
isLabelVisible: badge > 0,
|
||||||
|
backgroundColor: scheme.primary,
|
||||||
|
textColor: scheme.onPrimary,
|
||||||
|
label: Text('$badge'),
|
||||||
|
child: Icon(icon),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The clock: "6:00 / 24:00" or "-18:00"; a click switches.
|
||||||
|
class _Clock extends StatelessWidget {
|
||||||
|
final String text;
|
||||||
|
final String tooltip;
|
||||||
|
final double scale;
|
||||||
|
final VoidCallback onPressed;
|
||||||
|
const _Clock({required this.text, required this.tooltip, required this.scale, required this.onPressed});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Tooltip(
|
||||||
|
message: tooltip,
|
||||||
|
waitDuration: const Duration(milliseconds: 500),
|
||||||
|
child: TextButton(
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 10 * scale),
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
),
|
||||||
|
onPressed: onPressed,
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: TextStyle(fontSize: 14 * scale, fontFeatures: const [FontFeature.tabularFigures()]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,359 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:signals/signals_flutter.dart';
|
|
||||||
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
|
|
||||||
|
|
||||||
import '../../jellyfin/track_prefs.dart' show MpvTrack;
|
|
||||||
import '../../playback/media_marks.dart';
|
|
||||||
import '../../playback/playback_controller.dart' show RenderMode;
|
|
||||||
import '../../screens/session/player_controls.dart' show TrackPick;
|
|
||||||
import '../../shaders/shader_menu.dart';
|
|
||||||
import '../../shaders/shader_presets.dart';
|
|
||||||
import '../../state/settings.dart';
|
|
||||||
import '../volume.dart';
|
|
||||||
import 'controls.dart';
|
|
||||||
import 'menu.dart';
|
|
||||||
import 'style.dart';
|
|
||||||
|
|
||||||
/// Flat uosc button: a white icon (optionally a [label] beside it and a
|
|
||||||
/// small count [badge] in the corner), a faint highlight on hover, a dark
|
|
||||||
/// tooltip. Never takes focus (the session's keys are global).
|
|
||||||
class UoscButton extends StatefulWidget {
|
|
||||||
final IconData icon;
|
|
||||||
final String tooltip;
|
|
||||||
final VoidCallback? onPressed;
|
|
||||||
final String? label;
|
|
||||||
final int? badge;
|
|
||||||
/// Tint for an "on" state (ready, shaders on).
|
|
||||||
final Color? color;
|
|
||||||
const UoscButton({
|
|
||||||
super.key,
|
|
||||||
required this.icon,
|
|
||||||
required this.tooltip,
|
|
||||||
required this.onPressed,
|
|
||||||
this.label,
|
|
||||||
this.badge,
|
|
||||||
this.color,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<UoscButton> createState() => _UoscButtonState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _UoscButtonState extends State<UoscButton> {
|
|
||||||
bool _hover = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final w = widget;
|
|
||||||
final color = w.onPressed == null ? UoscStyle.dim.withValues(alpha: 0.4) : (w.color ?? UoscStyle.text);
|
|
||||||
final badge = w.badge;
|
|
||||||
return UoscTooltip(
|
|
||||||
message: w.tooltip,
|
|
||||||
child: MouseRegion(
|
|
||||||
cursor: w.onPressed == null ? SystemMouseCursors.basic : SystemMouseCursors.click,
|
|
||||||
onEnter: (_) => setState(() => _hover = true),
|
|
||||||
onExit: (_) => setState(() => _hover = false),
|
|
||||||
child: GestureDetector(
|
|
||||||
behavior: HitTestBehavior.opaque,
|
|
||||||
onTap: w.onPressed,
|
|
||||||
child: Container(
|
|
||||||
height: UoscControls.buttonRowHeight,
|
|
||||||
constraints: const BoxConstraints(minWidth: UoscControls.buttonRowHeight),
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: w.label == null ? 0 : 8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: _hover && w.onPressed != null ? UoscStyle.hover : Colors.transparent,
|
|
||||||
borderRadius: BorderRadius.circular(UoscStyle.radius),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Stack(
|
|
||||||
clipBehavior: Clip.none,
|
|
||||||
children: [
|
|
||||||
Icon(w.icon, color: color, size: 22),
|
|
||||||
if (badge != null && badge > 0)
|
|
||||||
Positioned(
|
|
||||||
right: -8,
|
|
||||||
bottom: -5,
|
|
||||||
child: Container(
|
|
||||||
key: const ValueKey('uosc-badge'),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 3),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: UoscStyle.text,
|
|
||||||
borderRadius: BorderRadius.circular(3),
|
|
||||||
),
|
|
||||||
child: Text('$badge',
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.black, fontSize: 9, height: 1.3, fontWeight: FontWeight.w700)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if (w.label != null) ...[
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(w.label!, style: TextStyle(color: color, fontSize: UoscStyle.fontSize)),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Badge-style text button ("HQ" in uosc): a small outlined box with a
|
|
||||||
/// short label, filled when [active].
|
|
||||||
class UoscBadgeButton extends StatefulWidget {
|
|
||||||
final String text;
|
|
||||||
final String tooltip;
|
|
||||||
final bool active;
|
|
||||||
final VoidCallback onPressed;
|
|
||||||
const UoscBadgeButton({
|
|
||||||
super.key,
|
|
||||||
required this.text,
|
|
||||||
required this.tooltip,
|
|
||||||
required this.active,
|
|
||||||
required this.onPressed,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<UoscBadgeButton> createState() => _UoscBadgeButtonState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _UoscBadgeButtonState extends State<UoscBadgeButton> {
|
|
||||||
bool _hover = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final w = widget;
|
|
||||||
return UoscTooltip(
|
|
||||||
message: w.tooltip,
|
|
||||||
child: MouseRegion(
|
|
||||||
cursor: SystemMouseCursors.click,
|
|
||||||
onEnter: (_) => setState(() => _hover = true),
|
|
||||||
onExit: (_) => setState(() => _hover = false),
|
|
||||||
child: GestureDetector(
|
|
||||||
behavior: HitTestBehavior.opaque,
|
|
||||||
onTap: w.onPressed,
|
|
||||||
child: Container(
|
|
||||||
height: UoscControls.buttonRowHeight,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: _hover ? UoscStyle.hover : Colors.transparent,
|
|
||||||
borderRadius: BorderRadius.circular(UoscStyle.radius),
|
|
||||||
),
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: w.active ? UoscStyle.text : Colors.transparent,
|
|
||||||
border: Border.all(color: w.active ? UoscStyle.text : UoscStyle.dim, width: 1.4),
|
|
||||||
borderRadius: BorderRadius.circular(3),
|
|
||||||
),
|
|
||||||
child: Text(w.text,
|
|
||||||
style: TextStyle(
|
|
||||||
color: w.active ? Colors.black : UoscStyle.dim,
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
letterSpacing: 0.3)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Volume: click mutes / unmutes, hovering shows a [VolumeBar] up to
|
|
||||||
/// [maxVolume], the wheel steps [volumeStep].
|
|
||||||
class UoscVolumeButton extends StatefulWidget {
|
|
||||||
/// 0..[maxVolume].
|
|
||||||
final double volume;
|
|
||||||
final ValueChanged<double> onChanged;
|
|
||||||
const UoscVolumeButton({super.key, required this.volume, required this.onChanged});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<UoscVolumeButton> createState() => _UoscVolumeButtonState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _UoscVolumeButtonState extends State<UoscVolumeButton> {
|
|
||||||
final _steps = WheelSteps();
|
|
||||||
double _beforeMute = 100;
|
|
||||||
bool _hover = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final v = widget.volume;
|
|
||||||
final icon = v <= 0 ? Icons.volume_off_rounded : (v < 50 ? Icons.volume_down_rounded : Icons.volume_up_rounded);
|
|
||||||
return MouseRegion(
|
|
||||||
onEnter: (_) => setState(() => _hover = true),
|
|
||||||
onExit: (_) => setState(() => _hover = false),
|
|
||||||
child: Listener(
|
|
||||||
// Claimed here, so the player's own wheel handler skips it.
|
|
||||||
onPointerSignal: (e) => _steps.handle(e, (step) => widget.onChanged(stepVolume(v, step * volumeStep))),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
AnimatedSize(
|
|
||||||
duration: const Duration(milliseconds: 150),
|
|
||||||
child: _hover
|
|
||||||
? Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 6, right: 2),
|
|
||||||
child: VolumeBar(
|
|
||||||
key: const ValueKey('uosc-volume-bar'),
|
|
||||||
volume: v,
|
|
||||||
onChanged: widget.onChanged,
|
|
||||||
color: UoscStyle.text,
|
|
||||||
trackColor: UoscStyle.dim.withValues(alpha: 0.5),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const SizedBox.shrink(),
|
|
||||||
),
|
|
||||||
UoscButton(
|
|
||||||
key: const ValueKey('uosc-volume'),
|
|
||||||
icon: icon,
|
|
||||||
color: v > 100 ? boostColor : null,
|
|
||||||
tooltip: 'Volume ${v.round()} % (scroll to change)',
|
|
||||||
onPressed: () {
|
|
||||||
if (v > 0) {
|
|
||||||
_beforeMute = v;
|
|
||||||
widget.onChanged(0);
|
|
||||||
} else {
|
|
||||||
widget.onChanged(_beforeMute <= 0 ? 100 : _beforeMute);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Subtitles or audio: the icon with the track count; the menu lists the
|
|
||||||
/// tracks (mpv's selection active).
|
|
||||||
class UoscTracksButton extends StatelessWidget {
|
|
||||||
final JellyfinStreamType type;
|
|
||||||
final List<MpvTrack> tracks;
|
|
||||||
final void Function(TrackPick pick) onPick;
|
|
||||||
final ValueChanged<bool>? onMenuOpen;
|
|
||||||
const UoscTracksButton({super.key, required this.type, required this.tracks, required this.onPick, this.onMenuOpen});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final audio = type == JellyfinStreamType.audio;
|
|
||||||
final count = tracks.where((t) => t.type == type).length;
|
|
||||||
return UoscButton(
|
|
||||||
key: ValueKey(audio ? 'uosc-audio' : 'uosc-subtitles'),
|
|
||||||
icon: audio ? Icons.graphic_eq_rounded : Icons.subtitles_outlined,
|
|
||||||
tooltip: audio ? 'Audio' : 'Subtitles',
|
|
||||||
badge: count,
|
|
||||||
onPressed: () async {
|
|
||||||
onMenuOpen?.call(true);
|
|
||||||
final pick = await showUoscMenu<TrackPick>(context,
|
|
||||||
title: audio ? 'Audio' : 'Subtitles', items: trackMenuItems(type, tracks));
|
|
||||||
onMenuOpen?.call(false);
|
|
||||||
if (pick != null) onPick(pick);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Chapters: the list with start times, the playing one active; a pick
|
|
||||||
/// jumps there. Nothing without chapters.
|
|
||||||
class UoscChaptersButton extends StatelessWidget {
|
|
||||||
final List<MediaChapter> chapters;
|
|
||||||
final int? current;
|
|
||||||
final Duration duration;
|
|
||||||
final ValueChanged<MediaChapter> onPick;
|
|
||||||
final ValueChanged<bool>? onMenuOpen;
|
|
||||||
const UoscChaptersButton({
|
|
||||||
super.key,
|
|
||||||
required this.chapters,
|
|
||||||
required this.current,
|
|
||||||
required this.duration,
|
|
||||||
required this.onPick,
|
|
||||||
this.onMenuOpen,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
if (chapters.isEmpty) return const SizedBox.shrink();
|
|
||||||
return UoscButton(
|
|
||||||
key: const ValueKey('uosc-chapters'),
|
|
||||||
icon: Icons.bookmarks_outlined,
|
|
||||||
tooltip: 'Chapters (PgUp / PgDn)',
|
|
||||||
onPressed: () async {
|
|
||||||
onMenuOpen?.call(true);
|
|
||||||
final pick = await showUoscMenu<MediaChapter>(context,
|
|
||||||
title: 'Chapters', items: chapterMenuItems(chapters, current, duration));
|
|
||||||
onMenuOpen?.call(false);
|
|
||||||
if (pick != null) onPick(pick);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shaders as uosc's quality badge: the active Anime4K preset ("A HQ"),
|
|
||||||
/// "4K" dimmed when off. Opens the shader menu.
|
|
||||||
class UoscShaderButton extends StatelessWidget {
|
|
||||||
final ReadonlySignal<RenderMode> renderMode;
|
|
||||||
final void Function(ShaderPreset preset, ShaderQuality? quality) onPick;
|
|
||||||
final ValueChanged<bool>? onMenuOpen;
|
|
||||||
const UoscShaderButton({super.key, required this.renderMode, required this.onPick, this.onMenuOpen});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final s = AppSettings.instance;
|
|
||||||
return SignalBuilder(builder: (context) {
|
|
||||||
final preset = s.shaderPreset.value;
|
|
||||||
final quality = s.shaderQuality.value;
|
|
||||||
final short = shaderShortLabel(preset, quality);
|
|
||||||
return Builder(
|
|
||||||
builder: (context) => UoscBadgeButton(
|
|
||||||
key: const ValueKey('uosc-shaders'),
|
|
||||||
text: short ?? '4K',
|
|
||||||
active: short != null,
|
|
||||||
tooltip: short == null
|
|
||||||
? 'Shaders: off (Ctrl+1..6)'
|
|
||||||
: 'Shaders: ${shaderLabel(preset, quality)} (Ctrl+0 off)',
|
|
||||||
onPressed: () {
|
|
||||||
final controls = UoscControls.maybeOf(context);
|
|
||||||
Future<void> open() =>
|
|
||||||
showShaderMenu(context, renderMode: renderMode, onPick: onPick, onMenuOpen: onMenuOpen);
|
|
||||||
controls == null ? open() : controls.holdDuring(open);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One entry of the ≡ menu.
|
|
||||||
typedef UoscAction = ({String title, String? hint, VoidCallback run});
|
|
||||||
|
|
||||||
/// ≡: the session's actions as a menu.
|
|
||||||
class UoscMainMenuButton extends StatelessWidget {
|
|
||||||
final List<UoscAction> actions;
|
|
||||||
final ValueChanged<bool>? onMenuOpen;
|
|
||||||
const UoscMainMenuButton({super.key, required this.actions, this.onMenuOpen});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return UoscButton(
|
|
||||||
key: const ValueKey('uosc-menu-button'),
|
|
||||||
icon: Icons.menu_rounded,
|
|
||||||
tooltip: 'Menu',
|
|
||||||
onPressed: () async {
|
|
||||||
onMenuOpen?.call(true);
|
|
||||||
final pick = await showUoscMenu<UoscAction>(context, items: [
|
|
||||||
for (final a in actions) UoscMenuItem(value: a, title: a.title, hint: a.hint),
|
|
||||||
]);
|
|
||||||
onMenuOpen?.call(false);
|
|
||||||
pick?.run();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,293 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:signals/signals_flutter.dart';
|
|
||||||
|
|
||||||
import '../../state/settings.dart';
|
|
||||||
import '../controls_probe.dart' show ControlsHold;
|
|
||||||
|
|
||||||
// The mpv-style (uosc) controls frame: a full-width timeline at the
|
|
||||||
// bottom with a row of flat buttons above it, the title top left and a
|
|
||||||
// corner widget top right.
|
|
||||||
// Mouse movement shows them; after [UoscControls.hideAfter] without any
|
|
||||||
// they fade out, the timeline shrinks to uosc's progress line (gone when
|
|
||||||
// "Progress line when hidden" is off) and the cursor hides. Our own frame: no
|
|
||||||
// media_kit controls state to reach into.
|
|
||||||
|
|
||||||
class UoscControls extends StatefulWidget {
|
|
||||||
/// The timeline; [expanded] is false while the controls are hidden.
|
|
||||||
final Widget Function(BuildContext context, bool expanded) timeline;
|
|
||||||
/// Buttons left and right of the row above the timeline.
|
|
||||||
final List<Widget> left;
|
|
||||||
final List<Widget> right;
|
|
||||||
/// Top-right corner (chat, back to the library), shown with the controls.
|
|
||||||
final Widget? top;
|
|
||||||
/// Top left (what is playing), shown with the controls; gets the width
|
|
||||||
/// [top] leaves.
|
|
||||||
final Widget? topLeft;
|
|
||||||
final VoidCallback? onDoubleTap;
|
|
||||||
final Duration hideAfter;
|
|
||||||
/// Follows whether the controls are up (for overlays outside them).
|
|
||||||
final ValueNotifier<bool>? shown;
|
|
||||||
/// Keeps them up while held (the mouse on an overlay, an open popover).
|
|
||||||
final ControlsHold? holdBy;
|
|
||||||
const UoscControls({
|
|
||||||
super.key,
|
|
||||||
required this.timeline,
|
|
||||||
this.left = const [],
|
|
||||||
this.right = const [],
|
|
||||||
this.top,
|
|
||||||
this.topLeft,
|
|
||||||
this.onDoubleTap,
|
|
||||||
this.hideAfter = const Duration(seconds: 3),
|
|
||||||
this.shown,
|
|
||||||
this.holdBy,
|
|
||||||
});
|
|
||||||
|
|
||||||
static const buttonRowHeight = 40.0;
|
|
||||||
/// Under the button row, above the timeline.
|
|
||||||
static const rowBottomPadding = 4.0;
|
|
||||||
|
|
||||||
/// The controls around [context], for widgets that need them kept up
|
|
||||||
/// (a timeline drag, an open menu).
|
|
||||||
static UoscControlsState? maybeOf(BuildContext context) =>
|
|
||||||
context.findAncestorStateOfType<UoscControlsState>();
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<UoscControls> createState() => UoscControlsState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class UoscControlsState extends State<UoscControls> {
|
|
||||||
bool _visible = true;
|
|
||||||
int _holds = 0;
|
|
||||||
bool _heldBy = false;
|
|
||||||
Timer? _timer;
|
|
||||||
|
|
||||||
bool get visible => _visible;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
widget.holdBy?.addListener(_holdByChanged);
|
|
||||||
// Not during this build: an overlay listening to [shown] may be built.
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (mounted) widget.shown?.value = true;
|
|
||||||
});
|
|
||||||
_hideLater();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didUpdateWidget(UoscControls oldWidget) {
|
|
||||||
super.didUpdateWidget(oldWidget);
|
|
||||||
if (oldWidget.holdBy != widget.holdBy) {
|
|
||||||
oldWidget.holdBy?.removeListener(_holdByChanged);
|
|
||||||
widget.holdBy?.addListener(_holdByChanged);
|
|
||||||
_holdByChanged();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
widget.holdBy?.removeListener(_holdByChanged);
|
|
||||||
_timer?.cancel();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _holdByChanged() {
|
|
||||||
final on = widget.holdBy?.held ?? false;
|
|
||||||
if (on == _heldBy || !mounted) return;
|
|
||||||
_heldBy = on;
|
|
||||||
hold(on);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Show the controls and restart the hide countdown.
|
|
||||||
void wake() {
|
|
||||||
if (!_visible && mounted) {
|
|
||||||
setState(() => _visible = true);
|
|
||||||
widget.shown?.value = true;
|
|
||||||
}
|
|
||||||
_hideLater();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Keep the controls up while [on] (paired calls): a drag sends no
|
|
||||||
/// hover events and a menu route takes the mouse.
|
|
||||||
void hold(bool on) {
|
|
||||||
_holds = on ? _holds + 1 : (_holds - 1).clamp(0, 1 << 20);
|
|
||||||
wake();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runs [open] (a menu) with the controls held up.
|
|
||||||
Future<T> holdDuring<T>(Future<T> Function() open) async {
|
|
||||||
hold(true);
|
|
||||||
try {
|
|
||||||
return await open();
|
|
||||||
} finally {
|
|
||||||
if (mounted) hold(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _hideLater() {
|
|
||||||
_timer?.cancel();
|
|
||||||
_timer = Timer(widget.hideAfter, _hide);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _hide() {
|
|
||||||
if (!mounted || _holds > 0 || !_visible) return;
|
|
||||||
setState(() => _visible = false);
|
|
||||||
widget.shown?.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final visible = _visible;
|
|
||||||
const fade = Duration(milliseconds: 150);
|
|
||||||
return MouseRegion(
|
|
||||||
cursor: visible ? SystemMouseCursors.basic : SystemMouseCursors.none,
|
|
||||||
onEnter: (_) => wake(),
|
|
||||||
onHover: (_) => wake(),
|
|
||||||
onExit: (_) {
|
|
||||||
_timer?.cancel();
|
|
||||||
_hide();
|
|
||||||
},
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
Positioned.fill(
|
|
||||||
child: GestureDetector(
|
|
||||||
behavior: HitTestBehavior.opaque,
|
|
||||||
onDoubleTap: widget.onDoubleTap,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
height: 160,
|
|
||||||
child: IgnorePointer(
|
|
||||||
child: AnimatedOpacity(
|
|
||||||
duration: fade,
|
|
||||||
opacity: visible ? 1 : 0,
|
|
||||||
child: const DecoratedBox(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: Alignment.bottomCenter,
|
|
||||||
colors: [Color(0x00000000), Color(0x8C000000)],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (widget.topLeft != null)
|
|
||||||
Positioned(
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
top: 0,
|
|
||||||
height: 120,
|
|
||||||
child: IgnorePointer(
|
|
||||||
child: AnimatedOpacity(
|
|
||||||
duration: fade,
|
|
||||||
opacity: visible ? 1 : 0,
|
|
||||||
child: const DecoratedBox(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: Alignment.bottomCenter,
|
|
||||||
colors: [Color(0x8C000000), Color(0x00000000)],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (widget.top != null || widget.topLeft != null)
|
|
||||||
Positioned(
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
child: IgnorePointer(
|
|
||||||
key: const ValueKey('uosc-top'),
|
|
||||||
ignoring: !visible,
|
|
||||||
child: AnimatedOpacity(
|
|
||||||
duration: fade,
|
|
||||||
opacity: visible ? 1 : 0,
|
|
||||||
// The buttons centred on the title, like the standard
|
|
||||||
// design's top bar.
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
|
||||||
child: widget.topLeft == null
|
|
||||||
? const SizedBox.shrink()
|
|
||||||
: Align(alignment: AlignmentDirectional.centerStart, child: widget.topLeft),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (widget.top != null)
|
|
||||||
Padding(padding: const EdgeInsets.fromLTRB(0, 12, 6, 0), child: widget.top),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
IgnorePointer(
|
|
||||||
key: const ValueKey('uosc-buttons'),
|
|
||||||
ignoring: !visible,
|
|
||||||
child: AnimatedOpacity(
|
|
||||||
duration: fade,
|
|
||||||
opacity: visible ? 1 : 0,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(6, 0, 6, UoscControls.rowBottomPadding),
|
|
||||||
child: SizedBox(
|
|
||||||
height: UoscControls.buttonRowHeight,
|
|
||||||
child: Row(children: [...widget.left, const Spacer(), ...widget.right]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Hidden: the collapsed timeline is the progress line,
|
|
||||||
// unless the player settings turn it off.
|
|
||||||
SignalBuilder(
|
|
||||||
builder: (context) {
|
|
||||||
final line = AppSettings.instance.progressLine.value;
|
|
||||||
return AnimatedOpacity(
|
|
||||||
key: const ValueKey('uosc-timeline-wrap'),
|
|
||||||
duration: fade,
|
|
||||||
opacity: visible || line ? 1 : 0,
|
|
||||||
child: widget.timeline(context, visible),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The session's controls: the standard ones ([material]) or, with the
|
|
||||||
/// "Player design" setting on uosc, the uosc ones ([mpv]). Switches live
|
|
||||||
/// when the setting changes.
|
|
||||||
class SessionControlsSwitch extends StatelessWidget {
|
|
||||||
final WidgetBuilder material;
|
|
||||||
final WidgetBuilder mpv;
|
|
||||||
const SessionControlsSwitch({super.key, required this.material, required this.mpv});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return SignalBuilder(
|
|
||||||
builder: (context) =>
|
|
||||||
AppSettings.instance.mpvControls.value ? mpv(context) : material(context),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
import 'dart:math' as math;
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
|
|
||||||
|
|
||||||
import '../../jellyfin/track_prefs.dart' show MpvTrack;
|
|
||||||
import '../../playback/media_marks.dart';
|
|
||||||
import '../../screens/session/player_controls.dart' show TrackPick;
|
|
||||||
import 'controls.dart';
|
|
||||||
import 'style.dart';
|
|
||||||
|
|
||||||
/// One row of a uosc menu: [title], a dim [hint] on the right (a time, a
|
|
||||||
/// key), [active] = the current one (highlighted).
|
|
||||||
class UoscMenuItem<T> {
|
|
||||||
final T value;
|
|
||||||
final String title;
|
|
||||||
final String? hint;
|
|
||||||
final bool active;
|
|
||||||
final bool enabled;
|
|
||||||
const UoscMenuItem({
|
|
||||||
required this.value,
|
|
||||||
required this.title,
|
|
||||||
this.hint,
|
|
||||||
this.active = false,
|
|
||||||
this.enabled = true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A flat dark menu over the controls, above the widget of [context] (the
|
|
||||||
/// button that opens it), with an optional [title] row. Resolves to the
|
|
||||||
/// picked value, or null when dismissed (click outside, Esc). Keeps the
|
|
||||||
/// controls up while open.
|
|
||||||
Future<T?> showUoscMenu<T>(
|
|
||||||
BuildContext context, {
|
|
||||||
String? title,
|
|
||||||
required List<UoscMenuItem<T>> items,
|
|
||||||
}) {
|
|
||||||
final navigator = Navigator.of(context);
|
|
||||||
final overlay = navigator.overlay!.context.findRenderObject() as RenderBox;
|
|
||||||
final box = context.findRenderObject() as RenderBox;
|
|
||||||
final anchor = Rect.fromPoints(
|
|
||||||
box.localToGlobal(Offset.zero, ancestor: overlay),
|
|
||||||
box.localToGlobal(box.size.bottomRight(Offset.zero), ancestor: overlay),
|
|
||||||
);
|
|
||||||
Future<T?> open() => navigator.push<T>(_UoscMenuRoute<T>(anchor: anchor, title: title, items: items));
|
|
||||||
final controls = UoscControls.maybeOf(context);
|
|
||||||
return controls == null ? open() : controls.holdDuring(open);
|
|
||||||
}
|
|
||||||
|
|
||||||
class _UoscMenuRoute<T> extends PopupRoute<T> {
|
|
||||||
final Rect anchor;
|
|
||||||
final String? title;
|
|
||||||
final List<UoscMenuItem<T>> items;
|
|
||||||
_UoscMenuRoute({required this.anchor, required this.title, required this.items});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Color? get barrierColor => null;
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool get barrierDismissible => true;
|
|
||||||
|
|
||||||
@override
|
|
||||||
String? get barrierLabel => 'Close menu';
|
|
||||||
|
|
||||||
@override
|
|
||||||
Duration get transitionDuration => Duration.zero;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
|
|
||||||
return LayoutBuilder(builder: (context, constraints) {
|
|
||||||
const width = 360.0;
|
|
||||||
final screen = constraints.biggest;
|
|
||||||
final left = anchor.left.clamp(8.0, math.max(8.0, screen.width - width - 8));
|
|
||||||
final maxHeight = math.max(120.0, anchor.top - 16);
|
|
||||||
return Stack(
|
|
||||||
children: [
|
|
||||||
Positioned(
|
|
||||||
left: left.toDouble(),
|
|
||||||
bottom: screen.height - anchor.top + 6,
|
|
||||||
width: math.min(width, screen.width - 16),
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: BoxConstraints(maxHeight: maxHeight),
|
|
||||||
child: UoscMenuPanel<T>(
|
|
||||||
title: title,
|
|
||||||
items: items,
|
|
||||||
onPick: (v) => Navigator.of(context).pop(v),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The menu's panel alone (tests pump it without a route).
|
|
||||||
class UoscMenuPanel<T> extends StatefulWidget {
|
|
||||||
final String? title;
|
|
||||||
final List<UoscMenuItem<T>> items;
|
|
||||||
final ValueChanged<T> onPick;
|
|
||||||
const UoscMenuPanel({super.key, this.title, required this.items, required this.onPick});
|
|
||||||
|
|
||||||
static const rowHeight = 36.0;
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<UoscMenuPanel<T>> createState() => _UoscMenuPanelState<T>();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _UoscMenuPanelState<T> extends State<UoscMenuPanel<T>> {
|
|
||||||
// Long lists open on the current row.
|
|
||||||
late final _scroll = ScrollController(
|
|
||||||
initialScrollOffset: math.max(0, widget.items.indexWhere((i) => i.active) - 3) * UoscMenuPanel.rowHeight);
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_scroll.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final title = widget.title;
|
|
||||||
return Material(
|
|
||||||
key: const ValueKey('uosc-menu'),
|
|
||||||
color: UoscStyle.panel,
|
|
||||||
borderRadius: BorderRadius.circular(UoscStyle.radius * 2),
|
|
||||||
clipBehavior: Clip.antiAlias,
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
if (title != null)
|
|
||||||
Container(
|
|
||||||
height: 34,
|
|
||||||
alignment: Alignment.center,
|
|
||||||
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Color(0x22FFFFFF)))),
|
|
||||||
child: Text(title,
|
|
||||||
style: const TextStyle(color: UoscStyle.dim, fontSize: UoscStyle.fontSize, fontWeight: FontWeight.w600)),
|
|
||||||
),
|
|
||||||
Flexible(
|
|
||||||
child: ListView.builder(
|
|
||||||
shrinkWrap: true,
|
|
||||||
padding: const EdgeInsets.all(4),
|
|
||||||
controller: _scroll,
|
|
||||||
itemCount: widget.items.length,
|
|
||||||
itemExtent: UoscMenuPanel.rowHeight,
|
|
||||||
itemBuilder: (context, i) => _Row<T>(item: widget.items[i], onPick: widget.onPick),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _Row<T> extends StatefulWidget {
|
|
||||||
final UoscMenuItem<T> item;
|
|
||||||
final ValueChanged<T> onPick;
|
|
||||||
const _Row({super.key, required this.item, required this.onPick});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_Row<T>> createState() => _RowState<T>();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _RowState<T> extends State<_Row<T>> {
|
|
||||||
bool _hover = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final item = widget.item;
|
|
||||||
final color = item.active
|
|
||||||
? UoscStyle.fill
|
|
||||||
: _hover && item.enabled
|
|
||||||
? UoscStyle.hover
|
|
||||||
: Colors.transparent;
|
|
||||||
final textColor = item.enabled ? UoscStyle.text : UoscStyle.dim.withValues(alpha: 0.4);
|
|
||||||
return MouseRegion(
|
|
||||||
cursor: item.enabled ? SystemMouseCursors.click : SystemMouseCursors.basic,
|
|
||||||
onEnter: (_) => setState(() => _hover = true),
|
|
||||||
onExit: (_) => setState(() => _hover = false),
|
|
||||||
child: GestureDetector(
|
|
||||||
key: ValueKey('uosc-menu-item-${item.title}'),
|
|
||||||
behavior: HitTestBehavior.opaque,
|
|
||||||
onTap: item.enabled ? () => widget.onPick(item.value) : null,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
|
||||||
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(UoscStyle.radius)),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(item.title,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(
|
|
||||||
color: textColor,
|
|
||||||
fontSize: UoscStyle.fontSize,
|
|
||||||
fontWeight: item.active ? FontWeight.w600 : FontWeight.normal)),
|
|
||||||
),
|
|
||||||
if (item.hint != null) ...[
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Text(item.hint!,
|
|
||||||
style: TextStyle(
|
|
||||||
color: item.active ? UoscStyle.text : UoscStyle.dim,
|
|
||||||
fontSize: 12,
|
|
||||||
fontFeatures: const [FontFeature.tabularFigures()])),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Chapters with their start times, the playing one active.
|
|
||||||
List<UoscMenuItem<MediaChapter>> chapterMenuItems(List<MediaChapter> chapters, int? current, Duration duration) {
|
|
||||||
final withHours = duration >= const Duration(hours: 1);
|
|
||||||
return [
|
|
||||||
for (final c in chapters)
|
|
||||||
UoscMenuItem(
|
|
||||||
value: c,
|
|
||||||
title: c.title.isEmpty ? 'Chapter ${c.index + 1}' : c.title,
|
|
||||||
hint: formatUoscTime(c.start, withHours: withHours),
|
|
||||||
active: c.index == current,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The audio or subtitle tracks ("Japanese · E-AC3 2.0 · title"), mpv's
|
|
||||||
/// selection active; subtitles start with Off.
|
|
||||||
List<UoscMenuItem<TrackPick>> trackMenuItems(JellyfinStreamType type, List<MpvTrack> tracks) {
|
|
||||||
final audio = type == JellyfinStreamType.audio;
|
|
||||||
final list = tracks.where((t) => t.type == type).toList();
|
|
||||||
return [
|
|
||||||
if (!audio)
|
|
||||||
UoscMenuItem(
|
|
||||||
value: (aid: null, sid: null, subtitlesOff: true),
|
|
||||||
title: 'Off',
|
|
||||||
active: !list.any((t) => t.selected),
|
|
||||||
),
|
|
||||||
for (final t in list)
|
|
||||||
UoscMenuItem(
|
|
||||||
value: audio ? (aid: t.id, sid: null, subtitlesOff: false) : (aid: null, sid: t.id, subtitlesOff: false),
|
|
||||||
title: t.label,
|
|
||||||
hint: '#${t.id}',
|
|
||||||
active: t.selected,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
// Look of the experimental mpv-style controls, after the uosc theme as the
|
|
||||||
// owner runs it: flat black panels, white icons, a muted blue fill.
|
|
||||||
|
|
||||||
abstract final class UoscStyle {
|
|
||||||
/// The active menu row.
|
|
||||||
static const fill = Color(0xFF4C6A8E);
|
|
||||||
/// Menus and tooltips.
|
|
||||||
static const panel = Color(0xF0101010);
|
|
||||||
static const hover = Color(0x24FFFFFF);
|
|
||||||
static const text = Colors.white;
|
|
||||||
static const dim = Color(0xA6FFFFFF);
|
|
||||||
static const radius = 4.0;
|
|
||||||
static const fontSize = 13.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// uosc's clock: "01:51", "1:02:03" style but zero-padded ("01:02:03")
|
|
||||||
/// once [withHours] (the media is an hour or longer).
|
|
||||||
String formatUoscTime(Duration d, {bool withHours = false}) {
|
|
||||||
final t = d.isNegative ? Duration.zero : d;
|
|
||||||
String two(int n) => n.toString().padLeft(2, '0');
|
|
||||||
final h = t.inHours, m = t.inMinutes % 60, s = t.inSeconds % 60;
|
|
||||||
return withHours || h > 0 ? '${two(h)}:${two(m)}:${two(s)}' : '${two(m)}:${two(s)}';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dark rounded hover tip ("Subtitles"), shown above its child.
|
|
||||||
class UoscTooltip extends StatelessWidget {
|
|
||||||
final String message;
|
|
||||||
final Widget child;
|
|
||||||
const UoscTooltip({super.key, required this.message, required this.child});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
if (message.isEmpty) return child;
|
|
||||||
return Tooltip(
|
|
||||||
message: message,
|
|
||||||
preferBelow: false,
|
|
||||||
verticalOffset: 26,
|
|
||||||
waitDuration: const Duration(milliseconds: 250),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: UoscStyle.panel,
|
|
||||||
borderRadius: BorderRadius.circular(UoscStyle.radius),
|
|
||||||
),
|
|
||||||
textStyle: const TextStyle(color: UoscStyle.text, fontSize: 12),
|
|
||||||
child: child,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A small dark box with one line of text (the timeline's hover time and
|
|
||||||
/// chapter title).
|
|
||||||
class UoscBox extends StatelessWidget {
|
|
||||||
final String text;
|
|
||||||
final Color color;
|
|
||||||
final TextStyle style;
|
|
||||||
const UoscBox(
|
|
||||||
this.text, {
|
|
||||||
super.key,
|
|
||||||
this.color = UoscStyle.panel,
|
|
||||||
this.style = const TextStyle(color: UoscStyle.text, fontSize: UoscStyle.fontSize),
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 360),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
|
||||||
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(UoscStyle.radius)),
|
|
||||||
child: Text(text,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: style.copyWith(fontFeatures: const [FontFeature.tabularFigures()])),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,645 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:math' as math;
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import '../../playback/media_marks.dart';
|
|
||||||
import 'controls.dart';
|
|
||||||
import 'style.dart';
|
|
||||||
|
|
||||||
// A port of uosc's timeline (`elements/Timeline.lua`, defaults from
|
|
||||||
// `uosc.conf` / `main.lua`), style `line`, scale 1: a translucent black
|
|
||||||
// band, the position a thin white line (no filled progress), the
|
|
||||||
// uncached part hatched, chapter diamonds on the top edge, elapsed time
|
|
||||||
// left and the time remaining right, the buffered seconds next to the
|
|
||||||
// position while under the threshold, and a tooltip with the time (and
|
|
||||||
// the chapter title) under the mouse.
|
|
||||||
|
|
||||||
/// uosc's defaults at scale 1.
|
|
||||||
abstract final class UoscTimelineSpec {
|
|
||||||
/// `timeline_size`: the band's height when expanded.
|
|
||||||
static const size = 40.0;
|
|
||||||
|
|
||||||
/// `timeline_border`: band above the line, also the diamonds' border.
|
|
||||||
static const border = 1.0;
|
|
||||||
|
|
||||||
/// `timeline_line_width`: the position line.
|
|
||||||
static const lineWidth = 2.0;
|
|
||||||
|
|
||||||
/// `progress_size`: what is left when the controls hide.
|
|
||||||
static const progressSize = 2.0;
|
|
||||||
|
|
||||||
/// `progress_line_width`: the position line once minimized.
|
|
||||||
static const progressLineWidth = 20.0;
|
|
||||||
|
|
||||||
/// floor(min((size + 60) * 0.2, size * 0.96)).
|
|
||||||
static const fontSize = 20.0;
|
|
||||||
|
|
||||||
/// max(floor((size - fontSize) / 2.5), 4): the times' inset.
|
|
||||||
static const spacing = 8.0;
|
|
||||||
|
|
||||||
/// `text_border`: the outline of text drawn on the band.
|
|
||||||
static const textBorder = 1.2;
|
|
||||||
|
|
||||||
/// `opacity.timeline` (the band) and `opacity.chapters` (diamond fill).
|
|
||||||
static const opacity = 0.9;
|
|
||||||
static const chaptersOpacity = 0.8;
|
|
||||||
|
|
||||||
/// Gap between the band and its tooltips, and between tooltips.
|
|
||||||
static const tooltipGap = 2.0;
|
|
||||||
|
|
||||||
/// `buffered_time_threshold`: the cache label shows below this.
|
|
||||||
static const bufferedThreshold = Duration(seconds: 60);
|
|
||||||
|
|
||||||
/// max((size + border) / 10, 3): diamond radius cap.
|
|
||||||
static const chapterSize = (size + border) / 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// uosc's `format_time(seconds, duration)`: mpv's "HH:MM:SS", trimmed to
|
|
||||||
/// "MM:SS" for media under an hour and "SS" under a minute; negative
|
|
||||||
/// values keep their minus ("-01:47:30").
|
|
||||||
String uoscClock(Duration d, Duration max) {
|
|
||||||
final neg = d.isNegative && d.inSeconds != 0;
|
|
||||||
final t = d.abs();
|
|
||||||
String two(int n) => n.toString().padLeft(2, '0');
|
|
||||||
final full = '${two(t.inHours)}:${two(t.inMinutes % 60)}:${two(t.inSeconds % 60)}';
|
|
||||||
final trim = max < const Duration(minutes: 1) ? 6 : (max < const Duration(hours: 1) ? 3 : 0);
|
|
||||||
return '${neg ? '-' : ''}${full.substring(trim)}';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fraction of [total] at [d], 0..1 (0 without a duration).
|
|
||||||
double timelineFraction(Duration d, Duration total) {
|
|
||||||
final t = total.inMicroseconds;
|
|
||||||
if (t <= 0) return 0;
|
|
||||||
return (d.inMicroseconds / t).clamp(0.0, 1.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Where the uncached part starts (it runs to the end), as a fraction;
|
|
||||||
/// null when nothing known is uncached. We know only the end of mpv's
|
|
||||||
/// cache ahead ([buffered]); without it (0) uosc draws no ranges either,
|
|
||||||
/// and a cache that reaches the end (within uosc's 0.5 s) leaves none.
|
|
||||||
double? uncachedFrom({required Duration position, required Duration buffered, required Duration duration}) {
|
|
||||||
if (duration <= Duration.zero || buffered <= Duration.zero) return null;
|
|
||||||
final from = buffered > position ? buffered : position;
|
|
||||||
if (duration - from <= const Duration(milliseconds: 500)) return null;
|
|
||||||
return timelineFraction(from, duration);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The cache label next to the position ("12s"): the seconds buffered
|
|
||||||
/// ahead, shown while under [UoscTimelineSpec.bufferedThreshold] and the
|
|
||||||
/// cache does not reach the end (uosc drops it at eof-cached).
|
|
||||||
String? cacheLabel({required Duration position, required Duration buffered, required Duration duration}) {
|
|
||||||
if (duration <= Duration.zero || buffered <= Duration.zero) return null;
|
|
||||||
if (duration - buffered <= const Duration(milliseconds: 500)) return null;
|
|
||||||
final ahead = buffered > position ? buffered - position : Duration.zero;
|
|
||||||
if (ahead >= UoscTimelineSpec.bufferedThreshold) return null;
|
|
||||||
return '${(ahead.inMilliseconds / 1000).round()}s';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The band's geometry at one size, as uosc lays it out: the position
|
|
||||||
/// line takes [lineWidth] pixels, and times map around it (`t2x`) so no
|
|
||||||
/// time lies inside the line.
|
|
||||||
class UoscTimelineGeometry {
|
|
||||||
final double width;
|
|
||||||
|
|
||||||
/// The foreground height (without the top border).
|
|
||||||
final double size;
|
|
||||||
|
|
||||||
/// The playing position, 0..1.
|
|
||||||
final double progress;
|
|
||||||
final double lineWidth;
|
|
||||||
const UoscTimelineGeometry({
|
|
||||||
required this.width,
|
|
||||||
required this.size,
|
|
||||||
required this.progress,
|
|
||||||
this.lineWidth = UoscTimelineSpec.lineWidth,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// [size] while the band animates between hidden (0) and shown (1)
|
|
||||||
/// (`get_effective_size`).
|
|
||||||
static double sizeAt(double visibility) =>
|
|
||||||
UoscTimelineSpec.progressSize +
|
|
||||||
((UoscTimelineSpec.size - UoscTimelineSpec.progressSize) * visibility).ceilToDouble();
|
|
||||||
|
|
||||||
/// The position line's width at [size]: it grows to
|
|
||||||
/// [UoscTimelineSpec.progressLineWidth] over the last eighth of the
|
|
||||||
/// collapse.
|
|
||||||
static double lineWidthAt(double size) {
|
|
||||||
const p = UoscTimelineSpec.progressSize;
|
|
||||||
final minimized = 1 - math.min((size - p) / ((UoscTimelineSpec.size - p) / 8), 1.0);
|
|
||||||
return UoscTimelineSpec.lineWidth + (UoscTimelineSpec.progressLineWidth - UoscTimelineSpec.lineWidth) * minimized;
|
|
||||||
}
|
|
||||||
|
|
||||||
double get height => size + UoscTimelineSpec.border;
|
|
||||||
|
|
||||||
/// Top of the foreground (below the border).
|
|
||||||
double get top => UoscTimelineSpec.border;
|
|
||||||
double get lineLeft => ((width - lineWidth) * progress).roundToDouble();
|
|
||||||
double get lineRight => ((width - lineWidth) * progress + lineWidth).roundToDouble();
|
|
||||||
|
|
||||||
/// x of [fraction] (uosc's `t2x`): past the position, shifted over the line.
|
|
||||||
double x(double fraction) {
|
|
||||||
final lw = lineWidth - 1;
|
|
||||||
final at = 0.5 + (width - lw - 1) * fraction;
|
|
||||||
return fraction <= progress ? at : at + lw;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The fraction at [x] (uosc's `get_time_at_x`): x on the line is the
|
|
||||||
/// position.
|
|
||||||
double fractionAt(double x) {
|
|
||||||
const lw = UoscTimelineSpec.lineWidth - 1;
|
|
||||||
final timeWidth = width - lw - 1;
|
|
||||||
if (timeWidth <= 0) return 0;
|
|
||||||
final a = timeWidth * progress, b = a + lw;
|
|
||||||
var at = x - 0.5;
|
|
||||||
if (at > b) {
|
|
||||||
at -= lw;
|
|
||||||
} else if (at > a) {
|
|
||||||
at = a;
|
|
||||||
}
|
|
||||||
return (at / timeWidth).clamp(0.0, 1.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Chapter diamond radius at this size; doubled while hovered.
|
|
||||||
double get diamondRadius => math.min(math.max(1.0, size * 0.8), UoscTimelineSpec.chapterSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The uosc timeline. [expanded] false (the controls hidden) shrinks it,
|
|
||||||
/// animated, to the progress line. Seeks ([onSeek]) on press, on every
|
|
||||||
/// drag move and on release; a press on a hovered chapter diamond jumps
|
|
||||||
/// to that chapter. The session sends those through the user-seek path.
|
|
||||||
class UoscTimeline extends StatefulWidget {
|
|
||||||
final Duration position;
|
|
||||||
final Duration duration;
|
|
||||||
|
|
||||||
/// End of the cached range ([PlaybackController.buffered]).
|
|
||||||
final Duration buffered;
|
|
||||||
final List<MediaChapter> chapters;
|
|
||||||
final bool expanded;
|
|
||||||
final ValueChanged<Duration> onSeek;
|
|
||||||
const UoscTimeline({
|
|
||||||
super.key,
|
|
||||||
required this.position,
|
|
||||||
required this.duration,
|
|
||||||
this.buffered = Duration.zero,
|
|
||||||
this.chapters = const [],
|
|
||||||
this.expanded = true,
|
|
||||||
required this.onSeek,
|
|
||||||
});
|
|
||||||
|
|
||||||
static const height = UoscTimelineSpec.size + UoscTimelineSpec.border;
|
|
||||||
static const collapsedHeight = UoscTimelineSpec.progressSize + UoscTimelineSpec.border;
|
|
||||||
|
|
||||||
/// uosc's `animation_duration`.
|
|
||||||
static const animation = Duration(milliseconds: 100);
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<UoscTimeline> createState() => _UoscTimelineState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _UoscTimelineState extends State<UoscTimeline> {
|
|
||||||
Offset? _hover;
|
|
||||||
bool _dragging = false;
|
|
||||||
double _dragFraction = 0;
|
|
||||||
Duration? _lastSent;
|
|
||||||
// A released seek, shown until the position catches up (else the line
|
|
||||||
// jumps back to the old position for a moment).
|
|
||||||
double? _pending;
|
|
||||||
Timer? _pendingTimer;
|
|
||||||
|
|
||||||
Duration _at(double fraction) => widget.duration * fraction.clamp(0.0, 1.0);
|
|
||||||
|
|
||||||
void _send(Duration d) {
|
|
||||||
if (d == _lastSent) return;
|
|
||||||
_lastSent = d;
|
|
||||||
widget.onSeek(d);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _drag(bool on) {
|
|
||||||
if (_dragging == on) return;
|
|
||||||
setState(() => _dragging = on);
|
|
||||||
UoscControls.maybeOf(context)?.hold(on);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _settle(double fraction) {
|
|
||||||
_pending = fraction;
|
|
||||||
_pendingTimer?.cancel();
|
|
||||||
_pendingTimer = Timer(const Duration(seconds: 2), () {
|
|
||||||
if (mounted) setState(() => _pending = null);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didUpdateWidget(UoscTimeline old) {
|
|
||||||
super.didUpdateWidget(old);
|
|
||||||
if (!widget.expanded && old.expanded) _hover = null;
|
|
||||||
final p = _pending;
|
|
||||||
if (p != null && widget.duration > Duration.zero) {
|
|
||||||
final off = (widget.position - _at(p)).abs();
|
|
||||||
if (off < const Duration(milliseconds: 1500)) {
|
|
||||||
_pending = null;
|
|
||||||
_pendingTimer?.cancel();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_pendingTimer?.cancel();
|
|
||||||
// A drag cut short by a rebuild must not keep the controls up.
|
|
||||||
if (_dragging) UoscControls.maybeOf(context)?.hold(false);
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The chapter whose diamond is within twice its radius of [at].
|
|
||||||
int? _hoveredChapter(UoscTimelineGeometry geo, List<MediaChapter> marks, Offset? at) {
|
|
||||||
if (at == null || _dragging) return null;
|
|
||||||
final reach = geo.diamondRadius * 2;
|
|
||||||
int? best;
|
|
||||||
var bestD = double.infinity;
|
|
||||||
for (var i = 0; i < marks.length; i++) {
|
|
||||||
final cx = geo.x(timelineFraction(marks[i].start, widget.duration));
|
|
||||||
final d = (Offset(cx, 0) - at).distance;
|
|
||||||
if (d <= reach && d < bestD) {
|
|
||||||
best = i;
|
|
||||||
bestD = d;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final font = DefaultTextStyle.of(context).style.fontFamily ?? Theme.of(context).textTheme.bodyMedium?.fontFamily;
|
|
||||||
return LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
final width = constraints.maxWidth;
|
|
||||||
return TweenAnimationBuilder<double>(
|
|
||||||
tween: Tween(end: widget.expanded ? 1 : 0),
|
|
||||||
duration: UoscTimeline.animation,
|
|
||||||
builder: (context, visibility, _) => _band(width, visibility, font),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _band(double width, double visibility, String? font) {
|
|
||||||
final expanded = widget.expanded;
|
|
||||||
final duration = widget.duration;
|
|
||||||
final size = UoscTimelineGeometry.sizeAt(visibility);
|
|
||||||
final progress = _dragging ? _dragFraction : (_pending ?? timelineFraction(widget.position, duration));
|
|
||||||
final shown = _at(progress);
|
|
||||||
final geo = UoscTimelineGeometry(
|
|
||||||
width: width,
|
|
||||||
size: size,
|
|
||||||
progress: progress,
|
|
||||||
lineWidth: UoscTimelineGeometry.lineWidthAt(size),
|
|
||||||
);
|
|
||||||
final marks = duration > Duration.zero ? widget.chapters : const <MediaChapter>[];
|
|
||||||
final hover = expanded ? _hover : null;
|
|
||||||
final hovered = _hoveredChapter(geo, marks, hover);
|
|
||||||
final hoverFraction = hovered != null
|
|
||||||
? timelineFraction(marks[hovered].start, duration)
|
|
||||||
: (hover == null || duration <= Duration.zero ? null : geo.fractionAt(hover.dx));
|
|
||||||
final hoverAt = hoverFraction == null ? null : _at(hoverFraction);
|
|
||||||
final cursorX = hovered != null ? geo.x(hoverFraction!) : hover?.dx;
|
|
||||||
final hoverChapter = hoverAt == null ? null : chapterIndexAt(widget.chapters, hoverAt);
|
|
||||||
final hoverTitle = hoverChapter == null ? '' : widget.chapters[hoverChapter].title;
|
|
||||||
// Text fades out just before it would overflow the shrinking band.
|
|
||||||
const hideBelow = UoscTimelineSpec.fontSize * 0.8;
|
|
||||||
final textOpacity = (size - hideBelow).clamp(0.0, hideBelow / 2) / (hideBelow / 2);
|
|
||||||
final radius = geo.diamondRadius;
|
|
||||||
final timestampGap = UoscTimelineSpec.tooltipGap + (hovered != null ? radius * 2 : radius).roundToDouble();
|
|
||||||
final painter = UoscTimelinePainter(
|
|
||||||
geometry: geo,
|
|
||||||
visibility: visibility,
|
|
||||||
uncached: uncachedFrom(position: widget.position, buffered: widget.buffered, duration: duration),
|
|
||||||
chapters: [for (final c in marks) timelineFraction(c.start, duration)],
|
|
||||||
hoveredChapter: hovered,
|
|
||||||
hoverX: cursorX,
|
|
||||||
textOpacity: textOpacity,
|
|
||||||
elapsed: duration > Duration.zero ? uoscClock(shown, duration) : null,
|
|
||||||
remaining: duration > Duration.zero ? uoscClock(shown - duration, duration) : null,
|
|
||||||
cache: cacheLabel(position: widget.position, buffered: widget.buffered, duration: duration),
|
|
||||||
fontFamily: font,
|
|
||||||
);
|
|
||||||
final bar = SizedBox(
|
|
||||||
key: const ValueKey('uosc-timeline'),
|
|
||||||
width: width,
|
|
||||||
height: geo.height,
|
|
||||||
child: Stack(
|
|
||||||
clipBehavior: Clip.none,
|
|
||||||
children: [
|
|
||||||
Positioned.fill(
|
|
||||||
child: RepaintBoundary(child: CustomPaint(painter: painter)),
|
|
||||||
),
|
|
||||||
if (hoverAt != null && cursorX != null)
|
|
||||||
Positioned(
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: geo.height + timestampGap,
|
|
||||||
height: 160,
|
|
||||||
child: IgnorePointer(
|
|
||||||
child: CustomMultiChildLayout(
|
|
||||||
delegate: _TooltipLayout(x: cursorX),
|
|
||||||
children: [
|
|
||||||
LayoutId(
|
|
||||||
id: #time,
|
|
||||||
child: UoscBox(
|
|
||||||
uoscClock(hoverAt, duration),
|
|
||||||
key: const ValueKey('uosc-hover-time'),
|
|
||||||
color: Colors.black,
|
|
||||||
style: _tipStyle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (hoverTitle.isNotEmpty)
|
|
||||||
LayoutId(
|
|
||||||
id: #chapter,
|
|
||||||
child: UoscBox(
|
|
||||||
hoverTitle,
|
|
||||||
key: const ValueKey('uosc-hover-chapter'),
|
|
||||||
color: Colors.black,
|
|
||||||
style: _tipStyle.copyWith(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (!expanded) return IgnorePointer(child: bar);
|
|
||||||
Offset clampTo(Offset p) => Offset(p.dx.clamp(0.0, width), p.dy);
|
|
||||||
return MouseRegion(
|
|
||||||
cursor: SystemMouseCursors.click,
|
|
||||||
onEnter: (e) => setState(() => _hover = clampTo(e.localPosition)),
|
|
||||||
onHover: (e) => setState(() => _hover = clampTo(e.localPosition)),
|
|
||||||
onExit: (_) {
|
|
||||||
if (!_dragging) setState(() => _hover = null);
|
|
||||||
},
|
|
||||||
child: Listener(
|
|
||||||
behavior: HitTestBehavior.opaque,
|
|
||||||
onPointerDown: (e) {
|
|
||||||
if (duration <= Duration.zero || width <= 0) return;
|
|
||||||
final at = clampTo(e.localPosition);
|
|
||||||
final chapter = _hoveredChapter(geo, marks, at);
|
|
||||||
_lastSent = null;
|
|
||||||
if (chapter != null) {
|
|
||||||
final f = timelineFraction(marks[chapter].start, duration);
|
|
||||||
_settle(f);
|
|
||||||
_send(marks[chapter].start);
|
|
||||||
setState(() {});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_pending = null;
|
|
||||||
_dragFraction = geo.fractionAt(at.dx);
|
|
||||||
_drag(true);
|
|
||||||
_send(_at(_dragFraction));
|
|
||||||
},
|
|
||||||
onPointerMove: (e) {
|
|
||||||
if (!_dragging) return;
|
|
||||||
final at = clampTo(e.localPosition);
|
|
||||||
setState(() {
|
|
||||||
_hover = at;
|
|
||||||
_dragFraction = geo.fractionAt(at.dx);
|
|
||||||
});
|
|
||||||
_send(_at(_dragFraction));
|
|
||||||
},
|
|
||||||
onPointerUp: (e) {
|
|
||||||
if (!_dragging) return;
|
|
||||||
_send(_at(_dragFraction));
|
|
||||||
_settle(_dragFraction);
|
|
||||||
final p = e.localPosition;
|
|
||||||
if (p.dx < 0 || p.dx > width || p.dy < 0 || p.dy > geo.height) _hover = null;
|
|
||||||
_drag(false);
|
|
||||||
},
|
|
||||||
onPointerCancel: (_) => _drag(false),
|
|
||||||
child: bar,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static const _tipStyle = TextStyle(color: UoscStyle.text, fontSize: UoscTimelineSpec.fontSize, height: 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The hover tooltips: the time centred on [x], the chapter title over
|
|
||||||
/// it; each kept [UoscTimelineSpec.tooltipGap] inside the band's ends.
|
|
||||||
class _TooltipLayout extends MultiChildLayoutDelegate {
|
|
||||||
final double x;
|
|
||||||
_TooltipLayout({required this.x});
|
|
||||||
|
|
||||||
@override
|
|
||||||
void performLayout(Size size) {
|
|
||||||
const gap = UoscTimelineSpec.tooltipGap;
|
|
||||||
double left(double w) => (x - w / 2).clamp(gap, math.max(gap, size.width - gap - w));
|
|
||||||
final loose = BoxConstraints.loose(size);
|
|
||||||
final time = layoutChild(#time, loose);
|
|
||||||
final timeTop = size.height - time.height;
|
|
||||||
positionChild(#time, Offset(left(time.width), timeTop));
|
|
||||||
if (hasChild(#chapter)) {
|
|
||||||
final chapter = layoutChild(#chapter, loose);
|
|
||||||
positionChild(#chapter, Offset(left(chapter.width), timeTop - gap - chapter.height));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool shouldRelayout(_TooltipLayout old) => old.x != x;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Paints the band as uosc does: background, the position line, the
|
|
||||||
/// uncached hatching, chapter diamonds, the times, the hover line.
|
|
||||||
class UoscTimelinePainter extends CustomPainter {
|
|
||||||
final UoscTimelineGeometry geometry;
|
|
||||||
|
|
||||||
/// 0 hidden .. 1 shown (picks the hatching texture and its strength).
|
|
||||||
final double visibility;
|
|
||||||
|
|
||||||
/// Start of the uncached part (to the end), as a fraction.
|
|
||||||
final double? uncached;
|
|
||||||
|
|
||||||
/// Chapter starts, as fractions.
|
|
||||||
final List<double> chapters;
|
|
||||||
final int? hoveredChapter;
|
|
||||||
|
|
||||||
/// The hover line (the mouse, or the hovered chapter).
|
|
||||||
final double? hoverX;
|
|
||||||
final double textOpacity;
|
|
||||||
final String? elapsed;
|
|
||||||
final String? remaining;
|
|
||||||
final String? cache;
|
|
||||||
final String? fontFamily;
|
|
||||||
const UoscTimelinePainter({
|
|
||||||
required this.geometry,
|
|
||||||
this.visibility = 1,
|
|
||||||
this.uncached,
|
|
||||||
this.chapters = const [],
|
|
||||||
this.hoveredChapter,
|
|
||||||
this.hoverX,
|
|
||||||
this.textOpacity = 1,
|
|
||||||
this.elapsed,
|
|
||||||
this.remaining,
|
|
||||||
this.cache,
|
|
||||||
this.fontFamily,
|
|
||||||
});
|
|
||||||
|
|
||||||
double get played => geometry.progress;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void paint(Canvas canvas, Size size) {
|
|
||||||
final g = geometry;
|
|
||||||
final w = size.width, bottom = size.height, top = g.top;
|
|
||||||
final line = Rect.fromLTRB(g.lineLeft, top, g.lineRight, bottom);
|
|
||||||
// Background (`opacity.timeline`) around the position line.
|
|
||||||
final bg = Path()
|
|
||||||
..fillType = PathFillType.evenOdd
|
|
||||||
..addRect(Offset.zero & size)
|
|
||||||
..addRect(line);
|
|
||||||
canvas.drawPath(bg, Paint()..color = Colors.black.withValues(alpha: UoscTimelineSpec.opacity));
|
|
||||||
canvas.drawRect(line, Paint()..color = Colors.white);
|
|
||||||
_hatch(canvas, w, top, bottom);
|
|
||||||
_chapters(canvas);
|
|
||||||
if (textOpacity > 0) _times(canvas, w, top, bottom, line);
|
|
||||||
final x = hoverX;
|
|
||||||
if (x != null) {
|
|
||||||
final onLine = line.left - 0.5 < x && x < line.right + 0.5;
|
|
||||||
canvas.drawRect(
|
|
||||||
Rect.fromLTRB(x - 0.5, top, x + 0.5, bottom),
|
|
||||||
Paint()..color = (onLine ? Colors.black : Colors.white).withValues(alpha: 0.33),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// uosc's texture "b" (shown) or "a" (hidden) at 80 px: "/" stripes
|
|
||||||
/// 1.6 px thick, white then black shifted by size / 24 (or / 28).
|
|
||||||
void _hatch(Canvas canvas, double w, double top, double bottom) {
|
|
||||||
final from = uncached;
|
|
||||||
if (from == null) return;
|
|
||||||
final ax = from * (geometry.width) < 0.5 ? 0.0 : geometry.x(from).floorToDouble();
|
|
||||||
if (ax >= w) return;
|
|
||||||
final shown = visibility > 0;
|
|
||||||
const tile = 80.0, unit = tile / 100;
|
|
||||||
final period = (shown ? 100 / 12 : 100 / 14) * unit;
|
|
||||||
final phase = shown ? 0.0 : 5.727 * unit;
|
|
||||||
final offset = tile / (shown ? 24 : 28);
|
|
||||||
final h = bottom - top;
|
|
||||||
canvas.save();
|
|
||||||
canvas.clipRect(Rect.fromLTRB(ax, top, w, bottom));
|
|
||||||
void stripes(Color color, double opacity, double anchor) {
|
|
||||||
final paint = Paint()
|
|
||||||
..color = color.withValues(alpha: opacity)
|
|
||||||
..strokeWidth = 2 * unit;
|
|
||||||
// x + (y - bottom) = anchor + phase + k * period.
|
|
||||||
final base = anchor + phase;
|
|
||||||
final k0 = ((ax - h - base) / period).floor() - 1;
|
|
||||||
for (var k = k0; base + k * period <= w + 1; k++) {
|
|
||||||
final c = base + k * period;
|
|
||||||
canvas.drawLine(Offset(c, bottom), Offset(c + h, top), paint);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stripes(Colors.white, 0.4 - 0.2 * visibility, 0);
|
|
||||||
stripes(Colors.black, 0.6 - 0.2 * visibility, offset);
|
|
||||||
canvas.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _chapters(Canvas canvas) {
|
|
||||||
final g = geometry;
|
|
||||||
final r = g.diamondRadius;
|
|
||||||
if (r <= 0) return;
|
|
||||||
void diamond(double fraction, double radius) {
|
|
||||||
final c = Offset(g.x(fraction), 0);
|
|
||||||
Path shape(double r) => Path()
|
|
||||||
..moveTo(c.dx - r, c.dy)
|
|
||||||
..lineTo(c.dx, c.dy - r)
|
|
||||||
..lineTo(c.dx + r, c.dy)
|
|
||||||
..lineTo(c.dx, c.dy + r)
|
|
||||||
..close();
|
|
||||||
final inner = shape(radius);
|
|
||||||
// A 1 px border outside the fill (`\bord`), the fill translucent.
|
|
||||||
final outer = shape(radius + UoscTimelineSpec.border * math.sqrt2);
|
|
||||||
canvas.drawPath(Path.combine(PathOperation.difference, outer, inner), Paint()..color = Colors.black);
|
|
||||||
canvas.drawPath(inner, Paint()..color = Colors.white.withValues(alpha: UoscTimelineSpec.chaptersOpacity));
|
|
||||||
}
|
|
||||||
|
|
||||||
for (var i = 0; i < chapters.length; i++) {
|
|
||||||
if (i != hoveredChapter) diamond(chapters[i], r);
|
|
||||||
}
|
|
||||||
final h = hoveredChapter;
|
|
||||||
if (h != null && h < chapters.length) diamond(chapters[h], r * 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
TextPainter _text(String s, double fontSize, Color color, {Paint? foreground}) => TextPainter(
|
|
||||||
text: TextSpan(
|
|
||||||
text: s,
|
|
||||||
style: TextStyle(
|
|
||||||
fontFamily: fontFamily,
|
|
||||||
fontSize: fontSize,
|
|
||||||
height: 1,
|
|
||||||
color: foreground == null ? color : null,
|
|
||||||
foreground: foreground,
|
|
||||||
fontFeatures: const [FontFeature.tabularFigures()],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
textDirection: TextDirection.ltr,
|
|
||||||
)..layout();
|
|
||||||
|
|
||||||
/// Text on the band: white with a black outline, inverted where it
|
|
||||||
/// crosses the position line (uosc clips one copy to each).
|
|
||||||
void _label(Canvas canvas, String s, double fontSize, double opacity, Offset at, Rect line, Size size) {
|
|
||||||
void copy(Color fill, Color outline) {
|
|
||||||
_text(
|
|
||||||
s,
|
|
||||||
fontSize,
|
|
||||||
fill,
|
|
||||||
foreground: Paint()
|
|
||||||
..style = PaintingStyle.stroke
|
|
||||||
..strokeWidth = UoscTimelineSpec.textBorder * 2
|
|
||||||
..strokeJoin = StrokeJoin.round
|
|
||||||
..color = outline.withValues(alpha: opacity),
|
|
||||||
).paint(canvas, at);
|
|
||||||
_text(s, fontSize, fill.withValues(alpha: opacity)).paint(canvas, at);
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas.save();
|
|
||||||
canvas.clipPath(
|
|
||||||
Path()
|
|
||||||
..fillType = PathFillType.evenOdd
|
|
||||||
..addRect(Rect.fromLTWH(-20, -20, size.width + 40, size.height + 40))
|
|
||||||
..addRect(line),
|
|
||||||
);
|
|
||||||
copy(Colors.white, Colors.black);
|
|
||||||
canvas.restore();
|
|
||||||
canvas.save();
|
|
||||||
canvas.clipRect(line);
|
|
||||||
copy(Colors.black, Colors.white);
|
|
||||||
canvas.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _times(Canvas canvas, double w, double top, double bottom, Rect line) {
|
|
||||||
const fs = UoscTimelineSpec.fontSize, spacing = UoscTimelineSpec.spacing;
|
|
||||||
final size = Size(w, bottom);
|
|
||||||
final cy = top + (bottom - top) / 2;
|
|
||||||
final e = elapsed, r = remaining;
|
|
||||||
final ew = e == null ? 0.0 : _text(e, fs, Colors.white).width;
|
|
||||||
final rw = r == null ? 0.0 : _text(r, fs, Colors.white).width;
|
|
||||||
final c = cache;
|
|
||||||
if (c != null) {
|
|
||||||
const cfs = fs * 0.8, margin = 5.0;
|
|
||||||
final cw = _text(c, cfs, Colors.white).width;
|
|
||||||
var x = line.right + margin;
|
|
||||||
final minX = spacing + margin + ew, maxX = w - spacing - margin - rw;
|
|
||||||
if (x < minX) {
|
|
||||||
x = minX;
|
|
||||||
} else if (x + cw > maxX) {
|
|
||||||
x = maxX - cw;
|
|
||||||
}
|
|
||||||
_label(canvas, c, cfs, textOpacity * 0.6, Offset(x, cy - cfs / 2), line, size);
|
|
||||||
}
|
|
||||||
if (e != null) _label(canvas, e, fs, textOpacity, Offset(spacing, cy - fs / 2), line, size);
|
|
||||||
if (r != null) _label(canvas, r, fs, textOpacity, Offset(w - spacing - rw, cy - fs / 2), line, size);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool shouldRepaint(UoscTimelinePainter old) => true;
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
import 'dart:math' as math;
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import '../volume.dart' show maxVolume;
|
|
||||||
|
|
||||||
// uosc's volume slider (`elements/Volume.lua`) as it flashes on a volume
|
|
||||||
// change: a vertical band at the right edge, black at `opacity.slider`
|
|
||||||
// with a white gauge from the bottom, notches at 100 %, the value at the
|
|
||||||
// bottom (black on the gauge, white off it).
|
|
||||||
|
|
||||||
/// The flashed uosc volume slider; size it with [UoscVolumeSlider.width]
|
|
||||||
/// and the height uosc gives it (see [UoscVolumeSlider.heightFor]).
|
|
||||||
class UoscVolumeSlider extends StatelessWidget {
|
|
||||||
/// 0..[maxVolume].
|
|
||||||
final double volume;
|
|
||||||
const UoscVolumeSlider({super.key, required this.volume});
|
|
||||||
|
|
||||||
/// `volume_size`.
|
|
||||||
static const width = 40.0;
|
|
||||||
|
|
||||||
/// uosc: min(size * 8, 80 % of the room between the bars), less the
|
|
||||||
/// mute button (one [width]) below it.
|
|
||||||
static double heightFor(double room) => math.min(width * 8, room * 0.8) - width;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) => CustomPaint(
|
|
||||||
key: const ValueKey('uosc-volume-flash'),
|
|
||||||
painter: UoscVolumePainter(
|
|
||||||
volume: volume,
|
|
||||||
fontFamily: DefaultTextStyle.of(context).style.fontFamily ?? Theme.of(context).textTheme.bodyMedium?.fontFamily,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class UoscVolumePainter extends CustomPainter {
|
|
||||||
final double volume;
|
|
||||||
final String? fontFamily;
|
|
||||||
const UoscVolumePainter({required this.volume, this.fontFamily});
|
|
||||||
|
|
||||||
/// `volume_border`, `border_radius`.
|
|
||||||
static const border = 1.0;
|
|
||||||
static const radius = 4.0;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void paint(Canvas canvas, Size size) {
|
|
||||||
final w = size.width, h = size.height;
|
|
||||||
if (w <= 0 || h <= w) return;
|
|
||||||
final v = volume.clamp(0.0, maxVolume);
|
|
||||||
final nudgeY = h - (h * (100 / maxVolume)).roundToDouble();
|
|
||||||
final nudge = (w * 0.18).roundToDouble();
|
|
||||||
final spacing = (w * 0.2).roundToDouble();
|
|
||||||
Path notches(double inset) {
|
|
||||||
final n = nudge - inset;
|
|
||||||
return Path()
|
|
||||||
..moveTo(0, nudgeY - nudge)
|
|
||||||
..lineTo(inset + n, nudgeY)
|
|
||||||
..lineTo(0, nudgeY + nudge)
|
|
||||||
..close()
|
|
||||||
..moveTo(w, nudgeY - nudge)
|
|
||||||
..lineTo(w - inset - n, nudgeY)
|
|
||||||
..lineTo(w, nudgeY + nudge)
|
|
||||||
..close();
|
|
||||||
}
|
|
||||||
|
|
||||||
final bg = RRect.fromRectAndRadius(Offset.zero & size, const Radius.circular(radius + border));
|
|
||||||
final volumeY = border + (h - border * 2) * (1 - math.min(v / maxVolume, 1));
|
|
||||||
final gauge = RRect.fromRectAndRadius(
|
|
||||||
Rect.fromLTRB(border, math.min(volumeY, h - border), w - border, h - border),
|
|
||||||
const Radius.circular(radius),
|
|
||||||
);
|
|
||||||
// The notches cut into both.
|
|
||||||
canvas.save();
|
|
||||||
canvas.clipPath(
|
|
||||||
Path()
|
|
||||||
..fillType = PathFillType.evenOdd
|
|
||||||
..addRect(Offset.zero & size)
|
|
||||||
..addPath(notches(0), Offset.zero),
|
|
||||||
);
|
|
||||||
canvas.drawPath(
|
|
||||||
Path()
|
|
||||||
..fillType = PathFillType.evenOdd
|
|
||||||
..addRRect(bg)
|
|
||||||
..addRRect(gauge),
|
|
||||||
Paint()..color = Colors.black.withValues(alpha: 0.9),
|
|
||||||
);
|
|
||||||
canvas.restore();
|
|
||||||
canvas.save();
|
|
||||||
canvas.clipPath(
|
|
||||||
Path()
|
|
||||||
..fillType = PathFillType.evenOdd
|
|
||||||
..addRect(Offset.zero & size)
|
|
||||||
..addPath(notches(border), Offset.zero),
|
|
||||||
);
|
|
||||||
canvas.drawRRect(gauge, Paint()..color = Colors.white);
|
|
||||||
canvas.restore();
|
|
||||||
|
|
||||||
// round(volume * 10) / 10, as Lua prints it ("85", "85.5").
|
|
||||||
final tenths = (v * 10).round();
|
|
||||||
final text = tenths % 10 == 0 ? '${tenths ~/ 10}' : (tenths / 10).toString();
|
|
||||||
final fontSize = (w * 0.6 - text.length * (w / 20)).roundToDouble();
|
|
||||||
TextPainter label(Color color) => TextPainter(
|
|
||||||
text: TextSpan(
|
|
||||||
text: text,
|
|
||||||
style: TextStyle(fontFamily: fontFamily, fontSize: fontSize, height: 1, color: color),
|
|
||||||
),
|
|
||||||
textDirection: TextDirection.ltr,
|
|
||||||
)..layout();
|
|
||||||
final dark = label(Colors.black), light = label(Colors.white);
|
|
||||||
final at = Offset((w - dark.width) / 2, h - spacing - dark.height);
|
|
||||||
canvas.save();
|
|
||||||
canvas.clipRRect(gauge);
|
|
||||||
dark.paint(canvas, at);
|
|
||||||
canvas.restore();
|
|
||||||
canvas.save();
|
|
||||||
canvas.clipPath(
|
|
||||||
Path()
|
|
||||||
..fillType = PathFillType.evenOdd
|
|
||||||
..addRect(Offset.zero & size)
|
|
||||||
..addRRect(gauge),
|
|
||||||
);
|
|
||||||
light.paint(canvas, at);
|
|
||||||
canvas.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool shouldRepaint(UoscVolumePainter old) => old.volume != volume || old.fontFamily != fontFamily;
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ import 'dart:async';
|
|||||||
import 'package:flutter/foundation.dart' show ValueListenable;
|
import 'package:flutter/foundation.dart' show ValueListenable;
|
||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
|
||||||
|
|
||||||
import '../playback/playback_controller.dart' show PlaybackController;
|
import '../playback/playback_controller.dart' show PlaybackController;
|
||||||
import '../widgets/app_menu.dart' show AppGlass;
|
import '../widgets/app_menu.dart' show AppGlass;
|
||||||
@@ -90,7 +89,7 @@ class VolumeFlash extends StatefulWidget {
|
|||||||
final WidgetBuilder builder;
|
final WidgetBuilder builder;
|
||||||
const VolumeFlash({super.key, required this.trigger, required this.builder});
|
const VolumeFlash({super.key, required this.trigger, required this.builder});
|
||||||
|
|
||||||
/// uosc's `flash_duration`.
|
/// How long the readout stays after the last step.
|
||||||
static const hold = Duration(seconds: 1);
|
static const hold = Duration(seconds: 1);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -142,7 +141,7 @@ class _VolumeFlashState extends State<VolumeFlash> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The standard design's volume readout for [VolumeFlash]: a glass pill
|
/// The volume readout for [VolumeFlash] (both skins): a glass pill
|
||||||
/// (the player's pill look) with the icon, a [VolumeBar] and the
|
/// (the player's pill look) with the icon, a [VolumeBar] and the
|
||||||
/// percentage.
|
/// percentage.
|
||||||
class VolumeReadout extends StatelessWidget {
|
class VolumeReadout extends StatelessWidget {
|
||||||
@@ -159,11 +158,7 @@ class VolumeReadout extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(volumeIcon(v), size: 20, color: v > 100 ? boostColor : Colors.white),
|
||||||
v <= 0 ? Icons.volume_off : (v < 50 ? Icons.volume_down : Icons.volume_up),
|
|
||||||
size: 20,
|
|
||||||
color: v > 100 ? boostColor : Colors.white,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
VolumeBar(volume: v, onChanged: (_) {}, width: 96),
|
VolumeBar(volume: v, onChanged: (_) {}, width: 96),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
@@ -246,72 +241,88 @@ class _VolumePainter extends CustomPainter {
|
|||||||
bool shouldRepaint(_VolumePainter old) => old.volume != volume || old.color != color || old.trackColor != trackColor;
|
bool shouldRepaint(_VolumePainter old) => old.volume != volume || old.color != color || old.trackColor != trackColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Volume for the standard bar (instead of media_kit's, which stops at
|
/// The speaker icon for [volume].
|
||||||
/// 100): the icon mutes / unmutes, hovering shows a [VolumeBar], the
|
IconData volumeIcon(double volume) =>
|
||||||
/// wheel steps [volumeStep].
|
volume <= 0 ? Icons.volume_off_rounded : (volume < 50 ? Icons.volume_down_rounded : Icons.volume_up_rounded);
|
||||||
class VolumeButton extends StatefulWidget {
|
|
||||||
final Player player;
|
/// Builds a skin's button for the [VolumeControl].
|
||||||
final FocusNode? focusNode;
|
typedef VolumeButtonBuilder = Widget Function(
|
||||||
const VolumeButton({super.key, required this.player, this.focusNode});
|
BuildContext context,
|
||||||
|
IconData icon,
|
||||||
|
Color? color,
|
||||||
|
String tooltip,
|
||||||
|
VoidCallback onPressed,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The bar's volume, in both skins: the skin's speaker button (click
|
||||||
|
/// mutes / unmutes), a [VolumeBar] sliding out on hover, the wheel on it
|
||||||
|
/// stepping [volumeStep] (claimed, so the player's wheel skips it).
|
||||||
|
class VolumeControl extends StatefulWidget {
|
||||||
|
/// 0..[maxVolume].
|
||||||
|
final double volume;
|
||||||
|
final ValueChanged<double> onChanged;
|
||||||
|
final VoidCallback onToggleMute;
|
||||||
|
final VolumeButtonBuilder button;
|
||||||
|
final Color sliderColor;
|
||||||
|
final Color trackColor;
|
||||||
|
final double sliderWidth;
|
||||||
|
const VolumeControl({
|
||||||
|
super.key,
|
||||||
|
required this.volume,
|
||||||
|
required this.onChanged,
|
||||||
|
required this.onToggleMute,
|
||||||
|
required this.button,
|
||||||
|
this.sliderColor = Colors.white,
|
||||||
|
this.trackColor = Colors.white30,
|
||||||
|
this.sliderWidth = 72,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<VolumeButton> createState() => _VolumeButtonState();
|
State<VolumeControl> createState() => _VolumeControlState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _VolumeButtonState extends State<VolumeButton> {
|
class _VolumeControlState extends State<VolumeControl> {
|
||||||
final _steps = WheelSteps();
|
final _steps = WheelSteps();
|
||||||
bool _hover = false;
|
bool _hover = false;
|
||||||
double _beforeMute = 100;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final player = widget.player;
|
final v = widget.volume;
|
||||||
return StreamBuilder<double>(
|
return MouseRegion(
|
||||||
stream: player.stream.volume,
|
onEnter: (_) => setState(() => _hover = true),
|
||||||
initialData: player.state.volume,
|
onExit: (_) => setState(() => _hover = false),
|
||||||
builder: (context, snap) {
|
child: Listener(
|
||||||
final v = snap.data ?? 100;
|
onPointerSignal: (e) => _steps.handle(e, (step) => widget.onChanged(stepVolume(v, step * volumeStep))),
|
||||||
return MouseRegion(
|
child: Row(
|
||||||
onEnter: (_) => setState(() => _hover = true),
|
key: const ValueKey('volume-control'),
|
||||||
onExit: (_) => setState(() => _hover = false),
|
mainAxisSize: MainAxisSize.min,
|
||||||
child: Listener(
|
children: [
|
||||||
// Claimed here, so the player's own wheel handler skips it.
|
widget.button(
|
||||||
onPointerSignal: (e) =>
|
context,
|
||||||
_steps.handle(e, (step) => player.setVolume(stepVolume(player.state.volume, step * volumeStep))),
|
volumeIcon(v),
|
||||||
child: Row(
|
v > 100 ? boostColor : null,
|
||||||
mainAxisSize: MainAxisSize.min,
|
'Volume ${v.round()} % (scroll, ↑ / ↓)',
|
||||||
children: [
|
widget.onToggleMute,
|
||||||
const SizedBox(width: 4),
|
|
||||||
IconButton(
|
|
||||||
key: const ValueKey('volume-button'),
|
|
||||||
focusNode: widget.focusNode,
|
|
||||||
tooltip: 'Volume ${v.round()} %',
|
|
||||||
iconSize: 22,
|
|
||||||
color: Colors.white,
|
|
||||||
onPressed: () {
|
|
||||||
if (v > 0) {
|
|
||||||
_beforeMute = v;
|
|
||||||
player.setVolume(0);
|
|
||||||
} else {
|
|
||||||
player.setVolume(_beforeMute <= 0 ? 100 : _beforeMute);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
icon: Icon(v <= 0 ? Icons.volume_off : (v < 50 ? Icons.volume_down : Icons.volume_up)),
|
|
||||||
),
|
|
||||||
AnimatedSize(
|
|
||||||
duration: const Duration(milliseconds: 150),
|
|
||||||
child: _hover
|
|
||||||
? Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 4, right: 12),
|
|
||||||
child: VolumeBar(volume: v, onChanged: player.setVolume),
|
|
||||||
)
|
|
||||||
: const SizedBox.shrink(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
AnimatedSize(
|
||||||
);
|
duration: const Duration(milliseconds: 150),
|
||||||
},
|
child: _hover
|
||||||
|
? Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 4, right: 8),
|
||||||
|
child: VolumeBar(
|
||||||
|
key: const ValueKey('volume-bar'),
|
||||||
|
volume: v,
|
||||||
|
onChanged: widget.onChanged,
|
||||||
|
width: widget.sliderWidth,
|
||||||
|
color: widget.sliderColor,
|
||||||
|
trackColor: widget.trackColor,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,15 +33,36 @@ const _timeout = Duration(seconds: 20);
|
|||||||
int _running = 0;
|
int _running = 0;
|
||||||
final _waiting = <Completer<void>>[];
|
final _waiting = <Completer<void>>[];
|
||||||
final _inFlight = <String, Future<Thumbnail?>>{};
|
final _inFlight = <String, Future<Thumbnail?>>{};
|
||||||
|
final _players = <Player>{};
|
||||||
|
bool _closed = false;
|
||||||
|
|
||||||
|
/// App close: no new thumbnails, running ones stop at their next check
|
||||||
|
/// and dispose their player. Players still up after [deadline] are
|
||||||
|
/// disposed here, so no mpv outlives the engine.
|
||||||
|
Future<void> cancelAllThumbnails({
|
||||||
|
Duration deadline = const Duration(milliseconds: 800),
|
||||||
|
}) async {
|
||||||
|
_closed = true;
|
||||||
|
try {
|
||||||
|
await Future.wait(_inFlight.values.toList()).timeout(deadline);
|
||||||
|
} catch (_) {}
|
||||||
|
for (final p in _players.toList()) {
|
||||||
|
_players.remove(p);
|
||||||
|
try {
|
||||||
|
await p.dispose().timeout(deadline);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The cached or freshly made thumbnail of the video at [path], or null
|
/// The cached or freshly made thumbnail of the video at [path], or null
|
||||||
/// when it can't be made (not a video, unreadable, timed out, cancelled).
|
/// when it can't be made (not a video, unreadable, timed out, cancelled).
|
||||||
/// Never throws. [cancelled] is checked while queued and before the
|
/// Never throws. [cancelled] is checked while queued and before the
|
||||||
/// expensive steps: a scrolled-away tile returns null without decoding.
|
/// expensive steps: a scrolled-away tile returns null without decoding.
|
||||||
Future<Thumbnail?> thumbnailFor(String path, {bool Function()? cancelled}) {
|
Future<Thumbnail?> thumbnailFor(String path, {bool Function()? cancelled}) {
|
||||||
|
if (_closed) return Future.value(null);
|
||||||
final existing = _inFlight[path];
|
final existing = _inFlight[path];
|
||||||
if (existing != null) return existing;
|
if (existing != null) return existing;
|
||||||
final f = _thumbnailFor(path, cancelled ?? () => false).catchError((
|
final f = _thumbnailFor(path, () => _closed || (cancelled?.call() ?? false)).catchError((
|
||||||
Object e,
|
Object e,
|
||||||
StackTrace st,
|
StackTrace st,
|
||||||
) {
|
) {
|
||||||
@@ -136,6 +157,7 @@ Future<Thumbnail?> _generate(
|
|||||||
bufferSize: 4 << 20,
|
bufferSize: 4 << 20,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
_players.add(player);
|
||||||
try {
|
try {
|
||||||
final mpv = player.platform as NativePlayer;
|
final mpv = player.platform as NativePlayer;
|
||||||
for (final (k, v) in [
|
for (final (k, v) in [
|
||||||
@@ -176,6 +198,7 @@ Future<Thumbnail?> _generate(
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
await player.dispose();
|
// [cancelAllThumbnails] may have taken it already.
|
||||||
|
if (_players.remove(player)) await player.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:file_picker/file_picker.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
import '../../shell/motion.dart';
|
import '../../shell/motion.dart';
|
||||||
|
import '../../platform/ports/folder_access.dart';
|
||||||
|
import '../../state/session_state.dart';
|
||||||
import '../../widgets/app_menu.dart';
|
import '../../widgets/app_menu.dart';
|
||||||
import '../folder_store.dart';
|
import '../folder_store.dart';
|
||||||
import '../name_parser.dart';
|
import '../name_parser.dart';
|
||||||
@@ -32,6 +33,8 @@ String? _lastOpen;
|
|||||||
final _expanded = <String>{};
|
final _expanded = <String>{};
|
||||||
final _showRequests = ValueNotifier(0);
|
final _showRequests = ValueNotifier(0);
|
||||||
|
|
||||||
|
FolderAccess get _folders => SessionState.instance.ports.folders;
|
||||||
|
|
||||||
/// Opens [dir] (null: Recent) in the Files page: now if one is showing,
|
/// Opens [dir] (null: Recent) in the Files page: now if one is showing,
|
||||||
/// else when it next opens.
|
/// else when it next opens.
|
||||||
void showFilesFolder(String? dir) {
|
void showFilesFolder(String? dir) {
|
||||||
@@ -60,7 +63,7 @@ class FilesPage extends StatefulWidget {
|
|||||||
final String? nowPlayingPath;
|
final String? nowPlayingPath;
|
||||||
/// Defaults to [thumbnailFor].
|
/// Defaults to [thumbnailFor].
|
||||||
final ThumbnailLoader? thumbnails;
|
final ThumbnailLoader? thumbnails;
|
||||||
/// Defaults to [scanFolder].
|
/// Defaults to [FolderAccess.scan].
|
||||||
final Future<FolderScan> Function(String dir)? scan;
|
final Future<FolderScan> Function(String dir)? scan;
|
||||||
/// Defaults to [FolderStore.instance].
|
/// Defaults to [FolderStore.instance].
|
||||||
final FolderStore? store;
|
final FolderStore? store;
|
||||||
@@ -142,7 +145,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
|
|
||||||
void _shown() => _select(_lastOpen);
|
void _shown() => _select(_lastOpen);
|
||||||
|
|
||||||
Future<FolderScan> _runScan(String dir) => (widget.scan ?? scanFolder)(dir);
|
Future<FolderScan> _runScan(String dir) => (widget.scan ?? _folders.scan)(dir);
|
||||||
|
|
||||||
void _select(String? dir) {
|
void _select(String? dir) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -176,7 +179,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
String? dir;
|
String? dir;
|
||||||
try {
|
try {
|
||||||
dir = await (widget.pickFolder ??
|
dir = await (widget.pickFolder ??
|
||||||
() => FilePicker.getDirectoryPath(dialogTitle: 'Add a folder with videos'))();
|
() => _folders.pickFolder(dialogTitle: 'Add a folder with videos'))();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_showError('Folder dialog unavailable (${e.runtimeType})');
|
_showError('Folder dialog unavailable (${e.runtimeType})');
|
||||||
}
|
}
|
||||||
@@ -196,12 +199,8 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static Future<String?> _pickVideo() async {
|
static Future<String?> _pickVideo() async {
|
||||||
final files = await FilePicker.pickFiles(
|
final files = await _folders.pickFiles(dialogTitle: 'Pick a video', extensions: videoExtensions.toList());
|
||||||
dialogTitle: 'Pick a video',
|
return files.firstOrNull;
|
||||||
type: FileType.custom,
|
|
||||||
allowedExtensions: videoExtensions.toList(),
|
|
||||||
);
|
|
||||||
return files.isEmpty ? null : files.first.path;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _removeFolder(String dir) {
|
void _removeFolder(String dir) {
|
||||||
@@ -589,7 +588,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
),
|
),
|
||||||
if (scan.videos.isEmpty)
|
if (scan.videos.isEmpty)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Directory(dir).existsSync()
|
child: _folders.folderExists(dir)
|
||||||
? const FilesEmpty(
|
? const FilesEmpty(
|
||||||
Icons.search_off,
|
Icons.search_off,
|
||||||
'No videos here',
|
'No videos here',
|
||||||
@@ -701,7 +700,7 @@ class _RecentRowState extends State<_RecentRow> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
File(widget.path).stat().then((s) {
|
_folders.stat(widget.path).then((s) {
|
||||||
if (mounted) setState(() => _stat = s);
|
if (mounted) setState(() => _stat = s);
|
||||||
}, onError: (Object _) {});
|
}, onError: (Object _) {});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
import '../playback/yt_dlp_app.dart';
|
|
||||||
import '../screens/home/ui.dart' show appName, displayVersion;
|
import '../screens/home/ui.dart' show appName, displayVersion;
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
|
|
||||||
@@ -59,7 +58,7 @@ Future<String> diagnosticsText({Duration since = const Duration(hours: 6), bool
|
|||||||
b.writeln('mpv: unknown');
|
b.writeln('mpv: unknown');
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final y = await AppYtDlp.instance.ytDlp.timeout(const Duration(seconds: 2));
|
final y = await SessionState.instance.ports.links.ytDlp.timeout(const Duration(seconds: 2));
|
||||||
final cookies = y.cookies.read(count: false);
|
final cookies = y.cookies.read(count: false);
|
||||||
b.writeln('yt-dlp: ${y.version ?? 'not ready'}${y.lastError == null ? '' : ' (${y.lastError})'}, '
|
b.writeln('yt-dlp: ${y.version ?? 'not ready'}${y.lastError == null ? '' : ' (${y.lastError})'}, '
|
||||||
'cookies: ${cookies == null ? 'none' : 'set'}');
|
'cookies: ${cookies == null ? 'none' : 'set'}');
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../files/thumbnails.dart';
|
||||||
|
import '../state/session_state.dart';
|
||||||
|
|
||||||
|
// The one way out of the app (close button, Alt+F4, Update and restart).
|
||||||
|
// Order matters: every mpv player goes before the engine does (a live one
|
||||||
|
// calls a dead Dart callback), then the network goodbye, then the window
|
||||||
|
// closes through the platform's normal path so the engine is torn down
|
||||||
|
// inside the message loop. The runners end the process right after
|
||||||
|
// (`linux/runner/main.cc`, `windows/runner/main.cpp`).
|
||||||
|
|
||||||
|
bool _shuttingDown = false;
|
||||||
|
|
||||||
|
/// True once [shutDownApp] started.
|
||||||
|
bool get shuttingDown => _shuttingDown;
|
||||||
|
|
||||||
|
/// Hides the window, releases every player and the sound device, leaves
|
||||||
|
/// (others see "left", a hosted server stops), runs [beforeQuit], then
|
||||||
|
/// closes the window. Every step is bounded; a second call does nothing.
|
||||||
|
Future<void> shutDownApp({Future<void> Function()? beforeQuit}) async {
|
||||||
|
if (_shuttingDown) return;
|
||||||
|
_shuttingDown = true;
|
||||||
|
final session = SessionState.instance;
|
||||||
|
final shell = session.ports.shell;
|
||||||
|
final total = Stopwatch()..start();
|
||||||
|
await _step('window save', shell.saveWindow(), const Duration(milliseconds: 500));
|
||||||
|
await _step('hide', shell.hide(), const Duration(milliseconds: 500));
|
||||||
|
await Future.wait([
|
||||||
|
_step('players', session.coordinator.releasePlayers(), const Duration(seconds: 3)),
|
||||||
|
_step('thumbnails', cancelAllThumbnails(), const Duration(milliseconds: 1500)),
|
||||||
|
_step('sounds', session.sfx.dispose(), const Duration(milliseconds: 500)),
|
||||||
|
]);
|
||||||
|
// Windows: media_kit_video frees a player's video output on a thread of
|
||||||
|
// its own after dispose returns; it must be done before the engine
|
||||||
|
// stops, or the plugin's teardown waits on it for good.
|
||||||
|
if (Platform.isWindows) await Future.delayed(const Duration(milliseconds: 300));
|
||||||
|
// Jellyfin stop report, last diagnostics, the goodbye, then the hosted
|
||||||
|
// server: 3 s in all.
|
||||||
|
await _step('leave', session.leave(), const Duration(milliseconds: 2500));
|
||||||
|
await _step('server stop', session.stopServer(), const Duration(milliseconds: 500));
|
||||||
|
if (beforeQuit != null) await _step('before quit', beforeQuit(), const Duration(seconds: 3));
|
||||||
|
debugPrint('close: ready to quit after ${total.elapsedMilliseconds} ms');
|
||||||
|
await shell.quit();
|
||||||
|
// The engine stops with the window, so this only fires when the close
|
||||||
|
// did not go through. A kill, not exit(): no exit handlers (NVIDIA's
|
||||||
|
// EGL one segfaults, Windows DLL detach can hang).
|
||||||
|
Timer(const Duration(seconds: 3), () {
|
||||||
|
debugPrint('close: the window did not close; ending the process');
|
||||||
|
Process.killPid(pid, ProcessSignal.sigkill);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _step(String what, Future<void>? f, Duration limit) async {
|
||||||
|
if (f == null) return;
|
||||||
|
final sw = Stopwatch()..start();
|
||||||
|
try {
|
||||||
|
await f.timeout(limit);
|
||||||
|
} on TimeoutException {
|
||||||
|
debugPrint('close: $what did not finish in ${limit.inMilliseconds} ms');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('close: $what failed: $e');
|
||||||
|
}
|
||||||
|
if (sw.elapsedMilliseconds > 200) debugPrint('close: $what took ${sw.elapsedMilliseconds} ms');
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_soloud/flutter_soloud.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
|
final _log = createLogger('sound');
|
||||||
|
|
||||||
|
/// What [SoundEngine] needs from an audio library: [SoLoudBackend] in the
|
||||||
|
/// app, a fake in tests. A sound handle is whatever [load] returns.
|
||||||
|
abstract class AudioBackend {
|
||||||
|
Future<void> init();
|
||||||
|
/// Decodes [wav] (a whole WAV file) into memory under [name].
|
||||||
|
Future<Object> load(String name, Uint8List wav);
|
||||||
|
/// Starts one voice of [sound] at [volume] (linear gain, 0..1); voices
|
||||||
|
/// overlap.
|
||||||
|
void play(Object sound, double volume);
|
||||||
|
Future<void> unload(Object sound);
|
||||||
|
Future<void> deinit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// flutter_soloud (SoLoud mixing into miniaudio's output device).
|
||||||
|
class SoLoudBackend implements AudioBackend {
|
||||||
|
SoLoud get _s => SoLoud.instance;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> init() => _s.init();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Object> load(String name, Uint8List wav) => _s.loadMem(name, wav);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void play(Object sound, double volume) => _s.play(sound as AudioSource, volume: volume);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> unload(Object sound) => _s.disposeSource(sound as AudioSource);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deinit() => _s.deinitAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The app's short in-memory sounds. The audio device opens on [warmUp] or
|
||||||
|
/// the first [play]; if it can't (no device, a broken driver) the engine
|
||||||
|
/// logs once and every play is silently dropped. Each sound is loaded once
|
||||||
|
/// per slot from the WAV bytes its renderer gives; a new key in a slot
|
||||||
|
/// replaces (and frees) what the slot held.
|
||||||
|
class SoundEngine {
|
||||||
|
final AudioBackend _backend;
|
||||||
|
SoundEngine({AudioBackend? backend}) : _backend = backend ?? SoLoudBackend();
|
||||||
|
|
||||||
|
Future<bool>? _ready;
|
||||||
|
var _disposed = false;
|
||||||
|
/// Per slot: the loaded key and its sound (null: failed to load).
|
||||||
|
final Map<String, (String, Future<Object?>)> _slots = {};
|
||||||
|
/// Load names are unique: SoLoud hands back the old sound for a name it
|
||||||
|
/// already holds, which would defeat a re-render.
|
||||||
|
var _loads = 0;
|
||||||
|
|
||||||
|
/// Opens the audio device now, so the first sound has no wait.
|
||||||
|
Future<bool> warmUp() => _ready ??= _init();
|
||||||
|
|
||||||
|
Future<bool> _init() async {
|
||||||
|
if (_disposed) return false;
|
||||||
|
try {
|
||||||
|
await _backend.init();
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
_log.warning('no sounds: audio init failed: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plays the sound of [slot] at [volume] (linear gain). [key] names what
|
||||||
|
/// [wav] renders: the same key reuses the loaded sound; another key, or
|
||||||
|
/// [fresh], renders and loads it again.
|
||||||
|
Future<void> play(String slot, String key, Future<Uint8List> Function() wav,
|
||||||
|
{required double volume, bool fresh = false}) async {
|
||||||
|
if (_disposed || volume <= 0) return;
|
||||||
|
if (!await warmUp() || _disposed) return;
|
||||||
|
var held = _slots[slot];
|
||||||
|
if (held == null || held.$1 != key || fresh) {
|
||||||
|
if (held != null) unawaited(_unload(held.$2));
|
||||||
|
held = (key, _load(key, wav));
|
||||||
|
_slots[slot] = held;
|
||||||
|
}
|
||||||
|
final sound = await held.$2;
|
||||||
|
if (sound == null) {
|
||||||
|
// Retry on the next play.
|
||||||
|
if (identical(_slots[slot], held)) _slots.remove(slot);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_disposed || !identical(_slots[slot], held)) return;
|
||||||
|
try {
|
||||||
|
_backend.play(sound, volume.clamp(0.0, 1.0));
|
||||||
|
} catch (e) {
|
||||||
|
_log.warning('sound $key: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Object?> _load(String key, Future<Uint8List> Function() wav) async {
|
||||||
|
try {
|
||||||
|
return await _backend.load('$key#${_loads++}', await wav());
|
||||||
|
} catch (e) {
|
||||||
|
_log.warning('sound $key: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _unload(Future<Object?> sound) async {
|
||||||
|
try {
|
||||||
|
final s = await sound;
|
||||||
|
if (s != null && !_disposed) await _backend.unload(s);
|
||||||
|
} catch (e) {
|
||||||
|
_log.fine('unload: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Frees every sound and closes the audio device. Plays after it are
|
||||||
|
/// dropped.
|
||||||
|
Future<void> dispose() async {
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
_slots.clear();
|
||||||
|
final ready = _ready;
|
||||||
|
if (ready == null || !await ready) return;
|
||||||
|
try {
|
||||||
|
// Deinit frees the loaded sounds with the engine.
|
||||||
|
await _backend.deinit();
|
||||||
|
} catch (e) {
|
||||||
|
_log.warning('audio deinit: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File renamed without changes.
@@ -0,0 +1,40 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
|
||||||
|
import '../../files/scanner.dart';
|
||||||
|
import '../ports/folder_access.dart';
|
||||||
|
import '../ports/url_opener.dart';
|
||||||
|
|
||||||
|
/// [FolderAccess] on a desktop file system: file_picker's dialogs, the
|
||||||
|
/// [scanFolder] walk, the file manager through [UrlOpener].
|
||||||
|
class DesktopFolders implements FolderAccess {
|
||||||
|
final UrlOpener urls;
|
||||||
|
const DesktopFolders(this.urls);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> pickFolder({required String dialogTitle}) => FilePicker.getDirectoryPath(dialogTitle: dialogTitle);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<String>> pickFiles({required String dialogTitle, required List<String> extensions}) async {
|
||||||
|
final files = await FilePicker.pickFiles(
|
||||||
|
dialogTitle: dialogTitle,
|
||||||
|
type: FileType.custom,
|
||||||
|
allowedExtensions: extensions,
|
||||||
|
);
|
||||||
|
return [for (final f in files) ?f.path];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<FolderScan> scan(String dir) => scanFolder(dir);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool folderExists(String dir) => Directory(dir).existsSync();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<FileStat> stat(String path) => File(path).stat();
|
||||||
|
|
||||||
|
// The file manager opens a folder path the way a browser opens a link.
|
||||||
|
@override
|
||||||
|
Future<bool> reveal(String dir) => urls.open(Uri.directory(dir).toString());
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import '../../playback/yt_dlp_app.dart';
|
||||||
|
import '../ports/ports.dart';
|
||||||
|
import 'desktop_folders.dart';
|
||||||
|
import 'desktop_shell.dart';
|
||||||
|
import 'desktop_urls.dart';
|
||||||
|
|
||||||
|
/// The desktop's [Ports].
|
||||||
|
Ports desktopPorts() {
|
||||||
|
const urls = DesktopUrls();
|
||||||
|
return Ports(
|
||||||
|
shell: DesktopShell(),
|
||||||
|
urls: urls,
|
||||||
|
folders: const DesktopFolders(urls),
|
||||||
|
links: AppYtDlp.instance,
|
||||||
|
notifications: const NoSystemNotifications(),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:fox_utils/fox_utils.dart' show ClipboardX, DesktopDropX, DropTarget;
|
||||||
|
import 'package:hive_ce_flutter/hive_flutter.dart';
|
||||||
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
|
import '../../state/settings.dart' show settingsBoxName;
|
||||||
|
import 'attention.dart';
|
||||||
|
import '../ports/shell_port.dart';
|
||||||
|
import 'window_memory.dart';
|
||||||
|
|
||||||
|
/// [ShellPort] on Linux and Windows: window_manager for the window,
|
||||||
|
/// [WindowMemory] for its size and place, the runner's channel for
|
||||||
|
/// attention, fox_utils for drops and the clipboard.
|
||||||
|
class DesktopShell implements ShellPort {
|
||||||
|
WindowMemory? _memory;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> start({required String title, required Future<void> Function() onCloseRequest}) async {
|
||||||
|
await windowManager.ensureInitialized();
|
||||||
|
await windowManager.setPreventClose(true);
|
||||||
|
final memory = _memory = WindowMemory(Hive.isBoxOpen(settingsBoxName) ? Hive.box(settingsBoxName) : null);
|
||||||
|
windowManager.addListener(_CloseGuard(onCloseRequest));
|
||||||
|
final windowOptions = WindowOptions(
|
||||||
|
size: memory.size,
|
||||||
|
minimumSize: const Size(160, 90),
|
||||||
|
center: !memory.hasPosition,
|
||||||
|
backgroundColor: const Color(0xFF000000),
|
||||||
|
titleBarStyle: TitleBarStyle.hidden,
|
||||||
|
title: title,
|
||||||
|
);
|
||||||
|
unawaited(windowManager.waitUntilReadyToShow(windowOptions, () async {
|
||||||
|
await memory.restore();
|
||||||
|
await windowManager.show();
|
||||||
|
await windowManager.focus();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setFullScreen(bool on) => windowManager.setFullScreen(on);
|
||||||
|
@override
|
||||||
|
Future<void> minimize() => windowManager.minimize();
|
||||||
|
@override
|
||||||
|
Future<bool> isMaximized() => windowManager.isMaximized();
|
||||||
|
@override
|
||||||
|
Future<void> maximize() => windowManager.maximize();
|
||||||
|
@override
|
||||||
|
Future<void> unmaximize() => windowManager.unmaximize();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void Function() watchMaximized(void Function(bool maximized) onChange) {
|
||||||
|
final l = _MaximizeWatch(onChange);
|
||||||
|
windowManager.addListener(l);
|
||||||
|
return () => windowManager.removeListener(l);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> startDragging() => windowManager.startDragging();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> requestClose() => windowManager.close();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveWindow() async => _memory?.save();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> hide() => windowManager.hide();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> quit() async {
|
||||||
|
try {
|
||||||
|
await windowManager.setPreventClose(false);
|
||||||
|
await windowManager.close();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('window close failed: $e');
|
||||||
|
await windowManager.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> requestAttention() => requestWindowAttention();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Paste> readPaste() async {
|
||||||
|
var files = const <String>[];
|
||||||
|
String? text;
|
||||||
|
try {
|
||||||
|
files = await ClipboardX.readFiles();
|
||||||
|
} catch (_) {}
|
||||||
|
if (files.isEmpty) {
|
||||||
|
try {
|
||||||
|
text = await ClipboardX.readText();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
return (files: files, text: text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget dropTarget({
|
||||||
|
Key? key,
|
||||||
|
bool enable = true,
|
||||||
|
VoidCallback? onEnter,
|
||||||
|
VoidCallback? onExit,
|
||||||
|
required void Function(List<String> paths, Offset at) onDrop,
|
||||||
|
required Widget child,
|
||||||
|
}) =>
|
||||||
|
DropTarget(
|
||||||
|
key: key,
|
||||||
|
enable: enable,
|
||||||
|
onDragEntered: onEnter == null ? null : (_) => onEnter(),
|
||||||
|
onDragExited: onExit == null ? null : (_) => onExit(),
|
||||||
|
onDragDone: (d) => onDrop(DesktopDropX.pathsOf(d.files), d.localPosition),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close button / Alt+F4 run the orderly close instead.
|
||||||
|
class _CloseGuard with WindowListener {
|
||||||
|
final Future<void> Function() onClose;
|
||||||
|
_CloseGuard(this.onClose);
|
||||||
|
@override
|
||||||
|
void onWindowClose() => unawaited(onClose());
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MaximizeWatch with WindowListener {
|
||||||
|
final void Function(bool) onChange;
|
||||||
|
_MaximizeWatch(this.onChange);
|
||||||
|
@override
|
||||||
|
void onWindowMaximize() => onChange(true);
|
||||||
|
@override
|
||||||
|
void onWindowUnmaximize() => onChange(false);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import '../ports/url_opener.dart';
|
||||||
|
|
||||||
|
/// [UrlOpener] without url_launcher: `xdg-open`, `open` or Windows'
|
||||||
|
/// `url.dll` handler.
|
||||||
|
class DesktopUrls implements UrlOpener {
|
||||||
|
const DesktopUrls();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> open(String url) async {
|
||||||
|
try {
|
||||||
|
final ProcessResult r;
|
||||||
|
if (Platform.isWindows) {
|
||||||
|
r = await Process.run('rundll32', ['url.dll,FileProtocolHandler', url]);
|
||||||
|
} else if (Platform.isMacOS) {
|
||||||
|
r = await Process.run('open', [url]);
|
||||||
|
} else {
|
||||||
|
r = await Process.run('xdg-open', [url]);
|
||||||
|
}
|
||||||
|
return r.exitCode == 0;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File renamed without changes.
@@ -0,0 +1,28 @@
|
|||||||
|
import 'dart:io' show FileStat;
|
||||||
|
|
||||||
|
import '../../files/scanner.dart' show FolderScan;
|
||||||
|
|
||||||
|
/// The user's folders and files: picking them, listing videos, looking a
|
||||||
|
/// path up. Paths for now; a platform without them (Android's tree URIs)
|
||||||
|
/// brings its own implementation.
|
||||||
|
abstract interface class FolderAccess {
|
||||||
|
/// The system folder dialog; null when cancelled.
|
||||||
|
Future<String?> pickFolder({required String dialogTitle});
|
||||||
|
|
||||||
|
/// The system file dialog limited to [extensions]; the picked paths
|
||||||
|
/// (empty when cancelled).
|
||||||
|
Future<List<String>> pickFiles({required String dialogTitle, required List<String> extensions});
|
||||||
|
|
||||||
|
/// The videos under [dir] (see [FolderScan]); never throws.
|
||||||
|
Future<FolderScan> scan(String dir);
|
||||||
|
|
||||||
|
/// Whether the folder [dir] exists.
|
||||||
|
bool folderExists(String dir);
|
||||||
|
|
||||||
|
/// Size and dates of [path]; throws when it is gone.
|
||||||
|
Future<FileStat> stat(String path);
|
||||||
|
|
||||||
|
/// Shows the folder [dir] in the file manager; false when nothing
|
||||||
|
/// opened it.
|
||||||
|
Future<bool> reveal(String dir);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import 'package:sync_relay_core/core.dart' show LinkProbe, YtDlp;
|
||||||
|
|
||||||
|
/// Web page links: what they are and how they play, through the app's
|
||||||
|
/// yt-dlp (a process on desktop).
|
||||||
|
abstract interface class LinkResolver {
|
||||||
|
/// The yt-dlp behind it (relaying a link, cookies, the hosted server's
|
||||||
|
/// shared folder).
|
||||||
|
Future<YtDlp> get ytDlp;
|
||||||
|
|
||||||
|
/// Gets it ready in the background (download on first run, updates).
|
||||||
|
void warmUp();
|
||||||
|
|
||||||
|
/// mpv's `ytdl-raw-options` (sub languages, cookies).
|
||||||
|
Future<String> mpvRawOptions();
|
||||||
|
|
||||||
|
/// What [url] is, remembered for a while ([keep]); [fresh] asks again.
|
||||||
|
Future<LinkProbe> probe(String url, {Duration keep, bool fresh});
|
||||||
|
|
||||||
|
/// [url] no longer needs keeping longer than usual.
|
||||||
|
void release(String url);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import 'folder_access.dart';
|
||||||
|
import 'link_resolver.dart';
|
||||||
|
import 'shell_port.dart';
|
||||||
|
import 'system_notifications.dart';
|
||||||
|
import 'url_opener.dart';
|
||||||
|
|
||||||
|
export 'folder_access.dart';
|
||||||
|
export 'link_resolver.dart';
|
||||||
|
export 'shell_port.dart';
|
||||||
|
export 'system_notifications.dart';
|
||||||
|
export 'url_opener.dart';
|
||||||
|
|
||||||
|
/// The platform as the app sees it: one set, made in the composition
|
||||||
|
/// root ([SessionState]) and handed on from there.
|
||||||
|
class Ports {
|
||||||
|
final ShellPort shell;
|
||||||
|
final UrlOpener urls;
|
||||||
|
final FolderAccess folders;
|
||||||
|
final LinkResolver links;
|
||||||
|
final SystemNotifications notifications;
|
||||||
|
const Ports({
|
||||||
|
required this.shell,
|
||||||
|
required this.urls,
|
||||||
|
required this.folders,
|
||||||
|
required this.links,
|
||||||
|
required this.notifications,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
/// What the clipboard holds for a paste: its files, else its text.
|
||||||
|
typedef Paste = ({List<String> files, String? text});
|
||||||
|
|
||||||
|
/// The app window and what the desktop hands it (drops, pastes). The
|
||||||
|
/// desktop implementation wraps window_manager and fox_utils
|
||||||
|
/// ([DesktopShell]); another platform brings its own.
|
||||||
|
abstract interface class ShellPort {
|
||||||
|
/// Before the first frame: the window shown at its remembered size and
|
||||||
|
/// place; a close request (close button, Alt+F4) runs [onCloseRequest]
|
||||||
|
/// instead of closing.
|
||||||
|
Future<void> start({required String title, required Future<void> Function() onCloseRequest});
|
||||||
|
|
||||||
|
Future<void> setFullScreen(bool on);
|
||||||
|
Future<void> minimize();
|
||||||
|
Future<bool> isMaximized();
|
||||||
|
Future<void> maximize();
|
||||||
|
Future<void> unmaximize();
|
||||||
|
|
||||||
|
/// Calls [onChange] whenever the window is maximized or restored;
|
||||||
|
/// returns the call that stops it.
|
||||||
|
void Function() watchMaximized(void Function(bool maximized) onChange);
|
||||||
|
|
||||||
|
/// Moves the window with the pointer (a press on the title area).
|
||||||
|
Future<void> startDragging();
|
||||||
|
|
||||||
|
/// Asks to close the window: the same path as the close button.
|
||||||
|
Future<void> requestClose();
|
||||||
|
|
||||||
|
/// The way out: remembers the window, hides it ([hide]), and later
|
||||||
|
/// closes it for good ([quit]).
|
||||||
|
Future<void> saveWindow();
|
||||||
|
Future<void> hide();
|
||||||
|
Future<void> quit();
|
||||||
|
|
||||||
|
/// Points the desktop at the window when it is not focused (taskbar
|
||||||
|
/// flash, urgency hint); nothing when it is.
|
||||||
|
Future<void> requestAttention();
|
||||||
|
|
||||||
|
/// The clipboard's files, else its text (Ctrl+V outside a text field).
|
||||||
|
Future<Paste> readPaste();
|
||||||
|
|
||||||
|
/// [child] taking dropped files ([onDrop]: their paths and where they
|
||||||
|
/// landed); [enable] off lets drops through.
|
||||||
|
Widget dropTarget({
|
||||||
|
Key? key,
|
||||||
|
bool enable = true,
|
||||||
|
VoidCallback? onEnter,
|
||||||
|
VoidCallback? onExit,
|
||||||
|
required void Function(List<String> paths, Offset at) onDrop,
|
||||||
|
required Widget child,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/// Notifications outside the window (the desktop's notification center,
|
||||||
|
/// Android's shade). The policy that uses them comes with the Notifier.
|
||||||
|
abstract interface class SystemNotifications {
|
||||||
|
/// Shows [title] / [body]; replaces the one with the same [tag].
|
||||||
|
Future<void> show({required String title, required String body, String tag = ''});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows nothing (desktop today).
|
||||||
|
class NoSystemNotifications implements SystemNotifications {
|
||||||
|
const NoSystemNotifications();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> show({required String title, required String body, String tag = ''}) async {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/// Opens links outside the app.
|
||||||
|
abstract interface class UrlOpener {
|
||||||
|
/// Opens [url] with the system's handler (a browser for web links, the
|
||||||
|
/// file manager for a `file:` folder); false when nothing opened it.
|
||||||
|
Future<bool> open(String url);
|
||||||
|
}
|
||||||
@@ -30,7 +30,9 @@ class PlaybackController {
|
|||||||
static const readAheadSecs = 150;
|
static const readAheadSecs = 150;
|
||||||
/// Forward demuxer cache. Holds [readAheadSecs] up to ~14 Mbit/s (a
|
/// Forward demuxer cache. Holds [readAheadSecs] up to ~14 Mbit/s (a
|
||||||
/// 1.4 GB / 24 min file is ~8); higher bitrates get less time. In RAM:
|
/// 1.4 GB / 24 min file is ~8); higher bitrates get less time. In RAM:
|
||||||
/// media_kit sets cache-on-disk but no cache-dir, so mpv falls back.
|
/// `cache-on-disk=no` (media_kit sets it on but no cache-dir, so mpv
|
||||||
|
/// fell back to RAM anyway, warning "Failed to create file cache." per
|
||||||
|
/// open).
|
||||||
static const maxBytes = 256 * 1024 * 1024;
|
static const maxBytes = 256 * 1024 * 1024;
|
||||||
/// Back buffer (media_kit sets it to bufferSize too): enough for a short
|
/// Back buffer (media_kit sets it to bufferSize too): enough for a short
|
||||||
/// seek back without holding another 256 MiB.
|
/// seek back without holding another 256 MiB.
|
||||||
@@ -149,6 +151,9 @@ class PlaybackController {
|
|||||||
/// `demuxer-cache-time`, media_kit's `buffer`), in 250 ms steps.
|
/// `demuxer-cache-time`, media_kit's `buffer`), in 250 ms steps.
|
||||||
final buffered = signal(Duration.zero);
|
final buffered = signal(Duration.zero);
|
||||||
|
|
||||||
|
/// The player's volume, 0..[maxVolume] (media_kit's `volume`).
|
||||||
|
final volume = signal(100.0);
|
||||||
|
|
||||||
/// The opening ([openingRange]: Intro segment, else an OP-titled
|
/// The opening ([openingRange]: Intro segment, else an OP-titled
|
||||||
/// chapter) and the ending ([endingRange]: Outro, else ED-titled).
|
/// chapter) and the ending ([endingRange]: Outro, else ED-titled).
|
||||||
late final Computed<SkipRange?> opening =
|
late final Computed<SkipRange?> opening =
|
||||||
@@ -172,6 +177,7 @@ class PlaybackController {
|
|||||||
final existing = _video;
|
final existing = _video;
|
||||||
if (existing != null) return existing;
|
if (existing != null) return existing;
|
||||||
final hwdec = _hwdecOverride;
|
final hwdec = _hwdecOverride;
|
||||||
|
_logRenderEnv();
|
||||||
_requestLog(player, 'v'); // back to [logLevel] by _detectRenderMode
|
_requestLog(player, 'v'); // back to [logLevel] by _detectRenderMode
|
||||||
// media_kit's video output sets hwdec itself on attach ('auto' unless
|
// media_kit's video output sets hwdec itself on attach ('auto' unless
|
||||||
// configured), which used to override SYNC_RELAY_HWDEC.
|
// configured), which used to override SYNC_RELAY_HWDEC.
|
||||||
@@ -250,6 +256,7 @@ class PlaybackController {
|
|||||||
await _pin('cache-secs', '$readAheadSecs', on);
|
await _pin('cache-secs', '$readAheadSecs', on);
|
||||||
await _pin('demuxer-max-bytes', '$ahead', on);
|
await _pin('demuxer-max-bytes', '$ahead', on);
|
||||||
await _pin('demuxer-max-back-bytes', '$back', on);
|
await _pin('demuxer-max-back-bytes', '$back', on);
|
||||||
|
await _pin('cache-on-disk', 'no', on);
|
||||||
final hwdec = _hwdecOverride;
|
final hwdec = _hwdecOverride;
|
||||||
if (hwdec != null) {
|
if (hwdec != null) {
|
||||||
await _pin('hwdec', hwdec, on);
|
await _pin('hwdec', hwdec, on);
|
||||||
@@ -307,13 +314,12 @@ class PlaybackController {
|
|||||||
}
|
}
|
||||||
if (!identical(p, _player)) return;
|
if (!identical(p, _player)) return;
|
||||||
if (problem != null) paths = const [];
|
if (problem != null) paths = const [];
|
||||||
final value = joinShaderPaths(paths);
|
if (joinShaderPaths(paths) != _shaders || problem != null) {
|
||||||
if (value != _shaders || problem != null) {
|
final took = await setShaderPaths(paths);
|
||||||
final got = await setShaderValue(value);
|
|
||||||
if (!identical(p, _player)) return;
|
if (!identical(p, _player)) return;
|
||||||
if (problem == null && got != value) {
|
if (problem == null && !took) {
|
||||||
problem = 'mpv did not take them';
|
problem = 'mpv did not take them';
|
||||||
await setShaderValue('');
|
await setShaderPaths(const []);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (problem != null) {
|
if (problem != null) {
|
||||||
@@ -323,20 +329,23 @@ class PlaybackController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets mpv `glsl-shaders` on the current player and returns the
|
/// Sets mpv `glsl-shaders` to [paths] on the current player; true when
|
||||||
/// read-back (null on error).
|
/// mpv's read-back is that list ([shaderListMatches]: old mpv prints it
|
||||||
|
/// joined with ',').
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
Future<String?> setShaderValue(String value) async {
|
Future<bool> setShaderPaths(List<String> paths) async {
|
||||||
|
final value = joinShaderPaths(paths);
|
||||||
try {
|
try {
|
||||||
final dynamic platform = player.platform;
|
final dynamic platform = player.platform;
|
||||||
await platform?.setProperty('glsl-shaders', value);
|
await platform?.setProperty('glsl-shaders', value);
|
||||||
final got = '${await platform?.getProperty('glsl-shaders')}';
|
final got = '${await platform?.getProperty('glsl-shaders')}';
|
||||||
_shaders = got;
|
final took = shaderListMatches(paths, got);
|
||||||
if (got != value) debugPrint('glsl-shaders=$value not applied: "$got"');
|
_shaders = took ? value : got;
|
||||||
return got;
|
if (!took) debugPrint('glsl-shaders=$value not applied: "$got"');
|
||||||
|
return took;
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint('glsl-shaders=$value failed: $e\n$st');
|
debugPrint('glsl-shaders=$value failed: $e\n$st');
|
||||||
return null;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,6 +396,9 @@ class PlaybackController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (log.prefix != 'libmpv_render') return;
|
if (log.prefix != 'libmpv_render') return;
|
||||||
|
// Which GL the video output got (verbose, heard only during the
|
||||||
|
// render-mode probe): kept in the app log.
|
||||||
|
if (_glInfo.hasMatch(log.text)) _mpvLog.info('render: [libmpv_render] ${log.text.trim()}');
|
||||||
if (log.text.contains('suspected software renderer')) {
|
if (log.text.contains('suspected software renderer')) {
|
||||||
_setRenderMode(RenderMode.softwareGl);
|
_setRenderMode(RenderMode.softwareGl);
|
||||||
} else if (log.text.startsWith('GL_VERSION') &&
|
} else if (log.text.startsWith('GL_VERSION') &&
|
||||||
@@ -399,7 +411,7 @@ class PlaybackController {
|
|||||||
_shaders = '';
|
_shaders = '';
|
||||||
_shaderOp = _shaderOp.then((_) async {
|
_shaderOp = _shaderOp.then((_) async {
|
||||||
if (!identical(p, _player)) return;
|
if (!identical(p, _player)) return;
|
||||||
await setShaderValue('');
|
await setShaderPaths(const []);
|
||||||
_turnShadersOff('Shaders failed in mpv (${log.text}) — turned off');
|
_turnShadersOff('Shaders failed in mpv (${log.text}) — turned off');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -411,6 +423,52 @@ class PlaybackController {
|
|||||||
static final _mpvLog = createLogger('mpv');
|
static final _mpvLog = createLogger('mpv');
|
||||||
static final _mpvThrottle = LogThrottle(burst: 5);
|
static final _mpvThrottle = LogThrottle(burst: 5);
|
||||||
|
|
||||||
|
// mpv's GL init lines (`GL_VENDOR='...'`, `Detected desktop OpenGL 4.6.`).
|
||||||
|
static final _glInfo =
|
||||||
|
RegExp(r'^\s*(GL_(VENDOR|RENDERER|VERSION|SHADING_LANGUAGE_VERSION)=|Detected (GLES|desktop OpenGL))');
|
||||||
|
|
||||||
|
// Once per app start: what decides how media_kit and mpv render (the
|
||||||
|
// display server, Flutter's and Mesa's overrides). Not secrets.
|
||||||
|
static bool _renderEnvLogged = false;
|
||||||
|
static void _logRenderEnv() {
|
||||||
|
if (_renderEnvLogged || !Platform.isLinux) return;
|
||||||
|
_renderEnvLogged = true;
|
||||||
|
const names = [
|
||||||
|
'XDG_SESSION_TYPE',
|
||||||
|
'WAYLAND_DISPLAY',
|
||||||
|
'DISPLAY',
|
||||||
|
'GDK_BACKEND',
|
||||||
|
'FLUTTER_LINUX_RENDERER',
|
||||||
|
'LIBGL_ALWAYS_SOFTWARE',
|
||||||
|
'__EGL_VENDOR_LIBRARY_FILENAMES',
|
||||||
|
];
|
||||||
|
final env = Platform.environment;
|
||||||
|
_mpvLog.info('render env: ${[for (final n in names) '$n=${env[n] ?? '-'}'].join(' ')}');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once per player, after its first frame: the decoder mpv picked; the
|
||||||
|
// first time per app start also mpv's and ffmpeg's versions.
|
||||||
|
static bool _versionsLogged = false;
|
||||||
|
Player? _frameLoggedFor;
|
||||||
|
Future<void> _logFirstFrame(Player p, int? width) async {
|
||||||
|
if (width == null || width <= 0 || identical(p, _frameLoggedFor)) return;
|
||||||
|
_frameLoggedFor = p;
|
||||||
|
try {
|
||||||
|
final dynamic platform = p.platform;
|
||||||
|
final current = await platform?.getProperty('hwdec-current');
|
||||||
|
final wanted = await platform?.getProperty('hwdec');
|
||||||
|
_mpvLog.info('render: first frame ${p.state.width}x${p.state.height}, '
|
||||||
|
'hwdec-current=${'$current'.isEmpty ? 'no' : current} (hwdec=$wanted), mode ${renderMode.peek().name}');
|
||||||
|
if (!_versionsLogged) {
|
||||||
|
_versionsLogged = true;
|
||||||
|
_mpvLog.info('render: ${await platform?.getProperty('mpv-version')}, '
|
||||||
|
'ffmpeg ${await platform?.getProperty('ffmpeg-version')}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('first frame info: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static void _toAppLog(PlayerLog log, {String player = ''}) {
|
static void _toAppLog(PlayerLog log, {String player = ''}) {
|
||||||
final level = log.level;
|
final level = log.level;
|
||||||
if (level != 'warn' && level != 'error' && level != 'fatal') return;
|
if (level != 'warn' && level != 'error' && level != 'fatal') return;
|
||||||
@@ -448,11 +506,15 @@ class PlaybackController {
|
|||||||
} finally {
|
} finally {
|
||||||
if (identical(p, _player)) _requestLog(p, logLevel);
|
if (identical(p, _player)) _requestLog(p, logLevel);
|
||||||
}
|
}
|
||||||
if (!identical(p, _player) || renderMode.peek() != RenderMode.unknown) {
|
if (!identical(p, _player)) return;
|
||||||
|
final mode = renderMode.peek();
|
||||||
|
if (mode != RenderMode.unknown) {
|
||||||
|
_mpvLog.info('render: ${mode.name} (${mode == RenderMode.gpu ? 'OpenGL on the GPU' : 'OpenGL on the CPU'})');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_setRenderMode(RenderMode.software);
|
_setRenderMode(RenderMode.software);
|
||||||
debugPrint('video output: software rendering (no GL init logged)');
|
_mpvLog.info("render: software (no GL init logged: media_kit's own fallback; "
|
||||||
|
'its reason is on stderr, "media_kit: VideoOutput: ..." when run from a terminal)');
|
||||||
if (_shaders.isNotEmpty) {
|
if (_shaders.isNotEmpty) {
|
||||||
_notice('Shaders have no effect: video uses software rendering');
|
_notice('Shaders have no effect: video uses software rendering');
|
||||||
}
|
}
|
||||||
@@ -504,9 +566,11 @@ class PlaybackController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Position / duration from media_kit's streams; chapter-list and
|
// Position, duration and volume from media_kit's streams; chapter-list and
|
||||||
// track-list observed in mpv (read back as JSON).
|
// track-list observed in mpv (read back as JSON).
|
||||||
void _watchMarks(SyncPlayer p) {
|
void _watchMarks(SyncPlayer p) {
|
||||||
|
volume.value = p.state.volume;
|
||||||
|
_timeSubs.add(p.stream.volume.listen((v) => volume.value = v));
|
||||||
_timeSubs.add(p.stream.position.listen((d) {
|
_timeSubs.add(p.stream.position.listen((d) {
|
||||||
// At the end the bar shows full: mpv's last position is the last
|
// At the end the bar shows full: mpv's last position is the last
|
||||||
// frame's start (and the 250 ms steps round down), short of duration.
|
// frame's start (and the 250 ms steps round down), short of duration.
|
||||||
@@ -520,6 +584,8 @@ class PlaybackController {
|
|||||||
_timeSubs.add(p.stream.completed.listen((done) {
|
_timeSubs.add(p.stream.completed.listen((done) {
|
||||||
if (done && duration.value > Duration.zero) position.value = duration.value;
|
if (done && duration.value > Duration.zero) position.value = duration.value;
|
||||||
}));
|
}));
|
||||||
|
_timeSubs.add(p.stream.width.listen((w) => _logFirstFrame(p, w)));
|
||||||
|
unawaited(_logFirstFrame(p, p.state.width)); // a promoted preload has one already
|
||||||
_timeSubs.add(p.stream.buffer.listen((d) {
|
_timeSubs.add(p.stream.buffer.listen((d) {
|
||||||
final ms = d.inMilliseconds;
|
final ms = d.inMilliseconds;
|
||||||
buffered.value = Duration(milliseconds: ms - ms % 250);
|
buffered.value = Duration(milliseconds: ms - ms % 250);
|
||||||
@@ -686,6 +752,24 @@ class PlaybackController {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// App close: every mpv this controller holds goes before the engine
|
||||||
|
/// does (a live one calls back into a dead isolate): the preload, the
|
||||||
|
/// session player and the one still retiring, each bounded by [each].
|
||||||
|
/// A player an open in flight brought up meanwhile goes too.
|
||||||
|
Future<void> disposeAll({Duration each = const Duration(milliseconds: 1500)}) async {
|
||||||
|
Future<void> bounded(Future<void> f) => f.timeout(each).catchError((Object _) {});
|
||||||
|
for (var round = 0; round < 2; round++) {
|
||||||
|
final pre = _pre;
|
||||||
|
_pre = null;
|
||||||
|
await Future.wait([
|
||||||
|
if (pre != null) bounded(_disposePreload(pre)),
|
||||||
|
bounded(_retiring),
|
||||||
|
bounded(dispose()),
|
||||||
|
]);
|
||||||
|
if (_player == null && _pre == null) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Two frames: one may already be mid-build when [alive] flips. Then
|
// Two frames: one may already be mid-build when [alive] flips. Then
|
||||||
// the length of a route pop (the fullscreen route holds its own `Video`
|
// the length of a route pop (the fullscreen route holds its own `Video`
|
||||||
// and leaves animated). Bounded, since a hidden window renders no
|
// and leaves animated). Bounded, since a hidden window renders no
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import 'dart:io';
|
|||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
|
import '../platform/ports/link_resolver.dart';
|
||||||
|
|
||||||
/// mpv's `ytdl-format` for links every player opens itself.
|
/// mpv's `ytdl-format` for links every player opens itself.
|
||||||
const ytdlFormat = 'bv*[height<=1080]+ba/b';
|
const ytdlFormat = 'bv*[height<=1080]+ba/b';
|
||||||
|
|
||||||
@@ -16,13 +18,14 @@ const ytdlSubLangs = 'all,-danmaku';
|
|||||||
/// The app's yt-dlp (see [YtDlp]): in the app-support folder, shared with
|
/// The app's yt-dlp (see [YtDlp]): in the app-support folder, shared with
|
||||||
/// the hosted server. [probe] remembers what pasted links are for a while,
|
/// the hosted server. [probe] remembers what pasted links are for a while,
|
||||||
/// so the Link preview and the swap ask once.
|
/// so the Link preview and the swap ask once.
|
||||||
class AppYtDlp {
|
class AppYtDlp implements LinkResolver {
|
||||||
static final instance = AppYtDlp();
|
static final instance = AppYtDlp();
|
||||||
|
|
||||||
/// [ytDlp]: a given one (tests); default in the app-support folder.
|
/// [ytDlp]: a given one (tests); default in the app-support folder.
|
||||||
AppYtDlp([YtDlp? ytDlp]) : _ytDlp = ytDlp == null ? null : Future.value(ytDlp);
|
AppYtDlp([YtDlp? ytDlp]) : _ytDlp = ytDlp == null ? null : Future.value(ytDlp);
|
||||||
|
|
||||||
Future<YtDlp>? _ytDlp;
|
Future<YtDlp>? _ytDlp;
|
||||||
|
@override
|
||||||
Future<YtDlp> get ytDlp => _ytDlp ??= () async {
|
Future<YtDlp> get ytDlp => _ytDlp ??= () async {
|
||||||
final base = await getApplicationSupportDirectory();
|
final base = await getApplicationSupportDirectory();
|
||||||
return YtDlp(dir(base.path));
|
return YtDlp(dir(base.path));
|
||||||
@@ -32,10 +35,12 @@ class AppYtDlp {
|
|||||||
|
|
||||||
/// Gets yt-dlp ready in the background (download on first run, a daily
|
/// Gets yt-dlp ready in the background (download on first run, a daily
|
||||||
/// update check afterwards).
|
/// update check afterwards).
|
||||||
|
@override
|
||||||
void warmUp() => unawaited(ytDlp.then((y) => y.ensure()).catchError((_) => null));
|
void warmUp() => unawaited(ytDlp.then((y) => y.ensure()).catchError((_) => null));
|
||||||
|
|
||||||
/// mpv's `ytdl-raw-options`: [ytdlSubLangs] and the cookies in the
|
/// mpv's `ytdl-raw-options`: [ytdlSubLangs] and the cookies in the
|
||||||
/// yt-dlp folder (see [YtDlpCookies]).
|
/// yt-dlp folder (see [YtDlpCookies]).
|
||||||
|
@override
|
||||||
Future<String> mpvRawOptions() async {
|
Future<String> mpvRawOptions() async {
|
||||||
const subs = 'sub-langs=%${ytdlSubLangs.length}%$ytdlSubLangs';
|
const subs = 'sub-langs=%${ytdlSubLangs.length}%$ytdlSubLangs';
|
||||||
try {
|
try {
|
||||||
@@ -54,6 +59,7 @@ class AppYtDlp {
|
|||||||
/// is probed again before that runs out). [fresh] probes again: the
|
/// is probed again before that runs out). [fresh] probes again: the
|
||||||
/// remembered result stays until the new one is in, and stays when the
|
/// remembered result stays until the new one is in, and stays when the
|
||||||
/// new one fails.
|
/// new one fails.
|
||||||
|
@override
|
||||||
Future<LinkProbe> probe(String url, {Duration keep = _probeTtl, bool fresh = false}) {
|
Future<LinkProbe> probe(String url, {Duration keep = _probeTtl, bool fresh = false}) {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
_probes.removeWhere((_, v) => now.isAfter(v.until));
|
_probes.removeWhere((_, v) => now.isAfter(v.until));
|
||||||
@@ -80,6 +86,7 @@ class AppYtDlp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// [url] no longer needs keeping longer: back to the usual 10 minutes.
|
/// [url] no longer needs keeping longer: back to the usual 10 minutes.
|
||||||
|
@override
|
||||||
void release(String url) {
|
void release(String url) {
|
||||||
final hit = _probes[url];
|
final hit = _probes[url];
|
||||||
if (hit == null) return;
|
if (hit == null) return;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import '../jellyfin/accounts.dart';
|
|||||||
import '../jellyfin/format.dart' show pickFor;
|
import '../jellyfin/format.dart' show pickFor;
|
||||||
import '../jellyfin/session_media.dart';
|
import '../jellyfin/session_media.dart';
|
||||||
import '../screens/home/picked_media.dart';
|
import '../screens/home/picked_media.dart';
|
||||||
|
import '../session/app_events.dart' show AddedToUpNext;
|
||||||
import '../session/media_art.dart';
|
import '../session/media_art.dart';
|
||||||
import '../session/session_coordinator.dart';
|
import '../session/session_coordinator.dart';
|
||||||
import '../state/settings.dart';
|
import '../state/settings.dart';
|
||||||
@@ -82,7 +83,6 @@ class QueueController {
|
|||||||
if (_attached) return;
|
if (_attached) return;
|
||||||
_attached = true;
|
_attached = true;
|
||||||
coord.sync.states.listen(_onState);
|
coord.sync.states.listen(_onState);
|
||||||
coord.sync.feeds.listen(_onFeed);
|
|
||||||
effect(() {
|
effect(() {
|
||||||
final next = coord.nextEpisode.value;
|
final next = coord.nextEpisode.value;
|
||||||
final plan = coord.jellyfinPlan.value;
|
final plan = coord.jellyfinPlan.value;
|
||||||
@@ -122,7 +122,7 @@ class QueueController {
|
|||||||
if (item.key.isNotEmpty) _local[item.key] = (pick: pick, at: DateTime.now());
|
if (item.key.isNotEmpty) _local[item.key] = (pick: pick, at: DateTime.now());
|
||||||
final thumb = built.thumb;
|
final thumb = built.thumb;
|
||||||
coord.sync.sendQueue(QueueIntent.add(item, next: next, thumb: thumb == null ? '' : base64Encode(thumb)));
|
coord.sync.sendQueue(QueueIntent.add(item, next: next, thumb: thumb == null ? '' : base64Encode(thumb)));
|
||||||
coord.showToast('Added ${item.title} to Up next');
|
coord.notify(AddedToUpNext(item.title));
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,12 +369,6 @@ class QueueController {
|
|||||||
_warm(r);
|
_warm(r);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onFeed(FeedMessage f) {
|
|
||||||
if (f.action == 'queue-skip') {
|
|
||||||
coord.showToast('Skipped ${f.target ?? 'an item'}${f.reason.isEmpty ? '' : ': ${f.reason}'}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Forgets what no longer matters: performing marks after a minute (a
|
/// Forgets what no longer matters: performing marks after a minute (a
|
||||||
/// swap that never landed may be tried again), local picks no longer
|
/// swap that never landed may be tried again), local picks no longer
|
||||||
/// queued.
|
/// queued.
|
||||||
|
|||||||
@@ -3,15 +3,12 @@ import 'dart:math' as math;
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:fox_utils/fox_utils.dart' show ClipboardX, DesktopDropX, DropTarget;
|
|
||||||
import 'package:leaf_widgets/leaf_widgets.dart' show LeafPopover, LeafPopoverController;
|
import 'package:leaf_widgets/leaf_widgets.dart' show LeafPopover, LeafPopoverController;
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
import '../controls/controls_probe.dart' show ControlsHold;
|
import '../controls/controls_probe.dart' show ControlsHold;
|
||||||
import '../controls/uosc/buttons.dart' show UoscButton;
|
import '../controls/minimal/style.dart';
|
||||||
import '../controls/uosc/controls.dart' show UoscControls;
|
|
||||||
import '../controls/uosc/style.dart';
|
|
||||||
import '../files/scanner.dart' show FileKind, kindOf;
|
import '../files/scanner.dart' show FileKind, kindOf;
|
||||||
import '../jellyfin/images.dart';
|
import '../jellyfin/images.dart';
|
||||||
import '../jellyfin/session_media.dart' show EpisodeLink;
|
import '../jellyfin/session_media.dart' show EpisodeLink;
|
||||||
@@ -28,16 +25,16 @@ import '../widgets/text_focus.dart' show KeyboardClaim;
|
|||||||
import 'queue_controller.dart';
|
import 'queue_controller.dart';
|
||||||
import 'queue_logic.dart';
|
import 'queue_logic.dart';
|
||||||
|
|
||||||
// Up next in the player: its control in the top bar (with chat and
|
// Up next in the player: its control in the top bar (with the window
|
||||||
// fullscreen), the popover it opens (the queue, the continuation, a field
|
// controls), the popover it opens (the queue, the continuation, a field
|
||||||
// and a drop zone to add), and the end-of-media card for the first item.
|
// and a drop zone to add), and the end-of-media card for the first item.
|
||||||
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- button
|
// ---------------------------------------------------------------- button
|
||||||
|
|
||||||
/// Which controls design the Up next control sits in: it takes the look
|
/// Which controls skin the Up next control sits in: it takes the look
|
||||||
/// of that bar's buttons.
|
/// of that bar's buttons.
|
||||||
enum UpNextLook { standard, uosc }
|
enum UpNextLook { standard, minimal }
|
||||||
|
|
||||||
/// The next item's picture, "UP NEXT" over its title, a +n count and ⏭
|
/// The next item's picture, "UP NEXT" over its title, a +n count and ⏭
|
||||||
/// (play it now) in the player's top bar, one flat control among its
|
/// (play it now) in the player's top bar, one flat control among its
|
||||||
@@ -66,7 +63,7 @@ class _UpNextButtonState extends State<UpNextButton> {
|
|||||||
bool _hoverTitle = false;
|
bool _hoverTitle = false;
|
||||||
|
|
||||||
QueueController get _q => widget.queue;
|
QueueController get _q => widget.queue;
|
||||||
bool get _uosc => widget.look == UpNextLook.uosc;
|
bool get _minimal => widget.look == UpNextLook.minimal;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(UpNextButton oldWidget) {
|
void didUpdateWidget(UpNextButton oldWidget) {
|
||||||
@@ -135,16 +132,16 @@ class _UpNextButtonState extends State<UpNextButton> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// The bar's own metrics: uosc's flat 40 px boxes, or media_kit's
|
// The bar's own metrics: the Minimal skin's rounded squares, or the
|
||||||
// round 44 px icon buttons.
|
// Standard skin's round 44 px icon buttons.
|
||||||
final height = _uosc ? UoscControls.buttonRowHeight : 44.0;
|
final height = _minimal ? MinimalStyle.button + 2 * MinimalStyle.capsulePad : 44.0;
|
||||||
final radius = _uosc ? UoscStyle.radius : height / 2;
|
final radius = _minimal ? MinimalStyle.radius + MinimalStyle.capsulePad : height / 2;
|
||||||
final hover = _uosc ? UoscStyle.hover : Colors.white10;
|
final hover = _minimal ? MinimalStyle.hover : Colors.white10;
|
||||||
final fontSize = _uosc ? UoscStyle.fontSize : 14.0;
|
final fontSize = _minimal ? MinimalStyle.fontSize : 14.0;
|
||||||
final dim = _uosc ? UoscStyle.dim : Colors.white70;
|
final dim = _minimal ? MinimalStyle.dim : Colors.white70;
|
||||||
// The picture fills the button's height but for a 7 px margin.
|
// The picture fills the button's height but for a 7 px margin.
|
||||||
final thumbWidth = (height - 14) * 16 / 9;
|
final thumbWidth = (height - 14) * 16 / 9;
|
||||||
final thumbRadius = _uosc ? UoscStyle.radius : 6.0;
|
final thumbRadius = _minimal ? MinimalStyle.radius - 4 : 6.0;
|
||||||
return SignalBuilder(builder: (context) {
|
return SignalBuilder(builder: (context) {
|
||||||
final items = _q.items.value;
|
final items = _q.items.value;
|
||||||
final cont = _q.shownContinuation.value;
|
final cont = _q.shownContinuation.value;
|
||||||
@@ -197,7 +194,7 @@ class _UpNextButtonState extends State<UpNextButton> {
|
|||||||
duration: const Duration(milliseconds: 150),
|
duration: const Duration(milliseconds: 150),
|
||||||
height: height,
|
height: height,
|
||||||
// The picture clears a round button's curve.
|
// The picture clears a round button's curve.
|
||||||
padding: EdgeInsets.only(left: _uosc ? 7 : 12, right: 6),
|
padding: EdgeInsets.only(left: _minimal ? 7 : 12, right: 6),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _hoverTitle || open ? hover : Colors.transparent,
|
color: _hoverTitle || open ? hover : Colors.transparent,
|
||||||
borderRadius: BorderRadius.circular(radius),
|
borderRadius: BorderRadius.circular(radius),
|
||||||
@@ -234,7 +231,7 @@ class _UpNextButtonState extends State<UpNextButton> {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||||
decoration: BoxDecoration(color: UoscStyle.hover, borderRadius: BorderRadius.circular(999)),
|
decoration: BoxDecoration(color: MinimalStyle.hover, borderRadius: BorderRadius.circular(999)),
|
||||||
child: Text('+$more', style: TextStyle(color: dim, fontSize: 11)),
|
child: Text('+$more', style: TextStyle(color: dim, fontSize: 11)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -254,8 +251,8 @@ class _UpNextButtonState extends State<UpNextButton> {
|
|||||||
? 'Play ${item!.title} now ($playNextKeys)'
|
? 'Play ${item!.title} now ($playNextKeys)'
|
||||||
: 'Play ${episode == null ? '' : episode.label} now ($playNextKeys)';
|
: 'Play ${episode == null ? '' : episode.label} now ($playNextKeys)';
|
||||||
final VoidCallback? play = queued ? _q.skip : (episode != null ? widget.onPlayNextEpisode : null);
|
final VoidCallback? play = queued ? _q.skip : (episode != null ? widget.onPlayNextEpisode : null);
|
||||||
final playButton = _uosc
|
final playButton = _minimal
|
||||||
? UoscButton(key: const ValueKey('up-next-play'), icon: Icons.skip_next_rounded, tooltip: playTip, onPressed: play)
|
? MinimalButton(key: const ValueKey('up-next-play'), icon: Icons.skip_next_rounded, tooltip: playTip, onPressed: play)
|
||||||
: Tooltip(
|
: Tooltip(
|
||||||
message: playTip,
|
message: playTip,
|
||||||
waitDuration: const Duration(milliseconds: 500),
|
waitDuration: const Duration(milliseconds: 500),
|
||||||
@@ -642,23 +639,9 @@ class _QueuePanelState extends State<QueuePanel> {
|
|||||||
return KeyEventResult.ignored;
|
return KeyEventResult.ignored;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<({List<String> files, String? text})> _readClipboard() async {
|
|
||||||
var files = const <String>[];
|
|
||||||
String? text;
|
|
||||||
try {
|
|
||||||
files = await ClipboardX.readFiles();
|
|
||||||
} catch (_) {}
|
|
||||||
if (files.isEmpty) {
|
|
||||||
try {
|
|
||||||
text = await ClipboardX.readText();
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
return (files: files, text: text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ctrl+V: the clipboard's video files, else the links in its text.
|
/// Ctrl+V: the clipboard's video files, else the links in its text.
|
||||||
Future<void> _paste() async {
|
Future<void> _paste() async {
|
||||||
final clip = await (widget.readClipboard ?? _readClipboard)();
|
final clip = await (widget.readClipboard ?? SessionState.instance.ports.shell.readPaste)();
|
||||||
final picks = _picksFrom([...clip.files, ...?clip.text?.split(RegExp(r'\s+'))]);
|
final picks = _picksFrom([...clip.files, ...?clip.text?.split(RegExp(r'\s+'))]);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (picks.isEmpty) {
|
if (picks.isEmpty) {
|
||||||
@@ -831,12 +814,12 @@ class _QueuePanelState extends State<QueuePanel> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final height = MediaQuery.sizeOf(context).height;
|
final height = MediaQuery.sizeOf(context).height;
|
||||||
final drop = DropTarget(
|
final drop = SessionState.instance.ports.shell.dropTarget(
|
||||||
onDragEntered: (_) => setState(() => _dragging = true),
|
onEnter: () => setState(() => _dragging = true),
|
||||||
onDragExited: (_) => setState(() => _dragging = false),
|
onExit: () => setState(() => _dragging = false),
|
||||||
onDragDone: (d) {
|
onDrop: (paths, _) {
|
||||||
setState(() => _dragging = false);
|
setState(() => _dragging = false);
|
||||||
_dropped(DesktopDropX.pathsOf(d.files));
|
_dropped(paths);
|
||||||
},
|
},
|
||||||
// Esc: the popover's own (it fades out).
|
// Esc: the popover's own (it fades out).
|
||||||
child: Builder(
|
child: Builder(
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import 'settings_page.dart';
|
|||||||
import 'ui.dart';
|
import 'ui.dart';
|
||||||
import 'update_indicator.dart';
|
import 'update_indicator.dart';
|
||||||
|
|
||||||
|
/// The Minimal player skin's credit (README says the same).
|
||||||
|
const uoscCredit = 'Player timeline and controls inspired by uosc by tomasklaen, LGPL-2.1 '
|
||||||
|
'(github.com/tomasklaen/uosc).';
|
||||||
|
|
||||||
/// Name, version, update state, the source link and the logs (Settings →
|
/// Name, version, update state, the source link and the logs (Settings →
|
||||||
/// About).
|
/// About).
|
||||||
class AboutPanel extends StatelessWidget {
|
class AboutPanel extends StatelessWidget {
|
||||||
@@ -48,6 +52,8 @@ class AboutPanel extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(uoscCredit, key: const ValueKey('uosc-credit'), style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||||
const SizedBox(height: kGap),
|
const SizedBox(height: kGap),
|
||||||
Text('Something went wrong? Copy diagnostics and send them to whoever helps you: '
|
Text('Something went wrong? Copy diagnostics and send them to whoever helps you: '
|
||||||
'versions and the last hours of the log, passwords, tokens and cookies left out. '
|
'versions and the last hours of the log, passwords, tokens and cookies left out. '
|
||||||
@@ -113,8 +119,7 @@ class AboutPanel extends StatelessWidget {
|
|||||||
static Future<void> _openLogs() async {
|
static Future<void> _openLogs() async {
|
||||||
final dir = appLogDir;
|
final dir = appLogDir;
|
||||||
if (dir == null) return;
|
if (dir == null) return;
|
||||||
// The file manager opens a folder path the way a browser opens a link.
|
if (!await SessionState.instance.ports.folders.reveal(dir)) {
|
||||||
if (!await openInBrowser(Uri.directory(dir).toString())) {
|
|
||||||
await copyToClipboard(dir, done: "Couldn't open it — folder path copied");
|
await copyToClipboard(dir, done: "Couldn't open it — folder path copied");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:file_picker/file_picker.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
|
import '../../state/session_state.dart';
|
||||||
import '../../state/settings.dart';
|
import '../../state/settings.dart';
|
||||||
import '../../update/update_controller.dart';
|
import '../../update/update_controller.dart';
|
||||||
import 'ui.dart';
|
import 'ui.dart';
|
||||||
@@ -89,12 +89,11 @@ class CustomShaderFiles extends StatelessWidget {
|
|||||||
const CustomShaderFiles({super.key});
|
const CustomShaderFiles({super.key});
|
||||||
|
|
||||||
Future<void> _addFiles() async {
|
Future<void> _addFiles() async {
|
||||||
final List<PlatformFile> files;
|
final List<String> files;
|
||||||
try {
|
try {
|
||||||
files = await FilePicker.pickFiles(
|
files = await SessionState.instance.ports.folders.pickFiles(
|
||||||
dialogTitle: 'Pick mpv GLSL shaders',
|
dialogTitle: 'Pick mpv GLSL shaders',
|
||||||
type: FileType.custom,
|
extensions: const ['glsl', 'hook'],
|
||||||
allowedExtensions: const ['glsl', 'hook'],
|
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return;
|
return;
|
||||||
@@ -102,8 +101,8 @@ class CustomShaderFiles extends StatelessWidget {
|
|||||||
final s = AppSettings.instance;
|
final s = AppSettings.instance;
|
||||||
final current = s.customShaders.peek();
|
final current = s.customShaders.peek();
|
||||||
final added = [
|
final added = [
|
||||||
for (final f in files)
|
for (final p in files)
|
||||||
if (f.path case final p? when !current.contains(p)) p,
|
if (!current.contains(p)) p,
|
||||||
];
|
];
|
||||||
if (added.isNotEmpty) s.setCustomShaders([...current, ...added]);
|
if (added.isNotEmpty) s.setCustomShaders([...current, ...added]);
|
||||||
}
|
}
|
||||||
@@ -198,8 +197,8 @@ class UpdateSettings extends StatelessWidget {
|
|||||||
builder: (context) => SwitchListTile(
|
builder: (context) => SwitchListTile(
|
||||||
key: const ValueKey('auto-check-updates'),
|
key: const ValueKey('auto-check-updates'),
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
title: const Text('Check for updates on start'),
|
title: const Text('Check for updates automatically'),
|
||||||
subtitle: const Text('Looks for a new version when the app opens; nothing installs without asking'),
|
subtitle: const Text('When the app opens and every few hours; nothing installs without asking'),
|
||||||
value: s.autoCheckUpdates.value,
|
value: s.autoCheckUpdates.value,
|
||||||
onChanged: s.setAutoCheckUpdates,
|
onChanged: s.setAutoCheckUpdates,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ String displayVersion({String version = appVersion, String pubspec = _pubspecVer
|
|||||||
return isDevBuild(version) && commit.isNotEmpty ? '$version · $commit' : version;
|
return isDevBuild(version) && commit.isNotEmpty ? '$version · $commit' : version;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The version in a few characters, always on screen (under the strip's
|
||||||
|
/// Settings tab): "1.2.1", "1.2.1-dev.2", "dev run".
|
||||||
|
String shortVersion({String version = appVersion}) => version == '0.0.0-dev' ? 'dev run' : version;
|
||||||
|
|
||||||
const kRadius = 12.0;
|
const kRadius = 12.0;
|
||||||
const kGap = 16.0;
|
const kGap = 16.0;
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart' show isDevBuild;
|
||||||
|
|
||||||
import '../../update/update_controller.dart';
|
import '../../update/update_controller.dart';
|
||||||
import '../../widgets/update_banner.dart';
|
import '../../widgets/update_banner.dart';
|
||||||
import 'ui.dart';
|
import 'ui.dart';
|
||||||
|
|
||||||
/// Compact "Update vX" pill for the party strip; hidden until there is
|
/// The band across the top of the window while an update is up for
|
||||||
/// something to install. Click opens the full [UpdateBanner].
|
/// grabs: "Starsync 1.2.1 is ready", its notes, Update and restart and
|
||||||
class UpdateIndicator extends StatelessWidget {
|
/// Later (gone until the next check or start). The shell keeps it off
|
||||||
|
/// the full player while a video is on ([AppShell]).
|
||||||
|
class UpdateBar extends StatelessWidget {
|
||||||
final UpdateController? controller;
|
final UpdateController? controller;
|
||||||
const UpdateIndicator({super.key, this.controller});
|
const UpdateBar({super.key, this.controller});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -21,34 +24,102 @@ class UpdateIndicator extends StatelessWidget {
|
|||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final alert = m.isRequired || s.phase == UpdatePhase.failed;
|
||||||
|
final fg = alert ? scheme.onErrorContainer : scheme.onPrimaryContainer;
|
||||||
|
final current = c.currentVersion;
|
||||||
|
// A release (not a test build) that is behind: say which it runs.
|
||||||
|
final behind = isDevBuild(current) ? null : 'You are running $current';
|
||||||
|
final String title;
|
||||||
|
String detail;
|
||||||
|
switch (s.phase) {
|
||||||
|
case UpdatePhase.downloading:
|
||||||
|
title = 'Downloading $appName ${m.version}… ${((s.progress ?? 0) * 100).round()}%';
|
||||||
|
detail = '';
|
||||||
|
case UpdatePhase.applying:
|
||||||
|
title = 'Installing $appName ${m.version}…';
|
||||||
|
detail = '';
|
||||||
|
case UpdatePhase.restarting:
|
||||||
|
title = 'Restarting into $appName ${m.version}…';
|
||||||
|
detail = '';
|
||||||
|
case UpdatePhase.failed:
|
||||||
|
title = 'Update to $appName ${m.version} failed';
|
||||||
|
detail = '${s.error}';
|
||||||
|
case UpdatePhase.available || UpdatePhase.idle:
|
||||||
|
if (!s.canInstall) {
|
||||||
|
title = 'This copy of $appName is older than the latest release (${m.version})';
|
||||||
|
detail = 'Download the new release and replace this folder by hand';
|
||||||
|
} else if (m.isRequired) {
|
||||||
|
title = '$appName ${m.version} is required to join servers on it';
|
||||||
|
detail = m.notes;
|
||||||
|
} else {
|
||||||
|
title = '$appName ${m.version} is ready';
|
||||||
|
detail = m.notes;
|
||||||
|
}
|
||||||
|
detail = [?behind, detail].where((t) => t.isNotEmpty).join(' · ');
|
||||||
|
}
|
||||||
final busy = s.phase == UpdatePhase.downloading ||
|
final busy = s.phase == UpdatePhase.downloading ||
|
||||||
s.phase == UpdatePhase.applying ||
|
s.phase == UpdatePhase.applying ||
|
||||||
s.phase == UpdatePhase.restarting;
|
s.phase == UpdatePhase.restarting;
|
||||||
final label = switch (s.phase) {
|
return Material(
|
||||||
UpdatePhase.downloading =>
|
key: const ValueKey('update-bar'),
|
||||||
'Updating ${((s.progress ?? 0) * 100).round()}%',
|
color: alert ? scheme.errorContainer : scheme.primaryContainer,
|
||||||
UpdatePhase.applying => 'Installing…',
|
child: Padding(
|
||||||
UpdatePhase.restarting => 'Restarting…',
|
padding: const EdgeInsets.fromLTRB(16, 8, 12, 8),
|
||||||
UpdatePhase.failed => 'Update failed',
|
child: Row(
|
||||||
_ => m.isRequired ? 'Required update' : 'Update v${m.version}',
|
children: [
|
||||||
};
|
if (busy)
|
||||||
return Tooltip(
|
SizedBox.square(
|
||||||
message: 'Show update',
|
dimension: 18,
|
||||||
child: FilledButton.tonalIcon(
|
child: CircularProgressIndicator(
|
||||||
style: FilledButton.styleFrom(
|
strokeWidth: 2,
|
||||||
backgroundColor: m.isRequired || s.phase == UpdatePhase.failed
|
color: fg,
|
||||||
? scheme.errorContainer
|
value: s.phase == UpdatePhase.downloading ? s.progress : null,
|
||||||
: null,
|
),
|
||||||
visualDensity: VisualDensity.compact,
|
)
|
||||||
|
else
|
||||||
|
Icon(Icons.system_update_alt, size: 20, color: fg),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(color: fg, fontWeight: FontWeight.w600)),
|
||||||
|
if (detail.isNotEmpty)
|
||||||
|
Tooltip(
|
||||||
|
message: detail,
|
||||||
|
waitDuration: const Duration(milliseconds: 600),
|
||||||
|
child: Text(detail,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(color: fg.withValues(alpha: 0.8), fontSize: 12)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!busy) ...[
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
if (!m.isRequired)
|
||||||
|
TextButton(
|
||||||
|
key: const ValueKey('update-later'),
|
||||||
|
style: TextButton.styleFrom(foregroundColor: fg),
|
||||||
|
onPressed: c.dismiss,
|
||||||
|
child: const Text('Later'),
|
||||||
|
),
|
||||||
|
if (s.canInstall) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
FilledButton(
|
||||||
|
key: const ValueKey('update-install'),
|
||||||
|
onPressed: c.install,
|
||||||
|
child: Text(s.phase == UpdatePhase.failed ? 'Retry' : 'Update and restart'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
onPressed: () => showUpdateDialog(context, controller: c),
|
|
||||||
icon: busy
|
|
||||||
? const SizedBox(
|
|
||||||
width: 14,
|
|
||||||
height: 14,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2))
|
|
||||||
: const Icon(Icons.system_update_alt, size: 18),
|
|
||||||
label: Text(label),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -85,6 +156,9 @@ Future<void> showUpdateDialog(BuildContext context, {UpdateController? controlle
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How often a running app looks again (auto-check on).
|
||||||
|
const updateCheckEvery = Duration(hours: 4);
|
||||||
|
|
||||||
enum UpdateCheck { notRun, checking, done }
|
enum UpdateCheck { notRun, checking, done }
|
||||||
|
|
||||||
/// Progress of this run's update check, for Settings/About wording.
|
/// Progress of this run's update check, for Settings/About wording.
|
||||||
|
|||||||
@@ -8,35 +8,6 @@ import 'player_controls.dart' show FloatingPill;
|
|||||||
// Jellyfin pieces of the session player. Plain widgets fed by the
|
// Jellyfin pieces of the session player. Plain widgets fed by the
|
||||||
// coordinator's signals, so tests can pump them without the player.
|
// coordinator's signals, so tests can pump them without the player.
|
||||||
|
|
||||||
/// Previous / next episode in the video controls. Nothing when there is
|
|
||||||
/// no such episode or this viewer cannot put it up (no account).
|
|
||||||
class EpisodeSkipButton extends StatelessWidget {
|
|
||||||
final EpisodeLink? episode;
|
|
||||||
final bool next;
|
|
||||||
final VoidCallback onPressed;
|
|
||||||
final FocusNode? focusNode;
|
|
||||||
const EpisodeSkipButton(
|
|
||||||
{super.key, required this.episode, required this.next, required this.onPressed, this.focusNode});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final e = episode;
|
|
||||||
if (e == null) return const SizedBox.shrink();
|
|
||||||
return Tooltip(
|
|
||||||
message: '${next ? 'Next' : 'Previous'}: ${e.label} (${next ? '> or Shift+N' : '< or Shift+P'})',
|
|
||||||
child: IconButton(
|
|
||||||
key: ValueKey(next ? 'episode-next' : 'episode-previous'),
|
|
||||||
focusNode: focusNode,
|
|
||||||
color: Colors.white,
|
|
||||||
iconSize: 22,
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
icon: Icon(next ? Icons.skip_next_rounded : Icons.skip_previous_rounded),
|
|
||||||
onPressed: onPressed,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Floating "Next episode ›" from the ED (or the last two minutes), while
|
/// Floating "Next episode ›" from the ED (or the last two minutes), while
|
||||||
/// the controls are up (`PillGate`): faint at rest, lit on hover.
|
/// the controls are up (`PillGate`): faint at rest, lit on hover.
|
||||||
class NextEpisodePill extends StatelessWidget {
|
class NextEpisodePill extends StatelessWidget {
|
||||||
|
|||||||
@@ -1,530 +1,11 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:math' as math;
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
|
||||||
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
|
|
||||||
|
|
||||||
import '../../jellyfin/track_prefs.dart' show MpvTrack;
|
|
||||||
import '../../playback/media_marks.dart';
|
import '../../playback/media_marks.dart';
|
||||||
import '../../widgets/app_menu.dart' show AppGlass;
|
import '../../widgets/app_menu.dart' show AppGlass;
|
||||||
|
|
||||||
// Session control-bar pieces built on the playback signals (chapters,
|
// The player's floating action pills (Skip OP / ED / 1:30, Next episode):
|
||||||
// tracks, skip offers): the seek bar with chapter marks, the chapter and
|
// plain widgets fed by values and callbacks, so tests pump them without a
|
||||||
// track menus, the 1:30 skip button and the floating skip pill. Plain
|
// player. They live in the action area, outside the controls.
|
||||||
// widgets fed by values and callbacks, so tests pump them without a player.
|
|
||||||
|
|
||||||
/// Keeps media_kit's desktop controls shown, as a mouse hover would.
|
|
||||||
/// While a seek bar drag runs no hover events arrive, and the controls
|
|
||||||
/// would hide (and unmount the bar) mid-drag. A guarded dynamic call on
|
|
||||||
/// the private controls state; nothing happens outside those controls.
|
|
||||||
void keepVideoControlsAwake(BuildContext context) {
|
|
||||||
context.visitAncestorElements((e) {
|
|
||||||
if (e is StatefulElement &&
|
|
||||||
e.state.runtimeType.toString() == '_MaterialDesktopVideoControlsState') {
|
|
||||||
try {
|
|
||||||
(e.state as dynamic).onHover();
|
|
||||||
} catch (_) {}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The seek bar: played / buffered track, a diamond at each chapter start
|
|
||||||
/// (not the one at 0), and on hover the time with the chapter title above
|
|
||||||
/// it. Like media_kit's bar it reports a seek on every drag move and on
|
|
||||||
/// release ([onSeek]); the session routes those through the user-seek path
|
|
||||||
/// (debounced, everyone follows).
|
|
||||||
class ChapterSeekBar extends StatefulWidget {
|
|
||||||
final Duration position;
|
|
||||||
final Duration duration;
|
|
||||||
final Duration buffer;
|
|
||||||
final List<MediaChapter> chapters;
|
|
||||||
final ValueChanged<Duration> onSeek;
|
|
||||||
/// Any pointer activity on the bar (keeps the controls up).
|
|
||||||
final VoidCallback? onActivity;
|
|
||||||
const ChapterSeekBar({
|
|
||||||
super.key,
|
|
||||||
required this.position,
|
|
||||||
required this.duration,
|
|
||||||
this.buffer = Duration.zero,
|
|
||||||
this.chapters = const [],
|
|
||||||
required this.onSeek,
|
|
||||||
this.onActivity,
|
|
||||||
});
|
|
||||||
|
|
||||||
static const height = 28.0;
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<ChapterSeekBar> createState() => _ChapterSeekBarState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ChapterSeekBarState extends State<ChapterSeekBar> {
|
|
||||||
double? _hoverX;
|
|
||||||
bool _dragging = false;
|
|
||||||
double _dragFraction = 0;
|
|
||||||
|
|
||||||
double _fraction(Duration d) {
|
|
||||||
final total = widget.duration.inMicroseconds;
|
|
||||||
if (total <= 0) return 0;
|
|
||||||
return (d.inMicroseconds / total).clamp(0.0, 1.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
Duration _at(double fraction) => widget.duration * fraction.clamp(0.0, 1.0);
|
|
||||||
|
|
||||||
void _seekTo(double x, double width) {
|
|
||||||
if (widget.duration <= Duration.zero || width <= 0) return;
|
|
||||||
_dragFraction = (x / width).clamp(0.0, 1.0);
|
|
||||||
widget.onSeek(_at(_dragFraction));
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final primary = Theme.of(context).colorScheme.primary;
|
|
||||||
return LayoutBuilder(builder: (context, constraints) {
|
|
||||||
final width = constraints.maxWidth;
|
|
||||||
final active = _hoverX != null || _dragging;
|
|
||||||
final barHeight = active ? 5.0 : 3.0;
|
|
||||||
final played = _dragging ? _dragFraction : _fraction(widget.position);
|
|
||||||
const mid = ChapterSeekBar.height / 2;
|
|
||||||
final hoverX = _hoverX;
|
|
||||||
final hoverAt = hoverX == null || width <= 0 ? null : _at(hoverX / width);
|
|
||||||
final hoverChapter = hoverAt == null ? null : chapterIndexAt(widget.chapters, hoverAt);
|
|
||||||
final hoverTitle = hoverChapter == null ? '' : widget.chapters[hoverChapter].title;
|
|
||||||
return MouseRegion(
|
|
||||||
cursor: SystemMouseCursors.click,
|
|
||||||
onEnter: (e) => setState(() => _hoverX = e.localPosition.dx.clamp(0, width)),
|
|
||||||
onHover: (e) {
|
|
||||||
setState(() => _hoverX = e.localPosition.dx.clamp(0, width));
|
|
||||||
widget.onActivity?.call();
|
|
||||||
},
|
|
||||||
onExit: (_) => setState(() => _hoverX = null),
|
|
||||||
child: Listener(
|
|
||||||
behavior: HitTestBehavior.opaque,
|
|
||||||
onPointerDown: (e) {
|
|
||||||
widget.onActivity?.call();
|
|
||||||
setState(() {
|
|
||||||
_dragging = true;
|
|
||||||
_dragFraction = width <= 0 ? 0 : (e.localPosition.dx / width).clamp(0.0, 1.0);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onPointerMove: (e) {
|
|
||||||
widget.onActivity?.call();
|
|
||||||
setState(() => _hoverX = e.localPosition.dx.clamp(0, width));
|
|
||||||
_seekTo(e.localPosition.dx, width);
|
|
||||||
},
|
|
||||||
onPointerUp: (e) {
|
|
||||||
_seekTo(e.localPosition.dx, width);
|
|
||||||
setState(() => _dragging = false);
|
|
||||||
widget.onActivity?.call();
|
|
||||||
},
|
|
||||||
onPointerCancel: (_) => setState(() => _dragging = false),
|
|
||||||
child: SizedBox(
|
|
||||||
key: const ValueKey('seek-bar'),
|
|
||||||
width: width,
|
|
||||||
height: ChapterSeekBar.height,
|
|
||||||
child: Stack(
|
|
||||||
clipBehavior: Clip.none,
|
|
||||||
children: [
|
|
||||||
Positioned(
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
top: mid - barHeight / 2,
|
|
||||||
height: barHeight,
|
|
||||||
child: Container(color: Colors.white24),
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
left: 0,
|
|
||||||
top: mid - barHeight / 2,
|
|
||||||
height: barHeight,
|
|
||||||
width: width * _fraction(widget.buffer),
|
|
||||||
child: Container(color: Colors.white24),
|
|
||||||
),
|
|
||||||
if (hoverX != null && !_dragging)
|
|
||||||
Positioned(
|
|
||||||
left: 0,
|
|
||||||
top: mid - barHeight / 2,
|
|
||||||
height: barHeight,
|
|
||||||
width: hoverX,
|
|
||||||
child: Container(color: Colors.white24),
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
left: 0,
|
|
||||||
top: mid - barHeight / 2,
|
|
||||||
height: barHeight,
|
|
||||||
width: width * played,
|
|
||||||
child: Container(color: primary),
|
|
||||||
),
|
|
||||||
for (final c in widget.chapters)
|
|
||||||
if (c.start > Duration.zero && widget.duration > Duration.zero)
|
|
||||||
Positioned(
|
|
||||||
key: ValueKey('chapter-mark-${c.index}'),
|
|
||||||
left: width * _fraction(c.start) - 4,
|
|
||||||
top: mid - 4,
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
child: IgnorePointer(
|
|
||||||
child: Transform.rotate(
|
|
||||||
angle: math.pi / 4,
|
|
||||||
child: Container(
|
|
||||||
margin: const EdgeInsets.all(1),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
border: Border.all(color: Colors.black54, width: 0.8),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (active)
|
|
||||||
Positioned(
|
|
||||||
left: width * played - 6,
|
|
||||||
top: mid - 6,
|
|
||||||
width: 12,
|
|
||||||
height: 12,
|
|
||||||
child: IgnorePointer(
|
|
||||||
child: DecoratedBox(
|
|
||||||
decoration: BoxDecoration(color: primary, shape: BoxShape.circle),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (hoverAt != null)
|
|
||||||
Positioned(
|
|
||||||
// Kept clear of the window edges (the tip is centred).
|
|
||||||
left: hoverX!.clamp(math.min(60.0, width / 2), math.max(width - 60, width / 2)),
|
|
||||||
bottom: ChapterSeekBar.height - 2,
|
|
||||||
child: IgnorePointer(
|
|
||||||
child: FractionalTranslation(
|
|
||||||
translation: const Offset(-0.5, 0),
|
|
||||||
child: _HoverTip(title: hoverTitle, time: formatClock(hoverAt)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _HoverTip extends StatelessWidget {
|
|
||||||
final String title;
|
|
||||||
final String time;
|
|
||||||
const _HoverTip({required this.title, required this.time});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 320),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.black.withValues(alpha: 0.8),
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
if (title.isNotEmpty)
|
|
||||||
Text(title,
|
|
||||||
key: const ValueKey('seek-hover-chapter'),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600)),
|
|
||||||
Text(time,
|
|
||||||
key: const ValueKey('seek-hover-time'),
|
|
||||||
style: const TextStyle(color: Colors.white70, fontSize: 11)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [ChapterSeekBar] on a live player: position, duration and buffer from
|
|
||||||
/// its streams. Seeks go to `player.seek`, which the session's
|
|
||||||
/// `SyncPlayer` turns into user seeks.
|
|
||||||
class PlayerSeekBar extends StatefulWidget {
|
|
||||||
final Player player;
|
|
||||||
final List<MediaChapter> chapters;
|
|
||||||
const PlayerSeekBar({super.key, required this.player, required this.chapters});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<PlayerSeekBar> createState() => _PlayerSeekBarState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _PlayerSeekBarState extends State<PlayerSeekBar> {
|
|
||||||
final List<StreamSubscription<Object>> _subs = [];
|
|
||||||
late Duration _position, _duration, _buffer;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_listen();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didUpdateWidget(PlayerSeekBar old) {
|
|
||||||
super.didUpdateWidget(old);
|
|
||||||
if (!identical(old.player, widget.player)) {
|
|
||||||
_cancel();
|
|
||||||
_listen();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _listen() {
|
|
||||||
final p = widget.player;
|
|
||||||
_position = p.state.position;
|
|
||||||
_duration = p.state.duration;
|
|
||||||
_buffer = p.state.buffer;
|
|
||||||
void set(VoidCallback f) {
|
|
||||||
if (mounted) setState(f);
|
|
||||||
}
|
|
||||||
|
|
||||||
_subs.addAll([
|
|
||||||
p.stream.position.listen((d) => set(() => _position = d)),
|
|
||||||
p.stream.duration.listen((d) => set(() => _duration = d)),
|
|
||||||
p.stream.buffer.listen((d) => set(() => _buffer = d)),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _cancel() {
|
|
||||||
for (final s in _subs) {
|
|
||||||
s.cancel();
|
|
||||||
}
|
|
||||||
_subs.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_cancel();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return ChapterSeekBar(
|
|
||||||
position: _position,
|
|
||||||
duration: _duration,
|
|
||||||
buffer: _buffer,
|
|
||||||
chapters: widget.chapters,
|
|
||||||
onSeek: (d) => widget.player.seek(d),
|
|
||||||
onActivity: () => keepVideoControlsAwake(context),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
RelativeRect _menuPosition(BuildContext context) {
|
|
||||||
final button = context.findRenderObject() as RenderBox;
|
|
||||||
final overlay = Navigator.of(context).overlay!.context.findRenderObject() as RenderBox;
|
|
||||||
return RelativeRect.fromRect(
|
|
||||||
Rect.fromPoints(
|
|
||||||
button.localToGlobal(Offset.zero, ancestor: overlay),
|
|
||||||
button.localToGlobal(button.size.bottomRight(Offset.zero), ancestor: overlay),
|
|
||||||
),
|
|
||||||
Offset.zero & overlay.size,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Control-bar icon with a small count in the corner.
|
|
||||||
class _BadgedIcon extends StatelessWidget {
|
|
||||||
final IconData icon;
|
|
||||||
final int count;
|
|
||||||
const _BadgedIcon(this.icon, this.count);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Stack(
|
|
||||||
clipBehavior: Clip.none,
|
|
||||||
children: [
|
|
||||||
Icon(icon),
|
|
||||||
if (count > 0)
|
|
||||||
Positioned(
|
|
||||||
right: -7,
|
|
||||||
top: -5,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0.5),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
borderRadius: BorderRadius.circular(999),
|
|
||||||
),
|
|
||||||
child: Text('$count',
|
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 9, fontWeight: FontWeight.bold)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Chapter list for the controls: current chapter checked, a pick seeks
|
|
||||||
/// to its start. Nothing when the file has no chapters.
|
|
||||||
class ChaptersControlButton extends StatelessWidget {
|
|
||||||
final List<MediaChapter> chapters;
|
|
||||||
final int? current;
|
|
||||||
final ValueChanged<MediaChapter> onPick;
|
|
||||||
final FocusNode? focusNode;
|
|
||||||
final ValueChanged<bool>? onMenuOpen;
|
|
||||||
const ChaptersControlButton({
|
|
||||||
super.key,
|
|
||||||
required this.chapters,
|
|
||||||
required this.current,
|
|
||||||
required this.onPick,
|
|
||||||
this.focusNode,
|
|
||||||
this.onMenuOpen,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
if (chapters.isEmpty) return const SizedBox.shrink();
|
|
||||||
return Tooltip(
|
|
||||||
message: 'Chapters (PgUp / PgDn)',
|
|
||||||
child: IconButton(
|
|
||||||
key: const ValueKey('chapters-button'),
|
|
||||||
focusNode: focusNode,
|
|
||||||
color: Colors.white,
|
|
||||||
iconSize: 20,
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
icon: _BadgedIcon(Icons.format_list_bulleted, chapters.length),
|
|
||||||
onPressed: () => _open(context),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _open(BuildContext context) async {
|
|
||||||
final position = _menuPosition(context);
|
|
||||||
onMenuOpen?.call(true);
|
|
||||||
final pick = await showMenu<MediaChapter>(
|
|
||||||
context: context,
|
|
||||||
position: position,
|
|
||||||
popUpAnimationStyle: AnimationStyle.noAnimation,
|
|
||||||
constraints: const BoxConstraints(minWidth: 240, maxWidth: 420),
|
|
||||||
items: [
|
|
||||||
for (final c in chapters)
|
|
||||||
CheckedPopupMenuItem(
|
|
||||||
value: c,
|
|
||||||
checked: c.index == current,
|
|
||||||
height: 36,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(c.title.isEmpty ? 'Chapter ${c.index + 1}' : c.title,
|
|
||||||
maxLines: 1, overflow: TextOverflow.ellipsis),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Text(formatClock(c.start), style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
onMenuOpen?.call(false);
|
|
||||||
if (pick != null) onPick(pick);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A track pick from [TrackMenuButton]: audio id, subtitle id, or off.
|
|
||||||
typedef TrackPick = ({int? aid, int? sid, bool subtitlesOff});
|
|
||||||
|
|
||||||
/// Audio or subtitle menu for the controls bar (the count of tracks on the
|
|
||||||
/// icon): "Japanese · E-AC3 2.0 · title", mpv's selection checked,
|
|
||||||
/// subtitles with Off. The choice is remembered for the next Jellyfin item
|
|
||||||
/// (per viewer).
|
|
||||||
class TrackMenuButton extends StatelessWidget {
|
|
||||||
final JellyfinStreamType type;
|
|
||||||
/// All tracks; only those of [type] are listed.
|
|
||||||
final List<MpvTrack> tracks;
|
|
||||||
final void Function(TrackPick pick) onPick;
|
|
||||||
final FocusNode? focusNode;
|
|
||||||
final ValueChanged<bool>? onMenuOpen;
|
|
||||||
const TrackMenuButton({
|
|
||||||
super.key,
|
|
||||||
required this.type,
|
|
||||||
required this.tracks,
|
|
||||||
required this.onPick,
|
|
||||||
this.focusNode,
|
|
||||||
this.onMenuOpen,
|
|
||||||
});
|
|
||||||
|
|
||||||
bool get _audio => type == JellyfinStreamType.audio;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final list = tracks.where((t) => t.type == type).toList();
|
|
||||||
return Tooltip(
|
|
||||||
message: _audio ? 'Audio' : 'Subtitles',
|
|
||||||
child: IconButton(
|
|
||||||
key: ValueKey(_audio ? 'audio-button' : 'subtitles-button'),
|
|
||||||
focusNode: focusNode,
|
|
||||||
color: Colors.white,
|
|
||||||
iconSize: 20,
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
icon: _BadgedIcon(_audio ? Icons.audiotrack_outlined : Icons.subtitles_outlined, list.length),
|
|
||||||
onPressed: () => _open(context, list),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _open(BuildContext context, List<MpvTrack> list) async {
|
|
||||||
final position = _menuPosition(context);
|
|
||||||
onMenuOpen?.call(true);
|
|
||||||
final pick = await showMenu<TrackPick>(
|
|
||||||
context: context,
|
|
||||||
position: position,
|
|
||||||
popUpAnimationStyle: AnimationStyle.noAnimation,
|
|
||||||
constraints: const BoxConstraints(minWidth: 280, maxWidth: 460),
|
|
||||||
items: [
|
|
||||||
if (!_audio)
|
|
||||||
CheckedPopupMenuItem(
|
|
||||||
value: (aid: null, sid: null, subtitlesOff: true),
|
|
||||||
checked: !list.any((t) => t.selected),
|
|
||||||
height: 36,
|
|
||||||
child: const Text('Off'),
|
|
||||||
),
|
|
||||||
if (_audio && list.isEmpty)
|
|
||||||
const PopupMenuItem(enabled: false, height: 32, child: Text('None')),
|
|
||||||
for (final t in list)
|
|
||||||
CheckedPopupMenuItem(
|
|
||||||
value: _audio
|
|
||||||
? (aid: t.id, sid: null, subtitlesOff: false)
|
|
||||||
: (aid: null, sid: t.id, subtitlesOff: false),
|
|
||||||
checked: t.selected,
|
|
||||||
height: 36,
|
|
||||||
child: Text(t.label, maxLines: 1, overflow: TextOverflow.ellipsis),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
onMenuOpen?.call(false);
|
|
||||||
if (pick != null) onPick(pick);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// "1:30" in the controls: skip forward [fallbackSkip] (Shift+Right).
|
|
||||||
class SkipForwardButton extends StatelessWidget {
|
|
||||||
final VoidCallback onPressed;
|
|
||||||
final FocusNode? focusNode;
|
|
||||||
const SkipForwardButton({super.key, required this.onPressed, this.focusNode});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Tooltip(
|
|
||||||
message: 'Skip 1:30 for everyone (Shift+Right)',
|
|
||||||
child: TextButton.icon(
|
|
||||||
key: const ValueKey('skip-forward-button'),
|
|
||||||
focusNode: focusNode,
|
|
||||||
style: TextButton.styleFrom(
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
||||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
||||||
),
|
|
||||||
onPressed: onPressed,
|
|
||||||
icon: const Icon(Icons.fast_forward_rounded, size: 16),
|
|
||||||
label: const Text('1:30', style: TextStyle(fontSize: 12)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The player's action pill (Next episode, Skip OP / ED / 1:30): the
|
/// The player's action pill (Next episode, Skip OP / ED / 1:30): the
|
||||||
/// status pill's glass ([AppGlass.pill]) at [height], faint at rest, lit
|
/// status pill's glass ([AppGlass.pill]) at [height], faint at rest, lit
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import 'package:sync_relay_core/core.dart' show FeedMessage;
|
||||||
|
|
||||||
|
// Everything that may become a toast, a sound, an attention request or an
|
||||||
|
// activity-log line, as one sealed type. Code that has news emits an
|
||||||
|
// event ([Notifier.notify]); only the notifier decides what the user sees
|
||||||
|
// and hears. Pure Dart (no Flutter): it can move to core with the policy.
|
||||||
|
|
||||||
|
sealed class AppEvent {
|
||||||
|
const AppEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- feed
|
||||||
|
|
||||||
|
/// A server `feed` line (see PROTOCOL.md, `feed.action`); every one lands
|
||||||
|
/// in the activity log.
|
||||||
|
sealed class FeedEvent extends AppEvent {
|
||||||
|
final FeedMessage feed;
|
||||||
|
const FeedEvent(this.feed);
|
||||||
|
|
||||||
|
String get actor => feed.actor;
|
||||||
|
|
||||||
|
/// [feed] as its typed event. [selfName]: this member's name (the feed
|
||||||
|
/// names members by name); [readyCount]: members ready in the latest
|
||||||
|
/// state (the pitch of a `ready` sound).
|
||||||
|
factory FeedEvent.of(FeedMessage f, {String? selfName, int readyCount = 0}) => switch (f.action) {
|
||||||
|
'join' => MemberJoined(f),
|
||||||
|
'leave' => MemberLeft(f),
|
||||||
|
'stale' => MemberStale(f),
|
||||||
|
'out' => MemberSatOut(f, atYou: f.target != null && f.target == selfName && f.actor != selfName),
|
||||||
|
'back' => MemberBack(f),
|
||||||
|
'ready' => ReadyChanged(f, ready: true, readyIndex: readyCount),
|
||||||
|
'unready' => ReadyChanged(f, ready: false),
|
||||||
|
'force-ready' => ForcedReady(f, ready: true),
|
||||||
|
'force-unready' => ForcedReady(f, ready: false),
|
||||||
|
'force-play' => ForcedTransport(f, play: true),
|
||||||
|
'force-hold' => ForcedTransport(f, play: false),
|
||||||
|
'swap' => MediaSwapped(f),
|
||||||
|
'seek' => Seeked(f),
|
||||||
|
'pause' => Paused(f),
|
||||||
|
'resume' => Resumed(f),
|
||||||
|
'finished' => Finished(f),
|
||||||
|
'chat' => ChatPosted(f, own: f.actor == selfName),
|
||||||
|
'rename' => MemberRenamed(f),
|
||||||
|
'avatar' => AvatarChanged(f),
|
||||||
|
'queue-add' => QueueAdded(f),
|
||||||
|
'queue-remove' => QueueRemoved(f),
|
||||||
|
'queue-skip' => QueueSkipped(f),
|
||||||
|
'fetch-fail' => FetchFailed(f),
|
||||||
|
_ => OtherFeed(f),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class MemberJoined extends FeedEvent {
|
||||||
|
const MemberJoined(super.feed);
|
||||||
|
}
|
||||||
|
|
||||||
|
class MemberLeft extends FeedEvent {
|
||||||
|
const MemberLeft(super.feed);
|
||||||
|
}
|
||||||
|
|
||||||
|
class MemberStale extends FeedEvent {
|
||||||
|
const MemberStale(super.feed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Someone sat out (`target` sat out by `actor`); [atYou]: someone else
|
||||||
|
/// sat this member out.
|
||||||
|
class MemberSatOut extends FeedEvent {
|
||||||
|
final bool atYou;
|
||||||
|
const MemberSatOut(super.feed, {this.atYou = false});
|
||||||
|
}
|
||||||
|
|
||||||
|
class MemberBack extends FeedEvent {
|
||||||
|
const MemberBack(super.feed);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReadyChanged extends FeedEvent {
|
||||||
|
final bool ready;
|
||||||
|
/// Members ready when it came (pitch of the ready sound).
|
||||||
|
final int readyIndex;
|
||||||
|
const ReadyChanged(super.feed, {required this.ready, this.readyIndex = 0});
|
||||||
|
}
|
||||||
|
|
||||||
|
class ForcedReady extends FeedEvent {
|
||||||
|
final bool ready;
|
||||||
|
const ForcedReady(super.feed, {required this.ready});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force play / hold.
|
||||||
|
class ForcedTransport extends FeedEvent {
|
||||||
|
final bool play;
|
||||||
|
const ForcedTransport(super.feed, {required this.play});
|
||||||
|
}
|
||||||
|
|
||||||
|
class MediaSwapped extends FeedEvent {
|
||||||
|
const MediaSwapped(super.feed);
|
||||||
|
}
|
||||||
|
|
||||||
|
class Seeked extends FeedEvent {
|
||||||
|
const Seeked(super.feed);
|
||||||
|
}
|
||||||
|
|
||||||
|
class Paused extends FeedEvent {
|
||||||
|
const Paused(super.feed);
|
||||||
|
}
|
||||||
|
|
||||||
|
class Resumed extends FeedEvent {
|
||||||
|
const Resumed(super.feed);
|
||||||
|
}
|
||||||
|
|
||||||
|
class Finished extends FeedEvent {
|
||||||
|
const Finished(super.feed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A chat message (`reason` carries the text); [own]: this member sent it.
|
||||||
|
class ChatPosted extends FeedEvent {
|
||||||
|
final bool own;
|
||||||
|
const ChatPosted(super.feed, {this.own = false});
|
||||||
|
}
|
||||||
|
|
||||||
|
class MemberRenamed extends FeedEvent {
|
||||||
|
const MemberRenamed(super.feed);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AvatarChanged extends FeedEvent {
|
||||||
|
const AvatarChanged(super.feed);
|
||||||
|
}
|
||||||
|
|
||||||
|
class QueueAdded extends FeedEvent {
|
||||||
|
const QueueAdded(super.feed);
|
||||||
|
}
|
||||||
|
|
||||||
|
class QueueRemoved extends FeedEvent {
|
||||||
|
const QueueRemoved(super.feed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Up next skipped an item (`target` its title, `reason` why).
|
||||||
|
class QueueSkipped extends FeedEvent {
|
||||||
|
const QueueSkipped(super.feed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The server could not fetch the current media.
|
||||||
|
class FetchFailed extends FeedEvent {
|
||||||
|
const FetchFailed(super.feed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An action this app does not know (a newer server).
|
||||||
|
class OtherFeed extends FeedEvent {
|
||||||
|
const OtherFeed(super.feed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- session
|
||||||
|
|
||||||
|
/// Someone poked this member.
|
||||||
|
class Nudged extends AppEvent {
|
||||||
|
/// Their name; '' when unknown.
|
||||||
|
final String name;
|
||||||
|
const Nudged(this.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Another member's player cannot open the current media.
|
||||||
|
class MemberCantPlay extends AppEvent {
|
||||||
|
final String name;
|
||||||
|
final String error;
|
||||||
|
const MemberCantPlay(this.name, this.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This member passes the video on slower than it plays.
|
||||||
|
class SlowSource extends AppEvent {
|
||||||
|
final String text;
|
||||||
|
const SlowSource(this.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This member started passing the video on through the server.
|
||||||
|
class RelayStarted extends AppEvent {
|
||||||
|
const RelayStarted();
|
||||||
|
}
|
||||||
|
|
||||||
|
class RelayFailed extends AppEvent {
|
||||||
|
final String error;
|
||||||
|
const RelayFailed(this.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Idle: auto-unready (or sit-out, [sitsOut]) in 2 minutes.
|
||||||
|
class IdleWarning extends AppEvent {
|
||||||
|
final bool sitsOut;
|
||||||
|
const IdleWarning({required this.sitsOut});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The connection to the server dropped (once per drop).
|
||||||
|
class ConnectionLost extends AppEvent {
|
||||||
|
const ConnectionLost();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// It came back after [ConnectionLost].
|
||||||
|
class ConnectionBack extends AppEvent {
|
||||||
|
const ConnectionBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Jellyfin progress reports fail (once per media).
|
||||||
|
class JellyfinProgressFailed extends AppEvent {
|
||||||
|
final bool signInExpired;
|
||||||
|
const JellyfinProgressFailed({required this.signInExpired});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ app
|
||||||
|
|
||||||
|
/// The player's shader message (a preset change, shaders turned off).
|
||||||
|
class ShaderMessage extends AppEvent {
|
||||||
|
final String text;
|
||||||
|
const ShaderMessage(this.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This app added an item to Up next.
|
||||||
|
class AddedToUpNext extends AppEvent {
|
||||||
|
final String title;
|
||||||
|
const AddedToUpNext(this.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Putting something up or adding it to Up next failed ([text]: why).
|
||||||
|
class ActionFailed extends AppEvent {
|
||||||
|
final String text;
|
||||||
|
const ActionFailed(this.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Schedule tab's request results and errors.
|
||||||
|
class ScheduleResult extends AppEvent {
|
||||||
|
final String text;
|
||||||
|
const ScheduleResult(this.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Mark a problem" done; [inSession]: in the server's session log.
|
||||||
|
class ProblemMarked extends AppEvent {
|
||||||
|
final bool inSession;
|
||||||
|
const ProblemMarked({required this.inSession});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Something went on the clipboard ([text]: the confirmation, e.g.
|
||||||
|
/// "Link copied").
|
||||||
|
class Copied extends AppEvent {
|
||||||
|
final String text;
|
||||||
|
const Copied(this.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
class CopyFailed extends AppEvent {
|
||||||
|
const CopyFailed();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A newer release is out (the update band shows it).
|
||||||
|
class UpdateAvailable extends AppEvent {
|
||||||
|
final String version;
|
||||||
|
const UpdateAvailable(this.version);
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:signals/signals_core.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart' show FeedMessage;
|
||||||
|
|
||||||
|
import '../sfx/sfx.dart';
|
||||||
|
import 'app_events.dart';
|
||||||
|
|
||||||
|
export 'app_events.dart';
|
||||||
|
|
||||||
|
class ToastItem {
|
||||||
|
final int id;
|
||||||
|
final String text;
|
||||||
|
final DateTime at;
|
||||||
|
/// The member a feed or chat toast is about (their name, as the feed
|
||||||
|
/// carries it; [text] starts with it); null for the app's own toasts.
|
||||||
|
final String? actor;
|
||||||
|
const ToastItem({required this.id, required this.text, required this.at, this.actor});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one [AppEvent] does: a toast ([toast], about [actor]), a sound,
|
||||||
|
/// the window asking for attention, an activity-log line.
|
||||||
|
class Notice {
|
||||||
|
final String? toast;
|
||||||
|
final String? actor;
|
||||||
|
final SfxCue? sound;
|
||||||
|
final bool attention;
|
||||||
|
/// The feed line it goes into the activity log as.
|
||||||
|
final FeedMessage? log;
|
||||||
|
const Notice({this.toast, this.actor, this.sound, this.attention = false, this.log});
|
||||||
|
|
||||||
|
static const none = Notice();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The policy: what each event does.
|
||||||
|
Notice noticeFor(AppEvent e) => switch (e) {
|
||||||
|
// The feed: everything is logged; joins, leaves, swaps and chat
|
||||||
|
// pop up; the social actions have a sound.
|
||||||
|
MemberJoined(:final feed) => Notice(
|
||||||
|
log: feed, toast: '${feed.actor} joined', actor: feed.actor, sound: const SfxCue(SfxKind.join)),
|
||||||
|
MemberLeft(:final feed) => Notice(
|
||||||
|
log: feed, toast: '${feed.actor} left', actor: feed.actor, sound: const SfxCue(SfxKind.leave)),
|
||||||
|
MediaSwapped(:final feed) => Notice(
|
||||||
|
log: feed, toast: '${feed.actor} changed media', actor: feed.actor, sound: const SfxCue(SfxKind.swap)),
|
||||||
|
ChatPosted(:final feed, :final own) => Notice(
|
||||||
|
log: feed,
|
||||||
|
toast: '${feed.actor}: ${feed.reason}',
|
||||||
|
actor: feed.actor,
|
||||||
|
// Your own message needs no sound.
|
||||||
|
sound: own ? null : const SfxCue(SfxKind.chat)),
|
||||||
|
MemberSatOut(:final feed, atYou: true) => Notice(
|
||||||
|
log: feed, toast: '${feed.actor} sat you out', actor: feed.actor, sound: const SfxCue(SfxKind.kick)),
|
||||||
|
ReadyChanged(:final feed, ready: true, :final readyIndex) =>
|
||||||
|
Notice(log: feed, sound: SfxCue(SfxKind.ready, readyIndex: readyIndex)),
|
||||||
|
ReadyChanged(:final feed, ready: false) => Notice(log: feed, sound: const SfxCue(SfxKind.unready)),
|
||||||
|
ForcedReady(:final feed, ready: true) => Notice(log: feed, sound: const SfxCue(SfxKind.forceReady)),
|
||||||
|
QueueSkipped(:final feed) => Notice(
|
||||||
|
log: feed, toast: 'Skipped ${feed.target ?? 'an item'}${feed.reason.isEmpty ? '' : ': ${feed.reason}'}'),
|
||||||
|
FeedEvent(:final feed) => Notice(log: feed),
|
||||||
|
// The session.
|
||||||
|
Nudged(:final name) => Notice(
|
||||||
|
toast: '${name.isEmpty ? 'Someone' : name} nudged you',
|
||||||
|
actor: name.isEmpty ? null : name,
|
||||||
|
sound: const SfxCue(SfxKind.nudge),
|
||||||
|
attention: true),
|
||||||
|
MemberCantPlay(:final name, :final error) => Notice(toast: "$name can't play this: $error", actor: name),
|
||||||
|
SlowSource(:final text) => Notice(toast: text),
|
||||||
|
RelayStarted() =>
|
||||||
|
const Notice(toast: 'Passing the video on through the server. Keep the app open while it plays.'),
|
||||||
|
RelayFailed(:final error) => Notice(toast: 'Cannot pass the video on: $error'),
|
||||||
|
IdleWarning(:final sitsOut) => Notice(
|
||||||
|
toast: sitsOut
|
||||||
|
? 'idle — sitting out in 2 min unless you press Space'
|
||||||
|
: 'idle — auto-unready in 2 min unless you press Space',
|
||||||
|
sound: const SfxCue(SfxKind.warn)),
|
||||||
|
ConnectionLost() => const Notice(sound: SfxCue(SfxKind.lost)),
|
||||||
|
ConnectionBack() => const Notice(sound: SfxCue(SfxKind.back)),
|
||||||
|
JellyfinProgressFailed(:final signInExpired) =>
|
||||||
|
Notice(toast: 'Jellyfin progress not saved: ${signInExpired ? 'sign-in expired' : 'server unreachable'}'),
|
||||||
|
// The app.
|
||||||
|
ShaderMessage(:final text) => Notice(toast: text),
|
||||||
|
AddedToUpNext(:final title) => Notice(toast: 'Added $title to Up next'),
|
||||||
|
ActionFailed(:final text) => Notice(toast: text),
|
||||||
|
ScheduleResult(:final text) => Notice(toast: text),
|
||||||
|
ProblemMarked(:final inSession) =>
|
||||||
|
Notice(toast: inSession ? 'Marked in the session log' : "Marked in this app's log"),
|
||||||
|
Copied(:final text) => Notice(toast: text),
|
||||||
|
CopyFailed() => const Notice(toast: "Couldn't copy"),
|
||||||
|
// The update band shows it.
|
||||||
|
UpdateAvailable() => Notice.none,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The one way news reaches the user: [notify] turns an [AppEvent] into
|
||||||
|
/// toasts ([toasts]), sounds, an attention request and the activity log
|
||||||
|
/// ([feedLog]) per [noticeFor].
|
||||||
|
class Notifier {
|
||||||
|
final SfxService sfx;
|
||||||
|
final Future<void> Function() attention;
|
||||||
|
Notifier({this.sfx = const NoopSfxService(), Future<void> Function()? attention})
|
||||||
|
: attention = attention ?? _noAttention;
|
||||||
|
|
||||||
|
static Future<void> _noAttention() async {}
|
||||||
|
|
||||||
|
/// Toasts on screen (the shell shows them; [expire] drops old ones).
|
||||||
|
final toasts = signal<List<ToastItem>>([]);
|
||||||
|
/// The activity log: the last 100 feed lines.
|
||||||
|
final feedLog = signal<List<FeedMessage>>([]);
|
||||||
|
int _toastId = 0;
|
||||||
|
|
||||||
|
void notify(AppEvent e) {
|
||||||
|
final n = noticeFor(e);
|
||||||
|
final log = n.log;
|
||||||
|
if (log != null) {
|
||||||
|
final all = [...feedLog.peek(), log];
|
||||||
|
feedLog.value = all.length > 100 ? all.sublist(all.length - 100) : all;
|
||||||
|
}
|
||||||
|
final text = n.toast;
|
||||||
|
if (text != null) {
|
||||||
|
_toastId++;
|
||||||
|
toasts.value = [
|
||||||
|
...toasts.peek(),
|
||||||
|
ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc(), actor: n.actor),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
final sound = n.sound;
|
||||||
|
if (sound != null) unawaited(sfx.play(sound));
|
||||||
|
if (n.attention) unawaited(attention());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops toasts 4 s old or older.
|
||||||
|
void expire(DateTime now) {
|
||||||
|
toasts.value = toasts.peek().where((t) => now.difference(t.at).inSeconds < 4).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A new session: no toasts, an empty log.
|
||||||
|
void clear() {
|
||||||
|
feedLog.value = [];
|
||||||
|
toasts.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,12 +14,12 @@ import '../jellyfin/session_media.dart';
|
|||||||
import '../jellyfin/track_prefs.dart';
|
import '../jellyfin/track_prefs.dart';
|
||||||
import '../playback/media_marks.dart';
|
import '../playback/media_marks.dart';
|
||||||
import '../playback/playback_controller.dart';
|
import '../playback/playback_controller.dart';
|
||||||
import '../platform/attention.dart';
|
import '../platform/ports/link_resolver.dart';
|
||||||
import '../playback/yt_dlp_app.dart';
|
import '../playback/yt_dlp_app.dart' show AppYtDlp;
|
||||||
import '../schedule/anilist.dart';
|
import '../schedule/anilist.dart';
|
||||||
import '../screens/home/ui.dart' show appName, displayVersion;
|
import '../screens/home/ui.dart' show appName, displayVersion;
|
||||||
import '../screens/home/picked_media.dart';
|
import '../screens/home/picked_media.dart';
|
||||||
import '../sfx/sfx.dart';
|
import '../sfx/sfx.dart' show NoopSfxService, SfxService;
|
||||||
import '../state/settings.dart';
|
import '../state/settings.dart';
|
||||||
import '../sync/avatar_cache.dart';
|
import '../sync/avatar_cache.dart';
|
||||||
import '../sync/state_diff.dart';
|
import '../sync/state_diff.dart';
|
||||||
@@ -30,10 +30,13 @@ import 'delivery.dart';
|
|||||||
import 'health.dart';
|
import 'health.dart';
|
||||||
import 'idle.dart';
|
import 'idle.dart';
|
||||||
import 'media_art.dart';
|
import 'media_art.dart';
|
||||||
|
import 'notifier.dart';
|
||||||
import 'now_playing.dart';
|
import 'now_playing.dart';
|
||||||
import 'now_playing_info.dart';
|
import 'now_playing_info.dart';
|
||||||
import 'switch_trace.dart';
|
import 'switch_trace.dart';
|
||||||
|
|
||||||
|
export 'notifier.dart' show ToastItem;
|
||||||
|
|
||||||
final _log = createLogger('session');
|
final _log = createLogger('session');
|
||||||
|
|
||||||
/// Thrown when a connect is superseded by cancel/leave before it finished.
|
/// Thrown when a connect is superseded by cancel/leave before it finished.
|
||||||
@@ -100,18 +103,6 @@ class AutoAdvance {
|
|||||||
/// Seconds of the end-of-episode countdown.
|
/// Seconds of the end-of-episode countdown.
|
||||||
const autoAdvanceSecs = 10;
|
const autoAdvanceSecs = 10;
|
||||||
|
|
||||||
class ToastItem {
|
|
||||||
final int id;
|
|
||||||
final String text;
|
|
||||||
final DateTime at;
|
|
||||||
/// The member a feed or chat toast is about (their name, as the feed
|
|
||||||
/// carries it; [text] starts with it); null for the app's own toasts.
|
|
||||||
final String? actor;
|
|
||||||
const ToastItem({required this.id, required this.text, required this.at, this.actor});
|
|
||||||
}
|
|
||||||
|
|
||||||
const _toastActions = {'join', 'leave', 'swap', 'chat'};
|
|
||||||
|
|
||||||
/// "Unready when the video changes": a state with a newer [mediaId] than
|
/// "Unready when the video changes": a state with a newer [mediaId] than
|
||||||
/// the last one this session saw ([lastMediaId], -1 before the first) while
|
/// the last one this session saw ([lastMediaId], -1 before the first) while
|
||||||
/// self is ready (or a reconnect is restoring it). A swap keeps the
|
/// self is ready (or a reconnect is restoring it). A swap keeps the
|
||||||
@@ -164,7 +155,9 @@ String? stateMediaUrl(StateBroadcast state) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class SessionCoordinator {
|
class SessionCoordinator {
|
||||||
final SfxService sfx;
|
/// Toasts, sounds and the activity log: every event goes through
|
||||||
|
/// [notify].
|
||||||
|
final Notifier notifier;
|
||||||
/// Linked Jellyfin servers: how this viewer plays Jellyfin refs.
|
/// Linked Jellyfin servers: how this viewer plays Jellyfin refs.
|
||||||
final JellyfinAccounts jellyfin;
|
final JellyfinAccounts jellyfin;
|
||||||
/// Audio/subtitle carry-over between Jellyfin items.
|
/// Audio/subtitle carry-over between Jellyfin items.
|
||||||
@@ -173,7 +166,7 @@ class SessionCoordinator {
|
|||||||
/// null when this app is not the host.
|
/// null when this app is not the host.
|
||||||
Future<({String name, int size})?> Function(String path)? hostFile;
|
Future<({String name, int size})?> Function(String path)? hostFile;
|
||||||
/// The app's yt-dlp and link probes (tests pass their own).
|
/// The app's yt-dlp and link probes (tests pass their own).
|
||||||
final AppYtDlp ytDlp;
|
final LinkResolver ytDlp;
|
||||||
final SyncClient sync = SyncClient();
|
final SyncClient sync = SyncClient();
|
||||||
/// This app's log lines to the server's session log (Settings: "Share
|
/// This app's log lines to the server's session log (Settings: "Share
|
||||||
/// diagnostics with the server"), and "Mark a problem".
|
/// diagnostics with the server"), and "Mark a problem".
|
||||||
@@ -217,8 +210,10 @@ class SessionCoordinator {
|
|||||||
final health =
|
final health =
|
||||||
signal(const HealthStatus(HealthSeverity.info, 'idle'));
|
signal(const HealthStatus(HealthSeverity.info, 'idle'));
|
||||||
final serverState = signal<StateBroadcast?>(null);
|
final serverState = signal<StateBroadcast?>(null);
|
||||||
final feedLog = signal<List<FeedMessage>>([]);
|
/// The activity log ([Notifier.feedLog]).
|
||||||
final toasts = signal<List<ToastItem>>([]);
|
Signal<List<FeedMessage>> get feedLog => notifier.feedLog;
|
||||||
|
/// Toasts on screen ([Notifier.toasts]).
|
||||||
|
Signal<List<ToastItem>> get toasts => notifier.toasts;
|
||||||
final reconnecting = signal(false);
|
final reconnecting = signal(false);
|
||||||
final starving = signal<String?>(null);
|
final starving = signal<String?>(null);
|
||||||
/// Why this player cannot open the current media, in plain words
|
/// Why this player cannot open the current media, in plain words
|
||||||
@@ -279,9 +274,6 @@ class SessionCoordinator {
|
|||||||
/// Self sits out: the player has nothing open until [joinBack].
|
/// Self sits out: the player has nothing open until [joinBack].
|
||||||
late final Computed<bool> selfOut = computed(() => selfMember.value?.out ?? false);
|
late final Computed<bool> selfOut = computed(() => selfMember.value?.out ?? false);
|
||||||
|
|
||||||
/// Asks the desktop to point at the window (a nudge); tests swap it.
|
|
||||||
Future<void> Function() requestAttention = requestWindowAttention;
|
|
||||||
|
|
||||||
Timer? _ticker;
|
Timer? _ticker;
|
||||||
StreamSubscription? _stateSub;
|
StreamSubscription? _stateSub;
|
||||||
StreamSubscription? _feedSub;
|
StreamSubscription? _feedSub;
|
||||||
@@ -362,7 +354,6 @@ class SessionCoordinator {
|
|||||||
String _resumeToken = '';
|
String _resumeToken = '';
|
||||||
DateTime? _lastStateAt;
|
DateTime? _lastStateAt;
|
||||||
final IdleTracker _idle = IdleTracker(DateTime.now().toUtc());
|
final IdleTracker _idle = IdleTracker(DateTime.now().toUtc());
|
||||||
int _toastId = 0;
|
|
||||||
ConnectParams? _params;
|
ConnectParams? _params;
|
||||||
bool _everConnected = false;
|
bool _everConnected = false;
|
||||||
/// The connection-lost sound played; the reconnected one answers it once.
|
/// The connection-lost sound played; the reconnected one answers it once.
|
||||||
@@ -388,9 +379,15 @@ class SessionCoordinator {
|
|||||||
// Members' last `error`, by id, so a new one toasts once.
|
// Members' last `error`, by id, so a new one toasts once.
|
||||||
Map<String, String> _memberErrors = const {};
|
Map<String, String> _memberErrors = const {};
|
||||||
|
|
||||||
|
/// [notifier] defaults to one playing [sfx] (tests).
|
||||||
SessionCoordinator(
|
SessionCoordinator(
|
||||||
{this.sfx = const NoopSfxService(), JellyfinAccounts? jellyfin, TrackPrefs? trackPrefs, AppYtDlp? ytDlp})
|
{SfxService sfx = const NoopSfxService(),
|
||||||
: ytDlp = ytDlp ?? AppYtDlp.instance,
|
Notifier? notifier,
|
||||||
|
JellyfinAccounts? jellyfin,
|
||||||
|
TrackPrefs? trackPrefs,
|
||||||
|
LinkResolver? ytDlp})
|
||||||
|
: notifier = notifier ?? Notifier(sfx: sfx),
|
||||||
|
ytDlp = ytDlp ?? AppYtDlp.instance,
|
||||||
jellyfin = jellyfin ?? JellyfinAccounts.instance,
|
jellyfin = jellyfin ?? JellyfinAccounts.instance,
|
||||||
trackPrefs = trackPrefs ?? TrackPrefs();
|
trackPrefs = trackPrefs ?? TrackPrefs();
|
||||||
|
|
||||||
@@ -438,7 +435,7 @@ class SessionCoordinator {
|
|||||||
for (final m in r.members) {
|
for (final m in r.members) {
|
||||||
final e = now[m.id];
|
final e = now[m.id];
|
||||||
if (e != null && m.id != sync.memberId && _memberErrors[m.id] != e) {
|
if (e != null && m.id != sync.memberId && _memberErrors[m.id] != e) {
|
||||||
_pushToast("${m.name} can't play this: $e", actor: m.name);
|
notify(MemberCantPlay(m.name, e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_memberErrors = now;
|
_memberErrors = now;
|
||||||
@@ -452,7 +449,7 @@ class SessionCoordinator {
|
|||||||
final warning = slowSourceWarning(r, sync.memberId);
|
final warning = slowSourceWarning(r, sync.memberId);
|
||||||
if (warning == null) return;
|
if (warning == null) return;
|
||||||
_slowWarnedFor = r.mediaId;
|
_slowWarnedFor = r.mediaId;
|
||||||
_pushToast(warning);
|
notify(SlowSource(warning));
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _reloadingVideo = false;
|
bool _reloadingVideo = false;
|
||||||
@@ -528,8 +525,7 @@ class SessionCoordinator {
|
|||||||
diag
|
diag
|
||||||
..share = AppSettings.instance.shareDiagnostics.peek()
|
..share = AppSettings.instance.shareDiagnostics.peek()
|
||||||
..start();
|
..start();
|
||||||
feedLog.value = [];
|
notifier.clear();
|
||||||
toasts.value = [];
|
|
||||||
await initPlayer();
|
await initPlayer();
|
||||||
if (epoch != _epoch) throw const ConnectCancelled();
|
if (epoch != _epoch) throw const ConnectCancelled();
|
||||||
health.value =
|
health.value =
|
||||||
@@ -807,9 +803,7 @@ class SessionCoordinator {
|
|||||||
_relaySource = warm.src;
|
_relaySource = warm.src;
|
||||||
_relaySourceMediaId = mediaId;
|
_relaySourceMediaId = mediaId;
|
||||||
relaying.value = true;
|
relaying.value = true;
|
||||||
if (announce) {
|
if (announce) notify(const RelayStarted());
|
||||||
_pushToast('Passing the video on through the server. Keep the app open while it plays.');
|
|
||||||
}
|
|
||||||
unawaited(warm.src.done.then((_) {
|
unawaited(warm.src.done.then((_) {
|
||||||
if (identical(_relaySource, warm.src)) {
|
if (identical(_relaySource, warm.src)) {
|
||||||
_relaySource = null;
|
_relaySource = null;
|
||||||
@@ -823,9 +817,7 @@ class SessionCoordinator {
|
|||||||
_relaySource = src;
|
_relaySource = src;
|
||||||
_relaySourceMediaId = mediaId;
|
_relaySourceMediaId = mediaId;
|
||||||
relaying.value = true;
|
relaying.value = true;
|
||||||
if (announce) {
|
if (announce) notify(const RelayStarted());
|
||||||
_pushToast('Passing the video on through the server. Keep the app open while it plays.');
|
|
||||||
}
|
|
||||||
unawaited(() async {
|
unawaited(() async {
|
||||||
try {
|
try {
|
||||||
await src.start();
|
await src.start();
|
||||||
@@ -834,7 +826,7 @@ class SessionCoordinator {
|
|||||||
if (identical(_relaySource, src)) {
|
if (identical(_relaySource, src)) {
|
||||||
_relaySource = null;
|
_relaySource = null;
|
||||||
relaying.value = false;
|
relaying.value = false;
|
||||||
_pushToast('Cannot pass the video on: $e');
|
notify(RelayFailed('$e'));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1138,62 +1130,19 @@ class SessionCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onFeed(FeedMessage f) {
|
void _onFeed(FeedMessage f) {
|
||||||
feedLog.value = [...feedLog.peek(), f].length > 100
|
final event = FeedEvent.of(f,
|
||||||
? [...feedLog.peek(), f].sublist(feedLog.peek().length - 99)
|
selfName: selfMember.peek()?.name,
|
||||||
: [...feedLog.peek(), f];
|
readyCount: f.action == 'ready' ? serverState.peek()?.members.where((m) => m.ready).length ?? 0 : 0);
|
||||||
if (_toastActions.contains(f.action)) {
|
notify(event);
|
||||||
final text = f.action == 'chat'
|
|
||||||
? '${f.actor}: ${f.reason}'
|
|
||||||
: _feedText(f);
|
|
||||||
_toastId++;
|
|
||||||
toasts.value = [
|
|
||||||
...toasts.peek(),
|
|
||||||
ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc(), actor: f.actor)
|
|
||||||
];
|
|
||||||
}
|
|
||||||
// The server gave up fetching what everyone waits for.
|
// The server gave up fetching what everyone waits for.
|
||||||
if (f.action == 'fetch-fail' && _awaitLiveFor != -1 && _awaitLiveFor == serverState.peek()?.mediaId) {
|
if (event is FetchFailed && _awaitLiveFor != -1 && _awaitLiveFor == serverState.peek()?.mediaId) {
|
||||||
health.value = const HealthStatus(HealthSeverity.error, "the server couldn't fetch this video");
|
health.value = const HealthStatus(HealthSeverity.error, "the server couldn't fetch this video");
|
||||||
}
|
}
|
||||||
final selfName = selfMember.peek()?.name;
|
|
||||||
// Sat out by someone else: say who (the feed names members by name).
|
|
||||||
if (f.action == 'out' && f.target != null && f.target == selfName && f.actor != selfName) {
|
|
||||||
_pushToast('${f.actor} sat you out', actor: f.actor);
|
|
||||||
unawaited(sfx.play(const SfxCue(SfxKind.kick)));
|
|
||||||
}
|
|
||||||
final kind = sfxForFeedAction(f.action);
|
|
||||||
// Your own message needs no sound.
|
|
||||||
final own = f.action == 'chat' && f.actor == selfName;
|
|
||||||
if (kind != null && !own) {
|
|
||||||
var readyIndex = 0;
|
|
||||||
if (f.action == 'ready') {
|
|
||||||
readyIndex = serverState.peek()?.members.where((m) => m.ready).length ?? 0;
|
|
||||||
}
|
|
||||||
unawaited(sfx.play(SfxCue(kind, readyIndex: readyIndex)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _feedText(FeedMessage f) {
|
|
||||||
switch (f.action) {
|
|
||||||
case 'join':
|
|
||||||
return '${f.actor} joined';
|
|
||||||
case 'leave':
|
|
||||||
return '${f.actor} left';
|
|
||||||
case 'swap':
|
|
||||||
return '${f.actor} changed media';
|
|
||||||
default:
|
|
||||||
return '${f.actor} ${f.action}';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Someone poked this member: sound (even when its switch is off; the
|
/// Someone poked this member: sound (even when its switch is off; the
|
||||||
/// volume still applies), a toast, and the window asks for attention.
|
/// volume still applies), a toast, and the window asks for attention.
|
||||||
void _onNudge(NudgeNotice n) {
|
void _onNudge(NudgeNotice n) => notify(Nudged(n.name));
|
||||||
final name = n.name.isEmpty ? 'Someone' : n.name;
|
|
||||||
_pushToast('$name nudged you', actor: n.name.isEmpty ? null : n.name);
|
|
||||||
unawaited(sfx.play(const SfxCue(SfxKind.nudge)));
|
|
||||||
unawaited(requestAttention());
|
|
||||||
}
|
|
||||||
|
|
||||||
void _startTicker() {
|
void _startTicker() {
|
||||||
_ticker?.cancel();
|
_ticker?.cancel();
|
||||||
@@ -1227,8 +1176,7 @@ class SessionCoordinator {
|
|||||||
|
|
||||||
Future<void> _tick() async {
|
Future<void> _tick() async {
|
||||||
final now = DateTime.now().toUtc();
|
final now = DateTime.now().toUtc();
|
||||||
toasts.value =
|
notifier.expire(now);
|
||||||
toasts.peek().where((t) => now.difference(t.at).inSeconds < 4).toList();
|
|
||||||
final r = serverState.peek();
|
final r = serverState.peek();
|
||||||
if (sync.token.isNotEmpty) sync.sendPing();
|
if (sync.token.isNotEmpty) sync.sendPing();
|
||||||
diag
|
diag
|
||||||
@@ -1271,17 +1219,7 @@ class SessionCoordinator {
|
|||||||
selfReady: selfMember.peek()?.ready ?? false);
|
selfReady: selfMember.peek()?.ready ?? false);
|
||||||
switch (event) {
|
switch (event) {
|
||||||
case IdleEvent.warn:
|
case IdleEvent.warn:
|
||||||
_toastId++;
|
notify(IdleWarning(sitsOut: sitOutWhenIdle));
|
||||||
toasts.value = [
|
|
||||||
...toasts.peek(),
|
|
||||||
ToastItem(
|
|
||||||
id: _toastId,
|
|
||||||
text: sitOutWhenIdle
|
|
||||||
? 'idle — sitting out in 2 min unless you press Space'
|
|
||||||
: 'idle — auto-unready in 2 min unless you press Space',
|
|
||||||
at: now)
|
|
||||||
];
|
|
||||||
unawaited(sfx.play(const SfxCue(SfxKind.warn)));
|
|
||||||
case IdleEvent.unready:
|
case IdleEvent.unready:
|
||||||
_lastReady = false;
|
_lastReady = false;
|
||||||
if (sitOutWhenIdle) {
|
if (sitOutWhenIdle) {
|
||||||
@@ -1738,17 +1676,9 @@ class SessionCoordinator {
|
|||||||
|
|
||||||
// ------------------------------------------------------------- jellyfin
|
// ------------------------------------------------------------- jellyfin
|
||||||
|
|
||||||
/// A toast from outside the session (e.g. the Schedule tab's request
|
/// News for the user, from the session or anywhere in the app (see
|
||||||
/// results), shown like the session's own.
|
/// [Notifier]).
|
||||||
void showToast(String text) => _pushToast(text);
|
void notify(AppEvent event) => notifier.notify(event);
|
||||||
|
|
||||||
void _pushToast(String text, {String? actor}) {
|
|
||||||
_toastId++;
|
|
||||||
toasts.value = [
|
|
||||||
...toasts.peek(),
|
|
||||||
ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc(), actor: actor)
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
void _resetJellyfin() {
|
void _resetJellyfin() {
|
||||||
_jfGen++;
|
_jfGen++;
|
||||||
@@ -1928,7 +1858,7 @@ class SessionCoordinator {
|
|||||||
onError: (e) {
|
onError: (e) {
|
||||||
if (_jfToastShown) return;
|
if (_jfToastShown) return;
|
||||||
_jfToastShown = true;
|
_jfToastShown = true;
|
||||||
_pushToast('Jellyfin progress not saved: ${e is JellyfinException && e.isUnauthorized ? 'sign-in expired' : 'server unreachable'}');
|
notify(JellyfinProgressFailed(signInExpired: e is JellyfinException && e.isUnauthorized));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
final snap = await _snapshot();
|
final snap = await _snapshot();
|
||||||
@@ -2153,7 +2083,7 @@ class SessionCoordinator {
|
|||||||
_everConnected = true;
|
_everConnected = true;
|
||||||
if (_lostSounded) {
|
if (_lostSounded) {
|
||||||
_lostSounded = false;
|
_lostSounded = false;
|
||||||
unawaited(sfx.play(const SfxCue(SfxKind.back)));
|
notify(const ConnectionBack());
|
||||||
}
|
}
|
||||||
_resumeToken = sync.token;
|
_resumeToken = sync.token;
|
||||||
_lastStateAt = DateTime.now().toUtc();
|
_lastStateAt = DateTime.now().toUtc();
|
||||||
@@ -2195,7 +2125,7 @@ class SessionCoordinator {
|
|||||||
void _soundLost() {
|
void _soundLost() {
|
||||||
if (!_everConnected || _lostSounded) return;
|
if (!_everConnected || _lostSounded) return;
|
||||||
_lostSounded = true;
|
_lostSounded = true;
|
||||||
unawaited(sfx.play(const SfxCue(SfxKind.lost)));
|
notify(const ConnectionLost());
|
||||||
}
|
}
|
||||||
|
|
||||||
void _invalidateAttempts() {
|
void _invalidateAttempts() {
|
||||||
@@ -2242,6 +2172,17 @@ class SessionCoordinator {
|
|||||||
return 'connect failed: $e';
|
return 'connect failed: $e';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// App close, before [leave]: the players go first (no mpv may outlive
|
||||||
|
/// the engine). The Jellyfin stop report [leave] sends keeps the
|
||||||
|
/// position they had.
|
||||||
|
Future<void> releasePlayers() async {
|
||||||
|
if (_playerReady) _lastSnap = _snapNow();
|
||||||
|
_playerReady = false;
|
||||||
|
_ticker?.cancel();
|
||||||
|
_ticker = null;
|
||||||
|
await playback.disposeAll();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> leave({bool silent = false}) async {
|
Future<void> leave({bool silent = false}) async {
|
||||||
_invalidateAttempts();
|
_invalidateAttempts();
|
||||||
await _teardown(silent: silent);
|
await _teardown(silent: silent);
|
||||||
|
|||||||
@@ -1,88 +1,60 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
|
||||||
import 'package:path_provider/path_provider.dart';
|
|
||||||
|
|
||||||
|
import '../platform/audio/sound_engine.dart';
|
||||||
import 'sfx.dart';
|
import 'sfx.dart';
|
||||||
import 'sound_bank.dart';
|
import 'sound_bank.dart';
|
||||||
import 'synth.dart';
|
import 'synth.dart';
|
||||||
|
|
||||||
/// Plays [SoundSpec]s: each is rendered once to a WAV in the temp dir and
|
/// Plays [SoundSpec]s on a [SoundEngine]: each is rendered once (off the
|
||||||
/// played on a small pool of audio-only mpv players (so two sounds can
|
/// UI isolate) to WAV bytes and loaded into memory; sounds overlap.
|
||||||
/// overlap). [volume] (0..1) is read per play and applied on a square
|
/// [volume] (0..1) is read per play and applied as v^6: the square curve
|
||||||
/// curve (ears are log); [enabled] filters cues.
|
/// (ears are log) the app always used, times mpv's own cubic volume it
|
||||||
|
/// used to play through, so the slider sounds as it did; [enabled]
|
||||||
|
/// filters cues.
|
||||||
class SynthSfx implements SfxService {
|
class SynthSfx implements SfxService {
|
||||||
final double Function() volume;
|
final double Function() volume;
|
||||||
final bool Function(SfxKind kind) enabled;
|
final bool Function(SfxKind kind) enabled;
|
||||||
/// Which sound set each sound plays from.
|
/// Which sound set each sound plays from.
|
||||||
final SoundPreset Function(SfxKind kind) presetFor;
|
final SoundPreset Function(SfxKind kind) presetFor;
|
||||||
SynthSfx({required this.volume, bool Function(SfxKind kind)? enabled, SoundPreset Function(SfxKind kind)? presetFor})
|
final SoundEngine engine;
|
||||||
|
SynthSfx(
|
||||||
|
{required this.volume,
|
||||||
|
bool Function(SfxKind kind)? enabled,
|
||||||
|
SoundPreset Function(SfxKind kind)? presetFor,
|
||||||
|
SoundEngine? engine})
|
||||||
: enabled = enabled ?? ((_) => true),
|
: enabled = enabled ?? ((_) => true),
|
||||||
presetFor = presetFor ?? ((_) => defaultPreset);
|
presetFor = presetFor ?? ((_) => defaultPreset),
|
||||||
|
engine = engine ?? SoundEngine();
|
||||||
static const _poolSize = 3;
|
|
||||||
final List<Player> _pool = [];
|
|
||||||
var _next = 0;
|
|
||||||
final Map<String, Future<String>> _files = {};
|
|
||||||
Directory? _dir;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> play(SfxCue cue) async {
|
Future<void> play(SfxCue cue) {
|
||||||
if (!enabled(cue.kind)) return;
|
if (!enabled(cue.kind)) return Future.value();
|
||||||
final preset = presetFor(cue.kind);
|
return _play(cue.kind, cue.readyIndex, presetFor(cue.kind));
|
||||||
await playSpec('${preset.name}-${cue.kind.name}-${cue.readyIndex}',
|
|
||||||
soundFor(cue.kind, readyIndex: cue.readyIndex, preset: preset));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Plays [kind] even when it is switched off (Settings' preview), always
|
/// Plays [kind] even when it is switched off (Settings' preview), always
|
||||||
/// freshly rendered: an edited spec plays after a hot reload.
|
/// freshly rendered: an edited spec plays after a hot reload.
|
||||||
Future<void> preview(SfxKind kind, {int readyIndex = 1, SoundPreset? preset}) {
|
Future<void> preview(SfxKind kind, {int readyIndex = 1, SoundPreset? preset}) =>
|
||||||
final p = preset ?? presetFor(kind);
|
_play(kind, readyIndex, preset ?? presetFor(kind), fresh: true);
|
||||||
final key = '${p.name}-${kind.name}-$readyIndex';
|
|
||||||
_files.remove(key);
|
|
||||||
return playSpec(key, soundFor(kind, readyIndex: readyIndex, preset: p));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Plays [spec], cached under [key] (same key = same sound).
|
/// One slot per sound and ready step: a new preset replaces it.
|
||||||
Future<void> playSpec(String key, SoundSpec spec) async {
|
Future<void> _play(SfxKind kind, int readyIndex, SoundPreset preset, {bool fresh = false}) {
|
||||||
final v = volume();
|
final v = volume();
|
||||||
if (v <= 0) return;
|
return engine.play('${kind.name}-$readyIndex', '${preset.name}-${kind.name}-$readyIndex',
|
||||||
try {
|
() => renderWav(soundFor(kind, readyIndex: readyIndex, preset: preset)),
|
||||||
final path = await (_files[key] ??= _write(key, spec));
|
volume: math.pow(v, 6).toDouble(), fresh: fresh);
|
||||||
final player = await _player();
|
|
||||||
await player.setVolume(100 * v * v);
|
|
||||||
await player.open(Media(path));
|
|
||||||
} catch (e) {
|
|
||||||
_files.remove(key);
|
|
||||||
debugPrint('sfx $key: $e');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String> _write(String key, SoundSpec spec) async {
|
/// Opens the audio device ahead of the first sound.
|
||||||
_dir ??= await Directory('${(await getTemporaryDirectory()).path}/watch_together_sfx').create(recursive: true);
|
Future<void> warmUp() => engine.warmUp();
|
||||||
final f = File('${_dir!.path}/$key.wav');
|
|
||||||
await f.writeAsBytes(wav(render(spec)));
|
|
||||||
return f.path;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Player> _player() async {
|
Future<void> dispose() => engine.dispose();
|
||||||
if (_pool.length < _poolSize) {
|
|
||||||
final p = Player(
|
|
||||||
configuration: const PlayerConfiguration(vo: 'null', osc: false, libass: false, title: 'sfx'),
|
|
||||||
);
|
|
||||||
await (p.platform as NativePlayer).setProperty('audio-display', 'no');
|
|
||||||
_pool.add(p);
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
return _pool[_next++ % _poolSize];
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> dispose() async {
|
|
||||||
for (final p in _pool) {
|
|
||||||
await p.dispose();
|
|
||||||
}
|
|
||||||
_pool.clear();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [spec] as WAV bytes, rendered on a worker isolate.
|
||||||
|
Future<Uint8List> renderWav(SoundSpec spec) => compute(_renderWav, spec, debugLabel: 'sfx render');
|
||||||
|
|
||||||
|
Uint8List _renderWav(SoundSpec spec) => wav(render(spec));
|
||||||
@@ -8,50 +8,6 @@ import 'shader_presets.dart';
|
|||||||
/// A shader pick from the menu; a null quality keeps the saved one.
|
/// A shader pick from the menu; a null quality keeps the saved one.
|
||||||
typedef ShaderPick = (ShaderPreset, ShaderQuality?);
|
typedef ShaderPick = (ShaderPreset, ShaderQuality?);
|
||||||
|
|
||||||
/// Compact shader button for the video controls bar (normal and
|
|
||||||
/// fullscreen). Opens a small menu: Off, the six Anime4K modes with
|
|
||||||
/// HQ / Fast, Custom. The menu is a route, so it outlives the controls
|
|
||||||
/// bar hiding under it; [onPick] runs on the caller's side.
|
|
||||||
class ShaderControlButton extends StatelessWidget {
|
|
||||||
final FocusNode focusNode;
|
|
||||||
final ReadonlySignal<RenderMode> renderMode;
|
|
||||||
final void Function(ShaderPreset preset, ShaderQuality? quality) onPick;
|
|
||||||
final ValueChanged<bool>? onMenuOpen;
|
|
||||||
const ShaderControlButton({
|
|
||||||
super.key,
|
|
||||||
required this.focusNode,
|
|
||||||
required this.renderMode,
|
|
||||||
required this.onPick,
|
|
||||||
this.onMenuOpen,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final s = AppSettings.instance;
|
|
||||||
return SignalBuilder(builder: (context) {
|
|
||||||
final preset = s.shaderPreset.value;
|
|
||||||
final quality = s.shaderQuality.value;
|
|
||||||
final short = shaderShortLabel(preset, quality) ?? 'Shaders';
|
|
||||||
return Tooltip(
|
|
||||||
message: 'Video shaders (Ctrl+1..6, Ctrl+0 off)',
|
|
||||||
child: TextButton.icon(
|
|
||||||
focusNode: focusNode,
|
|
||||||
style: TextButton.styleFrom(
|
|
||||||
foregroundColor:
|
|
||||||
preset == ShaderPreset.off ? Colors.white : Colors.amberAccent,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
||||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
||||||
),
|
|
||||||
onPressed: () => showShaderMenu(context,
|
|
||||||
renderMode: renderMode, onPick: onPick, onMenuOpen: onMenuOpen),
|
|
||||||
icon: const Icon(Icons.auto_awesome, size: 16),
|
|
||||||
label: Text(short, style: const TextStyle(fontSize: 12)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// "A HQ", "C+A Fast", "Custom"; null when shaders are off.
|
/// "A HQ", "C+A Fast", "Custom"; null when shaders are off.
|
||||||
String? shaderShortLabel(ShaderPreset preset, ShaderQuality quality) =>
|
String? shaderShortLabel(ShaderPreset preset, ShaderQuality quality) =>
|
||||||
switch (preset) {
|
switch (preset) {
|
||||||
@@ -61,7 +17,8 @@ String? shaderShortLabel(ShaderPreset preset, ShaderQuality quality) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
/// The shader menu, anchored on [context]'s box (the button that opens
|
/// The shader menu, anchored on [context]'s box (the button that opens
|
||||||
/// it): Off, the six Anime4K modes with HQ / Fast, Custom.
|
/// it; the controls' ⚙): Off, the six Anime4K modes with HQ / Fast,
|
||||||
|
/// Custom. A route, so it outlives the controls hiding under it.
|
||||||
Future<void> showShaderMenu(
|
Future<void> showShaderMenu(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required ReadonlySignal<RenderMode> renderMode,
|
required ReadonlySignal<RenderMode> renderMode,
|
||||||
|
|||||||
@@ -85,3 +85,11 @@ String shaderListSeparator({bool? windows}) =>
|
|||||||
/// The `glsl-shaders` value for [paths].
|
/// The `glsl-shaders` value for [paths].
|
||||||
String joinShaderPaths(List<String> paths, {bool? windows}) =>
|
String joinShaderPaths(List<String> paths, {bool? windows}) =>
|
||||||
paths.join(shaderListSeparator(windows: windows));
|
paths.join(shaderListSeparator(windows: windows));
|
||||||
|
|
||||||
|
/// Whether mpv's read-back [got] of `glsl-shaders` is the list [paths].
|
||||||
|
/// Recent mpv prints a path list joined with [shaderListSeparator]; older
|
||||||
|
/// mpv (0.37 and before, the bundled Windows libmpv among them) joins it
|
||||||
|
/// with ','. Matched against each joined form rather than split: a path
|
||||||
|
/// may itself hold ':' (a Windows drive letter) or ','.
|
||||||
|
bool shaderListMatches(List<String> paths, String? got) =>
|
||||||
|
got != null && const [',', ';', ':'].any((sep) => paths.join(sep) == got);
|
||||||
@@ -3,16 +3,16 @@ import 'dart:io' show File;
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:fox_utils/fox_utils.dart' show ClipboardX, DesktopDropX, DropTarget;
|
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
|
||||||
|
|
||||||
|
import '../controls/frame.dart' show DoubleClickListener;
|
||||||
import '../files/folder_store.dart';
|
import '../files/folder_store.dart';
|
||||||
import '../files/ui/files_page.dart' show showFilesFolder;
|
import '../files/ui/files_page.dart' show showFilesFolder;
|
||||||
import '../jellyfin/accounts.dart';
|
import '../jellyfin/accounts.dart';
|
||||||
import '../jellyfin/browse_scope.dart' show BrowseStart;
|
import '../jellyfin/browse_scope.dart' show BrowseStart;
|
||||||
import '../jellyfin/session_media.dart';
|
import '../jellyfin/session_media.dart';
|
||||||
import '../screens/home/connect_flow.dart';
|
import '../screens/home/connect_flow.dart';
|
||||||
|
import '../session/app_events.dart' show ActionFailed, ScheduleResult;
|
||||||
import '../session/session_coordinator.dart' show ConnectParams;
|
import '../session/session_coordinator.dart' show ConnectParams;
|
||||||
import '../screens/home/health_probe.dart';
|
import '../screens/home/health_probe.dart';
|
||||||
import '../screens/home/media_picker.dart';
|
import '../screens/home/media_picker.dart';
|
||||||
@@ -24,6 +24,7 @@ import '../update/update_controller.dart';
|
|||||||
import 'chat_panel.dart';
|
import 'chat_panel.dart';
|
||||||
import 'connect_cards.dart';
|
import 'connect_cards.dart';
|
||||||
import 'handoff.dart';
|
import 'handoff.dart';
|
||||||
|
import 'library_link.dart' show LinkProber, defaultLinkProber;
|
||||||
import 'library_tab.dart';
|
import 'library_tab.dart';
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
import 'party_strip.dart';
|
import 'party_strip.dart';
|
||||||
@@ -57,6 +58,9 @@ class AppShell extends StatefulWidget {
|
|||||||
final JellyfinAccounts? jellyfin;
|
final JellyfinAccounts? jellyfin;
|
||||||
/// Stand-in for media_kit's `Video` (tests).
|
/// Stand-in for media_kit's `Video` (tests).
|
||||||
final VideoViewBuilder? videoBuilder;
|
final VideoViewBuilder? videoBuilder;
|
||||||
|
/// What the Library's Link page asks about a link (tests: a fake, so no
|
||||||
|
/// network or yt-dlp runs).
|
||||||
|
final LinkProber linkProbe;
|
||||||
const AppShell({
|
const AppShell({
|
||||||
super.key,
|
super.key,
|
||||||
this.updates,
|
this.updates,
|
||||||
@@ -65,6 +69,7 @@ class AppShell extends StatefulWidget {
|
|||||||
this.healthGet = defaultHealthGet,
|
this.healthGet = defaultHealthGet,
|
||||||
this.jellyfin,
|
this.jellyfin,
|
||||||
this.videoBuilder,
|
this.videoBuilder,
|
||||||
|
this.linkProbe = defaultLinkProber,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -127,6 +132,14 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
SessionState.instance.route.value == AppRoute.session &&
|
SessionState.instance.route.value == AppRoute.session &&
|
||||||
SessionState.instance.coordinator.serverState.value?.media != null);
|
SessionState.instance.coordinator.serverState.value?.media != null);
|
||||||
|
|
||||||
|
/// A video is on in the session (not finished): the update bar stays
|
||||||
|
/// off the full player.
|
||||||
|
final _watching = computed(() {
|
||||||
|
final s = SessionState.instance;
|
||||||
|
final r = s.coordinator.serverState.value;
|
||||||
|
return s.route.value == AppRoute.session && r?.media != null && r?.transport != 'finished';
|
||||||
|
});
|
||||||
|
|
||||||
PlayerMode get mode => _mode;
|
PlayerMode get mode => _mode;
|
||||||
PlayerMode get shownMode => _shown;
|
PlayerMode get shownMode => _shown;
|
||||||
bool get fullscreen => _fullscreen;
|
bool get fullscreen => _fullscreen;
|
||||||
@@ -225,10 +238,12 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
stop();
|
stop();
|
||||||
}
|
}
|
||||||
HardwareKeyboard.instance.removeHandler(_onKey);
|
HardwareKeyboard.instance.removeHandler(_onKey);
|
||||||
|
_updateTimer?.cancel();
|
||||||
_fade.dispose();
|
_fade.dispose();
|
||||||
_online.dispose();
|
_online.dispose();
|
||||||
_alone.dispose();
|
_alone.dispose();
|
||||||
_hasMedia.dispose();
|
_hasMedia.dispose();
|
||||||
|
_watching.dispose();
|
||||||
_flow.dispose();
|
_flow.dispose();
|
||||||
_join.dispose();
|
_join.dispose();
|
||||||
_host.dispose();
|
_host.dispose();
|
||||||
@@ -244,9 +259,17 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
if (mounted) f();
|
if (mounted) f();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Timer? _updateTimer;
|
||||||
|
|
||||||
|
/// At start, then every [updateCheckEvery] while the app runs (when the
|
||||||
|
/// auto-check setting is on).
|
||||||
Future<void> _autoCheck() async {
|
Future<void> _autoCheck() async {
|
||||||
final s = AppSettings.instance;
|
final s = AppSettings.instance;
|
||||||
await s.ensureLoaded();
|
await s.ensureLoaded();
|
||||||
|
if (!mounted) return;
|
||||||
|
_updateTimer = Timer.periodic(updateCheckEvery, (_) {
|
||||||
|
if (s.autoCheckUpdates.peek()) unawaited(runUpdateCheck(widget.updates, true));
|
||||||
|
});
|
||||||
if (s.autoCheckUpdates.peek()) await runUpdateCheck(widget.updates);
|
if (s.autoCheckUpdates.peek()) await runUpdateCheck(widget.updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,7 +333,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
Future<void> _setWindowFullscreen(bool on) async {
|
Future<void> _setWindowFullscreen(bool on) async {
|
||||||
if (!widget.windowControls) return;
|
if (!widget.windowControls) return;
|
||||||
try {
|
try {
|
||||||
await windowManager.setFullScreen(on);
|
await SessionState.instance.ports.shell.setFullScreen(on);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// No window manager.
|
// No window manager.
|
||||||
}
|
}
|
||||||
@@ -438,17 +461,8 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
/// Clipboard files first, then text (a link or absolute paths). A pasted
|
/// Clipboard files first, then text (a link or absolute paths). A pasted
|
||||||
/// image has no taker yet, so the clipboard's image data is not read.
|
/// image has no taker yet, so the clipboard's image data is not read.
|
||||||
Future<void> _paste() async {
|
Future<void> _paste() async {
|
||||||
var files = const <String>[];
|
final clip = await SessionState.instance.ports.shell.readPaste();
|
||||||
String? text;
|
await _handOff(classifyHandoff(paths: clip.files, text: clip.text), _pointer);
|
||||||
try {
|
|
||||||
files = await ClipboardX.readFiles();
|
|
||||||
} catch (_) {}
|
|
||||||
if (files.isEmpty) {
|
|
||||||
try {
|
|
||||||
text = await ClipboardX.readText();
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
await _handOff(classifyHandoff(paths: files, text: text), _pointer);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handOff(Handoff item, Offset? at) async {
|
Future<void> _handOff(Handoff item, Offset? at) async {
|
||||||
@@ -470,7 +484,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
case (HandoffVideos(:final paths), HandoffAction.putUp):
|
case (HandoffVideos(:final paths), HandoffAction.putUp):
|
||||||
final error = await _putUp(FileMedia(paths.first));
|
final error = await _putUp(FileMedia(paths.first));
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
SessionState.instance.coordinator.showToast(error);
|
SessionState.instance.coordinator.notify(ActionFailed(error));
|
||||||
} else {
|
} else {
|
||||||
await FolderStore.instance.ensureLoaded();
|
await FolderStore.instance.ensureLoaded();
|
||||||
FolderStore.instance.addRecent(paths.first);
|
FolderStore.instance.addRecent(paths.first);
|
||||||
@@ -481,7 +495,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
for (final p in next ? paths.reversed : paths) {
|
for (final p in next ? paths.reversed : paths) {
|
||||||
final error = await SessionState.instance.coordinator.queue.add(FileMedia(p), next: next);
|
final error = await SessionState.instance.coordinator.queue.add(FileMedia(p), next: next);
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
SessionState.instance.coordinator.showToast(error);
|
SessionState.instance.coordinator.notify(ActionFailed(error));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -490,7 +504,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
final coord = SessionState.instance.coordinator;
|
final coord = SessionState.instance.coordinator;
|
||||||
final error = await coord.queue
|
final error = await coord.queue
|
||||||
.add(LinkMedia(url, relay: _picker.linkRelay), next: action == HandoffAction.playNext);
|
.add(LinkMedia(url, relay: _picker.linkRelay), next: action == HandoffAction.playNext);
|
||||||
if (error != null) coord.showToast(error);
|
if (error != null) coord.notify(ActionFailed(error));
|
||||||
case (HandoffVideos(:final paths), HandoffAction.addFolder):
|
case (HandoffVideos(:final paths), HandoffAction.addFolder):
|
||||||
await _addFolder(File(paths.first).parent.path);
|
await _addFolder(File(paths.first).parent.path);
|
||||||
case (HandoffFolder(:final path), _):
|
case (HandoffFolder(:final path), _):
|
||||||
@@ -521,13 +535,13 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Off on Settings: its own drop targets (cookies) take the files there.
|
// Off on Settings: its own drop targets (cookies) take the files there.
|
||||||
Widget _dropTarget(Widget child) => DropTarget(
|
Widget _dropTarget(Widget child) => SessionState.instance.ports.shell.dropTarget(
|
||||||
enable: _tab != ShellTab.settings,
|
enable: _tab != ShellTab.settings,
|
||||||
onDragEntered: (_) => setState(() => _dragging = _onTop),
|
onEnter: () => setState(() => _dragging = _onTop),
|
||||||
onDragExited: (_) => setState(() => _dragging = false),
|
onExit: () => setState(() => _dragging = false),
|
||||||
onDragDone: (d) {
|
onDrop: (paths, at) {
|
||||||
setState(() => _dragging = false);
|
setState(() => _dragging = false);
|
||||||
_handOff(classifyHandoff(paths: DesktopDropX.pathsOf(d.files)), d.localPosition);
|
_handOff(classifyHandoff(paths: paths), at);
|
||||||
},
|
},
|
||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
@@ -615,6 +629,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
final online = _online.value;
|
final online = _online.value;
|
||||||
final alone = _alone.value;
|
final alone = _alone.value;
|
||||||
final hasMedia = _hasMedia.value;
|
final hasMedia = _hasMedia.value;
|
||||||
|
final onPlayer = _shown == PlayerMode.full && _watching.value;
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
if (!_fullscreen)
|
if (!_fullscreen)
|
||||||
@@ -631,9 +646,14 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
onInvite: () => _connect(ConnectMode.host),
|
onInvite: () => _connect(ConnectMode.host),
|
||||||
onProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates),
|
onProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates),
|
||||||
onShowPlayer: hasMedia && _mode == PlayerMode.none ? () => _show(PlayerMode.mini) : null,
|
onShowPlayer: hasMedia && _mode == PlayerMode.none ? () => _show(PlayerMode.mini) : null,
|
||||||
updates: widget.updates,
|
|
||||||
windowControls: widget.windowControls,
|
windowControls: widget.windowControls,
|
||||||
),
|
),
|
||||||
|
AnimatedSize(
|
||||||
|
duration: easeTime,
|
||||||
|
curve: ease,
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: _fullscreen || onPlayer ? const SizedBox(width: double.infinity) : UpdateBar(controller: widget.updates),
|
||||||
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
key: const ValueKey('body'),
|
key: const ValueKey('body'),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -693,12 +713,13 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
// Up next needs a server (a solo session has one).
|
// Up next needs a server (a solo session has one).
|
||||||
onAddToQueue: online ? coord.queue.add : null,
|
onAddToQueue: online ? coord.queue.add : null,
|
||||||
onOpenIntegrations: () => _openSettings(SettingsSection.integrations),
|
onOpenIntegrations: () => _openSettings(SettingsSection.integrations),
|
||||||
|
probe: widget.linkProbe,
|
||||||
),
|
),
|
||||||
ShellTab.schedule => SchedulePage(
|
ShellTab.schedule => SchedulePage(
|
||||||
solo: alone,
|
solo: alone,
|
||||||
onPutUp: _putUp,
|
onPutUp: _putUp,
|
||||||
onAddToQueue: online ? coord.queue.add : null,
|
onAddToQueue: online ? coord.queue.add : null,
|
||||||
onToast: coord.showToast,
|
onToast: (text) => coord.notify(ScheduleResult(text)),
|
||||||
),
|
),
|
||||||
ShellTab.settings => SettingsTab(
|
ShellTab.settings => SettingsTab(
|
||||||
section: _section,
|
section: _section,
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:sync_relay_core/core.dart'
|
import 'package:sync_relay_core/core.dart'
|
||||||
show LinkProbe, hasMediaExtension, parseJellyfinLink, playErrorText, unreachableText;
|
show LinkProbe, hasMediaExtension, parseJellyfinLink, playErrorText, unreachableText;
|
||||||
|
|
||||||
import '../playback/yt_dlp_app.dart';
|
|
||||||
import '../screens/home/media_picker.dart';
|
import '../screens/home/media_picker.dart';
|
||||||
import '../widgets/app_menu.dart';
|
import '../widgets/app_menu.dart';
|
||||||
import '../widgets/copy_open.dart';
|
import '../widgets/copy_open.dart';
|
||||||
import '../state/connect_prefs.dart';
|
import '../state/connect_prefs.dart';
|
||||||
|
import '../state/session_state.dart';
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
|
|
||||||
// The Library's Link source: a big paste field, a preview of what the link
|
// The Library's Link source: a big paste field, a preview of what the link
|
||||||
@@ -236,10 +236,10 @@ class LinkPreviewStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a link is (see [AppYtDlp.probe]).
|
/// What a link is (see [LinkResolver.probe]).
|
||||||
typedef LinkProber = Future<LinkProbe> Function(String url);
|
typedef LinkProber = Future<LinkProbe> Function(String url);
|
||||||
|
|
||||||
Future<LinkProbe> defaultLinkProber(String url) => AppYtDlp.instance.probe(url);
|
Future<LinkProbe> defaultLinkProber(String url) => SessionState.instance.ports.links.probe(url);
|
||||||
|
|
||||||
String _host(Uri uri) => uri.host.startsWith('www.') ? uri.host.substring(4) : uri.host;
|
String _host(Uri uri) => uri.host.startsWith('www.') ? uri.host.substring(4) : uri.host;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:sync_relay_core/core.dart' show RelayConstants;
|
import 'package:sync_relay_core/core.dart' show RelayConstants;
|
||||||
|
|
||||||
|
import '../session/app_events.dart' show ProblemMarked;
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
import '../widgets/app_menu.dart' show AppGlass;
|
import '../widgets/app_menu.dart' show AppGlass;
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
@@ -19,7 +20,7 @@ Future<void> showMarkProblem(BuildContext context) async {
|
|||||||
if (note == null) return;
|
if (note == null) return;
|
||||||
final coord = SessionState.instance.coordinator;
|
final coord = SessionState.instance.coordinator;
|
||||||
final inSession = await coord.markProblem(note);
|
final inSession = await coord.markProblem(note);
|
||||||
coord.showToast(inSession ? 'Marked in the session log' : "Marked in this app's log");
|
coord.notify(ProblemMarked(inSession: inSession));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The small card: a one-line note and Mark. Pops with the note (maybe
|
/// The small card: a one-line note and Mark. Pops with the note (maybe
|
||||||
|
|||||||
@@ -3,13 +3,10 @@ import 'dart:io';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
|
||||||
|
|
||||||
import '../screens/home/ui.dart' show appName, displayVersion;
|
import '../screens/home/ui.dart' show appName, displayVersion, shortVersion;
|
||||||
import '../screens/home/update_indicator.dart';
|
|
||||||
import '../session/delivery.dart';
|
import '../session/delivery.dart';
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
import '../update/update_controller.dart';
|
|
||||||
import '../widgets/app_keys.dart';
|
import '../widgets/app_keys.dart';
|
||||||
import '../widgets/app_menu.dart';
|
import '../widgets/app_menu.dart';
|
||||||
import '../widgets/copy_open.dart';
|
import '../widgets/copy_open.dart';
|
||||||
@@ -56,7 +53,6 @@ class PartyStrip extends StatelessWidget {
|
|||||||
|
|
||||||
/// Online with something up but the player closed: brings it back.
|
/// Online with something up but the player closed: brings it back.
|
||||||
final VoidCallback? onShowPlayer;
|
final VoidCallback? onShowPlayer;
|
||||||
final UpdateController? updates;
|
|
||||||
|
|
||||||
/// Off in tests: the drag area and window buttons need window_manager.
|
/// Off in tests: the drag area and window buttons need window_manager.
|
||||||
final bool windowControls;
|
final bool windowControls;
|
||||||
@@ -73,7 +69,6 @@ class PartyStrip extends StatelessWidget {
|
|||||||
required this.onProfile,
|
required this.onProfile,
|
||||||
this.onInvite,
|
this.onInvite,
|
||||||
this.onShowPlayer,
|
this.onShowPlayer,
|
||||||
this.updates,
|
|
||||||
this.windowControls = true,
|
this.windowControls = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -125,8 +120,10 @@ class PartyStrip extends StatelessWidget {
|
|||||||
label: t.label,
|
label: t.label,
|
||||||
selected: t == tab,
|
selected: t == tab,
|
||||||
compact: compact,
|
compact: compact,
|
||||||
// The version, a rest of the mouse away.
|
// The version: always in sight under Settings,
|
||||||
|
// the full one a rest of the mouse away.
|
||||||
tooltip: t == ShellTab.settings ? '$appName ${displayVersion()}' : null,
|
tooltip: t == ShellTab.settings ? '$appName ${displayVersion()}' : null,
|
||||||
|
caption: t == ShellTab.settings ? shortVersion() : null,
|
||||||
onTap: () => onTab(t),
|
onTap: () => onTab(t),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -135,7 +132,6 @@ class PartyStrip extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
UpdateIndicator(controller: updates),
|
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
if (online && onShowPlayer != null)
|
if (online && onShowPlayer != null)
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -266,7 +262,7 @@ class PartyStrip extends StatelessWidget {
|
|||||||
// their own clicks.
|
// their own clicks.
|
||||||
if (windowControls)
|
if (windowControls)
|
||||||
const Positioned.fill(
|
const Positioned.fill(
|
||||||
child: DragToMoveArea(child: SizedBox.expand()),
|
child: WindowDragArea(child: SizedBox.expand()),
|
||||||
),
|
),
|
||||||
Positioned.fill(child: row),
|
Positioned.fill(child: row),
|
||||||
],
|
],
|
||||||
@@ -369,6 +365,8 @@ class StripTab extends StatelessWidget {
|
|||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
/// Shown after resting on the tab.
|
/// Shown after resting on the tab.
|
||||||
final String? tooltip;
|
final String? tooltip;
|
||||||
|
/// Small text under the tab (the version under Settings).
|
||||||
|
final String? caption;
|
||||||
const StripTab({
|
const StripTab({
|
||||||
super.key,
|
super.key,
|
||||||
required this.label,
|
required this.label,
|
||||||
@@ -376,6 +374,7 @@ class StripTab extends StatelessWidget {
|
|||||||
required this.onTap,
|
required this.onTap,
|
||||||
this.compact = false,
|
this.compact = false,
|
||||||
this.tooltip,
|
this.tooltip,
|
||||||
|
this.caption,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -391,28 +390,55 @@ class StripTab extends StatelessWidget {
|
|||||||
padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 12),
|
padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 12),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: partyStripHeight,
|
height: partyStripHeight,
|
||||||
child: Column(
|
child: Stack(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
clipBehavior: Clip.none,
|
||||||
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 3),
|
Column(
|
||||||
Text(
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
label,
|
children: [
|
||||||
style: TextStyle(
|
const SizedBox(height: 3),
|
||||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
Text(
|
||||||
color: selected ? scheme.primary : scheme.onSurfaceVariant,
|
label,
|
||||||
),
|
style: TextStyle(
|
||||||
|
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||||
|
color: selected ? scheme.primary : scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
AnimatedContainer(
|
||||||
|
duration: easeTime,
|
||||||
|
curve: ease,
|
||||||
|
height: 3,
|
||||||
|
width: selected ? 22 : 0,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: scheme.primary,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
if (caption case final c?)
|
||||||
AnimatedContainer(
|
Positioned(
|
||||||
duration: easeTime,
|
left: 0,
|
||||||
curve: ease,
|
right: 0,
|
||||||
height: 3,
|
bottom: 2,
|
||||||
width: selected ? 22 : 0,
|
height: 13,
|
||||||
decoration: BoxDecoration(
|
child: OverflowBox(
|
||||||
color: scheme.primary,
|
maxWidth: 160,
|
||||||
borderRadius: BorderRadius.circular(2),
|
child: Text(
|
||||||
|
c,
|
||||||
|
key: const ValueKey('strip-version'),
|
||||||
|
maxLines: 1,
|
||||||
|
softWrap: false,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
height: 1.2,
|
||||||
|
color: scheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import 'dart:math' as math;
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'dart:ui' as ui;
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
import 'package:file_picker/file_picker.dart';
|
|
||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
@@ -329,18 +328,17 @@ class _CropPickerState extends State<CropPicker> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pick() async {
|
Future<void> _pick() async {
|
||||||
final List<PlatformFile> files;
|
final List<String> files;
|
||||||
try {
|
try {
|
||||||
files = await FilePicker.pickFiles(
|
files = await SessionState.instance.ports.folders.pickFiles(
|
||||||
dialogTitle: 'Pick a picture',
|
dialogTitle: 'Pick a picture',
|
||||||
type: FileType.custom,
|
extensions: const ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'],
|
||||||
allowedExtensions: const ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'],
|
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) setState(() => _error = 'file dialog unavailable — try again');
|
if (mounted) setState(() => _error = 'file dialog unavailable — try again');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final path = files.isEmpty ? null : files.first.path;
|
final path = files.firstOrNull;
|
||||||
if (path == null) return;
|
if (path == null) return;
|
||||||
try {
|
try {
|
||||||
final codec = await ui.instantiateImageCodec(await File(path).readAsBytes());
|
final codec = await ui.instantiateImageCodec(await File(path).readAsBytes());
|
||||||
|
|||||||
@@ -2,26 +2,21 @@ import 'dart:async';
|
|||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart' show ValueListenable;
|
import 'package:flutter/foundation.dart' show ValueListenable;
|
||||||
import 'package:flutter/gestures.dart' show kPrimaryMouseButton;
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:media_kit_video/media_kit_video.dart';
|
import 'package:media_kit_video/media_kit_video.dart';
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
|
||||||
|
|
||||||
import '../controls/controls_probe.dart';
|
import '../controls/controls_probe.dart';
|
||||||
import '../controls/player_settings.dart';
|
import '../controls/model/controls_model.dart';
|
||||||
import '../controls/uosc/buttons.dart';
|
import '../controls/session_controls.dart';
|
||||||
import '../controls/uosc/controls.dart';
|
|
||||||
import '../controls/uosc/timeline.dart';
|
|
||||||
import '../controls/uosc/volume_flash.dart';
|
|
||||||
import '../controls/volume.dart';
|
import '../controls/volume.dart';
|
||||||
import '../queue/queue_ui.dart';
|
import '../queue/queue_ui.dart';
|
||||||
import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock;
|
import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock;
|
||||||
import '../screens/session/jellyfin_widgets.dart';
|
import '../screens/session/jellyfin_widgets.dart';
|
||||||
import '../screens/session/player_controls.dart';
|
import '../screens/session/player_controls.dart';
|
||||||
|
import '../session/app_events.dart' show ShaderMessage;
|
||||||
import '../session/session_coordinator.dart';
|
import '../session/session_coordinator.dart';
|
||||||
import '../shaders/shader_menu.dart';
|
|
||||||
import '../shaders/shader_presets.dart';
|
import '../shaders/shader_presets.dart';
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
import '../state/settings.dart';
|
import '../state/settings.dart';
|
||||||
@@ -29,7 +24,6 @@ import '../widgets/app_keys.dart';
|
|||||||
import '../widgets/text_focus.dart';
|
import '../widgets/text_focus.dart';
|
||||||
import '../widgets/window_buttons.dart';
|
import '../widgets/window_buttons.dart';
|
||||||
import 'avatar.dart';
|
import 'avatar.dart';
|
||||||
import 'mark_problem.dart';
|
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
import 'now_playing_title.dart';
|
import 'now_playing_title.dart';
|
||||||
import 'player_status.dart';
|
import 'player_status.dart';
|
||||||
@@ -46,8 +40,8 @@ enum PlayerMode { none, mini, full }
|
|||||||
typedef VideoViewBuilder = Widget Function(BuildContext context, VideoControlsBuilder? controls);
|
typedef VideoViewBuilder = Widget Function(BuildContext context, VideoControlsBuilder? controls);
|
||||||
|
|
||||||
/// The session's player: the one `Video` of the current player and
|
/// The session's player: the one `Video` of the current player and
|
||||||
/// everything over it. Full: our bars on media_kit's controls or the uosc
|
/// everything over it. Full: the controls (one model, the skin picked in
|
||||||
/// set (Settings), the start card, the status pill, Jellyfin overlays and
|
/// Settings), the start card, the status pill, Jellyfin overlays and
|
||||||
/// the player keys. Mini: hover controls and a progress line. The shell keeps this
|
/// the player keys. Mini: hover controls and a progress line. The shell keeps this
|
||||||
/// widget mounted at one place in the tree while the mode changes, so the
|
/// widget mounted at one place in the tree while the mode changes, so the
|
||||||
/// `Video` is never mounted twice nor rebuilt for a move.
|
/// `Video` is never mounted twice nor rebuilt for a move.
|
||||||
@@ -99,21 +93,29 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
final _noFocus = FocusNode(skipTraversal: true, canRequestFocus: false);
|
final _noFocus = FocusNode(skipTraversal: true, canRequestFocus: false);
|
||||||
DateTime? _lastKeyAt;
|
DateTime? _lastKeyAt;
|
||||||
late final void Function() _stopShaderWatch;
|
late final void Function() _stopShaderWatch;
|
||||||
bool _shaderMenuOpen = false;
|
|
||||||
// Own toast ids, negative: the coordinator counts up from 1.
|
|
||||||
int _toastSeq = 0;
|
|
||||||
int _shaderNoticeSeen = 0;
|
int _shaderNoticeSeen = 0;
|
||||||
bool _miniHover = false;
|
bool _miniHover = false;
|
||||||
// Whether the controls are up: the standard ones tracked like media_kit
|
// Whether the controls are up (their frame reports it).
|
||||||
// does (shown on mount and on mouse movement, hidden 3 s later or on
|
|
||||||
// exit), the uosc ones reporting it.
|
|
||||||
final _controlsShown = ValueNotifier(true);
|
final _controlsShown = ValueNotifier(true);
|
||||||
// The mouse on a floating element, or the Up next popover open, keeps
|
// The mouse on a floating element, or the Up next popover open, keeps
|
||||||
// the controls up.
|
// the controls up.
|
||||||
final _hold = ControlsHold();
|
final _hold = ControlsHold();
|
||||||
// Ticks on each wheel / key volume step: flashes the volume readout.
|
|
||||||
final _volumeFlash = ValueNotifier(0);
|
|
||||||
late final void Function() _stopPopoverWatch;
|
late final void Function() _stopPopoverWatch;
|
||||||
|
// The shell's state the controls show, as signals for the model.
|
||||||
|
late final _fullscreen = signal(widget.fullscreen);
|
||||||
|
late final _chatOpen = signal(widget.chatOpen);
|
||||||
|
late final _unread = signal(widget.unread);
|
||||||
|
late final PlayerControlsModel _model = PlayerControlsModel(
|
||||||
|
coord: _coord,
|
||||||
|
fullscreen: _fullscreen,
|
||||||
|
chatOpen: _chatOpen,
|
||||||
|
unread: _unread,
|
||||||
|
onToggleFullscreen: () => widget.onToggleFullscreen(),
|
||||||
|
onToggleChat: () => widget.onToggleChat(),
|
||||||
|
onChangeVideo: () => widget.onChangeVideo(),
|
||||||
|
onCollapse: () => widget.onCollapse(),
|
||||||
|
onLeave: () => widget.onLeave(),
|
||||||
|
);
|
||||||
|
|
||||||
SessionCoordinator get _coord => SessionState.instance.coordinator;
|
SessionCoordinator get _coord => SessionState.instance.coordinator;
|
||||||
bool get _full => widget.mode == PlayerMode.full;
|
bool get _full => widget.mode == PlayerMode.full;
|
||||||
@@ -131,13 +133,21 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
final n = _coord.playback.shaderNotice.value;
|
final n = _coord.playback.shaderNotice.value;
|
||||||
if (n == null || n.id == _shaderNoticeSeen) return;
|
if (n == null || n.id == _shaderNoticeSeen) return;
|
||||||
_shaderNoticeSeen = n.id;
|
_shaderNoticeSeen = n.id;
|
||||||
_toast(n.text);
|
_coord.notify(ShaderMessage(n.text));
|
||||||
});
|
});
|
||||||
_stopPopoverWatch = effect(() {
|
_stopPopoverWatch = effect(() {
|
||||||
_hold.set(#upNextPopover, _coord.queue.popoverOpen.value);
|
_hold.set(#upNextPopover, _coord.queue.popoverOpen.value);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(SessionPlayer oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
_fullscreen.value = widget.fullscreen;
|
||||||
|
_chatOpen.value = widget.chatOpen;
|
||||||
|
_unread.value = widget.unread;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
HardwareKeyboard.instance.removeHandler(_globalKey);
|
HardwareKeyboard.instance.removeHandler(_globalKey);
|
||||||
@@ -147,38 +157,11 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
_noFocus.dispose();
|
_noFocus.dispose();
|
||||||
_controlsShown.dispose();
|
_controlsShown.dispose();
|
||||||
_hold.dispose();
|
_hold.dispose();
|
||||||
_volumeFlash.dispose();
|
_model.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _reloadVideo() {
|
void _toggleChat() => _model.toggleChat();
|
||||||
_coord.markInput();
|
|
||||||
_coord.reloadVideo();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _toast(String text) {
|
|
||||||
_coord.toasts.value = [
|
|
||||||
..._coord.toasts.peek(),
|
|
||||||
ToastItem(id: --_toastSeq, text: text, at: DateTime.now().toUtc()),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Menu, Ctrl+0..6: saved as the choice (also used on the next join)
|
|
||||||
/// and applied live. A null quality keeps the saved one.
|
|
||||||
void _pickShader(ShaderPreset preset, ShaderQuality? quality) => applyShaderChoice(preset, quality);
|
|
||||||
|
|
||||||
/// One volume step up (+1) or down (-1), from the wheel or ↑ / ↓;
|
|
||||||
/// flashes the design's volume readout.
|
|
||||||
void _stepVolume(int step) {
|
|
||||||
final player = _coord.playback.player;
|
|
||||||
player.setVolume(stepVolume(player.state.volume, step * volumeStep));
|
|
||||||
_volumeFlash.value++;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _toggleChat() {
|
|
||||||
_coord.markInput();
|
|
||||||
widget.onToggleChat();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The player keys, while the player fills the body and nothing (a
|
/// The player keys, while the player fills the body and nothing (a
|
||||||
/// dialog, a card, a text field) is in front of it. Space and Tab are
|
/// dialog, a card, a text field) is in front of it. Space and Tab are
|
||||||
@@ -196,7 +179,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
final route = ModalRoute.of(context);
|
final route = ModalRoute.of(context);
|
||||||
if (route != null && !route.isCurrent) {
|
if (route != null && !route.isCurrent) {
|
||||||
// A menu of ours is open: Esc closes it.
|
// A menu of ours is open: Esc closes it.
|
||||||
if (_shaderMenuOpen && event.logicalKey == LogicalKeyboardKey.escape) {
|
if (_model.menusOpen > 0 && event.logicalKey == LogicalKeyboardKey.escape) {
|
||||||
Navigator.of(context, rootNavigator: true).maybePop();
|
Navigator.of(context, rootNavigator: true).maybePop();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -207,62 +190,55 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
if (_lastKeyAt != null && now.difference(_lastKeyAt!).inMilliseconds < 350) {
|
if (_lastKeyAt != null && now.difference(_lastKeyAt!).inMilliseconds < 350) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
final coord = _coord;
|
final model = _model;
|
||||||
final key = event.logicalKey;
|
final key = event.logicalKey;
|
||||||
// Ctrl+0..6: Anime4K's own mpv keys (off, modes A..C+A).
|
// Ctrl+0..6: Anime4K's own mpv keys (off, modes A..C+A).
|
||||||
final digit = _shaderKeys.indexOf(key);
|
final digit = _shaderKeys.indexOf(key);
|
||||||
if (digit >= 0 && HardwareKeyboard.instance.isControlPressed) {
|
if (digit >= 0 && HardwareKeyboard.instance.isControlPressed) {
|
||||||
_lastKeyAt = now;
|
_lastKeyAt = now;
|
||||||
_pickShader(digit == 0 ? ShaderPreset.off : ShaderPreset.modes[digit - 1], null);
|
model.pickShader(digit == 0 ? ShaderPreset.off : ShaderPreset.modes[digit - 1], null);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (key == LogicalKeyboardKey.keyR && HardwareKeyboard.instance.isControlPressed) {
|
if (key == LogicalKeyboardKey.keyR && HardwareKeyboard.instance.isControlPressed) {
|
||||||
_lastKeyAt = now;
|
_lastKeyAt = now;
|
||||||
_reloadVideo();
|
model.reload();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// mpv's playlist keys (< >) and Shift+P / Shift+N: Jellyfin episodes.
|
// mpv's playlist keys (< >) and Shift+P / Shift+N: Jellyfin episodes.
|
||||||
final shift = HardwareKeyboard.instance.isShiftPressed;
|
final shift = HardwareKeyboard.instance.isShiftPressed;
|
||||||
if (event.character == '>' || (shift && key == LogicalKeyboardKey.keyN)) {
|
if (event.character == '>' || (shift && key == LogicalKeyboardKey.keyN)) {
|
||||||
// Up next first, then the next episode.
|
// Up next first, then the next episode.
|
||||||
if (coord.queue.items.peek().isNotEmpty) {
|
if (!model.next()) return false;
|
||||||
_lastKeyAt = now;
|
|
||||||
coord.queue.skip();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (coord.nextEpisode.peek() == null) return false;
|
|
||||||
_lastKeyAt = now;
|
_lastKeyAt = now;
|
||||||
coord.playNextEpisode();
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (event.character == '<' || (shift && key == LogicalKeyboardKey.keyP)) {
|
if (event.character == '<' || (shift && key == LogicalKeyboardKey.keyP)) {
|
||||||
if (coord.previousEpisode.peek() == null) return false;
|
if (!model.previous()) return false;
|
||||||
_lastKeyAt = now;
|
_lastKeyAt = now;
|
||||||
coord.playPreviousEpisode();
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// mpv's chapter keys.
|
// mpv's chapter keys.
|
||||||
if (key == LogicalKeyboardKey.pageDown || key == LogicalKeyboardKey.pageUp) {
|
if (key == LogicalKeyboardKey.pageDown || key == LogicalKeyboardKey.pageUp) {
|
||||||
_lastKeyAt = now;
|
_lastKeyAt = now;
|
||||||
return coord.stepChapter(key == LogicalKeyboardKey.pageDown ? 1 : -1);
|
return model.stepChapter(key == LogicalKeyboardKey.pageDown ? 1 : -1);
|
||||||
}
|
}
|
||||||
if (shift && key == LogicalKeyboardKey.arrowRight) {
|
if (shift && key == LogicalKeyboardKey.arrowRight) {
|
||||||
_lastKeyAt = now;
|
_lastKeyAt = now;
|
||||||
coord.skipForward();
|
model.skipForward();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (key == LogicalKeyboardKey.arrowUp || key == LogicalKeyboardKey.arrowDown) {
|
if (key == LogicalKeyboardKey.arrowUp || key == LogicalKeyboardKey.arrowDown) {
|
||||||
_stepVolume(key == LogicalKeyboardKey.arrowUp ? 1 : -1);
|
model.stepVolume(key == LogicalKeyboardKey.arrowUp ? 1 : -1);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (key == LogicalKeyboardKey.arrowRight) {
|
if (key == LogicalKeyboardKey.arrowRight) {
|
||||||
_lastKeyAt = now;
|
_lastKeyAt = now;
|
||||||
coord.nudge(10);
|
model.nudge(10);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (key == LogicalKeyboardKey.arrowLeft) {
|
if (key == LogicalKeyboardKey.arrowLeft) {
|
||||||
_lastKeyAt = now;
|
_lastKeyAt = now;
|
||||||
coord.nudge(-10);
|
model.nudge(-10);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (key == LogicalKeyboardKey.escape) {
|
if (key == LogicalKeyboardKey.escape) {
|
||||||
@@ -312,13 +288,12 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
// (lists, menus and popovers under the mouse scroll instead).
|
// (lists, menus and popovers under the mouse scroll instead).
|
||||||
return VolumeWheel(
|
return VolumeWheel(
|
||||||
enabled: _full,
|
enabled: _full,
|
||||||
onStep: _stepVolume,
|
onStep: _model.stepVolume,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
video,
|
video,
|
||||||
if (_full) ...[
|
if (_full) ...[
|
||||||
_progressLine(),
|
|
||||||
_blockedCard(),
|
_blockedCard(),
|
||||||
_actionArea(),
|
_actionArea(),
|
||||||
if (widget.fullscreen) ...[if (!SessionState.instance.solo.value) _pausedParty(), _unreadChip()],
|
if (widget.fullscreen) ...[if (!SessionState.instance.solo.value) _pausedParty(), _unreadChip()],
|
||||||
@@ -336,24 +311,12 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _video(VideoControlsBuilder? controls) {
|
Widget _video(VideoControlsBuilder? controls) => Video(
|
||||||
final theme = _controlsTheme();
|
|
||||||
return MaterialDesktopVideoControlsTheme(
|
|
||||||
normal: theme,
|
|
||||||
fullscreen: theme,
|
|
||||||
child: Video(
|
|
||||||
key: _videoKey,
|
key: _videoKey,
|
||||||
controller: _coord.playback.video,
|
controller: _coord.playback.video,
|
||||||
subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false),
|
subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false),
|
||||||
controls: controls,
|
controls: controls,
|
||||||
),
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static const _barHeight = 56.0;
|
|
||||||
/// The standard top bar: room for the cover (2:3, 60 px tall).
|
|
||||||
static const _topBarHeight = 72.0;
|
|
||||||
static const _controlsHide = Duration(seconds: 3);
|
|
||||||
|
|
||||||
/// Fullscreen hides the strip and its window buttons: the top bar
|
/// Fullscreen hides the strip and its window buttons: the top bar
|
||||||
/// carries them while the controls are up (restore leaves fullscreen).
|
/// carries them while the controls are up (restore leaves fullscreen).
|
||||||
@@ -373,337 +336,18 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The standard design's progress line while its controls are hidden
|
|
||||||
/// (the uosc timeline collapses into its own).
|
|
||||||
Widget _progressLine() => Positioned(
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
child: IgnorePointer(
|
|
||||||
child: SignalBuilder(builder: (context) {
|
|
||||||
final s = AppSettings.instance;
|
|
||||||
if (s.mpvControls.value || !s.progressLine.value) return const SizedBox.shrink();
|
|
||||||
return ValueListenableBuilder<bool>(
|
|
||||||
valueListenable: _controlsShown,
|
|
||||||
builder: (context, shown, _) => shown
|
|
||||||
? const SizedBox.shrink()
|
|
||||||
: const _MiniSeekBar(key: ValueKey('progress-line-standard'), thin: true),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
MaterialDesktopVideoControlsThemeData _controlsTheme() {
|
|
||||||
final coord = _coord;
|
|
||||||
return MaterialDesktopVideoControlsThemeData(
|
|
||||||
playAndPauseOnTap: false,
|
|
||||||
modifyVolumeOnScroll: false,
|
|
||||||
// media_kit's double-click pushes its own fullscreen route (a second
|
|
||||||
// `Video`); ours toggles the window instead (see [DoubleClickListener]).
|
|
||||||
toggleFullscreenOnDoublePress: false,
|
|
||||||
keyboardShortcuts: {},
|
|
||||||
visibleOnMount: true,
|
|
||||||
hideMouseOnControlsRemoval: true,
|
|
||||||
controlsHoverDuration: _controlsHide,
|
|
||||||
// Our seek bar (chapter marks, hover title) sits in the bottom bar,
|
|
||||||
// which grows by its height; the top bar keeps its row at the top.
|
|
||||||
displaySeekBar: false,
|
|
||||||
buttonBarHeight: _barHeight + ChapterSeekBar.height,
|
|
||||||
topButtonBar: [
|
|
||||||
Expanded(
|
|
||||||
child: Align(
|
|
||||||
alignment: Alignment.topCenter,
|
|
||||||
child: SizedBox(
|
|
||||||
height: _topBarHeight,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
const Expanded(child: NowPlayingTitle()),
|
|
||||||
UpNextButton(
|
|
||||||
queue: coord.queue,
|
|
||||||
onPlayNextEpisode: coord.playNextEpisode,
|
|
||||||
look: UpNextLook.standard,
|
|
||||||
hold: _hold),
|
|
||||||
MaterialDesktopCustomButton(
|
|
||||||
icon: const Icon(Icons.video_library_outlined),
|
|
||||||
onPressed: widget.onChangeVideo,
|
|
||||||
),
|
|
||||||
MaterialDesktopCustomButton(
|
|
||||||
icon: Badge(
|
|
||||||
isLabelVisible: widget.unread > 0,
|
|
||||||
label: Text('${widget.unread}'),
|
|
||||||
child: Icon(widget.chatOpen ? Icons.chat_bubble : Icons.chat_bubble_outline),
|
|
||||||
),
|
|
||||||
onPressed: _toggleChat,
|
|
||||||
),
|
|
||||||
MaterialDesktopCustomButton(
|
|
||||||
icon: Icon(widget.fullscreen ? Icons.fullscreen_exit : Icons.close_fullscreen),
|
|
||||||
onPressed: widget.fullscreen ? widget.onToggleFullscreen : widget.onCollapse,
|
|
||||||
),
|
|
||||||
?_fullscreenWindowButtons(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
bottomButtonBar: [
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
SignalBuilder(
|
|
||||||
builder: (context) => PlayerSeekBar(
|
|
||||||
player: coord.playback.player,
|
|
||||||
chapters: coord.playback.chapters.value,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(child: Row(children: _bottomButtons())),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Widget> _bottomButtons() {
|
|
||||||
final coord = _coord;
|
|
||||||
final playback = coord.playback;
|
|
||||||
void menuOpen(bool open) => _shaderMenuOpen = open;
|
|
||||||
void pickTrack(TrackPick p) => coord.selectTrack(aid: p.aid, sid: p.sid, subtitlesOff: p.subtitlesOff);
|
|
||||||
return [
|
|
||||||
SignalBuilder(
|
|
||||||
builder: (context) => EpisodeSkipButton(
|
|
||||||
focusNode: _noFocus,
|
|
||||||
episode: coord.previousEpisode.value,
|
|
||||||
next: false,
|
|
||||||
onPressed: coord.playPreviousEpisode,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SignalBuilder(
|
|
||||||
builder: (context) => EpisodeSkipButton(
|
|
||||||
focusNode: _noFocus,
|
|
||||||
episode: coord.nextEpisode.value,
|
|
||||||
next: true,
|
|
||||||
onPressed: coord.playNextEpisode,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
VolumeButton(player: playback.player, focusNode: _noFocus),
|
|
||||||
_ReadyControlButton(focusNode: _noFocus),
|
|
||||||
_BarTextButton(
|
|
||||||
focusNode: _noFocus,
|
|
||||||
label: 'Mark everyone ready',
|
|
||||||
onPressed: coord.forcePlayAll,
|
|
||||||
),
|
|
||||||
_BarTextButton(
|
|
||||||
focusNode: _noFocus,
|
|
||||||
icon: Icons.refresh,
|
|
||||||
label: 'Reload video',
|
|
||||||
tooltip: 'Reload video (Ctrl+R) — fixes a frozen picture',
|
|
||||||
onPressed: _reloadVideo,
|
|
||||||
),
|
|
||||||
const MaterialDesktopPositionIndicator(),
|
|
||||||
const Spacer(),
|
|
||||||
SkipForwardButton(focusNode: _noFocus, onPressed: coord.skipForward),
|
|
||||||
SignalBuilder(
|
|
||||||
builder: (context) => ChaptersControlButton(
|
|
||||||
focusNode: _noFocus,
|
|
||||||
chapters: playback.chapters.value,
|
|
||||||
current: playback.currentChapter.value,
|
|
||||||
onPick: (c) => coord.seekTo(c.start.inMicroseconds / 1e6),
|
|
||||||
onMenuOpen: menuOpen,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SignalBuilder(
|
|
||||||
builder: (context) => TrackMenuButton(
|
|
||||||
focusNode: _noFocus,
|
|
||||||
type: JellyfinStreamType.audio,
|
|
||||||
tracks: playback.tracks.value,
|
|
||||||
onPick: pickTrack,
|
|
||||||
onMenuOpen: menuOpen,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SignalBuilder(
|
|
||||||
builder: (context) => TrackMenuButton(
|
|
||||||
focusNode: _noFocus,
|
|
||||||
type: JellyfinStreamType.subtitle,
|
|
||||||
tracks: playback.tracks.value,
|
|
||||||
onPick: pickTrack,
|
|
||||||
onMenuOpen: menuOpen,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ShaderControlButton(
|
|
||||||
focusNode: _noFocus,
|
|
||||||
renderMode: playback.renderMode,
|
|
||||||
onPick: _pickShader,
|
|
||||||
onMenuOpen: menuOpen,
|
|
||||||
),
|
|
||||||
PlayerSettingsButton(focusNode: _noFocus, renderMode: playback.renderMode),
|
|
||||||
MaterialDesktopCustomButton(
|
|
||||||
icon: Icon(widget.fullscreen ? Icons.fullscreen_exit : Icons.fullscreen),
|
|
||||||
onPressed: widget.onToggleFullscreen,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _sessionControls(VideoState state) {
|
Widget _sessionControls(VideoState state) {
|
||||||
// The start card sits under the controls (they stay usable on it).
|
// The start card sits under the controls (they stay usable on it).
|
||||||
// The "Player design" setting swaps them live.
|
return Stack(
|
||||||
return Stack(fit: StackFit.expand, children: [const StartBanner(), _controlsSwitch(state)]);
|
fit: StackFit.expand,
|
||||||
}
|
children: [
|
||||||
|
const StartBanner(),
|
||||||
Widget _controlsSwitch(VideoState state) {
|
SessionControls(
|
||||||
return SessionControlsSwitch(
|
model: _model,
|
||||||
material: (_) => ControlsHoverProbe(
|
shown: _controlsShown,
|
||||||
shown: _controlsShown,
|
hold: _hold,
|
||||||
hideAfter: _controlsHide,
|
|
||||||
hold: _hold,
|
|
||||||
child: DoubleClickListener(
|
|
||||||
onDoubleClick: widget.onToggleFullscreen,
|
onDoubleClick: widget.onToggleFullscreen,
|
||||||
// Clicks on the bars are button clicks, not the video.
|
windowButtons: _fullscreenWindowButtons(),
|
||||||
skipTop: _topBarHeight,
|
|
||||||
skipBottom: _barHeight + ChapterSeekBar.height,
|
|
||||||
child: MaterialDesktopVideoControls(state),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
mpv: (_) => _mpvControls(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The uosc controls: same actions as the bars above,
|
|
||||||
/// all through the coordinator (seeks via `player.seek`, the user path).
|
|
||||||
Widget _mpvControls() {
|
|
||||||
final coord = _coord;
|
|
||||||
final playback = coord.playback;
|
|
||||||
void menuOpen(bool open) => _shaderMenuOpen = open;
|
|
||||||
void pickTrack(TrackPick p) => coord.selectTrack(aid: p.aid, sid: p.sid, subtitlesOff: p.subtitlesOff);
|
|
||||||
Widget episode(bool next) => SignalBuilder(builder: (context) {
|
|
||||||
final e = next ? coord.nextEpisode.value : coord.previousEpisode.value;
|
|
||||||
if (e == null) return const SizedBox.shrink();
|
|
||||||
return UoscButton(
|
|
||||||
key: ValueKey(next ? 'episode-next' : 'episode-previous'),
|
|
||||||
icon: next ? Icons.skip_next_rounded : Icons.skip_previous_rounded,
|
|
||||||
tooltip: '${next ? 'Next' : 'Previous'}: ${e.label} (${next ? '> or Shift+N' : '< or Shift+P'})',
|
|
||||||
onPressed: next ? coord.playNextEpisode : coord.playPreviousEpisode,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return UoscControls(
|
|
||||||
shown: _controlsShown,
|
|
||||||
holdBy: _hold,
|
|
||||||
onDoubleTap: widget.onToggleFullscreen,
|
|
||||||
topLeft: const NowPlayingTitle(),
|
|
||||||
// Up next is one of the top bar's controls, before chat.
|
|
||||||
top: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
UpNextButton(
|
|
||||||
queue: coord.queue, onPlayNextEpisode: coord.playNextEpisode, look: UpNextLook.uosc, hold: _hold),
|
|
||||||
UoscButton(
|
|
||||||
key: const ValueKey('uosc-chat'),
|
|
||||||
icon: widget.chatOpen ? Icons.chat_bubble_rounded : Icons.chat_bubble_outline_rounded,
|
|
||||||
tooltip: 'Chat & activity (Tab)',
|
|
||||||
badge: widget.unread,
|
|
||||||
onPressed: _toggleChat,
|
|
||||||
),
|
|
||||||
UoscButton(
|
|
||||||
icon: widget.fullscreen ? Icons.fullscreen_exit_rounded : Icons.close_fullscreen_rounded,
|
|
||||||
tooltip: widget.fullscreen ? 'Exit fullscreen (Esc)' : 'Back to the library (keeps playing)',
|
|
||||||
onPressed: widget.fullscreen ? widget.onToggleFullscreen : widget.onCollapse,
|
|
||||||
),
|
|
||||||
?_fullscreenWindowButtons(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
timeline: (context, expanded) => SignalBuilder(
|
|
||||||
builder: (context) => UoscTimeline(
|
|
||||||
position: playback.position.value,
|
|
||||||
duration: playback.duration.value,
|
|
||||||
buffered: playback.buffered.value,
|
|
||||||
chapters: playback.chapters.value,
|
|
||||||
expanded: expanded,
|
|
||||||
onSeek: (d) => playback.player.seek(d),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
left: [
|
|
||||||
UoscMainMenuButton(
|
|
||||||
onMenuOpen: menuOpen,
|
|
||||||
actions: [
|
|
||||||
(title: 'Change video', hint: null, run: widget.onChangeVideo),
|
|
||||||
(title: 'Chat & activity', hint: 'Tab', run: _toggleChat),
|
|
||||||
(title: 'Reload video', hint: 'Ctrl+R', run: _reloadVideo),
|
|
||||||
(title: 'Mark a problem', hint: 'Ctrl+Shift+B', run: () => showMarkProblem(context)),
|
|
||||||
(title: 'Mark everyone ready', hint: null, run: coord.forcePlayAll),
|
|
||||||
(title: 'Fullscreen', hint: 'F', run: widget.onToggleFullscreen),
|
|
||||||
(title: 'Leave server', hint: 'Esc', run: widget.onLeave),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
SignalBuilder(
|
|
||||||
builder: (context) => UoscTracksButton(
|
|
||||||
type: JellyfinStreamType.subtitle,
|
|
||||||
tracks: playback.tracks.value,
|
|
||||||
onPick: pickTrack,
|
|
||||||
onMenuOpen: menuOpen,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SignalBuilder(
|
|
||||||
builder: (context) => UoscTracksButton(
|
|
||||||
type: JellyfinStreamType.audio,
|
|
||||||
tracks: playback.tracks.value,
|
|
||||||
onPick: pickTrack,
|
|
||||||
onMenuOpen: menuOpen,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
UoscShaderButton(renderMode: playback.renderMode, onPick: _pickShader, onMenuOpen: menuOpen),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
SignalBuilder(builder: (context) {
|
|
||||||
final ready = coord.selfMember.value?.ready ?? false;
|
|
||||||
return UoscButton(
|
|
||||||
key: const ValueKey('uosc-ready'),
|
|
||||||
icon: ready ? Icons.check_circle_rounded : Icons.radio_button_unchecked_rounded,
|
|
||||||
label: ready ? 'Ready' : 'Not ready',
|
|
||||||
color: ready ? Colors.greenAccent : null,
|
|
||||||
tooltip: 'Ready / not ready (Space)',
|
|
||||||
onPressed: coord.toggleReady,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
UoscButton(icon: Icons.done_all_rounded, tooltip: 'Mark everyone ready', onPressed: coord.forcePlayAll),
|
|
||||||
UoscButton(
|
|
||||||
icon: Icons.refresh_rounded,
|
|
||||||
tooltip: 'Reload video (Ctrl+R) — fixes a frozen picture',
|
|
||||||
onPressed: _reloadVideo,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
right: [
|
|
||||||
UoscButton(
|
|
||||||
key: const ValueKey('uosc-skip-forward'),
|
|
||||||
icon: Icons.fast_forward_rounded,
|
|
||||||
label: '1:30',
|
|
||||||
tooltip: 'Skip 1:30 for everyone (Shift+Right)',
|
|
||||||
onPressed: coord.skipForward,
|
|
||||||
),
|
|
||||||
SignalBuilder(
|
|
||||||
builder: (context) => UoscChaptersButton(
|
|
||||||
chapters: playback.chapters.value,
|
|
||||||
current: playback.currentChapter.value,
|
|
||||||
duration: playback.duration.value,
|
|
||||||
onPick: (c) => coord.seekTo(c.start.inMicroseconds / 1e6),
|
|
||||||
onMenuOpen: menuOpen,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
episode(false),
|
|
||||||
episode(true),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
UoscButton(icon: Icons.video_library_outlined, tooltip: 'Change video', onPressed: widget.onChangeVideo),
|
|
||||||
StreamBuilder<double>(
|
|
||||||
stream: playback.player.stream.volume,
|
|
||||||
initialData: playback.player.state.volume,
|
|
||||||
builder: (context, v) => UoscVolumeButton(
|
|
||||||
volume: v.data ?? 100,
|
|
||||||
onChanged: (x) => playback.player.setVolume(x),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
UoscPlayerSettingsButton(renderMode: playback.renderMode),
|
|
||||||
UoscButton(
|
|
||||||
icon: widget.fullscreen ? Icons.fullscreen_exit_rounded : Icons.fullscreen_rounded,
|
|
||||||
tooltip: 'Fullscreen (F)',
|
|
||||||
onPressed: widget.onToggleFullscreen,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -713,64 +357,26 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
/// the gap between them and the controls.
|
/// the gap between them and the controls.
|
||||||
static const _edge = 16.0;
|
static const _edge = 16.0;
|
||||||
|
|
||||||
/// The bottom controls' height in the design in use.
|
/// The bottom controls' height in the skin and size in use.
|
||||||
static double _bottomControlsHeight(bool uosc) => uosc
|
static double _bottomControlsHeight() {
|
||||||
? UoscControls.buttonRowHeight + UoscControls.rowBottomPadding + UoscTimeline.height
|
final s = AppSettings.instance;
|
||||||
: _barHeight + ChapterSeekBar.height;
|
return controlsBottomHeight(s.controlsSkin.value, s.controlsSize.value);
|
||||||
|
}
|
||||||
|
|
||||||
/// The volume flash after a wheel or key step, in the design in use:
|
static double get _topBarHeight => controlsTopHeight(AppSettings.instance.controlsSkin.peek());
|
||||||
/// uosc's vertical slider at the right edge (centred between the top
|
|
||||||
/// buttons and the bottom controls, as uosc places it), else a pill
|
/// The volume flash after a wheel or key step: a pill under the top bar.
|
||||||
/// under the top bar.
|
Widget _volumeReadout() => Positioned(
|
||||||
Widget _volumeReadout() {
|
|
||||||
final playback = _coord.playback;
|
|
||||||
Widget withVolume(Widget Function(double v) build) => StreamBuilder<double>(
|
|
||||||
stream: playback.player.stream.volume,
|
|
||||||
initialData: playback.player.state.volume,
|
|
||||||
builder: (context, v) => build(v.data ?? 100),
|
|
||||||
);
|
|
||||||
return SignalBuilder(builder: (context) {
|
|
||||||
if (AppSettings.instance.mpvControls.value) {
|
|
||||||
const top = 12 + UoscControls.buttonRowHeight;
|
|
||||||
final bottom = _bottomControlsHeight(true);
|
|
||||||
return Positioned(
|
|
||||||
right: UoscVolumeSlider.width / 2,
|
|
||||||
top: top,
|
|
||||||
bottom: bottom,
|
|
||||||
width: UoscVolumeSlider.width,
|
|
||||||
child: LayoutBuilder(builder: (context, c) {
|
|
||||||
final h = UoscVolumeSlider.heightFor(c.maxHeight);
|
|
||||||
if (h <= UoscVolumeSlider.width) return const SizedBox.shrink();
|
|
||||||
return Align(
|
|
||||||
// Centred with the mute button's room below, like uosc.
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: UoscVolumeSlider.width),
|
|
||||||
child: SizedBox(
|
|
||||||
height: h,
|
|
||||||
child: VolumeFlash(
|
|
||||||
trigger: _volumeFlash,
|
|
||||||
builder: (context) => withVolume((v) => UoscVolumeSlider(volume: v)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Positioned(
|
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
top: _topBarHeight + 8,
|
top: _topBarHeight + 8,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: VolumeFlash(
|
child: VolumeFlash(
|
||||||
trigger: _volumeFlash,
|
trigger: _model.volumeFlash,
|
||||||
builder: (context) => withVolume((v) => VolumeReadout(volume: v)),
|
builder: (context) => SignalBuilder(builder: (context) => VolumeReadout(volume: _model.volume.value)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The action area: one band above the bottom controls for everything
|
/// The action area: one band above the bottom controls for everything
|
||||||
/// that floats there. The status pill (the ready prompt among it)
|
/// that floats there. The status pill (the ready prompt among it)
|
||||||
@@ -781,7 +387,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
/// ([ControlsHold]).
|
/// ([ControlsHold]).
|
||||||
Widget _actionArea() {
|
Widget _actionArea() {
|
||||||
return SignalBuilder(builder: (context) {
|
return SignalBuilder(builder: (context) {
|
||||||
final controls = _bottomControlsHeight(AppSettings.instance.mpvControls.value);
|
final controls = _bottomControlsHeight();
|
||||||
return ValueListenableBuilder<bool>(
|
return ValueListenableBuilder<bool>(
|
||||||
valueListenable: _controlsShown,
|
valueListenable: _controlsShown,
|
||||||
builder: (context, shown, child) => AnimatedPositioned(
|
builder: (context, shown, child) => AnimatedPositioned(
|
||||||
@@ -848,7 +454,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
announce: skip.kind != SkipKind.ending,
|
announce: skip.kind != SkipKind.ending,
|
||||||
controlsShown: _controlsShown,
|
controlsShown: _controlsShown,
|
||||||
hold: _hold,
|
hold: _hold,
|
||||||
child: SkipPill(offer: skip, onPressed: () => coord.skip(skip)),
|
child: SkipPill(offer: skip, onPressed: () => _model.skip(skip)),
|
||||||
),
|
),
|
||||||
if (showNext)
|
if (showNext)
|
||||||
PillGate(
|
PillGate(
|
||||||
@@ -1171,7 +777,7 @@ const _shaderKeys = [
|
|||||||
class _MiniSeekBar extends StatelessWidget {
|
class _MiniSeekBar extends StatelessWidget {
|
||||||
final bool thin;
|
final bool thin;
|
||||||
final ValueChanged<Duration>? onSeek;
|
final ValueChanged<Duration>? onSeek;
|
||||||
const _MiniSeekBar({super.key, this.thin = false, this.onSeek});
|
const _MiniSeekBar({this.thin = false, this.onSeek});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -1199,91 +805,3 @@ class _MiniSeekBar extends StatelessWidget {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Double-click on the picture (not on the bars) without joining the
|
|
||||||
/// gesture arena, so single clicks on buttons stay instant.
|
|
||||||
class DoubleClickListener extends StatefulWidget {
|
|
||||||
final VoidCallback onDoubleClick;
|
|
||||||
final double skipTop, skipBottom;
|
|
||||||
final Widget child;
|
|
||||||
const DoubleClickListener(
|
|
||||||
{super.key, required this.onDoubleClick, this.skipTop = 0, this.skipBottom = 0, required this.child});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<DoubleClickListener> createState() => _DoubleClickListenerState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DoubleClickListenerState extends State<DoubleClickListener> {
|
|
||||||
DateTime? _last;
|
|
||||||
Offset? _lastAt;
|
|
||||||
|
|
||||||
void _down(PointerDownEvent e) {
|
|
||||||
if (e.buttons != kPrimaryMouseButton) return;
|
|
||||||
final h = context.size?.height ?? 0;
|
|
||||||
final y = e.localPosition.dy;
|
|
||||||
if (y < widget.skipTop || y > h - widget.skipBottom) {
|
|
||||||
_last = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final now = DateTime.now();
|
|
||||||
final last = _last, at = _lastAt;
|
|
||||||
if (last != null &&
|
|
||||||
at != null &&
|
|
||||||
now.difference(last) < const Duration(milliseconds: 400) &&
|
|
||||||
(e.localPosition - at).distance < 24) {
|
|
||||||
_last = null;
|
|
||||||
widget.onDoubleClick();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_last = now;
|
|
||||||
_lastAt = e.localPosition;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) =>
|
|
||||||
Listener(behavior: HitTestBehavior.translucent, onPointerDown: _down, child: widget.child);
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ReadyControlButton extends StatelessWidget {
|
|
||||||
final FocusNode focusNode;
|
|
||||||
const _ReadyControlButton({required this.focusNode});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return SignalBuilder(
|
|
||||||
builder: (context) {
|
|
||||||
final ready = SessionState.instance.coordinator.selfMember.value?.ready ?? false;
|
|
||||||
return _BarTextButton(
|
|
||||||
focusNode: focusNode,
|
|
||||||
label: ready ? 'Ready ✓' : 'Not ready',
|
|
||||||
onPressed: SessionState.instance.coordinator.toggleReady,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _BarTextButton extends StatelessWidget {
|
|
||||||
final FocusNode focusNode;
|
|
||||||
final String label;
|
|
||||||
final IconData? icon;
|
|
||||||
final String? tooltip;
|
|
||||||
final VoidCallback onPressed;
|
|
||||||
const _BarTextButton(
|
|
||||||
{required this.focusNode, required this.label, required this.onPressed, this.icon, this.tooltip});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final style = TextButton.styleFrom(
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
||||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
||||||
);
|
|
||||||
final text = Text(label, style: const TextStyle(fontSize: 12));
|
|
||||||
final button = icon == null
|
|
||||||
? TextButton(focusNode: focusNode, style: style, onPressed: onPressed, child: text)
|
|
||||||
: TextButton.icon(
|
|
||||||
focusNode: focusNode, style: style, onPressed: onPressed, icon: Icon(icon, size: 16), label: text);
|
|
||||||
return tooltip == null ? button : Tooltip(message: tooltip, child: button);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,9 +2,7 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:file_picker/file_picker.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:fox_utils/fox_utils.dart' show DesktopDropX, DropTarget;
|
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
import 'package:sync_relay_core/core.dart'
|
import 'package:sync_relay_core/core.dart'
|
||||||
show CookieBrowser, CookieFile, NetscapeCookie, YtDlp, YtDlpCookies, cookieBrowsers, parseCookies;
|
show CookieBrowser, CookieFile, NetscapeCookie, YtDlp, YtDlpCookies, cookieBrowsers, parseCookies;
|
||||||
@@ -12,13 +10,11 @@ import 'package:sync_relay_core/core.dart'
|
|||||||
import '../controls/player_settings.dart';
|
import '../controls/player_settings.dart';
|
||||||
import '../jellyfin/accounts.dart';
|
import '../jellyfin/accounts.dart';
|
||||||
import '../jellyfin/accounts_panel.dart';
|
import '../jellyfin/accounts_panel.dart';
|
||||||
import '../playback/yt_dlp_app.dart';
|
|
||||||
import '../schedule/animeflow.dart';
|
import '../schedule/animeflow.dart';
|
||||||
import '../screens/home/about_page.dart';
|
import '../screens/home/about_page.dart';
|
||||||
import '../screens/home/settings_page.dart';
|
import '../screens/home/settings_page.dart';
|
||||||
import '../sfx/sfx.dart';
|
import '../sfx/sfx.dart';
|
||||||
import '../sfx/sound_bank.dart';
|
import '../sfx/sound_bank.dart';
|
||||||
import '../sfx/synth_sfx.dart';
|
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
import '../state/settings.dart';
|
import '../state/settings.dart';
|
||||||
import '../update/update_controller.dart';
|
import '../update/update_controller.dart';
|
||||||
@@ -121,12 +117,16 @@ class SettingsTab extends StatelessWidget {
|
|||||||
return [
|
return [
|
||||||
const SettingsHintCard(
|
const SettingsHintCard(
|
||||||
icon: Icons.tune,
|
icon: Icons.tune,
|
||||||
text: "Player settings are also in the player's ⚙, at the right end of its controls.",
|
text: "Player settings are also in the player: ⚙ → Player settings, at the right of its controls.",
|
||||||
),
|
),
|
||||||
SettingsPanel(
|
SettingsPanel(
|
||||||
title: 'Player',
|
title: 'Player',
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: playerSettingRows()),
|
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: playerSettingRows()),
|
||||||
),
|
),
|
||||||
|
SettingsPanel(
|
||||||
|
title: 'Controls',
|
||||||
|
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: controlsSettingRows()),
|
||||||
|
),
|
||||||
const SettingsPanel(title: 'Custom shaders', child: CustomShaderFiles()),
|
const SettingsPanel(title: 'Custom shaders', child: CustomShaderFiles()),
|
||||||
const SettingsPanel(title: 'yt-dlp', child: YtDlpSettings()),
|
const SettingsPanel(title: 'yt-dlp', child: YtDlpSettings()),
|
||||||
];
|
];
|
||||||
@@ -377,7 +377,7 @@ class RecentServersPanel extends StatelessWidget {
|
|||||||
/// The app's yt-dlp (web page links): its version, "Update now", and the
|
/// The app's yt-dlp (web page links): its version, "Update now", and the
|
||||||
/// last error.
|
/// last error.
|
||||||
class YtDlpSettings extends StatefulWidget {
|
class YtDlpSettings extends StatefulWidget {
|
||||||
/// Defaults to [AppYtDlp.instance]'s.
|
/// Defaults to the app's ([Ports.links]).
|
||||||
final Future<YtDlp>? ytDlp;
|
final Future<YtDlp>? ytDlp;
|
||||||
const YtDlpSettings({super.key, this.ytDlp});
|
const YtDlpSettings({super.key, this.ytDlp});
|
||||||
|
|
||||||
@@ -386,7 +386,7 @@ class YtDlpSettings extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _YtDlpSettingsState extends State<YtDlpSettings> {
|
class _YtDlpSettingsState extends State<YtDlpSettings> {
|
||||||
late final Future<YtDlp> _y = widget.ytDlp ?? AppYtDlp.instance.ytDlp;
|
late final Future<YtDlp> _y = widget.ytDlp ?? SessionState.instance.ports.links.ytDlp;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -469,18 +469,17 @@ class _YtDlpCookieSettingsState extends State<YtDlpCookieSettings> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
Future<void> _pickFile() async {
|
Future<void> _pickFile() async {
|
||||||
final List<PlatformFile> files;
|
final List<String> files;
|
||||||
try {
|
try {
|
||||||
files = await FilePicker.pickFiles(
|
files = await SessionState.instance.ports.folders.pickFiles(
|
||||||
dialogTitle: 'Pick a cookies.txt or a JSON cookie export',
|
dialogTitle: 'Pick a cookies.txt or a JSON cookie export',
|
||||||
type: FileType.custom,
|
extensions: const ['txt', 'json'],
|
||||||
allowedExtensions: const ['txt', 'json'],
|
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
_reload('file dialog unavailable — try again');
|
_reload('file dialog unavailable — try again');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final path = files.firstOrNull?.path;
|
final path = files.firstOrNull;
|
||||||
if (path != null) await _importFile(path);
|
if (path != null) await _importFile(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -532,12 +531,12 @@ class _YtDlpCookieSettingsState extends State<YtDlpCookieSettings> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
return DropTarget(
|
return SessionState.instance.ports.shell.dropTarget(
|
||||||
onDragEntered: (_) => setState(() => _dragging = true),
|
onEnter: () => setState(() => _dragging = true),
|
||||||
onDragExited: (_) => setState(() => _dragging = false),
|
onExit: () => setState(() => _dragging = false),
|
||||||
onDragDone: (d) {
|
onDrop: (paths, _) {
|
||||||
setState(() => _dragging = false);
|
setState(() => _dragging = false);
|
||||||
final path = DesktopDropX.pathsOf(d.files).firstOrNull;
|
final path = paths.firstOrNull;
|
||||||
if (path != null) _importFile(path);
|
if (path != null) _importFile(path);
|
||||||
},
|
},
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
@@ -794,10 +793,7 @@ class NotificationsInfo extends StatelessWidget {
|
|||||||
class SoundSettings extends StatelessWidget {
|
class SoundSettings extends StatelessWidget {
|
||||||
const SoundSettings({super.key});
|
const SoundSettings({super.key});
|
||||||
|
|
||||||
void _preview(SfxKind kind) {
|
void _preview(SfxKind kind) => unawaited(SessionState.instance.sfx.preview(kind, readyIndex: 2));
|
||||||
final sfx = SessionState.instance.sfx;
|
|
||||||
if (sfx is SynthSfx) unawaited(sfx.preview(kind, readyIndex: 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ import '../jellyfin/session_media.dart';
|
|||||||
import '../screens/home/health_probe.dart';
|
import '../screens/home/health_probe.dart';
|
||||||
import '../screens/home/picked_media.dart';
|
import '../screens/home/picked_media.dart';
|
||||||
import '../platform/app_log.dart' show appLogDir;
|
import '../platform/app_log.dart' show appLogDir;
|
||||||
import '../playback/yt_dlp_app.dart';
|
import '../platform/desktop/desktop_ports.dart';
|
||||||
|
import '../platform/ports/ports.dart';
|
||||||
import '../sfx/sfx.dart';
|
import '../sfx/sfx.dart';
|
||||||
import '../sfx/sound_bank.dart';
|
import '../sfx/sound_bank.dart';
|
||||||
import '../sfx/synth_sfx.dart';
|
import '../sfx/synth_sfx.dart';
|
||||||
import '../session/health.dart';
|
import '../session/health.dart';
|
||||||
|
import '../session/notifier.dart';
|
||||||
import '../session/session_coordinator.dart';
|
import '../session/session_coordinator.dart';
|
||||||
import '../widgets/avatar_pipeline.dart';
|
import '../widgets/avatar_pipeline.dart';
|
||||||
import 'connect_prefs.dart';
|
import 'connect_prefs.dart';
|
||||||
@@ -160,13 +162,18 @@ class SessionState {
|
|||||||
final route = signal(AppRoute.home);
|
final route = signal(AppRoute.home);
|
||||||
final recents = signal<List<RecentServer>>(const []);
|
final recents = signal<List<RecentServer>>(const []);
|
||||||
final status = signal(const HealthStatus(HealthSeverity.info, 'idle'));
|
final status = signal(const HealthStatus(HealthSeverity.info, 'idle'));
|
||||||
final SfxService sfx = SynthSfx(
|
/// Owns the app's audio device ([SynthSfx.dispose] on close).
|
||||||
|
final SynthSfx sfx = SynthSfx(
|
||||||
volume: () => AppSettings.instance.soundVolume.peek(),
|
volume: () => AppSettings.instance.soundVolume.peek(),
|
||||||
// A nudge is an explicit poke: it ignores its switch (not the volume).
|
// A nudge is an explicit poke: it ignores its switch (not the volume).
|
||||||
enabled: (k) => k == SfxKind.nudge || !AppSettings.instance.soundsOff.peek().contains(k.name),
|
enabled: (k) => k == SfxKind.nudge || !AppSettings.instance.soundsOff.peek().contains(k.name),
|
||||||
presetFor: soundPresetFor,
|
presetFor: soundPresetFor,
|
||||||
);
|
);
|
||||||
late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx);
|
/// The platform (window, links, folders, yt-dlp, notifications).
|
||||||
|
final Ports ports = desktopPorts();
|
||||||
|
/// Every toast, sound and activity-log line goes through it.
|
||||||
|
late final Notifier notifier = Notifier(sfx: sfx, attention: ports.shell.requestAttention);
|
||||||
|
late final SessionCoordinator coordinator = SessionCoordinator(notifier: notifier, ytDlp: ports.links);
|
||||||
final lastForm = ConnectForm();
|
final lastForm = ConnectForm();
|
||||||
JellyfinAccounts get jellyfin => JellyfinAccounts.instance;
|
JellyfinAccounts get jellyfin => JellyfinAccounts.instance;
|
||||||
/// Set when the hosted server died mid-session; Home shows it on the
|
/// Set when the hosted server died mid-session; Home shows it on the
|
||||||
@@ -187,7 +194,7 @@ class SessionState {
|
|||||||
/// default) when the app-support folder is not available.
|
/// default) when the app-support folder is not available.
|
||||||
static Future<String> _sharedYtDlpDir() async {
|
static Future<String> _sharedYtDlpDir() async {
|
||||||
try {
|
try {
|
||||||
return (await AppYtDlp.instance.ytDlp).dir;
|
return (await instance.ports.links.ytDlp).dir;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -284,7 +291,7 @@ class SessionState {
|
|||||||
try {
|
try {
|
||||||
if (carry != null) await _resume(carry);
|
if (carry != null) await _resume(carry);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
coordinator.showToast("couldn't put it up again: $e");
|
coordinator.notify(ActionFailed("couldn't put it up again: $e"));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
switching.value = false;
|
switching.value = false;
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:hive_ce/hive.dart';
|
import 'package:hive_ce/hive.dart';
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
|
import '../controls/model/controls_prefs.dart';
|
||||||
import '../shaders/shader_presets.dart';
|
import '../shaders/shader_presets.dart';
|
||||||
|
|
||||||
// App preferences (Settings page), one Hive box. Signals start at their
|
// App preferences (Settings page), one Hive box. Signals start at their
|
||||||
@@ -17,7 +20,14 @@ const _keyShaderPreset = 'shaderPreset';
|
|||||||
const _keyShaderQuality = 'shaderQuality';
|
const _keyShaderQuality = 'shaderQuality';
|
||||||
const _keyCustomShaders = 'customShaders';
|
const _keyCustomShaders = 'customShaders';
|
||||||
const _keyAutoAdvance = 'autoAdvance';
|
const _keyAutoAdvance = 'autoAdvance';
|
||||||
|
/// Legacy: true was the uosc design (now [ControlsSkin.minimal]); read
|
||||||
|
/// once when [_keyControlsSkin] is missing.
|
||||||
const _keyMpvControls = 'mpvControls';
|
const _keyMpvControls = 'mpvControls';
|
||||||
|
const _keyControlsSkin = 'controlsSkin';
|
||||||
|
const _keyTimeDisplay = 'timeDisplay';
|
||||||
|
const _keyControlsSize = 'controlsSize';
|
||||||
|
const _keyControlsHideSecs = 'controlsHideSecs';
|
||||||
|
const _keyHiddenControls = 'hiddenControls';
|
||||||
const _keyHideReReleases = 'hideReReleases';
|
const _keyHideReReleases = 'hideReReleases';
|
||||||
const _keyProgressLine = 'progressLine';
|
const _keyProgressLine = 'progressLine';
|
||||||
const _keySoundVolume = 'soundVolume';
|
const _keySoundVolume = 'soundVolume';
|
||||||
@@ -32,6 +42,12 @@ const _keyShareDiagnostics = 'shareDiagnostics';
|
|||||||
|
|
||||||
enum HwdecMode { auto, off, custom }
|
enum HwdecMode { auto, off, custom }
|
||||||
|
|
||||||
|
/// The mpv `hwdec` of [HwdecMode.auto]. Windows: 'auto-copy' (decoded on
|
||||||
|
/// the GPU, frames copied back): media_kit renders through ANGLE, where
|
||||||
|
/// plain 'auto' picks the dxva2 interop, which cannot create its EGL
|
||||||
|
/// surface there ("Failed to create EGL surface"). Elsewhere 'auto'.
|
||||||
|
String get autoHwdec => Platform.isWindows ? 'auto-copy' : 'auto';
|
||||||
|
|
||||||
/// When chat messages pop up as toasts (they always reach the chat).
|
/// When chat messages pop up as toasts (they always reach the chat).
|
||||||
enum ChatPopups {
|
enum ChatPopups {
|
||||||
all('All'),
|
all('All'),
|
||||||
@@ -62,20 +78,26 @@ class AppSettings {
|
|||||||
final unreadyOnSwap = signal(false);
|
final unreadyOnSwap = signal(false);
|
||||||
/// Session: the idle auto-unready sits this member out instead.
|
/// Session: the idle auto-unready sits this member out instead.
|
||||||
final idleSitsOut = signal(false);
|
final idleSitsOut = signal(false);
|
||||||
/// Player design: the uosc controls (`controls/uosc/`) instead of the
|
/// The player controls' skin (`controls/`). Applies live.
|
||||||
/// standard ones (media_kit's with our bars). Applies live.
|
final controlsSkin = signal(ControlsSkin.standard);
|
||||||
final mpvControls = signal(false);
|
/// The bar's clock: elapsed / total or the time left.
|
||||||
|
final timeDisplay = signal(TimeDisplay.elapsed);
|
||||||
|
final controlsSize = signal(ControlsSize.normal);
|
||||||
|
/// Seconds without mouse movement before the controls hide.
|
||||||
|
final controlsHideSecs = signal(defaultControlsHideSecs);
|
||||||
|
/// Optional bar buttons switched off (they stay in ⚙).
|
||||||
|
final hiddenControls = signal<Set<OptionalControl>>(const {});
|
||||||
/// Library: leave out of "Latest in X" what was added long after it
|
/// Library: leave out of "Latest in X" what was added long after it
|
||||||
/// first aired (re-uploads, old seasons added late).
|
/// first aired (re-uploads, old seasons added late).
|
||||||
final hideReReleases = signal(true);
|
final hideReReleases = signal(true);
|
||||||
/// A 2 px progress line at the bottom of the video while the controls
|
/// A 2 px progress line at the bottom of the video while the controls
|
||||||
/// are hidden (both designs). Applies live.
|
/// are hidden (both skins). Applies live.
|
||||||
final progressLine = signal(true);
|
final progressLine = signal(true);
|
||||||
/// Send this app's log lines to the server's session log while in a
|
/// Send this app's log lines to the server's session log while in a
|
||||||
/// session (never chat). Off: nothing is sent but a note that it is off.
|
/// session (never chat). Off: nothing is sent but a note that it is off.
|
||||||
final shareDiagnostics = signal(true);
|
final shareDiagnostics = signal(true);
|
||||||
/// Chat message toasts (default: only when paused). Applies live.
|
/// Chat message toasts (default: all). Applies live.
|
||||||
final chatPopups = signal(ChatPopups.paused);
|
final chatPopups = signal(ChatPopups.all);
|
||||||
/// App sounds (`sfx/`): 0..1, 0 = silent.
|
/// App sounds (`sfx/`): 0..1, 0 = silent.
|
||||||
final soundVolume = signal(1.0);
|
final soundVolume = signal(1.0);
|
||||||
/// `SfxKind` names switched off one by one.
|
/// `SfxKind` names switched off one by one.
|
||||||
@@ -95,13 +117,13 @@ class AppSettings {
|
|||||||
/// .glsl files of [ShaderPreset.custom], in mpv order.
|
/// .glsl files of [ShaderPreset.custom], in mpv order.
|
||||||
final customShaders = signal<List<String>>(const []);
|
final customShaders = signal<List<String>>(const []);
|
||||||
|
|
||||||
/// mpv `hwdec` value: 'auto', 'no', or the custom value ('auto' when
|
/// mpv `hwdec` value: [autoHwdec], 'no', or the custom value
|
||||||
/// custom is blank).
|
/// ([autoHwdec] when custom is blank).
|
||||||
late final hwdec = computed(() => switch (hwdecMode.value) {
|
late final hwdec = computed(() => switch (hwdecMode.value) {
|
||||||
HwdecMode.auto => 'auto',
|
HwdecMode.auto => autoHwdec,
|
||||||
HwdecMode.off => 'no',
|
HwdecMode.off => 'no',
|
||||||
HwdecMode.custom =>
|
HwdecMode.custom =>
|
||||||
hwdecCustom.value.trim().isEmpty ? 'auto' : hwdecCustom.value.trim(),
|
hwdecCustom.value.trim().isEmpty ? autoHwdec : hwdecCustom.value.trim(),
|
||||||
});
|
});
|
||||||
|
|
||||||
Future<void>? _load;
|
Future<void>? _load;
|
||||||
@@ -127,8 +149,18 @@ class AppSettings {
|
|||||||
if (unready is bool) unreadyOnSwap.value = unready;
|
if (unready is bool) unreadyOnSwap.value = unready;
|
||||||
final idleOut = box.get(_keyIdleSitsOut);
|
final idleOut = box.get(_keyIdleSitsOut);
|
||||||
if (idleOut is bool) idleSitsOut.value = idleOut;
|
if (idleOut is bool) idleSitsOut.value = idleOut;
|
||||||
final mpv = box.get(_keyMpvControls);
|
controlsSkin.value =
|
||||||
if (mpv is bool) mpvControls.value = mpv;
|
ControlsSkin.fromStored(box.get(_keyControlsSkin), legacyMpvControls: box.get(_keyMpvControls));
|
||||||
|
final time = box.get(_keyTimeDisplay);
|
||||||
|
timeDisplay.value = TimeDisplay.values.where((t) => t.name == time).firstOrNull ?? TimeDisplay.elapsed;
|
||||||
|
final size = box.get(_keyControlsSize);
|
||||||
|
controlsSize.value = ControlsSize.values.where((t) => t.name == size).firstOrNull ?? ControlsSize.normal;
|
||||||
|
final hide = box.get(_keyControlsHideSecs);
|
||||||
|
if (hide is int && controlsHideChoices.contains(hide)) controlsHideSecs.value = hide;
|
||||||
|
final hidden = box.get(_keyHiddenControls);
|
||||||
|
if (hidden is List) {
|
||||||
|
hiddenControls.value = Set.unmodifiable(OptionalControl.values.where((c) => hidden.contains(c.name)));
|
||||||
|
}
|
||||||
final hideRe = box.get(_keyHideReReleases);
|
final hideRe = box.get(_keyHideReReleases);
|
||||||
if (hideRe is bool) hideReReleases.value = hideRe;
|
if (hideRe is bool) hideReReleases.value = hideRe;
|
||||||
final line = box.get(_keyProgressLine);
|
final line = box.get(_keyProgressLine);
|
||||||
@@ -136,7 +168,7 @@ class AppSettings {
|
|||||||
final share = box.get(_keyShareDiagnostics);
|
final share = box.get(_keyShareDiagnostics);
|
||||||
if (share is bool) shareDiagnostics.value = share;
|
if (share is bool) shareDiagnostics.value = share;
|
||||||
final popups = box.get(_keyChatPopups);
|
final popups = box.get(_keyChatPopups);
|
||||||
chatPopups.value = ChatPopups.values.where((m) => m.name == popups).firstOrNull ?? ChatPopups.paused;
|
chatPopups.value = ChatPopups.values.where((m) => m.name == popups).firstOrNull ?? ChatPopups.all;
|
||||||
final vol = box.get(_keySoundVolume);
|
final vol = box.get(_keySoundVolume);
|
||||||
if (vol is num) soundVolume.value = vol.toDouble().clamp(0, 1);
|
if (vol is num) soundVolume.value = vol.toDouble().clamp(0, 1);
|
||||||
final off = box.get(_keySoundsOff);
|
final off = box.get(_keySoundsOff);
|
||||||
@@ -199,9 +231,31 @@ class AppSettings {
|
|||||||
_put(_keyIdleSitsOut, on);
|
_put(_keyIdleSitsOut, on);
|
||||||
}
|
}
|
||||||
|
|
||||||
void setMpvControls(bool on) {
|
void setControlsSkin(ControlsSkin skin) {
|
||||||
mpvControls.value = on;
|
controlsSkin.value = skin;
|
||||||
_put(_keyMpvControls, on);
|
_put(_keyControlsSkin, skin.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setTimeDisplay(TimeDisplay t) {
|
||||||
|
timeDisplay.value = t;
|
||||||
|
_put(_keyTimeDisplay, t.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setControlsSize(ControlsSize size) {
|
||||||
|
controlsSize.value = size;
|
||||||
|
_put(_keyControlsSize, size.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setControlsHideSecs(int secs) {
|
||||||
|
controlsHideSecs.value = secs;
|
||||||
|
_put(_keyControlsHideSecs, secs);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setControlShown(OptionalControl c, bool on) {
|
||||||
|
final next = {...hiddenControls.value};
|
||||||
|
on ? next.remove(c) : next.add(c);
|
||||||
|
hiddenControls.value = Set.unmodifiable(next);
|
||||||
|
_put(_keyHiddenControls, [for (final c in next) c.name]);
|
||||||
}
|
}
|
||||||
|
|
||||||
void setProgressLine(bool on) {
|
void setProgressLine(bool on) {
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:hive_ce_flutter/hive_flutter.dart';
|
import 'package:hive_ce_flutter/hive_flutter.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
// In-app update flow behind the Home update indicator. Same code path as
|
import '../platform/app_shutdown.dart';
|
||||||
|
|
||||||
|
// In-app update flow behind the update bar (UpdateBar). Same code path as
|
||||||
// `--upgrade` (core's updater, downloadAndApply); this adds state for the
|
// `--upgrade` (core's updater, downloadAndApply); this adds state for the
|
||||||
// UI and the restart. Check failures are logged only — the banner shows up just when
|
// UI and the restart. Check failures are logged only — the banner shows up just when
|
||||||
// there is something to install.
|
// there is something to install.
|
||||||
@@ -28,26 +30,23 @@ class UpdateStatus {
|
|||||||
// Process args as main() received them — restart relaunches with these.
|
// Process args as main() received them — restart relaunches with these.
|
||||||
List<String> launchArgs = const [];
|
List<String> launchArgs = const [];
|
||||||
|
|
||||||
// Relaunch the (now replaced) binary detached with the same args, then
|
|
||||||
// exit this process.
|
|
||||||
Future<Never> restartApp(String executable, List<String> args) async {
|
|
||||||
final keep = args.where((a) => a != '--upgrade').toList();
|
|
||||||
await Process.start(executable, keep,
|
|
||||||
mode: ProcessStartMode.detached,
|
|
||||||
workingDirectory: Directory.current.path);
|
|
||||||
exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
typedef RestartFn = Future<void> Function(String executable, List<String> args);
|
typedef RestartFn = Future<void> Function(String executable, List<String> args);
|
||||||
|
|
||||||
// Release Hive's box locks before the new instance opens the same boxes.
|
// The orderly close (players, leave), Hive's box locks released before
|
||||||
|
// the new instance opens the same boxes, the (now replaced) binary
|
||||||
|
// relaunched detached with the same args, then the window closes.
|
||||||
Future<void> _closeAndRestart(String executable, List<String> args) async {
|
Future<void> _closeAndRestart(String executable, List<String> args) async {
|
||||||
try {
|
final keep = args.where((a) => a != '--upgrade').toList();
|
||||||
await Hive.close();
|
await shutDownApp(beforeQuit: () async {
|
||||||
} catch (e) {
|
try {
|
||||||
_log.warning('closing hive before restart: $e');
|
await Hive.close();
|
||||||
}
|
} catch (e) {
|
||||||
await restartApp(executable, args);
|
_log.warning('closing hive before restart: $e');
|
||||||
|
}
|
||||||
|
await Process.start(executable, keep,
|
||||||
|
mode: ProcessStartMode.detached,
|
||||||
|
workingDirectory: Directory.current.path);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class UpdateController {
|
class UpdateController {
|
||||||
@@ -115,15 +114,22 @@ class UpdateController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_log.info('update available: ${m.version} (proto ${m.protocol})');
|
_log.info('update available: ${m.version} (proto ${m.protocol})');
|
||||||
|
lastCheck.value = 'Update v${m.version} available';
|
||||||
|
// A re-check never cuts into an install under way.
|
||||||
|
if (_installing) return;
|
||||||
status.value = UpdateStatus(UpdatePhase.available,
|
status.value = UpdateStatus(UpdatePhase.available,
|
||||||
manifest: m, canInstall: root != null);
|
manifest: m, canInstall: root != null);
|
||||||
lastCheck.value = 'Update v${m.version} available';
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.warning('update check failed: $e');
|
_log.warning('update check failed: $e');
|
||||||
lastCheck.value = 'Update check failed: $e';
|
lastCheck.value = 'Update check failed: $e';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool get _installing => switch (status.value.phase) {
|
||||||
|
UpdatePhase.downloading || UpdatePhase.applying || UpdatePhase.restarting => true,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
void dismiss() {
|
void dismiss() {
|
||||||
final s = status.value;
|
final s = status.value;
|
||||||
if ((s.phase == UpdatePhase.available || s.phase == UpdatePhase.failed) &&
|
if ((s.phase == UpdatePhase.available || s.phase == UpdatePhase.failed) &&
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import 'dart:ui' show ImageFilter;
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import '../controls/uosc/controls.dart';
|
import '../controls/frame.dart';
|
||||||
import '../shell/motion.dart';
|
import '../shell/motion.dart';
|
||||||
|
|
||||||
// The app's one menu: a frosted dark panel anchored under (or over) the
|
// The app's one menu: a frosted dark panel anchored under (or over) the
|
||||||
@@ -158,9 +158,8 @@ Future<int?> _openMenu(BuildContext context, List<AppMenuItem<Object?>> items, d
|
|||||||
minWidth: minWidth - shift,
|
minWidth: minWidth - shift,
|
||||||
capturedThemes: InheritedTheme.capture(from: context, to: navigator.context),
|
capturedThemes: InheritedTheme.capture(from: context, to: navigator.context),
|
||||||
));
|
));
|
||||||
// Over the uosc controls: keep them up while the menu is open.
|
// Over the player's controls: keep them up while the menu is open.
|
||||||
final controls = UoscControls.maybeOf(context);
|
return ControlsFrame.holdWhile(context, open);
|
||||||
return controls == null ? open() : controls.holdDuring(open);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppMenuRoute extends PopupRoute<int> {
|
class _AppMenuRoute extends PopupRoute<int> {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import '../session/notifier.dart' show AppEvent, Copied, CopyFailed, noticeFor;
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
|
|
||||||
// Reusing what the app shows elsewhere: copying a link or a title, opening
|
// Reusing what the app shows elsewhere: copying a link or a title, opening
|
||||||
@@ -22,28 +21,10 @@ abstract final class ReuseText {
|
|||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
void Function(String text)? debugReuseToast;
|
void Function(String text)? debugReuseToast;
|
||||||
|
|
||||||
void _toast(String text) {
|
void _notify(AppEvent event) {
|
||||||
final debug = debugReuseToast;
|
final debug = debugReuseToast;
|
||||||
if (debug != null) return debug(text);
|
if (debug != null) return debug(noticeFor(event).toast ?? '');
|
||||||
SessionState.instance.coordinator.showToast(text);
|
SessionState.instance.coordinator.notify(event);
|
||||||
}
|
|
||||||
|
|
||||||
/// Opens [url] in the default browser (no url_launcher dependency); false
|
|
||||||
/// when no browser could be started.
|
|
||||||
Future<bool> openInBrowser(String url) async {
|
|
||||||
try {
|
|
||||||
final ProcessResult r;
|
|
||||||
if (Platform.isWindows) {
|
|
||||||
r = await Process.run('rundll32', ['url.dll,FileProtocolHandler', url]);
|
|
||||||
} else if (Platform.isMacOS) {
|
|
||||||
r = await Process.run('open', [url]);
|
|
||||||
} else {
|
|
||||||
r = await Process.run('xdg-open', [url]);
|
|
||||||
}
|
|
||||||
return r.exitCode == 0;
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Puts [text] on the clipboard and confirms it with a toast ([done]:
|
/// Puts [text] on the clipboard and confirms it with a toast ([done]:
|
||||||
@@ -52,15 +33,15 @@ Future<void> copyToClipboard(String text, {String done = 'Copied'}) async {
|
|||||||
try {
|
try {
|
||||||
await Clipboard.setData(ClipboardData(text: text));
|
await Clipboard.setData(ClipboardData(text: text));
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
_toast("Couldn't copy");
|
_notify(const CopyFailed());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_toast(done);
|
_notify(Copied(done));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens [url] in the browser; when none opens, copies it instead and
|
/// Opens [url] in the browser; when none opens, copies it instead and
|
||||||
/// says so.
|
/// says so.
|
||||||
Future<void> openLink(String url) async {
|
Future<void> openLink(String url) async {
|
||||||
if (await openInBrowser(url)) return;
|
if (await SessionState.instance.ports.urls.open(url)) return;
|
||||||
await copyToClipboard(url, done: "Couldn't open a browser — link copied");
|
await copyToClipboard(url, done: "Couldn't open a browser — link copied");
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@ import 'package:sync_relay_core/core.dart';
|
|||||||
|
|
||||||
import '../update/update_controller.dart';
|
import '../update/update_controller.dart';
|
||||||
|
|
||||||
// "Update available" card (Home update indicator dialog). Kicks off the one
|
// "Update available" card (Settings → Show update dialog). Kicks off the one
|
||||||
// background check on first build; renders nothing until there is an
|
// background check on first build; renders nothing until there is an
|
||||||
// update to offer. Never shown over playback.
|
// update to offer. Never shown over playback.
|
||||||
class UpdateBanner extends StatefulWidget {
|
class UpdateBanner extends StatefulWidget {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
|
||||||
|
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
|
|
||||||
@@ -18,43 +17,37 @@ class WindowButtons extends StatefulWidget {
|
|||||||
State<WindowButtons> createState() => _WindowButtonsState();
|
State<WindowButtons> createState() => _WindowButtonsState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _WindowButtonsState extends State<WindowButtons> with WindowListener {
|
class _WindowButtonsState extends State<WindowButtons> {
|
||||||
|
final _shell = SessionState.instance.ports.shell;
|
||||||
bool _maximized = false;
|
bool _maximized = false;
|
||||||
|
late final void Function() _stopWatch;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
windowManager.addListener(this);
|
_stopWatch = _shell.watchMaximized((v) {
|
||||||
windowManager.isMaximized().then((v) {
|
if (mounted) setState(() => _maximized = v);
|
||||||
|
});
|
||||||
|
_shell.isMaximized().then((v) {
|
||||||
if (mounted) setState(() => _maximized = v);
|
if (mounted) setState(() => _maximized = v);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
windowManager.removeListener(this);
|
_stopWatch();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
void onWindowMaximize() {
|
|
||||||
if (mounted) setState(() => _maximized = true);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void onWindowUnmaximize() {
|
|
||||||
if (mounted) setState(() => _maximized = false);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _toggleMaximize() async {
|
Future<void> _toggleMaximize() async {
|
||||||
if (widget.onToggleFullscreen != null) {
|
if (widget.onToggleFullscreen != null) {
|
||||||
widget.onToggleFullscreen!();
|
widget.onToggleFullscreen!();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (await windowManager.isMaximized()) {
|
if (await _shell.isMaximized()) {
|
||||||
await windowManager.unmaximize();
|
await _shell.unmaximize();
|
||||||
} else {
|
} else {
|
||||||
await windowManager.maximize();
|
await _shell.maximize();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +56,7 @@ class _WindowButtonsState extends State<WindowButtons> with WindowListener {
|
|||||||
await SessionState.instance.coordinator.leave();
|
await SessionState.instance.coordinator.leave();
|
||||||
await SessionState.instance.stopServer();
|
await SessionState.instance.stopServer();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
await windowManager.close();
|
await _shell.requestClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -77,7 +70,7 @@ class _WindowButtonsState extends State<WindowButtons> with WindowListener {
|
|||||||
icon: Icons.remove,
|
icon: Icons.remove,
|
||||||
tooltip: 'Minimize',
|
tooltip: 'Minimize',
|
||||||
focusNode: focus,
|
focusNode: focus,
|
||||||
onPressed: () => windowManager.minimize(),
|
onPressed: () => _shell.minimize(),
|
||||||
),
|
),
|
||||||
_WinBtn(
|
_WinBtn(
|
||||||
icon: restored ? Icons.filter_none : Icons.crop_square,
|
icon: restored ? Icons.filter_none : Icons.crop_square,
|
||||||
@@ -131,3 +124,27 @@ class _WinBtn extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The title area: drag moves the window, a double click maximizes or
|
||||||
|
/// restores it (window_manager's DragToMoveArea, through [ShellPort]).
|
||||||
|
class WindowDragArea extends StatelessWidget {
|
||||||
|
final Widget child;
|
||||||
|
const WindowDragArea({super.key, required this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final shell = SessionState.instance.ports.shell;
|
||||||
|
return GestureDetector(
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
onPanStart: (_) => shell.startDragging(),
|
||||||
|
onDoubleTap: () async {
|
||||||
|
if (await shell.isMaximized()) {
|
||||||
|
await shell.unmaximize();
|
||||||
|
} else {
|
||||||
|
await shell.maximize();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
flutter_soloud
|
||||||
jni
|
jni
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
|
#include <stdio.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
#include "my_application.h"
|
#include "my_application.h"
|
||||||
|
|
||||||
int main(int argc, char** argv) {
|
int main(int argc, char** argv) {
|
||||||
g_autoptr(MyApplication) app = my_application_new();
|
MyApplication* app = my_application_new();
|
||||||
return g_application_run(G_APPLICATION(app), argc, argv);
|
int status = g_application_run(G_APPLICATION(app), argc, argv);
|
||||||
|
// The window is gone and the engine with it (the app disposed its mpv
|
||||||
|
// players and left before closing the window). Skip exit(): its handlers
|
||||||
|
// include NVIDIA's EGL teardown (libEGL_nvidia), which segfaults inside
|
||||||
|
// exit() for Flutter apps. Nothing is left to flush but stdio.
|
||||||
|
fflush(stdout);
|
||||||
|
fflush(stderr);
|
||||||
|
_exit(status);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
#include "my_application.h"
|
#include "my_application.h"
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
#include <flutter_linux/flutter_linux.h>
|
#include <flutter_linux/flutter_linux.h>
|
||||||
#ifdef GDK_WINDOWING_X11
|
#ifdef GDK_WINDOWING_X11
|
||||||
#include <gdk/gdkx.h>
|
#include <gdk/gdkx.h>
|
||||||
@@ -38,6 +41,28 @@ static void window_method_cb(FlMethodChannel* channel, FlMethodCall* call,
|
|||||||
fl_method_call_respond(call, response, nullptr);
|
fl_method_call_respond(call, response, nullptr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ends the process 15 s after the first close request whatever is still
|
||||||
|
// running: the app's close (lib/src/platform/app_shutdown.dart) is bounded
|
||||||
|
// to a few seconds, but a native teardown can block for good, and the
|
||||||
|
// process must never stay behind a hidden window.
|
||||||
|
static gpointer exit_watchdog(gpointer data) {
|
||||||
|
g_usleep(15 * G_USEC_PER_SEC);
|
||||||
|
fflush(stdout);
|
||||||
|
fflush(stderr);
|
||||||
|
_exit(0);
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
static gboolean window_delete_cb(GtkWidget* widget, GdkEvent* event,
|
||||||
|
gpointer user_data) {
|
||||||
|
static gboolean armed = FALSE;
|
||||||
|
if (!armed) {
|
||||||
|
armed = TRUE;
|
||||||
|
g_thread_unref(g_thread_new("exit-watchdog", exit_watchdog, nullptr));
|
||||||
|
}
|
||||||
|
return FALSE; // window_manager decides whether the window closes
|
||||||
|
}
|
||||||
|
|
||||||
static gboolean window_focus_in_cb(GtkWidget* widget, GdkEvent* event,
|
static gboolean window_focus_in_cb(GtkWidget* widget, GdkEvent* event,
|
||||||
gpointer user_data) {
|
gpointer user_data) {
|
||||||
gtk_window_set_urgency_hint(GTK_WINDOW(widget), FALSE);
|
gtk_window_set_urgency_hint(GTK_WINDOW(widget), FALSE);
|
||||||
@@ -111,6 +136,10 @@ static void my_application_activate(GApplication* application) {
|
|||||||
self);
|
self);
|
||||||
gtk_widget_realize(GTK_WIDGET(view));
|
gtk_widget_realize(GTK_WIDGET(view));
|
||||||
|
|
||||||
|
// Before the plugins: window_manager's delete-event handler stops the
|
||||||
|
// emission while the app holds the close.
|
||||||
|
g_signal_connect(window, "delete-event", G_CALLBACK(window_delete_cb),
|
||||||
|
nullptr);
|
||||||
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
|
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
|
||||||
|
|
||||||
g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new();
|
g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new();
|
||||||
|
|||||||
@@ -153,6 +153,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.1.2"
|
version: "0.1.2"
|
||||||
|
file:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file
|
||||||
|
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.1"
|
||||||
file_picker:
|
file_picker:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -227,6 +235,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
flutter_soloud:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_soloud
|
||||||
|
sha256: "1c297e52182c5e8fd447ae8430bf0b598445dc586e72863ceeef0416242b2ae8"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.2.1"
|
||||||
flutter_test:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -246,6 +262,14 @@ packages:
|
|||||||
url: "https://git.senkochan.moe/Foxyz/fox_utils.git"
|
url: "https://git.senkochan.moe/Foxyz/fox_utils.git"
|
||||||
source: git
|
source: git
|
||||||
version: "0.1.9"
|
version: "0.1.9"
|
||||||
|
glob:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: glob
|
||||||
|
sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
hive_ce:
|
hive_ce:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -495,6 +519,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.0"
|
version: "2.1.0"
|
||||||
|
native_toolchain_c:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: native_toolchain_c
|
||||||
|
sha256: "90eaad8059ebc2004a63810a4696ea9acdb02473e47ed3b6b3bafb100ac77e31"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.19.5"
|
||||||
objective_c:
|
objective_c:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -623,6 +655,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.0"
|
version: "7.0.0"
|
||||||
|
process:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: process
|
||||||
|
sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.0.6"
|
||||||
pub_semver:
|
pub_semver:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -786,14 +826,14 @@ packages:
|
|||||||
path: "../../packages/core"
|
path: "../../packages/core"
|
||||||
relative: true
|
relative: true
|
||||||
source: path
|
source: path
|
||||||
version: "1.1.2"
|
version: "1.2.0"
|
||||||
sync_relay_server:
|
sync_relay_server:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: "../../packages/server"
|
path: "../../packages/server"
|
||||||
relative: true
|
relative: true
|
||||||
source: path
|
source: path
|
||||||
version: "1.1.2"
|
version: "1.2.0"
|
||||||
synchronized:
|
synchronized:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -36,6 +36,15 @@ dependencies:
|
|||||||
git:
|
git:
|
||||||
url: https://git.senkochan.moe/Foxyz/leaf_widgets.git
|
url: https://git.senkochan.moe/Foxyz/leaf_widgets.git
|
||||||
ref: v0.1.0
|
ref: v0.1.0
|
||||||
|
# App sounds (sfx/, platform/audio/): SoLoud + miniaudio, built from
|
||||||
|
# source by its build hook (needs only a C++ toolchain).
|
||||||
|
flutter_soloud: ^5.2.1
|
||||||
|
# The sounds are WAVs made in memory: no Ogg/Opus/FLAC codecs, so the
|
||||||
|
# build hook downloads and bundles no Xiph libraries.
|
||||||
|
hooks:
|
||||||
|
user_defines:
|
||||||
|
flutter_soloud:
|
||||||
|
no_xiph_libs: true
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
import 'package:watch_together/src/controls/controls_probe.dart';
|
||||||
|
import 'package:watch_together/src/controls/model/controls_layout.dart';
|
||||||
|
import 'package:watch_together/src/controls/model/controls_model.dart';
|
||||||
|
import 'package:watch_together/src/controls/model/controls_prefs.dart';
|
||||||
|
import 'package:watch_together/src/controls/session_controls.dart';
|
||||||
|
import 'package:watch_together/src/playback/media_marks.dart';
|
||||||
|
import 'package:watch_together/src/state/session_state.dart';
|
||||||
|
import 'package:watch_together/src/state/settings.dart';
|
||||||
|
|
||||||
|
// The controls model and its layout spec: which controls the bar has, in
|
||||||
|
// which order, what goes to ⚙; and both skins drawing the same order.
|
||||||
|
|
||||||
|
const none = <OptionalControl>{};
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('bar layout', () {
|
||||||
|
test('play first, fullscreen last; no episode, no chapters: the base set', () {
|
||||||
|
final bar = barControls(const ControlsSnapshot(), none);
|
||||||
|
expect(bar.left, [BarControl.playPause, BarControl.volume, BarControl.time]);
|
||||||
|
expect(bar.right, [BarControl.subtitles, BarControl.audio, BarControl.settings, BarControl.chat, BarControl.fullscreen]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('episodes and chapters slot in after play, in the spec order', () {
|
||||||
|
final bar = barControls(
|
||||||
|
const ControlsSnapshot(previousLabel: 'S1E6', nextLabel: 'S1E8', hasChapters: true, hasDuration: true), none);
|
||||||
|
expect(bar.left, [
|
||||||
|
BarControl.playPause,
|
||||||
|
BarControl.previous,
|
||||||
|
BarControl.next,
|
||||||
|
BarControl.chapterPrevious,
|
||||||
|
BarControl.chapterNext,
|
||||||
|
BarControl.volume,
|
||||||
|
BarControl.time,
|
||||||
|
]);
|
||||||
|
expect(barControls(const ControlsSnapshot(nextLabel: 'Up'), none).left,
|
||||||
|
[BarControl.playPause, BarControl.next, BarControl.volume, BarControl.time]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hidden optional buttons leave the bar and show in ⚙', () {
|
||||||
|
const s = ControlsSnapshot(hasChapters: true, hasDuration: true);
|
||||||
|
final hidden = OptionalControl.values.toSet();
|
||||||
|
final bar = barControls(s, hidden);
|
||||||
|
expect(bar.left, [BarControl.playPause, BarControl.volume, BarControl.time]);
|
||||||
|
expect(bar.right, [BarControl.settings, BarControl.fullscreen]);
|
||||||
|
expect(menuActions(s, hidden), containsAllInOrder([ControlAction.subtitles, ControlAction.audio, ControlAction.chat]));
|
||||||
|
final shown = menuActions(s, none);
|
||||||
|
expect(shown, isNot(contains(ControlAction.subtitles)));
|
||||||
|
expect(shown, isNot(contains(ControlAction.chat)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('⚙ menu: what applies, in a fixed order', () {
|
||||||
|
expect(menuActions(const ControlsSnapshot(), none), [
|
||||||
|
ControlAction.shaders,
|
||||||
|
ControlAction.changeVideo,
|
||||||
|
ControlAction.reload,
|
||||||
|
ControlAction.markAllReady,
|
||||||
|
ControlAction.markProblem,
|
||||||
|
ControlAction.playerSettings,
|
||||||
|
ControlAction.leave,
|
||||||
|
]);
|
||||||
|
final all = menuActions(const ControlsSnapshot(hasChapters: true, hasDuration: true, hasLink: true), none);
|
||||||
|
expect(all.first, ControlAction.chapters);
|
||||||
|
expect(all, containsAllInOrder([ControlAction.skipForward, ControlAction.copyLink]));
|
||||||
|
expect(menuActions(const ControlsSnapshot(solo: true), none), isNot(contains(ControlAction.markAllReady)),
|
||||||
|
reason: 'nobody to mark ready');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('play / pause follows the session: playing, ready and waiting, not ready', () {
|
||||||
|
expect(controlIcon(BarControl.playPause, const ControlsSnapshot(play: PlayState.playing)), Icons.pause_rounded);
|
||||||
|
expect(controlIcon(BarControl.playPause, const ControlsSnapshot(play: PlayState.paused)), Icons.play_arrow_rounded);
|
||||||
|
expect(controlIcon(BarControl.playPause, const ControlsSnapshot(play: PlayState.waiting)), Icons.check_rounded);
|
||||||
|
expect(controlTooltip(BarControl.playPause, const ControlsSnapshot(play: PlayState.playing)),
|
||||||
|
'Pause for everyone (Space)');
|
||||||
|
expect(controlTooltip(BarControl.playPause, const ControlsSnapshot(solo: true)), 'Play (Space)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('episode buttons: the label and the keys', () {
|
||||||
|
const s = ControlsSnapshot(previousLabel: 'S1E6 Before', nextLabel: 'S1E7 Battle with the Heroes');
|
||||||
|
expect(controlTooltip(BarControl.next, s), 'Next: S1E7 Battle with the Heroes (> or Shift+N)');
|
||||||
|
expect(controlTooltip(BarControl.previous, s), 'Previous: S1E6 Before (< or Shift+P)');
|
||||||
|
expect(controlTooltip(BarControl.next, const ControlsSnapshot(nextLabel: 'Clip', nextIsUpNext: true)),
|
||||||
|
'Up next: Clip (> or Shift+N)');
|
||||||
|
expect(controlEnabled(BarControl.chapterNext, const ControlsSnapshot(hasChapters: true)), isFalse,
|
||||||
|
reason: 'no duration yet');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clock: elapsed / total, or the time left', () {
|
||||||
|
const p = Duration(minutes: 6), d = Duration(minutes: 24);
|
||||||
|
expect(clockText(p, d, TimeDisplay.elapsed), '6:00 / 24:00');
|
||||||
|
expect(clockText(p, d, TimeDisplay.remaining), '-18:00');
|
||||||
|
expect(clockText(p, Duration.zero, TimeDisplay.remaining), '6:00 / 0:00', reason: 'length unknown');
|
||||||
|
expect(TimeDisplay.elapsed.toggled, TimeDisplay.remaining);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('both skins draw the model', () {
|
||||||
|
final s = AppSettings.instance;
|
||||||
|
final coord = SessionState.instance.coordinator;
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
s.controlsSkin.value = ControlsSkin.standard;
|
||||||
|
s.hiddenControls.value = const {};
|
||||||
|
s.progressLine.value = true;
|
||||||
|
coord.playback.chapters.value = const [];
|
||||||
|
coord.playback.duration.value = Duration.zero;
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<PlayerControlsModel> pump(WidgetTester tester, ControlsSkin skin) async {
|
||||||
|
tester.view.physicalSize = const Size(1280, 720);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
s.controlsSkin.value = skin;
|
||||||
|
final model = PlayerControlsModel(
|
||||||
|
coord: coord,
|
||||||
|
fullscreen: signal(false),
|
||||||
|
chatOpen: signal(false),
|
||||||
|
unread: signal(0),
|
||||||
|
onToggleFullscreen: () {},
|
||||||
|
onToggleChat: () {},
|
||||||
|
onChangeVideo: () {},
|
||||||
|
onCollapse: () {},
|
||||||
|
onLeave: () {},
|
||||||
|
);
|
||||||
|
final shown = ValueNotifier(true);
|
||||||
|
final hold = ControlsHold();
|
||||||
|
addTearDown(() {
|
||||||
|
model.dispose();
|
||||||
|
shown.dispose();
|
||||||
|
hold.dispose();
|
||||||
|
});
|
||||||
|
await tester.pumpWidget(MaterialApp(
|
||||||
|
theme: ThemeData.dark(),
|
||||||
|
home: Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
body: SessionControls(model: model, shown: shown, hold: hold, onDoubleClick: () {}),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
await tester.pump();
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bar's controls left to right, by their keys.
|
||||||
|
List<BarControl> drawn(WidgetTester tester) {
|
||||||
|
final found = <(double, BarControl)>[];
|
||||||
|
for (final c in BarControl.values) {
|
||||||
|
final f = find.byKey(ValueKey('control-${c.name}'));
|
||||||
|
if (f.evaluate().isEmpty) continue;
|
||||||
|
final r = tester.getRect(f);
|
||||||
|
if (r.width > 0) found.add((r.left, c));
|
||||||
|
}
|
||||||
|
found.sort((a, b) => a.$1.compareTo(b.$1));
|
||||||
|
return [for (final (_, c) in found) c];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final skin in ControlsSkin.values) {
|
||||||
|
testWidgets('${skin.label}: the layout\'s order, play leftmost, fullscreen rightmost', (tester) async {
|
||||||
|
coord.playback.duration.value = const Duration(minutes: 24);
|
||||||
|
coord.playback.chapters.value = chaptersFromStarts([
|
||||||
|
('A', Duration.zero),
|
||||||
|
('B', const Duration(minutes: 2)),
|
||||||
|
], const Duration(minutes: 24));
|
||||||
|
final model = await pump(tester, skin);
|
||||||
|
final bar = model.bar.value;
|
||||||
|
final want = [
|
||||||
|
...bar.left,
|
||||||
|
...bar.right,
|
||||||
|
].where((c) => skin == ControlsSkin.standard || c != BarControl.time).toList();
|
||||||
|
expect(drawn(tester), want);
|
||||||
|
expect(want.first, BarControl.playPause);
|
||||||
|
expect(want.last, BarControl.fullscreen);
|
||||||
|
expect(want, contains(BarControl.chapterNext));
|
||||||
|
|
||||||
|
s.setControlShown(OptionalControl.chat, false);
|
||||||
|
await tester.pump();
|
||||||
|
expect(drawn(tester), isNot(contains(BarControl.chat)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('⚙ lists the rest', (tester) async {
|
||||||
|
await pump(tester, ControlsSkin.minimal);
|
||||||
|
await tester.tap(find.byKey(const ValueKey('control-settings')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
for (final a in [ControlAction.reload, ControlAction.markProblem, ControlAction.playerSettings]) {
|
||||||
|
expect(find.byKey(ValueKey('controls-menu-${a.name}')), findsOneWidget, reason: a.name);
|
||||||
|
}
|
||||||
|
expect(find.byKey(const ValueKey('controls-menu-chapters')), findsNothing, reason: 'no chapters');
|
||||||
|
await tester.tapAt(const Offset(5, 5));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Minimal hidden: the timeline stays as a line unless switched off', (tester) async {
|
||||||
|
s.controlsHideSecs.value = 2;
|
||||||
|
addTearDown(() => s.controlsHideSecs.value = defaultControlsHideSecs);
|
||||||
|
await pump(tester, ControlsSkin.minimal);
|
||||||
|
double opacity() => tester.widget<AnimatedOpacity>(find.byKey(const ValueKey('minimal-timeline-wrap'))).opacity;
|
||||||
|
await tester.pump(const Duration(seconds: 3));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(opacity(), 1, reason: 'hidden, line on');
|
||||||
|
s.progressLine.value = false;
|
||||||
|
await tester.pump();
|
||||||
|
expect(opacity(), 0, reason: 'hidden, line off');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Standard hidden: a thin progress line unless switched off', (tester) async {
|
||||||
|
await pump(tester, ControlsSkin.standard);
|
||||||
|
expect(find.byKey(const ValueKey('progress-line-standard')), findsNothing, reason: 'controls up');
|
||||||
|
await tester.pump(const Duration(seconds: 4));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const ValueKey('progress-line-standard')), findsOneWidget);
|
||||||
|
s.progressLine.value = false;
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byKey(const ValueKey('progress-line-standard')), findsNothing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// restructure: rewrite in step 1 (Connection + Identity + Presence): reconnect,
|
||||||
|
// resume and ready restore; "feed and chat toasts" in step 2 (Notifier).
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// restructure: rewrite in step 2 (Status + Notifier): delivery lines become
|
||||||
|
// part of the one computed status (and the source-gone half of P2).
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// restructure: rewrite in step 4 (Library + Media identity): series
|
||||||
|
// grouping on the page follows L1/L2.
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// restructure: rewrite in step 4 (Library + Media identity): groupBySeries
|
||||||
|
// becomes L1 local grouping; the scan and classify tests stay.
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
// restructure: rewrite in step 3 (Your server): hosted recents and the host
|
||||||
|
// card; the rest stays.
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
@@ -7,6 +10,7 @@ import 'package:watch_together/src/schedule/ui/schedule_page.dart';
|
|||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
import 'package:watch_together/src/screens/home/health_probe.dart';
|
import 'package:watch_together/src/screens/home/health_probe.dart';
|
||||||
import 'package:watch_together/src/screens/home/media_picker.dart';
|
import 'package:watch_together/src/screens/home/media_picker.dart';
|
||||||
|
import 'package:watch_together/src/screens/home/update_indicator.dart';
|
||||||
import 'package:watch_together/src/shell/app_shell.dart';
|
import 'package:watch_together/src/shell/app_shell.dart';
|
||||||
import 'package:watch_together/src/shell/library_link.dart' show soloPlayHint;
|
import 'package:watch_together/src/shell/library_link.dart' show soloPlayHint;
|
||||||
import 'package:watch_together/src/state/session_state.dart';
|
import 'package:watch_together/src/state/session_state.dart';
|
||||||
@@ -58,13 +62,18 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('chat pop-ups default to All', () {
|
||||||
|
expect(AppSettings().chatPopups.value, ChatPopups.all);
|
||||||
|
});
|
||||||
|
|
||||||
test('hwdec value follows mode', () {
|
test('hwdec value follows mode', () {
|
||||||
final s = AppSettings();
|
final s = AppSettings();
|
||||||
expect(s.hwdec.value, 'auto');
|
expect(s.hwdec.value, autoHwdec);
|
||||||
|
expect(autoHwdec, Platform.isWindows ? 'auto-copy' : 'auto');
|
||||||
s.setHwdecMode(HwdecMode.off);
|
s.setHwdecMode(HwdecMode.off);
|
||||||
expect(s.hwdec.value, 'no');
|
expect(s.hwdec.value, 'no');
|
||||||
s.setHwdecMode(HwdecMode.custom);
|
s.setHwdecMode(HwdecMode.custom);
|
||||||
expect(s.hwdec.value, 'auto');
|
expect(s.hwdec.value, autoHwdec);
|
||||||
s.setHwdecCustom(' vaapi ');
|
s.setHwdecCustom(' vaapi ');
|
||||||
expect(s.hwdec.value, 'vaapi');
|
expect(s.hwdec.value, 'vaapi');
|
||||||
});
|
});
|
||||||
@@ -150,7 +159,7 @@ void main() {
|
|||||||
expect(find.text('Hardware decoding'), findsOneWidget);
|
expect(find.text('Hardware decoding'), findsOneWidget);
|
||||||
await tester.tap(find.byKey(const ValueKey('settings-updates')));
|
await tester.tap(find.byKey(const ValueKey('settings-updates')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Check for updates on start'), findsOneWidget);
|
expect(find.text('Check for updates automatically'), findsOneWidget);
|
||||||
await tester.tap(find.byKey(const ValueKey('settings-shortcuts')));
|
await tester.tap(find.byKey(const ValueKey('settings-shortcuts')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Toggle ready'), findsOneWidget);
|
expect(find.text('Toggle ready'), findsOneWidget);
|
||||||
@@ -288,4 +297,53 @@ void main() {
|
|||||||
expect(tester.takeException(), isNull, reason: source);
|
expect(tester.takeException(), isNull, reason: source);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('update bar', () {
|
||||||
|
const m = UpdateManifest(
|
||||||
|
version: '1.2.1', protocol: 1, url: 'http://x/a.tgz', notes: 'Faster joins');
|
||||||
|
Future<UpdateController> pump(WidgetTester tester, UpdateStatus s,
|
||||||
|
{String current = '1.2.0'}) async {
|
||||||
|
final c = UpdateController(
|
||||||
|
currentVersion: current,
|
||||||
|
executablePath: '/nowhere/watch_together',
|
||||||
|
restart: (_, _) async {});
|
||||||
|
c.status.value = s;
|
||||||
|
await tester.pumpWidget(MaterialApp(
|
||||||
|
home: Scaffold(body: Column(children: [UpdateBar(controller: c)]))));
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('says what is ready, the version running, Later hides it',
|
||||||
|
(tester) async {
|
||||||
|
await pump(tester,
|
||||||
|
const UpdateStatus(UpdatePhase.available, manifest: m, canInstall: true));
|
||||||
|
expect(find.text('Starsync 1.2.1 is ready'), findsOneWidget);
|
||||||
|
expect(find.text('You are running 1.2.0 · Faster joins'), findsOneWidget);
|
||||||
|
expect(find.text('Update and restart'), findsOneWidget);
|
||||||
|
await tester.tap(find.byKey(const ValueKey('update-later')));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byKey(const ValueKey('update-bar')), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a copy that cannot update itself says it is older',
|
||||||
|
(tester) async {
|
||||||
|
await pump(tester, const UpdateStatus(UpdatePhase.available, manifest: m));
|
||||||
|
expect(find.text('This copy of Starsync is older than the latest release (1.2.1)'),
|
||||||
|
findsOneWidget);
|
||||||
|
expect(find.text('Update and restart'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a test build is not told which version it runs',
|
||||||
|
(tester) async {
|
||||||
|
await pump(tester,
|
||||||
|
const UpdateStatus(UpdatePhase.available, manifest: m, canInstall: true),
|
||||||
|
current: '1.2.1-dev.2');
|
||||||
|
expect(find.text('Faster joins'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('nothing to install: no bar', (tester) async {
|
||||||
|
await pump(tester, const UpdateStatus(UpdatePhase.idle));
|
||||||
|
expect(find.byKey(const ValueKey('update-bar')), findsNothing);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// restructure: rewrite in step 5 (Watch tracking): the progress-reporting
|
||||||
|
// checks; the guest-through-the-relay flow stays.
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// restructure: rewrite in step 5 (What's next + Watch tracking): the
|
||||||
|
// "progress" and "episodes" groups (reporting, auto-advance swapper).
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
@@ -364,11 +366,6 @@ void main() {
|
|||||||
expect(media.jellyfin!.subtitle, 'Black Summoner · S1E7');
|
expect(media.jellyfin!.subtitle, 'Black Summoner · S1E7');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the floating prompt starts two minutes before the end', () {
|
|
||||||
expect(nextPromptStart(null, const Duration(minutes: 24)), const Duration(minutes: 22));
|
|
||||||
expect(nextPromptStart(null, const Duration(seconds: 90)), Duration.zero);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('one auto-advance swapper: the lowest id among members with their own account', () {
|
test('one auto-advance swapper: the lowest id among members with their own account', () {
|
||||||
final members = [
|
final members = [
|
||||||
member('m10', 'Ann', jf: {fixtureServerId: true}),
|
member('m10', 'Ann', jf: {fixtureServerId: true}),
|
||||||
@@ -501,18 +498,6 @@ void main() {
|
|||||||
const JellyfinMedia(
|
const JellyfinMedia(
|
||||||
serverId: fixtureServerId, baseUrl: 'b', itemId: nextEpisodeId, title: 'Battle with the Heroes'));
|
serverId: fixtureServerId, baseUrl: 'b', itemId: nextEpisodeId, title: 'Battle with the Heroes'));
|
||||||
|
|
||||||
testWidgets('next / previous buttons: tooltip, hidden without an episode', (tester) async {
|
|
||||||
var pressed = 0;
|
|
||||||
await tester.pumpWidget(app(Row(children: [
|
|
||||||
EpisodeSkipButton(episode: null, next: false, onPressed: () {}),
|
|
||||||
EpisodeSkipButton(episode: next, next: true, onPressed: () => pressed++),
|
|
||||||
])));
|
|
||||||
expect(find.byKey(const ValueKey('episode-previous')), findsNothing);
|
|
||||||
expect(find.byTooltip('Next: S1E7 Battle with the Heroes (> or Shift+N)'), findsOneWidget);
|
|
||||||
await tester.tap(find.byKey(const ValueKey('episode-next')));
|
|
||||||
expect(pressed, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('end card: Next prompt, countdown with Cancel', (tester) async {
|
testWidgets('end card: Next prompt, countdown with Cancel', (tester) async {
|
||||||
var play = 0, cancel = 0;
|
var play = 0, cancel = 0;
|
||||||
await tester.pumpWidget(app(EndOfEpisodeCard(
|
await tester.pumpWidget(app(EndOfEpisodeCard(
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// restructure: rewrite in step 3 (Your server): the "solo" and "hosting"
|
||||||
|
// tests (D9: no solo mode); the rest stays.
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:watch_together/src/widgets/media_input.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
test('http url routes to linkUrl', () {
|
|
||||||
expect(parseMediaInput('http://example.com/v.mp4'),
|
|
||||||
(mediaPath: '', linkUrl: 'http://example.com/v.mp4'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('https url routes to linkUrl', () {
|
|
||||||
expect(parseMediaInput('https://example.com/v.mp4'),
|
|
||||||
(mediaPath: '', linkUrl: 'https://example.com/v.mp4'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('scheme match is case-insensitive and trimmed', () {
|
|
||||||
expect(parseMediaInput(' HTTPS://example.com/v.mp4 '),
|
|
||||||
(mediaPath: '', linkUrl: 'HTTPS://example.com/v.mp4'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('file path routes to mediaPath', () {
|
|
||||||
expect(parseMediaInput('/home/u/Videos/chiyo.mp4'),
|
|
||||||
(mediaPath: '/home/u/Videos/chiyo.mp4', linkUrl: ''));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('empty input stays empty', () {
|
|
||||||
expect(parseMediaInput(' '), (mediaPath: '', linkUrl: ''));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('non-http scheme stays a path', () {
|
|
||||||
expect(parseMediaInput('ftp://example.com/v.mp4'),
|
|
||||||
(mediaPath: 'ftp://example.com/v.mp4', linkUrl: ''));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
import 'package:flutter/gestures.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:watch_together/src/controls/frame.dart';
|
||||||
|
import 'package:watch_together/src/controls/minimal/style.dart';
|
||||||
|
import 'package:watch_together/src/controls/minimal/timeline.dart';
|
||||||
|
import 'package:watch_together/src/controls/model/controls_prefs.dart';
|
||||||
|
import 'package:watch_together/src/controls/volume.dart';
|
||||||
|
import 'package:watch_together/src/playback/media_marks.dart';
|
||||||
|
import 'package:watch_together/src/shell/settings_tab.dart';
|
||||||
|
import 'package:watch_together/src/state/settings.dart';
|
||||||
|
import 'package:watch_together/src/update/update_controller.dart';
|
||||||
|
import 'package:watch_together/src/widgets/app_menu.dart';
|
||||||
|
|
||||||
|
// The Minimal skin's pieces and the shared controls frame (never the
|
||||||
|
// session screen or a media_kit `Video`).
|
||||||
|
|
||||||
|
Duration m(int min, [int sec = 0]) => Duration(minutes: min, seconds: sec);
|
||||||
|
|
||||||
|
final chapters = chaptersFromStarts([
|
||||||
|
('Prologue', Duration.zero),
|
||||||
|
('Opening', m(0, 30)),
|
||||||
|
('Part A', m(2)),
|
||||||
|
('Ending', m(20)),
|
||||||
|
('Part B', m(21, 30)),
|
||||||
|
], m(24));
|
||||||
|
|
||||||
|
Widget host(Widget child, {double width = 800}) => MaterialApp(
|
||||||
|
theme: ThemeData.dark(),
|
||||||
|
home: Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
body: Align(
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 40),
|
||||||
|
child: SizedBox(width: width, child: child),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
String text(WidgetTester tester, String key) => tester.widget<Text>(find.byKey(ValueKey(key))).data!;
|
||||||
|
|
||||||
|
MinimalTimelinePainter painter(WidgetTester tester) => tester
|
||||||
|
.widget<CustomPaint>(find.descendant(
|
||||||
|
of: find.byKey(const ValueKey('minimal-timeline')), matching: find.byType(CustomPaint)))
|
||||||
|
.painter! as MinimalTimelinePainter;
|
||||||
|
|
||||||
|
Future<TestGesture> mouseAt(WidgetTester tester, Offset at) async {
|
||||||
|
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||||
|
await mouse.addPointer(location: at);
|
||||||
|
addTearDown(mouse.removePointer);
|
||||||
|
return mouse;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('timeline math', () {
|
||||||
|
test('missing part: from the cache end (or the position) to the end', () {
|
||||||
|
expect(uncachedFrom(position: m(6), buffered: m(9), duration: m(24)), 9 / 24);
|
||||||
|
expect(uncachedFrom(position: m(6), buffered: m(5), duration: m(24)), 6 / 24, reason: 'stale cache end');
|
||||||
|
expect(uncachedFrom(position: m(6), buffered: Duration.zero, duration: m(24)), isNull, reason: 'unknown');
|
||||||
|
expect(uncachedFrom(position: m(6), buffered: m(24), duration: m(24)), isNull, reason: 'cached to the end');
|
||||||
|
expect(uncachedFrom(position: m(6), buffered: m(9), duration: Duration.zero), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cache label: seconds ahead, under 60 s, not when cached to the end', () {
|
||||||
|
expect(cacheLabel(position: m(6), buffered: m(6, 30), duration: m(24)), '30s');
|
||||||
|
expect(cacheLabel(position: m(6), buffered: m(6), duration: m(24)), '0s');
|
||||||
|
expect(cacheLabel(position: m(6), buffered: m(5), duration: m(24)), '0s');
|
||||||
|
expect(cacheLabel(position: m(6), buffered: m(7), duration: m(24)), isNull, reason: 'a minute or more');
|
||||||
|
expect(cacheLabel(position: m(23, 40), buffered: m(24), duration: m(24)), isNull, reason: 'eof cached');
|
||||||
|
expect(cacheLabel(position: m(6), buffered: Duration.zero, duration: m(24)), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('band size: the full band shown, the line hidden, scaled', () {
|
||||||
|
expect(minimalBandSize(1, 1), MinimalTimelineSpec.size);
|
||||||
|
expect(minimalBandSize(0, 1), MinimalTimelineSpec.lineSize);
|
||||||
|
expect(minimalBandSize(1, 1.25), MinimalTimelineSpec.size * 1.25);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('timeline', () {
|
||||||
|
testWidgets('played part, elapsed left, remaining right, cache label', (tester) async {
|
||||||
|
await tester.pumpWidget(host(MinimalTimeline(
|
||||||
|
position: m(6), duration: m(24), buffered: m(6, 30), chapters: chapters, onSeek: (_) {})));
|
||||||
|
final bar = tester.getRect(find.byKey(const ValueKey('minimal-timeline')));
|
||||||
|
expect(bar.width, 800);
|
||||||
|
expect(bar.height, MinimalTimelineSpec.size);
|
||||||
|
final p = painter(tester);
|
||||||
|
expect(p.progress, 0.25);
|
||||||
|
expect(p.lineX(800), 200);
|
||||||
|
expect(p.visibility, 1);
|
||||||
|
expect(p.uncached, 6.5 / 24);
|
||||||
|
expect(p.elapsed, '6:00');
|
||||||
|
expect(p.right, '-18:00');
|
||||||
|
expect(p.cache, '30s');
|
||||||
|
expect(p.textOpacity, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the total instead of the time left', (tester) async {
|
||||||
|
await tester.pumpWidget(host(MinimalTimeline(
|
||||||
|
position: m(6), duration: m(24), remaining: false, onSeek: (_) {})));
|
||||||
|
expect(painter(tester).right, '24:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a press on the right-hand time switches it, no seek', (tester) async {
|
||||||
|
final seeks = <Duration>[];
|
||||||
|
var toggled = 0;
|
||||||
|
await tester.pumpWidget(host(MinimalTimeline(
|
||||||
|
position: m(6), duration: m(24), onSeek: seeks.add, onToggleTime: () => toggled++)));
|
||||||
|
final bar = tester.getRect(find.byKey(const ValueKey('minimal-timeline')));
|
||||||
|
await tester.tapAt(Offset(bar.right - 20, bar.center.dy));
|
||||||
|
expect(toggled, 1);
|
||||||
|
expect(seeks, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a mark at each chapter start', (tester) async {
|
||||||
|
await tester.pumpWidget(host(MinimalTimeline(position: m(1), duration: m(24), chapters: chapters, onSeek: (_) {})));
|
||||||
|
expect(painter(tester).chapters, [for (final c in chapters) c.start.inSeconds / m(24).inSeconds]);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('hover: chapter title over the time, above the band', (tester) async {
|
||||||
|
await tester.pumpWidget(host(MinimalTimeline(position: m(1), duration: m(24), chapters: chapters, onSeek: (_) {})));
|
||||||
|
final bar = tester.getRect(find.byKey(const ValueKey('minimal-timeline')));
|
||||||
|
final mouse = await mouseAt(tester, Offset.zero);
|
||||||
|
// 1:51 of 24:00: inside "Opening".
|
||||||
|
await mouse.moveTo(Offset(bar.left + bar.width * 111 / (24 * 60) + 0.2, bar.center.dy));
|
||||||
|
await tester.pump();
|
||||||
|
expect(text(tester, 'minimal-hover-chapter'), 'Opening');
|
||||||
|
expect(text(tester, 'minimal-hover-time'), '1:51');
|
||||||
|
final title = tester.getRect(find.byKey(const ValueKey('minimal-hover-chapter')));
|
||||||
|
final time = tester.getRect(find.byKey(const ValueKey('minimal-hover-time')));
|
||||||
|
expect(title.bottom, lessThanOrEqualTo(time.top), reason: 'title above');
|
||||||
|
expect(time.bottom, lessThan(bar.top), reason: 'above the band');
|
||||||
|
expect(painter(tester).hoverX, isNotNull);
|
||||||
|
await mouse.moveTo(Offset(bar.left + bar.width * (10 / 24), bar.center.dy));
|
||||||
|
await tester.pump();
|
||||||
|
expect(text(tester, 'minimal-hover-chapter'), 'Part A');
|
||||||
|
await mouse.moveTo(Offset(bar.left, bar.top - 200));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byKey(const ValueKey('minimal-hover-time')), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a press on a hovered chapter mark jumps to its chapter', (tester) async {
|
||||||
|
final seeks = <Duration>[];
|
||||||
|
await tester.pumpWidget(host(MinimalTimeline(position: m(1), duration: m(24), chapters: chapters, onSeek: seeks.add)));
|
||||||
|
final bar = tester.getRect(find.byKey(const ValueKey('minimal-timeline')));
|
||||||
|
final at = Offset(bar.left + bar.width * 2 / 24 + 3, bar.top + 2);
|
||||||
|
final mouse = await mouseAt(tester, at);
|
||||||
|
await mouse.moveTo(at);
|
||||||
|
await tester.pump();
|
||||||
|
expect(painter(tester).hoveredChapter, 2);
|
||||||
|
expect(text(tester, 'minimal-hover-time'), '2:00');
|
||||||
|
await mouse.down(at);
|
||||||
|
await mouse.up();
|
||||||
|
await tester.pump();
|
||||||
|
expect(seeks, [m(2)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('hidden controls: the progress line, no text, no input', (tester) async {
|
||||||
|
final seeks = <Duration>[];
|
||||||
|
await tester.pumpWidget(host(MinimalTimeline(
|
||||||
|
position: m(12), duration: m(24), buffered: m(14), chapters: chapters, expanded: false, onSeek: seeks.add)));
|
||||||
|
final bar = tester.getRect(find.byKey(const ValueKey('minimal-timeline')));
|
||||||
|
expect(bar.height, MinimalTimeline.collapsedHeight);
|
||||||
|
final p = painter(tester);
|
||||||
|
expect(p.progress, 0.5);
|
||||||
|
expect(p.visibility, 0);
|
||||||
|
expect(p.textOpacity, 0);
|
||||||
|
final mouse = await mouseAt(tester, Offset.zero);
|
||||||
|
await mouse.moveTo(bar.center);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byKey(const ValueKey('minimal-hover-time')), findsNothing);
|
||||||
|
await tester.tapAt(bar.center);
|
||||||
|
expect(seeks, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('press seeks, every drag move seeks, release holds the target', (tester) async {
|
||||||
|
final seeks = <Duration>[];
|
||||||
|
await tester.pumpWidget(host(MinimalTimeline(position: m(1), duration: m(24), chapters: chapters, onSeek: seeks.add)));
|
||||||
|
final bar = tester.getRect(find.byKey(const ValueKey('minimal-timeline')));
|
||||||
|
final g0 = await tester.startGesture(Offset(bar.left + bar.width / 4, bar.center.dy), kind: PointerDeviceKind.mouse);
|
||||||
|
expect(seeks, [m(6)], reason: 'the press seeks');
|
||||||
|
await g0.moveTo(Offset(bar.left + bar.width / 2, bar.center.dy));
|
||||||
|
await tester.pump();
|
||||||
|
expect(painter(tester).elapsed, formatClock(seeks.last), reason: 'follows the drag');
|
||||||
|
await g0.moveTo(Offset(bar.left + bar.width * 3 / 4, bar.center.dy));
|
||||||
|
await g0.up();
|
||||||
|
await tester.pump();
|
||||||
|
expect(seeks, hasLength(3), reason: 'release at the last move: no repeat');
|
||||||
|
expect(painter(tester).progress, 0.75, reason: 'shows the target until the position catches up');
|
||||||
|
await tester.pump(const Duration(seconds: 3));
|
||||||
|
expect(painter(tester).progress, 1 / 24, reason: 'never confirmed: back to the position');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('controls frame', () {
|
||||||
|
Widget frame({Duration hideAfter = const Duration(seconds: 3), Widget? child}) => MaterialApp(
|
||||||
|
theme: ThemeData.dark(),
|
||||||
|
home: Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
body: ControlsFrame(
|
||||||
|
hideAfter: hideAfter,
|
||||||
|
builder: (context, visible) => Stack(children: [
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||||
|
Opacity(opacity: visible ? 1 : 0, child: child ?? const SizedBox(height: 40)),
|
||||||
|
MinimalTimeline(
|
||||||
|
position: m(6), duration: m(24), chapters: chapters, expanded: visible, onSeek: (_) {}),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
bool up(WidgetTester tester) => tester.state<ControlsFrameState>(find.byType(ControlsFrame)).visible;
|
||||||
|
|
||||||
|
testWidgets('hide after the delay, mouse brings them back', (tester) async {
|
||||||
|
await tester.pumpWidget(frame());
|
||||||
|
expect(up(tester), isTrue);
|
||||||
|
await tester.pump(const Duration(seconds: 4));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(up(tester), isFalse);
|
||||||
|
expect(tester.getRect(find.byKey(const ValueKey('minimal-timeline'))).height, MinimalTimeline.collapsedHeight,
|
||||||
|
reason: 'still visible as a thin line');
|
||||||
|
final mouse = await mouseAt(tester, const Offset(400, 200));
|
||||||
|
await mouse.moveTo(const Offset(420, 220));
|
||||||
|
await tester.pump();
|
||||||
|
expect(up(tester), isTrue);
|
||||||
|
await tester.pumpAndSettle(const Duration(milliseconds: 50));
|
||||||
|
expect(tester.getRect(find.byKey(const ValueKey('minimal-timeline'))).height, MinimalTimelineSpec.size,
|
||||||
|
reason: 'grown back (animated)');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a long drag on the timeline keeps the controls up', (tester) async {
|
||||||
|
await tester.pumpWidget(frame());
|
||||||
|
final bar = tester.getRect(find.byKey(const ValueKey('minimal-timeline')));
|
||||||
|
final g = await tester.startGesture(Offset(bar.left + 100, bar.center.dy), kind: PointerDeviceKind.mouse);
|
||||||
|
for (var i = 0; i < 10; i++) {
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
}
|
||||||
|
await g.moveTo(Offset(bar.left + 300, bar.center.dy));
|
||||||
|
await tester.pump(const Duration(seconds: 5));
|
||||||
|
expect(up(tester), isTrue, reason: 'held while dragging');
|
||||||
|
await g.up();
|
||||||
|
await g.removePointer();
|
||||||
|
await tester.pump(const Duration(seconds: 4));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(up(tester), isFalse, reason: 'hides again after release');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('an open app menu keeps the controls up', (tester) async {
|
||||||
|
await tester.pumpWidget(frame(
|
||||||
|
child: Builder(
|
||||||
|
builder: (context) => TextButton(
|
||||||
|
key: const ValueKey('open'),
|
||||||
|
onPressed: () => showAppMenu<int>(context, items: const [AppMenuItem(value: 1, label: 'One')]),
|
||||||
|
child: const Text('open'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
await tester.tap(find.byKey(const ValueKey('open')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('One'), findsOneWidget);
|
||||||
|
await tester.pump(const Duration(seconds: 10));
|
||||||
|
expect(up(tester), isTrue);
|
||||||
|
await tester.tapAt(const Offset(5, 5)); // outside: dismiss
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.pump(const Duration(seconds: 4));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(up(tester), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('double-click on the picture, not on the bars', (tester) async {
|
||||||
|
var doubles = 0;
|
||||||
|
await tester.pumpWidget(MaterialApp(
|
||||||
|
home: ControlsFrame(
|
||||||
|
onDoubleClick: () => doubles++,
|
||||||
|
barBottom: 100,
|
||||||
|
builder: (context, visible) => const SizedBox.expand(),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
Future<void> twice(Offset at) async {
|
||||||
|
await tester.tapAt(at, kind: PointerDeviceKind.mouse);
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
await tester.tapAt(at, kind: PointerDeviceKind.mouse);
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
await twice(const Offset(400, 200));
|
||||||
|
expect(doubles, 1);
|
||||||
|
await twice(const Offset(400, 580));
|
||||||
|
expect(doubles, 1, reason: 'on the bottom bar');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('buttons', () {
|
||||||
|
testWidgets('count badge in the accent; tooltip on the app glass', (tester) async {
|
||||||
|
await tester.pumpWidget(host(const Row(children: [
|
||||||
|
MinimalButton(key: ValueKey('subs'), icon: Icons.subtitles_outlined, tooltip: 'Subtitles', badge: 2, onPressed: null),
|
||||||
|
MinimalButton(key: ValueKey('plain'), icon: Icons.menu, tooltip: 'Menu', onPressed: null),
|
||||||
|
])));
|
||||||
|
expect(
|
||||||
|
find.descendant(of: find.byKey(const ValueKey('subs')), matching: find.byKey(const ValueKey('minimal-badge'))),
|
||||||
|
findsOneWidget);
|
||||||
|
expect(find.descendant(of: find.byKey(const ValueKey('subs')), matching: find.text('2')), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.descendant(of: find.byKey(const ValueKey('plain')), matching: find.byKey(const ValueKey('minimal-badge'))),
|
||||||
|
findsNothing);
|
||||||
|
final tip = tester.widget<Tooltip>(
|
||||||
|
find.descendant(of: find.byKey(const ValueKey('subs')), matching: find.byType(Tooltip)).first);
|
||||||
|
expect((tip.decoration as BoxDecoration).color, MinimalStyle.tip);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('volume: click mutes and restores, wheel steps 5', (tester) async {
|
||||||
|
final got = <double>[];
|
||||||
|
var volume = 80.0;
|
||||||
|
var before = 80.0;
|
||||||
|
await tester.pumpWidget(host(StatefulBuilder(
|
||||||
|
builder: (context, setState) => Row(children: [
|
||||||
|
VolumeControl(
|
||||||
|
volume: volume,
|
||||||
|
onChanged: (v) => setState(() => got.add(volume = v)),
|
||||||
|
onToggleMute: () => setState(() {
|
||||||
|
if (volume > 0) {
|
||||||
|
before = volume;
|
||||||
|
got.add(volume = 0);
|
||||||
|
} else {
|
||||||
|
got.add(volume = before);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
button: (context, icon, color, tip, onPressed) =>
|
||||||
|
MinimalButton(key: const ValueKey('speaker'), icon: icon, tooltip: tip, onPressed: onPressed),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
)));
|
||||||
|
await tester.tap(find.byKey(const ValueKey('speaker')));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.byKey(const ValueKey('speaker')));
|
||||||
|
await tester.pump();
|
||||||
|
final at = tester.getCenter(find.byKey(const ValueKey('speaker')));
|
||||||
|
await tester.sendEventToBinding(
|
||||||
|
PointerScrollEvent(kind: PointerDeviceKind.mouse, position: at, scrollDelta: const Offset(0, -60)));
|
||||||
|
await tester.pump();
|
||||||
|
expect(got, [0, 80, 85], reason: 'a notch is one step');
|
||||||
|
for (var i = 0; i < 3; i++) {
|
||||||
|
await tester.sendEventToBinding(
|
||||||
|
PointerScrollEvent(kind: PointerDeviceKind.mouse, position: at, scrollDelta: const Offset(0, 20)));
|
||||||
|
}
|
||||||
|
await tester.pump();
|
||||||
|
expect(got, [0, 80, 85, 80], reason: 'smooth deltas add up to a step');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('volume wheel', () {
|
||||||
|
testWidgets('anywhere: vertical steps; lists scroll instead; sideways is nothing', (tester) async {
|
||||||
|
final steps = <int>[];
|
||||||
|
final list = ScrollController();
|
||||||
|
addTearDown(list.dispose);
|
||||||
|
await tester.pumpWidget(MaterialApp(
|
||||||
|
home: VolumeWheel(
|
||||||
|
onStep: steps.add,
|
||||||
|
child: Stack(children: [
|
||||||
|
const Positioned.fill(child: ColoredBox(color: Colors.black)),
|
||||||
|
Positioned(
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
child: ListView(controller: list, children: [for (var i = 0; i < 50; i++) Text('row $i')]),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
Future<void> wheel(Offset at, Offset delta) =>
|
||||||
|
tester.sendEventToBinding(PointerScrollEvent(kind: PointerDeviceKind.mouse, position: at, scrollDelta: delta));
|
||||||
|
await wheel(const Offset(100, 400), const Offset(0, -60));
|
||||||
|
await wheel(const Offset(100, 400), const Offset(0, 60));
|
||||||
|
expect(steps, [1, -1], reason: 'up louder, down quieter');
|
||||||
|
await wheel(const Offset(100, 400), const Offset(60, 0));
|
||||||
|
await wheel(const Offset(100, 400), const Offset(60, -10));
|
||||||
|
expect(steps, [1, -1], reason: 'sideways');
|
||||||
|
await wheel(const Offset(700, 100), const Offset(0, 60));
|
||||||
|
await tester.pump();
|
||||||
|
expect(steps, [1, -1], reason: 'the list took it');
|
||||||
|
expect(list.offset, greaterThan(0));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the old uosc choice becomes Minimal', () {
|
||||||
|
expect(ControlsSkin.fromStored(null), ControlsSkin.standard);
|
||||||
|
expect(ControlsSkin.fromStored(null, legacyMpvControls: true), ControlsSkin.minimal);
|
||||||
|
expect(ControlsSkin.fromStored(null, legacyMpvControls: false), ControlsSkin.standard);
|
||||||
|
expect(ControlsSkin.fromStored('standard', legacyMpvControls: true), ControlsSkin.standard,
|
||||||
|
reason: 'a saved skin wins');
|
||||||
|
expect(ControlsSkin.fromStored('minimal'), ControlsSkin.minimal);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Settings: "Player design", Standard by default, Minimal picked live', (tester) async {
|
||||||
|
final s = AppSettings.instance;
|
||||||
|
s.controlsSkin.value = ControlsSkin.standard;
|
||||||
|
addTearDown(() => s.controlsSkin.value = ControlsSkin.standard);
|
||||||
|
tester.view.physicalSize = const Size(1280, 2400);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(MaterialApp(
|
||||||
|
theme: ThemeData.dark(),
|
||||||
|
home: Scaffold(
|
||||||
|
body: SettingsTab(
|
||||||
|
section: SettingsSection.player,
|
||||||
|
onSection: (_) {},
|
||||||
|
updates: UpdateController(currentVersion: '0.0.0-dev'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
final design = find.byKey(const ValueKey('player-design'));
|
||||||
|
expect(find.text('Player design'), findsOneWidget);
|
||||||
|
expect(tester.widget<AppSelect<ControlsSkin>>(design).value, ControlsSkin.standard);
|
||||||
|
await tester.tap(design);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Minimal').last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(s.controlsSkin.value, ControlsSkin.minimal);
|
||||||
|
expect(tester.widget<AppSelect<ControlsSkin>>(design).value, ControlsSkin.minimal);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,11 +2,15 @@ import 'package:flutter/gestures.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
|
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
|
||||||
|
import 'package:watch_together/src/controls/control_menus.dart';
|
||||||
|
import 'package:watch_together/src/controls/standard/seek_bar.dart';
|
||||||
import 'package:watch_together/src/jellyfin/track_prefs.dart';
|
import 'package:watch_together/src/jellyfin/track_prefs.dart';
|
||||||
import 'package:watch_together/src/playback/media_marks.dart';
|
import 'package:watch_together/src/playback/media_marks.dart';
|
||||||
import 'package:watch_together/src/screens/session/player_controls.dart';
|
import 'package:watch_together/src/screens/session/player_controls.dart';
|
||||||
|
|
||||||
// The control-bar pieces alone (never the session screen or a `Video`).
|
// The control-bar pieces alone (never the session screen or a `Video`):
|
||||||
|
// the Standard seek bar, the skip pill and the shared track / chapter
|
||||||
|
// lists.
|
||||||
|
|
||||||
Duration m(int min, [int sec = 0]) => Duration(minutes: min, seconds: sec);
|
Duration m(int min, [int sec = 0]) => Duration(minutes: min, seconds: sec);
|
||||||
|
|
||||||
@@ -18,9 +22,6 @@ final chapters = chaptersFromStarts([
|
|||||||
('Part B', m(21, 30)),
|
('Part B', m(21, 30)),
|
||||||
], m(24));
|
], m(24));
|
||||||
|
|
||||||
Finder item<T>(String text) =>
|
|
||||||
find.ancestor(of: find.text(text), matching: find.byType(CheckedPopupMenuItem<T>));
|
|
||||||
|
|
||||||
Widget host(Widget child, {double width = 960}) => MaterialApp(
|
Widget host(Widget child, {double width = 960}) => MaterialApp(
|
||||||
home: Scaffold(
|
home: Scaffold(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
@@ -143,56 +144,30 @@ void main() {
|
|||||||
MpvTrack(id: 1, type: JellyfinStreamType.subtitle, language: 'eng', codec: 'ass', title: 'Full'),
|
MpvTrack(id: 1, type: JellyfinStreamType.subtitle, language: 'eng', codec: 'ass', title: 'Full'),
|
||||||
];
|
];
|
||||||
|
|
||||||
testWidgets('audio menu: count badge, labels, current checked, pick', (tester) async {
|
test('audio list: counts, labels, mpv\'s selection checked', () {
|
||||||
TrackPick? got;
|
expect(trackCount(tracks, JellyfinStreamType.audio), 2);
|
||||||
await tester.pumpWidget(host(Row(children: [
|
expect(trackCount(tracks, JellyfinStreamType.subtitle), 1);
|
||||||
TrackMenuButton(type: JellyfinStreamType.audio, tracks: tracks, onPick: (p) => got = p),
|
final items = trackMenuItems(JellyfinStreamType.audio, tracks);
|
||||||
TrackMenuButton(type: JellyfinStreamType.subtitle, tracks: tracks, onPick: (p) => got = p),
|
expect(items.map((i) => i.label), ['Japanese · E-AC3 2.0', 'English · AAC 5.1 · Dub']);
|
||||||
])));
|
|
||||||
expect(find.descendant(of: find.byKey(const ValueKey('audio-button')), matching: find.text('2')),
|
|
||||||
findsOneWidget);
|
|
||||||
expect(find.descendant(of: find.byKey(const ValueKey('subtitles-button')), matching: find.text('1')),
|
|
||||||
findsOneWidget);
|
|
||||||
await tester.tap(find.byKey(const ValueKey('audio-button')));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
final items = tester.widgetList<CheckedPopupMenuItem<TrackPick>>(find.byType(CheckedPopupMenuItem<TrackPick>));
|
|
||||||
expect(items.map((i) => i.checked), [true, false]);
|
expect(items.map((i) => i.checked), [true, false]);
|
||||||
expect(find.text('Japanese · E-AC3 2.0'), findsOneWidget);
|
expect(items[1].value, (aid: 2, sid: null, subtitlesOff: false));
|
||||||
await tester.tap(item<TrackPick>('English · AAC 5.1 · Dub'));
|
expect(trackMenuItems(JellyfinStreamType.audio, const []).single.enabled, isFalse, reason: 'None');
|
||||||
await tester.pumpAndSettle();
|
|
||||||
expect(got, (aid: 2, sid: null, subtitlesOff: false));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('subtitle menu has Off, checked when none is selected', (tester) async {
|
test('subtitle list starts with Off, checked when none is selected', () {
|
||||||
TrackPick? got;
|
final items = trackMenuItems(JellyfinStreamType.subtitle, tracks);
|
||||||
await tester.pumpWidget(host(TrackMenuButton(
|
expect(items.map((i) => i.label), ['Off', 'English · ASS · Full']);
|
||||||
type: JellyfinStreamType.subtitle, tracks: tracks, onPick: (p) => got = p)));
|
|
||||||
await tester.tap(find.byKey(const ValueKey('subtitles-button')));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
final items = tester.widgetList<CheckedPopupMenuItem<TrackPick>>(find.byType(CheckedPopupMenuItem<TrackPick>));
|
|
||||||
expect(items.map((i) => i.checked), [true, false]);
|
expect(items.map((i) => i.checked), [true, false]);
|
||||||
await tester.tap(item<TrackPick>('Off'));
|
expect(items.first.value, (aid: null, sid: null, subtitlesOff: true));
|
||||||
await tester.pumpAndSettle();
|
expect(items.last.value, (aid: null, sid: 1, subtitlesOff: false));
|
||||||
expect(got, (aid: null, sid: null, subtitlesOff: true));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('chapter menu: current checked, a pick seeks to its start', (tester) async {
|
test('chapter list: start times, the current one checked', () {
|
||||||
MediaChapter? got;
|
final items = chapterMenuItems(chapters, 2);
|
||||||
await tester.pumpWidget(host(ChaptersControlButton(chapters: chapters, current: 2, onPick: (c) => got = c)));
|
|
||||||
await tester.tap(find.byKey(const ValueKey('chapters-button')));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
final items = tester.widgetList<CheckedPopupMenuItem<MediaChapter>>(
|
|
||||||
find.byType(CheckedPopupMenuItem<MediaChapter>));
|
|
||||||
expect(items.map((i) => i.checked), [false, false, true, false, false]);
|
expect(items.map((i) => i.checked), [false, false, true, false, false]);
|
||||||
expect(find.text('20:00'), findsOneWidget);
|
expect(items[3].label, 'ED');
|
||||||
await tester.tap(item<MediaChapter>('ED'));
|
expect(items[3].hint, '20:00');
|
||||||
await tester.pumpAndSettle();
|
expect(items[3].value.start, m(20));
|
||||||
expect(got!.start, m(20));
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('no chapters, no chapter button', (tester) async {
|
|
||||||
await tester.pumpWidget(host(ChaptersControlButton(chapters: const [], current: null, onPick: (_) {})));
|
|
||||||
expect(find.byKey(const ValueKey('chapters-button')), findsNothing);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
|
// restructure: rewrite in step 3 (Your server): invite addresses (P24) and
|
||||||
|
// the hosting invite; the settings cards stay.
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/gestures.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
import 'package:watch_together/src/controls/controls_probe.dart';
|
import 'package:watch_together/src/controls/frame.dart';
|
||||||
|
import 'package:watch_together/src/controls/model/controls_prefs.dart';
|
||||||
import 'package:watch_together/src/controls/player_settings.dart';
|
import 'package:watch_together/src/controls/player_settings.dart';
|
||||||
import 'package:watch_together/src/controls/uosc/controls.dart';
|
|
||||||
import 'package:watch_together/src/jellyfin/track_prefs.dart';
|
import 'package:watch_together/src/jellyfin/track_prefs.dart';
|
||||||
import 'package:watch_together/src/schedule/animeflow.dart';
|
import 'package:watch_together/src/schedule/animeflow.dart';
|
||||||
import 'package:watch_together/src/shaders/shader_presets.dart';
|
import 'package:watch_together/src/shaders/shader_presets.dart';
|
||||||
@@ -18,7 +19,7 @@ import 'package:watch_together/src/state/session_state.dart';
|
|||||||
import 'package:watch_together/src/state/settings.dart';
|
import 'package:watch_together/src/state/settings.dart';
|
||||||
import 'package:watch_together/src/update/update_controller.dart';
|
import 'package:watch_together/src/update/update_controller.dart';
|
||||||
|
|
||||||
// The player's ⚙ card (both designs, never a media_kit `Video`), the
|
// The player settings card (both skins, never a media_kit `Video`), the
|
||||||
// Settings pages, the host flag in the party UI and invite addresses.
|
// Settings pages, the host flag in the party UI and invite addresses.
|
||||||
|
|
||||||
Widget app(Widget child) => MaterialApp(theme: ThemeData.dark(), home: Scaffold(body: child));
|
Widget app(Widget child) => MaterialApp(theme: ThemeData.dark(), home: Scaffold(body: child));
|
||||||
@@ -41,7 +42,11 @@ void main() {
|
|||||||
final coord = SessionState.instance.coordinator;
|
final coord = SessionState.instance.coordinator;
|
||||||
|
|
||||||
tearDown(() {
|
tearDown(() {
|
||||||
s.mpvControls.value = false;
|
s.controlsSkin.value = ControlsSkin.standard;
|
||||||
|
s.timeDisplay.value = TimeDisplay.elapsed;
|
||||||
|
s.controlsSize.value = ControlsSize.normal;
|
||||||
|
s.controlsHideSecs.value = defaultControlsHideSecs;
|
||||||
|
s.hiddenControls.value = const {};
|
||||||
s.progressLine.value = true;
|
s.progressLine.value = true;
|
||||||
s.shaderPreset.value = ShaderPreset.off;
|
s.shaderPreset.value = ShaderPreset.off;
|
||||||
s.shaderQuality.value = ShaderQuality.hq;
|
s.shaderQuality.value = ShaderQuality.hq;
|
||||||
@@ -53,15 +58,23 @@ void main() {
|
|||||||
SessionState.instance.recents.value = const [];
|
SessionState.instance.recents.value = const [];
|
||||||
});
|
});
|
||||||
|
|
||||||
group('⚙ player settings', () {
|
Widget opener() => Builder(
|
||||||
testWidgets('standard controls: the gear opens every player setting, changes apply live', (tester) async {
|
builder: (context) => TextButton(
|
||||||
|
key: const ValueKey('open-settings'),
|
||||||
|
onPressed: () => showPlayerSettings(context),
|
||||||
|
child: const Text('settings'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
group('player settings card', () {
|
||||||
|
testWidgets('lists every player setting, changes apply live', (tester) async {
|
||||||
bigWindow(tester);
|
bigWindow(tester);
|
||||||
coord.playback.tracks.value = const [
|
coord.playback.tracks.value = const [
|
||||||
MpvTrack(id: 1, type: JellyfinStreamType.audio, language: 'jpn', codec: 'eac3', channels: 2, selected: true),
|
MpvTrack(id: 1, type: JellyfinStreamType.audio, language: 'jpn', codec: 'eac3', channels: 2, selected: true),
|
||||||
MpvTrack(id: 1, type: JellyfinStreamType.subtitle, language: 'eng', codec: 'ass', title: 'Full'),
|
MpvTrack(id: 1, type: JellyfinStreamType.subtitle, language: 'eng', codec: 'ass', title: 'Full'),
|
||||||
];
|
];
|
||||||
await tester.pumpWidget(app(const Align(alignment: Alignment.bottomRight, child: PlayerSettingsButton())));
|
await tester.pumpWidget(app(Align(alignment: Alignment.bottomRight, child: opener())));
|
||||||
await tester.tap(find.byKey(const ValueKey('player-settings')));
|
await tester.tap(find.byKey(const ValueKey('open-settings')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
final card = find.byKey(const ValueKey('player-settings-card'));
|
final card = find.byKey(const ValueKey('player-settings-card'));
|
||||||
expect(card, findsOneWidget);
|
expect(card, findsOneWidget);
|
||||||
@@ -81,8 +94,8 @@ void main() {
|
|||||||
expect(find.descendant(of: find.byKey(const ValueKey('audio-track')), matching: find.textContaining('Japanese')),
|
expect(find.descendant(of: find.byKey(const ValueKey('audio-track')), matching: find.textContaining('Japanese')),
|
||||||
findsOneWidget);
|
findsOneWidget);
|
||||||
|
|
||||||
await pick(tester, 'player-design', 'uosc');
|
await pick(tester, 'player-design', 'Minimal');
|
||||||
expect(s.mpvControls.value, isTrue);
|
expect(s.controlsSkin.value, ControlsSkin.minimal);
|
||||||
await tester.tap(find.byKey(const ValueKey('progress-line')));
|
await tester.tap(find.byKey(const ValueKey('progress-line')));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(s.progressLine.value, isFalse);
|
expect(s.progressLine.value, isFalse);
|
||||||
@@ -98,19 +111,17 @@ void main() {
|
|||||||
expect(card, findsNothing);
|
expect(card, findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('uosc controls: the gear opens the card and keeps the controls up', (tester) async {
|
testWidgets('keeps the controls up while open', (tester) async {
|
||||||
bigWindow(tester);
|
bigWindow(tester);
|
||||||
s.mpvControls.value = true;
|
await tester.pumpWidget(app(ControlsFrame(
|
||||||
await tester.pumpWidget(app(UoscControls(
|
|
||||||
hideAfter: const Duration(seconds: 1),
|
hideAfter: const Duration(seconds: 1),
|
||||||
timeline: (context, expanded) => const SizedBox(height: 30),
|
builder: (context, visible) => Align(alignment: Alignment.bottomRight, child: opener()),
|
||||||
right: const [UoscPlayerSettingsButton()],
|
|
||||||
)));
|
)));
|
||||||
final controls = tester.state<UoscControlsState>(find.byType(UoscControls));
|
final controls = tester.state<ControlsFrameState>(find.byType(ControlsFrame));
|
||||||
await tester.tap(find.byKey(const ValueKey('uosc-player-settings')));
|
await tester.tap(find.byKey(const ValueKey('open-settings')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.byKey(const ValueKey('player-settings-card')), findsOneWidget);
|
expect(find.byKey(const ValueKey('player-settings-card')), findsOneWidget);
|
||||||
expect(find.text('uosc'), findsOneWidget, reason: 'the current design');
|
expect(find.text('Standard'), findsOneWidget, reason: 'the current skin');
|
||||||
await tester.pump(const Duration(seconds: 3));
|
await tester.pump(const Duration(seconds: 3));
|
||||||
expect(controls.visible, isTrue, reason: 'held while the card is open');
|
expect(controls.visible, isTrue, reason: 'held while the card is open');
|
||||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||||
@@ -118,49 +129,6 @@ void main() {
|
|||||||
await tester.pump(const Duration(seconds: 2));
|
await tester.pump(const Duration(seconds: 2));
|
||||||
expect(controls.visible, isFalse);
|
expect(controls.visible, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('uosc: hidden controls keep the line unless the setting is off', (tester) async {
|
|
||||||
await tester.pumpWidget(app(UoscControls(
|
|
||||||
hideAfter: const Duration(milliseconds: 100),
|
|
||||||
timeline: (context, expanded) => const SizedBox(height: 2),
|
|
||||||
)));
|
|
||||||
double opacity() =>
|
|
||||||
tester.widget<AnimatedOpacity>(find.byKey(const ValueKey('uosc-timeline-wrap'))).opacity;
|
|
||||||
await tester.pump(const Duration(milliseconds: 200));
|
|
||||||
expect(opacity(), 1, reason: 'hidden, line on');
|
|
||||||
s.setProgressLine(false);
|
|
||||||
await tester.pump();
|
|
||||||
expect(opacity(), 0, reason: 'hidden, line off');
|
|
||||||
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
|
||||||
await mouse.addPointer(location: const Offset(400, 300));
|
|
||||||
addTearDown(mouse.removePointer);
|
|
||||||
await mouse.moveTo(const Offset(410, 300));
|
|
||||||
await tester.pump();
|
|
||||||
expect(opacity(), 1, reason: 'controls up: the full timeline');
|
|
||||||
await tester.pump(const Duration(milliseconds: 200));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('standard controls: the probe follows media_kit\'s show / hide rule', (tester) async {
|
|
||||||
final shown = ValueNotifier(false);
|
|
||||||
addTearDown(shown.dispose);
|
|
||||||
await tester.pumpWidget(app(ControlsHoverProbe(
|
|
||||||
shown: shown,
|
|
||||||
hideAfter: const Duration(seconds: 3),
|
|
||||||
child: const SizedBox.expand(),
|
|
||||||
)));
|
|
||||||
await tester.pump();
|
|
||||||
expect(shown.value, isTrue, reason: 'up on mount');
|
|
||||||
await tester.pump(const Duration(seconds: 3));
|
|
||||||
expect(shown.value, isFalse, reason: 'down 3 s later');
|
|
||||||
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
|
||||||
await mouse.addPointer(location: const Offset(400, 300));
|
|
||||||
addTearDown(mouse.removePointer);
|
|
||||||
await mouse.moveTo(const Offset(420, 300));
|
|
||||||
await tester.pump();
|
|
||||||
expect(shown.value, isTrue, reason: 'mouse movement');
|
|
||||||
await tester.pump(const Duration(seconds: 3));
|
|
||||||
expect(shown.value, isFalse);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
group('Settings pages', () {
|
group('Settings pages', () {
|
||||||
@@ -195,16 +163,37 @@ void main() {
|
|||||||
expect(find.byIcon(Icons.hub_outlined), findsOneWidget);
|
expect(find.byIcon(Icons.hub_outlined), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Player: the same rows as the ⚙, plus a pointer to it', (tester) async {
|
testWidgets('Player: the card\'s rows, a pointer to it, and the controls\' choices', (tester) async {
|
||||||
await pumpSettings(tester, SettingsSection.player);
|
await pumpSettings(tester, SettingsSection.player, size: const Size(1280, 2400));
|
||||||
expect(find.textContaining("also in the player's ⚙"), findsOneWidget);
|
expect(find.textContaining('also in the player: ⚙ → Player settings'), findsOneWidget);
|
||||||
for (final key in ['player-design', 'progress-line', 'shader-preset', 'hwdec']) {
|
for (final key in [
|
||||||
|
'player-design',
|
||||||
|
'progress-line',
|
||||||
|
'shader-preset',
|
||||||
|
'hwdec',
|
||||||
|
'time-display',
|
||||||
|
'controls-size',
|
||||||
|
'controls-hide',
|
||||||
|
for (final c in OptionalControl.values) 'bar-${c.name}',
|
||||||
|
]) {
|
||||||
expect(find.byKey(ValueKey(key)), findsOneWidget, reason: key);
|
expect(find.byKey(ValueKey(key)), findsOneWidget, reason: key);
|
||||||
}
|
}
|
||||||
expect(find.byKey(const ValueKey('audio-track')), findsNothing, reason: 'tracks only in the player');
|
expect(find.byKey(const ValueKey('audio-track')), findsNothing, reason: 'tracks only in the player');
|
||||||
expect(find.text('Standard'), findsOneWidget);
|
expect(find.text('Standard'), findsOneWidget);
|
||||||
await pick(tester, 'player-design', 'uosc');
|
await pick(tester, 'player-design', 'Minimal');
|
||||||
expect(s.mpvControls.value, isTrue);
|
expect(s.controlsSkin.value, ControlsSkin.minimal);
|
||||||
|
await pick(tester, 'time-display', 'Remaining');
|
||||||
|
expect(s.timeDisplay.value, TimeDisplay.remaining);
|
||||||
|
await pick(tester, 'controls-size', 'Large');
|
||||||
|
expect(s.controlsSize.value, ControlsSize.large);
|
||||||
|
await pick(tester, 'controls-hide', '5 s');
|
||||||
|
expect(s.controlsHideSecs.value, 5);
|
||||||
|
await tester.tap(find.byKey(const ValueKey('bar-chat')));
|
||||||
|
await tester.pump();
|
||||||
|
expect(s.hiddenControls.value, {OptionalControl.chat});
|
||||||
|
await tester.tap(find.byKey(const ValueKey('bar-chat')));
|
||||||
|
await tester.pump();
|
||||||
|
expect(s.hiddenControls.value, isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Party & network: host defaults and recent servers with remove', (tester) async {
|
testWidgets('Party & network: host defaults and recent servers with remove', (tester) async {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
|
// restructure: rewrite in step 2 (Status + Notifier): the "status" group
|
||||||
|
// (one computed level); the title block and overflow checks stay.
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
import 'package:watch_together/src/controls/uosc/buttons.dart';
|
import 'package:watch_together/src/controls/frame.dart';
|
||||||
import 'package:watch_together/src/controls/uosc/controls.dart';
|
import 'package:watch_together/src/controls/minimal/style.dart';
|
||||||
import 'package:watch_together/src/controls/uosc/timeline.dart';
|
import 'package:watch_together/src/controls/minimal/timeline.dart';
|
||||||
import 'package:watch_together/src/session/health.dart';
|
import 'package:watch_together/src/session/health.dart';
|
||||||
import 'package:watch_together/src/session/now_playing.dart';
|
import 'package:watch_together/src/session/now_playing.dart';
|
||||||
import 'package:watch_together/src/shell/now_playing_title.dart';
|
import 'package:watch_together/src/shell/now_playing_title.dart';
|
||||||
@@ -165,16 +167,6 @@ void main() {
|
|||||||
expect(find.byKey(const ValueKey('now-playing-episode')), findsNothing);
|
expect(find.byKey(const ValueKey('now-playing-episode')), findsNothing);
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('a new media fades through', (tester) async {
|
|
||||||
await tester.pumpWidget(host(const NowPlayingBlock(now: now)));
|
|
||||||
await tester.pumpWidget(host(const NowPlayingBlock(now: NowPlaying(title: 'Dune'))));
|
|
||||||
await tester.pump(const Duration(milliseconds: 60));
|
|
||||||
expect(find.text('Frieren'), findsOneWidget, reason: 'still fading out');
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
expect(find.text('Frieren'), findsNothing);
|
|
||||||
expect(find.text('Dune'), findsOneWidget);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('start banner: up before playback, fades on play, never back for that media', (tester) async {
|
testWidgets('start banner: up before playback, fades on play, never back for that media', (tester) async {
|
||||||
@@ -248,21 +240,37 @@ void main() {
|
|||||||
expect(find.textContaining('Waiting for Someone With A Long Name 0'), findsOneWidget);
|
expect(find.textContaining('Waiting for Someone With A Long Name 0'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('uosc top bar and start card: ${size.width.toInt()}', (tester) async {
|
testWidgets('minimal top bar and start card: ${size.width.toInt()}', (tester) async {
|
||||||
debugArtBytes = (hash) => png;
|
debugArtBytes = (hash) => png;
|
||||||
await sized(
|
await sized(
|
||||||
tester,
|
tester,
|
||||||
size,
|
size,
|
||||||
Stack(fit: StackFit.expand, children: [
|
Stack(fit: StackFit.expand, children: [
|
||||||
StartCard(now: long),
|
StartCard(now: long),
|
||||||
UoscControls(
|
ControlsFrame(
|
||||||
topLeft: NowPlayingBlock(now: long),
|
builder: (context, visible) => Stack(children: [
|
||||||
top: Row(mainAxisSize: MainAxisSize.min, children: [
|
Positioned(
|
||||||
for (final i in [Icons.chat_bubble_outline, Icons.fullscreen, Icons.close])
|
left: 16,
|
||||||
UoscButton(icon: i, tooltip: '', onPressed: () {}),
|
right: 10,
|
||||||
|
top: 0,
|
||||||
|
height: 72,
|
||||||
|
child: Row(children: [
|
||||||
|
Expanded(child: NowPlayingBlock(now: long)),
|
||||||
|
for (final i in [Icons.fullscreen, Icons.close])
|
||||||
|
MinimalButton(icon: i, tooltip: '', onPressed: () {}),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: MinimalTimeline(
|
||||||
|
position: Duration.zero,
|
||||||
|
duration: const Duration(minutes: 24),
|
||||||
|
expanded: visible,
|
||||||
|
onSeek: (_) {}),
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
timeline: (context, expanded) =>
|
|
||||||
UoscTimeline(position: Duration.zero, duration: const Duration(minutes: 24), expanded: expanded, onSeek: (_) {}),
|
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// restructure: rewrite in step 5 (What's next): who performs an item and
|
||||||
|
// the janitor become server state.
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
import 'package:watch_together/src/queue/queue_logic.dart';
|
import 'package:watch_together/src/queue/queue_logic.dart';
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// restructure: rewrite in step 6 (Media pipeline): preload and the warm
|
||||||
|
// relay become one pipeline; keep the timing per source.
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// restructure: rewrite in step 1 (Connection + Identity + Presence): "the
|
||||||
|
// source drops uncleanly and resumes" (resume token, D6 source resume); the
|
||||||
|
// rest stays.
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
@@ -65,6 +68,7 @@ Future<(HttpServer, List<String>)> _origin(String file) async {
|
|||||||
void main() {
|
void main() {
|
||||||
late SyncServer server;
|
late SyncServer server;
|
||||||
late List<SessionCoordinator> all;
|
late List<SessionCoordinator> all;
|
||||||
|
late List<Object> serverErrors;
|
||||||
|
|
||||||
Future<SessionCoordinator> member(String name) async {
|
Future<SessionCoordinator> member(String name) async {
|
||||||
final acc = JellyfinAccounts(store: MemoryJellyfinStore());
|
final acc = JellyfinAccounts(store: MemoryJellyfinStore());
|
||||||
@@ -102,7 +106,15 @@ void main() {
|
|||||||
MediaKit.ensureInitialized();
|
MediaKit.ensureInitialized();
|
||||||
all = [];
|
all = [];
|
||||||
server = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'vps'));
|
server = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'vps'));
|
||||||
await server.start();
|
// Relic reports an error writing a response (a relay read cut short
|
||||||
|
// when its relay is dropped at a swap or at the stop in tearDown) to
|
||||||
|
// the zone the server started in, as it logs it outside tests;
|
||||||
|
// anything else fails the test.
|
||||||
|
serverErrors = [];
|
||||||
|
await runZonedGuarded(server.start, (e, _) {
|
||||||
|
if ('$e'.contains('Content size below specified contentLength') || '$e'.contains('did not arrive')) return;
|
||||||
|
serverErrors.add(e);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
tearDown(() async {
|
tearDown(() async {
|
||||||
@@ -110,6 +122,7 @@ void main() {
|
|||||||
await x.dispose();
|
await x.dispose();
|
||||||
}
|
}
|
||||||
await server.stop();
|
await server.stop();
|
||||||
|
expect(serverErrors, isEmpty, reason: 'server errors');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a guest puts up a file: it plays for everyone through the relay', timeout: const Timeout(Duration(minutes: 2)),
|
test('a guest puts up a file: it plays for everyone through the relay', timeout: const Timeout(Duration(minutes: 2)),
|
||||||
@@ -127,8 +140,9 @@ void main() {
|
|||||||
expect(relay.buffers.single.bytesToViewers, greaterThan(0), reason: 'alice played through the relay');
|
expect(relay.buffers.single.bytesToViewers, greaterThan(0), reason: 'alice played through the relay');
|
||||||
expect(relay.sourceToken, bob.sync.token);
|
expect(relay.sourceToken, bob.sync.token);
|
||||||
|
|
||||||
// The next swap retires bob's source and the buffer.
|
// The next swap retires bob's source and the buffer. Nothing answers
|
||||||
alice.swapPicked(const LinkMedia('http://127.0.0.1:1/none.mkv'));
|
// at that link, so its probe refuses it unless put up anyway.
|
||||||
|
expect(await alice.swapPicked(const LinkMedia('http://127.0.0.1:1/none.mkv', anyway: true)), isNull);
|
||||||
await _poll(() => !bob.relaying.peek(), 'source stopped');
|
await _poll(() => !bob.relaying.peek(), 'source stopped');
|
||||||
expect(server.hub.relay, isNull);
|
expect(server.hub.relay, isNull);
|
||||||
});
|
});
|
||||||
@@ -155,7 +169,8 @@ void main() {
|
|||||||
await playTogether([alice, bob]);
|
await playTogether([alice, bob]);
|
||||||
expect(hits.length, greaterThan(before));
|
expect(hits.length, greaterThan(before));
|
||||||
|
|
||||||
// A page yt-dlp cannot read is refused up front.
|
// A page is refused up front when this computer cannot read it (here
|
||||||
|
// yt-dlp is off, and the refusal says so).
|
||||||
final page = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
final page = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||||
page.listen((r) {
|
page.listen((r) {
|
||||||
r.response.headers.contentType = ContentType.html;
|
r.response.headers.contentType = ContentType.html;
|
||||||
@@ -163,7 +178,7 @@ void main() {
|
|||||||
r.response.close();
|
r.response.close();
|
||||||
});
|
});
|
||||||
expect(await alice.swapPicked(LinkMedia('http://127.0.0.1:${page.port}/watch?v=x', relay: LinkRelay.me)),
|
expect(await alice.swapPicked(LinkMedia('http://127.0.0.1:${page.port}/watch?v=x', relay: LinkRelay.me)),
|
||||||
contains('Not a video'));
|
'yt-dlp is turned off');
|
||||||
await page.close(force: true);
|
await page.close(force: true);
|
||||||
} finally {
|
} finally {
|
||||||
await origin.close(force: true);
|
await origin.close(force: true);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// restructure: rewrite in step 3 (Your server): "solo: the button says
|
||||||
|
// Play"; the rest stays.
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// restructure: rewrite in step 3 (Your server): one hub with listeners
|
||||||
|
// attached, nothing restarts, no solo mode.
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// restructure: rewrite in step 1 (Connection + Identity + Presence):
|
||||||
|
// IdleTracker and unreadyForNewMedia give way to auto sit-out;
|
||||||
|
// linkDisplayName stays.
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:watch_together/src/session/idle.dart';
|
import 'package:watch_together/src/session/idle.dart';
|
||||||
import 'package:watch_together/src/session/session_coordinator.dart';
|
import 'package:watch_together/src/session/session_coordinator.dart';
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ void main() {
|
|||||||
s.setShaderPreset(ShaderPreset.a);
|
s.setShaderPreset(ShaderPreset.a);
|
||||||
await p.init();
|
await p.init();
|
||||||
final dynamic mpv = p.player.platform;
|
final dynamic mpv = p.player.platform;
|
||||||
final modeA = joinShaderPaths(
|
final modeA =
|
||||||
await shaderPathsFor(ShaderPreset.a, ShaderQuality.hq, const []));
|
await shaderPathsFor(ShaderPreset.a, ShaderQuality.hq, const []);
|
||||||
expect(await mpv.getProperty('glsl-shaders'), modeA);
|
expect(shaderListMatches(modeA, await mpv.getProperty('glsl-shaders')), isTrue);
|
||||||
expect(p.shaderNotice.value, isNull);
|
expect(p.shaderNotice.value, isNull);
|
||||||
|
|
||||||
// Live change, announced.
|
// Live change, announced.
|
||||||
@@ -39,7 +39,7 @@ void main() {
|
|||||||
final caFast = await shaderPathsFor(
|
final caFast = await shaderPathsFor(
|
||||||
ShaderPreset.ca, ShaderQuality.fast, const []);
|
ShaderPreset.ca, ShaderQuality.fast, const []);
|
||||||
expect(caFast.first, startsWith(support.path));
|
expect(caFast.first, startsWith(support.path));
|
||||||
expect(await mpv.getProperty('glsl-shaders'), joinShaderPaths(caFast));
|
expect(shaderListMatches(caFast, await mpv.getProperty('glsl-shaders')), isTrue);
|
||||||
expect(p.shaderNotice.value?.text, 'Shaders: Mode C+A (Fast)');
|
expect(p.shaderNotice.value?.text, 'Shaders: Mode C+A (Fast)');
|
||||||
|
|
||||||
// A missing custom file: toast, off, nothing set.
|
// A missing custom file: toast, off, nothing set.
|
||||||
|
|||||||
@@ -70,6 +70,22 @@ void main() {
|
|||||||
expect(joinShaderPaths([], windows: false), '');
|
expect(joinShaderPaths([], windows: false), '');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("mpv's read-back: the list in ',', ';' or ':' form", () {
|
||||||
|
const win = [r'C:\Users\a b\shaders\x.glsl', r'D:\y.glsl'];
|
||||||
|
expect(shaderListMatches(win, r'C:\Users\a b\shaders\x.glsl;D:\y.glsl'), isTrue, reason: 'new mpv, Windows');
|
||||||
|
expect(shaderListMatches(win, r'C:\Users\a b\shaders\x.glsl,D:\y.glsl'), isTrue, reason: 'old mpv, Windows');
|
||||||
|
const unix = ['/home/a/x.glsl', '/home/a/y.glsl'];
|
||||||
|
expect(shaderListMatches(unix, '/home/a/x.glsl:/home/a/y.glsl'), isTrue, reason: 'new mpv, Linux');
|
||||||
|
expect(shaderListMatches(unix, '/home/a/x.glsl,/home/a/y.glsl'), isTrue, reason: 'old mpv, Linux');
|
||||||
|
expect(shaderListMatches([r'C:\x.glsl'], r'C:\x.glsl'), isTrue, reason: 'one path');
|
||||||
|
expect(shaderListMatches(const [], ''), isTrue, reason: 'off');
|
||||||
|
expect(shaderListMatches(win, r'C:\Users\a b\shaders\x.glsl'), isFalse, reason: 'one dropped');
|
||||||
|
expect(shaderListMatches(unix, '/home/a/y.glsl:/home/a/x.glsl'), isFalse, reason: 'order matters');
|
||||||
|
expect(shaderListMatches(unix, ''), isFalse, reason: 'mpv kept none');
|
||||||
|
expect(shaderListMatches(const [], 'null'), isFalse);
|
||||||
|
expect(shaderListMatches(unix, null), isFalse, reason: 'read failed');
|
||||||
|
});
|
||||||
|
|
||||||
test('path problems: missing file, separator inside a path', () {
|
test('path problems: missing file, separator inside a path', () {
|
||||||
final d = _fresh('shaders_problem');
|
final d = _fresh('shaders_problem');
|
||||||
final ok = File('${d.path}/ok.glsl')..writeAsStringSync('//');
|
final ok = File('${d.path}/ok.glsl')..writeAsStringSync('//');
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// restructure: rewrite in step 2 (Status + Notifier): toasts and the status
|
||||||
|
// pill; in step 3 (Your server): the solo strip and Invite friends.
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
@@ -68,17 +70,19 @@ void main() {
|
|||||||
coord.toasts.value = [];
|
coord.toasts.value = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
Future<void> pumpShell(WidgetTester tester, Size size) async {
|
Future<void> pumpShell(WidgetTester tester, Size size, {UpdateController? updates}) async {
|
||||||
tester.view.physicalSize = size;
|
tester.view.physicalSize = size;
|
||||||
tester.view.devicePixelRatio = 1;
|
tester.view.devicePixelRatio = 1;
|
||||||
addTearDown(tester.view.reset);
|
addTearDown(tester.view.reset);
|
||||||
await tester.pumpWidget(MaterialApp(
|
await tester.pumpWidget(MaterialApp(
|
||||||
theme: ThemeData.dark(),
|
theme: ThemeData.dark(),
|
||||||
home: AppShell(
|
home: AppShell(
|
||||||
updates: UpdateController(currentVersion: '0.0.0-dev'),
|
updates: updates ?? UpdateController(currentVersion: '0.0.0-dev'),
|
||||||
autoUpdateCheck: false,
|
autoUpdateCheck: false,
|
||||||
windowControls: false,
|
windowControls: false,
|
||||||
videoBuilder: (context, controls) => const FakeVideo(),
|
videoBuilder: (context, controls) => const FakeVideo(),
|
||||||
|
// A real probe (network, yt-dlp) never answers on the test clock.
|
||||||
|
linkProbe: (url) async => LinkProbe.file(Uri.parse(url), 1000),
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
@@ -93,6 +97,36 @@ void main() {
|
|||||||
member('f', 'Finn'),
|
member('f', 'Finn'),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
group('version and update bar', () {
|
||||||
|
testWidgets('the version under Settings; the bar on the tabs, off the full player until the video ends',
|
||||||
|
(tester) async {
|
||||||
|
final updates = UpdateController(currentVersion: '0.0.0-dev');
|
||||||
|
await pumpShell(tester, const Size(1280, 800), updates: updates);
|
||||||
|
expect(find.text('dev run'), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('update-bar')), findsNothing);
|
||||||
|
updates.status.value = const UpdateStatus(UpdatePhase.available,
|
||||||
|
manifest: UpdateManifest(version: '9.9.9', protocol: 1, url: 'http://x/a.tgz', notes: ''),
|
||||||
|
canInstall: true);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const ValueKey('update-bar')), findsOneWidget);
|
||||||
|
online(party);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const ValueKey('update-bar')), findsNothing);
|
||||||
|
final r = coord.serverState.value!;
|
||||||
|
coord.serverState.value = StateBroadcast(
|
||||||
|
eventId: 2,
|
||||||
|
transport: 'finished',
|
||||||
|
position: 0,
|
||||||
|
serverTime: r.serverTime,
|
||||||
|
mediaId: r.mediaId,
|
||||||
|
media: r.media,
|
||||||
|
members: r.members,
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const ValueKey('update-bar')), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('party strip', () {
|
group('party strip', () {
|
||||||
testWidgets('offline: Join and Host, no party', (tester) async {
|
testWidgets('offline: Join and Host, no party', (tester) async {
|
||||||
await pumpShell(tester, const Size(1280, 800));
|
await pumpShell(tester, const Size(1280, 800));
|
||||||
@@ -162,18 +196,6 @@ void main() {
|
|||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('tabs: the underline moves to the selected one', (tester) async {
|
|
||||||
await pumpShell(tester, const Size(1280, 800));
|
|
||||||
double underline(String tab) => tester
|
|
||||||
.getSize(find.descendant(of: find.byKey(ValueKey('tab-$tab')), matching: find.byType(AnimatedContainer)))
|
|
||||||
.width;
|
|
||||||
expect(underline('library'), 22);
|
|
||||||
expect(underline('settings'), 0);
|
|
||||||
await tester.tap(find.byKey(const ValueKey('tab-settings')));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
expect(underline('library'), 0);
|
|
||||||
expect(underline('settings'), 22);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
group('popovers', () {
|
group('popovers', () {
|
||||||
@@ -388,16 +410,4 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Shaders off'), findsNothing);
|
expect(find.text('Shaders off'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('status avatar fills its box at any size', (tester) async {
|
|
||||||
await tester.pumpWidget(const MaterialApp(
|
|
||||||
home: Center(
|
|
||||||
child: StatusAvatar(info: AvatarInfo(name: 'Zed', color: Colors.teal, ready: true), size: 88),
|
|
||||||
),
|
|
||||||
));
|
|
||||||
final circle = tester.getSize(find.byType(CircleAvatar));
|
|
||||||
// Ring and gap (2 x 4 px each side at this size) inside the 88 px box.
|
|
||||||
expect(circle.width, 88 - 4 * 4);
|
|
||||||
expect(find.text('Z'), findsOneWidget);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_soloud/flutter_soloud.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:watch_together/src/platform/audio/sound_engine.dart';
|
||||||
|
import 'package:watch_together/src/sfx/sfx.dart';
|
||||||
|
import 'package:watch_together/src/sfx/sound_bank.dart';
|
||||||
|
import 'package:watch_together/src/sfx/synth_sfx.dart';
|
||||||
|
|
||||||
|
// Needs a real audio output: opens the device through flutter_soloud and
|
||||||
|
// plays every sound of every preset once, quietly (WT_SFX_VOLUME, default
|
||||||
|
// 0.1, on the square curve like the app).
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// flutter_soloud's init asks path_provider for a temp dir.
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized().defaultBinaryMessenger.setMockMethodCallHandler(
|
||||||
|
const MethodChannel('plugins.flutter.io/path_provider'), (_) async => Directory.systemTemp.path);
|
||||||
|
test('every sound loads and plays on the real device', timeout: const Timeout(Duration(minutes: 2)), () async {
|
||||||
|
final volume = double.tryParse(Platform.environment['WT_SFX_VOLUME'] ?? '') ?? 0.1;
|
||||||
|
final engine = SoundEngine();
|
||||||
|
expect(await engine.warmUp(), isTrue, reason: 'audio device opened');
|
||||||
|
final sfx = SynthSfx(volume: () => volume, engine: engine);
|
||||||
|
for (final p in SoundPreset.values) {
|
||||||
|
for (final k in SfxKind.values) {
|
||||||
|
await sfx.preview(k, readyIndex: 2, preset: p);
|
||||||
|
expect(SoLoud.instance.getActiveVoiceCount(), greaterThan(0), reason: '${p.name}/${k.name}');
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 250));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Future<void>.delayed(const Duration(seconds: 2));
|
||||||
|
await sfx.dispose();
|
||||||
|
expect(SoLoud.instance.isInitialized, isFalse);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:watch_together/src/platform/audio/sound_engine.dart';
|
||||||
|
import 'package:watch_together/src/sfx/sfx.dart';
|
||||||
|
import 'package:watch_together/src/sfx/sound_bank.dart';
|
||||||
|
import 'package:watch_together/src/sfx/synth.dart';
|
||||||
|
import 'package:watch_together/src/sfx/synth_sfx.dart';
|
||||||
|
|
||||||
|
class _FakeBackend implements AudioBackend {
|
||||||
|
final bool failInit;
|
||||||
|
_FakeBackend({this.failInit = false});
|
||||||
|
|
||||||
|
var inits = 0, deinits = 0;
|
||||||
|
final loads = <String, Uint8List>{};
|
||||||
|
final unloaded = <Object>[];
|
||||||
|
final plays = <(String, double)>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> init() async {
|
||||||
|
inits++;
|
||||||
|
if (failInit) throw StateError('no audio device');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Object> load(String name, Uint8List wav) async {
|
||||||
|
loads[name] = wav;
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void play(Object sound, double volume) => plays.add((sound as String, volume));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> unload(Object sound) async => unloaded.add(sound);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deinit() async => deinits++;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Uint8List> _bytes() async => Uint8List.fromList([1, 2, 3]);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('loads a sound once per key and lets plays overlap', () async {
|
||||||
|
final b = _FakeBackend();
|
||||||
|
final e = SoundEngine(backend: b);
|
||||||
|
await Future.wait([
|
||||||
|
e.play('chat', 'abyss-chat', _bytes, volume: 0.5),
|
||||||
|
e.play('chat', 'abyss-chat', _bytes, volume: 0.5),
|
||||||
|
]);
|
||||||
|
await e.play('chat', 'abyss-chat', _bytes, volume: 0.25);
|
||||||
|
expect(b.inits, 1);
|
||||||
|
expect(b.loads.length, 1);
|
||||||
|
expect(b.plays.map((p) => p.$2), [0.5, 0.5, 0.25]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a new key or fresh replaces and frees the slot', () async {
|
||||||
|
final b = _FakeBackend();
|
||||||
|
final e = SoundEngine(backend: b);
|
||||||
|
await e.play('chat', 'abyss-chat', _bytes, volume: 1);
|
||||||
|
await e.play('chat', 'cosmic-chat', _bytes, volume: 1);
|
||||||
|
await e.play('chat', 'cosmic-chat', _bytes, volume: 1, fresh: true);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(b.loads.length, 3);
|
||||||
|
expect(b.loads.keys.toSet().length, 3, reason: 'load names are unique');
|
||||||
|
expect(b.unloaded.length, 2);
|
||||||
|
expect(b.plays.map((p) => p.$1).toSet().length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('silence and zero volume load nothing', () async {
|
||||||
|
final b = _FakeBackend();
|
||||||
|
final e = SoundEngine(backend: b);
|
||||||
|
await e.play('chat', 'abyss-chat', _bytes, volume: 0);
|
||||||
|
expect(b.inits, 0);
|
||||||
|
expect(b.loads, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed init means no sounds, no throw, one try', () async {
|
||||||
|
final b = _FakeBackend(failInit: true);
|
||||||
|
final e = SoundEngine(backend: b);
|
||||||
|
expect(await e.warmUp(), isFalse);
|
||||||
|
await e.play('chat', 'abyss-chat', _bytes, volume: 1);
|
||||||
|
await e.play('join', 'abyss-join', _bytes, volume: 1);
|
||||||
|
expect(b.inits, 1);
|
||||||
|
expect(b.loads, isEmpty);
|
||||||
|
expect(b.plays, isEmpty);
|
||||||
|
await e.dispose();
|
||||||
|
expect(b.deinits, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed render is retried on the next play', () async {
|
||||||
|
final b = _FakeBackend();
|
||||||
|
final e = SoundEngine(backend: b);
|
||||||
|
await e.play('chat', 'abyss-chat', () async => throw StateError('render'), volume: 1);
|
||||||
|
expect(b.plays, isEmpty);
|
||||||
|
await e.play('chat', 'abyss-chat', _bytes, volume: 1);
|
||||||
|
expect(b.plays.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dispose deinits once and drops later plays', () async {
|
||||||
|
final b = _FakeBackend();
|
||||||
|
final e = SoundEngine(backend: b);
|
||||||
|
await e.play('chat', 'abyss-chat', _bytes, volume: 1);
|
||||||
|
await e.dispose();
|
||||||
|
await e.dispose();
|
||||||
|
await e.play('chat', 'abyss-chat', _bytes, volume: 1);
|
||||||
|
expect(b.deinits, 1);
|
||||||
|
expect(b.plays.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dispose without a sound ever played opens nothing', () async {
|
||||||
|
final b = _FakeBackend();
|
||||||
|
await SoundEngine(backend: b).dispose();
|
||||||
|
expect(b.inits + b.deinits, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('SynthSfx', () {
|
||||||
|
test('renders a WAV per sound, volume as before (v^6), switches and preview', () async {
|
||||||
|
final b = _FakeBackend();
|
||||||
|
var volume = 0.5;
|
||||||
|
final off = <SfxKind>{SfxKind.join};
|
||||||
|
final sfx = SynthSfx(
|
||||||
|
volume: () => volume,
|
||||||
|
enabled: (k) => !off.contains(k),
|
||||||
|
presetFor: (_) => SoundPreset.woodAndGlass,
|
||||||
|
engine: SoundEngine(backend: b),
|
||||||
|
);
|
||||||
|
for (final k in SfxKind.values) {
|
||||||
|
await sfx.play(SfxCue(k, readyIndex: 2));
|
||||||
|
}
|
||||||
|
expect(b.plays.length, SfxKind.values.length - 1);
|
||||||
|
expect(b.plays.every((p) => p.$2 == 0.015625), isTrue); // 0.5^6
|
||||||
|
for (final MapEntry(:key, :value) in b.loads.entries) {
|
||||||
|
expect(String.fromCharCodes(value.sublist(0, 4)), 'RIFF', reason: key);
|
||||||
|
expect(key, startsWith('woodAndGlass-'));
|
||||||
|
}
|
||||||
|
final chat = b.loads.entries.firstWhere((e) => e.key.startsWith('woodAndGlass-chat-2'));
|
||||||
|
expect(chat.value, wav(render(soundFor(SfxKind.chat, readyIndex: 2, preset: SoundPreset.woodAndGlass))));
|
||||||
|
|
||||||
|
await sfx.preview(SfxKind.join, readyIndex: 2);
|
||||||
|
expect(b.plays.last.$1, startsWith('woodAndGlass-join-2'));
|
||||||
|
|
||||||
|
volume = 0;
|
||||||
|
await sfx.play(const SfxCue(SfxKind.chat));
|
||||||
|
expect(b.plays.length, SfxKind.values.length);
|
||||||
|
await sfx.dispose();
|
||||||
|
expect(b.deinits, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user