Skip to content

feat: Add support for taking screenshots with hotkeys#508

Merged
PartyDonut merged 22 commits into
DonutWare:developfrom
RainbowCookie32:develop
Dec 5, 2025
Merged

feat: Add support for taking screenshots with hotkeys#508
PartyDonut merged 22 commits into
DonutWare:developfrom
RainbowCookie32:develop

Conversation

@RainbowCookie32

Copy link
Copy Markdown
Contributor

Pull Request Description

My take on implementing my request on #497. This bring the possibility to take screenshots with or without subtitles using hotkeys. The screenshots are saved to a folder picked by the user, using an incrementing number for file name, which can be optionally zero-padded (by default, it's padded to 3 digits).

The feature works on both backends on desktop and (I think) should only show up on settings on that platform. The default keybind is G for screenshots with subtitles, and Ctrl+G for screenshots without subtitles. Gave it 0 thought though and I was trying to figure out inputs and how hotkeys worked when setting it. S was already taken too D:

Tested the feature today through a movie with the mpv backend and worked great. Like I mentioned on the discussion post, I hadn't done any coding in Dart/Flutter before so here be dragons kinda.

Issue Being Fixed

Fixes #497

Screenshots / Recordings

image image

Checklist

  • For a future revision, it might be nice to have more flexibility on the file name.
  • Testing on other OS's
  • Make sure the feature doesn't show up on unsupported platforms.
  • The MDK backend does image processing synchronously. In my testing it can cause a small stutter, so it'd probably be a good idea to move the processing somewhere it doesn't block (currently beyond my abilities)

@sourcery-ai

sourcery-ai Bot commented Sep 30, 2025

Copy link
Copy Markdown
Contributor

Reviewer's Guide

This PR implements end-to-end screenshot support triggered by hotkeys by extending the settings model and UI, wiring up a new takeScreenshot API in player wrappers, and handling file storage and naming logic in the desktop controls.

Sequence diagram for taking a screenshot via hotkey

sequenceDiagram
  actor User
  participant "DesktopControls"
  participant "MediaControlsWrapper"
  participant "BasePlayer"
  participant "File System"
  User->>"DesktopControls": Press screenshot hotkey
  "DesktopControls"->>"MediaControlsWrapper": takeScreenshot(withSubtitles)
  "MediaControlsWrapper"->>"BasePlayer": takeScreenshot(format, withSubtitles)
  "BasePlayer"-->>"MediaControlsWrapper": Uint8List screenshotBuf
  "MediaControlsWrapper"-->>"DesktopControls": screenshotBuf
  "DesktopControls"->>"File System": Save screenshotBuf to file (with padded name)
  "File System"-->>"DesktopControls": Screenshot saved
Loading

Class diagram for updated VideoPlayerSettingsModel and related types

classDiagram
  class VideoPlayerSettingsModel {
    +String? screenshotsPath
    +ScreenshotFormat screenshotFormat
    +int screenshotNamePadding
    +Map<VideoHotKeys, KeyCombination> hotKeys
    +copyWith(...)
  }
  class ScreenshotFormat {
    +png
    +jpeg
    +label(BuildContext context)
  }
  class VideoHotKeys {
    +takeScreenshot
    +takeScreenshotClean
  }
  VideoPlayerSettingsModel --> ScreenshotFormat
  VideoPlayerSettingsModel --> VideoHotKeys
  VideoPlayerSettingsModel --> KeyCombination
Loading

File-Level Changes

Change Details Files
Extend settings model with screenshot configuration
  • Add screenshotsPath, screenshotFormat, screenshotNamePadding fields
  • Update Freezed copyWith, diagnostics, JSON serialization
  • Define ScreenshotFormat enum and its label logic
  • Expose getters/setters in settings provider
lib/models/settings/video_player_settings.freezed.dart
lib/models/settings/video_player_settings.dart
lib/models/settings/video_player_settings.g.dart
lib/providers/settings/video_player_settings_provider.dart
Add UI for screenshot settings
  • Insert a “Screenshots” section in player settings
  • Add directory picker tile with FilePicker and clear action
  • Add format selector (PNG/JPEG) via EnumBox
  • Add name-padding input field with IntInputField
lib/screens/settings/player_settings_page.dart
Integrate hotkeys and file-saving logic in desktop controls
  • Introduce takeScreenshot method that collects buffer, determines next file name with padding, and writes file
  • Map VideoHotKeys.takeScreenshot and takeScreenshotClean to hotkey cases
lib/screens/video_player/video_player_controls.dart
Define and implement takeScreenshot API across player wrappers
  • Add takeScreenshot to BasePlayer interface
  • Implement in LibMPV (native screenshot call) and LibMDK (snapshot, subtitle toggle, encode with image lib)
  • Provide no-op stubs for web and native players
  • Forward takeScreenshot through MediaControlsWrapper
lib/wrappers/players/base_player.dart
lib/wrappers/players/lib_mpv.dart
lib/wrappers/players/lib_mdk.dart
lib/stubs/web/lib_mdk_web.dart
lib/wrappers/players/native_player.dart
lib/wrappers/media_control_wrapper.dart
Add image dependency for encoding screenshots
  • Include image package in pubspec for PNG/JPEG encoding
pubspec.yaml

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey there - I've reviewed your changes and they look great!

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location> `lib/screens/settings/player_settings_page.dart:232` </location>
<code_context>
+                    (current) => current.copyWith(screenshotNamePadding: value ?? 3),
+                  );
+
+                  ref.read(videoPlayerSettingsProvider.notifier).setscreenshotNamePadding(value ?? 3);
+                },
+              )),
</code_context>

<issue_to_address>
**issue:** Duplicate state update for screenshotNamePadding.

Consider removing one of these calls to prevent redundant state updates.
</issue_to_address>

