mirror of
https://github.com/johrpan/musicus.git
synced 2025-10-26 03:47:23 +01:00
Prepare for switching to new branch
This commit is contained in:
parent
a10ab3d8e9
commit
9969fb065e
126 changed files with 0 additions and 15558 deletions
661
COPYING
661
COPYING
|
|
@ -1,661 +0,0 @@
|
|||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 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 Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are 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.
|
||||
|
||||
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.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
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 Affero 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. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
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 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 work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero 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 Affero 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 Affero 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 Affero 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 Affero 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 Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero 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 your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
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 AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
1858
Cargo.lock
generated
1858
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +0,0 @@
|
|||
[workspace]
|
||||
members = [
|
||||
"crates/backend",
|
||||
"crates/database",
|
||||
"crates/import",
|
||||
"crates/musicus"
|
||||
]
|
||||
73
README.md
73
README.md
|
|
@ -1,73 +0,0 @@
|
|||
# Musicus
|
||||
|
||||
This is a desktop app for Musicus.
|
||||
|
||||
## Hacking
|
||||
|
||||
### Building
|
||||
|
||||
Musicus uses the [Meson build system](https://mesonbuild.com/). You can build
|
||||
it using the following commands:
|
||||
|
||||
```
|
||||
$ meson build
|
||||
$ ninja -C build
|
||||
```
|
||||
|
||||
Afterwards the resulting binary executable is under
|
||||
`build/target/debug/musicus`.
|
||||
|
||||
### Flatpak
|
||||
|
||||
There is a Flatpak manifest file called `de.johrpan.musicus.json`. To build a
|
||||
Flatpak you need the the latest Gnome SDK and the Freedesktop SDK with the Rust
|
||||
extension. You can install those using the following commands:
|
||||
|
||||
```
|
||||
$ flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
$ flatpak remote-add --user --if-not-exists gnome-nightly https://nightly.gnome.org/gnome-nightly.flatpakrepo
|
||||
$ flatpak install --user gnome-nightly org.gnome.Sdk org.gnome.Platform
|
||||
$ flatpak install --user flathub org.freedesktop.Sdk.Extension.rust-stable//19.08
|
||||
```
|
||||
|
||||
Afterwards, the following commands will build, install and run the application:
|
||||
|
||||
```
|
||||
$ rm -rf flatpak
|
||||
$ flatpak-builder --user --install flatpak de.johrpan.musicus.json
|
||||
$ flatpak run de.johrpan.musicus
|
||||
```
|
||||
|
||||
### Special requirements
|
||||
|
||||
This program uses [Diesel](https://diesel.rs) as its ORM. After installing
|
||||
the Diesel command line utility, you will be able to create a new schema
|
||||
migration using the following command:
|
||||
|
||||
```
|
||||
$ diesel migration generate [change_description]
|
||||
```
|
||||
|
||||
To update the `src/database/schema.rs` file, you should use the following
|
||||
command:
|
||||
|
||||
```
|
||||
$ diesel migration run --database-url test.sqlite
|
||||
```
|
||||
|
||||
This file should never be edited manually.
|
||||
|
||||
## License
|
||||
|
||||
Musicus is free and open source software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by the
|
||||
Free Software Foundation, either version 3 of the License, or (at your option)
|
||||
any later version.
|
||||
|
||||
Musicus 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 Affero General Public License for more
|
||||
details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License along
|
||||
with this program. If not, see https://www.gnu.org/licenses/.
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
#!/bin/sh
|
||||
|
||||
export MESON_BUILD_ROOT="$1"
|
||||
export MESON_SOURCE_ROOT="$2"
|
||||
export CARGO_TARGET_DIR="$MESON_BUILD_ROOT"/target
|
||||
export CARGO_HOME="$CARGO_TARGET_DIR"/cargo-home
|
||||
export OUTPUT="$3"
|
||||
export BUILDTYPE="$4"
|
||||
export APP_BIN="$5"
|
||||
|
||||
if [ -z ${CARGO_BUILD_TARGET+defined} ]; then
|
||||
CARGO_OUTPUT_PATH="${CARGO_TARGET_DIR}"
|
||||
else
|
||||
CARGO_OUTPUT_PATH="${CARGO_TARGET_DIR}/${CARGO_BUILD_TARGET}"
|
||||
fi
|
||||
|
||||
if [ $BUILDTYPE = "release" ]; then
|
||||
echo "RELEASE MODE"
|
||||
cargo build --manifest-path \
|
||||
"$MESON_SOURCE_ROOT"/Cargo.toml --release && \
|
||||
cp "$CARGO_OUTPUT_PATH"/release/"$APP_BIN" "$OUTPUT"
|
||||
else
|
||||
echo "DEBUG MODE"
|
||||
cargo build --manifest-path \
|
||||
"$MESON_SOURCE_ROOT"/Cargo.toml --verbose && \
|
||||
cp "$CARGO_OUTPUT_PATH"/debug/"$APP_BIN" "$OUTPUT"
|
||||
fi
|
||||
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
from os import environ, path
|
||||
from subprocess import call
|
||||
|
||||
prefix = environ.get('MESON_INSTALL_PREFIX', '/usr/local')
|
||||
datadir = path.join(prefix, 'share')
|
||||
destdir = environ.get('DESTDIR', '')
|
||||
|
||||
# Package managers set this so we don't need to run
|
||||
if not destdir:
|
||||
print('Updating icon cache...')
|
||||
call(['gtk-update-icon-cache', '-qtf', path.join(datadir, 'icons', 'hicolor')])
|
||||
|
||||
print('Updating desktop database...')
|
||||
call(['update-desktop-database', '-q', path.join(datadir, 'applications')])
|
||||
|
||||
print('Compiling GSettings schemas...')
|
||||
call(['glib-compile-schemas', path.join(datadir, 'glib-2.0', 'schemas')])
|
||||
|
||||
|
||||
|
|
@ -1 +0,0 @@
|
|||
type-complexity-threshold = 500
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
[package]
|
||||
name = "musicus_backend"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
chrono = "0.4"
|
||||
fragile = "2"
|
||||
gio = "0.17"
|
||||
glib = "0.17"
|
||||
gstreamer = "0.20"
|
||||
gstreamer-player = "0.20"
|
||||
log = { version = "0.4", features = ["std"] }
|
||||
musicus_database = { version = "0.1.0", path = "../database" }
|
||||
musicus_import = { version = "0.1.0", path = "../import" }
|
||||
thiserror = "1"
|
||||
tokio = { version = "1", features = ["sync"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
mpris-player = "0.6"
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
/// An error that happened within the backend.
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error(transparent)]
|
||||
DatabaseError(#[from] musicus_database::Error),
|
||||
|
||||
#[error("An error happened while decoding to UTF-8.")]
|
||||
Utf8Error(#[from] std::str::Utf8Error),
|
||||
|
||||
#[error("Failed to receive an event.")]
|
||||
RecvError(#[from] tokio::sync::broadcast::error::RecvError),
|
||||
|
||||
#[error("An error happened: {0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
use db::SqliteConnection;
|
||||
use gio::traits::SettingsExt;
|
||||
use log::warn;
|
||||
use std::{
|
||||
cell::{Cell, RefCell},
|
||||
path::PathBuf,
|
||||
rc::Rc,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use tokio::sync::{broadcast, broadcast::Sender};
|
||||
|
||||
pub use musicus_database as db;
|
||||
pub use musicus_import as import;
|
||||
|
||||
pub mod error;
|
||||
pub use error::*;
|
||||
|
||||
pub mod library;
|
||||
pub use library::*;
|
||||
|
||||
mod logger;
|
||||
pub use logger::{LogMessage, Logger};
|
||||
|
||||
pub mod player;
|
||||
pub use player::*;
|
||||
|
||||
/// General states the application can be in.
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub enum BackendState {
|
||||
/// The backend is not set up yet. This means that no backend methods except for setting the
|
||||
/// music library path should be called. The user interface should adapt and only present this
|
||||
/// option.
|
||||
NoMusicLibrary,
|
||||
|
||||
/// The backend is loading the music library. No methods should be called. The user interface
|
||||
/// should represent that state by prohibiting all interaction.
|
||||
Loading,
|
||||
|
||||
/// The backend is ready and all methods may be called.
|
||||
Ready,
|
||||
}
|
||||
|
||||
/// A collection of all backend state and functionality.
|
||||
pub struct Backend {
|
||||
/// Registered instance of [Logger].
|
||||
logger: Arc<Logger>,
|
||||
|
||||
/// A closure that will be called whenever the backend state changes.
|
||||
state_cb: RefCell<Option<Box<dyn Fn(BackendState)>>>,
|
||||
|
||||
/// Access to GSettings.
|
||||
settings: gio::Settings,
|
||||
|
||||
/// The current path to the music library, which is used by the player and the database. This
|
||||
/// is guaranteed to be Some, when the state is set to BackendState::Ready.
|
||||
music_library_path: RefCell<Option<PathBuf>>,
|
||||
|
||||
/// The sender for sending library update notifications.
|
||||
library_updated_sender: Sender<()>,
|
||||
|
||||
/// The database. This can be assumed to exist, when the state is set to BackendState::Ready.
|
||||
database: RefCell<Option<Arc<Mutex<SqliteConnection>>>>,
|
||||
|
||||
/// The player handling playlist and playback. This can be assumed to exist, when the state is
|
||||
/// set to BackendState::Ready.
|
||||
player: RefCell<Option<Rc<Player>>>,
|
||||
|
||||
/// Whether to keep playing random tracks after the playlist ends.
|
||||
keep_playing: Cell<bool>,
|
||||
|
||||
/// Whether to choose full recordings for random playback.
|
||||
play_full_recordings: Cell<bool>,
|
||||
}
|
||||
|
||||
impl Backend {
|
||||
/// Create a new backend initerface. The user interface should subscribe to the state stream
|
||||
/// and call init() afterwards. There may be only one backend for a process and this method
|
||||
/// may only be called exactly once. Otherwise it will panic.
|
||||
pub fn new() -> Self {
|
||||
let logger = logger::register();
|
||||
let (library_updated_sender, _) = broadcast::channel(1024);
|
||||
|
||||
Backend {
|
||||
logger,
|
||||
state_cb: RefCell::new(None),
|
||||
settings: gio::Settings::new("de.johrpan.musicus"),
|
||||
music_library_path: RefCell::new(None),
|
||||
library_updated_sender,
|
||||
database: RefCell::new(None),
|
||||
player: RefCell::new(None),
|
||||
keep_playing: Cell::new(false),
|
||||
play_full_recordings: Cell::new(true),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the registered instance of [Logger].
|
||||
pub fn logger(&self) -> Arc<Logger> {
|
||||
Arc::clone(&self.logger)
|
||||
}
|
||||
|
||||
/// Set the closure to be called whenever the backend state changes.
|
||||
pub fn set_state_cb<F: Fn(BackendState) + 'static>(&self, cb: F) {
|
||||
self.state_cb.replace(Some(Box::new(cb)));
|
||||
}
|
||||
|
||||
/// Initialize the backend. A state callback should already have been registered using
|
||||
/// [`set_state_cb()`] to react to the result.
|
||||
pub fn init(self: Rc<Self>) -> Result<()> {
|
||||
self.keep_playing.set(self.settings.boolean("keep-playing"));
|
||||
self.play_full_recordings
|
||||
.set(self.settings.boolean("play-full-recordings"));
|
||||
|
||||
Rc::clone(&self).init_library()?;
|
||||
|
||||
match self.get_music_library_path() {
|
||||
None => self.set_state(BackendState::NoMusicLibrary),
|
||||
Some(_) => self.set_state(BackendState::Ready),
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether to keep playing random tracks after the playlist ends.
|
||||
pub fn keep_playing(&self) -> bool {
|
||||
self.keep_playing.get()
|
||||
}
|
||||
|
||||
/// Set whether to keep playing random tracks after the playlist ends.
|
||||
pub fn set_keep_playing(self: Rc<Self>, keep_playing: bool) {
|
||||
if let Err(err) = self.settings.set_boolean("keep-playing", keep_playing) {
|
||||
warn!(
|
||||
"The preference \"keep-playing\" could not be saved using GSettings. It will most \
|
||||
likely not be available at the next startup. Error message: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
self.keep_playing.set(keep_playing);
|
||||
self.update_track_generator();
|
||||
}
|
||||
|
||||
/// Whether to choose full recordings for random playback.
|
||||
pub fn play_full_recordings(&self) -> bool {
|
||||
self.play_full_recordings.get()
|
||||
}
|
||||
|
||||
/// Set whether to choose full recordings for random playback.
|
||||
pub fn set_play_full_recordings(self: Rc<Self>, play_full_recordings: bool) {
|
||||
if let Err(err) = self
|
||||
.settings
|
||||
.set_boolean("play-full-recordings", play_full_recordings)
|
||||
{
|
||||
warn!(
|
||||
"The preference \"play-full-recordings\" could not be saved using GSettings. It \
|
||||
will most likely not be available at the next startup. Error message: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
self.play_full_recordings.set(play_full_recordings);
|
||||
self.update_track_generator();
|
||||
}
|
||||
|
||||
/// Set the current state and notify the user interface.
|
||||
fn set_state(&self, state: BackendState) {
|
||||
if let Some(cb) = &*self.state_cb.borrow() {
|
||||
cb(state);
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the current track generation settings.
|
||||
fn update_track_generator(self: Rc<Self>) {
|
||||
if let Some(player) = self.get_player() {
|
||||
if self.keep_playing() {
|
||||
if self.play_full_recordings() {
|
||||
player.set_track_generator(Some(RandomRecordingGenerator::new(self)));
|
||||
} else {
|
||||
player.set_track_generator(Some(RandomTrackGenerator::new(self)));
|
||||
}
|
||||
} else {
|
||||
player.set_track_generator(None::<RandomRecordingGenerator>);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Backend {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
use crate::{Backend, BackendState, Player, Result};
|
||||
use gio::prelude::*;
|
||||
use log::warn;
|
||||
use musicus_database::SqliteConnection;
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
impl Backend {
|
||||
/// Initialize the music library if it is set in the settings.
|
||||
pub(super) fn init_library(self: Rc<Self>) -> Result<()> {
|
||||
let path = self.settings.string("music-library-path");
|
||||
if !path.is_empty() {
|
||||
self.set_music_library_path_priv(PathBuf::from(path.to_string()))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set the path to the music library folder and connect to the database.
|
||||
pub fn set_music_library_path(self: Rc<Self>, path: PathBuf) -> Result<()> {
|
||||
if let Err(err) = self
|
||||
.settings
|
||||
.set_string("music-library-path", path.to_str().unwrap())
|
||||
{
|
||||
warn!(
|
||||
"The music library path could not be saved using GSettings. It will most likely \
|
||||
not be available at the next startup. Error message: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
self.set_music_library_path_priv(path)
|
||||
}
|
||||
|
||||
/// Set the path to the music library folder and and connect to the database.
|
||||
pub fn set_music_library_path_priv(self: Rc<Self>, path: PathBuf) -> Result<()> {
|
||||
self.set_state(BackendState::Loading);
|
||||
|
||||
self.music_library_path.replace(Some(path.clone()));
|
||||
|
||||
let mut db_path = path.clone();
|
||||
db_path.push("musicus.db");
|
||||
|
||||
let database = Arc::new(Mutex::new(musicus_database::connect(
|
||||
db_path.to_str().unwrap(),
|
||||
)?));
|
||||
|
||||
self.database.replace(Some(database));
|
||||
|
||||
let player = Player::new(path);
|
||||
self.player.replace(Some(player));
|
||||
|
||||
Rc::clone(&self).update_track_generator();
|
||||
|
||||
self.set_state(BackendState::Ready);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the currently set music library path.
|
||||
pub fn get_music_library_path(&self) -> Option<PathBuf> {
|
||||
self.music_library_path.borrow().clone()
|
||||
}
|
||||
|
||||
/// Get an interface to the database and panic if there is none.
|
||||
pub fn db(&self) -> Arc<Mutex<SqliteConnection>> {
|
||||
self.database.borrow().clone().unwrap()
|
||||
}
|
||||
|
||||
/// Get an interface to the playback service.
|
||||
pub fn get_player(&self) -> Option<Rc<Player>> {
|
||||
self.player.borrow().clone()
|
||||
}
|
||||
|
||||
/// Wait for the next library update.
|
||||
pub async fn library_update(&self) -> Result<()> {
|
||||
Ok(self.library_updated_sender.subscribe().recv().await?)
|
||||
}
|
||||
|
||||
/// Notify the frontend that the library was changed.
|
||||
pub fn library_changed(&self) {
|
||||
self.library_updated_sender.send(()).unwrap();
|
||||
}
|
||||
|
||||
/// Get an interface to the player and panic if there is none.
|
||||
pub fn pl(&self) -> Rc<Player> {
|
||||
self.get_player().unwrap()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
use chrono::{DateTime, Local};
|
||||
use log::{Level, LevelFilter, Log, Metadata, Record};
|
||||
use std::{
|
||||
fmt::Display,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
/// Register the custom logger. This will panic if called more than once.
|
||||
pub fn register() -> Arc<Logger> {
|
||||
let logger = Arc::new(Logger::default());
|
||||
|
||||
log::set_boxed_logger(Box::new(Arc::clone(&logger)))
|
||||
.map(|()| log::set_max_level(LevelFilter::Info))
|
||||
.unwrap();
|
||||
|
||||
logger
|
||||
}
|
||||
|
||||
/// A simple logging handler that prints out all messages and caches them for
|
||||
/// later access by the user interface.
|
||||
pub struct Logger {
|
||||
/// All messages since the start of the program.
|
||||
messages: Mutex<Vec<LogMessage>>,
|
||||
}
|
||||
|
||||
impl Logger {
|
||||
pub fn messages(&self) -> Vec<LogMessage> {
|
||||
self.messages.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Logger {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
messages: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Log for Logger {
|
||||
fn enabled(&self, metadata: &Metadata) -> bool {
|
||||
metadata.level() <= Level::Info
|
||||
}
|
||||
|
||||
fn log(&self, record: &Record) {
|
||||
if record.level() <= Level::Info {
|
||||
let message = record.into();
|
||||
println!("{}", message);
|
||||
self.messages.lock().unwrap().push(message);
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
/// A simplified representation of a [`Record`].
|
||||
#[derive(Clone)]
|
||||
pub struct LogMessage {
|
||||
pub time: DateTime<Local>,
|
||||
pub level: String,
|
||||
pub module: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl<'a> From<&Record<'a>> for LogMessage {
|
||||
fn from(record: &Record<'a>) -> Self {
|
||||
Self {
|
||||
time: Local::now(),
|
||||
level: record.level().to_string(),
|
||||
module: String::from(record.module_path().unwrap_or_else(|| record.target())),
|
||||
message: format!("{}", record.args()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for LogMessage {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{} {} ({}): {}",
|
||||
self.time, self.module, self.level, self.message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,484 +0,0 @@
|
|||
use crate::{Backend, Error, Result};
|
||||
use db::Track;
|
||||
use glib::clone;
|
||||
use gstreamer_player::PlayerVideoRenderer;
|
||||
use musicus_database as db;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use mpris_player::{Metadata, MprisPlayer, PlaybackStatus};
|
||||
|
||||
pub struct Player {
|
||||
music_library_path: PathBuf,
|
||||
player: gstreamer_player::Player,
|
||||
playlist: RefCell<Vec<Track>>,
|
||||
current_track: Cell<Option<usize>>,
|
||||
playing: Cell<bool>,
|
||||
duration: Cell<u64>,
|
||||
track_generator: RefCell<Option<Box<dyn TrackGenerator>>>,
|
||||
playlist_cbs: RefCell<Vec<Box<dyn Fn(Vec<Track>)>>>,
|
||||
track_cbs: RefCell<Vec<Box<dyn Fn(usize)>>>,
|
||||
duration_cbs: RefCell<Vec<Box<dyn Fn(u64)>>>,
|
||||
playing_cbs: RefCell<Vec<Box<dyn Fn(bool)>>>,
|
||||
position_cbs: RefCell<Vec<Box<dyn Fn(u64)>>>,
|
||||
raise_cb: RefCell<Option<Box<dyn Fn()>>>,
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mpris: Arc<MprisPlayer>,
|
||||
}
|
||||
|
||||
impl Player {
|
||||
pub fn new(music_library_path: PathBuf) -> Rc<Self> {
|
||||
let dispatcher = gstreamer_player::PlayerGMainContextSignalDispatcher::new(None);
|
||||
let player = gstreamer_player::Player::new(None::<PlayerVideoRenderer>, Some(dispatcher));
|
||||
let mut config = player.config();
|
||||
config.set_position_update_interval(250);
|
||||
player.set_config(config).unwrap();
|
||||
player.set_video_track_enabled(false);
|
||||
|
||||
let result = Rc::new(Self {
|
||||
music_library_path,
|
||||
player: player.clone(),
|
||||
playlist: RefCell::new(Vec::new()),
|
||||
current_track: Cell::new(None),
|
||||
playing: Cell::new(false),
|
||||
duration: Cell::new(0),
|
||||
track_generator: RefCell::new(None),
|
||||
playlist_cbs: RefCell::new(Vec::new()),
|
||||
track_cbs: RefCell::new(Vec::new()),
|
||||
duration_cbs: RefCell::new(Vec::new()),
|
||||
playing_cbs: RefCell::new(Vec::new()),
|
||||
position_cbs: RefCell::new(Vec::new()),
|
||||
raise_cb: RefCell::new(None),
|
||||
#[cfg(target_os = "linux")]
|
||||
mpris: {
|
||||
let mpris = MprisPlayer::new(
|
||||
"de.johrpan.musicus".to_string(),
|
||||
"Musicus".to_string(),
|
||||
"de.johrpan.musicus.desktop".to_string(),
|
||||
);
|
||||
|
||||
mpris.set_can_raise(true);
|
||||
mpris.set_can_play(false);
|
||||
mpris.set_can_go_previous(false);
|
||||
mpris.set_can_go_next(false);
|
||||
mpris.set_can_seek(false);
|
||||
mpris.set_can_set_fullscreen(false);
|
||||
|
||||
mpris
|
||||
},
|
||||
});
|
||||
|
||||
let clone = fragile::Fragile::new(result.clone());
|
||||
player.connect_end_of_stream(move |_| {
|
||||
let clone = clone.get();
|
||||
if clone.has_next() {
|
||||
clone.next().unwrap();
|
||||
} else {
|
||||
clone.player.stop();
|
||||
clone.playing.replace(false);
|
||||
|
||||
for cb in &*clone.playing_cbs.borrow() {
|
||||
cb(false);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
clone.mpris.set_playback_status(PlaybackStatus::Paused);
|
||||
}
|
||||
});
|
||||
|
||||
let clone = fragile::Fragile::new(result.clone());
|
||||
player.connect_position_updated(move |_, position| {
|
||||
for cb in &*clone.get().position_cbs.borrow() {
|
||||
cb(position.unwrap().mseconds());
|
||||
}
|
||||
});
|
||||
|
||||
let clone = fragile::Fragile::new(result.clone());
|
||||
player.connect_duration_changed(move |_, duration| {
|
||||
for cb in &*clone.get().duration_cbs.borrow() {
|
||||
let duration = duration.unwrap().mseconds();
|
||||
clone.get().duration.set(duration);
|
||||
cb(duration);
|
||||
}
|
||||
});
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
result
|
||||
.mpris
|
||||
.connect_play_pause(clone!(@weak result => move || {
|
||||
result.play_pause().unwrap();
|
||||
}));
|
||||
|
||||
result.mpris.connect_play(clone!(@weak result => move || {
|
||||
if !result.is_playing() {
|
||||
result.play_pause().unwrap();
|
||||
}
|
||||
}));
|
||||
|
||||
result.mpris.connect_pause(clone!(@weak result => move || {
|
||||
if result.is_playing() {
|
||||
result.play_pause().unwrap();
|
||||
}
|
||||
}));
|
||||
|
||||
result
|
||||
.mpris
|
||||
.connect_previous(clone!(@weak result => move || {
|
||||
let _ = result.previous();
|
||||
}));
|
||||
|
||||
result.mpris.connect_next(clone!(@weak result => move || {
|
||||
let _ = result.next();
|
||||
}));
|
||||
|
||||
result.mpris.connect_raise(clone!(@weak result => move || {
|
||||
let cb = result.raise_cb.borrow();
|
||||
if let Some(cb) = &*cb {
|
||||
cb()
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub fn set_track_generator<G: TrackGenerator + 'static>(&self, generator: Option<G>) {
|
||||
self.track_generator.replace(match generator {
|
||||
Some(generator) => Some(Box::new(generator)),
|
||||
None => None,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn add_playlist_cb<F: Fn(Vec<Track>) + 'static>(&self, cb: F) {
|
||||
self.playlist_cbs.borrow_mut().push(Box::new(cb));
|
||||
}
|
||||
|
||||
pub fn add_track_cb<F: Fn(usize) + 'static>(&self, cb: F) {
|
||||
self.track_cbs.borrow_mut().push(Box::new(cb));
|
||||
}
|
||||
|
||||
pub fn add_duration_cb<F: Fn(u64) + 'static>(&self, cb: F) {
|
||||
self.duration_cbs.borrow_mut().push(Box::new(cb));
|
||||
}
|
||||
|
||||
pub fn add_playing_cb<F: Fn(bool) + 'static>(&self, cb: F) {
|
||||
self.playing_cbs.borrow_mut().push(Box::new(cb));
|
||||
}
|
||||
|
||||
pub fn add_position_cb<F: Fn(u64) + 'static>(&self, cb: F) {
|
||||
self.position_cbs.borrow_mut().push(Box::new(cb));
|
||||
}
|
||||
|
||||
pub fn set_raise_cb<F: Fn() + 'static>(&self, cb: F) {
|
||||
self.raise_cb.replace(Some(Box::new(cb)));
|
||||
}
|
||||
|
||||
pub fn get_playlist(&self) -> Vec<Track> {
|
||||
self.playlist.borrow().clone()
|
||||
}
|
||||
|
||||
pub fn get_current_track(&self) -> Option<usize> {
|
||||
self.current_track.get()
|
||||
}
|
||||
|
||||
pub fn get_duration(&self) -> Option<gstreamer::ClockTime> {
|
||||
self.player.duration()
|
||||
}
|
||||
|
||||
pub fn is_playing(&self) -> bool {
|
||||
self.playing.get()
|
||||
}
|
||||
|
||||
/// Add some items to the playlist.
|
||||
pub fn add_items(&self, mut items: Vec<Track>) -> Result<()> {
|
||||
if items.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let was_empty = {
|
||||
let mut playlist = self.playlist.borrow_mut();
|
||||
let was_empty = playlist.is_empty();
|
||||
|
||||
playlist.append(&mut items);
|
||||
|
||||
was_empty
|
||||
};
|
||||
|
||||
for cb in &*self.playlist_cbs.borrow() {
|
||||
cb(self.playlist.borrow().clone());
|
||||
}
|
||||
|
||||
if was_empty {
|
||||
self.set_track(0)?;
|
||||
self.player.play();
|
||||
self.playing.set(true);
|
||||
|
||||
for cb in &*self.playing_cbs.borrow() {
|
||||
cb(true);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
self.mpris.set_can_play(true);
|
||||
self.mpris.set_playback_status(PlaybackStatus::Playing);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn play_pause(&self) -> Result<()> {
|
||||
if self.is_playing() {
|
||||
self.player.pause();
|
||||
self.playing.set(false);
|
||||
|
||||
for cb in &*self.playing_cbs.borrow() {
|
||||
cb(false);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
self.mpris.set_playback_status(PlaybackStatus::Paused);
|
||||
} else {
|
||||
if self.current_track.get().is_none() {
|
||||
self.next()?;
|
||||
}
|
||||
|
||||
self.player.play();
|
||||
self.playing.set(true);
|
||||
|
||||
for cb in &*self.playing_cbs.borrow() {
|
||||
cb(true);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
self.mpris.set_playback_status(PlaybackStatus::Playing);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn seek(&self, ms: u64) {
|
||||
self.player.seek(gstreamer::ClockTime::from_mseconds(ms));
|
||||
}
|
||||
|
||||
pub fn has_previous(&self) -> bool {
|
||||
if let Some(current_track) = self.current_track.get() {
|
||||
current_track > 0
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn previous(&self) -> Result<()> {
|
||||
let mut current_track = self.current_track.get().ok_or_else(|| {
|
||||
Error::Other(String::from(
|
||||
"Player tried to access non existant current track.",
|
||||
))
|
||||
})?;
|
||||
|
||||
if current_track > 0 {
|
||||
current_track -= 1;
|
||||
} else {
|
||||
return Err(Error::Other(String::from("No existing previous track.")));
|
||||
}
|
||||
|
||||
self.set_track(current_track)
|
||||
}
|
||||
|
||||
pub fn has_next(&self) -> bool {
|
||||
if let Some(generator) = &*self.track_generator.borrow() {
|
||||
generator.has_next()
|
||||
} else if let Some(current_track) = self.current_track.get() {
|
||||
let playlist = self.playlist.borrow();
|
||||
current_track + 1 < playlist.len()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next(&self) -> Result<()> {
|
||||
let current_track = self.current_track.get();
|
||||
let generator = self.track_generator.borrow();
|
||||
|
||||
if let Some(current_track) = current_track {
|
||||
if current_track + 1 >= self.playlist.borrow().len() {
|
||||
if let Some(generator) = &*generator {
|
||||
let items = generator.next();
|
||||
if !items.is_empty() {
|
||||
self.add_items(items)?;
|
||||
} else {
|
||||
return Err(Error::Other(String::from(
|
||||
"Track generator failed to generate next track.",
|
||||
)));
|
||||
}
|
||||
} else {
|
||||
return Err(Error::Other(String::from("No existing next track.")));
|
||||
}
|
||||
}
|
||||
|
||||
self.set_track(current_track + 1)?;
|
||||
|
||||
Ok(())
|
||||
} else if let Some(generator) = &*generator {
|
||||
let items = generator.next();
|
||||
if !items.is_empty() {
|
||||
self.add_items(items)?;
|
||||
} else {
|
||||
return Err(Error::Other(String::from(
|
||||
"Track generator failed to generate next track.",
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::Other(String::from("No existing next track.")))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_track(&self, current_track: usize) -> Result<()> {
|
||||
let track = &self.playlist.borrow()[current_track];
|
||||
|
||||
let path = self
|
||||
.music_library_path
|
||||
.join(track.path.clone())
|
||||
.into_os_string()
|
||||
.into_string()
|
||||
.unwrap();
|
||||
|
||||
let uri = glib::filename_to_uri(&path, None)
|
||||
.map_err(|_| Error::Other(format!("Failed to create URI from path: {}", path)))?;
|
||||
|
||||
self.player.set_uri(Some(&uri));
|
||||
|
||||
if self.is_playing() {
|
||||
self.player.play();
|
||||
}
|
||||
|
||||
self.current_track.set(Some(current_track));
|
||||
|
||||
for cb in &*self.track_cbs.borrow() {
|
||||
cb(current_track);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let mut parts = Vec::<String>::new();
|
||||
for part in &track.work_parts {
|
||||
parts.push(track.recording.work.parts[*part].title.clone());
|
||||
}
|
||||
|
||||
let mut title = track.recording.work.get_title();
|
||||
if !parts.is_empty() {
|
||||
title = format!("{}: {}", title, parts.join(", "));
|
||||
}
|
||||
|
||||
let subtitle = track.recording.get_performers();
|
||||
|
||||
let mut metadata = Metadata::new();
|
||||
metadata.artist = Some(vec![title]);
|
||||
metadata.title = Some(subtitle);
|
||||
|
||||
self.mpris.set_metadata(metadata);
|
||||
self.mpris.set_can_go_previous(self.has_previous());
|
||||
self.mpris.set_can_go_next(self.has_next());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn send_data(&self) {
|
||||
for cb in &*self.playlist_cbs.borrow() {
|
||||
cb(self.playlist.borrow().clone());
|
||||
}
|
||||
|
||||
for cb in &*self.track_cbs.borrow() {
|
||||
cb(self.current_track.get().unwrap());
|
||||
}
|
||||
|
||||
for cb in &*self.duration_cbs.borrow() {
|
||||
cb(self.duration.get());
|
||||
}
|
||||
|
||||
for cb in &*self.playing_cbs.borrow() {
|
||||
cb(self.is_playing());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(&self) {
|
||||
self.player.stop();
|
||||
self.playing.set(false);
|
||||
self.current_track.set(None);
|
||||
self.playlist.replace(Vec::new());
|
||||
|
||||
for cb in &*self.playing_cbs.borrow() {
|
||||
cb(false);
|
||||
}
|
||||
|
||||
for cb in &*self.playlist_cbs.borrow() {
|
||||
cb(Vec::new());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
self.mpris.set_can_play(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Generator for new tracks to be appended to the playlist.
|
||||
pub trait TrackGenerator {
|
||||
/// Whether the generator will provide a next track if asked.
|
||||
fn has_next(&self) -> bool;
|
||||
|
||||
/// Provide the next track.
|
||||
///
|
||||
/// This function should always return at least one track in a state where
|
||||
/// `has_next()` returns `true`.
|
||||
fn next(&self) -> Vec<Track>;
|
||||
}
|
||||
|
||||
/// A track generator that generates one random track per call.
|
||||
pub struct RandomTrackGenerator {
|
||||
backend: Rc<Backend>,
|
||||
}
|
||||
|
||||
impl RandomTrackGenerator {
|
||||
pub fn new(backend: Rc<Backend>) -> Self {
|
||||
Self { backend }
|
||||
}
|
||||
}
|
||||
|
||||
impl TrackGenerator for RandomTrackGenerator {
|
||||
fn has_next(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn next(&self) -> Vec<Track> {
|
||||
vec![db::random_track(&mut self.backend.db().lock().unwrap()).unwrap()]
|
||||
}
|
||||
}
|
||||
|
||||
/// A track generator that returns the tracks of one random recording per call.
|
||||
pub struct RandomRecordingGenerator {
|
||||
backend: Rc<Backend>,
|
||||
}
|
||||
|
||||
impl RandomRecordingGenerator {
|
||||
pub fn new(backend: Rc<Backend>) -> Self {
|
||||
Self { backend }
|
||||
}
|
||||
}
|
||||
|
||||
impl TrackGenerator for RandomRecordingGenerator {
|
||||
fn has_next(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn next(&self) -> Vec<Track> {
|
||||
let recording = db::random_recording(&mut self.backend.db().lock().unwrap()).unwrap();
|
||||
db::get_tracks(&mut self.backend.db().lock().unwrap(), &recording.id).unwrap()
|
||||
}
|
||||
}
|
||||
1
crates/database/.gitignore
vendored
1
crates/database/.gitignore
vendored
|
|
@ -1 +0,0 @@
|
|||
test.sqlite
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
[package]
|
||||
name = "musicus_database"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
diesel = { version = "2", features = ["sqlite"] }
|
||||
diesel_migrations = "2"
|
||||
chrono = "0.4"
|
||||
log = "0.4"
|
||||
rand = "0.8"
|
||||
thiserror = "1"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
[print_schema]
|
||||
file = "src/schema.rs"
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
PRAGMA defer_foreign_keys;
|
||||
|
||||
DROP TABLE "persons";
|
||||
DROP TABLE "instruments";
|
||||
DROP TABLE "works";
|
||||
DROP TABLE "instrumentations";
|
||||
DROP TABLE "work_parts";
|
||||
DROP TABLE "ensembles";
|
||||
DROP TABLE "recordings";
|
||||
DROP TABLE "performances";
|
||||
DROP TABLE "mediums";
|
||||
DROP TABLE "tracks";
|
||||
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
CREATE TABLE "persons" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"first_name" TEXT NOT NULL,
|
||||
"last_name" TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "instruments" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "works" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"composer" TEXT NOT NULL REFERENCES "persons"("id"),
|
||||
"title" TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "instrumentations" (
|
||||
"id" BIGINT NOT NULL PRIMARY KEY,
|
||||
"work" TEXT NOT NULL REFERENCES "works"("id") ON DELETE CASCADE,
|
||||
"instrument" TEXT NOT NULL REFERENCES "instruments"("id") ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE "work_parts" (
|
||||
"id" BIGINT NOT NULL PRIMARY KEY,
|
||||
"work" TEXT NOT NULL REFERENCES "works"("id") ON DELETE CASCADE,
|
||||
"part_index" BIGINT NOT NULL,
|
||||
"title" TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "ensembles" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "recordings" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"work" TEXT NOT NULL REFERENCES "works"("id"),
|
||||
"comment" TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "performances" (
|
||||
"id" BIGINT NOT NULL PRIMARY KEY,
|
||||
"recording" TEXT NOT NULL REFERENCES "recordings"("id") ON DELETE CASCADE,
|
||||
"person" TEXT REFERENCES "persons"("id"),
|
||||
"ensemble" TEXT REFERENCES "ensembles"("id"),
|
||||
"role" TEXT REFERENCES "instruments"("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "mediums" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"discid" TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE "tracks" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"medium" TEXT NOT NULL REFERENCES "mediums"("id") ON DELETE CASCADE,
|
||||
"index" INTEGER NOT NULL,
|
||||
"recording" TEXT NOT NULL REFERENCES "recordings"("id"),
|
||||
"work_parts" TEXT NOT NULL,
|
||||
"source_index" INTEGER NOT NULL,
|
||||
"path" TEXT NOT NULL
|
||||
);
|
||||
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
ALTER TABLE "persons" DROP COLUMN "last_used";
|
||||
ALTER TABLE "persons" DROP COLUMN "last_played";
|
||||
|
||||
ALTER TABLE "instruments" DROP COLUMN "last_used";
|
||||
ALTER TABLE "instruments" DROP COLUMN "last_played";
|
||||
|
||||
ALTER TABLE "works" DROP COLUMN "last_used";
|
||||
ALTER TABLE "works" DROP COLUMN "last_played";
|
||||
|
||||
ALTER TABLE "ensembles" DROP COLUMN "last_used";
|
||||
ALTER TABLE "ensembles" DROP COLUMN "last_played";
|
||||
|
||||
ALTER TABLE "recordings" DROP COLUMN "last_used";
|
||||
ALTER TABLE "recordings" DROP COLUMN "last_played";
|
||||
|
||||
ALTER TABLE "mediums" DROP COLUMN "last_used";
|
||||
ALTER TABLE "mediums" DROP COLUMN "last_played";
|
||||
|
||||
ALTER TABLE "tracks" DROP COLUMN "last_used";
|
||||
ALTER TABLE "tracks" DROP COLUMN "last_played";
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
ALTER TABLE "persons" ADD COLUMN "last_used" BIGINT;
|
||||
ALTER TABLE "persons" ADD COLUMN "last_played" BIGINT;
|
||||
|
||||
ALTER TABLE "instruments" ADD COLUMN "last_used" BIGINT;
|
||||
ALTER TABLE "instruments" ADD COLUMN "last_played" BIGINT;
|
||||
|
||||
ALTER TABLE "works" ADD COLUMN "last_used" BIGINT;
|
||||
ALTER TABLE "works" ADD COLUMN "last_played" BIGINT;
|
||||
|
||||
ALTER TABLE "ensembles" ADD COLUMN "last_used" BIGINT;
|
||||
ALTER TABLE "ensembles" ADD COLUMN "last_played" BIGINT;
|
||||
|
||||
ALTER TABLE "recordings" ADD COLUMN "last_used" BIGINT;
|
||||
ALTER TABLE "recordings" ADD COLUMN "last_played" BIGINT;
|
||||
|
||||
ALTER TABLE "mediums" ADD COLUMN "last_used" BIGINT;
|
||||
ALTER TABLE "mediums" ADD COLUMN "last_played" BIGINT;
|
||||
|
||||
ALTER TABLE "tracks" ADD COLUMN "last_used" BIGINT;
|
||||
ALTER TABLE "tracks" ADD COLUMN "last_played" BIGINT;
|
||||
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
CREATE TABLE "old_tracks" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"medium" TEXT NOT NULL REFERENCES "mediums"("id") ON DELETE CASCADE,
|
||||
"index" INTEGER NOT NULL,
|
||||
"recording" TEXT NOT NULL REFERENCES "recordings"("id"),
|
||||
"work_parts" TEXT NOT NULL,
|
||||
"source_index" INTEGER NOT NULL,
|
||||
"path" TEXT NOT NULL,
|
||||
"last_used" BIGINT,
|
||||
"last_played" BIGINT
|
||||
);
|
||||
|
||||
INSERT INTO "old_tracks" SELECT * FROM "tracks" WHERE "medium" IS NOT NULL;
|
||||
DROP TABLE "tracks";
|
||||
ALTER TABLE "old_tracks" RENAME TO "tracks";
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
CREATE TABLE "new_tracks" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"medium" TEXT REFERENCES "mediums"("id") ON DELETE CASCADE,
|
||||
"index" INTEGER NOT NULL,
|
||||
"recording" TEXT NOT NULL REFERENCES "recordings"("id"),
|
||||
"work_parts" TEXT NOT NULL,
|
||||
"source_index" INTEGER NOT NULL,
|
||||
"path" TEXT NOT NULL,
|
||||
"last_used" BIGINT,
|
||||
"last_played" BIGINT
|
||||
);
|
||||
|
||||
INSERT INTO "new_tracks" SELECT * FROM "tracks";
|
||||
DROP TABLE "tracks";
|
||||
ALTER TABLE "new_tracks" RENAME TO "tracks";
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
use chrono::Utc;
|
||||
use diesel::prelude::*;
|
||||
use log::info;
|
||||
|
||||
use crate::{defer_foreign_keys, schema::ensembles, Result};
|
||||
|
||||
/// An ensemble that takes part in recordings.
|
||||
#[derive(Insertable, Queryable, PartialEq, Eq, Hash, Debug, Clone)]
|
||||
pub struct Ensemble {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub last_used: Option<i64>,
|
||||
pub last_played: Option<i64>,
|
||||
}
|
||||
|
||||
impl Ensemble {
|
||||
pub fn new(id: String, name: String) -> Self {
|
||||
Self {
|
||||
id,
|
||||
name,
|
||||
last_used: Some(Utc::now().timestamp()),
|
||||
last_played: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update an existing ensemble or insert a new one.
|
||||
pub fn update_ensemble(connection: &mut SqliteConnection, mut ensemble: Ensemble) -> Result<()> {
|
||||
info!("Updating ensemble {:?}", ensemble);
|
||||
defer_foreign_keys(connection)?;
|
||||
|
||||
ensemble.last_used = Some(Utc::now().timestamp());
|
||||
|
||||
connection.transaction(|connection| {
|
||||
diesel::replace_into(ensembles::table)
|
||||
.values(ensemble)
|
||||
.execute(connection)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get an existing ensemble.
|
||||
pub fn get_ensemble(connection: &mut SqliteConnection, id: &str) -> Result<Option<Ensemble>> {
|
||||
let ensemble = ensembles::table
|
||||
.filter(ensembles::id.eq(id))
|
||||
.load::<Ensemble>(connection)?
|
||||
.into_iter()
|
||||
.next();
|
||||
|
||||
Ok(ensemble)
|
||||
}
|
||||
|
||||
/// Delete an existing ensemble.
|
||||
pub fn delete_ensemble(connection: &mut SqliteConnection, id: &str) -> Result<()> {
|
||||
info!("Deleting ensemble {}", id);
|
||||
diesel::delete(ensembles::table.filter(ensembles::id.eq(id))).execute(connection)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all existing ensembles.
|
||||
pub fn get_ensembles(connection: &mut SqliteConnection) -> Result<Vec<Ensemble>> {
|
||||
let ensembles = ensembles::table.load::<Ensemble>(connection)?;
|
||||
Ok(ensembles)
|
||||
}
|
||||
|
||||
/// Get recently used ensembles.
|
||||
pub fn get_recent_ensembles(connection: &mut SqliteConnection) -> Result<Vec<Ensemble>> {
|
||||
let ensembles = ensembles::table
|
||||
.order(ensembles::last_used.desc())
|
||||
.load::<Ensemble>(connection)?;
|
||||
|
||||
Ok(ensembles)
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
/// Error that happens within the database module.
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error(transparent)]
|
||||
ConnectionError(#[from] diesel::result::ConnectionError),
|
||||
|
||||
#[error(transparent)]
|
||||
Migrations(#[from] Box<dyn std::error::Error + Send + Sync>),
|
||||
|
||||
#[error(transparent)]
|
||||
QueryError(#[from] diesel::result::Error),
|
||||
|
||||
#[error("Missing item dependency ({0} {1})")]
|
||||
MissingItem(&'static str, String),
|
||||
|
||||
#[error("Failed to parse {0} from '{1}'")]
|
||||
ParsingError(&'static str, String),
|
||||
|
||||
#[error("{0}")]
|
||||
Other(&'static str),
|
||||
}
|
||||
|
||||
/// Return type for database methods.
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
use chrono::Utc;
|
||||
use diesel::prelude::*;
|
||||
use log::info;
|
||||
|
||||
use crate::{defer_foreign_keys, schema::instruments, Result};
|
||||
|
||||
/// An instrument or any other possible role within a recording.
|
||||
#[derive(Insertable, Queryable, PartialEq, Eq, Hash, Debug, Clone)]
|
||||
pub struct Instrument {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub last_used: Option<i64>,
|
||||
pub last_played: Option<i64>,
|
||||
}
|
||||
|
||||
impl Instrument {
|
||||
pub fn new(id: String, name: String) -> Self {
|
||||
Self {
|
||||
id,
|
||||
name,
|
||||
last_used: Some(Utc::now().timestamp()),
|
||||
last_played: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update an existing instrument or insert a new one.
|
||||
pub fn update_instrument(
|
||||
connection: &mut SqliteConnection,
|
||||
mut instrument: Instrument,
|
||||
) -> Result<()> {
|
||||
info!("Updating instrument {:?}", instrument);
|
||||
defer_foreign_keys(connection)?;
|
||||
|
||||
instrument.last_used = Some(Utc::now().timestamp());
|
||||
|
||||
connection.transaction(|connection| {
|
||||
diesel::replace_into(instruments::table)
|
||||
.values(instrument)
|
||||
.execute(connection)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get an existing instrument.
|
||||
pub fn get_instrument(connection: &mut SqliteConnection, id: &str) -> Result<Option<Instrument>> {
|
||||
let instrument = instruments::table
|
||||
.filter(instruments::id.eq(id))
|
||||
.load::<Instrument>(connection)?
|
||||
.into_iter()
|
||||
.next();
|
||||
|
||||
Ok(instrument)
|
||||
}
|
||||
|
||||
/// Delete an existing instrument.
|
||||
pub fn delete_instrument(connection: &mut SqliteConnection, id: &str) -> Result<()> {
|
||||
info!("Deleting instrument {}", id);
|
||||
diesel::delete(instruments::table.filter(instruments::id.eq(id))).execute(connection)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all existing instruments.
|
||||
pub fn get_instruments(connection: &mut SqliteConnection) -> Result<Vec<Instrument>> {
|
||||
let instruments = instruments::table.load::<Instrument>(connection)?;
|
||||
|
||||
Ok(instruments)
|
||||
}
|
||||
|
||||
/// Get recently used instruments.
|
||||
pub fn get_recent_instruments(connection: &mut SqliteConnection) -> Result<Vec<Instrument>> {
|
||||
let instruments = instruments::table
|
||||
.order(instruments::last_used.desc())
|
||||
.load::<Instrument>(connection)?;
|
||||
|
||||
Ok(instruments)
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
use diesel::prelude::*;
|
||||
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
|
||||
use log::info;
|
||||
|
||||
pub use diesel::SqliteConnection;
|
||||
|
||||
pub mod ensembles;
|
||||
pub use ensembles::*;
|
||||
|
||||
pub mod error;
|
||||
pub use error::*;
|
||||
|
||||
pub mod instruments;
|
||||
pub use instruments::*;
|
||||
|
||||
pub mod medium;
|
||||
pub use medium::*;
|
||||
|
||||
pub mod persons;
|
||||
pub use persons::*;
|
||||
|
||||
pub mod recordings;
|
||||
pub use recordings::*;
|
||||
|
||||
pub mod works;
|
||||
pub use works::*;
|
||||
|
||||
mod schema;
|
||||
|
||||
// This makes the SQL migration scripts accessible from the code.
|
||||
const MIGRATIONS: EmbeddedMigrations = embed_migrations!();
|
||||
|
||||
/// Connect to a Musicus database running migrations if necessary.
|
||||
pub fn connect(file_name: &str) -> Result<SqliteConnection> {
|
||||
info!("Opening database file '{}'", file_name);
|
||||
let mut connection = SqliteConnection::establish(file_name)?;
|
||||
diesel::sql_query("PRAGMA foreign_keys = ON").execute(&mut connection)?;
|
||||
|
||||
info!("Running migrations if necessary");
|
||||
connection.run_pending_migrations(MIGRATIONS)?;
|
||||
|
||||
Ok(connection)
|
||||
}
|
||||
|
||||
/// Generate a random string suitable as an item ID.
|
||||
pub fn generate_id() -> String {
|
||||
uuid::Uuid::new_v4().simple().to_string()
|
||||
}
|
||||
|
||||
/// Defer all foreign keys for the next transaction.
|
||||
fn defer_foreign_keys(connection: &mut SqliteConnection) -> Result<()> {
|
||||
diesel::sql_query("PRAGMA defer_foreign_keys = ON").execute(connection)?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1,351 +0,0 @@
|
|||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use diesel::prelude::*;
|
||||
use log::info;
|
||||
|
||||
use crate::{
|
||||
defer_foreign_keys, generate_id, get_recording,
|
||||
schema::{ensembles, mediums, performances, persons, recordings, tracks},
|
||||
update_recording, Error, Recording, Result,
|
||||
};
|
||||
|
||||
/// Representation of someting like a physical audio disc or a folder with
|
||||
/// audio files (i.e. a collection of tracks for one or more recordings).
|
||||
#[derive(PartialEq, Eq, Hash, Debug, Clone)]
|
||||
pub struct Medium {
|
||||
/// An unique ID for the medium.
|
||||
pub id: String,
|
||||
|
||||
/// The human identifier for the medium.
|
||||
pub name: String,
|
||||
|
||||
/// If applicable, the MusicBrainz DiscID.
|
||||
pub discid: Option<String>,
|
||||
|
||||
/// The tracks of the medium.
|
||||
pub tracks: Vec<Track>,
|
||||
|
||||
pub last_used: Option<DateTime<Utc>>,
|
||||
pub last_played: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl Medium {
|
||||
pub fn new(id: String, name: String, discid: Option<String>, tracks: Vec<Track>) -> Self {
|
||||
Self {
|
||||
id,
|
||||
name,
|
||||
discid,
|
||||
tracks,
|
||||
last_used: Some(Utc::now()),
|
||||
last_played: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A track on a medium.
|
||||
#[derive(PartialEq, Eq, Hash, Debug, Clone)]
|
||||
pub struct Track {
|
||||
/// The recording on this track.
|
||||
pub recording: Recording,
|
||||
|
||||
/// The work parts that are played on this track. They are indices to the
|
||||
/// work parts of the work that is associated with the recording.
|
||||
pub work_parts: Vec<usize>,
|
||||
|
||||
/// The index of the track within its source. This is used to associate
|
||||
/// the metadata with the audio data from the source when importing.
|
||||
pub source_index: usize,
|
||||
|
||||
/// The path to the audio file containing this track.
|
||||
pub path: String,
|
||||
|
||||
pub last_used: Option<DateTime<Utc>>,
|
||||
pub last_played: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl Track {
|
||||
pub fn new(
|
||||
recording: Recording,
|
||||
work_parts: Vec<usize>,
|
||||
source_index: usize,
|
||||
path: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
recording,
|
||||
work_parts,
|
||||
source_index,
|
||||
path,
|
||||
last_used: Some(Utc::now()),
|
||||
last_played: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Table data for a [`Medium`].
|
||||
#[derive(Insertable, Queryable, Debug, Clone)]
|
||||
#[diesel(table_name = mediums)]
|
||||
struct MediumRow {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub discid: Option<String>,
|
||||
pub last_used: Option<i64>,
|
||||
pub last_played: Option<i64>,
|
||||
}
|
||||
|
||||
/// Table data for a [`Track`].
|
||||
#[derive(Insertable, Queryable, QueryableByName, Debug, Clone)]
|
||||
#[diesel(table_name = tracks)]
|
||||
struct TrackRow {
|
||||
pub id: String,
|
||||
pub medium: Option<String>,
|
||||
pub index: i32,
|
||||
pub recording: String,
|
||||
pub work_parts: String,
|
||||
pub source_index: i32,
|
||||
pub path: String,
|
||||
pub last_used: Option<i64>,
|
||||
pub last_played: Option<i64>,
|
||||
}
|
||||
|
||||
/// Update an existing medium or insert a new one.
|
||||
pub fn update_medium(connection: &mut SqliteConnection, medium: Medium) -> Result<()> {
|
||||
info!("Updating medium {:?}", medium);
|
||||
defer_foreign_keys(connection)?;
|
||||
|
||||
connection.transaction::<(), Error, _>(|connection| {
|
||||
let medium_id = &medium.id;
|
||||
|
||||
// This will also delete the tracks.
|
||||
delete_medium(connection, medium_id)?;
|
||||
|
||||
// Add the new medium.
|
||||
|
||||
let medium_row = MediumRow {
|
||||
id: medium_id.to_owned(),
|
||||
name: medium.name.clone(),
|
||||
discid: medium.discid.clone(),
|
||||
last_used: Some(Utc::now().timestamp()),
|
||||
last_played: medium.last_played.map(|t| t.timestamp()),
|
||||
};
|
||||
|
||||
diesel::insert_into(mediums::table)
|
||||
.values(medium_row)
|
||||
.execute(connection)?;
|
||||
|
||||
for (index, track) in medium.tracks.iter().enumerate() {
|
||||
// Add associated items from the server, if they don't already exist.
|
||||
|
||||
if get_recording(connection, &track.recording.id)?.is_none() {
|
||||
update_recording(connection, track.recording.clone())?;
|
||||
}
|
||||
|
||||
// Add the actual track data.
|
||||
|
||||
let work_parts = track
|
||||
.work_parts
|
||||
.iter()
|
||||
.map(|part_index| part_index.to_string())
|
||||
.collect::<Vec<String>>()
|
||||
.join(",");
|
||||
|
||||
let track_row = TrackRow {
|
||||
id: generate_id(),
|
||||
medium: Some(medium_id.to_owned()),
|
||||
index: index as i32,
|
||||
recording: track.recording.id.clone(),
|
||||
work_parts,
|
||||
source_index: track.source_index as i32,
|
||||
path: track.path.clone(),
|
||||
last_used: Some(Utc::now().timestamp()),
|
||||
last_played: track.last_played.map(|t| t.timestamp()),
|
||||
};
|
||||
|
||||
diesel::insert_into(tracks::table)
|
||||
.values(track_row)
|
||||
.execute(connection)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get an existing medium.
|
||||
pub fn get_medium(connection: &mut SqliteConnection, id: &str) -> Result<Option<Medium>> {
|
||||
let row = mediums::table
|
||||
.filter(mediums::id.eq(id))
|
||||
.load::<MediumRow>(connection)?
|
||||
.into_iter()
|
||||
.next();
|
||||
|
||||
let medium = match row {
|
||||
Some(row) => Some(get_medium_data(connection, row)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
Ok(medium)
|
||||
}
|
||||
|
||||
/// Get mediums that have a specific source ID.
|
||||
pub fn get_mediums_by_source_id(
|
||||
connection: &mut SqliteConnection,
|
||||
source_id: &str,
|
||||
) -> Result<Vec<Medium>> {
|
||||
let mut mediums: Vec<Medium> = Vec::new();
|
||||
|
||||
let rows = mediums::table
|
||||
.filter(mediums::discid.nullable().eq(source_id))
|
||||
.load::<MediumRow>(connection)?;
|
||||
|
||||
for row in rows {
|
||||
let medium = get_medium_data(connection, row)?;
|
||||
mediums.push(medium);
|
||||
}
|
||||
|
||||
Ok(mediums)
|
||||
}
|
||||
|
||||
/// Get mediums on which this person is performing.
|
||||
pub fn get_mediums_for_person(
|
||||
connection: &mut SqliteConnection,
|
||||
person_id: &str,
|
||||
) -> Result<Vec<Medium>> {
|
||||
let mut mediums: Vec<Medium> = Vec::new();
|
||||
|
||||
let rows = mediums::table
|
||||
.inner_join(tracks::table.on(tracks::medium.eq(mediums::id.nullable())))
|
||||
.inner_join(recordings::table.on(recordings::id.eq(tracks::recording)))
|
||||
.inner_join(performances::table.on(performances::recording.eq(recordings::id)))
|
||||
.inner_join(persons::table.on(persons::id.nullable().eq(performances::person)))
|
||||
.filter(persons::id.eq(person_id))
|
||||
.select(mediums::table::all_columns())
|
||||
.distinct()
|
||||
.load::<MediumRow>(connection)?;
|
||||
|
||||
for row in rows {
|
||||
let medium = get_medium_data(connection, row)?;
|
||||
mediums.push(medium);
|
||||
}
|
||||
|
||||
Ok(mediums)
|
||||
}
|
||||
|
||||
/// Get mediums on which this ensemble is performing.
|
||||
pub fn get_mediums_for_ensemble(
|
||||
connection: &mut SqliteConnection,
|
||||
ensemble_id: &str,
|
||||
) -> Result<Vec<Medium>> {
|
||||
let mut mediums: Vec<Medium> = Vec::new();
|
||||
|
||||
let rows = mediums::table
|
||||
.inner_join(tracks::table.on(tracks::medium.eq(tracks::id.nullable())))
|
||||
.inner_join(recordings::table.on(recordings::id.eq(tracks::recording)))
|
||||
.inner_join(performances::table.on(performances::recording.eq(recordings::id)))
|
||||
.inner_join(ensembles::table.on(ensembles::id.nullable().eq(performances::ensemble)))
|
||||
.filter(ensembles::id.eq(ensemble_id))
|
||||
.select(mediums::table::all_columns())
|
||||
.distinct()
|
||||
.load::<MediumRow>(connection)?;
|
||||
|
||||
for row in rows {
|
||||
let medium = get_medium_data(connection, row)?;
|
||||
mediums.push(medium);
|
||||
}
|
||||
|
||||
Ok(mediums)
|
||||
}
|
||||
|
||||
/// Delete a medium and all of its tracks. This will fail, if the music
|
||||
/// library contains audio files referencing any of those tracks.
|
||||
pub fn delete_medium(connection: &mut SqliteConnection, id: &str) -> Result<()> {
|
||||
info!("Deleting medium {}", id);
|
||||
diesel::delete(mediums::table.filter(mediums::id.eq(id))).execute(connection)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all available tracks for a recording.
|
||||
pub fn get_tracks(connection: &mut SqliteConnection, recording_id: &str) -> Result<Vec<Track>> {
|
||||
let mut tracks: Vec<Track> = Vec::new();
|
||||
|
||||
let rows = tracks::table
|
||||
.inner_join(recordings::table.on(recordings::id.eq(tracks::recording)))
|
||||
.filter(recordings::id.eq(recording_id))
|
||||
.select(tracks::table::all_columns())
|
||||
.load::<TrackRow>(connection)?;
|
||||
|
||||
for row in rows {
|
||||
let track = get_track_from_row(connection, row)?;
|
||||
tracks.push(track);
|
||||
}
|
||||
|
||||
Ok(tracks)
|
||||
}
|
||||
|
||||
/// Get a random track from the database.
|
||||
pub fn random_track(connection: &mut SqliteConnection) -> Result<Track> {
|
||||
let row = diesel::sql_query("SELECT * FROM tracks ORDER BY RANDOM() LIMIT 1")
|
||||
.load::<TrackRow>(connection)?
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or(Error::Other("Failed to generate random track"))?;
|
||||
|
||||
get_track_from_row(connection, row)
|
||||
}
|
||||
|
||||
/// Retrieve all available information on a medium from related tables.
|
||||
fn get_medium_data(connection: &mut SqliteConnection, row: MediumRow) -> Result<Medium> {
|
||||
let track_rows = tracks::table
|
||||
.filter(tracks::medium.eq(&row.id))
|
||||
.order_by(tracks::index)
|
||||
.load::<TrackRow>(connection)?;
|
||||
|
||||
let mut tracks = Vec::new();
|
||||
|
||||
for track_row in track_rows {
|
||||
let track = get_track_from_row(connection, track_row)?;
|
||||
tracks.push(track);
|
||||
}
|
||||
|
||||
let medium = Medium {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
discid: row.discid,
|
||||
tracks,
|
||||
last_used: row.last_used.map(|t| Utc.timestamp_opt(t, 0).unwrap()),
|
||||
last_played: row.last_played.map(|t| Utc.timestamp_opt(t, 0).unwrap()),
|
||||
};
|
||||
|
||||
Ok(medium)
|
||||
}
|
||||
|
||||
/// Convert a track row from the database to an actual track.
|
||||
fn get_track_from_row(connection: &mut SqliteConnection, row: TrackRow) -> Result<Track> {
|
||||
let recording_id = row.recording;
|
||||
|
||||
let recording = get_recording(connection, &recording_id)?
|
||||
.ok_or(Error::MissingItem("recording", recording_id))?;
|
||||
|
||||
let mut part_indices = Vec::new();
|
||||
|
||||
let work_parts = row.work_parts.split(',');
|
||||
|
||||
for part_index in work_parts {
|
||||
if !part_index.is_empty() {
|
||||
let index = str::parse(part_index)
|
||||
.map_err(|_| Error::ParsingError("part index", String::from(part_index)))?;
|
||||
|
||||
part_indices.push(index);
|
||||
}
|
||||
}
|
||||
|
||||
let track = Track {
|
||||
recording,
|
||||
work_parts: part_indices,
|
||||
source_index: row.source_index as usize,
|
||||
path: row.path,
|
||||
last_used: row.last_used.map(|t| Utc.timestamp_opt(t, 0).unwrap()),
|
||||
last_played: row.last_played.map(|t| Utc.timestamp_opt(t, 0).unwrap()),
|
||||
};
|
||||
|
||||
Ok(track)
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
use chrono::Utc;
|
||||
use diesel::prelude::*;
|
||||
use log::info;
|
||||
|
||||
use crate::{defer_foreign_keys, schema::persons, Result};
|
||||
|
||||
/// A person that is a composer, an interpret or both.
|
||||
#[derive(Insertable, Queryable, PartialEq, Eq, Hash, Debug, Clone)]
|
||||
pub struct Person {
|
||||
pub id: String,
|
||||
pub first_name: String,
|
||||
pub last_name: String,
|
||||
pub last_used: Option<i64>,
|
||||
pub last_played: Option<i64>,
|
||||
}
|
||||
|
||||
impl Person {
|
||||
pub fn new(id: String, first_name: String, last_name: String) -> Self {
|
||||
Self {
|
||||
id,
|
||||
first_name,
|
||||
last_name,
|
||||
last_used: Some(Utc::now().timestamp()),
|
||||
last_played: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the full name in the form "First Last".
|
||||
pub fn name_fl(&self) -> String {
|
||||
format!("{} {}", self.first_name, self.last_name)
|
||||
}
|
||||
|
||||
/// Get the full name in the form "Last, First".
|
||||
pub fn name_lf(&self) -> String {
|
||||
format!("{}, {}", self.last_name, self.first_name)
|
||||
}
|
||||
}
|
||||
/// Update an existing person or insert a new one.
|
||||
pub fn update_person(connection: &mut SqliteConnection, mut person: Person) -> Result<()> {
|
||||
info!("Updating person {:?}", person);
|
||||
defer_foreign_keys(connection)?;
|
||||
|
||||
person.last_used = Some(Utc::now().timestamp());
|
||||
|
||||
connection.transaction(|connection| {
|
||||
diesel::replace_into(persons::table)
|
||||
.values(person)
|
||||
.execute(connection)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get an existing person.
|
||||
pub fn get_person(connection: &mut SqliteConnection, id: &str) -> Result<Option<Person>> {
|
||||
let person = persons::table
|
||||
.filter(persons::id.eq(id))
|
||||
.load::<Person>(connection)?
|
||||
.into_iter()
|
||||
.next();
|
||||
|
||||
Ok(person)
|
||||
}
|
||||
|
||||
/// Delete an existing person.
|
||||
pub fn delete_person(connection: &mut SqliteConnection, id: &str) -> Result<()> {
|
||||
info!("Deleting person {}", id);
|
||||
diesel::delete(persons::table.filter(persons::id.eq(id))).execute(connection)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all existing persons.
|
||||
pub fn get_persons(connection: &mut SqliteConnection) -> Result<Vec<Person>> {
|
||||
let persons = persons::table.load::<Person>(connection)?;
|
||||
|
||||
Ok(persons)
|
||||
}
|
||||
|
||||
/// Get recently used persons.
|
||||
pub fn get_recent_persons(connection: &mut SqliteConnection) -> Result<Vec<Person>> {
|
||||
let persons = persons::table
|
||||
.order(persons::last_used.desc())
|
||||
.load::<Person>(connection)?;
|
||||
|
||||
Ok(persons)
|
||||
}
|
||||
|
|
@ -1,350 +0,0 @@
|
|||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use diesel::prelude::*;
|
||||
use log::info;
|
||||
|
||||
use crate::{
|
||||
defer_foreign_keys, generate_id, get_ensemble, get_instrument, get_person, get_work,
|
||||
schema::{ensembles, performances, persons, recordings},
|
||||
update_ensemble, update_instrument, update_person, update_work, Ensemble, Error, Instrument,
|
||||
Person, Result, Work,
|
||||
};
|
||||
|
||||
/// A specific recording of a work.
|
||||
#[derive(PartialEq, Eq, Hash, Debug, Clone)]
|
||||
pub struct Recording {
|
||||
pub id: String,
|
||||
pub work: Work,
|
||||
pub comment: String,
|
||||
pub performances: Vec<Performance>,
|
||||
pub last_used: Option<DateTime<Utc>>,
|
||||
pub last_played: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl Recording {
|
||||
pub fn new(id: String, work: Work, comment: String, performances: Vec<Performance>) -> Self {
|
||||
Self {
|
||||
id,
|
||||
work,
|
||||
comment,
|
||||
performances,
|
||||
last_used: Some(Utc::now()),
|
||||
last_played: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize a new recording with a work.
|
||||
pub fn from_work(work: Work) -> Self {
|
||||
Self {
|
||||
id: generate_id(),
|
||||
work,
|
||||
comment: String::new(),
|
||||
performances: Vec::new(),
|
||||
last_used: Some(Utc::now()),
|
||||
last_played: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a string representation of the performances in this recording.
|
||||
// TODO: Maybe replace with impl Display?
|
||||
pub fn get_performers(&self) -> String {
|
||||
let texts: Vec<String> = self
|
||||
.performances
|
||||
.iter()
|
||||
.map(|performance| performance.get_title())
|
||||
.collect();
|
||||
|
||||
texts.join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
/// How a person or ensemble was involved in a recording.
|
||||
#[derive(PartialEq, Eq, Hash, Debug, Clone)]
|
||||
pub struct Performance {
|
||||
pub performer: PersonOrEnsemble,
|
||||
pub role: Option<Instrument>,
|
||||
}
|
||||
|
||||
impl Performance {
|
||||
/// Get a string representation of the performance.
|
||||
// TODO: Replace with impl Display.
|
||||
pub fn get_title(&self) -> String {
|
||||
let performer_title = self.performer.get_title();
|
||||
|
||||
if let Some(role) = &self.role {
|
||||
format!("{} ({})", performer_title, role.name)
|
||||
} else {
|
||||
performer_title
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Either a person or an ensemble.
|
||||
#[derive(PartialEq, Eq, Hash, Clone, Debug)]
|
||||
pub enum PersonOrEnsemble {
|
||||
Person(Person),
|
||||
Ensemble(Ensemble),
|
||||
}
|
||||
|
||||
impl PersonOrEnsemble {
|
||||
/// Get a short textual representation of the item.
|
||||
pub fn get_title(&self) -> String {
|
||||
match self {
|
||||
PersonOrEnsemble::Person(person) => person.name_lf(),
|
||||
PersonOrEnsemble::Ensemble(ensemble) => ensemble.name.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Database table data for a recording.
|
||||
#[derive(Insertable, Queryable, QueryableByName, Debug, Clone)]
|
||||
#[diesel(table_name = recordings)]
|
||||
struct RecordingRow {
|
||||
pub id: String,
|
||||
pub work: String,
|
||||
pub comment: String,
|
||||
pub last_used: Option<i64>,
|
||||
pub last_played: Option<i64>,
|
||||
}
|
||||
|
||||
impl From<Recording> for RecordingRow {
|
||||
fn from(recording: Recording) -> Self {
|
||||
RecordingRow {
|
||||
id: recording.id,
|
||||
work: recording.work.id,
|
||||
comment: recording.comment,
|
||||
last_used: Some(Utc::now().timestamp()),
|
||||
last_played: recording.last_played.map(|t| t.timestamp()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Database table data for a performance.
|
||||
#[derive(Insertable, Queryable, Debug, Clone)]
|
||||
#[diesel(table_name = performances)]
|
||||
struct PerformanceRow {
|
||||
pub id: i64,
|
||||
pub recording: String,
|
||||
pub person: Option<String>,
|
||||
pub ensemble: Option<String>,
|
||||
pub role: Option<String>,
|
||||
}
|
||||
|
||||
/// Update an existing recording or insert a new one.
|
||||
// TODO: Think about whether to also insert the other items.
|
||||
pub fn update_recording(connection: &mut SqliteConnection, recording: Recording) -> Result<()> {
|
||||
info!("Updating recording {:?}", recording);
|
||||
defer_foreign_keys(connection)?;
|
||||
|
||||
connection.transaction::<(), Error, _>(|connection| {
|
||||
let recording_id = &recording.id;
|
||||
delete_recording(connection, recording_id)?;
|
||||
|
||||
// Add associated items from the server, if they don't already exist.
|
||||
|
||||
if get_work(connection, &recording.work.id)?.is_none() {
|
||||
update_work(connection, recording.work.clone())?;
|
||||
}
|
||||
|
||||
for performance in &recording.performances {
|
||||
match &performance.performer {
|
||||
PersonOrEnsemble::Person(person) => {
|
||||
if get_person(connection, &person.id)?.is_none() {
|
||||
update_person(connection, person.clone())?;
|
||||
}
|
||||
}
|
||||
PersonOrEnsemble::Ensemble(ensemble) => {
|
||||
if get_ensemble(connection, &ensemble.id)?.is_none() {
|
||||
update_ensemble(connection, ensemble.clone())?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(role) = &performance.role {
|
||||
if get_instrument(connection, &role.id)?.is_none() {
|
||||
update_instrument(connection, role.clone())?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add the actual recording.
|
||||
|
||||
let row: RecordingRow = recording.clone().into();
|
||||
diesel::insert_into(recordings::table)
|
||||
.values(row)
|
||||
.execute(connection)?;
|
||||
|
||||
for performance in recording.performances {
|
||||
let (person, ensemble) = match performance.performer {
|
||||
PersonOrEnsemble::Person(person) => (Some(person.id), None),
|
||||
PersonOrEnsemble::Ensemble(ensemble) => (None, Some(ensemble.id)),
|
||||
};
|
||||
|
||||
let row = PerformanceRow {
|
||||
id: rand::random(),
|
||||
recording: recording_id.to_string(),
|
||||
person,
|
||||
ensemble,
|
||||
role: performance.role.map(|role| role.id),
|
||||
};
|
||||
|
||||
diesel::insert_into(performances::table)
|
||||
.values(row)
|
||||
.execute(connection)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check whether the database contains a recording.
|
||||
pub fn recording_exists(connection: &mut SqliteConnection, id: &str) -> Result<bool> {
|
||||
let exists = recordings::table
|
||||
.filter(recordings::id.eq(id))
|
||||
.load::<RecordingRow>(connection)?
|
||||
.first()
|
||||
.is_some();
|
||||
|
||||
Ok(exists)
|
||||
}
|
||||
|
||||
/// Get an existing recording.
|
||||
pub fn get_recording(connection: &mut SqliteConnection, id: &str) -> Result<Option<Recording>> {
|
||||
let row = recordings::table
|
||||
.filter(recordings::id.eq(id))
|
||||
.load::<RecordingRow>(connection)?
|
||||
.into_iter()
|
||||
.next();
|
||||
|
||||
let recording = match row {
|
||||
Some(row) => Some(get_recording_data(connection, row)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
Ok(recording)
|
||||
}
|
||||
|
||||
/// Get a random recording from the database.
|
||||
pub fn random_recording(connection: &mut SqliteConnection) -> Result<Recording> {
|
||||
let row = diesel::sql_query("SELECT * FROM recordings ORDER BY RANDOM() LIMIT 1")
|
||||
.load::<RecordingRow>(connection)?
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or(Error::Other("Failed to find random recording."))?;
|
||||
|
||||
get_recording_data(connection, row)
|
||||
}
|
||||
|
||||
/// Retrieve all available information on a recording from related tables.
|
||||
fn get_recording_data(connection: &mut SqliteConnection, row: RecordingRow) -> Result<Recording> {
|
||||
let mut performance_descriptions: Vec<Performance> = Vec::new();
|
||||
|
||||
let performance_rows = performances::table
|
||||
.filter(performances::recording.eq(&row.id))
|
||||
.load::<PerformanceRow>(connection)?;
|
||||
|
||||
for row in performance_rows {
|
||||
performance_descriptions.push(Performance {
|
||||
performer: if let Some(id) = row.person {
|
||||
PersonOrEnsemble::Person(
|
||||
get_person(connection, &id)?.ok_or(Error::MissingItem("person", id))?,
|
||||
)
|
||||
} else if let Some(id) = row.ensemble {
|
||||
PersonOrEnsemble::Ensemble(
|
||||
get_ensemble(connection, &id)?.ok_or(Error::MissingItem("ensemble", id))?,
|
||||
)
|
||||
} else {
|
||||
return Err(Error::Other("Performance without performer"));
|
||||
},
|
||||
role: match row.role {
|
||||
Some(id) => Some(
|
||||
get_instrument(connection, &id)?.ok_or(Error::MissingItem("instrument", id))?,
|
||||
),
|
||||
None => None,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let work_id = row.work;
|
||||
let work = get_work(connection, &work_id)?.ok_or(Error::MissingItem("work", work_id))?;
|
||||
|
||||
let recording_description = Recording {
|
||||
id: row.id,
|
||||
work,
|
||||
comment: row.comment,
|
||||
performances: performance_descriptions,
|
||||
last_used: row.last_used.map(|t| Utc.timestamp_opt(t, 0).unwrap()),
|
||||
last_played: row.last_played.map(|t| Utc.timestamp_opt(t, 0).unwrap()),
|
||||
};
|
||||
|
||||
Ok(recording_description)
|
||||
}
|
||||
|
||||
/// Get all available information on all recordings where a person is performing.
|
||||
pub fn get_recordings_for_person(
|
||||
connection: &mut SqliteConnection,
|
||||
person_id: &str,
|
||||
) -> Result<Vec<Recording>> {
|
||||
let mut recordings: Vec<Recording> = Vec::new();
|
||||
|
||||
let rows = recordings::table
|
||||
.inner_join(performances::table.on(performances::recording.eq(recordings::id)))
|
||||
.inner_join(persons::table.on(persons::id.nullable().eq(performances::person)))
|
||||
.filter(persons::id.eq(person_id))
|
||||
.select(recordings::table::all_columns())
|
||||
.load::<RecordingRow>(connection)?;
|
||||
|
||||
for row in rows {
|
||||
recordings.push(get_recording_data(connection, row)?);
|
||||
}
|
||||
|
||||
Ok(recordings)
|
||||
}
|
||||
|
||||
/// Get all available information on all recordings where an ensemble is performing.
|
||||
pub fn get_recordings_for_ensemble(
|
||||
connection: &mut SqliteConnection,
|
||||
ensemble_id: &str,
|
||||
) -> Result<Vec<Recording>> {
|
||||
let mut recordings: Vec<Recording> = Vec::new();
|
||||
|
||||
let rows = recordings::table
|
||||
.inner_join(performances::table.on(performances::recording.eq(recordings::id)))
|
||||
.inner_join(ensembles::table.on(ensembles::id.nullable().eq(performances::ensemble)))
|
||||
.filter(ensembles::id.eq(ensemble_id))
|
||||
.select(recordings::table::all_columns())
|
||||
.load::<RecordingRow>(connection)?;
|
||||
|
||||
for row in rows {
|
||||
recordings.push(get_recording_data(connection, row)?);
|
||||
}
|
||||
|
||||
Ok(recordings)
|
||||
}
|
||||
|
||||
/// Get allavailable information on all recordings of a work.
|
||||
pub fn get_recordings_for_work(
|
||||
connection: &mut SqliteConnection,
|
||||
work_id: &str,
|
||||
) -> Result<Vec<Recording>> {
|
||||
let mut recordings: Vec<Recording> = Vec::new();
|
||||
|
||||
let rows = recordings::table
|
||||
.filter(recordings::work.eq(work_id))
|
||||
.load::<RecordingRow>(connection)?;
|
||||
|
||||
for row in rows {
|
||||
recordings.push(get_recording_data(connection, row)?);
|
||||
}
|
||||
|
||||
Ok(recordings)
|
||||
}
|
||||
|
||||
/// Delete an existing recording. This will fail if there are still references to this
|
||||
/// recording from other tables that are not directly part of the recording data.
|
||||
pub fn delete_recording(connection: &mut SqliteConnection, id: &str) -> Result<()> {
|
||||
info!("Deleting recording {}", id);
|
||||
diesel::delete(recordings::table.filter(recordings::id.eq(id))).execute(connection)?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
// @generated automatically by Diesel CLI.
|
||||
|
||||
diesel::table! {
|
||||
ensembles (id) {
|
||||
id -> Text,
|
||||
name -> Text,
|
||||
last_used -> Nullable<BigInt>,
|
||||
last_played -> Nullable<BigInt>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
instrumentations (id) {
|
||||
id -> BigInt,
|
||||
work -> Text,
|
||||
instrument -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
instruments (id) {
|
||||
id -> Text,
|
||||
name -> Text,
|
||||
last_used -> Nullable<BigInt>,
|
||||
last_played -> Nullable<BigInt>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
mediums (id) {
|
||||
id -> Text,
|
||||
name -> Text,
|
||||
discid -> Nullable<Text>,
|
||||
last_used -> Nullable<BigInt>,
|
||||
last_played -> Nullable<BigInt>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
performances (id) {
|
||||
id -> BigInt,
|
||||
recording -> Text,
|
||||
person -> Nullable<Text>,
|
||||
ensemble -> Nullable<Text>,
|
||||
role -> Nullable<Text>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
persons (id) {
|
||||
id -> Text,
|
||||
first_name -> Text,
|
||||
last_name -> Text,
|
||||
last_used -> Nullable<BigInt>,
|
||||
last_played -> Nullable<BigInt>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
recordings (id) {
|
||||
id -> Text,
|
||||
work -> Text,
|
||||
comment -> Text,
|
||||
last_used -> Nullable<BigInt>,
|
||||
last_played -> Nullable<BigInt>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
tracks (id) {
|
||||
id -> Text,
|
||||
medium -> Nullable<Text>,
|
||||
index -> Integer,
|
||||
recording -> Text,
|
||||
work_parts -> Text,
|
||||
source_index -> Integer,
|
||||
path -> Text,
|
||||
last_used -> Nullable<BigInt>,
|
||||
last_played -> Nullable<BigInt>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
work_parts (id) {
|
||||
id -> BigInt,
|
||||
work -> Text,
|
||||
part_index -> BigInt,
|
||||
title -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
works (id) {
|
||||
id -> Text,
|
||||
composer -> Text,
|
||||
title -> Text,
|
||||
last_used -> Nullable<BigInt>,
|
||||
last_played -> Nullable<BigInt>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::joinable!(instrumentations -> instruments (instrument));
|
||||
diesel::joinable!(instrumentations -> works (work));
|
||||
diesel::joinable!(performances -> ensembles (ensemble));
|
||||
diesel::joinable!(performances -> instruments (role));
|
||||
diesel::joinable!(performances -> persons (person));
|
||||
diesel::joinable!(performances -> recordings (recording));
|
||||
diesel::joinable!(recordings -> works (work));
|
||||
diesel::joinable!(tracks -> mediums (medium));
|
||||
diesel::joinable!(tracks -> recordings (recording));
|
||||
diesel::joinable!(work_parts -> works (work));
|
||||
diesel::joinable!(works -> persons (composer));
|
||||
|
||||
diesel::allow_tables_to_appear_in_same_query!(
|
||||
ensembles,
|
||||
instrumentations,
|
||||
instruments,
|
||||
mediums,
|
||||
performances,
|
||||
persons,
|
||||
recordings,
|
||||
tracks,
|
||||
work_parts,
|
||||
works,
|
||||
);
|
||||
|
|
@ -1,252 +0,0 @@
|
|||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use diesel::{prelude::*, Insertable, Queryable};
|
||||
use log::info;
|
||||
|
||||
use crate::{
|
||||
defer_foreign_keys, generate_id, get_instrument, get_person,
|
||||
schema::{instrumentations, work_parts, works},
|
||||
update_instrument, update_person, Error, Instrument, Person, Result,
|
||||
};
|
||||
|
||||
/// Table row data for a work.
|
||||
#[derive(Insertable, Queryable, Debug, Clone)]
|
||||
#[diesel(table_name = works)]
|
||||
struct WorkRow {
|
||||
pub id: String,
|
||||
pub composer: String,
|
||||
pub title: String,
|
||||
pub last_used: Option<i64>,
|
||||
pub last_played: Option<i64>,
|
||||
}
|
||||
|
||||
impl From<Work> for WorkRow {
|
||||
fn from(work: Work) -> Self {
|
||||
WorkRow {
|
||||
id: work.id,
|
||||
composer: work.composer.id,
|
||||
title: work.title,
|
||||
last_used: Some(Utc::now().timestamp()),
|
||||
last_played: work.last_played.map(|t| t.timestamp()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Definition that a work uses an instrument.
|
||||
#[derive(Insertable, Queryable, Debug, Clone)]
|
||||
#[diesel(table_name = instrumentations)]
|
||||
struct InstrumentationRow {
|
||||
pub id: i64,
|
||||
pub work: String,
|
||||
pub instrument: String,
|
||||
}
|
||||
|
||||
/// Table row data for a work part.
|
||||
#[derive(Insertable, Queryable, Debug, Clone)]
|
||||
#[diesel(table_name = work_parts)]
|
||||
struct WorkPartRow {
|
||||
pub id: i64,
|
||||
pub work: String,
|
||||
pub part_index: i64,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
/// A concrete work part that can be recorded.
|
||||
#[derive(PartialEq, Eq, Hash, Debug, Clone)]
|
||||
pub struct WorkPart {
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
/// A specific work by a composer.
|
||||
#[derive(PartialEq, Eq, Hash, Debug, Clone)]
|
||||
pub struct Work {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub composer: Person,
|
||||
pub instruments: Vec<Instrument>,
|
||||
pub parts: Vec<WorkPart>,
|
||||
pub last_used: Option<DateTime<Utc>>,
|
||||
pub last_played: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl Work {
|
||||
pub fn new(
|
||||
id: String,
|
||||
title: String,
|
||||
composer: Person,
|
||||
instruments: Vec<Instrument>,
|
||||
parts: Vec<WorkPart>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
title,
|
||||
composer,
|
||||
instruments,
|
||||
parts,
|
||||
last_used: Some(Utc::now()),
|
||||
last_played: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize a new work with a composer.
|
||||
pub fn from_composer(composer: Person) -> Self {
|
||||
Self {
|
||||
id: generate_id(),
|
||||
title: String::new(),
|
||||
composer,
|
||||
instruments: Vec::new(),
|
||||
parts: Vec::new(),
|
||||
last_used: Some(Utc::now()),
|
||||
last_played: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a string including the composer and title of the work.
|
||||
// TODO: Replace with impl Display.
|
||||
pub fn get_title(&self) -> String {
|
||||
format!("{}: {}", self.composer.name_fl(), self.title)
|
||||
}
|
||||
}
|
||||
|
||||
/// Update an existing work or insert a new one.
|
||||
// TODO: Think about also inserting related items.
|
||||
pub fn update_work(connection: &mut SqliteConnection, work: Work) -> Result<()> {
|
||||
info!("Updating work {:?}", work);
|
||||
defer_foreign_keys(connection)?;
|
||||
|
||||
connection.transaction::<(), Error, _>(|connection| {
|
||||
let work_id = &work.id;
|
||||
delete_work(connection, work_id)?;
|
||||
|
||||
// Add associated items from the server, if they don't already exist.
|
||||
|
||||
if get_person(connection, &work.composer.id)?.is_none() {
|
||||
update_person(connection, work.composer.clone())?;
|
||||
}
|
||||
|
||||
for instrument in &work.instruments {
|
||||
if get_instrument(connection, &instrument.id)?.is_none() {
|
||||
update_instrument(connection, instrument.clone())?;
|
||||
}
|
||||
}
|
||||
|
||||
// Add the actual work.
|
||||
|
||||
let row: WorkRow = work.clone().into();
|
||||
diesel::insert_into(works::table)
|
||||
.values(row)
|
||||
.execute(connection)?;
|
||||
|
||||
let Work {
|
||||
instruments, parts, ..
|
||||
} = work;
|
||||
|
||||
for instrument in instruments {
|
||||
let row = InstrumentationRow {
|
||||
id: rand::random(),
|
||||
work: work_id.to_string(),
|
||||
instrument: instrument.id,
|
||||
};
|
||||
|
||||
diesel::insert_into(instrumentations::table)
|
||||
.values(row)
|
||||
.execute(connection)?;
|
||||
}
|
||||
|
||||
for (index, part) in parts.into_iter().enumerate() {
|
||||
let row = WorkPartRow {
|
||||
id: rand::random(),
|
||||
work: work_id.to_string(),
|
||||
part_index: index as i64,
|
||||
title: part.title,
|
||||
};
|
||||
|
||||
diesel::insert_into(work_parts::table)
|
||||
.values(row)
|
||||
.execute(connection)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get an existing work.
|
||||
pub fn get_work(connection: &mut SqliteConnection, id: &str) -> Result<Option<Work>> {
|
||||
let row = works::table
|
||||
.filter(works::id.eq(id))
|
||||
.load::<WorkRow>(connection)?
|
||||
.first()
|
||||
.cloned();
|
||||
|
||||
let work = match row {
|
||||
Some(row) => Some(get_work_data(connection, row)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
Ok(work)
|
||||
}
|
||||
|
||||
/// Retrieve all available information on a work from related tables.
|
||||
fn get_work_data(connection: &mut SqliteConnection, row: WorkRow) -> Result<Work> {
|
||||
let mut instruments: Vec<Instrument> = Vec::new();
|
||||
|
||||
let instrumentations = instrumentations::table
|
||||
.filter(instrumentations::work.eq(&row.id))
|
||||
.load::<InstrumentationRow>(connection)?;
|
||||
|
||||
for instrumentation in instrumentations {
|
||||
let id = instrumentation.instrument;
|
||||
instruments
|
||||
.push(get_instrument(connection, &id)?.ok_or(Error::MissingItem("instrument", id))?);
|
||||
}
|
||||
|
||||
let mut parts: Vec<WorkPart> = Vec::new();
|
||||
|
||||
let part_rows = work_parts::table
|
||||
.filter(work_parts::work.eq(&row.id))
|
||||
.load::<WorkPartRow>(connection)?;
|
||||
|
||||
for part_row in part_rows {
|
||||
parts.push(WorkPart {
|
||||
title: part_row.title,
|
||||
});
|
||||
}
|
||||
|
||||
let person_id = row.composer;
|
||||
let person =
|
||||
get_person(connection, &person_id)?.ok_or(Error::MissingItem("person", person_id))?;
|
||||
|
||||
Ok(Work {
|
||||
id: row.id,
|
||||
composer: person,
|
||||
title: row.title,
|
||||
instruments,
|
||||
parts,
|
||||
last_used: row.last_used.map(|t| Utc.timestamp_opt(t, 0).unwrap()),
|
||||
last_played: row.last_played.map(|t| Utc.timestamp_opt(t, 0).unwrap()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Delete an existing work. This will fail if there are still other tables that relate to
|
||||
/// this work except for the things that are part of the information on the work it
|
||||
pub fn delete_work(connection: &mut SqliteConnection, id: &str) -> Result<()> {
|
||||
info!("Deleting work {}", id);
|
||||
diesel::delete(works::table.filter(works::id.eq(id))).execute(connection)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all existing works by a composer and related information from other tables.
|
||||
pub fn get_works(connection: &mut SqliteConnection, composer_id: &str) -> Result<Vec<Work>> {
|
||||
let mut works: Vec<Work> = Vec::new();
|
||||
|
||||
let rows = works::table
|
||||
.filter(works::composer.eq(composer_id))
|
||||
.load::<WorkRow>(connection)?;
|
||||
|
||||
for row in rows {
|
||||
works.push(get_work_data(connection, row)?);
|
||||
}
|
||||
|
||||
Ok(works)
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
[package]
|
||||
name = "musicus_import"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.21"
|
||||
glib = "0.17"
|
||||
gstreamer = "0.20"
|
||||
gstreamer-pbutils = "0.20"
|
||||
log = "0.4"
|
||||
once_cell = "1"
|
||||
rand = "0.8"
|
||||
thiserror = "1"
|
||||
sha2 = "0.10"
|
||||
tokio = { version = "1", features = ["sync"] }
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
use crate::error::{Error, Result};
|
||||
use crate::session::{ImportSession, ImportTrack, State};
|
||||
use base64::Engine;
|
||||
use gstreamer::prelude::*;
|
||||
use gstreamer::tags::{Duration, TrackNumber};
|
||||
use gstreamer::{ClockTime, ElementFactory, MessageType, MessageView, TocEntryType};
|
||||
use log::info;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::PathBuf;
|
||||
use tokio::sync::watch;
|
||||
|
||||
/// Create a new import session for the default disc drive.
|
||||
pub(super) fn new() -> Result<ImportSession> {
|
||||
let (state_sender, state_receiver) = watch::channel(State::Waiting);
|
||||
|
||||
let mut tracks = Vec::new();
|
||||
let mut hasher = Sha256::new();
|
||||
|
||||
// Build the GStreamer pipeline. It will contain a fakesink initially to be able to run it
|
||||
// forward to the paused state without specifying a file name before knowing the tracks.
|
||||
|
||||
let cdparanoiasrc = ElementFactory::make("cdparanoiasrc").build()?;
|
||||
let queue = ElementFactory::make("queue").build()?;
|
||||
let audioconvert = ElementFactory::make("audioconvert").build()?;
|
||||
let flacenc = ElementFactory::make("flacenc").build()?;
|
||||
let fakesink = gstreamer::ElementFactory::make("fakesink").build()?;
|
||||
|
||||
let pipeline = gstreamer::Pipeline::new(None);
|
||||
pipeline.add_many(&[&cdparanoiasrc, &queue, &audioconvert, &flacenc, &fakesink])?;
|
||||
gstreamer::Element::link_many(&[&cdparanoiasrc, &queue, &audioconvert, &flacenc, &fakesink])?;
|
||||
|
||||
let bus = pipeline
|
||||
.bus()
|
||||
.ok_or_else(|| Error::u(String::from("Failed to get bus from pipeline.")))?;
|
||||
|
||||
// Run the pipeline into the paused state and wait for the resulting TOC message on the bus.
|
||||
|
||||
pipeline.set_state(gstreamer::State::Paused)?;
|
||||
|
||||
let msg = bus.timed_pop_filtered(
|
||||
ClockTime::from_seconds(5),
|
||||
&[MessageType::Toc, MessageType::Error],
|
||||
);
|
||||
|
||||
let toc = match msg {
|
||||
Some(msg) => match msg.view() {
|
||||
MessageView::Error(err) => Err(Error::os(err.error())),
|
||||
MessageView::Toc(toc) => Ok(toc.toc().0),
|
||||
_ => Err(Error::u(format!(
|
||||
"Unexpected message from GStreamer: {:?}",
|
||||
msg
|
||||
))),
|
||||
},
|
||||
None => Err(Error::Timeout(
|
||||
"Timeout while waiting for first message from GStreamer.".to_string(),
|
||||
)),
|
||||
}?;
|
||||
|
||||
pipeline.set_state(gstreamer::State::Ready)?;
|
||||
|
||||
// Replace the fakesink with the real filesink. This won't need to be synced to the pipeline
|
||||
// state, because we will set the whole pipeline's state to playing later.
|
||||
|
||||
gstreamer::Element::unlink(&flacenc, &fakesink);
|
||||
fakesink.set_state(gstreamer::State::Null)?;
|
||||
pipeline.remove(&fakesink)?;
|
||||
|
||||
let filesink = gstreamer::ElementFactory::make("filesink").build()?;
|
||||
pipeline.add(&filesink)?;
|
||||
gstreamer::Element::link(&flacenc, &filesink)?;
|
||||
|
||||
// Get track data from the toc message that was received above.
|
||||
|
||||
let tmp_dir = create_tmp_dir()?;
|
||||
|
||||
for entry in toc.entries() {
|
||||
if entry.entry_type() == TocEntryType::Track {
|
||||
let duration = entry
|
||||
.tags()
|
||||
.ok_or_else(|| Error::u(String::from("No tags in TOC entry.")))?
|
||||
.get::<Duration>()
|
||||
.ok_or_else(|| Error::u(String::from("No duration tag found in TOC entry.")))?
|
||||
.get()
|
||||
.mseconds();
|
||||
|
||||
let number = entry
|
||||
.tags()
|
||||
.ok_or_else(|| Error::u(String::from("No tags in TOC entry.")))?
|
||||
.get::<TrackNumber>()
|
||||
.ok_or_else(|| Error::u(String::from("No track number tag found in TOC entry.")))?
|
||||
.get();
|
||||
|
||||
hasher.update(duration.to_le_bytes());
|
||||
|
||||
let name = format!("Track {}", number);
|
||||
|
||||
let file_name = format!("track_{:02}.flac", number);
|
||||
let mut path = tmp_dir.clone();
|
||||
path.push(file_name);
|
||||
|
||||
let track = ImportTrack {
|
||||
number,
|
||||
name,
|
||||
path,
|
||||
duration,
|
||||
};
|
||||
|
||||
tracks.push(track);
|
||||
}
|
||||
}
|
||||
|
||||
let source_id = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hasher.finalize());
|
||||
|
||||
info!("Successfully loaded audio CD with {} tracks.", tracks.len());
|
||||
info!("Source ID: {}", source_id);
|
||||
|
||||
let tracks_clone = tracks.clone();
|
||||
let copy = move || {
|
||||
for track in &tracks_clone {
|
||||
info!("Starting to rip track {}.", track.number);
|
||||
|
||||
cdparanoiasrc.set_property("track", &track.number);
|
||||
|
||||
// The filesink needs to be reset to be able to change the file location.
|
||||
filesink.set_state(gstreamer::State::Null)?;
|
||||
|
||||
let path = track.path.to_str().unwrap();
|
||||
filesink.set_property("location", &path);
|
||||
|
||||
// This will also affect the filesink as expected.
|
||||
pipeline.set_state(gstreamer::State::Playing)?;
|
||||
|
||||
for msg in bus.iter_timed(None) {
|
||||
match msg.view() {
|
||||
MessageView::Eos(..) => {
|
||||
info!("Finished ripping track {}.", track.number);
|
||||
pipeline.set_state(gstreamer::State::Ready)?;
|
||||
break;
|
||||
}
|
||||
MessageView::Error(err) => {
|
||||
pipeline.set_state(gstreamer::State::Null)?;
|
||||
return Err(Error::os(err.error()));
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pipeline.set_state(gstreamer::State::Null)?;
|
||||
|
||||
Ok(())
|
||||
};
|
||||
|
||||
let session = ImportSession {
|
||||
source_id,
|
||||
tracks,
|
||||
copy: Some(Box::new(copy)),
|
||||
state_sender,
|
||||
state_receiver,
|
||||
};
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// Create a new temporary directory and return its path.
|
||||
fn create_tmp_dir() -> Result<PathBuf> {
|
||||
let mut tmp_dir = glib::tmp_dir();
|
||||
|
||||
let dir_name = format!("musicus-{}", rand::random::<u64>());
|
||||
tmp_dir.push(dir_name);
|
||||
|
||||
std::fs::create_dir(&tmp_dir)?;
|
||||
|
||||
Ok(tmp_dir)
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
use std::error;
|
||||
|
||||
/// An error within an import session.
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum Error {
|
||||
/// A timeout was reached.
|
||||
#[error("{0}")]
|
||||
Timeout(String),
|
||||
|
||||
/// Some common error.
|
||||
#[error("{msg}")]
|
||||
Other {
|
||||
/// The error message.
|
||||
msg: String,
|
||||
|
||||
#[source]
|
||||
source: Option<Box<dyn error::Error + Send + Sync>>,
|
||||
},
|
||||
|
||||
/// Something unexpected happened.
|
||||
#[error("{msg}")]
|
||||
Unexpected {
|
||||
/// The error message.
|
||||
msg: String,
|
||||
|
||||
#[source]
|
||||
source: Option<Box<dyn error::Error + Send + Sync>>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Create a new error with an explicit source.
|
||||
pub(super) fn os(source: impl error::Error + Send + Sync + 'static) -> Self {
|
||||
Self::Unexpected {
|
||||
msg: format!("An error has happened: {}", source),
|
||||
source: Some(Box::new(source)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new unexpected error without an explicit source.
|
||||
pub(super) fn u(msg: String) -> Self {
|
||||
Self::Unexpected { msg, source: None }
|
||||
}
|
||||
|
||||
/// Create a new unexpected error with an explicit source.
|
||||
pub(super) fn us(source: impl error::Error + Send + Sync + 'static) -> Self {
|
||||
Self::Unexpected {
|
||||
msg: format!("An unexpected error has happened: {}", source),
|
||||
source: Some(Box::new(source)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<tokio::sync::oneshot::error::RecvError> for Error {
|
||||
fn from(err: tokio::sync::oneshot::error::RecvError) -> Self {
|
||||
Self::us(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<gstreamer::glib::Error> for Error {
|
||||
fn from(err: gstreamer::glib::Error) -> Self {
|
||||
Self::us(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<gstreamer::glib::BoolError> for Error {
|
||||
fn from(err: gstreamer::glib::BoolError) -> Self {
|
||||
Self::us(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<gstreamer::StateChangeError> for Error {
|
||||
fn from(err: gstreamer::StateChangeError) -> Self {
|
||||
Self::us(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for Error {
|
||||
fn from(err: std::io::Error) -> Self {
|
||||
Self::us(err)
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
use crate::error::{Error, Result};
|
||||
use crate::session::{ImportSession, ImportTrack, State};
|
||||
use base64::Engine;
|
||||
use gstreamer::ClockTime;
|
||||
use gstreamer_pbutils::Discoverer;
|
||||
use log::{info, warn};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs::DirEntry;
|
||||
use std::path::PathBuf;
|
||||
use tokio::sync::watch;
|
||||
|
||||
/// Create a new import session for the specified folder.
|
||||
pub(super) fn new(path: PathBuf) -> Result<ImportSession> {
|
||||
let (state_sender, state_receiver) = watch::channel(State::Ready);
|
||||
|
||||
let mut tracks = Vec::new();
|
||||
let mut number: u32 = 1;
|
||||
let mut hasher = Sha256::new();
|
||||
let discoverer = Discoverer::new(ClockTime::from_seconds(1))?;
|
||||
|
||||
let mut entries =
|
||||
std::fs::read_dir(path)?.collect::<std::result::Result<Vec<DirEntry>, std::io::Error>>()?;
|
||||
entries.sort_by_key(|entry| entry.file_name());
|
||||
|
||||
for entry in entries {
|
||||
if entry.file_type()?.is_file() {
|
||||
let path = entry.path();
|
||||
|
||||
let uri = glib::filename_to_uri(&path, None)
|
||||
.map_err(|_| Error::u(format!("Failed to create URI from path: {:?}", path)))?;
|
||||
|
||||
let info = discoverer.discover_uri(&uri)?;
|
||||
|
||||
if !info.audio_streams().is_empty() {
|
||||
let duration = info
|
||||
.duration()
|
||||
.ok_or_else(|| Error::u(format!("Failed to get duration for {}.", uri)))?
|
||||
.mseconds();
|
||||
|
||||
let file_name = entry.file_name();
|
||||
let name = file_name.into_string().map_err(|_| {
|
||||
Error::u(format!(
|
||||
"Failed to convert OsString to String: {:?}",
|
||||
entry.file_name()
|
||||
))
|
||||
})?;
|
||||
|
||||
hasher.update(duration.to_le_bytes());
|
||||
|
||||
let track = ImportTrack {
|
||||
number,
|
||||
name,
|
||||
path,
|
||||
duration,
|
||||
};
|
||||
|
||||
tracks.push(track);
|
||||
number += 1;
|
||||
} else {
|
||||
warn!(
|
||||
"File {} skipped, because it doesn't contain any audio streams.",
|
||||
uri
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let source_id = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hasher.finalize());
|
||||
|
||||
info!("Source ID: {}", source_id);
|
||||
|
||||
let session = ImportSession {
|
||||
source_id,
|
||||
tracks,
|
||||
copy: None,
|
||||
state_sender,
|
||||
state_receiver,
|
||||
};
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
pub use error::{Error, Result};
|
||||
pub use session::{ImportSession, ImportTrack, State};
|
||||
|
||||
pub mod error;
|
||||
pub mod session;
|
||||
|
||||
mod disc;
|
||||
mod folder;
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
use crate::error::Result;
|
||||
use crate::{disc, folder};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use tokio::sync::{oneshot, watch};
|
||||
|
||||
/// The current state of the import process.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum State {
|
||||
/// The import process has not been started yet.
|
||||
Waiting,
|
||||
|
||||
/// The audio is copied from the source.
|
||||
Copying,
|
||||
|
||||
/// The audio files are ready to be imported into the music library.
|
||||
Ready,
|
||||
|
||||
/// An error has happened.
|
||||
Error,
|
||||
}
|
||||
|
||||
/// Interface for importing audio tracks from a medium or folder.
|
||||
pub struct ImportSession {
|
||||
/// A string identifying the source as specific as possible across platforms and formats.
|
||||
pub(super) source_id: String,
|
||||
|
||||
/// The tracks that are available on the source.
|
||||
pub(super) tracks: Vec<ImportTrack>,
|
||||
|
||||
/// A closure that has to be called to copy the tracks if set.
|
||||
pub(super) copy: Option<Box<dyn Fn() -> Result<()> + Send + Sync>>,
|
||||
|
||||
/// Sender through which listeners are notified of state changes.
|
||||
pub(super) state_sender: watch::Sender<State>,
|
||||
|
||||
/// Receiver for state changes.
|
||||
pub(super) state_receiver: watch::Receiver<State>,
|
||||
}
|
||||
|
||||
impl ImportSession {
|
||||
/// Create a new import session for an audio CD.
|
||||
pub async fn audio_cd() -> Result<Arc<Self>> {
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
|
||||
thread::spawn(move || {
|
||||
let result = disc::new();
|
||||
let _ = sender.send(result);
|
||||
});
|
||||
|
||||
Ok(Arc::new(receiver.await??))
|
||||
}
|
||||
|
||||
/// Create a new import session for a folder.
|
||||
pub async fn folder(path: PathBuf) -> Result<Arc<Self>> {
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
|
||||
thread::spawn(move || {
|
||||
let result = folder::new(path);
|
||||
let _ = sender.send(result);
|
||||
});
|
||||
|
||||
Ok(Arc::new(receiver.await??))
|
||||
}
|
||||
|
||||
/// Get a string identifying the source as specific as possible across platforms and mediums.
|
||||
pub fn source_id(&self) -> &str {
|
||||
&self.source_id
|
||||
}
|
||||
|
||||
/// Get the tracks that are available on the source.
|
||||
pub fn tracks(&self) -> &[ImportTrack] {
|
||||
&self.tracks
|
||||
}
|
||||
|
||||
/// Retrieve the current state of the import process.
|
||||
pub fn state(&self) -> State {
|
||||
self.state_receiver.borrow().clone()
|
||||
}
|
||||
|
||||
/// Wait for the next state change and get the new state.
|
||||
pub async fn state_change(&self) -> State {
|
||||
let mut receiver = self.state_receiver.clone();
|
||||
match receiver.changed().await {
|
||||
Ok(()) => self.state(),
|
||||
Err(_) => State::Error,
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy the tracks to their advertised locations in the background, if neccessary. The state
|
||||
/// will be updated as the import is done.
|
||||
pub fn copy(self: &Arc<Self>) {
|
||||
if self.copy.is_some() {
|
||||
let clone = Arc::clone(self);
|
||||
|
||||
thread::spawn(move || {
|
||||
let copy = clone.copy.as_ref().unwrap();
|
||||
|
||||
match copy() {
|
||||
Ok(()) => clone.state_sender.send(State::Ready).unwrap(),
|
||||
Err(_) => clone.state_sender.send(State::Error).unwrap(),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A track on an import source.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ImportTrack {
|
||||
/// The track number.
|
||||
pub number: u32,
|
||||
|
||||
/// A human readable identifier for the track. This will be used to present the track for
|
||||
/// selection.
|
||||
pub name: String,
|
||||
|
||||
/// The path to the file where the corresponding audio file is. This file is only required to
|
||||
/// exist, once the import was successfully completed. This will not be the actual file within
|
||||
/// the user's music library, but the temporary location from which it can be copied to the
|
||||
/// music library.
|
||||
pub path: PathBuf,
|
||||
|
||||
/// The track's duration in milliseconds.
|
||||
pub duration: u64,
|
||||
}
|
||||
2
crates/musicus/.gitignore
vendored
2
crates/musicus/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
|||
/src/config.rs
|
||||
/src/resources.rs
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
[package]
|
||||
name = "musicus"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
adw = { package = "libadwaita", version = "0.3", features = ["v1_2"] }
|
||||
futures-channel = "0.3"
|
||||
gettext-rs = { version = "0.7", features = ["gettext-system"] }
|
||||
gio = "0.17"
|
||||
glib = "0.17"
|
||||
gstreamer = "0.20"
|
||||
gtk = { package = "gtk4", version = "0.6" }
|
||||
gtk-macros = "0.3"
|
||||
log = "0.4"
|
||||
musicus_backend = { version = "0.1.0", path = "../backend" }
|
||||
once_cell = "1"
|
||||
rand = "0.8"
|
||||
sanitize-filename = "0.4"
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><path d="m 2 1 c -0.550781 0 -0.992188 0.445312 -0.992188 0.992188 l -0.007812 9.007812 c 0 0.265625 0.105469 0.519531 0.292969 0.707031 s 0.441406 0.292969 0.707031 0.292969 h 2 v -6 c 0 -1.105469 0.894531 -2 2 -2 h 5 v -2 c 0 -0.550781 -0.449219 -1 -1 -1 z m 4 4 c -0.550781 0 -1 0.449219 -1 1 v 9 c 0 0.550781 0.449219 1 1 1 h 6 l 3 -3 v -7 c 0 -0.550781 -0.449219 -1 -1 -1 z m 0 0" fill="#222222"/></svg>
|
||||
|
Before Width: | Height: | Size: 535 B |
|
|
@ -1,9 +0,0 @@
|
|||
pkgdatadir = join_paths(get_option('prefix'), get_option('datadir'), meson.project_name())
|
||||
gnome = import('gnome')
|
||||
|
||||
resources = gnome.compile_resources('musicus',
|
||||
'musicus.gresource.xml',
|
||||
gresource_bundle: true,
|
||||
install: true,
|
||||
install_dir: pkgdatadir,
|
||||
)
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gresources>
|
||||
<gresource prefix="/de/johrpan/musicus">
|
||||
<file preprocess="xml-stripblanks">icons/copy-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">ui/editor.ui</file>
|
||||
<file preprocess="xml-stripblanks">ui/import_screen.ui</file>
|
||||
<file preprocess="xml-stripblanks">ui/main_screen.ui</file>
|
||||
<file preprocess="xml-stripblanks">ui/medium_editor.ui</file>
|
||||
<file preprocess="xml-stripblanks">ui/medium_preview.ui</file>
|
||||
<file preprocess="xml-stripblanks">ui/performance_editor.ui</file>
|
||||
<file preprocess="xml-stripblanks">ui/player_bar.ui</file>
|
||||
<file preprocess="xml-stripblanks">ui/player_screen.ui</file>
|
||||
<file preprocess="xml-stripblanks">ui/preferences.ui</file>
|
||||
<file preprocess="xml-stripblanks">ui/recording_editor.ui</file>
|
||||
<file preprocess="xml-stripblanks">ui/screen.ui</file>
|
||||
<file preprocess="xml-stripblanks">ui/section.ui</file>
|
||||
<file preprocess="xml-stripblanks">ui/selector.ui</file>
|
||||
<file preprocess="xml-stripblanks">ui/source_selector.ui</file>
|
||||
<file preprocess="xml-stripblanks">ui/track_editor.ui</file>
|
||||
<file preprocess="xml-stripblanks">ui/track_row.ui</file>
|
||||
<file preprocess="xml-stripblanks">ui/track_selector.ui</file>
|
||||
<file preprocess="xml-stripblanks">ui/track_set_editor.ui</file>
|
||||
<file preprocess="xml-stripblanks">ui/work_editor.ui</file>
|
||||
<file preprocess="xml-stripblanks">ui/work_part_editor.ui</file>
|
||||
</gresource>
|
||||
</gresources>
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<interface>
|
||||
<requires lib="gtk" version="4.0"/>
|
||||
<requires lib="libadwaita" version="1.0"/>
|
||||
<object class="GtkStack" id="widget">
|
||||
<property name="transition-type">crossfade</property>
|
||||
<child>
|
||||
<object class="GtkStackPage">
|
||||
<property name="name">content</property>
|
||||
<property name="child">
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="AdwHeaderBar">
|
||||
<property name="show-start-title-buttons">false</property>
|
||||
<property name="show-end-title-buttons">false</property>
|
||||
<property name="title-widget">
|
||||
<object class="AdwWindowTitle" id="window_title"/>
|
||||
</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="back_button">
|
||||
<property name="label" translatable="yes">Cancel</property>
|
||||
</object>
|
||||
</child>
|
||||
<child type="end">
|
||||
<object class="GtkButton" id="save_button">
|
||||
<property name="label" translatable="yes">Save</property>
|
||||
<style>
|
||||
<class name="suggested-action"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkScrolledWindow">
|
||||
<property name="vexpand">true</property>
|
||||
<child>
|
||||
<object class="AdwClamp">
|
||||
<child>
|
||||
<object class="GtkBox" id="content_box">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="margin-start">12</property>
|
||||
<property name="margin-end">12</property>
|
||||
<property name="margin-bottom">36</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkStackPage">
|
||||
<property name="name">error</property>
|
||||
<property name="child">
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="AdwHeaderBar">
|
||||
<property name="show-start-title-buttons">false</property>
|
||||
<property name="show-end-title-buttons">false</property>
|
||||
<property name="title-widget">
|
||||
<object class="AdwWindowTitle">
|
||||
<property name="title" translatable="yes">Error</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="AdwStatusPage" id="status_page">
|
||||
<property name="icon-name">network-error-symbolic</property>
|
||||
<property name="title">Error</property>
|
||||
<property name="vexpand">true</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="try_again_button">
|
||||
<property name="label" translatable="yes">Try again</property>
|
||||
<property name="hexpand">true</property>
|
||||
<property name="vexpand">true</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="valign">center</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<interface>
|
||||
<requires lib="gtk" version="4.0" />
|
||||
<requires lib="libadwaita" version="1.0" />
|
||||
<object class="GtkBox" id="widget">
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="AdwHeaderBar">
|
||||
<property name="show-start-title-buttons">false</property>
|
||||
<property name="show-end-title-buttons">false</property>
|
||||
<property name="title-widget">
|
||||
<object class="AdwWindowTitle" id="window_title">
|
||||
<property name="title" translatable="yes">Import music</property>
|
||||
</object>
|
||||
</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="back_button">
|
||||
<property name="icon-name">go-previous-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkScrolledWindow">
|
||||
<property name="vexpand">True</property>
|
||||
<child>
|
||||
<object class="AdwClamp">
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="margin-start">6</property>
|
||||
<property name="margin-end">6</property>
|
||||
<property name="margin-bottom">6</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="margin-top">12</property>
|
||||
<property name="margin-bottom">6</property>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="hexpand">true</property>
|
||||
<property name="halign">start</property>
|
||||
<property name="label" translatable="yes">Matching metadata</property>
|
||||
<attributes>
|
||||
<attribute name="weight" value="bold" />
|
||||
</attributes>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkStack" id="matching_stack">
|
||||
<property name="transition-type">crossfade</property>
|
||||
<property name="vhomogeneous">false</property>
|
||||
<property name="interpolate-size">true</property>
|
||||
<child>
|
||||
<object class="GtkStackPage">
|
||||
<property name="name">loading</property>
|
||||
<property name="child">
|
||||
<object class="GtkListBox">
|
||||
<property name="selection-mode">none</property>
|
||||
<child>
|
||||
<object class="AdwActionRow">
|
||||
<property name="activatable">False</property>
|
||||
<property name="title" translatable="yes">Loading…</property>
|
||||
<child>
|
||||
<object class="GtkSpinner">
|
||||
<property name="spinning">True</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<style>
|
||||
<class name="boxed-list" />
|
||||
</style>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkStackPage">
|
||||
<property name="name">error</property>
|
||||
<property name="child">
|
||||
<object class="GtkListBox">
|
||||
<property name="selection-mode">none</property>
|
||||
<child>
|
||||
<object class="AdwActionRow" id="error_row">
|
||||
<property name="focusable">False</property>
|
||||
<property name="title" translatable="yes">Error while searching for matching metadata</property>
|
||||
<property name="activatable-widget">try_again_button</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="try_again_button">
|
||||
<property name="icon-name">view-refresh-symbolic</property>
|
||||
<property name="valign">center</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<style>
|
||||
<class name="boxed-list" />
|
||||
</style>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkStackPage">
|
||||
<property name="name">empty</property>
|
||||
<property name="child">
|
||||
<object class="GtkListBox">
|
||||
<property name="selection-mode">none</property>
|
||||
<child>
|
||||
<object class="AdwActionRow">
|
||||
<property name="activatable">False</property>
|
||||
<property name="title" translatable="yes">No matching metadata found</property>
|
||||
</object>
|
||||
</child>
|
||||
<style>
|
||||
<class name="boxed-list" />
|
||||
</style>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkStackPage">
|
||||
<property name="name">content</property>
|
||||
<property name="child">
|
||||
<object class="GtkListBox" id="matching_list">
|
||||
<property name="selection-mode">none</property>
|
||||
<style>
|
||||
<class name="boxed-list" />
|
||||
</style>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="halign">start</property>
|
||||
<property name="margin-top">12</property>
|
||||
<property name="margin-bottom">6</property>
|
||||
<property name="label" translatable="yes">Manually add metadata</property>
|
||||
<attributes>
|
||||
<attribute name="weight" value="bold" />
|
||||
</attributes>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkListBox">
|
||||
<property name="selection-mode">none</property>
|
||||
<child>
|
||||
<object class="AdwActionRow">
|
||||
<property name="focusable">False</property>
|
||||
<property name="title" translatable="yes">Select existing medium</property>
|
||||
<property name="activatable-widget">select_button</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="select_button">
|
||||
<property name="label" translatable="yes">Select</property>
|
||||
<property name="valign">center</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="AdwActionRow">
|
||||
<property name="focusable">False</property>
|
||||
<property name="title" translatable="yes">Add a new medium</property>
|
||||
<property name="activatable-widget">add_button</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="add_button">
|
||||
<property name="label" translatable="yes">Add</property>
|
||||
<property name="valign">center</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<style>
|
||||
<class name="boxed-list" />
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<interface>
|
||||
<requires lib="gtk" version="4.0" />
|
||||
<requires lib="libadwaita" version="1.0" />
|
||||
<object class="GtkBox" id="empty_screen">
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="AdwHeaderBar">
|
||||
<property name="title-widget">
|
||||
<object class="GtkLabel" />
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="AdwStatusPage">
|
||||
<property name="icon-name">folder-music-symbolic</property>
|
||||
<property name="title" translatable="yes">Welcome to Musicus!</property>
|
||||
<property name="description" translatable="yes">Get startet by selecting something from the sidebar or adding new things to your library using the button in the top left corner.</property>
|
||||
<property name="vexpand">true</property>
|
||||
<child>
|
||||
<object class="GtkRevealer" id="play_button_revealer">
|
||||
<property name="reveal-child">true</property>
|
||||
<property name="transition-type">crossfade</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="play_button">
|
||||
<property name="halign">center</property>
|
||||
<property name="label" translatable="yes">Play something</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
<object class="GtkBox" id="widget">
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="AdwLeaflet" id="leaflet">
|
||||
<property name="vexpand">true</property>
|
||||
<child>
|
||||
<object class="AdwLeafletPage">
|
||||
<property name="name">sidebar</property>
|
||||
<property name="child">
|
||||
<object class="GtkBox">
|
||||
<property name="hexpand">False</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="AdwHeaderBar">
|
||||
<property name="show-start-title-buttons">false</property>
|
||||
<property name="show-end-title-buttons">false</property>
|
||||
<property name="title-widget">
|
||||
<object class="GtkLabel">
|
||||
<property name="label">Musicus</property>
|
||||
<style>
|
||||
<class name="title" />
|
||||
</style>
|
||||
</object>
|
||||
</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="add_button">
|
||||
<property name="receives-default">True</property>
|
||||
<child>
|
||||
<object class="GtkImage">
|
||||
<property name="icon-name">list-add-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child type="end">
|
||||
<object class="GtkMenuButton">
|
||||
<property name="receives-default">True</property>
|
||||
<property name="icon-name">open-menu-symbolic</property>
|
||||
<property name="menu-model">menu</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkSearchBar">
|
||||
<property name="search-mode-enabled">True</property>
|
||||
<child>
|
||||
<object class="AdwClamp">
|
||||
<property name="maximum-size">400</property>
|
||||
<property name="tightening-threshold">300</property>
|
||||
<property name="hexpand">true</property>
|
||||
<child>
|
||||
<object class="GtkSearchEntry" id="search_entry">
|
||||
<property name="placeholder-text" translatable="yes">Search persons and ensembles …</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkStack" id="stack">
|
||||
<property name="hexpand">True</property>
|
||||
<property name="transition-type">crossfade</property>
|
||||
<child>
|
||||
<object class="GtkStackPage">
|
||||
<property name="name">loading</property>
|
||||
<property name="child">
|
||||
<object class="GtkSpinner">
|
||||
<property name="spinning">True</property>
|
||||
<property name="hexpand">True</property>
|
||||
<property name="vexpand">True</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="valign">center</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkStackPage">
|
||||
<property name="name">content</property>
|
||||
<property name="child">
|
||||
<object class="GtkScrolledWindow" id="scroll">
|
||||
<child>
|
||||
<placeholder />
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="AdwLeafletPage">
|
||||
<property name="navigatable">False</property>
|
||||
<property name="child">
|
||||
<object class="GtkSeparator">
|
||||
<property name="orientation">vertical</property>
|
||||
<style>
|
||||
<class name="sidebar" />
|
||||
</style>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
<menu id="menu">
|
||||
<section>
|
||||
<item>
|
||||
<attribute name="label" translatable="yes">Preferences</attribute>
|
||||
<attribute name="action">widget.preferences</attribute>
|
||||
</item>
|
||||
<item>
|
||||
<attribute name="label" translatable="yes">Debug log</attribute>
|
||||
<attribute name="action">widget.log</attribute>
|
||||
</item>
|
||||
<item>
|
||||
<attribute name="label" translatable="yes">About Musicus</attribute>
|
||||
<attribute name="action">widget.about</attribute>
|
||||
</item>
|
||||
</section>
|
||||
</menu>
|
||||
</interface>
|
||||
|
|
@ -1,206 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<interface>
|
||||
<requires lib="gtk" version="4.0" />
|
||||
<requires lib="libadwaita" version="1.0" />
|
||||
<object class="GtkStack" id="widget">
|
||||
<property name="transition-type">crossfade</property>
|
||||
<child>
|
||||
<object class="GtkStackPage">
|
||||
<property name="name">content</property>
|
||||
<property name="child">
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="AdwHeaderBar">
|
||||
<property name="show-start-title-buttons">false</property>
|
||||
<property name="show-end-title-buttons">false</property>
|
||||
<property name="title-widget">
|
||||
<object class="GtkLabel">
|
||||
<property name="label" translatable="yes">Import music</property>
|
||||
<style>
|
||||
<class name="title" />
|
||||
</style>
|
||||
</object>
|
||||
</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="back_button">
|
||||
<property name="icon-name">go-previous-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
<child type="end">
|
||||
<object class="GtkButton" id="done_button">
|
||||
<property name="icon-name">object-select-symbolic</property>
|
||||
<style>
|
||||
<class name="suggested-action" />
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkInfoBar" id="info_bar">
|
||||
<property name="revealed">False</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkScrolledWindow">
|
||||
<property name="vexpand">True</property>
|
||||
<child>
|
||||
<object class="AdwClamp">
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="margin-start">6</property>
|
||||
<property name="margin-end">6</property>
|
||||
<property name="margin-bottom">6</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="halign">start</property>
|
||||
<property name="margin-top">12</property>
|
||||
<property name="margin-bottom">6</property>
|
||||
<property name="label" translatable="yes">Medium</property>
|
||||
<attributes>
|
||||
<attribute name="weight" value="bold" />
|
||||
</attributes>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkListBox">
|
||||
<property name="selection-mode">none</property>
|
||||
<child>
|
||||
<object class="AdwEntryRow" id="name_row">
|
||||
<property name="title" translatable="yes">Name of the medium</property>
|
||||
</object>
|
||||
</child>
|
||||
<style>
|
||||
<class name="boxed-list" />
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="margin-top">12</property>
|
||||
<property name="margin-bottom">6</property>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="halign">start</property>
|
||||
<property name="valign">end</property>
|
||||
<property name="hexpand">True</property>
|
||||
<property name="label" translatable="yes">Recordings</property>
|
||||
<attributes>
|
||||
<attribute name="weight" value="bold" />
|
||||
</attributes>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="add_button">
|
||||
<property name="has-frame">false</property>
|
||||
<property name="icon-name">list-add-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkFrame" id="frame" />
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkStackPage">
|
||||
<property name="name">loading</property>
|
||||
<property name="child">
|
||||
<object class="GtkSpinner">
|
||||
<property name="spinning">true</property>
|
||||
<property name="hexpand">true</property>
|
||||
<property name="vexpand">true</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="valign">center</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkStackPage">
|
||||
<property name="name">error</property>
|
||||
<property name="child">
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="AdwHeaderBar">
|
||||
<property name="show-start-title-buttons">false</property>
|
||||
<property name="show-end-title-buttons">false</property>
|
||||
<property name="title-widget">
|
||||
<object class="AdwWindowTitle">
|
||||
<property name="title" translatable="yes">Error</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="AdwStatusPage" id="status_page">
|
||||
<property name="icon-name">dialog-error-symbolic</property>
|
||||
<property name="title">Error</property>
|
||||
<property name="vexpand">true</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="try_again_button">
|
||||
<property name="label" translatable="yes">Try again</property>
|
||||
<property name="hexpand">true</property>
|
||||
<property name="vexpand">true</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="valign">center</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkStackPage">
|
||||
<property name="name">disc_error</property>
|
||||
<property name="child">
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="AdwHeaderBar">
|
||||
<property name="show-start-title-buttons">false</property>
|
||||
<property name="show-end-title-buttons">false</property>
|
||||
<property name="title-widget">
|
||||
<object class="AdwWindowTitle">
|
||||
<property name="title" translatable="yes">Error</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="AdwStatusPage" id="disc_status_page">
|
||||
<property name="icon-name">action-unavailable-symbolic</property>
|
||||
<property name="title">Error</property>
|
||||
<property name="vexpand">true</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="cancel_button">
|
||||
<property name="label" translatable="yes">Cancel</property>
|
||||
<property name="hexpand">true</property>
|
||||
<property name="vexpand">true</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="valign">center</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<interface>
|
||||
<requires lib="gtk" version="4.0"/>
|
||||
<requires lib="libadwaita" version="1.0"/>
|
||||
<object class="GtkStack" id="widget">
|
||||
<property name="transition-type">crossfade</property>
|
||||
<child>
|
||||
<object class="GtkStackPage">
|
||||
<property name="name">content</property>
|
||||
<property name="child">
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="AdwHeaderBar">
|
||||
<property name="show-start-title-buttons">false</property>
|
||||
<property name="show-end-title-buttons">false</property>
|
||||
<property name="title-widget">
|
||||
<object class="AdwWindowTitle" id="window_title">
|
||||
<property name="title" translatable="yes">Preview</property>
|
||||
</object>
|
||||
</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="back_button">
|
||||
<property name="icon-name">go-previous-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
<child type="end">
|
||||
<object class="GtkButton" id="import_button">
|
||||
<property name="sensitive">False</property>
|
||||
<child>
|
||||
<object class="GtkStack" id="done_stack">
|
||||
<property name="transition-type">crossfade</property>
|
||||
<property name="interpolate-size">true</property>
|
||||
<property name="hhomogeneous">false</property>
|
||||
<child>
|
||||
<object class="GtkStackPage">
|
||||
<property name="name">loading</property>
|
||||
<property name="child">
|
||||
<object class="GtkSpinner">
|
||||
<property name="spinning">True</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkStackPage">
|
||||
<property name="name">ready</property>
|
||||
<property name="child">
|
||||
<object class="GtkLabel">
|
||||
<property name="label" translatable="yes">Import</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<style>
|
||||
<class name="suggested-action"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
<child type="end">
|
||||
<object class="GtkButton" id="edit_button">
|
||||
<property name="icon-name">document-edit-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkScrolledWindow">
|
||||
<property name="vexpand">True</property>
|
||||
<child>
|
||||
<object class="AdwClamp">
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="margin-start">6</property>
|
||||
<property name="margin-end">6</property>
|
||||
<property name="margin-bottom">6</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="GtkLabel" id="name_label">
|
||||
<property name="halign">start</property>
|
||||
<property name="margin-top">12</property>
|
||||
<property name="margin-bottom">6</property>
|
||||
<attributes>
|
||||
<attribute name="weight" value="bold"/>
|
||||
</attributes>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox" id="medium_box">
|
||||
<property name="orientation">vertical</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkStackPage">
|
||||
<property name="name">loading</property>
|
||||
<property name="child">
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="AdwHeaderBar">
|
||||
<property name="show-start-title-buttons">false</property>
|
||||
<property name="show-end-title-buttons">false</property>
|
||||
<property name="title-widget">
|
||||
<object class="AdwWindowTitle">
|
||||
<property name="title" translatable="yes">Loading</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkSpinner">
|
||||
<property name="hexpand">true</property>
|
||||
<property name="vexpand">true</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="valign">center</property>
|
||||
<property name="width-request">32</property>
|
||||
<property name="height-request">32</property>
|
||||
<property name="spinning">true</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkStackPage">
|
||||
<property name="name">error</property>
|
||||
<property name="child">
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="AdwHeaderBar">
|
||||
<property name="show-start-title-buttons">false</property>
|
||||
<property name="show-end-title-buttons">false</property>
|
||||
<property name="title-widget">
|
||||
<object class="AdwWindowTitle">
|
||||
<property name="title" translatable="yes">Error</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="AdwStatusPage" id="status_page">
|
||||
<property name="icon-name">dialog-error-symbolic</property>
|
||||
<property name="title">Error</property>
|
||||
<property name="vexpand">true</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="try_again_button">
|
||||
<property name="label" translatable="yes">Try again</property>
|
||||
<property name="hexpand">true</property>
|
||||
<property name="vexpand">true</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="valign">center</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<interface>
|
||||
<requires lib="gtk" version="4.0" />
|
||||
<requires lib="libadwaita" version="1.0" />
|
||||
<object class="GtkBox" id="widget">
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="AdwHeaderBar">
|
||||
<property name="show-start-title-buttons">false</property>
|
||||
<property name="show-end-title-buttons">false</property>
|
||||
<property name="title-widget">
|
||||
<object class="GtkLabel">
|
||||
<property name="label" translatable="yes">Performance</property>
|
||||
<style>
|
||||
<class name="title" />
|
||||
</style>
|
||||
</object>
|
||||
</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="back_button">
|
||||
<property name="icon-name">go-previous-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
<child type="end">
|
||||
<object class="GtkButton" id="save_button">
|
||||
<property name="sensitive">False</property>
|
||||
<property name="icon-name">object-select-symbolic</property>
|
||||
<style>
|
||||
<class name="suggested-action" />
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkScrolledWindow">
|
||||
<property name="vexpand">true</property>
|
||||
<child>
|
||||
<object class="AdwClamp">
|
||||
<property name="margin-start">12</property>
|
||||
<property name="margin-end">12</property>
|
||||
<property name="margin-top">18</property>
|
||||
<property name="margin-bottom">12</property>
|
||||
<property name="maximum-size">500</property>
|
||||
<property name="tightening-threshold">300</property>
|
||||
<child>
|
||||
<object class="GtkListBox">
|
||||
<property name="selection-mode">none</property>
|
||||
<child>
|
||||
<object class="AdwActionRow" id="person_row">
|
||||
<property name="focusable">False</property>
|
||||
<property name="title" translatable="yes">Select a person</property>
|
||||
<property name="activatable-widget">person_button</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="person_button">
|
||||
<property name="label" translatable="yes">Select</property>
|
||||
<property name="valign">center</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="AdwActionRow" id="ensemble_row">
|
||||
<property name="focusable">False</property>
|
||||
<property name="title" translatable="yes">Select an ensemble</property>
|
||||
<property name="activatable-widget">ensemble_button</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="ensemble_button">
|
||||
<property name="label" translatable="yes">Select</property>
|
||||
<property name="valign">center</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="AdwActionRow" id="role_row">
|
||||
<property name="focusable">False</property>
|
||||
<property name="title" translatable="yes">Select a role</property>
|
||||
<property name="activatable-widget">role_button</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="reset_role_button">
|
||||
<property name="visible">false</property>
|
||||
<property name="icon-name">user-trash-symbolic</property>
|
||||
<property name="valign">center</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="role_button">
|
||||
<property name="label" translatable="yes">Select</property>
|
||||
<property name="valign">center</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<style>
|
||||
<class name="boxed-list" />
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<interface>
|
||||
<requires lib="gtk" version="4.0"/>
|
||||
<object class="GtkImage" id="play_image">
|
||||
<property name="icon-name">media-playback-start-symbolic</property>
|
||||
</object>
|
||||
<object class="GtkRevealer" id="widget">
|
||||
<property name="transition-type">slide-up</property>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="GtkSeparator"/>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="margin-top">6</property>
|
||||
<property name="margin-bottom">6</property>
|
||||
<property name="margin-start">6</property>
|
||||
<property name="margin-end">6</property>
|
||||
<property name="spacing">12</property>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="valign">center</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="previous_button">
|
||||
<property name="sensitive">False</property>
|
||||
<child>
|
||||
<object class="GtkImage">
|
||||
<property name="icon-name">media-skip-backward-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="play_button">
|
||||
<child>
|
||||
<object class="GtkImage" id="pause_image">
|
||||
<property name="icon-name">media-playback-pause-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="next_button">
|
||||
<property name="sensitive">False</property>
|
||||
<property name="receives-default">True</property>
|
||||
<child>
|
||||
<object class="GtkImage">
|
||||
<property name="icon-name">media-skip-forward-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="hexpand">True</property>
|
||||
<child>
|
||||
<object class="GtkLabel" id="title_label">
|
||||
<property name="halign">start</property>
|
||||
<property name="label" translatable="yes">Title</property>
|
||||
<property name="ellipsize">end</property>
|
||||
<attributes>
|
||||
<attribute name="weight" value="bold"/>
|
||||
</attributes>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkLabel" id="subtitle_label">
|
||||
<property name="halign">start</property>
|
||||
<property name="label" translatable="yes">Subtitle</property>
|
||||
<property name="ellipsize">end</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="spacing">2</property>
|
||||
<child>
|
||||
<object class="GtkLabel" id="position_label">
|
||||
<property name="label" translatable="yes">0:00</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="label" translatable="yes">/</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkLabel" id="duration_label">
|
||||
<property name="label" translatable="yes">0:00</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="playlist_button">
|
||||
<property name="valign">center</property>
|
||||
<child>
|
||||
<object class="GtkImage">
|
||||
<property name="icon-name">view-list-bullet-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
|
|
@ -1,154 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<interface>
|
||||
<requires lib="gtk" version="4.0"/>
|
||||
<requires lib="libadwaita" version="1.0"/>
|
||||
<object class="GtkImage" id="play_image">
|
||||
<property name="icon-name">media-playback-start-symbolic</property>
|
||||
</object>
|
||||
<object class="GtkAdjustment" id="position">
|
||||
<property name="upper">1</property>
|
||||
<property name="step-increment">0.01</property>
|
||||
<property name="page-increment">0.05</property>
|
||||
</object>
|
||||
<object class="GtkBox" id="widget">
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="AdwHeaderBar">
|
||||
<property name="title-widget">
|
||||
<object class="GtkLabel">
|
||||
<property name="label" translatable="yes">Player</property>
|
||||
<style>
|
||||
<class name="title"/>
|
||||
</style>
|
||||
</object>
|
||||
</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="back_button">
|
||||
<child>
|
||||
<object class="GtkImage">
|
||||
<property name="icon-name">go-previous-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkScrolledWindow">
|
||||
<property name="vexpand">true</property>
|
||||
<child>
|
||||
<object class="AdwClamp">
|
||||
<property name="margin-start">12</property>
|
||||
<property name="margin-end">12</property>
|
||||
<property name="margin-top">18</property>
|
||||
<property name="margin-bottom">12</property>
|
||||
<property name="maximum-size">800</property>
|
||||
<child>
|
||||
<object class="GtkBox" id="content">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="spacing">12</property>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="spacing">12</property>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="valign">center</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="previous_button">
|
||||
<property name="sensitive">False</property>
|
||||
<child>
|
||||
<object class="GtkImage">
|
||||
<property name="icon-name">media-skip-backward-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="play_button">
|
||||
<property name="receives-default">True</property>
|
||||
<child>
|
||||
<object class="GtkImage" id="pause_image">
|
||||
<property name="icon-name">media-playback-pause-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="next_button">
|
||||
<property name="sensitive">False</property>
|
||||
<property name="receives-default">True</property>
|
||||
<child>
|
||||
<object class="GtkImage">
|
||||
<property name="icon-name">media-skip-forward-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="hexpand">True</property>
|
||||
<child>
|
||||
<object class="GtkLabel" id="title_label">
|
||||
<property name="halign">start</property>
|
||||
<property name="label" translatable="yes">Title</property>
|
||||
<property name="ellipsize">end</property>
|
||||
<attributes>
|
||||
<attribute name="weight" value="bold"/>
|
||||
</attributes>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkLabel" id="subtitle_label">
|
||||
<property name="halign">start</property>
|
||||
<property name="label" translatable="yes">Subtitle</property>
|
||||
<property name="ellipsize">end</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="stop_button">
|
||||
<property name="receives-default">True</property>
|
||||
<property name="valign">center</property>
|
||||
<child>
|
||||
<object class="GtkImage">
|
||||
<property name="icon-name">media-playback-stop-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkLabel" id="position_label">
|
||||
<property name="label" translatable="yes">0:00</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkScale" id="position_scale">
|
||||
<property name="adjustment">position</property>
|
||||
<property name="hexpand">True</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkLabel" id="duration_label">
|
||||
<property name="label" translatable="yes">0:00</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<interface>
|
||||
<requires lib="gtk" version="4.0" />
|
||||
<requires lib="libadwaita" version="1.0" />
|
||||
<object class="AdwPreferencesWindow" id="window">
|
||||
<property name="modal">True</property>
|
||||
<property name="default-width">400</property>
|
||||
<property name="default-height">400</property>
|
||||
<child>
|
||||
<object class="AdwPreferencesPage">
|
||||
<property name="title" translatable="yes">General</property>
|
||||
<child>
|
||||
<object class="AdwPreferencesGroup">
|
||||
<property name="title" translatable="yes">Music library</property>
|
||||
<child>
|
||||
<object class="AdwActionRow" id="music_library_path_row">
|
||||
<property name="focusable">False</property>
|
||||
<property name="title" translatable="yes">Music library folder</property>
|
||||
<property name="activatable-widget">select_music_library_path_button</property>
|
||||
<property name="subtitle" translatable="yes">None selected</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="select_music_library_path_button">
|
||||
<property name="label" translatable="yes">Select</property>
|
||||
<property name="receives-default">True</property>
|
||||
<property name="valign">center</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="AdwPreferencesGroup">
|
||||
<property name="title" translatable="yes">Playlist</property>
|
||||
<child>
|
||||
<object class="AdwActionRow">
|
||||
<property name="focusable">False</property>
|
||||
<property name="title" translatable="yes">Keep playing</property>
|
||||
<property name="activatable-widget">keep_playing_switch</property>
|
||||
<property name="subtitle" translatable="yes">Whether to keep playing random tracks after the playlist ends.</property>
|
||||
<child>
|
||||
<object class="GtkSwitch" id="keep_playing_switch">
|
||||
<property name="valign">center</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="AdwActionRow">
|
||||
<property name="focusable">False</property>
|
||||
<property name="title" translatable="yes">Choose full recordings</property>
|
||||
<property name="activatable-widget">play_full_recordings_switch</property>
|
||||
<property name="subtitle" translatable="yes">Whether to choose full recordings instead of single tracks for random playback.</property>
|
||||
<child>
|
||||
<object class="GtkSwitch" id="play_full_recordings_switch">
|
||||
<property name="valign">center</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<interface>
|
||||
<requires lib="gtk" version="4.0" />
|
||||
<requires lib="libadwaita" version="1.0" />
|
||||
<object class="GtkStack" id="widget">
|
||||
<child>
|
||||
<object class="GtkStackPage">
|
||||
<property name="name">content</property>
|
||||
<property name="child">
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="AdwHeaderBar">
|
||||
<property name="show-start-title-buttons">false</property>
|
||||
<property name="show-end-title-buttons">false</property>
|
||||
<property name="title-widget">
|
||||
<object class="GtkLabel">
|
||||
<property name="label" translatable="yes">Recording</property>
|
||||
<style>
|
||||
<class name="title" />
|
||||
</style>
|
||||
</object>
|
||||
</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="back_button">
|
||||
<property name="icon-name">go-previous-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
<child type="end">
|
||||
<object class="GtkButton" id="save_button">
|
||||
<property name="sensitive">False</property>
|
||||
<property name="icon-name">object-select-symbolic</property>
|
||||
<style>
|
||||
<class name="suggested-action" />
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkInfoBar" id="info_bar">
|
||||
<property name="revealed">False</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkScrolledWindow">
|
||||
<property name="vexpand">true</property>
|
||||
<child>
|
||||
<object class="AdwClamp">
|
||||
<property name="margin-start">12</property>
|
||||
<property name="margin-end">12</property>
|
||||
<property name="margin-top">18</property>
|
||||
<property name="margin-bottom">12</property>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="margin-start">6</property>
|
||||
<property name="margin-end">6</property>
|
||||
<property name="margin-bottom">6</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="halign">start</property>
|
||||
<property name="margin-top">12</property>
|
||||
<property name="margin-bottom">6</property>
|
||||
<property name="label" translatable="yes">Overview</property>
|
||||
<attributes>
|
||||
<attribute name="weight" value="bold" />
|
||||
</attributes>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkListBox">
|
||||
<property name="selection-mode">none</property>
|
||||
<child>
|
||||
<object class="AdwActionRow" id="work_row">
|
||||
<property name="focusable">False</property>
|
||||
<property name="title" translatable="yes">Select a work</property>
|
||||
<property name="activatable-widget">work_button</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="work_button">
|
||||
<property name="label" translatable="yes">Select</property>
|
||||
<property name="valign">center</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="AdwEntryRow" id="comment_row">
|
||||
<property name="title" translatable="yes">Comment</property>
|
||||
</object>
|
||||
</child>
|
||||
<style>
|
||||
<class name="boxed-list" />
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="margin-top">12</property>
|
||||
<property name="margin-bottom">6</property>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="halign">start</property>
|
||||
<property name="valign">end</property>
|
||||
<property name="hexpand">True</property>
|
||||
<property name="label" translatable="yes">Performers</property>
|
||||
<attributes>
|
||||
<attribute name="weight" value="bold" />
|
||||
</attributes>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="add_performer_button">
|
||||
<property name="has-frame">false</property>
|
||||
<property name="icon-name">list-add-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkFrame" id="performance_frame" />
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkStackPage">
|
||||
<property name="name">loading</property>
|
||||
<property name="child">
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="AdwHeaderBar">
|
||||
<property name="show-start-title-buttons">false</property>
|
||||
<property name="show-end-title-buttons">false</property>
|
||||
<property name="title-widget">
|
||||
<object class="GtkLabel">
|
||||
<property name="label" translatable="yes">Recording</property>
|
||||
<style>
|
||||
<class name="title" />
|
||||
</style>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkSpinner">
|
||||
<property name="hexpand">true</property>
|
||||
<property name="vexpand">true</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="valign">center</property>
|
||||
<property name="spinning">true</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<interface>
|
||||
<requires lib="gtk" version="4.0"/>
|
||||
<requires lib="libadwaita" version="1.0"/>
|
||||
<object class="GtkBox" id="widget">
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="AdwHeaderBar">
|
||||
<property name="title-widget">
|
||||
<object class="AdwWindowTitle" id="window_title"/>
|
||||
</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="back_button">
|
||||
<property name="icon-name">go-previous-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
<child type="end">
|
||||
<object class="GtkMenuButton">
|
||||
<property name="menu-model">menu</property>
|
||||
<property name="icon-name">view-more-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
<child type="end">
|
||||
<object class="GtkToggleButton" id="search_button">
|
||||
<property name="icon-name">edit-find-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkSearchBar">
|
||||
<property name="search-mode-enabled" bind-source="search_button" bind-property="active" bind-flags="bidirectional|sync-create">False</property>
|
||||
<child>
|
||||
<object class="AdwClamp">
|
||||
<property name="hexpand">true</property>
|
||||
<child>
|
||||
<object class="GtkSearchEntry" id="search_entry"/>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkStack" id="stack">
|
||||
<child>
|
||||
<object class="GtkStackPage">
|
||||
<property name="name">loading</property>
|
||||
<property name="child">
|
||||
<object class="GtkSpinner">
|
||||
<property name="hexpand">true</property>
|
||||
<property name="vexpand">true</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="valign">center</property>
|
||||
<property name="width-request">32</property>
|
||||
<property name="height-request">32</property>
|
||||
<property name="spinning">true</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkStackPage">
|
||||
<property name="name">content</property>
|
||||
<property name="child">
|
||||
<object class="GtkScrolledWindow">
|
||||
<child>
|
||||
<object class="AdwClamp">
|
||||
<child>
|
||||
<object class="GtkBox" id="content_box">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="margin-start">12</property>
|
||||
<property name="margin-end">12</property>
|
||||
<property name="margin-bottom">36</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
<menu id="menu"/>
|
||||
</interface>
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<interface>
|
||||
<requires lib="gtk" version="4.0"/>
|
||||
<object class="GtkBox" id="widget">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkBox" id="title_box">
|
||||
<property name="spacing">12</property>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="margin-top">18</property>
|
||||
<property name="valign">end</property>
|
||||
<property name="hexpand">true</property>
|
||||
<child>
|
||||
<object class="GtkLabel" id="title_label">
|
||||
<property name="ellipsize">end</property>
|
||||
<property name="xalign">0.0</property>
|
||||
<attributes>
|
||||
<attribute name="weight" value="bold"/>
|
||||
</attributes>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkLabel" id="subtitle_label">
|
||||
<property name="wrap">true</property>
|
||||
<property name="xalign">0.0</property>
|
||||
<property name="visible">false</property>
|
||||
<property name="margin-bottom">6</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<interface>
|
||||
<requires lib="gtk" version="4.0"/>
|
||||
<requires lib="libadwaita" version="1.0"/>
|
||||
<object class="GtkBox" id="widget">
|
||||
<property name="width-request">250</property>
|
||||
<property name="hexpand">False</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="AdwHeaderBar" id="header">
|
||||
<property name="show-start-title-buttons">false</property>
|
||||
<property name="show-end-title-buttons">false</property>
|
||||
<property name="title-widget">
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="valign">center</property>
|
||||
<child>
|
||||
<object class="GtkLabel" id="title_label">
|
||||
<style>
|
||||
<class name="title"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkLabel" id="subtitle_label">
|
||||
<property name="visible">false</property>
|
||||
<style>
|
||||
<class name="subtitle"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="back_button">
|
||||
<property name="icon-name">go-previous-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
<child type="end">
|
||||
<object class="GtkButton" id="add_button">
|
||||
<property name="icon-name">list-add-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkSearchBar">
|
||||
<property name="search-mode-enabled">True</property>
|
||||
<child>
|
||||
<object class="AdwClamp">
|
||||
<property name="maximum-size">500</property>
|
||||
<property name="tightening-threshold">300</property>
|
||||
<property name="hexpand">true</property>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkSearchEntry" id="search_entry">
|
||||
<property name="placeholder-text" translatable="yes">Search …</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkStack" id="stack">
|
||||
<property name="hhomogeneous">False</property>
|
||||
<property name="vhomogeneous">False</property>
|
||||
<property name="transition-type">crossfade</property>
|
||||
<property name="interpolate-size">True</property>
|
||||
<child>
|
||||
<object class="GtkStackPage">
|
||||
<property name="name">loading</property>
|
||||
<property name="child">
|
||||
<object class="GtkSpinner">
|
||||
<property name="margin-top">12</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="valign">start</property>
|
||||
<property name="spinning">True</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkStackPage">
|
||||
<property name="name">content</property>
|
||||
<property name="child">
|
||||
<object class="GtkScrolledWindow">
|
||||
<property name="height-request">200</property>
|
||||
<property name="vexpand">true</property>
|
||||
<child>
|
||||
<object class="AdwClamp" id="clamp">
|
||||
<property name="maximum-size">500</property>
|
||||
<property name="tightening-threshold">300</property>
|
||||
<property name="margin-start">6</property>
|
||||
<property name="margin-end">6</property>
|
||||
<property name="margin-top">12</property>
|
||||
<property name="margin-bottom">6</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<interface>
|
||||
<requires lib="gtk" version="4.0"/>
|
||||
<requires lib="libadwaita" version="1.0"/>
|
||||
<object class="GtkStack" id="widget">
|
||||
<property name="transition-type">crossfade</property>
|
||||
<child>
|
||||
<object class="GtkStackPage">
|
||||
<property name="name">content</property>
|
||||
<property name="child">
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="AdwHeaderBar">
|
||||
<property name="show-start-title-buttons">false</property>
|
||||
<property name="show-end-title-buttons">false</property>
|
||||
<property name="title-widget">
|
||||
<object class="AdwWindowTitle" id="window_title"/>
|
||||
</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="back_button">
|
||||
<property name="icon-name">go-previous-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="AdwStatusPage">
|
||||
<property name="vexpand">true</property>
|
||||
<property name="icon-name">folder-music-symbolic</property>
|
||||
<property name="title" translatable="yes">Import music</property>
|
||||
<property name="description" translatable="yes">Select the source which contains the new audio files below.</property>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="homogeneous">true</property>
|
||||
<property name="spacing">6</property>
|
||||
<property name="halign">center</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="folder_button">
|
||||
<property name="label" translatable="yes">Select folder</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="disc_button">
|
||||
<property name="label" translatable="yes">Copy audio CD</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkStackPage">
|
||||
<property name="name">loading</property>
|
||||
<property name="child">
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="AdwHeaderBar">
|
||||
<property name="show-start-title-buttons">false</property>
|
||||
<property name="show-end-title-buttons">false</property>
|
||||
<property name="title-widget">
|
||||
<object class="AdwWindowTitle">
|
||||
<property name="title" translatable="yes">Loading</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkSpinner">
|
||||
<property name="hexpand">true</property>
|
||||
<property name="vexpand">true</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="valign">center</property>
|
||||
<property name="width-request">32</property>
|
||||
<property name="height-request">32</property>
|
||||
<property name="spinning">true</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkStackPage">
|
||||
<property name="name">error</property>
|
||||
<property name="child">
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="AdwHeaderBar">
|
||||
<property name="show-start-title-buttons">false</property>
|
||||
<property name="show-end-title-buttons">false</property>
|
||||
<property name="title-widget">
|
||||
<object class="AdwWindowTitle">
|
||||
<property name="title" translatable="yes">Error</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="AdwStatusPage" id="status_page">
|
||||
<property name="icon-name">dialog-error-symbolic</property>
|
||||
<property name="title">Error</property>
|
||||
<property name="vexpand">true</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="try_again_button">
|
||||
<property name="label" translatable="yes">Try again</property>
|
||||
<property name="hexpand">true</property>
|
||||
<property name="vexpand">true</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="valign">center</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<interface>
|
||||
<requires lib="gtk" version="4.0" />
|
||||
<requires lib="libadwaita" version="1.0" />
|
||||
<object class="GtkBox" id="widget">
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="AdwHeaderBar">
|
||||
<property name="show-start-title-buttons">false</property>
|
||||
<property name="show-end-title-buttons">false</property>
|
||||
<property name="title-widget">
|
||||
<object class="GtkLabel">
|
||||
<property name="label" translatable="yes">Track</property>
|
||||
<style>
|
||||
<class name="title" />
|
||||
</style>
|
||||
</object>
|
||||
</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="back_button">
|
||||
<property name="icon-name">go-previous-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
<child type="end">
|
||||
<object class="GtkButton" id="select_button">
|
||||
<property name="icon-name">object-select-symbolic</property>
|
||||
<style>
|
||||
<class name="suggested-action" />
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkScrolledWindow">
|
||||
<property name="vexpand">True</property>
|
||||
<child>
|
||||
<object class="AdwClamp" id="clamp">
|
||||
<property name="margin-top">12</property>
|
||||
<property name="margin-start">6</property>
|
||||
<property name="margin-end">6</property>
|
||||
<property name="margin-bottom">6</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<interface>
|
||||
<requires lib="gtk" version="4.0"/>
|
||||
<object class="GtkListBoxRow" id="widget">
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="margin-top">12</property>
|
||||
<property name="margin-start">6</property>
|
||||
<property name="margin-end">6</property>
|
||||
<child>
|
||||
<object class="GtkRevealer" id="playing_revealer">
|
||||
<child>
|
||||
<object class="GtkImage" id="playing_image">
|
||||
<property name="icon-name">media-playback-start-symbolic</property>
|
||||
<property name="margin-top">6</property>
|
||||
<property name="margin-start">12</property>
|
||||
<property name="margin-end">18</property>
|
||||
<property name="valign">start</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="hexpand">true</property>
|
||||
<child>
|
||||
<object class="GtkBox" id="header_box">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="visible">false</property>
|
||||
<property name="margin-bottom">12</property>
|
||||
<child>
|
||||
<object class="GtkLabel" id="work_title_label">
|
||||
<property name="wrap">true</property>
|
||||
<property name="xalign">0.0</property>
|
||||
<attributes>
|
||||
<attribute name="weight" value="bold"/>
|
||||
</attributes>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkLabel" id="performances_label">
|
||||
<property name="wrap">true</property>
|
||||
<property name="xalign">0.0</property>
|
||||
<style>
|
||||
<class name="subtitle"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkLabel" id="track_title_label">
|
||||
<property name="wrap">true</property>
|
||||
<property name="xalign">0.0</property>
|
||||
<property name="margin-bottom">12</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<interface>
|
||||
<requires lib="gtk" version="4.0"/>
|
||||
<requires lib="libadwaita" version="1.0"/>
|
||||
<object class="GtkBox" id="widget">
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="AdwHeaderBar">
|
||||
<property name="show-start-title-buttons">false</property>
|
||||
<property name="show-end-title-buttons">false</property>
|
||||
<property name="title-widget">
|
||||
<object class="GtkLabel">
|
||||
<property name="label" translatable="yes">Select tracks</property>
|
||||
<style>
|
||||
<class name="title"/>
|
||||
</style>
|
||||
</object>
|
||||
</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="back_button">
|
||||
<property name="icon-name">go-previous-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
<child type="end">
|
||||
<object class="GtkButton" id="select_button">
|
||||
<property name="sensitive">False</property>
|
||||
<property name="icon-name">object-select-symbolic</property>
|
||||
<style>
|
||||
<class name="suggested-action"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkScrolledWindow">
|
||||
<property name="vexpand">True</property>
|
||||
<child>
|
||||
<object class="AdwClamp" id="clamp">
|
||||
<property name="margin-top">12</property>
|
||||
<property name="margin-start">6</property>
|
||||
<property name="margin-end">6</property>
|
||||
<property name="margin-bottom">6</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<interface>
|
||||
<requires lib="gtk" version="4.0" />
|
||||
<requires lib="libadwaita" version="1.0" />
|
||||
<object class="GtkBox" id="widget">
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="AdwHeaderBar">
|
||||
<property name="show-start-title-buttons">false</property>
|
||||
<property name="show-end-title-buttons">false</property>
|
||||
<property name="title-widget">
|
||||
<object class="GtkLabel">
|
||||
<property name="label" translatable="yes">Import music</property>
|
||||
<style>
|
||||
<class name="title" />
|
||||
</style>
|
||||
</object>
|
||||
</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="back_button">
|
||||
<property name="icon-name">go-previous-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
<child type="end">
|
||||
<object class="GtkButton" id="save_button">
|
||||
<property name="icon-name">object-select-symbolic</property>
|
||||
<property name="sensitive">False</property>
|
||||
<style>
|
||||
<class name="suggested-action" />
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkScrolledWindow">
|
||||
<property name="vexpand">True</property>
|
||||
<child>
|
||||
<object class="AdwClamp">
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="margin-start">6</property>
|
||||
<property name="margin-end">6</property>
|
||||
<property name="margin-bottom">6</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="halign">start</property>
|
||||
<property name="margin-top">12</property>
|
||||
<property name="margin-bottom">6</property>
|
||||
<property name="label" translatable="yes">Recording</property>
|
||||
<attributes>
|
||||
<attribute name="weight" value="bold" />
|
||||
</attributes>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkListBox">
|
||||
<property name="selection-mode">none</property>
|
||||
<child>
|
||||
<object class="AdwActionRow" id="recording_row">
|
||||
<property name="focusable">False</property>
|
||||
<property name="title" translatable="yes">Select a recording</property>
|
||||
<property name="activatable-widget">select_recording_button</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="select_recording_button">
|
||||
<property name="label" translatable="yes">Select</property>
|
||||
<property name="valign">center</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<style>
|
||||
<class name="boxed-list" />
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="margin-top">12</property>
|
||||
<property name="margin-bottom">6</property>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="halign">start</property>
|
||||
<property name="valign">end</property>
|
||||
<property name="hexpand">True</property>
|
||||
<property name="label" translatable="yes">Tracks</property>
|
||||
<attributes>
|
||||
<attribute name="weight" value="bold" />
|
||||
</attributes>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="edit_tracks_button">
|
||||
<property name="has-frame">false</property>
|
||||
<property name="icon-name">document-edit-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkFrame" id="tracks_frame" />
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
|
|
@ -1,195 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<interface>
|
||||
<requires lib="gtk" version="4.0" />
|
||||
<requires lib="libadwaita" version="1.0" />
|
||||
<object class="GtkStack" id="widget">
|
||||
<child>
|
||||
<object class="GtkStackPage">
|
||||
<property name="name">content</property>
|
||||
<property name="child">
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="AdwHeaderBar">
|
||||
<property name="show-start-title-buttons">false</property>
|
||||
<property name="show-end-title-buttons">false</property>
|
||||
<property name="title-widget">
|
||||
<object class="GtkLabel">
|
||||
<property name="label" translatable="yes">Work</property>
|
||||
<style>
|
||||
<class name="title" />
|
||||
</style>
|
||||
</object>
|
||||
</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="back_button">
|
||||
<property name="icon-name">go-previous-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
<child type="end">
|
||||
<object class="GtkButton" id="save_button">
|
||||
<property name="sensitive">False</property>
|
||||
<property name="icon-name">object-select-symbolic</property>
|
||||
<style>
|
||||
<class name="suggested-action" />
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkInfoBar" id="info_bar">
|
||||
<property name="revealed">False</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkScrolledWindow">
|
||||
<property name="vexpand">true</property>
|
||||
<child>
|
||||
<object class="AdwClamp">
|
||||
<property name="margin-start">12</property>
|
||||
<property name="margin-end">12</property>
|
||||
<property name="margin-top">18</property>
|
||||
<property name="margin-bottom">12</property>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="margin-start">6</property>
|
||||
<property name="margin-end">6</property>
|
||||
<property name="margin-bottom">6</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="halign">start</property>
|
||||
<property name="margin-top">12</property>
|
||||
<property name="margin-bottom">6</property>
|
||||
<property name="label" translatable="yes">Overview</property>
|
||||
<attributes>
|
||||
<attribute name="weight" value="bold" />
|
||||
</attributes>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkListBox">
|
||||
<property name="selection-mode">none</property>
|
||||
<child>
|
||||
<object class="AdwActionRow" id="composer_row">
|
||||
<property name="focusable">False</property>
|
||||
<property name="title" translatable="yes">Select a composer</property>
|
||||
<property name="activatable-widget">composer_button</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="composer_button">
|
||||
<property name="label" translatable="yes">Select</property>
|
||||
<property name="valign">center</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="AdwEntryRow" id="title_row">
|
||||
<property name="title" translatable="yes">Title</property>
|
||||
</object>
|
||||
</child>
|
||||
<style>
|
||||
<class name="boxed-list" />
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="margin-top">12</property>
|
||||
<property name="margin-bottom">6</property>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="halign">start</property>
|
||||
<property name="valign">end</property>
|
||||
<property name="hexpand">True</property>
|
||||
<property name="label" translatable="yes">Instruments</property>
|
||||
<attributes>
|
||||
<attribute name="weight" value="bold" />
|
||||
</attributes>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="add_instrument_button">
|
||||
<property name="has-frame">false</property>
|
||||
<property name="icon-name">list-add-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkFrame" id="instrument_frame" />
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="margin-top">12</property>
|
||||
<property name="margin-bottom">6</property>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="halign">start</property>
|
||||
<property name="valign">end</property>
|
||||
<property name="hexpand">True</property>
|
||||
<property name="label" translatable="yes">Structure</property>
|
||||
<attributes>
|
||||
<attribute name="weight" value="bold" />
|
||||
</attributes>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="add_part_button">
|
||||
<property name="has-frame">false</property>
|
||||
<property name="icon-name">list-add-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkFrame" id="structure_frame" />
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkStackPage">
|
||||
<property name="name">loading</property>
|
||||
<property name="child">
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="AdwHeaderBar">
|
||||
<property name="show-start-title-buttons">false</property>
|
||||
<property name="show-end-title-buttons">false</property>
|
||||
<property name="title-widget">
|
||||
<object class="GtkLabel">
|
||||
<property name="label" translatable="yes">Work</property>
|
||||
<style>
|
||||
<class name="title" />
|
||||
</style>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkSpinner">
|
||||
<property name="hexpand">true</property>
|
||||
<property name="vexpand">true</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="valign">center</property>
|
||||
<property name="spinning">True</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<interface>
|
||||
<requires lib="gtk" version="4.0" />
|
||||
<requires lib="libadwaita" version="1.0" />
|
||||
<object class="GtkBox" id="widget">
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="AdwHeaderBar">
|
||||
<property name="show-start-title-buttons">false</property>
|
||||
<property name="show-end-title-buttons">false</property>
|
||||
<property name="title-widget">
|
||||
<object class="GtkLabel">
|
||||
<property name="label" translatable="yes">Work part</property>
|
||||
<style>
|
||||
<class name="title" />
|
||||
</style>
|
||||
</object>
|
||||
</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="back_button">
|
||||
<property name="icon-name">go-previous-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
<child type="end">
|
||||
<object class="GtkButton" id="save_button">
|
||||
<property name="icon-name">object-select-symbolic</property>
|
||||
<style>
|
||||
<class name="suggested-action" />
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkInfoBar" id="info_bar">
|
||||
<property name="revealed">False</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkScrolledWindow">
|
||||
<property name="vexpand">true</property>
|
||||
<child>
|
||||
<object class="AdwClamp">
|
||||
<property name="margin-start">12</property>
|
||||
<property name="margin-end">12</property>
|
||||
<property name="margin-top">18</property>
|
||||
<property name="margin-bottom">12</property>
|
||||
<property name="maximum-size">500</property>
|
||||
<property name="tightening-threshold">300</property>
|
||||
<child>
|
||||
<object class="GtkListBox">
|
||||
<property name="selection-mode">none</property>
|
||||
<property name="valign">start</property>
|
||||
<child>
|
||||
<object class="AdwEntryRow" id="title_row">
|
||||
<property name="title" translatable="yes">Title</property>
|
||||
</object>
|
||||
</child>
|
||||
<style>
|
||||
<class name="boxed-list" />
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
pub static VERSION: &str = @VERSION@;
|
||||
pub static LOCALEDIR: &str = @LOCALEDIR@;
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
use crate::navigator::{NavigationHandle, Screen};
|
||||
use crate::widgets::{Editor, Section, Widget};
|
||||
use anyhow::Result;
|
||||
use gettextrs::gettext;
|
||||
use gtk::{glib::clone, prelude::*};
|
||||
use musicus_backend::db::{self, generate_id, Ensemble};
|
||||
use std::rc::Rc;
|
||||
|
||||
/// A dialog for creating or editing a ensemble.
|
||||
pub struct EnsembleEditor {
|
||||
handle: NavigationHandle<Ensemble>,
|
||||
|
||||
/// The ID of the ensemble that is edited or a newly generated one.
|
||||
id: String,
|
||||
|
||||
editor: Editor,
|
||||
name: adw::EntryRow,
|
||||
}
|
||||
|
||||
impl Screen<Option<Ensemble>, Ensemble> for EnsembleEditor {
|
||||
/// Create a new ensemble editor and optionally initialize it.
|
||||
fn new(ensemble: Option<Ensemble>, handle: NavigationHandle<Ensemble>) -> Rc<Self> {
|
||||
let editor = Editor::new();
|
||||
editor.set_title("Ensemble");
|
||||
|
||||
let list = gtk::ListBox::builder()
|
||||
.selection_mode(gtk::SelectionMode::None)
|
||||
.css_classes(vec![String::from("boxed-list")])
|
||||
.build();
|
||||
|
||||
let name = adw::EntryRow::builder().title(gettext("Name")).build();
|
||||
list.append(&name);
|
||||
|
||||
let section = Section::new(&gettext("General"), &list);
|
||||
editor.add_content(§ion.widget);
|
||||
|
||||
let id = match ensemble {
|
||||
Some(ensemble) => {
|
||||
name.set_text(&ensemble.name);
|
||||
ensemble.id
|
||||
}
|
||||
None => generate_id(),
|
||||
};
|
||||
|
||||
let this = Rc::new(Self {
|
||||
handle,
|
||||
id,
|
||||
editor,
|
||||
name,
|
||||
});
|
||||
|
||||
// Connect signals and callbacks
|
||||
|
||||
this.editor.set_back_cb(clone!(@weak this => move || {
|
||||
this.handle.pop(None);
|
||||
}));
|
||||
|
||||
this.editor.set_save_cb(clone!(@weak this => move || {
|
||||
match this.save() {
|
||||
Ok(ensemble) => {
|
||||
this.handle.pop(Some(ensemble));
|
||||
}
|
||||
Err(err) => {
|
||||
let description = gettext!("Cause: {}", err);
|
||||
this.editor.error(&gettext("Failed to save ensemble!"), &description);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
this.name
|
||||
.connect_changed(clone!(@weak this => move |_| this.validate()));
|
||||
|
||||
this.validate();
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
impl EnsembleEditor {
|
||||
/// Validate inputs and enable/disable saving.
|
||||
fn validate(&self) {
|
||||
self.editor.set_may_save(!self.name.text().is_empty());
|
||||
}
|
||||
|
||||
/// Save the ensemble.
|
||||
fn save(&self) -> Result<Ensemble> {
|
||||
let name = self.name.text();
|
||||
|
||||
let ensemble = Ensemble::new(self.id.clone(), name.to_string());
|
||||
|
||||
db::update_ensemble(
|
||||
&mut self.handle.backend.db().lock().unwrap(),
|
||||
ensemble.clone(),
|
||||
)?;
|
||||
self.handle.backend.library_changed();
|
||||
|
||||
Ok(ensemble)
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for EnsembleEditor {
|
||||
fn get_widget(&self) -> gtk::Widget {
|
||||
self.editor.widget.clone().upcast()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
use crate::navigator::{NavigationHandle, Screen};
|
||||
use crate::widgets::{Editor, Section, Widget};
|
||||
use anyhow::Result;
|
||||
use gettextrs::gettext;
|
||||
use gtk::{glib::clone, prelude::*};
|
||||
use musicus_backend::db::{self, generate_id, Instrument};
|
||||
use std::rc::Rc;
|
||||
|
||||
/// A dialog for creating or editing a instrument.
|
||||
pub struct InstrumentEditor {
|
||||
handle: NavigationHandle<Instrument>,
|
||||
|
||||
/// The ID of the instrument that is edited or a newly generated one.
|
||||
id: String,
|
||||
|
||||
editor: Editor,
|
||||
name: adw::EntryRow,
|
||||
}
|
||||
|
||||
impl Screen<Option<Instrument>, Instrument> for InstrumentEditor {
|
||||
/// Create a new instrument editor and optionally initialize it.
|
||||
fn new(instrument: Option<Instrument>, handle: NavigationHandle<Instrument>) -> Rc<Self> {
|
||||
let editor = Editor::new();
|
||||
editor.set_title("Instrument/Role");
|
||||
|
||||
let list = gtk::ListBox::builder()
|
||||
.selection_mode(gtk::SelectionMode::None)
|
||||
.css_classes(vec![String::from("boxed-list")])
|
||||
.build();
|
||||
|
||||
let name = adw::EntryRow::builder().title(gettext("Name")).build();
|
||||
list.append(&name);
|
||||
|
||||
let section = Section::new(&gettext("General"), &list);
|
||||
editor.add_content(§ion.widget);
|
||||
|
||||
let id = match instrument {
|
||||
Some(instrument) => {
|
||||
name.set_text(&instrument.name);
|
||||
instrument.id
|
||||
}
|
||||
None => generate_id(),
|
||||
};
|
||||
|
||||
let this = Rc::new(Self {
|
||||
handle,
|
||||
id,
|
||||
editor,
|
||||
name,
|
||||
});
|
||||
|
||||
// Connect signals and callbacks
|
||||
|
||||
this.editor.set_back_cb(clone!(@weak this => move || {
|
||||
this.handle.pop(None);
|
||||
}));
|
||||
|
||||
this.editor.set_save_cb(clone!(@weak this => move || {
|
||||
match this.save() {
|
||||
Ok(instrument) => {
|
||||
this.handle.pop(Some(instrument));
|
||||
}
|
||||
Err(err) => {
|
||||
let description = gettext!("Cause: {}", err);
|
||||
this.editor.error(&gettext("Failed to save instrument!"), &description);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
this.name
|
||||
.connect_changed(clone!(@weak this => move |_| this.validate()));
|
||||
|
||||
this.validate();
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
impl InstrumentEditor {
|
||||
/// Validate inputs and enable/disable saving.
|
||||
fn validate(&self) {
|
||||
self.editor.set_may_save(!self.name.text().is_empty());
|
||||
}
|
||||
|
||||
/// Save the instrument.
|
||||
fn save(&self) -> Result<Instrument> {
|
||||
let name = self.name.text();
|
||||
|
||||
let instrument = Instrument::new(self.id.clone(), name.to_string());
|
||||
|
||||
db::update_instrument(
|
||||
&mut self.handle.backend.db().lock().unwrap(),
|
||||
instrument.clone(),
|
||||
)?;
|
||||
|
||||
self.handle.backend.library_changed();
|
||||
|
||||
Ok(instrument)
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for InstrumentEditor {
|
||||
fn get_widget(&self) -> gtk::Widget {
|
||||
self.editor.widget.clone().upcast()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
pub mod ensemble;
|
||||
pub use ensemble::*;
|
||||
|
||||
pub mod instrument;
|
||||
pub use instrument::*;
|
||||
|
||||
pub mod person;
|
||||
pub use person::*;
|
||||
|
||||
pub mod recording;
|
||||
pub use recording::*;
|
||||
|
||||
pub mod work;
|
||||
pub use work::*;
|
||||
|
||||
mod performance;
|
||||
mod work_part;
|
||||
|
|
@ -1,203 +0,0 @@
|
|||
use crate::navigator::{NavigationHandle, Screen};
|
||||
use crate::selectors::{EnsembleSelector, InstrumentSelector, PersonSelector};
|
||||
use crate::widgets::{ButtonRow, Editor, Section, Widget};
|
||||
use adw::prelude::*;
|
||||
use gettextrs::gettext;
|
||||
|
||||
use gtk::glib::clone;
|
||||
use log::error;
|
||||
use musicus_backend::db::{Ensemble, Instrument, Performance, Person, PersonOrEnsemble};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// A dialog for editing a performance within a recording.
|
||||
pub struct PerformanceEditor {
|
||||
handle: NavigationHandle<Performance>,
|
||||
editor: Editor,
|
||||
person_row: ButtonRow,
|
||||
ensemble_row: ButtonRow,
|
||||
role_row: ButtonRow,
|
||||
reset_role_button: gtk::Button,
|
||||
person: RefCell<Option<Person>>,
|
||||
ensemble: RefCell<Option<Ensemble>>,
|
||||
role: RefCell<Option<Instrument>>,
|
||||
}
|
||||
|
||||
impl Screen<Option<Performance>, Performance> for PerformanceEditor {
|
||||
/// Create a new performance editor.
|
||||
fn new(performance: Option<Performance>, handle: NavigationHandle<Performance>) -> Rc<Self> {
|
||||
let editor = Editor::new();
|
||||
editor.set_title("Performance");
|
||||
editor.set_may_save(false);
|
||||
|
||||
let performer_list = gtk::ListBox::builder()
|
||||
.selection_mode(gtk::SelectionMode::None)
|
||||
.css_classes(vec![String::from("boxed-list")])
|
||||
.build();
|
||||
|
||||
let person_row = ButtonRow::new("Person", "Select");
|
||||
let ensemble_row = ButtonRow::new("Ensemble", "Select");
|
||||
|
||||
performer_list.append(&person_row.get_widget());
|
||||
performer_list.append(&ensemble_row.get_widget());
|
||||
|
||||
let performer_section = Section::new(&gettext("Performer"), &performer_list);
|
||||
performer_section.set_subtitle(&gettext(
|
||||
"Select either a person or an ensemble as a performer.",
|
||||
));
|
||||
|
||||
let role_list = gtk::ListBox::builder()
|
||||
.selection_mode(gtk::SelectionMode::None)
|
||||
.css_classes(vec![String::from("boxed-list")])
|
||||
.build();
|
||||
|
||||
let reset_role_button = gtk::Button::builder()
|
||||
.icon_name("user-trash-symbolic")
|
||||
.valign(gtk::Align::Center)
|
||||
.visible(false)
|
||||
.build();
|
||||
|
||||
let role_row = ButtonRow::new("Role", "Select");
|
||||
role_row.widget.add_suffix(&reset_role_button);
|
||||
|
||||
role_list.append(&role_row.get_widget());
|
||||
|
||||
let role_section = Section::new(&gettext("Role"), &role_list);
|
||||
role_section.set_subtitle(&gettext(
|
||||
"Optionally, choose a role to specify what the performer does.",
|
||||
));
|
||||
|
||||
editor.add_content(&performer_section);
|
||||
editor.add_content(&role_section);
|
||||
|
||||
let this = Rc::new(PerformanceEditor {
|
||||
handle,
|
||||
editor,
|
||||
person_row,
|
||||
ensemble_row,
|
||||
role_row,
|
||||
reset_role_button,
|
||||
person: RefCell::new(None),
|
||||
ensemble: RefCell::new(None),
|
||||
role: RefCell::new(None),
|
||||
});
|
||||
|
||||
this.editor.set_back_cb(clone!(@weak this => move || {
|
||||
this.handle.pop(None);
|
||||
}));
|
||||
|
||||
this.editor.set_save_cb(clone!(@weak this => move || {
|
||||
let performance = Performance {
|
||||
performer: if let Some(person) = this.person.borrow().clone() {
|
||||
PersonOrEnsemble::Person(person)
|
||||
} else if let Some(ensemble) = this.ensemble.borrow().clone() {
|
||||
PersonOrEnsemble::Ensemble(ensemble)
|
||||
} else {
|
||||
error!("Tried to save performance without performer");
|
||||
return;
|
||||
},
|
||||
role: this.role.borrow().clone(),
|
||||
};
|
||||
|
||||
this.handle.pop(Some(performance));
|
||||
}));
|
||||
|
||||
this.person_row.set_cb(clone!(@weak this => move || {
|
||||
spawn!(@clone this, async move {
|
||||
if let Some(person) = push!(this.handle, PersonSelector).await {
|
||||
this.show_person(Some(&person));
|
||||
this.person.replace(Some(person));
|
||||
this.show_ensemble(None);
|
||||
this.ensemble.replace(None);
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
this.ensemble_row.set_cb(clone!(@weak this => move || {
|
||||
spawn!(@clone this, async move {
|
||||
if let Some(ensemble) = push!(this.handle, EnsembleSelector).await {
|
||||
this.show_person(None);
|
||||
this.person.replace(None);
|
||||
this.show_ensemble(Some(&ensemble));
|
||||
this.ensemble.replace(Some(ensemble));
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
this.role_row.set_cb(clone!(@weak this => move || {
|
||||
spawn!(@clone this, async move {
|
||||
if let Some(role) = push!(this.handle, InstrumentSelector).await {
|
||||
this.show_role(Some(&role));
|
||||
this.role.replace(Some(role));
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
this.reset_role_button
|
||||
.connect_clicked(clone!(@weak this => move |_| {
|
||||
this.show_role(None);
|
||||
this.role.replace(None);
|
||||
}));
|
||||
|
||||
// Initialize
|
||||
|
||||
if let Some(performance) = performance {
|
||||
match performance.performer {
|
||||
PersonOrEnsemble::Person(person) => {
|
||||
this.show_person(Some(&person));
|
||||
this.person.replace(Some(person));
|
||||
}
|
||||
PersonOrEnsemble::Ensemble(ensemble) => {
|
||||
this.show_ensemble(Some(&ensemble));
|
||||
this.ensemble.replace(Some(ensemble));
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(role) = performance.role {
|
||||
this.show_role(Some(&role));
|
||||
this.role.replace(Some(role));
|
||||
}
|
||||
}
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
impl PerformanceEditor {
|
||||
/// Update the UI according to person.
|
||||
fn show_person(&self, person: Option<&Person>) {
|
||||
if let Some(person) = person {
|
||||
self.person_row.set_subtitle(&person.name_fl());
|
||||
self.editor.set_may_save(true);
|
||||
} else {
|
||||
self.person_row.set_subtitle("");
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the UI according to ensemble.
|
||||
fn show_ensemble(&self, ensemble: Option<&Ensemble>) {
|
||||
if let Some(ensemble) = ensemble {
|
||||
self.ensemble_row.set_subtitle(&ensemble.name);
|
||||
self.editor.set_may_save(true);
|
||||
} else {
|
||||
self.ensemble_row.set_subtitle("");
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the UI according to role.
|
||||
fn show_role(&self, role: Option<&Instrument>) {
|
||||
if let Some(role) = role {
|
||||
self.role_row.set_subtitle(&role.name);
|
||||
self.reset_role_button.show();
|
||||
} else {
|
||||
self.role_row.set_subtitle("");
|
||||
self.reset_role_button.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for PerformanceEditor {
|
||||
fn get_widget(&self) -> gtk::Widget {
|
||||
self.editor.widget.clone().upcast()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
use crate::navigator::{NavigationHandle, Screen};
|
||||
use crate::widgets::{Editor, Section, Widget};
|
||||
use anyhow::Result;
|
||||
use gettextrs::gettext;
|
||||
use glib::clone;
|
||||
use gtk::prelude::*;
|
||||
use musicus_backend::db::{self, generate_id, Person};
|
||||
use std::rc::Rc;
|
||||
|
||||
/// A dialog for creating or editing a person.
|
||||
pub struct PersonEditor {
|
||||
handle: NavigationHandle<Person>,
|
||||
|
||||
/// The ID of the person that is edited or a newly generated one.
|
||||
id: String,
|
||||
|
||||
editor: Editor,
|
||||
first_name: adw::EntryRow,
|
||||
last_name: adw::EntryRow,
|
||||
}
|
||||
|
||||
impl Screen<Option<Person>, Person> for PersonEditor {
|
||||
/// Create a new person editor and optionally initialize it.
|
||||
fn new(person: Option<Person>, handle: NavigationHandle<Person>) -> Rc<Self> {
|
||||
let editor = Editor::new();
|
||||
editor.set_title("Person");
|
||||
|
||||
let list = gtk::ListBox::builder()
|
||||
.selection_mode(gtk::SelectionMode::None)
|
||||
.css_classes(vec![String::from("boxed-list")])
|
||||
.build();
|
||||
|
||||
let first_name = adw::EntryRow::builder()
|
||||
.title(gettext("First name"))
|
||||
.build();
|
||||
|
||||
let last_name = adw::EntryRow::builder().title(gettext("Last name")).build();
|
||||
|
||||
list.append(&first_name);
|
||||
list.append(&last_name);
|
||||
|
||||
let section = Section::new(&gettext("General"), &list);
|
||||
editor.add_content(§ion.widget);
|
||||
|
||||
let id = match person {
|
||||
Some(person) => {
|
||||
first_name.set_text(&person.first_name);
|
||||
last_name.set_text(&person.last_name);
|
||||
|
||||
person.id
|
||||
}
|
||||
None => generate_id(),
|
||||
};
|
||||
|
||||
let this = Rc::new(Self {
|
||||
handle,
|
||||
id,
|
||||
editor,
|
||||
first_name,
|
||||
last_name,
|
||||
});
|
||||
|
||||
// Connect signals and callbacks
|
||||
|
||||
this.editor.set_back_cb(clone!(@weak this => move || {
|
||||
this.handle.pop(None);
|
||||
}));
|
||||
|
||||
this.editor.set_save_cb(clone!(@strong this => move || {
|
||||
match this.save() {
|
||||
Ok(person) => {
|
||||
this.handle.pop(Some(person));
|
||||
}
|
||||
Err(err) => {
|
||||
let description = gettext!("Cause: {}", err);
|
||||
this.editor.error(&gettext("Failed to save person!"), &description);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
this.first_name
|
||||
.connect_changed(clone!(@weak this => move |_| this.validate()));
|
||||
|
||||
this.last_name
|
||||
.connect_changed(clone!(@weak this => move |_| this.validate()));
|
||||
|
||||
this.validate();
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
impl PersonEditor {
|
||||
/// Validate inputs and enable/disable saving.
|
||||
fn validate(&self) {
|
||||
self.editor
|
||||
.set_may_save(!self.first_name.text().is_empty() && !self.last_name.text().is_empty());
|
||||
}
|
||||
|
||||
/// Save the person.
|
||||
fn save(self: &Rc<Self>) -> Result<Person> {
|
||||
let first_name = self.first_name.text();
|
||||
let last_name = self.last_name.text();
|
||||
|
||||
let person = Person::new(
|
||||
self.id.clone(),
|
||||
first_name.to_string(),
|
||||
last_name.to_string(),
|
||||
);
|
||||
|
||||
db::update_person(
|
||||
&mut self.handle.backend.db().lock().unwrap(),
|
||||
person.clone(),
|
||||
)?;
|
||||
|
||||
self.handle.backend.library_changed();
|
||||
|
||||
Ok(person)
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for PersonEditor {
|
||||
fn get_widget(&self) -> gtk::Widget {
|
||||
self.editor.widget.clone().upcast()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,206 +0,0 @@
|
|||
use super::performance::PerformanceEditor;
|
||||
use crate::navigator::{NavigationHandle, Screen};
|
||||
use crate::selectors::WorkSelector;
|
||||
use crate::widgets::{List, Widget};
|
||||
|
||||
use adw::prelude::*;
|
||||
use anyhow::Result;
|
||||
use gettextrs::gettext;
|
||||
use glib::clone;
|
||||
use gtk_macros::get_widget;
|
||||
use musicus_backend::db::{self, generate_id, Performance, Recording, Work};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// A widget for creating or editing a recording.
|
||||
pub struct RecordingEditor {
|
||||
handle: NavigationHandle<Recording>,
|
||||
widget: gtk::Stack,
|
||||
save_button: gtk::Button,
|
||||
info_bar: gtk::InfoBar,
|
||||
work_row: adw::ActionRow,
|
||||
comment_row: adw::EntryRow,
|
||||
performance_list: Rc<List>,
|
||||
id: String,
|
||||
work: RefCell<Option<Work>>,
|
||||
performances: RefCell<Vec<Performance>>,
|
||||
}
|
||||
|
||||
impl Screen<Option<Recording>, Recording> for RecordingEditor {
|
||||
/// Create a new recording editor widget and optionally initialize it.
|
||||
fn new(recording: Option<Recording>, handle: NavigationHandle<Recording>) -> Rc<Self> {
|
||||
// Create UI
|
||||
|
||||
let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/recording_editor.ui");
|
||||
|
||||
get_widget!(builder, gtk::Stack, widget);
|
||||
get_widget!(builder, gtk::Button, back_button);
|
||||
get_widget!(builder, gtk::Button, save_button);
|
||||
get_widget!(builder, gtk::InfoBar, info_bar);
|
||||
get_widget!(builder, adw::ActionRow, work_row);
|
||||
get_widget!(builder, gtk::Button, work_button);
|
||||
get_widget!(builder, adw::EntryRow, comment_row);
|
||||
get_widget!(builder, gtk::Frame, performance_frame);
|
||||
get_widget!(builder, gtk::Button, add_performer_button);
|
||||
|
||||
let performance_list = List::new();
|
||||
performance_frame.set_child(Some(&performance_list.widget));
|
||||
|
||||
let (id, work, performances) = match recording {
|
||||
Some(recording) => {
|
||||
comment_row.set_text(&recording.comment);
|
||||
(recording.id, Some(recording.work), recording.performances)
|
||||
}
|
||||
None => (generate_id(), None, Vec::new()),
|
||||
};
|
||||
|
||||
let this = Rc::new(RecordingEditor {
|
||||
handle,
|
||||
widget,
|
||||
save_button,
|
||||
info_bar,
|
||||
work_row,
|
||||
comment_row,
|
||||
performance_list,
|
||||
id,
|
||||
work: RefCell::new(work),
|
||||
performances: RefCell::new(performances),
|
||||
});
|
||||
|
||||
// Connect signals and callbacks
|
||||
|
||||
back_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
this.handle.pop(None);
|
||||
}));
|
||||
|
||||
this.save_button
|
||||
.connect_clicked(clone!(@weak this => move |_| {
|
||||
match this.save() {
|
||||
Ok(recording) => {
|
||||
this.handle.pop(Some(recording));
|
||||
}
|
||||
Err(_) => {
|
||||
this.info_bar.set_revealed(true);
|
||||
this.widget.set_visible_child_name("content");
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
work_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
spawn!(@clone this, async move {
|
||||
if let Some(work) = push!(this.handle, WorkSelector).await {
|
||||
this.work_selected(&work);
|
||||
this.work.replace(Some(work));
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
this.performance_list.set_make_widget_cb(clone!(@weak this => @default-panic, move |index| {
|
||||
let performance = &this.performances.borrow()[index];
|
||||
|
||||
let delete_button = gtk::Button::from_icon_name("user-trash-symbolic");
|
||||
delete_button.set_valign(gtk::Align::Center);
|
||||
|
||||
delete_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
let length = {
|
||||
let mut performances = this.performances.borrow_mut();
|
||||
performances.remove(index);
|
||||
performances.len()
|
||||
};
|
||||
|
||||
this.performance_list.update(length);
|
||||
}));
|
||||
|
||||
let edit_button = gtk::Button::from_icon_name("document-edit-symbolic");
|
||||
edit_button.set_valign(gtk::Align::Center);
|
||||
|
||||
edit_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
spawn!(@clone this, async move {
|
||||
let performance = this.performances.borrow()[index].clone();
|
||||
if let Some(performance) = push!(this.handle, PerformanceEditor, Some(performance)).await {
|
||||
let length = {
|
||||
let mut performances = this.performances.borrow_mut();
|
||||
performances[index] = performance;
|
||||
performances.len()
|
||||
};
|
||||
|
||||
this.performance_list.update(length);
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
let row = adw::ActionRow::builder()
|
||||
.focusable(false)
|
||||
.activatable_widget(&edit_button)
|
||||
.title(performance.get_title())
|
||||
.build();
|
||||
|
||||
row.add_suffix(&delete_button);
|
||||
row.add_suffix(&edit_button);
|
||||
|
||||
row.upcast()
|
||||
}));
|
||||
|
||||
add_performer_button.connect_clicked(clone!(@strong this => move |_| {
|
||||
spawn!(@clone this, async move {
|
||||
if let Some(performance) = push!(this.handle, PerformanceEditor, None).await {
|
||||
let length = {
|
||||
let mut performances = this.performances.borrow_mut();
|
||||
performances.push(performance);
|
||||
performances.len()
|
||||
};
|
||||
|
||||
this.performance_list.update(length);
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
// Initialize
|
||||
|
||||
if let Some(work) = &*this.work.borrow() {
|
||||
this.work_selected(work);
|
||||
}
|
||||
|
||||
let length = this.performances.borrow().len();
|
||||
this.performance_list.update(length);
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
impl RecordingEditor {
|
||||
/// Update the UI according to work.
|
||||
fn work_selected(&self, work: &Work) {
|
||||
self.work_row.set_title(&gettext("Work"));
|
||||
self.work_row.set_subtitle(&work.get_title());
|
||||
self.save_button.set_sensitive(true);
|
||||
}
|
||||
|
||||
/// Save the recording.
|
||||
fn save(self: &Rc<Self>) -> Result<Recording> {
|
||||
let recording = Recording::new(
|
||||
self.id.clone(),
|
||||
self.work
|
||||
.borrow()
|
||||
.clone()
|
||||
.expect("Tried to create recording without work!"),
|
||||
self.comment_row.text().to_string(),
|
||||
self.performances.borrow().clone(),
|
||||
);
|
||||
|
||||
db::update_recording(
|
||||
&mut self.handle.backend.db().lock().unwrap(),
|
||||
recording.clone(),
|
||||
)?;
|
||||
|
||||
self.handle.backend.library_changed();
|
||||
|
||||
Ok(recording)
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for RecordingEditor {
|
||||
fn get_widget(&self) -> gtk::Widget {
|
||||
self.widget.clone().upcast()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,275 +0,0 @@
|
|||
use super::work_part::WorkPartEditor;
|
||||
use crate::navigator::{NavigationHandle, Screen};
|
||||
use crate::selectors::{InstrumentSelector, PersonSelector};
|
||||
use crate::widgets::{List, Widget};
|
||||
|
||||
use adw::prelude::*;
|
||||
use anyhow::Result;
|
||||
use gettextrs::gettext;
|
||||
use glib::clone;
|
||||
use gtk_macros::get_widget;
|
||||
use musicus_backend::db::{self, generate_id, Instrument, Person, Work, WorkPart};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// A widget for editing and creating works.
|
||||
pub struct WorkEditor {
|
||||
handle: NavigationHandle<Work>,
|
||||
widget: gtk::Stack,
|
||||
save_button: gtk::Button,
|
||||
title_row: adw::EntryRow,
|
||||
info_bar: gtk::InfoBar,
|
||||
composer_row: adw::ActionRow,
|
||||
instrument_list: Rc<List>,
|
||||
part_list: Rc<List>,
|
||||
id: String,
|
||||
composer: RefCell<Option<Person>>,
|
||||
instruments: RefCell<Vec<Instrument>>,
|
||||
parts: RefCell<Vec<WorkPart>>,
|
||||
}
|
||||
|
||||
impl Screen<Option<Work>, Work> for WorkEditor {
|
||||
/// Create a new work editor widget and optionally initialize it.
|
||||
fn new(work: Option<Work>, handle: NavigationHandle<Work>) -> Rc<Self> {
|
||||
// Create UI
|
||||
|
||||
let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/work_editor.ui");
|
||||
|
||||
get_widget!(builder, gtk::Stack, widget);
|
||||
get_widget!(builder, gtk::Button, back_button);
|
||||
get_widget!(builder, gtk::Button, save_button);
|
||||
get_widget!(builder, gtk::InfoBar, info_bar);
|
||||
get_widget!(builder, adw::EntryRow, title_row);
|
||||
get_widget!(builder, gtk::Button, composer_button);
|
||||
get_widget!(builder, adw::ActionRow, composer_row);
|
||||
get_widget!(builder, gtk::Frame, instrument_frame);
|
||||
get_widget!(builder, gtk::Button, add_instrument_button);
|
||||
get_widget!(builder, gtk::Frame, structure_frame);
|
||||
get_widget!(builder, gtk::Button, add_part_button);
|
||||
|
||||
let instrument_list = List::new();
|
||||
instrument_frame.set_child(Some(&instrument_list.widget));
|
||||
|
||||
let part_list = List::new();
|
||||
part_list.set_enable_dnd(true);
|
||||
structure_frame.set_child(Some(&part_list.widget));
|
||||
|
||||
let (id, composer, instruments, structure) = match work {
|
||||
Some(work) => {
|
||||
title_row.set_text(&work.title);
|
||||
(work.id, Some(work.composer), work.instruments, work.parts)
|
||||
}
|
||||
None => (generate_id(), None, Vec::new(), Vec::new()),
|
||||
};
|
||||
|
||||
let this = Rc::new(Self {
|
||||
handle,
|
||||
widget,
|
||||
save_button,
|
||||
id,
|
||||
info_bar,
|
||||
title_row,
|
||||
composer_row,
|
||||
instrument_list,
|
||||
part_list,
|
||||
composer: RefCell::new(composer),
|
||||
instruments: RefCell::new(instruments),
|
||||
parts: RefCell::new(structure),
|
||||
});
|
||||
|
||||
// Connect signals and callbacks
|
||||
|
||||
back_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
this.handle.pop(None);
|
||||
}));
|
||||
|
||||
this.save_button
|
||||
.connect_clicked(clone!(@weak this => move |_| {
|
||||
match this.save() {
|
||||
Ok(work) => {
|
||||
this.handle.pop(Some(work));
|
||||
}
|
||||
Err(_) => {
|
||||
this.info_bar.set_revealed(true);
|
||||
this.widget.set_visible_child_name("content");
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
composer_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
spawn!(@clone this, async move {
|
||||
if let Some(person) = push!(this.handle, PersonSelector).await {
|
||||
this.show_composer(&person);
|
||||
this.composer.replace(Some(person));
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
this.title_row
|
||||
.connect_changed(clone!(@weak this => move |_| this.validate()));
|
||||
|
||||
this.instrument_list.set_make_widget_cb(
|
||||
clone!(@weak this => @default-panic, move |index| {
|
||||
let instrument = &this.instruments.borrow()[index];
|
||||
|
||||
let delete_button = gtk::Button::from_icon_name("user-trash-symbolic");
|
||||
delete_button.set_valign(gtk::Align::Center);
|
||||
|
||||
delete_button.connect_clicked(clone!(@strong this => move |_| {
|
||||
let length = {
|
||||
let mut instruments = this.instruments.borrow_mut();
|
||||
instruments.remove(index);
|
||||
instruments.len()
|
||||
};
|
||||
|
||||
this.instrument_list.update(length);
|
||||
}));
|
||||
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&instrument.name)
|
||||
.build();
|
||||
|
||||
row.add_suffix(&delete_button);
|
||||
|
||||
row.upcast()
|
||||
}),
|
||||
);
|
||||
|
||||
add_instrument_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
spawn!(@clone this, async move {
|
||||
if let Some(instrument) = push!(this.handle, InstrumentSelector).await {
|
||||
let length = {
|
||||
let mut instruments = this.instruments.borrow_mut();
|
||||
instruments.push(instrument);
|
||||
instruments.len()
|
||||
};
|
||||
|
||||
this.instrument_list.update(length);
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
this.part_list
|
||||
.set_make_widget_cb(clone!(@weak this => @default-panic, move |index| {
|
||||
let part = &this.parts.borrow()[index];
|
||||
|
||||
let delete_button = gtk::Button::from_icon_name("user-trash-symbolic");
|
||||
delete_button.set_valign(gtk::Align::Center);
|
||||
|
||||
delete_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
let length = {
|
||||
let mut structure = this.parts.borrow_mut();
|
||||
structure.remove(index);
|
||||
structure.len()
|
||||
};
|
||||
|
||||
this.part_list.update(length);
|
||||
}));
|
||||
|
||||
let edit_button = gtk::Button::from_icon_name("document-edit-symbolic");
|
||||
edit_button.set_valign(gtk::Align::Center);
|
||||
|
||||
edit_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
spawn!(@clone this, async move {
|
||||
let part = this.parts.borrow()[index].clone();
|
||||
if let Some(part) = push!(this.handle, WorkPartEditor, Some(part)).await {
|
||||
let length = {
|
||||
let mut structure = this.parts.borrow_mut();
|
||||
structure[index] = part;
|
||||
structure.len()
|
||||
};
|
||||
|
||||
this.part_list.update(length);
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
let row = adw::ActionRow::builder()
|
||||
.focusable(false)
|
||||
.title(&part.title)
|
||||
.activatable_widget(&edit_button)
|
||||
.build();
|
||||
|
||||
row.add_suffix(&delete_button);
|
||||
row.add_suffix(&edit_button);
|
||||
|
||||
row.upcast()
|
||||
}));
|
||||
|
||||
this.part_list
|
||||
.set_move_cb(clone!(@weak this => move |old_index, new_index| {
|
||||
let length = {
|
||||
let mut parts = this.parts.borrow_mut();
|
||||
parts.swap(old_index, new_index);
|
||||
parts.len()
|
||||
};
|
||||
|
||||
this.part_list.update(length);
|
||||
}));
|
||||
|
||||
add_part_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
spawn!(@clone this, async move {
|
||||
if let Some(part) = push!(this.handle, WorkPartEditor, None).await {
|
||||
let length = {
|
||||
let mut parts = this.parts.borrow_mut();
|
||||
parts.push(part);
|
||||
parts.len()
|
||||
};
|
||||
|
||||
this.part_list.update(length);
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
// Initialization
|
||||
|
||||
if let Some(composer) = &*this.composer.borrow() {
|
||||
this.show_composer(composer);
|
||||
}
|
||||
|
||||
this.instrument_list.update(this.instruments.borrow().len());
|
||||
this.part_list.update(this.parts.borrow().len());
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
impl WorkEditor {
|
||||
/// Update the UI according to person.
|
||||
fn show_composer(&self, person: &Person) {
|
||||
self.composer_row.set_title(&gettext("Composer"));
|
||||
self.composer_row.set_subtitle(&person.name_fl());
|
||||
self.validate();
|
||||
}
|
||||
|
||||
/// Validate inputs and enable/disable saving.
|
||||
fn validate(&self) {
|
||||
self.save_button
|
||||
.set_sensitive(!self.title_row.text().is_empty() && self.composer.borrow().is_some());
|
||||
}
|
||||
|
||||
/// Save the work.
|
||||
fn save(self: &Rc<Self>) -> Result<Work> {
|
||||
let work = Work::new(
|
||||
self.id.clone(),
|
||||
self.title_row.text().to_string(),
|
||||
self.composer
|
||||
.borrow()
|
||||
.clone()
|
||||
.expect("Tried to create work without composer!"),
|
||||
self.instruments.borrow().clone(),
|
||||
self.parts.borrow().clone(),
|
||||
);
|
||||
|
||||
db::update_work(&mut self.handle.backend.db().lock().unwrap(), work.clone())?;
|
||||
self.handle.backend.library_changed();
|
||||
|
||||
Ok(work)
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for WorkEditor {
|
||||
fn get_widget(&self) -> gtk::Widget {
|
||||
self.widget.clone().upcast()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
use crate::navigator::{NavigationHandle, Screen};
|
||||
use crate::widgets::Widget;
|
||||
use glib::clone;
|
||||
use gtk::prelude::*;
|
||||
use gtk_macros::get_widget;
|
||||
use musicus_backend::db::WorkPart;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// A dialog for creating or editing a work section.
|
||||
pub struct WorkPartEditor {
|
||||
handle: NavigationHandle<WorkPart>,
|
||||
widget: gtk::Box,
|
||||
save_button: gtk::Button,
|
||||
title_row: adw::EntryRow,
|
||||
}
|
||||
|
||||
impl Screen<Option<WorkPart>, WorkPart> for WorkPartEditor {
|
||||
/// Create a new part editor and optionally initialize it.
|
||||
fn new(section: Option<WorkPart>, handle: NavigationHandle<WorkPart>) -> Rc<Self> {
|
||||
// Create UI
|
||||
|
||||
let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/work_part_editor.ui");
|
||||
|
||||
get_widget!(builder, gtk::Box, widget);
|
||||
get_widget!(builder, gtk::Button, back_button);
|
||||
get_widget!(builder, gtk::Button, save_button);
|
||||
get_widget!(builder, adw::EntryRow, title_row);
|
||||
|
||||
if let Some(section) = section {
|
||||
title_row.set_text(§ion.title);
|
||||
}
|
||||
|
||||
let this = Rc::new(Self {
|
||||
handle,
|
||||
widget,
|
||||
save_button,
|
||||
title_row,
|
||||
});
|
||||
|
||||
// Connect signals and callbacks
|
||||
|
||||
back_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
this.handle.pop(None);
|
||||
}));
|
||||
|
||||
this.save_button
|
||||
.connect_clicked(clone!(@weak this => move |_| {
|
||||
let section = WorkPart {
|
||||
title: this.title_row.text().to_string(),
|
||||
};
|
||||
|
||||
this.handle.pop(Some(section));
|
||||
}));
|
||||
|
||||
this.title_row
|
||||
.connect_changed(clone!(@weak this => move |_| this.validate()));
|
||||
|
||||
this.validate();
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
impl WorkPartEditor {
|
||||
/// Validate inputs and enable/disable saving.
|
||||
fn validate(&self) {
|
||||
self.save_button
|
||||
.set_sensitive(!self.title_row.text().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for WorkPartEditor {
|
||||
fn get_widget(&self) -> gtk::Widget {
|
||||
self.widget.clone().upcast()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
use super::medium_editor::MediumEditor;
|
||||
use super::medium_preview::MediumPreview;
|
||||
use crate::navigator::{NavigationHandle, Screen};
|
||||
use crate::selectors::MediumSelector;
|
||||
use crate::widgets::Widget;
|
||||
|
||||
use adw::prelude::*;
|
||||
use glib::clone;
|
||||
use gtk_macros::get_widget;
|
||||
use musicus_backend::db::{self, Medium};
|
||||
use musicus_backend::import::ImportSession;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A dialog for selecting metadata when importing music.
|
||||
pub struct ImportScreen {
|
||||
handle: NavigationHandle<()>,
|
||||
session: Arc<ImportSession>,
|
||||
widget: gtk::Box,
|
||||
matching_stack: gtk::Stack,
|
||||
error_row: adw::ActionRow,
|
||||
matching_list: gtk::ListBox,
|
||||
}
|
||||
|
||||
impl ImportScreen {
|
||||
/// Find matching mediums in the library.
|
||||
fn load_matches(self: &Rc<Self>) {
|
||||
self.matching_stack.set_visible_child_name("loading");
|
||||
|
||||
let this = self;
|
||||
spawn!(@clone this, async move {
|
||||
let mediums = db::get_mediums_by_source_id(
|
||||
&mut this.handle.backend.db().lock().unwrap(),
|
||||
this.session.source_id()
|
||||
);
|
||||
|
||||
match mediums {
|
||||
Ok(mediums) => {
|
||||
if !mediums.is_empty() {
|
||||
this.show_matches(mediums);
|
||||
this.matching_stack.set_visible_child_name("content");
|
||||
} else {
|
||||
this.matching_stack.set_visible_child_name("empty");
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
this.error_row.set_subtitle(&err.to_string());
|
||||
this.matching_stack.set_visible_child_name("error");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Populate the list of matches
|
||||
fn show_matches(self: &Rc<Self>, mediums: Vec<Medium>) {
|
||||
if let Some(mut child) = self.matching_list.first_child() {
|
||||
loop {
|
||||
let next_child = child.next_sibling();
|
||||
self.matching_list.remove(&child);
|
||||
|
||||
match next_child {
|
||||
Some(next_child) => child = next_child,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let this = self;
|
||||
|
||||
for medium in mediums {
|
||||
let row = adw::ActionRow::builder()
|
||||
.activatable(true)
|
||||
.title(&medium.name)
|
||||
.subtitle(format!("{} Tracks", medium.tracks.len()))
|
||||
.build();
|
||||
|
||||
row.connect_activated(clone!(@weak this => move |_| {
|
||||
let medium = medium.clone();
|
||||
spawn!(@clone this, async move {
|
||||
if let Some(()) = push!(this.handle, MediumPreview, (this.session.clone(), medium.clone())).await {
|
||||
this.handle.pop(Some(()));
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
this.matching_list.append(&row);
|
||||
}
|
||||
}
|
||||
|
||||
/// Select a medium from somewhere and present a preview.
|
||||
fn select_medium(self: &Rc<Self>, medium: Medium) {
|
||||
let this = self;
|
||||
|
||||
spawn!(@clone this, async move {
|
||||
if let Some(()) = push!(this.handle, MediumPreview, (this.session.clone(), medium)).await {
|
||||
this.handle.pop(Some(()));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Screen<Arc<ImportSession>, ()> for ImportScreen {
|
||||
/// Create a new import screen.
|
||||
fn new(session: Arc<ImportSession>, handle: NavigationHandle<()>) -> Rc<Self> {
|
||||
// Create UI
|
||||
|
||||
let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/import_screen.ui");
|
||||
|
||||
get_widget!(builder, gtk::Box, widget);
|
||||
get_widget!(builder, gtk::Button, back_button);
|
||||
get_widget!(builder, gtk::Stack, matching_stack);
|
||||
get_widget!(builder, gtk::Button, try_again_button);
|
||||
get_widget!(builder, adw::ActionRow, error_row);
|
||||
get_widget!(builder, gtk::ListBox, matching_list);
|
||||
get_widget!(builder, gtk::Button, select_button);
|
||||
get_widget!(builder, gtk::Button, add_button);
|
||||
|
||||
let this = Rc::new(Self {
|
||||
handle,
|
||||
session,
|
||||
widget,
|
||||
matching_stack,
|
||||
error_row,
|
||||
matching_list,
|
||||
});
|
||||
|
||||
// Connect signals and callbacks
|
||||
|
||||
back_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
this.handle.pop(None);
|
||||
}));
|
||||
|
||||
try_again_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
this.load_matches();
|
||||
}));
|
||||
|
||||
select_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
spawn!(@clone this, async move {
|
||||
if let Some(medium) = push!(this.handle, MediumSelector).await {
|
||||
this.select_medium(medium);
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
add_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
spawn!(@clone this, async move {
|
||||
if let Some(medium) = push!(this.handle, MediumEditor, (Arc::clone(&this.session), None)).await {
|
||||
this.select_medium(medium);
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
// Initialize the view
|
||||
|
||||
this.load_matches();
|
||||
|
||||
// Copy the tracks in the background, if necessary.
|
||||
this.session.copy();
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for ImportScreen {
|
||||
fn get_widget(&self) -> gtk::Widget {
|
||||
self.widget.clone().upcast()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,220 +0,0 @@
|
|||
use super::track_set_editor::{TrackData, TrackSetData, TrackSetEditor};
|
||||
use crate::navigator::{NavigationHandle, Screen};
|
||||
use crate::widgets::{List, Widget};
|
||||
|
||||
use adw::prelude::*;
|
||||
use anyhow::Result;
|
||||
use glib::clone;
|
||||
use gtk_macros::get_widget;
|
||||
use musicus_backend::db::{generate_id, Medium, Track};
|
||||
use musicus_backend::import::ImportSession;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A dialog for editing metadata while importing music into the music library.
|
||||
pub struct MediumEditor {
|
||||
handle: NavigationHandle<Medium>,
|
||||
session: Arc<ImportSession>,
|
||||
widget: gtk::Stack,
|
||||
done_button: gtk::Button,
|
||||
name_row: adw::EntryRow,
|
||||
status_page: adw::StatusPage,
|
||||
track_set_list: Rc<List>,
|
||||
track_sets: RefCell<Vec<TrackSetData>>,
|
||||
}
|
||||
|
||||
impl Screen<(Arc<ImportSession>, Option<Medium>), Medium> for MediumEditor {
|
||||
/// Create a new medium editor.
|
||||
fn new(
|
||||
(session, medium): (Arc<ImportSession>, Option<Medium>),
|
||||
handle: NavigationHandle<Medium>,
|
||||
) -> Rc<Self> {
|
||||
// Create UI
|
||||
|
||||
let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/medium_editor.ui");
|
||||
|
||||
get_widget!(builder, gtk::Stack, widget);
|
||||
get_widget!(builder, gtk::Button, back_button);
|
||||
get_widget!(builder, gtk::Button, done_button);
|
||||
get_widget!(builder, adw::EntryRow, name_row);
|
||||
get_widget!(builder, gtk::Button, add_button);
|
||||
get_widget!(builder, gtk::Frame, frame);
|
||||
get_widget!(builder, adw::StatusPage, status_page);
|
||||
get_widget!(builder, gtk::Button, try_again_button);
|
||||
get_widget!(builder, gtk::Button, cancel_button);
|
||||
|
||||
let list = List::new();
|
||||
frame.set_child(Some(&list.widget));
|
||||
|
||||
let this = Rc::new(Self {
|
||||
handle,
|
||||
session,
|
||||
widget,
|
||||
done_button,
|
||||
name_row,
|
||||
status_page,
|
||||
track_set_list: list,
|
||||
track_sets: RefCell::new(Vec::new()),
|
||||
});
|
||||
|
||||
// Connect signals and callbacks
|
||||
|
||||
back_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
this.handle.pop(None);
|
||||
}));
|
||||
|
||||
this.done_button
|
||||
.connect_clicked(clone!(@weak this => move |_| {
|
||||
this.widget.set_visible_child_name("loading");
|
||||
spawn!(@clone this, async move {
|
||||
match this.save().await {
|
||||
Ok(medium) => this.handle.pop(Some(medium)),
|
||||
Err(err) => {
|
||||
this.status_page.set_description(Some(&err.to_string()));
|
||||
this.widget.set_visible_child_name("error");
|
||||
}
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
this.name_row
|
||||
.connect_changed(clone!(@weak this => move |_| this.validate()));
|
||||
|
||||
add_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
spawn!(@clone this, async move {
|
||||
if let Some(track_set) = push!(this.handle, TrackSetEditor, Arc::clone(&this.session)).await {
|
||||
let length = {
|
||||
let mut track_sets = this.track_sets.borrow_mut();
|
||||
track_sets.push(track_set);
|
||||
track_sets.len()
|
||||
};
|
||||
|
||||
this.track_set_list.update(length);
|
||||
this.validate();
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
this.track_set_list.set_make_widget_cb(
|
||||
clone!(@weak this => @default-panic, move |index| {
|
||||
let track_set = &this.track_sets.borrow()[index];
|
||||
|
||||
let title = track_set.recording.work.get_title();
|
||||
let subtitle = track_set.recording.get_performers();
|
||||
|
||||
let edit_image = gtk::Image::from_icon_name("document-edit-symbolic");
|
||||
let edit_button = gtk::Button::new();
|
||||
edit_button.set_has_frame(false);
|
||||
edit_button.set_valign(gtk::Align::Center);
|
||||
edit_button.set_child(Some(&edit_image));
|
||||
|
||||
let row = adw::ActionRow::builder()
|
||||
.focusable(false)
|
||||
.title(title)
|
||||
.subtitle(subtitle)
|
||||
.activatable_widget(&edit_button)
|
||||
.build();
|
||||
|
||||
row.add_suffix(&edit_button);
|
||||
|
||||
edit_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
// TODO: Implement editing.
|
||||
}));
|
||||
|
||||
row.upcast()
|
||||
}),
|
||||
);
|
||||
|
||||
try_again_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
this.widget.set_visible_child_name("content");
|
||||
}));
|
||||
|
||||
cancel_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
this.handle.pop(None);
|
||||
}));
|
||||
|
||||
// Initialize, if necessary.
|
||||
|
||||
if let Some(medium) = medium {
|
||||
this.name_row.set_text(&medium.name);
|
||||
|
||||
let mut track_sets: Vec<TrackSetData> = Vec::new();
|
||||
|
||||
for track in medium.tracks {
|
||||
let track_data = TrackData {
|
||||
track_source: track.source_index,
|
||||
work_parts: track.work_parts,
|
||||
};
|
||||
|
||||
if let Some(track_set) = track_sets.last_mut() {
|
||||
if track.recording.id == track_set.recording.id {
|
||||
track_set.tracks.push(track_data);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
track_sets.push(TrackSetData {
|
||||
recording: track.recording,
|
||||
tracks: vec![track_data],
|
||||
});
|
||||
}
|
||||
|
||||
let length = track_sets.len();
|
||||
this.track_sets.replace(track_sets);
|
||||
this.track_set_list.update(length);
|
||||
}
|
||||
|
||||
this.validate();
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
impl MediumEditor {
|
||||
/// Validate inputs and enable/disable saving.
|
||||
fn validate(&self) {
|
||||
self.done_button.set_sensitive(
|
||||
!self.name_row.text().is_empty() && !self.track_sets.borrow().is_empty(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Create the medium.
|
||||
async fn save(&self) -> Result<Medium> {
|
||||
// Convert the track set data to real track sets.
|
||||
|
||||
let mut tracks = Vec::new();
|
||||
|
||||
for track_set_data in &*self.track_sets.borrow() {
|
||||
for track_data in &track_set_data.tracks {
|
||||
let track = Track::new(
|
||||
track_set_data.recording.clone(),
|
||||
track_data.work_parts.clone(),
|
||||
track_data.track_source,
|
||||
String::new(),
|
||||
);
|
||||
|
||||
tracks.push(track);
|
||||
}
|
||||
}
|
||||
|
||||
let medium = Medium::new(
|
||||
generate_id(),
|
||||
self.name_row.text().to_string(),
|
||||
Some(self.session.source_id().to_owned()),
|
||||
tracks,
|
||||
);
|
||||
|
||||
// The medium is not added to the database, because the track paths are not known until the
|
||||
// medium is actually imported into the music library. This step will be handled by the
|
||||
// medium preview dialog.
|
||||
|
||||
Ok(medium)
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for MediumEditor {
|
||||
fn get_widget(&self) -> gtk::Widget {
|
||||
self.widget.clone().upcast()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,271 +0,0 @@
|
|||
use super::medium_editor::MediumEditor;
|
||||
use crate::navigator::{NavigationHandle, Screen};
|
||||
use crate::widgets::Widget;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use gettextrs::gettext;
|
||||
use glib::clone;
|
||||
|
||||
use gtk::prelude::*;
|
||||
use gtk_macros::get_widget;
|
||||
use musicus_backend::db::{self, Medium};
|
||||
use musicus_backend::import::{ImportSession, State};
|
||||
use std::cell::RefCell;
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A dialog for presenting the selected medium when importing music.
|
||||
pub struct MediumPreview {
|
||||
handle: NavigationHandle<()>,
|
||||
session: Arc<ImportSession>,
|
||||
medium: RefCell<Option<Medium>>,
|
||||
widget: gtk::Stack,
|
||||
import_button: gtk::Button,
|
||||
done_stack: gtk::Stack,
|
||||
name_label: gtk::Label,
|
||||
medium_box: gtk::Box,
|
||||
status_page: adw::StatusPage,
|
||||
}
|
||||
|
||||
impl Screen<(Arc<ImportSession>, Medium), ()> for MediumPreview {
|
||||
/// Create a new medium preview screen.
|
||||
fn new(
|
||||
(session, medium): (Arc<ImportSession>, Medium),
|
||||
handle: NavigationHandle<()>,
|
||||
) -> Rc<Self> {
|
||||
// Create UI
|
||||
|
||||
let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/medium_preview.ui");
|
||||
|
||||
get_widget!(builder, gtk::Stack, widget);
|
||||
get_widget!(builder, gtk::Button, back_button);
|
||||
get_widget!(builder, gtk::Button, edit_button);
|
||||
get_widget!(builder, gtk::Button, import_button);
|
||||
get_widget!(builder, gtk::Stack, done_stack);
|
||||
get_widget!(builder, gtk::Box, medium_box);
|
||||
get_widget!(builder, gtk::Label, name_label);
|
||||
get_widget!(builder, adw::StatusPage, status_page);
|
||||
get_widget!(builder, gtk::Button, try_again_button);
|
||||
|
||||
let this = Rc::new(Self {
|
||||
handle,
|
||||
session,
|
||||
medium: RefCell::new(None),
|
||||
widget,
|
||||
import_button,
|
||||
done_stack,
|
||||
name_label,
|
||||
medium_box,
|
||||
status_page,
|
||||
});
|
||||
|
||||
// Connect signals and callbacks
|
||||
|
||||
back_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
this.handle.pop(None);
|
||||
}));
|
||||
|
||||
edit_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
spawn!(@clone this, async move {
|
||||
let old_medium = this.medium.borrow().clone().unwrap();
|
||||
if let Some(medium) = push!(this.handle, MediumEditor, (this.session.clone(), Some(old_medium))).await {
|
||||
this.set_medium(medium);
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
this.import_button
|
||||
.connect_clicked(clone!(@weak this => move |_| {
|
||||
this.widget.set_visible_child_name("loading");
|
||||
|
||||
spawn!(@clone this, async move {
|
||||
match this.import().await {
|
||||
Ok(()) => this.handle.pop(Some(())),
|
||||
Err(err) => {
|
||||
this.widget.set_visible_child_name("error");
|
||||
this.status_page.set_description(Some(&err.to_string()));
|
||||
}
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
try_again_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
this.widget.set_visible_child_name("content");
|
||||
}));
|
||||
|
||||
this.set_medium(medium);
|
||||
|
||||
this.handle_state(&this.session.state());
|
||||
spawn!(@clone this, async move {
|
||||
loop {
|
||||
let state = this.session.state_change().await;
|
||||
this.handle_state(&state);
|
||||
|
||||
match state {
|
||||
State::Ready | State::Error => break,
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
impl MediumPreview {
|
||||
/// Set a new medium and update the view accordingly.
|
||||
fn set_medium(&self, medium: Medium) {
|
||||
self.name_label.set_text(&medium.name);
|
||||
|
||||
if let Some(widget) = self.medium_box.first_child() {
|
||||
let mut child = widget;
|
||||
|
||||
loop {
|
||||
let next_child = child.next_sibling();
|
||||
self.medium_box.remove(&child);
|
||||
|
||||
match next_child {
|
||||
Some(widget) => child = widget,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut last_recording_id = "";
|
||||
let mut last_list = None::<gtk::ListBox>;
|
||||
|
||||
let import_tracks = self.session.tracks();
|
||||
|
||||
for track in &medium.tracks {
|
||||
if track.recording.id != last_recording_id {
|
||||
last_recording_id = &track.recording.id;
|
||||
|
||||
let list = gtk::ListBox::builder()
|
||||
.selection_mode(gtk::SelectionMode::None)
|
||||
.margin_bottom(12)
|
||||
.css_classes(vec![String::from("boxed-list")])
|
||||
.build();
|
||||
|
||||
let header = adw::ActionRow::builder()
|
||||
.activatable(false)
|
||||
.title(track.recording.work.get_title())
|
||||
.subtitle(track.recording.get_performers())
|
||||
.build();
|
||||
|
||||
list.append(&header);
|
||||
|
||||
if let Some(list) = &last_list {
|
||||
self.medium_box.append(list);
|
||||
}
|
||||
|
||||
last_list = Some(list);
|
||||
}
|
||||
|
||||
if let Some(list) = &last_list {
|
||||
let mut parts = Vec::<String>::new();
|
||||
for part in &track.work_parts {
|
||||
parts.push(track.recording.work.parts[*part].title.clone());
|
||||
}
|
||||
|
||||
let title = if parts.is_empty() {
|
||||
gettext("Unknown")
|
||||
} else {
|
||||
parts.join(", ")
|
||||
};
|
||||
|
||||
let row = adw::ActionRow::builder()
|
||||
.activatable(false)
|
||||
.title(title)
|
||||
.subtitle(&import_tracks[track.source_index].name)
|
||||
.margin_start(12)
|
||||
.build();
|
||||
|
||||
list.append(&row);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(list) = &last_list {
|
||||
let frame = gtk::Frame::builder().margin_bottom(12).build();
|
||||
|
||||
frame.set_child(Some(list));
|
||||
self.medium_box.append(&frame);
|
||||
}
|
||||
|
||||
self.medium.replace(Some(medium));
|
||||
}
|
||||
|
||||
/// Handle a state change of the import process.
|
||||
fn handle_state(&self, state: &State) {
|
||||
match state {
|
||||
State::Waiting | State::Copying => self.done_stack.set_visible_child_name("loading"),
|
||||
State::Ready => {
|
||||
self.done_stack.set_visible_child_name("ready");
|
||||
self.import_button.set_sensitive(true);
|
||||
}
|
||||
State::Error => todo!("Import error!"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy the tracks to the music library and add the medium to the database.
|
||||
async fn import(&self) -> Result<()> {
|
||||
let medium = self.medium.borrow();
|
||||
let medium = medium.as_ref().ok_or_else(|| anyhow!("No medium set!"))?;
|
||||
|
||||
// Create a new directory in the music library path for the imported medium.
|
||||
|
||||
let music_library_path = self.handle.backend.get_music_library_path().unwrap();
|
||||
|
||||
let directory_name = sanitize_filename::sanitize_with_options(
|
||||
&medium.name,
|
||||
sanitize_filename::Options {
|
||||
windows: true,
|
||||
truncate: true,
|
||||
replacement: "",
|
||||
},
|
||||
);
|
||||
|
||||
let directory = PathBuf::from(&directory_name);
|
||||
std::fs::create_dir(music_library_path.join(&directory))?;
|
||||
|
||||
// Copy the tracks to the music library.
|
||||
|
||||
let mut tracks = Vec::new();
|
||||
let import_tracks = self.session.tracks();
|
||||
|
||||
for track in &medium.tracks {
|
||||
let mut track = track.clone();
|
||||
|
||||
// Set the track path to the new audio file location.
|
||||
|
||||
let import_track = &import_tracks[track.source_index];
|
||||
let track_path = directory.join(import_track.path.file_name().unwrap());
|
||||
track.path = track_path.to_str().unwrap().to_owned();
|
||||
|
||||
// Copy the corresponding audio file to the music library.
|
||||
std::fs::copy(&import_track.path, music_library_path.join(&track_path))?;
|
||||
|
||||
tracks.push(track);
|
||||
}
|
||||
|
||||
// Add the modified medium to the database.
|
||||
|
||||
let medium = Medium::new(
|
||||
medium.id.clone(),
|
||||
medium.name.clone(),
|
||||
medium.discid.clone(),
|
||||
tracks,
|
||||
);
|
||||
|
||||
db::update_medium(&mut self.handle.backend.db().lock().unwrap(), medium)?;
|
||||
self.handle.backend.library_changed();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for MediumPreview {
|
||||
fn get_widget(&self) -> gtk::Widget {
|
||||
self.widget.clone().upcast()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
mod import_screen;
|
||||
mod medium_editor;
|
||||
mod medium_preview;
|
||||
mod source_selector;
|
||||
mod track_editor;
|
||||
mod track_selector;
|
||||
mod track_set_editor;
|
||||
|
||||
pub use source_selector::SourceSelector;
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
use super::import_screen::ImportScreen;
|
||||
use crate::navigator::{NavigationHandle, Screen};
|
||||
use crate::widgets::Widget;
|
||||
use gettextrs::gettext;
|
||||
use glib::clone;
|
||||
use gtk::prelude::*;
|
||||
use gtk_macros::get_widget;
|
||||
use musicus_backend::import::ImportSession;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// A dialog for starting to import music.
|
||||
pub struct SourceSelector {
|
||||
handle: NavigationHandle<()>,
|
||||
widget: gtk::Stack,
|
||||
status_page: adw::StatusPage,
|
||||
}
|
||||
|
||||
impl Screen<(), ()> for SourceSelector {
|
||||
/// Create a new source selector.
|
||||
fn new(_: (), handle: NavigationHandle<()>) -> Rc<Self> {
|
||||
// Create UI
|
||||
|
||||
let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/source_selector.ui");
|
||||
|
||||
get_widget!(builder, gtk::Stack, widget);
|
||||
get_widget!(builder, gtk::Button, back_button);
|
||||
get_widget!(builder, gtk::Button, folder_button);
|
||||
get_widget!(builder, gtk::Button, disc_button);
|
||||
get_widget!(builder, adw::StatusPage, status_page);
|
||||
get_widget!(builder, gtk::Button, try_again_button);
|
||||
|
||||
let this = Rc::new(Self {
|
||||
handle,
|
||||
widget,
|
||||
status_page,
|
||||
});
|
||||
|
||||
// Connect signals and callbacks
|
||||
|
||||
back_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
this.handle.pop(None);
|
||||
}));
|
||||
|
||||
folder_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
let dialog = gtk::FileChooserDialog::new(
|
||||
Some(&gettext("Select folder")),
|
||||
Some(&this.handle.window),
|
||||
gtk::FileChooserAction::SelectFolder,
|
||||
&[
|
||||
(&gettext("Cancel"), gtk::ResponseType::Cancel),
|
||||
(&gettext("Select"), gtk::ResponseType::Accept),
|
||||
]);
|
||||
|
||||
dialog.set_modal(true);
|
||||
|
||||
dialog.connect_response(clone!(@weak this => move |dialog, response| {
|
||||
dialog.hide();
|
||||
|
||||
if let gtk::ResponseType::Accept = response {
|
||||
if let Some(file) = dialog.file() {
|
||||
if let Some(path) = file.path() {
|
||||
this.widget.set_visible_child_name("loading");
|
||||
|
||||
spawn!(@clone this, async move {
|
||||
match ImportSession::folder(path).await {
|
||||
Ok(session) => {
|
||||
let result = push!(this.handle, ImportScreen, session).await;
|
||||
this.handle.pop(result);
|
||||
}
|
||||
Err(err) => {
|
||||
this.status_page.set_description(Some(&err.to_string()));
|
||||
this.widget.set_visible_child_name("error");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
dialog.show();
|
||||
}));
|
||||
|
||||
disc_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
this.widget.set_visible_child_name("loading");
|
||||
|
||||
spawn!(@clone this, async move {
|
||||
match ImportSession::audio_cd().await {
|
||||
Ok(session) => {
|
||||
let result = push!(this.handle, ImportScreen, session).await;
|
||||
this.handle.pop(result);
|
||||
}
|
||||
Err(err) => {
|
||||
this.status_page.set_description(Some(&err.to_string()));
|
||||
this.widget.set_visible_child_name("error");
|
||||
}
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
try_again_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
this.widget.set_visible_child_name("content");
|
||||
}));
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for SourceSelector {
|
||||
fn get_widget(&self) -> gtk::Widget {
|
||||
self.widget.clone().upcast()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
use crate::navigator::{NavigationHandle, Screen};
|
||||
use crate::widgets::Widget;
|
||||
|
||||
use adw::prelude::*;
|
||||
use glib::clone;
|
||||
|
||||
use gtk_macros::get_widget;
|
||||
use musicus_backend::db::Recording;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// A screen for editing a single track.
|
||||
pub struct TrackEditor {
|
||||
handle: NavigationHandle<Vec<usize>>,
|
||||
widget: gtk::Box,
|
||||
selection: RefCell<Vec<usize>>,
|
||||
}
|
||||
|
||||
impl Screen<(Recording, Vec<usize>), Vec<usize>> for TrackEditor {
|
||||
/// Create a new track editor.
|
||||
fn new(
|
||||
(recording, selection): (Recording, Vec<usize>),
|
||||
handle: NavigationHandle<Vec<usize>>,
|
||||
) -> Rc<Self> {
|
||||
// Create UI
|
||||
|
||||
let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/track_editor.ui");
|
||||
|
||||
get_widget!(builder, gtk::Box, widget);
|
||||
get_widget!(builder, gtk::Button, back_button);
|
||||
get_widget!(builder, gtk::Button, select_button);
|
||||
get_widget!(builder, adw::Clamp, clamp);
|
||||
|
||||
let parts_list = gtk::ListBox::builder()
|
||||
.selection_mode(gtk::SelectionMode::None)
|
||||
.css_classes(vec![String::from("boxed-list")])
|
||||
.build();
|
||||
|
||||
clamp.set_child(Some(&parts_list));
|
||||
|
||||
let this = Rc::new(Self {
|
||||
handle,
|
||||
widget,
|
||||
selection: RefCell::new(selection),
|
||||
});
|
||||
|
||||
// Connect signals and callbacks
|
||||
|
||||
back_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
this.handle.pop(None);
|
||||
}));
|
||||
|
||||
select_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
let selection = this.selection.borrow().clone();
|
||||
this.handle.pop(Some(selection));
|
||||
}));
|
||||
|
||||
for (index, part) in recording.work.parts.iter().enumerate() {
|
||||
let check = gtk::CheckButton::new();
|
||||
check.set_active(this.selection.borrow().contains(&index));
|
||||
|
||||
check.connect_toggled(clone!(@weak this => move |check| {
|
||||
let mut selection = this.selection.borrow_mut();
|
||||
if check.is_active() {
|
||||
selection.push(index);
|
||||
} else if let Some(pos) = selection.iter().position(|part| *part == index) {
|
||||
selection.remove(pos);
|
||||
}
|
||||
}));
|
||||
|
||||
let row = adw::ActionRow::builder()
|
||||
.focusable(false)
|
||||
.title(&part.title)
|
||||
.activatable_widget(&check)
|
||||
.build();
|
||||
|
||||
row.add_prefix(&check);
|
||||
parts_list.append(&row);
|
||||
}
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for TrackEditor {
|
||||
fn get_widget(&self) -> gtk::Widget {
|
||||
self.widget.clone().upcast()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
use crate::navigator::{NavigationHandle, Screen};
|
||||
use crate::widgets::Widget;
|
||||
|
||||
use adw::prelude::*;
|
||||
use glib::clone;
|
||||
|
||||
use gtk_macros::get_widget;
|
||||
use musicus_backend::import::ImportSession;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A screen for selecting tracks from a source.
|
||||
pub struct TrackSelector {
|
||||
handle: NavigationHandle<Vec<usize>>,
|
||||
session: Arc<ImportSession>,
|
||||
widget: gtk::Box,
|
||||
select_button: gtk::Button,
|
||||
selection: RefCell<Vec<usize>>,
|
||||
}
|
||||
|
||||
impl Screen<Arc<ImportSession>, Vec<usize>> for TrackSelector {
|
||||
/// Create a new track selector.
|
||||
fn new(session: Arc<ImportSession>, handle: NavigationHandle<Vec<usize>>) -> Rc<Self> {
|
||||
// Create UI
|
||||
|
||||
let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/track_selector.ui");
|
||||
|
||||
get_widget!(builder, gtk::Box, widget);
|
||||
get_widget!(builder, gtk::Button, back_button);
|
||||
get_widget!(builder, gtk::Button, select_button);
|
||||
get_widget!(builder, adw::Clamp, clamp);
|
||||
|
||||
let track_list = gtk::ListBox::builder()
|
||||
.selection_mode(gtk::SelectionMode::None)
|
||||
.css_classes(vec![String::from("boxed-list")])
|
||||
.build();
|
||||
|
||||
clamp.set_child(Some(&track_list));
|
||||
|
||||
let this = Rc::new(Self {
|
||||
handle,
|
||||
session,
|
||||
widget,
|
||||
select_button,
|
||||
selection: RefCell::new(Vec::new()),
|
||||
});
|
||||
|
||||
// Connect signals and callbacks
|
||||
|
||||
back_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
this.handle.pop(None);
|
||||
}));
|
||||
|
||||
this.select_button
|
||||
.connect_clicked(clone!(@weak this => move |_| {
|
||||
let selection = this.selection.borrow().clone();
|
||||
this.handle.pop(Some(selection));
|
||||
}));
|
||||
|
||||
let tracks = this.session.tracks();
|
||||
|
||||
for (index, track) in tracks.iter().enumerate() {
|
||||
let check = gtk::CheckButton::new();
|
||||
|
||||
check.connect_toggled(clone!(@weak this => move |check| {
|
||||
let mut selection = this.selection.borrow_mut();
|
||||
if check.is_active() {
|
||||
selection.push(index);
|
||||
} else if let Some(pos) = selection.iter().position(|part| *part == index) {
|
||||
selection.remove(pos);
|
||||
}
|
||||
|
||||
if selection.is_empty() {
|
||||
this.select_button.set_sensitive(false);
|
||||
} else {
|
||||
this.select_button.set_sensitive(true);
|
||||
}
|
||||
}));
|
||||
|
||||
let row = adw::ActionRow::builder()
|
||||
.focusable(false)
|
||||
.title(&track.name)
|
||||
.activatable_widget(&check)
|
||||
.build();
|
||||
|
||||
row.add_prefix(&check);
|
||||
|
||||
track_list.append(&row);
|
||||
}
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for TrackSelector {
|
||||
fn get_widget(&self) -> gtk::Widget {
|
||||
self.widget.clone().upcast()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,233 +0,0 @@
|
|||
use super::track_editor::TrackEditor;
|
||||
use super::track_selector::TrackSelector;
|
||||
use crate::navigator::{NavigationHandle, Screen};
|
||||
use crate::selectors::RecordingSelector;
|
||||
use crate::widgets::{List, Widget};
|
||||
|
||||
use adw::prelude::*;
|
||||
use gettextrs::gettext;
|
||||
use glib::clone;
|
||||
use gtk_macros::get_widget;
|
||||
use musicus_backend::db::Recording;
|
||||
use musicus_backend::import::ImportSession;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A track set before being imported.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TrackSetData {
|
||||
pub recording: Recording,
|
||||
pub tracks: Vec<TrackData>,
|
||||
}
|
||||
|
||||
/// A track before being imported.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TrackData {
|
||||
/// Index of the track source within the medium source's tracks.
|
||||
pub track_source: usize,
|
||||
|
||||
/// Actual track data.
|
||||
pub work_parts: Vec<usize>,
|
||||
}
|
||||
|
||||
/// A screen for editing a set of tracks for one recording.
|
||||
pub struct TrackSetEditor {
|
||||
handle: NavigationHandle<TrackSetData>,
|
||||
session: Arc<ImportSession>,
|
||||
widget: gtk::Box,
|
||||
save_button: gtk::Button,
|
||||
recording_row: adw::ActionRow,
|
||||
track_list: Rc<List>,
|
||||
recording: RefCell<Option<Recording>>,
|
||||
tracks: RefCell<Vec<TrackData>>,
|
||||
}
|
||||
|
||||
impl Screen<Arc<ImportSession>, TrackSetData> for TrackSetEditor {
|
||||
/// Create a new track set editor.
|
||||
fn new(session: Arc<ImportSession>, handle: NavigationHandle<TrackSetData>) -> Rc<Self> {
|
||||
// Create UI
|
||||
|
||||
let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/track_set_editor.ui");
|
||||
|
||||
get_widget!(builder, gtk::Box, widget);
|
||||
get_widget!(builder, gtk::Button, back_button);
|
||||
get_widget!(builder, gtk::Button, save_button);
|
||||
get_widget!(builder, adw::ActionRow, recording_row);
|
||||
get_widget!(builder, gtk::Button, select_recording_button);
|
||||
get_widget!(builder, gtk::Button, edit_tracks_button);
|
||||
get_widget!(builder, gtk::Frame, tracks_frame);
|
||||
|
||||
let track_list = List::new();
|
||||
tracks_frame.set_child(Some(&track_list.widget));
|
||||
|
||||
let this = Rc::new(Self {
|
||||
handle,
|
||||
session,
|
||||
widget,
|
||||
save_button,
|
||||
recording_row,
|
||||
track_list,
|
||||
recording: RefCell::new(None),
|
||||
tracks: RefCell::new(Vec::new()),
|
||||
});
|
||||
|
||||
// Connect signals and callbacks
|
||||
|
||||
back_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
this.handle.pop(None);
|
||||
}));
|
||||
|
||||
this.save_button
|
||||
.connect_clicked(clone!(@weak this => move |_| {
|
||||
let data = TrackSetData {
|
||||
recording: this.recording.borrow().clone().unwrap(),
|
||||
tracks: this.tracks.borrow().clone(),
|
||||
};
|
||||
|
||||
this.handle.pop(Some(data));
|
||||
}));
|
||||
|
||||
select_recording_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
spawn!(@clone this, async move {
|
||||
if let Some(recording) = push!(this.handle, RecordingSelector).await {
|
||||
this.recording.replace(Some(recording));
|
||||
this.recording_selected();
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
edit_tracks_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
spawn!(@clone this, async move {
|
||||
if let Some(selection) = push!(this.handle, TrackSelector, Arc::clone(&this.session)).await {
|
||||
let mut tracks = Vec::new();
|
||||
|
||||
for index in selection {
|
||||
let data = TrackData {
|
||||
track_source: index,
|
||||
work_parts: Vec::new(),
|
||||
};
|
||||
|
||||
tracks.push(data);
|
||||
}
|
||||
|
||||
let length = tracks.len();
|
||||
this.tracks.replace(tracks);
|
||||
this.track_list.update(length);
|
||||
this.autofill_parts();
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
this.track_list.set_make_widget_cb(clone!(@weak this => @default-panic, move |index| {
|
||||
let track = &this.tracks.borrow()[index];
|
||||
|
||||
let mut title_parts = Vec::<String>::new();
|
||||
|
||||
if let Some(recording) = &*this.recording.borrow() {
|
||||
for part in &track.work_parts {
|
||||
title_parts.push(recording.work.parts[*part].title.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let title = if title_parts.is_empty() {
|
||||
gettext("Unknown")
|
||||
} else {
|
||||
title_parts.join(", ")
|
||||
};
|
||||
|
||||
let tracks = this.session.tracks();
|
||||
let track_name = &tracks[track.track_source].name;
|
||||
|
||||
let edit_image = gtk::Image::from_icon_name("document-edit-symbolic");
|
||||
let edit_button = gtk::Button::new();
|
||||
edit_button.set_has_frame(false);
|
||||
edit_button.set_valign(gtk::Align::Center);
|
||||
edit_button.set_child(Some(&edit_image));
|
||||
|
||||
let row = adw::ActionRow::builder()
|
||||
.focusable(false)
|
||||
.title(title)
|
||||
.subtitle(track_name)
|
||||
.activatable_widget(&edit_button)
|
||||
.build();
|
||||
|
||||
row.add_suffix(&edit_button);
|
||||
|
||||
edit_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
let recording = this.recording.borrow().clone();
|
||||
if let Some(recording) = recording {
|
||||
spawn!(@clone this, async move {
|
||||
let work_parts = this.tracks.borrow()[index].work_parts.clone();
|
||||
if let Some(selection) = push!(this.handle, TrackEditor, (recording, work_parts)).await {
|
||||
{
|
||||
let mut tracks = this.tracks.borrow_mut();
|
||||
let mut track = &mut tracks[index];
|
||||
track.work_parts = selection;
|
||||
};
|
||||
|
||||
this.update_tracks();
|
||||
}
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
row.upcast()
|
||||
}));
|
||||
|
||||
this.validate();
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
impl TrackSetEditor {
|
||||
/// Set everything up after selecting a recording.
|
||||
fn recording_selected(&self) {
|
||||
if let Some(recording) = &*self.recording.borrow() {
|
||||
self.recording_row.set_title(&recording.work.get_title());
|
||||
self.recording_row.set_subtitle(&recording.get_performers());
|
||||
self.save_button.set_sensitive(true);
|
||||
}
|
||||
|
||||
// This will also call validate().
|
||||
self.autofill_parts();
|
||||
}
|
||||
|
||||
/// Automatically try to put work part information from the selected recording into the
|
||||
/// selected tracks.
|
||||
fn autofill_parts(&self) {
|
||||
if let Some(recording) = &*self.recording.borrow() {
|
||||
let mut tracks = self.tracks.borrow_mut();
|
||||
|
||||
for (index, _) in recording.work.parts.iter().enumerate() {
|
||||
if let Some(mut track) = tracks.get_mut(index) {
|
||||
track.work_parts = vec![index];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.update_tracks();
|
||||
}
|
||||
|
||||
/// Update the track list.
|
||||
fn update_tracks(&self) {
|
||||
let length = self.tracks.borrow().len();
|
||||
self.track_list.update(length);
|
||||
self.validate();
|
||||
}
|
||||
|
||||
/// Validate data and allow saving if possible.
|
||||
fn validate(&self) {
|
||||
self.save_button
|
||||
.set_sensitive(self.recording.borrow().is_some() && !self.tracks.borrow().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for TrackSetEditor {
|
||||
fn get_widget(&self) -> gtk::Widget {
|
||||
self.widget.clone().upcast()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
/// Simplification for pushing new screens.
|
||||
///
|
||||
/// This macro can be invoked in two forms.
|
||||
///
|
||||
/// 1. To push screens without an input value:
|
||||
///
|
||||
/// ```
|
||||
/// let result = push!(handle, ScreenType).await;
|
||||
/// ```
|
||||
///
|
||||
/// 2. To push screens with an input value:
|
||||
///
|
||||
/// ```
|
||||
/// let result = push!(handle, ScreenType, input).await;
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! push {
|
||||
($handle:expr, $screen:ty) => {
|
||||
$handle.push::<_, _, $screen>(())
|
||||
};
|
||||
($handle:expr, $screen:ty, $input:expr) => {
|
||||
$handle.push::<_, _, $screen>($input)
|
||||
};
|
||||
}
|
||||
|
||||
/// Simplification for replacing the current navigator screen.
|
||||
///
|
||||
/// This macro can be invoked in two forms.
|
||||
///
|
||||
/// 1. To replace with screens without an input value:
|
||||
///
|
||||
/// ```
|
||||
/// let result = replace!(navigator, ScreenType).await;
|
||||
/// ```
|
||||
///
|
||||
/// 2. To replace with screens with an input value:
|
||||
///
|
||||
/// ```
|
||||
/// let result = replace!(navigator, ScreenType, input).await;
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! replace {
|
||||
($navigator:expr, $screen:ty) => {
|
||||
$navigator.replace::<_, _, $screen>(())
|
||||
};
|
||||
($navigator:expr, $screen:ty, $input:expr) => {
|
||||
$navigator.replace::<_, _, $screen>($input)
|
||||
};
|
||||
}
|
||||
|
||||
/// Spawn a future on the GLib MainContext.
|
||||
///
|
||||
/// This can be invoked in the following forms:
|
||||
///
|
||||
/// 1. For spawning a future and nothing more:
|
||||
///
|
||||
/// ```
|
||||
/// spawn!(async {
|
||||
/// // Some code
|
||||
/// });
|
||||
///
|
||||
/// 2. For spawning a future and cloning some data, that will be accessible
|
||||
/// from the async code:
|
||||
///
|
||||
/// ```
|
||||
/// spawn!(@clone data: Rc<_>, async move {
|
||||
/// // Some code
|
||||
/// });
|
||||
#[macro_export]
|
||||
macro_rules! spawn {
|
||||
($future:expr) => {{
|
||||
let context = glib::MainContext::default();
|
||||
context.spawn_local($future);
|
||||
}};
|
||||
(@clone $data:ident, $future:expr) => {{
|
||||
let context = glib::MainContext::default();
|
||||
let $data = Rc::clone(&$data);
|
||||
context.spawn_local($future);
|
||||
}};
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
use gio::prelude::*;
|
||||
use glib::clone;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
#[macro_use]
|
||||
mod macros;
|
||||
|
||||
mod config;
|
||||
mod editors;
|
||||
mod import;
|
||||
mod navigator;
|
||||
mod preferences;
|
||||
mod screens;
|
||||
mod selectors;
|
||||
mod widgets;
|
||||
|
||||
mod window;
|
||||
use window::Window;
|
||||
|
||||
mod resources;
|
||||
|
||||
fn main() {
|
||||
gettextrs::setlocale(gettextrs::LocaleCategory::LcAll, "");
|
||||
gettextrs::bindtextdomain("musicus", config::LOCALEDIR).unwrap();
|
||||
gettextrs::textdomain("musicus").unwrap();
|
||||
|
||||
gstreamer::init().expect("Failed to initialize GStreamer!");
|
||||
adw::init().expect("Failed to initialize libadwaita!");
|
||||
resources::init().expect("Failed to initialize resources!");
|
||||
|
||||
let app = gtk::Application::new(Some("de.johrpan.musicus"), gio::ApplicationFlags::empty());
|
||||
let window: RefCell<Option<Rc<Window>>> = RefCell::new(None);
|
||||
|
||||
app.connect_activate(clone!(@strong app => move |_| {
|
||||
let mut window = window.borrow_mut();
|
||||
if window.is_none() {
|
||||
window.replace(Window::new(&app));
|
||||
}
|
||||
window.as_ref().unwrap().present();
|
||||
}));
|
||||
|
||||
app.run();
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
prefix = get_option('prefix')
|
||||
localedir = join_paths(prefix, get_option('localedir'))
|
||||
|
||||
global_conf = configuration_data()
|
||||
global_conf.set_quoted('LOCALEDIR', localedir)
|
||||
global_conf.set_quoted('VERSION', meson.project_version())
|
||||
config_rs = configure_file(
|
||||
input: 'config.rs.in',
|
||||
output: 'config.rs',
|
||||
configuration: global_conf
|
||||
)
|
||||
|
||||
run_command(
|
||||
'cp',
|
||||
config_rs,
|
||||
meson.current_source_dir(),
|
||||
check: true
|
||||
)
|
||||
|
||||
resource_conf = configuration_data()
|
||||
resource_conf.set_quoted('RESOURCEFILE', resources.full_path())
|
||||
resource_rs = configure_file(
|
||||
input: 'resources.rs.in',
|
||||
output: 'resources.rs',
|
||||
configuration: resource_conf
|
||||
)
|
||||
|
||||
run_command(
|
||||
'cp',
|
||||
resource_rs,
|
||||
meson.current_source_dir(),
|
||||
check: true
|
||||
)
|
||||
|
||||
sources = files(
|
||||
'config.rs',
|
||||
'resources.rs',
|
||||
)
|
||||
|
||||
system = host_machine.system()
|
||||
if system == 'windows'
|
||||
output = meson.project_name() + '.exe'
|
||||
else
|
||||
output = meson.project_name()
|
||||
endif
|
||||
|
||||
cargo_script = find_program(join_paths(meson.source_root(), 'build-aux/cargo.sh'))
|
||||
cargo_release = custom_target(
|
||||
'cargo-build',
|
||||
build_by_default: true,
|
||||
input: sources,
|
||||
build_always_stale: true,
|
||||
depends: resources,
|
||||
output: output,
|
||||
console: true,
|
||||
install: true,
|
||||
install_dir: get_option('bindir'),
|
||||
command: [
|
||||
cargo_script,
|
||||
meson.build_root(),
|
||||
meson.source_root(),
|
||||
'@OUTPUT@',
|
||||
get_option('buildtype'),
|
||||
output,
|
||||
]
|
||||
)
|
||||
|
|
@ -1,225 +0,0 @@
|
|||
use crate::widgets::Widget;
|
||||
use futures_channel::oneshot;
|
||||
use futures_channel::oneshot::{Receiver, Sender};
|
||||
use glib::clone;
|
||||
|
||||
use gtk::prelude::*;
|
||||
use musicus_backend::Backend;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::{Rc, Weak};
|
||||
|
||||
pub mod window;
|
||||
pub use window::*;
|
||||
|
||||
/// A widget that represents a logical unit of transient user interaction and
|
||||
/// that optionally resolves to a specific return value.
|
||||
pub trait Screen<I, O>: Widget {
|
||||
/// Create a new screen and initialize it with the provided input value.
|
||||
fn new(input: I, navigation_handle: NavigationHandle<O>) -> Rc<Self>
|
||||
where
|
||||
Self: Sized;
|
||||
}
|
||||
|
||||
/// An accessor to navigation functionality for screens.
|
||||
pub struct NavigationHandle<O> {
|
||||
/// The backend, in case the screen needs it.
|
||||
pub backend: Rc<Backend>,
|
||||
|
||||
/// The toplevel window, in case the screen needs it.
|
||||
pub window: gtk::Window,
|
||||
|
||||
/// The navigator that created this navigation handle.
|
||||
navigator: Weak<Navigator>,
|
||||
|
||||
/// The sender through which the result should be sent.
|
||||
sender: Cell<Option<Sender<Option<O>>>>,
|
||||
}
|
||||
|
||||
impl<O> NavigationHandle<O> {
|
||||
/// Switch to another screen and wait for that screen's result.
|
||||
pub async fn push<I, R, S: Screen<I, R> + 'static>(&self, input: I) -> Option<R> {
|
||||
let navigator = self.unwrap_navigator();
|
||||
let receiver = navigator.push::<I, R, S>(input);
|
||||
|
||||
// If the sender is dropped, return None.
|
||||
receiver.await.unwrap_or(None)
|
||||
}
|
||||
|
||||
/// Go back to the previous screen optionally returning something.
|
||||
pub fn pop(&self, output: Option<O>) {
|
||||
self.unwrap_navigator().pop();
|
||||
|
||||
let sender = self
|
||||
.sender
|
||||
.take()
|
||||
.expect("Tried to send result from screen through a dropped sender.");
|
||||
|
||||
if sender.send(output).is_err() {
|
||||
panic!("Tried to send result from screen to non-existing previous screen.");
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the navigator and panic if it doesn't exist.
|
||||
fn unwrap_navigator(&self) -> Rc<Navigator> {
|
||||
Weak::upgrade(&self.navigator)
|
||||
.expect("Tried to access non-existing navigator from a screen.")
|
||||
}
|
||||
}
|
||||
|
||||
/// A toplevel widget for managing screens.
|
||||
pub struct Navigator {
|
||||
/// The underlying GTK widget.
|
||||
pub widget: gtk::Stack,
|
||||
|
||||
/// The backend, in case screens need it.
|
||||
backend: Rc<Backend>,
|
||||
|
||||
/// The toplevel window of the navigator, in case screens need it.
|
||||
window: gtk::Window,
|
||||
|
||||
/// The currently active screens. The last screen in this vector is the one
|
||||
/// that is currently visible.
|
||||
screens: RefCell<Vec<Rc<dyn Widget>>>,
|
||||
|
||||
/// A vector holding the widgets of the old screens that are waiting to be
|
||||
/// removed after the animation has finished.
|
||||
old_widgets: RefCell<Vec<gtk::Widget>>,
|
||||
|
||||
/// A closure that will be called when the last screen is popped.
|
||||
back_cb: RefCell<Option<Box<dyn Fn()>>>,
|
||||
}
|
||||
|
||||
impl Navigator {
|
||||
/// Create a new navigator which will display the provided widget
|
||||
/// initially.
|
||||
pub fn new<W, E>(backend: Rc<Backend>, window: &W, empty_screen: &E) -> Rc<Self>
|
||||
where
|
||||
W: IsA<gtk::Window>,
|
||||
E: IsA<gtk::Widget>,
|
||||
{
|
||||
let widget = gtk::Stack::builder()
|
||||
.hhomogeneous(false)
|
||||
.vhomogeneous(false)
|
||||
.interpolate_size(true)
|
||||
.transition_type(gtk::StackTransitionType::Crossfade)
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
widget.add_named(empty_screen, Some("empty_screen"));
|
||||
|
||||
let this = Rc::new(Self {
|
||||
widget,
|
||||
backend,
|
||||
window: window.to_owned().upcast(),
|
||||
screens: RefCell::new(Vec::new()),
|
||||
old_widgets: RefCell::new(Vec::new()),
|
||||
back_cb: RefCell::new(None),
|
||||
});
|
||||
|
||||
this.widget
|
||||
.connect_transition_running_notify(clone!(@strong this => move |_| {
|
||||
if !this.widget.is_transition_running() {
|
||||
this.clear_old_widgets();
|
||||
}
|
||||
}));
|
||||
|
||||
this
|
||||
}
|
||||
|
||||
/// Set the closure to be called when the last screen is popped so that
|
||||
/// the navigator shows its empty state.
|
||||
pub fn set_back_cb<F: Fn() + 'static>(&self, cb: F) {
|
||||
self.back_cb.replace(Some(Box::new(cb)));
|
||||
}
|
||||
|
||||
/// Drop all screens and show the provided screen instead.
|
||||
pub async fn replace<I, O, S: Screen<I, O> + 'static>(self: &Rc<Self>, input: I) -> Option<O> {
|
||||
for screen in self.screens.replace(Vec::new()) {
|
||||
self.old_widgets.borrow_mut().push(screen.get_widget());
|
||||
}
|
||||
|
||||
let receiver = self.push::<I, O, S>(input);
|
||||
|
||||
if !self.widget.is_transition_running() {
|
||||
self.clear_old_widgets();
|
||||
}
|
||||
|
||||
// We ignore the case, if a sender is dropped.
|
||||
receiver.await.unwrap_or(None)
|
||||
}
|
||||
|
||||
/// Drop all screens and go back to the initial screen. The back callback
|
||||
/// will not be called.
|
||||
pub fn reset(&self) {
|
||||
self.widget.set_visible_child_name("empty_screen");
|
||||
|
||||
for screen in self.screens.replace(Vec::new()) {
|
||||
self.old_widgets.borrow_mut().push(screen.get_widget());
|
||||
}
|
||||
|
||||
if !self.widget.is_transition_running() {
|
||||
self.clear_old_widgets();
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a screen with the provided input. This should only be called from
|
||||
/// within a navigation handle.
|
||||
fn push<I, O, S: Screen<I, O> + 'static>(self: &Rc<Self>, input: I) -> Receiver<Option<O>> {
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
|
||||
let handle = NavigationHandle {
|
||||
backend: Rc::clone(&self.backend),
|
||||
window: self.window.clone(),
|
||||
navigator: Rc::downgrade(self),
|
||||
sender: Cell::new(Some(sender)),
|
||||
};
|
||||
|
||||
let screen = S::new(input, handle);
|
||||
|
||||
let widget = screen.get_widget();
|
||||
self.widget.add_child(&widget);
|
||||
self.widget.set_visible_child(&widget);
|
||||
|
||||
self.screens.borrow_mut().push(screen);
|
||||
|
||||
receiver
|
||||
}
|
||||
|
||||
/// Pop the last screen from the list of screens.
|
||||
fn pop(&self) {
|
||||
let popped = if let Some(screen) = self.screens.borrow_mut().pop() {
|
||||
let widget = screen.get_widget();
|
||||
self.old_widgets.borrow_mut().push(widget);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if popped {
|
||||
if let Some(screen) = self.screens.borrow().last() {
|
||||
let widget = screen.get_widget();
|
||||
self.widget.set_visible_child(&widget);
|
||||
} else {
|
||||
self.widget.set_visible_child_name("empty_screen");
|
||||
|
||||
if let Some(cb) = &*self.back_cb.borrow() {
|
||||
cb()
|
||||
}
|
||||
}
|
||||
|
||||
if !self.widget.is_transition_running() {
|
||||
self.clear_old_widgets();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the old widgets.
|
||||
fn clear_old_widgets(&self) {
|
||||
for widget in self.old_widgets.borrow().iter() {
|
||||
self.widget.remove(widget);
|
||||
}
|
||||
|
||||
self.old_widgets.borrow_mut().clear();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
use super::Navigator;
|
||||
use adw::prelude::*;
|
||||
use glib::clone;
|
||||
use musicus_backend::Backend;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// A window hosting a navigator.
|
||||
pub struct NavigatorWindow {
|
||||
pub navigator: Rc<Navigator>,
|
||||
window: adw::Window,
|
||||
}
|
||||
|
||||
impl NavigatorWindow {
|
||||
/// Create a new navigator window and show it.
|
||||
pub fn new(backend: Rc<Backend>) -> Rc<Self> {
|
||||
let window = adw::Window::new();
|
||||
window.set_default_size(600, 424);
|
||||
let placeholder = gtk::Label::new(None);
|
||||
let navigator = Navigator::new(backend, &window, &placeholder);
|
||||
window.set_content(Some(&navigator.widget));
|
||||
|
||||
let this = Rc::new(Self { navigator, window });
|
||||
|
||||
this.navigator.set_back_cb(clone!(@strong this => move || {
|
||||
this.window.close();
|
||||
}));
|
||||
|
||||
this.window.show();
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
use adw::prelude::*;
|
||||
use gettextrs::gettext;
|
||||
use glib::clone;
|
||||
use gtk_macros::get_widget;
|
||||
use musicus_backend::Backend;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// A dialog for configuring the app.
|
||||
pub struct Preferences {
|
||||
backend: Rc<Backend>,
|
||||
window: adw::Window,
|
||||
music_library_path_row: adw::ActionRow,
|
||||
}
|
||||
|
||||
impl Preferences {
|
||||
/// Create a new preferences dialog.
|
||||
pub fn new<P: IsA<gtk::Window>>(backend: Rc<Backend>, parent: &P) -> Rc<Self> {
|
||||
// Create UI
|
||||
let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/preferences.ui");
|
||||
|
||||
get_widget!(builder, adw::Window, window);
|
||||
get_widget!(builder, adw::ActionRow, music_library_path_row);
|
||||
get_widget!(builder, gtk::Button, select_music_library_path_button);
|
||||
get_widget!(builder, gtk::Switch, keep_playing_switch);
|
||||
get_widget!(builder, gtk::Switch, play_full_recordings_switch);
|
||||
|
||||
window.set_transient_for(Some(parent));
|
||||
|
||||
let this = Rc::new(Self {
|
||||
backend,
|
||||
window,
|
||||
music_library_path_row,
|
||||
});
|
||||
|
||||
// Connect signals and callbacks
|
||||
|
||||
select_music_library_path_button.connect_clicked(clone!(@strong this => move |_| {
|
||||
let dialog = gtk::FileChooserDialog::new(
|
||||
Some(&gettext("Select music library folder")),
|
||||
Some(&this.window),
|
||||
gtk::FileChooserAction::SelectFolder,
|
||||
&[
|
||||
(&gettext("Cancel"), gtk::ResponseType::Cancel),
|
||||
(&gettext("Select"), gtk::ResponseType::Accept),
|
||||
]);
|
||||
|
||||
dialog.set_modal(true);
|
||||
|
||||
dialog.connect_response(clone!(@strong this => move |dialog, response| {
|
||||
if let gtk::ResponseType::Accept = response {
|
||||
if let Some(file) = dialog.file() {
|
||||
if let Some(path) = file.path() {
|
||||
Rc::clone(&this.backend).set_music_library_path(path.clone()).unwrap();
|
||||
this.music_library_path_row.set_subtitle(path.to_str().unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dialog.hide();
|
||||
}));
|
||||
|
||||
dialog.show();
|
||||
}));
|
||||
|
||||
keep_playing_switch.connect_active_notify(clone!(@weak this => move |switch| {
|
||||
Rc::clone(&this.backend).set_keep_playing(switch.is_active());
|
||||
}));
|
||||
|
||||
play_full_recordings_switch.connect_active_notify(clone!(@weak this => move |switch| {
|
||||
Rc::clone(&this.backend).set_play_full_recordings(switch.is_active());
|
||||
}));
|
||||
|
||||
// Initialize
|
||||
|
||||
if let Some(path) = this.backend.get_music_library_path() {
|
||||
this.music_library_path_row
|
||||
.set_subtitle(path.to_str().unwrap());
|
||||
}
|
||||
|
||||
keep_playing_switch.set_active(this.backend.keep_playing());
|
||||
play_full_recordings_switch.set_active(this.backend.play_full_recordings());
|
||||
|
||||
this
|
||||
}
|
||||
|
||||
/// Show the preferences dialog.
|
||||
pub fn show(&self) {
|
||||
self.window.show();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
use anyhow::Result;
|
||||
|
||||
pub fn init() -> Result<()> {
|
||||
let bytes = glib::Bytes::from(include_bytes!(@RESOURCEFILE@).as_ref());
|
||||
let resource = gio::Resource::from_data(&bytes)?;
|
||||
gio::resources_register(&resource);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
use super::{MediumScreen, RecordingScreen};
|
||||
use crate::editors::EnsembleEditor;
|
||||
use crate::navigator::{NavigationHandle, NavigatorWindow, Screen};
|
||||
use crate::widgets;
|
||||
use crate::widgets::{List, Section, Widget};
|
||||
|
||||
use adw::prelude::*;
|
||||
use gettextrs::gettext;
|
||||
use glib::clone;
|
||||
use musicus_backend::db::{self, Ensemble, Medium, Recording};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// A screen for showing recordings with a ensemble.
|
||||
pub struct EnsembleScreen {
|
||||
handle: NavigationHandle<()>,
|
||||
ensemble: Ensemble,
|
||||
widget: widgets::Screen,
|
||||
recording_list: Rc<List>,
|
||||
medium_list: Rc<List>,
|
||||
recordings: RefCell<Vec<Recording>>,
|
||||
mediums: RefCell<Vec<Medium>>,
|
||||
}
|
||||
|
||||
impl Screen<Ensemble, ()> for EnsembleScreen {
|
||||
/// Create a new ensemble screen for the specified ensemble and load the
|
||||
/// contents asynchronously.
|
||||
fn new(ensemble: Ensemble, handle: NavigationHandle<()>) -> Rc<Self> {
|
||||
let widget = widgets::Screen::new();
|
||||
widget.set_title(&ensemble.name);
|
||||
|
||||
let recording_list = List::new();
|
||||
let medium_list = List::new();
|
||||
|
||||
let this = Rc::new(Self {
|
||||
handle,
|
||||
ensemble,
|
||||
widget,
|
||||
recording_list,
|
||||
medium_list,
|
||||
recordings: RefCell::new(Vec::new()),
|
||||
mediums: RefCell::new(Vec::new()),
|
||||
});
|
||||
|
||||
this.widget.set_back_cb(clone!(@weak this => move || {
|
||||
this.handle.pop(None);
|
||||
}));
|
||||
|
||||
this.widget.add_action(
|
||||
&gettext("Edit ensemble"),
|
||||
clone!(@weak this => move || {
|
||||
spawn!(@clone this, async move {
|
||||
let window = NavigatorWindow::new(this.handle.backend.clone());
|
||||
replace!(window.navigator, EnsembleEditor, Some(this.ensemble.clone())).await;
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
this.widget.add_action(
|
||||
&gettext("Delete ensemble"),
|
||||
clone!(@weak this => move || {
|
||||
spawn!(@clone this, async move {
|
||||
db::delete_ensemble(&mut this.handle.backend.db().lock().unwrap(), &this.ensemble.id).unwrap();
|
||||
this.handle.backend.library_changed();
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
this.widget.set_search_cb(clone!(@weak this => move || {
|
||||
this.recording_list.invalidate_filter();
|
||||
this.medium_list.invalidate_filter();
|
||||
}));
|
||||
|
||||
this.recording_list.set_make_widget_cb(
|
||||
clone!(@weak this => @default-panic, move |index| {
|
||||
let recording = &this.recordings.borrow()[index];
|
||||
|
||||
let row = adw::ActionRow::builder()
|
||||
.activatable(true)
|
||||
.title(recording.work.get_title())
|
||||
.subtitle(recording.get_performers())
|
||||
.build();
|
||||
|
||||
let recording = recording.to_owned();
|
||||
row.connect_activated(clone!(@weak this => move |_| {
|
||||
let recording = recording.clone();
|
||||
spawn!(@clone this, async move {
|
||||
push!(this.handle, RecordingScreen, recording.clone()).await;
|
||||
});
|
||||
}));
|
||||
|
||||
row.upcast()
|
||||
}),
|
||||
);
|
||||
|
||||
this.recording_list
|
||||
.set_filter_cb(clone!(@weak this => @default-panic, move |index| {
|
||||
let recording = &this.recordings.borrow()[index];
|
||||
let search = this.widget.get_search();
|
||||
let text = recording.work.get_title() + &recording.get_performers();
|
||||
search.is_empty() || text.to_lowercase().contains(&search)
|
||||
}));
|
||||
|
||||
this.medium_list
|
||||
.set_make_widget_cb(clone!(@weak this => @default-panic, move |index| {
|
||||
let medium = &this.mediums.borrow()[index];
|
||||
|
||||
let row = adw::ActionRow::builder()
|
||||
.activatable(true)
|
||||
.title(&medium.name)
|
||||
.build();
|
||||
|
||||
let medium = medium.to_owned();
|
||||
row.connect_activated(clone!(@weak this => move |_| {
|
||||
let medium = medium.clone();
|
||||
spawn!(@clone this, async move {
|
||||
push!(this.handle, MediumScreen, medium.clone()).await;
|
||||
});
|
||||
}));
|
||||
|
||||
row.upcast()
|
||||
}));
|
||||
|
||||
this.medium_list
|
||||
.set_filter_cb(clone!(@weak this => @default-panic, move |index| {
|
||||
let medium = &this.mediums.borrow()[index];
|
||||
let search = this.widget.get_search();
|
||||
let name = medium.name.to_lowercase();
|
||||
search.is_empty() || name.contains(&search)
|
||||
}));
|
||||
|
||||
// Load the content.
|
||||
|
||||
let recordings = db::get_recordings_for_ensemble(
|
||||
&mut this.handle.backend.db().lock().unwrap(),
|
||||
&this.ensemble.id,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mediums = db::get_mediums_for_ensemble(
|
||||
&mut this.handle.backend.db().lock().unwrap(),
|
||||
&this.ensemble.id,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
if !recordings.is_empty() {
|
||||
let length = recordings.len();
|
||||
this.recordings.replace(recordings);
|
||||
this.recording_list.update(length);
|
||||
|
||||
let section = Section::new("Recordings", &this.recording_list.widget);
|
||||
this.widget.add_content(§ion.widget);
|
||||
}
|
||||
|
||||
if !mediums.is_empty() {
|
||||
let length = mediums.len();
|
||||
this.mediums.replace(mediums);
|
||||
this.medium_list.update(length);
|
||||
|
||||
let section = Section::new("Mediums", &this.medium_list.widget);
|
||||
this.widget.add_content(§ion.widget);
|
||||
}
|
||||
|
||||
this.widget.ready();
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for EnsembleScreen {
|
||||
fn get_widget(&self) -> gtk::Widget {
|
||||
self.widget.widget.clone().upcast()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,284 +0,0 @@
|
|||
use super::{EnsembleScreen, PersonScreen, PlayerScreen};
|
||||
use crate::config;
|
||||
use crate::import::SourceSelector;
|
||||
use crate::navigator::{NavigationHandle, Navigator, NavigatorWindow, Screen};
|
||||
use crate::preferences::Preferences;
|
||||
use crate::widgets::{List, PlayerBar, Widget};
|
||||
|
||||
use adw::prelude::*;
|
||||
use gettextrs::gettext;
|
||||
use glib::clone;
|
||||
use gtk_macros::get_widget;
|
||||
use musicus_backend::db::{self, PersonOrEnsemble};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// The main screen of the app, once it's set up and finished loading. The screen assumes that the
|
||||
/// music library and the player are available and initialized.
|
||||
pub struct MainScreen {
|
||||
handle: NavigationHandle<()>,
|
||||
widget: gtk::Box,
|
||||
leaflet: adw::Leaflet,
|
||||
search_entry: gtk::SearchEntry,
|
||||
stack: gtk::Stack,
|
||||
poe_list: Rc<List>,
|
||||
navigator: Rc<Navigator>,
|
||||
poes: RefCell<Vec<PersonOrEnsemble>>,
|
||||
}
|
||||
|
||||
impl Screen<(), ()> for MainScreen {
|
||||
/// Create a new main screen.
|
||||
fn new(_: (), handle: NavigationHandle<()>) -> Rc<Self> {
|
||||
let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/main_screen.ui");
|
||||
|
||||
get_widget!(builder, gtk::Box, widget);
|
||||
get_widget!(builder, adw::Leaflet, leaflet);
|
||||
get_widget!(builder, gtk::Revealer, play_button_revealer);
|
||||
get_widget!(builder, gtk::Button, play_button);
|
||||
get_widget!(builder, gtk::Button, add_button);
|
||||
get_widget!(builder, gtk::SearchEntry, search_entry);
|
||||
get_widget!(builder, gtk::Stack, stack);
|
||||
get_widget!(builder, gtk::ScrolledWindow, scroll);
|
||||
get_widget!(builder, gtk::Box, empty_screen);
|
||||
|
||||
let actions = gio::SimpleActionGroup::new();
|
||||
let preferences_action = gio::SimpleAction::new("preferences", None);
|
||||
let log_action = gio::SimpleAction::new("log", None);
|
||||
let about_action = gio::SimpleAction::new("about", None);
|
||||
actions.add_action(&preferences_action);
|
||||
actions.add_action(&log_action);
|
||||
actions.add_action(&about_action);
|
||||
widget.insert_action_group("widget", Some(&actions));
|
||||
|
||||
let poe_list = List::new();
|
||||
poe_list.widget.set_css_classes(&["navigation-sidebar"]);
|
||||
poe_list.enable_selection();
|
||||
|
||||
let navigator = Navigator::new(Rc::clone(&handle.backend), &handle.window, &empty_screen);
|
||||
|
||||
scroll.set_child(Some(&poe_list.widget));
|
||||
leaflet.append(&navigator.widget);
|
||||
|
||||
let player_bar = PlayerBar::new();
|
||||
widget.append(&player_bar.widget);
|
||||
player_bar.set_player(Some(Rc::clone(&handle.backend.pl())));
|
||||
|
||||
let this = Rc::new(Self {
|
||||
handle,
|
||||
widget,
|
||||
leaflet,
|
||||
search_entry,
|
||||
stack,
|
||||
poe_list,
|
||||
navigator,
|
||||
poes: RefCell::new(Vec::new()),
|
||||
});
|
||||
|
||||
preferences_action.connect_activate(clone!(@weak this => move |_, _| {
|
||||
Preferences::new(Rc::clone(&this.handle.backend), &this.handle.window).show();
|
||||
}));
|
||||
|
||||
log_action.connect_activate(clone!(@weak this => move |_, _| {
|
||||
this.show_log_window();
|
||||
}));
|
||||
|
||||
about_action.connect_activate(clone!(@weak this => move |_, _| {
|
||||
this.show_about_dialog();
|
||||
}));
|
||||
|
||||
add_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
spawn!(@clone this, async move {
|
||||
let window = NavigatorWindow::new(Rc::clone(&this.handle.backend));
|
||||
replace!(window.navigator, SourceSelector).await;
|
||||
});
|
||||
}));
|
||||
|
||||
this.search_entry
|
||||
.connect_search_changed(clone!(@weak this => move |_| {
|
||||
this.poe_list.invalidate_filter();
|
||||
}));
|
||||
|
||||
this.poe_list
|
||||
.set_make_widget_cb(clone!(@weak this => @default-panic, move |index| {
|
||||
let poe = &this.poes.borrow()[index];
|
||||
|
||||
let row = adw::ActionRow::builder()
|
||||
.activatable(true)
|
||||
.title(poe.get_title())
|
||||
.build();
|
||||
|
||||
let poe = poe.to_owned();
|
||||
row.connect_activated(clone!(@weak this => move |_| {
|
||||
let poe = poe.clone();
|
||||
spawn!(@clone this, async move {
|
||||
this.leaflet.set_visible_child(&this.navigator.widget);
|
||||
|
||||
match poe {
|
||||
PersonOrEnsemble::Person(person) => {
|
||||
replace!(this.navigator, PersonScreen, person).await;
|
||||
}
|
||||
PersonOrEnsemble::Ensemble(ensemble) => {
|
||||
replace!(this.navigator, EnsembleScreen, ensemble).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
row.upcast()
|
||||
}));
|
||||
|
||||
this.poe_list
|
||||
.set_filter_cb(clone!(@weak this => @default-panic, move |index| {
|
||||
let poe = &this.poes.borrow()[index];
|
||||
let search = this.search_entry.text().to_string().to_lowercase();
|
||||
let title = poe.get_title().to_lowercase();
|
||||
search.is_empty() || title.contains(&search)
|
||||
}));
|
||||
|
||||
this.handle.backend.pl().add_playlist_cb(
|
||||
clone!(@weak play_button_revealer => move |new_playlist| {
|
||||
play_button_revealer.set_reveal_child(new_playlist.is_empty());
|
||||
}),
|
||||
);
|
||||
|
||||
play_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
let recording = db::random_recording(&mut this.handle.backend.db().lock().unwrap());
|
||||
if let Ok(recording) = recording {
|
||||
this.handle.backend.pl().add_items(db::get_tracks(&mut this.handle.backend.db().lock().unwrap(), &recording.id).unwrap()).unwrap();
|
||||
}
|
||||
}));
|
||||
|
||||
this.navigator.set_back_cb(clone!(@weak this => move || {
|
||||
this.leaflet.set_visible_child_name("sidebar");
|
||||
}));
|
||||
|
||||
player_bar.set_playlist_cb(clone!(@weak this => move || {
|
||||
spawn!(@clone this, async move {
|
||||
push!(this.handle, PlayerScreen).await;
|
||||
});
|
||||
}));
|
||||
|
||||
// Load the content whenever there is a new library update.
|
||||
spawn!(@clone this, async move {
|
||||
loop {
|
||||
this.navigator.reset();
|
||||
|
||||
let mut poes = Vec::new();
|
||||
|
||||
let persons = db::get_persons(&mut this.handle.backend.db().lock().unwrap(), ).unwrap();
|
||||
let ensembles = db::get_ensembles(&mut this.handle.backend.db().lock().unwrap(), ).unwrap();
|
||||
|
||||
for person in persons {
|
||||
poes.push(PersonOrEnsemble::Person(person));
|
||||
}
|
||||
|
||||
for ensemble in ensembles {
|
||||
poes.push(PersonOrEnsemble::Ensemble(ensemble));
|
||||
}
|
||||
|
||||
let length = poes.len();
|
||||
this.poes.replace(poes);
|
||||
this.poe_list.update(length);
|
||||
|
||||
this.stack.set_visible_child_name("content");
|
||||
|
||||
if this.handle.backend.library_update().await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for MainScreen {
|
||||
fn get_widget(&self) -> gtk::Widget {
|
||||
self.widget.clone().upcast()
|
||||
}
|
||||
}
|
||||
|
||||
impl MainScreen {
|
||||
/// Show a window displaying all currently cached log messages.
|
||||
fn show_log_window(&self) {
|
||||
let copy_button = gtk::Button::builder().icon_name("copy-symbolic").build();
|
||||
let logger = self.handle.backend.logger();
|
||||
let toast_overlay = adw::ToastOverlay::new();
|
||||
|
||||
copy_button.connect_clicked(clone!(@weak logger, @weak toast_overlay => move |widget| {
|
||||
widget.clipboard().set_text(&logger.messages().into_iter().map(|m| m.to_string()).collect::<Vec<String>>().join("\n"));
|
||||
toast_overlay.add_toast(adw::Toast::builder().title(gettext("Copied to clipboard")).build());
|
||||
}));
|
||||
|
||||
let header = adw::HeaderBar::builder()
|
||||
.title_widget(
|
||||
&adw::WindowTitle::builder()
|
||||
.title(gettext("Debug log"))
|
||||
.build(),
|
||||
)
|
||||
.build();
|
||||
|
||||
header.pack_end(©_button);
|
||||
|
||||
let log_list = gtk::ListBox::builder()
|
||||
.selection_mode(gtk::SelectionMode::None)
|
||||
.build();
|
||||
|
||||
for message in logger.messages() {
|
||||
log_list.append(
|
||||
&adw::ActionRow::builder()
|
||||
.title(format!(
|
||||
"<b>{}</b> {} <i>{}</i>",
|
||||
message.level,
|
||||
message.time.format("%Y-%m-%d %H:%M:%S"),
|
||||
message.module
|
||||
))
|
||||
.subtitle(&message.message)
|
||||
.build(),
|
||||
);
|
||||
}
|
||||
|
||||
let content = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.build();
|
||||
|
||||
content.append(&header);
|
||||
content.append(
|
||||
>k::ScrolledWindow::builder()
|
||||
.vexpand(true)
|
||||
.child(&log_list)
|
||||
.build(),
|
||||
);
|
||||
|
||||
toast_overlay.set_child(Some(&content));
|
||||
|
||||
adw::Window::builder()
|
||||
.transient_for(&self.handle.window)
|
||||
.modal(true)
|
||||
.title(gettext("Debug log"))
|
||||
.default_width(640)
|
||||
.default_height(480)
|
||||
.content(&toast_overlay)
|
||||
.build()
|
||||
.show();
|
||||
}
|
||||
|
||||
/// Show a dialog with information on this application.
|
||||
fn show_about_dialog(&self) {
|
||||
let dialog = adw::AboutWindow::builder()
|
||||
.transient_for(&self.handle.window)
|
||||
.modal(true)
|
||||
.application_icon("de.johrpan.musicus")
|
||||
.application_name(gettext("Musicus"))
|
||||
.developer_name("Elias Projahn")
|
||||
.version(config::VERSION)
|
||||
.comments(gettext("The classical music player and organizer."))
|
||||
.website("https://code.johrpan.de/johrpan/musicus")
|
||||
.developers(vec![String::from("Elias Projahn <elias@johrpan.de>")])
|
||||
.copyright("© 2022 Elias Projahn")
|
||||
.license_type(gtk::License::Agpl30)
|
||||
.build();
|
||||
|
||||
dialog.show();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
use crate::navigator::{NavigationHandle, Screen};
|
||||
use crate::widgets;
|
||||
use crate::widgets::{List, Section, Widget};
|
||||
|
||||
use adw::prelude::*;
|
||||
use gettextrs::gettext;
|
||||
use glib::clone;
|
||||
use musicus_backend::db::Medium;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// A screen for showing the contents of a medium.
|
||||
pub struct MediumScreen {
|
||||
handle: NavigationHandle<()>,
|
||||
medium: Medium,
|
||||
widget: widgets::Screen,
|
||||
list: Rc<List>,
|
||||
}
|
||||
|
||||
impl Screen<Medium, ()> for MediumScreen {
|
||||
/// Create a new medium screen for the specified medium and load the
|
||||
/// contents asynchronously.
|
||||
fn new(medium: Medium, handle: NavigationHandle<()>) -> Rc<Self> {
|
||||
let widget = widgets::Screen::new();
|
||||
widget.set_title(&medium.name);
|
||||
|
||||
let list = List::new();
|
||||
let section = Section::new("Recordings", &list.widget);
|
||||
widget.add_content(§ion.widget);
|
||||
widget.ready();
|
||||
|
||||
let this = Rc::new(Self {
|
||||
handle,
|
||||
medium,
|
||||
widget,
|
||||
list,
|
||||
});
|
||||
|
||||
this.widget.set_back_cb(clone!(@weak this => move || {
|
||||
this.handle.pop(None);
|
||||
}));
|
||||
|
||||
this.widget.add_action(
|
||||
&gettext("Edit medium"),
|
||||
clone!(@weak this => move || {
|
||||
// TODO: Show medium editor.
|
||||
}),
|
||||
);
|
||||
|
||||
this.widget.add_action(
|
||||
&gettext("Delete medium"),
|
||||
clone!(@weak this => move || {
|
||||
// TODO: Delete medium and maybe also the tracks?
|
||||
}),
|
||||
);
|
||||
|
||||
section.add_action(
|
||||
"media-playback-start-symbolic",
|
||||
clone!(@weak this => move || {
|
||||
this.handle.backend.pl().add_items(this.medium.tracks.clone()).unwrap();
|
||||
}),
|
||||
);
|
||||
|
||||
this.list
|
||||
.set_make_widget_cb(clone!(@weak this => @default-panic, move |index| {
|
||||
let track = &this.medium.tracks[index];
|
||||
|
||||
let mut parts = Vec::<String>::new();
|
||||
for part in &track.work_parts {
|
||||
parts.push(track.recording.work.parts[*part].title.clone());
|
||||
}
|
||||
|
||||
let title = if parts.is_empty() {
|
||||
gettext("Unknown")
|
||||
} else {
|
||||
parts.join(", ")
|
||||
};
|
||||
|
||||
let row = adw::ActionRow::builder()
|
||||
.margin_start(12)
|
||||
.selectable(false)
|
||||
.title(title)
|
||||
.build();
|
||||
|
||||
row.upcast()
|
||||
}));
|
||||
|
||||
this.list.update(this.medium.tracks.len());
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for MediumScreen {
|
||||
fn get_widget(&self) -> gtk::Widget {
|
||||
self.widget.widget.clone().upcast()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
pub mod ensemble;
|
||||
pub use ensemble::*;
|
||||
|
||||
pub mod main;
|
||||
pub use main::*;
|
||||
|
||||
pub mod medium;
|
||||
pub use medium::*;
|
||||
|
||||
pub mod person;
|
||||
pub use person::*;
|
||||
|
||||
pub mod player;
|
||||
pub use player::*;
|
||||
|
||||
pub mod work;
|
||||
pub use work::*;
|
||||
|
||||
pub mod welcome;
|
||||
pub use welcome::*;
|
||||
|
||||
pub mod recording;
|
||||
pub use recording::*;
|
||||
|
|
@ -1,223 +0,0 @@
|
|||
use super::{MediumScreen, RecordingScreen, WorkScreen};
|
||||
use crate::editors::PersonEditor;
|
||||
use crate::navigator::{NavigationHandle, NavigatorWindow, Screen};
|
||||
use crate::widgets;
|
||||
use crate::widgets::{List, Section, Widget};
|
||||
|
||||
use adw::prelude::*;
|
||||
use gettextrs::gettext;
|
||||
use glib::clone;
|
||||
use musicus_backend::db::{self, Medium, Person, Recording, Work};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// A screen for showing works by and recordings with a person.
|
||||
pub struct PersonScreen {
|
||||
handle: NavigationHandle<()>,
|
||||
person: Person,
|
||||
widget: widgets::Screen,
|
||||
work_list: Rc<List>,
|
||||
recording_list: Rc<List>,
|
||||
medium_list: Rc<List>,
|
||||
works: RefCell<Vec<Work>>,
|
||||
recordings: RefCell<Vec<Recording>>,
|
||||
mediums: RefCell<Vec<Medium>>,
|
||||
}
|
||||
|
||||
impl Screen<Person, ()> for PersonScreen {
|
||||
/// Create a new person screen for the specified person and load the
|
||||
/// contents asynchronously.
|
||||
fn new(person: Person, handle: NavigationHandle<()>) -> Rc<Self> {
|
||||
let widget = widgets::Screen::new();
|
||||
widget.set_title(&person.name_fl());
|
||||
|
||||
let work_list = List::new();
|
||||
let recording_list = List::new();
|
||||
let medium_list = List::new();
|
||||
|
||||
let this = Rc::new(Self {
|
||||
handle,
|
||||
person,
|
||||
widget,
|
||||
work_list,
|
||||
recording_list,
|
||||
medium_list,
|
||||
works: RefCell::new(Vec::new()),
|
||||
recordings: RefCell::new(Vec::new()),
|
||||
mediums: RefCell::new(Vec::new()),
|
||||
});
|
||||
|
||||
this.widget.set_back_cb(clone!(@weak this => move || {
|
||||
this.handle.pop(None);
|
||||
}));
|
||||
|
||||
this.widget.add_action(
|
||||
&gettext("Edit person"),
|
||||
clone!(@weak this => move || {
|
||||
spawn!(@clone this, async move {
|
||||
let window = NavigatorWindow::new(this.handle.backend.clone());
|
||||
replace!(window.navigator, PersonEditor, Some(this.person.clone())).await;
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
this.widget.add_action(
|
||||
&gettext("Delete person"),
|
||||
clone!(@weak this => move || {
|
||||
spawn!(@clone this, async move {
|
||||
db::delete_person(&mut this.handle.backend.db().lock().unwrap(), &this.person.id).unwrap();
|
||||
this.handle.backend.library_changed();
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
this.widget.set_search_cb(clone!(@weak this => move || {
|
||||
this.work_list.invalidate_filter();
|
||||
this.recording_list.invalidate_filter();
|
||||
this.medium_list.invalidate_filter();
|
||||
}));
|
||||
|
||||
this.work_list
|
||||
.set_make_widget_cb(clone!(@weak this => @default-panic, move |index| {
|
||||
let work = &this.works.borrow()[index];
|
||||
|
||||
let row = adw::ActionRow::builder()
|
||||
.activatable(true)
|
||||
.title(&work.title)
|
||||
.build();
|
||||
|
||||
let work = work.to_owned();
|
||||
row.connect_activated(clone!(@weak this => move |_| {
|
||||
let work = work.clone();
|
||||
spawn!(@clone this, async move {
|
||||
push!(this.handle, WorkScreen, work.clone()).await;
|
||||
});
|
||||
}));
|
||||
|
||||
row.upcast()
|
||||
}));
|
||||
|
||||
this.work_list
|
||||
.set_filter_cb(clone!(@weak this => @default-panic, move|index| {
|
||||
let work = &this.works.borrow()[index];
|
||||
let search = this.widget.get_search();
|
||||
let title = work.title.to_lowercase();
|
||||
search.is_empty() || title.contains(&search)
|
||||
}));
|
||||
|
||||
this.recording_list.set_make_widget_cb(
|
||||
clone!(@weak this => @default-panic, move |index| {
|
||||
let recording = &this.recordings.borrow()[index];
|
||||
|
||||
let row = adw::ActionRow::builder()
|
||||
.activatable(true)
|
||||
.title(recording.work.get_title())
|
||||
.subtitle(recording.get_performers())
|
||||
.build();
|
||||
|
||||
let recording = recording.to_owned();
|
||||
row.connect_activated(clone!(@weak this => move |_| {
|
||||
let recording = recording.clone();
|
||||
spawn!(@clone this, async move {
|
||||
push!(this.handle, RecordingScreen, recording.clone()).await;
|
||||
});
|
||||
}));
|
||||
|
||||
row.upcast()
|
||||
}),
|
||||
);
|
||||
|
||||
this.recording_list
|
||||
.set_filter_cb(clone!(@weak this => @default-panic,move |index| {
|
||||
let recording = &this.recordings.borrow()[index];
|
||||
let search = this.widget.get_search();
|
||||
let text = recording.work.get_title() + &recording.get_performers();
|
||||
search.is_empty() || text.to_lowercase().contains(&search)
|
||||
}));
|
||||
|
||||
this.medium_list
|
||||
.set_make_widget_cb(clone!(@weak this => @default-panic, move |index| {
|
||||
let medium = &this.mediums.borrow()[index];
|
||||
|
||||
let row = adw::ActionRow::builder()
|
||||
.activatable(true)
|
||||
.title(&medium.name)
|
||||
.build();
|
||||
|
||||
let medium = medium.to_owned();
|
||||
row.connect_activated(clone!(@weak this => move |_| {
|
||||
let medium = medium.clone();
|
||||
spawn!(@clone this, async move {
|
||||
push!(this.handle, MediumScreen, medium.clone()).await;
|
||||
});
|
||||
}));
|
||||
|
||||
row.upcast()
|
||||
}));
|
||||
|
||||
this.medium_list
|
||||
.set_filter_cb(clone!(@weak this => @default-panic, move |index| {
|
||||
let medium = &this.mediums.borrow()[index];
|
||||
let search = this.widget.get_search();
|
||||
let name = medium.name.to_lowercase();
|
||||
search.is_empty() || name.contains(&search)
|
||||
}));
|
||||
|
||||
// Load the content.
|
||||
|
||||
let works = db::get_works(
|
||||
&mut this.handle.backend.db().lock().unwrap(),
|
||||
&this.person.id,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let recordings = db::get_recordings_for_person(
|
||||
&mut this.handle.backend.db().lock().unwrap(),
|
||||
&this.person.id,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mediums = db::get_mediums_for_person(
|
||||
&mut this.handle.backend.db().lock().unwrap(),
|
||||
&this.person.id,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
if !works.is_empty() {
|
||||
let length = works.len();
|
||||
this.works.replace(works);
|
||||
this.work_list.update(length);
|
||||
|
||||
let section = Section::new("Works", &this.work_list.widget);
|
||||
this.widget.add_content(§ion.widget);
|
||||
}
|
||||
|
||||
if !recordings.is_empty() {
|
||||
let length = recordings.len();
|
||||
this.recordings.replace(recordings);
|
||||
this.recording_list.update(length);
|
||||
|
||||
let section = Section::new("Recordings", &this.recording_list.widget);
|
||||
this.widget.add_content(§ion.widget);
|
||||
}
|
||||
|
||||
if !mediums.is_empty() {
|
||||
let length = mediums.len();
|
||||
this.mediums.replace(mediums);
|
||||
this.medium_list.update(length);
|
||||
|
||||
let section = Section::new("Mediums", &this.medium_list.widget);
|
||||
this.widget.add_content(§ion.widget);
|
||||
}
|
||||
|
||||
this.widget.ready();
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for PersonScreen {
|
||||
fn get_widget(&self) -> gtk::Widget {
|
||||
self.widget.widget.clone().upcast()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,260 +0,0 @@
|
|||
use crate::navigator::{NavigationHandle, Screen};
|
||||
use crate::widgets::{List, TrackRow, Widget};
|
||||
use adw::prelude::*;
|
||||
use glib::clone;
|
||||
use gtk::gdk;
|
||||
use gtk_macros::get_widget;
|
||||
use musicus_backend::db::Track;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
/// A playable track within the playlist.
|
||||
#[derive(Clone)]
|
||||
struct ListItem {
|
||||
/// Index within the playlist.
|
||||
index: usize,
|
||||
|
||||
/// Whether this is the first track of the recording.
|
||||
first: bool,
|
||||
|
||||
/// Whether this is the currently played track.
|
||||
playing: bool,
|
||||
}
|
||||
|
||||
pub struct PlayerScreen {
|
||||
handle: NavigationHandle<()>,
|
||||
widget: gtk::Box,
|
||||
title_label: gtk::Label,
|
||||
subtitle_label: gtk::Label,
|
||||
previous_button: gtk::Button,
|
||||
play_button: gtk::Button,
|
||||
next_button: gtk::Button,
|
||||
position_label: gtk::Label,
|
||||
position: gtk::Adjustment,
|
||||
duration_label: gtk::Label,
|
||||
play_image: gtk::Image,
|
||||
pause_image: gtk::Image,
|
||||
list: Rc<List>,
|
||||
playlist: RefCell<Vec<Track>>,
|
||||
items: RefCell<Vec<ListItem>>,
|
||||
seeking: Cell<bool>,
|
||||
current_track: Cell<usize>,
|
||||
}
|
||||
|
||||
impl Screen<(), ()> for PlayerScreen {
|
||||
fn new(_: (), handle: NavigationHandle<()>) -> Rc<Self> {
|
||||
let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/player_screen.ui");
|
||||
|
||||
get_widget!(builder, gtk::Box, widget);
|
||||
get_widget!(builder, gtk::Button, back_button);
|
||||
get_widget!(builder, gtk::Box, content);
|
||||
get_widget!(builder, gtk::Label, title_label);
|
||||
get_widget!(builder, gtk::Label, subtitle_label);
|
||||
get_widget!(builder, gtk::Button, previous_button);
|
||||
get_widget!(builder, gtk::Button, play_button);
|
||||
get_widget!(builder, gtk::Button, next_button);
|
||||
get_widget!(builder, gtk::Button, stop_button);
|
||||
get_widget!(builder, gtk::Label, position_label);
|
||||
get_widget!(builder, gtk::Scale, position_scale);
|
||||
get_widget!(builder, gtk::Adjustment, position);
|
||||
get_widget!(builder, gtk::Label, duration_label);
|
||||
get_widget!(builder, gtk::Image, play_image);
|
||||
get_widget!(builder, gtk::Image, pause_image);
|
||||
|
||||
let list = List::new();
|
||||
content.append(&list.widget);
|
||||
|
||||
let event_controller = gtk::EventControllerLegacy::new();
|
||||
position_scale.add_controller(event_controller.clone());
|
||||
|
||||
let this = Rc::new(Self {
|
||||
handle,
|
||||
widget,
|
||||
title_label,
|
||||
subtitle_label,
|
||||
previous_button,
|
||||
play_button,
|
||||
next_button,
|
||||
position_label,
|
||||
position,
|
||||
duration_label,
|
||||
play_image,
|
||||
pause_image,
|
||||
list,
|
||||
items: RefCell::new(Vec::new()),
|
||||
playlist: RefCell::new(Vec::new()),
|
||||
seeking: Cell::new(false),
|
||||
current_track: Cell::new(0),
|
||||
});
|
||||
|
||||
let player = &this.handle.backend.pl();
|
||||
|
||||
player.add_playlist_cb(clone!(@weak this => move |playlist| {
|
||||
if playlist.is_empty() {
|
||||
this.handle.pop(None);
|
||||
}
|
||||
|
||||
this.playlist.replace(playlist);
|
||||
this.show_playlist();
|
||||
}));
|
||||
|
||||
player.add_track_cb(clone!(@weak this, @weak player => move |current_track| {
|
||||
this.previous_button.set_sensitive(this.handle.backend.pl().has_previous());
|
||||
this.next_button.set_sensitive(this.handle.backend.pl().has_next());
|
||||
|
||||
let track = &this.playlist.borrow()[current_track];
|
||||
|
||||
let mut parts = Vec::<String>::new();
|
||||
for part in &track.work_parts {
|
||||
parts.push(track.recording.work.parts[*part].title.clone());
|
||||
}
|
||||
|
||||
let mut title = track.recording.work.get_title();
|
||||
if !parts.is_empty() {
|
||||
title = format!("{}: {}", title, parts.join(", "));
|
||||
}
|
||||
|
||||
this.title_label.set_text(&title);
|
||||
this.subtitle_label.set_text(&track.recording.get_performers());
|
||||
this.position_label.set_text("0:00");
|
||||
|
||||
this.current_track.set(current_track);
|
||||
|
||||
this.show_playlist();
|
||||
}));
|
||||
|
||||
player.add_duration_cb(clone!(@weak this => move |ms| {
|
||||
let min = ms / 60000;
|
||||
let sec = (ms % 60000) / 1000;
|
||||
this.duration_label.set_text(&format!("{}:{:02}", min, sec));
|
||||
this.position.set_upper(ms as f64);
|
||||
}));
|
||||
|
||||
player.add_playing_cb(clone!(@weak this => move |playing| {
|
||||
this.play_button.set_child(Some(if playing {
|
||||
&this.pause_image
|
||||
} else {
|
||||
&this.play_image
|
||||
}));
|
||||
}));
|
||||
|
||||
player.add_position_cb(clone!(@weak this => move |ms| {
|
||||
if !this.seeking.get() {
|
||||
let min = ms / 60000;
|
||||
let sec = (ms % 60000) / 1000;
|
||||
this.position_label.set_text(&format!("{}:{:02}", min, sec));
|
||||
this.position.set_value(ms as f64);
|
||||
}
|
||||
}));
|
||||
|
||||
back_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
this.handle.pop(None);
|
||||
}));
|
||||
|
||||
this.previous_button
|
||||
.connect_clicked(clone!(@weak this => move |_| {
|
||||
this.handle.backend.pl().previous().unwrap();
|
||||
}));
|
||||
|
||||
this.play_button
|
||||
.connect_clicked(clone!(@weak this => move |_| {
|
||||
this.handle.backend.pl().play_pause().unwrap();
|
||||
}));
|
||||
|
||||
this.next_button
|
||||
.connect_clicked(clone!(@weak this => move |_| {
|
||||
this.handle.backend.pl().next().unwrap();
|
||||
}));
|
||||
|
||||
stop_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
this.handle.backend.pl().clear();
|
||||
}));
|
||||
|
||||
event_controller.connect_event(
|
||||
clone!(@weak this => @default-return glib::signal::Inhibit(false), move |_, event| {
|
||||
if let Some(event) = event.downcast_ref::<gdk::ButtonEvent>() {
|
||||
if event.button() == gdk::BUTTON_PRIMARY {
|
||||
match event.event_type() {
|
||||
gdk::EventType::ButtonPress => {
|
||||
this.seeking.replace(true);
|
||||
}
|
||||
gdk::EventType::ButtonRelease => {
|
||||
this.handle.backend.pl().seek(this.position.value() as u64);
|
||||
this.seeking.replace(false);
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
glib::signal::Inhibit(false)
|
||||
}),
|
||||
);
|
||||
|
||||
position_scale.connect_value_changed(clone!(@weak this => move |_| {
|
||||
if this.seeking.get() {
|
||||
let ms = this.position.value() as u64;
|
||||
let min = ms / 60000;
|
||||
let sec = (ms % 60000) / 1000;
|
||||
|
||||
this.position_label.set_text(&format!("{}:{:02}", min, sec));
|
||||
}
|
||||
}));
|
||||
|
||||
this.list
|
||||
.set_make_widget_cb(clone!(@weak this => @default-panic, move |index| {
|
||||
let item = &this.items.borrow()[index];
|
||||
let track = &this.playlist.borrow()[item.index];
|
||||
TrackRow::new(track, item.first, item.playing).get_widget()
|
||||
}));
|
||||
|
||||
this.list
|
||||
.widget
|
||||
.connect_row_activated(clone!(@weak this => move |_, row| {
|
||||
let list_index = row.index();
|
||||
let list_item = this.items.borrow()[list_index as usize].clone();
|
||||
this.handle.backend.pl().set_track(list_item.index).unwrap();
|
||||
}));
|
||||
|
||||
player.send_data();
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
impl PlayerScreen {
|
||||
/// Update the user interface according to the playlist.
|
||||
fn show_playlist(&self) {
|
||||
let playlist = self.playlist.borrow();
|
||||
let current_track = self.current_track.get();
|
||||
|
||||
let mut items = Vec::new();
|
||||
let mut last_recording_id = "";
|
||||
|
||||
for (index, track) in playlist.iter().enumerate() {
|
||||
let first_track = if track.recording.id != last_recording_id {
|
||||
last_recording_id = &track.recording.id;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
items.push(ListItem {
|
||||
index,
|
||||
first: first_track,
|
||||
playing: index == current_track,
|
||||
});
|
||||
}
|
||||
|
||||
let length = items.len();
|
||||
self.items.replace(items);
|
||||
self.list.update(length);
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for PlayerScreen {
|
||||
fn get_widget(&self) -> gtk::Widget {
|
||||
self.widget.clone().upcast()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
use crate::editors::RecordingEditor;
|
||||
use crate::navigator::{NavigationHandle, NavigatorWindow, Screen};
|
||||
use crate::widgets;
|
||||
use crate::widgets::{List, Section, Widget};
|
||||
|
||||
use adw::prelude::*;
|
||||
use gettextrs::gettext;
|
||||
use glib::clone;
|
||||
use musicus_backend::db::{self, Recording, Track};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// A screen for showing a recording.
|
||||
pub struct RecordingScreen {
|
||||
handle: NavigationHandle<()>,
|
||||
recording: Recording,
|
||||
widget: widgets::Screen,
|
||||
list: Rc<List>,
|
||||
tracks: RefCell<Vec<Track>>,
|
||||
}
|
||||
|
||||
impl Screen<Recording, ()> for RecordingScreen {
|
||||
/// Create a new recording screen for the specified recording and load the
|
||||
/// contents asynchronously.
|
||||
fn new(recording: Recording, handle: NavigationHandle<()>) -> Rc<Self> {
|
||||
let widget = widgets::Screen::new();
|
||||
widget.set_title(&recording.work.get_title());
|
||||
widget.set_subtitle(&recording.get_performers());
|
||||
|
||||
let list = List::new();
|
||||
let section = Section::new(&gettext("Tracks"), &list.widget);
|
||||
widget.add_content(§ion.widget);
|
||||
|
||||
let this = Rc::new(Self {
|
||||
handle,
|
||||
recording,
|
||||
widget,
|
||||
list,
|
||||
tracks: RefCell::new(Vec::new()),
|
||||
});
|
||||
|
||||
section.add_action(
|
||||
"media-playback-start-symbolic",
|
||||
clone!(@weak this => move || {
|
||||
this.handle.backend.pl().add_items(this.tracks.borrow().clone()).unwrap();
|
||||
}),
|
||||
);
|
||||
|
||||
this.widget.set_back_cb(clone!(@weak this => move || {
|
||||
this.handle.pop(None);
|
||||
}));
|
||||
|
||||
this.widget.add_action(
|
||||
&gettext("Edit recording"),
|
||||
clone!(@weak this => move || {
|
||||
spawn!(@clone this, async move {
|
||||
let window = NavigatorWindow::new(this.handle.backend.clone());
|
||||
replace!(window.navigator, RecordingEditor, Some(this.recording.clone())).await;
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
this.widget.add_action(
|
||||
&gettext("Delete recording"),
|
||||
clone!(@weak this => move || {
|
||||
spawn!(@clone this, async move {
|
||||
db::delete_recording(&mut this.handle.backend.db().lock().unwrap(), &this.recording.id).unwrap();
|
||||
this.handle.backend.library_changed();
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
this.list
|
||||
.set_make_widget_cb(clone!(@weak this => @default-panic, move |index| {
|
||||
let track = &this.tracks.borrow()[index];
|
||||
|
||||
let mut title_parts = Vec::<String>::new();
|
||||
for part in &track.work_parts {
|
||||
title_parts.push(this.recording.work.parts[*part].title.clone());
|
||||
}
|
||||
|
||||
let title = if title_parts.is_empty() {
|
||||
gettext("Unknown")
|
||||
} else {
|
||||
title_parts.join(", ")
|
||||
};
|
||||
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(title)
|
||||
.build();
|
||||
|
||||
row.upcast()
|
||||
}));
|
||||
|
||||
// Load the content.
|
||||
|
||||
let tracks = db::get_tracks(
|
||||
&mut this.handle.backend.db().lock().unwrap(),
|
||||
&this.recording.id,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
this.show_tracks(tracks);
|
||||
this.widget.ready();
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
impl RecordingScreen {
|
||||
/// Update the tracks variable as well as the user interface.
|
||||
fn show_tracks(&self, tracks: Vec<Track>) {
|
||||
let length = tracks.len();
|
||||
self.tracks.replace(tracks);
|
||||
self.list.update(length);
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for RecordingScreen {
|
||||
fn get_widget(&self) -> gtk::Widget {
|
||||
self.widget.widget.clone().upcast()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
use crate::navigator::{NavigationHandle, Screen};
|
||||
use crate::widgets::Widget;
|
||||
use gettextrs::gettext;
|
||||
use glib::clone;
|
||||
use gtk::prelude::*;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// A screen displaying a welcome message and the necessary means to set up the application. This
|
||||
/// screen doesn't access the backend except for setting the initial values and is safe to be used
|
||||
/// while the backend is loading.
|
||||
pub struct WelcomeScreen {
|
||||
handle: NavigationHandle<()>,
|
||||
widget: gtk::Box,
|
||||
}
|
||||
|
||||
impl Screen<(), ()> for WelcomeScreen {
|
||||
fn new(_: (), handle: NavigationHandle<()>) -> Rc<Self> {
|
||||
let widget = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.build();
|
||||
|
||||
let header = gtk::HeaderBar::builder()
|
||||
.title_widget(&adw::WindowTitle::new("Musicus", ""))
|
||||
.build();
|
||||
|
||||
let button = gtk::Button::builder()
|
||||
.halign(gtk::Align::Center)
|
||||
.label(gettext("Select folder"))
|
||||
.build();
|
||||
|
||||
let welcome = adw::StatusPage::builder()
|
||||
.icon_name("folder-music-symbolic")
|
||||
.title(gettext("Welcome to Musicus!"))
|
||||
.description(gettext(
|
||||
"Get startet by selecting the folder containing your music \
|
||||
files! Musicus will create a new database there or open one that already exists.",
|
||||
))
|
||||
.child(&button)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
button.add_css_class("suggested-action");
|
||||
|
||||
widget.append(&header);
|
||||
widget.append(&welcome);
|
||||
|
||||
let this = Rc::new(Self { handle, widget });
|
||||
|
||||
button.connect_clicked(clone!(@weak this => move |_| {
|
||||
let dialog = gtk::FileChooserDialog::new(
|
||||
Some(&gettext("Select music library folder")),
|
||||
Some(&this.handle.window),
|
||||
gtk::FileChooserAction::SelectFolder,
|
||||
&[
|
||||
(&gettext("Cancel"), gtk::ResponseType::Cancel),
|
||||
(&gettext("Select"), gtk::ResponseType::Accept),
|
||||
]);
|
||||
|
||||
dialog.set_modal(true);
|
||||
|
||||
dialog.connect_response(clone!(@weak this => move |dialog, response| {
|
||||
if let gtk::ResponseType::Accept = response {
|
||||
if let Some(file) = dialog.file() {
|
||||
if let Some(path) = file.path() {
|
||||
Rc::clone(&this.handle.backend).set_music_library_path(path).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dialog.hide();
|
||||
}));
|
||||
|
||||
dialog.show();
|
||||
}));
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for WelcomeScreen {
|
||||
fn get_widget(&self) -> gtk::Widget {
|
||||
self.widget.clone().upcast()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
use super::RecordingScreen;
|
||||
use crate::editors::WorkEditor;
|
||||
use crate::navigator::{NavigationHandle, NavigatorWindow, Screen};
|
||||
use crate::widgets;
|
||||
use crate::widgets::{List, Section, Widget};
|
||||
|
||||
use adw::prelude::*;
|
||||
use gettextrs::gettext;
|
||||
use glib::clone;
|
||||
use musicus_backend::db::{self, Recording, Work};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// A screen for showing recordings of a work.
|
||||
pub struct WorkScreen {
|
||||
handle: NavigationHandle<()>,
|
||||
work: Work,
|
||||
widget: widgets::Screen,
|
||||
recording_list: Rc<List>,
|
||||
recordings: RefCell<Vec<Recording>>,
|
||||
}
|
||||
|
||||
impl Screen<Work, ()> for WorkScreen {
|
||||
/// Create a new work screen for the specified work and load the
|
||||
/// contents asynchronously.
|
||||
fn new(work: Work, handle: NavigationHandle<()>) -> Rc<Self> {
|
||||
let widget = widgets::Screen::new();
|
||||
widget.set_title(&work.title);
|
||||
widget.set_subtitle(&work.composer.name_fl());
|
||||
|
||||
let recording_list = List::new();
|
||||
|
||||
let this = Rc::new(Self {
|
||||
handle,
|
||||
work,
|
||||
widget,
|
||||
recording_list,
|
||||
recordings: RefCell::new(Vec::new()),
|
||||
});
|
||||
|
||||
this.widget.set_back_cb(clone!(@weak this => move || {
|
||||
this.handle.pop(None);
|
||||
}));
|
||||
|
||||
this.widget.add_action(
|
||||
&gettext("Edit work"),
|
||||
clone!(@weak this => move || {
|
||||
spawn!(@clone this, async move {
|
||||
let window = NavigatorWindow::new(this.handle.backend.clone());
|
||||
replace!(window.navigator, WorkEditor, Some(this.work.clone())).await;
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
this.widget.add_action(
|
||||
&gettext("Delete work"),
|
||||
clone!(@weak this => move || {
|
||||
spawn!(@clone this, async move {
|
||||
db::delete_work(&mut this.handle.backend.db().lock().unwrap(), &this.work.id).unwrap();
|
||||
this.handle.backend.library_changed();
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
this.widget.set_search_cb(clone!(@weak this => move || {
|
||||
this.recording_list.invalidate_filter();
|
||||
}));
|
||||
|
||||
this.recording_list.set_make_widget_cb(
|
||||
clone!(@weak this => @default-panic, move |index| {
|
||||
let recording = &this.recordings.borrow()[index];
|
||||
|
||||
let row = adw::ActionRow::builder()
|
||||
.activatable(true)
|
||||
.title(recording.work.get_title())
|
||||
.subtitle(recording.get_performers())
|
||||
.build();
|
||||
|
||||
let recording = recording.to_owned();
|
||||
row.connect_activated(clone!(@weak this => move |_| {
|
||||
let recording = recording.clone();
|
||||
spawn!(@clone this, async move {
|
||||
push!(this.handle, RecordingScreen, recording.clone()).await;
|
||||
});
|
||||
}));
|
||||
|
||||
row.upcast()
|
||||
}),
|
||||
);
|
||||
|
||||
this.recording_list
|
||||
.set_filter_cb(clone!(@weak this => @default-panic, move |index| {
|
||||
let recording = &this.recordings.borrow()[index];
|
||||
let search = this.widget.get_search();
|
||||
let text = recording.work.get_title() + &recording.get_performers();
|
||||
search.is_empty() || text.to_lowercase().contains(&search)
|
||||
}));
|
||||
|
||||
// Load the content.
|
||||
|
||||
let recordings = db::get_recordings_for_work(
|
||||
&mut this.handle.backend.db().lock().unwrap(),
|
||||
&this.work.id,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
if !recordings.is_empty() {
|
||||
let length = recordings.len();
|
||||
this.recordings.replace(recordings);
|
||||
this.recording_list.update(length);
|
||||
|
||||
let section = Section::new("Recordings", &this.recording_list.widget);
|
||||
this.widget.add_content(§ion.widget);
|
||||
}
|
||||
|
||||
this.widget.ready();
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for WorkScreen {
|
||||
fn get_widget(&self) -> gtk::Widget {
|
||||
self.widget.widget.clone().upcast()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
use super::selector::Selector;
|
||||
use crate::editors::EnsembleEditor;
|
||||
use crate::navigator::{NavigationHandle, Screen};
|
||||
use crate::widgets::Widget;
|
||||
|
||||
use adw::prelude::*;
|
||||
use gettextrs::gettext;
|
||||
use glib::clone;
|
||||
use log::warn;
|
||||
use musicus_backend::db::{self, Ensemble};
|
||||
use std::rc::Rc;
|
||||
|
||||
/// A screen for selecting a ensemble.
|
||||
pub struct EnsembleSelector {
|
||||
handle: NavigationHandle<Ensemble>,
|
||||
selector: Rc<Selector<Ensemble>>,
|
||||
}
|
||||
|
||||
impl Screen<(), Ensemble> for EnsembleSelector {
|
||||
/// Create a new ensemble selector.
|
||||
fn new(_: (), handle: NavigationHandle<Ensemble>) -> Rc<Self> {
|
||||
// Create UI
|
||||
|
||||
let selector = Selector::<Ensemble>::new();
|
||||
selector.set_title(&gettext("Select ensemble"));
|
||||
|
||||
let this = Rc::new(Self { handle, selector });
|
||||
|
||||
// Connect signals and callbacks
|
||||
|
||||
this.selector.set_back_cb(clone!(@weak this => move || {
|
||||
this.handle.pop(None);
|
||||
}));
|
||||
|
||||
this.selector.set_add_cb(clone!(@weak this => move || {
|
||||
spawn!(@clone this, async move {
|
||||
if let Some(ensemble) = push!(this.handle, EnsembleEditor, None).await {
|
||||
this.handle.pop(Some(ensemble));
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
this.selector
|
||||
.set_make_widget(clone!(@weak this => @default-panic, move |ensemble| {
|
||||
let row = adw::ActionRow::builder()
|
||||
.activatable(true)
|
||||
.title(&ensemble.name)
|
||||
.build();
|
||||
|
||||
let ensemble = ensemble.to_owned();
|
||||
|
||||
row.connect_activated(clone!(@weak this => move |_| {
|
||||
if let Err(err) = db::update_ensemble(&mut this.handle.backend.db().lock().unwrap(), ensemble.clone()) {
|
||||
warn!("Failed to update access time. {err}");
|
||||
}
|
||||
|
||||
this.handle.pop(Some(ensemble.clone()))
|
||||
}));
|
||||
|
||||
row.upcast()
|
||||
}));
|
||||
|
||||
this.selector
|
||||
.set_filter(|search, ensemble| ensemble.name.to_lowercase().contains(search));
|
||||
|
||||
this.selector.set_items(
|
||||
db::get_recent_ensembles(&mut this.handle.backend.db().lock().unwrap()).unwrap(),
|
||||
);
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for EnsembleSelector {
|
||||
fn get_widget(&self) -> gtk::Widget {
|
||||
self.selector.widget.clone().upcast()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
use super::selector::Selector;
|
||||
use crate::editors::InstrumentEditor;
|
||||
use crate::navigator::{NavigationHandle, Screen};
|
||||
use crate::widgets::Widget;
|
||||
|
||||
use adw::prelude::*;
|
||||
use gettextrs::gettext;
|
||||
use glib::clone;
|
||||
use log::warn;
|
||||
use musicus_backend::db::{self, Instrument};
|
||||
use std::rc::Rc;
|
||||
|
||||
/// A screen for selecting a instrument.
|
||||
pub struct InstrumentSelector {
|
||||
handle: NavigationHandle<Instrument>,
|
||||
selector: Rc<Selector<Instrument>>,
|
||||
}
|
||||
|
||||
impl Screen<(), Instrument> for InstrumentSelector {
|
||||
/// Create a new instrument selector.
|
||||
fn new(_: (), handle: NavigationHandle<Instrument>) -> Rc<Self> {
|
||||
// Create UI
|
||||
|
||||
let selector = Selector::<Instrument>::new();
|
||||
selector.set_title(&gettext("Select instrument"));
|
||||
|
||||
let this = Rc::new(Self { handle, selector });
|
||||
|
||||
// Connect signals and callbacks
|
||||
|
||||
this.selector.set_back_cb(clone!(@weak this => move || {
|
||||
this.handle.pop(None);
|
||||
}));
|
||||
|
||||
this.selector.set_add_cb(clone!(@weak this => move || {
|
||||
spawn!(@clone this, async move {
|
||||
if let Some(instrument) = push!(this.handle, InstrumentEditor, None).await {
|
||||
this.handle.pop(Some(instrument));
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
this.selector
|
||||
.set_make_widget(clone!(@weak this => @default-panic, move |instrument| {
|
||||
let row = adw::ActionRow::builder()
|
||||
.activatable(true)
|
||||
.title(&instrument.name)
|
||||
.build();
|
||||
|
||||
let instrument = instrument.to_owned();
|
||||
|
||||
row.connect_activated(clone!(@weak this => move |_| {
|
||||
if let Err(err) = db::update_instrument(&mut this.handle.backend.db().lock().unwrap(), instrument.clone()) {
|
||||
warn!("Failed to update access time. {err}");
|
||||
}
|
||||
|
||||
this.handle.pop(Some(instrument.clone()))
|
||||
}));
|
||||
|
||||
row.upcast()
|
||||
}));
|
||||
|
||||
this.selector
|
||||
.set_filter(|search, instrument| instrument.name.to_lowercase().contains(search));
|
||||
|
||||
this.selector.set_items(
|
||||
db::get_recent_instruments(&mut this.handle.backend.db().lock().unwrap()).unwrap(),
|
||||
);
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for InstrumentSelector {
|
||||
fn get_widget(&self) -> gtk::Widget {
|
||||
self.selector.widget.clone().upcast()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,159 +0,0 @@
|
|||
use super::selector::Selector;
|
||||
use crate::navigator::{NavigationHandle, Screen};
|
||||
use crate::widgets::Widget;
|
||||
|
||||
use adw::prelude::*;
|
||||
use gettextrs::gettext;
|
||||
use glib::clone;
|
||||
use log::warn;
|
||||
use musicus_backend::db::{self, Medium, PersonOrEnsemble};
|
||||
use std::rc::Rc;
|
||||
|
||||
/// A screen for selecting a medium.
|
||||
pub struct MediumSelector {
|
||||
handle: NavigationHandle<Medium>,
|
||||
selector: Rc<Selector<PersonOrEnsemble>>,
|
||||
}
|
||||
|
||||
impl Screen<(), Medium> for MediumSelector {
|
||||
fn new(_: (), handle: NavigationHandle<Medium>) -> Rc<Self> {
|
||||
// Create UI
|
||||
|
||||
let selector = Selector::<PersonOrEnsemble>::new();
|
||||
selector.set_title(&gettext("Select performer"));
|
||||
|
||||
let this = Rc::new(Self { handle, selector });
|
||||
|
||||
// Connect signals and callbacks
|
||||
|
||||
this.selector.set_back_cb(clone!(@weak this => move || {
|
||||
this.handle.pop(None);
|
||||
}));
|
||||
|
||||
this.selector.set_make_widget(clone!(@weak this => @default-panic, move |poe| {
|
||||
let row = adw::ActionRow::builder()
|
||||
.activatable(true)
|
||||
.title(poe.get_title())
|
||||
.build();
|
||||
|
||||
let poe = poe.to_owned();
|
||||
row.connect_activated(clone!(@weak this => move |_| {
|
||||
let poe = poe.clone();
|
||||
spawn!(@clone this, async move {
|
||||
if let Some(medium) = push!(this.handle, MediumSelectorMediumScreen, poe).await {
|
||||
this.handle.pop(Some(medium));
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
row.upcast()
|
||||
}));
|
||||
|
||||
this.selector
|
||||
.set_filter(|search, poe| poe.get_title().to_lowercase().contains(search));
|
||||
|
||||
// Initialize items.
|
||||
|
||||
let mut poes = Vec::new();
|
||||
|
||||
let persons =
|
||||
db::get_recent_persons(&mut this.handle.backend.db().lock().unwrap()).unwrap();
|
||||
let ensembles =
|
||||
db::get_recent_ensembles(&mut this.handle.backend.db().lock().unwrap()).unwrap();
|
||||
|
||||
for person in persons {
|
||||
poes.push(PersonOrEnsemble::Person(person));
|
||||
}
|
||||
|
||||
for ensemble in ensembles {
|
||||
poes.push(PersonOrEnsemble::Ensemble(ensemble));
|
||||
}
|
||||
|
||||
this.selector.set_items(poes);
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for MediumSelector {
|
||||
fn get_widget(&self) -> gtk::Widget {
|
||||
self.selector.widget.clone().upcast()
|
||||
}
|
||||
}
|
||||
|
||||
/// The actual medium selector that is displayed after the user has selected a person or ensemble.
|
||||
struct MediumSelectorMediumScreen {
|
||||
handle: NavigationHandle<Medium>,
|
||||
poe: PersonOrEnsemble,
|
||||
selector: Rc<Selector<Medium>>,
|
||||
}
|
||||
|
||||
impl Screen<PersonOrEnsemble, Medium> for MediumSelectorMediumScreen {
|
||||
fn new(poe: PersonOrEnsemble, handle: NavigationHandle<Medium>) -> Rc<Self> {
|
||||
let selector = Selector::<Medium>::new();
|
||||
selector.set_title(&gettext("Select medium"));
|
||||
selector.set_subtitle(&poe.get_title());
|
||||
|
||||
let this = Rc::new(Self {
|
||||
handle,
|
||||
poe,
|
||||
selector,
|
||||
});
|
||||
|
||||
this.selector.set_back_cb(clone!(@weak this => move || {
|
||||
this.handle.pop(None);
|
||||
}));
|
||||
|
||||
this.selector
|
||||
.set_make_widget(clone!(@weak this => @default-panic, move |medium| {
|
||||
let row = adw::ActionRow::builder()
|
||||
.activatable(true)
|
||||
.title(&medium.name)
|
||||
.build();
|
||||
|
||||
let medium = medium.to_owned();
|
||||
row.connect_activated(clone!(@weak this => move |_| {
|
||||
if let Err(err) = db::update_medium(&mut this.handle.backend.db().lock().unwrap(), medium.clone()) {
|
||||
warn!("Failed to update access time. {err}");
|
||||
}
|
||||
|
||||
this.handle.pop(Some(medium.clone()));
|
||||
}));
|
||||
|
||||
row.upcast()
|
||||
}));
|
||||
|
||||
this.selector
|
||||
.set_filter(|search, medium| medium.name.to_lowercase().contains(search));
|
||||
|
||||
// Initialize items.
|
||||
match this.poe.clone() {
|
||||
PersonOrEnsemble::Person(person) => {
|
||||
this.selector.set_items(
|
||||
db::get_mediums_for_person(
|
||||
&mut this.handle.backend.db().lock().unwrap(),
|
||||
&person.id,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
PersonOrEnsemble::Ensemble(ensemble) => {
|
||||
this.selector.set_items(
|
||||
db::get_mediums_for_ensemble(
|
||||
&mut this.handle.backend.db().lock().unwrap(),
|
||||
&ensemble.id,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for MediumSelectorMediumScreen {
|
||||
fn get_widget(&self) -> gtk::Widget {
|
||||
self.selector.widget.clone().upcast()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
pub mod ensemble;
|
||||
pub use ensemble::*;
|
||||
|
||||
pub mod instrument;
|
||||
pub use instrument::*;
|
||||
|
||||
pub mod medium;
|
||||
pub use medium::*;
|
||||
|
||||
pub mod person;
|
||||
pub use person::*;
|
||||
|
||||
pub mod recording;
|
||||
pub use recording::*;
|
||||
|
||||
pub mod work;
|
||||
pub use work::*;
|
||||
|
||||
mod selector;
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
use super::selector::Selector;
|
||||
use crate::editors::PersonEditor;
|
||||
use crate::navigator::{NavigationHandle, Screen};
|
||||
use crate::widgets::Widget;
|
||||
|
||||
use adw::prelude::*;
|
||||
use gettextrs::gettext;
|
||||
use glib::clone;
|
||||
use log::warn;
|
||||
use musicus_backend::db::{self, Person};
|
||||
use std::rc::Rc;
|
||||
|
||||
/// A screen for selecting a person.
|
||||
pub struct PersonSelector {
|
||||
handle: NavigationHandle<Person>,
|
||||
selector: Rc<Selector<Person>>,
|
||||
}
|
||||
|
||||
impl Screen<(), Person> for PersonSelector {
|
||||
/// Create a new person selector.
|
||||
fn new(_: (), handle: NavigationHandle<Person>) -> Rc<Self> {
|
||||
// Create UI
|
||||
|
||||
let selector = Selector::<Person>::new();
|
||||
selector.set_title(&gettext("Select person"));
|
||||
|
||||
let this = Rc::new(Self { handle, selector });
|
||||
|
||||
// Connect signals and callbacks
|
||||
|
||||
this.selector.set_back_cb(clone!(@weak this => move || {
|
||||
this.handle.pop(None);
|
||||
}));
|
||||
|
||||
this.selector.set_add_cb(clone!(@weak this => move || {
|
||||
spawn!(@clone this, async move {
|
||||
if let Some(person) = push!(this.handle, PersonEditor, None).await {
|
||||
this.handle.pop(Some(person));
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
this.selector
|
||||
.set_make_widget(clone!(@weak this => @default-panic, move |person| {
|
||||
let row = adw::ActionRow::builder()
|
||||
.activatable(true)
|
||||
.title(person.name_lf())
|
||||
.build();
|
||||
|
||||
let person = person.to_owned();
|
||||
|
||||
row.connect_activated(clone!(@weak this => move |_| {
|
||||
if let Err(err) = db::update_person(&mut this.handle.backend.db().lock().unwrap(), person.clone()) {
|
||||
warn!("Failed to update access time. {err}");
|
||||
}
|
||||
|
||||
this.handle.pop(Some(person.clone()));
|
||||
}));
|
||||
|
||||
row.upcast()
|
||||
}));
|
||||
|
||||
this.selector
|
||||
.set_filter(|search, person| person.name_fl().to_lowercase().contains(search));
|
||||
|
||||
this.selector.set_items(
|
||||
db::get_recent_persons(&mut this.handle.backend.db().lock().unwrap()).unwrap(),
|
||||
);
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for PersonSelector {
|
||||
fn get_widget(&self) -> gtk::Widget {
|
||||
self.selector.widget.clone().upcast()
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue