refactor: extract shared mixins and helpers to dedupe
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../utils/scroll_utils.dart';
|
||||
|
||||
/// Manages the internal/external FocusNode lifecycle for list-tile widgets and
|
||||
/// auto-scrolls the tile into view when it gains focus.
|
||||
mixin FocusableTileStateMixin<T extends StatefulWidget> on State<T> {
|
||||
late FocusNode _effectiveFocusNode;
|
||||
bool _ownsNode = false;
|
||||
|
||||
FocusNode? get widgetFocusNode;
|
||||
|
||||
FocusNode get effectiveFocusNode => _effectiveFocusNode;
|
||||
|
||||
void initFocusNode() {
|
||||
if (widgetFocusNode != null) {
|
||||
_effectiveFocusNode = widgetFocusNode!;
|
||||
_ownsNode = false;
|
||||
} else {
|
||||
_effectiveFocusNode = FocusNode();
|
||||
_ownsNode = true;
|
||||
}
|
||||
_effectiveFocusNode.addListener(_onFocusChange);
|
||||
}
|
||||
|
||||
void updateFocusNode(FocusNode? oldFocusNode) {
|
||||
if (oldFocusNode != widgetFocusNode) {
|
||||
disposeFocusNode();
|
||||
initFocusNode();
|
||||
}
|
||||
}
|
||||
|
||||
void disposeFocusNode() {
|
||||
_effectiveFocusNode.removeListener(_onFocusChange);
|
||||
if (_ownsNode) _effectiveFocusNode.dispose();
|
||||
}
|
||||
|
||||
void _onFocusChange() {
|
||||
if (_effectiveFocusNode.hasFocus) {
|
||||
scrollContextToCenter(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Key-repeat timer for held dpad/keyboard inputs: fires immediately, then
|
||||
/// every 100 ms after a 400 ms initial delay. Call [stopRepeat] in `dispose`.
|
||||
mixin KeyRepeatHelper<T extends StatefulWidget> on State<T> {
|
||||
static const _initialDelay = Duration(milliseconds: 400);
|
||||
static const _repeatInterval = Duration(milliseconds: 100);
|
||||
|
||||
Timer? _repeatTimer;
|
||||
|
||||
void startRepeat(VoidCallback action) {
|
||||
action();
|
||||
_repeatTimer?.cancel();
|
||||
_repeatTimer = Timer(_initialDelay, () {
|
||||
_repeatTimer = Timer.periodic(_repeatInterval, (_) => action());
|
||||
});
|
||||
}
|
||||
|
||||
void stopRepeat() {
|
||||
_repeatTimer?.cancel();
|
||||
_repeatTimer = null;
|
||||
}
|
||||
}
|
||||
@@ -209,6 +209,22 @@ class PlexChapter {
|
||||
|
||||
Duration get startTime => Duration(milliseconds: startTimeOffset ?? 0);
|
||||
Duration? get endTime => endTimeOffset != null ? Duration(milliseconds: endTimeOffset!) : null;
|
||||
|
||||
/// Find the chapter index containing [position]. Returns null if none match.
|
||||
/// A chapter's end defaults to the next chapter's start when [endTimeOffset]
|
||||
/// is missing; the final chapter without an end extends to infinity.
|
||||
static int? indexAtPosition(Duration position, List<PlexChapter> chapters) {
|
||||
final positionMs = position.inMilliseconds;
|
||||
for (int i = 0; i < chapters.length; i++) {
|
||||
final chapter = chapters[i];
|
||||
final startMs = chapter.startTimeOffset ?? 0;
|
||||
final endMs =
|
||||
chapter.endTimeOffset ??
|
||||
(i < chapters.length - 1 ? chapters[i + 1].startTimeOffset ?? 0 : double.maxFinite.toInt());
|
||||
if (positionMs >= startMs && positionMs < endMs) return i;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class PlexMarker {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../models.dart';
|
||||
import '../../../utils/app_logger.dart';
|
||||
import '../player_base.dart';
|
||||
|
||||
/// Android implementation of [Player] using ExoPlayer.
|
||||
@@ -138,15 +137,7 @@ class PlayerAndroid extends PlayerBase {
|
||||
|
||||
@override
|
||||
Future<void> seek(Duration position) async {
|
||||
try {
|
||||
await invoke('seek', {'positionMs': position.inMilliseconds});
|
||||
} on PlatformException catch (e) {
|
||||
if (e.code == 'COMMAND_FAILED' || e.code == 'NOT_INITIALIZED') {
|
||||
appLogger.w('Seek failed (${e.code}), player not ready');
|
||||
return;
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
await runSeek(() => invoke('seek', {'positionMs': position.inMilliseconds}));
|
||||
}
|
||||
|
||||
// ============================================
|
||||
|
||||
@@ -599,6 +599,25 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Seek helpers
|
||||
// ============================================
|
||||
|
||||
/// Run a backend-specific seek call, swallowing the common "not ready" errors
|
||||
/// the native channel throws when the engine was torn down mid-seek.
|
||||
@protected
|
||||
Future<void> runSeek(Future<void> Function() seekFn) async {
|
||||
try {
|
||||
await seekFn();
|
||||
} on PlatformException catch (e) {
|
||||
if (e.code == 'COMMAND_FAILED' || e.code == 'NOT_INITIALIZED') {
|
||||
appLogger.w('Seek failed (${e.code}), player not ready');
|
||||
return;
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Debug helpers
|
||||
// ============================================
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'dart:io' show Platform;
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../models.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import 'player_base.dart';
|
||||
|
||||
/// Shared native implementation of [Player] for iOS, macOS, Android (MPV fallback), and Linux.
|
||||
@@ -156,15 +155,7 @@ class PlayerNative extends PlayerBase {
|
||||
|
||||
@override
|
||||
Future<void> seek(Duration position) async {
|
||||
try {
|
||||
await command(['seek', (position.inMilliseconds / 1000.0).toString(), 'absolute']);
|
||||
} on PlatformException catch (e) {
|
||||
if (e.code == 'COMMAND_FAILED' || e.code == 'NOT_INITIALIZED') {
|
||||
appLogger.w('Seek failed (${e.code}), player not ready');
|
||||
return;
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
await runSeek(() => command(['seek', (position.inMilliseconds / 1000.0).toString(), 'absolute']));
|
||||
}
|
||||
|
||||
// ============================================
|
||||
|
||||
@@ -8,7 +8,6 @@ import '../i18n/strings.g.dart';
|
||||
import 'base_media_list_detail_screen.dart';
|
||||
import 'focusable_detail_screen_mixin.dart';
|
||||
import '../mixins/grid_focus_node_mixin.dart';
|
||||
import '../focus/key_event_utils.dart';
|
||||
import '../focus/focusable_action_bar.dart';
|
||||
|
||||
/// Screen to browse all media featuring a specific actor
|
||||
@@ -130,27 +129,13 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen<ActorMediaScreen>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (BackKeyCoordinator.consumeIfHandled()) return;
|
||||
if (didPop) return;
|
||||
final shouldPop = handleBackNavigation();
|
||||
if (shouldPop && mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
CustomAppBar(title: Text(widget.actorName), pinned: true, actions: buildFocusableAppBarActions()),
|
||||
_buildActorHeader(),
|
||||
...buildStateSlivers(),
|
||||
if (items.isNotEmpty) buildFocusableGrid(items: items, onRefresh: updateItem),
|
||||
],
|
||||
),
|
||||
),
|
||||
return buildDetailScaffold(
|
||||
slivers: [
|
||||
CustomAppBar(title: Text(widget.actorName), pinned: true, actions: buildFocusableAppBarActions()),
|
||||
_buildActorHeader(),
|
||||
...buildStateSlivers(),
|
||||
if (items.isNotEmpty) buildFocusableGrid(items: items, onRefresh: updateItem),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import '../utils/snackbar_helper.dart';
|
||||
import 'base_media_list_detail_screen.dart';
|
||||
import 'focusable_detail_screen_mixin.dart';
|
||||
import '../mixins/grid_focus_node_mixin.dart';
|
||||
import '../focus/key_event_utils.dart';
|
||||
|
||||
/// Screen to display the contents of a collection
|
||||
class CollectionDetailScreen extends StatefulWidget {
|
||||
@@ -182,32 +181,18 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (BackKeyCoordinator.consumeIfHandled()) return;
|
||||
if (didPop) return;
|
||||
final shouldPop = handleBackNavigation();
|
||||
if (shouldPop && mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
CustomAppBar(title: Text(widget.collection.title!), actions: buildFocusableAppBarActions()),
|
||||
...buildStateSlivers(),
|
||||
if (items.isNotEmpty)
|
||||
buildFocusableGrid(
|
||||
items: items,
|
||||
onRefresh: updateItem,
|
||||
collectionId: widget.collection.ratingKey,
|
||||
onListRefresh: loadItems,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
return buildDetailScaffold(
|
||||
slivers: [
|
||||
CustomAppBar(title: Text(widget.collection.title!), actions: buildFocusableAppBarActions()),
|
||||
...buildStateSlivers(),
|
||||
if (items.isNotEmpty)
|
||||
buildFocusableGrid(
|
||||
items: items,
|
||||
onRefresh: updateItem,
|
||||
collectionId: widget.collection.ratingKey,
|
||||
onListRefresh: loadItems,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../focus/focusable_action_bar.dart';
|
||||
import '../focus/input_mode_tracker.dart';
|
||||
import '../focus/key_event_utils.dart';
|
||||
import '../mixins/grid_focus_node_mixin.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../services/settings_service.dart' show ViewMode;
|
||||
@@ -79,6 +80,27 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap [slivers] in the standard detail-screen scaffold — PopScope that
|
||||
/// defers to [handleBackNavigation], plus a Scaffold with a CustomScrollView
|
||||
/// bound to [scrollController]. Callers build the slivers themselves
|
||||
/// (typically `[appBar, ...header, ...buildStateSlivers(), grid]`).
|
||||
Widget buildDetailScaffold({required List<Widget> slivers}) {
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (BackKeyCoordinator.consumeIfHandled()) return;
|
||||
if (didPop) return;
|
||||
final shouldPop = handleBackNavigation();
|
||||
if (shouldPop && mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(controller: scrollController, slivers: slivers),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle back navigation for PopScope. Returns true if should pop.
|
||||
bool handleBackNavigation() {
|
||||
// If BACK was already handled by a key event, don't pop
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../models/livetv_channel.dart';
|
||||
import '../../models/livetv_program.dart';
|
||||
import '../../providers/multi_server_provider.dart';
|
||||
import '../../utils/live_tv_player_navigation.dart';
|
||||
import '../../utils/plex_image_helper.dart';
|
||||
import 'program_details_sheet.dart';
|
||||
|
||||
/// Shared live-TV actions: channel lookup, tuning, and program-details sheet.
|
||||
///
|
||||
/// Implementers expose their channel list via [liveTvChannels] and invoke
|
||||
/// [findChannel], [tuneChannel], and [showProgramDetails] as needed.
|
||||
mixin LiveTvActionsMixin<T extends StatefulWidget> on State<T> {
|
||||
/// Channel list used for lookups and passed into the playback navigator.
|
||||
List<LiveTvChannel> get liveTvChannels;
|
||||
|
||||
/// Look up a channel by identifier or key. Returns null if no match.
|
||||
LiveTvChannel? findChannel(String? channelIdentifier) {
|
||||
if (channelIdentifier == null) return null;
|
||||
return liveTvChannels.where((ch) {
|
||||
return ch.identifier == channelIdentifier || ch.key == channelIdentifier;
|
||||
}).firstOrNull;
|
||||
}
|
||||
|
||||
/// Start live playback for [channel] on its owning server.
|
||||
Future<void> tuneChannel(LiveTvChannel channel) async {
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
final serverInfo =
|
||||
multiServer.liveTvServers.where((s) => s.serverId == channel.serverId).firstOrNull ??
|
||||
multiServer.liveTvServers.firstOrNull;
|
||||
if (serverInfo == null) return;
|
||||
|
||||
final client = multiServer.getClientForServer(serverInfo.serverId);
|
||||
if (client == null) return;
|
||||
|
||||
await navigateToLiveTv(
|
||||
context,
|
||||
client: client,
|
||||
dvrKey: serverInfo.dvrKey,
|
||||
channel: channel,
|
||||
channels: liveTvChannels,
|
||||
);
|
||||
}
|
||||
|
||||
/// Open the program-details bottom sheet. The poster is resolved from
|
||||
/// [posterThumb] on the server identified by [posterServerId].
|
||||
void showProgramDetails({
|
||||
required LiveTvProgram program,
|
||||
required LiveTvChannel? channel,
|
||||
required String? posterThumb,
|
||||
required String posterServerId,
|
||||
}) {
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
final client = multiServer.getClientForServer(posterServerId);
|
||||
String? posterUrl;
|
||||
if (posterThumb != null && client != null) {
|
||||
posterUrl = PlexImageHelper.getOptimizedImageUrl(
|
||||
client: client,
|
||||
thumbPath: posterThumb,
|
||||
maxWidth: 80,
|
||||
maxHeight: 120,
|
||||
devicePixelRatio: PlexImageHelper.effectiveDevicePixelRatio(context),
|
||||
imageType: ImageType.poster,
|
||||
);
|
||||
}
|
||||
|
||||
showProgramDetailsSheet(
|
||||
context,
|
||||
program: program,
|
||||
channel: channel,
|
||||
posterUrl: posterUrl,
|
||||
onTuneChannel: channel != null ? () => tuneChannel(channel) : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -321,6 +321,21 @@ class _LiveTvScreenState extends State<LiveTvScreen>
|
||||
// Build
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
List<Widget> _buildTabChipItems() {
|
||||
return [
|
||||
for (int i = 0; i < LiveTvTab.values.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 8),
|
||||
buildTabChip(
|
||||
_getTabLabel(LiveTvTab.values[i]),
|
||||
i,
|
||||
onSelectWhenActive: _focusCurrentTab,
|
||||
onNavigateDown: _focusCurrentTab,
|
||||
onNavigateRightFromLast: () => _actionBarKey.currentState?.requestFocusOnFirst(),
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
@@ -328,22 +343,7 @@ class _LiveTvScreenState extends State<LiveTvScreen>
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: useSideNav
|
||||
? Row(
|
||||
children: [
|
||||
for (int i = 0; i < LiveTvTab.values.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 8),
|
||||
buildTabChip(
|
||||
_getTabLabel(LiveTvTab.values[i]),
|
||||
i,
|
||||
onSelectWhenActive: _focusCurrentTab,
|
||||
onNavigateDown: _focusCurrentTab,
|
||||
onNavigateRightFromLast: () => _actionBarKey.currentState?.requestFocusOnFirst(),
|
||||
),
|
||||
],
|
||||
],
|
||||
)
|
||||
: Text(t.liveTv.title),
|
||||
title: useSideNav ? Row(children: _buildTabChipItems()) : Text(t.liveTv.title),
|
||||
actions: DesktopAppBarHelper.buildAdjustedActions([
|
||||
FocusableActionBar(
|
||||
key: _actionBarKey,
|
||||
@@ -407,20 +407,7 @@ class _LiveTvScreenState extends State<LiveTvScreen>
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
for (int i = 0; i < LiveTvTab.values.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 8),
|
||||
buildTabChip(
|
||||
_getTabLabel(LiveTvTab.values[i]),
|
||||
i,
|
||||
onSelectWhenActive: _focusCurrentTab,
|
||||
onNavigateDown: _focusCurrentTab,
|
||||
onNavigateRightFromLast: () => _actionBarKey.currentState?.requestFocusOnFirst(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
child: Row(children: _buildTabChipItems()),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
|
||||
@@ -9,12 +9,10 @@ import '../../models/livetv_program.dart';
|
||||
import '../../providers/multi_server_provider.dart';
|
||||
import '../../theme/mono_tokens.dart';
|
||||
import '../../utils/formatters.dart';
|
||||
import '../../utils/live_tv_player_navigation.dart';
|
||||
import '../../utils/plex_image_helper.dart';
|
||||
import '../../widgets/app_icon.dart';
|
||||
import '../../widgets/focused_scroll_scaffold.dart';
|
||||
import '../../widgets/overlay_sheet.dart';
|
||||
import 'program_details_sheet.dart';
|
||||
import 'live_tv_actions_mixin.dart';
|
||||
|
||||
/// Shows all upcoming airings of a show, matching the Plex "upcoming episodes" view.
|
||||
class LiveTvShowScheduleScreen extends StatefulWidget {
|
||||
@@ -33,10 +31,14 @@ class LiveTvShowScheduleScreen extends StatefulWidget {
|
||||
State<LiveTvShowScheduleScreen> createState() => _LiveTvShowScheduleScreenState();
|
||||
}
|
||||
|
||||
class _LiveTvShowScheduleScreenState extends State<LiveTvShowScheduleScreen> {
|
||||
class _LiveTvShowScheduleScreenState extends State<LiveTvShowScheduleScreen>
|
||||
with LiveTvActionsMixin<LiveTvShowScheduleScreen> {
|
||||
List<LiveTvProgram> _programs = [];
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
List<LiveTvChannel> get liveTvChannels => widget.channels;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -76,56 +78,6 @@ class _LiveTvShowScheduleScreenState extends State<LiveTvShowScheduleScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
LiveTvChannel? _findChannel(String? channelIdentifier) {
|
||||
if (channelIdentifier == null) return null;
|
||||
return widget.channels.where((ch) {
|
||||
return ch.identifier == channelIdentifier || ch.key == channelIdentifier;
|
||||
}).firstOrNull;
|
||||
}
|
||||
|
||||
Future<void> _tuneChannel(LiveTvChannel channel) async {
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
final serverInfo =
|
||||
multiServer.liveTvServers.where((s) => s.serverId == channel.serverId).firstOrNull ??
|
||||
multiServer.liveTvServers.firstOrNull;
|
||||
if (serverInfo == null) return;
|
||||
|
||||
final client = multiServer.getClientForServer(serverInfo.serverId);
|
||||
if (client == null) return;
|
||||
|
||||
await navigateToLiveTv(
|
||||
context,
|
||||
client: client,
|
||||
dvrKey: serverInfo.dvrKey,
|
||||
channel: channel,
|
||||
channels: widget.channels,
|
||||
);
|
||||
}
|
||||
|
||||
void _showProgramDetails(LiveTvProgram program, LiveTvChannel? channel) {
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
final client = multiServer.getClientForServer(widget.serverId);
|
||||
String? posterUrl;
|
||||
if (program.thumb != null && client != null) {
|
||||
posterUrl = PlexImageHelper.getOptimizedImageUrl(
|
||||
client: client,
|
||||
thumbPath: program.thumb,
|
||||
maxWidth: 80,
|
||||
maxHeight: 120,
|
||||
devicePixelRatio: PlexImageHelper.effectiveDevicePixelRatio(context),
|
||||
imageType: ImageType.poster,
|
||||
);
|
||||
}
|
||||
|
||||
showProgramDetailsSheet(
|
||||
context,
|
||||
program: program,
|
||||
channel: channel,
|
||||
posterUrl: posterUrl,
|
||||
onTuneChannel: channel != null ? () => _tuneChannel(channel) : null,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return OverlaySheetHost(
|
||||
@@ -140,12 +92,17 @@ class _LiveTvShowScheduleScreenState extends State<LiveTvShowScheduleScreen> {
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final program = _programs[index];
|
||||
final channel = _findChannel(program.channelIdentifier);
|
||||
final channel = findChannel(program.channelIdentifier);
|
||||
void onTap() {
|
||||
if (program.isCurrentlyAiring && channel != null) {
|
||||
_tuneChannel(channel);
|
||||
tuneChannel(channel);
|
||||
} else {
|
||||
_showProgramDetails(program, channel);
|
||||
showProgramDetails(
|
||||
program: program,
|
||||
channel: channel,
|
||||
posterThumb: program.thumb,
|
||||
posterServerId: widget.serverId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,8 +17,6 @@ import '../../../providers/settings_provider.dart';
|
||||
import '../../../utils/grid_size_calculator.dart';
|
||||
import '../../../theme/mono_tokens.dart';
|
||||
import '../../../utils/app_logger.dart';
|
||||
import '../../../utils/live_tv_player_navigation.dart';
|
||||
import '../../../utils/plex_image_helper.dart';
|
||||
import '../../../utils/provider_extensions.dart';
|
||||
import '../../../widgets/app_icon.dart';
|
||||
import '../../../widgets/focus_builders.dart';
|
||||
@@ -26,8 +24,8 @@ import '../../../widgets/overlay_sheet.dart';
|
||||
import '../../../utils/scroll_utils.dart';
|
||||
import '../../../widgets/horizontal_scroll_with_arrows.dart';
|
||||
import '../../../widgets/plex_optimized_image.dart';
|
||||
import '../live_tv_actions_mixin.dart';
|
||||
import '../live_tv_show_schedule_screen.dart';
|
||||
import '../program_details_sheet.dart';
|
||||
|
||||
class WhatsOnTab extends StatefulWidget {
|
||||
final List<LiveTvChannel> channels;
|
||||
@@ -40,12 +38,15 @@ class WhatsOnTab extends StatefulWidget {
|
||||
State<WhatsOnTab> createState() => WhatsOnTabState();
|
||||
}
|
||||
|
||||
class WhatsOnTabState extends State<WhatsOnTab> {
|
||||
class WhatsOnTabState extends State<WhatsOnTab> with LiveTvActionsMixin<WhatsOnTab> {
|
||||
List<LiveTvHubResult> _hubs = [];
|
||||
bool _isLoading = true;
|
||||
Timer? _refreshTimer;
|
||||
List<GlobalKey<_LiveTvHubSectionState>> _hubKeys = [];
|
||||
|
||||
@override
|
||||
List<LiveTvChannel> get liveTvChannels => widget.channels;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -135,39 +136,12 @@ class WhatsOnTabState extends State<WhatsOnTab> {
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Find a channel by its identifier from the channel list.
|
||||
LiveTvChannel? _findChannel(String? channelIdentifier) {
|
||||
if (channelIdentifier == null) return null;
|
||||
return widget.channels.where((ch) {
|
||||
return ch.identifier == channelIdentifier || ch.key == channelIdentifier;
|
||||
}).firstOrNull;
|
||||
}
|
||||
|
||||
Future<void> _tuneChannel(LiveTvChannel channel) async {
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
final serverInfo =
|
||||
multiServer.liveTvServers.where((s) => s.serverId == channel.serverId).firstOrNull ??
|
||||
multiServer.liveTvServers.firstOrNull;
|
||||
if (serverInfo == null) return;
|
||||
|
||||
final client = multiServer.getClientForServer(serverInfo.serverId);
|
||||
if (client == null) return;
|
||||
|
||||
await navigateToLiveTv(
|
||||
context,
|
||||
client: client,
|
||||
dvrKey: serverInfo.dvrKey,
|
||||
channel: channel,
|
||||
channels: widget.channels,
|
||||
);
|
||||
}
|
||||
|
||||
void _onItemTap(LiveTvHubEntry entry) {
|
||||
final channel = _findChannel(entry.program.channelIdentifier);
|
||||
final channel = findChannel(entry.program.channelIdentifier);
|
||||
|
||||
if (entry.program.isCurrentlyAiring && channel != null) {
|
||||
// Live → play directly
|
||||
_tuneChannel(channel);
|
||||
tuneChannel(channel);
|
||||
} else if (entry.metadata.mediaType == PlexMediaType.show) {
|
||||
// Show with upcoming episodes → show full schedule
|
||||
Navigator.of(context).push(
|
||||
@@ -181,36 +155,13 @@ class WhatsOnTabState extends State<WhatsOnTab> {
|
||||
);
|
||||
} else {
|
||||
// Individual program (episode, movie, etc.) → bottom sheet
|
||||
_showProgramDetails(entry, channel);
|
||||
}
|
||||
}
|
||||
|
||||
void _showProgramDetails(LiveTvHubEntry entry, LiveTvChannel? channel) {
|
||||
final program = entry.program;
|
||||
final metadata = entry.metadata;
|
||||
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
final client = multiServer.getClientForServer(metadata.serverId ?? '');
|
||||
final posterImage = metadata.grandparentThumb ?? metadata.thumb;
|
||||
String? posterUrl;
|
||||
if (posterImage != null && client != null) {
|
||||
posterUrl = PlexImageHelper.getOptimizedImageUrl(
|
||||
client: client,
|
||||
thumbPath: posterImage,
|
||||
maxWidth: 80,
|
||||
maxHeight: 120,
|
||||
devicePixelRatio: PlexImageHelper.effectiveDevicePixelRatio(context),
|
||||
imageType: ImageType.poster,
|
||||
showProgramDetails(
|
||||
program: entry.program,
|
||||
channel: channel,
|
||||
posterThumb: entry.metadata.grandparentThumb ?? entry.metadata.thumb,
|
||||
posterServerId: entry.metadata.serverId ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
showProgramDetailsSheet(
|
||||
context,
|
||||
program: program,
|
||||
channel: channel,
|
||||
posterUrl: posterUrl,
|
||||
onTuneChannel: channel != null ? () => _tuneChannel(channel) : null,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -233,7 +184,12 @@ class WhatsOnTabState extends State<WhatsOnTab> {
|
||||
key: _hubKeys[index],
|
||||
hub: _hubs[index],
|
||||
onTap: _onItemTap,
|
||||
onLongPress: (entry) => _showProgramDetails(entry, _findChannel(entry.program.channelIdentifier)),
|
||||
onLongPress: (entry) => showProgramDetails(
|
||||
program: entry.program,
|
||||
channel: findChannel(entry.program.channelIdentifier),
|
||||
posterThumb: entry.metadata.grandparentThumb ?? entry.metadata.thumb,
|
||||
posterServerId: entry.metadata.serverId ?? '',
|
||||
),
|
||||
onVerticalNavigation: (isUp) => _handleVerticalNavigation(index, isUp),
|
||||
onBack: widget.onBack,
|
||||
);
|
||||
|
||||
@@ -723,44 +723,20 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
|
||||
(value: 'tvdbAbsolute', label: t.metadataEdit.tvdbAbsolute),
|
||||
],
|
||||
),
|
||||
_buildAdvancedTile(
|
||||
title: t.metadataEdit.metadataLanguage,
|
||||
prefKey: 'languageOverride',
|
||||
options: _metadataLanguageOptions(t.metadataEdit.libraryDefault),
|
||||
),
|
||||
_buildAdvancedTile(
|
||||
title: t.metadataEdit.useOriginalTitle,
|
||||
prefKey: 'useOriginalTitle',
|
||||
options: [
|
||||
(value: '-1', label: t.metadataEdit.libraryDefault),
|
||||
(value: '0', label: t.common.no),
|
||||
(value: '1', label: t.common.yes),
|
||||
],
|
||||
),
|
||||
_buildAdvancedTile(
|
||||
title: t.metadataEdit.preferredAudioLanguage,
|
||||
prefKey: 'audioLanguage',
|
||||
options: _audioSubtitleLanguageOptions(t.metadataEdit.accountDefault),
|
||||
),
|
||||
_buildAdvancedTile(
|
||||
title: t.metadataEdit.preferredSubtitleLanguage,
|
||||
prefKey: 'subtitleLanguage',
|
||||
options: _audioSubtitleLanguageOptions(t.metadataEdit.accountDefault),
|
||||
),
|
||||
_buildAdvancedTile(
|
||||
title: t.metadataEdit.subtitleMode,
|
||||
prefKey: 'subtitleMode',
|
||||
options: [
|
||||
(value: '-1', label: t.metadataEdit.accountDefault),
|
||||
(value: '0', label: t.metadataEdit.manuallySelected),
|
||||
(value: '1', label: t.metadataEdit.shownWithForeignAudio),
|
||||
(value: '2', label: t.metadataEdit.alwaysEnabled),
|
||||
],
|
||||
),
|
||||
..._buildMetadataLanguageTiles(),
|
||||
..._buildAudioSubtitleTiles(t.metadataEdit.accountDefault),
|
||||
];
|
||||
}
|
||||
|
||||
List<Widget> _buildMovieAdvancedSettings() {
|
||||
return _buildMetadataLanguageTiles();
|
||||
}
|
||||
|
||||
List<Widget> _buildSeasonAdvancedSettings() {
|
||||
return _buildAudioSubtitleTiles(t.metadataEdit.seriesDefault);
|
||||
}
|
||||
|
||||
List<Widget> _buildMetadataLanguageTiles() {
|
||||
return [
|
||||
_buildAdvancedTile(
|
||||
title: t.metadataEdit.metadataLanguage,
|
||||
@@ -779,23 +755,23 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
|
||||
];
|
||||
}
|
||||
|
||||
List<Widget> _buildSeasonAdvancedSettings() {
|
||||
List<Widget> _buildAudioSubtitleTiles(String defaultLabel) {
|
||||
return [
|
||||
_buildAdvancedTile(
|
||||
title: t.metadataEdit.preferredAudioLanguage,
|
||||
prefKey: 'audioLanguage',
|
||||
options: _audioSubtitleLanguageOptions(t.metadataEdit.seriesDefault),
|
||||
options: _audioSubtitleLanguageOptions(defaultLabel),
|
||||
),
|
||||
_buildAdvancedTile(
|
||||
title: t.metadataEdit.preferredSubtitleLanguage,
|
||||
prefKey: 'subtitleLanguage',
|
||||
options: _audioSubtitleLanguageOptions(t.metadataEdit.seriesDefault),
|
||||
options: _audioSubtitleLanguageOptions(defaultLabel),
|
||||
),
|
||||
_buildAdvancedTile(
|
||||
title: t.metadataEdit.subtitleMode,
|
||||
prefKey: 'subtitleMode',
|
||||
options: [
|
||||
(value: '-1', label: t.metadataEdit.seriesDefault),
|
||||
(value: '-1', label: defaultLabel),
|
||||
(value: '0', label: t.metadataEdit.manuallySelected),
|
||||
(value: '1', label: t.metadataEdit.shownWithForeignAudio),
|
||||
(value: '2', label: t.metadataEdit.alwaysEnabled),
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
@@ -8,6 +6,7 @@ import '../../focus/dpad_navigator.dart';
|
||||
import '../../focus/focus_theme.dart';
|
||||
import '../../focus/input_mode_tracker.dart';
|
||||
import '../../focus/key_event_utils.dart';
|
||||
import '../../focus/key_repeat_helper.dart';
|
||||
import '../../focus/focusable_button.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../utils/platform_detector.dart';
|
||||
@@ -143,11 +142,10 @@ class _TvPinInput extends StatefulWidget {
|
||||
State<_TvPinInput> createState() => _TvPinInputState();
|
||||
}
|
||||
|
||||
class _TvPinInputState extends State<_TvPinInput> {
|
||||
class _TvPinInputState extends State<_TvPinInput> with KeyRepeatHelper<_TvPinInput> {
|
||||
final List<int?> _digits = [null, null, null, null];
|
||||
int _activeIndex = 0;
|
||||
bool _isFocused = false;
|
||||
Timer? _repeatTimer;
|
||||
|
||||
// Hidden text fields for mobile keyboard input
|
||||
final List<FocusNode> _mobileFocusNodes = List.generate(4, (_) => FocusNode());
|
||||
@@ -173,7 +171,7 @@ class _TvPinInputState extends State<_TvPinInput> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_repeatTimer?.cancel();
|
||||
stopRepeat();
|
||||
_focusNode.dispose();
|
||||
for (final node in _mobileFocusNodes) {
|
||||
node.dispose();
|
||||
@@ -222,21 +220,6 @@ class _TvPinInputState extends State<_TvPinInput> {
|
||||
});
|
||||
}
|
||||
|
||||
void _startRepeat(VoidCallback action) {
|
||||
action();
|
||||
_repeatTimer?.cancel();
|
||||
_repeatTimer = Timer(const Duration(milliseconds: 400), () {
|
||||
_repeatTimer = Timer.periodic(const Duration(milliseconds: 100), (_) {
|
||||
action();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _stopRepeat() {
|
||||
_repeatTimer?.cancel();
|
||||
_repeatTimer = null;
|
||||
}
|
||||
|
||||
// Map digit keys (both main keyboard and numpad)
|
||||
static final _digitKeyMap = <LogicalKeyboardKey, int>{
|
||||
LogicalKeyboardKey.digit0: 0,
|
||||
@@ -296,13 +279,13 @@ class _TvPinInputState extends State<_TvPinInput> {
|
||||
|
||||
// Up arrow → increment digit
|
||||
if (key.isUpKey) {
|
||||
_startRepeat(_incrementDigit);
|
||||
startRepeat(_incrementDigit);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// Down arrow → decrement digit
|
||||
if (key.isDownKey) {
|
||||
_startRepeat(_decrementDigit);
|
||||
startRepeat(_decrementDigit);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
@@ -333,7 +316,7 @@ class _TvPinInputState extends State<_TvPinInput> {
|
||||
|
||||
if (event is KeyUpEvent) {
|
||||
if (key.isUpKey || key.isDownKey) {
|
||||
_stopRepeat();
|
||||
stopRepeat();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
@@ -394,7 +377,7 @@ class _TvPinInputState extends State<_TvPinInput> {
|
||||
autofocus: true,
|
||||
onFocusChange: (hasFocus) {
|
||||
setState(() => _isFocused = hasFocus);
|
||||
if (!hasFocus) _stopRepeat();
|
||||
if (!hasFocus) stopRepeat();
|
||||
},
|
||||
onKeyEvent: _handleKeyEvent,
|
||||
child: _buildDigitRow(context, showArrows: showArrows),
|
||||
|
||||
@@ -145,22 +145,8 @@ class AmbientLightingService {
|
||||
('BLUR8A', 'BLUR8B', '6.0', 'Blur2'),
|
||||
('BLUR8B', 'BLUR8C', '12.0', 'Blur3'),
|
||||
];
|
||||
for (final (input, output, offset, desc) in blur8Steps) {
|
||||
buf.writeln('//!HOOK MAIN');
|
||||
buf.writeln('//!BIND $input');
|
||||
buf.writeln('//!SAVE $output');
|
||||
buf.writeln('//!WIDTH $input.w');
|
||||
buf.writeln('//!HEIGHT $input.h');
|
||||
buf.writeln('//!DESC Ambient Lighting $desc');
|
||||
buf.writeln('vec4 hook() {');
|
||||
buf.writeln(' vec2 ps = ${input}_pt;');
|
||||
buf.writeln(' vec4 s = ${input}_tex(${input}_pos + vec2( $offset, $offset) * ps)');
|
||||
buf.writeln(' + ${input}_tex(${input}_pos + vec2( $offset, -$offset) * ps)');
|
||||
buf.writeln(' + ${input}_tex(${input}_pos + vec2(-$offset, $offset) * ps)');
|
||||
buf.writeln(' + ${input}_tex(${input}_pos + vec2(-$offset, -$offset) * ps);');
|
||||
buf.writeln(' return s * 0.25;');
|
||||
buf.writeln('}');
|
||||
buf.writeln();
|
||||
for (final step in blur8Steps) {
|
||||
_writeKawasePass(buf, step.$1, step.$2, step.$3, step.$4);
|
||||
}
|
||||
|
||||
// Pass 6: Downscale the already-blurred 1/8 texture to 1/64.
|
||||
@@ -177,22 +163,8 @@ class AmbientLightingService {
|
||||
|
||||
// Pass 7-8: Two more Kawase blur passes at 1/64 for maximum diffusion.
|
||||
const blur64Steps = [('TINY', 'GLOW1', '3.0', 'Blur4'), ('GLOW1', 'GLOW', '6.0', 'Blur5')];
|
||||
for (final (input, output, offset, desc) in blur64Steps) {
|
||||
buf.writeln('//!HOOK MAIN');
|
||||
buf.writeln('//!BIND $input');
|
||||
buf.writeln('//!SAVE $output');
|
||||
buf.writeln('//!WIDTH $input.w');
|
||||
buf.writeln('//!HEIGHT $input.h');
|
||||
buf.writeln('//!DESC Ambient Lighting $desc');
|
||||
buf.writeln('vec4 hook() {');
|
||||
buf.writeln(' vec2 ps = ${input}_pt;');
|
||||
buf.writeln(' vec4 s = ${input}_tex(${input}_pos + vec2( $offset, $offset) * ps)');
|
||||
buf.writeln(' + ${input}_tex(${input}_pos + vec2( $offset, -$offset) * ps)');
|
||||
buf.writeln(' + ${input}_tex(${input}_pos + vec2(-$offset, $offset) * ps)');
|
||||
buf.writeln(' + ${input}_tex(${input}_pos + vec2(-$offset, -$offset) * ps);');
|
||||
buf.writeln(' return s * 0.25;');
|
||||
buf.writeln('}');
|
||||
buf.writeln();
|
||||
for (final step in blur64Steps) {
|
||||
_writeKawasePass(buf, step.$1, step.$2, step.$3, step.$4);
|
||||
}
|
||||
|
||||
// Pass 9: Composite — no //!SAVE so this replaces MAIN.
|
||||
@@ -230,6 +202,24 @@ class AmbientLightingService {
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
void _writeKawasePass(StringBuffer buf, String input, String output, String offset, String desc) {
|
||||
buf.writeln('//!HOOK MAIN');
|
||||
buf.writeln('//!BIND $input');
|
||||
buf.writeln('//!SAVE $output');
|
||||
buf.writeln('//!WIDTH $input.w');
|
||||
buf.writeln('//!HEIGHT $input.h');
|
||||
buf.writeln('//!DESC Ambient Lighting $desc');
|
||||
buf.writeln('vec4 hook() {');
|
||||
buf.writeln(' vec2 ps = ${input}_pt;');
|
||||
buf.writeln(' vec4 s = ${input}_tex(${input}_pos + vec2( $offset, $offset) * ps)');
|
||||
buf.writeln(' + ${input}_tex(${input}_pos + vec2( $offset, -$offset) * ps)');
|
||||
buf.writeln(' + ${input}_tex(${input}_pos + vec2(-$offset, $offset) * ps)');
|
||||
buf.writeln(' + ${input}_tex(${input}_pos + vec2(-$offset, -$offset) * ps);');
|
||||
buf.writeln(' return s * 0.25;');
|
||||
buf.writeln('}');
|
||||
buf.writeln();
|
||||
}
|
||||
|
||||
/// Write the shader to a temp file and return the path.
|
||||
Future<String> _writeShaderToTemp(String shader) async {
|
||||
final cacheDir = await getTemporaryDirectory();
|
||||
|
||||
@@ -296,35 +296,27 @@ class GamepadService with WindowListener {
|
||||
}
|
||||
|
||||
void _dispatchKeyDown(LogicalKeyboardKey logicalKey) {
|
||||
final focusNode = FocusManager.instance.primaryFocus;
|
||||
if (focusNode == null) return;
|
||||
|
||||
final event = KeyDownEvent(
|
||||
physicalKey: _getPhysicalKey(logicalKey),
|
||||
logicalKey: logicalKey,
|
||||
timeStamp: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch),
|
||||
_dispatchKeyEvent(
|
||||
KeyDownEvent(
|
||||
physicalKey: _getPhysicalKey(logicalKey),
|
||||
logicalKey: logicalKey,
|
||||
timeStamp: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch),
|
||||
),
|
||||
);
|
||||
|
||||
FocusNode? node = focusNode;
|
||||
while (node != null) {
|
||||
if (node.onKeyEvent != null) {
|
||||
if (node.onKeyEvent!(node, event) == KeyEventResult.handled) break;
|
||||
}
|
||||
node = node.parent;
|
||||
}
|
||||
}
|
||||
|
||||
void _dispatchKeyUp(LogicalKeyboardKey logicalKey) {
|
||||
final focusNode = FocusManager.instance.primaryFocus;
|
||||
if (focusNode == null) return;
|
||||
|
||||
final event = KeyUpEvent(
|
||||
physicalKey: _getPhysicalKey(logicalKey),
|
||||
logicalKey: logicalKey,
|
||||
timeStamp: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch),
|
||||
_dispatchKeyEvent(
|
||||
KeyUpEvent(
|
||||
physicalKey: _getPhysicalKey(logicalKey),
|
||||
logicalKey: logicalKey,
|
||||
timeStamp: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
FocusNode? node = focusNode;
|
||||
void _dispatchKeyEvent(KeyEvent event) {
|
||||
FocusNode? node = FocusManager.instance.primaryFocus;
|
||||
while (node != null) {
|
||||
if (node.onKeyEvent != null) {
|
||||
if (node.onKeyEvent!(node, event) == KeyEventResult.handled) break;
|
||||
|
||||
@@ -16,6 +16,70 @@ import '../utils/language_codes.dart';
|
||||
// codec, title, etc.) instead of list index, since the two may be ordered
|
||||
// differently.
|
||||
|
||||
/// Score how well an MPV subtitle track matches a Plex subtitle track.
|
||||
/// Language (+10 / +1 exact) and codec (+5) carry the most weight; title,
|
||||
/// forced flag, and identical ordinal position (only when [ordinalMatches]
|
||||
/// is true) add smaller nudges.
|
||||
int _scoreSubtitleMatch(SubtitleTrack mpvTrack, PlexSubtitleTrack plexTrack, {required bool ordinalMatches}) {
|
||||
int score = 0;
|
||||
|
||||
if (_languagesMatch(mpvTrack.language, plexTrack.languageCode)) {
|
||||
score += 10;
|
||||
if (_languageCodesExactMatch(mpvTrack.language, plexTrack.languageCode)) {
|
||||
score += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (_subtitleCodecsMatch(mpvTrack.codec, plexTrack.codec)) {
|
||||
score += 5;
|
||||
}
|
||||
|
||||
score += _titleScore(mpvTrack.title, plexTrack.title, plexTrack.displayTitle);
|
||||
|
||||
if (mpvTrack.isForced == plexTrack.forced) {
|
||||
score += 2;
|
||||
}
|
||||
|
||||
if (ordinalMatches) {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
/// Score how well an MPV audio track matches a Plex audio track.
|
||||
/// Language (+10 / +1 exact) and codec (+5) dominate; channel count (+3),
|
||||
/// title match (+2), and identical ordinal position ([ordinalMatches], +1)
|
||||
/// act as tiebreakers.
|
||||
int _scoreAudioMatch(AudioTrack mpvTrack, PlexAudioTrack plexTrack, {required bool ordinalMatches}) {
|
||||
int score = 0;
|
||||
|
||||
if (_languagesMatch(mpvTrack.language, plexTrack.languageCode)) {
|
||||
score += 10;
|
||||
if (_languageCodesExactMatch(mpvTrack.language, plexTrack.languageCode)) {
|
||||
score += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (_audioCodecsMatch(mpvTrack.codec, plexTrack.codec)) {
|
||||
score += 5;
|
||||
}
|
||||
|
||||
if (mpvTrack.channels != null && plexTrack.channels != null && mpvTrack.channels == plexTrack.channels) {
|
||||
score += 3;
|
||||
}
|
||||
|
||||
if (_titlesMatch(mpvTrack.title, plexTrack.title, plexTrack.displayTitle)) {
|
||||
score += 2;
|
||||
}
|
||||
|
||||
if (ordinalMatches) {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
/// Find the MPV subtitle track that matches a Plex subtitle track
|
||||
SubtitleTrack? findMpvTrackForPlexSubtitle(
|
||||
PlexSubtitleTrack plexTrack,
|
||||
@@ -50,37 +114,10 @@ SubtitleTrack? findMpvTrackForPlexSubtitle(
|
||||
// Skip external tracks when matching internal Plex tracks
|
||||
if (!plexTrack.isExternal && mpvTrack.isExternal) continue;
|
||||
|
||||
int score = 0;
|
||||
final ordinalMatches =
|
||||
internalMpvTracks != null && plexOrdinal >= 0 && internalMpvTracks.indexOf(mpvTrack) == plexOrdinal;
|
||||
|
||||
// Language match is most important (+10, +1 bonus for exact code match)
|
||||
if (_languagesMatch(mpvTrack.language, plexTrack.languageCode)) {
|
||||
score += 10;
|
||||
if (_languageCodesExactMatch(mpvTrack.language, plexTrack.languageCode)) {
|
||||
score += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Codec match (+5)
|
||||
if (_subtitleCodecsMatch(mpvTrack.codec, plexTrack.codec)) {
|
||||
score += 5;
|
||||
}
|
||||
|
||||
// Title match (+3 for text match, +1 for null/empty)
|
||||
score += _titleScore(mpvTrack.title, plexTrack.title, plexTrack.displayTitle);
|
||||
|
||||
// Forced flag match (+2)
|
||||
if (mpvTrack.isForced == plexTrack.forced) {
|
||||
score += 2;
|
||||
}
|
||||
|
||||
// Ordinal position tiebreaker (+1): when all properties match identically,
|
||||
// prefer the track at the same position in both lists.
|
||||
if (internalMpvTracks != null && plexOrdinal >= 0) {
|
||||
final mpvOrdinal = internalMpvTracks.indexOf(mpvTrack);
|
||||
if (mpvOrdinal >= 0 && plexOrdinal == mpvOrdinal) {
|
||||
score += 1;
|
||||
}
|
||||
}
|
||||
final score = _scoreSubtitleMatch(mpvTrack, plexTrack, ordinalMatches: ordinalMatches);
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
@@ -123,36 +160,10 @@ PlexSubtitleTrack? findPlexTrackForMpvSubtitle(
|
||||
// Skip external Plex tracks when matching internal MPV tracks
|
||||
if (!mpvTrack.isExternal && plexTrack.isExternal) continue;
|
||||
|
||||
int score = 0;
|
||||
final ordinalMatches =
|
||||
internalPlexTracks != null && mpvOrdinal >= 0 && internalPlexTracks.indexOf(plexTrack) == mpvOrdinal;
|
||||
|
||||
// Language match is most important (+10, +1 bonus for exact code match)
|
||||
if (_languagesMatch(mpvTrack.language, plexTrack.languageCode)) {
|
||||
score += 10;
|
||||
if (_languageCodesExactMatch(mpvTrack.language, plexTrack.languageCode)) {
|
||||
score += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Codec match (+5)
|
||||
if (_subtitleCodecsMatch(mpvTrack.codec, plexTrack.codec)) {
|
||||
score += 5;
|
||||
}
|
||||
|
||||
// Title match (+3 for text match, +1 for null/empty)
|
||||
score += _titleScore(mpvTrack.title, plexTrack.title, plexTrack.displayTitle);
|
||||
|
||||
// Forced flag match (+2)
|
||||
if (mpvTrack.isForced == plexTrack.forced) {
|
||||
score += 2;
|
||||
}
|
||||
|
||||
// Ordinal position tiebreaker (+1)
|
||||
if (internalPlexTracks != null && mpvOrdinal >= 0) {
|
||||
final plexOrdinal = internalPlexTracks.indexOf(plexTrack);
|
||||
if (plexOrdinal >= 0 && mpvOrdinal == plexOrdinal) {
|
||||
score += 1;
|
||||
}
|
||||
}
|
||||
final score = _scoreSubtitleMatch(mpvTrack, plexTrack, ordinalMatches: ordinalMatches);
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
@@ -177,40 +188,9 @@ AudioTrack? findMpvTrackForPlexAudio(
|
||||
final plexOrdinal = allPlexTracks?.indexOf(plexTrack) ?? -1;
|
||||
|
||||
for (final mpvTrack in mpvTracks) {
|
||||
int score = 0;
|
||||
final ordinalMatches = plexOrdinal >= 0 && mpvTracks.indexOf(mpvTrack) == plexOrdinal;
|
||||
|
||||
// Language match is most important (+10, +1 bonus for exact code match)
|
||||
if (_languagesMatch(mpvTrack.language, plexTrack.languageCode)) {
|
||||
score += 10;
|
||||
if (_languageCodesExactMatch(mpvTrack.language, plexTrack.languageCode)) {
|
||||
score += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Codec match (+5)
|
||||
if (_audioCodecsMatch(mpvTrack.codec, plexTrack.codec)) {
|
||||
score += 5;
|
||||
}
|
||||
|
||||
// Channel count match (+3)
|
||||
if (mpvTrack.channels != null && plexTrack.channels != null) {
|
||||
if (mpvTrack.channels == plexTrack.channels) {
|
||||
score += 3;
|
||||
}
|
||||
}
|
||||
|
||||
// Title match (+2)
|
||||
if (_titlesMatch(mpvTrack.title, plexTrack.title, plexTrack.displayTitle)) {
|
||||
score += 2;
|
||||
}
|
||||
|
||||
// Ordinal position tiebreaker (+1)
|
||||
if (plexOrdinal >= 0) {
|
||||
final mpvOrdinal = mpvTracks.indexOf(mpvTrack);
|
||||
if (mpvOrdinal >= 0 && plexOrdinal == mpvOrdinal) {
|
||||
score += 1;
|
||||
}
|
||||
}
|
||||
final score = _scoreAudioMatch(mpvTrack, plexTrack, ordinalMatches: ordinalMatches);
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
@@ -235,40 +215,9 @@ PlexAudioTrack? findPlexTrackForMpvAudio(
|
||||
final mpvOrdinal = allMpvTracks?.indexOf(mpvTrack) ?? -1;
|
||||
|
||||
for (final plexTrack in plexTracks) {
|
||||
int score = 0;
|
||||
final ordinalMatches = mpvOrdinal >= 0 && plexTracks.indexOf(plexTrack) == mpvOrdinal;
|
||||
|
||||
// Language match is most important (+10, +1 bonus for exact code match)
|
||||
if (_languagesMatch(mpvTrack.language, plexTrack.languageCode)) {
|
||||
score += 10;
|
||||
if (_languageCodesExactMatch(mpvTrack.language, plexTrack.languageCode)) {
|
||||
score += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Codec match (+5)
|
||||
if (_audioCodecsMatch(mpvTrack.codec, plexTrack.codec)) {
|
||||
score += 5;
|
||||
}
|
||||
|
||||
// Channel count match (+3)
|
||||
if (mpvTrack.channels != null && plexTrack.channels != null) {
|
||||
if (mpvTrack.channels == plexTrack.channels) {
|
||||
score += 3;
|
||||
}
|
||||
}
|
||||
|
||||
// Title match (+2)
|
||||
if (_titlesMatch(mpvTrack.title, plexTrack.title, plexTrack.displayTitle)) {
|
||||
score += 2;
|
||||
}
|
||||
|
||||
// Ordinal position tiebreaker (+1)
|
||||
if (mpvOrdinal >= 0) {
|
||||
final plexOrdinal = plexTracks.indexOf(plexTrack);
|
||||
if (plexOrdinal >= 0 && mpvOrdinal == plexOrdinal) {
|
||||
score += 1;
|
||||
}
|
||||
}
|
||||
final score = _scoreAudioMatch(mpvTrack, plexTrack, ordinalMatches: ordinalMatches);
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
|
||||
+35
-46
@@ -4,6 +4,7 @@ import '../focus/focusable_button.dart';
|
||||
import '../focus/input_mode_tracker.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../widgets/app_icon.dart';
|
||||
import '../widgets/dialog_action_button.dart';
|
||||
import '../widgets/focusable_list_tile.dart';
|
||||
import 'focus_utils.dart';
|
||||
|
||||
@@ -198,6 +199,29 @@ Future<String?> showMultilineTextInputDialog(
|
||||
);
|
||||
}
|
||||
|
||||
/// Shared lifecycle for the two private text-input dialogs below: a single
|
||||
/// [TextEditingController] seeded from [initialValue], plus a focus node for
|
||||
/// the save button.
|
||||
mixin _TextInputDialogStateMixin<T extends StatefulWidget> on State<T> {
|
||||
late final TextEditingController _controller;
|
||||
final _saveFocusNode = FocusNode();
|
||||
|
||||
String? get initialValue;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: initialValue);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_saveFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _MultilineTextInputDialog extends StatefulWidget {
|
||||
final String title;
|
||||
final String labelText;
|
||||
@@ -209,22 +233,10 @@ class _MultilineTextInputDialog extends StatefulWidget {
|
||||
State<_MultilineTextInputDialog> createState() => _MultilineTextInputDialogState();
|
||||
}
|
||||
|
||||
class _MultilineTextInputDialogState extends State<_MultilineTextInputDialog> {
|
||||
late final TextEditingController _controller;
|
||||
final _saveFocusNode = FocusNode();
|
||||
|
||||
class _MultilineTextInputDialogState extends State<_MultilineTextInputDialog>
|
||||
with _TextInputDialogStateMixin<_MultilineTextInputDialog> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: widget.initialValue);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_saveFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
String? get initialValue => widget.initialValue;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -241,14 +253,11 @@ class _MultilineTextInputDialogState extends State<_MultilineTextInputDialog> {
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
FocusableButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)),
|
||||
),
|
||||
FocusableButton(
|
||||
focusNode: _saveFocusNode,
|
||||
DialogActionButton(onPressed: () => Navigator.pop(context), label: t.common.cancel),
|
||||
DialogActionButton(
|
||||
onPressed: () => Navigator.pop(context, _controller.text),
|
||||
child: TextButton(onPressed: () => Navigator.pop(context, _controller.text), child: Text(t.common.save)),
|
||||
label: t.common.save,
|
||||
focusNode: _saveFocusNode,
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -280,22 +289,9 @@ class _TextInputDialog extends StatefulWidget {
|
||||
State<_TextInputDialog> createState() => _TextInputDialogState();
|
||||
}
|
||||
|
||||
class _TextInputDialogState extends State<_TextInputDialog> {
|
||||
late final TextEditingController _controller;
|
||||
final _saveFocusNode = FocusNode();
|
||||
|
||||
class _TextInputDialogState extends State<_TextInputDialog> with _TextInputDialogStateMixin<_TextInputDialog> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: widget.initialValue);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_saveFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
String? get initialValue => widget.initialValue;
|
||||
|
||||
void _submit() {
|
||||
final text = _controller.text;
|
||||
@@ -318,15 +314,8 @@ class _TextInputDialogState extends State<_TextInputDialog> {
|
||||
onSubmitted: (_) => _saveFocusNode.requestFocus(),
|
||||
),
|
||||
actions: [
|
||||
FocusableButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)),
|
||||
),
|
||||
FocusableButton(
|
||||
focusNode: _saveFocusNode,
|
||||
onPressed: _submit,
|
||||
child: TextButton(onPressed: _submit, child: Text(widget.confirmText ?? t.common.save)),
|
||||
),
|
||||
DialogActionButton(onPressed: () => Navigator.pop(context), label: t.common.cancel),
|
||||
DialogActionButton(onPressed: _submit, label: widget.confirmText ?? t.common.save, focusNode: _saveFocusNode),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,18 +61,8 @@ class _ArtworkPickerDialogState extends State<ArtworkPickerDialog> {
|
||||
if (url == null || _isApplying) return;
|
||||
|
||||
setState(() => _isApplying = true);
|
||||
|
||||
final success = await widget.client.setArtworkFromUrl(widget.ratingKey, widget.element, url);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => _isApplying = false);
|
||||
|
||||
if (success) {
|
||||
showSuccessSnackBar(context, t.metadataEdit.artworkUpdated);
|
||||
Navigator.pop(context, true);
|
||||
} else {
|
||||
showErrorSnackBar(context, t.metadataEdit.artworkUpdateFailed);
|
||||
}
|
||||
_handleArtworkUpdate(success);
|
||||
}
|
||||
|
||||
Future<void> _addFromUrl() async {
|
||||
@@ -86,18 +76,8 @@ class _ArtworkPickerDialogState extends State<ArtworkPickerDialog> {
|
||||
if (url == null || url.isEmpty || !mounted) return;
|
||||
|
||||
setState(() => _isApplying = true);
|
||||
|
||||
final success = await widget.client.setArtworkFromUrl(widget.ratingKey, widget.element, url);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => _isApplying = false);
|
||||
|
||||
if (success) {
|
||||
showSuccessSnackBar(context, t.metadataEdit.artworkUpdated);
|
||||
Navigator.pop(context, true);
|
||||
} else {
|
||||
showErrorSnackBar(context, t.metadataEdit.artworkUpdateFailed);
|
||||
}
|
||||
_handleArtworkUpdate(success);
|
||||
}
|
||||
|
||||
Future<void> _uploadFile() async {
|
||||
@@ -109,12 +89,13 @@ class _ArtworkPickerDialogState extends State<ArtworkPickerDialog> {
|
||||
if (bytes == null) return;
|
||||
|
||||
setState(() => _isApplying = true);
|
||||
|
||||
final success = await widget.client.uploadArtwork(widget.ratingKey, widget.element, bytes);
|
||||
_handleArtworkUpdate(success);
|
||||
}
|
||||
|
||||
void _handleArtworkUpdate(bool success) {
|
||||
if (!mounted) return;
|
||||
setState(() => _isApplying = false);
|
||||
|
||||
if (success) {
|
||||
showSuccessSnackBar(context, t.metadataEdit.artworkUpdated);
|
||||
Navigator.pop(context, true);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../focus/focusable_button.dart';
|
||||
|
||||
/// A dialog action button that wraps [FocusableButton] around a [TextButton]
|
||||
/// (or [FilledButton] when [isPrimary] is true).
|
||||
///
|
||||
/// Use in an [AlertDialog]'s `actions:` list — replaces the 4-line
|
||||
/// `FocusableButton(onPressed: ..., child: TextButton(onPressed: ..., ...))`
|
||||
/// boilerplate with a single call.
|
||||
class DialogActionButton extends StatelessWidget {
|
||||
final VoidCallback onPressed;
|
||||
final String label;
|
||||
final FocusNode? focusNode;
|
||||
final bool isPrimary;
|
||||
|
||||
const DialogActionButton({
|
||||
super.key,
|
||||
required this.onPressed,
|
||||
required this.label,
|
||||
this.focusNode,
|
||||
this.isPrimary = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FocusableButton(
|
||||
focusNode: focusNode,
|
||||
onPressed: onPressed,
|
||||
child: isPrimary
|
||||
? FilledButton(onPressed: onPressed, child: Text(label))
|
||||
: TextButton(onPressed: onPressed, child: Text(label)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../focus/dpad_navigator.dart';
|
||||
import '../utils/scroll_utils.dart';
|
||||
import '../focus/focusable_tile_mixin.dart';
|
||||
|
||||
/// A ListTile that accepts a FocusNode for keyboard/controller navigation.
|
||||
///
|
||||
@@ -83,55 +83,31 @@ class FocusableListTile extends StatefulWidget {
|
||||
State<FocusableListTile> createState() => _FocusableListTileState();
|
||||
}
|
||||
|
||||
class _FocusableListTileState extends State<FocusableListTile> {
|
||||
class _FocusableListTileState extends State<FocusableListTile> with FocusableTileStateMixin<FocusableListTile> {
|
||||
bool _suppressionConsumed = false;
|
||||
bool _isHoveredOrFocused = false;
|
||||
late FocusNode _effectiveFocusNode;
|
||||
bool _ownsNode = false;
|
||||
|
||||
@override
|
||||
FocusNode? get widgetFocusNode => widget.focusNode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initFocusNode();
|
||||
initFocusNode();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(FocusableListTile oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.focusNode != oldWidget.focusNode) {
|
||||
_disposeFocusNode();
|
||||
_initFocusNode();
|
||||
}
|
||||
updateFocusNode(oldWidget.focusNode);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposeFocusNode();
|
||||
disposeFocusNode();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _initFocusNode() {
|
||||
if (widget.focusNode != null) {
|
||||
_effectiveFocusNode = widget.focusNode!;
|
||||
_ownsNode = false;
|
||||
} else {
|
||||
_effectiveFocusNode = FocusNode();
|
||||
_ownsNode = true;
|
||||
}
|
||||
_effectiveFocusNode.addListener(_onFocusChange);
|
||||
}
|
||||
|
||||
void _disposeFocusNode() {
|
||||
_effectiveFocusNode.removeListener(_onFocusChange);
|
||||
if (_ownsNode) _effectiveFocusNode.dispose();
|
||||
}
|
||||
|
||||
void _onFocusChange() {
|
||||
if (_effectiveFocusNode.hasFocus) {
|
||||
scrollContextToCenter(context);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// When hovered/focused with a custom hoverColor, use onError-style foreground
|
||||
@@ -155,7 +131,7 @@ class _FocusableListTileState extends State<FocusableListTile> {
|
||||
selected: widget.selected,
|
||||
contentPadding: widget.contentPadding,
|
||||
visualDensity: widget.visualDensity,
|
||||
focusNode: widget.suppressInitialSelect ? null : _effectiveFocusNode,
|
||||
focusNode: widget.suppressInitialSelect ? null : effectiveFocusNode,
|
||||
autofocus: widget.suppressInitialSelect ? false : widget.autofocus,
|
||||
hoverColor: widget.hoverColor,
|
||||
textColor: textColor,
|
||||
@@ -168,7 +144,7 @@ class _FocusableListTileState extends State<FocusableListTile> {
|
||||
}
|
||||
|
||||
return Focus(
|
||||
focusNode: _effectiveFocusNode,
|
||||
focusNode: effectiveFocusNode,
|
||||
autofocus: widget.autofocus,
|
||||
onKeyEvent: (node, event) {
|
||||
if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) {
|
||||
@@ -234,53 +210,29 @@ class FocusableRadioListTile<T> extends StatefulWidget {
|
||||
State<FocusableRadioListTile<T>> createState() => _FocusableRadioListTileState<T>();
|
||||
}
|
||||
|
||||
class _FocusableRadioListTileState<T> extends State<FocusableRadioListTile<T>> {
|
||||
late FocusNode _effectiveFocusNode;
|
||||
bool _ownsNode = false;
|
||||
class _FocusableRadioListTileState<T> extends State<FocusableRadioListTile<T>>
|
||||
with FocusableTileStateMixin<FocusableRadioListTile<T>> {
|
||||
@override
|
||||
FocusNode? get widgetFocusNode => widget.focusNode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initFocusNode();
|
||||
initFocusNode();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(FocusableRadioListTile<T> oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.focusNode != oldWidget.focusNode) {
|
||||
_disposeFocusNode();
|
||||
_initFocusNode();
|
||||
}
|
||||
updateFocusNode(oldWidget.focusNode);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposeFocusNode();
|
||||
disposeFocusNode();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _initFocusNode() {
|
||||
if (widget.focusNode != null) {
|
||||
_effectiveFocusNode = widget.focusNode!;
|
||||
_ownsNode = false;
|
||||
} else {
|
||||
_effectiveFocusNode = FocusNode();
|
||||
_ownsNode = true;
|
||||
}
|
||||
_effectiveFocusNode.addListener(_onFocusChange);
|
||||
}
|
||||
|
||||
void _disposeFocusNode() {
|
||||
_effectiveFocusNode.removeListener(_onFocusChange);
|
||||
if (_ownsNode) _effectiveFocusNode.dispose();
|
||||
}
|
||||
|
||||
void _onFocusChange() {
|
||||
if (_effectiveFocusNode.hasFocus) {
|
||||
scrollContextToCenter(context);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RadioListTile<T>(
|
||||
@@ -291,7 +243,7 @@ class _FocusableRadioListTileState<T> extends State<FocusableRadioListTile<T>> {
|
||||
// groupValue and onChanged provided by RadioGroup ancestor
|
||||
dense: widget.dense,
|
||||
visualDensity: widget.visualDensity,
|
||||
focusNode: _effectiveFocusNode,
|
||||
focusNode: effectiveFocusNode,
|
||||
autofocus: widget.autofocus,
|
||||
enabled: widget.enabled,
|
||||
);
|
||||
@@ -346,53 +298,29 @@ class FocusableSwitchListTile extends StatefulWidget {
|
||||
State<FocusableSwitchListTile> createState() => _FocusableSwitchListTileState();
|
||||
}
|
||||
|
||||
class _FocusableSwitchListTileState extends State<FocusableSwitchListTile> {
|
||||
late FocusNode _effectiveFocusNode;
|
||||
bool _ownsNode = false;
|
||||
class _FocusableSwitchListTileState extends State<FocusableSwitchListTile>
|
||||
with FocusableTileStateMixin<FocusableSwitchListTile> {
|
||||
@override
|
||||
FocusNode? get widgetFocusNode => widget.focusNode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initFocusNode();
|
||||
initFocusNode();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(FocusableSwitchListTile oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.focusNode != oldWidget.focusNode) {
|
||||
_disposeFocusNode();
|
||||
_initFocusNode();
|
||||
}
|
||||
updateFocusNode(oldWidget.focusNode);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposeFocusNode();
|
||||
disposeFocusNode();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _initFocusNode() {
|
||||
if (widget.focusNode != null) {
|
||||
_effectiveFocusNode = widget.focusNode!;
|
||||
_ownsNode = false;
|
||||
} else {
|
||||
_effectiveFocusNode = FocusNode();
|
||||
_ownsNode = true;
|
||||
}
|
||||
_effectiveFocusNode.addListener(_onFocusChange);
|
||||
}
|
||||
|
||||
void _disposeFocusNode() {
|
||||
_effectiveFocusNode.removeListener(_onFocusChange);
|
||||
if (_ownsNode) _effectiveFocusNode.dispose();
|
||||
}
|
||||
|
||||
void _onFocusChange() {
|
||||
if (_effectiveFocusNode.hasFocus) {
|
||||
scrollContextToCenter(context);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SwitchListTile(
|
||||
@@ -403,7 +331,7 @@ class _FocusableSwitchListTileState extends State<FocusableSwitchListTile> {
|
||||
onChanged: widget.onChanged,
|
||||
dense: widget.dense,
|
||||
visualDensity: widget.visualDensity,
|
||||
focusNode: _effectiveFocusNode,
|
||||
focusNode: effectiveFocusNode,
|
||||
autofocus: widget.autofocus,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -99,60 +99,7 @@ class PlexOptimizedImage extends StatelessWidget {
|
||||
}) = PlexOptimizedImage._;
|
||||
|
||||
/// Named constructor for poster images with default fallback icon.
|
||||
const factory PlexOptimizedImage.poster({
|
||||
Key? key,
|
||||
PlexClient? client,
|
||||
required String? imagePath,
|
||||
double? width,
|
||||
double? height,
|
||||
BoxFit fit,
|
||||
FilterQuality filterQuality,
|
||||
Widget Function(BuildContext, String)? placeholder,
|
||||
Widget Function(BuildContext, String, dynamic)? errorWidget,
|
||||
Duration fadeInDuration,
|
||||
bool enableTranscoding,
|
||||
String? cacheKey,
|
||||
Alignment alignment,
|
||||
String? localFilePath,
|
||||
}) = PlexOptimizedImage._poster;
|
||||
|
||||
/// Named constructor for episode thumbnails.
|
||||
const factory PlexOptimizedImage.thumb({
|
||||
Key? key,
|
||||
PlexClient? client,
|
||||
required String? imagePath,
|
||||
double? width,
|
||||
double? height,
|
||||
BoxFit fit,
|
||||
FilterQuality filterQuality,
|
||||
Widget Function(BuildContext, String)? placeholder,
|
||||
Widget Function(BuildContext, String, dynamic)? errorWidget,
|
||||
Duration fadeInDuration,
|
||||
bool enableTranscoding,
|
||||
String? cacheKey,
|
||||
Alignment alignment,
|
||||
String? localFilePath,
|
||||
}) = PlexOptimizedImage._thumb;
|
||||
|
||||
/// Named constructor for playlist images.
|
||||
const factory PlexOptimizedImage.playlist({
|
||||
Key? key,
|
||||
PlexClient? client,
|
||||
required String? imagePath,
|
||||
double? width,
|
||||
double? height,
|
||||
BoxFit fit,
|
||||
FilterQuality filterQuality,
|
||||
Widget Function(BuildContext, String)? placeholder,
|
||||
Widget Function(BuildContext, String, dynamic)? errorWidget,
|
||||
Duration fadeInDuration,
|
||||
bool enableTranscoding,
|
||||
String? cacheKey,
|
||||
Alignment alignment,
|
||||
String? localFilePath,
|
||||
}) = PlexOptimizedImage._playlist;
|
||||
|
||||
const PlexOptimizedImage._poster({
|
||||
const PlexOptimizedImage.poster({
|
||||
Key? key,
|
||||
PlexClient? client,
|
||||
required String? imagePath,
|
||||
@@ -186,7 +133,8 @@ class PlexOptimizedImage extends StatelessWidget {
|
||||
localFilePath: localFilePath,
|
||||
);
|
||||
|
||||
const PlexOptimizedImage._thumb({
|
||||
/// Named constructor for episode thumbnails.
|
||||
const PlexOptimizedImage.thumb({
|
||||
Key? key,
|
||||
PlexClient? client,
|
||||
required String? imagePath,
|
||||
@@ -220,7 +168,8 @@ class PlexOptimizedImage extends StatelessWidget {
|
||||
localFilePath: localFilePath,
|
||||
);
|
||||
|
||||
const PlexOptimizedImage._playlist({
|
||||
/// Named constructor for playlist images.
|
||||
const PlexOptimizedImage.playlist({
|
||||
Key? key,
|
||||
PlexClient? client,
|
||||
required String? imagePath,
|
||||
|
||||
@@ -4,6 +4,7 @@ import '../focus/dpad_navigator.dart';
|
||||
import '../focus/focusable_button.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../widgets/app_icon.dart';
|
||||
import '../widgets/dialog_action_button.dart';
|
||||
import '../widgets/focusable_list_tile.dart';
|
||||
|
||||
class TagEditDialog extends StatefulWidget {
|
||||
@@ -104,14 +105,11 @@ class _TagEditDialogState extends State<TagEditDialog> {
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
FocusableButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)),
|
||||
),
|
||||
FocusableButton(
|
||||
focusNode: _saveFocusNode,
|
||||
DialogActionButton(onPressed: () => Navigator.pop(context), label: t.common.cancel),
|
||||
DialogActionButton(
|
||||
onPressed: () => Navigator.pop(context, _tags),
|
||||
child: TextButton(onPressed: () => Navigator.pop(context, _tags), child: Text(t.common.save)),
|
||||
label: t.common.save,
|
||||
focusNode: _saveFocusNode,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../focus/dpad_navigator.dart';
|
||||
import '../focus/focus_theme.dart';
|
||||
import '../focus/input_mode_tracker.dart';
|
||||
import '../focus/key_repeat_helper.dart';
|
||||
import '../theme/mono_tokens.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'app_icon.dart';
|
||||
@@ -216,9 +215,8 @@ class _ColorChannelRow extends StatefulWidget {
|
||||
State<_ColorChannelRow> createState() => _ColorChannelRowState();
|
||||
}
|
||||
|
||||
class _ColorChannelRowState extends State<_ColorChannelRow> {
|
||||
class _ColorChannelRowState extends State<_ColorChannelRow> with KeyRepeatHelper<_ColorChannelRow> {
|
||||
late FocusNode _focusNode;
|
||||
Timer? _repeatTimer;
|
||||
bool _isFocused = false;
|
||||
|
||||
@override
|
||||
@@ -229,7 +227,7 @@ class _ColorChannelRowState extends State<_ColorChannelRow> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_repeatTimer?.cancel();
|
||||
stopRepeat();
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -248,21 +246,6 @@ class _ColorChannelRowState extends State<_ColorChannelRow> {
|
||||
}
|
||||
}
|
||||
|
||||
void _startRepeat(VoidCallback action) {
|
||||
action();
|
||||
_repeatTimer?.cancel();
|
||||
_repeatTimer = Timer(const Duration(milliseconds: 400), () {
|
||||
_repeatTimer = Timer.periodic(const Duration(milliseconds: 100), (_) {
|
||||
action();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _stopRepeat() {
|
||||
_repeatTimer?.cancel();
|
||||
_repeatTimer = null;
|
||||
}
|
||||
|
||||
KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) {
|
||||
final key = event.logicalKey;
|
||||
|
||||
@@ -277,10 +260,10 @@ class _ColorChannelRowState extends State<_ColorChannelRow> {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isRightKey) {
|
||||
_startRepeat(_increment);
|
||||
startRepeat(_increment);
|
||||
return KeyEventResult.handled;
|
||||
} else if (key.isLeftKey) {
|
||||
_startRepeat(_decrement);
|
||||
startRepeat(_decrement);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
} else if (event is KeyRepeatEvent) {
|
||||
@@ -292,7 +275,7 @@ class _ColorChannelRowState extends State<_ColorChannelRow> {
|
||||
}
|
||||
} else if (event is KeyUpEvent) {
|
||||
if (key.isRightKey || key.isLeftKey) {
|
||||
_stopRepeat();
|
||||
stopRepeat();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
@@ -313,7 +296,7 @@ class _ColorChannelRowState extends State<_ColorChannelRow> {
|
||||
autofocus: widget.autofocus,
|
||||
onFocusChange: (hasFocus) {
|
||||
setState(() => _isFocused = hasFocus);
|
||||
if (!hasFocus) _stopRepeat();
|
||||
if (!hasFocus) stopRepeat();
|
||||
},
|
||||
onKeyEvent: _handleKeyEvent,
|
||||
child: AnimatedContainer(
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
@@ -7,6 +5,7 @@ import '../focus/dpad_navigator.dart';
|
||||
import '../focus/focus_theme.dart';
|
||||
import '../focus/input_mode_tracker.dart';
|
||||
import '../focus/key_event_utils.dart';
|
||||
import '../focus/key_repeat_helper.dart';
|
||||
import 'app_icon.dart';
|
||||
import '../theme/mono_tokens.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
@@ -62,9 +61,8 @@ class TvNumberSpinner extends StatefulWidget {
|
||||
State<TvNumberSpinner> createState() => _TvNumberSpinnerState();
|
||||
}
|
||||
|
||||
class _TvNumberSpinnerState extends State<TvNumberSpinner> {
|
||||
class _TvNumberSpinnerState extends State<TvNumberSpinner> with KeyRepeatHelper<TvNumberSpinner> {
|
||||
late FocusNode _focusNode;
|
||||
Timer? _repeatTimer;
|
||||
bool _isFocused = false;
|
||||
|
||||
@override
|
||||
@@ -75,7 +73,7 @@ class _TvNumberSpinnerState extends State<TvNumberSpinner> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_repeatTimer?.cancel();
|
||||
stopRepeat();
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -94,24 +92,6 @@ class _TvNumberSpinnerState extends State<TvNumberSpinner> {
|
||||
}
|
||||
}
|
||||
|
||||
void _startRepeat(VoidCallback action) {
|
||||
// Execute once immediately
|
||||
action();
|
||||
|
||||
// Start repeat timer after initial delay
|
||||
_repeatTimer?.cancel();
|
||||
_repeatTimer = Timer(const Duration(milliseconds: 400), () {
|
||||
_repeatTimer = Timer.periodic(const Duration(milliseconds: 100), (_) {
|
||||
action();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _stopRepeat() {
|
||||
_repeatTimer?.cancel();
|
||||
_repeatTimer = null;
|
||||
}
|
||||
|
||||
KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) {
|
||||
final key = event.logicalKey;
|
||||
|
||||
@@ -129,15 +109,15 @@ class _TvNumberSpinnerState extends State<TvNumberSpinner> {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isUpKey || key.isRightKey) {
|
||||
_startRepeat(_increment);
|
||||
startRepeat(_increment);
|
||||
return KeyEventResult.handled;
|
||||
} else if (key.isDownKey || key.isLeftKey) {
|
||||
_startRepeat(_decrement);
|
||||
startRepeat(_decrement);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
} else if (event is KeyUpEvent) {
|
||||
if (key.isUpKey || key.isRightKey || key.isDownKey || key.isLeftKey) {
|
||||
_stopRepeat();
|
||||
stopRepeat();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
@@ -158,7 +138,7 @@ class _TvNumberSpinnerState extends State<TvNumberSpinner> {
|
||||
autofocus: widget.autofocus,
|
||||
onFocusChange: (hasFocus) {
|
||||
setState(() => _isFocused = hasFocus);
|
||||
if (!hasFocus) _stopRepeat();
|
||||
if (!hasFocus) stopRepeat();
|
||||
},
|
||||
onKeyEvent: _handleKeyEvent,
|
||||
child: AnimatedContainer(
|
||||
@@ -181,8 +161,8 @@ class _TvNumberSpinnerState extends State<TvNumberSpinner> {
|
||||
_SpinnerButton(
|
||||
icon: Symbols.remove_rounded,
|
||||
onPressed: canDecrement ? _decrement : null,
|
||||
onLongPressStart: canDecrement ? () => _startRepeat(_decrement) : null,
|
||||
onLongPressEnd: _stopRepeat,
|
||||
onLongPressStart: canDecrement ? () => startRepeat(_decrement) : null,
|
||||
onLongPressEnd: stopRepeat,
|
||||
semanticLabel: 'Decrease',
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
@@ -200,8 +180,8 @@ class _TvNumberSpinnerState extends State<TvNumberSpinner> {
|
||||
_SpinnerButton(
|
||||
icon: Symbols.add_rounded,
|
||||
onPressed: canIncrement ? _increment : null,
|
||||
onLongPressStart: canIncrement ? () => _startRepeat(_increment) : null,
|
||||
onLongPressEnd: _stopRepeat,
|
||||
onLongPressStart: canIncrement ? () => startRepeat(_increment) : null,
|
||||
onLongPressEnd: stopRepeat,
|
||||
semanticLabel: 'Increase',
|
||||
),
|
||||
],
|
||||
|
||||
@@ -72,22 +72,7 @@ class _ChapterSheetState extends State<ChapterSheet> {
|
||||
initialData: widget.player.state.position,
|
||||
builder: (context, positionSnapshot) {
|
||||
final currentPosition = positionSnapshot.data ?? Duration.zero;
|
||||
final currentPositionMs = currentPosition.inMilliseconds;
|
||||
|
||||
// Find the current chapter based on position
|
||||
int? currentChapterIndex;
|
||||
for (int i = 0; i < widget.chapters.length; i++) {
|
||||
final chapter = widget.chapters[i];
|
||||
final startMs = chapter.startTimeOffset ?? 0;
|
||||
final endMs =
|
||||
chapter.endTimeOffset ??
|
||||
(i < widget.chapters.length - 1 ? widget.chapters[i + 1].startTimeOffset ?? 0 : double.maxFinite.toInt());
|
||||
|
||||
if (currentPositionMs >= startMs && currentPositionMs < endMs) {
|
||||
currentChapterIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
final currentChapterIndex = PlexChapter.indexAtPosition(currentPosition, widget.chapters);
|
||||
|
||||
Widget content;
|
||||
if (!widget.chaptersLoaded) {
|
||||
|
||||
@@ -327,26 +327,13 @@ class ContentStripState extends State<ContentStrip> {
|
||||
initialData: widget.player.state.position,
|
||||
builder: (context, positionSnapshot) {
|
||||
final currentPosition = positionSnapshot.data ?? Duration.zero;
|
||||
final currentPositionMs = currentPosition.inMilliseconds;
|
||||
|
||||
int? currentChapterIndex;
|
||||
for (int i = 0; i < widget.chapters.length; i++) {
|
||||
final chapter = widget.chapters[i];
|
||||
final startMs = chapter.startTimeOffset ?? 0;
|
||||
final endMs =
|
||||
chapter.endTimeOffset ??
|
||||
(i < widget.chapters.length - 1 ? widget.chapters[i + 1].startTimeOffset ?? 0 : double.maxFinite.toInt());
|
||||
if (currentPositionMs >= startMs && currentPositionMs < endMs) {
|
||||
currentChapterIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
final currentChapterIndex = PlexChapter.indexAtPosition(currentPosition, widget.chapters);
|
||||
|
||||
// Auto-scroll to current chapter on first build
|
||||
if (!_hasAutoScrolledChapters && currentChapterIndex != null) {
|
||||
_hasAutoScrolledChapters = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_autoScrollTo(_chapterScrollController, currentChapterIndex!, isTablet: isTablet);
|
||||
_autoScrollTo(_chapterScrollController, currentChapterIndex, isTablet: isTablet);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user