### Comment 2
<location> `lib/screens/video_player/video_player_controls.dart:714` </location>
<code_context>
+      final paddingAmount = ref.read(videoPlayerSettingsProvider).screenshotNamePadding;
+      int maxNumber = 0;
+
+      await for (var file in savePathDirectory.list()) {
+        final finalSegment = file.uri.pathSegments.last;
+
+        if (file is File && finalSegment.endsWith(fileExtension)) {
+          final match = RegExp(r'(\d+)').firstMatch(finalSegment);
+
</code_context>

<issue_to_address>
**suggestion (bug_risk):** File extension check may not be robust for multi-dot filenames.

Using 'endsWith(fileExtension)' may incorrectly match files with multiple dots. Use 'p.extension(finalSegment) == ".${fileExtension}"' for accurate extension checking.

```suggestion
        if (file is File && p.extension(finalSegment) == ".${fileExtension}") {
```
</issue_to_address>

### Comment 3
<location> `lib/wrappers/players/lib_mpv.dart:163` </location>
<code_context>
+  Future<Uint8List?> takeScreenshot(ScreenshotFormat format, bool withSubtitles) async {
+    String screenshotFormat = "";
+    
+    // I'm intentionally not provinding a default case so we explicitely handle potential new formats.
+    switch (format) {
+      case ScreenshotFormat.jpeg:
</code_context>

<issue_to_address>
**nitpick (typo):** Typo in comment: 'provinding' should be 'providing'.

```suggestion
    // I'm intentionally not providing a default case so we explicitely handle potential new formats.
```
</issue_to_address>

### Comment 4
<location> `lib/wrappers/media_control_wrapper.dart:374-376` </location>
<code_context>
     }
   }
+
+  Future<Uint8List?>? takeScreenshot(bool withSubtitles) {
+    return _player?.takeScreenshot(ref.read(videoPlayerSettingsProvider.select((value) => value.screenshotFormat)), withSubtitles);
+  }
</code_context>

<issue_to_address>
**suggestion:** Return type is nullable Future, which may be confusing.

Consider whether both the Future and its result need to be nullable, or if using 'Future<Uint8List?>' would be clearer.

```suggestion
  Future<Uint8List?> takeScreenshot(bool withSubtitles) {
    final player = _player;
    if (player == null) {
      return Future.value(null);
    }
    return player.takeScreenshot(
      ref.read(videoPlayerSettingsProvider.select((value) => value.screenshotFormat)),
      withSubtitles,
    );
  }
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread lib/screens/settings/player_settings_page.dart Outdated
Comment thread lib/screens/video_player/video_player_controls.dart Outdated
Comment thread lib/wrappers/players/lib_mpv.dart Outdated
Comment thread lib/wrappers/media_control_wrapper.dart Outdated

@PartyDonut PartyDonut left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for taking a shot at this. Functionality wise it seems to work just great!.
I've left a few comments let me know if any of them are unclear.

Some other notes/suggestions

Do we need the format selection? Would it not be better to always use PNG and let the user decide or convert it themselves later on. This would also clean up the user settings.

I would also suggest to use the existing download path directory and use "Screenshots" as sub directory.

One other thing taking a screenshot gives the user no feedback, we should add some sort of message or pop-up that notifies the user that screenshot "001.png" was saved.

Comment thread lib/screens/settings/player_settings_page.dart Outdated
}
}

void takeScreenshot({required bool withSubtitles}) async {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's move this function outside of the video player controls into it's own provider.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be fine to put this on the video_player_provider?

Comment thread lib/wrappers/players/lib_mdk.dart Outdated
}

@override
Future<Uint8List?> takeScreenshot(ScreenshotFormat format, bool withSubtitles) async {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MDK seems to always take screenshots without subtitles?

Lets also change the track to disabled instead of loading/reloading the entire list. Currently the mdk player stutters quite heavily when taking a screenshot this might be part of the reason.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MDK seems to always take screenshots without subtitles?

It seems to be inconsistent, befitting of a jank approach like this. In my tests, it usually takes the screenshot without subtitles, but it fails to enable them again. Sometimes it does enable them back though...

Lets also change the track to disabled instead of loading/reloading the entire list. Currently the mdk player stutters quite heavily when taking a screenshot this might be part of the reason.

Hints welcome on how to approach this one

Comment thread lib/wrappers/players/lib_mdk.dart
Comment thread pubspec.yaml
Comment thread lib/providers/settings/video_player_settings_provider.dart Outdated
Comment thread lib/providers/settings/video_player_settings_provider.dart Outdated
Comment thread lib/providers/settings/video_player_settings_provider.dart Outdated
@RainbowCookie32

Copy link
Copy Markdown
Contributor Author

Thanks for the review! I'll be chipping away at the comments later today, or tomorrow.

Do we need the format selection? Would it not be better to always use PNG and let the user decide or convert it themselves later on. This would also clean up the user settings.

Personally I always use PNG. I only added the setting for user choice, and maybe allowing support for more formats in the future, but I'm definitely not attached to it.

I would also suggest to use the existing download path directory and use "Screenshots" as sub directory.

Do you mean as a default, or just fixed to that folder? Personally I'd like to keep the option to select the directory where my screenshots go, mostly to keep them at hand and in the folder I had all the others.

One other thing taking a screenshot gives the user no feedback, we should add some sort of message or pop-up that notifies the user that screenshot "001.png" was saved.

Fair enough, I'll take a look and try to put something together

@RainbowCookie32

Copy link
Copy Markdown
Contributor Author

Thank god, I didn't break the PR after the rebase lol.

I pushed the first batch of changes and fixes. Still missing getting takeScreenshot a new home, plus the other things you mentioned that I replied to

@PartyDonut

Copy link
Copy Markdown
Collaborator

Thanks for the review! I'll be chipping away at the comments later today, or tomorrow.

Do we need the format selection? Would it not be better to always use PNG and let the user decide or convert it themselves later on. This would also clean up the user settings.

Personally I always use PNG. I only added the setting for user choice, and maybe allowing support for more formats in the future, but I'm definitely not attached to it.

The only reason really is that this is a "nice to have" not so much a big requirement of a Jellyfin frontend.
So for that reason I would like to keep the amount of additional settings to a minimum, maybe we only need the screenshot hotkeys and keep everything else to a sane default?

For defaults we could use what you have now.
Format = PNG.
Name padding = 3?

Maybe it's a feature a lot of people will use don't know but those defaults will probably cater to most people.

I would also suggest to use the existing download path directory and use "Screenshots" as sub directory.

Do you mean as a default, or just fixed to that folder? Personally I'd like to keep the option to select the directory where my screenshots go, mostly to keep them at hand and in the folder I had all the others.

I would say fixed, so it always saves to "$downloadsPath/Screenshots"/, to prevent the application from having to handle multiple paths for saving different kinds of data.

All that being said it is not that I don't like the options I am just scared if we keep adding advanced settings like this we will and up with huge models and huge settings screens.

@RainbowCookie32

Copy link
Copy Markdown
Contributor Author

Sorry for disappearing! Lack of time/sleep/willpower took turns on controlling my brain this week.

Nuked the settings, replaced for the defaults and using the Downloads path for screenshots in its own folder. Got over keeping it as soon as I remembered symlinks are a thing :)

I noticed that the hotkey for taking screenshots without subtitles isn't being saved for some reason. It works fine after being changed, but it resets to the default after closing Fladder. Not sure what's going on there.

I'm aware I'm still dancing around the other issues - I intend to tackle them in the next few days (or try at least).

@RainbowCookie32

Copy link
Copy Markdown
Contributor Author

I noticed that the hotkey for taking screenshots without subtitles isn't being saved for some reason. It works fine after being changed, but it resets to the default after closing Fladder. Not sure what's going on there.

Seemingly fixed itself somehow, won't complain

@PartyDonut

Copy link
Copy Markdown
Collaborator

Sorry for disappearing! Lack of time/sleep/willpower took turns on controlling my brain this week.

Nuked the settings, replaced for the defaults and using the Downloads path for screenshots in its own folder. Got over keeping it as soon as I remembered symlinks are a thing :)

I noticed that the hotkey for taking screenshots without subtitles isn't being saved for some reason. It works fine after being changed, but it resets to the default after closing Fladder. Not sure what's going on there.

I'm aware I'm still dancing around the other issues - I intend to tackle them in the next few days (or try at least).

That is perfectly fine, no rush take your time 👍🏼.

@RainbowCookie32

Copy link
Copy Markdown
Contributor Author

I moved takeScreenshot out of video_player_controls to video_player_provider, which may or may not be good. It feels a bit more appropriate at least.
While I was there, I also made it return a bool to check for success for the brand new screenshot indicator to display on screen. I based it off the seek indicator. I can't quite get the duration right, I feel like 500ms might be too fast but I tried higher numbers and it felt like it overstays its welcome. Might just be a matter of getting used to it

@RainbowCookie32

Copy link
Copy Markdown
Contributor Author

Forgot to add the new strings to the big file.

An issue I noticed since I added the screenshots indicator is that the seek key shortcut doesn't work anymore. If I take out the screenshot indicator, it works, and if I move the seek indicator to be after the screenshot one on video_player_controls.dart, then the latter one breaks. Clearly I broke something with my approach, but I can't tell what that something is (I'm also still failing to get mdk to play along with the clean screenshots, but I haven't dedicated that much time to it tbf).

@PartyDonut

Copy link
Copy Markdown
Collaborator

Forgot to add the new strings to the big file.

An issue I noticed since I added the screenshots indicator is that the seek key shortcut doesn't work anymore. If I take out the screenshot indicator, it works, and if I move the seek indicator to be after the screenshot one on video_player_controls.dart, then the latter one breaks. Clearly I broke something with my approach, but I can't tell what that something is (I'm also still failing to get mdk to play along with the clean screenshots, but I haven't dedicated that much time to it tbf).

The input handler can be a bit strange when nesting multiple listeners with autoFocus enabled. My first suggestion would be play around with who does/does not have autoFocus. But this might also require more investigating on my end to improve stability.

For disabling the subtitles in mdk I would suggest looking at the LibMDK class, specifically the setSubtitleTrack function. Easiest would be to use that function, save the current model, set the subtitle to (SubStreamModel.no()). And after the screenshot, set the original subtitle again. That should work.

@RainbowCookie32

Copy link
Copy Markdown
Contributor Author

The input handler can be a bit strange when nesting multiple listeners with autoFocus enabled. My first suggestion would be play around with who does/does not have autoFocus. But this might also require more investigating on my end to improve stability.

I tried setting autoFocus to true on just one of them, on both, and then both on false and the result is still the same. Hotkey for screenshots works, but the seek ones don't.

For disabling the subtitles in mdk I would suggest looking at the LibMDK class, specifically the setSubtitleTrack function. Easiest would be to use that function, save the current model, set the subtitle to (SubStreamModel.no()). And after the screenshot, set the original subtitle again. That should work.

I see setSubtitleTrack takes a SubStreamModel and PlaybackModel. Is there any way I can get the current models from LibMDK? For the sub one I thought about just saving it when the function is called, but idk if that'd work well for the playback one, since I assume it can also be modified from other places. My other idea is adding a PlaybackModel argument to takeScreenshot, but it'd be nice if it could be avoided imo as it's a generic method and only MDK would use it.

@PartyDonut

Copy link
Copy Markdown
Collaborator

I tried setting autoFocus to true on just one of them, on both, and then both on false and the result is still the same. Hotkey for screenshots works, but the seek ones don't.

This is probably a tree ordering issue. The last one in the tree gets focus, however normally it should pass on any key that is not handled so that could be the problem. But looking at your code that seems to be handled just fine.

I see setSubtitleTrack takes a SubStreamModel and PlaybackModel. Is there any way I can get the current models from LibMDK? For the sub one I thought about just saving it when the function is called, but idk if that'd work well for the playback one, since I assume it can also be modified from other places. My other idea is adding a PlaybackModel argument to takeScreenshot, but it'd be nice if it could be avoided imo as it's a generic method and only MDK would use it.

The players should not be aware of the selected subtitles or at least not as they are presented in the UI. My suggestion would be to set the subtitles just before you ask the player to take the screenshot and then implement it like in the video_player_options_sheet.dart -> showSubSelection() function.

Keep in mind disabling subtitles while transcoding could cause the playback the re-evaluate whether transcoding is needed. Make sure you keep the subtitles state as is when transcoding.

@RainbowCookie32 RainbowCookie32 force-pushed the develop branch 2 times, most recently from 81169a8 to 0e660e6 Compare November 1, 2025 14:31
makes clean screenshots consistent on mdk
@RainbowCookie32

Copy link
Copy Markdown
Contributor Author

I feel like it's a miracle I haven't borked this PR yet with the jank rebases.

I think I got the clean screenshots working on both players now, did a quick test and they are looking fine. Subs noticeably blink for a second now on mpv, which didn't happen before and is an unfortunate nerf, but I think this is the cleanest way to get feature parity with both players.

The seek hotkeys still refuse to play ball alongside with the screenshot ones tho...

@PartyDonut

Copy link
Copy Markdown
Collaborator

Just checking in @RainbowCookie32 are you still interested in finishing this?

@RainbowCookie32

Copy link
Copy Markdown
Contributor Author

Oof, it's been a while. Seems like I have a few rebases pending.

I think that the last batch of changes I pushed addressed everything you mentioned, with only the hotkeys conflict (seek and screenshots) being left, which I never really managed to figure out. Do let me know if there's anything else I should look into tho, I'll take a look at it

@RainbowCookie32

Copy link
Copy Markdown
Contributor Author

Re-rebased, didn't realise I could request a new review so there it goes!

@PartyDonut PartyDonut left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed the layered input not working by adding a "hardwareKeyboard" fallback option. Not the cleanest option but good enough for now.

Everything else looks fine. Let's merge this for now and fix anything we find in new pull-requests so this one doesn't become too large.

Thanks for implementing this 😄

Comment thread lib/models/settings/video_player_settings.dart Outdated
@PartyDonut PartyDonut merged commit 12ef666 into DonutWare:develop Dec 5, 2025
1 check passed
@RainbowCookie32

Copy link
Copy Markdown
Contributor Author

Nice! Thanks for the guidance on this too :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants