aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--LICENSE674
-rw-r--r--README.md52
-rw-r--r--com.dec05eba.gpu_screen_recorder.desktop10
-rw-r--r--gsr-logo.pngbin0 -> 2110 bytes
-rw-r--r--icons/hicolor/128x128/apps/com.dec05eba.gpu_screen_recorder.pngbin0 -> 3709 bytes
-rw-r--r--icons/hicolor/128x128/status/com.dec05eba.gpu_screen_recorder.tray-idle.pngbin0 -> 2202 bytes
-rw-r--r--icons/hicolor/128x128/status/com.dec05eba.gpu_screen_recorder.tray-paused.pngbin0 -> 2206 bytes
-rw-r--r--icons/hicolor/128x128/status/com.dec05eba.gpu_screen_recorder.tray-recording.pngbin0 -> 2186 bytes
-rw-r--r--icons/hicolor/32x32/apps/com.dec05eba.gpu_screen_recorder.pngbin0 -> 2202 bytes
-rw-r--r--icons/hicolor/32x32/status/com.dec05eba.gpu_screen_recorder.tray-idle.pngbin0 -> 2202 bytes
-rw-r--r--icons/hicolor/32x32/status/com.dec05eba.gpu_screen_recorder.tray-paused.pngbin0 -> 2206 bytes
-rw-r--r--icons/hicolor/32x32/status/com.dec05eba.gpu_screen_recorder.tray-recording.pngbin0 -> 2186 bytes
-rw-r--r--icons/hicolor/64x64/apps/com.dec05eba.gpu_screen_recorder.pngbin0 -> 2110 bytes
-rw-r--r--icons/hicolor/64x64/status/com.dec05eba.gpu_screen_recorder.tray-idle.pngbin0 -> 2202 bytes
-rw-r--r--icons/hicolor/64x64/status/com.dec05eba.gpu_screen_recorder.tray-paused.pngbin0 -> 2206 bytes
-rw-r--r--icons/hicolor/64x64/status/com.dec05eba.gpu_screen_recorder.tray-recording.pngbin0 -> 2186 bytes
-rw-r--r--meson.build66
-rw-r--r--meson_options.txt2
-rw-r--r--screenshot1.pngbin0 -> 54152 bytes
-rw-r--r--src/global_shortcuts.c336
-rw-r--r--src/global_shortcuts.h41
-rw-r--r--src/gsr-config-page.c1403
-rw-r--r--src/gsr-config-page.h101
-rw-r--r--src/gsr-config.c591
-rw-r--r--src/gsr-config.h175
-rw-r--r--src/gsr-hotkeys.c435
-rw-r--r--src/gsr-hotkeys.h52
-rw-r--r--src/gsr-info.c393
-rw-r--r--src/gsr-info.h111
-rw-r--r--src/gsr-record-page.c747
-rw-r--r--src/gsr-record-page.h44
-rw-r--r--src/gsr-replay-page.c726
-rw-r--r--src/gsr-replay-page.h45
-rw-r--r--src/gsr-shortcut-accel-dialog.c372
-rw-r--r--src/gsr-shortcut-accel-dialog.h35
-rw-r--r--src/gsr-stream-page.c691
-rw-r--r--src/gsr-stream-page.h39
-rw-r--r--src/gsr-window.c1214
-rw-r--r--src/gsr-window.h113
-rw-r--r--src/gsr-x11-hotkeys.c233
-rw-r--r--src/gsr-x11-hotkeys.h46
-rw-r--r--src/gsr-x11-window-picker.c329
-rw-r--r--src/gsr-x11-window-picker.h56
-rw-r--r--src/main.c138
44 files changed, 9270 insertions, 0 deletions
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..f288702
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ <program> Copyright (C) <year> <name of author>
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+<https://www.gnu.org/licenses/>.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+<https://www.gnu.org/licenses/why-not-lgpl.html>.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..fe2e05c
--- /dev/null
+++ b/README.md
@@ -0,0 +1,52 @@
+![](./gsr-logo.png)
+
+# GPU Screen Recorder (libadwaita port)
+GNOME frontend for [GPU Screen Recorder](https://git.dec05eba.com/gpu-screen-recorder/about/).
+
+## Notes
+The program has to be launched from your application launcher or hotkeys may not work properly in your Wayland compositor (this is the case with GNOME).
+
+# Installation
+```sh
+meson setup build
+ninja -C build
+sudo ninja -C build install
+```
+
+## Build options
+Both X11 and Wayland backends are enabled by default. Distributions can disable either at compile time:
+
+```sh
+# Wayland-only build (no X11 dependency)
+meson setup build -Dx11=false
+
+# X11-only build (no Wayland portal shortcuts)
+meson setup build -Dwayland=false
+```
+
+| Option | Default | Description |
+|-----------|---------|----------------------------------------------|
+| `x11` | `true` | Enable X11 hotkeys and window picker |
+| `wayland` | `true` | Enable Wayland global shortcuts via portal |
+
+# Dependencies
+The app uses the meson build system so you need to install `meson` and `ninja`.
+
+## Build dependencies
+These are the dependencies needed to build GPU Screen Recorder Adwaita:
+
+* libadwaita (>= 1.8)
+* libx11 (optional, required when `-Dx11=true`)
+* desktop-file-utils
+
+## Runtime dependencies
+There are also additional dependencies needed at runtime:
+
+* [GPU Screen Recorder](https://git.dec05eba.com/gpu-screen-recorder/)
+
+## Screenshots
+
+![](./screenshot1.png)
+
+# Reporting bugs, contributing patches, questions or donation
+See [gpu-screen-recorder-adwaita](https://github.com/runlevel5/gpu-screen-recorder-adwaita/issues)
diff --git a/com.dec05eba.gpu_screen_recorder.desktop b/com.dec05eba.gpu_screen_recorder.desktop
new file mode 100644
index 0000000..53226ae
--- /dev/null
+++ b/com.dec05eba.gpu_screen_recorder.desktop
@@ -0,0 +1,10 @@
+[Desktop Entry]
+Type=Application
+Name=GPU Screen Recorder
+GenericName=Screen recorder
+Comment=A gpu based screen recorder / streaming program
+Icon=com.dec05eba.gpu_screen_recorder
+Exec=gpu-screen-recorder-adw
+Terminal=false
+Keywords=gpu-screen-recorder;screen recorder;streaming;twitch;replay;shadowplay;
+Categories=AudioVideo;Recorder;
diff --git a/gsr-logo.png b/gsr-logo.png
new file mode 100644
index 0000000..f66332b
--- /dev/null
+++ b/gsr-logo.png
Binary files differ
diff --git a/icons/hicolor/128x128/apps/com.dec05eba.gpu_screen_recorder.png b/icons/hicolor/128x128/apps/com.dec05eba.gpu_screen_recorder.png
new file mode 100644
index 0000000..f2b99a1
--- /dev/null
+++ b/icons/hicolor/128x128/apps/com.dec05eba.gpu_screen_recorder.png
Binary files differ
diff --git a/icons/hicolor/128x128/status/com.dec05eba.gpu_screen_recorder.tray-idle.png b/icons/hicolor/128x128/status/com.dec05eba.gpu_screen_recorder.tray-idle.png
new file mode 100644
index 0000000..431162b
--- /dev/null
+++ b/icons/hicolor/128x128/status/com.dec05eba.gpu_screen_recorder.tray-idle.png
Binary files differ
diff --git a/icons/hicolor/128x128/status/com.dec05eba.gpu_screen_recorder.tray-paused.png b/icons/hicolor/128x128/status/com.dec05eba.gpu_screen_recorder.tray-paused.png
new file mode 100644
index 0000000..d24957a
--- /dev/null
+++ b/icons/hicolor/128x128/status/com.dec05eba.gpu_screen_recorder.tray-paused.png
Binary files differ
diff --git a/icons/hicolor/128x128/status/com.dec05eba.gpu_screen_recorder.tray-recording.png b/icons/hicolor/128x128/status/com.dec05eba.gpu_screen_recorder.tray-recording.png
new file mode 100644
index 0000000..151a5a7
--- /dev/null
+++ b/icons/hicolor/128x128/status/com.dec05eba.gpu_screen_recorder.tray-recording.png
Binary files differ
diff --git a/icons/hicolor/32x32/apps/com.dec05eba.gpu_screen_recorder.png b/icons/hicolor/32x32/apps/com.dec05eba.gpu_screen_recorder.png
new file mode 100644
index 0000000..431162b
--- /dev/null
+++ b/icons/hicolor/32x32/apps/com.dec05eba.gpu_screen_recorder.png
Binary files differ
diff --git a/icons/hicolor/32x32/status/com.dec05eba.gpu_screen_recorder.tray-idle.png b/icons/hicolor/32x32/status/com.dec05eba.gpu_screen_recorder.tray-idle.png
new file mode 100644
index 0000000..431162b
--- /dev/null
+++ b/icons/hicolor/32x32/status/com.dec05eba.gpu_screen_recorder.tray-idle.png
Binary files differ
diff --git a/icons/hicolor/32x32/status/com.dec05eba.gpu_screen_recorder.tray-paused.png b/icons/hicolor/32x32/status/com.dec05eba.gpu_screen_recorder.tray-paused.png
new file mode 100644
index 0000000..d24957a
--- /dev/null
+++ b/icons/hicolor/32x32/status/com.dec05eba.gpu_screen_recorder.tray-paused.png
Binary files differ
diff --git a/icons/hicolor/32x32/status/com.dec05eba.gpu_screen_recorder.tray-recording.png b/icons/hicolor/32x32/status/com.dec05eba.gpu_screen_recorder.tray-recording.png
new file mode 100644
index 0000000..151a5a7
--- /dev/null
+++ b/icons/hicolor/32x32/status/com.dec05eba.gpu_screen_recorder.tray-recording.png
Binary files differ
diff --git a/icons/hicolor/64x64/apps/com.dec05eba.gpu_screen_recorder.png b/icons/hicolor/64x64/apps/com.dec05eba.gpu_screen_recorder.png
new file mode 100644
index 0000000..f66332b
--- /dev/null
+++ b/icons/hicolor/64x64/apps/com.dec05eba.gpu_screen_recorder.png
Binary files differ
diff --git a/icons/hicolor/64x64/status/com.dec05eba.gpu_screen_recorder.tray-idle.png b/icons/hicolor/64x64/status/com.dec05eba.gpu_screen_recorder.tray-idle.png
new file mode 100644
index 0000000..431162b
--- /dev/null
+++ b/icons/hicolor/64x64/status/com.dec05eba.gpu_screen_recorder.tray-idle.png
Binary files differ
diff --git a/icons/hicolor/64x64/status/com.dec05eba.gpu_screen_recorder.tray-paused.png b/icons/hicolor/64x64/status/com.dec05eba.gpu_screen_recorder.tray-paused.png
new file mode 100644
index 0000000..d24957a
--- /dev/null
+++ b/icons/hicolor/64x64/status/com.dec05eba.gpu_screen_recorder.tray-paused.png
Binary files differ
diff --git a/icons/hicolor/64x64/status/com.dec05eba.gpu_screen_recorder.tray-recording.png b/icons/hicolor/64x64/status/com.dec05eba.gpu_screen_recorder.tray-recording.png
new file mode 100644
index 0000000..151a5a7
--- /dev/null
+++ b/icons/hicolor/64x64/status/com.dec05eba.gpu_screen_recorder.tray-recording.png
Binary files differ
diff --git a/meson.build b/meson.build
new file mode 100644
index 0000000..c891547
--- /dev/null
+++ b/meson.build
@@ -0,0 +1,66 @@
+project('gpu-screen-recorder-adwaita', 'c',
+ version : '6.0.0',
+ default_options : ['warning_level=2', 'c_std=gnu17'],
+)
+
+add_project_arguments('-Wshadow', language : 'c')
+if get_option('buildtype') == 'debug'
+ add_project_arguments('-g3', language : 'c')
+elif get_option('buildtype') == 'release'
+ add_project_arguments('-DNDEBUG', language : 'c')
+endif
+
+src = [
+ 'src/main.c',
+ 'src/gsr-window.c',
+ 'src/gsr-info.c',
+ 'src/gsr-config.c',
+ 'src/gsr-config-page.c',
+ 'src/gsr-stream-page.c',
+ 'src/gsr-record-page.c',
+ 'src/gsr-replay-page.c',
+ 'src/gsr-hotkeys.c',
+]
+
+dep = [
+ dependency('libadwaita-1', version : '>=1.8'),
+]
+
+c_args = [
+ '-DGSR_ICONS_PATH="' + join_paths(get_option('prefix'), get_option('datadir'), 'icons') + '"',
+ '-DGSR_VERSION="' + meson.project_version() + '"',
+]
+
+if get_option('x11')
+ src += [
+ 'src/gsr-x11-hotkeys.c',
+ 'src/gsr-x11-window-picker.c',
+ 'src/gsr-shortcut-accel-dialog.c',
+ ]
+ dep += dependency('x11')
+ c_args += '-DHAVE_X11'
+endif
+
+if get_option('wayland')
+ src += [
+ 'src/global_shortcuts.c',
+ ]
+ c_args += '-DHAVE_WAYLAND'
+endif
+
+executable('gpu-screen-recorder-adw',
+ src,
+ dependencies : dep,
+ include_directories : include_directories('src'),
+ install : true,
+ c_args : c_args,
+)
+
+install_data(
+ files('../com.dec05eba.gpu_screen_recorder.desktop'),
+ install_dir : join_paths(get_option('prefix'), get_option('datadir'), 'applications'),
+)
+install_subdir('icons/hicolor', install_dir : join_paths(get_option('prefix'), get_option('datadir'), 'icons'))
+
+gnome = import('gnome')
+gnome.post_install(gtk_update_icon_cache : true, update_desktop_database : true)
diff --git a/meson_options.txt b/meson_options.txt
new file mode 100644
index 0000000..4c2aeb2
--- /dev/null
+++ b/meson_options.txt
@@ -0,0 +1,2 @@
+option('x11', type : 'boolean', value : true, description : 'Enable X11 support')
+option('wayland', type : 'boolean', value : true, description : 'Enable Wayland support')
diff --git a/screenshot1.png b/screenshot1.png
new file mode 100644
index 0000000..ac7478f
--- /dev/null
+++ b/screenshot1.png
Binary files differ
diff --git a/src/global_shortcuts.c b/src/global_shortcuts.c
new file mode 100644
index 0000000..a7540f0
--- /dev/null
+++ b/src/global_shortcuts.c
@@ -0,0 +1,336 @@
+#include "global_shortcuts.h"
+#include <stdio.h>
+#include <errno.h>
+#include <string.h>
+#include <sys/random.h>
+#include <gio/gio.h>
+
+/* TODO: Remove G_DBUS_CALL_FLAGS_NO_AUTO_START and G_DBUS_PROXY_FLAGS_DO_NOT_AUTO_START? also in gpu screen recorder equivalent */
+/* TODO: More error handling and clean up resources after done */
+/* TODO: Use GArray instead of GVariant where possible */
+
+static bool generate_random_characters(char *buffer, int buffer_size, const char *alphabet, size_t alphabet_size) {
+ /* TODO: Use other functions on other platforms than linux */
+ if(getrandom(buffer, buffer_size, 0) < buffer_size) {
+ fprintf(stderr, "gsr error: generate_random_characters: failed to get random bytes, error: %s\n", strerror(errno));
+ return false;
+ }
+
+ for(int i = 0; i < buffer_size; ++i) {
+ unsigned char c = *(unsigned char*)&buffer[i];
+ buffer[i] = alphabet[c % alphabet_size];
+ }
+
+ return true;
+}
+
+static void gsr_dbus_portal_get_unique_handle_token(gsr_global_shortcuts *self, char *buffer, int size) {
+ snprintf(buffer, size, "gpu_screen_recorder_adwaita_handle_%s_%u", self->random_str, self->handle_counter++);
+}
+
+/* Assumes shortcuts is an array */
+static void handle_shortcuts_data(GVariant *shortcuts, gsr_shortcut_callback callback, void *userdata) {
+ for(guint i = 0; i < g_variant_n_children(shortcuts); ++i) {
+ gchar *shortcut_id = NULL;
+ GVariant *shortcut_values = NULL;
+ g_variant_get_child(shortcuts, i, "(s@a{sv})", &shortcut_id, &shortcut_values);
+
+ if(!shortcut_id || !shortcut_values)
+ continue;
+
+ // gchar *description = NULL;
+ // g_variant_lookup(shortcut_values, "description", "s", &description);
+
+ gchar *trigger_description = NULL;
+ g_variant_lookup(shortcut_values, "trigger_description", "s", &trigger_description);
+
+ gsr_shortcut shortcut;
+ shortcut.id = shortcut_id;
+ shortcut.trigger_description = trigger_description ? trigger_description : "";
+ callback(shortcut, userdata);
+ }
+}
+
+typedef struct {
+ gsr_global_shortcuts *self;
+ gsr_shortcut_callback callback;
+ void *userdata;
+} signal_list_bind_userdata;
+
+static void dbus_signal_list_bind(GDBusProxy *proxy, gchar *sender_name, gchar *signal_name, GVariant *parameters, signal_list_bind_userdata *userdata) {
+ (void)proxy;
+ (void)sender_name;
+ if(g_strcmp0(signal_name, "Response") != 0)
+ goto done;
+
+ guint32 response = 0;
+ GVariant *results = NULL;
+ g_variant_get(parameters, "(u@a{sv})", &response, &results);
+
+ if(response != 0 || !results)
+ goto done;
+
+ GVariant *shortcuts = g_variant_lookup_value(results, "shortcuts", G_VARIANT_TYPE("a(sa{sv})"));
+ if(!shortcuts)
+ goto done;
+
+ handle_shortcuts_data(shortcuts, userdata->callback, userdata->userdata);
+
+ done:
+ free(userdata);
+}
+
+typedef struct {
+ gsr_global_shortcuts *self;
+ gsr_deactivated_callback deactivated_callback;
+ gsr_shortcut_callback shortcut_changed_callback;
+ void *userdata;
+} signal_userdata;
+
+static void signal_callback(GDBusConnection *connection,
+ const gchar *sender_name,
+ const gchar *object_path,
+ const gchar *interface_name,
+ const gchar *signal_name,
+ GVariant *parameters,
+ gpointer userdata)
+{
+ (void)connection;
+ (void)sender_name;
+ (void)object_path;
+ (void)interface_name;
+ (void)signal_name;
+ (void)parameters;
+ signal_userdata *cu = userdata;
+
+ /* Button released */
+ if(strcmp(signal_name, "Deactivated") == 0) {
+ gchar *session_handle = NULL;
+ gchar *shortcut_id = NULL;
+ gchar *timestamp = NULL;
+ GVariant *options = NULL;
+ g_variant_get(parameters, "(ost@a{sv})", &session_handle, &shortcut_id, &timestamp, &options);
+
+ if(session_handle && shortcut_id && strcmp(session_handle, cu->self->session_handle) == 0)
+ cu->deactivated_callback(shortcut_id, cu->userdata);
+ } else if(strcmp(signal_name, "ShortcutsChanged") == 0) {
+ gchar *session_handle = NULL;
+ GVariant *shortcuts = NULL;
+ g_variant_get(parameters, "(o@a(sa{sv}))", &session_handle, &shortcuts);
+
+ if(session_handle && shortcuts && strcmp(session_handle, cu->self->session_handle) == 0)
+ handle_shortcuts_data(shortcuts, cu->shortcut_changed_callback, cu->userdata);
+ }
+}
+
+typedef struct {
+ gsr_global_shortcuts *self;
+ gsr_init_callback callback;
+ void *userdata;
+} signal_create_session_userdata;
+
+static void dbus_signal_create_session(GDBusProxy *proxy, gchar *sender_name, gchar *signal_name, GVariant *parameters, signal_create_session_userdata *cu) {
+ (void)proxy;
+ (void)sender_name;
+ if(g_strcmp0(signal_name, "Response") != 0)
+ goto done;
+
+ guint32 response = 0;
+ GVariant *results = NULL;
+ g_variant_get(parameters, "(u@a{sv})", &response, &results);
+
+ if(response != 0 || !results) {
+ cu->callback(false, cu->userdata);
+ goto done;
+ }
+
+ gchar *session_handle = NULL;
+ if(g_variant_lookup(results, "session_handle", "s", &session_handle) && session_handle) {
+ cu->self->session_handle = strdup(session_handle);
+ cu->self->session_created = true;
+ cu->callback(true, cu->userdata);
+ }
+
+ done:
+ free(cu);
+}
+
+static bool gsr_global_shortcuts_create_session(gsr_global_shortcuts *self, gsr_init_callback callback, void *userdata) {
+ char handle_token[64];
+ gsr_dbus_portal_get_unique_handle_token(self, handle_token, sizeof(handle_token));
+
+ char session_handle_token[64];
+ snprintf(session_handle_token, sizeof(session_handle_token), "gpu_screen_recorder_adwaita");
+
+ GVariantBuilder builder;
+ g_variant_builder_init(&builder, G_VARIANT_TYPE("a{sv}"));
+ g_variant_builder_add(&builder, "{sv}", "handle_token", g_variant_new_string(handle_token));
+ g_variant_builder_add(&builder, "{sv}", "session_handle_token", g_variant_new_string(session_handle_token));
+ GVariant *aa = g_variant_builder_end(&builder);
+
+ GVariant *ret = g_dbus_connection_call_sync(self->gdbus_con, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.GlobalShortcuts", "CreateSession", g_variant_new_tuple(&aa, 1), NULL, G_DBUS_CALL_FLAGS_NO_AUTO_START, 1000, NULL, NULL);
+
+ if(ret) {
+ const gchar *val = NULL;
+ g_variant_get(ret, "(&o)", &val);
+ if(!val)
+ return false;
+ //g_variant_unref(ret);
+
+ GDBusProxy *proxy = g_dbus_proxy_new_for_bus_sync(G_BUS_TYPE_SESSION, G_DBUS_PROXY_FLAGS_DO_NOT_AUTO_START, NULL, "org.freedesktop.portal.Desktop", val, "org.freedesktop.portal.Request", NULL, NULL);
+ if(!proxy)
+ return false;
+ //g_object_unref(proxy);
+
+ signal_create_session_userdata *cu = malloc(sizeof(signal_create_session_userdata));
+ cu->self = self;
+ cu->callback = callback;
+ cu->userdata = userdata;
+ g_signal_connect(proxy, "g-signal", G_CALLBACK(dbus_signal_create_session), cu);
+ return true;
+ } else {
+ return false;
+ }
+}
+
+bool gsr_global_shortcuts_init(gsr_global_shortcuts *self, gsr_init_callback callback, void *userdata) {
+ memset(self, 0, sizeof(*self));
+
+ self->random_str[DBUS_RANDOM_STR_SIZE] = '\0';
+ if(!generate_random_characters(self->random_str, DBUS_RANDOM_STR_SIZE, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", 62)) {
+ fprintf(stderr, "gsr error: gsr_global_shortcuts_init: failed to generate random string\n");
+ return false;
+ }
+
+ self->gdbus_con = g_bus_get_sync(G_BUS_TYPE_SESSION, NULL, NULL);
+ if(!self->gdbus_con) {
+ fprintf(stderr, "gsr error: gsr_global_shortcuts_init: g_bus_get_sync failed\n");
+ return false;
+ }
+
+ if(!gsr_global_shortcuts_create_session(self, callback, userdata)) {
+ gsr_global_shortcuts_deinit(self);
+ return false;
+ }
+
+ return true;
+}
+
+void gsr_global_shortcuts_deinit(gsr_global_shortcuts *self) {
+ if(self->gdbus_con) {
+ /* TODO: Re-add this. Right now it causes errors as the connection is already closed, but checking if it's already closed here has no effect */
+ //g_dbus_connection_close(self->gdbus_con, NULL, NULL, NULL);
+ self->gdbus_con = NULL;
+ }
+
+ if(self->session_handle) {
+ free(self->session_handle);
+ self->session_handle = NULL;
+ }
+}
+
+bool gsr_global_shortcuts_list_shortcuts(gsr_global_shortcuts *self, gsr_shortcut_callback callback, void *userdata) {
+ if(!self->session_created)
+ return false;
+
+ char handle_token[64];
+ gsr_dbus_portal_get_unique_handle_token(self, handle_token, sizeof(handle_token));
+
+ GVariant *session_handle_obj = g_variant_new_object_path(self->session_handle);
+
+ GVariantBuilder builder;
+ g_variant_builder_init(&builder, G_VARIANT_TYPE("a{sv}"));
+ g_variant_builder_add(&builder, "{sv}", "handle_token", g_variant_new_string(handle_token));
+ GVariant *aa = g_variant_builder_end(&builder);
+
+ GVariant *args[2] = { session_handle_obj, aa };
+
+ GVariant *ret = g_dbus_connection_call_sync(self->gdbus_con, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.GlobalShortcuts", "ListShortcuts", g_variant_new_tuple(args, 2), NULL, G_DBUS_CALL_FLAGS_NO_AUTO_START, 1000, NULL, NULL);
+ if(ret) {
+ const gchar *val = NULL;
+ g_variant_get(ret, "(&o)", &val);
+ if(!val)
+ return false;
+
+ GDBusProxy *proxy = g_dbus_proxy_new_for_bus_sync(G_BUS_TYPE_SESSION, G_DBUS_PROXY_FLAGS_DO_NOT_AUTO_START, NULL, "org.freedesktop.portal.Desktop", val, "org.freedesktop.portal.Request", NULL, NULL);
+ if(!proxy)
+ return false;
+ //g_object_unref(proxy);
+
+ signal_list_bind_userdata *cu = malloc(sizeof(signal_list_bind_userdata));
+ cu->self = self;
+ cu->callback = callback;
+ cu->userdata = userdata;
+ g_signal_connect(proxy, "g-signal", G_CALLBACK(dbus_signal_list_bind), cu);
+ return true;
+ } else {
+ return false;
+ }
+}
+
+bool gsr_global_shortcuts_bind_shortcuts(gsr_global_shortcuts *self, const gsr_bind_shortcut *shortcuts, int num_shortcuts, gsr_shortcut_callback callback, void *userdata) {
+ if(!self->session_created)
+ return false;
+
+ char handle_token[64];
+ gsr_dbus_portal_get_unique_handle_token(self, handle_token, sizeof(handle_token));
+
+ GVariant *session_handle_obj = g_variant_new_object_path(self->session_handle);
+
+ GVariantBuilder builder;
+ g_variant_builder_init(&builder, G_VARIANT_TYPE("a(sa{sv})"));
+
+ for(int i = 0; i < num_shortcuts; ++i) {
+ GVariantBuilder shortcuts_builder;
+ g_variant_builder_init(&shortcuts_builder, G_VARIANT_TYPE("a{sv}"));
+ g_variant_builder_add(&shortcuts_builder, "{sv}", "description", g_variant_new_string(shortcuts[i].description));
+ g_variant_builder_add(&shortcuts_builder, "{sv}", "preferred_trigger", g_variant_new_string(shortcuts[i].shortcut.trigger_description));
+ GVariant *shortcuts_data = g_variant_builder_end(&shortcuts_builder);
+ GVariant *ss_l[2] = { g_variant_new_string(shortcuts[i].shortcut.id), shortcuts_data };
+ g_variant_builder_add_value(&builder, g_variant_new_tuple(ss_l, 2));
+ }
+ GVariant *aa = g_variant_builder_end(&builder);
+
+ GVariantBuilder builder_zzz;
+ g_variant_builder_init(&builder_zzz, G_VARIANT_TYPE("a{sv}"));
+ g_variant_builder_add(&builder_zzz, "{sv}", "handle_token", g_variant_new_string(handle_token));
+ GVariant *bb = g_variant_builder_end(&builder_zzz);
+
+ GVariant *parent_window = g_variant_new_string("");
+ GVariant *args[4] = { session_handle_obj, aa, parent_window, bb };
+
+ GVariant *ret = g_dbus_connection_call_sync(self->gdbus_con, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.GlobalShortcuts", "BindShortcuts", g_variant_new_tuple(args, 4), NULL, G_DBUS_CALL_FLAGS_NO_AUTO_START, -1, NULL, NULL);
+ if(ret) {
+ const gchar *val = NULL;
+ g_variant_get(ret, "(&o)", &val);
+ if(!val)
+ return false;
+
+ GDBusProxy *proxy = g_dbus_proxy_new_for_bus_sync(G_BUS_TYPE_SESSION, G_DBUS_PROXY_FLAGS_DO_NOT_AUTO_START, NULL, "org.freedesktop.portal.Desktop", val, "org.freedesktop.portal.Request", NULL, NULL);
+ if(!proxy)
+ return false;
+ //g_object_unref(proxy);
+
+ signal_list_bind_userdata *cu = malloc(sizeof(signal_list_bind_userdata));
+ cu->self = self;
+ cu->callback = callback;
+ cu->userdata = userdata;
+ g_signal_connect(proxy, "g-signal", G_CALLBACK(dbus_signal_list_bind), cu);
+ return true;
+ } else {
+ return false;
+ }
+}
+
+bool gsr_global_shortcuts_subscribe_activated_signal(gsr_global_shortcuts *self, gsr_deactivated_callback deactivated_callback, gsr_shortcut_callback shortcut_changed_callback, void *userdata) {
+ if(!self->session_created)
+ return false;
+
+ signal_userdata *cu = malloc(sizeof(signal_userdata));
+ cu->self = self;
+ cu->deactivated_callback = deactivated_callback;
+ cu->shortcut_changed_callback = shortcut_changed_callback;
+ cu->userdata = userdata;
+ g_dbus_connection_signal_subscribe(self->gdbus_con, "org.freedesktop.portal.Desktop", "org.freedesktop.portal.GlobalShortcuts", NULL, "/org/freedesktop/portal/desktop", NULL, G_DBUS_SIGNAL_FLAGS_NONE, signal_callback, cu, free);
+ return true;
+}
diff --git a/src/global_shortcuts.h b/src/global_shortcuts.h
new file mode 100644
index 0000000..087c177
--- /dev/null
+++ b/src/global_shortcuts.h
@@ -0,0 +1,41 @@
+#ifndef GLOBAL_SHORTCUTS_H
+#define GLOBAL_SHORTCUTS_H
+
+/* Global shortcuts via desktop portal */
+
+#include <stdbool.h>
+#include <gio/gio.h>
+
+#define DBUS_RANDOM_STR_SIZE 16
+
+typedef struct {
+ const char *id;
+ const char *trigger_description;
+} gsr_shortcut;
+
+typedef struct {
+ const char *description;
+ gsr_shortcut shortcut;
+} gsr_bind_shortcut;
+
+typedef void (*gsr_init_callback)(bool success, void *userdata);
+typedef void (*gsr_shortcut_callback)(gsr_shortcut shortcut, void *userdata);
+typedef void (*gsr_deactivated_callback)(const char *id, void *userdata);
+
+typedef struct {
+ GDBusConnection *gdbus_con;
+ char *session_handle;
+ bool session_created;
+ char random_str[DBUS_RANDOM_STR_SIZE + 1];
+ unsigned int handle_counter;
+} gsr_global_shortcuts;
+
+bool gsr_global_shortcuts_init(gsr_global_shortcuts *self, gsr_init_callback callback, void *userdata);
+void gsr_global_shortcuts_deinit(gsr_global_shortcuts *self);
+
+bool gsr_global_shortcuts_list_shortcuts(gsr_global_shortcuts *self, gsr_shortcut_callback callback, void *userdata);
+bool gsr_global_shortcuts_bind_shortcuts(gsr_global_shortcuts *self, const gsr_bind_shortcut *shortcuts, int num_shortcuts, gsr_shortcut_callback callback, void *userdata);
+
+bool gsr_global_shortcuts_subscribe_activated_signal(gsr_global_shortcuts *self, gsr_deactivated_callback deactivated_callback, gsr_shortcut_callback shortcut_changed_callback, void *userdata);
+
+#endif /* GLOBAL_SHORTCUTS_H */
diff --git a/src/gsr-config-page.c b/src/gsr-config-page.c
new file mode 100644
index 0000000..b1c325c
--- /dev/null
+++ b/src/gsr-config-page.c
@@ -0,0 +1,1403 @@
+#include "gsr-config-page.h"
+#ifdef HAVE_X11
+#include "gsr-x11-window-picker.h"
+#endif
+
+/* ═══════════════════════════════════════════════════════════════════
+ * GsrConfigPage — "Config" tab
+ *
+ * Groups: Capture Target · Audio · Video · Notifications
+ * All widgets are built programmatically from GsrInfo data.
+ * ═══════════════════════════════════════════════════════════════════ */
+
+struct _GsrConfigPage {
+ AdwPreferencesPage parent_instance;
+
+ const GsrInfo *info; /* borrowed, must outlive page */
+
+ /* ── Capture Target group ─── */
+ AdwPreferencesGroup *capture_group;
+ AdwComboRow *record_area_row;
+ GtkStringList *record_area_model;
+ /* parallel array of IDs for the record_area_model */
+ char **record_area_ids;
+ int n_record_area_ids;
+
+ AdwSwitchRow *change_resolution_row;
+ AdwSpinRow *video_width_row;
+ AdwSpinRow *video_height_row;
+ AdwSpinRow *area_width_row;
+ AdwSpinRow *area_height_row;
+ AdwSwitchRow *restore_portal_row;
+
+#ifdef HAVE_X11
+ /* X11 window picker */
+ AdwActionRow *select_window_row;
+ unsigned long selected_window_id; /* X11 Window, 0 = none */
+ char *selected_window_name; /* owned, or NULL */
+ GsrX11WindowPicker *active_picker; /* non-NULL during pick */
+#endif
+
+ /* ── Audio group ─── */
+ AdwPreferencesGroup *audio_group;
+ GtkListBox *audio_rows_box;
+ GtkButton *add_device_btn;
+ GtkButton *add_app_btn;
+ GtkButton *add_custom_app_btn;
+ AdwSwitchRow *split_audio_row;
+ AdwSwitchRow *app_audio_inverted_row;
+ AdwComboRow *audio_codec_row;
+
+ /* ── Video group ─── */
+ AdwPreferencesGroup *video_group;
+ AdwComboRow *quality_row;
+ AdwSpinRow *bitrate_row;
+ AdwComboRow *video_codec_row;
+ GtkStringList *video_codec_model;
+ char **video_codec_ids;
+ int n_video_codec_ids;
+ AdwComboRow *color_range_row;
+ AdwSpinRow *fps_row;
+ AdwComboRow *framerate_mode_row;
+ AdwSwitchRow *overclock_row;
+ AdwSwitchRow *record_cursor_row;
+
+ /* ── Notifications group ─── */
+ AdwPreferencesGroup *notifications_group;
+ AdwSwitchRow *notify_started_row;
+ AdwSwitchRow *notify_stopped_row;
+ AdwSwitchRow *notify_saved_row;
+};
+
+G_DEFINE_FINAL_TYPE(GsrConfigPage, gsr_config_page, ADW_TYPE_PREFERENCES_PAGE)
+
+/* ── Helpers ─────────────────────────────────────────────────────── */
+
+static void
+ids_array_append(char ***ids, int *n, int *cap, const char *id)
+{
+ if (*n >= *cap) {
+ *cap = *cap ? *cap * 2 : 16;
+ *ids = g_realloc_n(*ids, (gsize)(*cap + 1), sizeof(char *));
+ }
+ (*ids)[*n] = g_strdup(id);
+ (*n)++;
+ (*ids)[*n] = NULL;
+}
+
+/* ── Capture Target ──────────────────────────────────────────────── */
+
+static void
+on_record_area_changed(GObject *obj, GParamSpec *pspec G_GNUC_UNUSED, gpointer user_data)
+{
+ GsrConfigPage *self = GSR_CONFIG_PAGE(user_data);
+ guint idx = adw_combo_row_get_selected(ADW_COMBO_ROW(obj));
+ const char *id = (idx < (guint)self->n_record_area_ids)
+ ? self->record_area_ids[idx] : "";
+
+ gboolean is_focused = g_str_equal(id, "focused");
+ gboolean is_portal = g_str_equal(id, "portal");
+
+ /* area size rows only for "focused" */
+ gtk_widget_set_visible(GTK_WIDGET(self->area_width_row), is_focused);
+ gtk_widget_set_visible(GTK_WIDGET(self->area_height_row), is_focused);
+
+ /* change-resolution + video size rows hidden for "focused" */
+ gtk_widget_set_visible(GTK_WIDGET(self->change_resolution_row), !is_focused);
+
+ gboolean show_vid_res = !is_focused &&
+ adw_switch_row_get_active(self->change_resolution_row);
+ gtk_widget_set_visible(GTK_WIDGET(self->video_width_row), show_vid_res);
+ gtk_widget_set_visible(GTK_WIDGET(self->video_height_row), show_vid_res);
+
+ /* portal session restore */
+ gtk_widget_set_visible(GTK_WIDGET(self->restore_portal_row), is_portal);
+
+#ifdef HAVE_X11
+ /* "Select window..." row */
+ gboolean is_window = g_str_equal(id, "window");
+ gtk_widget_set_visible(GTK_WIDGET(self->select_window_row), is_window);
+#endif
+}
+
+static void
+on_change_resolution_toggled(GObject *obj, GParamSpec *pspec G_GNUC_UNUSED, gpointer user_data)
+{
+ GsrConfigPage *self = GSR_CONFIG_PAGE(user_data);
+ gboolean active = adw_switch_row_get_active(ADW_SWITCH_ROW(obj));
+
+ /* Also check that we're not in "focused" mode */
+ guint idx = adw_combo_row_get_selected(self->record_area_row);
+ const char *id = (idx < (guint)self->n_record_area_ids)
+ ? self->record_area_ids[idx] : "";
+ gboolean show = active && !g_str_equal(id, "focused");
+
+ gtk_widget_set_visible(GTK_WIDGET(self->video_width_row), show);
+ gtk_widget_set_visible(GTK_WIDGET(self->video_height_row), show);
+}
+
+/* ── Window picker callback & handler ────────────────────────────── */
+
+#ifdef HAVE_X11
+static void
+on_window_picked(const GsrX11WindowPickResult *result, void *userdata)
+{
+ GsrConfigPage *self = GSR_CONFIG_PAGE(userdata);
+ self->active_picker = NULL; /* picker self-destructs after callback */
+
+ if (result->window == None) {
+ /* Cancelled — keep previous selection (if any) */
+ return;
+ }
+
+ /* Store the selected window */
+ g_free(self->selected_window_name);
+ self->selected_window_id = (unsigned long)result->window;
+ self->selected_window_name = g_strdup(result->name);
+
+ /* Update the row subtitle */
+ char *subtitle = g_strdup_printf("%s (0x%lx)",
+ result->name ? result->name : "(no name)",
+ (unsigned long)result->window);
+ adw_action_row_set_subtitle(self->select_window_row, subtitle);
+ g_free(subtitle);
+}
+
+static void
+on_select_window_activated(AdwActionRow *row G_GNUC_UNUSED, gpointer user_data)
+{
+ GsrConfigPage *self = GSR_CONFIG_PAGE(user_data);
+
+ /* Only allow on X11 */
+ if (self->info->system_info.display_server != GSR_DISPLAY_SERVER_X11)
+ return;
+
+ /* Cancel any existing picker */
+ if (self->active_picker) {
+ gsr_x11_window_picker_free(self->active_picker);
+ self->active_picker = NULL;
+ }
+
+ self->active_picker = gsr_x11_window_picker_new(on_window_picked, self);
+ if (!self->active_picker) {
+ adw_action_row_set_subtitle(self->select_window_row,
+ "Failed to grab pointer");
+ }
+}
+#endif /* HAVE_X11 */
+
+static void
+build_capture_group(GsrConfigPage *self)
+{
+ self->capture_group = ADW_PREFERENCES_GROUP(adw_preferences_group_new());
+ adw_preferences_group_set_title(self->capture_group, "Capture Target");
+
+ /* Record area combo */
+ self->record_area_model = gtk_string_list_new(NULL);
+ self->record_area_ids = NULL;
+ self->n_record_area_ids = 0;
+ int cap = 0;
+
+ const GsrInfo *info = self->info;
+
+ /* "Window" */
+ if (info->system_info.display_server == GSR_DISPLAY_SERVER_WAYLAND)
+ gtk_string_list_append(self->record_area_model, "Window (Wayland N/A)");
+ else
+ gtk_string_list_append(self->record_area_model, "Window");
+ ids_array_append(&self->record_area_ids, &self->n_record_area_ids, &cap, "window");
+
+ /* "Follow focused window" */
+ if (info->system_info.display_server == GSR_DISPLAY_SERVER_WAYLAND)
+ gtk_string_list_append(self->record_area_model, "Focused window (Wayland N/A)");
+ else
+ gtk_string_list_append(self->record_area_model, "Focused window");
+ ids_array_append(&self->record_area_ids, &self->n_record_area_ids, &cap, "focused");
+
+ /* Monitors */
+ for (int i = 0; i < info->supported_capture_options.n_monitors; i++) {
+ const GsrMonitor *m = &info->supported_capture_options.monitors[i];
+ char *label;
+ if (m->width > 0 && m->height > 0)
+ label = g_strdup_printf("Monitor %s (%dx%d)", m->name, m->width, m->height);
+ else
+ label = g_strdup_printf("Monitor %s", m->name);
+ gtk_string_list_append(self->record_area_model, label);
+ ids_array_append(&self->record_area_ids, &self->n_record_area_ids, &cap, m->name);
+ g_free(label);
+ }
+
+ /* Desktop portal */
+ if (info->system_info.display_server == GSR_DISPLAY_SERVER_WAYLAND) {
+ if (info->supported_capture_options.portal)
+ gtk_string_list_append(self->record_area_model, "Desktop portal (no HDR)");
+ else
+ gtk_string_list_append(self->record_area_model, "Desktop portal (N/A)");
+ } else {
+ gtk_string_list_append(self->record_area_model, "Desktop portal (X11 N/A)");
+ }
+ ids_array_append(&self->record_area_ids, &self->n_record_area_ids, &cap, "portal");
+
+ self->record_area_row = ADW_COMBO_ROW(adw_combo_row_new());
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->record_area_row), "Record area");
+ adw_combo_row_set_model(self->record_area_row,
+ G_LIST_MODEL(self->record_area_model));
+ adw_combo_row_set_use_subtitle(self->record_area_row, TRUE);
+
+ /* Default selection: first monitor if available (index 2), else window (index 0) */
+ if (info->supported_capture_options.n_monitors > 0)
+ adw_combo_row_set_selected(self->record_area_row, 2);
+ else
+ adw_combo_row_set_selected(self->record_area_row, 0);
+
+ g_signal_connect(self->record_area_row, "notify::selected",
+ G_CALLBACK(on_record_area_changed), self);
+
+ adw_preferences_group_add(self->capture_group, GTK_WIDGET(self->record_area_row));
+
+ /* "Select window..." row (X11 only, shown when record area = "window") */
+#ifdef HAVE_X11
+ self->select_window_row = ADW_ACTION_ROW(adw_action_row_new());
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->select_window_row),
+ "Select window...");
+ adw_action_row_set_subtitle(self->select_window_row, "Click to pick a window");
+ gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(self->select_window_row), TRUE);
+ GtkImage *pick_icon = GTK_IMAGE(gtk_image_new_from_icon_name("find-location-symbolic"));
+ adw_action_row_add_suffix(self->select_window_row, GTK_WIDGET(pick_icon));
+ g_signal_connect(self->select_window_row, "activated",
+ G_CALLBACK(on_select_window_activated), self);
+ gtk_widget_set_visible(GTK_WIDGET(self->select_window_row), FALSE);
+ self->selected_window_id = 0;
+ self->selected_window_name = NULL;
+ self->active_picker = NULL;
+ adw_preferences_group_add(self->capture_group, GTK_WIDGET(self->select_window_row));
+#endif /* HAVE_X11 */
+
+ /* Change video resolution */
+ self->change_resolution_row = ADW_SWITCH_ROW(adw_switch_row_new());
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->change_resolution_row),
+ "Change video resolution");
+ adw_switch_row_set_active(self->change_resolution_row, FALSE);
+ g_signal_connect(self->change_resolution_row, "notify::active",
+ G_CALLBACK(on_change_resolution_toggled), self);
+ adw_preferences_group_add(self->capture_group, GTK_WIDGET(self->change_resolution_row));
+
+ /* Video resolution W×H */
+ self->video_width_row = ADW_SPIN_ROW(adw_spin_row_new_with_range(5, 10000, 1));
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->video_width_row), "Video width");
+ adw_spin_row_set_value(self->video_width_row, 1920);
+ gtk_widget_set_visible(GTK_WIDGET(self->video_width_row), FALSE);
+ adw_preferences_group_add(self->capture_group, GTK_WIDGET(self->video_width_row));
+
+ self->video_height_row = ADW_SPIN_ROW(adw_spin_row_new_with_range(5, 10000, 1));
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->video_height_row), "Video height");
+ adw_spin_row_set_value(self->video_height_row, 1080);
+ gtk_widget_set_visible(GTK_WIDGET(self->video_height_row), FALSE);
+ adw_preferences_group_add(self->capture_group, GTK_WIDGET(self->video_height_row));
+
+ /* Area size W×H (focused mode) */
+ self->area_width_row = ADW_SPIN_ROW(adw_spin_row_new_with_range(5, 10000, 1));
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->area_width_row), "Area width");
+ adw_spin_row_set_value(self->area_width_row, 1920);
+ gtk_widget_set_visible(GTK_WIDGET(self->area_width_row), FALSE);
+ adw_preferences_group_add(self->capture_group, GTK_WIDGET(self->area_width_row));
+
+ self->area_height_row = ADW_SPIN_ROW(adw_spin_row_new_with_range(5, 10000, 1));
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->area_height_row), "Area height");
+ adw_spin_row_set_value(self->area_height_row, 1080);
+ gtk_widget_set_visible(GTK_WIDGET(self->area_height_row), FALSE);
+ adw_preferences_group_add(self->capture_group, GTK_WIDGET(self->area_height_row));
+
+ /* Restore portal session */
+ self->restore_portal_row = ADW_SWITCH_ROW(adw_switch_row_new());
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->restore_portal_row),
+ "Restore portal session");
+ adw_switch_row_set_active(self->restore_portal_row, TRUE);
+ gtk_widget_set_visible(GTK_WIDGET(self->restore_portal_row), FALSE);
+ adw_preferences_group_add(self->capture_group, GTK_WIDGET(self->restore_portal_row));
+
+ adw_preferences_page_add(ADW_PREFERENCES_PAGE(self), self->capture_group);
+}
+
+/* ── Audio group ─────────────────────────────────────────────────── */
+
+static void on_add_audio_device_clicked(GtkButton *btn, gpointer user_data);
+static void on_add_app_audio_clicked(GtkButton *btn, gpointer user_data);
+static void on_add_custom_app_clicked(GtkButton *btn, gpointer user_data);
+
+static void
+build_audio_group(GsrConfigPage *self)
+{
+ self->audio_group = ADW_PREFERENCES_GROUP(adw_preferences_group_new());
+ adw_preferences_group_set_title(self->audio_group, "Audio");
+
+ /* Button row for adding audio tracks */
+ GtkBox *btn_box = GTK_BOX(gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6));
+ gtk_widget_set_margin_top(GTK_WIDGET(btn_box), 4);
+ gtk_widget_set_margin_bottom(GTK_WIDGET(btn_box), 4);
+
+ self->add_device_btn = GTK_BUTTON(gtk_button_new());
+ AdwButtonContent *dev_bc = ADW_BUTTON_CONTENT(adw_button_content_new());
+ adw_button_content_set_icon_name(dev_bc, "list-add-symbolic");
+ adw_button_content_set_label(dev_bc, "Audio device");
+ gtk_button_set_child(self->add_device_btn, GTK_WIDGET(dev_bc));
+ gtk_widget_add_css_class(GTK_WIDGET(self->add_device_btn), "flat");
+ g_signal_connect(self->add_device_btn, "clicked",
+ G_CALLBACK(on_add_audio_device_clicked), self);
+ gtk_box_append(btn_box, GTK_WIDGET(self->add_device_btn));
+
+ self->add_app_btn = GTK_BUTTON(gtk_button_new());
+ AdwButtonContent *app_bc = ADW_BUTTON_CONTENT(adw_button_content_new());
+ adw_button_content_set_icon_name(app_bc, "list-add-symbolic");
+ adw_button_content_set_label(app_bc, "App audio");
+ gtk_button_set_child(self->add_app_btn, GTK_WIDGET(app_bc));
+ gtk_widget_add_css_class(GTK_WIDGET(self->add_app_btn), "flat");
+ g_signal_connect(self->add_app_btn, "clicked",
+ G_CALLBACK(on_add_app_audio_clicked), self);
+ gtk_box_append(btn_box, GTK_WIDGET(self->add_app_btn));
+
+ self->add_custom_app_btn = GTK_BUTTON(gtk_button_new());
+ AdwButtonContent *custom_bc = ADW_BUTTON_CONTENT(adw_button_content_new());
+ adw_button_content_set_icon_name(custom_bc, "list-add-symbolic");
+ adw_button_content_set_label(custom_bc, "Custom app");
+ gtk_button_set_child(self->add_custom_app_btn, GTK_WIDGET(custom_bc));
+ gtk_widget_add_css_class(GTK_WIDGET(self->add_custom_app_btn), "flat");
+ g_signal_connect(self->add_custom_app_btn, "clicked",
+ G_CALLBACK(on_add_custom_app_clicked), self);
+ gtk_box_append(btn_box, GTK_WIDGET(self->add_custom_app_btn));
+
+ if (!self->info->system_info.supports_app_audio) {
+ gtk_widget_set_visible(GTK_WIDGET(self->add_app_btn), FALSE);
+ gtk_widget_set_visible(GTK_WIDGET(self->add_custom_app_btn), FALSE);
+ }
+
+ adw_preferences_group_add(self->audio_group, GTK_WIDGET(btn_box));
+
+ /* Container for dynamic audio rows — boxed-list gives the card look */
+ self->audio_rows_box = GTK_LIST_BOX(gtk_list_box_new());
+ gtk_list_box_set_selection_mode(self->audio_rows_box, GTK_SELECTION_NONE);
+ gtk_widget_add_css_class(GTK_WIDGET(self->audio_rows_box), "boxed-list");
+ gtk_widget_set_visible(GTK_WIDGET(self->audio_rows_box), FALSE);
+ g_object_set_data(G_OBJECT(self->audio_rows_box), "config-page", self);
+ adw_preferences_group_add(self->audio_group, GTK_WIDGET(self->audio_rows_box));
+
+ /* Split audio */
+ self->split_audio_row = ADW_SWITCH_ROW(adw_switch_row_new());
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->split_audio_row),
+ "Split audio tracks");
+ adw_switch_row_set_active(self->split_audio_row, FALSE);
+ gtk_widget_set_visible(GTK_WIDGET(self->split_audio_row), FALSE); /* advanced only */
+ adw_preferences_group_add(self->audio_group, GTK_WIDGET(self->split_audio_row));
+
+ /* Record app audio inverted */
+ self->app_audio_inverted_row = ADW_SWITCH_ROW(adw_switch_row_new());
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->app_audio_inverted_row),
+ "Record all apps except selected");
+ adw_switch_row_set_active(self->app_audio_inverted_row, FALSE);
+ if (!self->info->system_info.supports_app_audio)
+ gtk_widget_set_visible(GTK_WIDGET(self->app_audio_inverted_row), FALSE);
+ adw_preferences_group_add(self->audio_group, GTK_WIDGET(self->app_audio_inverted_row));
+
+ /* Audio codec */
+ self->audio_codec_row = ADW_COMBO_ROW(adw_combo_row_new());
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->audio_codec_row), "Audio codec");
+ GtkStringList *ac_model = gtk_string_list_new(
+ (const char *const[]){ "Opus (Recommended)", "AAC", NULL });
+ adw_combo_row_set_model(self->audio_codec_row, G_LIST_MODEL(ac_model));
+ adw_combo_row_set_selected(self->audio_codec_row, 0);
+ gtk_widget_set_visible(GTK_WIDGET(self->audio_codec_row), FALSE); /* advanced only */
+ adw_preferences_group_add(self->audio_group, GTK_WIDGET(self->audio_codec_row));
+
+ adw_preferences_page_add(ADW_PREFERENCES_PAGE(self), self->audio_group);
+}
+
+/* ── Audio row management ─────────────────────────────────────────── */
+
+static void
+update_audio_rows_visibility(GsrConfigPage *self)
+{
+ gboolean has_children =
+ gtk_widget_get_first_child(GTK_WIDGET(self->audio_rows_box)) != NULL;
+ gtk_widget_set_visible(GTK_WIDGET(self->audio_rows_box), has_children);
+}
+
+static void
+on_remove_audio_row(GtkButton *btn G_GNUC_UNUSED, gpointer user_data)
+{
+ GtkWidget *row = GTK_WIDGET(user_data);
+ GtkWidget *parent = gtk_widget_get_parent(row);
+ if (parent && GTK_IS_LIST_BOX(parent)) {
+ GsrConfigPage *self = g_object_get_data(G_OBJECT(parent), "config-page");
+ gtk_list_box_remove(GTK_LIST_BOX(parent), row);
+ if (self)
+ update_audio_rows_visibility(self);
+ }
+}
+
+static GtkWidget *
+create_audio_row(const char *track_type, const char *title_text,
+ const char *const *items, int n_items,
+ const char *preselect, GsrConfigPage *self)
+{
+ AdwActionRow *row = ADW_ACTION_ROW(adw_action_row_new());
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(row), title_text);
+
+ g_object_set_data_full(G_OBJECT(row), "audio-track-type",
+ g_strdup(track_type), g_free);
+
+ if (g_str_equal(track_type, "app-custom")) {
+ /* Free-form text entry */
+ GtkEntry *entry = GTK_ENTRY(gtk_entry_new());
+ gtk_widget_set_hexpand(GTK_WIDGET(entry), TRUE);
+ gtk_widget_set_valign(GTK_WIDGET(entry), GTK_ALIGN_CENTER);
+ if (preselect)
+ gtk_editable_set_text(GTK_EDITABLE(entry), preselect);
+ g_object_set_data(G_OBJECT(row), "input-widget", entry);
+ adw_action_row_add_suffix(row, GTK_WIDGET(entry));
+ } else {
+ /* Dropdown from list */
+ GtkStringList *model = gtk_string_list_new(NULL);
+ int selected_idx = 0;
+ for (int i = 0; i < n_items; i++) {
+ gtk_string_list_append(model, items[i]);
+ if (preselect && g_str_equal(items[i], preselect))
+ selected_idx = i;
+ }
+ GtkDropDown *dd = GTK_DROP_DOWN(gtk_drop_down_new(G_LIST_MODEL(model), NULL));
+ gtk_widget_set_valign(GTK_WIDGET(dd), GTK_ALIGN_CENTER);
+ if (n_items > 0)
+ gtk_drop_down_set_selected(dd, (guint)selected_idx);
+ g_object_set_data(G_OBJECT(row), "input-widget", dd);
+ g_object_set_data_full(G_OBJECT(row), "string-list",
+ g_object_ref(model), g_object_unref);
+ adw_action_row_add_suffix(row, GTK_WIDGET(dd));
+ }
+
+ /* Remove button with × icon */
+ GtkButton *rm = GTK_BUTTON(gtk_button_new_from_icon_name("window-close-symbolic"));
+ gtk_widget_add_css_class(GTK_WIDGET(rm), "flat");
+ gtk_widget_add_css_class(GTK_WIDGET(rm), "circular");
+ gtk_widget_set_valign(GTK_WIDGET(rm), GTK_ALIGN_CENTER);
+ g_signal_connect(rm, "clicked", G_CALLBACK(on_remove_audio_row), row);
+ adw_action_row_add_suffix(row, GTK_WIDGET(rm));
+
+ (void)self;
+ return GTK_WIDGET(row);
+}
+
+static void
+on_add_audio_device_clicked(GtkButton *btn G_GNUC_UNUSED, gpointer user_data)
+{
+ GsrConfigPage *self = GSR_CONFIG_PAGE(user_data);
+ int n_devs = 0;
+ GsrAudioDevice *devs = gsr_audio_devices_get(&n_devs);
+
+ const char **labels = g_new0(const char *, n_devs + 1);
+ for (int i = 0; i < n_devs; i++)
+ labels[i] = devs[i].description;
+
+ GtkWidget *row = create_audio_row("device", "Device", labels, n_devs,
+ NULL, self);
+ gtk_list_box_append(self->audio_rows_box, row);
+ update_audio_rows_visibility(self);
+
+ /* Store device names as data on the row for later retrieval */
+ char **names = g_new0(char *, n_devs + 1);
+ for (int i = 0; i < n_devs; i++)
+ names[i] = g_strdup(devs[i].name);
+ g_object_set_data_full(G_OBJECT(row), "device-names",
+ names, (GDestroyNotify)g_strfreev);
+
+ g_free(labels);
+ gsr_audio_devices_free(devs, n_devs);
+}
+
+static void
+on_add_app_audio_clicked(GtkButton *btn G_GNUC_UNUSED, gpointer user_data)
+{
+ GsrConfigPage *self = GSR_CONFIG_PAGE(user_data);
+ int n_apps = 0;
+ char **apps = gsr_application_audio_get(&n_apps);
+
+ GtkWidget *row = create_audio_row("app", "Application",
+ (const char *const *)apps, n_apps,
+ NULL, self);
+ gtk_list_box_append(self->audio_rows_box, row);
+ update_audio_rows_visibility(self);
+ gsr_application_audio_free(apps, n_apps);
+}
+
+static void
+on_add_custom_app_clicked(GtkButton *btn G_GNUC_UNUSED, gpointer user_data)
+{
+ GsrConfigPage *self = GSR_CONFIG_PAGE(user_data);
+ GtkWidget *row = create_audio_row("app-custom", "Application",
+ NULL, 0, "", self);
+ gtk_list_box_append(self->audio_rows_box, row);
+ update_audio_rows_visibility(self);
+}
+
+/* ── Video group ─────────────────────────────────────────────────── */
+
+static void
+on_quality_changed(GObject *obj, GParamSpec *pspec G_GNUC_UNUSED, gpointer user_data)
+{
+ GsrConfigPage *self = GSR_CONFIG_PAGE(user_data);
+ guint idx = adw_combo_row_get_selected(ADW_COMBO_ROW(obj));
+ /* index 0 = "Constant bitrate" (custom) */
+ gtk_widget_set_visible(GTK_WIDGET(self->bitrate_row), idx == 0);
+}
+
+static void
+build_video_group(GsrConfigPage *self)
+{
+ self->video_group = ADW_PREFERENCES_GROUP(adw_preferences_group_new());
+ adw_preferences_group_set_title(self->video_group, "Video");
+
+ const GsrInfo *info = self->info;
+
+ /* Quality */
+ self->quality_row = ADW_COMBO_ROW(adw_combo_row_new());
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->quality_row), "Video quality");
+ GtkStringList *q_model = gtk_string_list_new((const char *const[]){
+ "Constant bitrate",
+ "Medium", "High",
+ "Very High",
+ "Ultra", NULL });
+ adw_combo_row_set_model(self->quality_row, G_LIST_MODEL(q_model));
+ adw_combo_row_set_selected(self->quality_row, 0);
+ g_signal_connect(self->quality_row, "notify::selected",
+ G_CALLBACK(on_quality_changed), self);
+ adw_preferences_group_add(self->video_group, GTK_WIDGET(self->quality_row));
+
+ /* Bitrate */
+ self->bitrate_row = ADW_SPIN_ROW(adw_spin_row_new_with_range(1, 500000, 1));
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->bitrate_row), "Video bitrate (kbps)");
+ adw_spin_row_set_value(self->bitrate_row, 15000);
+ adw_preferences_group_add(self->video_group, GTK_WIDGET(self->bitrate_row));
+
+ /* Video codec */
+ self->video_codec_model = gtk_string_list_new(NULL);
+ self->video_codec_ids = NULL;
+ self->n_video_codec_ids = 0;
+ int vc_cap = 0;
+
+ struct { const char *id; const char *label_ok; const char *label_na; } codecs[] = {
+ { "auto", "Auto", NULL },
+ { "h264", "H.264",
+ "H.264 (N/A)" },
+ { "hevc", "HEVC",
+ "HEVC (N/A)" },
+ { "hevc_10bit", "HEVC 10-bit",
+ "HEVC 10-bit (N/A)" },
+ { "hevc_hdr",
+ info->system_info.display_server == GSR_DISPLAY_SERVER_WAYLAND
+ ? "HEVC HDR" : "HEVC HDR (X11 N/A)",
+ "HEVC HDR (N/A)" },
+ { "av1", "AV1",
+ "AV1 (N/A)" },
+ { "av1_10bit", "AV1 10-bit",
+ "AV1 10-bit (N/A)" },
+ { "av1_hdr",
+ info->system_info.display_server == GSR_DISPLAY_SERVER_WAYLAND
+ ? "AV1 HDR" : "AV1 HDR (X11 N/A)",
+ "AV1 HDR (N/A)" },
+ { "vp8", "VP8", "VP8 (N/A)" },
+ { "vp9", "VP9", "VP9 (N/A)" },
+ { "h264_software", "H.264 Software (slow)",
+ "H.264 Software (N/A)" },
+ };
+
+ for (size_t i = 0; i < G_N_ELEMENTS(codecs); i++) {
+ bool ok = gsr_info_is_codec_supported(info, codecs[i].id);
+ const char *label = (ok || !codecs[i].label_na) ? codecs[i].label_ok : codecs[i].label_na;
+ gtk_string_list_append(self->video_codec_model, label);
+ ids_array_append(&self->video_codec_ids, &self->n_video_codec_ids, &vc_cap, codecs[i].id);
+ }
+
+ self->video_codec_row = ADW_COMBO_ROW(adw_combo_row_new());
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->video_codec_row), "Video codec");
+ adw_combo_row_set_model(self->video_codec_row, G_LIST_MODEL(self->video_codec_model));
+ adw_combo_row_set_selected(self->video_codec_row, 0);
+ gtk_widget_set_visible(GTK_WIDGET(self->video_codec_row), FALSE); /* advanced only */
+ adw_preferences_group_add(self->video_group, GTK_WIDGET(self->video_codec_row));
+
+ /* Color range */
+ self->color_range_row = ADW_COMBO_ROW(adw_combo_row_new());
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->color_range_row), "Color range");
+ GtkStringList *cr_model = gtk_string_list_new(
+ (const char *const[]){ "Limited", "Full", NULL });
+ adw_combo_row_set_model(self->color_range_row, G_LIST_MODEL(cr_model));
+ adw_combo_row_set_selected(self->color_range_row, 0);
+ gtk_widget_set_visible(GTK_WIDGET(self->color_range_row), FALSE); /* advanced only */
+ adw_preferences_group_add(self->video_group, GTK_WIDGET(self->color_range_row));
+
+ /* FPS */
+ self->fps_row = ADW_SPIN_ROW(adw_spin_row_new_with_range(1, 500, 1));
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->fps_row), "Frame rate");
+ adw_spin_row_set_value(self->fps_row, 60);
+ adw_preferences_group_add(self->video_group, GTK_WIDGET(self->fps_row));
+
+ /* Framerate mode */
+ self->framerate_mode_row = ADW_COMBO_ROW(adw_combo_row_new());
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->framerate_mode_row), "Frame rate mode");
+ GtkStringList *fm_model = gtk_string_list_new(
+ (const char *const[]){ "Auto (Recommended)", "Constant", "Variable", NULL });
+ adw_combo_row_set_model(self->framerate_mode_row, G_LIST_MODEL(fm_model));
+ adw_combo_row_set_selected(self->framerate_mode_row, 0);
+ gtk_widget_set_visible(GTK_WIDGET(self->framerate_mode_row), FALSE); /* advanced only */
+ adw_preferences_group_add(self->video_group, GTK_WIDGET(self->framerate_mode_row));
+
+ /* Overclock (NVIDIA + X11 only) */
+ self->overclock_row = ADW_SWITCH_ROW(adw_switch_row_new());
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->overclock_row),
+ "Overclock memory transfer rate");
+ adw_switch_row_set_active(self->overclock_row, FALSE);
+ /* Only visible in advanced mode AND nvidia AND not wayland */
+ gtk_widget_set_visible(GTK_WIDGET(self->overclock_row), FALSE);
+ adw_preferences_group_add(self->video_group, GTK_WIDGET(self->overclock_row));
+
+ /* Record cursor */
+ self->record_cursor_row = ADW_SWITCH_ROW(adw_switch_row_new());
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->record_cursor_row), "Record cursor");
+ adw_switch_row_set_active(self->record_cursor_row, TRUE);
+ adw_preferences_group_add(self->video_group, GTK_WIDGET(self->record_cursor_row));
+
+ adw_preferences_page_add(ADW_PREFERENCES_PAGE(self), self->video_group);
+}
+
+/* ── Notifications group ─────────────────────────────────────────── */
+
+static void
+build_notifications_group(GsrConfigPage *self)
+{
+ self->notifications_group = ADW_PREFERENCES_GROUP(adw_preferences_group_new());
+ adw_preferences_group_set_title(self->notifications_group, "Notifications");
+ gtk_widget_set_visible(GTK_WIDGET(self->notifications_group), FALSE); /* advanced only */
+
+ self->notify_started_row = ADW_SWITCH_ROW(adw_switch_row_new());
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->notify_started_row),
+ "Show started notification");
+ adw_switch_row_set_active(self->notify_started_row, FALSE);
+ adw_preferences_group_add(self->notifications_group, GTK_WIDGET(self->notify_started_row));
+
+ self->notify_stopped_row = ADW_SWITCH_ROW(adw_switch_row_new());
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->notify_stopped_row),
+ "Show stopped notification");
+ adw_switch_row_set_active(self->notify_stopped_row, FALSE);
+ adw_preferences_group_add(self->notifications_group, GTK_WIDGET(self->notify_stopped_row));
+
+ self->notify_saved_row = ADW_SWITCH_ROW(adw_switch_row_new());
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->notify_saved_row),
+ "Show video saved notification");
+ adw_switch_row_set_active(self->notify_saved_row, TRUE);
+ adw_preferences_group_add(self->notifications_group, GTK_WIDGET(self->notify_saved_row));
+
+ adw_preferences_page_add(ADW_PREFERENCES_PAGE(self), self->notifications_group);
+}
+
+/* ── GObject lifecycle ───────────────────────────────────────────── */
+
+static void
+gsr_config_page_finalize(GObject *object)
+{
+ GsrConfigPage *self = GSR_CONFIG_PAGE(object);
+
+ g_strfreev(self->record_area_ids);
+ g_strfreev(self->video_codec_ids);
+
+ /* Window picker cleanup */
+#ifdef HAVE_X11
+ if (self->active_picker) {
+ gsr_x11_window_picker_free(self->active_picker);
+ self->active_picker = NULL;
+ }
+ g_free(self->selected_window_name);
+#endif
+
+ G_OBJECT_CLASS(gsr_config_page_parent_class)->finalize(object);
+}
+
+static void
+gsr_config_page_init(GsrConfigPage *self)
+{
+ (void)self;
+ /* Real init happens in gsr_config_page_new after info is set */
+}
+
+static void
+gsr_config_page_class_init(GsrConfigPageClass *klass)
+{
+ GObjectClass *obj_class = G_OBJECT_CLASS(klass);
+ obj_class->finalize = gsr_config_page_finalize;
+}
+
+/* ── Public API ──────────────────────────────────────────────────── */
+
+GsrConfigPage *
+gsr_config_page_new(const GsrInfo *info)
+{
+ GsrConfigPage *self = g_object_new(GSR_TYPE_CONFIG_PAGE, NULL);
+ self->info = info;
+
+ adw_preferences_page_set_title(ADW_PREFERENCES_PAGE(self), "Config");
+ adw_preferences_page_set_icon_name(ADW_PREFERENCES_PAGE(self),
+ "preferences-system-symbolic");
+
+ build_capture_group(self);
+ build_audio_group(self);
+ build_video_group(self);
+ build_notifications_group(self);
+
+ /* Trigger initial visibility */
+ on_record_area_changed(G_OBJECT(self->record_area_row), NULL, self);
+
+ return self;
+}
+
+void
+gsr_config_page_set_advanced(GsrConfigPage *self, gboolean advanced)
+{
+ /* Audio: advanced-only widgets */
+ gtk_widget_set_visible(GTK_WIDGET(self->split_audio_row), advanced);
+ gtk_widget_set_visible(GTK_WIDGET(self->audio_codec_row), advanced);
+
+ /* Video: advanced-only widgets */
+ gtk_widget_set_visible(GTK_WIDGET(self->video_codec_row), advanced);
+ gtk_widget_set_visible(GTK_WIDGET(self->color_range_row), advanced);
+ gtk_widget_set_visible(GTK_WIDGET(self->framerate_mode_row), advanced);
+
+ /* Overclock: advanced + NVIDIA + not Wayland */
+ gboolean show_oc = advanced
+ && self->info->gpu_info.vendor == GSR_GPU_VENDOR_NVIDIA
+ && self->info->system_info.display_server != GSR_DISPLAY_SERVER_WAYLAND;
+ gtk_widget_set_visible(GTK_WIDGET(self->overclock_row), show_oc);
+
+ /* Notifications: advanced only */
+ gtk_widget_set_visible(GTK_WIDGET(self->notifications_group), advanced);
+}
+
+/* ── Config apply/read ───────────────────────────────────────────── */
+
+/* Helper: find index in a parallel ID array matching a string, or -1 */
+static int
+find_id_index(char **ids, int n, const char *id)
+{
+ if (!id || !id[0])
+ return -1;
+ for (int i = 0; i < n; i++) {
+ if (g_str_equal(ids[i], id))
+ return i;
+ }
+ return -1;
+}
+
+/* Map config quality string to combo index */
+static guint
+quality_string_to_index(const char *q)
+{
+ if (!q) return 3; /* very_high */
+ if (g_str_equal(q, "custom")) return 0;
+ if (g_str_equal(q, "medium")) return 1;
+ if (g_str_equal(q, "high")) return 2;
+ if (g_str_equal(q, "very_high")) return 3;
+ if (g_str_equal(q, "ultra")) return 4;
+ return 3;
+}
+
+static const char *
+quality_index_to_string(guint idx)
+{
+ switch (idx) {
+ case 0: return "custom";
+ case 1: return "medium";
+ case 2: return "high";
+ case 3: return "very_high";
+ case 4: return "ultra";
+ default: return "very_high";
+ }
+}
+
+/* Map config audio_codec string to combo index */
+static guint
+audio_codec_string_to_index(const char *ac)
+{
+ if (ac && g_str_equal(ac, "aac")) return 1;
+ return 0; /* opus */
+}
+
+static const char *
+audio_codec_index_to_string(guint idx)
+{
+ return (idx == 1) ? "aac" : "opus";
+}
+
+/* Map config color_range string to combo index */
+static guint
+color_range_string_to_index(const char *cr)
+{
+ if (cr && g_str_equal(cr, "full")) return 1;
+ return 0; /* limited */
+}
+
+static const char *
+color_range_index_to_string(guint idx)
+{
+ return (idx == 1) ? "full" : "limited";
+}
+
+/* Map config framerate_mode string to combo index */
+static guint
+framerate_mode_string_to_index(const char *fm)
+{
+ if (!fm) return 0;
+ if (g_str_equal(fm, "auto")) return 0;
+ if (g_str_equal(fm, "cfr")) return 1;
+ if (g_str_equal(fm, "vfr")) return 2;
+ return 0;
+}
+
+static const char *
+framerate_mode_index_to_string(guint idx)
+{
+ switch (idx) {
+ case 0: return "auto";
+ case 1: return "cfr";
+ case 2: return "vfr";
+ default: return "auto";
+ }
+}
+
+void
+gsr_config_page_apply_config(GsrConfigPage *self, const GsrConfig *config)
+{
+ const GsrMainConfig *m = &config->main_config;
+
+ /* ── Capture Target ── */
+
+ /* Record area: find matching ID in our combo model */
+ int ra_idx = find_id_index(self->record_area_ids, self->n_record_area_ids,
+ m->record_area_option);
+ if (ra_idx >= 0)
+ adw_combo_row_set_selected(self->record_area_row, (guint)ra_idx);
+
+ /* Resolution */
+ adw_switch_row_set_active(self->change_resolution_row, m->change_video_resolution);
+ if (m->video_width > 0)
+ adw_spin_row_set_value(self->video_width_row, m->video_width);
+ if (m->video_height > 0)
+ adw_spin_row_set_value(self->video_height_row, m->video_height);
+ if (m->record_area_width > 0)
+ adw_spin_row_set_value(self->area_width_row, m->record_area_width);
+ if (m->record_area_height > 0)
+ adw_spin_row_set_value(self->area_height_row, m->record_area_height);
+
+ /* Portal session */
+ adw_switch_row_set_active(self->restore_portal_row, m->restore_portal_session);
+
+ /* ── Audio ── */
+
+ /* Clear existing audio rows */
+ GtkWidget *child;
+ while ((child = gtk_widget_get_first_child(GTK_WIDGET(self->audio_rows_box))) != NULL)
+ gtk_list_box_remove(self->audio_rows_box, child);
+
+ /* Populate from config audio_input array */
+ for (int i = 0; i < m->n_audio_input; i++) {
+ const char *input = m->audio_input[i];
+ if (!input) continue;
+
+ if (g_str_has_prefix(input, "app:")) {
+ const char *app_name = input + 4;
+ if (!self->info->system_info.supports_app_audio)
+ continue;
+
+ /* Try to find in app list; if not found, create custom row */
+ int n_apps = 0;
+ char **apps = gsr_application_audio_get(&n_apps);
+
+ gboolean found = FALSE;
+ for (int j = 0; j < n_apps; j++) {
+ if (g_ascii_strcasecmp(apps[j], app_name) == 0) {
+ found = TRUE;
+ break;
+ }
+ }
+
+ if (found) {
+ GtkWidget *row = create_audio_row("app", "Application",
+ (const char *const *)apps, n_apps, app_name, self);
+ gtk_list_box_append(self->audio_rows_box, row);
+ } else {
+ GtkWidget *row = create_audio_row("app-custom", "Application",
+ NULL, 0, app_name, self);
+ gtk_list_box_append(self->audio_rows_box, row);
+ }
+ gsr_application_audio_free(apps, n_apps);
+
+ } else {
+ /* "device:xxx" or bare legacy name */
+ const char *desc = input;
+ if (g_str_has_prefix(input, "device:"))
+ desc = input + 7;
+
+ int n_devs = 0;
+ GsrAudioDevice *devs = gsr_audio_devices_get(&n_devs);
+
+ const char **labels = g_new0(const char *, n_devs + 1);
+ for (int j = 0; j < n_devs; j++)
+ labels[j] = devs[j].description;
+
+ GtkWidget *row = create_audio_row("device", "Device",
+ labels, n_devs, desc, self);
+ gtk_list_box_append(self->audio_rows_box, row);
+
+ /* Attach device names */
+ char **names = g_new0(char *, n_devs + 1);
+ for (int j = 0; j < n_devs; j++)
+ names[j] = g_strdup(devs[j].name);
+ g_object_set_data_full(G_OBJECT(row), "device-names",
+ names, (GDestroyNotify)g_strfreev);
+
+ g_free(labels);
+ gsr_audio_devices_free(devs, n_devs);
+ }
+ }
+
+ update_audio_rows_visibility(self);
+
+ /* Split audio (inverted from merge_audio_tracks) */
+ adw_switch_row_set_active(self->split_audio_row, !m->merge_audio_tracks);
+ adw_switch_row_set_active(self->app_audio_inverted_row, m->record_app_audio_inverted);
+
+ /* Audio codec */
+ adw_combo_row_set_selected(self->audio_codec_row,
+ audio_codec_string_to_index(m->audio_codec));
+
+ /* ── Video ── */
+ adw_combo_row_set_selected(self->quality_row,
+ quality_string_to_index(m->quality));
+ if (m->video_bitrate > 0)
+ adw_spin_row_set_value(self->bitrate_row, m->video_bitrate);
+
+ /* Video codec: set "auto" first as fallback, then try actual value */
+ int vc_auto = find_id_index(self->video_codec_ids, self->n_video_codec_ids, "auto");
+ if (vc_auto >= 0)
+ adw_combo_row_set_selected(self->video_codec_row, (guint)vc_auto);
+ int vc_idx = find_id_index(self->video_codec_ids, self->n_video_codec_ids, m->codec);
+ if (vc_idx >= 0)
+ adw_combo_row_set_selected(self->video_codec_row, (guint)vc_idx);
+
+ /* Color range */
+ adw_combo_row_set_selected(self->color_range_row,
+ color_range_string_to_index(m->color_range));
+
+ /* FPS */
+ if (m->fps > 0)
+ adw_spin_row_set_value(self->fps_row, m->fps);
+
+ /* Framerate mode */
+ adw_combo_row_set_selected(self->framerate_mode_row,
+ framerate_mode_string_to_index(m->framerate_mode));
+
+ /* Overclock */
+ adw_switch_row_set_active(self->overclock_row, m->overclock);
+
+ /* Record cursor */
+ adw_switch_row_set_active(self->record_cursor_row, m->record_cursor);
+
+ /* ── Notifications ── */
+ adw_switch_row_set_active(self->notify_started_row,
+ m->show_recording_started_notifications);
+ adw_switch_row_set_active(self->notify_stopped_row,
+ m->show_recording_stopped_notifications);
+ adw_switch_row_set_active(self->notify_saved_row,
+ m->show_recording_saved_notifications);
+
+ /* Trigger visibility callbacks */
+ on_record_area_changed(G_OBJECT(self->record_area_row), NULL, self);
+ on_quality_changed(G_OBJECT(self->quality_row), NULL, self);
+}
+
+void
+gsr_config_page_read_config(GsrConfigPage *self, GsrConfig *config)
+{
+ GsrMainConfig *m = &config->main_config;
+
+ /* ── Capture Target ── */
+ guint ra_idx = adw_combo_row_get_selected(self->record_area_row);
+ g_free(m->record_area_option);
+ m->record_area_option = (ra_idx < (guint)self->n_record_area_ids)
+ ? g_strdup(self->record_area_ids[ra_idx])
+ : g_strdup("");
+
+ m->change_video_resolution = adw_switch_row_get_active(self->change_resolution_row);
+ m->video_width = (int32_t)adw_spin_row_get_value(self->video_width_row);
+ m->video_height = (int32_t)adw_spin_row_get_value(self->video_height_row);
+ m->record_area_width = (int32_t)adw_spin_row_get_value(self->area_width_row);
+ m->record_area_height = (int32_t)adw_spin_row_get_value(self->area_height_row);
+ m->restore_portal_session = adw_switch_row_get_active(self->restore_portal_row);
+
+ /* ── Audio ── */
+
+ /* Clear old audio_input array */
+ if (m->audio_input) {
+ for (int i = 0; i < m->n_audio_input; i++)
+ g_free(m->audio_input[i]);
+ g_free(m->audio_input);
+ m->audio_input = NULL;
+ m->n_audio_input = 0;
+ }
+
+ /* Walk audio rows */
+ int n_audio = 0;
+ int audio_cap = 0;
+ char **audio_arr = NULL;
+
+ for (GtkWidget *child = gtk_widget_get_first_child(GTK_WIDGET(self->audio_rows_box));
+ child != NULL;
+ child = gtk_widget_get_next_sibling(child))
+ {
+ const char *track_type = g_object_get_data(G_OBJECT(child), "audio-track-type");
+ if (!track_type) continue;
+
+ char *value = NULL;
+
+ if (g_str_equal(track_type, "device")) {
+ GtkWidget *input_w = g_object_get_data(G_OBJECT(child), "input-widget");
+ if (GTK_IS_DROP_DOWN(input_w)) {
+ GtkDropDown *dd = GTK_DROP_DOWN(input_w);
+ guint sel = gtk_drop_down_get_selected(dd);
+ GtkStringList *model = GTK_STRING_LIST(
+ g_object_get_data(G_OBJECT(child), "string-list"));
+ if (model && sel < g_list_model_get_n_items(G_LIST_MODEL(model))) {
+ const char *desc = gtk_string_list_get_string(model, sel);
+ value = g_strdup_printf("device:%s", desc ? desc : "");
+ }
+ }
+ } else if (g_str_equal(track_type, "app")) {
+ GtkWidget *input_w = g_object_get_data(G_OBJECT(child), "input-widget");
+ if (GTK_IS_DROP_DOWN(input_w)) {
+ GtkDropDown *dd = GTK_DROP_DOWN(input_w);
+ guint sel = gtk_drop_down_get_selected(dd);
+ GtkStringList *model = GTK_STRING_LIST(
+ g_object_get_data(G_OBJECT(child), "string-list"));
+ if (model && sel < g_list_model_get_n_items(G_LIST_MODEL(model))) {
+ const char *app = gtk_string_list_get_string(model, sel);
+ value = g_strdup_printf("app:%s", app ? app : "");
+ }
+ }
+ } else if (g_str_equal(track_type, "app-custom")) {
+ GtkWidget *input_w = g_object_get_data(G_OBJECT(child), "input-widget");
+ if (GTK_IS_ENTRY(input_w)) {
+ const char *text = gtk_editable_get_text(GTK_EDITABLE(input_w));
+ value = g_strdup_printf("app:%s", text ? text : "");
+ }
+ }
+
+ if (value) {
+ if (n_audio >= audio_cap) {
+ audio_cap = audio_cap ? audio_cap * 2 : 8;
+ audio_arr = g_realloc(audio_arr, sizeof(char *) * (gsize)(audio_cap + 1));
+ }
+ audio_arr[n_audio] = value;
+ n_audio++;
+ audio_arr[n_audio] = NULL;
+ }
+ }
+
+ m->audio_input = audio_arr;
+ m->n_audio_input = n_audio;
+
+ /* Split audio (inverted to merge) */
+ m->merge_audio_tracks = !adw_switch_row_get_active(self->split_audio_row);
+ m->record_app_audio_inverted = adw_switch_row_get_active(self->app_audio_inverted_row);
+
+ /* Audio codec */
+ g_free(m->audio_codec);
+ m->audio_codec = g_strdup(audio_codec_index_to_string(
+ adw_combo_row_get_selected(self->audio_codec_row)));
+
+ /* ── Video ── */
+ g_free(m->quality);
+ m->quality = g_strdup(quality_index_to_string(
+ adw_combo_row_get_selected(self->quality_row)));
+
+ m->video_bitrate = (int32_t)adw_spin_row_get_value(self->bitrate_row);
+
+ /* Video codec */
+ guint vc_idx = adw_combo_row_get_selected(self->video_codec_row);
+ g_free(m->codec);
+ m->codec = (vc_idx < (guint)self->n_video_codec_ids)
+ ? g_strdup(self->video_codec_ids[vc_idx])
+ : g_strdup("auto");
+
+ /* Color range */
+ g_free(m->color_range);
+ m->color_range = g_strdup(color_range_index_to_string(
+ adw_combo_row_get_selected(self->color_range_row)));
+
+ m->fps = (int32_t)adw_spin_row_get_value(self->fps_row);
+
+ g_free(m->framerate_mode);
+ m->framerate_mode = g_strdup(framerate_mode_index_to_string(
+ adw_combo_row_get_selected(self->framerate_mode_row)));
+
+ m->overclock = adw_switch_row_get_active(self->overclock_row);
+ m->record_cursor = adw_switch_row_get_active(self->record_cursor_row);
+
+ /* ── Notifications ── */
+ m->show_recording_started_notifications =
+ adw_switch_row_get_active(self->notify_started_row);
+ m->show_recording_stopped_notifications =
+ adw_switch_row_get_active(self->notify_stopped_row);
+ m->show_recording_saved_notifications =
+ adw_switch_row_get_active(self->notify_saved_row);
+}
+
+/* ── Command-line helpers (Phase 5) ──────────────────────────────── */
+
+const char *
+gsr_config_page_get_record_area_id(GsrConfigPage *self)
+{
+ guint idx = adw_combo_row_get_selected(self->record_area_row);
+ if (idx < (guint)self->n_record_area_ids)
+ return self->record_area_ids[idx];
+ return "";
+}
+
+const char *
+gsr_config_page_get_video_codec_id(GsrConfigPage *self)
+{
+ guint idx = adw_combo_row_get_selected(self->video_codec_row);
+ if (idx < (guint)self->n_video_codec_ids)
+ return self->video_codec_ids[idx];
+ return "auto";
+}
+
+gboolean
+gsr_config_page_get_app_audio_inverted(GsrConfigPage *self)
+{
+ return adw_switch_row_get_active(self->app_audio_inverted_row);
+}
+
+/* ── Scalar getters ──────────────────────────────────────────────── */
+
+int
+gsr_config_page_get_fps(GsrConfigPage *self)
+{
+ return (int)adw_spin_row_get_value(self->fps_row);
+}
+
+const char *
+gsr_config_page_get_quality_id(GsrConfigPage *self)
+{
+ return quality_index_to_string(adw_combo_row_get_selected(self->quality_row));
+}
+
+int
+gsr_config_page_get_video_bitrate(GsrConfigPage *self)
+{
+ return (int)adw_spin_row_get_value(self->bitrate_row);
+}
+
+const char *
+gsr_config_page_get_color_range_id(GsrConfigPage *self)
+{
+ return color_range_index_to_string(
+ adw_combo_row_get_selected(self->color_range_row));
+}
+
+const char *
+gsr_config_page_get_audio_codec_id(GsrConfigPage *self)
+{
+ return audio_codec_index_to_string(
+ adw_combo_row_get_selected(self->audio_codec_row));
+}
+
+const char *
+gsr_config_page_get_framerate_mode_id(GsrConfigPage *self)
+{
+ return framerate_mode_index_to_string(
+ adw_combo_row_get_selected(self->framerate_mode_row));
+}
+
+gboolean
+gsr_config_page_get_record_cursor(GsrConfigPage *self)
+{
+ return adw_switch_row_get_active(self->record_cursor_row);
+}
+
+gboolean
+gsr_config_page_get_overclock(GsrConfigPage *self)
+{
+ return adw_switch_row_get_active(self->overclock_row);
+}
+
+gboolean
+gsr_config_page_get_restore_portal_session(GsrConfigPage *self)
+{
+ return adw_switch_row_get_active(self->restore_portal_row);
+}
+
+gboolean
+gsr_config_page_get_change_video_resolution(GsrConfigPage *self)
+{
+ return adw_switch_row_get_active(self->change_resolution_row);
+}
+
+int
+gsr_config_page_get_video_width(GsrConfigPage *self)
+{
+ return (int)adw_spin_row_get_value(self->video_width_row);
+}
+
+int
+gsr_config_page_get_video_height(GsrConfigPage *self)
+{
+ return (int)adw_spin_row_get_value(self->video_height_row);
+}
+
+int
+gsr_config_page_get_area_width(GsrConfigPage *self)
+{
+ return (int)adw_spin_row_get_value(self->area_width_row);
+}
+
+int
+gsr_config_page_get_area_height(GsrConfigPage *self)
+{
+ return (int)adw_spin_row_get_value(self->area_height_row);
+}
+
+gboolean
+gsr_config_page_get_split_audio(GsrConfigPage *self)
+{
+ return adw_switch_row_get_active(self->split_audio_row);
+}
+
+gboolean
+gsr_config_page_get_notify_started(GsrConfigPage *self)
+{
+ return adw_switch_row_get_active(self->notify_started_row);
+}
+
+gboolean
+gsr_config_page_get_notify_stopped(GsrConfigPage *self)
+{
+ return adw_switch_row_get_active(self->notify_stopped_row);
+}
+
+gboolean
+gsr_config_page_get_notify_saved(GsrConfigPage *self)
+{
+ return adw_switch_row_get_active(self->notify_saved_row);
+}
+
+GPtrArray *
+gsr_config_page_build_audio_args(GsrConfigPage *self, gboolean merge_tracks)
+{
+ /*
+ * Walk the audio_rows_box children. Each child has:
+ * - "audio-track-type": "device", "app", or "app-custom"
+ * - "input-widget": GtkDropDown (device/app) or GtkEntry (app-custom)
+ * - "device-names": char** (only for device rows)
+ *
+ * For device rows: the real ID is device-names[selected_index].
+ * For app rows: the display string IS the app name.
+ * For app-custom: the entry text IS the app name.
+ *
+ * If app_audio_inverted: prefix app tracks with "app-inverse:" instead of "app:".
+ *
+ * Returns a GPtrArray of individual track strings.
+ * If merge_tracks, all tracks are merged into one pipe-delimited string.
+ */
+ gboolean inverted = adw_switch_row_get_active(self->app_audio_inverted_row);
+
+ GPtrArray *tracks = g_ptr_array_new_with_free_func(g_free);
+
+ for (GtkWidget *child = gtk_widget_get_first_child(GTK_WIDGET(self->audio_rows_box));
+ child != NULL;
+ child = gtk_widget_get_next_sibling(child))
+ {
+ const char *track_type = g_object_get_data(G_OBJECT(child), "audio-track-type");
+ if (!track_type) continue;
+
+ char *value = NULL;
+
+ if (g_str_equal(track_type, "device")) {
+ GtkWidget *input_w = g_object_get_data(G_OBJECT(child), "input-widget");
+ char **device_names = g_object_get_data(G_OBJECT(child), "device-names");
+ if (GTK_IS_DROP_DOWN(input_w) && device_names) {
+ guint sel = gtk_drop_down_get_selected(GTK_DROP_DOWN(input_w));
+ /* Count device names to bounds-check */
+ guint n_names = 0;
+ while (device_names[n_names]) n_names++;
+ if (sel < n_names)
+ value = g_strdup(device_names[sel]);
+ }
+ } else if (g_str_equal(track_type, "app")) {
+ GtkWidget *input_w = g_object_get_data(G_OBJECT(child), "input-widget");
+ if (GTK_IS_DROP_DOWN(input_w)) {
+ GtkStringList *model = GTK_STRING_LIST(
+ g_object_get_data(G_OBJECT(child), "string-list"));
+ guint sel = gtk_drop_down_get_selected(GTK_DROP_DOWN(input_w));
+ if (model && sel < g_list_model_get_n_items(G_LIST_MODEL(model))) {
+ const char *app = gtk_string_list_get_string(model, sel);
+ const char *prefix = inverted ? "app-inverse:" : "app:";
+ value = g_strdup_printf("%s%s", prefix, app ? app : "");
+ }
+ }
+ } else if (g_str_equal(track_type, "app-custom")) {
+ GtkWidget *input_w = g_object_get_data(G_OBJECT(child), "input-widget");
+ if (GTK_IS_ENTRY(input_w)) {
+ const char *text = gtk_editable_get_text(GTK_EDITABLE(input_w));
+ const char *prefix = inverted ? "app-inverse:" : "app:";
+ value = g_strdup_printf("%s%s", prefix, text ? text : "");
+ }
+ }
+
+ if (value)
+ g_ptr_array_add(tracks, value);
+ }
+
+ if (merge_tracks && tracks->len > 1) {
+ /* Merge all into one pipe-delimited string */
+ GString *merged = g_string_new(g_ptr_array_index(tracks, 0));
+ for (guint i = 1; i < tracks->len; i++) {
+ g_string_append_c(merged, '|');
+ g_string_append(merged, g_ptr_array_index(tracks, i));
+ }
+ g_ptr_array_set_size(tracks, 0);
+ g_ptr_array_add(tracks, g_string_free(merged, FALSE));
+ }
+
+ return tracks;
+}
+
+unsigned long
+gsr_config_page_get_selected_window(GsrConfigPage *self)
+{
+ g_return_val_if_fail(GSR_IS_CONFIG_PAGE(self), 0);
+#ifdef HAVE_X11
+ return self->selected_window_id;
+#else
+ return 0;
+#endif
+}
+
+gboolean
+gsr_config_page_has_valid_window_selection(GsrConfigPage *self)
+{
+ g_return_val_if_fail(GSR_IS_CONFIG_PAGE(self), FALSE);
+
+ const char *area_id = gsr_config_page_get_record_area_id(self);
+ if (!g_str_equal(area_id, "window"))
+ return TRUE; /* not in window mode, always valid */
+
+#ifdef HAVE_X11
+ return self->selected_window_id != 0;
+#else
+ return FALSE;
+#endif
+}
diff --git a/src/gsr-config-page.h b/src/gsr-config-page.h
new file mode 100644
index 0000000..851bd76
--- /dev/null
+++ b/src/gsr-config-page.h
@@ -0,0 +1,101 @@
+#pragma once
+
+#include <adwaita.h>
+#include "gsr-info.h"
+#include "gsr-config.h"
+
+G_BEGIN_DECLS
+
+#define GSR_TYPE_CONFIG_PAGE (gsr_config_page_get_type())
+G_DECLARE_FINAL_TYPE(GsrConfigPage, gsr_config_page, GSR, CONFIG_PAGE, AdwPreferencesPage)
+
+GsrConfigPage *gsr_config_page_new (const GsrInfo *info);
+void gsr_config_page_set_advanced (GsrConfigPage *self,
+ gboolean advanced);
+void gsr_config_page_apply_config (GsrConfigPage *self,
+ const GsrConfig *config);
+void gsr_config_page_read_config (GsrConfigPage *self,
+ GsrConfig *config);
+
+/* ── Command-line helpers (Phase 5) ──────────────────────────────── */
+
+/**
+ * Get the record area ID currently selected.
+ * Borrowed pointer, do NOT free.
+ */
+const char *gsr_config_page_get_record_area_id (GsrConfigPage *self);
+
+/**
+ * Get video codec ID. Borrowed pointer, do NOT free.
+ */
+const char *gsr_config_page_get_video_codec_id (GsrConfigPage *self);
+
+/**
+ * Build audio "-a" arguments into a GPtrArray of strings.
+ * Each string is one "-a" value (e.g. "device:xxx" or merged pipe-delimited).
+ * merge_tracks: if TRUE, merge all tracks into one pipe-delimited string.
+ * Caller must free the array with g_ptr_array_unref() (strings owned by array).
+ */
+GPtrArray *gsr_config_page_build_audio_args (GsrConfigPage *self,
+ gboolean merge_tracks);
+
+/**
+ * Check if app_audio_inverted is enabled.
+ */
+gboolean gsr_config_page_get_app_audio_inverted(GsrConfigPage *self);
+
+/* ── Scalar getters for command-line building ──────────────────── */
+
+int gsr_config_page_get_fps (GsrConfigPage *self);
+
+/**
+ * Get quality ID: "custom", "medium", "high", "very_high", "ultra".
+ */
+const char *gsr_config_page_get_quality_id (GsrConfigPage *self);
+
+int gsr_config_page_get_video_bitrate (GsrConfigPage *self);
+
+/**
+ * Get color range: "limited" or "full".
+ */
+const char *gsr_config_page_get_color_range_id (GsrConfigPage *self);
+
+/**
+ * Get audio codec: "opus" or "aac".
+ */
+const char *gsr_config_page_get_audio_codec_id (GsrConfigPage *self);
+
+/**
+ * Get framerate mode: "auto", "cfr", or "vfr".
+ */
+const char *gsr_config_page_get_framerate_mode_id (GsrConfigPage *self);
+
+gboolean gsr_config_page_get_record_cursor (GsrConfigPage *self);
+gboolean gsr_config_page_get_overclock (GsrConfigPage *self);
+gboolean gsr_config_page_get_restore_portal_session(GsrConfigPage *self);
+gboolean gsr_config_page_get_change_video_resolution(GsrConfigPage *self);
+int gsr_config_page_get_video_width (GsrConfigPage *self);
+int gsr_config_page_get_video_height (GsrConfigPage *self);
+int gsr_config_page_get_area_width (GsrConfigPage *self);
+int gsr_config_page_get_area_height (GsrConfigPage *self);
+gboolean gsr_config_page_get_split_audio (GsrConfigPage *self);
+gboolean gsr_config_page_get_notify_started (GsrConfigPage *self);
+gboolean gsr_config_page_get_notify_stopped (GsrConfigPage *self);
+gboolean gsr_config_page_get_notify_saved (GsrConfigPage *self);
+
+/* ── Window picker ──────────────────────────────────────────────── */
+
+/**
+ * Get the X11 window ID selected via the "Select window" picker.
+ * Returns 0 if no window has been selected.
+ */
+unsigned long gsr_config_page_get_selected_window (GsrConfigPage *self);
+
+/**
+ * Check if we have a valid window selection.
+ * Returns TRUE if not in "window" mode, or if a window has been picked.
+ * Returns FALSE if in "window" mode but no window selected yet.
+ */
+gboolean gsr_config_page_has_valid_window_selection(GsrConfigPage *self);
+
+G_END_DECLS
diff --git a/src/gsr-config.c b/src/gsr-config.c
new file mode 100644
index 0000000..21405ea
--- /dev/null
+++ b/src/gsr-config.c
@@ -0,0 +1,591 @@
+#include "gsr-config.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <errno.h>
+#include <inttypes.h>
+#include <unistd.h>
+#include <limits.h>
+#include <libgen.h>
+#include <pwd.h>
+#include <sys/stat.h>
+
+#include <gtk/gtk.h>
+
+/*
+ * X keysym constants for the custom modifier bitmask encoding.
+ * These are just integer values used for config file serialization —
+ * they do NOT require X11 headers or libraries at runtime.
+ */
+#define XK_Shift_L 0xFFE1
+#define XK_Shift_R 0xFFE2
+#define XK_Control_L 0xFFE3
+#define XK_Control_R 0xFFE4
+#define XK_Meta_L 0xFFE7
+#define XK_Meta_R 0xFFE8
+#define XK_Alt_L 0xFFE9
+#define XK_Alt_R 0xFFEA
+#define XK_Super_L 0xFFEB
+#define XK_Super_R 0xFFEC
+
+#ifdef HAVE_X11
+#include <X11/Xlib.h> /* ControlMask, ShiftMask, Mod1Mask, Mod4Mask */
+#endif
+
+/* ═══════════════════════════════════════════════════════════════════
+ * gsr-config.c — Config file read/write
+ *
+ * File format: one key-value pair per line, space-separated.
+ * STRING_ARRAY keys appear multiple times (one per element).
+ * Backward-compatible with the C++ GTK3 version.
+ * ═══════════════════════════════════════════════════════════════════ */
+
+/* ── Helpers ─────────────────────────────────────────────────────── */
+
+static char *
+get_home_dir(void)
+{
+ const char *home = g_get_home_dir();
+ if (home)
+ return g_strdup(home);
+ return g_strdup("/tmp");
+}
+
+char *
+gsr_config_get_dir(void)
+{
+ const char *xdg = g_getenv("XDG_CONFIG_HOME");
+ if (xdg && xdg[0])
+ return g_build_filename(xdg, "gpu-screen-recorder", NULL);
+
+ char *home = get_home_dir();
+ char *result = g_build_filename(home, ".config", "gpu-screen-recorder", NULL);
+ g_free(home);
+ return result;
+}
+
+char *
+gsr_config_get_videos_dir(void)
+{
+ const char *vdir = g_get_user_special_dir(G_USER_DIRECTORY_VIDEOS);
+ if (vdir)
+ return g_strdup(vdir);
+ char *home = get_home_dir();
+ char *result = g_build_filename(home, "Videos", NULL);
+ g_free(home);
+ return result;
+}
+
+static int
+create_directory_recursive(char *path)
+{
+ int path_len = (int)strlen(path);
+ char *p = path;
+ char *end = path + path_len;
+
+ for (;;) {
+ char *slash_p = strchr(p, '/');
+
+ /* Skip leading '/' — don't try to mkdir root */
+ if (slash_p == path) {
+ ++p;
+ continue;
+ }
+
+ if (!slash_p)
+ slash_p = end;
+
+ char prev = *slash_p;
+ *slash_p = '\0';
+ int err = mkdir(path, S_IRWXU);
+ *slash_p = prev;
+
+ if (err == -1 && errno != EEXIST)
+ return err;
+
+ if (slash_p == end)
+ break;
+ else
+ p = slash_p + 1;
+ }
+ return 0;
+}
+
+/* ── Config value type dispatch ──────────────────────────────────── */
+
+typedef enum {
+ CFG_BOOL,
+ CFG_STRING,
+ CFG_I32,
+ CFG_HOTKEY,
+ CFG_STRING_ARRAY,
+} CfgType;
+
+typedef struct {
+ const char *key;
+ CfgType type;
+ size_t offset; /* offset into GsrConfig */
+ size_t count_offset; /* for STRING_ARRAY: offset of n_audio_input */
+} CfgEntry;
+
+/* Helper macro to compute offset from a nested config member */
+#define CFG_OFF(section, member) offsetof(GsrConfig, section.member)
+
+static const CfgEntry config_entries[] = {
+ /* ── main ── */
+ { "main.record_area_option", CFG_STRING, CFG_OFF(main_config, record_area_option), 0 },
+ { "main.record_area_width", CFG_I32, CFG_OFF(main_config, record_area_width), 0 },
+ { "main.record_area_height", CFG_I32, CFG_OFF(main_config, record_area_height), 0 },
+ { "main.video_width", CFG_I32, CFG_OFF(main_config, video_width), 0 },
+ { "main.video_height", CFG_I32, CFG_OFF(main_config, video_height), 0 },
+ { "main.fps", CFG_I32, CFG_OFF(main_config, fps), 0 },
+ { "main.video_bitrate", CFG_I32, CFG_OFF(main_config, video_bitrate), 0 },
+ { "main.merge_audio_tracks", CFG_BOOL, CFG_OFF(main_config, merge_audio_tracks), 0 },
+ { "main.record_app_audio_inverted", CFG_BOOL, CFG_OFF(main_config, record_app_audio_inverted),0 },
+ { "main.change_video_resolution", CFG_BOOL, CFG_OFF(main_config, change_video_resolution), 0 },
+ { "main.audio_input", CFG_STRING_ARRAY, CFG_OFF(main_config, audio_input),
+ CFG_OFF(main_config, n_audio_input) },
+ { "main.color_range", CFG_STRING, CFG_OFF(main_config, color_range), 0 },
+ { "main.quality", CFG_STRING, CFG_OFF(main_config, quality), 0 },
+ { "main.codec", CFG_STRING, CFG_OFF(main_config, codec), 0 },
+ { "main.audio_codec", CFG_STRING, CFG_OFF(main_config, audio_codec), 0 },
+ { "main.framerate_mode", CFG_STRING, CFG_OFF(main_config, framerate_mode), 0 },
+ { "main.advanced_view", CFG_BOOL, CFG_OFF(main_config, advanced_view), 0 },
+ { "main.overclock", CFG_BOOL, CFG_OFF(main_config, overclock), 0 },
+ { "main.show_recording_started_notifications",CFG_BOOL, CFG_OFF(main_config, show_recording_started_notifications), 0 },
+ { "main.show_recording_stopped_notifications",CFG_BOOL, CFG_OFF(main_config, show_recording_stopped_notifications), 0 },
+ { "main.show_recording_saved_notifications", CFG_BOOL, CFG_OFF(main_config, show_recording_saved_notifications), 0 },
+ { "main.record_cursor", CFG_BOOL, CFG_OFF(main_config, record_cursor), 0 },
+ { "main.hide_window_when_recording", CFG_BOOL, CFG_OFF(main_config, hide_window_when_recording),0 },
+ { "main.software_encoding_warning_shown", CFG_BOOL, CFG_OFF(main_config, software_encoding_warning_shown),0 },
+ { "main.steam_deck_warning_shown", CFG_BOOL, CFG_OFF(main_config, steam_deck_warning_shown), 0 },
+ { "main.hevc_amd_bug_warning_shown", CFG_BOOL, CFG_OFF(main_config, hevc_amd_bug_warning_shown),0 },
+ { "main.av1_amd_bug_warning_shown", CFG_BOOL, CFG_OFF(main_config, av1_amd_bug_warning_shown),0 },
+ { "main.restore_portal_session", CFG_BOOL, CFG_OFF(main_config, restore_portal_session), 0 },
+ { "main.use_new_ui", CFG_BOOL, CFG_OFF(main_config, use_new_ui), 0 },
+ { "main.installed_gsr_global_hotkeys_version",CFG_I32, CFG_OFF(main_config, installed_gsr_global_hotkeys_version),0 },
+
+ /* ── streaming ── */
+ { "streaming.service", CFG_STRING, CFG_OFF(streaming_config, streaming_service), 0 },
+ { "streaming.youtube.key", CFG_STRING, CFG_OFF(streaming_config, youtube_stream_key), 0 },
+ { "streaming.twitch.key", CFG_STRING, CFG_OFF(streaming_config, twitch_stream_key), 0 },
+ { "streaming.custom.url", CFG_STRING, CFG_OFF(streaming_config, custom_url), 0 },
+ { "streaming.custom.container", CFG_STRING, CFG_OFF(streaming_config, custom_container), 0 },
+ { "streaming.start_stop_recording_hotkey", CFG_HOTKEY, CFG_OFF(streaming_config, start_stop_hotkey), 0 },
+
+ /* ── record ── */
+ { "record.save_directory", CFG_STRING, CFG_OFF(record_config, save_directory), 0 },
+ { "record.container", CFG_STRING, CFG_OFF(record_config, container), 0 },
+ { "record.start_stop_recording_hotkey", CFG_HOTKEY, CFG_OFF(record_config, start_stop_hotkey), 0 },
+ { "record.pause_unpause_recording_hotkey", CFG_HOTKEY, CFG_OFF(record_config, pause_unpause_hotkey), 0 },
+
+ /* ── replay ── */
+ { "replay.save_directory", CFG_STRING, CFG_OFF(replay_config, save_directory), 0 },
+ { "replay.container", CFG_STRING, CFG_OFF(replay_config, container), 0 },
+ { "replay.time", CFG_I32, CFG_OFF(replay_config, replay_time), 0 },
+ { "replay.start_stop_recording_hotkey", CFG_HOTKEY, CFG_OFF(replay_config, start_stop_hotkey), 0 },
+ { "replay.save_recording_hotkey", CFG_HOTKEY, CFG_OFF(replay_config, save_hotkey), 0 },
+};
+
+#define N_CONFIG_ENTRIES ((int)(sizeof(config_entries) / sizeof(config_entries[0])))
+
+static const CfgEntry *
+find_entry(const char *key, int key_len)
+{
+ for (int i = 0; i < N_CONFIG_ENTRIES; i++) {
+ if ((int)strlen(config_entries[i].key) == key_len &&
+ memcmp(config_entries[i].key, key, (size_t)key_len) == 0)
+ return &config_entries[i];
+ }
+ return NULL;
+}
+
+/* ── Default initialization ──────────────────────────────────────── */
+
+void
+gsr_config_init_defaults(GsrConfig *config)
+{
+ memset(config, 0, sizeof(*config));
+
+ GsrMainConfig *m = &config->main_config;
+ m->record_area_option = g_strdup("");
+ m->record_area_width = 0;
+ m->record_area_height = 0;
+ m->video_width = 0;
+ m->video_height = 0;
+ m->fps = 60;
+ m->video_bitrate = 15000;
+ m->merge_audio_tracks = true;
+ m->record_app_audio_inverted = false;
+ m->change_video_resolution = false;
+ m->audio_input = NULL;
+ m->n_audio_input = 0;
+ m->color_range = g_strdup("limited");
+ m->quality = g_strdup("very_high");
+ m->codec = g_strdup("auto");
+ m->audio_codec = g_strdup("opus");
+ m->framerate_mode = g_strdup("auto");
+ m->advanced_view = false;
+ m->overclock = false;
+ m->show_recording_started_notifications = false;
+ m->show_recording_stopped_notifications = false;
+ m->show_recording_saved_notifications = true;
+ m->record_cursor = true;
+ m->hide_window_when_recording = false;
+ m->restore_portal_session = true;
+ m->software_encoding_warning_shown = false;
+ m->steam_deck_warning_shown = false;
+ m->hevc_amd_bug_warning_shown = false;
+ m->av1_amd_bug_warning_shown = false;
+ m->use_new_ui = false;
+ m->installed_gsr_global_hotkeys_version = 0;
+
+ /* Default hotkeys: Alt+1 = start/stop, Alt+2 = pause/save
+ * Custom bitmask: Alt_L = 1 << (XK_Alt_L - XK_Shift_L) = 1 << 8 = 256
+ * XK_1 = 0x31 = 49, XK_2 = 0x32 = 50 */
+ #define DEFAULT_HOTKEY_START_STOP ((GsrConfigHotkey){ .keysym = 49, .modifiers = 256 })
+ #define DEFAULT_HOTKEY_SECONDARY ((GsrConfigHotkey){ .keysym = 50, .modifiers = 256 })
+
+ GsrStreamingConfig *s = &config->streaming_config;
+ s->streaming_service = g_strdup("twitch");
+ s->youtube_stream_key = g_strdup("");
+ s->twitch_stream_key = g_strdup("");
+ s->custom_url = g_strdup("");
+ s->custom_container = g_strdup("flv");
+ s->start_stop_hotkey = DEFAULT_HOTKEY_START_STOP;
+
+ GsrRecordConfig *r = &config->record_config;
+ r->save_directory = gsr_config_get_videos_dir();
+ r->container = g_strdup("mp4");
+ r->start_stop_hotkey = DEFAULT_HOTKEY_START_STOP;
+ r->pause_unpause_hotkey = DEFAULT_HOTKEY_SECONDARY;
+
+ GsrReplayConfig *rp = &config->replay_config;
+ rp->save_directory = gsr_config_get_videos_dir();
+ rp->container = g_strdup("mp4");
+ rp->replay_time = 30;
+ rp->start_stop_hotkey = DEFAULT_HOTKEY_START_STOP;
+ rp->save_hotkey = DEFAULT_HOTKEY_SECONDARY;
+
+ #undef DEFAULT_HOTKEY_START_STOP
+ #undef DEFAULT_HOTKEY_SECONDARY
+}
+
+/* ── Read ────────────────────────────────────────────────────────── */
+
+gboolean
+gsr_config_read(GsrConfig *config)
+{
+ char *config_dir = gsr_config_get_dir();
+ char *config_path = g_build_filename(config_dir, "config", NULL);
+ g_free(config_dir);
+
+ gchar *contents = NULL;
+ gsize length = 0;
+ if (!g_file_get_contents(config_path, &contents, &length, NULL)) {
+ g_free(config_path);
+ return FALSE;
+ }
+ g_free(config_path);
+
+ /* Parse line by line */
+ char *p = contents;
+ char *end = contents + length;
+
+ while (p < end) {
+ /* Find end of line */
+ char *nl = memchr(p, '\n', (size_t)(end - p));
+ if (!nl)
+ nl = end;
+
+ int line_len = (int)(nl - p);
+
+ /* Find space separator between key and value */
+ char *sp = memchr(p, ' ', (size_t)line_len);
+ if (sp && sp > p) {
+ int key_len = (int)(sp - p);
+ const char *val = sp + 1;
+ int val_len = (int)(nl - val);
+
+ if (key_len > 0 && val_len > 0) {
+ const CfgEntry *entry = find_entry(p, key_len);
+ if (entry) {
+ char *base = (char *)config;
+ switch (entry->type) {
+ case CFG_BOOL: {
+ bool *ptr = (bool *)(base + entry->offset);
+ *ptr = (val_len == 4 && memcmp(val, "true", 4) == 0);
+ break;
+ }
+ case CFG_STRING: {
+ char **ptr = (char **)(void *)(base + entry->offset);
+ g_free(*ptr);
+ *ptr = g_strndup(val, (gsize)val_len);
+ break;
+ }
+ case CFG_I32: {
+ int32_t *ptr = (int32_t *)(void *)(base + entry->offset);
+ char tmp[32];
+ int copy_len = val_len < 31 ? val_len : 31;
+ memcpy(tmp, val, (size_t)copy_len);
+ tmp[copy_len] = '\0';
+ if (sscanf(tmp, "%" PRIi32, ptr) != 1) {
+ *ptr = 0;
+ }
+ break;
+ }
+ case CFG_HOTKEY: {
+ GsrConfigHotkey *hk = (GsrConfigHotkey *)(void *)(base + entry->offset);
+ char tmp[64];
+ int copy_len = val_len < 63 ? val_len : 63;
+ memcpy(tmp, val, (size_t)copy_len);
+ tmp[copy_len] = '\0';
+ if (sscanf(tmp, "%" PRIi64 " %" PRIu32,
+ &hk->keysym, &hk->modifiers) != 2) {
+ hk->keysym = 0;
+ hk->modifiers = 0;
+ }
+ break;
+ }
+ case CFG_STRING_ARRAY: {
+ char ***arr_ptr = (char ***)(void *)(base + entry->offset);
+ int *count_ptr = (int *)(void *)(base + entry->count_offset);
+ int n = *count_ptr;
+ *arr_ptr = g_realloc(*arr_ptr, sizeof(char *) * (gsize)(n + 2));
+ (*arr_ptr)[n] = g_strndup(val, (gsize)val_len);
+ (*arr_ptr)[n + 1] = NULL;
+ *count_ptr = n + 1;
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ p = nl + 1;
+ }
+
+ g_free(contents);
+ return TRUE;
+}
+
+/* ── Save ────────────────────────────────────────────────────────── */
+
+void
+gsr_config_save(const GsrConfig *config)
+{
+ char *config_dir = gsr_config_get_dir();
+ char *config_path = g_build_filename(config_dir, "config", NULL);
+
+ /* Ensure directory exists */
+ if (create_directory_recursive(config_dir) != 0) {
+ g_warning("Failed to create config directory: %s", config_dir);
+ g_free(config_dir);
+ g_free(config_path);
+ return;
+ }
+ g_free(config_dir);
+
+ FILE *file = fopen(config_path, "wb");
+ if (!file) {
+ g_warning("Failed to create config file: %s", config_path);
+ g_free(config_path);
+ return;
+ }
+ g_free(config_path);
+
+ const char *base = (const char *)config;
+
+ for (int i = 0; i < N_CONFIG_ENTRIES; i++) {
+ const CfgEntry *e = &config_entries[i];
+ switch (e->type) {
+ case CFG_BOOL: {
+ const bool *ptr = (const bool *)(base + e->offset);
+ fprintf(file, "%s %s\n", e->key, *ptr ? "true" : "false");
+ break;
+ }
+ case CFG_STRING: {
+ const char *const *ptr = (const char *const *)(const void *)(base + e->offset);
+ fprintf(file, "%s %s\n", e->key, *ptr ? *ptr : "");
+ break;
+ }
+ case CFG_I32: {
+ const int32_t *ptr = (const int32_t *)(const void *)(base + e->offset);
+ fprintf(file, "%s %" PRIi32 "\n", e->key, *ptr);
+ break;
+ }
+ case CFG_HOTKEY: {
+ const GsrConfigHotkey *hk = (const GsrConfigHotkey *)(const void *)(base + e->offset);
+ fprintf(file, "%s %" PRIi64 " %" PRIu32 "\n", e->key,
+ hk->keysym, hk->modifiers);
+ break;
+ }
+ case CFG_STRING_ARRAY: {
+ char *const *const *arr_ptr = (char *const *const *)(const void *)(base + e->offset);
+ const int *count_ptr = (const int *)(const void *)(base + e->count_offset);
+ int n = *count_ptr;
+ char *const *arr = *arr_ptr;
+ for (int j = 0; j < n && arr && arr[j]; j++) {
+ fprintf(file, "%s %s\n", e->key, arr[j]);
+ }
+ break;
+ }
+ }
+ }
+
+ fclose(file);
+}
+
+/* ── Clear ───────────────────────────────────────────────────────── */
+
+void
+gsr_config_clear(GsrConfig *config)
+{
+ GsrMainConfig *m = &config->main_config;
+ g_free(m->record_area_option);
+ g_free(m->color_range);
+ g_free(m->quality);
+ g_free(m->codec);
+ g_free(m->audio_codec);
+ g_free(m->framerate_mode);
+
+ if (m->audio_input) {
+ for (int i = 0; i < m->n_audio_input; i++)
+ g_free(m->audio_input[i]);
+ g_free(m->audio_input);
+ }
+
+ GsrStreamingConfig *s = &config->streaming_config;
+ g_free(s->streaming_service);
+ g_free(s->youtube_stream_key);
+ g_free(s->twitch_stream_key);
+ g_free(s->custom_url);
+ g_free(s->custom_container);
+
+ g_free(config->record_config.save_directory);
+ g_free(config->record_config.container);
+
+ g_free(config->replay_config.save_directory);
+ g_free(config->replay_config.container);
+
+ memset(config, 0, sizeof(*config));
+}
+
+/* ── Hotkey conversion utilities ─────────────────────────────────── */
+
+/*
+ * The GTK3 code uses a custom bitmask for modifier keys:
+ * modkey_to_mask(keysym) = 1 << (keysym - XK_Shift_L)
+ *
+ * The relevant X keysyms (from <X11/keysym.h>):
+ * XK_Shift_L = 0xFFE1 → bit 0
+ * XK_Shift_R = 0xFFE2 → bit 1
+ * XK_Control_L = 0xFFE3 → bit 2
+ * XK_Control_R = 0xFFE4 → bit 3
+ * XK_Caps_Lock = 0xFFE5 → bit 4 (unused)
+ * XK_Shift_Lock= 0xFFE6 → bit 5 (unused)
+ * XK_Meta_L = 0xFFE7 → bit 6
+ * XK_Meta_R = 0xFFE8 → bit 7
+ * XK_Alt_L = 0xFFE9 → bit 8
+ * XK_Alt_R = 0xFFEA → bit 9
+ * XK_Super_L = 0xFFEB → bit 10
+ * XK_Super_R = 0xFFEC → bit 11
+ */
+
+#define CUSTOM_MASK(ks) (1u << ((ks) - XK_Shift_L))
+
+#define MASK_SHIFT (CUSTOM_MASK(XK_Shift_L) | CUSTOM_MASK(XK_Shift_R))
+#define MASK_CONTROL (CUSTOM_MASK(XK_Control_L) | CUSTOM_MASK(XK_Control_R))
+#define MASK_ALT (CUSTOM_MASK(XK_Alt_L) | CUSTOM_MASK(XK_Alt_R))
+#define MASK_META (CUSTOM_MASK(XK_Meta_L) | CUSTOM_MASK(XK_Meta_R))
+#define MASK_SUPER (CUSTOM_MASK(XK_Super_L) | CUSTOM_MASK(XK_Super_R))
+
+char *
+gsr_config_hotkey_to_accel(const GsrConfigHotkey *hk)
+{
+ if (!hk || hk->keysym == 0)
+ return NULL;
+
+ /* Convert custom modifier bitmask to GdkModifierType */
+ GdkModifierType mods = 0;
+ uint32_t m = hk->modifiers;
+
+ if (m & MASK_SHIFT) mods |= GDK_SHIFT_MASK;
+ if (m & MASK_CONTROL) mods |= GDK_CONTROL_MASK;
+ if (m & MASK_ALT) mods |= GDK_ALT_MASK;
+ if (m & (MASK_META | MASK_SUPER))
+ mods |= GDK_SUPER_MASK;
+
+ return gtk_accelerator_name((guint)hk->keysym, mods);
+}
+
+gboolean
+gsr_config_hotkey_from_accel(GsrConfigHotkey *hk, const char *accel)
+{
+ if (!hk)
+ return FALSE;
+
+ if (!accel || !accel[0]) {
+ hk->keysym = 0;
+ hk->modifiers = 0;
+ return TRUE;
+ }
+
+ guint keyval = 0;
+ GdkModifierType mods = 0;
+
+ if (!gtk_accelerator_parse(accel, &keyval, &mods))
+ return FALSE;
+
+ if (keyval == 0) {
+ hk->keysym = 0;
+ hk->modifiers = 0;
+ return TRUE;
+ }
+
+ hk->keysym = (int64_t)keyval;
+
+ /* Convert GdkModifierType back to custom bitmask.
+ * We use the _L variant for each modifier pair. */
+ uint32_t m = 0;
+ if (mods & GDK_SHIFT_MASK) m |= CUSTOM_MASK(XK_Shift_L);
+ if (mods & GDK_CONTROL_MASK) m |= CUSTOM_MASK(XK_Control_L);
+ if (mods & GDK_ALT_MASK) m |= CUSTOM_MASK(XK_Alt_L);
+ if (mods & GDK_SUPER_MASK) m |= CUSTOM_MASK(XK_Super_L);
+ hk->modifiers = m;
+
+ return TRUE;
+}
+
+#ifdef HAVE_X11
+void
+gsr_config_hotkey_to_x11(const GsrConfigHotkey *hk,
+ unsigned int *out_x11_modifiers,
+ uint64_t *out_keysym)
+{
+ if (!hk) {
+ if (out_x11_modifiers) *out_x11_modifiers = 0;
+ if (out_keysym) *out_keysym = 0;
+ return;
+ }
+
+ if (out_keysym)
+ *out_keysym = (uint64_t)hk->keysym;
+
+ if (!out_x11_modifiers)
+ return;
+
+ /* Convert custom modifier bitmask to X11 mask.
+ * This matches key_mod_mask_to_x11_mask() from the GTK3 code. */
+ uint32_t m = hk->modifiers;
+ unsigned int x11 = 0;
+
+ if (m & MASK_CONTROL) x11 |= ControlMask;
+ if (m & MASK_ALT) x11 |= Mod1Mask;
+ if (m & MASK_SHIFT) x11 |= ShiftMask;
+ if (m & (MASK_META | MASK_SUPER)) x11 |= Mod4Mask;
+
+ *out_x11_modifiers = x11;
+}
+#endif /* HAVE_X11 */
diff --git a/src/gsr-config.h b/src/gsr-config.h
new file mode 100644
index 0000000..3ed0525
--- /dev/null
+++ b/src/gsr-config.h
@@ -0,0 +1,175 @@
+#pragma once
+
+#include <glib.h>
+#include <stdbool.h>
+#include <stdint.h>
+
+G_BEGIN_DECLS
+
+/* ── Hotkey ──────────────────────────────────────────────────────── */
+
+typedef struct {
+ int64_t keysym;
+ uint32_t modifiers;
+} GsrConfigHotkey;
+
+/* ── Config struct ───────────────────────────────────────────────── */
+
+typedef struct {
+ /* Capture target */
+ char *record_area_option; /* "window", "focused", "portal", or monitor name */
+ int32_t record_area_width;
+ int32_t record_area_height;
+ int32_t video_width;
+ int32_t video_height;
+
+ /* Video */
+ int32_t fps;
+ int32_t video_bitrate;
+ char *color_range; /* "limited", "full" */
+ char *quality; /* "custom", "medium", "high", "very_high", "ultra" */
+ char *codec; /* "auto", "h264", "hevc", etc. */
+ char *audio_codec; /* "opus", "aac" */
+ char *framerate_mode; /* "auto", "cfr", "vfr" */
+ bool overclock;
+ bool record_cursor;
+
+ /* Audio */
+ char **audio_input; /* NULL-terminated array, each "device:xxx" / "app:xxx" */
+ int n_audio_input;
+ bool merge_audio_tracks;
+ bool record_app_audio_inverted;
+
+ /* Resolution */
+ bool change_video_resolution;
+
+ /* Notifications */
+ bool show_recording_started_notifications;
+ bool show_recording_stopped_notifications;
+ bool show_recording_saved_notifications;
+
+ /* UI state */
+ bool advanced_view;
+ bool hide_window_when_recording;
+ bool restore_portal_session;
+
+ /* Warning flags (persisted, not shown in UI) */
+ bool software_encoding_warning_shown;
+ bool steam_deck_warning_shown;
+ bool hevc_amd_bug_warning_shown;
+ bool av1_amd_bug_warning_shown;
+
+ /* Misc */
+ bool use_new_ui;
+ int32_t installed_gsr_global_hotkeys_version;
+} GsrMainConfig;
+
+typedef struct {
+ char *streaming_service; /* "twitch", "youtube", "custom" */
+ char *youtube_stream_key;
+ char *twitch_stream_key;
+ char *custom_url;
+ char *custom_container; /* "mp4", "flv", "matroska", etc. */
+
+ GsrConfigHotkey start_stop_hotkey;
+} GsrStreamingConfig;
+
+typedef struct {
+ char *save_directory;
+ char *container;
+
+ GsrConfigHotkey start_stop_hotkey;
+ GsrConfigHotkey pause_unpause_hotkey;
+} GsrRecordConfig;
+
+typedef struct {
+ char *save_directory;
+ char *container;
+ int32_t replay_time;
+
+ GsrConfigHotkey start_stop_hotkey;
+ GsrConfigHotkey save_hotkey;
+} GsrReplayConfig;
+
+typedef struct {
+ GsrMainConfig main_config;
+ GsrStreamingConfig streaming_config;
+ GsrRecordConfig record_config;
+ GsrReplayConfig replay_config;
+} GsrConfig;
+
+/* ── API ─────────────────────────────────────────────────────────── */
+
+/**
+ * Initialize config to default values.
+ * Must be called before gsr_config_read() or manual population.
+ */
+void gsr_config_init_defaults(GsrConfig *config);
+
+/**
+ * Read config from the standard file location.
+ * Returns TRUE on success, FALSE if file not found or error.
+ * On failure, config retains its default values.
+ */
+gboolean gsr_config_read(GsrConfig *config);
+
+/**
+ * Save config to the standard file location.
+ * Creates config directory if needed.
+ */
+void gsr_config_save(const GsrConfig *config);
+
+/**
+ * Free all heap-allocated members (strings, arrays).
+ * Does NOT free the GsrConfig struct itself.
+ */
+void gsr_config_clear(GsrConfig *config);
+
+/**
+ * Get the config directory path (e.g., ~/.config/gpu-screen-recorder).
+ * Returns a newly allocated string; caller must g_free().
+ */
+char *gsr_config_get_dir(void);
+
+/**
+ * Get the default videos directory.
+ * Returns a newly allocated string; caller must g_free().
+ */
+char *gsr_config_get_videos_dir(void);
+
+/* ── Hotkey conversion utilities ─────────────────────────────────── */
+
+/**
+ * Convert a GsrConfigHotkey (custom modifier bitmask + keysym) to a
+ * GTK accelerator string like "<Alt>1".
+ * Returns a newly allocated string, or NULL if the hotkey is empty.
+ */
+char *gsr_config_hotkey_to_accel(const GsrConfigHotkey *hk);
+
+/**
+ * Convert a GTK accelerator string like "<Alt>1" to a GsrConfigHotkey.
+ * Returns TRUE on success, FALSE if the accel string is invalid.
+ * If accel is NULL, the hotkey is cleared (keysym=0, modifiers=0).
+ */
+gboolean gsr_config_hotkey_from_accel(GsrConfigHotkey *hk, const char *accel);
+
+#ifdef HAVE_X11
+/**
+ * Convert a GsrConfigHotkey to an X11 modifier mask suitable for XGrabKey.
+ * The keysym is returned unchanged in *out_keysym.
+ */
+void gsr_config_hotkey_to_x11(const GsrConfigHotkey *hk,
+ unsigned int *out_x11_modifiers,
+ uint64_t *out_keysym);
+#endif /* HAVE_X11 */
+
+/**
+ * Check if a hotkey is empty (no key assigned).
+ */
+static inline gboolean
+gsr_config_hotkey_is_empty(const GsrConfigHotkey *hk)
+{
+ return hk->keysym == 0;
+}
+
+G_END_DECLS
diff --git a/src/gsr-hotkeys.c b/src/gsr-hotkeys.c
new file mode 100644
index 0000000..5c1b7f7
--- /dev/null
+++ b/src/gsr-hotkeys.c
@@ -0,0 +1,435 @@
+#include "gsr-hotkeys.h"
+#include "gsr-window.h"
+#include "gsr-config.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+/*
+ * On X11 we use gsr-x11-hotkeys.h for XGrabKey + GSource polling.
+ * On Wayland we use global_shortcuts.h for the D-Bus portal.
+ *
+ * Each backend is conditionally compiled via HAVE_X11 / HAVE_WAYLAND.
+ */
+
+#ifdef HAVE_X11
+#include "gsr-x11-hotkeys.h"
+#include <X11/keysym.h>
+#ifdef GDK_WINDOWING_X11
+#include <gdk/x11/gdkx.h>
+#endif
+#endif /* HAVE_X11 */
+
+#ifdef HAVE_WAYLAND
+#include "global_shortcuts.h"
+#endif
+
+/* ── Shortcut IDs (Wayland portal) ────────────────────────────────── */
+
+#ifdef HAVE_WAYLAND
+#define SHORTCUT_ID_START_STOP "gpu_screen_recorder_start_stop_recording"
+#define SHORTCUT_ID_PAUSE_UNPAUSE "gpu_screen_recorder_pause_unpause_recording"
+#define SHORTCUT_ID_SAVE_REPLAY "gpu_screen_recorder_save_replay"
+#endif
+
+/* ── Internal state ──────────────────────────────────────────────── */
+
+typedef enum {
+ HOTKEY_ACTION_START_STOP,
+ HOTKEY_ACTION_PAUSE_UNPAUSE,
+ HOTKEY_ACTION_SAVE_REPLAY,
+} HotkeyAction;
+
+#ifdef HAVE_X11
+typedef struct {
+ unsigned int x11_modifiers;
+ KeySym keysym;
+ HotkeyAction action;
+} GrabbedCombo;
+
+#define MAX_ACTIVE_COMBOS 4
+#endif /* HAVE_X11 */
+
+struct _GsrHotkeys {
+ GsrDisplayServer display_server;
+ GsrWindow *window; /* NOT owned */
+
+#ifdef HAVE_X11
+ /* X11 backend */
+ GsrX11Hotkeys *x11;
+
+ /* Currently grabbed combos and what action they map to */
+ GrabbedCombo active_combos[MAX_ACTIVE_COMBOS];
+ int n_active_combos;
+#endif /* HAVE_X11 */
+
+#ifdef HAVE_WAYLAND
+ /* Wayland backend */
+ gsr_global_shortcuts wayland;
+ bool wayland_initialized;
+ bool wayland_shortcuts_bound;
+ bool wayland_shortcuts_received;
+#endif /* HAVE_WAYLAND */
+};
+
+/* ── Forward declarations ────────────────────────────────────────── */
+
+#ifdef HAVE_X11
+static void on_x11_hotkey(unsigned int modifiers, KeySym keysym, void *userdata);
+#endif
+
+#ifdef HAVE_WAYLAND
+static void on_wayland_init(bool success, void *userdata);
+static void on_wayland_deactivated(const char *shortcut_id, void *userdata);
+static void on_wayland_shortcut_changed(gsr_shortcut shortcut, void *userdata);
+#endif
+
+/* ── Hotkey action dispatch (shared by both backends) ────────────── */
+
+/**
+ * Get the name of the currently visible page in the AdwViewStack.
+ * Returns a borrowed string like "stream", "record", "replay", "config".
+ */
+static const char *
+get_visible_page_name(GsrHotkeys *self)
+{
+ return gsr_window_get_visible_page_name(self->window);
+}
+
+static void
+dispatch_start_stop(GsrHotkeys *self)
+{
+ gsr_window_hotkey_start_stop(self->window);
+}
+
+static void
+dispatch_pause_unpause(GsrHotkeys *self)
+{
+ gsr_window_hotkey_pause_unpause(self->window);
+}
+
+static void
+dispatch_save_replay(GsrHotkeys *self)
+{
+ gsr_window_hotkey_save_replay(self->window);
+}
+
+/* ── X11 callback ────────────────────────────────────────────────── */
+
+#ifdef HAVE_X11
+static void
+on_x11_hotkey(unsigned int modifiers, KeySym keysym, void *userdata)
+{
+ GsrHotkeys *self = userdata;
+
+ /* Find which action this combo maps to */
+ HotkeyAction action = HOTKEY_ACTION_START_STOP;
+ bool found = false;
+
+ for (int i = 0; i < self->n_active_combos; i++) {
+ if (self->active_combos[i].keysym == keysym &&
+ self->active_combos[i].x11_modifiers == modifiers)
+ {
+ action = self->active_combos[i].action;
+ found = true;
+ break;
+ }
+ }
+
+ if (!found) {
+ /* Fallback: match by keysym only (modifier might have extra bits
+ * from NumLock/CapsLock that XGrabKey variants handle) */
+ unsigned int base_mods = modifiers & (ControlMask | ShiftMask | Mod1Mask | Mod4Mask);
+ for (int i = 0; i < self->n_active_combos; i++) {
+ if (self->active_combos[i].keysym == keysym &&
+ self->active_combos[i].x11_modifiers == base_mods)
+ {
+ action = self->active_combos[i].action;
+ found = true;
+ break;
+ }
+ }
+ }
+
+ if (!found)
+ return;
+
+ switch (action) {
+ case HOTKEY_ACTION_START_STOP:
+ dispatch_start_stop(self);
+ break;
+ case HOTKEY_ACTION_PAUSE_UNPAUSE:
+ dispatch_pause_unpause(self);
+ break;
+ case HOTKEY_ACTION_SAVE_REPLAY:
+ dispatch_save_replay(self);
+ break;
+ }
+}
+#endif /* HAVE_X11 */
+
+/* ── Wayland callbacks ───────────────────────────────────────────── */
+
+#ifdef HAVE_WAYLAND
+static void
+on_wayland_init(bool success, void *userdata)
+{
+ GsrHotkeys *self = userdata;
+ self->wayland_initialized = success;
+ if (success) {
+ g_debug("Wayland global shortcuts session created");
+ gsr_global_shortcuts_subscribe_activated_signal(
+ &self->wayland, on_wayland_deactivated,
+ on_wayland_shortcut_changed, self);
+ } else {
+ fprintf(stderr, "gsr warning: Wayland global shortcuts init failed\n");
+ }
+
+ /* Notify the window so it can update the UI */
+ gsr_window_on_wayland_hotkeys_init(self->window, success);
+}
+
+static void
+on_wayland_deactivated(const char *shortcut_id, void *userdata)
+{
+ GsrHotkeys *self = userdata;
+
+ const char *page = get_visible_page_name(self);
+ if (!page)
+ return;
+
+ /* The portal uses 3 shared IDs across all modes.
+ * Dispatch based on the currently visible page. */
+
+ if (strcmp(shortcut_id, SHORTCUT_ID_START_STOP) == 0) {
+ if (g_str_equal(page, "stream") ||
+ g_str_equal(page, "record") ||
+ g_str_equal(page, "replay"))
+ {
+ dispatch_start_stop(self);
+ }
+ } else if (strcmp(shortcut_id, SHORTCUT_ID_PAUSE_UNPAUSE) == 0) {
+ if (g_str_equal(page, "record"))
+ dispatch_pause_unpause(self);
+ } else if (strcmp(shortcut_id, SHORTCUT_ID_SAVE_REPLAY) == 0) {
+ if (g_str_equal(page, "replay"))
+ dispatch_save_replay(self);
+ }
+}
+
+static void
+on_wayland_shortcut_changed(gsr_shortcut shortcut, void *userdata)
+{
+ GsrHotkeys *self = userdata;
+ self->wayland_shortcuts_received = true;
+ g_debug("Wayland shortcut changed: id=%s trigger=%s",
+ shortcut.id, shortcut.trigger_description);
+}
+#endif /* HAVE_WAYLAND */
+
+/* ── Public API ──────────────────────────────────────────────────── */
+
+GsrHotkeys *
+gsr_hotkeys_new(GsrDisplayServer display_server, GsrWindow *window)
+{
+ GsrHotkeys *self = calloc(1, sizeof(GsrHotkeys));
+ if (!self)
+ return NULL;
+
+ self->display_server = display_server;
+ self->window = window;
+
+#ifdef HAVE_X11
+ if (display_server == GSR_DISPLAY_SERVER_X11) {
+ /*
+ * Get the X11 Display from GDK.
+ * We use gdk_x11_display_get_xdisplay() from <gdk/x11/gdkx.h>.
+ */
+ GdkDisplay *gdk_dpy = gdk_display_get_default();
+ if (!gdk_dpy) {
+ fprintf(stderr, "gsr warning: no default GDK display\n");
+ free(self);
+ return NULL;
+ }
+
+ /* GTK4 X11 backend */
+ Display *xdisplay = NULL;
+
+ /* Use the GDK X11 function if available */
+#ifdef GDK_WINDOWING_X11
+ G_GNUC_BEGIN_IGNORE_DEPRECATIONS
+ xdisplay = gdk_x11_display_get_xdisplay(gdk_dpy);
+ G_GNUC_END_IGNORE_DEPRECATIONS
+#endif
+
+ if (!xdisplay) {
+ fprintf(stderr, "gsr warning: could not get X11 display from GDK\n");
+ free(self);
+ return NULL;
+ }
+
+ self->x11 = gsr_x11_hotkeys_new(xdisplay, on_x11_hotkey, self);
+ if (!self->x11) {
+ fprintf(stderr, "gsr warning: failed to create X11 hotkey watcher\n");
+ free(self);
+ return NULL;
+ }
+
+ g_debug("X11 hotkey manager initialized");
+
+ }
+#endif /* HAVE_X11 */
+
+#ifdef HAVE_WAYLAND
+ if (display_server == GSR_DISPLAY_SERVER_WAYLAND) {
+ /* TODO: Skip Hyprland due to portal crash bug */
+ if (!gsr_global_shortcuts_init(&self->wayland, on_wayland_init, self)) {
+ fprintf(stderr, "gsr warning: failed to initialize Wayland global shortcuts\n");
+ /* Non-fatal: hotkeys just won't work */
+ }
+ }
+#endif /* HAVE_WAYLAND */
+
+ return self;
+}
+
+void
+gsr_hotkeys_free(GsrHotkeys *self)
+{
+ if (!self)
+ return;
+
+#ifdef HAVE_X11
+ if (self->x11) {
+ gsr_x11_hotkeys_free(self->x11);
+ self->x11 = NULL;
+ }
+#endif
+
+#ifdef HAVE_WAYLAND
+ if (self->wayland_initialized) {
+ gsr_global_shortcuts_deinit(&self->wayland);
+ self->wayland_initialized = false;
+ }
+#endif
+
+ free(self);
+}
+
+/* ── Helper: grab a hotkey from config and track its action ──────── */
+
+#ifdef HAVE_X11
+static void
+grab_hotkey_from_config(GsrHotkeys *self,
+ const GsrConfigHotkey *hk,
+ HotkeyAction action)
+{
+ if (gsr_config_hotkey_is_empty(hk))
+ return;
+
+ unsigned int x11_mods = 0;
+ uint64_t keysym = 0;
+ gsr_config_hotkey_to_x11(hk, &x11_mods, &keysym);
+
+ if (keysym == 0)
+ return;
+
+ GsrX11HotkeyCombo combo = { .modifiers = x11_mods, .keysym = (KeySym)keysym };
+ if (!gsr_x11_hotkeys_grab(self->x11, combo)) {
+ g_warning("Failed to grab hotkey (keysym=0x%lx, mods=0x%x)",
+ (unsigned long)keysym, x11_mods);
+ return;
+ }
+
+ if (self->n_active_combos < MAX_ACTIVE_COMBOS) {
+ self->active_combos[self->n_active_combos] = (GrabbedCombo){
+ .x11_modifiers = x11_mods,
+ .keysym = (KeySym)keysym,
+ .action = action,
+ };
+ self->n_active_combos++;
+ }
+}
+
+void
+gsr_hotkeys_regrab_for_visible_page(GsrHotkeys *self)
+{
+ if (!self || self->display_server != GSR_DISPLAY_SERVER_X11 || !self->x11)
+ return;
+
+ /* Ungrab everything first */
+ gsr_x11_hotkeys_ungrab_all(self->x11);
+ self->n_active_combos = 0;
+
+ const char *page = get_visible_page_name(self);
+ if (!page)
+ return;
+
+ const GsrConfig *config = gsr_window_get_config(self->window);
+ if (!config)
+ return;
+
+ /* Only grab hotkeys for action pages (not config) */
+ if (g_str_equal(page, "stream")) {
+ grab_hotkey_from_config(self,
+ &config->streaming_config.start_stop_hotkey,
+ HOTKEY_ACTION_START_STOP);
+
+ } else if (g_str_equal(page, "record")) {
+ grab_hotkey_from_config(self,
+ &config->record_config.start_stop_hotkey,
+ HOTKEY_ACTION_START_STOP);
+ grab_hotkey_from_config(self,
+ &config->record_config.pause_unpause_hotkey,
+ HOTKEY_ACTION_PAUSE_UNPAUSE);
+
+ } else if (g_str_equal(page, "replay")) {
+ grab_hotkey_from_config(self,
+ &config->replay_config.start_stop_hotkey,
+ HOTKEY_ACTION_START_STOP);
+ grab_hotkey_from_config(self,
+ &config->replay_config.save_hotkey,
+ HOTKEY_ACTION_SAVE_REPLAY);
+ }
+}
+#endif /* HAVE_X11 */
+
+#ifdef HAVE_WAYLAND
+void
+gsr_hotkeys_register_wayland_shortcuts_once(GsrHotkeys *self)
+{
+ if (!self || self->display_server != GSR_DISPLAY_SERVER_WAYLAND)
+ return;
+
+ if (!self->wayland_initialized)
+ return;
+
+ /* On GNOME: bind once. On KDE: the user clicks a button (deferred to Phase 7). */
+ if (self->wayland_shortcuts_bound || self->wayland_shortcuts_received)
+ return;
+
+ self->wayland_shortcuts_bound = true;
+
+ const gsr_bind_shortcut shortcuts[3] = {
+ {
+ .description = "Start/stop recording/replay/streaming",
+ .shortcut = { .id = SHORTCUT_ID_START_STOP, .trigger_description = "ALT+1" },
+ },
+ {
+ .description = "Pause/unpause recording",
+ .shortcut = { .id = SHORTCUT_ID_PAUSE_UNPAUSE, .trigger_description = "ALT+2" },
+ },
+ {
+ .description = "Save replay",
+ .shortcut = { .id = SHORTCUT_ID_SAVE_REPLAY, .trigger_description = "ALT+3" },
+ },
+ };
+
+ if (!gsr_global_shortcuts_bind_shortcuts(&self->wayland, shortcuts, 3,
+ on_wayland_shortcut_changed, self))
+ {
+ fprintf(stderr, "gsr warning: failed to bind Wayland shortcuts\n");
+ }
+}
+#endif /* HAVE_WAYLAND */
diff --git a/src/gsr-hotkeys.h b/src/gsr-hotkeys.h
new file mode 100644
index 0000000..e95ad58
--- /dev/null
+++ b/src/gsr-hotkeys.h
@@ -0,0 +1,52 @@
+#pragma once
+
+/*
+ * gsr-hotkeys.h — Unified hotkey manager for X11 and Wayland.
+ *
+ * Detects the display server at runtime and uses either:
+ * - X11: XGrabKey + GSource polling (gsr-x11-hotkeys)
+ * - Wayland: XDG GlobalShortcuts portal (global_shortcuts)
+ *
+ * Dispatches hotkey actions to the appropriate page based on the
+ * currently visible tab (page-scoped dispatch).
+ */
+
+#include <stdbool.h>
+#include "gsr-info.h"
+
+/* Forward declare — the hotkeys manager calls back into the window */
+typedef struct _GsrWindow GsrWindow;
+
+typedef struct _GsrHotkeys GsrHotkeys;
+
+/**
+ * Create a hotkey manager for the given display server type.
+ * The window pointer is used for page-scoped dispatch.
+ * Returns NULL on failure.
+ */
+GsrHotkeys *gsr_hotkeys_new(GsrDisplayServer display_server,
+ GsrWindow *window);
+
+/**
+ * Destroy the hotkey manager and release all resources.
+ */
+void gsr_hotkeys_free(GsrHotkeys *self);
+
+/**
+ * Re-grab hotkeys for the visible page (X11 only).
+ * On Wayland this is a no-op since the portal handles page dispatch
+ * in the deactivated callback.
+ *
+ * Call this whenever the visible tab changes.
+ */
+#ifdef HAVE_X11
+void gsr_hotkeys_regrab_for_visible_page(GsrHotkeys *self);
+#endif
+
+/**
+ * On Wayland (GNOME), bind shortcuts if not yet done.
+ * Should be called once when first navigating to an action page.
+ */
+#ifdef HAVE_WAYLAND
+void gsr_hotkeys_register_wayland_shortcuts_once(GsrHotkeys *self);
+#endif
diff --git a/src/gsr-info.c b/src/gsr-info.c
new file mode 100644
index 0000000..04da95c
--- /dev/null
+++ b/src/gsr-info.c
@@ -0,0 +1,393 @@
+#include "gsr-info.h"
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/wait.h>
+
+/* ── Helpers ─────────────────────────────────────────────────────── */
+
+static char *
+read_command_output(const char *cmd, int *exit_code)
+{
+ FILE *f = popen(cmd, "r");
+ if (!f) {
+ if (exit_code) *exit_code = -1;
+ return NULL;
+ }
+
+ size_t capacity = 8192;
+ size_t len = 0;
+ char *buf = g_malloc(capacity);
+
+ for (;;) {
+ size_t n = fread(buf + len, 1, capacity - len - 1, f);
+ if (n == 0) break;
+ len += n;
+ if (len + 1 >= capacity) {
+ capacity *= 2;
+ buf = g_realloc(buf, capacity);
+ }
+ }
+ buf[len] = '\0';
+
+ int status = pclose(f);
+ if (exit_code) {
+ if (WIFEXITED(status))
+ *exit_code = WEXITSTATUS(status);
+ else
+ *exit_code = -1;
+ }
+ return buf;
+}
+
+typedef void (*LineCallback)(const char *line, size_t len, void *user_data);
+
+static void
+for_each_line(const char *text, LineCallback cb, void *user_data)
+{
+ const char *p = text;
+ while (*p) {
+ const char *nl = strchr(p, '\n');
+ size_t len = nl ? (size_t)(nl - p) : strlen(p);
+ if (len > 0)
+ cb(p, len, user_data);
+ if (!nl) break;
+ p = nl + 1;
+ }
+}
+
+static bool
+str_eq(const char *s, size_t len, const char *literal)
+{
+ size_t llen = strlen(literal);
+ return len == llen && memcmp(s, literal, llen) == 0;
+}
+
+static bool
+str_starts_with(const char *s, size_t len, const char *prefix)
+{
+ size_t plen = strlen(prefix);
+ return len >= plen && memcmp(s, prefix, plen) == 0;
+}
+
+/* ── Section parsing ─────────────────────────────────────────────── */
+
+typedef enum {
+ SECTION_UNKNOWN,
+ SECTION_SYSTEM_INFO,
+ SECTION_GPU_INFO,
+ SECTION_VIDEO_CODECS,
+ SECTION_CAPTURE_OPTIONS,
+} InfoSection;
+
+typedef struct {
+ GsrInfo *info;
+ InfoSection section;
+ /* temporary growable monitor array */
+ GsrMonitor *monitors;
+ int n_monitors;
+ int monitors_capacity;
+} ParseState;
+
+static void
+parse_system_info(ParseState *st, const char *line, size_t len)
+{
+ const char *sep = memchr(line, '|', len);
+ if (!sep) return;
+
+ size_t klen = (size_t)(sep - line);
+ const char *val = sep + 1;
+ size_t vlen = len - klen - 1;
+
+ if (str_eq(line, klen, "display_server")) {
+ if (str_eq(val, vlen, "x11"))
+ st->info->system_info.display_server = GSR_DISPLAY_SERVER_X11;
+ else if (str_eq(val, vlen, "wayland"))
+ st->info->system_info.display_server = GSR_DISPLAY_SERVER_WAYLAND;
+ } else if (str_eq(line, klen, "is_steam_deck")) {
+ st->info->system_info.is_steam_deck = str_eq(val, vlen, "yes");
+ } else if (str_eq(line, klen, "supports_app_audio")) {
+ st->info->system_info.supports_app_audio = str_eq(val, vlen, "yes");
+ }
+}
+
+static void
+parse_gpu_info(ParseState *st, const char *line, size_t len)
+{
+ const char *sep = memchr(line, '|', len);
+ if (!sep) return;
+
+ size_t klen = (size_t)(sep - line);
+ const char *val = sep + 1;
+ size_t vlen = len - klen - 1;
+
+ if (str_eq(line, klen, "vendor")) {
+ if (str_eq(val, vlen, "amd"))
+ st->info->gpu_info.vendor = GSR_GPU_VENDOR_AMD;
+ else if (str_eq(val, vlen, "intel"))
+ st->info->gpu_info.vendor = GSR_GPU_VENDOR_INTEL;
+ else if (str_eq(val, vlen, "nvidia"))
+ st->info->gpu_info.vendor = GSR_GPU_VENDOR_NVIDIA;
+ else if (str_eq(val, vlen, "broadcom"))
+ st->info->gpu_info.vendor = GSR_GPU_VENDOR_BROADCOM;
+ }
+}
+
+static void
+parse_video_codecs(ParseState *st, const char *line, size_t len)
+{
+ GsrSupportedVideoCodecs *vc = &st->info->supported_video_codecs;
+
+ if (str_eq(line, len, "h264")) vc->h264 = true;
+ else if (str_eq(line, len, "h264_software")) vc->h264_software = true;
+ else if (str_eq(line, len, "hevc")) vc->hevc = true;
+ else if (str_eq(line, len, "hevc_hdr")) vc->hevc_hdr = true;
+ else if (str_eq(line, len, "hevc_10bit")) vc->hevc_10bit = true;
+ else if (str_eq(line, len, "av1")) vc->av1 = true;
+ else if (str_eq(line, len, "av1_hdr")) vc->av1_hdr = true;
+ else if (str_eq(line, len, "av1_10bit")) vc->av1_10bit = true;
+ else if (str_eq(line, len, "vp8")) vc->vp8 = true;
+ else if (str_eq(line, len, "vp9")) vc->vp9 = true;
+}
+
+static void
+parse_capture_options(ParseState *st, const char *line, size_t len)
+{
+ if (str_eq(line, len, "window")) {
+ st->info->supported_capture_options.window = true;
+ } else if (str_eq(line, len, "focused")) {
+ st->info->supported_capture_options.focused = true;
+ } else if (str_eq(line, len, "portal")) {
+ st->info->supported_capture_options.portal = true;
+ } else if (str_eq(line, len, "region") || (len > 0 && line[0] == '/')) {
+ /* skip "region" and DRM card paths */
+ } else {
+ /* monitor entry: name|WxH */
+ if (st->n_monitors >= st->monitors_capacity) {
+ st->monitors_capacity = st->monitors_capacity ? st->monitors_capacity * 2 : 8;
+ st->monitors = g_realloc_n(st->monitors, (size_t)st->monitors_capacity,
+ sizeof(GsrMonitor));
+ }
+ GsrMonitor *m = &st->monitors[st->n_monitors];
+ memset(m, 0, sizeof(*m));
+
+ const char *sep = memchr(line, '|', len);
+ if (sep) {
+ size_t name_len = (size_t)(sep - line);
+ m->name = g_strndup(line, name_len);
+ sscanf(sep + 1, "%dx%d", &m->width, &m->height);
+ } else {
+ m->name = g_strndup(line, len);
+ }
+ st->n_monitors++;
+ }
+}
+
+static void
+info_line_cb(const char *line, size_t len, void *user_data)
+{
+ ParseState *st = user_data;
+
+ if (str_starts_with(line, len, "section=")) {
+ const char *name = line + 8;
+ size_t nlen = len - 8;
+ if (str_eq(name, nlen, "system_info"))
+ st->section = SECTION_SYSTEM_INFO;
+ else if (str_eq(name, nlen, "gpu_info"))
+ st->section = SECTION_GPU_INFO;
+ else if (str_eq(name, nlen, "video_codecs"))
+ st->section = SECTION_VIDEO_CODECS;
+ else if (str_eq(name, nlen, "capture_options"))
+ st->section = SECTION_CAPTURE_OPTIONS;
+ else
+ st->section = SECTION_UNKNOWN;
+ return;
+ }
+
+ switch (st->section) {
+ case SECTION_SYSTEM_INFO: parse_system_info(st, line, len); break;
+ case SECTION_GPU_INFO: parse_gpu_info(st, line, len); break;
+ case SECTION_VIDEO_CODECS: parse_video_codecs(st, line, len); break;
+ case SECTION_CAPTURE_OPTIONS: parse_capture_options(st, line, len); break;
+ case SECTION_UNKNOWN: break;
+ }
+}
+
+/* ── Public API ──────────────────────────────────────────────────── */
+
+GsrInfoExitStatus
+gsr_info_load(GsrInfo *info)
+{
+ memset(info, 0, sizeof(*info));
+
+ int exit_code = -1;
+ char *output = read_command_output("gpu-screen-recorder --info", &exit_code);
+ if (!output) {
+ fprintf(stderr, "error: 'gpu-screen-recorder --info' failed to run\n");
+ return GSR_INFO_EXIT_FAILED_TO_RUN;
+ }
+
+ ParseState st = {
+ .info = info,
+ .section = SECTION_UNKNOWN,
+ };
+ for_each_line(output, info_line_cb, &st);
+ g_free(output);
+
+ /* Transfer monitors */
+ info->supported_capture_options.monitors = st.monitors;
+ info->supported_capture_options.n_monitors = st.n_monitors;
+
+ switch (exit_code) {
+ case 0: return GSR_INFO_EXIT_OK;
+ case 22: return GSR_INFO_EXIT_OPENGL_FAILED;
+ case 23: return GSR_INFO_EXIT_NO_DRM_CARD;
+ default: return GSR_INFO_EXIT_FAILED_TO_RUN;
+ }
+}
+
+void
+gsr_info_clear(GsrInfo *info)
+{
+ for (int i = 0; i < info->supported_capture_options.n_monitors; i++)
+ g_free(info->supported_capture_options.monitors[i].name);
+ g_free(info->supported_capture_options.monitors);
+ memset(info, 0, sizeof(*info));
+}
+
+bool
+gsr_info_is_codec_supported(const GsrInfo *info, const char *codec_id)
+{
+ if (strcmp(codec_id, "auto") == 0) return true;
+
+ const GsrSupportedVideoCodecs *vc = &info->supported_video_codecs;
+ if (strcmp(codec_id, "h264") == 0) return vc->h264;
+ if (strcmp(codec_id, "h264_software") == 0) return vc->h264_software;
+ if (strcmp(codec_id, "hevc") == 0) return vc->hevc;
+ if (strcmp(codec_id, "hevc_hdr") == 0) return vc->hevc_hdr;
+ if (strcmp(codec_id, "hevc_10bit") == 0) return vc->hevc_10bit;
+ if (strcmp(codec_id, "av1") == 0) return vc->av1;
+ if (strcmp(codec_id, "av1_hdr") == 0) return vc->av1_hdr;
+ if (strcmp(codec_id, "av1_10bit") == 0) return vc->av1_10bit;
+ if (strcmp(codec_id, "vp8") == 0) return vc->vp8;
+ if (strcmp(codec_id, "vp9") == 0) return vc->vp9;
+ return false;
+}
+
+bool
+gsr_info_is_capture_option_enabled(const GsrInfo *info, const char *option_id)
+{
+ if (info->system_info.display_server == GSR_DISPLAY_SERVER_WAYLAND) {
+ if (strcmp(option_id, "window") == 0 || strcmp(option_id, "focused") == 0)
+ return false;
+ }
+ if (strcmp(option_id, "portal") == 0)
+ return info->supported_capture_options.portal;
+ return true;
+}
+
+const char *
+gsr_info_get_first_usable_hw_video_codec(const GsrInfo *info)
+{
+ const GsrSupportedVideoCodecs *vc = &info->supported_video_codecs;
+ if (vc->h264) return "h264";
+ if (vc->hevc) return "hevc";
+ if (vc->av1) return "av1";
+ if (vc->vp8) return "vp8";
+ if (vc->vp9) return "vp9";
+ return NULL;
+}
+
+/* ── Audio device queries ────────────────────────────────────────── */
+
+GsrAudioDevice *
+gsr_audio_devices_get(int *n_devices)
+{
+ *n_devices = 0;
+
+ int exit_code = -1;
+ char *output = read_command_output("gpu-screen-recorder --list-audio-devices", &exit_code);
+ if (!output) return NULL;
+
+ /* Count lines */
+ int capacity = 16;
+ GsrAudioDevice *devs = g_new0(GsrAudioDevice, capacity);
+ int count = 0;
+
+ const char *p = output;
+ while (*p) {
+ const char *nl = strchr(p, '\n');
+ size_t len = nl ? (size_t)(nl - p) : strlen(p);
+ if (len > 0) {
+ const char *sep = memchr(p, '|', len);
+ if (sep) {
+ if (count >= capacity) {
+ capacity *= 2;
+ devs = g_realloc_n(devs, (size_t)capacity, sizeof(GsrAudioDevice));
+ }
+ devs[count].name = g_strndup(p, (size_t)(sep - p));
+ devs[count].description = g_strndup(sep + 1, len - (size_t)(sep - p) - 1);
+ count++;
+ }
+ }
+ if (!nl) break;
+ p = nl + 1;
+ }
+
+ g_free(output);
+ *n_devices = count;
+ return devs;
+}
+
+void
+gsr_audio_devices_free(GsrAudioDevice *devices, int n_devices)
+{
+ for (int i = 0; i < n_devices; i++) {
+ g_free(devices[i].name);
+ g_free(devices[i].description);
+ }
+ g_free(devices);
+}
+
+char **
+gsr_application_audio_get(int *n_apps)
+{
+ *n_apps = 0;
+
+ int exit_code = -1;
+ char *output = read_command_output("gpu-screen-recorder --list-application-audio", &exit_code);
+ if (!output) return NULL;
+
+ int capacity = 16;
+ char **apps = g_new0(char *, capacity + 1);
+ int count = 0;
+
+ const char *p = output;
+ while (*p) {
+ const char *nl = strchr(p, '\n');
+ size_t len = nl ? (size_t)(nl - p) : strlen(p);
+ if (len > 0) {
+ if (count >= capacity) {
+ capacity *= 2;
+ apps = g_realloc_n(apps, (size_t)(capacity + 1), sizeof(char *));
+ }
+ apps[count] = g_strndup(p, len);
+ count++;
+ }
+ if (!nl) break;
+ p = nl + 1;
+ }
+ apps[count] = NULL;
+
+ g_free(output);
+ *n_apps = count;
+ return apps;
+}
+
+void
+gsr_application_audio_free(char **apps, int n_apps)
+{
+ for (int i = 0; i < n_apps; i++)
+ g_free(apps[i]);
+ g_free(apps);
+}
diff --git a/src/gsr-info.h b/src/gsr-info.h
new file mode 100644
index 0000000..9764fee
--- /dev/null
+++ b/src/gsr-info.h
@@ -0,0 +1,111 @@
+#pragma once
+
+#include <glib.h>
+#include <stdbool.h>
+
+G_BEGIN_DECLS
+
+/* ── Enums ───────────────────────────────────────────────────────── */
+
+typedef enum {
+ GSR_DISPLAY_SERVER_UNKNOWN,
+ GSR_DISPLAY_SERVER_X11,
+ GSR_DISPLAY_SERVER_WAYLAND,
+} GsrDisplayServer;
+
+typedef enum {
+ GSR_GPU_VENDOR_UNKNOWN,
+ GSR_GPU_VENDOR_AMD,
+ GSR_GPU_VENDOR_INTEL,
+ GSR_GPU_VENDOR_NVIDIA,
+ GSR_GPU_VENDOR_BROADCOM,
+} GsrGpuVendor;
+
+typedef enum {
+ GSR_INFO_EXIT_OK,
+ GSR_INFO_EXIT_FAILED_TO_RUN,
+ GSR_INFO_EXIT_OPENGL_FAILED,
+ GSR_INFO_EXIT_NO_DRM_CARD,
+} GsrInfoExitStatus;
+
+/* ── Data structures ─────────────────────────────────────────────── */
+
+typedef struct {
+ GsrDisplayServer display_server;
+ bool supports_app_audio;
+ bool is_steam_deck;
+} GsrSystemInfo;
+
+typedef struct {
+ GsrGpuVendor vendor;
+} GsrGpuInfo;
+
+typedef struct {
+ bool h264;
+ bool h264_software;
+ bool hevc;
+ bool hevc_hdr;
+ bool hevc_10bit;
+ bool av1;
+ bool av1_hdr;
+ bool av1_10bit;
+ bool vp8;
+ bool vp9;
+} GsrSupportedVideoCodecs;
+
+typedef struct {
+ char *name; /* owned */
+ int width;
+ int height;
+} GsrMonitor;
+
+typedef struct {
+ bool window;
+ bool focused;
+ bool portal;
+ GsrMonitor *monitors; /* owned array */
+ int n_monitors;
+} GsrSupportedCaptureOptions;
+
+typedef struct {
+ GsrSystemInfo system_info;
+ GsrGpuInfo gpu_info;
+ GsrSupportedVideoCodecs supported_video_codecs;
+ GsrSupportedCaptureOptions supported_capture_options;
+} GsrInfo;
+
+/* ── Audio devices ───────────────────────────────────────────────── */
+
+typedef struct {
+ char *name; /* PulseAudio/PipeWire ID, owned */
+ char *description; /* human-readable label, owned */
+} GsrAudioDevice;
+
+/* ── Functions ───────────────────────────────────────────────────── */
+
+GsrInfoExitStatus gsr_info_load (GsrInfo *info);
+void gsr_info_clear (GsrInfo *info);
+
+bool gsr_info_is_codec_supported
+ (const GsrInfo *info,
+ const char *codec_id);
+
+bool gsr_info_is_capture_option_enabled
+ (const GsrInfo *info,
+ const char *option_id);
+
+/**
+ * Returns the first usable HW video codec name (h264 > hevc > av1 > vp8 > vp9),
+ * or NULL if none supported.
+ */
+const char *gsr_info_get_first_usable_hw_video_codec(const GsrInfo *info);
+
+/* Audio device / application queries (separate commands) */
+GsrAudioDevice *gsr_audio_devices_get (int *n_devices);
+void gsr_audio_devices_free (GsrAudioDevice *devices,
+ int n_devices);
+
+char **gsr_application_audio_get (int *n_apps);
+void gsr_application_audio_free(char **apps, int n_apps);
+
+G_END_DECLS
diff --git a/src/gsr-record-page.c b/src/gsr-record-page.c
new file mode 100644
index 0000000..a9575c4
--- /dev/null
+++ b/src/gsr-record-page.c
@@ -0,0 +1,747 @@
+#include "gsr-record-page.h"
+#ifdef HAVE_X11
+#include "gsr-shortcut-accel-dialog.h"
+#endif
+#include "gsr-window.h"
+#include <glib/gi18n.h>
+#include <signal.h>
+#include <time.h>
+
+/* ═══════════════════════════════════════════════════════════════════
+ * GsrRecordPage — "Record" tab
+ *
+ * Groups: Output · Action · Status
+ * ═══════════════════════════════════════════════════════════════════ */
+
+struct _GsrRecordPage {
+ AdwPreferencesPage parent_instance;
+
+ const GsrInfo *info; /* borrowed */
+
+ /* ── Hotkey group ─── */
+ AdwPreferencesGroup *hotkey_group;
+#ifdef HAVE_WAYLAND
+ GtkWidget *hotkey_not_supported_label; /* Wayland only */
+ GtkWidget *hotkey_info_row; /* Wayland only */
+#endif
+
+#ifdef HAVE_X11
+ /* X11 interactive hotkey rows */
+ AdwActionRow *x11_start_stop_row;
+ GtkShortcutLabel *x11_start_stop_label;
+ char *x11_start_stop_accel; /* owned */
+ AdwActionRow *x11_pause_row;
+ GtkShortcutLabel *x11_pause_label;
+ char *x11_pause_accel; /* owned */
+#endif
+
+ /* ── Output group ─── */
+ AdwPreferencesGroup *output_group;
+ AdwActionRow *save_dir_row;
+ char *save_directory; /* owned */
+ AdwComboRow *container_row;
+
+ /* ── Action group ─── */
+ AdwPreferencesGroup *action_group;
+ GtkButton *start_button;
+ GtkButton *pause_button;
+
+ /* ── Status group ─── */
+ AdwPreferencesGroup *status_group;
+ GtkBox *status_box;
+ GtkImage *record_icon;
+ GtkLabel *timer_label;
+
+ gboolean is_active;
+ gboolean is_paused;
+ double start_time;
+ double paused_time_offset;
+ double pause_start;
+ guint timer_source_id;
+};
+
+G_DEFINE_FINAL_TYPE(GsrRecordPage, gsr_record_page, ADW_TYPE_PREFERENCES_PAGE)
+
+/* ── Helpers ─────────────────────────────────────────────────────── */
+
+static char *
+get_default_videos_dir(void)
+{
+ const char *vdir = g_get_user_special_dir(G_USER_DIRECTORY_VIDEOS);
+ if (vdir)
+ return g_strdup(vdir);
+ return g_build_filename(g_get_home_dir(), "Videos", NULL);
+}
+
+/* ── Timer helpers ───────────────────────────────────────────────── */
+
+static double
+clock_get_monotonic_seconds(void)
+{
+ struct timespec ts;
+ clock_gettime(CLOCK_MONOTONIC, &ts);
+ return (double)ts.tv_sec + (double)ts.tv_nsec * 1e-9;
+}
+
+static void
+format_timer(double seconds, char *buf, size_t buf_size)
+{
+ int total = (int)seconds;
+ int h = total / 3600;
+ int m = (total % 3600) / 60;
+ int s = total % 60;
+ snprintf(buf, buf_size, "%02d:%02d:%02d", h, m, s);
+}
+
+static gboolean
+on_timer_tick(gpointer user_data)
+{
+ GsrRecordPage *self = GSR_RECORD_PAGE(user_data);
+ double now = clock_get_monotonic_seconds();
+ double elapsed = now - self->start_time - self->paused_time_offset;
+ /* If currently paused, subtract the ongoing pause duration */
+ if (self->is_paused)
+ elapsed -= (now - self->pause_start);
+ char buf[32];
+ format_timer(elapsed, buf, sizeof(buf));
+ gtk_label_set_text(self->timer_label, buf);
+ return G_SOURCE_CONTINUE;
+}
+
+/* ── File chooser ────────────────────────────────────────────────── */
+
+static void
+on_folder_dialog_finish(GObject *source,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ GsrRecordPage *self = GSR_RECORD_PAGE(user_data);
+ GtkFileDialog *dialog = GTK_FILE_DIALOG(source);
+ GFile *folder = gtk_file_dialog_select_folder_finish(dialog, result, NULL);
+ if (!folder) return;
+
+ g_free(self->save_directory);
+ self->save_directory = g_file_get_path(folder);
+ adw_action_row_set_subtitle(self->save_dir_row, self->save_directory);
+ g_object_unref(folder);
+}
+
+static void
+on_save_dir_activated(AdwActionRow *row G_GNUC_UNUSED,
+ gpointer user_data)
+{
+ GsrRecordPage *self = GSR_RECORD_PAGE(user_data);
+ GtkFileDialog *dialog = gtk_file_dialog_new();
+ gtk_file_dialog_set_title(dialog, "Select save directory");
+
+ if (self->save_directory) {
+ GFile *initial = g_file_new_for_path(self->save_directory);
+ gtk_file_dialog_set_initial_folder(dialog, initial);
+ g_object_unref(initial);
+ }
+
+ GtkWindow *win = GTK_WINDOW(gtk_widget_get_root(GTK_WIDGET(self)));
+ gtk_file_dialog_select_folder(dialog, win, NULL,
+ on_folder_dialog_finish, self);
+ g_object_unref(dialog);
+}
+
+/* ── Callbacks ───────────────────────────────────────────────────── */
+
+static void
+on_start_recording_clicked(GtkButton *btn G_GNUC_UNUSED,
+ gpointer user_data)
+{
+ GsrRecordPage *self = GSR_RECORD_PAGE(user_data);
+
+ GtkRoot *root = gtk_widget_get_root(GTK_WIDGET(self));
+ GsrWindow *window = (root && GSR_IS_WINDOW(root)) ? GSR_WINDOW(root) : NULL;
+
+ if (self->is_active) {
+ /* ── Stop ─── */
+ if (window) {
+ gsr_window_stop_process(window, NULL);
+ gsr_window_set_recording_active(window, FALSE);
+ }
+ gsr_record_page_set_active(self, FALSE);
+ /* set_active(FALSE) resets is_active, is_paused, and stops timer */
+ } else {
+ /* ── Start ─── */
+ if (!window) return;
+
+ gboolean ok = gsr_window_start_process(window, GSR_ACTIVE_MODE_RECORD);
+ if (!ok) return; /* fork failed — toast already shown by window */
+
+ self->is_active = TRUE;
+ self->is_paused = FALSE;
+ self->paused_time_offset = 0.0;
+ self->pause_start = 0.0;
+ gsr_record_page_set_active(self, TRUE);
+ gsr_window_set_recording_active(window, TRUE);
+
+ /* Start display timer */
+ self->start_time = clock_get_monotonic_seconds();
+ self->timer_source_id = g_timeout_add(500, on_timer_tick, self);
+ }
+}
+
+static void
+on_pause_recording_clicked(GtkButton *btn G_GNUC_UNUSED,
+ gpointer user_data)
+{
+ GsrRecordPage *self = GSR_RECORD_PAGE(user_data);
+ if (!self->is_active) return;
+
+ GtkRoot *root = gtk_widget_get_root(GTK_WIDGET(self));
+ GsrWindow *window = (root && GSR_IS_WINDOW(root)) ? GSR_WINDOW(root) : NULL;
+
+ /* Send SIGUSR2 to toggle pause/unpause */
+ if (window)
+ gsr_window_send_signal(window, SIGUSR2);
+
+ self->is_paused = !self->is_paused;
+ gsr_record_page_set_paused(self, self->is_paused);
+
+ if (self->is_paused) {
+ self->pause_start = clock_get_monotonic_seconds();
+ } else {
+ /* Accumulate paused duration */
+ self->paused_time_offset += clock_get_monotonic_seconds() - self->pause_start;
+ self->pause_start = 0.0;
+ }
+}
+
+/* ── Build groups ────────────────────────────────────────────────── */
+
+#ifdef HAVE_WAYLAND
+static gboolean
+is_kde_wayland(void)
+{
+ const char *desktop = g_getenv("XDG_CURRENT_DESKTOP");
+ return desktop && g_strstr_len(desktop, -1, "KDE") != NULL;
+}
+#endif
+
+/* ── X11 hotkey row callbacks ────────────────────────────────────── */
+
+#ifdef HAVE_X11
+static void
+on_x11_start_stop_shortcut_set(GsrShortcutAccelDialog *dialog,
+ gpointer user_data)
+{
+ GsrRecordPage *self = GSR_RECORD_PAGE(user_data);
+ const char *accel = gsr_shortcut_accel_dialog_get_accelerator(dialog);
+
+ g_free(self->x11_start_stop_accel);
+ self->x11_start_stop_accel = g_strdup(accel);
+
+ if (self->x11_start_stop_label)
+ gtk_shortcut_label_set_accelerator(self->x11_start_stop_label,
+ accel ? accel : "");
+
+ /* Save config & regrab hotkeys */
+ GtkRoot *root = gtk_widget_get_root(GTK_WIDGET(self));
+ if (root && GSR_IS_WINDOW(root))
+ gsr_window_on_hotkey_changed(GSR_WINDOW(root));
+}
+
+static void
+on_x11_start_stop_activated(AdwActionRow *row G_GNUC_UNUSED,
+ gpointer user_data)
+{
+ GsrRecordPage *self = GSR_RECORD_PAGE(user_data);
+ GsrShortcutAccelDialog *dialog = gsr_shortcut_accel_dialog_new(
+ "Start/Stop recording", self->x11_start_stop_accel);
+ g_signal_connect(dialog, "shortcut-set",
+ G_CALLBACK(on_x11_start_stop_shortcut_set), self);
+ adw_dialog_present(ADW_DIALOG(dialog), GTK_WIDGET(self));
+}
+
+static void
+on_x11_pause_shortcut_set(GsrShortcutAccelDialog *dialog,
+ gpointer user_data)
+{
+ GsrRecordPage *self = GSR_RECORD_PAGE(user_data);
+ const char *accel = gsr_shortcut_accel_dialog_get_accelerator(dialog);
+
+ g_free(self->x11_pause_accel);
+ self->x11_pause_accel = g_strdup(accel);
+
+ if (self->x11_pause_label)
+ gtk_shortcut_label_set_accelerator(self->x11_pause_label,
+ accel ? accel : "");
+
+ /* Save config & regrab hotkeys */
+ GtkRoot *root = gtk_widget_get_root(GTK_WIDGET(self));
+ if (root && GSR_IS_WINDOW(root))
+ gsr_window_on_hotkey_changed(GSR_WINDOW(root));
+}
+
+static void
+on_x11_pause_activated(AdwActionRow *row G_GNUC_UNUSED,
+ gpointer user_data)
+{
+ GsrRecordPage *self = GSR_RECORD_PAGE(user_data);
+ GsrShortcutAccelDialog *dialog = gsr_shortcut_accel_dialog_new(
+ "Pause/Unpause recording", self->x11_pause_accel);
+ g_signal_connect(dialog, "shortcut-set",
+ G_CALLBACK(on_x11_pause_shortcut_set), self);
+ adw_dialog_present(ADW_DIALOG(dialog), GTK_WIDGET(self));
+}
+#endif /* HAVE_X11 */
+
+static void
+build_hotkey_group(GsrRecordPage *self)
+{
+ self->hotkey_group = ADW_PREFERENCES_GROUP(adw_preferences_group_new());
+ adw_preferences_group_set_title(self->hotkey_group, "Hotkeys");
+
+ GsrDisplayServer ds = self->info->system_info.display_server;
+
+#ifdef HAVE_WAYLAND
+ if (ds == GSR_DISPLAY_SERVER_WAYLAND) {
+ /* "Not supported" label — initially hidden, shown if portal fails */
+ self->hotkey_not_supported_label = gtk_label_new(
+ "Your Wayland compositor doesn't support global hotkeys.\n"
+ "Use X11 or KDE Plasma on Wayland if you want to use hotkeys.");
+ gtk_label_set_wrap(GTK_LABEL(self->hotkey_not_supported_label), TRUE);
+ gtk_widget_add_css_class(self->hotkey_not_supported_label, "dim-label");
+ gtk_widget_set_margin_top(self->hotkey_not_supported_label, 6);
+ gtk_widget_set_margin_bottom(self->hotkey_not_supported_label, 6);
+ gtk_widget_set_visible(self->hotkey_not_supported_label, FALSE);
+ adw_preferences_group_add(self->hotkey_group,
+ self->hotkey_not_supported_label);
+
+ /* Info row about Wayland hotkeys */
+ AdwActionRow *info_row = ADW_ACTION_ROW(adw_action_row_new());
+ self->hotkey_info_row = GTK_WIDGET(info_row);
+ if (is_kde_wayland()) {
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(info_row),
+ "Hotkeys are managed by KDE Plasma");
+ adw_action_row_set_subtitle(info_row,
+ "Click to configure hotkeys in system settings");
+ GtkButton *change_btn = GTK_BUTTON(
+ gtk_button_new_with_label("Change hotkeys"));
+ gtk_widget_set_valign(GTK_WIDGET(change_btn), GTK_ALIGN_CENTER);
+ g_object_set_data(G_OBJECT(info_row), "change-button", change_btn);
+ adw_action_row_add_suffix(info_row, GTK_WIDGET(change_btn));
+ } else {
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(info_row),
+ "Hotkeys are managed by your compositor");
+ adw_action_row_set_subtitle(info_row,
+ "Go to system settings to change hotkeys");
+ }
+ adw_preferences_group_add(self->hotkey_group, GTK_WIDGET(info_row));
+
+ /* Hotkey labels (shown after portal succeeds) */
+ AdwActionRow *start_row = ADW_ACTION_ROW(adw_action_row_new());
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(start_row),
+ "Start/Stop recording");
+ adw_action_row_set_subtitle(start_row, "");
+ adw_preferences_group_add(self->hotkey_group, GTK_WIDGET(start_row));
+
+ AdwActionRow *pause_row = ADW_ACTION_ROW(adw_action_row_new());
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(pause_row),
+ "Pause/Unpause recording");
+ adw_action_row_set_subtitle(pause_row, "");
+ adw_preferences_group_add(self->hotkey_group, GTK_WIDGET(pause_row));
+
+ /* Initially hidden on Wayland */
+ gtk_widget_set_visible(GTK_WIDGET(self->hotkey_info_row), FALSE);
+ gtk_widget_set_visible(GTK_WIDGET(start_row), FALSE);
+ gtk_widget_set_visible(GTK_WIDGET(pause_row), FALSE);
+ }
+#endif /* HAVE_WAYLAND */
+
+#ifdef HAVE_X11
+ if (ds == GSR_DISPLAY_SERVER_X11) {
+ /* X11: interactive shortcut rows */
+ self->x11_start_stop_row = ADW_ACTION_ROW(adw_action_row_new());
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->x11_start_stop_row),
+ "Start/Stop recording");
+ gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(self->x11_start_stop_row), TRUE);
+
+ self->x11_start_stop_label = GTK_SHORTCUT_LABEL(
+ gtk_shortcut_label_new(self->x11_start_stop_accel ? self->x11_start_stop_accel : ""));
+ gtk_widget_set_valign(GTK_WIDGET(self->x11_start_stop_label), GTK_ALIGN_CENTER);
+ adw_action_row_add_suffix(self->x11_start_stop_row,
+ GTK_WIDGET(self->x11_start_stop_label));
+ GtkImage *arrow1 = GTK_IMAGE(gtk_image_new_from_icon_name("go-next-symbolic"));
+ gtk_widget_add_css_class(GTK_WIDGET(arrow1), "dim-label");
+ adw_action_row_add_suffix(self->x11_start_stop_row, GTK_WIDGET(arrow1));
+
+ g_signal_connect(self->x11_start_stop_row, "activated",
+ G_CALLBACK(on_x11_start_stop_activated), self);
+ adw_preferences_group_add(self->hotkey_group, GTK_WIDGET(self->x11_start_stop_row));
+
+ /* Pause/Unpause row */
+ self->x11_pause_row = ADW_ACTION_ROW(adw_action_row_new());
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->x11_pause_row),
+ "Pause/Unpause recording");
+ gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(self->x11_pause_row), TRUE);
+
+ self->x11_pause_label = GTK_SHORTCUT_LABEL(
+ gtk_shortcut_label_new(self->x11_pause_accel ? self->x11_pause_accel : ""));
+ gtk_widget_set_valign(GTK_WIDGET(self->x11_pause_label), GTK_ALIGN_CENTER);
+ adw_action_row_add_suffix(self->x11_pause_row,
+ GTK_WIDGET(self->x11_pause_label));
+ GtkImage *arrow2 = GTK_IMAGE(gtk_image_new_from_icon_name("go-next-symbolic"));
+ gtk_widget_add_css_class(GTK_WIDGET(arrow2), "dim-label");
+ adw_action_row_add_suffix(self->x11_pause_row, GTK_WIDGET(arrow2));
+
+ g_signal_connect(self->x11_pause_row, "activated",
+ G_CALLBACK(on_x11_pause_activated), self);
+ adw_preferences_group_add(self->hotkey_group, GTK_WIDGET(self->x11_pause_row));
+ }
+#endif /* HAVE_X11 */
+
+ (void)ds;
+
+ adw_preferences_page_add(ADW_PREFERENCES_PAGE(self), self->hotkey_group);
+}
+
+static void
+build_output_group(GsrRecordPage *self)
+{
+ self->output_group = ADW_PREFERENCES_GROUP(adw_preferences_group_new());
+ adw_preferences_group_set_title(self->output_group, "Output");
+
+ /* Save directory */
+ self->save_directory = get_default_videos_dir();
+ self->save_dir_row = ADW_ACTION_ROW(adw_action_row_new());
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->save_dir_row),
+ "Save directory");
+ adw_action_row_set_subtitle(self->save_dir_row, self->save_directory);
+ gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(self->save_dir_row), TRUE);
+
+ GtkImage *folder_icon = GTK_IMAGE(
+ gtk_image_new_from_icon_name("document-open-symbolic"));
+ adw_action_row_add_suffix(self->save_dir_row, GTK_WIDGET(folder_icon));
+
+ g_signal_connect(self->save_dir_row, "activated",
+ G_CALLBACK(on_save_dir_activated), self);
+ adw_preferences_group_add(self->output_group,
+ GTK_WIDGET(self->save_dir_row));
+
+ /* Container format */
+ self->container_row = ADW_COMBO_ROW(adw_combo_row_new());
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->container_row),
+ "Container");
+
+ GtkStringList *ct_model = gtk_string_list_new(NULL);
+ const char *containers[] = {
+ "mp4", "flv", "mkv", "mov", "ts", "m3u8", NULL
+ };
+ for (int i = 0; containers[i]; i++)
+ gtk_string_list_append(ct_model, containers[i]);
+
+ if (self->info->supported_video_codecs.vp8 ||
+ self->info->supported_video_codecs.vp9)
+ gtk_string_list_append(ct_model, "webm");
+
+ adw_combo_row_set_model(self->container_row, G_LIST_MODEL(ct_model));
+ adw_combo_row_set_selected(self->container_row, 0); /* default: mp4 */
+ adw_preferences_group_add(self->output_group,
+ GTK_WIDGET(self->container_row));
+
+ adw_preferences_page_add(ADW_PREFERENCES_PAGE(self), self->output_group);
+}
+
+static void
+build_action_group(GsrRecordPage *self)
+{
+ self->action_group = ADW_PREFERENCES_GROUP(adw_preferences_group_new());
+
+ GtkBox *box = GTK_BOX(gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 12));
+ gtk_widget_set_halign(GTK_WIDGET(box), GTK_ALIGN_CENTER);
+ gtk_widget_set_margin_top(GTK_WIDGET(box), 6);
+ gtk_widget_set_margin_bottom(GTK_WIDGET(box), 6);
+
+ self->start_button = GTK_BUTTON(
+ gtk_button_new_with_label("Start recording"));
+ gtk_widget_set_hexpand(GTK_WIDGET(self->start_button), TRUE);
+ gtk_widget_add_css_class(GTK_WIDGET(self->start_button), "suggested-action");
+ g_signal_connect(self->start_button, "clicked",
+ G_CALLBACK(on_start_recording_clicked), self);
+ gtk_box_append(box, GTK_WIDGET(self->start_button));
+
+ self->pause_button = GTK_BUTTON(
+ gtk_button_new_with_label("Pause recording"));
+ gtk_widget_set_hexpand(GTK_WIDGET(self->pause_button), TRUE);
+ gtk_widget_set_sensitive(GTK_WIDGET(self->pause_button), FALSE);
+ g_signal_connect(self->pause_button, "clicked",
+ G_CALLBACK(on_pause_recording_clicked), self);
+ gtk_box_append(box, GTK_WIDGET(self->pause_button));
+
+ adw_preferences_group_add(self->action_group, GTK_WIDGET(box));
+ adw_preferences_page_add(ADW_PREFERENCES_PAGE(self), self->action_group);
+}
+
+static void
+build_status_group(GsrRecordPage *self)
+{
+ self->status_group = ADW_PREFERENCES_GROUP(adw_preferences_group_new());
+
+ self->status_box = GTK_BOX(gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8));
+ gtk_widget_set_halign(GTK_WIDGET(self->status_box), GTK_ALIGN_CENTER);
+ gtk_widget_set_opacity(GTK_WIDGET(self->status_box), 0.5);
+
+ self->record_icon = GTK_IMAGE(
+ gtk_image_new_from_icon_name("media-record-symbolic"));
+ gtk_box_append(self->status_box, GTK_WIDGET(self->record_icon));
+
+ self->timer_label = GTK_LABEL(gtk_label_new("00:00:00"));
+ gtk_box_append(self->status_box, GTK_WIDGET(self->timer_label));
+
+ adw_preferences_group_add(self->status_group, GTK_WIDGET(self->status_box));
+ adw_preferences_page_add(ADW_PREFERENCES_PAGE(self), self->status_group);
+}
+
+/* ── GObject lifecycle ───────────────────────────────────────────── */
+
+static void
+gsr_record_page_finalize(GObject *object)
+{
+ GsrRecordPage *self = GSR_RECORD_PAGE(object);
+ g_free(self->save_directory);
+#ifdef HAVE_X11
+ g_free(self->x11_start_stop_accel);
+ g_free(self->x11_pause_accel);
+#endif
+ G_OBJECT_CLASS(gsr_record_page_parent_class)->finalize(object);
+}
+
+static void
+gsr_record_page_init(GsrRecordPage *self)
+{
+ (void)self;
+}
+
+static void
+gsr_record_page_class_init(GsrRecordPageClass *klass)
+{
+ GObjectClass *obj_class = G_OBJECT_CLASS(klass);
+ obj_class->finalize = gsr_record_page_finalize;
+}
+
+/* ── Config apply/read ───────────────────────────────────────────── */
+
+/* Helper: select a combo row item by matching string from model */
+static void
+combo_row_select_string(AdwComboRow *row, const char *value)
+{
+ if (!value || !value[0]) return;
+ GListModel *model = adw_combo_row_get_model(row);
+ guint n = g_list_model_get_n_items(model);
+ for (guint i = 0; i < n; i++) {
+ GtkStringObject *obj = GTK_STRING_OBJECT(g_list_model_get_item(model, i));
+ const char *str = gtk_string_object_get_string(obj);
+ g_object_unref(obj);
+ if (g_str_equal(str, value)) {
+ adw_combo_row_set_selected(row, i);
+ return;
+ }
+ }
+}
+
+static const char *
+combo_row_get_selected_string(AdwComboRow *row)
+{
+ GtkStringObject *obj = GTK_STRING_OBJECT(
+ adw_combo_row_get_selected_item(row));
+ return obj ? gtk_string_object_get_string(obj) : "";
+}
+
+/* Map config container ID to display name */
+static const char *
+container_id_to_display(const char *id)
+{
+ if (!id) return "mp4";
+ if (g_str_equal(id, "matroska")) return "mkv";
+ if (g_str_equal(id, "mpegts")) return "ts";
+ if (g_str_equal(id, "hls")) return "m3u8";
+ return id; /* mp4, flv, mov, webm pass through */
+}
+
+static const char *
+container_display_to_id(const char *display)
+{
+ if (!display) return "mp4";
+ if (g_str_equal(display, "mkv")) return "matroska";
+ if (g_str_equal(display, "ts")) return "mpegts";
+ if (g_str_equal(display, "m3u8")) return "hls";
+ return display; /* mp4, flv, mov, webm pass through */
+}
+
+void
+gsr_record_page_apply_config(GsrRecordPage *self, const GsrConfig *config)
+{
+ const GsrRecordConfig *r = &config->record_config;
+
+ /* Save directory */
+ if (r->save_directory && r->save_directory[0]) {
+ g_free(self->save_directory);
+ self->save_directory = g_strdup(r->save_directory);
+ adw_action_row_set_subtitle(self->save_dir_row, self->save_directory);
+ }
+
+ /* Container */
+ combo_row_select_string(self->container_row,
+ container_id_to_display(r->container));
+
+ /* Hotkeys (X11 only) */
+#ifdef HAVE_X11
+ if (self->x11_start_stop_label) {
+ g_free(self->x11_start_stop_accel);
+ self->x11_start_stop_accel = gsr_config_hotkey_to_accel(&r->start_stop_hotkey);
+ gtk_shortcut_label_set_accelerator(self->x11_start_stop_label,
+ self->x11_start_stop_accel ? self->x11_start_stop_accel : "");
+ }
+ if (self->x11_pause_label) {
+ g_free(self->x11_pause_accel);
+ self->x11_pause_accel = gsr_config_hotkey_to_accel(&r->pause_unpause_hotkey);
+ gtk_shortcut_label_set_accelerator(self->x11_pause_label,
+ self->x11_pause_accel ? self->x11_pause_accel : "");
+ }
+#endif
+}
+
+void
+gsr_record_page_read_config(GsrRecordPage *self, GsrConfig *config)
+{
+ GsrRecordConfig *r = &config->record_config;
+
+ /* Save directory */
+ g_free(r->save_directory);
+ r->save_directory = g_strdup(self->save_directory ? self->save_directory : "");
+
+ /* Container */
+ g_free(r->container);
+ r->container = g_strdup(container_display_to_id(
+ combo_row_get_selected_string(self->container_row)));
+
+ /* Hotkeys */
+#ifdef HAVE_X11
+ gsr_config_hotkey_from_accel(&r->start_stop_hotkey, self->x11_start_stop_accel);
+ gsr_config_hotkey_from_accel(&r->pause_unpause_hotkey, self->x11_pause_accel);
+#endif
+}
+
+/* ── Process management API ──────────────────────────────────────── */
+
+void
+gsr_record_page_set_active(GsrRecordPage *self, gboolean active)
+{
+ if (active) {
+ gtk_button_set_label(self->start_button, "Stop recording");
+ gtk_widget_remove_css_class(GTK_WIDGET(self->start_button), "suggested-action");
+ gtk_widget_add_css_class(GTK_WIDGET(self->start_button), "destructive-action");
+ gtk_widget_set_sensitive(GTK_WIDGET(self->pause_button), TRUE);
+ gtk_widget_set_opacity(GTK_WIDGET(self->status_box), 1.0);
+ gtk_widget_add_css_class(GTK_WIDGET(self->record_icon), "recording-active");
+ gtk_widget_remove_css_class(GTK_WIDGET(self->record_icon), "recording-paused");
+ } else {
+ gtk_button_set_label(self->start_button, "Start recording");
+ gtk_widget_remove_css_class(GTK_WIDGET(self->start_button), "destructive-action");
+ gtk_widget_add_css_class(GTK_WIDGET(self->start_button), "suggested-action");
+ gtk_widget_set_sensitive(GTK_WIDGET(self->pause_button), FALSE);
+ gtk_button_set_label(self->pause_button, "Pause recording");
+ gtk_widget_set_opacity(GTK_WIDGET(self->status_box), 0.5);
+ gtk_label_set_text(self->timer_label, "00:00:00");
+ gtk_image_set_from_icon_name(self->record_icon, "media-record-symbolic");
+ gtk_widget_remove_css_class(GTK_WIDGET(self->record_icon), "recording-active");
+ gtk_widget_remove_css_class(GTK_WIDGET(self->record_icon), "recording-paused");
+
+ /* Reset internal state (handles external stop via handle_child_death) */
+ self->is_active = FALSE;
+ self->is_paused = FALSE;
+ if (self->timer_source_id) {
+ g_source_remove(self->timer_source_id);
+ self->timer_source_id = 0;
+ }
+ }
+}
+
+void
+gsr_record_page_set_paused(GsrRecordPage *self, gboolean paused)
+{
+ if (paused) {
+ gtk_button_set_label(self->pause_button, "Unpause recording");
+ gtk_image_set_from_icon_name(self->record_icon, "media-playback-pause-symbolic");
+ gtk_widget_remove_css_class(GTK_WIDGET(self->record_icon), "recording-active");
+ gtk_widget_add_css_class(GTK_WIDGET(self->record_icon), "recording-paused");
+ } else {
+ gtk_button_set_label(self->pause_button, "Pause recording");
+ gtk_image_set_from_icon_name(self->record_icon, "media-record-symbolic");
+ gtk_widget_remove_css_class(GTK_WIDGET(self->record_icon), "recording-paused");
+ gtk_widget_add_css_class(GTK_WIDGET(self->record_icon), "recording-active");
+ }
+}
+
+void
+gsr_record_page_update_timer(GsrRecordPage *self, const char *text)
+{
+ gtk_label_set_text(self->timer_label, text);
+}
+
+const char *
+gsr_record_page_get_save_dir(GsrRecordPage *self)
+{
+ return self->save_directory;
+}
+
+char *
+gsr_record_page_get_container(GsrRecordPage *self)
+{
+ return g_strdup(container_display_to_id(
+ combo_row_get_selected_string(self->container_row)));
+}
+
+/* ── Public API ──────────────────────────────────────────────────── */
+
+GsrRecordPage *
+gsr_record_page_new(const GsrInfo *info)
+{
+ GsrRecordPage *self = g_object_new(GSR_TYPE_RECORD_PAGE, NULL);
+ self->info = info;
+
+ adw_preferences_page_set_title(ADW_PREFERENCES_PAGE(self), "Record");
+ adw_preferences_page_set_icon_name(ADW_PREFERENCES_PAGE(self),
+ "media-record-symbolic");
+
+ build_hotkey_group(self);
+ build_output_group(self);
+ build_action_group(self);
+ build_status_group(self);
+
+ return self;
+}
+
+void
+gsr_record_page_activate_start_stop(GsrRecordPage *self)
+{
+ g_return_if_fail(GSR_IS_RECORD_PAGE(self));
+ gtk_widget_activate(GTK_WIDGET(self->start_button));
+}
+
+void
+gsr_record_page_activate_pause(GsrRecordPage *self)
+{
+ g_return_if_fail(GSR_IS_RECORD_PAGE(self));
+ if (!self->is_active) return;
+ gtk_widget_activate(GTK_WIDGET(self->pause_button));
+}
+
+#ifdef HAVE_WAYLAND
+void
+gsr_record_page_set_wayland_hotkeys_supported(GsrRecordPage *self,
+ gboolean supported)
+{
+ g_return_if_fail(GSR_IS_RECORD_PAGE(self));
+
+ if (self->hotkey_not_supported_label)
+ gtk_widget_set_visible(self->hotkey_not_supported_label, !supported);
+ if (self->hotkey_info_row)
+ gtk_widget_set_visible(self->hotkey_info_row, supported);
+}
+#endif /* HAVE_WAYLAND */
diff --git a/src/gsr-record-page.h b/src/gsr-record-page.h
new file mode 100644
index 0000000..94acaf4
--- /dev/null
+++ b/src/gsr-record-page.h
@@ -0,0 +1,44 @@
+#pragma once
+
+#include <adwaita.h>
+#include "gsr-info.h"
+#include "gsr-config.h"
+
+G_BEGIN_DECLS
+
+#define GSR_TYPE_RECORD_PAGE (gsr_record_page_get_type())
+G_DECLARE_FINAL_TYPE(GsrRecordPage, gsr_record_page, GSR, RECORD_PAGE, AdwPreferencesPage)
+
+GsrRecordPage *gsr_record_page_new (const GsrInfo *info);
+void gsr_record_page_apply_config (GsrRecordPage *self,
+ const GsrConfig *config);
+void gsr_record_page_read_config (GsrRecordPage *self,
+ GsrConfig *config);
+
+/* Process management API (Phase 5) */
+void gsr_record_page_set_active (GsrRecordPage *self,
+ gboolean active);
+void gsr_record_page_set_paused (GsrRecordPage *self,
+ gboolean paused);
+void gsr_record_page_update_timer (GsrRecordPage *self,
+ const char *text);
+
+/* Get the save directory. Borrowed pointer, do NOT free. */
+const char *gsr_record_page_get_save_dir (GsrRecordPage *self);
+
+/* Get the container ID for -c argument. Caller must g_free(). */
+char *gsr_record_page_get_container (GsrRecordPage *self);
+
+/* Hotkey: programmatically toggle start/stop. */
+void gsr_record_page_activate_start_stop(GsrRecordPage *self);
+
+/* Hotkey: programmatically toggle pause/unpause. */
+void gsr_record_page_activate_pause(GsrRecordPage *self);
+
+/* Wayland: show/hide hotkey-not-supported banner. */
+#ifdef HAVE_WAYLAND
+void gsr_record_page_set_wayland_hotkeys_supported(
+ GsrRecordPage *self, gboolean supported);
+#endif
+
+G_END_DECLS
diff --git a/src/gsr-replay-page.c b/src/gsr-replay-page.c
new file mode 100644
index 0000000..67c7c9a
--- /dev/null
+++ b/src/gsr-replay-page.c
@@ -0,0 +1,726 @@
+#include "gsr-replay-page.h"
+#ifdef HAVE_X11
+#include "gsr-shortcut-accel-dialog.h"
+#endif
+#include "gsr-window.h"
+#include <signal.h>
+#include <time.h>
+
+/* ═══════════════════════════════════════════════════════════════════
+ * GsrReplayPage — "Replay" tab
+ *
+ * Groups: Output · Action · Status
+ * ═══════════════════════════════════════════════════════════════════ */
+
+struct _GsrReplayPage {
+ AdwPreferencesPage parent_instance;
+
+ const GsrInfo *info; /* borrowed */
+
+ /* ── Hotkey group ─── */
+ AdwPreferencesGroup *hotkey_group;
+#ifdef HAVE_WAYLAND
+ GtkWidget *hotkey_not_supported_label; /* Wayland only */
+ GtkWidget *hotkey_info_row; /* Wayland only */
+#endif
+
+#ifdef HAVE_X11
+ /* X11 interactive hotkey rows */
+ AdwActionRow *x11_start_stop_row;
+ GtkShortcutLabel *x11_start_stop_label;
+ char *x11_start_stop_accel; /* owned */
+ AdwActionRow *x11_save_row;
+ GtkShortcutLabel *x11_save_label;
+ char *x11_save_accel; /* owned */
+#endif
+
+ /* ── Output group ─── */
+ AdwPreferencesGroup *output_group;
+ AdwActionRow *save_dir_row;
+ char *save_directory; /* owned */
+ AdwComboRow *container_row;
+ AdwSpinRow *replay_time_row;
+
+ /* ── Action group ─── */
+ AdwPreferencesGroup *action_group;
+ GtkButton *start_button;
+ GtkButton *save_button;
+
+ /* ── Status group ─── */
+ AdwPreferencesGroup *status_group;
+ GtkBox *status_box;
+ GtkImage *record_icon;
+ GtkLabel *timer_label;
+
+ gboolean is_active;
+ double start_time;
+ guint timer_source_id;
+};
+
+G_DEFINE_FINAL_TYPE(GsrReplayPage, gsr_replay_page, ADW_TYPE_PREFERENCES_PAGE)
+
+/* ── Helpers ─────────────────────────────────────────────────────── */
+
+static char *
+get_default_videos_dir(void)
+{
+ const char *vdir = g_get_user_special_dir(G_USER_DIRECTORY_VIDEOS);
+ if (vdir)
+ return g_strdup(vdir);
+ return g_build_filename(g_get_home_dir(), "Videos", NULL);
+}
+
+/* ── Timer helpers ───────────────────────────────────────────────── */
+
+static double
+clock_get_monotonic_seconds(void)
+{
+ struct timespec ts;
+ clock_gettime(CLOCK_MONOTONIC, &ts);
+ return (double)ts.tv_sec + (double)ts.tv_nsec * 1e-9;
+}
+
+static void
+format_timer(double seconds, char *buf, size_t buf_size)
+{
+ int total = (int)seconds;
+ int h = total / 3600;
+ int m = (total % 3600) / 60;
+ int s = total % 60;
+ snprintf(buf, buf_size, "%02d:%02d:%02d", h, m, s);
+}
+
+static gboolean
+on_timer_tick(gpointer user_data)
+{
+ GsrReplayPage *self = GSR_REPLAY_PAGE(user_data);
+ double elapsed = clock_get_monotonic_seconds() - self->start_time;
+ char buf[32];
+ format_timer(elapsed, buf, sizeof(buf));
+ gtk_label_set_text(self->timer_label, buf);
+ return G_SOURCE_CONTINUE;
+}
+
+/* ── File chooser ────────────────────────────────────────────────── */
+
+static void
+on_folder_dialog_finish(GObject *source,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ GsrReplayPage *self = GSR_REPLAY_PAGE(user_data);
+ GtkFileDialog *dialog = GTK_FILE_DIALOG(source);
+ GFile *folder = gtk_file_dialog_select_folder_finish(dialog, result, NULL);
+ if (!folder) return;
+
+ g_free(self->save_directory);
+ self->save_directory = g_file_get_path(folder);
+ adw_action_row_set_subtitle(self->save_dir_row, self->save_directory);
+ g_object_unref(folder);
+}
+
+static void
+on_save_dir_activated(AdwActionRow *row G_GNUC_UNUSED,
+ gpointer user_data)
+{
+ GsrReplayPage *self = GSR_REPLAY_PAGE(user_data);
+ GtkFileDialog *dialog = gtk_file_dialog_new();
+ gtk_file_dialog_set_title(dialog, "Select save directory");
+
+ if (self->save_directory) {
+ GFile *initial = g_file_new_for_path(self->save_directory);
+ gtk_file_dialog_set_initial_folder(dialog, initial);
+ g_object_unref(initial);
+ }
+
+ GtkWindow *win = GTK_WINDOW(gtk_widget_get_root(GTK_WIDGET(self)));
+ gtk_file_dialog_select_folder(dialog, win, NULL,
+ on_folder_dialog_finish, self);
+ g_object_unref(dialog);
+}
+
+/* ── Callbacks ───────────────────────────────────────────────────── */
+
+static void
+on_start_replay_clicked(GtkButton *btn G_GNUC_UNUSED,
+ gpointer user_data)
+{
+ GsrReplayPage *self = GSR_REPLAY_PAGE(user_data);
+
+ GtkRoot *root = gtk_widget_get_root(GTK_WIDGET(self));
+ GsrWindow *window = (root && GSR_IS_WINDOW(root)) ? GSR_WINDOW(root) : NULL;
+
+ if (self->is_active) {
+ /* ── Stop ─── */
+ if (window) {
+ gsr_window_stop_process(window, NULL);
+ gsr_window_set_recording_active(window, FALSE);
+ }
+ gsr_replay_page_set_active(self, FALSE);
+ /* set_active(FALSE) resets is_active and stops timer */
+ } else {
+ /* ── Start ─── */
+ if (!window) return;
+
+ gboolean ok = gsr_window_start_process(window, GSR_ACTIVE_MODE_REPLAY);
+ if (!ok) return; /* fork failed — toast already shown by window */
+
+ self->is_active = TRUE;
+ gsr_replay_page_set_active(self, TRUE);
+ gsr_window_set_recording_active(window, TRUE);
+
+ /* Start display timer */
+ self->start_time = clock_get_monotonic_seconds();
+ self->timer_source_id = g_timeout_add(500, on_timer_tick, self);
+ }
+}
+
+static void
+on_save_replay_clicked(GtkButton *btn G_GNUC_UNUSED,
+ gpointer user_data)
+{
+ GsrReplayPage *self = GSR_REPLAY_PAGE(user_data);
+ if (!self->is_active) return;
+
+ GtkRoot *root = gtk_widget_get_root(GTK_WIDGET(self));
+ GsrWindow *window = (root && GSR_IS_WINDOW(root)) ? GSR_WINDOW(root) : NULL;
+
+ if (window) {
+ gsr_window_send_signal(window, SIGUSR1);
+ gsr_window_notify_replay_saved(window);
+ }
+}
+
+/* ── Build groups ────────────────────────────────────────────────── */
+
+#ifdef HAVE_WAYLAND
+static gboolean
+is_kde_wayland(void)
+{
+ const char *desktop = g_getenv("XDG_CURRENT_DESKTOP");
+ return desktop && g_strstr_len(desktop, -1, "KDE") != NULL;
+}
+#endif
+
+/* ── X11 hotkey row callbacks ────────────────────────────────────── */
+
+#ifdef HAVE_X11
+static void
+on_x11_start_stop_shortcut_set(GsrShortcutAccelDialog *dialog,
+ gpointer user_data)
+{
+ GsrReplayPage *self = GSR_REPLAY_PAGE(user_data);
+ const char *accel = gsr_shortcut_accel_dialog_get_accelerator(dialog);
+
+ g_free(self->x11_start_stop_accel);
+ self->x11_start_stop_accel = g_strdup(accel);
+
+ if (self->x11_start_stop_label)
+ gtk_shortcut_label_set_accelerator(self->x11_start_stop_label,
+ accel ? accel : "");
+
+ GtkRoot *root = gtk_widget_get_root(GTK_WIDGET(self));
+ if (root && GSR_IS_WINDOW(root))
+ gsr_window_on_hotkey_changed(GSR_WINDOW(root));
+}
+
+static void
+on_x11_start_stop_activated(AdwActionRow *row G_GNUC_UNUSED,
+ gpointer user_data)
+{
+ GsrReplayPage *self = GSR_REPLAY_PAGE(user_data);
+ GsrShortcutAccelDialog *dialog = gsr_shortcut_accel_dialog_new(
+ "Start/Stop replay", self->x11_start_stop_accel);
+ g_signal_connect(dialog, "shortcut-set",
+ G_CALLBACK(on_x11_start_stop_shortcut_set), self);
+ adw_dialog_present(ADW_DIALOG(dialog), GTK_WIDGET(self));
+}
+
+static void
+on_x11_save_shortcut_set(GsrShortcutAccelDialog *dialog,
+ gpointer user_data)
+{
+ GsrReplayPage *self = GSR_REPLAY_PAGE(user_data);
+ const char *accel = gsr_shortcut_accel_dialog_get_accelerator(dialog);
+
+ g_free(self->x11_save_accel);
+ self->x11_save_accel = g_strdup(accel);
+
+ if (self->x11_save_label)
+ gtk_shortcut_label_set_accelerator(self->x11_save_label,
+ accel ? accel : "");
+
+ GtkRoot *root = gtk_widget_get_root(GTK_WIDGET(self));
+ if (root && GSR_IS_WINDOW(root))
+ gsr_window_on_hotkey_changed(GSR_WINDOW(root));
+}
+
+static void
+on_x11_save_activated(AdwActionRow *row G_GNUC_UNUSED,
+ gpointer user_data)
+{
+ GsrReplayPage *self = GSR_REPLAY_PAGE(user_data);
+ GsrShortcutAccelDialog *dialog = gsr_shortcut_accel_dialog_new(
+ "Save replay", self->x11_save_accel);
+ g_signal_connect(dialog, "shortcut-set",
+ G_CALLBACK(on_x11_save_shortcut_set), self);
+ adw_dialog_present(ADW_DIALOG(dialog), GTK_WIDGET(self));
+}
+#endif /* HAVE_X11 */
+
+static void
+build_hotkey_group(GsrReplayPage *self)
+{
+ self->hotkey_group = ADW_PREFERENCES_GROUP(adw_preferences_group_new());
+ adw_preferences_group_set_title(self->hotkey_group, "Hotkeys");
+
+ GsrDisplayServer ds = self->info->system_info.display_server;
+
+#ifdef HAVE_WAYLAND
+ if (ds == GSR_DISPLAY_SERVER_WAYLAND) {
+ /* "Not supported" label — initially hidden, shown if portal fails */
+ self->hotkey_not_supported_label = gtk_label_new(
+ "Your Wayland compositor doesn't support global hotkeys.\n"
+ "Use X11 or KDE Plasma on Wayland if you want to use hotkeys.");
+ gtk_label_set_wrap(GTK_LABEL(self->hotkey_not_supported_label), TRUE);
+ gtk_widget_add_css_class(self->hotkey_not_supported_label, "dim-label");
+ gtk_widget_set_margin_top(self->hotkey_not_supported_label, 6);
+ gtk_widget_set_margin_bottom(self->hotkey_not_supported_label, 6);
+ gtk_widget_set_visible(self->hotkey_not_supported_label, FALSE);
+ adw_preferences_group_add(self->hotkey_group,
+ self->hotkey_not_supported_label);
+
+ /* Info row about Wayland hotkeys */
+ AdwActionRow *info_row = ADW_ACTION_ROW(adw_action_row_new());
+ self->hotkey_info_row = GTK_WIDGET(info_row);
+ if (is_kde_wayland()) {
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(info_row),
+ "Hotkeys are managed by KDE Plasma");
+ adw_action_row_set_subtitle(info_row,
+ "Click to configure hotkeys in system settings");
+ GtkButton *change_btn = GTK_BUTTON(
+ gtk_button_new_with_label("Change hotkeys"));
+ gtk_widget_set_valign(GTK_WIDGET(change_btn), GTK_ALIGN_CENTER);
+ g_object_set_data(G_OBJECT(info_row), "change-button", change_btn);
+ adw_action_row_add_suffix(info_row, GTK_WIDGET(change_btn));
+ } else {
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(info_row),
+ "Hotkeys are managed by your compositor");
+ adw_action_row_set_subtitle(info_row,
+ "Go to system settings to change hotkeys");
+ }
+ adw_preferences_group_add(self->hotkey_group, GTK_WIDGET(info_row));
+
+ /* Hotkey labels (shown after portal succeeds) */
+ AdwActionRow *start_row = ADW_ACTION_ROW(adw_action_row_new());
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(start_row),
+ "Start/Stop replay");
+ adw_action_row_set_subtitle(start_row, "");
+ adw_preferences_group_add(self->hotkey_group, GTK_WIDGET(start_row));
+
+ AdwActionRow *save_row = ADW_ACTION_ROW(adw_action_row_new());
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(save_row),
+ "Save replay");
+ adw_action_row_set_subtitle(save_row, "");
+ adw_preferences_group_add(self->hotkey_group, GTK_WIDGET(save_row));
+
+ /* Initially hidden on Wayland */
+ gtk_widget_set_visible(GTK_WIDGET(self->hotkey_info_row), FALSE);
+ gtk_widget_set_visible(GTK_WIDGET(start_row), FALSE);
+ gtk_widget_set_visible(GTK_WIDGET(save_row), FALSE);
+ }
+#endif /* HAVE_WAYLAND */
+
+#ifdef HAVE_X11
+ if (ds == GSR_DISPLAY_SERVER_X11) {
+ /* X11: interactive shortcut rows */
+ self->x11_start_stop_row = ADW_ACTION_ROW(adw_action_row_new());
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->x11_start_stop_row),
+ "Start/Stop replay");
+ gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(self->x11_start_stop_row), TRUE);
+
+ self->x11_start_stop_label = GTK_SHORTCUT_LABEL(
+ gtk_shortcut_label_new(self->x11_start_stop_accel ? self->x11_start_stop_accel : ""));
+ gtk_widget_set_valign(GTK_WIDGET(self->x11_start_stop_label), GTK_ALIGN_CENTER);
+ adw_action_row_add_suffix(self->x11_start_stop_row,
+ GTK_WIDGET(self->x11_start_stop_label));
+ GtkImage *arrow1 = GTK_IMAGE(gtk_image_new_from_icon_name("go-next-symbolic"));
+ gtk_widget_add_css_class(GTK_WIDGET(arrow1), "dim-label");
+ adw_action_row_add_suffix(self->x11_start_stop_row, GTK_WIDGET(arrow1));
+
+ g_signal_connect(self->x11_start_stop_row, "activated",
+ G_CALLBACK(on_x11_start_stop_activated), self);
+ adw_preferences_group_add(self->hotkey_group, GTK_WIDGET(self->x11_start_stop_row));
+
+ /* Save replay row */
+ self->x11_save_row = ADW_ACTION_ROW(adw_action_row_new());
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->x11_save_row),
+ "Save replay");
+ gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(self->x11_save_row), TRUE);
+
+ self->x11_save_label = GTK_SHORTCUT_LABEL(
+ gtk_shortcut_label_new(self->x11_save_accel ? self->x11_save_accel : ""));
+ gtk_widget_set_valign(GTK_WIDGET(self->x11_save_label), GTK_ALIGN_CENTER);
+ adw_action_row_add_suffix(self->x11_save_row,
+ GTK_WIDGET(self->x11_save_label));
+ GtkImage *arrow2 = GTK_IMAGE(gtk_image_new_from_icon_name("go-next-symbolic"));
+ gtk_widget_add_css_class(GTK_WIDGET(arrow2), "dim-label");
+ adw_action_row_add_suffix(self->x11_save_row, GTK_WIDGET(arrow2));
+
+ g_signal_connect(self->x11_save_row, "activated",
+ G_CALLBACK(on_x11_save_activated), self);
+ adw_preferences_group_add(self->hotkey_group, GTK_WIDGET(self->x11_save_row));
+ }
+#endif /* HAVE_X11 */
+
+ (void)ds;
+
+ adw_preferences_page_add(ADW_PREFERENCES_PAGE(self), self->hotkey_group);
+}
+
+static void
+build_output_group(GsrReplayPage *self)
+{
+ self->output_group = ADW_PREFERENCES_GROUP(adw_preferences_group_new());
+ adw_preferences_group_set_title(self->output_group, "Output");
+
+ /* Save directory */
+ self->save_directory = get_default_videos_dir();
+ self->save_dir_row = ADW_ACTION_ROW(adw_action_row_new());
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->save_dir_row),
+ "Save directory");
+ adw_action_row_set_subtitle(self->save_dir_row, self->save_directory);
+ gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(self->save_dir_row), TRUE);
+
+ GtkImage *folder_icon = GTK_IMAGE(
+ gtk_image_new_from_icon_name("document-open-symbolic"));
+ adw_action_row_add_suffix(self->save_dir_row, GTK_WIDGET(folder_icon));
+
+ g_signal_connect(self->save_dir_row, "activated",
+ G_CALLBACK(on_save_dir_activated), self);
+ adw_preferences_group_add(self->output_group,
+ GTK_WIDGET(self->save_dir_row));
+
+ /* Container format */
+ self->container_row = ADW_COMBO_ROW(adw_combo_row_new());
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->container_row),
+ "Container");
+
+ GtkStringList *ct_model = gtk_string_list_new(NULL);
+ const char *containers[] = {
+ "mp4", "flv", "mkv", "mov", "ts", "m3u8", NULL
+ };
+ for (int i = 0; containers[i]; i++)
+ gtk_string_list_append(ct_model, containers[i]);
+
+ if (self->info->supported_video_codecs.vp8 ||
+ self->info->supported_video_codecs.vp9)
+ gtk_string_list_append(ct_model, "webm");
+
+ adw_combo_row_set_model(self->container_row, G_LIST_MODEL(ct_model));
+ adw_combo_row_set_selected(self->container_row, 0); /* default: mp4 */
+ adw_preferences_group_add(self->output_group,
+ GTK_WIDGET(self->container_row));
+
+ /* Replay time */
+ self->replay_time_row = ADW_SPIN_ROW(
+ adw_spin_row_new_with_range(5, 1200, 1));
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->replay_time_row),
+ "Replay time (seconds)");
+ adw_spin_row_set_value(self->replay_time_row, 30);
+ adw_preferences_group_add(self->output_group,
+ GTK_WIDGET(self->replay_time_row));
+
+ adw_preferences_page_add(ADW_PREFERENCES_PAGE(self), self->output_group);
+}
+
+static void
+build_action_group(GsrReplayPage *self)
+{
+ self->action_group = ADW_PREFERENCES_GROUP(adw_preferences_group_new());
+
+ GtkBox *box = GTK_BOX(gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 12));
+ gtk_widget_set_halign(GTK_WIDGET(box), GTK_ALIGN_CENTER);
+ gtk_widget_set_margin_top(GTK_WIDGET(box), 6);
+ gtk_widget_set_margin_bottom(GTK_WIDGET(box), 6);
+
+ self->start_button = GTK_BUTTON(
+ gtk_button_new_with_label("Start replay"));
+ gtk_widget_set_hexpand(GTK_WIDGET(self->start_button), TRUE);
+ gtk_widget_add_css_class(GTK_WIDGET(self->start_button), "suggested-action");
+ g_signal_connect(self->start_button, "clicked",
+ G_CALLBACK(on_start_replay_clicked), self);
+ gtk_box_append(box, GTK_WIDGET(self->start_button));
+
+ self->save_button = GTK_BUTTON(
+ gtk_button_new_with_label("Save replay"));
+ gtk_widget_set_hexpand(GTK_WIDGET(self->save_button), TRUE);
+ gtk_widget_set_sensitive(GTK_WIDGET(self->save_button), FALSE);
+ g_signal_connect(self->save_button, "clicked",
+ G_CALLBACK(on_save_replay_clicked), self);
+ gtk_box_append(box, GTK_WIDGET(self->save_button));
+
+ adw_preferences_group_add(self->action_group, GTK_WIDGET(box));
+ adw_preferences_page_add(ADW_PREFERENCES_PAGE(self), self->action_group);
+}
+
+static void
+build_status_group(GsrReplayPage *self)
+{
+ self->status_group = ADW_PREFERENCES_GROUP(adw_preferences_group_new());
+
+ self->status_box = GTK_BOX(gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8));
+ gtk_widget_set_halign(GTK_WIDGET(self->status_box), GTK_ALIGN_CENTER);
+ gtk_widget_set_opacity(GTK_WIDGET(self->status_box), 0.5);
+
+ self->record_icon = GTK_IMAGE(
+ gtk_image_new_from_icon_name("media-record-symbolic"));
+ gtk_box_append(self->status_box, GTK_WIDGET(self->record_icon));
+
+ self->timer_label = GTK_LABEL(gtk_label_new("00:00:00"));
+ gtk_box_append(self->status_box, GTK_WIDGET(self->timer_label));
+
+ adw_preferences_group_add(self->status_group, GTK_WIDGET(self->status_box));
+ adw_preferences_page_add(ADW_PREFERENCES_PAGE(self), self->status_group);
+}
+
+/* ── GObject lifecycle ───────────────────────────────────────────── */
+
+static void
+gsr_replay_page_finalize(GObject *object)
+{
+ GsrReplayPage *self = GSR_REPLAY_PAGE(object);
+ g_free(self->save_directory);
+#ifdef HAVE_X11
+ g_free(self->x11_start_stop_accel);
+ g_free(self->x11_save_accel);
+#endif
+ G_OBJECT_CLASS(gsr_replay_page_parent_class)->finalize(object);
+}
+
+static void
+gsr_replay_page_init(GsrReplayPage *self)
+{
+ (void)self;
+}
+
+static void
+gsr_replay_page_class_init(GsrReplayPageClass *klass)
+{
+ GObjectClass *obj_class = G_OBJECT_CLASS(klass);
+ obj_class->finalize = gsr_replay_page_finalize;
+}
+
+/* ── Config apply/read ───────────────────────────────────────────── */
+
+/* Helper: select a combo row item by matching string from model */
+static void
+combo_row_select_string(AdwComboRow *row, const char *value)
+{
+ if (!value || !value[0]) return;
+ GListModel *model = adw_combo_row_get_model(row);
+ guint n = g_list_model_get_n_items(model);
+ for (guint i = 0; i < n; i++) {
+ GtkStringObject *obj = GTK_STRING_OBJECT(g_list_model_get_item(model, i));
+ const char *str = gtk_string_object_get_string(obj);
+ g_object_unref(obj);
+ if (g_str_equal(str, value)) {
+ adw_combo_row_set_selected(row, i);
+ return;
+ }
+ }
+}
+
+static const char *
+combo_row_get_selected_string(AdwComboRow *row)
+{
+ GtkStringObject *obj = GTK_STRING_OBJECT(
+ adw_combo_row_get_selected_item(row));
+ return obj ? gtk_string_object_get_string(obj) : "";
+}
+
+/* Map config container ID to display name */
+static const char *
+container_id_to_display(const char *id)
+{
+ if (!id) return "mp4";
+ if (g_str_equal(id, "matroska")) return "mkv";
+ if (g_str_equal(id, "mpegts")) return "ts";
+ if (g_str_equal(id, "hls")) return "m3u8";
+ return id; /* mp4, flv, mov, webm pass through */
+}
+
+static const char *
+container_display_to_id(const char *display)
+{
+ if (!display) return "mp4";
+ if (g_str_equal(display, "mkv")) return "matroska";
+ if (g_str_equal(display, "ts")) return "mpegts";
+ if (g_str_equal(display, "m3u8")) return "hls";
+ return display; /* mp4, flv, mov, webm pass through */
+}
+
+void
+gsr_replay_page_apply_config(GsrReplayPage *self, const GsrConfig *config)
+{
+ const GsrReplayConfig *rp = &config->replay_config;
+
+ /* Save directory */
+ if (rp->save_directory && rp->save_directory[0]) {
+ g_free(self->save_directory);
+ self->save_directory = g_strdup(rp->save_directory);
+ adw_action_row_set_subtitle(self->save_dir_row, self->save_directory);
+ }
+
+ /* Container */
+ combo_row_select_string(self->container_row,
+ container_id_to_display(rp->container));
+
+ /* Replay time */
+ if (rp->replay_time > 0)
+ adw_spin_row_set_value(self->replay_time_row, rp->replay_time);
+
+ /* Hotkeys (X11 only) */
+#ifdef HAVE_X11
+ if (self->x11_start_stop_label) {
+ g_free(self->x11_start_stop_accel);
+ self->x11_start_stop_accel = gsr_config_hotkey_to_accel(&rp->start_stop_hotkey);
+ gtk_shortcut_label_set_accelerator(self->x11_start_stop_label,
+ self->x11_start_stop_accel ? self->x11_start_stop_accel : "");
+ }
+ if (self->x11_save_label) {
+ g_free(self->x11_save_accel);
+ self->x11_save_accel = gsr_config_hotkey_to_accel(&rp->save_hotkey);
+ gtk_shortcut_label_set_accelerator(self->x11_save_label,
+ self->x11_save_accel ? self->x11_save_accel : "");
+ }
+#endif
+}
+
+void
+gsr_replay_page_read_config(GsrReplayPage *self, GsrConfig *config)
+{
+ GsrReplayConfig *rp = &config->replay_config;
+
+ /* Save directory */
+ g_free(rp->save_directory);
+ rp->save_directory = g_strdup(self->save_directory ? self->save_directory : "");
+
+ /* Container */
+ g_free(rp->container);
+ rp->container = g_strdup(container_display_to_id(
+ combo_row_get_selected_string(self->container_row)));
+
+ /* Replay time */
+ rp->replay_time = (int32_t)adw_spin_row_get_value(self->replay_time_row);
+
+ /* Hotkeys */
+#ifdef HAVE_X11
+ gsr_config_hotkey_from_accel(&rp->start_stop_hotkey, self->x11_start_stop_accel);
+ gsr_config_hotkey_from_accel(&rp->save_hotkey, self->x11_save_accel);
+#endif
+}
+
+/* ── Process management API ──────────────────────────────────────── */
+
+void
+gsr_replay_page_set_active(GsrReplayPage *self, gboolean active)
+{
+ if (active) {
+ gtk_button_set_label(self->start_button, "Stop replay");
+ gtk_widget_remove_css_class(GTK_WIDGET(self->start_button), "suggested-action");
+ gtk_widget_add_css_class(GTK_WIDGET(self->start_button), "destructive-action");
+ gtk_widget_set_sensitive(GTK_WIDGET(self->save_button), TRUE);
+ gtk_widget_set_opacity(GTK_WIDGET(self->status_box), 1.0);
+ gtk_widget_add_css_class(GTK_WIDGET(self->record_icon), "recording-active");
+ } else {
+ gtk_button_set_label(self->start_button, "Start replay");
+ gtk_widget_remove_css_class(GTK_WIDGET(self->start_button), "destructive-action");
+ gtk_widget_add_css_class(GTK_WIDGET(self->start_button), "suggested-action");
+ gtk_widget_set_sensitive(GTK_WIDGET(self->save_button), FALSE);
+ gtk_widget_set_opacity(GTK_WIDGET(self->status_box), 0.5);
+ gtk_label_set_text(self->timer_label, "00:00:00");
+ gtk_widget_remove_css_class(GTK_WIDGET(self->record_icon), "recording-active");
+
+ /* Reset internal state (handles external stop via handle_child_death) */
+ self->is_active = FALSE;
+ if (self->timer_source_id) {
+ g_source_remove(self->timer_source_id);
+ self->timer_source_id = 0;
+ }
+ }
+}
+
+void
+gsr_replay_page_update_timer(GsrReplayPage *self, const char *text)
+{
+ gtk_label_set_text(self->timer_label, text);
+}
+
+const char *
+gsr_replay_page_get_save_dir(GsrReplayPage *self)
+{
+ return self->save_directory;
+}
+
+char *
+gsr_replay_page_get_container(GsrReplayPage *self)
+{
+ return g_strdup(container_display_to_id(
+ combo_row_get_selected_string(self->container_row)));
+}
+
+int
+gsr_replay_page_get_time(GsrReplayPage *self)
+{
+ return (int)adw_spin_row_get_value(self->replay_time_row);
+}
+
+/* ── Public API ──────────────────────────────────────────────────── */
+
+GsrReplayPage *
+gsr_replay_page_new(const GsrInfo *info)
+{
+ GsrReplayPage *self = g_object_new(GSR_TYPE_REPLAY_PAGE, NULL);
+ self->info = info;
+
+ adw_preferences_page_set_title(ADW_PREFERENCES_PAGE(self), "Replay");
+ adw_preferences_page_set_icon_name(ADW_PREFERENCES_PAGE(self),
+ "media-playlist-repeat-symbolic");
+
+ build_hotkey_group(self);
+ build_output_group(self);
+ build_action_group(self);
+ build_status_group(self);
+
+ return self;
+}
+
+void
+gsr_replay_page_activate_start_stop(GsrReplayPage *self)
+{
+ g_return_if_fail(GSR_IS_REPLAY_PAGE(self));
+ gtk_widget_activate(GTK_WIDGET(self->start_button));
+}
+
+void
+gsr_replay_page_activate_save(GsrReplayPage *self)
+{
+ g_return_if_fail(GSR_IS_REPLAY_PAGE(self));
+ if (!self->is_active) return;
+ gtk_widget_activate(GTK_WIDGET(self->save_button));
+}
+
+#ifdef HAVE_WAYLAND
+void
+gsr_replay_page_set_wayland_hotkeys_supported(GsrReplayPage *self,
+ gboolean supported)
+{
+ g_return_if_fail(GSR_IS_REPLAY_PAGE(self));
+
+ if (self->hotkey_not_supported_label)
+ gtk_widget_set_visible(self->hotkey_not_supported_label, !supported);
+ if (self->hotkey_info_row)
+ gtk_widget_set_visible(self->hotkey_info_row, supported);
+}
+#endif /* HAVE_WAYLAND */
diff --git a/src/gsr-replay-page.h b/src/gsr-replay-page.h
new file mode 100644
index 0000000..697cba2
--- /dev/null
+++ b/src/gsr-replay-page.h
@@ -0,0 +1,45 @@
+#pragma once
+
+#include <adwaita.h>
+#include "gsr-info.h"
+#include "gsr-config.h"
+
+G_BEGIN_DECLS
+
+#define GSR_TYPE_REPLAY_PAGE (gsr_replay_page_get_type())
+G_DECLARE_FINAL_TYPE(GsrReplayPage, gsr_replay_page, GSR, REPLAY_PAGE, AdwPreferencesPage)
+
+GsrReplayPage *gsr_replay_page_new (const GsrInfo *info);
+void gsr_replay_page_apply_config (GsrReplayPage *self,
+ const GsrConfig *config);
+void gsr_replay_page_read_config (GsrReplayPage *self,
+ GsrConfig *config);
+
+/* Process management API (Phase 5) */
+void gsr_replay_page_set_active (GsrReplayPage *self,
+ gboolean active);
+void gsr_replay_page_update_timer (GsrReplayPage *self,
+ const char *text);
+
+/* Get the save directory. Borrowed pointer, do NOT free. */
+const char *gsr_replay_page_get_save_dir (GsrReplayPage *self);
+
+/* Get the container ID for -c argument. Caller must g_free(). */
+char *gsr_replay_page_get_container (GsrReplayPage *self);
+
+/* Get the replay time in seconds. */
+int gsr_replay_page_get_time (GsrReplayPage *self);
+
+/* Hotkey: programmatically toggle start/stop. */
+void gsr_replay_page_activate_start_stop(GsrReplayPage *self);
+
+/* Hotkey: programmatically save replay. */
+void gsr_replay_page_activate_save(GsrReplayPage *self);
+
+/* Wayland: show/hide hotkey-not-supported banner. */
+#ifdef HAVE_WAYLAND
+void gsr_replay_page_set_wayland_hotkeys_supported(
+ GsrReplayPage *self, gboolean supported);
+#endif
+
+G_END_DECLS
diff --git a/src/gsr-shortcut-accel-dialog.c b/src/gsr-shortcut-accel-dialog.c
new file mode 100644
index 0000000..bf5b0e9
--- /dev/null
+++ b/src/gsr-shortcut-accel-dialog.c
@@ -0,0 +1,372 @@
+#include "gsr-shortcut-accel-dialog.h"
+
+#include <gdk/gdk.h>
+
+/* ═══════════════════════════════════════════════════════════════════
+ * GsrShortcutAccelDialog — Ptyxis-style key capture dialog
+ *
+ * Opens in "editing" (capture) mode. A GtkEventControllerKey in
+ * capture phase intercepts all key events.
+ *
+ * - Escape (no modifiers) → cancel / close
+ * - Backspace (no modifiers) → clear shortcut, emit "shortcut-set"
+ * - Valid key combo → store, switch to display mode, user clicks "Set"
+ *
+ * The dialog emits "shortcut-set" with the accelerator string (or NULL
+ * if cleared).
+ * ═══════════════════════════════════════════════════════════════════ */
+
+struct _GsrShortcutAccelDialog {
+ AdwDialog parent_instance;
+
+ /* Title of the shortcut being edited (e.g. "Start/Stop streaming") */
+ char *shortcut_title;
+
+ /* Current accelerator from config (for Reset) */
+ char *initial_accel;
+
+ /* Captured accelerator */
+ char *accelerator;
+ guint keyval;
+ GdkModifierType modifier;
+
+ /* State */
+ gboolean editing; /* TRUE = waiting for key press */
+
+ /* Widgets */
+ GtkStack *stack; /* "capture" / "display" pages */
+ GtkLabel *capture_label;
+ GtkLabel *capture_hint;
+ GtkShortcutLabel *display_label;
+ GtkButton *set_button;
+ GtkButton *cancel_button;
+};
+
+G_DEFINE_FINAL_TYPE(GsrShortcutAccelDialog, gsr_shortcut_accel_dialog, ADW_TYPE_DIALOG)
+
+/* ── Signals ─────────────────────────────────────────────────────── */
+
+enum {
+ SIGNAL_SHORTCUT_SET,
+ N_SIGNALS
+};
+
+static guint signals[N_SIGNALS];
+
+/* ── Helpers ─────────────────────────────────────────────────────── */
+
+static void
+update_display(GsrShortcutAccelDialog *self)
+{
+ if (self->editing) {
+ gtk_stack_set_visible_child_name(self->stack, "capture");
+ gtk_widget_set_sensitive(GTK_WIDGET(self->set_button), FALSE);
+ } else {
+ if (self->accelerator) {
+ gtk_shortcut_label_set_accelerator(self->display_label,
+ self->accelerator);
+ } else {
+ gtk_shortcut_label_set_accelerator(self->display_label, "");
+ }
+ gtk_stack_set_visible_child_name(self->stack, "display");
+ gtk_widget_set_sensitive(GTK_WIDGET(self->set_button), TRUE);
+ }
+}
+
+static void
+set_accelerator(GsrShortcutAccelDialog *self, const char *accel)
+{
+ g_free(self->accelerator);
+ self->accelerator = g_strdup(accel);
+}
+
+/**
+ * Sanitize modifier mask: only keep standard modifier bits.
+ */
+static GdkModifierType
+sanitize_modifier_mask(GdkModifierType state)
+{
+ return state & gtk_accelerator_get_default_mod_mask();
+}
+
+/**
+ * Check if shift should be dropped (the key is the same with or without shift).
+ */
+static gboolean
+should_drop_shift(guint keyval_lower, guint keyval)
+{
+ /* If lowering the key didn't change it, shift isn't meaningful for this key */
+ return keyval_lower == keyval;
+}
+
+/* ── Key event handlers ──────────────────────────────────────────── */
+
+static gboolean
+on_key_pressed(GtkEventControllerKey *controller,
+ guint keyval,
+ guint keycode G_GNUC_UNUSED,
+ GdkModifierType state,
+ gpointer user_data)
+{
+ GsrShortcutAccelDialog *self = GSR_SHORTCUT_ACCEL_DIALOG(user_data);
+
+ if (!self->editing)
+ return GDK_EVENT_PROPAGATE;
+
+ GdkEvent *event = gtk_event_controller_get_current_event(
+ GTK_EVENT_CONTROLLER(controller));
+
+ /* Ignore pure modifier key presses */
+ if (gdk_key_event_is_modifier(event))
+ return GDK_EVENT_PROPAGATE;
+
+ GdkModifierType real_mask = sanitize_modifier_mask(state);
+ guint keyval_lower = gdk_keyval_to_lower(keyval);
+
+ /* Normalize ISO_Left_Tab → Tab */
+ if (keyval_lower == GDK_KEY_ISO_Left_Tab)
+ keyval_lower = GDK_KEY_Tab;
+
+ /* Put shift back if it changed the case of the key */
+ if (keyval_lower != keyval)
+ real_mask |= GDK_SHIFT_MASK;
+
+ /* Avoid SysRq translation (keep Alt+Print) */
+ if (keyval_lower == GDK_KEY_Sys_Req && (real_mask & GDK_ALT_MASK) != 0)
+ keyval_lower = GDK_KEY_Print;
+
+ /* Escape with no modifiers = cancel */
+ if (real_mask == 0 && keyval_lower == GDK_KEY_Escape) {
+ adw_dialog_close(ADW_DIALOG(self));
+ return GDK_EVENT_STOP;
+ }
+
+ /* Backspace with no modifiers = clear shortcut */
+ if (real_mask == 0 && keyval_lower == GDK_KEY_BackSpace) {
+ set_accelerator(self, NULL);
+ self->keyval = 0;
+ self->modifier = 0;
+ g_signal_emit(self, signals[SIGNAL_SHORTCUT_SET], 0);
+ adw_dialog_close(ADW_DIALOG(self));
+ return GDK_EVENT_STOP;
+ }
+
+ /* Store the captured shortcut */
+ self->keyval = gdk_keyval_to_lower(keyval);
+ self->modifier = sanitize_modifier_mask(state);
+
+ /* Handle shift normalization */
+ if ((state & GDK_SHIFT_MASK) != 0 && should_drop_shift(self->keyval, keyval))
+ self->modifier &= ~GDK_SHIFT_MASK;
+ if ((state & GDK_LOCK_MASK) == 0 && self->keyval != keyval)
+ self->modifier |= GDK_SHIFT_MASK;
+
+ /* Normalize Ctrl+ISO_Left_Tab → Ctrl+Shift+Tab */
+ if (self->keyval == GDK_KEY_ISO_Left_Tab &&
+ self->modifier == GDK_CONTROL_MASK)
+ {
+ self->keyval = GDK_KEY_Tab;
+ self->modifier = GDK_CONTROL_MASK | GDK_SHIFT_MASK;
+ }
+
+ /* Build accelerator string */
+ g_free(self->accelerator);
+ self->accelerator = gtk_accelerator_name(self->keyval, self->modifier);
+
+ /* Switch to display mode */
+ self->editing = FALSE;
+ update_display(self);
+ gtk_widget_grab_focus(GTK_WIDGET(self->set_button));
+
+ return GDK_EVENT_STOP;
+}
+
+/* ── Button callbacks ────────────────────────────────────────────── */
+
+static void
+on_set_clicked(GtkButton *btn G_GNUC_UNUSED, gpointer user_data)
+{
+ GsrShortcutAccelDialog *self = GSR_SHORTCUT_ACCEL_DIALOG(user_data);
+ g_signal_emit(self, signals[SIGNAL_SHORTCUT_SET], 0);
+ adw_dialog_close(ADW_DIALOG(self));
+}
+
+static void
+on_cancel_clicked(GtkButton *btn G_GNUC_UNUSED, gpointer user_data)
+{
+ GsrShortcutAccelDialog *self = GSR_SHORTCUT_ACCEL_DIALOG(user_data);
+ adw_dialog_close(ADW_DIALOG(self));
+}
+
+/* ── GObject lifecycle ───────────────────────────────────────────── */
+
+static void
+gsr_shortcut_accel_dialog_finalize(GObject *object)
+{
+ GsrShortcutAccelDialog *self = GSR_SHORTCUT_ACCEL_DIALOG(object);
+ g_free(self->shortcut_title);
+ g_free(self->initial_accel);
+ g_free(self->accelerator);
+ G_OBJECT_CLASS(gsr_shortcut_accel_dialog_parent_class)->finalize(object);
+}
+
+static void
+gsr_shortcut_accel_dialog_init(GsrShortcutAccelDialog *self)
+{
+ (void)self;
+}
+
+static void
+gsr_shortcut_accel_dialog_class_init(GsrShortcutAccelDialogClass *klass)
+{
+ GObjectClass *obj_class = G_OBJECT_CLASS(klass);
+ obj_class->finalize = gsr_shortcut_accel_dialog_finalize;
+
+ /**
+ * GsrShortcutAccelDialog::shortcut-set:
+ *
+ * Emitted when the user confirms a new shortcut (Set button)
+ * or clears it (Backspace). Call get_accelerator() to retrieve
+ * the result.
+ */
+ signals[SIGNAL_SHORTCUT_SET] = g_signal_new(
+ "shortcut-set",
+ G_TYPE_FROM_CLASS(klass),
+ G_SIGNAL_RUN_LAST,
+ 0, NULL, NULL, NULL,
+ G_TYPE_NONE, 0);
+}
+
+/* ── Build UI ────────────────────────────────────────────────────── */
+
+static void
+build_dialog_ui(GsrShortcutAccelDialog *self)
+{
+ adw_dialog_set_title(ADW_DIALOG(self), "Set Shortcut");
+ adw_dialog_set_content_width(ADW_DIALOG(self), 400);
+ adw_dialog_set_content_height(ADW_DIALOG(self), 260);
+
+ /* ── Header bar ─── */
+ AdwHeaderBar *header = ADW_HEADER_BAR(adw_header_bar_new());
+
+ self->cancel_button = GTK_BUTTON(gtk_button_new_with_label("Cancel"));
+ g_signal_connect(self->cancel_button, "clicked",
+ G_CALLBACK(on_cancel_clicked), self);
+ adw_header_bar_pack_start(header, GTK_WIDGET(self->cancel_button));
+
+ self->set_button = GTK_BUTTON(gtk_button_new_with_label("Set"));
+ gtk_widget_add_css_class(GTK_WIDGET(self->set_button), "suggested-action");
+ gtk_widget_set_sensitive(GTK_WIDGET(self->set_button), FALSE);
+ g_signal_connect(self->set_button, "clicked",
+ G_CALLBACK(on_set_clicked), self);
+ adw_header_bar_pack_end(header, GTK_WIDGET(self->set_button));
+
+ /* ── Content stack ─── */
+ self->stack = GTK_STACK(gtk_stack_new());
+ gtk_stack_set_transition_type(self->stack, GTK_STACK_TRANSITION_TYPE_CROSSFADE);
+
+ /* -- Capture page -- */
+ GtkBox *capture_box = GTK_BOX(gtk_box_new(GTK_ORIENTATION_VERTICAL, 12));
+ gtk_widget_set_halign(GTK_WIDGET(capture_box), GTK_ALIGN_CENTER);
+ gtk_widget_set_valign(GTK_WIDGET(capture_box), GTK_ALIGN_CENTER);
+ gtk_widget_set_margin_start(GTK_WIDGET(capture_box), 24);
+ gtk_widget_set_margin_end(GTK_WIDGET(capture_box), 24);
+ gtk_widget_set_margin_top(GTK_WIDGET(capture_box), 24);
+ gtk_widget_set_margin_bottom(GTK_WIDGET(capture_box), 24);
+
+ GtkImage *keyboard_icon = GTK_IMAGE(
+ gtk_image_new_from_icon_name("input-keyboard-symbolic"));
+ gtk_image_set_pixel_size(keyboard_icon, 64);
+ gtk_widget_add_css_class(GTK_WIDGET(keyboard_icon), "dim-label");
+ gtk_box_append(capture_box, GTK_WIDGET(keyboard_icon));
+
+ char *label_text = g_strdup_printf(
+ "Enter new shortcut for\n<b>%s</b>",
+ self->shortcut_title ? self->shortcut_title : "Shortcut");
+ self->capture_label = GTK_LABEL(gtk_label_new(NULL));
+ gtk_label_set_markup(self->capture_label, label_text);
+ gtk_label_set_justify(self->capture_label, GTK_JUSTIFY_CENTER);
+ gtk_label_set_wrap(self->capture_label, TRUE);
+ g_free(label_text);
+ gtk_box_append(capture_box, GTK_WIDGET(self->capture_label));
+
+ self->capture_hint = GTK_LABEL(gtk_label_new(
+ "Press Escape to cancel or Backspace to disable the shortcut."));
+ gtk_widget_add_css_class(GTK_WIDGET(self->capture_hint), "dim-label");
+ gtk_label_set_wrap(self->capture_hint, TRUE);
+ gtk_label_set_justify(self->capture_hint, GTK_JUSTIFY_CENTER);
+ gtk_box_append(capture_box, GTK_WIDGET(self->capture_hint));
+
+ gtk_stack_add_named(self->stack, GTK_WIDGET(capture_box), "capture");
+
+ /* -- Display page -- */
+ GtkBox *display_box = GTK_BOX(gtk_box_new(GTK_ORIENTATION_VERTICAL, 12));
+ gtk_widget_set_halign(GTK_WIDGET(display_box), GTK_ALIGN_CENTER);
+ gtk_widget_set_valign(GTK_WIDGET(display_box), GTK_ALIGN_CENTER);
+ gtk_widget_set_margin_start(GTK_WIDGET(display_box), 24);
+ gtk_widget_set_margin_end(GTK_WIDGET(display_box), 24);
+ gtk_widget_set_margin_top(GTK_WIDGET(display_box), 24);
+ gtk_widget_set_margin_bottom(GTK_WIDGET(display_box), 24);
+
+ GtkLabel *display_title = GTK_LABEL(gtk_label_new(NULL));
+ char *display_text = g_strdup_printf(
+ "Shortcut for <b>%s</b>",
+ self->shortcut_title ? self->shortcut_title : "Shortcut");
+ gtk_label_set_markup(display_title, display_text);
+ gtk_label_set_justify(display_title, GTK_JUSTIFY_CENTER);
+ gtk_label_set_wrap(display_title, TRUE);
+ g_free(display_text);
+ gtk_box_append(display_box, GTK_WIDGET(display_title));
+
+ self->display_label = GTK_SHORTCUT_LABEL(gtk_shortcut_label_new(""));
+ gtk_widget_set_halign(GTK_WIDGET(self->display_label), GTK_ALIGN_CENTER);
+ gtk_box_append(display_box, GTK_WIDGET(self->display_label));
+
+ gtk_stack_add_named(self->stack, GTK_WIDGET(display_box), "display");
+
+ /* ── Key event controller (capture phase) ─── */
+ GtkEventController *key_ctrl = gtk_event_controller_key_new();
+ gtk_event_controller_set_propagation_phase(key_ctrl, GTK_PHASE_CAPTURE);
+ g_signal_connect(key_ctrl, "key-pressed",
+ G_CALLBACK(on_key_pressed), self);
+ gtk_widget_add_controller(GTK_WIDGET(self), key_ctrl);
+
+ /* ── Layout: toolbar-view ─── */
+ AdwToolbarView *toolbar_view = ADW_TOOLBAR_VIEW(adw_toolbar_view_new());
+ adw_toolbar_view_add_top_bar(toolbar_view, GTK_WIDGET(header));
+ adw_toolbar_view_set_content(toolbar_view, GTK_WIDGET(self->stack));
+
+ adw_dialog_set_child(ADW_DIALOG(self), GTK_WIDGET(toolbar_view));
+
+ /* Start in capture mode */
+ self->editing = TRUE;
+ update_display(self);
+}
+
+/* ── Public API ──────────────────────────────────────────────────── */
+
+GsrShortcutAccelDialog *
+gsr_shortcut_accel_dialog_new(const char *shortcut_title,
+ const char *current_accel)
+{
+ GsrShortcutAccelDialog *self = g_object_new(
+ GSR_TYPE_SHORTCUT_ACCEL_DIALOG, NULL);
+
+ self->shortcut_title = g_strdup(shortcut_title);
+ self->initial_accel = g_strdup(current_accel);
+ self->accelerator = g_strdup(current_accel);
+ self->editing = TRUE;
+ self->keyval = 0;
+ self->modifier = 0;
+
+ build_dialog_ui(self);
+
+ return self;
+}
+
+const char *
+gsr_shortcut_accel_dialog_get_accelerator(GsrShortcutAccelDialog *self)
+{
+ g_return_val_if_fail(GSR_IS_SHORTCUT_ACCEL_DIALOG(self), NULL);
+ return self->accelerator;
+}
diff --git a/src/gsr-shortcut-accel-dialog.h b/src/gsr-shortcut-accel-dialog.h
new file mode 100644
index 0000000..87c3bd3
--- /dev/null
+++ b/src/gsr-shortcut-accel-dialog.h
@@ -0,0 +1,35 @@
+#pragma once
+
+/*
+ * gsr-shortcut-accel-dialog.h — Key capture dialog for hotkey assignment.
+ *
+ * Ptyxis-style AdwDialog with GtkEventControllerKey in capture phase.
+ * Opens in immediate capture mode: user presses key combo, then confirms.
+ *
+ * Escape = cancel, Backspace = clear/disable shortcut.
+ */
+
+#include <adwaita.h>
+
+G_BEGIN_DECLS
+
+#define GSR_TYPE_SHORTCUT_ACCEL_DIALOG (gsr_shortcut_accel_dialog_get_type())
+G_DECLARE_FINAL_TYPE(GsrShortcutAccelDialog, gsr_shortcut_accel_dialog,
+ GSR, SHORTCUT_ACCEL_DIALOG, AdwDialog)
+
+/**
+ * Create a new shortcut accel dialog.
+ * @shortcut_title: Human-readable name like "Start/Stop streaming"
+ * @current_accel: Current accelerator string (e.g. "<Alt>1") or NULL
+ */
+GsrShortcutAccelDialog *gsr_shortcut_accel_dialog_new(const char *shortcut_title,
+ const char *current_accel);
+
+/**
+ * Get the accelerator string that was captured.
+ * Returns NULL if the shortcut was cleared (Backspace).
+ * Returns a borrowed string, valid until the dialog is destroyed.
+ */
+const char *gsr_shortcut_accel_dialog_get_accelerator(GsrShortcutAccelDialog *self);
+
+G_END_DECLS
diff --git a/src/gsr-stream-page.c b/src/gsr-stream-page.c
new file mode 100644
index 0000000..fceef37
--- /dev/null
+++ b/src/gsr-stream-page.c
@@ -0,0 +1,691 @@
+#include "gsr-stream-page.h"
+#ifdef HAVE_X11
+#include "gsr-shortcut-accel-dialog.h"
+#endif
+#include "gsr-window.h"
+#include <time.h>
+
+/* ═══════════════════════════════════════════════════════════════════
+ * GsrStreamPage — "Stream" tab
+ *
+ * Groups: Service · Action · Status
+ * ═══════════════════════════════════════════════════════════════════ */
+
+typedef enum {
+ STREAM_SERVICE_TWITCH,
+ STREAM_SERVICE_YOUTUBE,
+ STREAM_SERVICE_CUSTOM,
+} StreamService;
+
+struct _GsrStreamPage {
+ AdwPreferencesPage parent_instance;
+
+ const GsrInfo *info; /* borrowed */
+
+ /* ── Hotkey group ─── */
+ AdwPreferencesGroup *hotkey_group;
+#ifdef HAVE_WAYLAND
+ GtkWidget *hotkey_not_supported_label; /* Wayland only */
+ GtkWidget *hotkey_info_row; /* Wayland only */
+#endif
+
+#ifdef HAVE_X11
+ /* X11 interactive hotkey rows */
+ AdwActionRow *x11_start_stop_row;
+ GtkShortcutLabel *x11_start_stop_label;
+ char *x11_start_stop_accel; /* owned */
+#endif
+
+ /* ── Service group ─── */
+ AdwPreferencesGroup *service_group;
+ AdwComboRow *service_row;
+ AdwPasswordEntryRow *twitch_key_row;
+ AdwPasswordEntryRow *youtube_key_row;
+ AdwPasswordEntryRow *custom_url_row;
+ AdwComboRow *container_row;
+
+ /* ── Action group ─── */
+ AdwPreferencesGroup *action_group;
+ GtkButton *start_button;
+
+ /* ── Status group ─── */
+ AdwPreferencesGroup *status_group;
+ GtkBox *status_box;
+ GtkImage *record_icon;
+ GtkLabel *timer_label;
+
+ gboolean is_active;
+ double start_time;
+ guint timer_source_id;
+};
+
+G_DEFINE_FINAL_TYPE(GsrStreamPage, gsr_stream_page, ADW_TYPE_PREFERENCES_PAGE)
+
+/* ── Helpers ─────────────────────────────────────────────────────── */
+
+static StreamService
+get_selected_service(GsrStreamPage *self)
+{
+ guint idx = adw_combo_row_get_selected(self->service_row);
+ switch (idx) {
+ case 0: return STREAM_SERVICE_TWITCH;
+ case 1: return STREAM_SERVICE_YOUTUBE;
+ case 2: return STREAM_SERVICE_CUSTOM;
+ default: return STREAM_SERVICE_TWITCH;
+ }
+}
+
+static void
+update_service_visibility(GsrStreamPage *self)
+{
+ StreamService svc = get_selected_service(self);
+ gtk_widget_set_visible(GTK_WIDGET(self->twitch_key_row),
+ svc == STREAM_SERVICE_TWITCH);
+ gtk_widget_set_visible(GTK_WIDGET(self->youtube_key_row),
+ svc == STREAM_SERVICE_YOUTUBE);
+ gtk_widget_set_visible(GTK_WIDGET(self->custom_url_row),
+ svc == STREAM_SERVICE_CUSTOM);
+ gtk_widget_set_visible(GTK_WIDGET(self->container_row),
+ svc == STREAM_SERVICE_CUSTOM);
+}
+
+/* ── Timer helpers ───────────────────────────────────────────────── */
+
+static double
+clock_get_monotonic_seconds(void)
+{
+ struct timespec ts;
+ clock_gettime(CLOCK_MONOTONIC, &ts);
+ return (double)ts.tv_sec + (double)ts.tv_nsec * 1e-9;
+}
+
+static void
+format_timer(double seconds, char *buf, size_t buf_size)
+{
+ int total = (int)seconds;
+ int h = total / 3600;
+ int m = (total % 3600) / 60;
+ int s = total % 60;
+ snprintf(buf, buf_size, "%02d:%02d:%02d", h, m, s);
+}
+
+static gboolean
+on_timer_tick(gpointer user_data)
+{
+ GsrStreamPage *self = GSR_STREAM_PAGE(user_data);
+ double elapsed = clock_get_monotonic_seconds() - self->start_time;
+ char buf[32];
+ format_timer(elapsed, buf, sizeof(buf));
+ gtk_label_set_text(self->timer_label, buf);
+ return G_SOURCE_CONTINUE;
+}
+
+/* ── Callbacks ───────────────────────────────────────────────────── */
+
+static void
+on_service_changed(GObject *obj G_GNUC_UNUSED,
+ GParamSpec *pspec G_GNUC_UNUSED,
+ gpointer user_data)
+{
+ update_service_visibility(GSR_STREAM_PAGE(user_data));
+}
+
+static void
+on_start_streaming_clicked(GtkButton *btn G_GNUC_UNUSED,
+ gpointer user_data)
+{
+ GsrStreamPage *self = GSR_STREAM_PAGE(user_data);
+
+ GtkRoot *root = gtk_widget_get_root(GTK_WIDGET(self));
+ GsrWindow *window = (root && GSR_IS_WINDOW(root)) ? GSR_WINDOW(root) : NULL;
+
+ if (self->is_active) {
+ /* ── Stop ─── */
+ if (window) {
+ gsr_window_stop_process(window, NULL);
+ gsr_window_set_recording_active(window, FALSE);
+ }
+ gsr_stream_page_set_active(self, FALSE);
+ /* set_active(FALSE) resets is_active and stops timer */
+ } else {
+ /* ── Start ─── */
+ if (!window) return;
+
+ gboolean ok = gsr_window_start_process(window, GSR_ACTIVE_MODE_STREAM);
+ if (!ok) return; /* fork failed — toast already shown by window */
+
+ self->is_active = TRUE;
+ gsr_stream_page_set_active(self, TRUE);
+ gsr_window_set_recording_active(window, TRUE);
+
+ /* Start display timer */
+ self->start_time = clock_get_monotonic_seconds();
+ self->timer_source_id = g_timeout_add(500, on_timer_tick, self);
+ }
+}
+
+/* ── Build groups ────────────────────────────────────────────────── */
+
+#ifdef HAVE_WAYLAND
+static gboolean
+is_kde_wayland(void)
+{
+ const char *desktop = g_getenv("XDG_CURRENT_DESKTOP");
+ return desktop && g_strstr_len(desktop, -1, "KDE") != NULL;
+}
+#endif
+
+/* ── X11 hotkey row callbacks ────────────────────────────────────── */
+
+#ifdef HAVE_X11
+static void
+on_x11_start_stop_shortcut_set(GsrShortcutAccelDialog *dialog,
+ gpointer user_data)
+{
+ GsrStreamPage *self = GSR_STREAM_PAGE(user_data);
+ const char *accel = gsr_shortcut_accel_dialog_get_accelerator(dialog);
+
+ g_free(self->x11_start_stop_accel);
+ self->x11_start_stop_accel = g_strdup(accel);
+
+ if (self->x11_start_stop_label)
+ gtk_shortcut_label_set_accelerator(self->x11_start_stop_label,
+ accel ? accel : "");
+
+ /* Save config & regrab hotkeys */
+ GtkRoot *root = gtk_widget_get_root(GTK_WIDGET(self));
+ if (root && GSR_IS_WINDOW(root)) {
+ gsr_window_on_hotkey_changed(GSR_WINDOW(root));
+ }
+}
+
+static void
+on_x11_start_stop_activated(AdwActionRow *row G_GNUC_UNUSED,
+ gpointer user_data)
+{
+ GsrStreamPage *self = GSR_STREAM_PAGE(user_data);
+ GsrShortcutAccelDialog *dialog = gsr_shortcut_accel_dialog_new(
+ "Start/Stop streaming", self->x11_start_stop_accel);
+ g_signal_connect(dialog, "shortcut-set",
+ G_CALLBACK(on_x11_start_stop_shortcut_set), self);
+ adw_dialog_present(ADW_DIALOG(dialog), GTK_WIDGET(self));
+}
+#endif /* HAVE_X11 */
+
+static void
+build_hotkey_group(GsrStreamPage *self)
+{
+ self->hotkey_group = ADW_PREFERENCES_GROUP(adw_preferences_group_new());
+ adw_preferences_group_set_title(self->hotkey_group, "Hotkeys");
+
+ GsrDisplayServer ds = self->info->system_info.display_server;
+
+#ifdef HAVE_WAYLAND
+ if (ds == GSR_DISPLAY_SERVER_WAYLAND) {
+ /* "Not supported" label — initially hidden, shown if portal fails */
+ self->hotkey_not_supported_label = gtk_label_new(
+ "Your Wayland compositor doesn't support global hotkeys.\n"
+ "Use X11 or KDE Plasma on Wayland if you want to use hotkeys.");
+ gtk_label_set_wrap(GTK_LABEL(self->hotkey_not_supported_label), TRUE);
+ gtk_widget_add_css_class(self->hotkey_not_supported_label, "dim-label");
+ gtk_widget_set_margin_top(self->hotkey_not_supported_label, 6);
+ gtk_widget_set_margin_bottom(self->hotkey_not_supported_label, 6);
+ gtk_widget_set_visible(self->hotkey_not_supported_label, FALSE);
+ adw_preferences_group_add(self->hotkey_group,
+ self->hotkey_not_supported_label);
+
+ /* Info row about Wayland hotkeys */
+ AdwActionRow *info_row = ADW_ACTION_ROW(adw_action_row_new());
+ self->hotkey_info_row = GTK_WIDGET(info_row);
+ if (is_kde_wayland()) {
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(info_row),
+ "Hotkeys are managed by KDE Plasma");
+ adw_action_row_set_subtitle(info_row,
+ "Click to configure hotkeys in system settings");
+ GtkButton *change_btn = GTK_BUTTON(
+ gtk_button_new_with_label("Change hotkeys"));
+ gtk_widget_set_valign(GTK_WIDGET(change_btn), GTK_ALIGN_CENTER);
+ /* The button will be wired to gsr_hotkeys from the window */
+ g_object_set_data(G_OBJECT(info_row), "change-button", change_btn);
+ adw_action_row_add_suffix(info_row, GTK_WIDGET(change_btn));
+ } else {
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(info_row),
+ "Hotkeys are managed by your compositor");
+ adw_action_row_set_subtitle(info_row,
+ "Go to system settings to change hotkeys");
+ }
+ adw_preferences_group_add(self->hotkey_group, GTK_WIDGET(info_row));
+
+ /* Hotkey labels (shown after portal succeeds) */
+ AdwActionRow *start_row = ADW_ACTION_ROW(adw_action_row_new());
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(start_row),
+ "Start/Stop streaming");
+ adw_action_row_set_subtitle(start_row, "");
+ adw_preferences_group_add(self->hotkey_group, GTK_WIDGET(start_row));
+
+ /* Initially hidden on Wayland */
+ gtk_widget_set_visible(GTK_WIDGET(self->hotkey_info_row), FALSE);
+ gtk_widget_set_visible(GTK_WIDGET(start_row), FALSE);
+ }
+#endif /* HAVE_WAYLAND */
+
+#ifdef HAVE_X11
+ if (ds == GSR_DISPLAY_SERVER_X11) {
+ /* X11: interactive shortcut rows */
+ self->x11_start_stop_row = ADW_ACTION_ROW(adw_action_row_new());
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->x11_start_stop_row),
+ "Start/Stop streaming");
+ gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(self->x11_start_stop_row), TRUE);
+
+ self->x11_start_stop_label = GTK_SHORTCUT_LABEL(
+ gtk_shortcut_label_new(self->x11_start_stop_accel ? self->x11_start_stop_accel : ""));
+ gtk_widget_set_valign(GTK_WIDGET(self->x11_start_stop_label), GTK_ALIGN_CENTER);
+ adw_action_row_add_suffix(self->x11_start_stop_row,
+ GTK_WIDGET(self->x11_start_stop_label));
+ /* Arrow indicator */
+ GtkImage *arrow1 = GTK_IMAGE(gtk_image_new_from_icon_name("go-next-symbolic"));
+ gtk_widget_add_css_class(GTK_WIDGET(arrow1), "dim-label");
+ adw_action_row_add_suffix(self->x11_start_stop_row, GTK_WIDGET(arrow1));
+
+ g_signal_connect(self->x11_start_stop_row, "activated",
+ G_CALLBACK(on_x11_start_stop_activated), self);
+ adw_preferences_group_add(self->hotkey_group, GTK_WIDGET(self->x11_start_stop_row));
+ }
+#endif /* HAVE_X11 */
+
+ (void)ds;
+
+ adw_preferences_page_add(ADW_PREFERENCES_PAGE(self), self->hotkey_group);
+}
+
+static void
+build_service_group(GsrStreamPage *self)
+{
+ self->service_group = ADW_PREFERENCES_GROUP(adw_preferences_group_new());
+ adw_preferences_group_set_title(self->service_group, "Streaming Service");
+
+ /* Service selector */
+ self->service_row = ADW_COMBO_ROW(adw_combo_row_new());
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->service_row),
+ "Service");
+ GtkStringList *svc_model = gtk_string_list_new(
+ (const char *const[]){ "Twitch", "YouTube", "Custom", NULL });
+ adw_combo_row_set_model(self->service_row, G_LIST_MODEL(svc_model));
+ adw_combo_row_set_selected(self->service_row, 0);
+ g_signal_connect(self->service_row, "notify::selected",
+ G_CALLBACK(on_service_changed), self);
+ adw_preferences_group_add(self->service_group,
+ GTK_WIDGET(self->service_row));
+
+ /* Twitch stream key */
+ self->twitch_key_row = ADW_PASSWORD_ENTRY_ROW(adw_password_entry_row_new());
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->twitch_key_row),
+ "Stream key");
+ adw_preferences_group_add(self->service_group,
+ GTK_WIDGET(self->twitch_key_row));
+
+ /* YouTube stream key */
+ self->youtube_key_row = ADW_PASSWORD_ENTRY_ROW(adw_password_entry_row_new());
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->youtube_key_row),
+ "Stream key");
+ gtk_widget_set_visible(GTK_WIDGET(self->youtube_key_row), FALSE);
+ adw_preferences_group_add(self->service_group,
+ GTK_WIDGET(self->youtube_key_row));
+
+ /* Custom URL */
+ self->custom_url_row = ADW_PASSWORD_ENTRY_ROW(adw_password_entry_row_new());
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->custom_url_row),
+ "URL");
+ gtk_widget_set_visible(GTK_WIDGET(self->custom_url_row), FALSE);
+ adw_preferences_group_add(self->service_group,
+ GTK_WIDGET(self->custom_url_row));
+
+ /* Container (custom service only) */
+ self->container_row = ADW_COMBO_ROW(adw_combo_row_new());
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(self->container_row),
+ "Container");
+
+ GtkStringList *ct_model = gtk_string_list_new(NULL);
+ const char *containers[] = {
+ "mp4", "flv", "mkv", "mov", "ts", "m3u8", NULL
+ };
+ for (int i = 0; containers[i]; i++)
+ gtk_string_list_append(ct_model, containers[i]);
+
+ /* Add webm if VP8 or VP9 supported */
+ if (self->info->supported_video_codecs.vp8 ||
+ self->info->supported_video_codecs.vp9)
+ gtk_string_list_append(ct_model, "webm");
+
+ adw_combo_row_set_model(self->container_row, G_LIST_MODEL(ct_model));
+ adw_combo_row_set_selected(self->container_row, 1); /* default: flv */
+ gtk_widget_set_visible(GTK_WIDGET(self->container_row), FALSE);
+ adw_preferences_group_add(self->service_group,
+ GTK_WIDGET(self->container_row));
+
+ adw_preferences_page_add(ADW_PREFERENCES_PAGE(self), self->service_group);
+}
+
+static void
+build_action_group(GsrStreamPage *self)
+{
+ self->action_group = ADW_PREFERENCES_GROUP(adw_preferences_group_new());
+
+ /* Button box */
+ GtkBox *box = GTK_BOX(gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 12));
+ gtk_widget_set_halign(GTK_WIDGET(box), GTK_ALIGN_CENTER);
+ gtk_widget_set_margin_top(GTK_WIDGET(box), 6);
+ gtk_widget_set_margin_bottom(GTK_WIDGET(box), 6);
+
+ self->start_button = GTK_BUTTON(gtk_button_new_with_label("Start streaming"));
+ gtk_widget_set_hexpand(GTK_WIDGET(self->start_button), TRUE);
+ gtk_widget_add_css_class(GTK_WIDGET(self->start_button), "suggested-action");
+ g_signal_connect(self->start_button, "clicked",
+ G_CALLBACK(on_start_streaming_clicked), self);
+ gtk_box_append(box, GTK_WIDGET(self->start_button));
+
+ adw_preferences_group_add(self->action_group, GTK_WIDGET(box));
+ adw_preferences_page_add(ADW_PREFERENCES_PAGE(self), self->action_group);
+}
+
+static void
+build_status_group(GsrStreamPage *self)
+{
+ self->status_group = ADW_PREFERENCES_GROUP(adw_preferences_group_new());
+
+ self->status_box = GTK_BOX(gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8));
+ gtk_widget_set_halign(GTK_WIDGET(self->status_box), GTK_ALIGN_CENTER);
+ gtk_widget_set_opacity(GTK_WIDGET(self->status_box), 0.5);
+
+ GtkImage *icon = GTK_IMAGE(gtk_image_new_from_icon_name("media-record-symbolic"));
+ self->record_icon = icon;
+ gtk_box_append(self->status_box, GTK_WIDGET(icon));
+
+ self->timer_label = GTK_LABEL(gtk_label_new("00:00:00"));
+ gtk_box_append(self->status_box, GTK_WIDGET(self->timer_label));
+
+ adw_preferences_group_add(self->status_group, GTK_WIDGET(self->status_box));
+ adw_preferences_page_add(ADW_PREFERENCES_PAGE(self), self->status_group);
+}
+
+/* ── GObject lifecycle ───────────────────────────────────────────── */
+
+static void
+gsr_stream_page_finalize(GObject *object)
+{
+#ifdef HAVE_X11
+ GsrStreamPage *self = GSR_STREAM_PAGE(object);
+ g_free(self->x11_start_stop_accel);
+#endif
+ G_OBJECT_CLASS(gsr_stream_page_parent_class)->finalize(object);
+}
+
+static void
+gsr_stream_page_init(GsrStreamPage *self)
+{
+ (void)self;
+}
+
+static void
+gsr_stream_page_class_init(GsrStreamPageClass *klass)
+{
+ GObjectClass *obj_class = G_OBJECT_CLASS(klass);
+ obj_class->finalize = gsr_stream_page_finalize;
+}
+
+/* ── Public API ──────────────────────────────────────────────────── */
+
+GsrStreamPage *
+gsr_stream_page_new(const GsrInfo *info)
+{
+ GsrStreamPage *self = g_object_new(GSR_TYPE_STREAM_PAGE, NULL);
+ self->info = info;
+
+ adw_preferences_page_set_title(ADW_PREFERENCES_PAGE(self), "Stream");
+ adw_preferences_page_set_icon_name(ADW_PREFERENCES_PAGE(self),
+ "network-transmit-symbolic");
+
+ build_hotkey_group(self);
+ build_service_group(self);
+ build_action_group(self);
+ build_status_group(self);
+
+ /* Set initial visibility */
+ update_service_visibility(self);
+
+ return self;
+}
+
+/* ── Config apply/read ───────────────────────────────────────────── */
+
+/* Helper: select a combo row item by matching string from model */
+static void
+combo_row_select_string(AdwComboRow *row, const char *value)
+{
+ if (!value || !value[0]) return;
+ GListModel *model = adw_combo_row_get_model(row);
+ guint n = g_list_model_get_n_items(model);
+ for (guint i = 0; i < n; i++) {
+ GtkStringObject *obj = GTK_STRING_OBJECT(g_list_model_get_item(model, i));
+ const char *str = gtk_string_object_get_string(obj);
+ g_object_unref(obj);
+ if (g_str_equal(str, value)) {
+ adw_combo_row_set_selected(row, i);
+ return;
+ }
+ }
+}
+
+static const char *
+combo_row_get_selected_string(AdwComboRow *row)
+{
+ GtkStringObject *obj = GTK_STRING_OBJECT(
+ adw_combo_row_get_selected_item(row));
+ return obj ? gtk_string_object_get_string(obj) : "";
+}
+
+/* Map config streaming service string to combo index */
+static guint
+service_string_to_index(const char *svc)
+{
+ if (!svc) return 0;
+ if (g_str_equal(svc, "twitch")) return 0;
+ if (g_str_equal(svc, "youtube")) return 1;
+ if (g_str_equal(svc, "custom")) return 2;
+ return 0;
+}
+
+static const char *
+service_index_to_string(guint idx)
+{
+ switch (idx) {
+ case 0: return "twitch";
+ case 1: return "youtube";
+ case 2: return "custom";
+ default: return "twitch";
+ }
+}
+
+/* Map config container ID to display name */
+static const char *
+stream_container_id_to_display(const char *id)
+{
+ if (!id) return "flv";
+ /* The config stores internal IDs like "matroska", but our combo shows "mkv" etc. */
+ if (g_str_equal(id, "matroska")) return "mkv";
+ if (g_str_equal(id, "mpegts")) return "ts";
+ if (g_str_equal(id, "hls")) return "m3u8";
+ return id; /* mp4, flv, mov, webm pass through */
+}
+
+static const char *
+stream_container_display_to_id(const char *display)
+{
+ if (!display) return "flv";
+ if (g_str_equal(display, "mkv")) return "matroska";
+ if (g_str_equal(display, "ts")) return "mpegts";
+ if (g_str_equal(display, "m3u8")) return "hls";
+ return display; /* mp4, flv, mov, webm pass through */
+}
+
+void
+gsr_stream_page_apply_config(GsrStreamPage *self, const GsrConfig *config)
+{
+ const GsrStreamingConfig *s = &config->streaming_config;
+
+ adw_combo_row_set_selected(self->service_row,
+ service_string_to_index(s->streaming_service));
+
+ gtk_editable_set_text(GTK_EDITABLE(self->twitch_key_row),
+ s->twitch_stream_key ? s->twitch_stream_key : "");
+ gtk_editable_set_text(GTK_EDITABLE(self->youtube_key_row),
+ s->youtube_stream_key ? s->youtube_stream_key : "");
+ gtk_editable_set_text(GTK_EDITABLE(self->custom_url_row),
+ s->custom_url ? s->custom_url : "");
+
+ /* Container for custom service */
+ combo_row_select_string(self->container_row,
+ stream_container_id_to_display(s->custom_container));
+
+ update_service_visibility(self);
+
+ /* Hotkeys (X11 only) */
+#ifdef HAVE_X11
+ if (self->x11_start_stop_label) {
+ g_free(self->x11_start_stop_accel);
+ self->x11_start_stop_accel = gsr_config_hotkey_to_accel(&s->start_stop_hotkey);
+ gtk_shortcut_label_set_accelerator(self->x11_start_stop_label,
+ self->x11_start_stop_accel ? self->x11_start_stop_accel : "");
+ }
+#endif
+}
+
+void
+gsr_stream_page_read_config(GsrStreamPage *self, GsrConfig *config)
+{
+ GsrStreamingConfig *s = &config->streaming_config;
+
+ g_free(s->streaming_service);
+ s->streaming_service = g_strdup(service_index_to_string(
+ adw_combo_row_get_selected(self->service_row)));
+
+ g_free(s->twitch_stream_key);
+ s->twitch_stream_key = g_strdup(
+ gtk_editable_get_text(GTK_EDITABLE(self->twitch_key_row)));
+
+ g_free(s->youtube_stream_key);
+ s->youtube_stream_key = g_strdup(
+ gtk_editable_get_text(GTK_EDITABLE(self->youtube_key_row)));
+
+ g_free(s->custom_url);
+ s->custom_url = g_strdup(
+ gtk_editable_get_text(GTK_EDITABLE(self->custom_url_row)));
+
+ g_free(s->custom_container);
+ s->custom_container = g_strdup(stream_container_display_to_id(
+ combo_row_get_selected_string(self->container_row)));
+
+ /* Hotkeys */
+#ifdef HAVE_X11
+ gsr_config_hotkey_from_accel(&s->start_stop_hotkey, self->x11_start_stop_accel);
+#endif
+}
+
+/* ── Process management API ──────────────────────────────────────── */
+
+void
+gsr_stream_page_set_active(GsrStreamPage *self, gboolean active)
+{
+ if (active) {
+ gtk_button_set_label(self->start_button, "Stop streaming");
+ gtk_widget_remove_css_class(GTK_WIDGET(self->start_button), "suggested-action");
+ gtk_widget_add_css_class(GTK_WIDGET(self->start_button), "destructive-action");
+ gtk_widget_set_opacity(GTK_WIDGET(self->status_box), 1.0);
+ gtk_widget_add_css_class(GTK_WIDGET(self->record_icon), "recording-active");
+ } else {
+ gtk_button_set_label(self->start_button, "Start streaming");
+ gtk_widget_remove_css_class(GTK_WIDGET(self->start_button), "destructive-action");
+ gtk_widget_add_css_class(GTK_WIDGET(self->start_button), "suggested-action");
+ gtk_widget_set_opacity(GTK_WIDGET(self->status_box), 0.5);
+ gtk_widget_remove_css_class(GTK_WIDGET(self->record_icon), "recording-active");
+ gtk_label_set_text(self->timer_label, "00:00:00");
+
+ /* Reset internal state (handles external stop via handle_child_death) */
+ self->is_active = FALSE;
+ if (self->timer_source_id) {
+ g_source_remove(self->timer_source_id);
+ self->timer_source_id = 0;
+ }
+ }
+}
+
+void
+gsr_stream_page_update_timer(GsrStreamPage *self, const char *text)
+{
+ gtk_label_set_text(self->timer_label, text);
+}
+
+char *
+gsr_stream_page_get_stream_url(GsrStreamPage *self)
+{
+ StreamService svc = get_selected_service(self);
+
+ switch (svc) {
+ case STREAM_SERVICE_TWITCH: {
+ const char *key = gtk_editable_get_text(GTK_EDITABLE(self->twitch_key_row));
+ return g_strdup_printf("rtmp://live.twitch.tv/app/%s", key ? key : "");
+ }
+ case STREAM_SERVICE_YOUTUBE: {
+ const char *key = gtk_editable_get_text(GTK_EDITABLE(self->youtube_key_row));
+ return g_strdup_printf("rtmp://a.rtmp.youtube.com/live2/%s", key ? key : "");
+ }
+ case STREAM_SERVICE_CUSTOM: {
+ const char *url = gtk_editable_get_text(GTK_EDITABLE(self->custom_url_row));
+ if (!url || !url[0])
+ return g_strdup("");
+ /* If no recognized scheme prefix, prepend rtmp:// */
+ if (g_str_has_prefix(url, "rtmp://") ||
+ g_str_has_prefix(url, "rtmps://") ||
+ g_str_has_prefix(url, "rtsp://") ||
+ g_str_has_prefix(url, "srt://") ||
+ g_str_has_prefix(url, "http://") ||
+ g_str_has_prefix(url, "https://") ||
+ g_str_has_prefix(url, "tcp://") ||
+ g_str_has_prefix(url, "udp://"))
+ return g_strdup(url);
+ return g_strdup_printf("rtmp://%s", url);
+ }
+ }
+ return g_strdup("");
+}
+
+char *
+gsr_stream_page_get_container(GsrStreamPage *self)
+{
+ StreamService svc = get_selected_service(self);
+ if (svc != STREAM_SERVICE_CUSTOM)
+ return g_strdup("flv");
+ return g_strdup(stream_container_display_to_id(
+ combo_row_get_selected_string(self->container_row)));
+}
+
+void
+gsr_stream_page_activate_start_stop(GsrStreamPage *self)
+{
+ g_return_if_fail(GSR_IS_STREAM_PAGE(self));
+ gtk_widget_activate(GTK_WIDGET(self->start_button));
+}
+
+#ifdef HAVE_WAYLAND
+void
+gsr_stream_page_set_wayland_hotkeys_supported(GsrStreamPage *self,
+ gboolean supported)
+{
+ g_return_if_fail(GSR_IS_STREAM_PAGE(self));
+
+ if (self->hotkey_not_supported_label)
+ gtk_widget_set_visible(self->hotkey_not_supported_label, !supported);
+ if (self->hotkey_info_row)
+ gtk_widget_set_visible(self->hotkey_info_row, supported);
+}
+#endif /* HAVE_WAYLAND */
diff --git a/src/gsr-stream-page.h b/src/gsr-stream-page.h
new file mode 100644
index 0000000..af63bc6
--- /dev/null
+++ b/src/gsr-stream-page.h
@@ -0,0 +1,39 @@
+#pragma once
+
+#include <adwaita.h>
+#include "gsr-info.h"
+#include "gsr-config.h"
+
+G_BEGIN_DECLS
+
+#define GSR_TYPE_STREAM_PAGE (gsr_stream_page_get_type())
+G_DECLARE_FINAL_TYPE(GsrStreamPage, gsr_stream_page, GSR, STREAM_PAGE, AdwPreferencesPage)
+
+GsrStreamPage *gsr_stream_page_new (const GsrInfo *info);
+void gsr_stream_page_apply_config (GsrStreamPage *self,
+ const GsrConfig *config);
+void gsr_stream_page_read_config (GsrStreamPage *self,
+ GsrConfig *config);
+
+/* Process management API (Phase 5) */
+void gsr_stream_page_set_active (GsrStreamPage *self,
+ gboolean active);
+void gsr_stream_page_update_timer (GsrStreamPage *self,
+ const char *text);
+
+/* Get the stream URL for -o argument. Caller must g_free(). */
+char *gsr_stream_page_get_stream_url(GsrStreamPage *self);
+
+/* Get the container ID for -c argument. Caller must g_free(). */
+char *gsr_stream_page_get_container (GsrStreamPage *self);
+
+/* Hotkey: programmatically toggle start/stop. */
+void gsr_stream_page_activate_start_stop(GsrStreamPage *self);
+
+/* Wayland: show/hide hotkey-not-supported banner. */
+#ifdef HAVE_WAYLAND
+void gsr_stream_page_set_wayland_hotkeys_supported(
+ GsrStreamPage *self, gboolean supported);
+#endif
+
+G_END_DECLS
diff --git a/src/gsr-window.c b/src/gsr-window.c
new file mode 100644
index 0000000..d224aba
--- /dev/null
+++ b/src/gsr-window.c
@@ -0,0 +1,1214 @@
+#include "gsr-window.h"
+#include "gsr-info.h"
+#include "gsr-config.h"
+#include "gsr-config-page.h"
+#include "gsr-stream-page.h"
+#include "gsr-record-page.h"
+#include "gsr-replay-page.h"
+#include "gsr-hotkeys.h"
+
+#include <signal.h>
+#include <sys/wait.h>
+#include <time.h>
+#include <unistd.h>
+#include <errno.h>
+
+#ifdef __linux__
+#include <sys/prctl.h>
+#endif
+
+struct _GsrWindow {
+ AdwApplicationWindow parent_instance;
+
+ /* Header bar */
+ AdwHeaderBar *header_bar;
+ AdwViewSwitcher *header_switcher;
+ GtkStack *header_title_stack;
+ GtkLabel *header_title_label;
+ GtkMenuButton *menu_button;
+
+ /* View stack & bottom switcher */
+ AdwViewStack *view_stack;
+ AdwViewSwitcherBar *view_switcher_bar;
+
+ /* Toast overlay */
+ AdwToastOverlay *toast_overlay;
+
+ /* System info (owned, lifetime = window) */
+ GsrInfo info;
+
+ /* Config (owned, lifetime = window) */
+ GsrConfig config;
+
+ /* Pages */
+ GsrConfigPage *config_page;
+ GsrStreamPage *stream_page;
+ GsrRecordPage *record_page;
+ GsrReplayPage *replay_page;
+
+ /* ── Hotkeys ─── */
+ GsrHotkeys *hotkeys;
+#ifdef HAVE_WAYLAND
+ gboolean wayland_shortcuts_registered;
+#endif
+
+ /* ── Process management ─── */
+ pid_t child_pid; /* -1 when idle */
+ int prev_exit_status;
+ GsrActiveMode active_mode;
+ char *record_filename; /* owned, recording only */
+ guint poll_timer_id; /* 500ms poll timer */
+
+ /* ── Desktop notifications ─── */
+ gboolean showing_notification;
+ gboolean is_kde; /* KDE workaround */
+
+ /* ── Startup error state ─── */
+ GsrInfoExitStatus info_status; /* cached for deferred dialog */
+};
+
+G_DEFINE_FINAL_TYPE(GsrWindow, gsr_window, ADW_TYPE_APPLICATION_WINDOW)
+
+/* ── Forward declarations ────────────────────────────────────────── */
+
+static void handle_child_death(GsrWindow *self, int exit_status);
+static void send_notification(GsrWindow *self, const char *title,
+ const char *body, GNotificationPriority priority);
+
+/* ── Desktop notification helpers ────────────────────────────────── */
+
+/**
+ * Withdraw the desktop notification if its timeout has elapsed.
+ * Called as a one-shot GLib timeout.
+ */
+static gboolean
+on_notification_withdraw(gpointer user_data)
+{
+ GsrWindow *self = GSR_WINDOW(user_data);
+
+ if (self->showing_notification) {
+ GtkApplication *app = GTK_APPLICATION(
+ gtk_window_get_application(GTK_WINDOW(self)));
+ if (app)
+ g_application_withdraw_notification(G_APPLICATION(app),
+ "gpu-screen-recorder");
+ self->showing_notification = FALSE;
+ }
+
+ return G_SOURCE_REMOVE; /* one-shot */
+}
+
+/**
+ * Send a desktop notification AND an in-app toast.
+ *
+ * GNotification goes to the desktop notification daemon via GApplication;
+ * AdwToast shows inside our window. The desktop notification is auto-withdrawn
+ * after 3 s (normal) or 10 s (urgent).
+ *
+ * KDE workaround: when a capture is active, KDE suppresses normal-priority
+ * notifications because the app is "screen sharing". We force URGENT in that
+ * case so the user still sees them.
+ */
+static void
+send_notification(GsrWindow *self, const char *title,
+ const char *body, GNotificationPriority priority)
+{
+ GtkApplication *app = GTK_APPLICATION(
+ gtk_window_get_application(GTK_WINDOW(self)));
+ if (!app)
+ return;
+
+ /* ── Desktop notification ── */
+ GNotification *notif = g_notification_new(title);
+ g_notification_set_body(notif, body);
+
+ /* KDE workaround: force urgent while capturing */
+ GNotificationPriority effective = priority;
+ if (self->is_kde && self->child_pid > 0 &&
+ effective < G_NOTIFICATION_PRIORITY_URGENT)
+ effective = G_NOTIFICATION_PRIORITY_URGENT;
+
+ g_notification_set_priority(notif, effective);
+
+ /* Cancel any pending withdrawal timer, then send */
+ if (self->showing_notification) {
+ g_application_withdraw_notification(G_APPLICATION(app),
+ "gpu-screen-recorder");
+ }
+ g_application_send_notification(G_APPLICATION(app),
+ "gpu-screen-recorder", notif);
+ self->showing_notification = TRUE;
+ g_object_unref(notif);
+
+ /* Auto-withdraw after timeout */
+ guint timeout_ms = (effective >= G_NOTIFICATION_PRIORITY_URGENT) ? 10000 : 3000;
+ g_timeout_add(timeout_ms, on_notification_withdraw, self);
+
+ /* ── In-app toast ── */
+ AdwToast *toast = adw_toast_new(body);
+ adw_toast_set_timeout(toast, (effective >= G_NOTIFICATION_PRIORITY_URGENT) ? 10 : 3);
+ adw_toast_overlay_add_toast(self->toast_overlay, toast);
+}
+
+/* ── Container compatibility fix ─────────────────────────────────── */
+
+static const char *
+fix_container_for_codec(const char *container, const char *codec)
+{
+ gboolean is_vp = g_str_equal(codec, "vp8") || g_str_equal(codec, "vp9");
+
+ if (is_vp) {
+ /* VP8/VP9 needs webm or matroska */
+ if (!g_str_equal(container, "webm") && !g_str_equal(container, "matroska"))
+ return "webm";
+ } else {
+ /* Non-VP codec in webm container → force mp4 */
+ if (g_str_equal(container, "webm"))
+ return "mp4";
+ }
+ return container;
+}
+
+/* ── Resolve codec for "auto" ────────────────────────────────────── */
+
+static void
+resolve_codec_and_encoder(GsrWindow *self, const char **out_codec,
+ gboolean *out_use_software)
+{
+ const char *selected = gsr_config_page_get_video_codec_id(self->config_page);
+ *out_use_software = FALSE;
+
+ if (g_str_equal(selected, "h264_software")) {
+ *out_codec = "h264";
+ *out_use_software = TRUE;
+ return;
+ }
+
+ if (g_str_equal(selected, "auto")) {
+ const char *hw = gsr_info_get_first_usable_hw_video_codec(&self->info);
+ if (hw) {
+ *out_codec = hw;
+ } else {
+ /* Fallback to h264 software */
+ *out_codec = "h264";
+ *out_use_software = TRUE;
+ }
+ return;
+ }
+
+ *out_codec = selected;
+}
+
+/* ── Build recording filename ────────────────────────────────────── */
+
+static char *
+build_record_filename(const char *dir, const char *container_display)
+{
+ time_t now = time(NULL);
+ struct tm *tm = localtime(&now);
+ char date_buf[64];
+ strftime(date_buf, sizeof(date_buf), "%Y-%m-%d_%H-%M-%S", tm);
+ return g_strdup_printf("%s/Video_%s.%s", dir, date_buf, container_display);
+}
+
+/* Map internal container ID to display extension for filename */
+static const char *
+container_id_to_extension(const char *id)
+{
+ if (g_str_equal(id, "matroska")) return "mkv";
+ if (g_str_equal(id, "mpegts")) return "ts";
+ if (g_str_equal(id, "hls")) return "m3u8";
+ return id; /* mp4, flv, mov, webm pass through */
+}
+
+/* ── Build command-line args ─────────────────────────────────────── */
+
+static GPtrArray *
+build_command_args(GsrWindow *self, GsrActiveMode mode)
+{
+ GPtrArray *args = g_ptr_array_new_with_free_func(g_free);
+
+ g_ptr_array_add(args, g_strdup("gpu-screen-recorder"));
+
+ /* ── Record area / window ─── */
+ const char *area_id = gsr_config_page_get_record_area_id(self->config_page);
+ g_ptr_array_add(args, g_strdup("-w"));
+
+ if (g_str_equal(area_id, "focused")) {
+ int aw = gsr_config_page_get_area_width(self->config_page);
+ int ah = gsr_config_page_get_area_height(self->config_page);
+ g_ptr_array_add(args, g_strdup_printf("focused:%dx%d", aw, ah));
+ } else if (g_str_equal(area_id, "portal")) {
+ g_ptr_array_add(args, g_strdup("portal"));
+ } else if (g_str_equal(area_id, "window")) {
+ unsigned long wid = gsr_config_page_get_selected_window(self->config_page);
+ if (wid == 0) {
+ g_ptr_array_unref(args);
+ return NULL;
+ }
+ g_ptr_array_add(args, g_strdup_printf("%lu", wid));
+ } else {
+ /* Monitor name */
+ g_ptr_array_add(args, g_strdup(area_id));
+ }
+
+ /* ── Codec & encoder ─── */
+ const char *codec = NULL;
+ gboolean use_software = FALSE;
+ resolve_codec_and_encoder(self, &codec, &use_software);
+
+ /* ── Container (mode-specific, with compat fix) ─── */
+ char *container_owned = NULL;
+ const char *container = NULL;
+
+ switch (mode) {
+ case GSR_ACTIVE_MODE_STREAM:
+ container_owned = gsr_stream_page_get_container(self->stream_page);
+ container = container_owned;
+ break;
+ case GSR_ACTIVE_MODE_RECORD:
+ container_owned = gsr_record_page_get_container(self->record_page);
+ container = container_owned;
+ break;
+ case GSR_ACTIVE_MODE_REPLAY:
+ container_owned = gsr_replay_page_get_container(self->replay_page);
+ container = container_owned;
+ break;
+ default:
+ container = "mp4";
+ break;
+ }
+
+ container = fix_container_for_codec(container, codec);
+
+ g_ptr_array_add(args, g_strdup("-c"));
+ g_ptr_array_add(args, g_strdup(container));
+
+ g_ptr_array_add(args, g_strdup("-k"));
+ g_ptr_array_add(args, g_strdup(codec));
+
+ /* Audio codec */
+ g_ptr_array_add(args, g_strdup("-ac"));
+ g_ptr_array_add(args, g_strdup(gsr_config_page_get_audio_codec_id(self->config_page)));
+
+ /* FPS */
+ g_ptr_array_add(args, g_strdup("-f"));
+ g_ptr_array_add(args, g_strdup_printf("%d", gsr_config_page_get_fps(self->config_page)));
+
+ /* Cursor */
+ g_ptr_array_add(args, g_strdup("-cursor"));
+ g_ptr_array_add(args, g_strdup(
+ gsr_config_page_get_record_cursor(self->config_page) ? "yes" : "no"));
+
+ /* Restore portal session */
+ g_ptr_array_add(args, g_strdup("-restore-portal-session"));
+ g_ptr_array_add(args, g_strdup(
+ gsr_config_page_get_restore_portal_session(self->config_page) ? "yes" : "no"));
+
+ /* Color range */
+ g_ptr_array_add(args, g_strdup("-cr"));
+ g_ptr_array_add(args, g_strdup(gsr_config_page_get_color_range_id(self->config_page)));
+
+ /* Encoder */
+ g_ptr_array_add(args, g_strdup("-encoder"));
+ g_ptr_array_add(args, g_strdup(use_software ? "cpu" : "gpu"));
+
+ /* ── Quality args ─── */
+ const char *quality = gsr_config_page_get_quality_id(self->config_page);
+ if (g_str_equal(quality, "custom")) {
+ g_ptr_array_add(args, g_strdup("-bm"));
+ g_ptr_array_add(args, g_strdup("cbr"));
+ g_ptr_array_add(args, g_strdup("-q"));
+ g_ptr_array_add(args, g_strdup_printf("%d",
+ gsr_config_page_get_video_bitrate(self->config_page)));
+ } else {
+ g_ptr_array_add(args, g_strdup("-q"));
+ g_ptr_array_add(args, g_strdup(quality));
+ }
+
+ /* ── Framerate mode ─── */
+ const char *fm = gsr_config_page_get_framerate_mode_id(self->config_page);
+ if (!g_str_equal(fm, "auto")) {
+ g_ptr_array_add(args, g_strdup("-fm"));
+ g_ptr_array_add(args, g_strdup(fm));
+ }
+
+ /* ── Resolution ─── */
+ if (gsr_config_page_get_change_video_resolution(self->config_page) &&
+ !g_str_equal(area_id, "focused"))
+ {
+ int vw = gsr_config_page_get_video_width(self->config_page);
+ int vh = gsr_config_page_get_video_height(self->config_page);
+ g_ptr_array_add(args, g_strdup("-s"));
+ g_ptr_array_add(args, g_strdup_printf("%dx%d", vw, vh));
+ }
+
+ /* ── Overclock ─── */
+ if (gsr_config_page_get_overclock(self->config_page)) {
+ g_ptr_array_add(args, g_strdup("-oc"));
+ g_ptr_array_add(args, g_strdup("yes"));
+ }
+
+ /* ── Audio args ─── */
+ gboolean split = gsr_config_page_get_split_audio(self->config_page);
+ gboolean merge = !split;
+ GPtrArray *audio_tracks = gsr_config_page_build_audio_args(self->config_page, merge);
+ for (guint i = 0; i < audio_tracks->len; i++) {
+ g_ptr_array_add(args, g_strdup("-a"));
+ g_ptr_array_add(args, g_strdup(g_ptr_array_index(audio_tracks, i)));
+ }
+ g_ptr_array_unref(audio_tracks);
+
+ /* ── Mode-specific: output (-o) and extra flags ─── */
+ switch (mode) {
+ case GSR_ACTIVE_MODE_REPLAY: {
+ int replay_time = gsr_replay_page_get_time(self->replay_page);
+ g_ptr_array_add(args, g_strdup("-r"));
+ g_ptr_array_add(args, g_strdup_printf("%d", replay_time));
+
+ const char *save_dir = gsr_replay_page_get_save_dir(self->replay_page);
+ g_ptr_array_add(args, g_strdup("-o"));
+ g_ptr_array_add(args, g_strdup(save_dir ? save_dir : "/tmp"));
+ break;
+ }
+ case GSR_ACTIVE_MODE_RECORD: {
+ const char *save_dir = gsr_record_page_get_save_dir(self->record_page);
+ const char *ext = container_id_to_extension(container);
+ char *filename = build_record_filename(
+ save_dir ? save_dir : "/tmp", ext);
+
+ g_free(self->record_filename);
+ self->record_filename = g_strdup(filename);
+
+ g_ptr_array_add(args, g_strdup("-o"));
+ g_ptr_array_add(args, filename); /* transfers ownership */
+ break;
+ }
+ case GSR_ACTIVE_MODE_STREAM: {
+ char *url = gsr_stream_page_get_stream_url(self->stream_page);
+ g_ptr_array_add(args, g_strdup("-o"));
+ g_ptr_array_add(args, url); /* transfers ownership */
+ break;
+ }
+ default:
+ break;
+ }
+
+ g_free(container_owned);
+
+ /* NULL-terminate for execvp */
+ g_ptr_array_add(args, NULL);
+
+ return args;
+}
+
+/* ── fork/exec ───────────────────────────────────────────────────── */
+
+static gboolean
+start_child_process(GsrWindow *self, GPtrArray *args)
+{
+ pid_t pid = fork();
+ if (pid == -1) {
+ g_warning("fork() failed: %s", g_strerror(errno));
+ return FALSE;
+ }
+
+ if (pid == 0) {
+ /* Child process */
+#ifdef __linux__
+ prctl(PR_SET_PDEATHSIG, SIGTERM);
+#endif
+ execvp(g_ptr_array_index(args, 0), (char **)args->pdata);
+ /* If execvp returns, it failed */
+ _exit(127);
+ }
+
+ /* Parent */
+ self->child_pid = pid;
+
+ /* Log the command line for debugging */
+ g_autofree char *cmdline = g_strjoinv(" ", (char **)args->pdata);
+ g_debug("Started gpu-screen-recorder (pid=%d): %s", pid, cmdline);
+
+ return TRUE;
+}
+
+/* ── Kill helper (matches kill_gpu_screen_recorder_get_result) ───── */
+
+static gboolean
+kill_and_wait(pid_t pid, gboolean *already_dead)
+{
+ if (already_dead) *already_dead = FALSE;
+
+ if (pid <= 0) {
+ if (already_dead) *already_dead = TRUE;
+ return TRUE;
+ }
+
+ /* Check if already dead */
+ int status = 0;
+ pid_t ret = waitpid(pid, &status, WNOHANG);
+ if (ret == pid) {
+ /* Already exited */
+ if (already_dead) *already_dead = TRUE;
+ if (WIFEXITED(status))
+ return WEXITSTATUS(status) == 0;
+ return FALSE;
+ }
+
+ /* Still running — send SIGINT and block until exit */
+ kill(pid, SIGINT);
+ ret = waitpid(pid, &status, 0);
+ if (ret == pid && WIFEXITED(status))
+ return WEXITSTATUS(status) == 0;
+ return FALSE;
+}
+
+/* ── 500ms poll timer ────────────────────────────────────────────── */
+
+static gboolean
+on_poll_timer(gpointer user_data)
+{
+ GsrWindow *self = GSR_WINDOW(user_data);
+
+ if (self->child_pid <= 0)
+ return G_SOURCE_CONTINUE;
+
+ /* Check if child died */
+ int status = 0;
+ pid_t ret = waitpid(self->child_pid, &status, WNOHANG);
+ if (ret == self->child_pid) {
+ /* Child died */
+ int exit_status = WIFEXITED(status) ? WEXITSTATUS(status) : -1;
+ self->child_pid = -1;
+ handle_child_death(self, exit_status);
+ }
+
+ return G_SOURCE_CONTINUE;
+}
+
+/* ── Handle unexpected child death ───────────────────────────────── */
+
+static void
+handle_child_death(GsrWindow *self, int exit_status)
+{
+ self->prev_exit_status = exit_status;
+ GsrActiveMode mode = self->active_mode;
+
+ g_debug("Child died with exit_status=%d, mode=%d", exit_status, mode);
+
+ /* Stop the poll timer */
+ if (self->poll_timer_id) {
+ g_source_remove(self->poll_timer_id);
+ self->poll_timer_id = 0;
+ }
+
+ /* Enter the "stopped" state on the appropriate page */
+ switch (mode) {
+ case GSR_ACTIVE_MODE_STREAM:
+ gsr_stream_page_set_active(self->stream_page, FALSE);
+ break;
+ case GSR_ACTIVE_MODE_RECORD:
+ gsr_record_page_set_active(self->record_page, FALSE);
+ break;
+ case GSR_ACTIVE_MODE_REPLAY:
+ gsr_replay_page_set_active(self->replay_page, FALSE);
+ break;
+ default:
+ break;
+ }
+
+ self->active_mode = GSR_ACTIVE_MODE_NONE;
+ gsr_window_set_recording_active(self, FALSE);
+
+ /* Show notification based on exit code */
+ if (exit_status == 60) {
+ /* Canceled by user — silent */
+ } else if (exit_status == 0) {
+ /* Success */
+ gboolean notify = gsr_config_page_get_notify_stopped(self->config_page);
+ if (notify) {
+ if (mode == GSR_ACTIVE_MODE_RECORD && self->record_filename) {
+ char *msg = g_strdup_printf("Recording saved to %s",
+ self->record_filename);
+ send_notification(self, "GPU Screen Recorder", msg,
+ G_NOTIFICATION_PRIORITY_NORMAL);
+ g_free(msg);
+ } else {
+ const char *mode_str =
+ mode == GSR_ACTIVE_MODE_STREAM ? "streaming" :
+ mode == GSR_ACTIVE_MODE_REPLAY ? "replay" : "recording";
+ char *msg = g_strdup_printf("Stopped %s", mode_str);
+ send_notification(self, "GPU Screen Recorder", msg,
+ G_NOTIFICATION_PRIORITY_NORMAL);
+ g_free(msg);
+ }
+ }
+ } else {
+ /* Error — always notify regardless of user prefs */
+ char *msg = NULL;
+ if (exit_status == 10)
+ msg = g_strdup_printf("You need to have pkexec installed and have "
+ "a polkit agent running to record your monitor");
+ else if (exit_status == 50)
+ msg = g_strdup_printf("Desktop portal capture failed. Either you "
+ "canceled the desktop portal or your Wayland compositor "
+ "doesn't support desktop portal capture or it's incorrectly "
+ "setup on your system");
+ else
+ msg = g_strdup_printf("Failed to save video. Either your graphics "
+ "card doesn't support GPU Screen Recorder with the settings "
+ "you used or you don't have enough disk space. "
+ "Start GPU Screen Recorder from the terminal for more info");
+ send_notification(self, "GPU Screen Recorder", msg,
+ G_NOTIFICATION_PRIORITY_URGENT);
+ g_free(msg);
+ }
+}
+
+/* ── Actions ─────────────────────────────────────────────────────── */
+
+static void
+save_config(GsrWindow *self)
+{
+ /* Read current widget state into config struct */
+ gsr_config_page_read_config(self->config_page, &self->config);
+ gsr_stream_page_read_config(self->stream_page, &self->config);
+ gsr_record_page_read_config(self->record_page, &self->config);
+ gsr_replay_page_read_config(self->replay_page, &self->config);
+
+ /* Persist view-mode */
+ GAction *action = g_action_map_lookup_action(G_ACTION_MAP(self), "view-mode");
+ if (action) {
+ GVariant *state = g_action_get_state(action);
+ const char *mode = g_variant_get_string(state, NULL);
+ self->config.main_config.advanced_view = g_str_equal(mode, "advanced");
+ g_variant_unref(state);
+ }
+
+ gsr_config_save(&self->config);
+}
+
+static void
+on_view_mode_change(GSimpleAction *action,
+ GVariant *parameter,
+ gpointer user_data)
+{
+ GsrWindow *self = GSR_WINDOW(user_data);
+ const char *mode = g_variant_get_string(parameter, NULL);
+ g_simple_action_set_state(action, g_variant_new_string(mode));
+
+ gboolean advanced = g_str_equal(mode, "advanced");
+ gsr_config_page_set_advanced(self->config_page, advanced);
+
+ /* Persist the change immediately */
+ save_config(self);
+
+ g_debug("View mode changed to: %s", mode);
+}
+
+/* ── Close request — save config while widgets are still alive ──── */
+
+static gboolean
+on_close_request(GtkWindow *window, gpointer user_data G_GNUC_UNUSED)
+{
+ GsrWindow *self = GSR_WINDOW(window);
+
+ /* If child is running, kill it before exiting */
+ if (self->child_pid > 0) {
+ g_debug("Window closing — killing child pid %d", self->child_pid);
+ kill(self->child_pid, SIGINT);
+ waitpid(self->child_pid, NULL, 0);
+ self->child_pid = -1;
+ }
+
+ /* Stop poll timer */
+ if (self->poll_timer_id) {
+ g_source_remove(self->poll_timer_id);
+ self->poll_timer_id = 0;
+ }
+
+ /* Free hotkeys before the window is destroyed */
+ if (self->hotkeys) {
+ gsr_hotkeys_free(self->hotkeys);
+ self->hotkeys = NULL;
+ }
+
+ /* Withdraw any pending desktop notification */
+ if (self->showing_notification) {
+ GtkApplication *app = GTK_APPLICATION(
+ gtk_window_get_application(GTK_WINDOW(self)));
+ if (app)
+ g_application_withdraw_notification(G_APPLICATION(app),
+ "gpu-screen-recorder");
+ self->showing_notification = FALSE;
+ }
+
+ save_config(self);
+ /* Return FALSE to let the default handler proceed */
+ return FALSE;
+}
+
+/* ── Hamburger menu ──────────────────────────────────────────────── */
+
+static GMenuModel *
+create_primary_menu(void)
+{
+ GMenu *menu = g_menu_new();
+
+ /* View mode section (with "View" header label) */
+ GMenu *view_section = g_menu_new();
+ GMenuItem *simple_item = g_menu_item_new("Simple", NULL);
+ g_menu_item_set_action_and_target_value(simple_item,
+ "win.view-mode", g_variant_new_string("simple"));
+ g_menu_append_item(view_section, simple_item);
+ g_object_unref(simple_item);
+
+ GMenuItem *advanced_item = g_menu_item_new("Advanced", NULL);
+ g_menu_item_set_action_and_target_value(advanced_item,
+ "win.view-mode", g_variant_new_string("advanced"));
+ g_menu_append_item(view_section, advanced_item);
+ g_object_unref(advanced_item);
+
+ g_menu_append_section(menu, "View", G_MENU_MODEL(view_section));
+ g_object_unref(view_section);
+
+ /* About section */
+ GMenu *about_section = g_menu_new();
+ g_menu_append(about_section, "Keyboard Shortcuts", "app.shortcuts");
+ g_menu_append(about_section, "About", "app.about");
+ g_menu_append_section(menu, NULL, G_MENU_MODEL(about_section));
+ g_object_unref(about_section);
+
+ return G_MENU_MODEL(menu);
+}
+
+/* ── Hotkey: page changed → regrab + register Wayland shortcuts ─── */
+
+static void
+on_visible_page_changed(GObject *object G_GNUC_UNUSED,
+ GParamSpec *pspec G_GNUC_UNUSED,
+ gpointer user_data)
+{
+ GsrWindow *self = GSR_WINDOW(user_data);
+
+ /* X11: re-grab hotkeys for the now-visible page */
+#ifdef HAVE_X11
+ if (self->hotkeys)
+ gsr_hotkeys_regrab_for_visible_page(self->hotkeys);
+#endif
+
+ /* Wayland: register shortcuts once when first visiting an action page */
+#ifdef HAVE_WAYLAND
+ if (self->hotkeys && !self->wayland_shortcuts_registered) {
+ const char *page = adw_view_stack_get_visible_child_name(self->view_stack);
+ if (page && (!g_str_equal(page, "config"))) {
+ self->wayland_shortcuts_registered = TRUE;
+ gsr_hotkeys_register_wayland_shortcuts_once(self->hotkeys);
+ }
+ }
+#endif
+}
+
+/* ── Startup error dialogs (AdwAlertDialog) ──────────────────────── */
+
+static void
+on_fatal_error_response(AdwAlertDialog *dialog G_GNUC_UNUSED,
+ const char *response G_GNUC_UNUSED,
+ gpointer user_data)
+{
+ GsrWindow *self = GSR_WINDOW(user_data);
+ GtkApplication *app = GTK_APPLICATION(
+ gtk_window_get_application(GTK_WINDOW(self)));
+ if (app)
+ g_application_quit(G_APPLICATION(app));
+}
+
+static void
+show_fatal_error(GsrWindow *self, const char *heading, const char *body)
+{
+ AdwAlertDialog *dlg = ADW_ALERT_DIALOG(adw_alert_dialog_new(heading, body));
+ adw_alert_dialog_add_response(dlg, "ok", "OK");
+ adw_alert_dialog_set_default_response(dlg, "ok");
+ adw_alert_dialog_set_close_response(dlg, "ok");
+ adw_alert_dialog_set_body_use_markup(dlg, TRUE);
+ g_signal_connect(dlg, "response",
+ G_CALLBACK(on_fatal_error_response), self);
+ adw_dialog_present(ADW_DIALOG(dlg), GTK_WIDGET(self));
+}
+
+static gboolean
+check_startup_errors_idle(gpointer user_data)
+{
+ GsrWindow *self = GSR_WINDOW(user_data);
+
+ switch (self->info_status) {
+ case GSR_INFO_EXIT_FAILED_TO_RUN:
+ show_fatal_error(self,
+ "Failed to run gpu-screen-recorder",
+ "Failed to run the <tt>gpu-screen-recorder</tt> command.\n\n"
+ "Make sure <tt>gpu-screen-recorder</tt> is installed and "
+ "accessible in your PATH.");
+ return G_SOURCE_REMOVE;
+
+ case GSR_INFO_EXIT_OPENGL_FAILED:
+ show_fatal_error(self,
+ "OpenGL initialization failed",
+ "Failed to get OpenGL information.\n\n"
+ "Make sure your GPU drivers are properly installed. "
+ "You may need to install the Vulkan or Mesa drivers for your GPU.");
+ return G_SOURCE_REMOVE;
+
+ case GSR_INFO_EXIT_NO_DRM_CARD:
+ show_fatal_error(self,
+ "No DRM card found",
+ "Failed to find a valid DRM card for your GPU.\n\n"
+ "If you are running in a VM, make sure GPU passthrough is "
+ "enabled and properly configured.");
+ return G_SOURCE_REMOVE;
+
+ case GSR_INFO_EXIT_OK:
+ break;
+ }
+
+ /* Check display server */
+ if (self->info.system_info.display_server == GSR_DISPLAY_SERVER_UNKNOWN) {
+ show_fatal_error(self,
+ "No display server detected",
+ "Neither X11 nor Wayland is running.\n\n"
+ "GPU Screen Recorder requires either X11 or Wayland.");
+ return G_SOURCE_REMOVE;
+ }
+
+ /* Check for monitors (Wayland without portal needs monitors) */
+ if (self->info.supported_capture_options.n_monitors == 0 &&
+ self->info.system_info.display_server == GSR_DISPLAY_SERVER_WAYLAND &&
+ !self->info.supported_capture_options.portal)
+ {
+ show_fatal_error(self,
+ "No monitors found",
+ "No monitors to record were found.\n\n"
+ "Make sure GPU Screen Recorder is running on the same GPU "
+ "that your monitors are connected to. You can use the "
+ "<tt>DRI_PRIME</tt> environment variable to choose a GPU.");
+ return G_SOURCE_REMOVE;
+ }
+
+ return G_SOURCE_REMOVE;
+}
+
+/* ── GObject boilerplate ─────────────────────────────────────────── */
+
+static void
+gsr_window_init(GsrWindow *self)
+{
+ /* ── Init process state ─── */
+ self->child_pid = -1;
+ self->prev_exit_status = 0;
+ self->active_mode = GSR_ACTIVE_MODE_NONE;
+ self->record_filename = NULL;
+ self->poll_timer_id = 0;
+
+ /* ── Init notification state ─── */
+ self->showing_notification = FALSE;
+ const char *desktop = g_getenv("XDG_CURRENT_DESKTOP");
+ self->is_kde = desktop && (g_strstr_len(desktop, -1, "KDE") != NULL);
+
+ /* ── Window properties ─── */
+ gtk_window_set_title(GTK_WINDOW(self), "GPU Screen Recorder");
+ gtk_window_set_default_size(GTK_WINDOW(self), 580, 600);
+ gtk_widget_set_size_request(GTK_WIDGET(self), 430, 300);
+
+ /* ── Load system info ─── */
+ GsrInfoExitStatus info_status = gsr_info_load(&self->info);
+ if (info_status != GSR_INFO_EXIT_OK)
+ g_warning("gsr_info_load returned status %d", info_status);
+
+ /* Store for deferred error dialog */
+ self->info_status = info_status;
+
+ /* ── Load config ─── */
+ gsr_config_init_defaults(&self->config);
+ gsr_config_read(&self->config);
+
+ /* ── View stack ─── */
+ self->view_stack = ADW_VIEW_STACK(adw_view_stack_new());
+
+ self->config_page = gsr_config_page_new(&self->info);
+ self->stream_page = gsr_stream_page_new(&self->info);
+ self->record_page = gsr_record_page_new(&self->info);
+ self->replay_page = gsr_replay_page_new(&self->info);
+
+ adw_view_stack_add_titled_with_icon(self->view_stack,
+ GTK_WIDGET(self->config_page), "config", "Config", "preferences-system-symbolic");
+ adw_view_stack_add_titled_with_icon(self->view_stack,
+ GTK_WIDGET(self->stream_page), "stream", "Stream", "network-transmit-symbolic");
+ adw_view_stack_add_titled_with_icon(self->view_stack,
+ GTK_WIDGET(self->record_page), "record", "Record", "media-record-symbolic");
+ adw_view_stack_add_titled_with_icon(self->view_stack,
+ GTK_WIDGET(self->replay_page), "replay", "Replay", "media-playlist-repeat-symbolic");
+
+ /* ── Header bar with view switcher / title stack ─── */
+ self->header_switcher = ADW_VIEW_SWITCHER(adw_view_switcher_new());
+ adw_view_switcher_set_stack(self->header_switcher, self->view_stack);
+ adw_view_switcher_set_policy(self->header_switcher, ADW_VIEW_SWITCHER_POLICY_WIDE);
+
+ self->header_title_label = GTK_LABEL(gtk_label_new("GPU Screen Recorder"));
+ gtk_widget_add_css_class(GTK_WIDGET(self->header_title_label), "title");
+
+ /* Stack to hold both the switcher (wide) and title label (narrow) */
+ self->header_title_stack = GTK_STACK(gtk_stack_new());
+ gtk_stack_set_transition_type(self->header_title_stack, GTK_STACK_TRANSITION_TYPE_CROSSFADE);
+ gtk_stack_add_named(self->header_title_stack,
+ GTK_WIDGET(self->header_switcher), "switcher");
+ gtk_stack_add_named(self->header_title_stack,
+ GTK_WIDGET(self->header_title_label), "title");
+
+ self->header_bar = ADW_HEADER_BAR(adw_header_bar_new());
+ adw_header_bar_set_centering_policy(self->header_bar,
+ ADW_CENTERING_POLICY_STRICT);
+ adw_header_bar_set_title_widget(self->header_bar,
+ GTK_WIDGET(self->header_title_stack));
+
+ /* Hamburger menu button */
+ self->menu_button = GTK_MENU_BUTTON(gtk_menu_button_new());
+ gtk_menu_button_set_icon_name(self->menu_button, "open-menu-symbolic");
+ gtk_menu_button_set_menu_model(self->menu_button, create_primary_menu());
+ adw_header_bar_pack_end(self->header_bar, GTK_WIDGET(self->menu_button));
+
+ /* ── Bottom view switcher bar (narrow mode fallback) ─── */
+ self->view_switcher_bar = ADW_VIEW_SWITCHER_BAR(adw_view_switcher_bar_new());
+ adw_view_switcher_bar_set_stack(self->view_switcher_bar, self->view_stack);
+
+ /* ── Toast overlay wrapping the view stack ─── */
+ self->toast_overlay = ADW_TOAST_OVERLAY(adw_toast_overlay_new());
+ adw_toast_overlay_set_child(self->toast_overlay, GTK_WIDGET(self->view_stack));
+
+ /* ── Layout: toolbar-view wrapping the toast overlay ─── */
+ AdwToolbarView *toolbar_view = ADW_TOOLBAR_VIEW(adw_toolbar_view_new());
+ adw_toolbar_view_add_top_bar(toolbar_view, GTK_WIDGET(self->header_bar));
+ adw_toolbar_view_set_content(toolbar_view, GTK_WIDGET(self->toast_overlay));
+ adw_toolbar_view_add_bottom_bar(toolbar_view, GTK_WIDGET(self->view_switcher_bar));
+
+ adw_application_window_set_content(ADW_APPLICATION_WINDOW(self),
+ GTK_WIDGET(toolbar_view));
+
+ /* ── Breakpoint: narrow window → show title label in header, show bottom bar ─── */
+ AdwBreakpoint *bp = adw_breakpoint_new(
+ adw_breakpoint_condition_parse("max-width: 550sp"));
+ adw_breakpoint_add_setters(bp,
+ G_OBJECT(self->header_title_stack), "visible-child-name", "title",
+ G_OBJECT(self->view_switcher_bar), "reveal", TRUE,
+ NULL);
+ adw_application_window_add_breakpoint(ADW_APPLICATION_WINDOW(self), bp);
+
+ /* ── Save config on close ─── */
+ g_signal_connect(self, "close-request",
+ G_CALLBACK(on_close_request), NULL);
+
+ /* ── Window actions ─── */
+ const char *initial_mode = self->config.main_config.advanced_view
+ ? "'advanced'" : "'simple'";
+ GActionEntry win_actions[] = {
+ { .name = "view-mode", .activate = on_view_mode_change,
+ .parameter_type = "s", .state = initial_mode },
+ };
+ g_action_map_add_action_entries(G_ACTION_MAP(self),
+ win_actions, G_N_ELEMENTS(win_actions), self);
+
+ /* ── Apply config to all pages ─── */
+ gsr_config_page_apply_config(self->config_page, &self->config);
+ gsr_stream_page_apply_config(self->stream_page, &self->config);
+ gsr_record_page_apply_config(self->record_page, &self->config);
+ gsr_replay_page_apply_config(self->replay_page, &self->config);
+
+ /* Apply advanced view mode */
+ gsr_config_page_set_advanced(self->config_page,
+ self->config.main_config.advanced_view);
+
+ /* ── Hotkeys ─── */
+ self->hotkeys = gsr_hotkeys_new(self->info.system_info.display_server, self);
+#ifdef HAVE_WAYLAND
+ self->wayland_shortcuts_registered = FALSE;
+#endif
+
+ /* Re-grab hotkeys whenever the visible page changes */
+ g_signal_connect(self->view_stack, "notify::visible-child-name",
+ G_CALLBACK(on_visible_page_changed), self);
+
+ /* Initial grab for the default visible page */
+#ifdef HAVE_X11
+ if (self->hotkeys)
+ gsr_hotkeys_regrab_for_visible_page(self->hotkeys);
+#endif
+
+ /* ── Deferred startup error check ─── */
+ g_idle_add(check_startup_errors_idle, self);
+}
+
+static void
+gsr_window_finalize(GObject *object)
+{
+ GsrWindow *self = GSR_WINDOW(object);
+
+ if (self->poll_timer_id) {
+ g_source_remove(self->poll_timer_id);
+ self->poll_timer_id = 0;
+ }
+
+ if (self->hotkeys) {
+ gsr_hotkeys_free(self->hotkeys);
+ self->hotkeys = NULL;
+ }
+
+ g_free(self->record_filename);
+ gsr_config_clear(&self->config);
+ gsr_info_clear(&self->info);
+ G_OBJECT_CLASS(gsr_window_parent_class)->finalize(object);
+}
+
+static void
+gsr_window_class_init(GsrWindowClass *klass)
+{
+ GObjectClass *obj_class = G_OBJECT_CLASS(klass);
+ obj_class->finalize = gsr_window_finalize;
+}
+
+/* ── Public API ──────────────────────────────────────────────────── */
+
+GsrWindow *
+gsr_window_new(AdwApplication *app)
+{
+ return g_object_new(GSR_TYPE_WINDOW,
+ "application", app,
+ NULL);
+}
+
+void
+gsr_window_set_recording_active(GsrWindow *self, gboolean active)
+{
+ g_return_if_fail(GSR_IS_WINDOW(self));
+ gtk_widget_set_sensitive(GTK_WIDGET(self->header_switcher), !active);
+ gtk_widget_set_sensitive(GTK_WIDGET(self->view_switcher_bar), !active);
+}
+
+gboolean
+gsr_window_start_process(GsrWindow *self, GsrActiveMode mode)
+{
+ g_return_val_if_fail(GSR_IS_WINDOW(self), FALSE);
+ g_return_val_if_fail(self->child_pid <= 0, FALSE);
+
+ /* Validate window selection if in "window" mode */
+ if (!gsr_config_page_has_valid_window_selection(self->config_page)) {
+ send_notification(self, "GPU Screen Recorder",
+ "No window selected! Please select a window first.",
+ G_NOTIFICATION_PRIORITY_URGENT);
+ return FALSE;
+ }
+
+ /* Build command line */
+ GPtrArray *args = build_command_args(self, mode);
+ if (!args) {
+ send_notification(self, "GPU Screen Recorder",
+ "Failed to build command (no window selected)",
+ G_NOTIFICATION_PRIORITY_URGENT);
+ return FALSE;
+ }
+
+ /* Fork/exec */
+ gboolean ok = start_child_process(self, args);
+ g_ptr_array_unref(args);
+
+ if (!ok) {
+ const char *mode_str =
+ mode == GSR_ACTIVE_MODE_STREAM ? "streaming" :
+ mode == GSR_ACTIVE_MODE_RECORD ? "recording" :
+ mode == GSR_ACTIVE_MODE_REPLAY ? "replay" : "unknown";
+ char *msg = g_strdup_printf("Failed to start %s (failed to fork)", mode_str);
+ send_notification(self, "GPU Screen Recorder", msg,
+ G_NOTIFICATION_PRIORITY_URGENT);
+ g_free(msg);
+ return FALSE;
+ }
+
+ self->active_mode = mode;
+
+ /* Start 500ms poll timer */
+ if (self->poll_timer_id == 0)
+ self->poll_timer_id = g_timeout_add(500, on_poll_timer, self);
+
+ /* Show "started" notification */
+ if (gsr_config_page_get_notify_started(self->config_page)) {
+ const char *mode_str =
+ mode == GSR_ACTIVE_MODE_STREAM ? "streaming" :
+ mode == GSR_ACTIVE_MODE_RECORD ? "recording" :
+ mode == GSR_ACTIVE_MODE_REPLAY ? "replay" : "unknown";
+ char *msg = g_strdup_printf("Started %s", mode_str);
+ send_notification(self, "GPU Screen Recorder", msg,
+ G_NOTIFICATION_PRIORITY_NORMAL);
+ g_free(msg);
+ }
+
+ return TRUE;
+}
+
+gboolean
+gsr_window_stop_process(GsrWindow *self, gboolean *already_dead)
+{
+ g_return_val_if_fail(GSR_IS_WINDOW(self), FALSE);
+
+ if (self->child_pid <= 0) {
+ if (already_dead) *already_dead = TRUE;
+ return TRUE;
+ }
+
+ pid_t pid = self->child_pid;
+ self->child_pid = -1;
+ GsrActiveMode mode = self->active_mode;
+
+ gboolean success = kill_and_wait(pid, already_dead);
+
+ /* Stop poll timer */
+ if (self->poll_timer_id) {
+ g_source_remove(self->poll_timer_id);
+ self->poll_timer_id = 0;
+ }
+
+ self->active_mode = GSR_ACTIVE_MODE_NONE;
+
+ /* Show stop notification (respecting user prefs) */
+ if (gsr_config_page_get_notify_stopped(self->config_page)) {
+ if (success && mode == GSR_ACTIVE_MODE_RECORD && self->record_filename) {
+ char *msg = g_strdup_printf("Recording saved to %s",
+ self->record_filename);
+ send_notification(self, "GPU Screen Recorder", msg,
+ G_NOTIFICATION_PRIORITY_NORMAL);
+ g_free(msg);
+ } else {
+ const char *mode_str =
+ mode == GSR_ACTIVE_MODE_STREAM ? "streaming" :
+ mode == GSR_ACTIVE_MODE_RECORD ? "recording" :
+ mode == GSR_ACTIVE_MODE_REPLAY ? "replay" : "unknown";
+ char *msg = g_strdup_printf("Stopped %s", mode_str);
+ send_notification(self, "GPU Screen Recorder", msg,
+ G_NOTIFICATION_PRIORITY_NORMAL);
+ g_free(msg);
+ }
+ }
+
+ return success;
+}
+
+void
+gsr_window_send_signal(GsrWindow *self, int sig)
+{
+ g_return_if_fail(GSR_IS_WINDOW(self));
+ if (self->child_pid > 0)
+ kill(self->child_pid, sig);
+}
+
+void
+gsr_window_notify_replay_saved(GsrWindow *self)
+{
+ g_return_if_fail(GSR_IS_WINDOW(self));
+ if (gsr_config_page_get_notify_saved(self->config_page))
+ send_notification(self, "GPU Screen Recorder", "Saved replay",
+ G_NOTIFICATION_PRIORITY_NORMAL);
+}
+
+void
+gsr_window_show_toast(GsrWindow *self, const char *message)
+{
+ g_return_if_fail(GSR_IS_WINDOW(self));
+ AdwToast *toast = adw_toast_new(message);
+ adw_toast_set_timeout(toast, 3);
+ adw_toast_overlay_add_toast(self->toast_overlay, toast);
+}
+
+gboolean
+gsr_window_is_process_running(GsrWindow *self)
+{
+ g_return_val_if_fail(GSR_IS_WINDOW(self), FALSE);
+ return self->child_pid > 0;
+}
+
+GsrActiveMode
+gsr_window_get_active_mode(GsrWindow *self)
+{
+ g_return_val_if_fail(GSR_IS_WINDOW(self), GSR_ACTIVE_MODE_NONE);
+ return self->active_mode;
+}
+
+/* ── Hotkey dispatch (called from gsr-hotkeys) ───────────────────── */
+
+const char *
+gsr_window_get_visible_page_name(GsrWindow *self)
+{
+ g_return_val_if_fail(GSR_IS_WINDOW(self), NULL);
+ return adw_view_stack_get_visible_child_name(self->view_stack);
+}
+
+void
+gsr_window_hotkey_start_stop(GsrWindow *self)
+{
+ g_return_if_fail(GSR_IS_WINDOW(self));
+
+ const char *page = adw_view_stack_get_visible_child_name(self->view_stack);
+ if (!page)
+ return;
+
+ if (g_str_equal(page, "stream"))
+ gsr_stream_page_activate_start_stop(self->stream_page);
+ else if (g_str_equal(page, "record"))
+ gsr_record_page_activate_start_stop(self->record_page);
+ else if (g_str_equal(page, "replay"))
+ gsr_replay_page_activate_start_stop(self->replay_page);
+}
+
+void
+gsr_window_hotkey_pause_unpause(GsrWindow *self)
+{
+ g_return_if_fail(GSR_IS_WINDOW(self));
+ gsr_record_page_activate_pause(self->record_page);
+}
+
+void
+gsr_window_hotkey_save_replay(GsrWindow *self)
+{
+ g_return_if_fail(GSR_IS_WINDOW(self));
+ gsr_replay_page_activate_save(self->replay_page);
+}
+
+#ifdef HAVE_WAYLAND
+void
+gsr_window_on_wayland_hotkeys_init(GsrWindow *self, gboolean success)
+{
+ g_return_if_fail(GSR_IS_WINDOW(self));
+
+ gsr_stream_page_set_wayland_hotkeys_supported(self->stream_page, success);
+ gsr_record_page_set_wayland_hotkeys_supported(self->record_page, success);
+ gsr_replay_page_set_wayland_hotkeys_supported(self->replay_page, success);
+}
+#endif /* HAVE_WAYLAND */
+
+void
+gsr_window_on_hotkey_changed(GsrWindow *self)
+{
+ g_return_if_fail(GSR_IS_WINDOW(self));
+
+ /* Save config so the new hotkey bindings are persisted */
+ save_config(self);
+
+ /* Re-grab X11 hotkeys with the updated bindings */
+#ifdef HAVE_X11
+ if (self->hotkeys)
+ gsr_hotkeys_regrab_for_visible_page(self->hotkeys);
+#endif
+}
+
+const GsrConfig *
+gsr_window_get_config(GsrWindow *self)
+{
+ g_return_val_if_fail(GSR_IS_WINDOW(self), NULL);
+ return &self->config;
+}
diff --git a/src/gsr-window.h b/src/gsr-window.h
new file mode 100644
index 0000000..7b42c9e
--- /dev/null
+++ b/src/gsr-window.h
@@ -0,0 +1,113 @@
+#pragma once
+
+#include <adwaita.h>
+#include "gsr-config.h"
+
+G_BEGIN_DECLS
+
+#define GSR_TYPE_WINDOW (gsr_window_get_type())
+G_DECLARE_FINAL_TYPE(GsrWindow, gsr_window, GSR, WINDOW, AdwApplicationWindow)
+
+typedef enum {
+ GSR_ACTIVE_MODE_NONE,
+ GSR_ACTIVE_MODE_STREAM,
+ GSR_ACTIVE_MODE_RECORD,
+ GSR_ACTIVE_MODE_REPLAY,
+} GsrActiveMode;
+
+GsrWindow *gsr_window_new(AdwApplication *app);
+
+void gsr_window_set_recording_active(GsrWindow *self, gboolean active);
+
+/* ── Process management (called from action pages) ───────────────── */
+
+/**
+ * Start gpu-screen-recorder for the given mode.
+ * Returns TRUE on success, FALSE on fork failure.
+ */
+gboolean gsr_window_start_process(GsrWindow *self,
+ GsrActiveMode mode);
+
+/**
+ * Stop the running gpu-screen-recorder process (SIGINT + waitpid).
+ * Returns TRUE if the child exited successfully.
+ * Sets *already_dead if the child was already gone.
+ */
+gboolean gsr_window_stop_process (GsrWindow *self,
+ gboolean *already_dead);
+
+/**
+ * Send a signal to the running child process.
+ * Used for SIGUSR1 (save replay) and SIGUSR2 (pause/unpause).
+ */
+void gsr_window_send_signal (GsrWindow *self, int sig);
+
+/**
+ * Notify replay saved (respects notify_saved config pref).
+ */
+void gsr_window_notify_replay_saved(GsrWindow *self);
+
+/**
+ * Show a toast notification in the window.
+ */
+void gsr_window_show_toast (GsrWindow *self,
+ const char *message);
+
+/**
+ * Check if a process is currently running.
+ */
+gboolean gsr_window_is_process_running(GsrWindow *self);
+
+/**
+ * Get the active mode.
+ */
+GsrActiveMode gsr_window_get_active_mode(GsrWindow *self);
+
+/* ── Hotkey dispatch (called from gsr-hotkeys) ───────────────────── */
+
+/**
+ * Get the name of the currently visible page ("config", "stream",
+ * "record", "replay"). Returns a borrowed string.
+ */
+const char *gsr_window_get_visible_page_name(GsrWindow *self);
+
+/**
+ * Hotkey: Start/Stop the active mode on the visible page.
+ * Programmatically activates the start/stop button on the visible page.
+ */
+void gsr_window_hotkey_start_stop (GsrWindow *self);
+
+/**
+ * Hotkey: Pause/Unpause recording (record page only).
+ */
+void gsr_window_hotkey_pause_unpause(GsrWindow *self);
+
+/**
+ * Hotkey: Save replay (replay page only).
+ */
+void gsr_window_hotkey_save_replay (GsrWindow *self);
+
+/**
+ * Called by gsr-hotkeys when Wayland global shortcuts init completes.
+ * Updates hotkey UI on all action pages.
+ */
+#ifdef HAVE_WAYLAND
+void gsr_window_on_wayland_hotkeys_init(GsrWindow *self,
+ gboolean success);
+#endif
+
+/* ── Hotkey config changes (called from action pages) ────────────── */
+
+/**
+ * Called when the user changes a hotkey binding on an action page.
+ * Saves config to disk and re-grabs X11 hotkeys.
+ */
+void gsr_window_on_hotkey_changed(GsrWindow *self);
+
+/**
+ * Get a read-only pointer to the window's config struct.
+ * Used by gsr-hotkeys to read dynamic hotkey bindings.
+ */
+const GsrConfig *gsr_window_get_config(GsrWindow *self);
+
+G_END_DECLS
diff --git a/src/gsr-x11-hotkeys.c b/src/gsr-x11-hotkeys.c
new file mode 100644
index 0000000..a338441
--- /dev/null
+++ b/src/gsr-x11-hotkeys.c
@@ -0,0 +1,233 @@
+#include "gsr-x11-hotkeys.h"
+
+#include <stdlib.h>
+#include <string.h>
+#include <X11/Xlib.h>
+#include <X11/Xutil.h>
+
+#include <glib.h>
+#include <glib-unix.h>
+
+/* ── Internal types ──────────────────────────────────────────────── */
+
+#define MAX_GRABBED_COMBOS 16
+
+struct _GsrX11Hotkeys {
+ Display *display;
+ Window root;
+ unsigned int numlockmask;
+
+ GsrX11HotkeyCombo combos[MAX_GRABBED_COMBOS];
+ int num_combos;
+
+ GsrX11HotkeyCallback callback;
+ void *userdata;
+
+ GSource *source; /* GLib source watching the X fd */
+ guint source_id;
+};
+
+/* ── NumLock detection ───────────────────────────────────────────── */
+
+static unsigned int
+detect_numlock_mask(Display *display)
+{
+ unsigned int mask = 0;
+ KeyCode numlock_keycode = XKeysymToKeycode(display, XK_Num_Lock);
+ XModifierKeymap *modmap = XGetModifierMapping(display);
+ if (modmap) {
+ for (int i = 0; i < 8; i++) {
+ for (int j = 0; j < modmap->max_keypermod; j++) {
+ if (modmap->modifiermap[i * modmap->max_keypermod + j] == numlock_keycode)
+ mask = (1u << i);
+ }
+ }
+ XFreeModifiermap(modmap);
+ }
+ return mask;
+}
+
+/* ── X error handling ────────────────────────────────────────────── */
+
+static bool x_grab_failed = false;
+
+static int
+xerror_grab(Display *dpy G_GNUC_UNUSED, XErrorEvent *ev G_GNUC_UNUSED)
+{
+ x_grab_failed = true;
+ return 0;
+}
+
+/* ── Strip NumLock/CapsLock from key state ───────────────────────── */
+
+static unsigned int
+key_state_without_locks(unsigned int state)
+{
+ return state & ~(Mod2Mask | LockMask);
+}
+
+/* ── GSource callbacks for X fd polling ──────────────────────────── */
+
+static gboolean
+x11_source_prepare(GSource *source G_GNUC_UNUSED, gint *timeout)
+{
+ *timeout = -1;
+ return FALSE;
+}
+
+static gboolean
+x11_source_check(GSource *source G_GNUC_UNUSED)
+{
+ /* We just wake up; dispatch will check for events */
+ return TRUE;
+}
+
+static gboolean
+x11_source_dispatch(GSource *source G_GNUC_UNUSED,
+ GSourceFunc callback G_GNUC_UNUSED,
+ gpointer user_data)
+{
+ GsrX11Hotkeys *self = user_data;
+
+ while (XPending(self->display)) {
+ XEvent ev;
+ XNextEvent(self->display, &ev);
+
+ if (ev.type != KeyRelease)
+ continue;
+
+ KeySym keysym = XLookupKeysym(&ev.xkey, 0);
+ unsigned int state = key_state_without_locks(ev.xkey.state);
+
+ for (int i = 0; i < self->num_combos; i++) {
+ if (keysym == self->combos[i].keysym &&
+ state == self->combos[i].modifiers)
+ {
+ self->callback(self->combos[i].modifiers,
+ self->combos[i].keysym,
+ self->userdata);
+ break;
+ }
+ }
+ }
+
+ return G_SOURCE_CONTINUE;
+}
+
+static GSourceFuncs x11_source_funcs = {
+ .prepare = x11_source_prepare,
+ .check = x11_source_check,
+ .dispatch = x11_source_dispatch,
+ .finalize = NULL,
+};
+
+/* ── Public API ──────────────────────────────────────────────────── */
+
+GsrX11Hotkeys *
+gsr_x11_hotkeys_new(Display *display,
+ GsrX11HotkeyCallback callback,
+ void *userdata)
+{
+ if (!display || !callback)
+ return NULL;
+
+ GsrX11Hotkeys *self = calloc(1, sizeof(GsrX11Hotkeys));
+ if (!self)
+ return NULL;
+
+ self->display = display;
+ self->root = DefaultRootWindow(display);
+ self->numlockmask = detect_numlock_mask(display);
+ self->callback = callback;
+ self->userdata = userdata;
+ self->num_combos = 0;
+
+ /* Create a GSource that polls the X connection fd */
+ int x_fd = ConnectionNumber(display);
+ self->source = g_source_new(&x11_source_funcs, sizeof(GSource));
+ g_source_set_callback(self->source, NULL, self, NULL);
+ g_source_add_unix_fd(self->source, x_fd, G_IO_IN | G_IO_HUP | G_IO_ERR);
+ self->source_id = g_source_attach(self->source, NULL);
+
+ return self;
+}
+
+void
+gsr_x11_hotkeys_free(GsrX11Hotkeys *self)
+{
+ if (!self)
+ return;
+
+ gsr_x11_hotkeys_ungrab_all(self);
+
+ if (self->source) {
+ g_source_destroy(self->source);
+ g_source_unref(self->source);
+ self->source = NULL;
+ }
+
+ free(self);
+}
+
+void
+gsr_x11_hotkeys_ungrab_all(GsrX11Hotkeys *self)
+{
+ if (!self)
+ return;
+
+ unsigned int modifiers[] = { 0, LockMask, self->numlockmask,
+ self->numlockmask | LockMask };
+
+ for (int i = 0; i < self->num_combos; i++) {
+ KeyCode kc = XKeysymToKeycode(self->display, self->combos[i].keysym);
+ for (int m = 0; m < 4; m++) {
+ XUngrabKey(self->display, kc,
+ self->combos[i].modifiers | modifiers[m],
+ self->root);
+ }
+ }
+ XSync(self->display, False);
+ self->num_combos = 0;
+}
+
+bool
+gsr_x11_hotkeys_grab(GsrX11Hotkeys *self, GsrX11HotkeyCombo combo)
+{
+ if (!self || self->num_combos >= MAX_GRABBED_COMBOS)
+ return false;
+
+ if (combo.keysym == None && combo.modifiers == 0)
+ return true; /* nothing to grab */
+
+ unsigned int modifiers[] = { 0, LockMask, self->numlockmask,
+ self->numlockmask | LockMask };
+
+ XSync(self->display, False);
+ x_grab_failed = false;
+ XErrorHandler prev = XSetErrorHandler(xerror_grab);
+
+ KeyCode kc = XKeysymToKeycode(self->display, combo.keysym);
+ for (int m = 0; m < 4; m++) {
+ XGrabKey(self->display, kc,
+ combo.modifiers | modifiers[m],
+ self->root, False,
+ GrabModeAsync, GrabModeAsync);
+ }
+
+ XSync(self->display, False);
+ XSetErrorHandler(prev);
+
+ if (x_grab_failed) {
+ /* Undo partial grabs */
+ for (int m = 0; m < 4; m++) {
+ XUngrabKey(self->display, kc,
+ combo.modifiers | modifiers[m],
+ self->root);
+ }
+ XSync(self->display, False);
+ return false;
+ }
+
+ self->combos[self->num_combos++] = combo;
+ return true;
+}
diff --git a/src/gsr-x11-hotkeys.h b/src/gsr-x11-hotkeys.h
new file mode 100644
index 0000000..8c528af
--- /dev/null
+++ b/src/gsr-x11-hotkeys.h
@@ -0,0 +1,46 @@
+#pragma once
+
+/*
+ * gsr-x11-hotkeys.h — X11 global hotkey grabbing for GTK4.
+ *
+ * GTK4 removed gdk_window_add_filter(), so we poll the X connection fd
+ * via a GLib GSource to receive KeyRelease events for grabbed combos.
+ */
+
+#include <stdbool.h>
+#include <X11/Xlib.h>
+
+typedef void (*GsrX11HotkeyCallback)(unsigned int modifiers, KeySym keysym, void *userdata);
+
+typedef struct {
+ unsigned int modifiers; /* X11 modifier mask (ControlMask, Mod1Mask, etc.) */
+ KeySym keysym; /* e.g. XK_1, XK_2 */
+} GsrX11HotkeyCombo;
+
+typedef struct _GsrX11Hotkeys GsrX11Hotkeys;
+
+/**
+ * Create a new X11 hotkey watcher on the given display.
+ * The callback fires on KeyRelease matching any grabbed combo.
+ * Returns NULL on failure.
+ */
+GsrX11Hotkeys *gsr_x11_hotkeys_new(Display *display,
+ GsrX11HotkeyCallback callback,
+ void *userdata);
+
+/**
+ * Destroy the hotkey watcher and ungrab all keys.
+ */
+void gsr_x11_hotkeys_free(GsrX11Hotkeys *self);
+
+/**
+ * Ungrab all currently grabbed keys.
+ */
+void gsr_x11_hotkeys_ungrab_all(GsrX11Hotkeys *self);
+
+/**
+ * Grab a key combo. The combo is added to the internal list and
+ * grabbed with NumLock/CapsLock variants.
+ * Returns true on success, false if XGrabKey failed.
+ */
+bool gsr_x11_hotkeys_grab(GsrX11Hotkeys *self, GsrX11HotkeyCombo combo);
diff --git a/src/gsr-x11-window-picker.c b/src/gsr-x11-window-picker.c
new file mode 100644
index 0000000..83f1aa2
--- /dev/null
+++ b/src/gsr-x11-window-picker.c
@@ -0,0 +1,329 @@
+#include "gsr-x11-window-picker.h"
+
+#include <stdlib.h>
+#include <string.h>
+#include <X11/Xutil.h>
+#include <X11/cursorfont.h>
+#include <X11/keysym.h>
+#include <X11/Xatom.h>
+
+#include <glib.h>
+#include <glib-unix.h>
+
+/* ── Internal struct ─────────────────────────────────────────────── */
+
+struct _GsrX11WindowPicker {
+ Display *display; /* own connection */
+ Window root;
+ Cursor crosshair;
+ GsrX11WindowPickCallback callback;
+ void *userdata;
+ GSource *source;
+ guint source_id;
+ bool finished; /* prevents double callback */
+};
+
+/* ── X11 tree walker ─────────────────────────────────────────────── */
+
+/**
+ * Check if a window has a given atom property.
+ */
+static bool
+window_has_atom(Display *display, Window window, Atom atom)
+{
+ Atom type_ret;
+ int format_ret;
+ unsigned long nitems, bytes_after;
+ unsigned char *data = NULL;
+
+ int rc = XGetWindowProperty(display, window, atom,
+ 0, 0, False, AnyPropertyType,
+ &type_ret, &format_ret,
+ &nitems, &bytes_after, &data);
+ if (data)
+ XFree(data);
+
+ return rc == Success && type_ret != None;
+}
+
+/**
+ * Walk the window tree looking for the first child with _NET_WM_STATE,
+ * which is the "real" toplevel window managed by the WM.
+ */
+static Window
+find_toplevel_window(Display *display, Window window)
+{
+ if (window == None)
+ return None;
+
+ Atom wm_state = XInternAtom(display, "_NET_WM_STATE", False);
+ if (!wm_state)
+ return None;
+
+ if (window_has_atom(display, window, wm_state))
+ return window;
+
+ Window root, parent;
+ Window *children = NULL;
+ unsigned int n_children = 0;
+
+ if (!XQueryTree(display, window, &root, &parent, &children, &n_children)
+ || !children)
+ return None;
+
+ Window found = None;
+
+ /* First pass: direct children with _NET_WM_STATE */
+ for (int i = (int)n_children - 1; i >= 0; i--) {
+ if (children[i] && window_has_atom(display, children[i], wm_state)) {
+ found = children[i];
+ goto done;
+ }
+ }
+ /* Second pass: recurse */
+ for (int i = (int)n_children - 1; i >= 0; i--) {
+ if (children[i]) {
+ Window w = find_toplevel_window(display, children[i]);
+ if (w != None) {
+ found = w;
+ goto done;
+ }
+ }
+ }
+
+done:
+ XFree(children);
+ return found;
+}
+
+/**
+ * Get the WM_NAME of a window. Returns a g_malloc'd string, or NULL.
+ */
+static char *
+get_window_name(Display *display, Window window)
+{
+ if (window == None)
+ return NULL;
+
+ /* Try _NET_WM_NAME first (UTF-8) */
+ Atom net_wm_name = XInternAtom(display, "_NET_WM_NAME", False);
+ Atom utf8_string = XInternAtom(display, "UTF8_STRING", False);
+
+ if (net_wm_name && utf8_string) {
+ Atom type_ret;
+ int format_ret;
+ unsigned long nitems, bytes_after;
+ unsigned char *data = NULL;
+
+ int rc = XGetWindowProperty(display, window, net_wm_name,
+ 0, 1024, False, utf8_string,
+ &type_ret, &format_ret,
+ &nitems, &bytes_after, &data);
+ if (rc == Success && data && nitems > 0) {
+ char *name = g_strdup((const char *)data);
+ XFree(data);
+ return name;
+ }
+ if (data)
+ XFree(data);
+ }
+
+ /* Fallback: XGetWMName */
+ XTextProperty wm_name;
+ if (XGetWMName(display, window, &wm_name) && wm_name.nitems > 0) {
+ char **list = NULL;
+ int count = 0;
+ char *result = NULL;
+
+ if (Xutf8TextPropertyToTextList(display, &wm_name, &list, &count) >= 0
+ && list && count > 0 && list[0])
+ {
+ result = g_strdup(list[0]);
+ } else {
+ /* Last resort: just copy the raw value */
+ result = g_strndup((const char *)wm_name.value, wm_name.nitems);
+ }
+
+ if (list)
+ XFreeStringList(list);
+ if (wm_name.value)
+ XFree(wm_name.value);
+
+ return result;
+ }
+
+ return NULL;
+}
+
+/* ── Finish the pick ─────────────────────────────────────────────── */
+
+static void
+finish_pick(GsrX11WindowPicker *self, Window window, char *name)
+{
+ if (self->finished)
+ return;
+ self->finished = true;
+
+ XUngrabPointer(self->display, CurrentTime);
+ XUngrabKeyboard(self->display, CurrentTime);
+ XSync(self->display, False);
+
+ GsrX11WindowPickResult result = {
+ .window = window,
+ .name = name,
+ };
+ self->callback(&result, self->userdata);
+
+ /* Self-destroy after callback */
+ gsr_x11_window_picker_free(self);
+}
+
+/* ── GSource dispatch — poll X events ────────────────────────────── */
+
+static gboolean
+picker_source_prepare(GSource *source G_GNUC_UNUSED, gint *timeout)
+{
+ *timeout = -1;
+ return FALSE;
+}
+
+static gboolean
+picker_source_check(GSource *source G_GNUC_UNUSED)
+{
+ return TRUE;
+}
+
+static gboolean
+picker_source_dispatch(GSource *source G_GNUC_UNUSED,
+ GSourceFunc callback G_GNUC_UNUSED,
+ gpointer user_data)
+{
+ GsrX11WindowPicker *self = user_data;
+
+ if (self->finished)
+ return G_SOURCE_REMOVE;
+
+ while (XPending(self->display)) {
+ XEvent ev;
+ XNextEvent(self->display, &ev);
+
+ if (ev.type == ButtonPress) {
+ Window clicked = ev.xbutton.subwindow;
+ if (clicked == None)
+ clicked = ev.xbutton.window;
+
+ /* Walk tree to find the real toplevel */
+ Window toplevel = find_toplevel_window(self->display, clicked);
+ if (toplevel != None)
+ clicked = toplevel;
+
+ if (clicked == None || clicked == self->root) {
+ /* Clicked root / empty space — cancel */
+ finish_pick(self, None, NULL);
+ return G_SOURCE_REMOVE;
+ }
+
+ char *name = get_window_name(self->display, clicked);
+ if (!name)
+ name = g_strdup("(no name)");
+
+ finish_pick(self, clicked, name);
+ return G_SOURCE_REMOVE;
+ }
+
+ if (ev.type == KeyPress) {
+ KeySym ks = XLookupKeysym(&ev.xkey, 0);
+ if (ks == XK_Escape) {
+ finish_pick(self, None, NULL);
+ return G_SOURCE_REMOVE;
+ }
+ }
+ }
+
+ return G_SOURCE_CONTINUE;
+}
+
+static GSourceFuncs picker_source_funcs = {
+ .prepare = picker_source_prepare,
+ .check = picker_source_check,
+ .dispatch = picker_source_dispatch,
+ .finalize = NULL,
+};
+
+/* ── Public API ──────────────────────────────────────────────────── */
+
+GsrX11WindowPicker *
+gsr_x11_window_picker_new(GsrX11WindowPickCallback callback,
+ void *userdata)
+{
+ if (!callback)
+ return NULL;
+
+ /* Open our own X connection so we don't conflict with GDK */
+ Display *dpy = XOpenDisplay(NULL);
+ if (!dpy) {
+ g_warning("gsr_x11_window_picker: failed to open X display");
+ return NULL;
+ }
+
+ GsrX11WindowPicker *self = g_new0(GsrX11WindowPicker, 1);
+ self->display = dpy;
+ self->root = DefaultRootWindow(dpy);
+ self->callback = callback;
+ self->userdata = userdata;
+ self->finished = false;
+
+ self->crosshair = XCreateFontCursor(dpy, XC_crosshair);
+
+ /* Grab pointer with crosshair */
+ int status = XGrabPointer(dpy, self->root, False,
+ ButtonPressMask | ButtonReleaseMask,
+ GrabModeAsync, GrabModeAsync,
+ self->root, self->crosshair, CurrentTime);
+ if (status != GrabSuccess) {
+ g_warning("gsr_x11_window_picker: XGrabPointer failed (%d)", status);
+ XFreeCursor(dpy, self->crosshair);
+ XCloseDisplay(dpy);
+ g_free(self);
+ return NULL;
+ }
+
+ /* Also grab keyboard so we can detect Escape */
+ XGrabKeyboard(dpy, self->root, False,
+ GrabModeAsync, GrabModeAsync, CurrentTime);
+
+ XSync(dpy, False);
+
+ /* Set up GSource to poll X events */
+ int x_fd = ConnectionNumber(dpy);
+ self->source = g_source_new(&picker_source_funcs, sizeof(GSource));
+ g_source_set_callback(self->source, NULL, self, NULL);
+ g_source_add_unix_fd(self->source, x_fd, G_IO_IN | G_IO_HUP | G_IO_ERR);
+ self->source_id = g_source_attach(self->source, NULL);
+
+ return self;
+}
+
+void
+gsr_x11_window_picker_free(GsrX11WindowPicker *self)
+{
+ if (!self)
+ return;
+
+ if (self->source) {
+ g_source_destroy(self->source);
+ g_source_unref(self->source);
+ self->source = NULL;
+ }
+
+ if (self->display) {
+ XUngrabPointer(self->display, CurrentTime);
+ XUngrabKeyboard(self->display, CurrentTime);
+ if (self->crosshair)
+ XFreeCursor(self->display, self->crosshair);
+ XCloseDisplay(self->display);
+ self->display = NULL;
+ }
+
+ g_free(self);
+}
diff --git a/src/gsr-x11-window-picker.h b/src/gsr-x11-window-picker.h
new file mode 100644
index 0000000..05a7111
--- /dev/null
+++ b/src/gsr-x11-window-picker.h
@@ -0,0 +1,56 @@
+#pragma once
+
+/*
+ * gsr-x11-window-picker.h — Interactive X11 window picker.
+ *
+ * Grabs the pointer with a crosshair cursor, waits for a click,
+ * walks the X11 window tree to find the real toplevel, and reports
+ * the result via callback.
+ *
+ * Uses its own X11 display connection + GLib GSource to avoid
+ * interfering with GDK's event loop.
+ *
+ * X11 only — do NOT use on Wayland.
+ */
+
+#include <stdbool.h>
+#include <X11/Xlib.h>
+
+typedef struct _GsrX11WindowPicker GsrX11WindowPicker;
+
+/**
+ * Result of a window pick. If window == None, the pick was cancelled
+ * (Escape pressed or no valid window found).
+ */
+typedef struct {
+ Window window; /* X11 window ID, or None on cancel */
+ char *name; /* Window name (heap-allocated), or NULL on cancel.
+ Caller must g_free() / free(). */
+} GsrX11WindowPickResult;
+
+/**
+ * Callback fired when the user has clicked a window or cancelled.
+ * Called from the GLib main loop context.
+ */
+typedef void (*GsrX11WindowPickCallback)(const GsrX11WindowPickResult *result,
+ void *userdata);
+
+/**
+ * Create a window picker. Returns NULL on failure (e.g. cannot open
+ * X display or grab pointer).
+ *
+ * The picker grabs the pointer immediately. When the user clicks or
+ * presses Escape, @callback is invoked and the picker is automatically
+ * destroyed (do NOT call _free after the callback fires).
+ *
+ * @callback: result callback
+ * @userdata: passed to callback
+ */
+GsrX11WindowPicker *gsr_x11_window_picker_new(GsrX11WindowPickCallback callback,
+ void *userdata);
+
+/**
+ * Cancel and destroy an in-progress pick.
+ * Safe to call if picker is NULL.
+ */
+void gsr_x11_window_picker_free(GsrX11WindowPicker *self);
diff --git a/src/main.c b/src/main.c
new file mode 100644
index 0000000..cd90ea8
--- /dev/null
+++ b/src/main.c
@@ -0,0 +1,138 @@
+#include <adwaita.h>
+#include "gsr-window.h"
+
+/* ── About dialog ────────────────────────────────────────────────── */
+
+static void
+on_about_action(GSimpleAction *action,
+ GVariant *parameter,
+ gpointer user_data)
+{
+ (void)action;
+ (void)parameter;
+
+ GtkApplication *app = GTK_APPLICATION(user_data);
+ GtkWindow *win = gtk_application_get_active_window(app);
+
+ AdwAboutDialog *about = ADW_ABOUT_DIALOG(adw_about_dialog_new());
+ adw_about_dialog_set_application_name(about, "GPU Screen Recorder");
+ adw_about_dialog_set_version(about, GSR_VERSION);
+ adw_about_dialog_set_developer_name(about, "Trung Lê");
+ adw_about_dialog_set_application_icon(about,
+ "com.dec05eba.gpu_screen_recorder");
+ adw_about_dialog_set_license_type(about, GTK_LICENSE_GPL_3_0);
+ adw_about_dialog_set_website(about,
+ "https://github.com/runlevel5/gpu-screen-recorder-adwaita");
+ adw_about_dialog_set_issue_url(about,
+ "https://github.com/runlevel5/gpu-screen-recorder-adwaita/issues");
+
+ const char *developers[] = { "Trung Lê", NULL };
+ adw_about_dialog_set_developers(about, developers);
+
+ adw_about_dialog_add_credit_section(about, "Original GTK3 Version",
+ (const char *[]){ "dec05eba", NULL });
+
+ adw_dialog_present(ADW_DIALOG(about), GTK_WIDGET(win));
+}
+
+/* ── Keyboard shortcuts dialog ───────────────────────────────────── */
+
+static void
+on_shortcuts_action(GSimpleAction *action,
+ GVariant *parameter,
+ gpointer user_data)
+{
+ (void)action;
+ (void)parameter;
+
+ GtkApplication *app = GTK_APPLICATION(user_data);
+ GtkWindow *win = gtk_application_get_active_window(app);
+
+ AdwShortcutsDialog *dialog = ADW_SHORTCUTS_DIALOG(
+ adw_shortcuts_dialog_new());
+
+ /* ── Stream section ─── */
+ AdwShortcutsSection *stream_sec = adw_shortcuts_section_new("Stream");
+ adw_shortcuts_section_add(stream_sec,
+ adw_shortcuts_item_new("Start / Stop streaming", "<Alt>1"));
+ adw_shortcuts_dialog_add(dialog, stream_sec);
+
+ /* ── Record section ─── */
+ AdwShortcutsSection *record_sec = adw_shortcuts_section_new("Record");
+ adw_shortcuts_section_add(record_sec,
+ adw_shortcuts_item_new("Start / Stop recording", "<Alt>1"));
+ adw_shortcuts_section_add(record_sec,
+ adw_shortcuts_item_new("Pause / Unpause recording", "<Alt>2"));
+ adw_shortcuts_dialog_add(dialog, record_sec);
+
+ /* ── Replay section ─── */
+ AdwShortcutsSection *replay_sec = adw_shortcuts_section_new("Replay");
+ adw_shortcuts_section_add(replay_sec,
+ adw_shortcuts_item_new("Start / Stop replay", "<Alt>1"));
+ adw_shortcuts_section_add(replay_sec,
+ adw_shortcuts_item_new("Save replay", "<Alt>2"));
+ adw_shortcuts_dialog_add(dialog, replay_sec);
+
+ adw_dialog_present(ADW_DIALOG(dialog), GTK_WIDGET(win));
+}
+
+/* ── Application activate ────────────────────────────────────────── */
+
+static void
+load_custom_css(void)
+{
+ GtkCssProvider *provider = gtk_css_provider_new();
+ gtk_css_provider_load_from_string(provider,
+ ".recording-active { color: @error_color; }\n"
+ ".recording-paused { color: @warning_color; }\n");
+ gtk_style_context_add_provider_for_display(
+ gdk_display_get_default(),
+ GTK_STYLE_PROVIDER(provider),
+ GTK_STYLE_PROVIDER_PRIORITY_APPLICATION);
+ g_object_unref(provider);
+}
+
+static void
+on_activate(GtkApplication *app, gpointer user_data)
+{
+ (void)user_data;
+
+ static gboolean css_loaded = FALSE;
+ if (!css_loaded) {
+ load_custom_css();
+ css_loaded = TRUE;
+ }
+
+ GtkWindow *win = gtk_application_get_active_window(app);
+ if (win == NULL) {
+ win = GTK_WINDOW(gsr_window_new(ADW_APPLICATION(app)));
+ }
+ gtk_window_present(win);
+}
+
+/* ── Main ────────────────────────────────────────────────────────── */
+
+int
+main(int argc, char *argv[])
+{
+ AdwApplication *app = adw_application_new(
+ "com.dec05eba.gpu_screen_recorder",
+ G_APPLICATION_DEFAULT_FLAGS);
+
+ static const GActionEntry app_actions[] = {
+ { .name = "shortcuts", .activate = on_shortcuts_action },
+ { .name = "about", .activate = on_about_action },
+ };
+ g_action_map_add_action_entries(G_ACTION_MAP(app),
+ app_actions, G_N_ELEMENTS(app_actions), app);
+
+ gtk_application_set_accels_for_action(GTK_APPLICATION(app),
+ "app.shortcuts",
+ (const char *[]){ "<Ctrl>question", NULL });
+
+ g_signal_connect(app, "activate", G_CALLBACK(on_activate), NULL);
+
+ int status = g_application_run(G_APPLICATION(app), argc, argv);
+ g_object_unref(app);
+ return status;
+}