From fcf48a22e6411cadaefe9a36932b8a8ec89266c0 Mon Sep 17 00:00:00 2001 From: Aiden Schembri Date: Sun, 15 Mar 2026 10:48:22 +0100 Subject: [PATCH 1/9] feat: Dynamically update desktop window title --- lib/models/item_base_model.dart | 13 +++ lib/models/items/episode_model.dart | 8 ++ lib/models/items/season_model.dart | 6 ++ lib/providers/video_player_provider.dart | 11 ++- lib/providers/window_title_provider.dart | 57 ++++++++++++ .../episode_detail_screen.dart | 1 + .../details_screens/season_detail_screen.dart | 1 + lib/screens/shared/animated_fade_size.dart | 2 +- lib/screens/shared/detail_scaffold.dart | 91 +++++++++++-------- .../components/floating_player_bar.dart | 13 +-- .../navigation_scaffold.dart | 14 +++ lib/wrappers/players/lib_mdk.dart | 4 +- lib/wrappers/players/lib_mpv.dart | 18 ++-- linux/my_application.cc | 7 +- 14 files changed, 188 insertions(+), 58 deletions(-) create mode 100644 lib/providers/window_title_provider.dart diff --git a/lib/models/item_base_model.dart b/lib/models/item_base_model.dart index 39bf8d294..71db28688 100644 --- a/lib/models/item_base_model.dart +++ b/lib/models/item_base_model.dart @@ -101,6 +101,19 @@ class ItemBaseModel with ItemBaseModelMappable { String get title => name; + String get windowTitle { + if (jellyType == dto.BaseItemKind.audio) { + final artists = overview.people + .where((p) => p.type == PersonKind.artist || p.type == PersonKind.albumartist) + .map((p) => p.name) + .join(', '); + if (artists.isNotEmpty) { + return '$artists – $name'; + } + } + return name; + } + ///Used for retrieving the correct id when fetching queue String get streamId => id; diff --git a/lib/models/items/episode_model.dart b/lib/models/items/episode_model.dart index 22a7b0e61..3fac2533f 100644 --- a/lib/models/items/episode_model.dart +++ b/lib/models/items/episode_model.dart @@ -81,6 +81,14 @@ class EpisodeModel extends ItemStreamModel with EpisodeModelMappable { }; } + @override + String get windowTitle { + final s = season.toString().padLeft(2, '0'); + final e = episodeRange.padLeft(2, '0'); + final prefix = seriesName != null ? '$seriesName • ' : ''; + return '${prefix}S${s}E${e} $name'; + } + @override String? detailedName(AppLocalizations l10n) => "${subTextShort(l10n)} - $name"; diff --git a/lib/models/items/season_model.dart b/lib/models/items/season_model.dart index 3b2f0e23c..7e26630c2 100644 --- a/lib/models/items/season_model.dart +++ b/lib/models/items/season_model.dart @@ -77,6 +77,12 @@ class SeasonModel extends ItemBaseModel with SeasonModelMappable { episodes.firstWhereOrNull((element) => element.userData.played == false); } + @override + String get windowTitle { + final prefix = seriesName.isNotEmpty ? '$seriesName • ' : ''; + return '$prefix$name'; + } + @override bool get syncAble => episodes.isNotEmpty && episodes.any((element) => element.syncAble); diff --git a/lib/providers/video_player_provider.dart b/lib/providers/video_player_provider.dart index 5c7037f61..99857e02e 100644 --- a/lib/providers/video_player_provider.dart +++ b/lib/providers/video_player_provider.dart @@ -1,11 +1,12 @@ import 'dart:async'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path/path.dart' as p; - +import 'package:fladder/providers/window_title_provider.dart'; import 'package:fladder/models/media_playback_model.dart'; import 'package:fladder/models/playback/playback_model.dart'; import 'package:fladder/providers/settings/client_settings_provider.dart'; @@ -52,6 +53,13 @@ class VideoPlayerNotifier extends StateNotifier { if (subscription != null) { subscriptions.add(subscription); } + + // Reset the window title when playback stops (model becomes null). + ref.listen(playBackModel, (_, next) { + if (next == null) { + ref.read(windowTitleProvider.notifier).setPlayTitle(null); + } + }); } Future updateBuffering(bool event) async => @@ -114,6 +122,7 @@ class VideoPlayerNotifier extends StateNotifier { Future loadPlaybackItem(PlaybackModel model, Duration startPosition) async { await state.stop(); + ref.read(windowTitleProvider.notifier).setPlayTitle(model.item.windowTitle); ref.read(playbackRateProvider.notifier).state = 1.0; mediaState.update((state) => state.copyWith( state: VideoPlayerState.fullScreen, diff --git a/lib/providers/window_title_provider.dart b/lib/providers/window_title_provider.dart new file mode 100644 index 000000000..9451500f5 --- /dev/null +++ b/lib/providers/window_title_provider.dart @@ -0,0 +1,57 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:window_manager/window_manager.dart'; + +/// Manages the context-aware window title. +final windowTitleProvider = StateNotifierProvider((ref) { + return WindowTitleNotifier(); +}); + +class WindowTitleNotifier extends StateNotifier { + WindowTitleNotifier() : super('Fladder'); + + final List _navStack = []; + String? _playTitle; + + void pushNavTitle(String title) { + _navStack.add(title); + _update(); + } + + void popNavTitle(String title) { + _navStack.remove(title); + _update(); + } + + void replaceNavTitle(String oldTitle, String newTitle) { + final index = _navStack.lastIndexOf(oldTitle); + if (index != -1) { + _navStack[index] = newTitle; + } else { + _navStack.add(newTitle); + } + _update(); + } + + void clearStack() { + _navStack.clear(); + _update(); + } + + void setPlayTitle(String? title) { + _playTitle = title; + _update(); + } + + void _update() { + final nav = _navStack.isNotEmpty ? _navStack.last : null; + final title = _playTitle ?? nav; + + state = title ?? 'Fladder'; + + if (!kIsWeb && (Platform.isLinux || Platform.isMacOS || Platform.isWindows)) { + windowManager.setTitle(state); + } + } +} diff --git a/lib/screens/details_screens/episode_detail_screen.dart b/lib/screens/details_screens/episode_detail_screen.dart index 3a0a714ec..fa463b2b9 100644 --- a/lib/screens/details_screens/episode_detail_screen.dart +++ b/lib/screens/details_screens/episode_detail_screen.dart @@ -54,6 +54,7 @@ class _ItemDetailScreenState extends ConsumerState { return DetailScaffold( label: widget.item.name, + windowTitle: widget.item.windowTitle, item: details.episode, actions: (context) => details.episode?.generateActions( context, diff --git a/lib/screens/details_screens/season_detail_screen.dart b/lib/screens/details_screens/season_detail_screen.dart index 1e6221c79..59dcb0850 100644 --- a/lib/screens/details_screens/season_detail_screen.dart +++ b/lib/screens/details_screens/season_detail_screen.dart @@ -43,6 +43,7 @@ class _SeasonDetailScreenState extends ConsumerState { return DetailScaffold( label: details?.localizedName(context.localized) ?? "", + windowTitle: details?.windowTitle, item: details, actions: (context) => details?.generateActions(context, ref, exclude: { ItemActions.details, diff --git a/lib/screens/shared/animated_fade_size.dart b/lib/screens/shared/animated_fade_size.dart index eadbf7e06..fcd07be52 100644 --- a/lib/screens/shared/animated_fade_size.dart +++ b/lib/screens/shared/animated_fade_size.dart @@ -23,7 +23,7 @@ class AnimatedFadeSize extends ConsumerWidget { duration: duration, switchInCurve: Curves.easeInOutCubic, switchOutCurve: Curves.easeInOutCubic, - child: child, + child: RepaintBoundary(child: child), ), ); } diff --git a/lib/screens/shared/detail_scaffold.dart b/lib/screens/shared/detail_scaffold.dart index 2d5b7e1ca..565db9e66 100644 --- a/lib/screens/shared/detail_scaffold.dart +++ b/lib/screens/shared/detail_scaffold.dart @@ -11,6 +11,7 @@ import 'package:fladder/providers/settings/client_settings_provider.dart'; import 'package:fladder/providers/sync/sync_provider_helpers.dart'; import 'package:fladder/providers/sync_provider.dart'; import 'package:fladder/providers/user_provider.dart'; +import 'package:fladder/providers/window_title_provider.dart'; import 'package:fladder/routes/auto_router.gr.dart'; import 'package:fladder/screens/syncing/sync_button.dart'; import 'package:fladder/screens/syncing/sync_item_details.dart'; @@ -38,6 +39,7 @@ Future getDominantColor(ImageProvider imageProvider) async { class DetailScaffold extends ConsumerStatefulWidget { final String label; + final String? windowTitle; final ItemBaseModel? item; final List? Function(BuildContext context)? actions; final Color? backgroundColor; @@ -47,6 +49,7 @@ class DetailScaffold extends ConsumerStatefulWidget { final bool posterFillsContent; const DetailScaffold({ required this.label, + this.windowTitle, this.item, this.actions, this.backgroundColor, @@ -70,9 +73,29 @@ class _DetailScaffoldState extends ConsumerState { ImageProvider? _lastRequestedImage; ImageData? _lastColorImage; + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(windowTitleProvider.notifier).pushNavTitle(widget.windowTitle ?? widget.label); + }); + } + + @override + void dispose() { + ref.read(windowTitleProvider.notifier).popNavTitle(widget.windowTitle ?? widget.label); + super.dispose(); + } + @override void didUpdateWidget(covariant DetailScaffold oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.label != widget.label || oldWidget.windowTitle != widget.windowTitle) { + ref.read(windowTitleProvider.notifier).replaceNavTitle( + oldWidget.windowTitle ?? oldWidget.label, + widget.windowTitle ?? widget.label, + ); + } updateImage(); _updateDominantColor(); if (widget.item != null && widget.item?.id != item?.id) { @@ -149,13 +172,13 @@ class _DetailScaffoldState extends ConsumerState { return PullToRefresh( onRefresh: () async { await widget.onRefresh?.call(); - setState(() { - if (context.mounted) { + if (mounted) { + setState(() { if (widget.backDrops?.backDrop?.contains(backgroundImage) == true) { backgroundImage = widget.backDrops?.randomBackDrop; } - } - }); + }); + } }, refreshOnStart: true, child: (context) => Scaffold( @@ -180,31 +203,29 @@ class _DetailScaffoldState extends ConsumerState { alignment: Alignment.topCenter, child: Padding( padding: EdgeInsets.only(left: sideBarPadding / 1.5, top: topBarPadding / 1.5), - child: RepaintBoundary( - child: ConstrainedBox( - constraints: BoxConstraints( - minWidth: double.infinity, - minHeight: minHeight - 22, - maxHeight: maxHeight.clamp(minHeight, 2500) - (20 + topBarPadding), - ), - child: FadeEdges( - leftFade: sideBarPadding > 0 ? 0.05 : 0.0, - topFade: topBarPadding > 0 ? 0.1 : 0.0, - bottomFade: 0.2, - child: FadeInImage( - placeholder: ResizeImage( - backgroundImage!.imageProvider, - height: maxHeight ~/ 1.5, - ), - placeholderColor: Colors.transparent, - fit: BoxFit.cover, - alignment: Alignment.topCenter, - placeholderFit: BoxFit.cover, - excludeFromSemantics: true, - image: ResizeImage( - backgroundImage!.imageProvider, - height: maxHeight ~/ 1.5, - ), + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: double.infinity, + minHeight: minHeight - 22, + maxHeight: maxHeight.clamp(minHeight, 2500) - (20 + topBarPadding), + ), + child: FadeEdges( + leftFade: sideBarPadding > 0 ? 0.05 : 0.0, + topFade: topBarPadding > 0 ? 0.1 : 0.0, + bottomFade: 0.2, + child: FadeInImage( + placeholder: ResizeImage( + backgroundImage!.imageProvider, + height: maxHeight ~/ 1.5, + ), + placeholderColor: Colors.transparent, + fit: BoxFit.cover, + alignment: Alignment.topCenter, + placeholderFit: BoxFit.cover, + excludeFromSemantics: true, + image: ResizeImage( + backgroundImage!.imageProvider, + height: maxHeight ~/ 1.5, ), ), ), @@ -350,13 +371,11 @@ class _DetailScaffoldState extends ConsumerState { ), ], if (AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer) - Builder( - builder: (context) => Tooltip( - message: context.localized.refresh, - child: IconButton( - onPressed: () => context.refreshData(), - icon: const Icon(IconsaxPlusLinear.refresh), - ), + Tooltip( + message: context.localized.refresh, + child: IconButton( + onPressed: () => context.refreshData(), + icon: const Icon(IconsaxPlusLinear.refresh), ), ), if (AdaptiveLayout.layoutModeOf(context) == LayoutMode.single || diff --git a/lib/widgets/navigation_scaffold/components/floating_player_bar.dart b/lib/widgets/navigation_scaffold/components/floating_player_bar.dart index ebc94bbca..954543d60 100644 --- a/lib/widgets/navigation_scaffold/components/floating_player_bar.dart +++ b/lib/widgets/navigation_scaffold/components/floating_player_bar.dart @@ -155,14 +155,11 @@ class _CurrentlyPlayingBarState extends ConsumerState { onExit: (event) => setState(() => showExpandButton = false), child: Stack( children: [ - Hero( - tag: videoPlayerHeroTag, - child: player.videoWidget( - UniqueKey(), - BoxFit.fitHeight, - ) ?? - const SizedBox.shrink(), - ), + player.videoWidget( + const ValueKey("mini_player_video"), + BoxFit.fitHeight, + ) ?? + const SizedBox.shrink(), Positioned.fill( child: Tooltip( message: "Expand player", diff --git a/lib/widgets/navigation_scaffold/navigation_scaffold.dart b/lib/widgets/navigation_scaffold/navigation_scaffold.dart index 658e010f6..6c4a24778 100644 --- a/lib/widgets/navigation_scaffold/navigation_scaffold.dart +++ b/lib/widgets/navigation_scaffold/navigation_scaffold.dart @@ -8,6 +8,7 @@ import 'package:fladder/models/media_playback_model.dart'; import 'package:fladder/providers/connectivity_provider.dart'; import 'package:fladder/providers/video_player_provider.dart'; import 'package:fladder/providers/views_provider.dart'; +import 'package:fladder/providers/window_title_provider.dart'; import 'package:fladder/routes/auto_router.dart'; import 'package:fladder/screens/home_screen.dart'; import 'package:fladder/screens/shared/animated_fade_size.dart'; @@ -51,9 +52,22 @@ class _NavigationScaffoldState extends ConsumerState { super.initState(); WidgetsBinding.instance.addPostFrameCallback((value) { ref.read(viewsProvider.notifier).fetchViews(); + ref.read(windowTitleProvider.notifier).clearStack(); }); } + @override + void didUpdateWidget(covariant NavigationScaffold oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.currentRouteName != oldWidget.currentRouteName && currentIndex != -1) { + Future.microtask(() { + if (mounted) { + ref.read(windowTitleProvider.notifier).clearStack(); + } + }); + } + } + @override Widget build(BuildContext context) { final views = ref.watch(viewsProvider.select((value) => value.views)); diff --git a/lib/wrappers/players/lib_mdk.dart b/lib/wrappers/players/lib_mdk.dart index 180521496..858c9a99d 100644 --- a/lib/wrappers/players/lib_mdk.dart +++ b/lib/wrappers/players/lib_mdk.dart @@ -230,7 +230,9 @@ class LibMDK extends BasePlayer { width: constraints.maxWidth, child: AspectRatio( aspectRatio: aspectRatio, - child: VideoPlayer(_controller!), + child: ExcludeSemantics( + child: VideoPlayer(_controller!), + ), ), ); }, diff --git a/lib/wrappers/players/lib_mpv.dart b/lib/wrappers/players/lib_mpv.dart index e6acc8e66..836115b99 100644 --- a/lib/wrappers/players/lib_mpv.dart +++ b/lib/wrappers/players/lib_mpv.dart @@ -219,14 +219,16 @@ class LibMPV extends BasePlayer { ) => _controller == null ? null - : Video( - key: key, - controller: _controller!, - wakelock: false, - fill: Colors.transparent, - fit: fit, - subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false), - controls: NoVideoControls, + : ExcludeSemantics( + child: Video( + key: key, + controller: _controller!, + wakelock: false, + fill: Colors.transparent, + fit: fit, + subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false), + controls: NoVideoControls, + ), ); @override diff --git a/linux/my_application.cc b/linux/my_application.cc index 7549b76ef..a7f7e791f 100644 --- a/linux/my_application.cc +++ b/linux/my_application.cc @@ -45,13 +45,13 @@ static void my_application_activate(GApplication *application) { GtkHeaderBar *header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); gtk_widget_show(GTK_WIDGET(header_bar)); - gtk_header_bar_set_title(header_bar, "fladder"); + gtk_header_bar_set_title(header_bar, "Fladder"); gtk_header_bar_set_show_close_button(header_bar, TRUE); gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); } else { - gtk_window_set_title(window, "fladder"); + gtk_window_set_title(window, "Fladder"); } gtk_window_set_default_size(window, 1280, 720); @@ -109,7 +109,8 @@ static void my_application_init(MyApplication *self) {} MyApplication *my_application_new() { - g_set_prgname(APPLICATION_ID); + g_set_prgname("Fladder"); + g_set_application_name("Fladder"); return MY_APPLICATION(g_object_new(my_application_get_type(), "application-id", APPLICATION_ID, "flags", G_APPLICATION_NON_UNIQUE, From 72de9245f4e57df0663bcccfb475702a4404195d Mon Sep 17 00:00:00 2001 From: Aiden Schembri Date: Sun, 15 Mar 2026 20:15:01 +0100 Subject: [PATCH 2/9] refactor: remove unused import and application name setting --- lib/providers/video_player_provider.dart | 1 - linux/my_application.cc | 1 - 2 files changed, 2 deletions(-) diff --git a/lib/providers/video_player_provider.dart b/lib/providers/video_player_provider.dart index 99857e02e..d2d36288c 100644 --- a/lib/providers/video_player_provider.dart +++ b/lib/providers/video_player_provider.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:io'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; diff --git a/linux/my_application.cc b/linux/my_application.cc index a7f7e791f..709ab6519 100644 --- a/linux/my_application.cc +++ b/linux/my_application.cc @@ -110,7 +110,6 @@ static void my_application_init(MyApplication *self) {} MyApplication *my_application_new() { g_set_prgname("Fladder"); - g_set_application_name("Fladder"); return MY_APPLICATION(g_object_new(my_application_get_type(), "application-id", APPLICATION_ID, "flags", G_APPLICATION_NON_UNIQUE, From 96f2d589e6bbba0835230640265b92cd1f061ed8 Mon Sep 17 00:00:00 2001 From: Aiden Schembri Date: Sun, 15 Mar 2026 20:25:17 +0100 Subject: [PATCH 3/9] feat: Adjust episode string interpolation in `episode_model.dart`. --- lib/models/items/episode_model.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/models/items/episode_model.dart b/lib/models/items/episode_model.dart index 3fac2533f..ddd5b0c78 100644 --- a/lib/models/items/episode_model.dart +++ b/lib/models/items/episode_model.dart @@ -86,7 +86,7 @@ class EpisodeModel extends ItemStreamModel with EpisodeModelMappable { final s = season.toString().padLeft(2, '0'); final e = episodeRange.padLeft(2, '0'); final prefix = seriesName != null ? '$seriesName • ' : ''; - return '${prefix}S${s}E${e} $name'; + return '${prefix}S${s}E$e $name'; } @override From 330875065d90723ba9df9be45b23652cf24f24b2 Mon Sep 17 00:00:00 2001 From: Aiden Schembri Date: Sun, 15 Mar 2026 21:41:21 +0100 Subject: [PATCH 4/9] feat: dynamic window title for web version --- lib/main.dart | 5 ++--- lib/providers/window_title_provider.dart | 6 +++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 0c803e552..ddf626b7f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,6 +15,7 @@ import 'package:fladder/providers/crash_log_provider.dart'; import 'package:fladder/providers/settings/client_settings_provider.dart'; import 'package:fladder/providers/shared_provider.dart'; import 'package:fladder/providers/sync_provider.dart'; +import 'package:fladder/providers/window_title_provider.dart'; import 'package:fladder/routes/auto_router.dart'; import 'package:fladder/theme.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; @@ -101,7 +102,7 @@ class _FladderApp extends ConsumerWidget { light: lightTheme, dark: darkTheme, child: MaterialApp.router( - onGenerateTitle: (context) => ref.watch(currentTitleProvider), + onGenerateTitle: (context) => ref.watch(windowTitleProvider), theme: lightTheme, scrollBehavior: scrollBehaviour.copyWith( dragDevices: { @@ -153,5 +154,3 @@ class _FladderApp extends ConsumerWidget { ); } } - -final currentTitleProvider = StateProvider((ref) => "Fladder"); diff --git a/lib/providers/window_title_provider.dart b/lib/providers/window_title_provider.dart index 9451500f5..4d6d59f29 100644 --- a/lib/providers/window_title_provider.dart +++ b/lib/providers/window_title_provider.dart @@ -48,7 +48,11 @@ class WindowTitleNotifier extends StateNotifier { final nav = _navStack.isNotEmpty ? _navStack.last : null; final title = _playTitle ?? nav; - state = title ?? 'Fladder'; + if (kIsWeb) { + state = title != null ? 'Fladder • $title' : 'Fladder'; + } else { + state = title ?? 'Fladder'; + } if (!kIsWeb && (Platform.isLinux || Platform.isMacOS || Platform.isWindows)) { windowManager.setTitle(state); From 26decae0cc9eef04ef5e87911aa7bab8a8ddc1ed Mon Sep 17 00:00:00 2001 From: Aiden Schembri Date: Mon, 30 Mar 2026 12:51:35 +0200 Subject: [PATCH 5/9] refactor: centralize window title management --- lib/models/item_base_model.dart | 13 +------------ lib/models/items/episode_model.dart | 4 ++-- lib/models/items/season_model.dart | 4 ++-- lib/providers/video_player_provider.dart | 9 --------- .../details_screens/episode_detail_screen.dart | 2 +- .../details_screens/season_detail_screen.dart | 2 +- lib/screens/shared/animated_fade_size.dart | 2 +- .../components/floating_player_bar.dart | 13 ++++++++----- lib/wrappers/media_control_wrapper.dart | 7 +++++++ lib/wrappers/players/lib_mdk.dart | 4 +--- 10 files changed, 24 insertions(+), 36 deletions(-) diff --git a/lib/models/item_base_model.dart b/lib/models/item_base_model.dart index 083641b0a..c20cd502d 100644 --- a/lib/models/item_base_model.dart +++ b/lib/models/item_base_model.dart @@ -101,18 +101,7 @@ class ItemBaseModel with ItemBaseModelMappable { String get title => name; - String get windowTitle { - if (jellyType == dto.BaseItemKind.audio) { - final artists = overview.people - .where((p) => p.type == PersonKind.artist || p.type == PersonKind.albumartist) - .map((p) => p.name) - .join(', '); - if (artists.isNotEmpty) { - return '$artists – $name'; - } - } - return name; - } + String windowTitle(AppLocalizations l10n) => name; ///Used for retrieving the correct id when fetching queue String get streamId => id; diff --git a/lib/models/items/episode_model.dart b/lib/models/items/episode_model.dart index ddd5b0c78..b29f5ee0d 100644 --- a/lib/models/items/episode_model.dart +++ b/lib/models/items/episode_model.dart @@ -82,11 +82,11 @@ class EpisodeModel extends ItemStreamModel with EpisodeModelMappable { } @override - String get windowTitle { + String windowTitle(AppLocalizations l10n) { final s = season.toString().padLeft(2, '0'); final e = episodeRange.padLeft(2, '0'); final prefix = seriesName != null ? '$seriesName • ' : ''; - return '${prefix}S${s}E$e $name'; + return '${prefix}${l10n.season(1)[0]}${s}${l10n.episode(1)[0]}$e $name'; } @override diff --git a/lib/models/items/season_model.dart b/lib/models/items/season_model.dart index 7e26630c2..8d6eb26d5 100644 --- a/lib/models/items/season_model.dart +++ b/lib/models/items/season_model.dart @@ -78,9 +78,9 @@ class SeasonModel extends ItemBaseModel with SeasonModelMappable { } @override - String get windowTitle { + String windowTitle(AppLocalizations l10n) { final prefix = seriesName.isNotEmpty ? '$seriesName • ' : ''; - return '$prefix$name'; + return '$prefix${localizedName(l10n)}'; } @override diff --git a/lib/providers/video_player_provider.dart b/lib/providers/video_player_provider.dart index d2d36288c..2c5ab93d3 100644 --- a/lib/providers/video_player_provider.dart +++ b/lib/providers/video_player_provider.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path/path.dart' as p; -import 'package:fladder/providers/window_title_provider.dart'; import 'package:fladder/models/media_playback_model.dart'; import 'package:fladder/models/playback/playback_model.dart'; import 'package:fladder/providers/settings/client_settings_provider.dart'; @@ -52,13 +51,6 @@ class VideoPlayerNotifier extends StateNotifier { if (subscription != null) { subscriptions.add(subscription); } - - // Reset the window title when playback stops (model becomes null). - ref.listen(playBackModel, (_, next) { - if (next == null) { - ref.read(windowTitleProvider.notifier).setPlayTitle(null); - } - }); } Future updateBuffering(bool event) async => @@ -121,7 +113,6 @@ class VideoPlayerNotifier extends StateNotifier { Future loadPlaybackItem(PlaybackModel model, Duration startPosition) async { await state.stop(); - ref.read(windowTitleProvider.notifier).setPlayTitle(model.item.windowTitle); ref.read(playbackRateProvider.notifier).state = 1.0; mediaState.update((state) => state.copyWith( state: VideoPlayerState.fullScreen, diff --git a/lib/screens/details_screens/episode_detail_screen.dart b/lib/screens/details_screens/episode_detail_screen.dart index fa463b2b9..317afa306 100644 --- a/lib/screens/details_screens/episode_detail_screen.dart +++ b/lib/screens/details_screens/episode_detail_screen.dart @@ -54,7 +54,7 @@ class _ItemDetailScreenState extends ConsumerState { return DetailScaffold( label: widget.item.name, - windowTitle: widget.item.windowTitle, + windowTitle: widget.item.windowTitle(context.localized), item: details.episode, actions: (context) => details.episode?.generateActions( context, diff --git a/lib/screens/details_screens/season_detail_screen.dart b/lib/screens/details_screens/season_detail_screen.dart index 59dcb0850..04238226e 100644 --- a/lib/screens/details_screens/season_detail_screen.dart +++ b/lib/screens/details_screens/season_detail_screen.dart @@ -43,7 +43,7 @@ class _SeasonDetailScreenState extends ConsumerState { return DetailScaffold( label: details?.localizedName(context.localized) ?? "", - windowTitle: details?.windowTitle, + windowTitle: details?.windowTitle(context.localized), item: details, actions: (context) => details?.generateActions(context, ref, exclude: { ItemActions.details, diff --git a/lib/screens/shared/animated_fade_size.dart b/lib/screens/shared/animated_fade_size.dart index fcd07be52..eadbf7e06 100644 --- a/lib/screens/shared/animated_fade_size.dart +++ b/lib/screens/shared/animated_fade_size.dart @@ -23,7 +23,7 @@ class AnimatedFadeSize extends ConsumerWidget { duration: duration, switchInCurve: Curves.easeInOutCubic, switchOutCurve: Curves.easeInOutCubic, - child: RepaintBoundary(child: child), + child: child, ), ); } diff --git a/lib/widgets/navigation_scaffold/components/floating_player_bar.dart b/lib/widgets/navigation_scaffold/components/floating_player_bar.dart index 954543d60..a9957695c 100644 --- a/lib/widgets/navigation_scaffold/components/floating_player_bar.dart +++ b/lib/widgets/navigation_scaffold/components/floating_player_bar.dart @@ -155,11 +155,14 @@ class _CurrentlyPlayingBarState extends ConsumerState { onExit: (event) => setState(() => showExpandButton = false), child: Stack( children: [ - player.videoWidget( - const ValueKey("mini_player_video"), - BoxFit.fitHeight, - ) ?? - const SizedBox.shrink(), + Hero( + tag: videoPlayerHeroTag, + child: player.videoWidget( + const ValueKey("mini_player_video"), + BoxFit.fitHeight, + ) ?? + const SizedBox.shrink(), + ), Positioned.fill( child: Tooltip( message: "Expand player", diff --git a/lib/wrappers/media_control_wrapper.dart b/lib/wrappers/media_control_wrapper.dart index 1e77f482c..b1562df8c 100644 --- a/lib/wrappers/media_control_wrapper.dart +++ b/lib/wrappers/media_control_wrapper.dart @@ -22,6 +22,7 @@ import 'package:fladder/providers/settings/client_settings_provider.dart'; import 'package:fladder/providers/settings/subtitle_settings_provider.dart'; import 'package:fladder/providers/settings/video_player_settings_provider.dart'; import 'package:fladder/providers/video_player_provider.dart'; +import 'package:fladder/providers/window_title_provider.dart'; import 'package:fladder/src/video_player_helper.g.dart' hide PlaybackState; import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/wrappers/players/base_player.dart'; @@ -120,6 +121,11 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro } await _player?.loadVideo(model.media?.url ?? "", play); _player?.applySubtitleSettings(ref.read(subtitleSettingsProvider)); + + final context = ref.read(localizationContextProvider); + if (context != null) { + ref.read(windowTitleProvider.notifier).setPlayTitle(model.item.windowTitle(context.localized)); + } } Future updateTVGuide(TVGuideModel guide) async { @@ -300,6 +306,7 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro WakelockPlus.disable(); super.stop(); _player?.stop(); + ref.read(windowTitleProvider.notifier).setPlayTitle(null); final position = _player?.lastState.position; final totalDuration = _player?.lastState.duration; diff --git a/lib/wrappers/players/lib_mdk.dart b/lib/wrappers/players/lib_mdk.dart index daa67f59b..f01fe6beb 100644 --- a/lib/wrappers/players/lib_mdk.dart +++ b/lib/wrappers/players/lib_mdk.dart @@ -232,9 +232,7 @@ class LibMDK extends BasePlayer { width: constraints.maxWidth, child: AspectRatio( aspectRatio: aspectRatio, - child: ExcludeSemantics( - child: VideoPlayer(controller), - ), + child: VideoPlayer(controller), ), ); }, From dc0ab9c9c0087074b08afd87648dc3e2d33582f8 Mon Sep 17 00:00:00 2001 From: Aiden Schembri Date: Mon, 30 Mar 2026 13:14:25 +0200 Subject: [PATCH 6/9] refactor: replace stack-based window title management with a key-based registry --- lib/providers/video_player_provider.dart | 1 + lib/providers/window_title_provider.dart | 65 +++++++++++-------- lib/screens/shared/detail_scaffold.dart | 28 +++++--- .../navigation_scaffold.dart | 3 +- 4 files changed, 59 insertions(+), 38 deletions(-) diff --git a/lib/providers/video_player_provider.dart b/lib/providers/video_player_provider.dart index 2c5ab93d3..5c7037f61 100644 --- a/lib/providers/video_player_provider.dart +++ b/lib/providers/video_player_provider.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path/path.dart' as p; + import 'package:fladder/models/media_playback_model.dart'; import 'package:fladder/models/playback/playback_model.dart'; import 'package:fladder/providers/settings/client_settings_provider.dart'; diff --git a/lib/providers/window_title_provider.dart b/lib/providers/window_title_provider.dart index 4d6d59f29..932e7f832 100644 --- a/lib/providers/window_title_provider.dart +++ b/lib/providers/window_title_provider.dart @@ -1,41 +1,41 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fladder/models/media_playback_model.dart'; +import 'package:fladder/providers/video_player_provider.dart'; import 'package:window_manager/window_manager.dart'; /// Manages the context-aware window title. final windowTitleProvider = StateNotifierProvider((ref) { - return WindowTitleNotifier(); + return WindowTitleNotifier(ref); }); class WindowTitleNotifier extends StateNotifier { - WindowTitleNotifier() : super('Fladder'); + final Ref ref; + WindowTitleNotifier(this.ref) : super('Fladder'); - final List _navStack = []; + final Map _titles = {}; + final List _stackKeys = []; String? _playTitle; - void pushNavTitle(String title) { - _navStack.add(title); + void updateTitle(Object key, String title) { + _stackKeys.remove(key); + _stackKeys.add(key); + _titles[key] = title; _update(); } - void popNavTitle(String title) { - _navStack.remove(title); - _update(); - } - - void replaceNavTitle(String oldTitle, String newTitle) { - final index = _navStack.lastIndexOf(oldTitle); - if (index != -1) { - _navStack[index] = newTitle; - } else { - _navStack.add(newTitle); + void removeTitle(Object key) { + final removed = _stackKeys.remove(key); + _titles.remove(key); + if (removed) { + _update(); } - _update(); } void clearStack() { - _navStack.clear(); + _stackKeys.clear(); + _titles.clear(); _update(); } @@ -45,17 +45,30 @@ class WindowTitleNotifier extends StateNotifier { } void _update() { - final nav = _navStack.isNotEmpty ? _navStack.last : null; - final title = _playTitle ?? nav; + final nav = _stackKeys.isNotEmpty ? _titles[_stackKeys.last] : null; + final playerState = ref.read(mediaPlaybackProvider).state; - if (kIsWeb) { - state = title != null ? 'Fladder • $title' : 'Fladder'; - } else { - state = title ?? 'Fladder'; - } + final isPlayerActive = playerState != VideoPlayerState.disposed; + final isPlayerMinimized = playerState == VideoPlayerState.minimized; + + // Use playTitle if player is active and expanded/fullscreen. + // If player is minimized or inactive, prefer navigation title. + final title = (isPlayerActive && !isPlayerMinimized) + ? (_playTitle ?? nav) + : (nav ?? _playTitle); + + final newState = kIsWeb ? (title != null ? 'Fladder • $title' : 'Fladder') : (title ?? 'Fladder'); + + if (state == newState) return; + + Future.microtask(() { + state = newState; + }); if (!kIsWeb && (Platform.isLinux || Platform.isMacOS || Platform.isWindows)) { - windowManager.setTitle(state); + // Setting window title directly is safe even during build + windowManager.setTitle(newState); } } + } diff --git a/lib/screens/shared/detail_scaffold.dart b/lib/screens/shared/detail_scaffold.dart index 565db9e66..b32bc9158 100644 --- a/lib/screens/shared/detail_scaffold.dart +++ b/lib/screens/shared/detail_scaffold.dart @@ -73,29 +73,37 @@ class _DetailScaffoldState extends ConsumerState { ImageProvider? _lastRequestedImage; ImageData? _lastColorImage; + void _pushTitle() { + final isCurrent = ModalRoute.of(context)?.isCurrent ?? false; + if (!isCurrent) return; + + final newTitle = widget.windowTitle ?? widget.item?.windowTitle(context.localized) ?? widget.label; + if (newTitle.isNotEmpty) { + ref.read(windowTitleProvider.notifier).updateTitle(this, newTitle); + } + } + @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(windowTitleProvider.notifier).pushNavTitle(widget.windowTitle ?? widget.label); - }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _pushTitle(); } @override void dispose() { - ref.read(windowTitleProvider.notifier).popNavTitle(widget.windowTitle ?? widget.label); + ref.read(windowTitleProvider.notifier).removeTitle(this); super.dispose(); } @override void didUpdateWidget(covariant DetailScaffold oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.label != widget.label || oldWidget.windowTitle != widget.windowTitle) { - ref.read(windowTitleProvider.notifier).replaceNavTitle( - oldWidget.windowTitle ?? oldWidget.label, - widget.windowTitle ?? widget.label, - ); - } + _pushTitle(); updateImage(); _updateDominantColor(); if (widget.item != null && widget.item?.id != item?.id) { diff --git a/lib/widgets/navigation_scaffold/navigation_scaffold.dart b/lib/widgets/navigation_scaffold/navigation_scaffold.dart index 6c4a24778..4773b7da6 100644 --- a/lib/widgets/navigation_scaffold/navigation_scaffold.dart +++ b/lib/widgets/navigation_scaffold/navigation_scaffold.dart @@ -52,7 +52,6 @@ class _NavigationScaffoldState extends ConsumerState { super.initState(); WidgetsBinding.instance.addPostFrameCallback((value) { ref.read(viewsProvider.notifier).fetchViews(); - ref.read(windowTitleProvider.notifier).clearStack(); }); } @@ -124,7 +123,7 @@ class _NavigationScaffoldState extends ConsumerState { child: Builder(builder: (context) { return Scaffold( key: _key, - appBar: fullScreenChildRoute ? null : const FladderAppBar(), + appBar: fullScreenChildRoute ? null : FladderAppBar(label: currentIndex == -1 ? "" : null), extendBodyBehindAppBar: true, resizeToAvoidBottomInset: false, extendBody: true, From 3d5d3a52daea40941c0026ec5ec94ecebefd41a4 Mon Sep 17 00:00:00 2001 From: Aiden Schembri Date: Mon, 30 Mar 2026 13:20:40 +0200 Subject: [PATCH 7/9] lint: clean up code formatting --- lib/models/items/episode_model.dart | 2 +- lib/providers/window_title_provider.dart | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/lib/models/items/episode_model.dart b/lib/models/items/episode_model.dart index b29f5ee0d..023934561 100644 --- a/lib/models/items/episode_model.dart +++ b/lib/models/items/episode_model.dart @@ -86,7 +86,7 @@ class EpisodeModel extends ItemStreamModel with EpisodeModelMappable { final s = season.toString().padLeft(2, '0'); final e = episodeRange.padLeft(2, '0'); final prefix = seriesName != null ? '$seriesName • ' : ''; - return '${prefix}${l10n.season(1)[0]}${s}${l10n.episode(1)[0]}$e $name'; + return '$prefix${l10n.season(1)[0]}$s${l10n.episode(1)[0]}$e $name'; } @override diff --git a/lib/providers/window_title_provider.dart b/lib/providers/window_title_provider.dart index 932e7f832..49e54d44b 100644 --- a/lib/providers/window_title_provider.dart +++ b/lib/providers/window_title_provider.dart @@ -53,9 +53,7 @@ class WindowTitleNotifier extends StateNotifier { // Use playTitle if player is active and expanded/fullscreen. // If player is minimized or inactive, prefer navigation title. - final title = (isPlayerActive && !isPlayerMinimized) - ? (_playTitle ?? nav) - : (nav ?? _playTitle); + final title = (isPlayerActive && !isPlayerMinimized) ? (_playTitle ?? nav) : (nav ?? _playTitle); final newState = kIsWeb ? (title != null ? 'Fladder • $title' : 'Fladder') : (title ?? 'Fladder'); @@ -70,5 +68,4 @@ class WindowTitleNotifier extends StateNotifier { windowManager.setTitle(newState); } } - } From ee1dbd1cc1f9e9f40c9468d9b189d7215c14aaa4 Mon Sep 17 00:00:00 2001 From: Aiden Schembri Date: Mon, 30 Mar 2026 13:26:49 +0200 Subject: [PATCH 8/9] refactor: remove ExcludeSemantics wrapper from lib_mpv --- lib/wrappers/players/lib_mpv.dart | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/lib/wrappers/players/lib_mpv.dart b/lib/wrappers/players/lib_mpv.dart index 836115b99..e6acc8e66 100644 --- a/lib/wrappers/players/lib_mpv.dart +++ b/lib/wrappers/players/lib_mpv.dart @@ -219,16 +219,14 @@ class LibMPV extends BasePlayer { ) => _controller == null ? null - : ExcludeSemantics( - child: Video( - key: key, - controller: _controller!, - wakelock: false, - fill: Colors.transparent, - fit: fit, - subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false), - controls: NoVideoControls, - ), + : Video( + key: key, + controller: _controller!, + wakelock: false, + fill: Colors.transparent, + fit: fit, + subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false), + controls: NoVideoControls, ); @override From 5b72e541f2beca28bd533d9c8b5aa72f02737b1c Mon Sep 17 00:00:00 2001 From: Aiden Schembri Date: Mon, 30 Mar 2026 13:28:57 +0200 Subject: [PATCH 9/9] fix: update window title provider to minimised/maximised player changes --- lib/providers/window_title_provider.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/providers/window_title_provider.dart b/lib/providers/window_title_provider.dart index 49e54d44b..0c845e81c 100644 --- a/lib/providers/window_title_provider.dart +++ b/lib/providers/window_title_provider.dart @@ -12,7 +12,10 @@ final windowTitleProvider = StateNotifierProvider(( class WindowTitleNotifier extends StateNotifier { final Ref ref; - WindowTitleNotifier(this.ref) : super('Fladder'); + WindowTitleNotifier(this.ref) : super('Fladder') { + // Listen to player state changes to handle minimized <-> maximized transitions + ref.listen(mediaPlaybackProvider.select((v) => v.state), (_, __) => _update()); + } final Map _titles = {}; final List _stackKeys = []; @@ -64,7 +67,6 @@ class WindowTitleNotifier extends StateNotifier { }); if (!kIsWeb && (Platform.isLinux || Platform.isMacOS || Platform.isWindows)) { - // Setting window title directly is safe even during build windowManager.setTitle(newState); } }