refactor: extract shared mixins and helpers to dedupe

This commit is contained in:
edde746
2026-04-20 21:25:50 +02:00
parent 3b7b532a8d
commit 5937e5c721
28 changed files with 536 additions and 777 deletions
+43
View File
@@ -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);
}
}
}
+25
View File
@@ -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;
}
}
+16
View File
@@ -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 -10
View File
@@ -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}));
}
// ============================================
+19
View File
@@ -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
// ============================================
+1 -10
View File
@@ -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']));
}
// ============================================
+7 -22
View File
@@ -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),
],
);
}
}
+12 -27
View File
@@ -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,
);
}
}
+17 -30
View File
@@ -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,
);
}
}
+18 -62
View File
@@ -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,
);
+14 -38
View File
@@ -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),
+7 -24
View File
@@ -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),
+22 -32
View File
@@ -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();
+15 -23
View File
@@ -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;
+74 -125
View File
@@ -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
View File
@@ -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),
],
);
}
+5 -24
View File
@@ -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);
+35
View File
@@ -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)),
);
}
}
+26 -98
View File
@@ -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,
);
}
+5 -56
View File
@@ -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,
+5 -7
View File
@@ -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,
),
],
);
+7 -24
View File
@@ -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(
+11 -31
View File
@@ -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);
});
}