forked from miao-moe/QZMusic_PC
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
760881de4f | ||
|
|
72f4510dc8 |
8
amll-local/.editorconfig
Normal file
8
amll-local/.editorconfig
Normal file
@@ -0,0 +1,8 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_style = tab
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
3
amll-local/.gitattributes
vendored
Normal file
3
amll-local/.gitattributes
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
*.md linguist-documentation
|
||||
*.mdx linguist-documentation
|
||||
packages/playground/core/src/components/ui/** linguist-vendored
|
||||
661
amll-local/LICENSE
Normal file
661
amll-local/LICENSE
Normal file
@@ -0,0 +1,661 @@
|
||||
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) 2024 <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/>.
|
||||
119
amll-local/README-CN.md
Normal file
119
amll-local/README-CN.md
Normal file
@@ -0,0 +1,119 @@
|
||||
<div align=center>
|
||||
|
||||

|
||||
|
||||
# Apple Music-like Lyrics
|
||||
|
||||
[English](./README.md) / 简体中文
|
||||
|
||||
一个基于 Web 技术制作的类 Apple Music 歌词显示组件库,同时支持 [DOM 原生](./packages/core/README.md)、[React](./packages/react/README.md) 和 [Vue](./packages/react/README.md) 绑定。
|
||||
|
||||
这是你能在前端系里能见到的最像 iPad Apple Music 的播放页面了。
|
||||
|
||||
尽管这个项目的目标并非完全模仿,但是会更好地打磨一些细节,以优于现阶段最好的歌词播放器。
|
||||
|
||||
**—— AMLL 生态作品 ——**
|
||||
|
||||
[AMLL TTML DB 逐词歌词仓库](https://github.com/amll-dev/amll-ttml-db)
|
||||
|
||||
[AMLL TTML Tool 逐词歌词编辑器](https://github.com/amll-dev/amll-ttml-tool)
|
||||
/
|
||||
[AMLL Editor 下一代逐词歌词编辑器](https://github.com/amll-dev/amll-editor)
|
||||
|
||||
[AMLL Player 本地播放器](https://github.com/amll-dev/amll-player)
|
||||
/
|
||||
[AMLL Page 网页播放器](https://github.com/apoint123/amll-page)
|
||||
|
||||
|
||||
[引用了 AMLL 的项目汇总](https://github.com/amll-dev/applemusic-like-lyrics/discussions/397)
|
||||
|
||||
</div>
|
||||
|
||||
> [!Warning]
|
||||
> 致 AMLL Player 的开发/使用者:
|
||||
> AMLL Player 已迁移至 [独立仓库](https://github.com/amll-dev/amll-player/blob/main/README-CN.md)
|
||||
>
|
||||
> 仓库链接已更新为 https://github.com/amll-dev/amll-player
|
||||
|
||||
## AMLL 生态及源码结构
|
||||
|
||||
### 主要模块
|
||||
|
||||
- [](./packages/core/README-CN.md):AMLL 核心组件库,以 DOM 原生方式编写,提供歌词显示组件和动态流体背景组件
|
||||
- [](./packages/react/README-CN.md):AMLL React 绑定,提供 React 组件形式的歌词显示组件和动态流体背景组件
|
||||
- [](./packages/react-full/README-CN.md):AMLL React 完整播放器组件库,提供可组合的播放页面组件
|
||||
- [](./packages/vue/README-CN.md):AMLL Vue 绑定,提供 Vue 组件形式的歌词显示组件和动态流体背景组件
|
||||
- [](./packages/lyric/README-CN.md):AMLL 歌词解析模块,提供对 LyRiC, YRC, QRC, Lyricify Syllable 各种歌词格式的解析和序列化支持
|
||||
- [](./packages/ttml/README-CN.md):AMLL TTML 处理模块,提供 TTML 的结构化解析、生成,以及与 AMLL 歌词数据的互转能力
|
||||
|
||||
## 浏览器兼容性提醒
|
||||
|
||||
本组件框架最低要求使用以下浏览器或更新版本:
|
||||
|
||||
- Chromium/Edge 91+
|
||||
- Firefox 100+
|
||||
- Safari 9.1+
|
||||
|
||||
完整呈现组件所有效果需要使用以下浏览器或更新版本:
|
||||
|
||||
- Chromium 120+
|
||||
- Firefox 100+
|
||||
- Safari 15.4+
|
||||
|
||||
参考链接:
|
||||
|
||||
- [https://caniuse.com/mdn-css_properties_mask-image](https://caniuse.com/mdn-css_properties_mask-image)
|
||||
- [https://caniuse.com/mdn-css_properties_mix-blend-mode_plus-lighter](https://caniuse.com/mdn-css_properties_mix-blend-mode_plus-lighter)
|
||||
|
||||
## 性能配置参考
|
||||
|
||||
经过性能基准测试,五年内的主流 CPU 处理器均可以以 30FPS 正常带动歌词组件,但如果需要 60FPS 流畅运行,请确保 CPU 频率至少为 3.0Ghz 或以上。如果需要 144FPS 以上流畅运行,请确保 CPU 频率至少为 4.2Ghz 或以上。
|
||||
|
||||
GPU 性能在以下状况下能够以预期尺寸下满 60 帧运行:
|
||||
|
||||
- `1080p (1920x1080)`: NVIDIA GTX 10 系列及以上
|
||||
- `2160p (3840x2160)`: NVIDIA RTX 2070 及以上
|
||||
|
||||
## 开发/构建/打包流程
|
||||
|
||||
### 前置依赖
|
||||
|
||||
- [Node.js](https://nodejs.org/)
|
||||
- [pnpm](https://pnpm.io/)
|
||||
|
||||
### 构建组件库
|
||||
|
||||
克隆本仓库后,在项目根目录执行以下指令:
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
pnpm install
|
||||
|
||||
# 生产构建所有库包
|
||||
pnpm run build:libs
|
||||
```
|
||||
|
||||
### 构建单个包
|
||||
|
||||
```bash
|
||||
# 示例:仅构建 @applemusic-like-lyrics/core
|
||||
pnpm nx run @applemusic-like-lyrics/core:build
|
||||
|
||||
# 示例:开发构建 @applemusic-like-lyrics/lyric
|
||||
pnpm nx run @applemusic-like-lyrics/lyric:build:dev
|
||||
```
|
||||
|
||||
## 鸣谢
|
||||
|
||||
- [woshizja/sound-processor](https://github.com/woshizja/sound-processor)
|
||||
- [FFmpeg](http://ffmpeg.org/)
|
||||
- 还有很多被 AMLL 使用的框架和库,非常感谢!
|
||||
|
||||
### 特别鸣谢
|
||||
|
||||
<div align="center">
|
||||
<image src="https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.svg"></image>
|
||||
<div>
|
||||
感谢 <a href=https://jb.gg/OpenSourceSupport>JetBrains</a> 系列开发工具为 AMLL 项目提供的大力支持
|
||||
</div>
|
||||
</div>
|
||||
112
amll-local/README.md
Normal file
112
amll-local/README.md
Normal file
@@ -0,0 +1,112 @@
|
||||
<div align=center>
|
||||
|
||||

|
||||
|
||||
English / [简体中文](./README-CN.md)
|
||||
|
||||
</div>
|
||||
|
||||
<div align=center>
|
||||
|
||||
A lyric player component library that aims to look similar to iPad version of Apple Music. With [DOM](./packages/core/README.md), [React](./packages/react/README.md) and [Vue](./packages/vue/README.md) bindings.
|
||||
|
||||
This is perhaps the most iPad Apple Music-like lyric page you've seen in frontend.
|
||||
|
||||
Although the goal of this project is not to imitate it completely, it will polish some details to be better than the current best lyric players.
|
||||
|
||||
**—— AMLL Series Projects ——**
|
||||
|
||||
[AMLL TTML DB - TTML Syllable Lyric Database](https://github.com/amll-dev/amll-ttml-db)
|
||||
|
||||
[AMLL TTML Tool - TTML Syllable Lyric Editor](https://github.com/amll-dev/amll-ttml-tool)
|
||||
/
|
||||
[AMLL Editor - Next-Gen TTML Syllable Lyric Editor](https://github.com/amll-dev/amll-editor)
|
||||
|
||||
[AMLL Player - Local Music Player](https://github.com/amll-dev/amll-player)
|
||||
/
|
||||
[AMLL Page - Web Music Player](https://github.com/amll-dev/amll-page)
|
||||
|
||||
[Projects that references AMLL](https://github.com/amll-dev/applemusic-like-lyrics/discussions/397)
|
||||
|
||||
</div>
|
||||
|
||||
## AMLL Ecology and source code structure
|
||||
|
||||
### Main modules
|
||||
|
||||
- [](./packages/core/README.md): AMLL Core Component Library, written natively with DOM, provides lyric display component and dynamic fluid background component
|
||||
- [](./packages/react/README.md): AMLL React binding, provides React component forms of lyric display and dynamic fluid background components
|
||||
- [](./packages/vue/README.md): AMLL Vue binding, provides Vue component forms of lyric display and dynamic fluid background components
|
||||
- [](./packages/lyric/README.md): AMLL lyric parsing module, provides parsing and serialization support for various lyric formats including LyRiC, YRC, QRC, and Lyricify Syllable
|
||||
|
||||
## Browser compatibility alerts
|
||||
|
||||
This component framework requires the following browsers or newer versions at a minimum:
|
||||
|
||||
- Chromium/Edge 91+
|
||||
- Firefox 100+
|
||||
- Safari 9.1+
|
||||
|
||||
To fully render all component effects, the following browser versions or newer are required:
|
||||
|
||||
- Chromium 120+
|
||||
- Firefox 100+
|
||||
- Safari 15.4+
|
||||
|
||||
Reference Links:
|
||||
|
||||
- [https://caniuse.com/mdn-css_properties_mask-image](https://caniuse.com/mdn-css_properties_mask-image)
|
||||
- [https://caniuse.com/mdn-css_properties_mix-blend-mode_plus-lighter](https://caniuse.com/mdn-css_properties_mix-blend-mode_plus-lighter)
|
||||
|
||||
## Performance configuration reference
|
||||
|
||||
Performance benchmarks have shown that mainstream CPU processors from the last five years can run the lyric component at 30FPS. However, if you need smooth 60FPS operation, ensure your CPU frequency is at least 3.0GHz or higher. For 144FPS or above, a CPU frequency of at least 4.2GHz is recommended.
|
||||
|
||||
GPU performance capable of running at full 60 fps at the expected sizes under the following conditions:
|
||||
|
||||
- `1080p (1920x1080)`: NVIDIA GTX 10 series and above
|
||||
- `2160p (3840x2160)`: NVIDIA RTX 2070 and above
|
||||
|
||||
## Development/build/packaging process
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/)
|
||||
- [pnpm](https://pnpm.io/)
|
||||
|
||||
### Building the component libraries
|
||||
|
||||
Clone this repository, then run the following commands in the project root:
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Production build (all library packages)
|
||||
pnpm run build:libs
|
||||
```
|
||||
|
||||
### Building a single package
|
||||
|
||||
```bash
|
||||
# Example: build only @applemusic-like-lyrics/core
|
||||
pnpm nx run @applemusic-like-lyrics/core:build
|
||||
|
||||
# Example: development build of @applemusic-like-lyrics/lyric
|
||||
pnpm nx run @applemusic-like-lyrics/lyric:build:dev
|
||||
```
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
- [woshizja/sound-processor](https://github.com/woshizja/sound-processor)
|
||||
- [FFmpeg](http://ffmpeg.org/)
|
||||
- And many other frameworks and libraries used by AMLL, thank you very much!
|
||||
|
||||
### Special Thanks
|
||||
|
||||
<div align="center">
|
||||
<image src="https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.svg"></image>
|
||||
<div>
|
||||
Thanks to <a href=https://jb.gg/OpenSourceSupport>JetBrains</a> for their development tools that provide great support to the AMLL project
|
||||
</div>
|
||||
</div>
|
||||
55
amll-local/biome.json
Normal file
55
amll-local/biome.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
||||
"files": {
|
||||
"includes": ["**", "!packages/playground/core/src/components/ui"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"formatWithErrors": false,
|
||||
"attributePosition": "auto",
|
||||
"indentStyle": "tab",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 80,
|
||||
"lineEnding": "lf"
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double",
|
||||
"jsxQuoteStyle": "double"
|
||||
}
|
||||
},
|
||||
"html": {
|
||||
"experimentalFullSupportEnabled": true,
|
||||
"formatter": {
|
||||
"enabled": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"a11y": "off"
|
||||
}
|
||||
},
|
||||
"assist": {
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on"
|
||||
}
|
||||
}
|
||||
},
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
"cssModules": true,
|
||||
"tailwindDirectives": true
|
||||
}
|
||||
}
|
||||
}
|
||||
104
amll-local/nx.json
Normal file
104
amll-local/nx.json
Normal file
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"tasksRunnerOptions": {
|
||||
"default": {
|
||||
"runner": "nx/tasks-runners/default",
|
||||
"options": {
|
||||
"cacheableOperations": ["build:dev", "build"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"targetDefaults": {
|
||||
"fmt": {
|
||||
"cache": true,
|
||||
"outputs": ["{projectRoot}/src"]
|
||||
},
|
||||
"build": {
|
||||
"cache": true,
|
||||
"dependsOn": ["^build"],
|
||||
"maxParallel": 1,
|
||||
"outputs": ["{projectRoot}/dist", "{projectRoot}/pkg"]
|
||||
},
|
||||
"build:dev": {
|
||||
"cache": true,
|
||||
"dependsOn": ["^build:dev"],
|
||||
"maxParallel": 1,
|
||||
"outputs": ["{projectRoot}/dist", "{projectRoot}/pkg"]
|
||||
}
|
||||
},
|
||||
"analytics": false,
|
||||
"release": {
|
||||
"versionPlans": {
|
||||
"ignorePatternsForPlanCheck": [
|
||||
"**/*.md",
|
||||
"**/*.mdx",
|
||||
".github/**",
|
||||
".nx/**",
|
||||
".editorconfig",
|
||||
".gitignore",
|
||||
".gitattributes",
|
||||
"biome.json",
|
||||
"Cargo.lock",
|
||||
"Cargo.toml",
|
||||
"lerna.json",
|
||||
"LICENSE",
|
||||
"nx.json",
|
||||
"package.json",
|
||||
"pnpm-lock.yaml",
|
||||
"bun.lock",
|
||||
"pnpm-workspace.yaml",
|
||||
"tsconfig.json",
|
||||
"tsconfig.base.json",
|
||||
"packages/docs/**",
|
||||
"packages/*/docs/**",
|
||||
"packages/playground/**",
|
||||
"packages/fft",
|
||||
"packages/ws-protocol",
|
||||
"patches/**",
|
||||
"**/test/**",
|
||||
"**/tests/**",
|
||||
"**/tsconfig*",
|
||||
"**/tsdown*"
|
||||
]
|
||||
},
|
||||
"version": {
|
||||
"conventionalCommits": false,
|
||||
"preVersionCommand": "pnpm run build:libs"
|
||||
},
|
||||
"changelog": {
|
||||
"workspaceChangelog": false,
|
||||
"projectChangelogs": {
|
||||
"renderer": "{workspaceRoot}/.nx/scripts/changelog-render.ts"
|
||||
}
|
||||
},
|
||||
"git": {
|
||||
"commit": true,
|
||||
"tag": true
|
||||
},
|
||||
"releaseTag": {
|
||||
"pattern": "{projectName}@{version}",
|
||||
"requireSemver": true
|
||||
},
|
||||
"groups": {
|
||||
"core-bundle": {
|
||||
"projects": [
|
||||
"@applemusic-like-lyrics/core",
|
||||
"@applemusic-like-lyrics/react",
|
||||
"@applemusic-like-lyrics/vue"
|
||||
],
|
||||
"projectsRelationship": "fixed",
|
||||
"releaseTag": {
|
||||
"pattern": "core-bundle@{version}",
|
||||
"requireSemver": true
|
||||
}
|
||||
},
|
||||
"libraries": {
|
||||
"projects": [
|
||||
"@applemusic-like-lyrics/lyric",
|
||||
"@applemusic-like-lyrics/react-full",
|
||||
"@applemusic-like-lyrics/ttml"
|
||||
],
|
||||
"projectsRelationship": "independent"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
26
amll-local/package.json
Normal file
26
amll-local/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "applemusic-like-lyrics",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "catalog:",
|
||||
"@nx/js": "^22.7.2",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260516.1",
|
||||
"npm-run-all2": "^9.0.0",
|
||||
"nx": "^22.7.2",
|
||||
"swc-node": "^1.0.0",
|
||||
"tsdown": "catalog:"
|
||||
},
|
||||
"scripts": {
|
||||
"build:libs": "nx run-many --target=build --projects=tag:library",
|
||||
"ci:build:libs": "pnpm run build:libs",
|
||||
"test:libs": "nx run-many --target=test --projects=ttml,lyric",
|
||||
"ci:test:libs": "pnpm run test:libs",
|
||||
"format": "biome format . --write",
|
||||
"lint": "biome lint . --fix",
|
||||
"ci:lint": "biome lint . --error-on-warnings",
|
||||
"release:plan:check": "nx release plan:check"
|
||||
},
|
||||
"packageManager": "pnpm@11.1.0"
|
||||
}
|
||||
76
amll-local/packages/core/CHANGELOG.md
Normal file
76
amll-local/packages/core/CHANGELOG.md
Normal file
@@ -0,0 +1,76 @@
|
||||
## 0.5.1 (2026-05-17)
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- **fix:** 修复含对唱时歌词错误提前导致的多行高亮 ([#521](https://github.com/amll-dev/applemusic-like-lyrics/pull/521))
|
||||
- **refactor:** 引入歌词组来包装主歌词和背景人声 & 前置背景人声 ([#531](https://github.com/amll-dev/applemusic-like-lyrics/pull/531))
|
||||
- **chore:** 更正 package.json 协议声明 ([#534](https://github.com/amll-dev/applemusic-like-lyrics/pull/534))
|
||||
|
||||
仓库根目录的 LICENSE 文件为 AGPL v3.0 协议,但是 package.json 中的 `license` 字段为 `GPL-3.0`。经与原开发者确认,package.json 中的 `license` 字段有误。仓库与其所有产出的 npm 包均应为 AGPL v3 only 协议,SPDX: `AGPL-3.0-only`。因此,更正各包 `package.json` 的 `license` 字段为 `AGPL-3.0-only`。
|
||||
- **chore(core):** 优化类型定义 ([#519](https://github.com/amll-dev/applemusic-like-lyrics/pull/519))
|
||||
|
||||
### Contributors
|
||||
|
||||
- apoint123 [@apoint123](https://github.com/apoint123)
|
||||
- Linho [@Linho1219](https://github.com/Linho1219)
|
||||
|
||||
## 0.5.0 (2026-05-12)
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- **refactor:** 整理核心播放器代码结构,将抽象接口部分集中到统一目录 ([#508](https://github.com/amll-dev/applemusic-like-lyrics/pull/508))
|
||||
- **refactor:** 整理核心播放器抽象类中时间线、滚动与单行布局部分的结构与状态管理 ([#509](https://github.com/amll-dev/applemusic-like-lyrics/pull/509))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- **fix:** 修复 setCurrentTime 在提供 isSeek 标志时,实际排版未遵守标志导致布局异常漂移的问题 ([#509](https://github.com/amll-dev/applemusic-like-lyrics/pull/509))
|
||||
- **fix:** 修复在同一行时间内拖拽进度条时逐字动画不同步的问题 ([#509](https://github.com/amll-dev/applemusic-like-lyrics/pull/509))
|
||||
- **fix:** 修复暂停状态下点击行跳转时仍播放逐字动画的问题 ([#509](https://github.com/amll-dev/applemusic-like-lyrics/pull/509))
|
||||
|
||||
### Contributors
|
||||
|
||||
- Linho [@Linho1219](https://github.com/Linho1219)
|
||||
|
||||
## 0.4.2 (2026-05-01)
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- **feat(core):** 平衡行长度时优先在标点处换行 ([#503](https://github.com/amll-dev/applemusic-like-lyrics/pull/503))
|
||||
- **fix:** 修复背景行注音高度错误 ([#497](https://github.com/amll-dev/applemusic-like-lyrics/pull/497))
|
||||
- **fix(core):** 修正平衡行长度时的行宽度计算 ([#502](https://github.com/amll-dev/applemusic-like-lyrics/pull/502))
|
||||
|
||||
### Contributors
|
||||
|
||||
- apoint123 [@apoint123](https://github.com/apoint123)
|
||||
- Linho [@Linho1219](https://github.com/Linho1219)
|
||||
|
||||
## 0.4.1 (2026-04-23)
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- **fix:** 在各绑定中暴露歌词优化选项 ([#492](https://github.com/amll-dev/applemusic-like-lyrics/pull/492))
|
||||
- **fix(vue):** 修复掩码模式错误的类型 ([#496](https://github.com/amll-dev/applemusic-like-lyrics/pull/496))
|
||||
- **refactor(core):** 重构平均行长度实现 ([#494](https://github.com/amll-dev/applemusic-like-lyrics/pull/494))
|
||||
|
||||
### Contributors
|
||||
|
||||
- apoint123 [@apoint123](https://github.com/apoint123)
|
||||
|
||||
## 0.4.0 (2026-04-14)
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- **chore:** 移除 canvas 歌词渲染器 ([#476](https://github.com/amll-dev/applemusic-like-lyrics/pull/476))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- **refactor:** 重构核心库测试组织模式 ([3db83c93](https://github.com/amll-dev/applemusic-like-lyrics/commit/3db83c93))
|
||||
- **docs:** 修正 optimize-lyric.ts 和 OptimizeLyricOptions 里 cleanUnintentionalOverlaps 的文档和注释 ([75a8c0bb](https://github.com/amll-dev/applemusic-like-lyrics/commit/75a8c0bb))
|
||||
- **chore:** 更换工具链 ([#476](https://github.com/amll-dev/applemusic-like-lyrics/pull/476))
|
||||
- **chore:** 在项目范围内启用 isolatedDeclarations ([#480](https://github.com/amll-dev/applemusic-like-lyrics/pull/480))
|
||||
|
||||
### Contributors
|
||||
|
||||
- apoint123 [@apoint123](https://github.com/apoint123)
|
||||
- Linho [@Linho1219](https://github.com/Linho1219)
|
||||
- MoYingJi [@MoYingJi](https://github.com/MoYingJi)
|
||||
83
amll-local/packages/core/README-CN.md
Normal file
83
amll-local/packages/core/README-CN.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# AMLL Core
|
||||
|
||||
[English](./README.md) / 简体中文
|
||||
|
||||
> 警告:此为个人项目,且尚未完成开发,可能仍有大量问题,所以请勿直接用于生产环境!
|
||||
|
||||

|
||||
[](https://www.npmjs.com/package/@applemusic-like-lyrics/core)
|
||||
[](https://www.npmjs.com/package/@applemusic-like-lyrics/core)
|
||||
|
||||
AMLL 的纯 JS 核心组件框架,包括歌词显示组件和背景组件等其它可以复用的组件。
|
||||
|
||||
此处的东西都是 UI 框架无关的,所以可以间接在各种动态页面框架下引用。
|
||||
|
||||
或者如果你需要使用组件绑定的话,这里有 [React 绑定版本](../react/README.md) 和 [Vue 绑定版本](../vue/README.md)
|
||||
|
||||
## 特性
|
||||
|
||||
- 纯前端渲染的歌词展示与背景渲染
|
||||
- 动态逐字歌词、翻译与音译显示
|
||||
- 支持多行、对唱与背景行
|
||||
- 可通过 CSS 变量自定义颜色与部分表现
|
||||
|
||||
## 安装
|
||||
|
||||
安装使用的依赖(如果以下列出的依赖包没有安装的话需要自行安装):
|
||||
```bash
|
||||
npm install @pixi/app @pixi/core @pixi/display @pixi/filter-blur @pixi/filter-bulge-pinch @pixi/filter-color-matrix @pixi/sprite # 使用 npm
|
||||
yarn add @pixi/app @pixi/core @pixi/display @pixi/filter-blur @pixi/filter-bulge-pinch @pixi/filter-color-matrix @pixi/sprite # 使用 yarn
|
||||
```
|
||||
|
||||
安装本体框架:
|
||||
```bash
|
||||
npm install @applemusic-like-lyrics/core # 使用 npm
|
||||
yarn add @applemusic-like-lyrics/core # 使用 yarn
|
||||
```
|
||||
|
||||
## 使用方式摘要
|
||||
|
||||
详细的 API 文档请参考 [./docs/modules.md](./docs/modules.md)
|
||||
|
||||
一个测试用途的程序可以在 [../playground/core/src/test.ts](../playground/core/src/test.ts) 里找到。
|
||||
|
||||
```typescript
|
||||
import { LyricPlayer } from "@applemusic-like-lyrics/core";
|
||||
import "@applemusic-like-lyrics/core/style.css"; // 导入需要的样式
|
||||
|
||||
const player = new LyricPlayer(); // 创建歌词播放组件
|
||||
document.body.appendChild(player.getElement()); // 将组件的元素添加到页面
|
||||
player.setLyricLines([]) // 设置歌词
|
||||
player.setCurrentTime(0) // 设定当前播放时间(需要逐帧调用)
|
||||
player.update(0) // 更新歌词组件动画(需要逐帧调用)
|
||||
```
|
||||
|
||||
每次通过 `LyricPlayer.setLyricLines` 设置的歌词是一个 `LyricLine[]` 参数,具体可以参考 [./src/interfaces.ts](./src/interfaces.ts) 中的代码。
|
||||
|
||||
## 数据结构
|
||||
|
||||
歌词的输入结构为 `LyricLine[]`,其中每行包含:
|
||||
|
||||
- `words`: 逐字歌词数组,每个单词包含 `startTime` / `endTime` / `word`,并可选包含 `romanWord`、`ruby`、`obscene` 等字段
|
||||
- `translatedLyric`: 翻译行文本
|
||||
- `romanLyric`: 音译行文本
|
||||
- `startTime` / `endTime`: 行级时间戳
|
||||
- `isBG` / `isDuet`: 背景行与对唱行标记
|
||||
|
||||
## 样式定制
|
||||
|
||||
主要样式由 `@applemusic-like-lyrics/core/style.css` 提供,常用自定义方式为覆写 CSS 变量,例如:
|
||||
|
||||
```css
|
||||
.amll-lyric-player {
|
||||
--amll-lp-color: #ffffff;
|
||||
--amll-lp-bg-color: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
```
|
||||
|
||||
## 开发与构建
|
||||
|
||||
```bash
|
||||
pnpm --filter @applemusic-like-lyrics/core dev
|
||||
pnpm --filter @applemusic-like-lyrics/core build
|
||||
```
|
||||
83
amll-local/packages/core/README.md
Normal file
83
amll-local/packages/core/README.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# AMLL Core
|
||||
|
||||
English / [简体中文](./README-CN.md)
|
||||
|
||||
> Warning: This is a personal project and is still under development. There may still be many issues, so please do not use it directly in production environments!
|
||||
|
||||

|
||||
[](https://www.npmjs.com/package/@applemusic-like-lyrics/core)
|
||||
[](https://www.npmjs.com/package/@applemusic-like-lyrics/core)
|
||||
|
||||
AMLL's pure JS core component framework, including lyric display components and background components and other reusable components.
|
||||
|
||||
Everything here is UI framework-independent, so it can be indirectly referenced under various dynamic page frameworks.
|
||||
|
||||
Or if you need to use component bindings, there's a [React binding version](../react/README.md) and a [Vue binding version](../vue/README.md)
|
||||
|
||||
## Features
|
||||
|
||||
- Pure frontend rendering for lyrics and background
|
||||
- Word-level timed lyrics with translation and romanization
|
||||
- Multi-line, duet, and background line support
|
||||
- Style customization via CSS variables
|
||||
|
||||
## Installation
|
||||
|
||||
Install the required dependencies (if the dependencies listed below are not installed, you need to install them yourself):
|
||||
```bash
|
||||
npm install @pixi/app @pixi/core @pixi/display @pixi/filter-blur @pixi/filter-bulge-pinch @pixi/filter-color-matrix @pixi/sprite # using npm
|
||||
yarn add @pixi/app @pixi/core @pixi/display @pixi/filter-blur @pixi/filter-bulge-pinch @pixi/filter-color-matrix @pixi/sprite # using yarn
|
||||
```
|
||||
|
||||
Install the framework:
|
||||
```bash
|
||||
npm install @applemusic-like-lyrics/core # using npm
|
||||
yarn add @applemusic-like-lyrics/core # using yarn
|
||||
```
|
||||
|
||||
## Usage Summary
|
||||
|
||||
For detailed API documentation, please refer to [./docs/modules.md](./docs/modules.md)
|
||||
|
||||
A test program can be found in [../playground/core/src/test.ts](../playground/core/src/test.ts).
|
||||
|
||||
```typescript
|
||||
import { LyricPlayer } from "@applemusic-like-lyrics/core";
|
||||
import "@applemusic-like-lyrics/core/style.css"; // Import required styles
|
||||
|
||||
const player = new LyricPlayer(); // Create a lyric player component
|
||||
document.body.appendChild(player.getElement()); // Add the component's element to the page
|
||||
player.setLyricLines([]) // Set lyrics
|
||||
player.setCurrentTime(0) // Set current playback time (needs to be called every frame)
|
||||
player.update(0) // Update lyric component animation (needs to be called every frame)
|
||||
```
|
||||
|
||||
The lyrics set through `LyricPlayer.setLyricLines` is a `LyricLine[]` parameter. For details, please refer to the code in [./src/interfaces.ts](./src/interfaces.ts).
|
||||
|
||||
## Data Model
|
||||
|
||||
Lyrics input is `LyricLine[]`, with each line containing:
|
||||
|
||||
- `words`: timed word array, each word includes `startTime` / `endTime` / `word`, with optional `romanWord`, `ruby`, and `obscene`
|
||||
- `translatedLyric`: translation text
|
||||
- `romanLyric`: romanization text
|
||||
- `startTime` / `endTime`: line timestamps
|
||||
- `isBG` / `isDuet`: background and duet flags
|
||||
|
||||
## Styling
|
||||
|
||||
The main styles are provided by `@applemusic-like-lyrics/core/style.css`. Common overrides are via CSS variables:
|
||||
|
||||
```css
|
||||
.amll-lyric-player {
|
||||
--amll-lp-color: #ffffff;
|
||||
--amll-lp-bg-color: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm --filter @applemusic-like-lyrics/core dev
|
||||
pnpm --filter @applemusic-like-lyrics/core build
|
||||
```
|
||||
88
amll-local/packages/core/package.json
Normal file
88
amll-local/packages/core/package.json
Normal file
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"name": "@applemusic-like-lyrics/core",
|
||||
"version": "0.5.1",
|
||||
"description": "AMLL 的纯 JS 核心组件框架,包括歌词显示组件和背景组件等其它可以复用的组件",
|
||||
"repository": {
|
||||
"url": "https://github.com/amll-dev/applemusic-like-lyrics.git",
|
||||
"directory": "packages/core",
|
||||
"type": "git"
|
||||
},
|
||||
"license": "AGPL-3.0-only",
|
||||
"nx": {
|
||||
"tags": [
|
||||
"library"
|
||||
],
|
||||
"targets": {
|
||||
"nx-release-publish": {
|
||||
"executor": "@nx/js:release-publish",
|
||||
"dependsOn": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build:docs": "typedoc --plugin typedoc-plugin-markdown --out docs src/index.ts",
|
||||
"typecheck": "tsgo -b",
|
||||
"build-only": "tsdown",
|
||||
"build": "run-p typecheck \"build-only {@}\" --",
|
||||
"build:dev": "tsdown",
|
||||
"fmt": "biome format --write ./src",
|
||||
"dev": "nx run @applemusic-like-lyrics/playground-core:dev"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "./dist/amll-core.cjs",
|
||||
"module": "./dist/amll-core.mjs",
|
||||
"typings": "./dist/amll-core.d.mts",
|
||||
"imports": {
|
||||
"#interfaces": "./src/interfaces.ts",
|
||||
"#utils/*": "./src/utils/*",
|
||||
"#styles/*": "./src/styles/*",
|
||||
"#lyric/*": "./src/lyric-player/*",
|
||||
"#bg/*": "./src/bg-player/*"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/amll-core.d.mts",
|
||||
"default": "./dist/amll-core.mjs"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/amll-core.d.cts",
|
||||
"default": "./dist/amll-core.cjs"
|
||||
}
|
||||
},
|
||||
"./style.css": {
|
||||
"import": "./dist/style.css",
|
||||
"require": "./dist/style.css"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.8",
|
||||
"@types/deep-freeze": "^0.1.5",
|
||||
"@types/stats.js": "^0.17.3",
|
||||
"@types/ungap__structured-clone": "^1.2.0",
|
||||
"lil-gui": "^0.21.0",
|
||||
"stats.js": "^0.17.0",
|
||||
"typedoc": "^0.28.18",
|
||||
"typedoc-plugin-markdown": "^4.11.0",
|
||||
"@tsdown/css": "^0.22.0",
|
||||
"tsdown": "^0.22.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@pixi/app": "*",
|
||||
"@pixi/core": "*",
|
||||
"@pixi/display": "*",
|
||||
"@pixi/filter-blur": "*",
|
||||
"@pixi/filter-bulge-pinch": "*",
|
||||
"@pixi/filter-color-matrix": "*",
|
||||
"@pixi/sprite": "*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ungap/structured-clone": "^1.3.0",
|
||||
"bezier-easing": "^3.0.0",
|
||||
"deep-freeze": "^0.0.1",
|
||||
"gl-matrix": "4.0.0-beta.2"
|
||||
}
|
||||
}
|
||||
154
amll-local/packages/core/src/bg-render/base.ts
Normal file
154
amll-local/packages/core/src/bg-render/base.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import type { Disposable, HasElement } from "../interfaces.ts";
|
||||
|
||||
export abstract class AbstractBaseRenderer implements Disposable, HasElement {
|
||||
/**
|
||||
* 修改背景的流动速度,数字越大越快,默认为 8
|
||||
* @param speed 背景的流动速度,默认为 8
|
||||
*/
|
||||
abstract setFlowSpeed(speed: number): void;
|
||||
/**
|
||||
* 修改背景的渲染比例,默认是 0.5
|
||||
*
|
||||
* 一般情况下这个程度既没有明显瑕疵也不会特别吃性能
|
||||
* @param scale 背景的渲染比例
|
||||
*/
|
||||
abstract setRenderScale(scale: number): void;
|
||||
/**
|
||||
* 是否启用静态模式,即图片在更换后就会保持静止状态并禁用更新,以节省性能
|
||||
* @param enable 是否启用静态模式
|
||||
*/
|
||||
abstract setStaticMode(enable: boolean): void;
|
||||
/**
|
||||
* 修改背景动画帧率,默认是 30 FPS
|
||||
*
|
||||
* 如果设置成 0 则会停止动画
|
||||
* @param fps 目标帧率,默认 30 FPS
|
||||
*/
|
||||
abstract setFPS(fps: number): void;
|
||||
/**
|
||||
* 暂停背景动画,画面即便是更新了图片也不会发生变化
|
||||
*/
|
||||
abstract pause(): void;
|
||||
/**
|
||||
* 恢复播放背景动画
|
||||
*/
|
||||
abstract resume(): void;
|
||||
/**
|
||||
* 设置背景专辑资源,纹理加载并设置完成后会返回
|
||||
* @param albumSource 专辑的资源链接,可以是图片或视频链接,抑或是任意 img/video 元素,如果提供字符串链接且为视频则需要指定第二个参数
|
||||
*/
|
||||
abstract setAlbum(
|
||||
albumSource: string | HTMLImageElement | HTMLVideoElement,
|
||||
isVideo?: boolean,
|
||||
): Promise<void>;
|
||||
/**
|
||||
* 设置低频的音量大小,范围在 80hz-120hz 之间为宜,取值范围在 [0.0-1.0] 之间
|
||||
*
|
||||
* 部分渲染器会根据音量大小调整背景效果(例如根据鼓点跳动)
|
||||
*
|
||||
* 如果无法获取到类似的数据,请传入 1.0 作为默认值,或不做任何处理(默认值即 1.0)
|
||||
* @param volume 低频的音量大小,范围在 50hz-120hz 之间为宜,取值范围在 [0.0-1.0] 之间
|
||||
*/
|
||||
abstract setLowFreqVolume(volume: number): void;
|
||||
/**
|
||||
* 设置背景是否根据“是否有歌词”这个特征调整自身效果,例如有歌词时会变得更加活跃
|
||||
*
|
||||
* 部分渲染器会根据这个特征调整自身效果
|
||||
*
|
||||
* 如果不确定是否需要赋值或无法知晓是否包含歌词,请传入 true 或不做任何处理(默认值为 true)
|
||||
*
|
||||
* @param hasLyric 是否有歌词,如不确定是否需要赋值,请传入 true 或不做任何处理(默认值为 true)
|
||||
*/
|
||||
abstract setHasLyric(hasLyric: boolean): void;
|
||||
abstract dispose(): void;
|
||||
abstract getElement(): HTMLElement;
|
||||
}
|
||||
|
||||
function clamp1(x: number): number {
|
||||
return Math.max(1, x);
|
||||
}
|
||||
|
||||
export abstract class BaseRenderer extends AbstractBaseRenderer {
|
||||
private observer: ResizeObserver;
|
||||
protected flowSpeed = 1;
|
||||
protected currerntRenderScale = 0.75;
|
||||
constructor(protected canvas: HTMLCanvasElement) {
|
||||
super();
|
||||
this.observer = new ResizeObserver(() => {
|
||||
const width = clamp1(
|
||||
canvas.clientWidth * window.devicePixelRatio * this.currerntRenderScale,
|
||||
);
|
||||
const height = clamp1(
|
||||
canvas.clientHeight *
|
||||
window.devicePixelRatio *
|
||||
this.currerntRenderScale,
|
||||
);
|
||||
this.onResize(width, height);
|
||||
});
|
||||
this.observer.observe(canvas);
|
||||
}
|
||||
setRenderScale(scale: number): void {
|
||||
this.currerntRenderScale = scale;
|
||||
this.onResize(
|
||||
this.canvas.clientWidth *
|
||||
window.devicePixelRatio *
|
||||
this.currerntRenderScale,
|
||||
this.canvas.clientHeight *
|
||||
window.devicePixelRatio *
|
||||
this.currerntRenderScale,
|
||||
);
|
||||
}
|
||||
/**
|
||||
* 当画板元素大小发生变化时此函数会被调用
|
||||
* 可以在此处重设和渲染器相关的尺寸设置
|
||||
* 考虑到初始化的时候元素不一定在文档中或出于某些特殊样式状态,尺寸长宽有可能会为 0,请注意进行特判处理
|
||||
* @param width 画板元素实际的物理像素宽度,有可能为 0
|
||||
* @param height 画板元素实际的物理像素高度,有可能为 0
|
||||
*/
|
||||
protected onResize(width: number, height: number): void {
|
||||
this.canvas.width = width;
|
||||
this.canvas.height = height;
|
||||
}
|
||||
/**
|
||||
* 修改背景的流动速度,数字越大越快,默认为 1
|
||||
* @param speed 背景的流动速度,默认为 1
|
||||
*/
|
||||
setFlowSpeed(speed: number): void {
|
||||
this.flowSpeed = speed;
|
||||
}
|
||||
/**
|
||||
* 是否启用静态模式,即图片在更换后就会保持静止状态并禁用更新,以节省性能
|
||||
* @param enable 是否启用静态模式
|
||||
*/
|
||||
abstract override setStaticMode(enable: boolean): void;
|
||||
/**
|
||||
* 修改背景动画帧率,默认是 30 FPS
|
||||
*
|
||||
* 如果设置成 0 则会停止动画
|
||||
* @param fps 目标帧率,默认 30 FPS
|
||||
*/
|
||||
abstract override setFPS(fps: number): void;
|
||||
/**
|
||||
* 暂停背景动画,画面即便是更新了图片也不会发生变化
|
||||
*/
|
||||
abstract override pause(): void;
|
||||
/**
|
||||
* 恢复播放背景动画
|
||||
*/
|
||||
abstract override resume(): void;
|
||||
/**
|
||||
* 设置背景专辑资源,纹理加载并设置完成后会返回
|
||||
* @param albumSource 专辑的资源链接,可以是图片或视频链接,抑或是任意 img/video 元素,如果提供字符串链接且为视频则需要指定第二个参数
|
||||
*/
|
||||
abstract override setAlbum(
|
||||
albumSource: string | HTMLImageElement | HTMLVideoElement,
|
||||
isVideo?: boolean,
|
||||
): Promise<void>;
|
||||
dispose(): void {
|
||||
this.observer.disconnect();
|
||||
this.canvas.remove();
|
||||
}
|
||||
override getElement(): HTMLElement {
|
||||
return this.canvas;
|
||||
}
|
||||
}
|
||||
168
amll-local/packages/core/src/bg-render/img.ts
Normal file
168
amll-local/packages/core/src/bg-render/img.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
export function blurImage(
|
||||
imageData: ImageData,
|
||||
radius: number,
|
||||
quality: number,
|
||||
): void {
|
||||
const pixels = imageData.data;
|
||||
const width = imageData.width;
|
||||
const height = imageData.height;
|
||||
|
||||
let rsum: number;
|
||||
let gsum: number;
|
||||
let bsum: number;
|
||||
let asum: number;
|
||||
let x: number;
|
||||
let y: number;
|
||||
let i: number;
|
||||
let p: number;
|
||||
let p1: number;
|
||||
let p2: number;
|
||||
let yp: number;
|
||||
let yi: number;
|
||||
let yw: number;
|
||||
const wm = width - 1;
|
||||
const hm = height - 1;
|
||||
const rad1x = radius + 1;
|
||||
const divx = radius + rad1x;
|
||||
const rad1y = radius + 1;
|
||||
const divy = radius + rad1y;
|
||||
const div2 = 1 / (divx * divy);
|
||||
|
||||
const r: number[] = [];
|
||||
const g: number[] = [];
|
||||
const b: number[] = [];
|
||||
const a: number[] = [];
|
||||
|
||||
const vmin: number[] = [];
|
||||
const vmax: number[] = [];
|
||||
|
||||
while (quality-- > 0) {
|
||||
yw = yi = 0;
|
||||
|
||||
for (y = 0; y < height; y++) {
|
||||
rsum = pixels[yw] * rad1x;
|
||||
gsum = pixels[yw + 1] * rad1x;
|
||||
bsum = pixels[yw + 2] * rad1x;
|
||||
asum = pixels[yw + 3] * rad1x;
|
||||
|
||||
for (i = 1; i <= radius; i++) {
|
||||
p = yw + ((i > wm ? wm : i) << 2);
|
||||
rsum += pixels[p++];
|
||||
gsum += pixels[p++];
|
||||
bsum += pixels[p++];
|
||||
asum += pixels[p];
|
||||
}
|
||||
|
||||
for (x = 0; x < width; x++) {
|
||||
r[yi] = rsum;
|
||||
g[yi] = gsum;
|
||||
b[yi] = bsum;
|
||||
a[yi] = asum;
|
||||
|
||||
if (y === 0) {
|
||||
vmin[x] = Math.min(x + rad1x, wm) << 2;
|
||||
vmax[x] = Math.max(x - radius, 0) << 2;
|
||||
}
|
||||
|
||||
p1 = yw + vmin[x];
|
||||
p2 = yw + vmax[x];
|
||||
|
||||
rsum += pixels[p1++] - pixels[p2++];
|
||||
gsum += pixels[p1++] - pixels[p2++];
|
||||
bsum += pixels[p1++] - pixels[p2++];
|
||||
asum += pixels[p1] - pixels[p2];
|
||||
|
||||
yi++;
|
||||
}
|
||||
yw += width << 2;
|
||||
}
|
||||
|
||||
for (x = 0; x < width; x++) {
|
||||
yp = x;
|
||||
rsum = r[yp] * rad1y;
|
||||
gsum = g[yp] * rad1y;
|
||||
bsum = b[yp] * rad1y;
|
||||
asum = a[yp] * rad1y;
|
||||
|
||||
for (i = 1; i <= radius; i++) {
|
||||
yp += i > hm ? 0 : width;
|
||||
rsum += r[yp];
|
||||
gsum += g[yp];
|
||||
bsum += b[yp];
|
||||
asum += a[yp];
|
||||
}
|
||||
|
||||
yi = x << 2;
|
||||
for (y = 0; y < height; y++) {
|
||||
pixels[yi] = (rsum * div2 + 0.5) | 0;
|
||||
pixels[yi + 1] = (gsum * div2 + 0.5) | 0;
|
||||
pixels[yi + 2] = (bsum * div2 + 0.5) | 0;
|
||||
pixels[yi + 3] = (asum * div2 + 0.5) | 0;
|
||||
|
||||
if (x === 0) {
|
||||
vmin[y] = Math.min(y + rad1y, hm) * width;
|
||||
vmax[y] = Math.max(y - radius, 0) * width;
|
||||
}
|
||||
|
||||
p1 = x + vmin[y];
|
||||
p2 = x + vmax[y];
|
||||
|
||||
rsum += r[p1] - r[p2];
|
||||
gsum += g[p1] - g[p2];
|
||||
bsum += b[p1] - b[p2];
|
||||
asum += a[p1] - a[p2];
|
||||
|
||||
yi += width << 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function saturateImage(imageData: ImageData, saturation: number): void {
|
||||
const pixels = imageData.data;
|
||||
|
||||
for (let i = 0; i < pixels.length; i += 4) {
|
||||
const r = pixels[i];
|
||||
const g = pixels[i + 1];
|
||||
const b = pixels[i + 2];
|
||||
const a = pixels[i + 3];
|
||||
const gray = r * 0.3 + g * 0.59 + b * 0.11;
|
||||
pixels[i] = gray * (1 - saturation) + r * saturation;
|
||||
pixels[i + 1] = gray * (1 - saturation) + g * saturation;
|
||||
pixels[i + 2] = gray * (1 - saturation) + b * saturation;
|
||||
pixels[i + 3] = a;
|
||||
}
|
||||
}
|
||||
|
||||
export function brightnessImage(
|
||||
imageData: ImageData,
|
||||
brightness: number,
|
||||
): void {
|
||||
const pixels = imageData.data;
|
||||
|
||||
for (let i = 0; i < pixels.length; i += 4) {
|
||||
const r = pixels[i];
|
||||
const g = pixels[i + 1];
|
||||
const b = pixels[i + 2];
|
||||
const a = pixels[i + 3];
|
||||
pixels[i] = r * brightness;
|
||||
pixels[i + 1] = g * brightness;
|
||||
pixels[i + 2] = b * brightness;
|
||||
pixels[i + 3] = a;
|
||||
}
|
||||
}
|
||||
|
||||
export function contrastImage(imageData: ImageData, contrast: number): void {
|
||||
const pixels = imageData.data;
|
||||
|
||||
for (let i = 0; i < pixels.length; i += 4) {
|
||||
const r = pixels[i];
|
||||
const g = pixels[i + 1];
|
||||
const b = pixels[i + 2];
|
||||
const a = pixels[i + 3];
|
||||
pixels[i] = (r - 128) * contrast + 128;
|
||||
pixels[i + 1] = (g - 128) * contrast + 128;
|
||||
pixels[i + 2] = (b - 128) * contrast + 128;
|
||||
pixels[i + 3] = a;
|
||||
}
|
||||
}
|
||||
71
amll-local/packages/core/src/bg-render/index.ts
Normal file
71
amll-local/packages/core/src/bg-render/index.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @fileoverview
|
||||
* 一个播放歌词的组件
|
||||
* @author SteveXMH
|
||||
*/
|
||||
|
||||
export { AbstractBaseRenderer, BaseRenderer } from "./base.ts";
|
||||
export { MeshGradientRenderer } from "./mesh-renderer/index.ts";
|
||||
export { PixiRenderer } from "./pixi-renderer.ts";
|
||||
import type { AbstractBaseRenderer, BaseRenderer } from "./base.ts";
|
||||
|
||||
export class BackgroundRender<Renderer extends BaseRenderer>
|
||||
implements AbstractBaseRenderer
|
||||
{
|
||||
private element: HTMLCanvasElement;
|
||||
private renderer: Renderer;
|
||||
constructor(renderer: Renderer, canvas: HTMLCanvasElement) {
|
||||
this.renderer = renderer;
|
||||
|
||||
this.element = canvas;
|
||||
canvas.style.pointerEvents = "none";
|
||||
canvas.style.zIndex = "-1";
|
||||
canvas.style.contain = "strict";
|
||||
}
|
||||
|
||||
static new<Renderer extends BaseRenderer>(type: {
|
||||
new (canvas: HTMLCanvasElement): Renderer;
|
||||
}): BackgroundRender<Renderer> {
|
||||
const newCanvas = document.createElement("canvas");
|
||||
return new BackgroundRender(new type(newCanvas), newCanvas);
|
||||
}
|
||||
|
||||
setRenderScale(scale: number): void {
|
||||
this.renderer.setRenderScale(scale);
|
||||
}
|
||||
|
||||
setFlowSpeed(speed: number): void {
|
||||
this.renderer.setFlowSpeed(speed);
|
||||
}
|
||||
setStaticMode(enable: boolean): void {
|
||||
this.renderer.setStaticMode(enable);
|
||||
}
|
||||
setFPS(fps: number): void {
|
||||
this.renderer.setFPS(fps);
|
||||
}
|
||||
pause(): void {
|
||||
this.renderer.pause();
|
||||
}
|
||||
resume(): void {
|
||||
this.renderer.resume();
|
||||
}
|
||||
setLowFreqVolume(volume: number): void {
|
||||
this.renderer.setLowFreqVolume(volume);
|
||||
}
|
||||
setHasLyric(hasLyric: boolean): void {
|
||||
this.renderer.setHasLyric(hasLyric);
|
||||
}
|
||||
setAlbum(
|
||||
albumSource: string | HTMLImageElement | HTMLVideoElement,
|
||||
isVideo?: boolean,
|
||||
): Promise<void> {
|
||||
return this.renderer.setAlbum(albumSource, isVideo);
|
||||
}
|
||||
getElement(): HTMLCanvasElement {
|
||||
return this.element;
|
||||
}
|
||||
dispose(): void {
|
||||
this.renderer.dispose();
|
||||
this.element.remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* @fileoverview
|
||||
* 实验性的随机控制点生成函数算法
|
||||
* 目的是取代原先大量的预设控制点代码
|
||||
*/
|
||||
|
||||
import { clamp01 } from "#utils/clamp.ts";
|
||||
import {
|
||||
type ControlPointConf,
|
||||
type ControlPointPreset,
|
||||
p,
|
||||
preset,
|
||||
} from "./cp-presets.ts";
|
||||
|
||||
const randomRange = (min: number, max: number): number =>
|
||||
Math.random() * (max - min) + min;
|
||||
|
||||
function smoothstep(edge0: number, edge1: number, x: number): number {
|
||||
const t = clamp01((x - edge0) / (edge1 - edge0));
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
function smoothifyControlPoints(
|
||||
conf: ControlPointConf[],
|
||||
w: number,
|
||||
h: number,
|
||||
iterations = 2,
|
||||
factor = 0.5,
|
||||
factorIterationModifier = 0.1,
|
||||
): void {
|
||||
let grid: ControlPointConf[][] = [];
|
||||
let f = factor;
|
||||
|
||||
for (let j = 0; j < h; j++) {
|
||||
grid[j] = [];
|
||||
for (let i = 0; i < w; i++) {
|
||||
grid[j][i] = conf[j * w + i];
|
||||
}
|
||||
}
|
||||
|
||||
const kernel = [
|
||||
[1, 2, 1],
|
||||
[2, 4, 2],
|
||||
[1, 2, 1],
|
||||
];
|
||||
const kernelSum = 16;
|
||||
|
||||
for (let iter = 0; iter < iterations; iter++) {
|
||||
const newGrid: ControlPointConf[][] = [];
|
||||
for (let j = 0; j < h; j++) {
|
||||
newGrid[j] = [];
|
||||
for (let i = 0; i < w; i++) {
|
||||
if (i === 0 || i === w - 1 || j === 0 || j === h - 1) {
|
||||
newGrid[j][i] = grid[j][i];
|
||||
continue;
|
||||
}
|
||||
let sumX = 0;
|
||||
let sumY = 0;
|
||||
let sumUR = 0;
|
||||
let sumVR = 0;
|
||||
let sumUP = 0;
|
||||
let sumVP = 0;
|
||||
for (let dj = -1; dj <= 1; dj++) {
|
||||
for (let di = -1; di <= 1; di++) {
|
||||
const weight = kernel[dj + 1][di + 1];
|
||||
const nb = grid[j + dj][i + di];
|
||||
sumX += nb.x * weight;
|
||||
sumY += nb.y * weight;
|
||||
sumUR += nb.ur * weight;
|
||||
sumVR += nb.vr * weight;
|
||||
sumUP += nb.up * weight;
|
||||
sumVP += nb.vp * weight;
|
||||
}
|
||||
}
|
||||
const avgX = sumX / kernelSum;
|
||||
const avgY = sumY / kernelSum;
|
||||
const avgUR = sumUR / kernelSum;
|
||||
const avgVR = sumVR / kernelSum;
|
||||
const avgUP = sumUP / kernelSum;
|
||||
const avgVP = sumVP / kernelSum;
|
||||
|
||||
const cur = grid[j][i];
|
||||
const newX = cur.x * (1 - f) + avgX * f;
|
||||
const newY = cur.y * (1 - f) + avgY * f;
|
||||
const newUR = cur.ur * (1 - f) + avgUR * f;
|
||||
const newVR = cur.vr * (1 - f) + avgVR * f;
|
||||
const newUP = cur.up * (1 - f) + avgUP * f;
|
||||
const newVP = cur.vp * (1 - f) + avgVP * f;
|
||||
newGrid[j][i] = p(i, j, newX, newY, newUR, newVR, newUP, newVP);
|
||||
}
|
||||
}
|
||||
grid = newGrid;
|
||||
f = clamp01(f + factorIterationModifier);
|
||||
}
|
||||
|
||||
for (let j = 0; j < h; j++) {
|
||||
for (let i = 0; i < w; i++) {
|
||||
conf[j * w + i] = grid[j][i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function noise(x: number, y: number): number {
|
||||
return fract(Math.sin(x * 12.9898 + y * 78.233) * 43758.5453);
|
||||
}
|
||||
|
||||
function fract(x: number): number {
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
|
||||
function smoothNoise(x: number, y: number): number {
|
||||
const x0 = Math.floor(x);
|
||||
const y0 = Math.floor(y);
|
||||
const x1 = x0 + 1;
|
||||
const y1 = y0 + 1;
|
||||
|
||||
const xf = x - x0;
|
||||
const yf = y - y0;
|
||||
|
||||
const u = xf * xf * (3 - 2 * xf);
|
||||
const v = yf * yf * (3 - 2 * yf);
|
||||
|
||||
const n00 = noise(x0, y0);
|
||||
const n10 = noise(x1, y0);
|
||||
const n01 = noise(x0, y1);
|
||||
const n11 = noise(x1, y1);
|
||||
|
||||
const nx0 = n00 * (1 - u) + n10 * u;
|
||||
const nx1 = n01 * (1 - u) + n11 * u;
|
||||
|
||||
return nx0 * (1 - v) + nx1 * v;
|
||||
}
|
||||
|
||||
function computeNoiseGradient(
|
||||
perlinFn: (x: number, y: number) => number,
|
||||
x: number,
|
||||
y: number,
|
||||
epsilon = 0.001,
|
||||
): [number, number] {
|
||||
const n1 = perlinFn(x + epsilon, y);
|
||||
const n2 = perlinFn(x - epsilon, y);
|
||||
const n3 = perlinFn(x, y + epsilon);
|
||||
const n4 = perlinFn(x, y - epsilon);
|
||||
const dx = (n1 - n2) / (2 * epsilon);
|
||||
const dy = (n3 - n4) / (2 * epsilon);
|
||||
const len = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
return [dx / len, dy / len];
|
||||
}
|
||||
|
||||
export function generateControlPoints(
|
||||
width: number,
|
||||
height: number,
|
||||
variationFraction: number = randomRange(0.4, 0.6), // = 0.2,
|
||||
normalOffset: number = randomRange(0.3, 0.6), // = 0.3,
|
||||
blendFactor = 0.8,
|
||||
smoothIters: number = Math.floor(randomRange(3, 5)), // = 3,
|
||||
smoothFactor: number = randomRange(0.2, 0.3), // = 0.3,
|
||||
smoothModifier: number = randomRange(-0.1, -0.05), // = -0.05,
|
||||
): ControlPointPreset {
|
||||
const w = width ?? Math.floor(randomRange(3, 6));
|
||||
const h = height ?? Math.floor(randomRange(3, 6));
|
||||
|
||||
const conf: ControlPointConf[] = [];
|
||||
const dx = w === 1 ? 0 : 2 / (w - 1);
|
||||
const dy = h === 1 ? 0 : 2 / (h - 1);
|
||||
|
||||
for (let j = 0; j < h; j++) {
|
||||
for (let i = 0; i < w; i++) {
|
||||
const baseX = (w === 1 ? 0 : i / (w - 1)) * 2 - 1;
|
||||
const baseY = (h === 1 ? 0 : j / (h - 1)) * 2 - 1;
|
||||
|
||||
const isBorder = i === 0 || i === w - 1 || j === 0 || j === h - 1;
|
||||
const pertX = isBorder
|
||||
? 0
|
||||
: randomRange(-variationFraction * dx, variationFraction * dx);
|
||||
const pertY = isBorder
|
||||
? 0
|
||||
: randomRange(-variationFraction * dy, variationFraction * dy);
|
||||
let x = baseX + pertX;
|
||||
let y = baseY + pertY;
|
||||
|
||||
const ur = isBorder ? 0 : randomRange(-60, 60);
|
||||
const vr = isBorder ? 0 : randomRange(-60, 60);
|
||||
const up = isBorder ? 1 : randomRange(0.8, 1.2);
|
||||
const vp = isBorder ? 1 : randomRange(0.8, 1.2);
|
||||
|
||||
if (!isBorder) {
|
||||
const uNorm = (baseX + 1) / 2;
|
||||
const vNorm = (baseY + 1) / 2;
|
||||
|
||||
const [nx, ny] = computeNoiseGradient(smoothNoise, uNorm, vNorm, 0.001);
|
||||
let offsetX = nx * normalOffset;
|
||||
let offsetY = ny * normalOffset;
|
||||
|
||||
const distToBorder = Math.min(uNorm, 1 - uNorm, vNorm, 1 - vNorm); // in [0,0.5]
|
||||
|
||||
const weight = smoothstep(0, 1.0, distToBorder);
|
||||
offsetX *= weight;
|
||||
offsetY *= weight;
|
||||
|
||||
x = x * (1 - blendFactor) + (x + offsetX) * blendFactor;
|
||||
y = y * (1 - blendFactor) + (y + offsetY) * blendFactor;
|
||||
}
|
||||
conf.push(p(i, j, x, y, ur, vr, up, vp));
|
||||
}
|
||||
}
|
||||
|
||||
smoothifyControlPoints(conf, w, h, smoothIters, smoothFactor, smoothModifier);
|
||||
|
||||
return preset(w, h, conf);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/** @internal */
|
||||
export interface ControlPointConf {
|
||||
cx: number;
|
||||
cy: number;
|
||||
x: number;
|
||||
y: number;
|
||||
ur: number;
|
||||
vr: number;
|
||||
up: number;
|
||||
vp: number;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export interface ControlPointPreset {
|
||||
width: number;
|
||||
height: number;
|
||||
conf: ControlPointConf[];
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export const p = (
|
||||
cx: number,
|
||||
cy: number,
|
||||
x: number,
|
||||
y: number,
|
||||
ur = 0,
|
||||
vr = 0,
|
||||
up = 1,
|
||||
vp = 1,
|
||||
) => Object.freeze({ cx, cy, x, y, ur, vr, up, vp }) as ControlPointConf;
|
||||
/** @internal */
|
||||
export const preset = (
|
||||
width: number,
|
||||
height: number,
|
||||
conf: ControlPointConf[],
|
||||
) => Object.freeze({ width, height, conf }) as ControlPointPreset;
|
||||
|
||||
export const CONTROL_POINT_PRESETS: ControlPointPreset[] = [
|
||||
// TODO: 竖屏推荐
|
||||
preset(5, 5, [
|
||||
p(0, 0, -1, -1, 0, 0, 1, 1),
|
||||
p(1, 0, -0.5, -1, 0, 0, 1, 1),
|
||||
p(2, 0, 0, -1, 0, 0, 1, 1),
|
||||
p(3, 0, 0.5, -1, 0, 0, 1, 1),
|
||||
p(4, 0, 1, -1, 0, 0, 1, 1),
|
||||
p(0, 1, -1, -0.5, 0, 0, 1, 1),
|
||||
p(1, 1, -0.5, -0.5, 0, 0, 1, 1),
|
||||
p(2, 1, -0.0052029684413368305, -0.6131420587090777, 0, 0, 1, 1),
|
||||
p(3, 1, 0.5884227308309977, -0.3990805107556692, 0, 0, 1, 1),
|
||||
p(4, 1, 1, -0.5, 0, 0, 1, 1),
|
||||
p(0, 2, -1, 0, 0, 0, 1, 1),
|
||||
p(1, 2, -0.4210024670505933, -0.11895058380429502, 0, 0, 1, 1),
|
||||
p(2, 2, -0.1019613423315412, -0.023812118047224606, 0, -47, 0.629, 0.849),
|
||||
p(3, 2, 0.40275125660925437, -0.06345314544600389, 0, 0, 1, 1),
|
||||
p(4, 2, 1, 0, 0, 0, 1, 1),
|
||||
p(0, 3, -1, 0.5, 0, 0, 1, 1),
|
||||
p(1, 3, 0.06801958477287173, 0.5205913248960121, -31, -45, 1, 1),
|
||||
p(2, 3, 0.21446469120128908, 0.29331610114301043, 6, -56, 0.566, 1.321),
|
||||
p(3, 3, 0.5, 0.5, 0, 0, 1, 1),
|
||||
p(4, 3, 1, 0.5, 0, 0, 1, 1),
|
||||
p(0, 4, -1, 1, 0, 0, 1, 1),
|
||||
p(1, 4, -0.31378372841550195, 1, 0, 0, 1, 1),
|
||||
p(2, 4, 0.26153633255328046, 1, 0, 0, 1, 1),
|
||||
p(3, 4, 0.5, 1, 0, 0, 1, 1),
|
||||
p(4, 4, 1, 1, 0, 0, 1, 1),
|
||||
]),
|
||||
// TODO: 横屏推荐
|
||||
preset(4, 4, [
|
||||
p(0, 0, -1, -1, 0, 0, 1, 1),
|
||||
p(1, 0, -0.33333333333333337, -1, 0, 0, 1, 1),
|
||||
p(2, 0, 0.33333333333333326, -1, 0, 0, 1, 1),
|
||||
p(3, 0, 1, -1, 0, 0, 1, 1),
|
||||
p(0, 1, -1, -0.04495399932657351, 0, 0, 1, 1),
|
||||
p(1, 1, -0.24056117520129328, -0.22465999020104, 0, 0, 1, 1),
|
||||
p(2, 1, 0.334758885767489, -0.00531297192779423, 0, 0, 1, 1),
|
||||
p(3, 1, 0.9989920470678106, -0.3382976020775408, 8, 0, 0.566, 1.792),
|
||||
p(0, 2, -1, 0.33333333333333326, 0, 0, 1, 1),
|
||||
p(1, 2, -0.3425497314639411, -0.000027501607956947893, 0, 0, 1, 1),
|
||||
p(2, 2, 0.3321437945812673, 0.1981776353859399, 0, 0, 1, 1),
|
||||
p(3, 2, 1, 0.0766118180296832, 0, 0, 1, 1),
|
||||
p(0, 3, -1, 1, 0, 0, 1, 1),
|
||||
p(1, 3, -0.33333333333333337, 1, 0, 0, 1, 1),
|
||||
p(2, 3, 0.33333333333333326, 1, 0, 0, 1, 1),
|
||||
p(3, 3, 1, 1, 0, 0, 1, 1),
|
||||
]),
|
||||
preset(4, 4, [
|
||||
p(0, 0, -1, -1, 0, 0, 1, 2.075),
|
||||
p(1, 0, -0.33333333333333337, -1, 0, 0, 1, 1),
|
||||
p(2, 0, 0.33333333333333326, -1, 0, 0, 1, 1),
|
||||
p(3, 0, 1, -1, 0, 0, 1, 1),
|
||||
p(0, 1, -1, -0.4545779491139603, 0, 0, 1, 1),
|
||||
p(1, 1, -0.33333333333333337, -0.33333333333333337, 0, 0, 1, 1),
|
||||
p(2, 1, 0.0889403142626457, -0.6025711180694033, -32, 45, 1, 1),
|
||||
p(3, 1, 1, -0.33333333333333337, 0, 0, 1, 1),
|
||||
p(0, 2, -1, -0.07402408608567845, 1, 0, 1, 0.094),
|
||||
p(1, 2, -0.2719422694359541, 0.09775369930903222, 25, -18, 1.321, 0),
|
||||
p(2, 2, 0.19877414408395877, 0.4307383294587789, 48, -40, 0.755, 0.975),
|
||||
p(3, 2, 1, 0.33333333333333326, -37, 0, 1, 1),
|
||||
p(0, 3, -1, 1, 0, 0, 1, 1),
|
||||
p(1, 3, -0.33333333333333337, 1, 0, 0, 1, 1),
|
||||
p(2, 3, 0.5125850864305672, 1, -20, -18, 0, 1.604),
|
||||
p(3, 3, 1, 1, 0, 0, 1, 1),
|
||||
]),
|
||||
preset(5, 5, [
|
||||
p(0, 0, -1, -1, 0, 0, 1, 1),
|
||||
p(1, 0, -0.4501953125, -1, 0, 55, 1, 2.075),
|
||||
p(2, 0, 0.1953125, -1, 0, 0, 1, 1),
|
||||
p(3, 0, 0.4580078125, -1, 0, -25, 1, 1),
|
||||
p(4, 0, 1, -1, 0, 0, 1, 1),
|
||||
p(0, 1, -1, -0.2514475377525607, -16, 0, 2.327, 0.943),
|
||||
p(1, 1, -0.55859375, -0.6609325945787148, 47, 0, 2.358, 0.377),
|
||||
p(2, 1, 0.232421875, -0.5244375756366635, -66, -25, 1.855, 1.164),
|
||||
p(3, 1, 0.685546875, -0.3753706470552125, 0, 0, 1, 1),
|
||||
p(4, 1, 1, -0.6699125300354287, 0, 0, 1, 1),
|
||||
p(0, 2, -1, 0.035910396862284255, 0, 0, 1, 1),
|
||||
p(1, 2, -0.4921875, 0.005378616309457018, 90, 23, 1, 1.981),
|
||||
p(2, 2, 0.021484375, -0.1365043639066228, 0, 42, 1, 1),
|
||||
p(3, 2, 0.4765625, 0.05925822904974043, -30, 0, 1.95, 0.44),
|
||||
p(4, 2, 1, 0.251428847823418, 0, 0, 1, 1),
|
||||
p(0, 3, -1, 0.6968336464764276, -68, 0, 1, 0.786),
|
||||
p(1, 3, -0.6904296875, 0.5890744209958608, -68, 0, 1, 1),
|
||||
p(2, 3, 0.1845703125, 0.3879238667654693, 61, 0, 1, 1),
|
||||
p(3, 3, 0.60546875, 0.4633553246018661, -47, -59, 0.849, 1.73),
|
||||
p(4, 3, 1, 0.6214021886400309, -33, 0, 0.377, 1.604),
|
||||
p(0, 4, -1, 1, 0, 0, 1, 1),
|
||||
p(1, 4, -0.5, 1, 0, -73, 1, 1),
|
||||
p(2, 4, -0.3271484375, 1, 0, -24, 0.314, 2.704),
|
||||
p(3, 4, 0.5, 1, 0, 0, 1, 1),
|
||||
p(4, 4, 1, 1, 0, 0, 1, 1),
|
||||
]),
|
||||
preset(5, 5, [
|
||||
p(0, 0, -1, -1),
|
||||
p(1, 0, -0.6393, -1, 0, 0, 1, 2.3884),
|
||||
p(2, 0, 0, -1),
|
||||
p(3, 0, 0.5, -1),
|
||||
p(4, 0, 1, -1),
|
||||
p(0, 1, -1, -0.2301),
|
||||
p(1, 1, -0.6934, -0.331, 0, -0.7188, 1, 1.063),
|
||||
p(2, 1, -0.0082, -0.6814, -0.2583, 0, 1.0964, 1),
|
||||
p(3, 1, 0.5836, -0.531, 0.7029, 0, 1.5466, 1),
|
||||
p(4, 1, 1, -0.6407),
|
||||
p(0, 2, -1, 0.2973, 0, 0, 1.8352, 1),
|
||||
p(1, 2, -0.4082, 0.0602),
|
||||
p(2, 2, -0.1803, -0.3646, -0.2998, 0, 1.1513, 1),
|
||||
p(3, 2, 0.477, -0.1027, 0.8903, -0.1882, 1.0807, 0.8551),
|
||||
p(4, 2, 1, -0.2973),
|
||||
p(0, 3, -1, 0.7628, 0, 0, 2.3868, 1),
|
||||
p(1, 3, -0.2525, 0.4814, -0.8406, -1.6199, 1.4093, 1.2215),
|
||||
p(2, 3, 0.3607, 0.2814, -1.0713, -0.0529, 1.0025, 0.7611),
|
||||
p(3, 3, 0.4885, 0.623, 0, 0.8184, 1, 1.2876),
|
||||
p(4, 3, 1, 0.5),
|
||||
p(0, 4, -1, 1),
|
||||
p(1, 4, -0.4033, 1),
|
||||
p(2, 4, 0.2672, 1),
|
||||
p(3, 4, 0.5967, 1),
|
||||
p(4, 4, 1, 1),
|
||||
]),
|
||||
preset(5, 5, [
|
||||
p(0, 0, -1, -1),
|
||||
p(1, 0, -0.2197, -1),
|
||||
p(2, 0, 0.0197, -1),
|
||||
p(3, 0, 0.8033, -1),
|
||||
p(4, 0, 1, -1),
|
||||
p(0, 1, -1, -0.5451),
|
||||
p(1, 1, -0.4885, -0.4035, -1.0246, -0.2268, 1.1936, 0.8005),
|
||||
p(2, 1, -0.1213, -0.2867, 0, -0.6981, 1, 0.809),
|
||||
p(3, 1, 0.3246, -0.5628, 0, -1.2188, 1, 1.044),
|
||||
p(4, 1, 1, -0.3292),
|
||||
p(0, 2, -1, 0.1416),
|
||||
p(1, 2, -0.341, -0.0142, 0, -0.4004, 1, 1.1293),
|
||||
p(2, 2, -0.0393, -0.023, 0.2915, -0.373, 1.044, 0.9879),
|
||||
p(3, 2, 0.3148, -0.0673, -0.7853, -0.8962, 1.4709, 1.0247),
|
||||
p(4, 2, 1, 0.1912),
|
||||
p(0, 3, -1, 0.5),
|
||||
p(1, 3, -0.2689, 0.2743, 0.3404, -0.5248, 1.0184, 0.4391),
|
||||
p(2, 3, 0.0721, 0.269, 0.5302, 0.1244, 0.6723, 0.3225),
|
||||
p(3, 3, 0.4148, 0.3894, -0.6977, -0.6783, 0.8094, 0.9247),
|
||||
p(4, 3, 1, 0.446),
|
||||
p(0, 4, -1, 1),
|
||||
p(1, 4, -0.7311, 1),
|
||||
p(2, 4, 0.323, 1),
|
||||
p(3, 4, 0.6393, 1),
|
||||
p(4, 4, 1, 1),
|
||||
]),
|
||||
] as const;
|
||||
1352
amll-local/packages/core/src/bg-render/mesh-renderer/index.ts
Normal file
1352
amll-local/packages/core/src/bg-render/mesh-renderer/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
precision highp float;
|
||||
|
||||
varying vec3 v_color;
|
||||
varying vec2 v_uv;
|
||||
uniform sampler2D u_texture;
|
||||
uniform float u_time;
|
||||
uniform float u_volume;
|
||||
uniform float u_alpha;
|
||||
|
||||
// 预计算常量
|
||||
const float INV_255 = 1.0 / 255.0;
|
||||
const float HALF_INV_255 = 0.5 / 255.0;
|
||||
const float GRADIENT_NOISE_A = 52.9829189;
|
||||
const vec2 GRADIENT_NOISE_B = vec2(0.06711056, 0.00583715);
|
||||
|
||||
/* Gradient noise from Jorge Jimenez's presentation: */
|
||||
/* http://www.iryoku.com/next-generation-post-processing-in-call-of-duty-advanced-warfare */
|
||||
float gradientNoise(in vec2 uv) {
|
||||
return fract(GRADIENT_NOISE_A * fract(dot(uv, GRADIENT_NOISE_B)));
|
||||
}
|
||||
|
||||
// 优化的旋转函数,避免重复计算sin/cos
|
||||
vec2 rot(vec2 v, float angle) {
|
||||
float s = sin(angle);
|
||||
float c = cos(angle);
|
||||
return vec2(c * v.x - s * v.y, s * v.x + c * v.y);
|
||||
}
|
||||
|
||||
void main() {
|
||||
// 合并计算以减少指令数
|
||||
float volumeEffect = u_volume * 2.0;
|
||||
float timeVolume = u_time + u_volume;
|
||||
|
||||
float dither = INV_255 * gradientNoise(gl_FragCoord.xy) - HALF_INV_255;
|
||||
vec2 centeredUV = v_uv - vec2(0.2);
|
||||
vec2 rotatedUV = rot(centeredUV, timeVolume * 2.0);
|
||||
vec2 finalUV = rotatedUV * max(0.001, 1.0 - volumeEffect) + vec2(0.5);
|
||||
|
||||
vec4 result = texture2D(u_texture, finalUV);
|
||||
|
||||
float alphaVolumeFactor = u_alpha * max(0.5, 1.0 - u_volume * 0.5);
|
||||
result.rgb *= v_color * alphaVolumeFactor;
|
||||
result.a *= alphaVolumeFactor;
|
||||
|
||||
result.rgb += vec3(dither);
|
||||
|
||||
float dist = distance(v_uv, vec2(0.5));
|
||||
float vignette = smoothstep(0.8, 0.3, dist);
|
||||
float mask = 0.6 + vignette * 0.4;
|
||||
result.rgb *= mask;
|
||||
|
||||
gl_FragColor = result;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
precision highp float;
|
||||
|
||||
attribute vec2 a_pos;
|
||||
attribute vec3 a_color;
|
||||
attribute vec2 a_uv;
|
||||
varying vec3 v_color;
|
||||
varying vec2 v_uv;
|
||||
|
||||
uniform float u_aspect;
|
||||
|
||||
void main() {
|
||||
v_color = a_color;
|
||||
v_uv = a_uv;
|
||||
vec2 pos = a_pos;
|
||||
if (u_aspect > 1.0) {
|
||||
pos.y *= u_aspect;
|
||||
} else {
|
||||
pos.x /= u_aspect;
|
||||
}
|
||||
gl_Position = vec4(pos, 0.0, 1.0);
|
||||
}
|
||||
264
amll-local/packages/core/src/bg-render/pixi-renderer.ts
Normal file
264
amll-local/packages/core/src/bg-render/pixi-renderer.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { Application } from "@pixi/app";
|
||||
import { Texture } from "@pixi/core";
|
||||
import { Container } from "@pixi/display";
|
||||
import { BlurFilter } from "@pixi/filter-blur";
|
||||
import { BulgePinchFilter } from "@pixi/filter-bulge-pinch";
|
||||
import { ColorMatrixFilter } from "@pixi/filter-color-matrix";
|
||||
import { Sprite } from "@pixi/sprite";
|
||||
import {
|
||||
loadResourceFromElement,
|
||||
loadResourceFromUrl,
|
||||
} from "../utils/resource";
|
||||
import { BaseRenderer } from "./base";
|
||||
import { clampPositive } from "#utils/clamp.ts";
|
||||
|
||||
class TimedContainer extends Container {
|
||||
public time = 0;
|
||||
}
|
||||
|
||||
export class PixiRenderer extends BaseRenderer {
|
||||
private app: Application;
|
||||
private curContainer?: TimedContainer;
|
||||
private staticMode = false;
|
||||
private lastContainer: Set<TimedContainer> = new Set();
|
||||
private onTick = (delta: number): void => {
|
||||
for (const lastContainer of this.lastContainer) {
|
||||
lastContainer.alpha = clampPositive(lastContainer.alpha - delta / 60);
|
||||
if (lastContainer.alpha <= 0) {
|
||||
this.app.stage.removeChild(lastContainer);
|
||||
this.lastContainer.delete(lastContainer);
|
||||
lastContainer.destroy(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.curContainer) {
|
||||
this.curContainer.alpha = Math.min(
|
||||
1,
|
||||
this.curContainer.alpha + delta / 60,
|
||||
);
|
||||
const [s1, s2, s3, s4] = this.curContainer.children as Sprite[];
|
||||
const maxSize = Math.max(this.app.screen.width, this.app.screen.height);
|
||||
s1.position.set(this.app.screen.width / 2, this.app.screen.height / 2);
|
||||
s2.position.set(
|
||||
this.app.screen.width / 2.5,
|
||||
this.app.screen.height / 2.5,
|
||||
);
|
||||
s3.position.set(this.app.screen.width / 2, this.app.screen.height / 2);
|
||||
s4.position.set(this.app.screen.width / 2, this.app.screen.height / 2);
|
||||
s1.width = maxSize * Math.sqrt(2);
|
||||
s1.height = s1.width;
|
||||
s2.width = maxSize * 0.8;
|
||||
s2.height = s2.width;
|
||||
s3.width = maxSize * 0.5;
|
||||
s3.height = s3.width;
|
||||
s4.width = maxSize * 0.25;
|
||||
s4.height = s4.width;
|
||||
|
||||
this.curContainer.time += delta * this.flowSpeed;
|
||||
|
||||
s1.rotation += (delta / 1000) * this.flowSpeed;
|
||||
s2.rotation -= (delta / 500) * this.flowSpeed;
|
||||
s3.rotation += (delta / 1000) * this.flowSpeed;
|
||||
s4.rotation -= (delta / 750) * this.flowSpeed;
|
||||
|
||||
s3.x =
|
||||
this.app.screen.width / 2 +
|
||||
(this.app.screen.width / 4) *
|
||||
Math.cos((this.curContainer.time / 1000) * 0.75);
|
||||
s3.y =
|
||||
this.app.screen.height / 2 +
|
||||
(this.app.screen.width / 4) *
|
||||
Math.cos((this.curContainer.time / 1000) * 0.75);
|
||||
|
||||
s4.x =
|
||||
this.app.screen.width / 2 +
|
||||
(this.app.screen.width / 4) * 0.1 +
|
||||
Math.cos(this.curContainer.time * 0.006 * 0.75);
|
||||
s4.y =
|
||||
this.app.screen.height / 2 +
|
||||
(this.app.screen.width / 4) * 0.1 +
|
||||
Math.cos(this.curContainer.time * 0.006 * 0.75);
|
||||
|
||||
if (
|
||||
this.curContainer.alpha >= 1 &&
|
||||
this.lastContainer.size === 0 &&
|
||||
this.staticMode
|
||||
) {
|
||||
this.app.ticker.stop();
|
||||
}
|
||||
}
|
||||
};
|
||||
constructor(protected override canvas: HTMLCanvasElement) {
|
||||
super(canvas);
|
||||
this.app = new Application({
|
||||
view: canvas,
|
||||
resizeTo: this.canvas,
|
||||
powerPreference: "low-power",
|
||||
backgroundAlpha: 1,
|
||||
});
|
||||
this.rebuildFilters();
|
||||
this.app.ticker.maxFPS = 30;
|
||||
this.app.ticker.add(this.onTick);
|
||||
this.app.ticker.start();
|
||||
}
|
||||
|
||||
protected override onResize(width: number, height: number): void {
|
||||
super.onResize(width, height);
|
||||
this.app.resize();
|
||||
this.rebuildFilters();
|
||||
}
|
||||
|
||||
override setRenderScale(scale: number): void {
|
||||
super.setRenderScale(scale);
|
||||
this.rebuildFilters();
|
||||
}
|
||||
private rebuildFilters() {
|
||||
const minBorder = Math.min(this.canvas.width, this.canvas.height);
|
||||
const maxBorder = Math.max(this.canvas.width, this.canvas.height);
|
||||
const c0 = new ColorMatrixFilter();
|
||||
c0.saturate(1.2, false);
|
||||
const c1 = new ColorMatrixFilter();
|
||||
c1.brightness(0.6, false);
|
||||
const c2 = new ColorMatrixFilter();
|
||||
c2.contrast(0.3, true);
|
||||
for (const filter of this.app.stage.filters ?? []) {
|
||||
filter.destroy();
|
||||
}
|
||||
this.app.stage.filters = [];
|
||||
this.app.stage.filters.push(new BlurFilter(5, 1));
|
||||
this.app.stage.filters.push(new BlurFilter(10, 1));
|
||||
this.app.stage.filters.push(new BlurFilter(20, 2));
|
||||
this.app.stage.filters.push(new BlurFilter(40, 2));
|
||||
this.app.stage.filters.push(new BlurFilter(80, 2));
|
||||
if (minBorder > 768) this.app.stage.filters.push(new BlurFilter(160, 4));
|
||||
if (minBorder > 768 * 2)
|
||||
this.app.stage.filters.push(new BlurFilter(320, 4));
|
||||
|
||||
this.app.stage.filters.push(c0, c1, c2);
|
||||
this.app.stage.filters.push(new BlurFilter(5, 1));
|
||||
if (Math.random() > 0.5) {
|
||||
this.app.stage.filters.push(
|
||||
new BulgePinchFilter({
|
||||
radius: (maxBorder + minBorder) / 2,
|
||||
strength: 1,
|
||||
center: [0.25, 1],
|
||||
}),
|
||||
);
|
||||
this.app.stage.filters.push(
|
||||
new BulgePinchFilter({
|
||||
radius: (maxBorder + minBorder) / 2,
|
||||
strength: 1,
|
||||
center: [0.75, 0],
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
this.app.stage.filters.push(
|
||||
new BulgePinchFilter({
|
||||
radius: (maxBorder + minBorder) / 2,
|
||||
strength: 1,
|
||||
center: [0.75, 1],
|
||||
}),
|
||||
);
|
||||
this.app.stage.filters.push(
|
||||
new BulgePinchFilter({
|
||||
radius: (maxBorder + minBorder) / 2,
|
||||
strength: 1,
|
||||
center: [0.25, 0],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
override setStaticMode(enable = false): void {
|
||||
this.staticMode = enable;
|
||||
this.app.ticker.start();
|
||||
}
|
||||
|
||||
override setFPS(fps: number): void {
|
||||
this.app.ticker.maxFPS = fps;
|
||||
}
|
||||
|
||||
override pause(): void {
|
||||
this.app.ticker.stop();
|
||||
this.app.render();
|
||||
}
|
||||
|
||||
override resume(): void {
|
||||
this.app.ticker.start();
|
||||
}
|
||||
|
||||
override setLowFreqVolume(_volume: number): void {
|
||||
// NOOP
|
||||
}
|
||||
|
||||
override setHasLyric(_hasLyric: boolean): void {
|
||||
// NOOP
|
||||
}
|
||||
|
||||
override async setAlbum(
|
||||
albumSource?: string | HTMLImageElement | HTMLVideoElement,
|
||||
isVideo?: boolean,
|
||||
): Promise<void> {
|
||||
if (
|
||||
!albumSource ||
|
||||
(typeof albumSource === "string" && albumSource.trim().length === 0)
|
||||
)
|
||||
return;
|
||||
let res: HTMLImageElement | HTMLVideoElement | null = null;
|
||||
let remainRetryTimes = 5;
|
||||
let tex: Texture | null = null;
|
||||
while (!tex?.baseTexture?.resource?.valid && remainRetryTimes > 0) {
|
||||
try {
|
||||
if (typeof albumSource === "string") {
|
||||
res = await loadResourceFromUrl(albumSource, isVideo);
|
||||
} else {
|
||||
res = await loadResourceFromElement(albumSource);
|
||||
}
|
||||
tex = Texture.from(res, {
|
||||
resourceOptions: {
|
||||
autoLoad: false,
|
||||
},
|
||||
});
|
||||
await tex.baseTexture.resource.load();
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`failed on loading album image, retrying (${remainRetryTimes})`,
|
||||
albumSource,
|
||||
error,
|
||||
);
|
||||
tex = null;
|
||||
remainRetryTimes--;
|
||||
}
|
||||
}
|
||||
if (!tex) return;
|
||||
const container = new TimedContainer();
|
||||
const s1 = new Sprite(tex);
|
||||
const s2 = new Sprite(tex);
|
||||
const s3 = new Sprite(tex);
|
||||
const s4 = new Sprite(tex);
|
||||
s1.anchor.set(0.5, 0.5);
|
||||
s2.anchor.set(0.5, 0.5);
|
||||
s3.anchor.set(0.5, 0.5);
|
||||
s4.anchor.set(0.5, 0.5);
|
||||
s1.rotation = Math.random() * Math.PI * 2;
|
||||
s2.rotation = Math.random() * Math.PI * 2;
|
||||
s3.rotation = Math.random() * Math.PI * 2;
|
||||
s4.rotation = Math.random() * Math.PI * 2;
|
||||
container.addChild(s1, s2, s3, s4);
|
||||
if (this.curContainer) this.lastContainer.add(this.curContainer);
|
||||
this.curContainer = container;
|
||||
this.app.stage.addChild(container);
|
||||
this.curContainer.alpha = 0;
|
||||
this.app.ticker.start();
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
super.dispose();
|
||||
this.app.ticker.remove(this.onTick);
|
||||
this.app.destroy(true);
|
||||
}
|
||||
|
||||
override getElement(): HTMLElement {
|
||||
return this.canvas;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
#version 300 es
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D src;
|
||||
in vec2 f_v_coord;
|
||||
out vec4 fragColor;
|
||||
|
||||
void main() {
|
||||
vec2 coord = f_v_coord;
|
||||
fragColor = texture(src, coord);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
#version 300 es
|
||||
precision highp float;
|
||||
|
||||
in vec2 v_coord;
|
||||
out vec2 f_v_coord;
|
||||
|
||||
void main() {
|
||||
gl_Position = vec4(v_coord, 0.0f, 1.0f);
|
||||
f_v_coord = (vec2(v_coord.x, v_coord.y) + vec2(1.0f, 1.0f)) / 2.0f;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#version 300 es
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D src;
|
||||
uniform float lerp;
|
||||
uniform float scale;
|
||||
in vec2 f_v_coord;
|
||||
out vec4 fragColor;
|
||||
|
||||
void main() {
|
||||
vec2 tex_coord = f_v_coord;
|
||||
if(scale < 1.0f) {
|
||||
tex_coord /= scale;
|
||||
}
|
||||
vec4 srcColor = texture(src, tex_coord);
|
||||
fragColor = mix(vec4(srcColor.xyz, 0.0f), srcColor, lerp);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
#version 300 es
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D src;
|
||||
in vec2 f_v_coord;
|
||||
out vec4 fragColor;
|
||||
|
||||
/* Gradient noise from Jorge Jimenez's presentation: */
|
||||
/* http://www.iryoku.com/next-generation-post-processing-in-call-of-duty-advanced-warfare */
|
||||
float gradientNoise(in vec2 uv) {
|
||||
return fract(52.9829189f * fract(dot(uv, vec2(0.06711056f, 0.00583715f))));
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 tex_coord = f_v_coord;
|
||||
float dither = (1.0f / 255.0f) * gradientNoise(gl_FragCoord.xy) - (0.5f / 255.0f);
|
||||
vec4 color = texture(src, tex_coord);
|
||||
color += dither;
|
||||
fragColor = color;
|
||||
}
|
||||
42
amll-local/packages/core/src/bg-render/shaders/taa.frag.glsl
Normal file
42
amll-local/packages/core/src/bg-render/shaders/taa.frag.glsl
Normal file
@@ -0,0 +1,42 @@
|
||||
#version 300 es
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D src;
|
||||
uniform sampler2D historyFrame0;
|
||||
uniform vec2 texSize;
|
||||
in vec2 f_v_coord;
|
||||
out vec4 fragColor;
|
||||
|
||||
void main() {
|
||||
fragColor = texture(src, f_v_coord);
|
||||
return;
|
||||
// get the neighborhood min / max from this frame's render
|
||||
vec3 center = texture(src, f_v_coord).rgb;
|
||||
vec3 minColor = center;
|
||||
vec3 maxColor = center;
|
||||
for (int iy = -1; iy <= 1; ++iy)
|
||||
{
|
||||
for (int ix = -1; ix <= 1; ++ix)
|
||||
{
|
||||
if (ix == 0 && iy == 0)
|
||||
continue;
|
||||
|
||||
|
||||
vec2 offsetUV = ((f_v_coord * texSize + vec2(ix, iy))) / texSize;
|
||||
vec3 color = texture(src, offsetUV).rgb;
|
||||
minColor = min(minColor, color);
|
||||
maxColor = max(maxColor, color);
|
||||
}
|
||||
}
|
||||
|
||||
// get last frame's pixel and clamp it to the neighborhood of this frame
|
||||
vec3 old = texture(historyFrame0, f_v_coord).rgb;
|
||||
old = max(minColor, old);
|
||||
old = min(maxColor, old);
|
||||
|
||||
// interpolate from the clamped old color to the new color.
|
||||
// Reject all history when the mouse moves.
|
||||
float lerpAmount = 0.1f;
|
||||
vec3 pixelColor = mix(old, center, lerpAmount);
|
||||
fragColor = vec4(pixelColor, 1.0f);
|
||||
}
|
||||
5
amll-local/packages/core/src/index.ts
Normal file
5
amll-local/packages/core/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference path="./types.d.ts" />
|
||||
export * from "./bg-render/index.ts";
|
||||
export type * from "./interfaces.ts";
|
||||
export * from "./lyric-player/index.ts";
|
||||
export type * as spring from "./utils/spring.ts";
|
||||
111
amll-local/packages/core/src/interfaces.ts
Normal file
111
amll-local/packages/core/src/interfaces.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 拥有一个 HTML 元素的接口
|
||||
*
|
||||
* 可以通过 `getElement` 获取这个类所对应的 HTML 元素实例
|
||||
*/
|
||||
export interface HasElement {
|
||||
/** 获取这个类所对应的 HTML 元素实例 */
|
||||
getElement(): HTMLElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* 实现了这个接口的东西需要在使用完毕后
|
||||
*
|
||||
* 手动调用 `dispose` 函数来销毁清除占用资源
|
||||
*
|
||||
* 以免产生泄露
|
||||
*/
|
||||
export interface Disposable {
|
||||
/**
|
||||
* 销毁实现了该接口的对象实例,释放占用的资源
|
||||
*
|
||||
* 一般情况下,调用本函数后就不可以再调用对象的任何函数了
|
||||
*/
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/** 一个歌词单词 */
|
||||
export interface LyricWordBase {
|
||||
/** 单词的起始时间,单位为毫秒 */
|
||||
startTime: number;
|
||||
/** 单词的结束时间,单位为毫秒 */
|
||||
endTime: number;
|
||||
/** 单词内容 */
|
||||
word: string;
|
||||
}
|
||||
|
||||
export interface LyricWord extends LyricWordBase {
|
||||
/** 单词的音译内容 */
|
||||
romanWord?: string;
|
||||
/** 单词内容是否包含冒犯性的不雅用语 */
|
||||
obscene?: boolean;
|
||||
/** 单词的注音内容 */
|
||||
ruby?: LyricWordBase[];
|
||||
}
|
||||
|
||||
/** 一行歌词,存储多个单词 */
|
||||
export interface LyricLine {
|
||||
/**
|
||||
* 该行的所有单词
|
||||
* 如果是 LyRiC 等只能表达一行歌词的格式,这里就只会有一个单词且通常其始末时间和本结构的 `startTime` 和 `endTime` 相同
|
||||
*/
|
||||
words: LyricWord[];
|
||||
/** 该行的翻译歌词,将会显示在主歌词行的下方 */
|
||||
translatedLyric: string;
|
||||
/** 该行的音译歌词,将会显示在翻译歌词行的下方 */
|
||||
romanLyric: string;
|
||||
/** 句子的起始时间,单位为毫秒 */
|
||||
startTime: number;
|
||||
/** 句子的结束时间,单位为毫秒 */
|
||||
endTime: number;
|
||||
/** 该行是否为背景歌词行,当该行歌词的上一句非背景歌词被激活时,这行歌词将会显示出来,注意每个非背景歌词下方只能拥有一个背景歌词 */
|
||||
isBG: boolean;
|
||||
/** 该行是否为对唱歌词行(即歌词行靠右对齐) */
|
||||
isDuet: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 优化歌词行的配置选项
|
||||
*/
|
||||
export interface OptimizeLyricOptions {
|
||||
/**
|
||||
* 规范化歌词中的空格
|
||||
*
|
||||
* 将多个连续空格替换为一个空格
|
||||
* @default true
|
||||
*/
|
||||
normalizeSpaces?: boolean;
|
||||
/**
|
||||
* 是否将行级时间戳强行设为字级时间戳
|
||||
* @default true
|
||||
*/
|
||||
resetLineTimestamps?: boolean;
|
||||
/**
|
||||
* 把多行背景人声转换为单行背景人声 + 主歌词行的形式
|
||||
* @default true
|
||||
*/
|
||||
convertExcessiveBackgroundLines?: boolean;
|
||||
/**
|
||||
* 是否同步主歌词与背景人声的时间
|
||||
* @default true
|
||||
*/
|
||||
syncMainAndBackgroundLines?: boolean;
|
||||
/**
|
||||
* 清洗非刻意的重叠,以免不必要的多行高亮效果
|
||||
*
|
||||
* 如果两行时间轴有重叠的歌词满足下列条件之一:
|
||||
* * 重叠小于 100ms
|
||||
* * 重叠时长不足下一行时长的 10%
|
||||
*
|
||||
* 则截断上一行歌词的结束时间为下一行歌词的开始时间
|
||||
* @default true
|
||||
*/
|
||||
cleanUnintentionalOverlaps?: boolean;
|
||||
/**
|
||||
* 尝试让歌词提前最多 1 秒开始
|
||||
*
|
||||
* 有重叠则尝试最多提前 400ms 或上一行时长的 30%
|
||||
* @default true
|
||||
*/
|
||||
tryAdvanceStartTime?: boolean;
|
||||
}
|
||||
128
amll-local/packages/core/src/lyric-player/base/bottom-line.ts
Normal file
128
amll-local/packages/core/src/lyric-player/base/bottom-line.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { Disposable, HasElement } from "#interfaces";
|
||||
import styles from "#styles/lyric-player.module.css";
|
||||
import { measure } from "#utils/schedule.ts";
|
||||
import { Spring } from "#utils/spring.ts";
|
||||
import type { LyricPlayerBase } from ".";
|
||||
|
||||
interface LineTransforms {
|
||||
posX: Spring;
|
||||
posY: Spring;
|
||||
}
|
||||
|
||||
export class BottomLineEl implements HasElement, Disposable {
|
||||
private element: HTMLElement = document.createElement("div");
|
||||
private left = 0;
|
||||
private top = 0;
|
||||
private delay = 0;
|
||||
// 由 LyricPlayer 来设置
|
||||
lineSize: [number, number] = [0, 0];
|
||||
readonly lineTransforms: LineTransforms = {
|
||||
posX: new Spring(0),
|
||||
posY: new Spring(0),
|
||||
};
|
||||
private isFocused = false;
|
||||
private blur = 0;
|
||||
constructor(private lyricPlayer: LyricPlayerBase) {
|
||||
this.element.setAttribute(
|
||||
"class",
|
||||
`${styles.lyricLine} ${styles.bottomLine}`,
|
||||
);
|
||||
this.element.dataset.bottomLine = "true";
|
||||
this.rebuildStyle();
|
||||
}
|
||||
async measureSize(): Promise<[number, number]> {
|
||||
const size: [number, number] = await measure(() => [
|
||||
this.element.clientWidth,
|
||||
this.element.clientHeight,
|
||||
]);
|
||||
return size;
|
||||
}
|
||||
private lastStyle = "";
|
||||
show(): void {
|
||||
this.rebuildStyle();
|
||||
}
|
||||
hide(): void {
|
||||
this.rebuildStyle();
|
||||
}
|
||||
setFocused(focused: boolean): void {
|
||||
if (this.isFocused !== focused) {
|
||||
this.isFocused = focused;
|
||||
if (focused) {
|
||||
this.element.dataset.focused = "true";
|
||||
} else {
|
||||
delete this.element.dataset.focused;
|
||||
}
|
||||
}
|
||||
}
|
||||
private rebuildStyle() {
|
||||
let style = `transform:translate(${this.lineTransforms.posX
|
||||
.getCurrentPosition()
|
||||
.toFixed(2)}px,${this.lineTransforms.posY
|
||||
.getCurrentPosition()
|
||||
.toFixed(2)}px);`;
|
||||
|
||||
if (!this.lyricPlayer.getEnableSpring() && this.isInSight) {
|
||||
style += `transition-delay:${this.delay}ms;`;
|
||||
}
|
||||
|
||||
style += `filter:blur(${Math.min(5, this.blur)}px);`;
|
||||
|
||||
if (style !== this.lastStyle) {
|
||||
this.lastStyle = style;
|
||||
this.element.setAttribute("style", style);
|
||||
}
|
||||
}
|
||||
getElement(): HTMLElement {
|
||||
return this.element;
|
||||
}
|
||||
setTransform(
|
||||
left: number = this.left,
|
||||
top: number = this.top,
|
||||
blur = 0,
|
||||
force = false,
|
||||
delay = 0,
|
||||
): void {
|
||||
this.left = left;
|
||||
this.top = top;
|
||||
this.delay = (delay * 1000) | 0;
|
||||
|
||||
if (force || !this.lyricPlayer.getEnableSpring()) {
|
||||
this.blur = Math.min(32, blur);
|
||||
if (force) this.element.classList.add(styles.tmpDisableTransition);
|
||||
this.lineTransforms.posX.setPosition(left);
|
||||
this.lineTransforms.posY.setPosition(top);
|
||||
if (!this.lyricPlayer.getEnableSpring()) this.show();
|
||||
else this.rebuildStyle();
|
||||
if (force)
|
||||
requestAnimationFrame(() => {
|
||||
this.element.classList.remove(styles.tmpDisableTransition);
|
||||
});
|
||||
} else {
|
||||
this.blur = Math.min(5, blur);
|
||||
this.lineTransforms.posX.setTargetPosition(left, delay);
|
||||
this.lineTransforms.posY.setTargetPosition(top, delay);
|
||||
}
|
||||
}
|
||||
update(delta = 0): void {
|
||||
if (!this.lyricPlayer.getEnableSpring()) return;
|
||||
this.lineTransforms.posX.update(delta);
|
||||
this.lineTransforms.posY.update(delta);
|
||||
if (this.isInSight) {
|
||||
this.show();
|
||||
} else {
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
get isInSight(): boolean {
|
||||
const l = this.lineTransforms.posX.getCurrentPosition();
|
||||
const t = this.lineTransforms.posY.getCurrentPosition();
|
||||
const r = l + this.lineSize[0];
|
||||
const b = t + this.lineSize[1];
|
||||
const pr = this.lyricPlayer.size[0];
|
||||
const pb = this.lyricPlayer.size[1];
|
||||
return !(l > pr || t > pb || r < 0 || b < 0);
|
||||
}
|
||||
dispose(): void {
|
||||
this.element.remove();
|
||||
}
|
||||
}
|
||||
39
amll-local/packages/core/src/lyric-player/base/consts.ts
Normal file
39
amll-local/packages/core/src/lyric-player/base/consts.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
type ValueOf<T extends Record<PropertyKey, unknown>> = T[keyof T];
|
||||
|
||||
/** 歌词中不雅用语的掩码模式 */
|
||||
export const MaskObsceneWordsMode = {
|
||||
/** 禁用任何不雅用语掩码 */
|
||||
Disabled: "",
|
||||
/** 完全掩码所有不雅用语 */
|
||||
FullMask: "full-mask",
|
||||
/** 保留首尾字符,屏蔽中间字符 */
|
||||
PartialMask: "partial-mask",
|
||||
} as const;
|
||||
|
||||
/** 歌词中不雅用语的掩码模式枚举类型,见 {@link MaskObsceneWordsMode} */
|
||||
export type MaskObsceneWordsMode = ValueOf<typeof MaskObsceneWordsMode>;
|
||||
|
||||
/**
|
||||
* 歌词行的渲染模式
|
||||
* @internal
|
||||
*/
|
||||
export const LyricLineRenderMode = {
|
||||
SOLID: 0,
|
||||
GRADIENT: 1,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 歌词行的渲染模式枚举类型,见 {@link LyricLineRenderMode}
|
||||
* @internal
|
||||
*/
|
||||
export type LyricLineRenderMode = ValueOf<typeof LyricLineRenderMode>;
|
||||
|
||||
/** 布局对齐锚点 */
|
||||
export const LayoutAlignAnchor = {
|
||||
Top: "top",
|
||||
Center: "center",
|
||||
Bottom: "bottom",
|
||||
} as const;
|
||||
|
||||
/** 布局对齐锚点枚举类型,见 {@link LayoutAlignAnchor} */
|
||||
export type LayoutAlignAnchor = ValueOf<typeof LayoutAlignAnchor>;
|
||||
142
amll-local/packages/core/src/lyric-player/base/group.ts
Normal file
142
amll-local/packages/core/src/lyric-player/base/group.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import type { Disposable } from "#interfaces";
|
||||
import { Spring } from "#utils/spring.ts";
|
||||
import { LyricLineRenderMode } from "./consts.ts";
|
||||
import type { LyricLineBase } from "./line.ts";
|
||||
|
||||
export interface LyricPlayerFlags {
|
||||
getEnableSpring(): boolean;
|
||||
getEnableScale(): boolean;
|
||||
getIsPlaying(): boolean;
|
||||
getAlwaysPostpositionBackground(): boolean;
|
||||
}
|
||||
|
||||
export abstract class LyricLineGroupBase<
|
||||
T extends LyricLineBase = LyricLineBase,
|
||||
> implements Disposable
|
||||
{
|
||||
protected abstract readonly lyricPlayer: LyricPlayerFlags;
|
||||
|
||||
public posY: Spring = new Spring(0);
|
||||
public bgSlideY: Spring = new Spring(-80);
|
||||
public top = 0;
|
||||
public delay = 0;
|
||||
|
||||
public isActive = false;
|
||||
public opacity = 1;
|
||||
public blur = 0;
|
||||
|
||||
public isBgFirst = false;
|
||||
|
||||
constructor(
|
||||
public mainLine: T,
|
||||
public bgLine?: T | undefined,
|
||||
) {}
|
||||
|
||||
get startTime(): number {
|
||||
// 优化歌词时 `syncMainAndBackgroundLines` 已经把时间同步好了,直接读取主歌词的即可
|
||||
// 要是用户关掉了这个优化,我们认为在这种情况下主歌词和背景人声显示不同步是符合用户预期的
|
||||
return this.mainLine.getLine().startTime;
|
||||
}
|
||||
|
||||
get endTime(): number {
|
||||
return this.mainLine.getLine().endTime;
|
||||
}
|
||||
|
||||
onLineSizeChange(size: [number, number]): void {
|
||||
this.mainLine.onLineSizeChange(size);
|
||||
this.bgLine?.onLineSizeChange(size);
|
||||
}
|
||||
|
||||
setTransform(
|
||||
top: number,
|
||||
force: boolean,
|
||||
delay: number,
|
||||
isActive: boolean,
|
||||
opacity: number,
|
||||
blur: number,
|
||||
): void {
|
||||
this.top = top;
|
||||
this.delay = delay;
|
||||
this.isActive = isActive;
|
||||
this.opacity = opacity;
|
||||
this.blur = blur;
|
||||
|
||||
this.setLineTransformations(force, delay);
|
||||
|
||||
const enableSpring = this.lyricPlayer.getEnableSpring();
|
||||
const alwaysPostposition =
|
||||
this.lyricPlayer.getAlwaysPostpositionBackground();
|
||||
const shouldBgFirst = alwaysPostposition ? false : this.isBgFirst;
|
||||
const hiddenSlideY = shouldBgFirst ? 80 : -80;
|
||||
|
||||
const isPlaying = this.lyricPlayer.getIsPlaying();
|
||||
|
||||
const targetBgSlideY = isActive || !isPlaying ? 0 : hiddenSlideY;
|
||||
|
||||
if (force || !enableSpring) {
|
||||
this.posY.setPosition(top);
|
||||
this.bgSlideY.setPosition(targetBgSlideY);
|
||||
this.renderStyles();
|
||||
} else {
|
||||
this.posY.setTargetPosition(top, delay);
|
||||
this.bgSlideY.setTargetPosition(targetBgSlideY, delay);
|
||||
}
|
||||
}
|
||||
|
||||
private setLineTransformations(force: boolean, delay: number) {
|
||||
const enableScale = this.lyricPlayer.getEnableScale();
|
||||
const isPlaying = this.lyricPlayer.getIsPlaying();
|
||||
|
||||
const renderMode = this.isActive
|
||||
? LyricLineRenderMode.GRADIENT
|
||||
: LyricLineRenderMode.SOLID;
|
||||
|
||||
const SCALE_ASPECT = enableScale ? 97 : 100;
|
||||
let mainScale = 100;
|
||||
if (!this.isActive && isPlaying) {
|
||||
mainScale = SCALE_ASPECT;
|
||||
}
|
||||
this.mainLine.setTransform(mainScale, 1, 0, force, delay, renderMode);
|
||||
|
||||
let bgScale = 100;
|
||||
if (!this.isActive && isPlaying) {
|
||||
bgScale = 75;
|
||||
}
|
||||
this.bgLine?.setTransform(bgScale, 1, 0, force, delay, renderMode);
|
||||
}
|
||||
|
||||
protected abstract renderStyles(): void;
|
||||
|
||||
abstract get isInSight(): boolean;
|
||||
|
||||
update(delta: number): void {
|
||||
if (this.lyricPlayer.getEnableSpring()) {
|
||||
this.posY.update(delta);
|
||||
this.bgSlideY.update(delta);
|
||||
this.renderStyles();
|
||||
}
|
||||
|
||||
this.mainLine.update(delta);
|
||||
this.bgLine?.update(delta);
|
||||
}
|
||||
|
||||
rebuildAllLines(): void {
|
||||
this.mainLine.rebuildElement();
|
||||
this.bgLine?.rebuildElement();
|
||||
}
|
||||
|
||||
enable(time?: number, shouldPlay?: boolean): void {
|
||||
this.mainLine.enable(time, shouldPlay);
|
||||
this.bgLine?.enable(time, shouldPlay);
|
||||
}
|
||||
|
||||
disable(): void {
|
||||
this.mainLine.disable();
|
||||
this.bgLine?.disable();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.mainLine.dispose();
|
||||
this.bgLine?.dispose();
|
||||
}
|
||||
}
|
||||
854
amll-local/packages/core/src/lyric-player/base/index.ts
Normal file
854
amll-local/packages/core/src/lyric-player/base/index.ts
Normal file
@@ -0,0 +1,854 @@
|
||||
import structuredClone from "@ungap/structured-clone";
|
||||
import type {
|
||||
Disposable,
|
||||
HasElement,
|
||||
LyricLine,
|
||||
LyricWord,
|
||||
OptimizeLyricOptions,
|
||||
} from "#interfaces";
|
||||
import styles from "#styles/lyric-player.module.css";
|
||||
import { clampPositive } from "#utils/clamp.ts";
|
||||
import { optimizeLyricLines } from "#utils/optimize-lyric.ts";
|
||||
import type { SpringParams } from "#utils/spring.ts";
|
||||
import { InterludeDots } from "../dom/interlude-dots.ts";
|
||||
import { BottomLineEl } from "./bottom-line.ts";
|
||||
import { LayoutAlignAnchor, MaskObsceneWordsMode } from "./consts.ts";
|
||||
import type { LyricLineGroupBase } from "./group.ts";
|
||||
import {
|
||||
computeCurrentInterlude,
|
||||
computeGroupPresentation,
|
||||
computeLineBlur,
|
||||
computeLinePosYSpringParams,
|
||||
type PlayerLayoutState,
|
||||
} from "./layout.ts";
|
||||
import type { LyricLineBase } from "./line.ts";
|
||||
import {
|
||||
attachPlayerScrollHandlers,
|
||||
type PlayerScrollState,
|
||||
resetPlayerScrollState,
|
||||
} from "./scroll.ts";
|
||||
import {
|
||||
commitPlayerTimeState,
|
||||
computePlayerTimeState,
|
||||
type PlayerTimelineState,
|
||||
} from "./timeline.ts";
|
||||
|
||||
export type { PlayerLayoutState } from "./layout.ts";
|
||||
export type { LyricLineBase } from "./line.ts";
|
||||
export type { PlayerScrollState } from "./scroll.ts";
|
||||
export type { PlayerTimelineState } from "./timeline.ts";
|
||||
|
||||
/**
|
||||
* 歌词播放器的基类,已经包含了有关歌词操作和排版的功能,
|
||||
* 子类需要为其实现对应的显示展示操作
|
||||
*/
|
||||
export abstract class LyricPlayerBase
|
||||
extends EventTarget
|
||||
implements HasElement, Disposable
|
||||
{
|
||||
protected element: HTMLElement = document.createElement("div");
|
||||
abstract get baseFontSize(): number;
|
||||
|
||||
/** 播放时间线状态 */
|
||||
protected timelineState: PlayerTimelineState = {
|
||||
currentTime: 0,
|
||||
lastCurrentTime: 0,
|
||||
hotGroups: new Set(),
|
||||
bufferedGroups: new Set(),
|
||||
scrollToIndex: 0,
|
||||
isSeeking: false,
|
||||
isPlaying: true,
|
||||
initialLayoutFinished: false,
|
||||
};
|
||||
/** @internal */
|
||||
lyricGroupElementMap: WeakMap<Element, LyricLineGroupBase> = new WeakMap();
|
||||
protected currentLyricLines: LyricLine[] = [];
|
||||
protected processedLines: LyricLine[] = [];
|
||||
protected lyricLinesIndexes: WeakMap<LyricLineBase, number> = new WeakMap();
|
||||
protected isNonDynamic = false;
|
||||
protected hasDuetLine = false;
|
||||
protected disableSpring = false;
|
||||
protected layoutState: PlayerLayoutState = {
|
||||
interludeDotsSize: [0, 0],
|
||||
targetAlignIndex: 0,
|
||||
lastInterludeState: false,
|
||||
alignAnchor: LayoutAlignAnchor.Center,
|
||||
alignPosition: 0.35,
|
||||
overscanPx: 300,
|
||||
};
|
||||
protected interludeDots: InterludeDots = new InterludeDots();
|
||||
protected bottomLine: BottomLineEl = new BottomLineEl(this);
|
||||
protected enableBlur = true;
|
||||
protected enableScale = true;
|
||||
protected maskObsceneWords: MaskObsceneWordsMode =
|
||||
MaskObsceneWordsMode.Disabled;
|
||||
protected maskObsceneWordChar = "*";
|
||||
protected hidePassedLines = false;
|
||||
protected scrollState: PlayerScrollState = {
|
||||
scrollBoundary: { minOffset: 0, maxOffset: 0 },
|
||||
scrollOffset: 0,
|
||||
allowScroll: true,
|
||||
isScrolled: false,
|
||||
isUserScrolling: false,
|
||||
resetToken: 0,
|
||||
};
|
||||
public currentLyricGroups: LyricLineGroupBase[] = [];
|
||||
lyricGroupSize: WeakMap<LyricLineGroupBase, [number, number]> = new WeakMap();
|
||||
readonly size: [number, number] = [0, 0];
|
||||
protected isPageVisible = true;
|
||||
protected optimizeOptions: OptimizeLyricOptions = {};
|
||||
|
||||
/** 是否强制让背景人声行始终后置(即始终在主歌词下方显示,不前置背景人声) */
|
||||
protected alwaysPostpositionBackground = false;
|
||||
|
||||
protected posXSpringParams: Partial<SpringParams> = {
|
||||
mass: 1,
|
||||
damping: 10,
|
||||
stiffness: 100,
|
||||
};
|
||||
protected posYSpringParams: Partial<SpringParams> = {
|
||||
mass: 0.9,
|
||||
damping: 15,
|
||||
stiffness: 90,
|
||||
};
|
||||
protected scaleSpringParams: Partial<SpringParams> = {
|
||||
mass: 2,
|
||||
damping: 25,
|
||||
stiffness: 100,
|
||||
};
|
||||
protected scaleForBGSpringParams: Partial<SpringParams> = {
|
||||
mass: 1,
|
||||
damping: 20,
|
||||
stiffness: 50,
|
||||
};
|
||||
private onPageShow = () => {
|
||||
this.isPageVisible = true;
|
||||
this.setCurrentTime(this.timelineState.currentTime, true);
|
||||
};
|
||||
private onPageHide = () => {
|
||||
this.isPageVisible = false;
|
||||
};
|
||||
private scrolledHandler: ReturnType<typeof setTimeout> | undefined;
|
||||
/** @internal */
|
||||
resizeObserver: ResizeObserver = new ResizeObserver(((entries) => {
|
||||
let shouldRelayout = false;
|
||||
let shouldRebuildPlayerStyle = false;
|
||||
for (const entry of entries) {
|
||||
if (entry.target === this.element) {
|
||||
const rect = entry.contentRect;
|
||||
this.size[0] = rect.width;
|
||||
this.size[1] = rect.height;
|
||||
shouldRebuildPlayerStyle = true;
|
||||
} else if (entry.target === this.interludeDots.getElement()) {
|
||||
this.layoutState.interludeDotsSize[0] = entry.target.clientWidth;
|
||||
this.layoutState.interludeDotsSize[1] = entry.target.clientHeight;
|
||||
shouldRelayout = true;
|
||||
} else if (entry.target === this.bottomLine.getElement()) {
|
||||
const newSize: [number, number] = [
|
||||
entry.target.clientWidth,
|
||||
entry.target.clientHeight,
|
||||
];
|
||||
const oldSize: [number, number] = this.bottomLine.lineSize;
|
||||
|
||||
if (newSize[0] !== oldSize[0] || newSize[1] !== oldSize[1]) {
|
||||
this.bottomLine.lineSize = newSize;
|
||||
shouldRelayout = true;
|
||||
}
|
||||
} else {
|
||||
const groupObj = this.lyricGroupElementMap.get(entry.target);
|
||||
if (groupObj) {
|
||||
const newSize: [number, number] = [
|
||||
entry.target.clientWidth,
|
||||
entry.target.clientHeight,
|
||||
];
|
||||
|
||||
const oldSize: [number, number] = this.lyricGroupSize.get(
|
||||
groupObj,
|
||||
) ?? [0, 0];
|
||||
|
||||
if (newSize[0] !== oldSize[0] || newSize[1] !== oldSize[1]) {
|
||||
this.lyricGroupSize.set(groupObj, newSize);
|
||||
groupObj.onLineSizeChange(newSize);
|
||||
shouldRelayout = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (shouldRelayout) {
|
||||
this.calcLayout(true);
|
||||
}
|
||||
if (shouldRebuildPlayerStyle) {
|
||||
this.onResize();
|
||||
}
|
||||
}) as ResizeObserverCallback);
|
||||
protected wordFadeWidth = 0.5;
|
||||
|
||||
constructor(element?: HTMLElement) {
|
||||
super();
|
||||
if (element) this.element = element;
|
||||
this.element.classList.add("amll-lyric-player");
|
||||
|
||||
this.resizeObserver.observe(this.element);
|
||||
this.resizeObserver.observe(this.interludeDots.getElement());
|
||||
|
||||
this.element.appendChild(this.interludeDots.getElement());
|
||||
this.element.appendChild(this.bottomLine.getElement());
|
||||
this.interludeDots.setTransform(0, 200);
|
||||
|
||||
window.addEventListener("pageshow", this.onPageShow);
|
||||
window.addEventListener("pagehide", this.onPageHide);
|
||||
attachPlayerScrollHandlers(this.element, this.scrollState, {
|
||||
onBeginScroll: () => this.beginScrollHandler(),
|
||||
onEndScroll: () => this.endScrollHandler(),
|
||||
onLayout: (sync, force) => this.calcLayout(sync, force),
|
||||
containsTarget: (target) => this.element.contains(target),
|
||||
clickTarget: (target) => target.click(),
|
||||
});
|
||||
}
|
||||
|
||||
private beginScrollHandler() {
|
||||
const allowed = this.scrollState.allowScroll;
|
||||
if (allowed) {
|
||||
this.scrollState.isScrolled = true;
|
||||
clearTimeout(this.scrolledHandler);
|
||||
this.scrolledHandler = setTimeout(() => {
|
||||
resetPlayerScrollState(this.scrollState);
|
||||
}, 5000);
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
private endScrollHandler() {}
|
||||
|
||||
/**
|
||||
* 设置文字动画的渐变宽度,单位以歌词行的主文字字体大小的倍数为单位,默认为 0.5,即一个全角字符的一半宽度
|
||||
*
|
||||
* 如果要模拟 Apple Music for Android 的效果,可以设置为 1
|
||||
*
|
||||
* 如果要模拟 Apple Music for iPad 的效果,可以设置为 0.5
|
||||
*
|
||||
* 如果想要近乎禁用渐变效果,可以设置成非常接近 0 的小数(例如 `0.0001` ),但是**不可以为 0**
|
||||
*
|
||||
* @param value 需要设置的渐变宽度,单位以歌词行的主文字字体大小的倍数为单位,默认为 0.5
|
||||
*/
|
||||
setWordFadeWidth(value = 0.5): void {
|
||||
this.wordFadeWidth = Math.max(0.0001, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否启用歌词行缩放效果,默认启用
|
||||
*
|
||||
* 如果启用,非选中的歌词行会轻微缩小以凸显当前播放歌词行效果
|
||||
*
|
||||
* 此效果对性能影响微乎其微,推荐启用
|
||||
* @param enable 是否启用歌词行缩放效果
|
||||
*/
|
||||
setEnableScale(enable = true): void {
|
||||
this.enableScale = enable;
|
||||
this.calcLayout();
|
||||
}
|
||||
/**
|
||||
* 获取当前是否启用了歌词行缩放效果
|
||||
* @returns 是否启用歌词行缩放效果
|
||||
*/
|
||||
getEnableScale(): boolean {
|
||||
return this.enableScale;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前文字动画的渐变宽度,单位以歌词行的主文字字体大小的倍数为单位
|
||||
* @returns 当前文字动画的渐变宽度,单位以歌词行的主文字字体大小的倍数为单位
|
||||
*/
|
||||
getWordFadeWidth(): number {
|
||||
return this.wordFadeWidth;
|
||||
}
|
||||
|
||||
setIsSeeking(isSeeking: boolean): void {
|
||||
this.timelineState.isSeeking = isSeeking;
|
||||
}
|
||||
/**
|
||||
* 设置是否隐藏已经播放过的歌词行,默认不隐藏
|
||||
* @param hide 是否隐藏已经播放过的歌词行,默认不隐藏
|
||||
*/
|
||||
setHidePassedLines(hide: boolean): void {
|
||||
this.hidePassedLines = hide;
|
||||
this.calcLayout();
|
||||
}
|
||||
/**
|
||||
* 设置是否启用歌词行的模糊效果
|
||||
* @param enable 是否启用
|
||||
*/
|
||||
setEnableBlur(enable: boolean): void {
|
||||
if (this.enableBlur === enable) return;
|
||||
this.enableBlur = enable;
|
||||
this.calcLayout();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置歌词中不雅用语的掩码模式
|
||||
* @param mode 掩码模式
|
||||
* @see {@link MaskObsceneWordsMode}
|
||||
*/
|
||||
setMaskObsceneWords(mode: MaskObsceneWordsMode): void {
|
||||
if (this.maskObsceneWords === mode) return;
|
||||
this.maskObsceneWords = mode;
|
||||
this.rebuildLyricLines();
|
||||
this.calcLayout();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置不雅用语掩码使用的字符,默认为 `*`
|
||||
* @param char 单个字符,用于替换不雅用语中的字符
|
||||
*/
|
||||
setMaskObsceneWordChar(char: string): void {
|
||||
const c = char.charAt(0) || "*";
|
||||
if (this.maskObsceneWordChar === c) return;
|
||||
this.maskObsceneWordChar = c;
|
||||
if (this.maskObsceneWords !== MaskObsceneWordsMode.Disabled) {
|
||||
this.rebuildLyricLines();
|
||||
this.calcLayout();
|
||||
}
|
||||
}
|
||||
|
||||
rebuildLyricLines(): void {
|
||||
for (const group of this.currentLyricGroups) {
|
||||
group.rebuildAllLines();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 根据当前配置处理不雅用语单词
|
||||
* @param word 单词对象
|
||||
* @internal
|
||||
*/
|
||||
processObsceneWord(word: LyricWord): string {
|
||||
const text = word.word;
|
||||
|
||||
if (
|
||||
!word.obscene ||
|
||||
this.maskObsceneWords === MaskObsceneWordsMode.Disabled
|
||||
) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const maskChar = this.maskObsceneWordChar;
|
||||
|
||||
if (this.maskObsceneWords === MaskObsceneWordsMode.FullMask) {
|
||||
return text.replace(/\S/g, maskChar);
|
||||
}
|
||||
|
||||
if (this.maskObsceneWords === MaskObsceneWordsMode.PartialMask) {
|
||||
const trimmed = text.trim();
|
||||
|
||||
if (trimmed.length <= 2) {
|
||||
return text.replace(/\S/g, maskChar);
|
||||
}
|
||||
|
||||
const startPos = text.indexOf(trimmed);
|
||||
const endPos = startPos + trimmed.length - 1;
|
||||
|
||||
return (
|
||||
text.slice(0, startPos + 1) +
|
||||
text.slice(startPos + 1, endPos).replace(/\S/g, maskChar) +
|
||||
text.slice(endPos)
|
||||
);
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
/**
|
||||
* 设置目标歌词行的对齐方式,默认为 `center`
|
||||
*
|
||||
* - 设置成 `top` 的话将会向目标歌词行的顶部对齐
|
||||
* - 设置成 `bottom` 的话将会向目标歌词行的底部对齐
|
||||
* - 设置成 `center` 的话将会向目标歌词行的垂直中心对齐
|
||||
* @param alignAnchor 歌词行对齐方式,详情见函数说明
|
||||
*/
|
||||
setAlignAnchor(alignAnchor: LayoutAlignAnchor): void {
|
||||
this.layoutState.alignAnchor = alignAnchor;
|
||||
}
|
||||
/**
|
||||
* 设置默认的歌词行对齐位置,相对于整个歌词播放组件的大小位置,默认为 `0.5`
|
||||
* @param alignPosition 一个 `[0.0-1.0]` 之间的任意数字,代表组件高度由上到下的比例位置
|
||||
*/
|
||||
setAlignPosition(alignPosition: number): void {
|
||||
this.layoutState.alignPosition = alignPosition;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 overscan(视图上下额外缓冲渲染区)距离,单位:像素。
|
||||
* @param px 像素值,默认 300
|
||||
*/
|
||||
setOverscanPx(px: number): void {
|
||||
this.layoutState.overscanPx = clampPositive(px | 0);
|
||||
}
|
||||
/** 获取当前 overscan 像素距离 */
|
||||
getOverscanPx(): number {
|
||||
return this.layoutState.overscanPx;
|
||||
}
|
||||
/**
|
||||
* 设置是否使用物理弹簧算法实现歌词动画效果,默认启用
|
||||
*
|
||||
* 如果启用,则会通过弹簧算法实时处理歌词位置,但是需要性能足够强劲的电脑方可流畅运行
|
||||
*
|
||||
* 如果不启用,则会回退到基于 `transition` 的过渡效果,对低性能的机器比较友好,但是效果会比较单一
|
||||
*/
|
||||
setEnableSpring(enable = true): void {
|
||||
this.disableSpring = !enable;
|
||||
if (enable) {
|
||||
this.element.classList.remove(styles.disableSpring);
|
||||
} else {
|
||||
this.element.classList.add(styles.disableSpring);
|
||||
}
|
||||
this.calcLayout(true);
|
||||
}
|
||||
/**
|
||||
* 获取当前是否启用了物理弹簧
|
||||
* @returns 是否启用物理弹簧
|
||||
*/
|
||||
getEnableSpring(): boolean {
|
||||
return !this.disableSpring;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置歌词的优化配置项,这些配置项默认全部开启
|
||||
*
|
||||
* 注意,如果在 `setLyricLines` 之后修改此配置,需要重新调用 `setLyricLines()` 才能对当前歌词生效
|
||||
* @param options 优化配置选项
|
||||
* @see {@link OptimizeLyricOptions}
|
||||
*/
|
||||
setOptimizeOptions(options: OptimizeLyricOptions): void {
|
||||
this.optimizeOptions = { ...this.optimizeOptions, ...options };
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置当前播放歌词,要注意传入后这个数组内的信息不得修改,否则会发生错误
|
||||
* @param lines 歌词数组
|
||||
* @param initialTime 初始时间,默认为 0
|
||||
*/
|
||||
setLyricLines(lines: LyricLine[], initialTime = 0): void {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log("设置歌词行", lines, initialTime);
|
||||
}
|
||||
|
||||
this.timelineState.initialLayoutFinished = true;
|
||||
this.timelineState.lastCurrentTime = initialTime;
|
||||
this.timelineState.currentTime = initialTime;
|
||||
|
||||
this.currentLyricLines = structuredClone(lines);
|
||||
this.processedLines = structuredClone(this.currentLyricLines);
|
||||
optimizeLyricLines(this.processedLines, this.optimizeOptions);
|
||||
|
||||
this.isNonDynamic = true;
|
||||
for (const line of this.processedLines) {
|
||||
if (line.words.length > 1) {
|
||||
this.isNonDynamic = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.hasDuetLine = this.processedLines.some((line) => line.isDuet);
|
||||
|
||||
for (const group of this.currentLyricGroups) {
|
||||
group.dispose();
|
||||
}
|
||||
this.currentLyricGroups = [];
|
||||
|
||||
this.interludeDots.setInterlude(undefined);
|
||||
this.timelineState.hotGroups.clear();
|
||||
this.timelineState.bufferedGroups.clear();
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log("歌词处理完成", this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前是否在播放
|
||||
* @returns 当前是否在播放
|
||||
*/
|
||||
public getIsPlaying(): boolean {
|
||||
return this.timelineState.isPlaying;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置当前播放进度,此时将会更新内部的歌词进度信息。
|
||||
*
|
||||
* 内部会根据调用间隔和播放进度自动决定如何滚动和显示歌词,所以这个的调用频率越快越准确越好。
|
||||
* 调用完成后,应每帧调用 {@link update} 方法来执行歌词动画效果。**此函数本身不会触发动画效果**。
|
||||
*
|
||||
* @param time 当前播放进度,单位为毫秒
|
||||
*/
|
||||
setCurrentTime(time: number, isSeek = false): void {
|
||||
// 歌词行为如下:
|
||||
// 如果当前仍有缓冲行的情况下加入新热行,则不会解除当前缓冲行,且也不会修改当前滚动位置
|
||||
// 如果当前所有缓冲行都将被删除且没有新热行加入,则删除所有缓冲行,且也不会修改当前滚动位置
|
||||
// 如果当前所有缓冲行都将被删除且有新热行加入,则删除所有缓冲行并加入新热行作为缓冲行,然后修改当前滚动位置
|
||||
|
||||
time = Math.round(time);
|
||||
|
||||
const { timelineState } = this;
|
||||
timelineState.isSeeking = Boolean(isSeek);
|
||||
timelineState.currentTime = time;
|
||||
|
||||
if (!timelineState.initialLayoutFinished && !timelineState.isSeeking)
|
||||
return;
|
||||
|
||||
const stateResult = computePlayerTimeState({
|
||||
time,
|
||||
currentGroups: this.currentLyricGroups,
|
||||
timelineState,
|
||||
});
|
||||
|
||||
const bottomEl = this.bottomLine.getElement();
|
||||
const hasBottomContent = bottomEl.innerHTML.trim().length > 0;
|
||||
const commitResult = commitPlayerTimeState({
|
||||
timelineState: timelineState,
|
||||
time,
|
||||
currentGroups: this.currentLyricGroups,
|
||||
hasBottomContent,
|
||||
stateResult,
|
||||
});
|
||||
|
||||
for (const id of commitResult.groupsToDisable)
|
||||
this.currentLyricGroups[id]?.disable();
|
||||
|
||||
for (const id of commitResult.groupsToEnable)
|
||||
this.currentLyricGroups[id]?.enable();
|
||||
|
||||
if (commitResult.shouldResetScroll) this.resetScroll();
|
||||
if (commitResult.shouldLayout) {
|
||||
this.calcLayout(timelineState.isSeeking, timelineState.isSeeking);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新布局定位歌词行的位置,调用完成后再逐帧调用 `update`
|
||||
* 函数即可让歌词通过动画移动到目标位置。
|
||||
*
|
||||
* 函数有一个 `force` 参数,用于指定是否强制修改布局,也就是不经过动画直接调整元素位置和大小。
|
||||
*
|
||||
* 此函数还有一个 `reflow` 参数,用于指定是否需要重新计算布局
|
||||
*
|
||||
* 因为计算布局必定会导致浏览器重排布局,所以会大幅度影响流畅度和性能,故请只在以下情况下将其设置为 true:
|
||||
*
|
||||
* 1. 歌词页面大小发生改变时(这个组件会自行处理)
|
||||
* 2. 加载了新的歌词时(不论前后歌词是否完全一样)
|
||||
* 3. 用户自行跳转了歌曲播放位置(不论距离远近)
|
||||
*
|
||||
* @param sync 是否同步执行,通常用于初始化或 Resize 时立即布局
|
||||
* @param force 是否绕过弹簧效果强制更新位置
|
||||
*/
|
||||
async calcLayout(sync = false, force = false): Promise<void> {
|
||||
const interlude = computeCurrentInterlude({
|
||||
currentTime: this.timelineState.currentTime,
|
||||
scrollToIndex: this.timelineState.scrollToIndex,
|
||||
currentGroups: this.currentLyricGroups,
|
||||
});
|
||||
const isInterludeActive = !!interlude;
|
||||
|
||||
if (
|
||||
this.layoutState.targetAlignIndex !== this.timelineState.scrollToIndex ||
|
||||
this.layoutState.lastInterludeState !== isInterludeActive
|
||||
) {
|
||||
this.layoutState.lastInterludeState = isInterludeActive;
|
||||
|
||||
const springParams = computeLinePosYSpringParams({
|
||||
enabled: this.getEnableSpring(),
|
||||
currentGroups: this.currentLyricGroups,
|
||||
scrollToIndex: this.timelineState.scrollToIndex,
|
||||
isSeeking: this.timelineState.isSeeking,
|
||||
isInterludeActive,
|
||||
});
|
||||
if (springParams.shouldUpdate && springParams.params) {
|
||||
this.setLinePosYSpringParams(springParams.params);
|
||||
}
|
||||
}
|
||||
|
||||
let curPos = -this.scrollState.scrollOffset;
|
||||
const targetAlignIndex = this.timelineState.scrollToIndex;
|
||||
let isNextDuet = false;
|
||||
if (interlude) {
|
||||
isNextDuet = interlude.isNextDuet;
|
||||
} else {
|
||||
this.interludeDots.setInterlude(undefined);
|
||||
}
|
||||
|
||||
const fontSize = this.baseFontSize || 24;
|
||||
const dotMargin = fontSize * 0.4;
|
||||
const totalInterludeHeight =
|
||||
this.layoutState.interludeDotsSize[1] + dotMargin * 2;
|
||||
|
||||
if (interlude) {
|
||||
if (interlude.anchorLineIndex !== -1) {
|
||||
curPos -= totalInterludeHeight;
|
||||
}
|
||||
}
|
||||
// 避免一开始就让所有歌词行挤在一起
|
||||
const LINE_HEIGHT_FALLBACK = this.size[1] / 5;
|
||||
const scrollOffset = this.currentLyricGroups
|
||||
.slice(0, targetAlignIndex)
|
||||
.reduce(
|
||||
(acc, group) =>
|
||||
acc + (this.lyricGroupSize.get(group)?.[1] ?? LINE_HEIGHT_FALLBACK),
|
||||
0,
|
||||
);
|
||||
|
||||
this.scrollState.scrollBoundary.minOffset = -scrollOffset;
|
||||
curPos -= scrollOffset;
|
||||
curPos += this.size[1] * this.layoutState.alignPosition;
|
||||
|
||||
const curGroup = this.currentLyricGroups[targetAlignIndex];
|
||||
this.layoutState.targetAlignIndex = targetAlignIndex;
|
||||
|
||||
const isBottomFocused = targetAlignIndex === this.currentLyricGroups.length;
|
||||
this.bottomLine.setFocused(isBottomFocused);
|
||||
|
||||
const targetLineHeight = curGroup
|
||||
? (this.lyricGroupSize.get(curGroup)?.[1] ?? LINE_HEIGHT_FALLBACK)
|
||||
: isBottomFocused
|
||||
? this.bottomLine.lineSize[1]
|
||||
: 0;
|
||||
|
||||
if (targetLineHeight > 0) {
|
||||
switch (this.layoutState.alignAnchor) {
|
||||
case LayoutAlignAnchor.Bottom:
|
||||
curPos -= targetLineHeight;
|
||||
break;
|
||||
case LayoutAlignAnchor.Center:
|
||||
curPos -= targetLineHeight / 2;
|
||||
break;
|
||||
case LayoutAlignAnchor.Top:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const latestIndex = Math.max(...this.timelineState.bufferedGroups);
|
||||
let delay = 0;
|
||||
let baseDelay = sync ? 0 : 0.05;
|
||||
let setDots = false;
|
||||
|
||||
this.currentLyricGroups.forEach((group, i) => {
|
||||
const hasBuffered = this.timelineState.bufferedGroups.has(i);
|
||||
|
||||
const shouldShowDots = interlude && i === interlude.anchorLineIndex + 1;
|
||||
|
||||
if (!setDots && shouldShowDots) {
|
||||
setDots = true;
|
||||
|
||||
curPos += dotMargin;
|
||||
|
||||
let targetX = 0;
|
||||
if (interlude && isNextDuet) {
|
||||
targetX = this.size[0] - this.layoutState.interludeDotsSize[0];
|
||||
}
|
||||
|
||||
this.interludeDots.setTransform(targetX, curPos);
|
||||
|
||||
if (interlude) {
|
||||
this.interludeDots.setInterlude([
|
||||
interlude.startTime,
|
||||
interlude.endTime,
|
||||
]);
|
||||
}
|
||||
curPos += this.layoutState.interludeDotsSize[1];
|
||||
curPos += dotMargin;
|
||||
}
|
||||
|
||||
const presentation = computeGroupPresentation({
|
||||
groupIndex: i,
|
||||
scrollToIndex: this.timelineState.scrollToIndex,
|
||||
latestIndex,
|
||||
hasBuffered,
|
||||
hidePassedLines: this.hidePassedLines,
|
||||
isPlaying: this.timelineState.isPlaying,
|
||||
isNonDynamic: this.isNonDynamic,
|
||||
enableBlur: this.enableBlur,
|
||||
isUserScrolling: this.scrollState.isUserScrolling,
|
||||
isCompact: window.innerWidth <= 1024,
|
||||
interlude,
|
||||
});
|
||||
|
||||
group.setTransform(
|
||||
curPos,
|
||||
force,
|
||||
delay,
|
||||
presentation.isActive,
|
||||
presentation.targetOpacity,
|
||||
presentation.blurLevel,
|
||||
);
|
||||
|
||||
curPos += this.lyricGroupSize.get(group)?.[1] ?? LINE_HEIGHT_FALLBACK;
|
||||
|
||||
if (curPos >= 0 && !this.timelineState.isSeeking) {
|
||||
delay += baseDelay;
|
||||
if (i >= this.timelineState.scrollToIndex) baseDelay /= 1.05;
|
||||
}
|
||||
});
|
||||
this.scrollState.scrollBoundary.maxOffset =
|
||||
curPos + this.scrollState.scrollOffset - this.size[1] / 2;
|
||||
|
||||
const bottomIndex = this.currentLyricGroups.length;
|
||||
const finalBottomBlur = computeLineBlur({
|
||||
enableBlur: this.enableBlur,
|
||||
isUserScrolling: this.scrollState.isUserScrolling,
|
||||
isActive: isBottomFocused,
|
||||
itemIndex: bottomIndex,
|
||||
scrollToIndex: this.timelineState.scrollToIndex,
|
||||
latestIndex,
|
||||
isCompact: window.innerWidth <= 1024,
|
||||
});
|
||||
|
||||
this.bottomLine.setTransform(0, curPos, finalBottomBlur, force, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置所有歌词行在横坐标上的弹簧属性,包括重量、弹力和阻力。
|
||||
*
|
||||
* @param params 需要设置的弹簧属性,提供的属性将会覆盖原来的属性,未提供的属性将会保持原样
|
||||
* @deprecated 考虑到横向弹簧效果并不常见,所以这个函数将会在未来的版本中移除
|
||||
*/
|
||||
setLinePosXSpringParams(_params: Partial<SpringParams> = {}): void {}
|
||||
/**
|
||||
* 设置所有歌词行在纵坐标上的弹簧属性,包括重量、弹力和阻力。
|
||||
*
|
||||
* @param params 需要设置的弹簧属性,提供的属性将会覆盖原来的属性,未提供的属性将会保持原样
|
||||
*/
|
||||
setLinePosYSpringParams(params: Partial<SpringParams> = {}): void {
|
||||
this.posYSpringParams = {
|
||||
...this.posYSpringParams,
|
||||
...params,
|
||||
};
|
||||
this.bottomLine.lineTransforms.posY.updateParams(this.posYSpringParams);
|
||||
for (const group of this.currentLyricGroups) {
|
||||
group.posY.updateParams(this.posYSpringParams);
|
||||
group.bgSlideY.updateParams(this.posYSpringParams);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 设置所有歌词行在缩放大小上的弹簧属性,包括重量、弹力和阻力。
|
||||
*
|
||||
* @param params 需要设置的弹簧属性,提供的属性将会覆盖原来的属性,未提供的属性将会保持原样
|
||||
*/
|
||||
setLineScaleSpringParams(params: Partial<SpringParams> = {}): void {
|
||||
this.scaleSpringParams = {
|
||||
...this.scaleSpringParams,
|
||||
...params,
|
||||
};
|
||||
this.scaleForBGSpringParams = {
|
||||
...this.scaleForBGSpringParams,
|
||||
...params,
|
||||
};
|
||||
for (const group of this.currentLyricGroups) {
|
||||
group.mainLine.lineTransforms.scale.updateParams(this.scaleSpringParams);
|
||||
|
||||
group.bgLine?.lineTransforms.scale.updateParams(
|
||||
this.scaleForBGSpringParams,
|
||||
);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 暂停部分效果演出,目前会暂停播放间奏点的动画,且将背景歌词显示出来
|
||||
*/
|
||||
pause(): void {
|
||||
this.interludeDots.pause();
|
||||
if (this.timelineState.isPlaying) {
|
||||
this.timelineState.isPlaying = false;
|
||||
this.calcLayout();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 恢复部分效果演出,目前会恢复播放间奏点的动画
|
||||
*/
|
||||
resume(): void {
|
||||
this.interludeDots.resume();
|
||||
if (!this.timelineState.isPlaying) {
|
||||
this.timelineState.isPlaying = true;
|
||||
this.calcLayout();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 更新动画,这个函数应该被逐帧调用或者在以下情况下调用一次:
|
||||
*
|
||||
* 1. 刚刚调用完设置歌词函数的时候
|
||||
* @param delta 距离上一次被调用到现在的时长,单位为毫秒(可为浮点数)
|
||||
*/
|
||||
|
||||
update(delta = 0): void {
|
||||
this.bottomLine.update(delta / 1000);
|
||||
this.interludeDots.update(delta);
|
||||
}
|
||||
|
||||
protected onResize(): void {}
|
||||
|
||||
/**
|
||||
* 获取一个特殊的底栏元素,默认是空白的,可以往内部添加任意元素
|
||||
*
|
||||
* 这个元素始终在歌词的底部,可以用于显示歌曲创作者等信息
|
||||
*
|
||||
* 但是请勿删除该元素,只能在内部存放元素
|
||||
*
|
||||
* @returns 一个元素,可以往内部添加任意元素
|
||||
*/
|
||||
getBottomLineElement(): HTMLElement {
|
||||
return this.bottomLine.getElement();
|
||||
}
|
||||
/**
|
||||
* 重置用户滚动状态
|
||||
*
|
||||
* 请在用户完成滚动点击跳转歌词时调用本事件再调用 `calcLayout` 以正确滚动到目标位置
|
||||
*/
|
||||
resetScroll(): void {
|
||||
resetPlayerScrollState(this.scrollState);
|
||||
clearTimeout(this.scrolledHandler);
|
||||
this.scrolledHandler = undefined;
|
||||
}
|
||||
/**
|
||||
* 获取当前歌词数组
|
||||
*
|
||||
* 一般和最后调用 `setLyricLines` 给予的参数一样
|
||||
* @returns 当前歌词数组
|
||||
*/
|
||||
getLyricLines(): LyricLine[] {
|
||||
return this.currentLyricLines;
|
||||
}
|
||||
/**
|
||||
* 获取当前歌词的播放位置
|
||||
*
|
||||
* 一般和最后调用 `setCurrentTime` 给予的参数一样
|
||||
* @returns 当前播放位置
|
||||
*/
|
||||
getCurrentTime(): number {
|
||||
return this.timelineState.currentTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置是否让背景人声行始终后置显示
|
||||
*
|
||||
* 默认情况下,如果背景歌词开始时间早于主歌词,会在主歌词上方展示;
|
||||
* 如果设置为 `true`,则无论时间顺序如何,背景歌词都会始终在主歌词下方展示
|
||||
* @param enable 是否启用始终后置
|
||||
*/
|
||||
setAlwaysPostpositionBackground(enable: boolean): void {
|
||||
if (this.alwaysPostpositionBackground === enable) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.alwaysPostpositionBackground = enable;
|
||||
|
||||
this.rebuildLyricLines();
|
||||
this.calcLayout();
|
||||
}
|
||||
|
||||
/** 获取当前是否设置了让背景人声行始终后置显示 */
|
||||
getAlwaysPostpositionBackground(): boolean {
|
||||
return this.alwaysPostpositionBackground;
|
||||
}
|
||||
|
||||
getElement(): HTMLElement {
|
||||
return this.element;
|
||||
}
|
||||
dispose(): void {
|
||||
this.element.remove();
|
||||
window.removeEventListener("pageshow", this.onPageShow);
|
||||
window.removeEventListener("pagehide", this.onPageHide);
|
||||
}
|
||||
}
|
||||
333
amll-local/packages/core/src/lyric-player/base/layout.ts
Normal file
333
amll-local/packages/core/src/lyric-player/base/layout.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
import { clamp } from "#utils/clamp.ts";
|
||||
import type { SpringParams } from "#utils/spring.ts";
|
||||
import type { LayoutAlignAnchor } from "./consts.ts";
|
||||
import type { LyricLineGroupBase } from "./group.ts";
|
||||
import type { PlayerTimelineState } from "./timeline.ts";
|
||||
|
||||
/**
|
||||
* 播放器布局状态。
|
||||
*
|
||||
* 这部分状态保存布局计算阶段所需的配置项与缓存值,
|
||||
* 例如对齐方式、间奏点尺寸、上一轮布局命中的目标行等。
|
||||
* 不描述播放时间线或用户滚动交互,仅记录当前歌词排布。
|
||||
*/
|
||||
export interface PlayerLayoutState {
|
||||
/** 间奏点元素当前测量得到的尺寸 */
|
||||
interludeDotsSize: [number, number];
|
||||
/** 上一轮布局实际对齐的目标歌词行索引 */
|
||||
targetAlignIndex: number;
|
||||
/** 上一轮布局时是否处于间奏区间 */
|
||||
lastInterludeState: boolean;
|
||||
/** 当前歌词目标行的对齐锚点 */
|
||||
alignAnchor: LayoutAlignAnchor;
|
||||
/** 当前歌词目标行在播放器高度中的相对对齐位置 */
|
||||
alignPosition: number;
|
||||
/** 视口上下额外保留的预渲染距离,单位为像素 */
|
||||
overscanPx: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前命中的间奏区间信息。
|
||||
*
|
||||
* 当播放器检测到当前时间处于两句歌词之间的较长空档期时,
|
||||
* 会生成该结构,用于驱动间奏点动画的显示位置与时间范围。
|
||||
*/
|
||||
export interface PlayerInterlude {
|
||||
/** 间奏动画的开始时间 */
|
||||
startTime: number;
|
||||
/** 间奏动画的结束时间 */
|
||||
endTime: number;
|
||||
/** 间奏点应插入到哪一行之后;`-1` 表示位于第一行之前 */
|
||||
anchorLineIndex: number;
|
||||
/** 间奏结束后的下一句是否为对唱歌词 */
|
||||
isNextDuet: boolean;
|
||||
}
|
||||
|
||||
/** {@link computeCurrentInterlude} 的参数类型 */
|
||||
export interface ComputeCurrentInterludeInput {
|
||||
currentTime: number;
|
||||
scrollToIndex: number;
|
||||
currentGroups: LyricLineGroupBase[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据当前时间与当前目标行,计算当前是否处于某个可展示的间奏区间。
|
||||
*
|
||||
* 仅识别时间轴上的间奏空档,不涉及具体 DOM 元素的创建与摆放。
|
||||
* 若当前不应展示间奏动画,则返回 `undefined`。
|
||||
*/
|
||||
export function computeCurrentInterlude(
|
||||
input: ComputeCurrentInterludeInput,
|
||||
): PlayerInterlude | undefined {
|
||||
const currentTime = input.currentTime + 20;
|
||||
const currentIndex = input.scrollToIndex;
|
||||
const groups = input.currentGroups;
|
||||
|
||||
const checkGap = (k: number): PlayerInterlude | undefined => {
|
||||
if (k < -1 || k >= groups.length - 1) return undefined;
|
||||
|
||||
const prevGroup = k === -1 ? null : groups[k];
|
||||
const nextGroup = groups[k + 1];
|
||||
|
||||
const gapStart = prevGroup ? prevGroup.endTime : 0;
|
||||
const gapEnd = Math.max(gapStart, nextGroup.startTime - 250);
|
||||
|
||||
if (gapEnd - gapStart < 4000) return undefined;
|
||||
|
||||
if (gapEnd > currentTime && gapStart < currentTime) {
|
||||
return {
|
||||
startTime: Math.max(gapStart, currentTime),
|
||||
endTime: gapEnd,
|
||||
anchorLineIndex: k,
|
||||
isNextDuet: nextGroup.mainLine.getLine().isDuet,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
checkGap(currentIndex - 1) ||
|
||||
checkGap(currentIndex) ||
|
||||
checkGap(currentIndex + 1)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link computeLinePosYSpringParams} 的参数类型,
|
||||
* 用于决定当前歌词纵向滚动动画的弹簧参数。
|
||||
*/
|
||||
export interface ComputeLinePosYSpringParamsInput {
|
||||
/** 是否启用弹簧动画 */
|
||||
enabled: boolean;
|
||||
/** 当前用于布局的歌词数据 */
|
||||
currentGroups: LyricLineGroupBase[];
|
||||
/** 当前目标对齐行索引 */
|
||||
scrollToIndex: number;
|
||||
/** 是否处于 seeking 模式 */
|
||||
isSeeking: boolean;
|
||||
/** 是否处于间奏区间 */
|
||||
isInterludeActive: boolean;
|
||||
}
|
||||
|
||||
/** {@link computeLinePosYSpringParams} 的结果类型 */
|
||||
export interface ComputeLinePosYSpringParamsResult {
|
||||
/** 是否需要更新纵向弹簧参数 */
|
||||
shouldUpdate: boolean;
|
||||
/** 若需要更新,则返回新的参数 */
|
||||
params?: Partial<SpringParams>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据当前播放上下文计算歌词纵向滚动动画的弹簧参数。
|
||||
*
|
||||
* 其策略为:
|
||||
* - seeking 或间奏时使用更稳定的固定参数
|
||||
* - 普通播放时根据相邻歌词的时间间隔动态调整 stiffness / damping
|
||||
*/
|
||||
export function computeLinePosYSpringParams(
|
||||
input: ComputeLinePosYSpringParamsInput,
|
||||
): ComputeLinePosYSpringParamsResult {
|
||||
const {
|
||||
enabled,
|
||||
currentGroups,
|
||||
scrollToIndex,
|
||||
isSeeking,
|
||||
isInterludeActive,
|
||||
} = input;
|
||||
|
||||
if (!enabled || currentGroups.length === 0) {
|
||||
return { shouldUpdate: false };
|
||||
}
|
||||
|
||||
if (isSeeking || isInterludeActive) {
|
||||
return {
|
||||
shouldUpdate: true,
|
||||
params: { stiffness: 90, damping: 15 },
|
||||
};
|
||||
}
|
||||
|
||||
const currentGroup = currentGroups[scrollToIndex];
|
||||
const prevGroup = currentGroups[scrollToIndex - 1];
|
||||
|
||||
if (!currentGroup || !prevGroup) {
|
||||
return { shouldUpdate: false };
|
||||
}
|
||||
|
||||
const interval = currentGroup.startTime - prevGroup.startTime;
|
||||
|
||||
const MIN_INTERVAL = 100;
|
||||
const MAX_INTERVAL = 800;
|
||||
const clampedInterval = clamp(interval, MIN_INTERVAL, MAX_INTERVAL);
|
||||
|
||||
const MAX_STIFFNESS = 220;
|
||||
const MIN_STIFFNESS = 170;
|
||||
|
||||
let ratio =
|
||||
1 - (clampedInterval - MIN_INTERVAL) / (MAX_INTERVAL - MIN_INTERVAL);
|
||||
|
||||
ratio = ratio ** 0.2;
|
||||
|
||||
const targetStiffness =
|
||||
MIN_STIFFNESS + ratio * (MAX_STIFFNESS - MIN_STIFFNESS);
|
||||
|
||||
const dampingMultiplier = 2.2;
|
||||
const targetDamping = Math.sqrt(targetStiffness) * dampingMultiplier;
|
||||
|
||||
return {
|
||||
shouldUpdate: true,
|
||||
params: {
|
||||
stiffness: targetStiffness,
|
||||
damping: targetDamping,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link computeGroupPresentation} 的参数类型。
|
||||
*
|
||||
* 描述一行歌词在当前布局上下文中的全部关键信息,
|
||||
* 用于计算其视觉呈现结果。
|
||||
*/
|
||||
export interface ComputeGroupPresentationInput {
|
||||
/** 当前歌词组索引 */
|
||||
groupIndex: number;
|
||||
/** 当前目标对齐行索引 */
|
||||
scrollToIndex: number;
|
||||
/** 当前缓冲区({@link PlayerTimelineState.bufferedGroups})中最靠后的歌词行索引 */
|
||||
latestIndex: number;
|
||||
/** 当前歌词行是否在缓冲集合内 */
|
||||
hasBuffered: boolean;
|
||||
/** 是否启用隐藏已播放行 */
|
||||
hidePassedLines: boolean;
|
||||
/** 是否处于播放状态 */
|
||||
isPlaying: boolean;
|
||||
/** 当前歌词是否为非逐词歌词 */
|
||||
isNonDynamic: boolean;
|
||||
/** 是否启用模糊效果 */
|
||||
enableBlur: boolean;
|
||||
/** 是否正在进行滚动交互 */
|
||||
isUserScrolling: boolean;
|
||||
/** 是否处于紧凑布局环境,例如窄屏 */
|
||||
isCompact: boolean;
|
||||
/** 当前命中的间奏区间信息 */
|
||||
interlude?: PlayerInterlude;
|
||||
}
|
||||
|
||||
/** {@link computeGroupPresentation} 的结果类型 */
|
||||
export interface ComputeGroupPresentationResult {
|
||||
/** 当前歌词行是否应视为活跃行 */
|
||||
isActive: boolean;
|
||||
/** 当前歌词行的目标不透明度 */
|
||||
targetOpacity: number;
|
||||
/** 当前歌词行的目标模糊值 */
|
||||
blurLevel: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算一组歌词在当前布局中的视觉呈现参数。
|
||||
*
|
||||
* 根据播放状态、缓冲状态、布局模式与间奏信息,
|
||||
* 生成一组歌词最终应使用的活跃状态、不透明度与模糊值。
|
||||
*/
|
||||
export function computeGroupPresentation(
|
||||
input: ComputeGroupPresentationInput,
|
||||
): ComputeGroupPresentationResult {
|
||||
const {
|
||||
groupIndex,
|
||||
scrollToIndex,
|
||||
latestIndex,
|
||||
hasBuffered,
|
||||
hidePassedLines,
|
||||
isPlaying,
|
||||
isNonDynamic,
|
||||
enableBlur,
|
||||
isUserScrolling,
|
||||
isCompact,
|
||||
interlude,
|
||||
} = input;
|
||||
|
||||
const isActive =
|
||||
hasBuffered || (groupIndex >= scrollToIndex && groupIndex < latestIndex);
|
||||
|
||||
const blurLevel = computeLineBlur({
|
||||
enableBlur,
|
||||
isUserScrolling,
|
||||
isActive,
|
||||
itemIndex: groupIndex,
|
||||
scrollToIndex,
|
||||
latestIndex,
|
||||
isCompact,
|
||||
});
|
||||
|
||||
let targetOpacity: number;
|
||||
if (hidePassedLines) {
|
||||
if (
|
||||
groupIndex <
|
||||
(interlude ? interlude.anchorLineIndex + 1 : scrollToIndex) &&
|
||||
isPlaying
|
||||
) {
|
||||
// 为了避免浏览器优化,这里使用了一个极小但不为零的值(几乎不可见)
|
||||
targetOpacity = 1e-4;
|
||||
} else if (hasBuffered) {
|
||||
targetOpacity = 0.85;
|
||||
} else {
|
||||
targetOpacity = isNonDynamic ? 0.2 : 1;
|
||||
}
|
||||
} else if (hasBuffered) {
|
||||
targetOpacity = 0.85;
|
||||
} else {
|
||||
targetOpacity = isNonDynamic ? 0.2 : 1;
|
||||
}
|
||||
|
||||
return { isActive, targetOpacity, blurLevel };
|
||||
}
|
||||
|
||||
/** {@link computeLineBlur} 的参数类型 */
|
||||
export interface ComputeLineBlurInput {
|
||||
/** 是否启用了模糊效果 */
|
||||
enableBlur: boolean;
|
||||
/** 用户是否正在滚动 */
|
||||
isUserScrolling: boolean;
|
||||
/** 当前项是否活跃 */
|
||||
isActive: boolean;
|
||||
/** 当前项索引 */
|
||||
itemIndex: number;
|
||||
/** 当前目标对齐行索引 */
|
||||
scrollToIndex: number;
|
||||
/** 缓冲区中最靠后的歌词行索引 */
|
||||
latestIndex: number;
|
||||
/** 是否处于紧凑布局环境,例如窄屏 */
|
||||
isCompact: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算一行歌词在当前布局中的模糊等级。
|
||||
*
|
||||
* 越远离当前对齐区域的歌词会得到更高的模糊值;
|
||||
* 活跃行、滚动交互中或关闭模糊效果时返回 `0`。
|
||||
*/
|
||||
export function computeLineBlur(input: ComputeLineBlurInput): number {
|
||||
const {
|
||||
enableBlur,
|
||||
isUserScrolling,
|
||||
isActive,
|
||||
itemIndex,
|
||||
scrollToIndex,
|
||||
latestIndex,
|
||||
isCompact,
|
||||
} = input;
|
||||
|
||||
if (!enableBlur || isUserScrolling || isActive) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let blurLevel = 1;
|
||||
|
||||
if (itemIndex < scrollToIndex) {
|
||||
blurLevel += Math.abs(scrollToIndex - itemIndex) + 1;
|
||||
} else {
|
||||
blurLevel += Math.abs(itemIndex - Math.max(scrollToIndex, latestIndex));
|
||||
}
|
||||
|
||||
return isCompact ? blurLevel * 0.8 : blurLevel;
|
||||
}
|
||||
85
amll-local/packages/core/src/lyric-player/base/line.ts
Normal file
85
amll-local/packages/core/src/lyric-player/base/line.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { Disposable, LyricLine, LyricWord } from "#interfaces";
|
||||
import { isCJK } from "#utils/is-cjk.ts";
|
||||
import { Spring } from "#utils/spring.ts";
|
||||
import { LyricLineRenderMode } from "./consts.ts";
|
||||
|
||||
interface LineTransforms {
|
||||
scale: Spring;
|
||||
}
|
||||
|
||||
/**
|
||||
* 所有标准歌词行的基类
|
||||
* @internal
|
||||
*/
|
||||
export abstract class LyricLineBase extends EventTarget implements Disposable {
|
||||
protected top = 0;
|
||||
protected scale = 1;
|
||||
protected blur = 0;
|
||||
protected opacity = 1;
|
||||
protected delay = 0;
|
||||
readonly lineTransforms: LineTransforms = {
|
||||
scale: new Spring(100),
|
||||
};
|
||||
|
||||
/**
|
||||
* 用于 CJK 词语边界检测的分词器
|
||||
*/
|
||||
static readonly wordSegmenter: Intl.Segmenter | null =
|
||||
typeof Intl !== "undefined" && Intl.Segmenter
|
||||
? new Intl.Segmenter(undefined, { granularity: "word" })
|
||||
: null;
|
||||
|
||||
/**
|
||||
* Unicode 标准的全局 Grapheme Cluster 分词器
|
||||
* 用于正确处理 emoji、复合字符等
|
||||
*/
|
||||
static readonly graphemeSegmenter: Intl.Segmenter | null =
|
||||
typeof Intl !== "undefined" && Intl.Segmenter
|
||||
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
||||
: null;
|
||||
|
||||
abstract getLine(): LyricLine;
|
||||
abstract enable(time?: number, shouldPlay?: boolean): void;
|
||||
abstract disable(): void;
|
||||
abstract resume(): void;
|
||||
abstract pause(): void;
|
||||
abstract onLineSizeChange(size: [number, number]): void;
|
||||
|
||||
setTransform(
|
||||
scale: number = this.scale,
|
||||
opacity: number = this.opacity,
|
||||
blur: number = this.blur,
|
||||
_force = false,
|
||||
delay = 0,
|
||||
_mode: LyricLineRenderMode = LyricLineRenderMode.SOLID,
|
||||
): void {
|
||||
this.scale = scale;
|
||||
this.opacity = opacity;
|
||||
this.blur = blur;
|
||||
this.delay = delay;
|
||||
}
|
||||
|
||||
rebuildElement(): void {}
|
||||
|
||||
/**
|
||||
* 判定歌词是否可以应用强调辉光效果
|
||||
*
|
||||
* 果子在对辉光效果的解释是一种强调(emphasized)效果
|
||||
*
|
||||
* 条件是一个单词时长大于等于 1s 且长度小于等于 7
|
||||
*
|
||||
* @param word 单词
|
||||
* @returns 是否可以应用强调辉光效果
|
||||
*/
|
||||
static shouldEmphasize(word: LyricWord): boolean {
|
||||
if (isCJK(word.word)) return word.endTime - word.startTime >= 1000;
|
||||
|
||||
return (
|
||||
word.endTime - word.startTime >= 1000 &&
|
||||
word.word.trim().length <= 7 &&
|
||||
word.word.trim().length > 1
|
||||
);
|
||||
}
|
||||
abstract update(delta?: number): void;
|
||||
dispose(): void {}
|
||||
}
|
||||
231
amll-local/packages/core/src/lyric-player/base/scroll.ts
Normal file
231
amll-local/packages/core/src/lyric-player/base/scroll.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { clamp } from "#utils/clamp.ts";
|
||||
|
||||
/**
|
||||
* 播放器滚动状态。
|
||||
*
|
||||
* 这部分状态描述用户手势/滚轮滚动产生的临时偏移,以及当前允许滚动的范围。
|
||||
* 改状态仅记录用户如何把当前视图上下拖动,不决定应该滚动到哪一行,
|
||||
* 后者由时间线状态与布局计算共同决定。
|
||||
*/
|
||||
export interface PlayerScrollState {
|
||||
/** 允许的滚动偏移范围 */
|
||||
scrollBoundary: {
|
||||
/** 允许的最小偏移量 */
|
||||
minOffset: number;
|
||||
/** 允许的最大偏移量 */
|
||||
maxOffset: number;
|
||||
};
|
||||
/** 当前用户滚动带来的额外偏移量 */
|
||||
scrollOffset: number;
|
||||
/** 是否允许用户通过手势或滚轮滚动歌词视图 */
|
||||
allowScroll: boolean;
|
||||
/** 是否处于用户滚动过,尚未回归自动对齐的状态 */
|
||||
isScrolled: boolean;
|
||||
/** 是否正在进行滚动交互或惯性滚动 */
|
||||
isUserScrolling: boolean;
|
||||
/** Bumped when scroll state is reset so pending inertial frames can stop. */
|
||||
resetToken?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将滚动偏移量限制在当前允许的滚动边界内。
|
||||
*
|
||||
* 当手势滚动、滚轮滚动或惯性滚动更新了 {@link PlayerScrollState.scrollOffset}
|
||||
* 后,应调用本函数以避免视图越界。
|
||||
*/
|
||||
export function clampPlayerScrollOffset(scrollState: PlayerScrollState): void {
|
||||
scrollState.scrollOffset = clamp(
|
||||
scrollState.scrollOffset,
|
||||
scrollState.scrollBoundary.minOffset,
|
||||
scrollState.scrollBoundary.maxOffset,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置滚动状态到未发生用户滚动时的初始状态。
|
||||
*
|
||||
* 本函数会清除当前偏移,并结束“已滚动”与“正在滚动”的标记;
|
||||
* **不会清理**外部持有的计时器或事件监听器。
|
||||
*/
|
||||
export function resetPlayerScrollState(scrollState: PlayerScrollState): void {
|
||||
scrollState.isScrolled = false;
|
||||
scrollState.scrollOffset = 0;
|
||||
scrollState.isUserScrolling = false;
|
||||
scrollState.resetToken = (scrollState.resetToken ?? 0) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link attachPlayerScrollHandlers} 所需的宿主回调。
|
||||
*
|
||||
* 这些回调将滚动模块与具体播放器实现解耦:
|
||||
* 滚动模块只负责处理输入事件和更新滚动状态,布局刷新、点击转发等副作用
|
||||
* 由宿主决定如何执行。
|
||||
*/
|
||||
export interface AttachPlayerScrollHandlersCallbacks {
|
||||
/** 开始一次滚动处理前调用,返回 `false` 可阻止本次滚动 */
|
||||
onBeginScroll: () => boolean;
|
||||
/** 一次滚动交互或惯性滚动结束时调用 */
|
||||
onEndScroll: () => void;
|
||||
/** 请求宿主重新布局 */
|
||||
onLayout: (sync: boolean, force: boolean) => void;
|
||||
/** 判断某个点击目标是否仍属于当前播放器视图 */
|
||||
containsTarget: (target: Node) => boolean;
|
||||
/** 将点击事件转发给命中的目标元素 */
|
||||
clickTarget: (target: HTMLElement) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 向指定元素挂载歌词滚动相关的交互处理器。
|
||||
*
|
||||
* 该函数会处理:
|
||||
* - 触摸拖拽滚动
|
||||
* - 触摸结束后的惯性滚动
|
||||
* - 滚轮滚动
|
||||
* - 轻触时的点击透传
|
||||
*
|
||||
* 只更新 {@link PlayerScrollState} 并通过回调通知宿主执行布局或其它副作用,
|
||||
* 不直接依赖具体的播放器类实现。
|
||||
*/
|
||||
export function attachPlayerScrollHandlers(
|
||||
element: HTMLElement,
|
||||
scrollState: PlayerScrollState,
|
||||
callbacks: AttachPlayerScrollHandlersCallbacks,
|
||||
): void {
|
||||
let startScrollY = 0;
|
||||
|
||||
let startTouchPosY = 0;
|
||||
let startTouchStartX = 0;
|
||||
let startTouchStartY = 0;
|
||||
|
||||
let lastMoveY = 0;
|
||||
let startScrollTime = 0;
|
||||
let scrollSpeed = 0;
|
||||
let curScrollId = 0;
|
||||
|
||||
element.addEventListener("touchstart", (evt) => {
|
||||
if (callbacks.onBeginScroll()) {
|
||||
curScrollId += 1;
|
||||
scrollState.isUserScrolling = true;
|
||||
|
||||
evt.preventDefault();
|
||||
startScrollY = scrollState.scrollOffset;
|
||||
|
||||
startTouchPosY = evt.touches[0].screenY;
|
||||
lastMoveY = startTouchPosY;
|
||||
|
||||
startTouchStartX = evt.touches[0].screenX;
|
||||
startTouchStartY = evt.touches[0].screenY;
|
||||
|
||||
startScrollTime = Date.now();
|
||||
scrollSpeed = 0;
|
||||
|
||||
callbacks.onLayout(true, true);
|
||||
}
|
||||
});
|
||||
|
||||
element.addEventListener("touchmove", (evt) => {
|
||||
if (callbacks.onBeginScroll()) {
|
||||
evt.preventDefault();
|
||||
const currentY = evt.touches[0].screenY;
|
||||
|
||||
const deltaY = currentY - startTouchPosY;
|
||||
scrollState.scrollOffset = startScrollY - deltaY;
|
||||
clampPlayerScrollOffset(scrollState);
|
||||
|
||||
const now = Date.now();
|
||||
const dt = now - startScrollTime;
|
||||
if (dt > 0) {
|
||||
scrollSpeed = (currentY - lastMoveY) / dt;
|
||||
}
|
||||
lastMoveY = currentY;
|
||||
startScrollTime = now;
|
||||
|
||||
callbacks.onLayout(true, true);
|
||||
}
|
||||
});
|
||||
|
||||
element.addEventListener("touchend", (evt) => {
|
||||
if (callbacks.onBeginScroll()) {
|
||||
evt.preventDefault();
|
||||
|
||||
const touch = evt.changedTouches[0];
|
||||
const moveX = Math.abs(touch.screenX - startTouchStartX);
|
||||
const moveY = Math.abs(touch.screenY - startTouchStartY);
|
||||
|
||||
if (moveX < 10 && moveY < 10) {
|
||||
const target = document.elementFromPoint(touch.clientX, touch.clientY);
|
||||
if (target instanceof HTMLElement && callbacks.containsTarget(target)) {
|
||||
callbacks.clickTarget(target);
|
||||
}
|
||||
scrollState.isUserScrolling = false;
|
||||
callbacks.onEndScroll();
|
||||
return;
|
||||
}
|
||||
|
||||
startTouchPosY = 0;
|
||||
const scrollId = ++curScrollId;
|
||||
const resetToken = scrollState.resetToken ?? 0;
|
||||
|
||||
if (Math.abs(scrollSpeed) < 0.1) scrollSpeed = 0;
|
||||
|
||||
let lastFrameTime = performance.now();
|
||||
|
||||
const onScrollFrame = (time: number) => {
|
||||
if (
|
||||
scrollId !== curScrollId ||
|
||||
resetToken !== (scrollState.resetToken ?? 0)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dt = time - lastFrameTime;
|
||||
lastFrameTime = time;
|
||||
|
||||
if (dt <= 0 || dt > 100) {
|
||||
requestAnimationFrame(onScrollFrame);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Math.abs(scrollSpeed) > 0.05) {
|
||||
scrollState.scrollOffset -= scrollSpeed * dt;
|
||||
|
||||
clampPlayerScrollOffset(scrollState);
|
||||
|
||||
const frictionFactor = 0.95 ** (dt / 16);
|
||||
scrollSpeed *= frictionFactor;
|
||||
|
||||
callbacks.onLayout(true, true);
|
||||
|
||||
requestAnimationFrame(onScrollFrame);
|
||||
} else {
|
||||
scrollState.isUserScrolling = false;
|
||||
callbacks.onEndScroll();
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(onScrollFrame);
|
||||
} else {
|
||||
scrollState.isUserScrolling = false;
|
||||
}
|
||||
});
|
||||
|
||||
element.addEventListener(
|
||||
"wheel",
|
||||
(evt) => {
|
||||
if (callbacks.onBeginScroll()) {
|
||||
evt.preventDefault();
|
||||
|
||||
if (evt.deltaMode === evt.DOM_DELTA_PIXEL) {
|
||||
scrollState.scrollOffset += evt.deltaY;
|
||||
clampPlayerScrollOffset(scrollState);
|
||||
callbacks.onLayout(true, false);
|
||||
} else {
|
||||
scrollState.scrollOffset += evt.deltaY * 50;
|
||||
clampPlayerScrollOffset(scrollState);
|
||||
callbacks.onLayout(false, false);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
}
|
||||
236
amll-local/packages/core/src/lyric-player/base/timeline.ts
Normal file
236
amll-local/packages/core/src/lyric-player/base/timeline.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { eqSet } from "#utils/eq-set.ts";
|
||||
import type { LyricLineGroupBase } from "./group.ts";
|
||||
|
||||
/**
|
||||
* 播放时间线状态。
|
||||
*
|
||||
* 描述播放器在时间轴上的当前位置,当前处于激活状态的歌词组信息
|
||||
*/
|
||||
export interface PlayerTimelineState {
|
||||
/** 当前播放时间,单位为毫秒 */
|
||||
currentTime: number;
|
||||
/** 上一次提交到时间线状态的播放时间,单位为毫秒 */
|
||||
lastCurrentTime: number;
|
||||
/** 热行:当前时间 {@link currentTime} 正在命中的组(含主行+可能的背景行) */
|
||||
hotGroups: Set<number>;
|
||||
/** 缓冲组:UI 上还保持激活表现的组索引,通常包含热组,和刚结束仍在过渡中的组 */
|
||||
bufferedGroups: Set<number>;
|
||||
/** 当前应滚动对齐到的歌词组索引 */
|
||||
scrollToIndex: number;
|
||||
/** 是否正在拖拽进度条。若是,更新时丢弃缓冲行,并根据当前时间直接计算热行 */
|
||||
isSeeking: boolean;
|
||||
/** 是否处于播放状态 */
|
||||
isPlaying: boolean;
|
||||
/** 是否已经完成至少一次初始布局 */
|
||||
initialLayoutFinished: boolean;
|
||||
}
|
||||
|
||||
/** {@link computePlayerTimeState} 的参数类型 */
|
||||
export interface ComputePlayerTimeStateInput {
|
||||
time: number;
|
||||
currentGroups: LyricLineGroupBase[];
|
||||
timelineState: Readonly<PlayerTimelineState>;
|
||||
}
|
||||
|
||||
/** {@link computePlayerTimeState} 的返回类型 */
|
||||
export interface ComputePlayerTimeStateResult {
|
||||
/** 计算后的新热组集合 */
|
||||
nextHotGroups: Set<number>;
|
||||
/** 需要新加入热组集合的组索引 */
|
||||
addedIds: Set<number>;
|
||||
/** 需要从热组集合中移除的组索引 */
|
||||
removedHotIds: Set<number>;
|
||||
/** 需要从缓冲组集合中移除的组索引 */
|
||||
removedBufferedIds: Set<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算指定时间点的热行/缓冲行状态转移的纯函数。其行为包括:
|
||||
*
|
||||
* - 根据当前时间和已有的热行状态,计算出新的热行状态,并返回应新增的热行 ID 和应移除的热行 ID
|
||||
* - 根据新的热行状态和已有的缓冲行状态,计算出应移除的缓冲行 ID
|
||||
*/
|
||||
export function computePlayerTimeState(
|
||||
input: ComputePlayerTimeStateInput,
|
||||
): ComputePlayerTimeStateResult {
|
||||
const {
|
||||
time,
|
||||
currentGroups,
|
||||
timelineState: { hotGroups, bufferedGroups },
|
||||
} = input;
|
||||
|
||||
const nextHotGroups = new Set(hotGroups);
|
||||
const addedIds = new Set<number>();
|
||||
const removedHotIds = new Set<number>();
|
||||
const removedBufferedIds = new Set<number>();
|
||||
|
||||
for (const lastHotId of hotGroups) {
|
||||
const group = currentGroups[lastHotId];
|
||||
if (!group || time < group.startTime || group.endTime <= time) {
|
||||
nextHotGroups.delete(lastHotId);
|
||||
removedHotIds.add(lastHotId);
|
||||
}
|
||||
}
|
||||
|
||||
for (let id = 0; id < currentGroups.length; id++) {
|
||||
const group = currentGroups[id];
|
||||
if (!group) continue;
|
||||
|
||||
if (
|
||||
group.startTime <= time &&
|
||||
group.endTime > time &&
|
||||
!nextHotGroups.has(id)
|
||||
) {
|
||||
nextHotGroups.add(id);
|
||||
addedIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of bufferedGroups) {
|
||||
if (!nextHotGroups.has(id)) {
|
||||
removedBufferedIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nextHotGroups,
|
||||
addedIds,
|
||||
removedHotIds,
|
||||
removedBufferedIds,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 seeking 场景下,根据当前时间选出应对齐滚动到的目标行索引。
|
||||
*
|
||||
* 若当前仍存在缓冲行,则优先对齐到最靠前的缓冲行;
|
||||
* 否则对齐到第一条开始时间不小于当前时间的歌词行。
|
||||
*/
|
||||
export function pickScrollToIndexForSeek(
|
||||
time: number,
|
||||
currentGroups: LyricLineGroupBase[],
|
||||
bufferedGroups: ReadonlySet<number>,
|
||||
): number {
|
||||
if (bufferedGroups.size > 0) {
|
||||
return Math.min(...bufferedGroups);
|
||||
}
|
||||
const foundIndex = currentGroups.findIndex(
|
||||
(group) => group.startTime >= time,
|
||||
);
|
||||
return foundIndex === -1 ? currentGroups.length : foundIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link commitPlayerTimeState} 的参数类型。
|
||||
*
|
||||
* 用于将一次时间线状态转移提交回 {@link PlayerTimelineState},
|
||||
* 并生成供宿主执行的副作用应用计划。
|
||||
*/
|
||||
export interface CommitPlayerTimeStateInput {
|
||||
/** 要被更新的时间线状态对象 */
|
||||
timelineState: PlayerTimelineState;
|
||||
/** 当前播放时间,单位为毫秒 */
|
||||
time: number;
|
||||
/** 当前用于计算的歌词数据 */
|
||||
currentGroups: LyricLineGroupBase[];
|
||||
/** 底部附加区域当前是否有可见内容 */
|
||||
hasBottomContent: boolean;
|
||||
/** 由 {@link computePlayerTimeState} 得到的状态转移结果 */
|
||||
stateResult: ComputePlayerTimeStateResult;
|
||||
}
|
||||
|
||||
/** {@link commitPlayerTimeState} 的返回类型 */
|
||||
export interface CommitPlayerTimeStateResult {
|
||||
/** 提交后是否需要重新布局 */
|
||||
shouldLayout: boolean;
|
||||
/** 提交后是否需要重置用户滚动状态 */
|
||||
shouldResetScroll: boolean;
|
||||
/** 需要启用的歌词组索引列表 */
|
||||
groupsToEnable: number[];
|
||||
/** 需要禁用的歌词组索引列表 */
|
||||
groupsToDisable: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交时间线状态转移的纯函数。
|
||||
*
|
||||
* 把一次时间线状态转移写回 {@link PlayerTimelineState},
|
||||
* 并返回一份供宿主执行的副作用应用计划,例如启用/禁用哪些歌词行、
|
||||
* 是否需要重置用户滚动状态、是否需要触发布局。
|
||||
*/
|
||||
export function commitPlayerTimeState(
|
||||
input: CommitPlayerTimeStateInput,
|
||||
): CommitPlayerTimeStateResult {
|
||||
const { timelineState, time, currentGroups, hasBottomContent, stateResult } =
|
||||
input;
|
||||
const { addedIds, removedHotIds, removedBufferedIds } = stateResult;
|
||||
const { isSeeking } = timelineState;
|
||||
|
||||
timelineState.currentTime = time;
|
||||
timelineState.hotGroups = stateResult.nextHotGroups;
|
||||
|
||||
let shouldLayout = false;
|
||||
let shouldResetScroll = false;
|
||||
const groupsToEnable: number[] = [];
|
||||
const groupsToDisable = new Set<number>();
|
||||
|
||||
if (isSeeking) {
|
||||
timelineState.bufferedGroups = new Set([...timelineState.hotGroups]);
|
||||
timelineState.scrollToIndex = pickScrollToIndexForSeek(
|
||||
time,
|
||||
currentGroups,
|
||||
timelineState.bufferedGroups,
|
||||
);
|
||||
for (const id of removedHotIds) groupsToDisable.add(id);
|
||||
for (const id of timelineState.hotGroups) groupsToEnable.push(id);
|
||||
for (const id of removedBufferedIds) groupsToDisable.add(id);
|
||||
|
||||
shouldResetScroll = true;
|
||||
shouldLayout = true;
|
||||
} else if (addedIds.size > 0) {
|
||||
for (const id of addedIds) {
|
||||
timelineState.bufferedGroups.add(id);
|
||||
groupsToEnable.push(id);
|
||||
}
|
||||
for (const id of removedBufferedIds) {
|
||||
timelineState.bufferedGroups.delete(id);
|
||||
groupsToDisable.add(id);
|
||||
}
|
||||
if (timelineState.bufferedGroups.size > 0) {
|
||||
timelineState.scrollToIndex = Math.min(...timelineState.bufferedGroups);
|
||||
}
|
||||
shouldLayout = true;
|
||||
} else if (
|
||||
removedBufferedIds.size > 0 &&
|
||||
eqSet(removedBufferedIds, timelineState.bufferedGroups)
|
||||
) {
|
||||
for (const id of timelineState.bufferedGroups) {
|
||||
if (timelineState.hotGroups.has(id)) continue;
|
||||
timelineState.bufferedGroups.delete(id);
|
||||
groupsToDisable.add(id);
|
||||
}
|
||||
shouldLayout = true;
|
||||
}
|
||||
|
||||
if (timelineState.bufferedGroups.size === 0 && currentGroups.length > 0) {
|
||||
const lastGroup = currentGroups[currentGroups.length - 1];
|
||||
if (time >= lastGroup.endTime) {
|
||||
const targetIndex = hasBottomContent
|
||||
? currentGroups.length
|
||||
: currentGroups.length - 1;
|
||||
if (timelineState.scrollToIndex !== targetIndex) {
|
||||
timelineState.scrollToIndex = targetIndex;
|
||||
shouldLayout = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
timelineState.lastCurrentTime = time;
|
||||
|
||||
return {
|
||||
shouldLayout,
|
||||
shouldResetScroll,
|
||||
groupsToEnable,
|
||||
groupsToDisable: [...groupsToDisable],
|
||||
};
|
||||
}
|
||||
247
amll-local/packages/core/src/lyric-player/dom/index.ts
Normal file
247
amll-local/packages/core/src/lyric-player/dom/index.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* @fileoverview
|
||||
* 一个播放歌词的组件
|
||||
* @author SteveXMH
|
||||
*/
|
||||
|
||||
import type { LyricLine } from "#interfaces";
|
||||
import "#styles/index.css";
|
||||
import { LyricPlayerBase } from "#lyric/base/index.ts";
|
||||
import type { LyricLineBase } from "#lyric/base/line.ts";
|
||||
import styles from "#styles/lyric-player.module.css";
|
||||
import { LyricLineGroup } from "./lyric-group.ts";
|
||||
import { LyricLineEl } from "./lyric-line.ts";
|
||||
|
||||
/**
|
||||
* 歌词行鼠标相关事件,可以获取到歌词行的索引、主歌词行以及背景歌词行(如果有)元素
|
||||
*/
|
||||
export class LyricLineMouseEvent extends MouseEvent {
|
||||
/**
|
||||
* 自定义标志位,用于记录外部是否调用了 `stopPropagation`
|
||||
*/
|
||||
public isPropagationStopped = false;
|
||||
|
||||
constructor(
|
||||
/**
|
||||
* 歌词行索引
|
||||
*/
|
||||
public readonly lineIndex: number,
|
||||
/**
|
||||
* 歌词行元素
|
||||
*/
|
||||
public readonly line: LyricLineBase,
|
||||
/**
|
||||
* 背景人声歌词行元素 (如果存在)
|
||||
*/
|
||||
public readonly bgLine: LyricLineBase | undefined,
|
||||
event: MouseEvent,
|
||||
) {
|
||||
super(`line-${event.type}`, event);
|
||||
}
|
||||
|
||||
override stopPropagation(): void {
|
||||
this.isPropagationStopped = true;
|
||||
super.stopPropagation();
|
||||
}
|
||||
|
||||
override stopImmediatePropagation(): void {
|
||||
this.isPropagationStopped = true;
|
||||
super.stopImmediatePropagation();
|
||||
}
|
||||
}
|
||||
|
||||
export type LyricLineMouseEventListener = (evt: LyricLineMouseEvent) => void;
|
||||
|
||||
/**
|
||||
* 歌词播放组件,本框架的核心组件
|
||||
*
|
||||
* 尽可能贴切 Apple Music for iPad 的歌词效果设计,且做了力所能及的优化措施
|
||||
*/
|
||||
export class DomLyricPlayer extends LyricPlayerBase {
|
||||
private abortController = new AbortController();
|
||||
override currentLyricGroups: LyricLineGroup[] = [];
|
||||
|
||||
override onResize(): void {
|
||||
const computedStyles = getComputedStyle(this.element);
|
||||
this._baseFontSize = Number.parseFloat(computedStyles.fontSize);
|
||||
this.rebuildStyle();
|
||||
}
|
||||
|
||||
readonly supportPlusLighter: boolean = CSS.supports(
|
||||
"mix-blend-mode",
|
||||
"plus-lighter",
|
||||
);
|
||||
readonly supportMaskImage: boolean = CSS.supports("mask-image", "none");
|
||||
readonly innerSize: [number, number] = [0, 0];
|
||||
|
||||
private readonly onMouseEventHandler = (e: MouseEvent) => {
|
||||
const target = e.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
|
||||
const groupEl = target.closest(`.${styles.lyricLineWrapper}`);
|
||||
if (!groupEl) return;
|
||||
|
||||
const group = this.lyricGroupElementMap.get(groupEl);
|
||||
if (!group) return;
|
||||
|
||||
const mainLine = group.mainLine;
|
||||
const bgLine = group.bgLine;
|
||||
const lineIndex = this.lyricLinesIndexes.get(mainLine) ?? -1;
|
||||
|
||||
if (e.type === "click") {
|
||||
this.resetScroll();
|
||||
}
|
||||
|
||||
const evt = new LyricLineMouseEvent(lineIndex, mainLine, bgLine, e);
|
||||
const isDispatched = this.dispatchEvent(evt);
|
||||
|
||||
if (!isDispatched || evt.defaultPrevented) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (evt.isPropagationStopped) {
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 是否为非逐词歌词
|
||||
* @internal
|
||||
*/
|
||||
_getIsNonDynamic(): boolean {
|
||||
return this.isNonDynamic;
|
||||
}
|
||||
private _baseFontSize = Number.parseFloat(
|
||||
getComputedStyle(this.element).fontSize,
|
||||
);
|
||||
public get baseFontSize(): number {
|
||||
return this._baseFontSize;
|
||||
}
|
||||
constructor() {
|
||||
super();
|
||||
this.onResize();
|
||||
this.element.classList.add("amll-lyric-player", "dom");
|
||||
if (this.disableSpring) {
|
||||
this.element.classList.add(styles.disableSpring);
|
||||
}
|
||||
|
||||
this.element.addEventListener("click", this.onMouseEventHandler, {
|
||||
signal: this.abortController.signal,
|
||||
});
|
||||
this.element.addEventListener("contextmenu", this.onMouseEventHandler, {
|
||||
signal: this.abortController.signal,
|
||||
});
|
||||
}
|
||||
|
||||
private rebuildStyle() {
|
||||
// const width = this.innerSize[0];
|
||||
// const height = this.innerSize[1];
|
||||
// this.element.style.setProperty("--amll-lp-width", `${width.toFixed(4)}px`);
|
||||
// this.element.style.setProperty(
|
||||
// "--amll-lp-height",
|
||||
// `${height.toFixed(4)}px`,
|
||||
// );
|
||||
}
|
||||
|
||||
override setWordFadeWidth(value = 0.5): void {
|
||||
super.setWordFadeWidth(value);
|
||||
for (const group of this.currentLyricGroups) {
|
||||
group.mainLine.updateMaskImageSync();
|
||||
group.bgLine?.updateMaskImageSync();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置当前播放歌词,要注意传入后这个数组内的信息不得修改,否则会发生错误
|
||||
* @param lines 歌词数组
|
||||
* @param initialTime 初始时间,默认为 0
|
||||
*/
|
||||
override setLyricLines(lines: LyricLine[], initialTime = 0): void {
|
||||
super.setLyricLines(lines, initialTime);
|
||||
if (this.hasDuetLine) {
|
||||
this.element.classList.add(styles.hasDuetLine);
|
||||
} else {
|
||||
this.element.classList.remove(styles.hasDuetLine);
|
||||
}
|
||||
if (!this.supportMaskImage) {
|
||||
this.element.style.setProperty("--amll-player-time", `${initialTime}`);
|
||||
}
|
||||
|
||||
for (const group of this.currentLyricGroups) {
|
||||
group.dispose();
|
||||
}
|
||||
this.currentLyricGroups = [];
|
||||
|
||||
let currentGroup: LyricLineGroup | null = null;
|
||||
|
||||
for (let i = 0; i < this.processedLines.length; i++) {
|
||||
const line = this.processedLines[i];
|
||||
const lineEl = new LyricLineEl(this, line);
|
||||
|
||||
this.lyricLinesIndexes.set(lineEl, i);
|
||||
|
||||
if (!line.isBG || !currentGroup) {
|
||||
currentGroup = new LyricLineGroup(this, lineEl);
|
||||
this.currentLyricGroups.push(currentGroup);
|
||||
this.lyricGroupElementMap.set(currentGroup.element, currentGroup);
|
||||
} else {
|
||||
currentGroup.addBgLine(lineEl);
|
||||
}
|
||||
}
|
||||
|
||||
this.setLinePosXSpringParams({});
|
||||
this.setLinePosYSpringParams({});
|
||||
this.setLineScaleSpringParams({});
|
||||
this.setCurrentTime(initialTime, true);
|
||||
this.calcLayout(true);
|
||||
this.update(0);
|
||||
}
|
||||
|
||||
override pause(): void {
|
||||
super.pause();
|
||||
this.element.classList.remove(styles.playing);
|
||||
this.interludeDots.pause();
|
||||
for (const group of this.currentLyricGroups) {
|
||||
group.mainLine.pause();
|
||||
group.bgLine?.pause();
|
||||
}
|
||||
}
|
||||
|
||||
override resume(): void {
|
||||
super.resume();
|
||||
this.element.classList.add(styles.playing);
|
||||
this.interludeDots.resume();
|
||||
for (const group of this.currentLyricGroups) {
|
||||
group.mainLine.resume();
|
||||
group.bgLine?.resume();
|
||||
}
|
||||
}
|
||||
|
||||
override update(delta = 0): void {
|
||||
if (!this.timelineState.initialLayoutFinished) return;
|
||||
super.update(delta);
|
||||
if (!this.supportMaskImage) {
|
||||
this.element.style.setProperty(
|
||||
"--amll-player-time",
|
||||
`${this.timelineState.currentTime}`,
|
||||
);
|
||||
}
|
||||
if (!this.isPageVisible) return;
|
||||
const deltaS = delta / 1000;
|
||||
for (const group of this.currentLyricGroups) {
|
||||
group.update(deltaS);
|
||||
}
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
super.dispose();
|
||||
this.abortController.abort();
|
||||
this.element.remove();
|
||||
for (const group of this.currentLyricGroups) {
|
||||
group.dispose();
|
||||
}
|
||||
this.bottomLine.dispose();
|
||||
this.interludeDots.dispose();
|
||||
}
|
||||
}
|
||||
153
amll-local/packages/core/src/lyric-player/dom/interlude-dots.ts
Normal file
153
amll-local/packages/core/src/lyric-player/dom/interlude-dots.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import type { Disposable, HasElement } from "#interfaces";
|
||||
import styles from "#styles/lyric-player.module.css";
|
||||
import { clamp, clamp01, clampPositive } from "#utils/clamp.ts";
|
||||
|
||||
function easeInOutBack(x: number): number {
|
||||
const c1 = 1.70158;
|
||||
const c2 = c1 * 1.525;
|
||||
|
||||
return x < 0.5
|
||||
? ((2 * x) ** 2 * ((c2 + 1) * 2 * x - c2)) / 2
|
||||
: ((2 * x - 2) ** 2 * ((c2 + 1) * (x * 2 - 2) + c2) + 2) / 2;
|
||||
}
|
||||
|
||||
function easeOutExpo(x: number): number {
|
||||
return x === 1 ? 1 : 1 - 2 ** (-10 * x);
|
||||
}
|
||||
|
||||
export class InterludeDots implements HasElement, Disposable {
|
||||
private element: HTMLElement = document.createElement("div");
|
||||
private dot0: HTMLElement = document.createElement("span");
|
||||
private dot1: HTMLElement = document.createElement("span");
|
||||
private dot2: HTMLElement = document.createElement("span");
|
||||
private left = 0;
|
||||
private top = 0;
|
||||
private playing = true;
|
||||
private lastStyle = "";
|
||||
private currentInterlude?: [number, number];
|
||||
private currentTime = 0;
|
||||
private targetBreatheDuration = 1500;
|
||||
constructor() {
|
||||
this.element.className = styles.interludeDots;
|
||||
this.element.appendChild(this.dot0);
|
||||
this.element.appendChild(this.dot1);
|
||||
this.element.appendChild(this.dot2);
|
||||
}
|
||||
getElement(): HTMLElement {
|
||||
return this.element;
|
||||
}
|
||||
setTransform(left: number = this.left, top: number = this.top): void {
|
||||
this.left = left;
|
||||
this.top = top;
|
||||
this.update();
|
||||
}
|
||||
setInterlude(interlude?: [number, number]): void {
|
||||
this.currentInterlude = interlude;
|
||||
this.currentTime = interlude?.[0] ?? 0;
|
||||
if (interlude) {
|
||||
this.element.classList.add(styles.enabled);
|
||||
} else {
|
||||
this.element.classList.remove(styles.enabled);
|
||||
}
|
||||
}
|
||||
pause(): void {
|
||||
this.playing = false;
|
||||
this.element.classList.remove(styles.playing);
|
||||
}
|
||||
resume(): void {
|
||||
this.playing = true;
|
||||
this.element.classList.add(styles.playing);
|
||||
}
|
||||
update(delta = 0): void {
|
||||
if (!this.playing) return;
|
||||
this.currentTime += delta;
|
||||
let curStyle = "";
|
||||
|
||||
curStyle += `transform:translate(${this.left.toFixed(
|
||||
2,
|
||||
)}px, ${this.top.toFixed(2)}px)`;
|
||||
|
||||
// 计算缩放大小
|
||||
|
||||
if (this.currentInterlude) {
|
||||
const interludeDuration =
|
||||
this.currentInterlude[1] - this.currentInterlude[0];
|
||||
const currentDuration = this.currentTime - this.currentInterlude[0];
|
||||
if (currentDuration <= interludeDuration) {
|
||||
const breatheDuration =
|
||||
interludeDuration /
|
||||
Math.ceil(interludeDuration / this.targetBreatheDuration);
|
||||
let scale = 1;
|
||||
let globalOpacity = 1;
|
||||
|
||||
scale *=
|
||||
Math.sin(1.5 * Math.PI - (currentDuration / breatheDuration) * 2) /
|
||||
20 +
|
||||
1;
|
||||
|
||||
if (currentDuration < 2000) {
|
||||
scale *= easeOutExpo(currentDuration / 2000);
|
||||
}
|
||||
|
||||
if (currentDuration < 500) {
|
||||
globalOpacity = 0;
|
||||
} else if (currentDuration < 1000) {
|
||||
globalOpacity *= (currentDuration - 500) / 500;
|
||||
}
|
||||
|
||||
if (interludeDuration - currentDuration < 750) {
|
||||
scale *=
|
||||
1 -
|
||||
easeInOutBack(
|
||||
(750 - (interludeDuration - currentDuration)) / 750 / 2,
|
||||
);
|
||||
}
|
||||
if (interludeDuration - currentDuration < 375) {
|
||||
globalOpacity *= clamp01((interludeDuration - currentDuration) / 375);
|
||||
}
|
||||
|
||||
const dotsDuration = clampPositive(interludeDuration - 750);
|
||||
|
||||
scale = clampPositive(scale) * 0.7;
|
||||
|
||||
curStyle += ` scale(${scale})`;
|
||||
|
||||
const dot0Opacity = clamp(
|
||||
0.25,
|
||||
((currentDuration * 3) / dotsDuration) * 0.75,
|
||||
1,
|
||||
);
|
||||
const dot1Opacity = clamp(
|
||||
0.25,
|
||||
(((currentDuration - dotsDuration / 3) * 3) / dotsDuration) * 0.75,
|
||||
1,
|
||||
);
|
||||
const dot2Opacity = clamp(
|
||||
0.25,
|
||||
(((currentDuration - (dotsDuration / 3) * 2) * 3) / dotsDuration) *
|
||||
0.75,
|
||||
1,
|
||||
);
|
||||
|
||||
this.dot0.style.opacity = `${clamp01(globalOpacity * dot0Opacity)}`;
|
||||
this.dot1.style.opacity = `${clamp01(globalOpacity * dot1Opacity)}`;
|
||||
this.dot2.style.opacity = `${clamp01(globalOpacity * dot2Opacity)}`;
|
||||
} else {
|
||||
curStyle += " scale(0)";
|
||||
this.dot0.style.opacity = "0";
|
||||
this.dot1.style.opacity = "0";
|
||||
this.dot2.style.opacity = "0";
|
||||
}
|
||||
|
||||
curStyle += ";";
|
||||
|
||||
if (this.lastStyle !== curStyle) {
|
||||
this.element.setAttribute("style", curStyle);
|
||||
this.lastStyle = curStyle;
|
||||
}
|
||||
}
|
||||
}
|
||||
dispose(): void {
|
||||
this.element.remove();
|
||||
}
|
||||
}
|
||||
173
amll-local/packages/core/src/lyric-player/dom/lyric-group.ts
Normal file
173
amll-local/packages/core/src/lyric-player/dom/lyric-group.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { LyricLineGroupBase } from "#lyric/base/group.ts";
|
||||
import styles from "#styles/lyric-player.module.css";
|
||||
import { clamp01 } from "#utils/clamp.ts";
|
||||
import type { DomLyricPlayer } from "./index.ts";
|
||||
import type { LyricLineEl } from "./lyric-line.ts";
|
||||
|
||||
export class LyricLineGroup extends LyricLineGroupBase<LyricLineEl> {
|
||||
public element: HTMLElement;
|
||||
public bgWrapper?: HTMLElement;
|
||||
private lastIsActive?: boolean;
|
||||
|
||||
constructor(
|
||||
public lyricPlayer: DomLyricPlayer,
|
||||
mainLine: LyricLineEl,
|
||||
) {
|
||||
super(mainLine);
|
||||
this.element = document.createElement("div");
|
||||
this.element.className = styles.lyricLineWrapper;
|
||||
this.element.appendChild(mainLine.getElement());
|
||||
this.posY.setPosition(window.innerHeight * 2);
|
||||
|
||||
lyricPlayer.resizeObserver.observe(this.element);
|
||||
}
|
||||
|
||||
get isInSight(): boolean {
|
||||
const t = this.posY.getCurrentPosition();
|
||||
|
||||
let h = this.lyricPlayer.lyricGroupSize?.get(this)?.[1];
|
||||
if (h === undefined || h === 0) {
|
||||
h = this.element.clientHeight || 0;
|
||||
}
|
||||
|
||||
const pb = this.lyricPlayer.size[1];
|
||||
const ov = this.lyricPlayer.getOverscanPx();
|
||||
|
||||
return !(t > pb + h + ov || t < -h - ov);
|
||||
}
|
||||
|
||||
show(): void {
|
||||
if (!this.element.parentElement) {
|
||||
const playerEl = this.lyricPlayer.getElement();
|
||||
const groups = this.lyricPlayer.currentLyricGroups;
|
||||
const myIndex = groups.indexOf(this);
|
||||
|
||||
let referenceNode: HTMLElement | null = null;
|
||||
if (myIndex !== -1) {
|
||||
for (let i = myIndex + 1; i < groups.length; i++) {
|
||||
if (groups[i].element.parentElement === playerEl) {
|
||||
referenceNode = groups[i].element;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
playerEl.insertBefore(this.element, referenceNode);
|
||||
|
||||
this.lyricPlayer.resizeObserver.observe(this.element);
|
||||
}
|
||||
|
||||
this.mainLine.show();
|
||||
this.bgLine?.show();
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
if (this.element.parentElement) {
|
||||
this.lyricPlayer.resizeObserver.unobserve(this.element);
|
||||
this.element.remove();
|
||||
|
||||
this.mainLine.teardownContent();
|
||||
this.bgLine?.teardownContent();
|
||||
}
|
||||
}
|
||||
|
||||
override update(delta: number): void {
|
||||
if (this.isInSight) {
|
||||
this.show();
|
||||
} else {
|
||||
this.hide();
|
||||
}
|
||||
|
||||
super.update(delta);
|
||||
}
|
||||
|
||||
addBgLine(bgLine: LyricLineEl): void {
|
||||
if (this.bgLine) {
|
||||
this.bgLine.dispose();
|
||||
}
|
||||
if (this.bgWrapper) {
|
||||
this.bgWrapper.remove();
|
||||
}
|
||||
|
||||
this.bgLine = bgLine;
|
||||
|
||||
// 需要对比第一个词的开始时间而不是行起始时间,因为行的起始时间已经被
|
||||
// `syncMainAndBackgroundLines` 同步过了
|
||||
const bgStartTime =
|
||||
bgLine.getLine().words[0]?.startTime ?? bgLine.getLine().startTime;
|
||||
const mainStartTime =
|
||||
this.mainLine.getLine().words[0]?.startTime ??
|
||||
this.mainLine.getLine().startTime;
|
||||
|
||||
this.isBgFirst = bgStartTime < mainStartTime;
|
||||
|
||||
if (this.mainLine.getLine().isDuet) {
|
||||
bgLine.getElement().classList.add(styles.lyricDuetLine);
|
||||
}
|
||||
|
||||
this.bgWrapper = document.createElement("div");
|
||||
this.bgWrapper.className = styles.bgWrapper;
|
||||
|
||||
this.bgWrapper.appendChild(bgLine.getElement());
|
||||
|
||||
const alwaysPostposition =
|
||||
this.lyricPlayer.getAlwaysPostpositionBackground();
|
||||
const shouldBgFirst = !alwaysPostposition && this.isBgFirst;
|
||||
|
||||
if (shouldBgFirst) {
|
||||
this.bgWrapper.classList.add(styles.bgWrapperTop);
|
||||
this.element.insertBefore(this.bgWrapper, this.mainLine.getElement());
|
||||
this.bgSlideY.setPosition(80);
|
||||
} else {
|
||||
this.element.appendChild(this.bgWrapper);
|
||||
}
|
||||
}
|
||||
|
||||
protected renderStyles(): void {
|
||||
const y = this.posY.getCurrentPosition().toFixed(1);
|
||||
|
||||
this.element.style.transform = `translateY(${y}px)`;
|
||||
this.element.style.opacity = this.opacity.toString();
|
||||
this.element.style.filter = `blur(${Math.min(5, this.blur)}px)`;
|
||||
|
||||
if (!this.lyricPlayer.getEnableSpring()) {
|
||||
this.element.style.transitionDelay = `${this.delay}ms`;
|
||||
}
|
||||
|
||||
if (this.bgWrapper) {
|
||||
if (this.lastIsActive !== this.isActive) {
|
||||
this.lastIsActive = this.isActive;
|
||||
this.bgWrapper.classList.toggle(styles.bgWrapperActive, this.isActive);
|
||||
}
|
||||
|
||||
const slideY = this.bgSlideY.getCurrentPosition();
|
||||
const slideYStr = slideY.toFixed(1);
|
||||
const activeProgress = clamp01(1 - Math.abs(slideY) / 80);
|
||||
|
||||
const scaleStr = (0.8 + activeProgress * 0.2).toFixed(3);
|
||||
this.bgWrapper.style.transform = `translateY(${slideYStr}%) scale(${scaleStr})`;
|
||||
|
||||
const alwaysPostposition =
|
||||
this.lyricPlayer.getAlwaysPostpositionBackground();
|
||||
const shouldBgFirst = !alwaysPostposition && this.isBgFirst;
|
||||
|
||||
if (shouldBgFirst) {
|
||||
const bgHeight = this.bgWrapper.clientHeight || 0;
|
||||
const currentMarginTop = -bgHeight * (1 - activeProgress);
|
||||
this.bgWrapper.style.marginTop = `${currentMarginTop.toFixed(1)}px`;
|
||||
} else {
|
||||
this.bgWrapper.style.marginTop = "";
|
||||
}
|
||||
|
||||
const targetHiddenYStr = shouldBgFirst ? "80.0" : "-80.0";
|
||||
const isHidden = slideYStr === targetHiddenYStr && !this.isActive;
|
||||
this.bgWrapper.classList.toggle(styles.bgWrapperHidden, isHidden);
|
||||
}
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
super.dispose();
|
||||
this.lyricPlayer.resizeObserver.unobserve(this.element);
|
||||
this.element.remove();
|
||||
}
|
||||
}
|
||||
1080
amll-local/packages/core/src/lyric-player/dom/lyric-line.ts
Normal file
1080
amll-local/packages/core/src/lyric-player/dom/lyric-line.ts
Normal file
File diff suppressed because it is too large
Load Diff
13
amll-local/packages/core/src/lyric-player/index.ts
Normal file
13
amll-local/packages/core/src/lyric-player/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { DomLyricPlayer } from "./dom/index.ts";
|
||||
|
||||
export * from "./base/consts.ts";
|
||||
export * from "./base/index.ts";
|
||||
|
||||
export * from "./dom/index.ts";
|
||||
|
||||
export {
|
||||
/**
|
||||
* 默认导出的歌词播放组件
|
||||
*/
|
||||
DomLyricPlayer as LyricPlayer,
|
||||
};
|
||||
36
amll-local/packages/core/src/styles/index.css
Normal file
36
amll-local/packages/core/src/styles/index.css
Normal file
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
给不需要重命名的全局类名的样式
|
||||
*/
|
||||
|
||||
/* 给歌词视图元素应用的样式 */
|
||||
.amll-lyric-player {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
color: var(--amll-lp-color, white);
|
||||
contain: strict;
|
||||
mix-blend-mode: plus-lighter;
|
||||
font-size: var(--amll-lp-font-size, max(max(5vh, 2.5vw), 12px));
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
font-size: var(--amll-lp-font-size, max(8vw, 12px));
|
||||
}
|
||||
|
||||
&.dom {
|
||||
--amll-lp-line-width-aspect: 0.8;
|
||||
--amll-lp-line-padding-x: 1em;
|
||||
--amll-lp-bg-line-scale: 0.7;
|
||||
user-select: none;
|
||||
box-sizing: content-box;
|
||||
z-index: 1;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.amll-lyric-player {
|
||||
--amll-lp-line-width-aspect: 1;
|
||||
--amll-lp-line-padding-x: 0;
|
||||
}
|
||||
}
|
||||
298
amll-local/packages/core/src/styles/lyric-player.module.css
Normal file
298
amll-local/packages/core/src/styles/lyric-player.module.css
Normal file
@@ -0,0 +1,298 @@
|
||||
.lyricLineWrapper {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.4em var(--lyric-line-padding-x);
|
||||
border-radius: 0.25em;
|
||||
|
||||
gap: 0.3em;
|
||||
|
||||
will-change: transform, opacity, filter;
|
||||
transition:
|
||||
opacity 0.4s ease,
|
||||
filter 0.4s ease,
|
||||
background-color 0.25s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--amll-lp-hover-bg-color, #fff1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--amll-lp-hover-bg-color, #ffffff05);
|
||||
}
|
||||
}
|
||||
|
||||
.lyricLine {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
width: var(--amll-lp-width, 100%);
|
||||
min-width: var(--amll-lp-width, 100%);
|
||||
max-width: var(--amll-lp-width, 100%);
|
||||
height: fit-content;
|
||||
|
||||
/* Keep font descenders inside the paint containment without changing layout. */
|
||||
padding: 0.2em 0.2em 0.32em;
|
||||
margin: -0.2em -0.2em -0.32em;
|
||||
|
||||
contain: layout style paint;
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 40px;
|
||||
backface-visibility: hidden;
|
||||
transform-origin: left;
|
||||
will-change: transform, filter, opacity;
|
||||
|
||||
transition: box-shadow 0.25s;
|
||||
}
|
||||
|
||||
.lyricDuetLine {
|
||||
text-align: right;
|
||||
transform-origin: right;
|
||||
}
|
||||
|
||||
.lyricMainLine {
|
||||
margin: -1em;
|
||||
padding: 1em;
|
||||
transition: opacity 0.3s 0.1s;
|
||||
contain: layout style;
|
||||
|
||||
& span {
|
||||
display: inline-block;
|
||||
text-align: start;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.romanWord {
|
||||
display: flex;
|
||||
padding-inline-end: 0.3em;
|
||||
font-size: 0.5em;
|
||||
line-height: 1em;
|
||||
}
|
||||
|
||||
.rubyWord {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-height: 1em;
|
||||
font-size: 0.5em;
|
||||
line-height: 1em;
|
||||
}
|
||||
|
||||
.wordWithRuby {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.wordBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
& > span,
|
||||
span.emphasizeWrapper {
|
||||
display: inline-block;
|
||||
margin: -1em;
|
||||
padding: 1em;
|
||||
white-space: pre-wrap;
|
||||
vertical-align: bottom;
|
||||
will-change: transform, text-shadow;
|
||||
contain: layout style;
|
||||
|
||||
&.emphasize,
|
||||
span.emphasize {
|
||||
margin: -1em;
|
||||
padding: 1em;
|
||||
backface-visibility: hidden;
|
||||
|
||||
& > span {
|
||||
margin: -1em;
|
||||
padding: 1em;
|
||||
will-change: transform, text-shadow;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lyricBgLine {
|
||||
opacity: 0.4;
|
||||
font-size: max(calc(1em * var(--amll-lp-bg-line-scale, 0.7)), 10px);
|
||||
transition:
|
||||
background-color 0.25s,
|
||||
box-shadow 0.25s;
|
||||
|
||||
.lyricMainLine {
|
||||
padding: 1.2em 1em;
|
||||
}
|
||||
|
||||
&.active {
|
||||
opacity: 0.4;
|
||||
transition:
|
||||
background-color 0.25s,
|
||||
box-shadow 0.25s;
|
||||
}
|
||||
}
|
||||
|
||||
.lyricSubLine {
|
||||
opacity: 0.3;
|
||||
font-size: max(0.5em, 10px);
|
||||
line-height: 1.5em;
|
||||
transition: opacity 0.2s 0.25s;
|
||||
|
||||
@supports (mix-blend-mode: plus-lighter) {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
.bottomLine {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
line-height: 1.8em;
|
||||
cursor: default;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
height: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.bgWrapper {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: var(--lyric-line-padding-x);
|
||||
z-index: -1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: inherit;
|
||||
width: calc(100% - var(--lyric-line-padding-x) * 2);
|
||||
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
opacity: 0;
|
||||
transform-origin: left top;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.bgWrapperTop {
|
||||
position: relative;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
top: auto;
|
||||
bottom: auto;
|
||||
margin-top: -999px;
|
||||
transform-origin: left bottom;
|
||||
}
|
||||
|
||||
.bgWrapperActive {
|
||||
position: relative;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
top: auto;
|
||||
bottom: auto;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.bgWrapperHidden {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.interludeDots {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
display: flex;
|
||||
gap: 0.25em;
|
||||
width: fit-content;
|
||||
height: clamp(0.5em, 1vh, 3em);
|
||||
padding: 2.5% 0.75em;
|
||||
|
||||
opacity: 0;
|
||||
transform-origin: center;
|
||||
transition: opacity 0.25s;
|
||||
|
||||
&.enabled {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
& > * {
|
||||
display: inline-block;
|
||||
width: clamp(0.5em, 1vh, 3em);
|
||||
height: clamp(0.5em, 1vh, 3em);
|
||||
margin-right: 4px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--amll-lp-color, white);
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
&.duet {
|
||||
right: 0;
|
||||
transform-origin: center;
|
||||
}
|
||||
}
|
||||
|
||||
.disableSpring > *,
|
||||
.disableSpring .lyricLine {
|
||||
transition:
|
||||
filter 0.25s,
|
||||
transform 0.5s,
|
||||
background-color 0.25s,
|
||||
box-shadow 0.25s;
|
||||
}
|
||||
|
||||
.tmpDisableTransition {
|
||||
/* biome-ignore lint/complexity/noImportantStyles: 覆盖内联样式 */
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
:global(.amll-lyric-player) {
|
||||
--lyric-line-padding-x: 1em;
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
--lyric-line-padding-x: 20px;
|
||||
}
|
||||
|
||||
&:hover .lyricLine,
|
||||
&:hover .lyricLineWrapper {
|
||||
/* biome-ignore lint/complexity/noImportantStyles: 覆盖内联样式 */
|
||||
filter: unset !important;
|
||||
}
|
||||
|
||||
&:not(.playing) .bgWrapper {
|
||||
position: relative;
|
||||
top: auto;
|
||||
bottom: auto;
|
||||
opacity: 1;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&.hasDuetLine {
|
||||
.lyricLine:not(.lyricDuetLine) {
|
||||
padding-right: 15%;
|
||||
}
|
||||
|
||||
.lyricDuetLine {
|
||||
padding-left: 15%;
|
||||
}
|
||||
|
||||
.lyricLineWrapper:has(.lyricDuetLine) {
|
||||
align-items: flex-end;
|
||||
|
||||
.bgWrapper {
|
||||
transform-origin: right top;
|
||||
}
|
||||
|
||||
.bgWrapperTop {
|
||||
transform-origin: right bottom;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
29
amll-local/packages/core/src/types.d.ts
vendored
Normal file
29
amll-local/packages/core/src/types.d.ts
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
/** biome-ignore-all lint/correctness/noUnusedVariables: used */
|
||||
declare module "*.glsl" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module "*.glsl?raw" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module "*.module.css" {
|
||||
const classes: Record<string, string>;
|
||||
export default classes;
|
||||
}
|
||||
|
||||
declare module "*.css" {
|
||||
const css: string;
|
||||
export default css;
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly DEV: boolean;
|
||||
readonly PROD: boolean;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
11
amll-local/packages/core/src/utils/clamp.ts
Normal file
11
amll-local/packages/core/src/utils/clamp.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export function clamp(x: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(x, min), max);
|
||||
}
|
||||
|
||||
export function clamp01(x: number): number {
|
||||
return clamp(x, 0, 1);
|
||||
}
|
||||
|
||||
export function clampPositive(x: number): number {
|
||||
return Math.max(0, x);
|
||||
}
|
||||
34
amll-local/packages/core/src/utils/debounce.ts
Normal file
34
amll-local/packages/core/src/utils/debounce.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// biome-ignore lint/suspicious/noExplicitAny: for debounce function
|
||||
export function debounce<T extends (...args: any) => any>(
|
||||
cb: T,
|
||||
wait = 20,
|
||||
): (...args: Parameters<T>) => void {
|
||||
let h: ReturnType<typeof setTimeout> | undefined;
|
||||
const callable = (...args: Parameters<T>) => {
|
||||
clearTimeout(h);
|
||||
h = setTimeout(() => cb(...args), wait);
|
||||
};
|
||||
return callable;
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: function can be any
|
||||
export function debounceFrame<T extends (...args: any) => any>(
|
||||
cb: T,
|
||||
frameTime = 1,
|
||||
): (...args: Parameters<T>) => void {
|
||||
let h = 0;
|
||||
let ft = frameTime;
|
||||
const callable = (...args: Parameters<T>) => {
|
||||
ft = frameTime;
|
||||
cancelAnimationFrame(h);
|
||||
const onCB = () => {
|
||||
if (--ft <= 0) {
|
||||
cb(...args);
|
||||
} else {
|
||||
h = requestAnimationFrame(onCB);
|
||||
}
|
||||
};
|
||||
h = requestAnimationFrame(onCB);
|
||||
};
|
||||
return callable;
|
||||
}
|
||||
8
amll-local/packages/core/src/utils/derivative.ts
Normal file
8
amll-local/packages/core/src/utils/derivative.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export function derivative(f: (x: number) => number) {
|
||||
const h = 0.001;
|
||||
return (x: number): number => (f(x + h) - f(x - h)) / (2 * h);
|
||||
}
|
||||
|
||||
export function getVelocity(f: (t: number) => number): (t: number) => number {
|
||||
return derivative(f);
|
||||
}
|
||||
4
amll-local/packages/core/src/utils/eq-set.ts
Normal file
4
amll-local/packages/core/src/utils/eq-set.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const eqSet: <T>(xs: Set<T>, ys: Set<T>) => boolean = (
|
||||
xs,
|
||||
ys,
|
||||
): boolean => xs.size === ys.size && [...xs].every((x) => ys.has(x));
|
||||
59
amll-local/packages/core/src/utils/flagsets.ts
Normal file
59
amll-local/packages/core/src/utils/flagsets.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
const SAFE_FLAG_CHUNK_SIZE = Number.MAX_SAFE_INTEGER.toString(2).length;
|
||||
|
||||
export class FlagSets {
|
||||
private flags: number[] = [];
|
||||
private _size = 0;
|
||||
|
||||
get size(): number {
|
||||
return this._size;
|
||||
}
|
||||
|
||||
[Symbol.iterator]() {
|
||||
let flag = 0;
|
||||
let chunkIndex = 0;
|
||||
let chunkOffset = 0;
|
||||
return {
|
||||
next: (): IteratorResult<number> => {
|
||||
while (chunkIndex < this.flags.length) {
|
||||
while (chunkOffset < SAFE_FLAG_CHUNK_SIZE) {
|
||||
const flagBit = 1 << chunkOffset;
|
||||
if (this.flags[chunkIndex] & flagBit) {
|
||||
return { value: flag, done: false };
|
||||
}
|
||||
flag++;
|
||||
chunkOffset++;
|
||||
}
|
||||
chunkIndex++;
|
||||
chunkOffset = 0;
|
||||
}
|
||||
return { value: undefined, done: true };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
add(flag: number): void {
|
||||
const chunkIndex = (flag / SAFE_FLAG_CHUNK_SIZE) | 0;
|
||||
const chunkOffset = flag % SAFE_FLAG_CHUNK_SIZE;
|
||||
const flagBit = 1 << chunkOffset;
|
||||
if (!(this.flags[chunkIndex] & flagBit)) {
|
||||
this._size++;
|
||||
this.flags[chunkIndex] |= flagBit;
|
||||
}
|
||||
}
|
||||
|
||||
delete(flag: number): void {
|
||||
const chunkIndex = (flag / SAFE_FLAG_CHUNK_SIZE) | 0;
|
||||
const chunkOffset = flag % SAFE_FLAG_CHUNK_SIZE;
|
||||
const flagBit = 1 << chunkOffset;
|
||||
if (!(this.flags[chunkIndex] & flagBit)) {
|
||||
this._size--;
|
||||
this.flags[chunkIndex] &= ~(1 << chunkOffset);
|
||||
}
|
||||
}
|
||||
|
||||
has(flag: number): boolean {
|
||||
const chunkIndex = (flag / SAFE_FLAG_CHUNK_SIZE) | 0;
|
||||
const chunkOffset = flag % SAFE_FLAG_CHUNK_SIZE;
|
||||
return (this.flags[chunkIndex] & (1 << chunkOffset)) !== 0;
|
||||
}
|
||||
}
|
||||
3
amll-local/packages/core/src/utils/is-cjk.ts
Normal file
3
amll-local/packages/core/src/utils/is-cjk.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const isCJK = (char: string): boolean => {
|
||||
return /^[\p{Unified_Ideograph}\u0800-\u9FFC]+$/u.test(char);
|
||||
};
|
||||
283
amll-local/packages/core/src/utils/line-balancer.ts
Normal file
283
amll-local/packages/core/src/utils/line-balancer.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { clampPositive } from "./clamp.ts";
|
||||
import { type ChildNodeInfo, calcBalancedBreaks } from "./lyric-line-break.ts";
|
||||
|
||||
let sharedCanvasCtx: CanvasRenderingContext2D | null = null;
|
||||
export function getMeasurementContext(): CanvasRenderingContext2D | null {
|
||||
if (!sharedCanvasCtx) {
|
||||
const canvas = document.createElement("canvas");
|
||||
sharedCanvasCtx = canvas.getContext("2d");
|
||||
}
|
||||
return sharedCanvasCtx;
|
||||
}
|
||||
|
||||
interface LineBalanceAdapter {
|
||||
resetDOM(): void;
|
||||
buildChildInfos(): { childInfos: ChildNodeInfo[]; fullText: string };
|
||||
applyBreaks(breaks: number[], childInfos: ChildNodeInfo[]): void;
|
||||
needsCalibration: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用于平衡歌词行在换行后的各行长度
|
||||
*/
|
||||
export class LineBalancer {
|
||||
private isBalancing = false;
|
||||
private lastBalancedContainerWidth = -1;
|
||||
|
||||
constructor(private mainElement: HTMLDivElement) {}
|
||||
|
||||
public balanceLineBreaks(
|
||||
isNonDynamic: boolean,
|
||||
hasSplittedWords: boolean,
|
||||
wordSegmenter: Intl.Segmenter,
|
||||
): void {
|
||||
if (this.isBalancing || !this.mainElement) return;
|
||||
|
||||
const computedStyle = getComputedStyle(this.mainElement);
|
||||
const paddingLeft = Number.parseFloat(computedStyle.paddingLeft) || 0;
|
||||
const paddingRight = Number.parseFloat(computedStyle.paddingRight) || 0;
|
||||
const containerWidth =
|
||||
this.mainElement.clientWidth - paddingLeft - paddingRight;
|
||||
|
||||
if (containerWidth <= 0) return;
|
||||
|
||||
if (isNonDynamic) {
|
||||
this.balanceNonDynamicLineBreaks(
|
||||
containerWidth,
|
||||
computedStyle,
|
||||
wordSegmenter,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasSplittedWords) return;
|
||||
this.balanceDynamicLineBreaks(containerWidth, wordSegmenter);
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.lastBalancedContainerWidth = -1;
|
||||
}
|
||||
|
||||
private executeLineBalance(
|
||||
containerWidth: number,
|
||||
adapter: LineBalanceAdapter,
|
||||
wordSegmenter: Intl.Segmenter,
|
||||
): void {
|
||||
const existingBrs = this.mainElement.querySelectorAll("br");
|
||||
if (
|
||||
containerWidth === this.lastBalancedContainerWidth &&
|
||||
existingBrs.length > 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
adapter.resetDOM();
|
||||
|
||||
// 临时设置 white-space: nowrap 以便测量单个歌词行的宽度
|
||||
const prevWhiteSpace = this.mainElement.style.whiteSpace;
|
||||
this.mainElement.style.whiteSpace = "nowrap";
|
||||
|
||||
// 临时移除父级的 transform 以便让 getBoundingClientRect 返回纯粹的布局尺寸
|
||||
// 基类在 enableScale 时设置的 0.97 缩放倍率会影响到计算的宽度
|
||||
const parentElement = this.mainElement.parentElement;
|
||||
let prevTransform = "";
|
||||
let transformChanged = false;
|
||||
|
||||
if (parentElement) {
|
||||
prevTransform = parentElement.style.transform;
|
||||
if (prevTransform && prevTransform !== "none") {
|
||||
parentElement.style.transform = "none";
|
||||
transformChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
let lockAcquired = false;
|
||||
|
||||
try {
|
||||
const { childInfos, fullText } = adapter.buildChildInfos();
|
||||
|
||||
let layoutWidth = childInfos.reduce((sum, c) => sum + c.width, 0);
|
||||
|
||||
// 非动态歌词(用 Canvas 测量)才用 range 来缩放校准;动态歌词的强调 wrapper 有 1em 的 padding 和
|
||||
// margin,用 range 测会把首尾溢出的 1em 也加进来,极大地增大了行长度,视觉上就是非常激进地换行
|
||||
if (adapter.needsCalibration) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(this.mainElement);
|
||||
const visualWidth = range.getBoundingClientRect().width;
|
||||
|
||||
if (layoutWidth > 0 && visualWidth > 0) {
|
||||
const scale = visualWidth / layoutWidth;
|
||||
for (const info of childInfos) {
|
||||
info.width *= scale;
|
||||
}
|
||||
}
|
||||
layoutWidth = visualWidth;
|
||||
}
|
||||
|
||||
const safeContainerWidth = Math.max(1, containerWidth);
|
||||
|
||||
if (layoutWidth <= safeContainerWidth) {
|
||||
this.lastBalancedContainerWidth = containerWidth;
|
||||
return;
|
||||
}
|
||||
|
||||
const breaks = calcBalancedBreaks(
|
||||
childInfos,
|
||||
safeContainerWidth,
|
||||
fullText,
|
||||
wordSegmenter,
|
||||
);
|
||||
|
||||
if (breaks.length === 0) {
|
||||
this.lastBalancedContainerWidth = containerWidth;
|
||||
return;
|
||||
}
|
||||
|
||||
this.isBalancing = true;
|
||||
lockAcquired = true;
|
||||
|
||||
adapter.applyBreaks(breaks, childInfos);
|
||||
this.lastBalancedContainerWidth = containerWidth;
|
||||
this.isBalancing = false;
|
||||
} finally {
|
||||
this.mainElement.style.whiteSpace = prevWhiteSpace;
|
||||
if (transformChanged && parentElement) {
|
||||
parentElement.style.transform = prevTransform;
|
||||
}
|
||||
|
||||
if (lockAcquired) {
|
||||
this.isBalancing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private balanceDynamicLineBreaks(
|
||||
containerWidth: number,
|
||||
wordSegmenter: Intl.Segmenter,
|
||||
): void {
|
||||
const infoToNode: Node[] = [];
|
||||
|
||||
const dynamicAdapter: LineBalanceAdapter = {
|
||||
resetDOM: () => {
|
||||
this.mainElement.querySelectorAll("br").forEach((br) => {
|
||||
br.remove();
|
||||
});
|
||||
},
|
||||
buildChildInfos: () => {
|
||||
infoToNode.length = 0;
|
||||
const childNodes = Array.from(this.mainElement.childNodes);
|
||||
const childInfos: ChildNodeInfo[] = [];
|
||||
const range = document.createRange();
|
||||
|
||||
for (const node of childNodes) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = node.textContent ?? "";
|
||||
if (text.length === 0) continue;
|
||||
range.selectNodeContents(node);
|
||||
childInfos.push({
|
||||
width: range.getBoundingClientRect().width,
|
||||
text,
|
||||
isSpace: text.trim().length === 0,
|
||||
});
|
||||
infoToNode.push(node);
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const el = node as HTMLElement;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const elStyle = getComputedStyle(el);
|
||||
const marginLeft = Number.parseFloat(elStyle.marginLeft) || 0;
|
||||
const marginRight = Number.parseFloat(elStyle.marginRight) || 0;
|
||||
childInfos.push({
|
||||
width: clampPositive(rect.width + marginLeft + marginRight),
|
||||
text: el.textContent ?? "",
|
||||
isSpace: false,
|
||||
});
|
||||
infoToNode.push(node);
|
||||
}
|
||||
}
|
||||
return { childInfos, fullText: childInfos.map((c) => c.text).join("") };
|
||||
},
|
||||
applyBreaks: (breaks) => {
|
||||
for (let i = breaks.length - 1; i >= 0; i--) {
|
||||
const breakIndex = breaks[i];
|
||||
if (breakIndex >= 0 && breakIndex < infoToNode.length) {
|
||||
this.mainElement.insertBefore(
|
||||
document.createElement("br"),
|
||||
infoToNode[breakIndex],
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
needsCalibration: false,
|
||||
};
|
||||
|
||||
this.executeLineBalance(containerWidth, dynamicAdapter, wordSegmenter);
|
||||
}
|
||||
|
||||
private balanceNonDynamicLineBreaks(
|
||||
containerWidth: number,
|
||||
computedStyle: CSSStyleDeclaration,
|
||||
wordSegmenter: Intl.Segmenter,
|
||||
): void {
|
||||
const fullText = this.mainElement.textContent ?? "";
|
||||
if (fullText.trim().length === 0) return;
|
||||
|
||||
const nonDynamicAdapter: LineBalanceAdapter = {
|
||||
resetDOM: () => {
|
||||
this.mainElement.innerHTML = "";
|
||||
this.mainElement.textContent = fullText;
|
||||
},
|
||||
buildChildInfos: () => {
|
||||
const ctx = getMeasurementContext();
|
||||
|
||||
if (!ctx) {
|
||||
console.debug(
|
||||
"Canvas 2D context is not supported, skipping line balancing",
|
||||
);
|
||||
return { childInfos: [], fullText };
|
||||
}
|
||||
|
||||
ctx.font = `${computedStyle.fontWeight} ${computedStyle.fontSize} ${computedStyle.fontFamily}`;
|
||||
|
||||
if ("letterSpacing" in ctx) {
|
||||
ctx.letterSpacing =
|
||||
computedStyle.letterSpacing !== "normal"
|
||||
? computedStyle.letterSpacing
|
||||
: "0px";
|
||||
}
|
||||
if ("wordSpacing" in ctx) {
|
||||
ctx.wordSpacing =
|
||||
computedStyle.wordSpacing !== "normal"
|
||||
? computedStyle.wordSpacing
|
||||
: "0px";
|
||||
}
|
||||
|
||||
const childInfos: ChildNodeInfo[] = [];
|
||||
for (const { segment } of wordSegmenter.segment(fullText)) {
|
||||
childInfos.push({
|
||||
width: ctx.measureText(segment).width,
|
||||
text: segment,
|
||||
isSpace: segment.trim().length === 0,
|
||||
});
|
||||
}
|
||||
|
||||
return { childInfos, fullText };
|
||||
},
|
||||
applyBreaks: (breaks, childInfos) => {
|
||||
this.mainElement.innerHTML = "";
|
||||
const breakSet = new Set(breaks);
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
for (let i = 0; i < childInfos.length; i++) {
|
||||
if (breakSet.has(i)) {
|
||||
fragment.appendChild(document.createElement("br"));
|
||||
}
|
||||
fragment.appendChild(document.createTextNode(childInfos[i].text));
|
||||
}
|
||||
this.mainElement.appendChild(fragment);
|
||||
},
|
||||
needsCalibration: true,
|
||||
};
|
||||
|
||||
this.executeLineBalance(containerWidth, nonDynamicAdapter, wordSegmenter);
|
||||
}
|
||||
}
|
||||
120
amll-local/packages/core/src/utils/linear.ts
Normal file
120
amll-local/packages/core/src/utils/linear.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
type seconds = number;
|
||||
|
||||
export interface LinearParams {
|
||||
duration: number; // Duration of the transition in seconds
|
||||
}
|
||||
|
||||
export class Linear {
|
||||
private currentPosition = 0;
|
||||
private targetPosition = 0;
|
||||
private currentTime = 0;
|
||||
private params: Partial<LinearParams> = {};
|
||||
private currentSolver: (t: seconds) => number;
|
||||
private startTime = 0;
|
||||
private queueParams:
|
||||
| (Partial<LinearParams> & {
|
||||
time: number;
|
||||
})
|
||||
| undefined;
|
||||
private queuePosition:
|
||||
| {
|
||||
time: number;
|
||||
position: number;
|
||||
}
|
||||
| undefined;
|
||||
constructor(currentPosition = 0) {
|
||||
this.targetPosition = currentPosition;
|
||||
this.currentPosition = this.targetPosition;
|
||||
this.currentSolver = () => this.targetPosition;
|
||||
}
|
||||
private resetSolver() {
|
||||
this.currentTime = 0;
|
||||
this.startTime = 0;
|
||||
this.currentSolver = solveLinear(
|
||||
this.currentPosition,
|
||||
this.targetPosition,
|
||||
this.params,
|
||||
);
|
||||
}
|
||||
arrived(): boolean {
|
||||
return (
|
||||
Math.abs(this.targetPosition - this.currentPosition) < 0.01 &&
|
||||
this.queueParams === undefined &&
|
||||
this.queuePosition === undefined
|
||||
);
|
||||
}
|
||||
setPosition(targetPosition: number): void {
|
||||
this.targetPosition = targetPosition;
|
||||
this.currentPosition = targetPosition;
|
||||
this.currentSolver = () => this.targetPosition;
|
||||
}
|
||||
update(delta = 0): void {
|
||||
this.currentTime += delta;
|
||||
this.currentPosition = this.currentSolver(
|
||||
this.currentTime - this.startTime,
|
||||
);
|
||||
if (this.queueParams) {
|
||||
this.queueParams.time -= delta;
|
||||
if (this.queueParams.time <= 0) {
|
||||
this.updateParams({
|
||||
...this.queueParams,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (this.queuePosition) {
|
||||
this.queuePosition.time -= delta;
|
||||
if (this.queuePosition.time <= 0) {
|
||||
this.setTargetPosition(this.queuePosition.position);
|
||||
}
|
||||
}
|
||||
if (this.arrived()) {
|
||||
this.setPosition(this.targetPosition);
|
||||
}
|
||||
}
|
||||
updateParams(params: Partial<LinearParams>, delay = 0): void {
|
||||
if (delay > 0) {
|
||||
this.queueParams = {
|
||||
...(this.queuePosition ?? {}),
|
||||
...params,
|
||||
time: delay,
|
||||
};
|
||||
} else {
|
||||
this.queuePosition = undefined;
|
||||
this.params = {
|
||||
...this.params,
|
||||
...params,
|
||||
};
|
||||
this.resetSolver();
|
||||
}
|
||||
}
|
||||
setTargetPosition(targetPosition: number, delay = 0): void {
|
||||
if (delay > 0) {
|
||||
this.queuePosition = {
|
||||
...(this.queuePosition ?? {}),
|
||||
position: targetPosition,
|
||||
time: delay,
|
||||
};
|
||||
} else {
|
||||
this.queuePosition = undefined;
|
||||
this.targetPosition = targetPosition;
|
||||
this.resetSolver();
|
||||
}
|
||||
}
|
||||
getCurrentPosition(): number {
|
||||
return this.currentPosition;
|
||||
}
|
||||
}
|
||||
|
||||
function solveLinear(
|
||||
from: number,
|
||||
to: number,
|
||||
params?: Partial<LinearParams>,
|
||||
): (t: seconds) => number {
|
||||
const duration = params?.duration ?? 1;
|
||||
const delta = to - from;
|
||||
return (t: seconds) => {
|
||||
if (t < 0) return from;
|
||||
if (t > duration) return to;
|
||||
return from + (delta * t) / duration;
|
||||
};
|
||||
}
|
||||
145
amll-local/packages/core/src/utils/lyric-line-break.ts
Normal file
145
amll-local/packages/core/src/utils/lyric-line-break.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { isCJK } from "./is-cjk.ts";
|
||||
|
||||
export interface ChildNodeInfo {
|
||||
width: number;
|
||||
text: string;
|
||||
isSpace: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个词超过容器宽度时的大惩罚倍数
|
||||
*/
|
||||
const OVERFLOW_PENALTY_MULTIPLIER = 1000;
|
||||
/**
|
||||
* 截断 CJK 词组边界的惩罚比例
|
||||
*
|
||||
* 相对于容器宽度
|
||||
*/
|
||||
const CJK_BREAK_PENALTY_RATIO = 0.15;
|
||||
/**
|
||||
* 截断普通文本(非空格、非 CJK 词界)的惩罚比例
|
||||
*/
|
||||
const NORMAL_BREAK_PENALTY_RATIO = 0.5;
|
||||
/**
|
||||
* 在空格处断开的奖励比例
|
||||
*/
|
||||
const SPACE_BREAK_REWARD_RATIO = 0.4;
|
||||
/**
|
||||
* 在标点符号处断开的奖励比例
|
||||
*
|
||||
* 比空格更高以便优先一点在标点处换行
|
||||
*/
|
||||
const PUNCTUATION_BREAK_REWARD_RATIO = 0.6;
|
||||
const PUNCTUATION_REGEX = /[,.;:!?,。;:!?、)】》」』’”)[\]}>~…]$/;
|
||||
|
||||
/**
|
||||
* 计算平均行长度的断点位置
|
||||
* @param children 子节点信息
|
||||
* @param containerWidth 容器可用内容宽度
|
||||
* @param fullText 完整的行文本
|
||||
* @param segmenter 预创建的 Intl.Segmenter 分词器
|
||||
* @returns 需要在其前面插入 `<br>` 的子节点索引数组,升序
|
||||
*/
|
||||
export function calcBalancedBreaks(
|
||||
children: ChildNodeInfo[],
|
||||
containerWidth: number,
|
||||
fullText: string,
|
||||
segmenter: Intl.Segmenter,
|
||||
): number[] {
|
||||
const n = children.length;
|
||||
if (n === 0 || containerWidth <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 计算哪里是 CJK 词组的边界
|
||||
const cjkBoundaries = new Set<number>();
|
||||
let offset = 0;
|
||||
for (const { segment, isWordLike } of segmenter.segment(fullText)) {
|
||||
if (offset > 0 && isWordLike) {
|
||||
if ([...segment].some((ch) => isCJK(ch))) {
|
||||
cjkBoundaries.add(offset);
|
||||
}
|
||||
}
|
||||
offset += segment.length;
|
||||
}
|
||||
|
||||
// 计算前缀宽和字符偏移量用于快速查询
|
||||
const charOffsets = new Int32Array(n + 1);
|
||||
const prefixWidth = new Float64Array(n + 1);
|
||||
for (let i = 0; i < n; i++) {
|
||||
charOffsets[i + 1] = charOffsets[i] + children[i].text.length;
|
||||
prefixWidth[i + 1] = prefixWidth[i] + children[i].width;
|
||||
}
|
||||
|
||||
if (prefixWidth[n] <= containerWidth) {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* dp[i] 表示将 index i 到 n-1 的节点进行排版的最小代价
|
||||
*/
|
||||
const dp = new Float64Array(n + 1).fill(Number.POSITIVE_INFINITY);
|
||||
const nextBreak = new Int32Array(n + 1).fill(-1);
|
||||
dp[n] = 0;
|
||||
|
||||
const PENALTY_CJK = (containerWidth * CJK_BREAK_PENALTY_RATIO) ** 2;
|
||||
const PENALTY_NORMAL = (containerWidth * NORMAL_BREAK_PENALTY_RATIO) ** 2;
|
||||
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
for (let j = i + 1; j <= n; j++) {
|
||||
const w = prefixWidth[j] - prefixWidth[i];
|
||||
|
||||
let lineCost = 0;
|
||||
|
||||
if (w > containerWidth) {
|
||||
if (j === i + 1) {
|
||||
// 单个无法分割的词自身就比容器宽,被迫独立成行,给大惩罚
|
||||
lineCost = (w - containerWidth) ** 2 * OVERFLOW_PENALTY_MULTIPLIER;
|
||||
} else {
|
||||
// 行内包含多个超过物理宽度的词就跳过
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// 迫使所有行的长度方差最小化
|
||||
lineCost = (containerWidth - w) ** 2;
|
||||
}
|
||||
|
||||
let breakPenalty = 0;
|
||||
if (j < n) {
|
||||
const prevChild = children[j - 1];
|
||||
|
||||
// 优先级:标点 > 空格 > CJK 词界 > 普通文本
|
||||
if (PUNCTUATION_REGEX.test(prevChild.text)) {
|
||||
breakPenalty = -(
|
||||
(containerWidth * PUNCTUATION_BREAK_REWARD_RATIO) **
|
||||
2
|
||||
);
|
||||
} else if (prevChild.isSpace) {
|
||||
breakPenalty = -((containerWidth * SPACE_BREAK_REWARD_RATIO) ** 2);
|
||||
} else if (cjkBoundaries.has(charOffsets[j])) {
|
||||
breakPenalty = PENALTY_CJK;
|
||||
} else {
|
||||
breakPenalty = PENALTY_NORMAL;
|
||||
}
|
||||
}
|
||||
|
||||
const totalCost = lineCost + breakPenalty + dp[j];
|
||||
|
||||
if (totalCost < dp[i]) {
|
||||
dp[i] = totalCost;
|
||||
nextBreak[i] = j;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const breaks: number[] = [];
|
||||
let curr = 0;
|
||||
while (curr < n) {
|
||||
curr = nextBreak[curr];
|
||||
if (curr > 0 && curr < n) {
|
||||
breaks.push(curr);
|
||||
}
|
||||
}
|
||||
|
||||
return breaks;
|
||||
}
|
||||
112
amll-local/packages/core/src/utils/lyric-split-words.ts
Normal file
112
amll-local/packages/core/src/utils/lyric-split-words.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { LyricWord } from "../interfaces.ts";
|
||||
import { isCJK } from "./is-cjk.ts";
|
||||
|
||||
const SPLIT_WHITESPACE_RE = /(\s+)/;
|
||||
const WHITESPACE_RE = /\s/g;
|
||||
|
||||
/**
|
||||
* 将输入的单词重新分组,之间没有空格的单词将会组合成一个单词数组
|
||||
*
|
||||
* 例如输入:`["Life", " ", "is", " a", " su", "gar so", "sweet"]`
|
||||
*
|
||||
* 应该返回:`["Life", " ", "is", " a", [" su", "gar"], "so", "sweet"]`
|
||||
* @param words 输入的单词数组
|
||||
* @returns 重新分组后的单词数组
|
||||
*/
|
||||
export function chunkAndSplitLyricWords(
|
||||
words: LyricWord[],
|
||||
): (LyricWord | LyricWord[])[] {
|
||||
const result: (LyricWord | LyricWord[])[] = [];
|
||||
let currentGroup: LyricWord[] = [];
|
||||
|
||||
const flushGroup = () => {
|
||||
if (currentGroup.length > 0) {
|
||||
result.push(
|
||||
currentGroup.length === 1 ? currentGroup[0] : [...currentGroup],
|
||||
);
|
||||
currentGroup = [];
|
||||
}
|
||||
};
|
||||
|
||||
const processAtom = (atom: LyricWord) => {
|
||||
const isSpace = atom.word.trim().length === 0;
|
||||
const hasRuby = (atom.ruby?.length ?? 0) > 0;
|
||||
const isCJKChar = isCJK(atom.word);
|
||||
|
||||
const isMergeable = !isSpace && !hasRuby && !isCJKChar;
|
||||
|
||||
if (isMergeable) {
|
||||
currentGroup.push(atom);
|
||||
} else {
|
||||
flushGroup();
|
||||
result.push(atom);
|
||||
}
|
||||
};
|
||||
|
||||
for (const w of words) {
|
||||
const content = w.word.trim();
|
||||
const isSpace = content.length === 0;
|
||||
const romanWord = w.romanWord ?? "";
|
||||
const obscene = w.obscene ?? false;
|
||||
const hasRuby = (w.ruby?.length ?? 0) > 0;
|
||||
|
||||
if (isSpace || hasRuby) {
|
||||
processAtom({ ...w });
|
||||
continue;
|
||||
}
|
||||
|
||||
const parts = w.word.split(SPLIT_WHITESPACE_RE).filter((p) => p.length > 0);
|
||||
|
||||
const totalLength = w.word.replace(WHITESPACE_RE, "").length || 1;
|
||||
const timeSpan = w.endTime - w.startTime;
|
||||
const timePerUnit = timeSpan / totalLength;
|
||||
|
||||
let currentOffset = 0;
|
||||
|
||||
for (const part of parts) {
|
||||
if (!part.trim()) {
|
||||
const startTime = w.startTime + currentOffset * timePerUnit;
|
||||
processAtom({
|
||||
word: part,
|
||||
romanWord: "",
|
||||
startTime: startTime,
|
||||
endTime: startTime,
|
||||
obscene: obscene,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isCJK(part) && part.length > 1 && romanWord.trim().length === 0) {
|
||||
const chars = part.split("");
|
||||
for (const char of chars) {
|
||||
const startTime = w.startTime + currentOffset * timePerUnit;
|
||||
processAtom({
|
||||
word: char,
|
||||
romanWord: "",
|
||||
startTime: startTime,
|
||||
endTime: startTime + timePerUnit,
|
||||
obscene: obscene,
|
||||
});
|
||||
currentOffset += 1;
|
||||
}
|
||||
} else {
|
||||
const partRealLen = part.length;
|
||||
const startTime = w.startTime + currentOffset * timePerUnit;
|
||||
const duration = partRealLen * timePerUnit;
|
||||
|
||||
processAtom({
|
||||
word: part,
|
||||
romanWord: romanWord,
|
||||
startTime: startTime,
|
||||
endTime: startTime + duration,
|
||||
obscene: obscene,
|
||||
});
|
||||
currentOffset += partRealLen;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flushGroup();
|
||||
|
||||
return result;
|
||||
}
|
||||
47
amll-local/packages/core/src/utils/matrix.ts
Normal file
47
amll-local/packages/core/src/utils/matrix.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// biome-ignore format: matrix
|
||||
export type Matrix4 = [
|
||||
number, number, number, number,
|
||||
number, number, number, number,
|
||||
number, number, number, number,
|
||||
number, number, number, number,
|
||||
];
|
||||
|
||||
export function createMatrix4(): Matrix4 {
|
||||
// biome-ignore format: matrix
|
||||
return [
|
||||
1, 0, 0, 0,
|
||||
0, 1, 0, 0,
|
||||
0, 0, 1, 0,
|
||||
0, 0, 0, 1
|
||||
];
|
||||
}
|
||||
|
||||
export function scaleMatrix4(
|
||||
m: Matrix4,
|
||||
scale = 1,
|
||||
origin = { x: 0, y: 0 },
|
||||
): Matrix4 {
|
||||
const [ox, oy] = [origin.x, origin.y];
|
||||
// biome-ignore format: matrix
|
||||
return [
|
||||
m[0] * scale , m[1] * scale , m[2] * scale , m[3],
|
||||
m[4] * scale , m[5] * scale , m[6] * scale , m[7],
|
||||
m[8] * scale , m[9] * scale , m[10] * scale, m[11],
|
||||
m[12] - ox * scale + ox, m[13] - oy * scale + oy, m[14] , m[15]
|
||||
];
|
||||
}
|
||||
|
||||
export function translateMatrix4(m: Matrix4, x = 0, y = 0, z = 0): Matrix4 {
|
||||
// biome-ignore format: matrix
|
||||
return [
|
||||
m[0] , m[1] , m[2] , m[3] ,
|
||||
m[4] , m[5] , m[6] , m[7] ,
|
||||
m[8] , m[9] , m[10] , m[11],
|
||||
m[12] + x, m[13] + y, m[14] + z, m[15]
|
||||
];
|
||||
}
|
||||
|
||||
export function matrix4ToCSS(m: Matrix4, fractionDigits = 4): string {
|
||||
const format = (n: number, _: number) => n.toFixed(fractionDigits);
|
||||
return `matrix3d(${m.map(format).join(", ")})`;
|
||||
}
|
||||
251
amll-local/packages/core/src/utils/optimize-lyric.ts
Normal file
251
amll-local/packages/core/src/utils/optimize-lyric.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import type { LyricLine, OptimizeLyricOptions } from "../interfaces.ts";
|
||||
|
||||
const DEFAULT_OPTIMIZE_OPTIONS: OptimizeLyricOptions = {
|
||||
normalizeSpaces: true,
|
||||
resetLineTimestamps: true,
|
||||
convertExcessiveBackgroundLines: true,
|
||||
syncMainAndBackgroundLines: true,
|
||||
cleanUnintentionalOverlaps: true,
|
||||
tryAdvanceStartTime: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* 规范化歌词中的空格,将多个连续空格替换为一个空格
|
||||
*/
|
||||
function normalizeSpaces(lines: LyricLine[]) {
|
||||
for (const line of lines) {
|
||||
for (const word of line.words) {
|
||||
word.word = word.word.replace(/\s+/g, " ");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将行级时间戳强行设为字级时间戳
|
||||
*/
|
||||
function resetLineTimestamps(lines: LyricLine[]) {
|
||||
for (const line of lines) {
|
||||
// 主要是给 TTML 解析器打补丁,其解析逐行歌词时获得的词时间戳均为0
|
||||
// 如果只有一个词,且该词的起止时间均为0,且行时间戳不全为0,则将行时间戳同步给词时间戳
|
||||
if (
|
||||
line.words.length === 1 &&
|
||||
line.words[0].startTime === 0 &&
|
||||
line.words[0].endTime === 0 &&
|
||||
(line.startTime !== 0 || line.endTime !== 0)
|
||||
) {
|
||||
line.words[0].startTime = line.startTime;
|
||||
line.words[0].endTime = line.endTime;
|
||||
} else if (line.words.length > 0) {
|
||||
const firstWord = line.words[0];
|
||||
const lastWord = line.words[line.words.length - 1];
|
||||
|
||||
line.startTime = firstWord.startTime;
|
||||
line.endTime = lastWord.endTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把多行背景人声转换为单行背景人声 + 主歌词行的形式
|
||||
*/
|
||||
function convertExcessiveBackgroundLines(lines: LyricLine[]) {
|
||||
let consecutiveBgCount = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.isBG) {
|
||||
consecutiveBgCount++;
|
||||
if (consecutiveBgCount > 1) {
|
||||
line.isBG = false;
|
||||
}
|
||||
} else {
|
||||
consecutiveBgCount = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步主歌词与背景人声的时间
|
||||
*
|
||||
* 取两者中最早的开始时间和最晚的结束时间,应用给双方
|
||||
*/
|
||||
function syncMainAndBackgroundLines(lines: LyricLine[]) {
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i];
|
||||
if (line.isBG) continue;
|
||||
|
||||
const nextLine = lines[i + 1];
|
||||
if (nextLine?.isBG) {
|
||||
const allWords = [...line.words, ...nextLine.words].filter(
|
||||
(w) => w.word.trim().length > 0,
|
||||
);
|
||||
|
||||
if (allWords.length > 0) {
|
||||
const minStart = Math.min(...allWords.map((w) => w.startTime));
|
||||
const maxEnd = Math.max(...allWords.map((w) => w.endTime));
|
||||
|
||||
const finalStart = Math.min(
|
||||
minStart,
|
||||
line.startTime,
|
||||
nextLine.startTime,
|
||||
);
|
||||
const finalEnd = Math.max(maxEnd, line.endTime, nextLine.endTime);
|
||||
|
||||
line.startTime = finalStart;
|
||||
line.endTime = finalEnd;
|
||||
nextLine.startTime = finalStart;
|
||||
nextLine.endTime = finalEnd;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清洗非刻意的重叠
|
||||
*
|
||||
* 如果重叠大于100ms 且 重叠超过下一行时长的10%,则视为刻意重叠,否则将结束时间设为下一行的开始时间
|
||||
*/
|
||||
function cleanUnintentionalOverlaps(lines: LyricLine[]) {
|
||||
for (let i = 0; i < lines.length - 1; i++) {
|
||||
const line = lines[i];
|
||||
if (line.isBG) continue;
|
||||
|
||||
let nextMainIndex = i + 1;
|
||||
while (nextMainIndex < lines.length && lines[nextMainIndex].isBG) {
|
||||
nextMainIndex++;
|
||||
}
|
||||
|
||||
if (nextMainIndex < lines.length) {
|
||||
const nextLine = lines[nextMainIndex];
|
||||
const overlap = line.endTime - nextLine.startTime;
|
||||
|
||||
if (overlap > 0) {
|
||||
const nextDuration = nextLine.endTime - nextLine.startTime;
|
||||
const percentageThreshold = nextDuration * 0.1;
|
||||
|
||||
// 重叠大于100ms 且 重叠超过下一行时长的10%
|
||||
const isIntentionalOverlap =
|
||||
overlap > 100 && overlap > percentageThreshold;
|
||||
|
||||
if (!isIntentionalOverlap) {
|
||||
line.endTime = nextLine.startTime;
|
||||
|
||||
const attachedBgLine = lines[i + 1];
|
||||
if (attachedBgLine?.isBG) {
|
||||
attachedBgLine.endTime = nextLine.startTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试让歌词提前最多 600ms 开始,如果有重叠则尝试最多提前 400ms 或上一行时长的 30%
|
||||
*/
|
||||
function tryAdvanceStartTime(lines: LyricLine[]) {
|
||||
const defaultAdvanceAmount = 600;
|
||||
const fallbackAdvanceAmount = 400;
|
||||
const fallbackAdvanceRatio = 0.3;
|
||||
|
||||
let prevLineStartTime = 0;
|
||||
let prevLineEndTime = 0;
|
||||
let prevMainGroupStartTime = 0;
|
||||
let prevMainGroupEndTime = 0;
|
||||
let hasPrevLine = false;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line.isBG) continue;
|
||||
|
||||
const originalStartTime = line.startTime;
|
||||
const originalEndTime = line.endTime;
|
||||
|
||||
let targetAdvanceAmount = 0;
|
||||
let safeBoundary = 0;
|
||||
|
||||
if (hasPrevLine) {
|
||||
const originallyHadGap = originalStartTime >= prevLineEndTime;
|
||||
|
||||
if (originallyHadGap) {
|
||||
targetAdvanceAmount = defaultAdvanceAmount;
|
||||
safeBoundary = prevMainGroupEndTime;
|
||||
} else {
|
||||
targetAdvanceAmount = fallbackAdvanceAmount;
|
||||
const prevDuration = prevLineEndTime - prevLineStartTime;
|
||||
safeBoundary = prevLineStartTime + prevDuration * fallbackAdvanceRatio;
|
||||
}
|
||||
} else {
|
||||
targetAdvanceAmount = defaultAdvanceAmount;
|
||||
safeBoundary = 0;
|
||||
}
|
||||
|
||||
const targetTime = line.startTime - targetAdvanceAmount;
|
||||
const newStartTime = Math.max(safeBoundary, targetTime);
|
||||
|
||||
if (newStartTime < line.startTime) {
|
||||
line.startTime = newStartTime;
|
||||
}
|
||||
|
||||
const nextLine = lines[i + 1];
|
||||
if (nextLine?.isBG) {
|
||||
nextLine.startTime = line.startTime;
|
||||
}
|
||||
|
||||
if (hasPrevLine) {
|
||||
const overlapsPrevGroup =
|
||||
originalStartTime < prevMainGroupEndTime &&
|
||||
originalEndTime > prevMainGroupStartTime;
|
||||
|
||||
if (overlapsPrevGroup) {
|
||||
prevMainGroupStartTime = Math.min(
|
||||
prevMainGroupStartTime,
|
||||
originalStartTime,
|
||||
);
|
||||
prevMainGroupEndTime = Math.max(prevMainGroupEndTime, originalEndTime);
|
||||
} else {
|
||||
prevMainGroupStartTime = originalStartTime;
|
||||
prevMainGroupEndTime = originalEndTime;
|
||||
}
|
||||
} else {
|
||||
prevMainGroupStartTime = originalStartTime;
|
||||
prevMainGroupEndTime = originalEndTime;
|
||||
}
|
||||
|
||||
prevLineStartTime = originalStartTime;
|
||||
prevLineEndTime = originalEndTime;
|
||||
hasPrevLine = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 优化歌词行的展示效果
|
||||
*
|
||||
* 注意会直接原地修改入参,确保你已经提前深克隆了歌词行数组
|
||||
* @param lines 歌词行数组
|
||||
* @param options 优化的可选配置,默认全部开启
|
||||
*/
|
||||
export function optimizeLyricLines(
|
||||
lines: LyricLine[],
|
||||
options?: OptimizeLyricOptions,
|
||||
): void {
|
||||
const config = { ...DEFAULT_OPTIMIZE_OPTIONS, ...options };
|
||||
|
||||
if (config.normalizeSpaces) {
|
||||
normalizeSpaces(lines);
|
||||
}
|
||||
if (config.resetLineTimestamps) {
|
||||
resetLineTimestamps(lines);
|
||||
}
|
||||
if (config.convertExcessiveBackgroundLines) {
|
||||
convertExcessiveBackgroundLines(lines);
|
||||
}
|
||||
if (config.syncMainAndBackgroundLines) {
|
||||
syncMainAndBackgroundLines(lines);
|
||||
}
|
||||
if (config.cleanUnintentionalOverlaps) {
|
||||
cleanUnintentionalOverlaps(lines);
|
||||
}
|
||||
if (config.tryAdvanceStartTime) {
|
||||
tryAdvanceStartTime(lines);
|
||||
}
|
||||
}
|
||||
79
amll-local/packages/core/src/utils/resource.ts
Normal file
79
amll-local/packages/core/src/utils/resource.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
export function loadImage(imageUrl: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = document.createElement("img");
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = imageUrl;
|
||||
img.crossOrigin = "anonymous";
|
||||
img.loading = "eager";
|
||||
});
|
||||
}
|
||||
|
||||
export function loadVideo(videoUrl: string): Promise<HTMLVideoElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const video = document.createElement("video");
|
||||
let playing = false;
|
||||
let timeupdate = false;
|
||||
let rejected = false;
|
||||
video.addEventListener(
|
||||
"playing",
|
||||
() => {
|
||||
playing = true;
|
||||
checkReady();
|
||||
},
|
||||
true,
|
||||
);
|
||||
video.addEventListener(
|
||||
"timeupdate",
|
||||
() => {
|
||||
timeupdate = true;
|
||||
checkReady();
|
||||
},
|
||||
true,
|
||||
);
|
||||
video.addEventListener(
|
||||
"error",
|
||||
(err) => {
|
||||
rejected = true;
|
||||
reject(err);
|
||||
},
|
||||
true,
|
||||
);
|
||||
function checkReady() {
|
||||
if (playing && timeupdate && !rejected) {
|
||||
resolve(video);
|
||||
}
|
||||
}
|
||||
video.src = videoUrl;
|
||||
video.playsInline = true;
|
||||
video.crossOrigin = "anonymous";
|
||||
video.autoplay = true;
|
||||
video.loop = true;
|
||||
video.muted = true;
|
||||
video.play();
|
||||
});
|
||||
}
|
||||
|
||||
export function loadResourceFromUrl(
|
||||
url: string,
|
||||
isVideo = false,
|
||||
): Promise<HTMLImageElement | HTMLVideoElement> {
|
||||
return isVideo ? loadVideo(url) : loadImage(url);
|
||||
}
|
||||
|
||||
export function loadResourceFromElement(
|
||||
element: HTMLImageElement | HTMLVideoElement,
|
||||
): Promise<HTMLImageElement | HTMLVideoElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (
|
||||
element instanceof HTMLImageElement
|
||||
? element.complete
|
||||
: element.readyState >= 3
|
||||
) {
|
||||
resolve(element);
|
||||
} else {
|
||||
element.onload = () => resolve(element);
|
||||
element.onerror = reject;
|
||||
}
|
||||
});
|
||||
}
|
||||
75
amll-local/packages/core/src/utils/schedule.ts
Normal file
75
amll-local/packages/core/src/utils/schedule.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @fileoverview
|
||||
* @see https://github.com/wilsonpage/fastdom/blob/master/fastdom.js
|
||||
*/
|
||||
|
||||
interface Task<T> {
|
||||
task: () => T;
|
||||
resolve: (value: T) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: util functions
|
||||
const measureTasks: Task<any>[] = [];
|
||||
// biome-ignore lint/suspicious/noExplicitAny: util functions
|
||||
const mutateTasks: Task<any>[] = [];
|
||||
let scheduled = false;
|
||||
|
||||
function onFlush() {
|
||||
let tmp = mutateTasks.shift();
|
||||
while (tmp) {
|
||||
try {
|
||||
tmp.resolve(tmp.task());
|
||||
} catch (error) {
|
||||
tmp.reject(error);
|
||||
}
|
||||
tmp = mutateTasks.shift();
|
||||
}
|
||||
tmp = measureTasks.shift();
|
||||
while (tmp) {
|
||||
try {
|
||||
tmp.resolve(tmp.task());
|
||||
} catch (error) {
|
||||
tmp.reject(error);
|
||||
}
|
||||
tmp = measureTasks.shift();
|
||||
}
|
||||
scheduled = false;
|
||||
}
|
||||
|
||||
function scheduleFlush() {
|
||||
if (!scheduled) {
|
||||
scheduled = true;
|
||||
requestAnimationFrame(onFlush);
|
||||
}
|
||||
}
|
||||
|
||||
export function measure<T>(callback: () => T): Promise<T> {
|
||||
const task: Task<T> = {
|
||||
task: callback,
|
||||
resolve: () => {},
|
||||
reject: () => {},
|
||||
};
|
||||
const promise = new Promise<T>((resolve, reject) => {
|
||||
task.resolve = resolve;
|
||||
task.reject = reject;
|
||||
});
|
||||
measureTasks.push(task);
|
||||
scheduleFlush();
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function mutate(callback: () => void): Promise<unknown> {
|
||||
const task: Task<void> = {
|
||||
task: callback,
|
||||
resolve: () => {},
|
||||
reject: () => {},
|
||||
};
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
task.resolve = resolve;
|
||||
task.reject = reject;
|
||||
});
|
||||
mutateTasks.push(task);
|
||||
scheduleFlush();
|
||||
return promise;
|
||||
}
|
||||
158
amll-local/packages/core/src/utils/spring.ts
Normal file
158
amll-local/packages/core/src/utils/spring.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { getVelocity } from "./derivative.ts";
|
||||
|
||||
/** MIT License github.com/pushkine/ */
|
||||
export interface SpringParams {
|
||||
mass: number; // = 1.0
|
||||
damping: number; // = 10.0
|
||||
stiffness: number; // = 100.0
|
||||
soft: boolean; // = false
|
||||
}
|
||||
|
||||
type seconds = number;
|
||||
|
||||
export class Spring {
|
||||
private currentPosition = 0;
|
||||
private targetPosition = 0;
|
||||
private currentTime = 0;
|
||||
private params: Partial<SpringParams> = {};
|
||||
private currentSolver: (t: seconds) => number;
|
||||
private getV: (t: seconds) => number;
|
||||
private getV2: (t: seconds) => number;
|
||||
private queueParams:
|
||||
| (Partial<SpringParams> & {
|
||||
time: number;
|
||||
})
|
||||
| undefined;
|
||||
private queuePosition:
|
||||
| {
|
||||
time: number;
|
||||
position: number;
|
||||
}
|
||||
| undefined;
|
||||
constructor(currentPosition = 0) {
|
||||
this.targetPosition = currentPosition;
|
||||
this.currentPosition = this.targetPosition;
|
||||
this.currentSolver = () => this.targetPosition;
|
||||
this.getV = () => 0;
|
||||
this.getV2 = () => 0;
|
||||
}
|
||||
private resetSolver() {
|
||||
const curV = this.getV(this.currentTime);
|
||||
this.currentTime = 0;
|
||||
this.currentSolver = solveSpring(
|
||||
this.currentPosition,
|
||||
curV,
|
||||
this.targetPosition,
|
||||
0,
|
||||
this.params,
|
||||
);
|
||||
this.getV = getVelocity(this.currentSolver);
|
||||
this.getV2 = getVelocity(this.getV);
|
||||
}
|
||||
arrived(): boolean {
|
||||
return (
|
||||
Math.abs(this.targetPosition - this.currentPosition) < 0.01 &&
|
||||
this.getV(this.currentTime) < 0.01 &&
|
||||
this.getV2(this.currentTime) < 0.01 &&
|
||||
this.queueParams === undefined &&
|
||||
this.queuePosition === undefined
|
||||
);
|
||||
}
|
||||
setPosition(targetPosition: number): void {
|
||||
this.targetPosition = targetPosition;
|
||||
this.currentPosition = targetPosition;
|
||||
this.currentSolver = () => this.targetPosition;
|
||||
this.getV = () => 0;
|
||||
this.getV2 = () => 0;
|
||||
}
|
||||
update(delta = 0): void {
|
||||
this.currentTime += delta;
|
||||
this.currentPosition = this.currentSolver(this.currentTime);
|
||||
if (this.queueParams) {
|
||||
this.queueParams.time -= delta;
|
||||
if (this.queueParams.time <= 0) {
|
||||
this.updateParams({
|
||||
...this.queueParams,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (this.queuePosition) {
|
||||
this.queuePosition.time -= delta;
|
||||
if (this.queuePosition.time <= 0) {
|
||||
this.setTargetPosition(this.queuePosition.position);
|
||||
}
|
||||
}
|
||||
if (this.arrived()) {
|
||||
this.setPosition(this.targetPosition);
|
||||
}
|
||||
}
|
||||
updateParams(params: Partial<SpringParams>, delay = 0): void {
|
||||
if (delay > 0) {
|
||||
this.queueParams = {
|
||||
...(this.queuePosition ?? {}),
|
||||
...params,
|
||||
time: delay,
|
||||
};
|
||||
} else {
|
||||
this.queuePosition = undefined;
|
||||
this.params = {
|
||||
...this.params,
|
||||
...params,
|
||||
};
|
||||
this.resetSolver();
|
||||
}
|
||||
}
|
||||
setTargetPosition(targetPosition: number, delay = 0): void {
|
||||
if (delay > 0) {
|
||||
this.queuePosition = {
|
||||
...(this.queuePosition ?? {}),
|
||||
position: targetPosition,
|
||||
time: delay,
|
||||
};
|
||||
} else {
|
||||
this.queuePosition = undefined;
|
||||
this.targetPosition = targetPosition;
|
||||
this.resetSolver();
|
||||
}
|
||||
}
|
||||
getCurrentPosition(): number {
|
||||
return this.currentPosition;
|
||||
}
|
||||
}
|
||||
|
||||
function solveSpring(
|
||||
from: number,
|
||||
velocity: number,
|
||||
to: number,
|
||||
delay: seconds = 0,
|
||||
params?: Partial<SpringParams>,
|
||||
): (t: seconds) => number {
|
||||
const soft = params?.soft ?? false;
|
||||
const stiffness = params?.stiffness ?? 100;
|
||||
const damping = params?.damping ?? 10;
|
||||
const mass = params?.mass ?? 1;
|
||||
const delta = to - from;
|
||||
if (soft || 1.0 <= damping / (2.0 * Math.sqrt(stiffness * mass))) {
|
||||
const angular_frequency = -Math.sqrt(stiffness / mass);
|
||||
const leftover = -angular_frequency * delta - velocity;
|
||||
return (t: seconds) => {
|
||||
t -= delay;
|
||||
if (t < 0) return from;
|
||||
return to - (delta + t * leftover) * Math.E ** (t * angular_frequency);
|
||||
};
|
||||
}
|
||||
const damping_frequency = Math.sqrt(4.0 * mass * stiffness - damping ** 2.0);
|
||||
const leftover =
|
||||
(damping * delta - 2.0 * mass * velocity) / damping_frequency;
|
||||
const dfm = (0.5 * damping_frequency) / mass;
|
||||
const dm = -(0.5 * damping) / mass;
|
||||
return (t: seconds) => {
|
||||
t -= delay;
|
||||
if (t < 0) return from;
|
||||
return (
|
||||
to -
|
||||
(Math.cos(t * dfm) * delta + Math.sin(t * dfm) * leftover) *
|
||||
Math.E ** (t * dm)
|
||||
);
|
||||
};
|
||||
}
|
||||
139
amll-local/packages/core/src/utils/wa-spring.ts
Normal file
139
amll-local/packages/core/src/utils/wa-spring.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import bezier from "bezier-easing";
|
||||
import type { Disposable } from "../interfaces.ts";
|
||||
import { getVelocity } from "./derivative";
|
||||
|
||||
// export interface SpringParams {
|
||||
// mass: number; // = 1.0
|
||||
// damping: number; // = 10.0
|
||||
// stiffness: number; // = 100.0
|
||||
// }
|
||||
|
||||
type seconds = number;
|
||||
|
||||
type CSSStyleKeys = {
|
||||
[Style in keyof CSSStyleDeclaration]: Style extends string
|
||||
? CSSStyleDeclaration[Style] extends string
|
||||
? Style
|
||||
: never
|
||||
: never;
|
||||
}[keyof CSSStyleDeclaration];
|
||||
|
||||
/**
|
||||
* 基于 Web Animation API 的弹簧动画工具类,效果上可能逊于实时演算的版本
|
||||
*/
|
||||
export class WebAnimationSpring extends EventTarget implements Disposable {
|
||||
private currentAnimation: Animation;
|
||||
private targetPosition = 0;
|
||||
private isStatic = true;
|
||||
// private params: Partial<SpringParams> = {};
|
||||
private currentSolver: (t: seconds) => number = () => this.targetPosition;
|
||||
private getV: (t: seconds) => number = () => 0;
|
||||
|
||||
constructor(
|
||||
private element: HTMLElement,
|
||||
private styleName: CSSStyleKeys,
|
||||
private valueGenerator: (value: number) => string,
|
||||
private currentPosition = 0,
|
||||
) {
|
||||
super();
|
||||
this.targetPosition = currentPosition;
|
||||
this.currentAnimation = element.animate(
|
||||
[
|
||||
{
|
||||
[styleName]: valueGenerator(currentPosition),
|
||||
},
|
||||
],
|
||||
{
|
||||
duration: 1000,
|
||||
fill: "both",
|
||||
composite: "add",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
makeStatic(): void {
|
||||
this.getV = () => 0;
|
||||
this.currentSolver = () => this.targetPosition;
|
||||
this.currentAnimation.cancel();
|
||||
this.currentAnimation = this.element.animate(
|
||||
[
|
||||
{
|
||||
[this.styleName]: this.valueGenerator(this.targetPosition),
|
||||
},
|
||||
{
|
||||
[this.styleName]: this.valueGenerator(this.targetPosition),
|
||||
},
|
||||
],
|
||||
{
|
||||
duration: Number.POSITIVE_INFINITY,
|
||||
id: `wa-spring-static-${this.styleName}`,
|
||||
fill: "both",
|
||||
easing: "cubic-bezier(0.5, 0, 0.5, 1)",
|
||||
composite: "add",
|
||||
},
|
||||
);
|
||||
this.currentAnimation.pause();
|
||||
}
|
||||
|
||||
setTargetPosition(targetPosition: number): void {
|
||||
this.targetPosition = targetPosition;
|
||||
this.onStepFinished();
|
||||
}
|
||||
|
||||
getCurrentPosition(): number {
|
||||
if (this.isStatic || !this.currentAnimation.effect)
|
||||
return this.currentPosition;
|
||||
|
||||
const timing = this.currentAnimation.effect?.getComputedTiming();
|
||||
return this.currentSolver(timing.progress ?? 1);
|
||||
}
|
||||
|
||||
getCurrentVelocity(): number {
|
||||
if (this.isStatic || !this.currentAnimation.effect) return 0;
|
||||
|
||||
const timing = this.currentAnimation.effect?.getComputedTiming();
|
||||
return this.getV(timing.progress ?? 1);
|
||||
}
|
||||
|
||||
private onStepFinished() {
|
||||
const currentPosition = this.getCurrentPosition();
|
||||
if (Math.abs(this.targetPosition - currentPosition) < 0.0001) {
|
||||
this.makeStatic();
|
||||
this.dispatchEvent(new Event("finished"));
|
||||
return;
|
||||
}
|
||||
this.currentSolver = bezier(0.5, 0, 0.5, 1);
|
||||
this.getV = getVelocity(this.currentSolver);
|
||||
this.currentAnimation.cancel();
|
||||
const delta = (this.targetPosition - currentPosition) * 1.05;
|
||||
this.currentPosition += delta;
|
||||
this.currentAnimation = this.element.animate(
|
||||
[
|
||||
{
|
||||
[this.styleName]: this.valueGenerator(currentPosition),
|
||||
},
|
||||
{
|
||||
[this.styleName]: this.valueGenerator(this.currentPosition),
|
||||
},
|
||||
],
|
||||
{
|
||||
duration: 250,
|
||||
id: `wa-spring-dynamic-${this.styleName}`,
|
||||
fill: "forwards",
|
||||
easing: "cubic-bezier(0.5, 0, 0.5, 1)",
|
||||
composite: "add",
|
||||
},
|
||||
);
|
||||
this.currentAnimation.onfinish = () => this.onStepFinished();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.currentAnimation) {
|
||||
this.currentAnimation.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
4
amll-local/packages/core/tsconfig.json
Normal file
4
amll-local/packages/core/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src"]
|
||||
}
|
||||
34
amll-local/packages/core/tsdown.config.ts
Normal file
34
amll-local/packages/core/tsdown.config.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { defineConfig } from "tsdown";
|
||||
import { baseConfig } from "../../tsdown.base.ts";
|
||||
|
||||
const rawQueryPlugin = {
|
||||
name: "raw-query",
|
||||
resolveId(id: string, importer: string | undefined) {
|
||||
if (id.endsWith("?raw")) {
|
||||
const rawPath = id.slice(0, -4);
|
||||
const base = importer ? `file://${importer}` : `file://${process.cwd()}/`;
|
||||
const resolved = new URL(rawPath, base).pathname.replace(
|
||||
/^\/([A-Za-z]:)/,
|
||||
"$1",
|
||||
);
|
||||
return `\0raw:${resolved}`;
|
||||
}
|
||||
},
|
||||
load(id: string) {
|
||||
if (id.startsWith("\0raw:")) {
|
||||
const file = id.slice(5);
|
||||
const content = readFileSync(file, "utf-8");
|
||||
return `export default ${JSON.stringify(content)}`;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
...baseConfig,
|
||||
entry: { "amll-core": "./src/index.ts" },
|
||||
plugins: [rawQueryPlugin],
|
||||
define: {
|
||||
"import.meta.env.DEV": "process.env.NODE_ENV !== 'production'",
|
||||
},
|
||||
});
|
||||
5
amll-local/packages/docs/README.md
Normal file
5
amll-local/packages/docs/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# AMLL Docs
|
||||
|
||||
AMLL 框架的开发文档页面,使用 Astro + React + MDX 编写并部署到 Github Pages。
|
||||
|
||||
[查看文档](https://steve-xmh.github.io/applemusic-like-lyrics/zh-CN)
|
||||
181
amll-local/packages/docs/astro.config.ts
Normal file
181
amll-local/packages/docs/astro.config.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import react from "@astrojs/react";
|
||||
import starlight from "@astrojs/starlight";
|
||||
import { defineConfig } from "astro/config";
|
||||
import starlightSidebarTopics from "starlight-sidebar-topics";
|
||||
import { generateTypedocDocs } from "./src/scripts/typedoc";
|
||||
|
||||
const docsSidebar = [
|
||||
{
|
||||
label: "概览",
|
||||
translations: { en: "Overview" },
|
||||
items: [{ slug: "guides/overview/intro" }, { slug: "guides/overview/eco" }],
|
||||
},
|
||||
{
|
||||
label: "歌词组件",
|
||||
translations: { en: "Lyric Component" },
|
||||
items: [
|
||||
{ slug: "guides/component/quickstart" },
|
||||
{ slug: "guides/component/sequence" },
|
||||
{ slug: "guides/component/background" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "歌词格式",
|
||||
translations: { en: "Lyric Formats" },
|
||||
items: [
|
||||
{ slug: "guides/lyric/quickstart" },
|
||||
{ slug: "guides/lyric/formats" },
|
||||
{ slug: "guides/lyric/ttml" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const referenceSidebar = [
|
||||
{
|
||||
label: "Core 核心",
|
||||
translations: { en: "Core" },
|
||||
collapsed: true,
|
||||
items: [{ autogenerate: { directory: "reference/core", collapsed: true } }],
|
||||
},
|
||||
{
|
||||
label: "React 绑定",
|
||||
translations: { en: "React Bindings" },
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ autogenerate: { directory: "reference/react", collapsed: true } },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "React Full 组件库",
|
||||
translations: { en: "React Full Components" },
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ autogenerate: { directory: "reference/react-full", collapsed: true } },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Vue 绑定",
|
||||
translations: { en: "Vue Bindings" },
|
||||
collapsed: true,
|
||||
items: [{ autogenerate: { directory: "reference/vue", collapsed: true } }],
|
||||
},
|
||||
{
|
||||
label: "Lyric 歌词处理",
|
||||
translations: { en: "Lyric Processing" },
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ autogenerate: { directory: "reference/lyric", collapsed: true } },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "TTML 歌词处理",
|
||||
translations: { en: "TTML Processing" },
|
||||
collapsed: true,
|
||||
items: [{ autogenerate: { directory: "reference/ttml", collapsed: true } }],
|
||||
},
|
||||
];
|
||||
|
||||
const contributeSidebar = [
|
||||
{
|
||||
label: "开发指南",
|
||||
translations: { en: "Development" },
|
||||
items: [
|
||||
{ slug: "contribute/development/environments" },
|
||||
{ slug: "contribute/development/structure" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "仓库规范",
|
||||
translations: { en: "Repository Guidelines" },
|
||||
items: [
|
||||
{ slug: "contribute/guidelines/pr" },
|
||||
{ slug: "contribute/guidelines/publishing" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default defineConfig({
|
||||
site: "https://amll.dev",
|
||||
trailingSlash: "never",
|
||||
build: {
|
||||
format: "file",
|
||||
},
|
||||
integrations: [
|
||||
react(),
|
||||
starlight({
|
||||
favicon: "favicon.ico",
|
||||
title: "AMLL Docs",
|
||||
logo: {
|
||||
src: "./src/assets/amll-logo.svg",
|
||||
alt: "AMLL",
|
||||
},
|
||||
customCss: [
|
||||
"./src/styles/consts.css",
|
||||
"./src/styles/frame.css",
|
||||
"./src/styles/content.css",
|
||||
],
|
||||
expressiveCode: {
|
||||
themes: ["github-dark", "github-light"],
|
||||
useStarlightDarkModeSwitch: true,
|
||||
useStarlightUiThemeColors: false,
|
||||
},
|
||||
locales: {
|
||||
root: { label: "简体中文", lang: "zh-CN" },
|
||||
en: { label: "English", lang: "en" },
|
||||
},
|
||||
social: [
|
||||
{
|
||||
icon: "github",
|
||||
label: "GitHub",
|
||||
href: "https://github.com/amll-dev/applemusic-like-lyrics",
|
||||
},
|
||||
],
|
||||
plugins: [
|
||||
starlightSidebarTopics([
|
||||
{
|
||||
id: "docs",
|
||||
label: { "zh-CN": "使用文档", en: "Guides" },
|
||||
link: "/guides",
|
||||
icon: "open-book",
|
||||
items: docsSidebar,
|
||||
},
|
||||
{
|
||||
id: "reference",
|
||||
label: { "zh-CN": "API 参考", en: "API Reference" },
|
||||
link: "/reference",
|
||||
icon: "information",
|
||||
items: referenceSidebar,
|
||||
},
|
||||
{
|
||||
id: "docs",
|
||||
label: { "zh-CN": "试验场", en: "Playground" },
|
||||
link: "/playground",
|
||||
icon: "puzzle",
|
||||
items: docsSidebar,
|
||||
},
|
||||
{
|
||||
id: "contribute",
|
||||
label: { "zh-CN": "贡献指南", en: "Contributing" },
|
||||
link: "/contribute",
|
||||
icon: "code-branch",
|
||||
items: contributeSidebar,
|
||||
},
|
||||
]),
|
||||
{
|
||||
name: "typedoc",
|
||||
hooks: {
|
||||
"config:setup": async (cfg) => {
|
||||
cfg.logger.info("Generating typedoc...");
|
||||
await generateTypedocDocs(cfg.logger);
|
||||
cfg.logger.info("Finished typedoc generation");
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
editLink: {
|
||||
baseUrl:
|
||||
"https://github.com/amll-dev/applemusic-like-lyrics/blob/main/packages/docs/",
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
36
amll-local/packages/docs/package.json
Normal file
36
amll-local/packages/docs/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@applemusic-like-lyrics/docs",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"build:full": "run-s build:full:projects build:full:copy-playground",
|
||||
"build:full:projects": "cross-env PLAYGROUND_BASE_URL=/playground/ nx run-many --target=build --projects=docs,playground-core",
|
||||
"build:full:copy-playground": "node ./scripts/copy-playground.js",
|
||||
"preview": "astro preview"
|
||||
},
|
||||
"imports": {
|
||||
"#components/*": "./src/components/*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@applemusic-like-lyrics/core": "workspace:^",
|
||||
"@applemusic-like-lyrics/react": "workspace:^",
|
||||
"@astrojs/react": "^5.0.5",
|
||||
"@astrojs/starlight": "^0.39.2",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"astro": "^6.3.3",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"sharp": "catalog:",
|
||||
"starlight-sidebar-topics": "^0.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^10.1.0",
|
||||
"typedoc": "catalog:",
|
||||
"typedoc-plugin-mark-react-functional-components": "^0.2.2",
|
||||
"typedoc-plugin-markdown": "catalog:",
|
||||
"typedoc-plugin-vue": "^1.5.1"
|
||||
}
|
||||
}
|
||||
BIN
amll-local/packages/docs/public/favicon.ico
Normal file
BIN
amll-local/packages/docs/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
5
amll-local/packages/docs/scripts/copy-playground.js
Normal file
5
amll-local/packages/docs/scripts/copy-playground.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import fs from "node:fs";
|
||||
|
||||
fs.rmSync("dist/playground", { recursive: true, force: true });
|
||||
fs.cpSync("../playground/core/dist", "dist/playground", { recursive: true });
|
||||
console.log("Playground copied to dist/playground");
|
||||
159
amll-local/packages/docs/src/assets/amll-logo.svg
Normal file
159
amll-local/packages/docs/src/assets/amll-logo.svg
Normal file
@@ -0,0 +1,159 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_5_32)">
|
||||
<rect width="64" height="64" fill="url(#paint0_linear_5_32)"/>
|
||||
<g filter="url(#filter0_dii_5_32)">
|
||||
<path d="M29.8252 23.9748C29.8252 24.2495 29.7467 24.4845 29.5896 24.6799C29.4326 24.8754 29.2231 24.9995 28.9614 25.0523L22.1288 26.5576C21.8042 26.6316 21.5921 26.7266 21.4927 26.8428C21.3932 26.959 21.3435 27.2126 21.3435 27.6034V41.3098C21.3435 42.3873 21.1681 43.309 20.8173 44.0748C20.4665 44.8407 20.0084 45.4692 19.4429 45.9605C18.8774 46.4517 18.2727 46.8108 17.6287 47.038C16.9848 47.2651 16.3748 47.3786 15.7989 47.3786C14.5318 47.3786 13.4978 47.0063 12.6967 46.2615C11.8957 45.5168 11.4951 44.5581 11.4951 43.3856C11.4951 42.213 11.859 41.2438 12.5868 40.4779C13.3145 39.712 14.4324 39.1707 15.9402 38.8537L18.312 38.3467C18.9507 38.2199 19.2387 37.8027 19.1759 37.0949L19.1445 20.4413C19.1445 19.575 19.6576 19.0257 20.6838 18.7933L28.3331 17.1137C28.7415 17.0397 29.0923 17.1216 29.3854 17.3593C29.6786 17.597 29.8252 17.9271 29.8252 18.3496V23.9748Z" fill="url(#paint1_linear_5_32)"/>
|
||||
</g>
|
||||
<g filter="url(#filter1_dii_5_32)">
|
||||
<path d="M36.5359 17.3981C35.4894 17.3981 34.9662 17.3981 34.5851 17.6399C34.4886 17.7012 34.3994 17.7727 34.3191 17.853C34.0379 18.1342 33.8641 18.5225 33.8641 18.9515C33.8641 19.3804 34.0379 19.7688 34.3191 20.0499C34.3994 20.1302 34.4886 20.2018 34.5851 20.263C34.9662 20.5049 35.4894 20.5049 36.5359 20.5049H48.5903C49.6368 20.5049 50.16 20.5049 50.5411 20.263C50.6376 20.2018 50.7268 20.1302 50.8072 20.0499C51.0883 19.7688 51.2621 19.3804 51.2621 18.9515C51.2621 18.5225 51.0883 18.1342 50.8072 17.853C50.7268 17.7727 50.6376 17.7012 50.5411 17.6399C50.16 17.3981 49.6368 17.3981 48.5903 17.3981H36.5359Z" fill="url(#paint2_linear_5_32)"/>
|
||||
</g>
|
||||
<g filter="url(#filter2_dii_5_32)">
|
||||
<path d="M36.5359 26.0971C35.4894 26.0971 34.9662 26.0971 34.5851 26.3389C34.4886 26.4002 34.3994 26.4717 34.3191 26.5521C34.0379 26.8332 33.8641 27.2215 33.8641 27.6505C33.8641 28.0794 34.0379 28.4678 34.3191 28.7489C34.3994 28.8293 34.4886 28.9008 34.5851 28.9621C34.9662 29.2039 35.4894 29.2039 36.5359 29.2039H48.5903C49.6368 29.2039 50.16 29.2039 50.5411 28.9621C50.6376 28.9008 50.7268 28.8293 50.8072 28.7489C51.0883 28.4678 51.2621 28.0794 51.2621 27.6505C51.2621 27.2215 51.0883 26.8332 50.8072 26.5521C50.7268 26.4717 50.6376 26.4002 50.5411 26.3389C50.16 26.0971 49.6368 26.0971 48.5903 26.0971H36.5359Z" fill="url(#paint3_linear_5_32)"/>
|
||||
</g>
|
||||
<g filter="url(#filter3_dii_5_32)">
|
||||
<path d="M36.5359 34.7961C35.4894 34.7961 34.9662 34.7961 34.5851 35.0379C34.4886 35.0992 34.3994 35.1707 34.3191 35.2511C34.0379 35.5322 33.8641 35.9206 33.8641 36.3495C33.8641 36.7785 34.0379 37.1668 34.3191 37.4479C34.3994 37.5283 34.4886 37.5998 34.5851 37.6611C34.9662 37.9029 35.4894 37.9029 36.5359 37.9029H48.5903C49.6368 37.9029 50.16 37.9029 50.5411 37.6611C50.6376 37.5998 50.7268 37.5283 50.8072 37.4479C51.0883 37.1668 51.2621 36.7785 51.2621 36.3495C51.2621 35.9206 51.0883 35.5322 50.8072 35.2511C50.7268 35.1707 50.6376 35.0992 50.5411 35.0379C50.16 34.7961 49.6368 34.7961 48.5903 34.7961H36.5359Z" fill="url(#paint4_linear_5_32)"/>
|
||||
</g>
|
||||
<g filter="url(#filter4_dii_5_32)">
|
||||
<path d="M36.5359 43.4951C35.4894 43.4951 34.9662 43.4951 34.5851 43.737C34.4886 43.7982 34.3994 43.8698 34.3191 43.9501C34.0379 44.2312 33.8641 44.6196 33.8641 45.0485C33.8641 45.4775 34.0379 45.8659 34.3191 46.147C34.3994 46.2273 34.4886 46.2989 34.5851 46.3601C34.9662 46.6019 35.4894 46.6019 36.5359 46.6019H48.5903C49.6368 46.6019 50.16 46.6019 50.5411 46.3601C50.6376 46.2989 50.7268 46.2273 50.8072 46.147C51.0883 45.8659 51.2621 45.4775 51.2621 45.0485C51.2621 44.6196 51.0883 44.2312 50.8072 43.9501C50.7268 43.8698 50.6376 43.7982 50.5411 43.737C50.16 43.4951 49.6368 43.4951 48.5903 43.4951H36.5359Z" fill="url(#paint5_linear_5_32)"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_dii_5_32" x="9.01515" y="15.8474" width="23.2901" height="35.2513" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="1.24"/>
|
||||
<feGaussianBlur stdDeviation="1.24"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
|
||||
<feBlend mode="plus-darker" in2="BackgroundImageFix" result="effect1_dropShadow_5_32"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_5_32" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="0.155"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.531765 0 0 0 0 0.00148115 0 0 0 0 0.00148115 0 0 0 0.25 0"/>
|
||||
<feBlend mode="plus-darker" in2="shape" result="effect2_innerShadow_5_32"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="-0.465"/>
|
||||
<feGaussianBlur stdDeviation="0.2325"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.111024 0 0 0 0 0.111024 0 0 0 0.18 0"/>
|
||||
<feBlend mode="plus-darker" in2="effect2_innerShadow_5_32" result="effect3_innerShadow_5_32"/>
|
||||
</filter>
|
||||
<filter id="filter1_dii_5_32" x="31.3841" y="16.1581" width="22.3581" height="8.06681" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="1.24"/>
|
||||
<feGaussianBlur stdDeviation="1.24"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
|
||||
<feBlend mode="plus-darker" in2="BackgroundImageFix" result="effect1_dropShadow_5_32"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_5_32" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="0.155"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.531765 0 0 0 0 0.00148115 0 0 0 0 0.00148115 0 0 0 0.25 0"/>
|
||||
<feBlend mode="plus-darker" in2="shape" result="effect2_innerShadow_5_32"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="-0.465"/>
|
||||
<feGaussianBlur stdDeviation="0.2325"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.111024 0 0 0 0 0.111024 0 0 0 0.18 0"/>
|
||||
<feBlend mode="plus-darker" in2="effect2_innerShadow_5_32" result="effect3_innerShadow_5_32"/>
|
||||
</filter>
|
||||
<filter id="filter2_dii_5_32" x="31.3841" y="24.8571" width="22.3581" height="8.06678" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="1.24"/>
|
||||
<feGaussianBlur stdDeviation="1.24"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
|
||||
<feBlend mode="plus-darker" in2="BackgroundImageFix" result="effect1_dropShadow_5_32"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_5_32" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="0.155"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.531765 0 0 0 0 0.00148115 0 0 0 0 0.00148115 0 0 0 0.25 0"/>
|
||||
<feBlend mode="plus-darker" in2="shape" result="effect2_innerShadow_5_32"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="-0.465"/>
|
||||
<feGaussianBlur stdDeviation="0.2325"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.111024 0 0 0 0 0.111024 0 0 0 0.18 0"/>
|
||||
<feBlend mode="plus-darker" in2="effect2_innerShadow_5_32" result="effect3_innerShadow_5_32"/>
|
||||
</filter>
|
||||
<filter id="filter3_dii_5_32" x="31.3841" y="33.5561" width="22.3581" height="8.06678" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="1.24"/>
|
||||
<feGaussianBlur stdDeviation="1.24"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
|
||||
<feBlend mode="plus-darker" in2="BackgroundImageFix" result="effect1_dropShadow_5_32"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_5_32" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="0.155"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.531765 0 0 0 0 0.00148115 0 0 0 0 0.00148115 0 0 0 0.25 0"/>
|
||||
<feBlend mode="plus-darker" in2="shape" result="effect2_innerShadow_5_32"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="-0.465"/>
|
||||
<feGaussianBlur stdDeviation="0.2325"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.111024 0 0 0 0 0.111024 0 0 0 0.18 0"/>
|
||||
<feBlend mode="plus-darker" in2="effect2_innerShadow_5_32" result="effect3_innerShadow_5_32"/>
|
||||
</filter>
|
||||
<filter id="filter4_dii_5_32" x="31.3841" y="42.2551" width="22.3581" height="8.06678" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="1.24"/>
|
||||
<feGaussianBlur stdDeviation="1.24"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
|
||||
<feBlend mode="plus-darker" in2="BackgroundImageFix" result="effect1_dropShadow_5_32"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_5_32" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="0.155"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.531765 0 0 0 0 0.00148115 0 0 0 0 0.00148115 0 0 0 0.25 0"/>
|
||||
<feBlend mode="plus-darker" in2="shape" result="effect2_innerShadow_5_32"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="-0.465"/>
|
||||
<feGaussianBlur stdDeviation="0.2325"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.111024 0 0 0 0 0.111024 0 0 0 0.18 0"/>
|
||||
<feBlend mode="plus-darker" in2="effect2_innerShadow_5_32" result="effect3_innerShadow_5_32"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_5_32" x1="34.375" y1="1.26961e-06" x2="34.375" y2="64" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#F96868"/>
|
||||
<stop offset="1" stop-color="#FF4040"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_5_32" x1="20.6602" y1="17.0874" x2="20.6602" y2="47.3786" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FEFEFE"/>
|
||||
<stop offset="1" stop-color="#ECECEC"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_5_32" x1="42.5631" y1="17.3981" x2="42.5631" y2="20.5049" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FEFEFE"/>
|
||||
<stop offset="1" stop-color="#ECECEC"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint3_linear_5_32" x1="42.5631" y1="26.0971" x2="42.5631" y2="29.2039" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FEFEFE"/>
|
||||
<stop offset="1" stop-color="#ECECEC"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint4_linear_5_32" x1="42.5631" y1="34.7961" x2="42.5631" y2="37.9029" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FEFEFE"/>
|
||||
<stop offset="1" stop-color="#ECECEC"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint5_linear_5_32" x1="42.5631" y1="43.4951" x2="42.5631" y2="46.6019" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FEFEFE"/>
|
||||
<stop offset="1" stop-color="#ECECEC"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_5_32">
|
||||
<rect width="64" height="64" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
161
amll-local/packages/docs/src/components/AMLLPreview/index.tsx
Normal file
161
amll-local/packages/docs/src/components/AMLLPreview/index.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import type { LyricLine } from "@applemusic-like-lyrics/core";
|
||||
import "@applemusic-like-lyrics/core/style.css";
|
||||
import { LyricPlayer } from "@applemusic-like-lyrics/react";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
const buildLyricLines = (
|
||||
lyric: string,
|
||||
startTime: number,
|
||||
otherParams: Partial<LyricLine> = {},
|
||||
): LyricLine => {
|
||||
let curTime = startTime;
|
||||
const words = [];
|
||||
for (const word of lyric.split("|")) {
|
||||
const [text, duration] = word.split(",");
|
||||
const endTime = curTime + Number(duration);
|
||||
words.push({
|
||||
word: text,
|
||||
startTime: curTime,
|
||||
endTime,
|
||||
obscene: false,
|
||||
});
|
||||
curTime = endTime;
|
||||
}
|
||||
return {
|
||||
startTime,
|
||||
endTime: curTime + 500,
|
||||
translatedLyric: "",
|
||||
romanLyric: "",
|
||||
isBG: false,
|
||||
isDuet: false,
|
||||
words,
|
||||
...otherParams,
|
||||
};
|
||||
};
|
||||
|
||||
const DEMO_LYRICS: LyricLine[][] = [
|
||||
[
|
||||
buildLyricLines(
|
||||
"Apple ,350|Music ,300|Like ,300|Ly,500|ri,900|cs ,250",
|
||||
2000,
|
||||
{ translatedLyric: "类苹果歌词" },
|
||||
),
|
||||
// A lyric component library for the web
|
||||
buildLyricLines(
|
||||
"A ,200|ly,100|ric ,250|com,200|po,200|nent ,200|li,100|bra,200|ry ,100|for ,100|the ,200|web ,600",
|
||||
5000,
|
||||
{ translatedLyric: "为 Web 而生的歌词组件库" },
|
||||
),
|
||||
// Brought to you with
|
||||
buildLyricLines("Brought ,300|to ,250|you ,800|with ,600", 8000, {
|
||||
translatedLyric: "为你带来",
|
||||
}),
|
||||
// Background Lyric Line
|
||||
buildLyricLines("Background ,750|lyric ,300|line ,500", 8500, {
|
||||
translatedLyric: "背景歌词行",
|
||||
isBG: true,
|
||||
}),
|
||||
// And Duet Lyric Line
|
||||
buildLyricLines("And ,300|Duet ,300|lyric ,500|line ,650", 10500, {
|
||||
translatedLyric: "还有对唱歌词行",
|
||||
isDuet: true,
|
||||
}),
|
||||
],
|
||||
];
|
||||
|
||||
export const AMLLPreview = () => {
|
||||
const [lyricLines, setLyricLines] = useState<LyricLine[]>([]);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const wRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
let selectedDemo = DEMO_LYRICS.length - 1;
|
||||
let endTime = 0;
|
||||
let startTime = 0;
|
||||
let canceled = false;
|
||||
|
||||
const onFrame = (time: number) => {
|
||||
if (canceled) return;
|
||||
if (time - startTime > endTime) {
|
||||
const w = wRef.current;
|
||||
if (!w) {
|
||||
if (canceled) return;
|
||||
return;
|
||||
}
|
||||
if (canceled) return;
|
||||
|
||||
setCurrentTime(0);
|
||||
|
||||
w.animate(
|
||||
{
|
||||
opacity: 0,
|
||||
filter: "blur(10px)",
|
||||
},
|
||||
{
|
||||
duration: 500,
|
||||
easing: "ease-in-out",
|
||||
fill: "forwards",
|
||||
},
|
||||
).onfinish = () => {
|
||||
if (canceled) return;
|
||||
|
||||
selectedDemo = (selectedDemo + 1) % DEMO_LYRICS.length;
|
||||
setLyricLines(JSON.parse(JSON.stringify(DEMO_LYRICS[selectedDemo])));
|
||||
endTime = DEMO_LYRICS[selectedDemo].reduce(
|
||||
(acc, v) => Math.max(acc, v.endTime),
|
||||
0,
|
||||
);
|
||||
startTime = time;
|
||||
|
||||
setTimeout(() => {
|
||||
if (canceled) return;
|
||||
w.animate(
|
||||
{
|
||||
opacity: 1,
|
||||
filter: "blur(0px)",
|
||||
},
|
||||
{
|
||||
duration: 300,
|
||||
easing: "ease-in-out",
|
||||
fill: "forwards",
|
||||
},
|
||||
).onfinish = () => {
|
||||
if (canceled) return;
|
||||
requestAnimationFrame(onFrame);
|
||||
};
|
||||
}, 600);
|
||||
};
|
||||
} else {
|
||||
setCurrentTime((time - startTime) | 0);
|
||||
requestAnimationFrame(onFrame);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(onFrame);
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
maskImage:
|
||||
"linear-gradient(to bottom, transparent 0%, white 5%, white 95%, transparent 100%)",
|
||||
transition: "opacity 0.5s, filter 0.5s",
|
||||
}}
|
||||
ref={wRef}
|
||||
>
|
||||
<LyricPlayer
|
||||
currentTime={currentTime}
|
||||
lyricLines={lyricLines}
|
||||
alignAnchor="top"
|
||||
alignPosition={0.05}
|
||||
style={{
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
30
amll-local/packages/docs/src/components/PackageInstall.astro
Normal file
30
amll-local/packages/docs/src/components/PackageInstall.astro
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
import { Code, TabItem, Tabs } from "@astrojs/starlight/components";
|
||||
|
||||
interface Props {
|
||||
packages: string | string[];
|
||||
dev?: boolean;
|
||||
syncKey?: string;
|
||||
}
|
||||
|
||||
const { packages, dev = false, syncKey = "pkg" } = Astro.props;
|
||||
const packageList = Array.isArray(packages)
|
||||
? packages
|
||||
: packages.split(/\s+/).filter(Boolean);
|
||||
const packageNames = packageList.join(" ");
|
||||
const devFlag = dev ? " -D" : "";
|
||||
|
||||
const commands = [
|
||||
{ label: "npm", command: `npm install${devFlag} ${packageNames}` },
|
||||
{ label: "pnpm", command: `pnpm add${devFlag} ${packageNames}` },
|
||||
{ label: "Yarn", command: `yarn add${devFlag} ${packageNames}` },
|
||||
];
|
||||
---
|
||||
|
||||
<Tabs syncKey={syncKey}>
|
||||
{commands.map(({ label, command }) => (
|
||||
<TabItem label={label}>
|
||||
<Code code={command} lang="sh" />
|
||||
</TabItem>
|
||||
))}
|
||||
</Tabs>
|
||||
7
amll-local/packages/docs/src/content.config.ts
Normal file
7
amll-local/packages/docs/src/content.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineCollection } from "astro:content";
|
||||
import { docsLoader } from "@astrojs/starlight/loaders";
|
||||
import { docsSchema } from "@astrojs/starlight/schema";
|
||||
|
||||
export const collections = {
|
||||
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: 开发环境配置
|
||||
---
|
||||
|
||||
## 必要环境
|
||||
|
||||
- pnpm([官网](https://pnpm.io/)),建议版本与仓库 `package.json` 中 `packageManager` 一致(当前为 `pnpm@11.1.0`)
|
||||
|
||||
本仓库默认使用 `pnpm nx ...` 执行 Nx 命令,不要求全局安装 Nx。为了本地便利开发,你也可以全局安装 Nx,效果是相同的。
|
||||
|
||||
Node.js 仅在 npm 发布相关 CI 步骤中作为运行时使用(当前发布工作流为 Node 24)。
|
||||
|
||||
### 版本自查
|
||||
|
||||
```bash
|
||||
pnpm --version
|
||||
nx --version # 可选
|
||||
```
|
||||
|
||||
## 首次初始化
|
||||
|
||||
在仓库根目录执行:
|
||||
|
||||
```bash
|
||||
pnpm install --frozen-lockfile
|
||||
```
|
||||
|
||||
完成后,执行一次构建所有包:`pnpm run build:libs`,若成功构建完成说明环境无误,可以开始工作。
|
||||
|
||||
### 依赖安装慢或失败
|
||||
|
||||
优先确认 pnpm 版本与锁文件一致,再重试:
|
||||
|
||||
```bash
|
||||
pnpm install --frozen-lockfile
|
||||
```
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
title: 仓库结构介绍
|
||||
---
|
||||
|
||||
import { FileTree } from "@astrojs/starlight/components";
|
||||
|
||||
## Monorepo 结构
|
||||
|
||||
仓库使用 `Nx` 管理任务编排,`pnpm workspace` 管理依赖,主要目录如下:
|
||||
|
||||
<FileTree>
|
||||
|
||||
- packages/
|
||||
- core/ 核心组件,Vanilla JS 实现
|
||||
- react/ 核心组件的 React 绑定
|
||||
- vue/ 核心组件的 Vue 绑定
|
||||
- react-full/ 可直接使用的完整播放器组件,仅 React 版本
|
||||
- ttml/ TTML 解析与处理库
|
||||
- lyric/ 歌词解析与处理库,TTML 实际依赖 ttml 包
|
||||
- docs/ Astro + Starlight 文档站
|
||||
- playground/ 用于开发和测试的示例项目
|
||||
- core/ 使用核心组件的示例
|
||||
- react/ 使用 React 绑定的示例
|
||||
- vue/ 使用 Vue 绑定的示例
|
||||
- react-full/ 使用完整播放器组件的示例
|
||||
- .github/
|
||||
- workflows/
|
||||
- scripts/
|
||||
|
||||
</FileTree>
|
||||
|
||||
## Nx 发布分组
|
||||
|
||||
`nx.json` 中的发布配置包含两个主要 group:
|
||||
|
||||
- `core-bundle` 三个包版本保持一致:
|
||||
- `@applemusic-like-lyrics/core`
|
||||
- `@applemusic-like-lyrics/react`
|
||||
- `@applemusic-like-lyrics/vue`
|
||||
- 其余包版本各自独立:
|
||||
- `@applemusic-like-lyrics/lyric`
|
||||
- `@applemusic-like-lyrics/react-full`
|
||||
- `@applemusic-like-lyrics/ttml`
|
||||
|
||||
## 依赖关系
|
||||
|
||||
你可以在终端执行:
|
||||
|
||||
```sh
|
||||
pnpm nx graph
|
||||
```
|
||||
|
||||
来查看完整的依赖关系图。
|
||||
|
||||
在提升版本号时,如果被依赖的包发生了更新,则依赖它的包也会被自动提升版本号,即使代码没有改动。
|
||||
|
||||
## 命令行
|
||||
|
||||
### 库开发
|
||||
|
||||
Nx 提供了便捷的命令行工具,无需输入 `@applemusic-like-lyrics/` 前缀也可执行命令。
|
||||
|
||||
例如,要构建 `@applemusic-like-lyrics/react` 包,可以直接执行:
|
||||
|
||||
```sh
|
||||
pnpm nx build react
|
||||
```
|
||||
|
||||
Nx 会自动解析依赖,因此会先构建 `core` 包,然后构建 `react` 包。
|
||||
|
||||
我们在 `package/playground` 中准备了用于测试的 web app,基于 Vite,具有 HMR 支持。我们从 `core`、`react`、`vue`、`react-full` 中添加了转发,因此要启动对应环境,只需要:
|
||||
|
||||
```sh
|
||||
# 启动 package/playground/core 的开发服务器
|
||||
pnpm nx dev core
|
||||
|
||||
# 下面同理
|
||||
pnpm nx dev react
|
||||
pnpm nx dev vue
|
||||
pnpm nx dev react-full
|
||||
```
|
||||
|
||||
要构建全部包,执行:
|
||||
|
||||
```sh
|
||||
pnpm build:libs
|
||||
```
|
||||
|
||||
即可自动并行构建所有包,构建顺序由依赖关系决定。
|
||||
|
||||
### 文档开发
|
||||
|
||||
同理,文档站的别名是 `docs`,有如下命令:
|
||||
|
||||
```sh
|
||||
# 启动文档站开发服务器
|
||||
pnpm nx dev docs
|
||||
# 构建文档站
|
||||
pnpm nx build docs
|
||||
```
|
||||
|
||||
会自动处理依赖关系。
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
title: PR 流程
|
||||
---
|
||||
|
||||
## 基本要求
|
||||
|
||||
- 所有改动通过 PR 合入 `main`。
|
||||
- PR 进入 Ready for Review 后会触发校验工作流:`.github/workflows/pr-release-check.yaml`。
|
||||
- 通过校验后再合并。
|
||||
|
||||
## PR 校验包含什么
|
||||
|
||||
工作流会做两类检查:
|
||||
|
||||
1. `Release metadata`
|
||||
- 判断本次 PR 是否需要 release plan。
|
||||
- 在需要时执行 `pnpm run release:plan:check --base=... --head=...`。
|
||||
|
||||
2. `Build libs`
|
||||
- 安装 pnpm 依赖(`pnpm install --frozen-lockfile`)。
|
||||
- 执行 `pnpm run ci:build:libs`。
|
||||
|
||||
## 什么时候需要 release plan
|
||||
|
||||
检查逻辑来自 `.github/scripts/check-release-requirements.mjs`:
|
||||
|
||||
- 如果改动全部是文档/CI/基础设施等忽略范围,PR 必须打上 `no-release` label。
|
||||
- 否则,即改动包含“非忽略文件”,必须有 release plan(存放于 `.nx/version-plans/`)。
|
||||
- `no-release` 只能用于“全部改动都在忽略范围”的 PR。
|
||||
|
||||
## 创建 release plan(本地)
|
||||
|
||||
可在仓库根目录执行:
|
||||
|
||||
```bash
|
||||
# 只为本次 touched 项目生成计划(默认行为)
|
||||
pnpm nx release plan
|
||||
```
|
||||
|
||||
然后根据提示选择各包变动幅度并输入 changelog。
|
||||
|
||||
命令会在 `.nx/version-plans/` 下生成计划文件,请将该文件一并提交。
|
||||
|
||||
## 关于合并
|
||||
|
||||
目前设置了分支保护规则,仅允许 squash merge,以保持主分支洁净。
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
title: 发布包
|
||||
---
|
||||
|
||||
## 发布方式
|
||||
|
||||
npm 包发布通过 GitHub Actions 手动触发,工作流文件:
|
||||
|
||||
- `.github/workflows/publish-libs.yaml`
|
||||
|
||||
触发方式为 `workflow_dispatch`,并带 `mode` 参数:
|
||||
|
||||
- `dry-run`:仅演练版本与发布流程,不真正发布。
|
||||
- `publish`:执行真实发版并推送 release commit/tag。
|
||||
|
||||
## 发布前置条件
|
||||
|
||||
- `publish` 必须从 `main` 分支触发,`dry-run` 可以从其他分支触发。
|
||||
- `.nx/version-plans/` 下必须存在 release plan 文件。
|
||||
|
||||
## 工作流执行步骤摘要
|
||||
|
||||
1. 当 `mode=publish` 时,强校验当前分支必须是 `main`。
|
||||
2. 安装依赖与发布环境(pnpm、Node 24)。
|
||||
3. 校验 trusted publishing 运行时(Node 版本等)。
|
||||
4. 执行 `pnpm install --frozen-lockfile`。
|
||||
5. 执行 `npx nx release --skip-publish --preid alpha` 创建 release commit 与 tags。
|
||||
6. 格式化 `package.json` 并 amend release commit。
|
||||
7. 当 `mode=publish` 时:
|
||||
- `git push origin HEAD:main --follow-tags`。
|
||||
- 调整发布前清单(强制 npm 发布器、准备 npm manifests)。
|
||||
- 执行 `npx nx release publish --excludeTaskDependencies` 发布到 npm。
|
||||
|
||||
## 推荐发布流程
|
||||
|
||||
1. 先手动触发一次 `mode=dry-run`,确认版本变更与发布前检查正常(该模式不会推送 tags,也不会发布到 npm)。
|
||||
2. 再触发 `mode=publish` 完成正式发布。
|
||||
3. 发布后检查 npm 与 GitHub tags 是否符合预期。
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
title: 贡献指南
|
||||
description: 项目结构介绍、开发环境配置与仓库规范
|
||||
editUrl: false
|
||||
---
|
||||
|
||||
欢迎参与 AMLL 开发。
|
||||
|
||||
本仓库是一个基于 `Nx + pnpm workspace` 的 monorepo,包含多个可发布 npm 包和一个 Astro 文档站。
|
||||
|
||||
你可以按下面顺序阅读:
|
||||
|
||||
1. [开发环境配置](/contribute/development/environments):本地依赖与常用命令。
|
||||
2. [仓库结构介绍](/contribute/development/structure):各包职责和依赖关系。
|
||||
3. [PR 流程](/contribute/guidelines/pr):以 PR 向 `main` 提交更新的要求。
|
||||
4. [发布包](/contribute/guidelines/publishing):手动触发 Actions 发版流程。
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: Development Environment Setup
|
||||
---
|
||||
|
||||
## Required Environment
|
||||
|
||||
- pnpm ([official site](https://pnpm.io/)); use the version specified in the repository `package.json` `packageManager` field when possible (currently `pnpm@10.15.1`)
|
||||
|
||||
This repository uses `pnpm nx ...` by default for Nx commands, so global Nx installation is not required. For local convenience, you can still install Nx globally; behavior is the same.
|
||||
|
||||
Node.js is only used as the runtime in npm publishing related CI steps (currently Node 24 in the publishing workflow).
|
||||
|
||||
### Version Check
|
||||
|
||||
```bash
|
||||
pnpm --version
|
||||
nx --version # optional
|
||||
```
|
||||
|
||||
## First-time Initialization
|
||||
|
||||
Run in the repository root:
|
||||
|
||||
```bash
|
||||
pnpm install --frozen-lockfile
|
||||
```
|
||||
|
||||
After setup, build all libraries once with `pnpm run build:libs`. If it succeeds, your environment is ready.
|
||||
|
||||
### Dependency installation is slow or fails
|
||||
|
||||
First verify your pnpm version matches the lockfile expectations, then retry:
|
||||
|
||||
```bash
|
||||
pnpm install --frozen-lockfile
|
||||
```
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
title: Repository Structure
|
||||
---
|
||||
|
||||
import { FileTree } from "@astrojs/starlight/components";
|
||||
|
||||
## Monorepo Structure
|
||||
|
||||
The repository uses `Nx` for task orchestration and `pnpm workspace` for dependency management. Main directories are:
|
||||
|
||||
<FileTree>
|
||||
|
||||
- packages/
|
||||
- core/ Core components in Vanilla JS
|
||||
- react/ React bindings for core
|
||||
- vue/ Vue bindings for core
|
||||
- react-full/ Ready-to-use full player components (React only)
|
||||
- ttml/ TTML parser and processing library
|
||||
- lyric/ Lyrics parser and processing library (TTML depends on the ttml package)
|
||||
- docs/ Astro + Starlight docs site
|
||||
- playground/ Example projects for development and testing
|
||||
- core/ Example using core
|
||||
- react/ Example using React bindings
|
||||
- vue/ Example using Vue bindings
|
||||
- react-full/ Example using full player components
|
||||
- .github/
|
||||
- workflows/
|
||||
- scripts/
|
||||
|
||||
</FileTree>
|
||||
|
||||
## Nx Release Groups
|
||||
|
||||
Release config in `nx.json` includes two major groups:
|
||||
|
||||
- `core-bundle`: three packages share the same version:
|
||||
- `@applemusic-like-lyrics/core`
|
||||
- `@applemusic-like-lyrics/react`
|
||||
- `@applemusic-like-lyrics/vue`
|
||||
- Other packages are versioned independently:
|
||||
- `@applemusic-like-lyrics/lyric`
|
||||
- `@applemusic-like-lyrics/react-full`
|
||||
- `@applemusic-like-lyrics/ttml`
|
||||
|
||||
## Dependency Graph
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
pnpm nx graph
|
||||
```
|
||||
|
||||
to view the full dependency graph.
|
||||
|
||||
When bumping versions, if a dependency package changes, dependent packages may also be bumped automatically even when their own code is unchanged.
|
||||
|
||||
## CLI
|
||||
|
||||
### Library Development
|
||||
|
||||
Nx provides convenient aliases; you do not need to type the `@applemusic-like-lyrics/` prefix.
|
||||
|
||||
For example, to build `@applemusic-like-lyrics/react`:
|
||||
|
||||
```sh
|
||||
pnpm nx build react
|
||||
```
|
||||
|
||||
Nx resolves dependencies automatically, so it builds `core` first, then `react`.
|
||||
|
||||
We provide web apps in `package/playground` for testing, based on Vite with HMR support. We added forwarding entries for `core`, `react`, `vue`, and `react-full`, so to start a matching environment:
|
||||
|
||||
```sh
|
||||
# Start dev server for package/playground/core
|
||||
pnpm nx dev core
|
||||
|
||||
# Same for others
|
||||
pnpm nx dev react
|
||||
pnpm nx dev vue
|
||||
pnpm nx dev react-full
|
||||
```
|
||||
|
||||
To build all libraries:
|
||||
|
||||
```sh
|
||||
pnpm build:libs
|
||||
```
|
||||
|
||||
It builds all packages in parallel with dependency-aware ordering.
|
||||
|
||||
### Docs Development
|
||||
|
||||
Similarly, the docs site alias is `docs`, with these commands:
|
||||
|
||||
```sh
|
||||
# Start docs dev server
|
||||
pnpm nx dev docs
|
||||
# Build docs site
|
||||
pnpm nx build docs
|
||||
```
|
||||
|
||||
Dependencies are handled automatically.
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
title: PR Process
|
||||
---
|
||||
|
||||
## Basic Requirements
|
||||
|
||||
- All changes are merged into `main` via PR.
|
||||
- After a PR is set to Ready for Review, the validation workflow `.github/workflows/pr-release-check.yaml` runs.
|
||||
- Merge only after checks pass.
|
||||
|
||||
## What PR Validation Includes
|
||||
|
||||
The workflow runs two groups of checks:
|
||||
|
||||
1. `Release metadata`
|
||||
- Determines whether this PR requires a release plan.
|
||||
- Runs `pnpm run release:plan:check --base=... --head=...` when needed.
|
||||
|
||||
2. `Build libs`
|
||||
- Installs pnpm dependencies (`pnpm install --frozen-lockfile`).
|
||||
- Runs `pnpm run ci:build:libs`.
|
||||
|
||||
## When a Release Plan Is Required
|
||||
|
||||
The check logic is in `.github/scripts/check-release-requirements.mjs`:
|
||||
|
||||
- If all changes are in ignored scopes (docs/CI/infrastructure, etc.), the PR must have the `no-release` label.
|
||||
- Otherwise, if there are non-ignored file changes, a release plan is required (stored in `.nx/version-plans/`).
|
||||
- `no-release` can only be used for PRs where all changes are in ignored scopes.
|
||||
|
||||
## Create a Release Plan Locally
|
||||
|
||||
Run in the repository root:
|
||||
|
||||
```bash
|
||||
# Generate plans only for touched projects (default behavior)
|
||||
pnpm nx release plan
|
||||
```
|
||||
|
||||
Then choose the bump level for each package and enter changelog messages as prompted.
|
||||
|
||||
The command generates plan files in `.nx/version-plans/`; commit them together with your changes.
|
||||
|
||||
## About Merging
|
||||
|
||||
A branch protection rule is enabled to allow only squash merges, keeping `main` clean.
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
title: Publishing Packages
|
||||
---
|
||||
|
||||
## Publishing Method
|
||||
|
||||
npm package publishing is triggered manually through GitHub Actions workflow:
|
||||
|
||||
- `.github/workflows/publish-libs.yaml`
|
||||
|
||||
The trigger is `workflow_dispatch` with a `mode` parameter:
|
||||
|
||||
- `dry-run`: rehearses versioning and publishing flow without actual publishing.
|
||||
- `publish`: performs real release and pushes release commit/tag.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- `publish` must be triggered from the `main` branch. `dry-run` can be triggered from other branches.
|
||||
- A release plan file must exist in `.nx/version-plans/`.
|
||||
|
||||
## Workflow Steps Summary
|
||||
|
||||
1. When `mode=publish`, strictly validate current branch is `main`.
|
||||
2. Install dependencies and release environment (pnpm, Node 24).
|
||||
3. Validate trusted publishing runtime requirements (Node version, etc.).
|
||||
4. Run `pnpm install --frozen-lockfile`.
|
||||
5. Run `npx nx release --skip-publish --preid alpha` to create release commit and tags.
|
||||
6. Format `package.json` and amend the release commit.
|
||||
7. When `mode=publish`:
|
||||
- `git push origin HEAD:main --follow-tags`
|
||||
- Adjust pre-publish manifest handling (force npm registries, prepare npm manifests)
|
||||
- Run `npx nx release publish --excludeTaskDependencies` to publish to npm.
|
||||
|
||||
## Recommended Process
|
||||
|
||||
1. Trigger `mode=dry-run` first, and confirm version changes and pre-publish checks are correct (no tags pushed and no npm publish in this mode).
|
||||
2. Trigger `mode=publish` to perform the formal release.
|
||||
3. Verify npm packages and GitHub tags after publishing.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
title: Contributing
|
||||
description: Repository structure, development environment setup, and repository guidelines
|
||||
editUrl: false
|
||||
---
|
||||
|
||||
Welcome to AMLL development.
|
||||
|
||||
This repository is a monorepo based on `Nx + pnpm workspace`, containing multiple publishable npm packages and an Astro documentation site.
|
||||
|
||||
You can read in this order:
|
||||
|
||||
1. [Development Environment Setup](/en/contribute/development/environments): local dependencies and common commands.
|
||||
2. [Repository Structure](/en/contribute/development/structure): package responsibilities and dependency relationships.
|
||||
3. [PR Process](/en/contribute/guidelines/pr): requirements for submitting updates to `main` through pull requests.
|
||||
4. [Publishing Packages](/en/contribute/guidelines/publishing): manually triggering the Actions publishing workflow.
|
||||
@@ -0,0 +1,218 @@
|
||||
---
|
||||
title: Dynamic Background
|
||||
---
|
||||
|
||||
AMLL Core provides a standalone background rendering component, [`BackgroundRender`](/en/reference/core/classbackgroundrender). It renders album artwork or album video into an Apple Music style dynamic background. The lyric component still only handles the lyric view itself; audio playback, resource loading, and layer mounting should be managed by the host environment.
|
||||
|
||||
This page mainly explains background integration with the vanilla Core API. If you use the React or Vue bindings, jump directly to the [bindings section](#react-and-vue-bindings).
|
||||
|
||||
## Basic Structure
|
||||
|
||||
The background component consists of two parts:
|
||||
|
||||
- [`BackgroundRender`](/en/reference/core/classbackgroundrender): a unified wrapper that provides methods for setting album media, frame rate, render scale, pause/resume state, and more.
|
||||
- Renderer: Core currently provides [`MeshGradientRenderer`](/en/reference/core/classmeshgradientrenderer) and [`PixiRenderer`](/en/reference/core/classpixirenderer).
|
||||
|
||||
When creating a background, choose one of these renderers:
|
||||
|
||||
```ts
|
||||
// Use Mesh Gradient
|
||||
import {
|
||||
BackgroundRender,
|
||||
MeshGradientRenderer,
|
||||
} from "@applemusic-like-lyrics/core";
|
||||
const meshBackground = BackgroundRender.new(MeshGradientRenderer);
|
||||
|
||||
// Use Pixi
|
||||
import { BackgroundRender, PixiRenderer } from "@applemusic-like-lyrics/core";
|
||||
const pixiBackground = BackgroundRender.new(PixiRenderer);
|
||||
```
|
||||
|
||||
## Layering With the Lyric Component
|
||||
|
||||
The background element is a `<canvas>`. Usually, place it in the same container as the lyric component and insert it before the lyric element.
|
||||
|
||||
```ts
|
||||
import {
|
||||
BackgroundRender,
|
||||
DomLyricPlayer,
|
||||
MeshGradientRenderer,
|
||||
} from "@applemusic-like-lyrics/core";
|
||||
import "@applemusic-like-lyrics/core/style.css";
|
||||
|
||||
const background = BackgroundRender.new(MeshGradientRenderer);
|
||||
const lyricPlayer = new DomLyricPlayer();
|
||||
|
||||
function mountPlayer(host: HTMLElement) {
|
||||
const backgroundElement = background.getElement();
|
||||
const lyricElement = lyricPlayer.getElement();
|
||||
|
||||
host.appendChild(backgroundElement);
|
||||
host.appendChild(lyricElement);
|
||||
}
|
||||
|
||||
const host = document.querySelector<HTMLElement>("#player");
|
||||
if (!host) throw new Error("missing #player");
|
||||
|
||||
mountPlayer(host);
|
||||
```
|
||||
|
||||
The canvas size is determined by CSS. Internally, a `ResizeObserver` adjusts the actual drawing size according to the device pixel ratio and render scale. Therefore, the host container should have an explicit width and height.
|
||||
|
||||
AMLL does not define positioning or z-index styles for the background and lyric components. Define those styles in your host application.
|
||||
|
||||
## Setting Album Media
|
||||
|
||||
Call [`setAlbum`](/en/reference/core/classbackgroundrender#setalbum) to set the background source. It accepts an image or video URL, an `HTMLImageElement`, or an `HTMLVideoElement`.
|
||||
|
||||
If you pass a string URL and the resource is a video, set the second parameter to `true`:
|
||||
|
||||
```ts
|
||||
// Image URL
|
||||
await background.setAlbum("/album-cover.jpg");
|
||||
|
||||
// Video URL
|
||||
await background.setAlbum("/album-video.webm", true);
|
||||
```
|
||||
|
||||
If you already have a `File` or `Blob` object, use [`URL.createObjectURL`](https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL_static) to create an object URL and pass it to `setAlbum`.
|
||||
|
||||
The background renderer draws the resource into a canvas or WebGL texture.
|
||||
|
||||
## Syncing Playback State
|
||||
|
||||
The background component has its own animation loop, so you do not need to manually call `update(delta)` frame by frame as you do with `DomLyricPlayer`. Just call `resume()` and `pause()` when playback starts or pauses:
|
||||
|
||||
```ts
|
||||
audio.addEventListener("play", () => {
|
||||
lyricPlayer.resume();
|
||||
background.resume();
|
||||
});
|
||||
|
||||
audio.addEventListener("pause", () => {
|
||||
lyricPlayer.pause();
|
||||
background.pause();
|
||||
});
|
||||
```
|
||||
|
||||
The background animation state is independent from the lyric animation state. You can also control only the background animation.
|
||||
|
||||
```ts
|
||||
function setBackgroundPlaying(playing: boolean) {
|
||||
if (playing) background.resume();
|
||||
else background.pause();
|
||||
}
|
||||
```
|
||||
|
||||
## Applying Render Settings
|
||||
|
||||
Common settings can be applied after initialization or when the user changes options. They take effect immediately.
|
||||
|
||||
```ts
|
||||
function applyBackgroundSettings() {
|
||||
background.setFPS(60);
|
||||
background.setRenderScale(1);
|
||||
background.setFlowSpeed(0.2);
|
||||
background.setStaticMode(false);
|
||||
background.setLowFreqVolume(1);
|
||||
}
|
||||
```
|
||||
|
||||
| Method | Description |
|
||||
| ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ |
|
||||
| [`setFPS(fps)`](/en/reference/core/classbackgroundrender#setfps) | Sets the frame rate of the background animation |
|
||||
| [`setRenderScale(scale)`](/en/reference/core/classbackgroundrender#setrenderscale) | Sets the render scale. Higher values are clearer and also more performance-intensive |
|
||||
| [`setFlowSpeed(speed)`](/en/reference/core/classbackgroundrender#setflowspeed) | Sets the background flow speed |
|
||||
| [`setStaticMode(enable)`](/en/reference/core/classbackgroundrender#setstaticmode) | When enabled, the background can stay static after the resource transition animation to save work |
|
||||
| [`setLowFreqVolume(volume)`](/en/reference/core/classbackgroundrender#setlowfreqvolume) | Passes a low-frequency volume hint. Some renderers may use it to adjust dynamic effects |
|
||||
| [`setHasLyric(hasLyric)`](/en/reference/core/classbackgroundrender#sethaslyric) | Tells the renderer whether the current song has lyrics. Some renderers may adjust effects |
|
||||
|
||||
`setRenderScale` is usually a tradeoff between `0.5` and `1`. On mobile or low-performance devices, lower the render scale and frame rate. For a fullscreen player, you can increase the render scale.
|
||||
|
||||
## Changing Renderers
|
||||
|
||||
After `BackgroundRender` is created, its internal renderer cannot be replaced. If the user switches from `MeshGradientRenderer` to `PixiRenderer`, dispose the old instance and create a new one:
|
||||
|
||||
```ts
|
||||
type PlayerBackground =
|
||||
| BackgroundRender<MeshGradientRenderer>
|
||||
| BackgroundRender<PixiRenderer>;
|
||||
|
||||
function switchToPixiRenderer(
|
||||
host: HTMLElement,
|
||||
lyricPlayer: DomLyricPlayer,
|
||||
currentBackground: PlayerBackground,
|
||||
) {
|
||||
currentBackground.dispose();
|
||||
|
||||
const nextBackground = BackgroundRender.new(PixiRenderer);
|
||||
host.insertBefore(nextBackground.getElement(), lyricPlayer.getElement());
|
||||
return nextBackground;
|
||||
}
|
||||
```
|
||||
|
||||
## Cleanup
|
||||
|
||||
When the page unloads, the player is destroyed, or you permanently switch implementations, release the background instance:
|
||||
|
||||
```ts
|
||||
background.dispose();
|
||||
lyricPlayer.dispose();
|
||||
```
|
||||
|
||||
`dispose()` releases internal renderer resources and removes the background canvas. If you created an `ObjectURL`, audio event listeners, or other async loading state yourself, clean those up in your host code as well.
|
||||
|
||||
## React and Vue Bindings
|
||||
|
||||
The background component has much less intermediate state than the lyric component, so componentized usage is simpler.
|
||||
|
||||
React and Vue are similar: both set options and maintain state through props. Use the `playing` prop to specify playback state, and use the `album` prop to specify album image or video media. See the API reference for the complete prop list.
|
||||
|
||||
- React prop reference: [BackgroundRenderProps](/en/reference/react/interfacebackgroundrenderprops)
|
||||
- Vue prop reference: [BackgroundRender](/en/reference/vue/classbackgroundrender)
|
||||
|
||||
The component automatically releases internal resources when unmounted. You still need to release listeners, object URLs, and similar resources that you create yourself.
|
||||
|
||||
Here is a minimal React example:
|
||||
|
||||
```tsx
|
||||
import { LyricPlayer, BackgroundRender } from "@applemusic-like-lyrics/react";
|
||||
|
||||
function app() {
|
||||
const albumUrl = "/album-cover.jpg";
|
||||
return (
|
||||
<>
|
||||
<BackgroundRender album={albumUrl} />
|
||||
<LyricPlayer lyricLines={lyricLines} currentTime={currentTime} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Here is a minimal Vue example:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<BackgroundRender :album="albumUrl" />
|
||||
<LyricPlayer :lyricLines="lyricLines" :currentTime="currentTime" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BackgroundRender, LyricPlayer } from "@applemusic-like-lyrics/vue";
|
||||
import { ref } from "vue";
|
||||
|
||||
const albumUrl = ref("/album-cover.jpg");
|
||||
const currentTime = ref(0);
|
||||
const lyricLines = ref([]);
|
||||
</script>
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
- The host container has an explicit size.
|
||||
- The background canvas is inserted before the lyric element, and the lyric layer is above the background layer.
|
||||
- Image or video resources allow cross-origin reads, or are same-origin with the page.
|
||||
- Video URLs are passed with `setAlbum(source, true)`.
|
||||
- Playback state is synced to `resume()` / `pause()`.
|
||||
- When switching renderers, call `dispose()` on the old background before creating the new one.
|
||||
- On unmount, release the background, lyric component, and resources created by host code.
|
||||
@@ -0,0 +1,286 @@
|
||||
---
|
||||
title: Quick Start
|
||||
---
|
||||
|
||||
import { CardGrid, LinkCard } from "@astrojs/starlight/components";
|
||||
import PackageInstall from "#components/PackageInstall.astro";
|
||||
|
||||
This page quickly introduces how to integrate AMLL lyric components into your project. AMLL does not provide a CDN usage mode; you must use a bundler.
|
||||
|
||||
For adjacent utility packages other than the component libraries, see the [API Reference](/en/reference).
|
||||
|
||||
## Dependencies
|
||||
|
||||
Install the AMLL core library:
|
||||
|
||||
<PackageInstall packages="@applemusic-like-lyrics/core" />
|
||||
|
||||
AMLL also declares several graphics and animation libraries as peer dependencies so it can reuse related dependencies that may already exist in your project. Some package managers or configurations may install peers automatically. If they are not installed, install them manually.
|
||||
|
||||
<PackageInstall
|
||||
packages={[
|
||||
"@pixi/app",
|
||||
"@pixi/core",
|
||||
"@pixi/display",
|
||||
"@pixi/filter-blur",
|
||||
"@pixi/filter-bulge-pinch",
|
||||
"@pixi/filter-color-matrix",
|
||||
"@pixi/sprite",
|
||||
"jss",
|
||||
"jss-preset-default",
|
||||
]}
|
||||
/>
|
||||
|
||||
The examples below use `@applemusic-like-lyrics/lyric` to parse TTML lyric files. If your project can already provide `LyricLine[]` directly, you can skip this package.
|
||||
|
||||
<PackageInstall packages="@applemusic-like-lyrics/lyric" />
|
||||
|
||||
Next, you can use the vanilla package, or use the React or Vue bindings.
|
||||
|
||||
<CardGrid>
|
||||
<LinkCard
|
||||
title="Use Vanilla JS"
|
||||
description="Without a frontend framework"
|
||||
href="#use-vanilla-js"
|
||||
/>
|
||||
<LinkCard
|
||||
title="Use React Bindings"
|
||||
description="For projects using React"
|
||||
href="#use-react-bindings"
|
||||
/>
|
||||
<LinkCard
|
||||
title="Use Vue Bindings"
|
||||
description="For projects using Vue"
|
||||
href="#use-vue-bindings"
|
||||
/>
|
||||
</CardGrid>
|
||||
|
||||
## Use Vanilla JS
|
||||
|
||||
The AMLL core library is framework-agnostic. You can use this method whether or not your project uses a framework.
|
||||
|
||||
The example below assumes:
|
||||
|
||||
- The page already has `<audio id="audio">` and `<div id="lyric-player">`, and the lyric container has an explicit height.
|
||||
- The TTML lyric file is available at `/lyrics/song.ttml`.
|
||||
|
||||
```js
|
||||
import { LyricPlayer } from "@applemusic-like-lyrics/core";
|
||||
import { parseTTML } from "@applemusic-like-lyrics/lyric";
|
||||
|
||||
// Bundlers usually handle CSS imports. If anything goes wrong, check your bundler's documentation.
|
||||
import "@applemusic-like-lyrics/core/style.css";
|
||||
|
||||
const audio = document.querySelector("#audio");
|
||||
const playerHost = document.querySelector("#lyric-player");
|
||||
const player = new LyricPlayer();
|
||||
|
||||
playerHost.appendChild(player.getElement());
|
||||
|
||||
async function loadLyric() {
|
||||
const ttml = await fetch("/lyrics/song.ttml").then((res) => res.text());
|
||||
const currentTime = Math.round(audio.currentTime * 1000);
|
||||
player.setLyricLines(parseTTML(ttml).lines, currentTime);
|
||||
player.setCurrentTime(currentTime, true);
|
||||
}
|
||||
|
||||
// Continuously sync audio progress during playback.
|
||||
let lastFrameTime = -1;
|
||||
function onFrame(frameTime) {
|
||||
const delta = lastFrameTime === -1 ? 0 : frameTime - lastFrameTime;
|
||||
lastFrameTime = frameTime;
|
||||
|
||||
if (!audio.paused) {
|
||||
player.setCurrentTime(Math.round(audio.currentTime * 1000));
|
||||
}
|
||||
|
||||
player.update(delta);
|
||||
requestAnimationFrame(onFrame);
|
||||
}
|
||||
|
||||
audio.addEventListener("play", () => player.resume());
|
||||
audio.addEventListener("pause", () => player.pause());
|
||||
audio.addEventListener("seeked", () => {
|
||||
// Align the lyric position immediately after seeking.
|
||||
player.setCurrentTime(Math.round(audio.currentTime * 1000), true);
|
||||
});
|
||||
|
||||
loadLyric();
|
||||
requestAnimationFrame(onFrame);
|
||||
```
|
||||
|
||||
For more detailed timing management, see [Timing and Lifecycle](./sequence).
|
||||
|
||||
See [API Reference: Core](/en/reference/core) for detailed API documentation.
|
||||
|
||||
## Use React Bindings
|
||||
|
||||
Make sure you have installed `react` and `react-dom`. Then install the AMLL React binding package:
|
||||
|
||||
<PackageInstall packages="@applemusic-like-lyrics/react" />
|
||||
|
||||
The React binding package exports [`LyricPlayer`](/en/reference/react/variablelyricplayer) as the core component. The example below assumes the TTML lyric file is available at `/lyrics/song.ttml` and the audio file is available at `/music/song.m4a`.
|
||||
|
||||
```tsx
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { LyricLine } from "@applemusic-like-lyrics/core";
|
||||
import { LyricPlayer } from "@applemusic-like-lyrics/react";
|
||||
import { parseTTML } from "@applemusic-like-lyrics/lyric";
|
||||
|
||||
// Bundlers usually handle CSS imports. If anything goes wrong, check your bundler's documentation.
|
||||
import "@applemusic-like-lyrics/core/style.css";
|
||||
|
||||
function App() {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const [lyricLines, setLyricLines] = useState<LyricLine[]>([]);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
|
||||
function syncCurrentTime() {
|
||||
const audio = audioRef.current;
|
||||
if (audio) setCurrentTime(Math.round(audio.currentTime * 1000));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
|
||||
// parseTTML returns an object with metadata. The component only needs its lines.
|
||||
fetch("/lyrics/song.ttml")
|
||||
.then((res) => res.text())
|
||||
.then((ttml) => {
|
||||
if (!canceled) setLyricLines(parseTTML(ttml).lines);
|
||||
});
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let frameId = 0;
|
||||
const onFrame = () => {
|
||||
const audio = audioRef.current;
|
||||
if (audio && !audio.paused) {
|
||||
setCurrentTime(Math.round(audio.currentTime * 1000));
|
||||
}
|
||||
frameId = requestAnimationFrame(onFrame);
|
||||
};
|
||||
|
||||
frameId = requestAnimationFrame(onFrame);
|
||||
return () => cancelAnimationFrame(frameId);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<LyricPlayer
|
||||
lyricLines={lyricLines}
|
||||
currentTime={currentTime}
|
||||
playing={playing}
|
||||
style={{ height: 420 }}
|
||||
/>
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src="/music/song.m4a"
|
||||
controls
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onEnded={() => setPlaying(false)}
|
||||
onSeeked={syncCurrentTime}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
```
|
||||
|
||||
See [API Reference: React Bindings](/en/reference/react) for detailed API documentation.
|
||||
|
||||
## Use Vue Bindings
|
||||
|
||||
Make sure you have installed Vue. Then install the AMLL Vue binding package:
|
||||
|
||||
<PackageInstall packages="@applemusic-like-lyrics/vue" />
|
||||
|
||||
The Vue binding package exports [`LyricPlayer`](/en/reference/vue/classlyricplayer) as the core component. Lyric objects, playback progress, and other state are passed in as reactive component props. The example below assumes the TTML lyric file is available at `/lyrics/song.ttml` and the audio file is available at `/music/song.m4a`.
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<LyricPlayer
|
||||
class="lyric-player"
|
||||
:lyric-lines="lyricLines"
|
||||
:current-time="currentTime"
|
||||
:playing="playing"
|
||||
/>
|
||||
<audio
|
||||
ref="audioEl"
|
||||
src="/music/song.m4a"
|
||||
controls
|
||||
@play="onPlay"
|
||||
@pause="onPause"
|
||||
@ended="onPause"
|
||||
@seeked="syncCurrentTime"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LyricLine } from "@applemusic-like-lyrics/core";
|
||||
import { parseTTML } from "@applemusic-like-lyrics/lyric";
|
||||
import { LyricPlayer } from "@applemusic-like-lyrics/vue";
|
||||
import {
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
useTemplateRef,
|
||||
} from "vue";
|
||||
|
||||
// Bundlers usually handle CSS imports. If anything goes wrong, check your bundler's documentation.
|
||||
import "@applemusic-like-lyrics/core/style.css";
|
||||
|
||||
const audioEl = useTemplateRef<HTMLAudioElement>("audioEl");
|
||||
const lyricLines = shallowRef<LyricLine[]>([]);
|
||||
const currentTime = ref(0);
|
||||
const playing = ref(false);
|
||||
let frameId = 0;
|
||||
|
||||
async function loadLyric() {
|
||||
const ttml = await fetch("/lyrics/song.ttml").then((res) => res.text());
|
||||
// parseTTML returns an object with metadata. The component only needs its lines.
|
||||
lyricLines.value = parseTTML(ttml).lines;
|
||||
}
|
||||
|
||||
function syncCurrentTime() {
|
||||
if (audioEl.value) {
|
||||
currentTime.value = Math.round(audioEl.value.currentTime * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function onFrame() {
|
||||
syncCurrentTime();
|
||||
if (playing.value) frameId = requestAnimationFrame(onFrame);
|
||||
}
|
||||
|
||||
function onPlay() {
|
||||
playing.value = true;
|
||||
cancelAnimationFrame(frameId);
|
||||
onFrame();
|
||||
}
|
||||
|
||||
function onPause() {
|
||||
playing.value = false;
|
||||
cancelAnimationFrame(frameId);
|
||||
}
|
||||
|
||||
onMounted(() => void loadLyric());
|
||||
onBeforeUnmount(() => cancelAnimationFrame(frameId));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.lyric-player {
|
||||
height: 420px;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
See [API Reference: Vue Bindings](/en/reference/vue) for detailed API documentation.
|
||||
@@ -0,0 +1,209 @@
|
||||
---
|
||||
title: Timing and Lifecycle
|
||||
---
|
||||
|
||||
This page explains timing and lifecycle management for the lyric component.
|
||||
|
||||
The lyric component only handles the lyric view itself. It **does not handle audio playback**. Therefore, **the host environment, which is your code, needs to manage audio playback and bridge the audio playback state to AMLL component state.**
|
||||
|
||||
If you use the React or Vue bindings, the component manages part of the lifecycle for you. If you use the vanilla API directly, you need to manage the full flow yourself. This page mainly covers lifecycle management with the vanilla API and explains the state managed by the bindings.
|
||||
|
||||
## Initialization
|
||||
|
||||
During initialization, you need to:
|
||||
|
||||
1. Create the lyric component and mount its element into a container with an **explicit size**.
|
||||
2. Optionally set custom lyric optimization options. The [`setOptimizeOptions`](/en/reference/core/classlyricplayerbase#setoptimizeoptions) method accepts [`OptimizeLyricOptions`](/en/reference/core/interfaceoptimizelyricoptions).
|
||||
3. Set lyric data. The [`setLyricLines`](/en/reference/core/classlyricplayerbase#setlyriclines) method accepts [`LyricLine[]`](/en/reference/core/interfacelyricline). After passing the objects in, do not modify them.
|
||||
4. Align the lyric position once with the current playback progress.
|
||||
|
||||
A typical vanilla sequence looks like this:
|
||||
|
||||
```ts
|
||||
import { LyricPlayer } from "@applemusic-like-lyrics/core";
|
||||
|
||||
const player = new LyricPlayer();
|
||||
host.appendChild(player.getElement());
|
||||
|
||||
const currentTime = Math.round(audio.currentTime * 1000);
|
||||
player.setOptimizeOptions({}); // Optional
|
||||
player.setLyricLines(lines, currentTime);
|
||||
player.setCurrentTime(currentTime, true);
|
||||
player.update(0);
|
||||
```
|
||||
|
||||
Lyric optimization runs when lyrics are set, so if you need to change these options, call `setOptimizeOptions` before `setLyricLines`. Changes after `setLyricLines` do not automatically reprocess existing lyrics; set the lyrics again to apply them.
|
||||
|
||||
Also note that `currentTime` is in milliseconds and should be an integer. `audio.currentTime` is in seconds, so multiply it by `1000`.
|
||||
|
||||
## Play and Pause
|
||||
|
||||
`pause()` and `resume()` control the lyric component's internal presentation state, including word-by-word animation, glow, and interlude dot animation. Call `resume()` when audio starts playing, and call `pause()` when audio pauses, ends, or is externally interrupted.
|
||||
|
||||
For example, when using `<audio>` for playback, drive this with its events:
|
||||
|
||||
```ts
|
||||
const onPlay = () => {
|
||||
player.resume();
|
||||
};
|
||||
const onPause = () => {
|
||||
player.pause();
|
||||
};
|
||||
|
||||
audio.addEventListener("play", onPlay);
|
||||
audio.addEventListener("pause", onPause);
|
||||
```
|
||||
|
||||
## Playback Progress
|
||||
|
||||
### Normal Playback
|
||||
|
||||
During playback, you need to update the lyric component's time progress. **All time values used by AMLL are in milliseconds**.
|
||||
|
||||
Two time values are easy to confuse:
|
||||
|
||||
| Time Type | Accepted By | Meaning |
|
||||
| ----------------- | ------------------------------------------ | ------------------------------- |
|
||||
| Current progress | `setCurrentTime(time)` / `currentTime` prop | Song playback progress |
|
||||
| Frame delta | `update(delta)` | Time elapsed since the previous frame |
|
||||
|
||||
In vanilla usage, `setCurrentTime` updates the lyric timeline, and `update` advances animation. **They are not the same value**.
|
||||
|
||||
```ts
|
||||
let frameId = 0;
|
||||
let lastFrameTime = -1;
|
||||
|
||||
function startFrameLoop() {
|
||||
const onFrame = (frameTime: number) => {
|
||||
const delta = lastFrameTime === -1 ? 0 : frameTime - lastFrameTime;
|
||||
lastFrameTime = frameTime;
|
||||
if (!audio.paused) {
|
||||
player.setCurrentTime(Math.round(audio.currentTime * 1000));
|
||||
}
|
||||
player.update(delta);
|
||||
frameId = requestAnimationFrame(onFrame);
|
||||
};
|
||||
frameId = requestAnimationFrame(onFrame);
|
||||
}
|
||||
|
||||
function stopFrameLoop() {
|
||||
cancelAnimationFrame(frameId);
|
||||
frameId = 0;
|
||||
lastFrameTime = -1;
|
||||
}
|
||||
```
|
||||
|
||||
**Do not rely on the `<audio>` `timeupdate` event to sync lyrics**. Browsers fire `timeupdate` at a low and unstable frequency, usually far below the animation frame rate. During playback, use `requestAnimationFrame` to sync current progress frame by frame.
|
||||
|
||||
### Seeking
|
||||
|
||||
Outside normal playback, playback progress may jump. Common cases include:
|
||||
|
||||
- Dragging the progress bar
|
||||
- Fast-forwarding or rewinding
|
||||
- Clicking a lyric line to seek
|
||||
- Loop playback, where progress jumps from the end back to the beginning
|
||||
|
||||
When playback progress jumps, set the second parameter of `setCurrentTime` to `true`:
|
||||
|
||||
```ts
|
||||
function onSeeked() {
|
||||
player.setCurrentTime(Math.round(audio.currentTime * 1000), true);
|
||||
}
|
||||
audio.addEventListener("seeked", onSeeked);
|
||||
```
|
||||
|
||||
**This parameter indicates that the current sync is a seek. Normal playback and seek state use different layout and animation behavior:**
|
||||
|
||||
- During normal playback, the component lays out and applies spring animation to each visible line individually for a refined visual effect.
|
||||
- During seeking, the component force-aligns the lyric position and applies layout plus spring animation to all lyric lines as a whole, reducing work and making the animation snappier.
|
||||
|
||||
If seek state is not marked correctly, layout glitches may occur, such as stutters or lyric lines quickly flying from one side of the screen to the other and disappearing. You can see screenshots in [issue #429](https://github.com/amll-dev/applemusic-like-lyrics/issues/429).
|
||||
|
||||
### Lyric Line Click Events
|
||||
|
||||
The component provides a `line-click` event, fired when a lyric line is clicked. Its event type is [`LyricLineMouseEvent`](/en/reference/core/classlyriclinemouseevent).
|
||||
|
||||
**The component itself does not respond to lyric line clicks.** The host environment needs to listen to the event and perform actions such as seeking the audio progress. For example:
|
||||
|
||||
```ts
|
||||
import type { LyricLineMouseEvent } from "@applemusic-like-lyrics/core";
|
||||
|
||||
player.addEventListener("line-click", (event) => {
|
||||
const lineEvent = event as LyricLineMouseEvent;
|
||||
audio.currentTime = lineEvent.line.getLine().startTime / 1000;
|
||||
player.setCurrentTime(lineEvent.line.getLine().startTime, true);
|
||||
});
|
||||
```
|
||||
|
||||
It is worth noting that clicking a lyric line to jump is also a seek.
|
||||
|
||||
## Changing Lyrics
|
||||
|
||||
When changing songs or lyric sources, set a new lyric line object array with `setLyricLines`. If loading fails, pass an empty array to clear the lyrics.
|
||||
|
||||
```ts
|
||||
player.setLyricLines([]);
|
||||
player.update(0);
|
||||
```
|
||||
|
||||
## React and Vue Bindings
|
||||
|
||||
The React and Vue bindings create and destroy the underlying Core component. They also automatically call `update` unless disabled. Therefore, when using bindings, you usually do not need to call the underlying `update` yourself.
|
||||
|
||||
You still need to provide these states:
|
||||
|
||||
| State | React / Vue Input | Description |
|
||||
| ------------------ | ----------------- | ------------------------------------------------ |
|
||||
| Lyric data | `lyricLines` | Parsed `LyricLine[]` |
|
||||
| Current progress | `currentTime` | Synced from audio with `requestAnimationFrame` during playback |
|
||||
| Playback state | `playing` | Pauses or resumes the lyric component's internal presentation |
|
||||
|
||||
The React binding additionally provides an `isSeeking` prop, which you can pass during seeking:
|
||||
|
||||
```tsx
|
||||
<LyricPlayer
|
||||
lyricLines={lyricLines}
|
||||
currentTime={currentTime}
|
||||
isSeeking={isSeeking}
|
||||
playing={playing}
|
||||
/>
|
||||
```
|
||||
|
||||
`isSeeking` should not stay `true` for a long time. Usually, set it to `true` briefly when the user completes a seek, then restore it to `false` after the next sync.
|
||||
|
||||
The Vue binding is currently less complete and does not have a separate `isSeeking` prop. In common scenarios, syncing `currentTime` is enough to work. If you need finer state control, use the vanilla API directly. We will continue improving the Vue binding functionality and usage experience in upcoming versions.
|
||||
|
||||
If `disabled` is set, the binding no longer manages frame-by-frame animation. In that case, you can access the underlying `lyricPlayer` through a component ref and call `update` yourself, just like with the vanilla API.
|
||||
|
||||
## Cleanup
|
||||
|
||||
When the lyric player component is no longer needed, vanilla usage requires cleaning up every resource you created yourself:
|
||||
|
||||
```ts
|
||||
// Clear the requestAnimationFrame loop you defined.
|
||||
stopFrameLoop();
|
||||
|
||||
// Remove listeners you added.
|
||||
audio.removeEventListener("play", onPlay);
|
||||
audio.removeEventListener("pause", onPause);
|
||||
audio.removeEventListener("seeked", onSeeked);
|
||||
|
||||
// Release component resources.
|
||||
player.dispose();
|
||||
```
|
||||
|
||||
`dispose()` removes the component element and releases internal listeners.
|
||||
|
||||
If you use the React or Vue bindings, the component automatically calls the underlying `dispose()` when unmounted. However, `requestAnimationFrame`, audio event listeners, `ObjectURL`s, and similar resources that you create yourself still need to be cleaned up when the component unmounts.
|
||||
|
||||
## Checklist
|
||||
|
||||
- The container has an explicit size and has been mounted to the DOM.
|
||||
- Lyrics are passed through `setLyricLines(lines, currentTime)` or the `lyricLines` prop.
|
||||
- Playback progress is represented in milliseconds.
|
||||
- During playback, `currentTime` is synced with `requestAnimationFrame`.
|
||||
- In vanilla usage, `update(delta)` is called frame by frame.
|
||||
- Pause, resume, and playback end are synced to `pause()` / `resume()` or `playing`.
|
||||
- Seeking uses the seek flag to align the lyric position.
|
||||
- On unmount, cancel animation frames, remove event listeners, and dispose the component.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
title: Guides
|
||||
editUrl: false
|
||||
---
|
||||
|
||||
import { CardGrid, LinkCard } from "@astrojs/starlight/components";
|
||||
|
||||
<CardGrid>
|
||||
<LinkCard
|
||||
title="Introduction"
|
||||
description="Basic information about AMLL"
|
||||
href="/en/guides/overview/intro"
|
||||
/>
|
||||
<LinkCard
|
||||
title="Lyric Component"
|
||||
description="Integrate lyric components into your project"
|
||||
href="/en/guides/component/quickstart"
|
||||
/>
|
||||
<LinkCard
|
||||
title="Background Component"
|
||||
description="Integrate background components into your project"
|
||||
href="/guides/component/background"
|
||||
/>
|
||||
<LinkCard
|
||||
title="Lyric Formats"
|
||||
description="Introducation to lyric formats and lib usage"
|
||||
href="/en/guides/lyric/quickstart"
|
||||
/>
|
||||
</CardGrid>
|
||||
@@ -0,0 +1,198 @@
|
||||
---
|
||||
title: Lyric Formats
|
||||
---
|
||||
|
||||
This page introduces lyric formats supported by [@applemusic-like-lyrics/lyric](https://www.npmjs.com/package/@applemusic-like-lyrics/lyric).
|
||||
|
||||
In this document, "word-by-word lyrics" means lyrics with timestamp precision finer than line-level. Depending on platform and format, it may be syllable-level or word-level.
|
||||
|
||||
## TTML
|
||||
|
||||
TTML is the primary lyric storage and exchange format in the AMLL ecosystem. It supports all major AMLL capabilities, including translation/transliteration, background vocals, word-level transliteration, and ruby annotation.
|
||||
|
||||
See [TTML](./ttml) for details.
|
||||
|
||||
**This package provides [`parseTTML`](/en/reference/lyric/functionparsettml) and [`stringifyTTML`](/en/reference/lyric/functionstringifyttml) for TTML parsing and serialization.**
|
||||
|
||||
## LRC
|
||||
|
||||
LRC is the most common lyric file format and supports line-level timestamps only. File extension is `.lrc`.
|
||||
|
||||
Here, "LRC" means basic unextended LRC (sometimes called simple LRC). LRC was not formally standardized by a single organization; many variants exist and details vary.
|
||||
|
||||
Typically, each lyric line starts with a timestamp indicating line start time. Common forms:
|
||||
|
||||
- `[mm:ss]`
|
||||
- `[mm:ss.xx]`
|
||||
- `[mm:ss.xxx]`
|
||||
|
||||
`mm` is minutes and `ss.xxx` is seconds. Fraction digits may be omitted, 2 digits, or 3 digits. One lyric line can have multiple timestamps to indicate repeated occurrences.
|
||||
|
||||
LRC also supports metadata lines at the top in `[tag:content]` format. Some implementations allow comment lines prefixed with `#`.
|
||||
|
||||
Example:
|
||||
|
||||
```lrc
|
||||
[al:崩坏星穹铁道-不虚此行 On the Journey]
|
||||
[ti:不虚此行 On the Journey]
|
||||
[ar:魏晨, Nea]
|
||||
[length: 2:36]
|
||||
|
||||
[00:25.494]We venture through the cosmic sea
|
||||
[00:27.541]A thousand light-years, wild and free
|
||||
[00:30.805]We dance beneath the galaxy
|
||||
[00:32.847]Then will you be with me?
|
||||
# ...
|
||||
[01:45.949]Catching on, our paths unknown
|
||||
[01:50.261]To sink into daylight
|
||||
[01:52.851]Break into the moonlight
|
||||
[01:56.487]Life goes on, through tides of time
|
||||
[02:00.900]Get in the line, to dream alive
|
||||
[02:03.580]In our souls, do we know?
|
||||
[02:05.896][02:08.473][02:11.262]On the journey
|
||||
```
|
||||
|
||||
LRC does not natively support translation/transliteration. Common practice is to provide separate files with matching timestamps.
|
||||
|
||||
More background: [Wikipedia: LRC (file format)](<https://en.wikipedia.org/wiki/LRC_(file_format)>).
|
||||
|
||||
**This package provides [`parseLrc`](/en/reference/lyric/functionparselrc) and [`stringifyLrc`](/en/reference/lyric/functionstringifylrc).**
|
||||
|
||||
## LRC A2
|
||||
|
||||
LRC A2 (from A2 Media Player) extends LRC with inline timestamps in angle brackets `<mm:ss.xx>` for word-level timing. Timestamp format is similar to LRC. Each angle-bracket timestamp indicates the start time of the following text segment.
|
||||
|
||||
File extension is `.lrc` or `.alrc`.
|
||||
|
||||
Example:
|
||||
|
||||
```alrc
|
||||
[ti: Somebody to Love]
|
||||
[ar: Jefferson Airplane]
|
||||
[al: Surrealistic Pillow]
|
||||
[length: 2:58]
|
||||
|
||||
[00:00.00] <00:00.04> When <00:00.16> the <00:00.82> truth <00:01.29> is <00:01.63> found <00:03.09> to <00:03.37> be <00:05.92> lies
|
||||
[00:06.47] <00:07.67> And <00:07.94> all <00:08.36> the <00:08.63> joy <00:10.28> within <00:10.53> you <00:13.09> dies
|
||||
[00:13.34] <00:14.32> Don't <00:14.73> you <00:15.14> want <00:15.57> somebody <00:16.09> to <00:16.46> Love
|
||||
```
|
||||
|
||||
When parsing, spaces around timestamps should be normalized.
|
||||
|
||||
Because timestamps are embedded with `< >`, escaping literal `<` or `>` in lyric text is not officially standardized. Parsing attempts to match `<mm:ss.xx>` as timestamp patterns; unmatched `<` or `>` are treated as normal text.
|
||||
|
||||
LRC A2 also does not natively support translation/transliteration.
|
||||
|
||||
**This package provides [`parseLrcA2`](/en/reference/lyric/functionparselrca2) and [`stringifyLrcA2`](/en/reference/lyric/functionstringifylrca2).**
|
||||
|
||||
## NetEase YRC and QQ QRC
|
||||
|
||||
Both are private word-level lyric formats used by music platforms:
|
||||
|
||||
- NetEase Cloud Music uses `.yrc`
|
||||
- QQ Music uses `.qrc`
|
||||
|
||||
Both use line timestamps in `[lineStart,lineDur]` format, where values are integer milliseconds. Unlike LRC, they do not support multiple timestamps for one repeated line.
|
||||
|
||||
Word-level syntax differs:
|
||||
|
||||
- YRC: `(sylStart,sylDur,0)text` (timestamp first, then text). The third field is always `0` in known samples.
|
||||
- QRC: `text(sylStart,sylDur)` (text first, then timestamp), without the extra `0` field.
|
||||
|
||||
All start times are absolute (from audio start).
|
||||
|
||||
Example for the same lyrics:
|
||||
|
||||
```
|
||||
# NetEase YRC
|
||||
[190871,1984](190871,361,0)For (191232,172,0)the (191404,376,0)first (191780,1075,0)time
|
||||
[193459,4198](193459,412,0)What's (193871,574,0)past (194445,506,0)is (194951,2706,0)past
|
||||
|
||||
# QQ QRC
|
||||
[190871,1984]For (190871,361)the (191232,172)first (191404,376)time(191780,1075)
|
||||
[193459,4198]What's (193459,412)past (193871,574)is (194445,506)past(194951,2706)
|
||||
```
|
||||
|
||||
QRC and YRC do not have LRC A2 style adjacent-space collapsing.
|
||||
|
||||
Parentheses in lyric text are tricky because timestamps also use `()`. Based on observed platform samples:
|
||||
|
||||
- YRC samples consistently use full-width parentheses `()` in text
|
||||
- QRC samples keep normal parentheses in text
|
||||
|
||||
Based on this behavior:
|
||||
|
||||
- YRC likely avoids half-width parentheses in text; this library replaces half-width parentheses with full-width ones when exporting YRC
|
||||
- QRC parser skips non-timestamp parentheses and treats them as text
|
||||
|
||||
YRC/QRC also do not natively support translation/transliteration; common practice is companion LRC files.
|
||||
|
||||
Also, this library treats fully parenthesized lines as background lines in YRC/QRC and removes outer parentheses during parsing.
|
||||
|
||||
**This package provides [`parseYrc`](/en/reference/lyric/functionparseyrc) and [`stringifyYrc`](/en/reference/lyric/functionstringifyyrc) for YRC; [`parseQrc`](/en/reference/lyric/functionparseqrc) and [`stringifyQrc`](/en/reference/lyric/functionstringifyqrc) for QRC.**
|
||||
|
||||
QQ Music also distributes an encrypted QRC format: XML containing QRC text, encrypted with a DES-like algorithm and base64-encoded. **This package provides [`decryptQrcHex`](/en/reference/lyric/functiondecryptqrchex) to decode such base64 payloads to XML text, and [`encryptQrcHex`](/en/reference/lyric/functionencryptqrchex) to encode plaintext XML to base64.**
|
||||
|
||||
## Lyricify Formats
|
||||
|
||||
[Lyricify](https://lyricify.app) is a popular word-by-word lyric display app. It defines three private formats: Lyricify Lines, Lyricify Syllable, and Lyricify Quick Export.
|
||||
|
||||
- Lyricify Lines: line-level format, extension `.lyl`
|
||||
- Lyricify Syllable: word-level format, extension `.lys`, with background and duet support
|
||||
|
||||
Official docs exist for the first two formats: [Lyricify format docs](https://github.com/WXRIW/Lyricify-App/blob/main/docs/Lyricify%204/Lyrics.md#lyricify-lines-%E6%A0%BC%E5%BC%8F%E8%A7%84%E8%8C%83).
|
||||
|
||||
**This package provides [`parseLyl`](/en/reference/lyric/functionparselyl)/[`stringifyLyl`](/en/reference/lyric/functionstringifylyl) and [`parseLys`](/en/reference/lyric/functionparselys)/[`stringifyLys`](/en/reference/lyric/functionstringifylys).**
|
||||
|
||||
Lyricify Quick Export uses extension `.lqe` (Lyricify Quick Export). There is no official formal spec, but the format is straightforward from exported samples:
|
||||
|
||||
```
|
||||
[Lyricify Quick Export]
|
||||
[version:1.0]
|
||||
|
||||
[lyrics: format@Lyricify Syllable]
|
||||
[4]A(365,350)ni(715,307)ro(1022,312)dham (1334,419)a(3203,337)nut(3540,350)pā(3890,306)dam(4196,382)
|
||||
[5]Qua(6206,312)e(6518,350)so (6868,370)do(7238,338)mi(7576,373)ne (7949,413)nos (8362,736)ple(9098,306)ne (9404,338)sal(9742,237)va (9979,244)tam(10223,350)
|
||||
[4]A(6164,1436)nuc(7600,744)che(8344,724)dam (9068,399)a(9467,293)śā(9760,240)śva(10000,225)tam(10225,893)
|
||||
[4]Hi (11851,812)ma(12663,344)ma (13007,369)ja(13376,263)gad (13639,237)i(13876,212)daṃ(14088,800)
|
||||
|
||||
|
||||
[translation: format@LRC]
|
||||
[00:00.365]不生亦不灭
|
||||
[00:06.206]主人啊,求你像这般,赐给我们完全的救恩
|
||||
[00:06.164]不常亦不断
|
||||
[00:11.851]此世已为我之世
|
||||
|
||||
|
||||
[pronunciation: format@LRC, language@romaji]
|
||||
[00:00.365]阿难罗昙 阿耨钵昙
|
||||
[00:06.164]阿耨遮昙 阿刹缚多
|
||||
[00:11.851]天摩诃满 荼揭谛檀
|
||||
```
|
||||
|
||||
The header defines file version. Then blocks follow:
|
||||
|
||||
- `[lyrics: format@Lyricify Syllable]` for word-level lyrics
|
||||
- `[translation: format@LRC]` for translation lines
|
||||
- `[pronunciation: format@LRC, language@romaji]` for pronunciation/transliteration lines
|
||||
|
||||
Translation/pronunciation blocks include only lines that have content. Missing translation/transliteration lines are omitted.
|
||||
|
||||
So Lyricify Quick Export supports word-level timing, background lines, duet lines, translation, and transliteration.
|
||||
|
||||
See also [Lyricify Lyrics Helper](https://github.com/WXRIW/Lyricify-Lyrics-Helper), an MIT-licensed project by the Lyricify developer that includes parsing and generation logic.
|
||||
|
||||
**This package provides [`parseLqe`](/en/reference/lyric/functionparselqe) and [`stringifyLqe`](/en/reference/lyric/functionstringifylqe).**
|
||||
|
||||
## Summary Table
|
||||
|
||||
| Format | Extension | Line timing | Word timing | Native translation/transliteration | Native background/duet |
|
||||
| --------------------- | --------------- | :---------: | :---------: | :--------------------------------: | :--------------------: |
|
||||
| TTML | `.ttml` | Yes | Yes | Yes | Yes |
|
||||
| LRC | `.lrc` | Yes | No | No | No |
|
||||
| LRC A2 | `.lrc`, `.alrc` | Yes | Yes | No | No |
|
||||
| NetEase YRC | `.yrc` | Yes | Yes | No | No |
|
||||
| QQ QRC | `.qrc` | Yes | Yes | No | No |
|
||||
| Lyricify Lines | `.lyl` | Yes | No | No | No |
|
||||
| Lyricify Syllable | `.lys` | Yes | Yes | No | Yes |
|
||||
| Lyricify Quick Export | `.lqe` | Yes | Yes | Yes | Yes |
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
title: Quick Start
|
||||
---
|
||||
|
||||
import PackageInstall from "../../../../../components/PackageInstall.astro";
|
||||
|
||||
AMLL provides two npm packages for lyric format parsing and serialization:
|
||||
|
||||
- [@applemusic-like-lyrics/lyric](https://www.npmjs.com/package/@applemusic-like-lyrics/lyric)
|
||||
Parsing and generation for mainstream lyric formats such as LRC, YRC, and LQE. TTML parsing/serialization in this package internally relies on `@applemusic-like-lyrics/ttml`.
|
||||
- [@applemusic-like-lyrics/ttml](https://www.npmjs.com/package/@applemusic-like-lyrics/ttml)
|
||||
Dedicated TTML parsing and serialization library with the most detailed TTML feature support.
|
||||
|
||||
## Lyric Package
|
||||
|
||||
<PackageInstall packages="@applemusic-like-lyrics/lyric" />
|
||||
|
||||
Supported formats in the Lyric package:
|
||||
|
||||
| Format | Extension | Parse | Stringify |
|
||||
| --------------------- | ---------------- | ------------------------------------------------------ | -------------------------------------------------------------- |
|
||||
| TTML | `.ttml` | [`parseTTML`](/en/reference/lyric/functionparsettml) | [`stringifyTTML`](/en/reference/lyric/functionstringifyttml) |
|
||||
| LRC | `.lrc` | [`parseLrc`](/en/reference/lyric/functionparselrc) | [`stringifyLrc`](/en/reference/lyric/functionstringifylrc) |
|
||||
| LRC A2 | `.lrc`, `.alrc` | [`parseLrcA2`](/en/reference/lyric/functionparselrca2) | [`stringifyLrcA2`](/en/reference/lyric/functionstringifylrca2) |
|
||||
| NetEase YRC | `.yrc` | [`parseYrc`](/en/reference/lyric/functionparseyrc) | [`stringifyYrc`](/en/reference/lyric/functionstringifyyrc) |
|
||||
| QQ QRC | `.qrc` | [`parseQrc`](/en/reference/lyric/functionparseqrc) | [`stringifyQrc`](/en/reference/lyric/functionstringifyqrc) |
|
||||
| EsLyric | `.lrc`, `.eslrc` | [`parseEslrc`](/en/reference/lyric/functionparseeslrc) | [`stringifyEslrc`](/en/reference/lyric/functionstringifyeslrc) |
|
||||
| ASS subtitle | `.ass` | Not supported | [`stringifyAss`](/en/reference/lyric/functionstringifyass) |
|
||||
| Lyricify Lines | `.lyl` | [`parseLyl`](/en/reference/lyric/functionparselyl) | [`stringifyLyl`](/en/reference/lyric/functionstringifylyl) |
|
||||
| Lyricify Syllable | `.lys` | [`parseLys`](/en/reference/lyric/functionparselys) | [`stringifyLys`](/en/reference/lyric/functionstringifylys) |
|
||||
| Lyricify Quick Export | `.lqe` | [`parseLqe`](/en/reference/lyric/functionparselqe) | [`stringifyLqe`](/en/reference/lyric/functionstringifylqe) |
|
||||
|
||||
All parse and stringify methods above are synchronous.
|
||||
|
||||
Except TTML, parse functions take a string and return [`LyricLine[]`](/en/reference/lyric/interfacelyricline). Stringify functions take a lyric object array and return a string.
|
||||
|
||||
For TTML, parse output and stringify input are not `LyricLine[]`, but a [`TTMLLyric`](/en/reference/lyric/interfacettmllyric) object with metadata. Also, TTML parse/stringify in the Lyric package is browser-only. For Node.js usage or advanced TTML data, use the dedicated TTML package below.
|
||||
|
||||
The Lyric package also supports decryption/encryption for QQ Music encrypted QRC payloads: [`decryptQrcHex`](/en/reference/lyric/functiondecryptqrchex) and [`encryptQrcHex`](/en/reference/lyric/functionencryptqrchex).
|
||||
|
||||
For format details, see [Lyric Formats](./formats).
|
||||
|
||||
## TTML Package
|
||||
|
||||
<PackageInstall packages="@applemusic-like-lyrics/ttml" />
|
||||
|
||||
The TTML package provides TTML parsing and serialization. For format details, see [TTML Format](./ttml).
|
||||
|
||||
It supports two usage modes: class mode and function mode.
|
||||
|
||||
### Class Mode
|
||||
|
||||
The TTML package provides two classes: [`TTMLParser`](/en/reference/ttml/classttmlparser) for parsing and [`TTMLGenerator`](/en/reference/ttml/classttmlgenerator) for generation. Both also provide static shortcuts.
|
||||
|
||||
```js
|
||||
import { TTMLParser, TTMLGenerator } from "@applemusic-like-lyrics/ttml";
|
||||
|
||||
const parser = new TTMLParser();
|
||||
const ttmlObject = parser.parse("Some TTML string...");
|
||||
|
||||
const _ttmlObject = TTMLParser.parse("Some TTML string...");
|
||||
|
||||
const generator = new TTMLGenerator();
|
||||
const ttmlString = generator.generate(ttmlObject);
|
||||
|
||||
const _ttmlString = TTMLGenerator.generate(ttmlObject);
|
||||
```
|
||||
|
||||
By default, these classes are browser-oriented. `TTMLParser` uses `DOMParser`, and `TTMLGenerator` uses `XMLSerializer` and `document.implementation`. For Node.js, provide implementations explicitly, for example with `@xmldom/xmldom`:
|
||||
|
||||
```js
|
||||
import { TTMLParser, TTMLGenerator } from "@applemusic-like-lyrics/ttml";
|
||||
import { DOMParser, DOMImplementation, XMLSerializer } from "@xmldom/xmldom";
|
||||
|
||||
const parser = new TTMLParser({
|
||||
domParser: new DOMParser(),
|
||||
});
|
||||
|
||||
const generator = new TTMLGenerator({
|
||||
domImplementation: new DOMImplementation(),
|
||||
xmlSerializer: new XMLSerializer(),
|
||||
});
|
||||
```
|
||||
|
||||
Static methods are not suitable for Node.js.
|
||||
|
||||
### Function Mode
|
||||
|
||||
Import [`parseTTML`](/en/reference/ttml/functionparsettml) and [`exportTTML`](/en/reference/ttml/functionexportttml) directly.
|
||||
|
||||
```js
|
||||
import { parseTTML, exportTTML } from "@applemusic-like-lyrics/ttml";
|
||||
|
||||
const amllObject = parseTTML("Some TTML string...");
|
||||
const ttmlString = exportTTML(amllObject);
|
||||
```
|
||||
|
||||
The parse result type is [`AmllLyricResult`](/en/reference/ttml/interfaceamlllyricresult), which can be directly used in AMLL core.
|
||||
|
||||
Internally, this mode calls static methods of `TTMLParser`/`TTMLGenerator`. For parsing, it additionally calls [`toAmllLyrics`](/en/reference/ttml/functiontoamlllyrics). So this mode is also not suitable for Node.js.
|
||||
@@ -0,0 +1,186 @@
|
||||
---
|
||||
title: TTML Format Overview
|
||||
---
|
||||
|
||||
TTML (Timed Text Markup Language) is a timed text markup standard defined by [W3C](https://www.w3.org/). Since it is XML-based, it is extensible, machine-readable, and suitable for cross-platform exchange.
|
||||
|
||||
TTML is whitespace-sensitive XML. Spaces and other whitespace in inline text are preserved in rendered lyrics. TTML snippets in this document are formatted for readability; in real usage, avoid arbitrary whitespace changes.
|
||||
|
||||
Apple Music uses TTML for word-by-word lyrics, so TTML is also the primary storage and exchange format in the AMLL ecosystem. It supports:
|
||||
|
||||
- Word-level timing
|
||||
- Translation and transliteration
|
||||
- Background vocals (`x-bg`)
|
||||
- Duet/multi-singer metadata (`ttm:agent`)
|
||||
- Song sections (`itunes:song-part`)
|
||||
- Ruby annotations
|
||||
|
||||
For AMLL TTML spec details, see [AMLL TTML DB Wiki - Format Specification](https://github.com/amll-dev/amll-ttml-db/wiki/%E6%A0%BC%E5%BC%8F%E8%A7%84%E8%8C%83).
|
||||
|
||||
## Overall Structure
|
||||
|
||||
A minimal usable TTML file usually contains:
|
||||
|
||||
- Root `<tt>`
|
||||
- Metadata section `<head><metadata>...</metadata></head>`
|
||||
- Main lyric section `<body><div><p>...</p></div></body>`
|
||||
|
||||
Namespaces:
|
||||
|
||||
- `xmlns="http://www.w3.org/ns/ttml"`
|
||||
- `xmlns:ttm="http://www.w3.org/ns/ttml#metadata"`
|
||||
- `xmlns:itunes="http://music.apple.com/lyric-ttml-internal"`
|
||||
- `xmlns:amll="http://www.example.com/ns/amll"`
|
||||
- `xmlns:tts="http://www.w3.org/ns/ttml#styling"` (required for Ruby)
|
||||
|
||||
Root attributes:
|
||||
|
||||
- `xml:lang`: primary lyric language (BCP-47, e.g. `ja`, `zh-Hans`, `en-US`)
|
||||
- `itunes:timing`: `Word` (word-by-word) or `Line` (line-by-line)
|
||||
|
||||
## Metadata
|
||||
|
||||
Metadata is mainly in `<head><metadata>`. In AMLL, common types are:
|
||||
|
||||
1. `ttm:*` metadata (TTML standard)
|
||||
2. `amll:meta` metadata (AMLL extension)
|
||||
|
||||
Example:
|
||||
|
||||
```xml
|
||||
<metadata>
|
||||
<ttm:title>Song Title</ttm:title>
|
||||
|
||||
<ttm:agent type="person" xml:id="v1">
|
||||
<ttm:name type="full">Singer A</ttm:name>
|
||||
</ttm:agent>
|
||||
|
||||
<amll:meta key="musicName" value="Song Title" />
|
||||
<amll:meta key="artists" value="Singer A" />
|
||||
<amll:meta key="album" value="Album Name" />
|
||||
<amll:meta key="isrc" value="XX0000000000" />
|
||||
<amll:meta key="appleMusicId" value="1234567890" />
|
||||
</metadata>
|
||||
```
|
||||
|
||||
Common `amll:meta` keys:
|
||||
|
||||
- `musicName`, `artists`, `album`, `isrc`
|
||||
- Platform IDs: `ncmMusicId`, `qqMusicId`, `spotifyId`, `appleMusicId`
|
||||
- Contributors: `ttmlAuthorGithub`, `ttmlAuthorGithubLogin`
|
||||
|
||||
## Timing and Modes
|
||||
|
||||
Timing is typically expressed by `begin` / `end` / `dur`. Supported units:
|
||||
|
||||
- Clock format: `MM:SS.fff`, `HH:MM:SS.fff` (fractional digits 0-3)
|
||||
- Seconds format: `12.3s`
|
||||
|
||||
With `itunes:timing="Word"`:
|
||||
|
||||
- `<p>` is one lyric line
|
||||
- Timestamped inline `<span>` elements represent words/syllables
|
||||
|
||||
With `itunes:timing="Line"`:
|
||||
|
||||
- Primarily uses `<p begin="..." end="...">whole line text</p>`
|
||||
- Inline word timing spans are usually not used
|
||||
|
||||
## Body Structure and Extension Roles
|
||||
|
||||
Typical body structure:
|
||||
|
||||
```xml
|
||||
<body>
|
||||
<div itunes:song-part="Verse">
|
||||
<p begin="10.000" end="12.000" itunes:key="L1" ttm:agent="v1">
|
||||
<span begin="10.000" end="10.500">こ</span>
|
||||
<span begin="10.500" end="11.000">れ</span>
|
||||
<span begin="11.000" end="12.000">は</span>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
```
|
||||
|
||||
Common attributes and conventions:
|
||||
|
||||
- `itunes:key="L1"`: unique line ID (`L1`, `L2`, ...)
|
||||
- `ttm:agent="v1"`: points to `<ttm:agent xml:id="v1">`
|
||||
- `itunes:song-part`: section info (`Verse`, `Chorus`, etc.)
|
||||
|
||||
Inline assistant content uses `ttm:role`:
|
||||
|
||||
- `x-translation`: translation
|
||||
- `x-roman`: transliteration / romanization
|
||||
- `x-bg`: background vocal
|
||||
|
||||
Example:
|
||||
|
||||
```xml
|
||||
<p begin="20.000" end="25.000" itunes:key="L3" ttm:agent="v1000">
|
||||
<span begin="20.000" end="21.500">コーラス</span>
|
||||
<span begin="21.500" end="22.000">です</span>
|
||||
|
||||
<span ttm:role="x-bg" begin="22.500" end="23.800">
|
||||
<span begin="22.500" end="23.800">(背景)</span>
|
||||
<span ttm:role="x-translation" xml:lang="en">Background</span>
|
||||
<span ttm:role="x-roman" xml:lang="ja-Latn">haikei</span>
|
||||
</span>
|
||||
</p>
|
||||
```
|
||||
|
||||
## Apple Music Style Translation/Transliteration Sidecar
|
||||
|
||||
In addition to inline spans, translation/transliteration can be stored in `<iTunesMetadata>`:
|
||||
|
||||
```xml
|
||||
<iTunesMetadata xmlns="http://music.apple.com/lyric-ttml-internal">
|
||||
<translations>
|
||||
<translation xml:lang="zh-Hans-CN" type="subtitle">
|
||||
<text for="L1">First line translation</text>
|
||||
</translation>
|
||||
</translations>
|
||||
<transliterations>
|
||||
<transliteration xml:lang="ja-Latn">
|
||||
<text for="L1">dai ichi gyou</text>
|
||||
</transliteration>
|
||||
</transliterations>
|
||||
</iTunesMetadata>
|
||||
```
|
||||
|
||||
`for="L1"` links to `itunes:key="L1"` in the body.
|
||||
|
||||
## Ruby Annotation
|
||||
|
||||
AMLL supports TTML Ruby (`tts:ruby`) for furigana, pinyin, etc.:
|
||||
|
||||
- `tts:ruby="container"`: ruby container
|
||||
- `tts:ruby="base"`: base text
|
||||
- `tts:ruby="textContainer"`: ruby text container
|
||||
- `tts:ruby="text"`: ruby text (can carry timing)
|
||||
|
||||
```xml
|
||||
<span tts:ruby="container">
|
||||
<span tts:ruby="base">所</span>
|
||||
<span tts:ruby="textContainer">
|
||||
<span tts:ruby="text" begin="00:27.690" end="00:27.820">しょ</span>
|
||||
</span>
|
||||
</span>
|
||||
<span tts:ruby="container">
|
||||
<span tts:ruby="base">詮</span>
|
||||
<span tts:ruby="textContainer">
|
||||
<span tts:ruby="text" begin="00:27.820" end="00:27.880">せ</span>
|
||||
<span tts:ruby="text" begin="00:27.880" end="00:27.950">ん</span>
|
||||
</span>
|
||||
</span>
|
||||
```
|
||||
|
||||
## Implementation Behavior in This Library
|
||||
|
||||
Current parse/export behavior includes:
|
||||
|
||||
- Accepts both `itunes:songPart` and `itunes:song-part`; export prefers `song-part`
|
||||
- Parses and preserves `amll:obscene` and `amll:empty-beat`
|
||||
- Background vocal text can be with/without parentheses; parser normalizes it
|
||||
- Can merge inline translations with Head Sidecar translations (same language may duplicate; dedupe in business layer)
|
||||
- If word-level timing is absent but line timing exists, it can fallback to placeholder word entries to avoid information loss
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
title: Ecosystem
|
||||
---
|
||||
|
||||
After continuous development, AMLL has built an open-source ecosystem around word-by-word lyrics, including lyric databases, editors, and music players.
|
||||
|
||||
## First-party Ecosystem
|
||||
|
||||
First-party projects are repositories under the [amll-dev](https://github.com/amll-dev/) GitHub organization. Content and docs for each project are maintained independently. Please report issues in the corresponding repository.
|
||||
|
||||
### AMLL TTML Database
|
||||
|
||||
[AMLL TTML Database](https://github.com/amll-dev/amll-ttml-db) is a high-quality open word-by-word lyrics database. Lyrics are community-contributed and reviewed, and published under [CC0-1.0](https://github.com/amll-dev/amll-ttml-db/blob/main/LICENSE).
|
||||
|
||||
If you are building a player, you can use it as your lyric source. You can also contribute lyrics back to the database. For submission and usage details, see its [repository wiki](https://github.com/amll-dev/amll-ttml-db/wiki).
|
||||
|
||||
### AMLL TTML Tool
|
||||
|
||||
[AMLL TTML Tool](https://github.com/amll-dev/amll-ttml-tool) is a React-based word-by-word lyrics editor with lyric editing and timing capabilities. Most lyrics in the database were created using this tool.
|
||||
|
||||

|
||||
|
||||
It is deployed at <https://tool.amll.dev/> and can be used directly.
|
||||
|
||||
### AMLL Editor
|
||||
|
||||
[AMLL Editor](https://github.com/amll-dev/amll-editor) is a next-generation Vue-based word-by-word lyrics editor, currently in early development. Compared with AMLL TTML Tool, it introduces additional conveniences such as find and replace.
|
||||
|
||||
It is deployed at <https://editor.amll.dev/> and can be used directly. Documentation is available in its [repository wiki](https://github.com/amll-dev/amll-editor/wiki).
|
||||
|
||||
### AMLL Player
|
||||
|
||||
[AMLL Player](https://github.com/amll-dev/amll-player) is a music player built on AMLL. It can be used as a local music player, or together with WS protocol to integrate with other music software.
|
||||
|
||||
## Recommended Third-party Projects
|
||||
|
||||
Here are selected third-party applications integrating AMLL. Because of GPL copyleft, these projects are also open-source under GPL and available for free use. We also maintain a [GitHub discussion](https://github.com/orgs/amll-dev/discussions/397).
|
||||
|
||||
### SPlayer
|
||||
|
||||
[SPlayer](https://github.com/imsyy/SPlayer) is a third-party NetEase Cloud Music client built with Vue.
|
||||
|
||||

|
||||
|
||||
## History
|
||||
|
||||
AMLL was born in [December 2022](https://github.com/amll-dev/applemusic-like-lyrics/commit/88a3c1d), initially as a BetterNCM plugin for NetEase Cloud Music PC client to enhance lyric UI.
|
||||
|
||||

|
||||
|
||||
In July 2023, AMLL released its first npm package [@applemusic-like-lyrics/core@0.0.1](https://www.npmjs.com/package/@applemusic-like-lyrics/core/v/0.0.1).
|
||||
|
||||
Due to multiple client limitations and performance issues in NetEase Cloud Music, AMLL Player development started in [August 2023](https://github.com/amll-dev/applemusic-like-lyrics/commit/28d3f6f). It communicates with clients through a WebSocket-based protocol and moved lyric rendering into an independent application.
|
||||
|
||||
In February 2024, the plugin released its final version [v3.1.0](https://github.com/amll-dev/applemusic-like-lyrics/releases/tag/v3.1.0), ending plugin-mode development and maintenance. Later, plugin UI parts were reorganized into reusable component libraries.
|
||||
|
||||
In September 2024, components from the original plugin were released as [@applemusic-like-lyrics/react-full@0.2.0-alpha.0](https://www.npmjs.com/package/@applemusic-like-lyrics/react-full/v/0.2.0-alpha.0).
|
||||
|
||||
In April 2026, AMLL Player was [split out](https://github.com/amll-dev/applemusic-like-lyrics/pull/455) from the main repository into an [independent repository](https://github.com/amll-dev/amll-player), and an automated release workflow was introduced. Through GitHub Actions, the first provenance package [@applemusic-like-lyrics/core@0.3.0](https://www.npmjs.com/package/@applemusic-like-lyrics/core/v/0.3.0) was published.
|
||||
|
||||
AMLL is still under active development. Contributions are welcome. See [Contributing](/en/contribute).
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 995 KiB |
@@ -0,0 +1,46 @@
|
||||
---
|
||||
title: Introduction
|
||||
description: A quick introduction to AMLL
|
||||
---
|
||||
|
||||
## What is AMLL
|
||||
|
||||
Apple Music Like Lyrics (AMLL) is an open-source frontend library for Apple Music style word-by-word lyric rendering.
|
||||
|
||||
Word-by-word lyrics (also called syllable-level lyrics) means lyric timing is aligned to syllables (Chinese characters, or syllables in alphabetic languages), similar to karaoke style rendering. During playback, text is highlighted progressively in sync with the music. You can see a simple demo on the [home page](/en/). Screenshots are shown below.
|
||||
|
||||

|
||||
|
||||
## Distribution and Usage
|
||||
|
||||
AMLL is distributed as npm packages and provides tools across rendering components, framework bindings, and lyric processing:
|
||||
|
||||
- **Rendering packages** (browser)
|
||||
- [@applemusic-like-lyrics/core](https://www.npmjs.com/package/@applemusic-like-lyrics/core)
|
||||
AMLL core library with framework-agnostic lyric and background rendering components
|
||||
- [@applemusic-like-lyrics/react](https://www.npmjs.com/package/@applemusic-like-lyrics/react)
|
||||
React bindings for the core library
|
||||
- [@applemusic-like-lyrics/vue](https://www.npmjs.com/package/@applemusic-like-lyrics/vue)
|
||||
Vue bindings for the core library
|
||||
- [@applemusic-like-lyrics/react-full](https://www.npmjs.com/package/@applemusic-like-lyrics/react-full)
|
||||
Ready-to-use full player package with progress bar, cover, lyrics, background, etc. (React only)
|
||||
|
||||
- **Peripheral tools** (browser and Node)
|
||||
- [@applemusic-like-lyrics/ttml](https://www.npmjs.com/package/@applemusic-like-lyrics/ttml)
|
||||
Parsing and generation library for TTML word-by-word lyrics
|
||||
- [@applemusic-like-lyrics/lyric](https://www.npmjs.com/package/@applemusic-like-lyrics/lyric)
|
||||
Parsing and generation library for popular lyric formats, such as LRC, YRC, and LQE
|
||||
- [@applemusic-like-lyrics/fft](https://www.npmjs.com/package/@applemusic-like-lyrics/fft)
|
||||
Audio visualization module that converts waveform data into spectrum data
|
||||
- [@applemusic-like-lyrics/ws-protocol](https://www.npmjs.com/package/@applemusic-like-lyrics/ws-protocol)
|
||||
Lyrics player protocol library for syncing playback progress and playback information
|
||||
|
||||
AMLL is **open-sourced under [AGPL v3.0 only](https://spdx.org/licenses/AGPL-3.0-only.html)**, with the repository hosted on [GitHub](https://github.com/amll-dev/applemusic-like-lyrics). You can integrate it into your projects under the license terms.
|
||||
|
||||
Thanks to the maturity of frontend technologies, web rendering now has strong consistency across browsers, desktop, and mobile platforms. If you are building a music player, karaoke app, or related product with frontend technologies, AMLL is a strong option.
|
||||
|
||||
## Next Step
|
||||
|
||||
Beyond AMLL itself, there is a growing ecosystem around it, including lyric databases, lyric editors, and first-party players. See [Ecosystem](./eco) for details.
|
||||
|
||||
If you want to start using AMLL in your project, continue with [Quick Start](./quickstart).
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
title: API Reference
|
||||
description: Browse API documentation by module
|
||||
editUrl: false
|
||||
---
|
||||
|
||||
import { CardGrid, LinkCard } from "@astrojs/starlight/components";
|
||||
|
||||
<CardGrid>
|
||||
<LinkCard
|
||||
title="Core"
|
||||
description="Framework-agnostic lyric player"
|
||||
href="/en/reference/core"
|
||||
/>
|
||||
<LinkCard
|
||||
title="React"
|
||||
description="React bindings and components for the core library"
|
||||
href="/en/reference/react"
|
||||
/>
|
||||
<LinkCard
|
||||
title="React Full"
|
||||
description="Ready-to-use full React player package"
|
||||
href="/en/reference/react-full"
|
||||
/>
|
||||
<LinkCard
|
||||
title="Vue"
|
||||
description="Vue bindings and components for the core library"
|
||||
href="/en/reference/vue"
|
||||
/>
|
||||
<LinkCard
|
||||
title="Lyric"
|
||||
description="Lyrics format processing library"
|
||||
href="/en/reference/lyric"
|
||||
/>
|
||||
<LinkCard
|
||||
title="TTML"
|
||||
description="TTML lyrics processing library"
|
||||
href="/en/reference/ttml"
|
||||
/>
|
||||
</CardGrid>
|
||||
@@ -0,0 +1,218 @@
|
||||
---
|
||||
title: 动态背景
|
||||
---
|
||||
|
||||
AMLL Core 提供了独立的背景渲染组件 [`BackgroundRender`](/reference/core/classbackgroundrender)。它负责把专辑图或专辑视频渲染成 Apple Music 风格的动态背景;歌词组件仍然只负责歌词视图本身,音频播放、资源加载与图层挂载需要由宿主环境管理。
|
||||
|
||||
本文主要使用原生 Core API 说明背景集成方式,如果你使用 React 或 Vue 绑定,请直接转到 [绑定部分](#react-与-vue-绑定)。
|
||||
|
||||
## 基本结构
|
||||
|
||||
背景组件由两部分组成:
|
||||
|
||||
- [`BackgroundRender`](/reference/core/classbackgroundrender):统一的包装器,提供设置专辑图、帧率、渲染比例、暂停恢复等方法。
|
||||
- 渲染器:目前 Core 提供 [`MeshGradientRenderer`](/reference/core/classmeshgradientrenderer) 和 [`PixiRenderer`](/reference/core/classpixirenderer)。
|
||||
|
||||
创建背景时需要选择其中一个渲染器:
|
||||
|
||||
```ts
|
||||
// 使用 Mesh Gradient
|
||||
import {
|
||||
BackgroundRender,
|
||||
MeshGradientRenderer,
|
||||
} from "@applemusic-like-lyrics/core";
|
||||
const meshBackground = BackgroundRender.new(MeshGradientRenderer);
|
||||
|
||||
// 使用 Pixi
|
||||
import { BackgroundRender, PixiRenderer } from "@applemusic-like-lyrics/core";
|
||||
const pixiBackground = BackgroundRender.new(PixiRenderer);
|
||||
```
|
||||
|
||||
## 与歌词组件叠放
|
||||
|
||||
背景元素是一个 `<canvas>`。通常把它和歌词组件放在同一个容器中,并放置在歌词元素之前。
|
||||
|
||||
```ts
|
||||
import {
|
||||
BackgroundRender,
|
||||
DomLyricPlayer,
|
||||
MeshGradientRenderer,
|
||||
} from "@applemusic-like-lyrics/core";
|
||||
import "@applemusic-like-lyrics/core/style.css";
|
||||
|
||||
const background = BackgroundRender.new(MeshGradientRenderer);
|
||||
const lyricPlayer = new DomLyricPlayer();
|
||||
|
||||
function mountPlayer(host: HTMLElement) {
|
||||
const backgroundElement = background.getElement();
|
||||
const lyricElement = lyricPlayer.getElement();
|
||||
|
||||
host.appendChild(backgroundElement);
|
||||
host.appendChild(lyricElement);
|
||||
}
|
||||
|
||||
const host = document.querySelector<HTMLElement>("#player");
|
||||
if (!host) throw new Error("missing #player");
|
||||
|
||||
mountPlayer(host);
|
||||
```
|
||||
|
||||
背景的 canvas 尺寸由 CSS 决定,内部会通过 `ResizeObserver` 按设备像素比和渲染比例调整实际绘制尺寸。因此,宿主容器应有明确宽高。
|
||||
|
||||
AMLL 本身不会定义背景组件与歌词组件的定位与层级样式,这部分样式应由宿主定义。
|
||||
|
||||
## 设置专辑资源
|
||||
|
||||
调用 [`setAlbum`](/reference/core/classbackgroundrender#setalbum) 设置背景来源。它可以接收图片或视频 URL、`HTMLImageElement` 或 `HTMLVideoElement`。
|
||||
|
||||
如果传入字符串 URL 且资源是视频,需要把第二个参数设为 `true`:
|
||||
|
||||
```ts
|
||||
// 图片 URL
|
||||
await background.setAlbum("/album-cover.jpg");
|
||||
|
||||
// 视频 URL
|
||||
await background.setAlbum("/album-video.webm", true);
|
||||
```
|
||||
|
||||
若你已经持有 `File` 或 `Blob` 对象,可以使用 [`URL.createObjectURL`](https://developer.mozilla.org/zh-CN/docs/Web/API/URL/createObjectURL_static) 创建对象 URL 并提供给 `setAlbum`。
|
||||
|
||||
背景渲染会把资源绘制到 canvas / WebGL 纹理中。
|
||||
|
||||
## 同步播放状态
|
||||
|
||||
背景组件拥有自己的动画循环,不需要像 `DomLyricPlayer` 一样手动逐帧调用 `update(delta)`。只需要在播放、暂停时调用 `resume()` 与 `pause()`:
|
||||
|
||||
```ts
|
||||
audio.addEventListener("play", () => {
|
||||
lyricPlayer.resume();
|
||||
background.resume();
|
||||
});
|
||||
|
||||
audio.addEventListener("pause", () => {
|
||||
lyricPlayer.pause();
|
||||
background.pause();
|
||||
});
|
||||
```
|
||||
|
||||
背景动画的播放状态与歌词动画独立,你也可以只控制背景动画。
|
||||
|
||||
```ts
|
||||
function setBackgroundPlaying(playing: boolean) {
|
||||
if (playing) background.resume();
|
||||
else background.pause();
|
||||
}
|
||||
```
|
||||
|
||||
## 应用渲染设置
|
||||
|
||||
常用设置可以在初始化后或用户调整选项时应用,设置后即时生效。
|
||||
|
||||
```ts
|
||||
function applyBackgroundSettings() {
|
||||
background.setFPS(60);
|
||||
background.setRenderScale(1);
|
||||
background.setFlowSpeed(0.2);
|
||||
background.setStaticMode(false);
|
||||
background.setLowFreqVolume(1);
|
||||
}
|
||||
```
|
||||
|
||||
| 方法 | 说明 |
|
||||
| ------------------------------------------------------------------------------------ | -------------------------------------------------------------- |
|
||||
| [`setFPS(fps)`](/reference/core/classbackgroundrender#setfps) | 设置背景动画帧率 |
|
||||
| [`setRenderScale(scale)`](/reference/core/classbackgroundrender#setrenderscale) | 设置渲染比例,数值越高越清晰,也越消耗性能 |
|
||||
| [`setFlowSpeed(speed)`](/reference/core/classbackgroundrender#setflowspeed) | 设置背景流动速度 |
|
||||
| [`setStaticMode(enable)`](/reference/core/classbackgroundrender#setstaticmode) | 开启后,背景在资源切换动画结束后可以停在静态状态,以节省性能 |
|
||||
| [`setLowFreqVolume(volume)`](/reference/core/classbackgroundrender#setlowfreqvolume) | 传入低频音量提示,部分渲染器可能会据此调整动态效果 |
|
||||
| [`setHasLyric(hasLyric)`](/reference/core/classbackgroundrender#sethaslyric) | 告诉渲染器当前歌曲是否有歌词,部分渲染器可能会据此调整动态效果 |
|
||||
|
||||
`setRenderScale` 的值通常在 `0.5` 到 `1` 之间取舍。移动端或低性能设备可以降低渲染比例和帧率;播放器全屏展示时可以提高渲染比例。
|
||||
|
||||
## 更换渲染器
|
||||
|
||||
`BackgroundRender` 创建后不能替换内部渲染器。如果用户从 `MeshGradientRenderer` 切换到 `PixiRenderer`,应该释放旧实例并创建新实例:
|
||||
|
||||
```ts
|
||||
type PlayerBackground =
|
||||
| BackgroundRender<MeshGradientRenderer>
|
||||
| BackgroundRender<PixiRenderer>;
|
||||
|
||||
function switchToPixiRenderer(
|
||||
host: HTMLElement,
|
||||
lyricPlayer: DomLyricPlayer,
|
||||
currentBackground: PlayerBackground,
|
||||
) {
|
||||
currentBackground.dispose();
|
||||
|
||||
const nextBackground = BackgroundRender.new(PixiRenderer);
|
||||
host.insertBefore(nextBackground.getElement(), lyricPlayer.getElement());
|
||||
return nextBackground;
|
||||
}
|
||||
```
|
||||
|
||||
## 清理
|
||||
|
||||
页面卸载、播放器销毁或永久切换实现时,需要释放背景实例:
|
||||
|
||||
```ts
|
||||
background.dispose();
|
||||
lyricPlayer.dispose();
|
||||
```
|
||||
|
||||
`dispose()` 会释放渲染器内部资源,并移除背景 canvas。若你自己创建了 `ObjectURL`、音频事件监听或其他异步加载状态,也需要在宿主代码中一并清理。
|
||||
|
||||
## React 与 Vue 绑定
|
||||
|
||||
背景组件没有歌词组件那么复杂的中间状态维护,因此组件化轻松许多。
|
||||
|
||||
React 与 Vue 是类似的,均通过 props 设置选项与维护状态。是否播放使用 `playing` 属性指定,专辑图片或视频资源使用 `album` 属性指定。你可以在 API 参考中查看完整的属性列表。
|
||||
|
||||
- React 属性列表参考:[BackgroundRenderProps](/reference/react/interfacebackgroundrenderprops)
|
||||
- Vue 属性列表参考:[BackgroundRender](/reference/vue/classbackgroundrender)
|
||||
|
||||
组件在卸载时会自动释放内部资源。但你自行定义的监听器、Object URL 等仍需你自行释放。
|
||||
|
||||
下面是一个 React 的最小示例:
|
||||
|
||||
```tsx
|
||||
import { LyricPlayer, BackgroundRender } from "@applemusic-like-lyrics/react";
|
||||
|
||||
function app() {
|
||||
const albumUrl = "/album-cover.jpg";
|
||||
return (
|
||||
<>
|
||||
<BackgroundRender album={albumUrl} />
|
||||
<LyricPlayer lyricLines={lyricLines} currentTime={currentTime} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
下面是一个 Vue 的最小示例:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<BackgroundRender :album="albumUrl" />
|
||||
<LyricPlayer :lyricLines="lyricLines" :currentTime="currentTime" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BackgroundRender, LyricPlayer } from "@applemusic-like-lyrics/vue";
|
||||
import { ref } from "vue";
|
||||
|
||||
const albumUrl = ref("/album-cover.jpg");
|
||||
const currentTime = ref(0);
|
||||
const lyricLines = ref([]);
|
||||
</script>
|
||||
```
|
||||
|
||||
## 检查清单
|
||||
|
||||
- 宿主容器有明确尺寸。
|
||||
- 背景 canvas 插入在歌词元素之前,且歌词层级高于背景层级。
|
||||
- 图片或视频资源允许跨域读取,或与页面同源。
|
||||
- 视频 URL 调用 `setAlbum(source, true)`。
|
||||
- 播放状态同步到 `resume()` / `pause()`。
|
||||
- 切换渲染器时先 `dispose()` 旧背景,再创建新背景。
|
||||
- 卸载时释放背景、歌词组件和宿主代码创建的资源。
|
||||
@@ -0,0 +1,286 @@
|
||||
---
|
||||
title: 快速开始
|
||||
---
|
||||
|
||||
import { CardGrid, LinkCard } from "@astrojs/starlight/components";
|
||||
import PackageInstall from "#components/PackageInstall.astro";
|
||||
|
||||
下面快速介绍如何将 AMLL 歌词组件集成到你的项目中。请注意,AMLL 不提供 CDN 引入方式,必须使用 bundler。
|
||||
|
||||
有关除组件库之外的其他周围工具包,请直接查阅 [API 参考](/reference)。
|
||||
|
||||
## 依赖
|
||||
|
||||
安装 AMLL 核心库:
|
||||
|
||||
<PackageInstall packages="@applemusic-like-lyrics/core" />
|
||||
|
||||
此外,AMLL 将一些图形与动画库声明为 peer,这是为了复用项目中可能存在的相关依赖。一些包管理器或配置下可能会自动安装 peer,若没有,需要手动安装。
|
||||
|
||||
<PackageInstall
|
||||
packages={[
|
||||
"@pixi/app",
|
||||
"@pixi/core",
|
||||
"@pixi/display",
|
||||
"@pixi/filter-blur",
|
||||
"@pixi/filter-bulge-pinch",
|
||||
"@pixi/filter-color-matrix",
|
||||
"@pixi/sprite",
|
||||
"jss",
|
||||
"jss-preset-default",
|
||||
]}
|
||||
/>
|
||||
|
||||
下面的示例会用 `@applemusic-like-lyrics/lyric` 解析 TTML 歌词文件。如果你的项目已经能直接提供 `LyricLine[]`,可以跳过这个包。
|
||||
|
||||
<PackageInstall packages="@applemusic-like-lyrics/lyric" />
|
||||
|
||||
接下来你可以使用原生包,也可以使用 React 或 Vue 绑定。
|
||||
|
||||
<CardGrid>
|
||||
<LinkCard
|
||||
title="使用原生方式引入"
|
||||
description="不使用前端框架"
|
||||
href="#使用原生方式引入"
|
||||
/>
|
||||
<LinkCard
|
||||
title="使用 React 绑定"
|
||||
description="项目使用 React 框架"
|
||||
href="#使用-react-绑定"
|
||||
/>
|
||||
<LinkCard
|
||||
title="使用 Vue 绑定"
|
||||
description="项目使用 Vue 框架"
|
||||
href="#使用-vue-绑定"
|
||||
/>
|
||||
</CardGrid>
|
||||
|
||||
## 使用原生方式引入
|
||||
|
||||
AMLL 核心库是框架无关的。无论是否使用框架,均可使用此方法引入。
|
||||
|
||||
下面假设:
|
||||
|
||||
- 页面中已有 `<audio id="audio">` 和 `<div id="lyric-player">`,并给歌词容器设置了明确高度
|
||||
- TTML 歌词文件在 `/lyrics/song.ttml` 上提供
|
||||
|
||||
```js
|
||||
import { LyricPlayer } from "@applemusic-like-lyrics/core";
|
||||
import { parseTTML } from "@applemusic-like-lyrics/lyric";
|
||||
|
||||
// 一般地,打包器会处理 CSS 导入;如果出现异常,请查阅你使用的打包器文档
|
||||
import "@applemusic-like-lyrics/core/style.css";
|
||||
|
||||
const audio = document.querySelector("#audio");
|
||||
const playerHost = document.querySelector("#lyric-player");
|
||||
const player = new LyricPlayer();
|
||||
|
||||
playerHost.appendChild(player.getElement());
|
||||
|
||||
async function loadLyric() {
|
||||
const ttml = await fetch("/lyrics/song.ttml").then((res) => res.text());
|
||||
const currentTime = Math.round(audio.currentTime * 1000);
|
||||
player.setLyricLines(parseTTML(ttml).lines, currentTime);
|
||||
player.setCurrentTime(currentTime, true);
|
||||
}
|
||||
|
||||
// 播放时持续同步音频进度
|
||||
let lastFrameTime = -1;
|
||||
function onFrame(frameTime) {
|
||||
const delta = lastFrameTime === -1 ? 0 : frameTime - lastFrameTime;
|
||||
lastFrameTime = frameTime;
|
||||
|
||||
if (!audio.paused) {
|
||||
player.setCurrentTime(Math.round(audio.currentTime * 1000));
|
||||
}
|
||||
|
||||
player.update(delta);
|
||||
requestAnimationFrame(onFrame);
|
||||
}
|
||||
|
||||
audio.addEventListener("play", () => player.resume());
|
||||
audio.addEventListener("pause", () => player.pause());
|
||||
audio.addEventListener("seeked", () => {
|
||||
// 跳转后立即对齐歌词位置
|
||||
player.setCurrentTime(Math.round(audio.currentTime * 1000), true);
|
||||
});
|
||||
|
||||
loadLyric();
|
||||
requestAnimationFrame(onFrame);
|
||||
```
|
||||
|
||||
有关更详细的时序管理,请转到 [时序与生命周期](./sequence)。
|
||||
|
||||
你可以前往 [API 参考:Core 核心](/reference/core) 获取详细的接口文档。
|
||||
|
||||
## 使用 React 绑定
|
||||
|
||||
确保你已安装 `react` 和 `react-dom` 包。然后安装 AMLL React 绑定包:
|
||||
|
||||
<PackageInstall packages="@applemusic-like-lyrics/react" />
|
||||
|
||||
React 绑定包拥有具名导出 [`LyricPlayer`](/reference/react/variablelyricplayer) 作为核心组件。下面是一段示例,假设 TTML 歌词文件在 `/lyrics/song.ttml` 上提供、音频文件在 `/music/song.m4a` 上提供。
|
||||
|
||||
```tsx
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { LyricLine } from "@applemusic-like-lyrics/core";
|
||||
import { LyricPlayer } from "@applemusic-like-lyrics/react";
|
||||
import { parseTTML } from "@applemusic-like-lyrics/lyric";
|
||||
|
||||
// 一般地,打包器会处理 CSS 导入;如果出现异常,请查阅你使用的打包器文档
|
||||
import "@applemusic-like-lyrics/core/style.css";
|
||||
|
||||
function App() {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const [lyricLines, setLyricLines] = useState<LyricLine[]>([]);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
|
||||
function syncCurrentTime() {
|
||||
const audio = audioRef.current;
|
||||
if (audio) setCurrentTime(Math.round(audio.currentTime * 1000));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
|
||||
// parseTTML 返回包含元数据的对象,组件只需要其中的 lines
|
||||
fetch("/lyrics/song.ttml")
|
||||
.then((res) => res.text())
|
||||
.then((ttml) => {
|
||||
if (!canceled) setLyricLines(parseTTML(ttml).lines);
|
||||
});
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let frameId = 0;
|
||||
const onFrame = () => {
|
||||
const audio = audioRef.current;
|
||||
if (audio && !audio.paused) {
|
||||
setCurrentTime(Math.round(audio.currentTime * 1000));
|
||||
}
|
||||
frameId = requestAnimationFrame(onFrame);
|
||||
};
|
||||
|
||||
frameId = requestAnimationFrame(onFrame);
|
||||
return () => cancelAnimationFrame(frameId);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<LyricPlayer
|
||||
lyricLines={lyricLines}
|
||||
currentTime={currentTime}
|
||||
playing={playing}
|
||||
style={{ height: 420 }}
|
||||
/>
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src="/music/song.m4a"
|
||||
controls
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onEnded={() => setPlaying(false)}
|
||||
onSeeked={syncCurrentTime}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
```
|
||||
|
||||
你可以前往 [API 参考:React 绑定](/reference/react) 获取详细的接口文档。
|
||||
|
||||
## 使用 Vue 绑定
|
||||
|
||||
确保你已安装 Vue。然后安装 AMLL Vue 绑定包:
|
||||
|
||||
<PackageInstall packages="@applemusic-like-lyrics/vue" />
|
||||
|
||||
Vue 绑定包拥有具名导出 [`LyricPlayer`](/reference/vue/classlyricplayer) 作为核心组件。歌词对象、播放进度等均以组件的响应式属性传入。下面是一段示例,假设 TTML 歌词文件在 `/lyrics/song.ttml` 上提供、音频文件在 `/music/song.m4a` 上提供。
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<LyricPlayer
|
||||
class="lyric-player"
|
||||
:lyric-lines="lyricLines"
|
||||
:current-time="currentTime"
|
||||
:playing="playing"
|
||||
/>
|
||||
<audio
|
||||
ref="audioEl"
|
||||
src="/music/song.m4a"
|
||||
controls
|
||||
@play="onPlay"
|
||||
@pause="onPause"
|
||||
@ended="onPause"
|
||||
@seeked="syncCurrentTime"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LyricLine } from "@applemusic-like-lyrics/core";
|
||||
import { parseTTML } from "@applemusic-like-lyrics/lyric";
|
||||
import { LyricPlayer } from "@applemusic-like-lyrics/vue";
|
||||
import {
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
useTemplateRef,
|
||||
} from "vue";
|
||||
|
||||
// 一般地,打包器会处理 CSS 导入;如果出现异常,请查阅你使用的打包器文档
|
||||
import "@applemusic-like-lyrics/core/style.css";
|
||||
|
||||
const audioEl = useTemplateRef<HTMLAudioElement>("audioEl");
|
||||
const lyricLines = shallowRef<LyricLine[]>([]);
|
||||
const currentTime = ref(0);
|
||||
const playing = ref(false);
|
||||
let frameId = 0;
|
||||
|
||||
async function loadLyric() {
|
||||
const ttml = await fetch("/lyrics/song.ttml").then((res) => res.text());
|
||||
// parseTTML 返回包含元数据的对象,组件只需要其中的 lines
|
||||
lyricLines.value = parseTTML(ttml).lines;
|
||||
}
|
||||
|
||||
function syncCurrentTime() {
|
||||
if (audioEl.value) {
|
||||
currentTime.value = Math.round(audioEl.value.currentTime * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function onFrame() {
|
||||
syncCurrentTime();
|
||||
if (playing.value) frameId = requestAnimationFrame(onFrame);
|
||||
}
|
||||
|
||||
function onPlay() {
|
||||
playing.value = true;
|
||||
cancelAnimationFrame(frameId);
|
||||
onFrame();
|
||||
}
|
||||
|
||||
function onPause() {
|
||||
playing.value = false;
|
||||
cancelAnimationFrame(frameId);
|
||||
}
|
||||
|
||||
onMounted(() => void loadLyric());
|
||||
onBeforeUnmount(() => cancelAnimationFrame(frameId));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.lyric-player {
|
||||
height: 420px;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
你可以前往 [API 参考:Vue 绑定](/reference/vue) 获取详细的接口文档。
|
||||
@@ -0,0 +1,209 @@
|
||||
---
|
||||
title: 时序与生命周期
|
||||
---
|
||||
|
||||
下面介绍歌词组件的时序与生命周期管理。
|
||||
|
||||
歌词组件只负责歌词视图本身,**不负责音频播放**。因此 **宿主环境(也就是你的代码)需要管理音频播放,并把音频播放状态与 AMLL 的组件状态桥接起来。**
|
||||
|
||||
如果你使用 React 或 Vue 绑定,组件会代管一部分生命周期;如果直接使用原生方式,则需要自己管理完整流程。本文主要介绍原生方式引入的周期管理,并介绍绑定托管的状态。
|
||||
|
||||
## 初始化
|
||||
|
||||
初始化时需要完成:
|
||||
|
||||
1. 创建歌词组件,并把它的元素挂载到一个 **有明确尺寸的** 容器里。
|
||||
2. (可选)设置自定义歌词优化选项。[`setOptimizeOptions`](/reference/core/classlyricplayerbase#setoptimizeoptions) 方法接受 [`OptimizeLyricOptions`](/reference/core/interfaceoptimizelyricoptions)。
|
||||
3. 设置歌词数据。[`setLyricLines`](/reference/core/classlyricplayerbase#setlyriclines) 方法接受 [`LyricLine[]`](/reference/core/interfacelyricline),传入后不应再修改这些对象。
|
||||
4. 用当前播放进度对齐一次歌词位置。
|
||||
|
||||
原生方式的典型顺序如下:
|
||||
|
||||
```ts
|
||||
import { LyricPlayer } from "@applemusic-like-lyrics/core";
|
||||
|
||||
const player = new LyricPlayer();
|
||||
host.appendChild(player.getElement());
|
||||
|
||||
const currentTime = Math.round(audio.currentTime * 1000);
|
||||
player.setOptimizeOptions({}); // 可选
|
||||
player.setLyricLines(lines, currentTime);
|
||||
player.setCurrentTime(currentTime, true);
|
||||
player.update(0);
|
||||
```
|
||||
|
||||
在设置歌词时会执行歌词优化处理,因此这部分选项如需调整,应在 `setLyricLines` 之前调用 `setOptimizeOptions`。在 `setLyricLines` 之后修改不会自动重新处理已有歌词,需要重新设置歌词。
|
||||
|
||||
另外需要注意其中 `currentTime` 的单位是毫秒,且应为整数。`audio.currentTime` 单位为秒,所以要乘以 `1000`。
|
||||
|
||||
## 播放与暂停
|
||||
|
||||
`pause()` 和 `resume()` 控制歌词组件内部的演出状态,包括逐字动画与辉光、间奏点动画。音频开始播放时调用 `resume()`,音频暂停、结束或被外部中断时调用 `pause()`。
|
||||
|
||||
例如,若使用 `<audio>` 播放音频,可以使用其事件驱动:
|
||||
|
||||
```ts
|
||||
const onPlay = () => {
|
||||
player.resume();
|
||||
};
|
||||
const onPause = () => {
|
||||
player.pause();
|
||||
};
|
||||
|
||||
audio.addEventListener("play", onPlay);
|
||||
audio.addEventListener("pause", onPause);
|
||||
```
|
||||
|
||||
## 播放进度
|
||||
|
||||
### 正常播放
|
||||
|
||||
在播放过程中需要更新歌词组件的时间进度。**AMLL 使用的所有时间,单位均为毫秒**。
|
||||
|
||||
其中有两个容易混淆的时间:
|
||||
|
||||
| 时间类型 | 接收于 | 含义 |
|
||||
| ------------ | ------------------------------------------- | -------------------- |
|
||||
| 当前播放进度 | `setCurrentTime(time)` / `currentTime` 属性 | 歌曲播放的进度 |
|
||||
| 帧间隔 | `update(delta)` | 距离上一帧过去的时间 |
|
||||
|
||||
原生方式下,`setCurrentTime` 会更新歌词时间线,`update` 会推进动画。**二者不是同一个值**。
|
||||
|
||||
```ts
|
||||
let frameId = 0;
|
||||
let lastFrameTime = -1;
|
||||
|
||||
function startFrameLoop() {
|
||||
const onFrame = (frameTime: number) => {
|
||||
const delta = lastFrameTime === -1 ? 0 : frameTime - lastFrameTime;
|
||||
lastFrameTime = frameTime;
|
||||
if (!audio.paused) {
|
||||
player.setCurrentTime(Math.round(audio.currentTime * 1000));
|
||||
}
|
||||
player.update(delta);
|
||||
frameId = requestAnimationFrame(onFrame);
|
||||
};
|
||||
frameId = requestAnimationFrame(onFrame);
|
||||
}
|
||||
|
||||
function stopFrameLoop() {
|
||||
cancelAnimationFrame(frameId);
|
||||
frameId = 0;
|
||||
lastFrameTime = -1;
|
||||
}
|
||||
```
|
||||
|
||||
**不应依赖 `<audio>` 的 `timeupdate` 事件同步歌词**。这是由于浏览器触发 `timeupdate` 的频率较低且不稳定,通常明显低于动画帧频率。播放中应使用 `requestAnimationFrame` 逐帧同步当前进度。
|
||||
|
||||
### 跳转
|
||||
|
||||
在正常播放之外,播放进度有可能产生跳变,常见于:
|
||||
|
||||
- 拖动进度条
|
||||
- 快进快退
|
||||
- 点击某一歌词行跳转
|
||||
- 循环播放时,进度从结尾跳至开头
|
||||
|
||||
播放进度发生跳变时,需要把 `setCurrentTime` 的第二个参数设为 `true`:
|
||||
|
||||
```ts
|
||||
function onSeeked() {
|
||||
player.setCurrentTime(Math.round(audio.currentTime * 1000), true);
|
||||
}
|
||||
audio.addEventListener("seeked", onSeeked);
|
||||
```
|
||||
|
||||
**这个参数表示本次同步是一次 seek。正常播放状态与 seek 状态的布局与动画行为是不同的:**
|
||||
|
||||
- 正常播放时,组件会对视图内的每一行单独执行布局与弹簧动画,实现细腻的视觉效果
|
||||
- 调整进度时,组件会强制对齐歌词位置,对所有歌词行整体执行布局与弹簧动画效果,减小性能消耗且动画更加利落
|
||||
|
||||
如果没有正确标记 seek 状态,可能出现布局异常,例如出现卡顿、歌词行从屏幕一端快速飞到另一端消失等等。你可以在 [issue #429](https://github.com/amll-dev/applemusic-like-lyrics/issues/429) 中看到截图。
|
||||
|
||||
### 歌词行点击事件
|
||||
|
||||
组件提供了 `line-click` 事件,在某一歌词行被点击时触发,其事件类型为 [`LyricLineMouseEvent`](/reference/core/classlyriclinemouseevent)。
|
||||
|
||||
**组件本身不会响应歌词行的点击操作。** 宿主环境需要监听该事件,并作出音频进度跳转等操作。例如:
|
||||
|
||||
```ts
|
||||
import type { LyricLineMouseEvent } from "@applemusic-like-lyrics/core";
|
||||
|
||||
player.addEventListener("line-click", (event) => {
|
||||
const lineEvent = event as LyricLineMouseEvent;
|
||||
audio.currentTime = lineEvent.line.getLine().startTime / 1000;
|
||||
player.setCurrentTime(lineEvent.line.getLine().startTime, true);
|
||||
});
|
||||
```
|
||||
|
||||
值得一提:点击歌词行跳转时也属于 seek。
|
||||
|
||||
## 更换歌词
|
||||
|
||||
更换歌曲或歌词源时,通过 `setLyricLines` 方法再次设置歌词行对象数组即可。如果加载失败,可以传入空数组清空歌词。
|
||||
|
||||
```ts
|
||||
player.setLyricLines([]);
|
||||
player.update(0);
|
||||
```
|
||||
|
||||
## React 与 Vue 绑定
|
||||
|
||||
React 和 Vue 绑定会创建并销毁底层 Core 组件,也会在未禁用时自动调用 `update`。因此使用绑定时,通常不需要自己调用底层 `update`。
|
||||
|
||||
你仍然需要负责这些状态:
|
||||
|
||||
| 状态 | React / Vue 传入方式 | 说明 |
|
||||
| ------------ | -------------------- | ------------------------------------------- |
|
||||
| 歌词数据 | `lyricLines` | 解析后的 `LyricLine[]` |
|
||||
| 当前播放进度 | `currentTime` | 播放中用 `requestAnimationFrame` 从音频同步 |
|
||||
| 播放状态 | `playing` | 控制歌词组件内部演出暂停或恢复 |
|
||||
|
||||
React 绑定额外提供 `isSeeking` 属性,可以在跳转时传入:
|
||||
|
||||
```tsx
|
||||
<LyricPlayer
|
||||
lyricLines={lyricLines}
|
||||
currentTime={currentTime}
|
||||
isSeeking={isSeeking}
|
||||
playing={playing}
|
||||
/>
|
||||
```
|
||||
|
||||
`isSeeking` 不应长期保持为 `true`。通常在用户完成一次跳转时短暂置为 `true`,下一轮同步后再恢复为 `false`。
|
||||
|
||||
Vue 绑定目前功能较为残缺,没有单独的 `isSeeking` 属性。一般场景下同步 `currentTime` 就可以工作。如果需要进一步控制状态,建议直接使用原生方式引入。我们将会在接下来的版本中逐步优化 Vue 绑定的功能与使用体验。
|
||||
|
||||
如果设置了 `disabled`,绑定将不再代管逐帧动画。此时你可以通过组件 ref 取得底层 `lyricPlayer`,并像原生方式一样自己调用 `update`。
|
||||
|
||||
## 清理
|
||||
|
||||
当不再需要歌词播放组件时,原生方式需要清理你自己创建的所有资源:
|
||||
|
||||
```ts
|
||||
// 清除你定义的 requestAnimationFrame 逐帧调用
|
||||
stopFrameLoop();
|
||||
|
||||
// 移除你添加的侦听器
|
||||
audio.removeEventListener("play", onPlay);
|
||||
audio.removeEventListener("pause", onPause);
|
||||
audio.removeEventListener("seeked", onSeeked);
|
||||
|
||||
// 释放组件资源
|
||||
player.dispose();
|
||||
```
|
||||
|
||||
`dispose()` 会移除组件元素并释放内部监听。
|
||||
|
||||
如果使用 React 或 Vue 绑定,组件卸载时会自动调用底层 `dispose()`;但你自己创建的 `requestAnimationFrame`、音频事件监听、`ObjectURL` 等仍然需要在组件卸载时清理。
|
||||
|
||||
## 检查清单
|
||||
|
||||
- 容器应有明确尺寸,且已经挂载到 DOM。
|
||||
- 歌词通过 `setLyricLines(lines, currentTime)` 或 `lyricLines` 属性传入。
|
||||
- 播放进度用毫秒表示。
|
||||
- 播放时用 `requestAnimationFrame` 同步 `currentTime`。
|
||||
- 原生方式逐帧调用 `update(delta)`。
|
||||
- 暂停、恢复、结束播放时同步 `pause()` / `resume()` 或 `playing`。
|
||||
- 跳转使用 seek 标志对齐。
|
||||
- 卸载时取消动画帧、移除事件监听并释放组件。
|
||||
29
amll-local/packages/docs/src/content/docs/guides/index.mdx
Normal file
29
amll-local/packages/docs/src/content/docs/guides/index.mdx
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
title: 使用文档
|
||||
editUrl: false
|
||||
---
|
||||
|
||||
import { CardGrid, LinkCard } from "@astrojs/starlight/components";
|
||||
|
||||
<CardGrid>
|
||||
<LinkCard
|
||||
title="介绍"
|
||||
description="AMLL 的基本信息"
|
||||
href="/guides/overview/intro"
|
||||
/>
|
||||
<LinkCard
|
||||
title="歌词组件"
|
||||
description="将歌词组件集成到项目中"
|
||||
href="/guides/component/quickstart"
|
||||
/>
|
||||
<LinkCard
|
||||
title="背景组件"
|
||||
description="将背景组件集成到项目中"
|
||||
href="/guides/component/background"
|
||||
/>
|
||||
<LinkCard
|
||||
title="歌词格式"
|
||||
description="歌词格式介绍与库使用说明"
|
||||
href="/guides/lyric/quickstart"
|
||||
/>
|
||||
</CardGrid>
|
||||
@@ -0,0 +1,195 @@
|
||||
---
|
||||
title: 各歌词格式介绍
|
||||
---
|
||||
|
||||
本文介绍 [@applemusic-like-lyrics/lyric](https://www.npmjs.com/package/@applemusic-like-lyrics/lyric) 库支持的一些歌词文件格式。
|
||||
|
||||
在本文中,「逐字歌词」**泛指时间戳精度高于行级别的歌词**。不同平台、格式实现有所不同,可能对应逐音节或逐词的时间戳。
|
||||
|
||||
## TTML
|
||||
|
||||
TTML 格式是 AMLL 生态的主要歌词存储与交换格式,支持 AMLL 生态的所有能力,包括翻译音译、背景对唱、逐字音译、注音等等。
|
||||
|
||||
有关 TTML 的介绍详见 [TTML](./ttml) 一文。
|
||||
|
||||
**本库提供 [`parseTTML`](/reference/lyric/functionparsettml) 和 [`stringifyTTML`](/reference/lyric/functionstringifyttml) 方法用于正反序列化 TTML 歌词。**
|
||||
|
||||
## LRC
|
||||
|
||||
LRC 是最常见的歌词文件格式,只支持逐行歌词。扩展名为 `.lrc`,是 **l**y**r**i**c**s 的缩写。
|
||||
|
||||
我们这里所说的 LRC 是最基本、未作任何扩展的 LRC 格式,有时也称为简单 LRC。LRC 并不是某个机构公开声明定义的,其最初来源已经不可考,目前已经成为业界的某种约定俗成,因此各种变体繁多,很多细节也没有固定。
|
||||
|
||||
一般地,LRC 格式中每一行对应着一个歌词行。歌词行的开头有时间戳,表示该行的开始时间。常见的格式有
|
||||
|
||||
- `[mm:ss]`
|
||||
- `[mm:ss.xx]`
|
||||
- `[mm:ss.xxx]`
|
||||
|
||||
其中 `mm` 为分钟,`ss.xxx` 为秒,小数点后可能不保留、保留 2 位或保留 3 位。特别地,同一行歌词可以使用多个时间戳,表示在不同时间重复出现。
|
||||
|
||||
LRC 格式还支持在开头添加元数据。元数据的格式为 `[tag:content]`。AMLL Editor 支持 LRC 元数据模板,包含了常见字段,在元数据侧边栏的下拉框中可以选择。此处不再列举。
|
||||
|
||||
一些实现允许使用 `#` 开头的行作为注释。
|
||||
|
||||
这里给出一段 LRC 歌词的示例。
|
||||
|
||||
```lrc
|
||||
[al:崩坏星穹铁道-不虚此行 On the Journey]
|
||||
[ti:不虚此行 On the Journey]
|
||||
[ar:魏晨, Nea]
|
||||
[length: 2:36]
|
||||
|
||||
[00:25.494]We venture through the cosmic sea
|
||||
[00:27.541]A thousand light-years, wild and free
|
||||
[00:30.805]We dance beneath the galaxy
|
||||
[00:32.847]Then will you be with me?
|
||||
# ...
|
||||
[01:45.949]Catching on, our paths unknown
|
||||
[01:50.261]To sink into daylight
|
||||
[01:52.851]Break into the moonlight
|
||||
[01:56.487]Life goes on, through tides of time
|
||||
[02:00.900]Get in the line, to dream alive
|
||||
[02:03.580]In our souls, do we know?
|
||||
[02:05.896][02:08.473][02:11.262]On the journey
|
||||
```
|
||||
|
||||
LRC 不支持为歌词添加翻译与音译。一般的处理方法是,如有翻译与音译,通过额外的独立文件提供,时间戳与原文歌词文件相同,对应时间戳对应行的翻译与音译。
|
||||
|
||||
[维基百科:LRC (file format)](<https://en.wikipedia.org/wiki/LRC_(file_format)>) 上有更多介绍。
|
||||
|
||||
**本库提供 [`parseLrc`](/reference/lyric/functionparselrc) 和 [`stringifyLrc`](/reference/lyric/functionstringifylrc) 方法用于正反序列化 LRC 歌词。**
|
||||
|
||||
## LRC A2
|
||||
|
||||
LRC A2 首先由 A2 Media Player 提出,故名。其是在 LRC 基础上扩展的结果,通过在文中加注尖括号包裹的时间戳 `<mm:ss.xx>` 实现了逐字。时间戳格式与 LRC 类似,可能不保留小数点、或小数点后保留 2 位或 3 位。每个尖括号都表示其**后续**一部分文本片段的开始时间。
|
||||
|
||||
LRC A2 的扩展名为 `.lrc` 或 `.alrc`。
|
||||
|
||||
这里直接给出示例。
|
||||
|
||||
```alrc
|
||||
[ti: Somebody to Love]
|
||||
[ar: Jefferson Airplane]
|
||||
[al: Surrealistic Pillow]
|
||||
[length: 2:58]
|
||||
|
||||
[00:00.00] <00:00.04> When <00:00.16> the <00:00.82> truth <00:01.29> is <00:01.63> found <00:03.09> to <00:03.37> be <00:05.92> lies
|
||||
[00:06.47] <00:07.67> And <00:07.94> all <00:08.36> the <00:08.63> joy <00:10.28> within <00:10.53> you <00:13.09> dies
|
||||
[00:13.34] <00:14.32> Don't <00:14.73> you <00:15.14> want <00:15.57> somebody <00:16.09> to <00:16.46> Love
|
||||
```
|
||||
|
||||
解析时,时间戳前后的空格应当合并。
|
||||
|
||||
LRC A2 使用尖括号夹入文中,这里涉及到转义的问题:如果歌词中出现了大于号 `>` 或小于号 `<`,应当如何处置?目前没有找到公开的规范说明,业内没有统一的转义方式。在解析时会尽量按时间戳模式匹配 `<mm:ss.xx>`,若歌词中出现 `<` 或 `>` 且不符合时间戳格式,则按普通字符处理。
|
||||
|
||||
LRC A2 也不支持为歌词添加翻译与音译。一般的做法是伴随提供 LRC 格式的翻译或音译。
|
||||
|
||||
**本库提供 [`parseLrcA2`](/reference/lyric/functionparselrca2) 和 [`stringifyLrcA2`](/reference/lyric/functionstringifylrca2) 方法用于正反序列化 LRC 歌词。**
|
||||
|
||||
## 网易云逐字与 QQ 音乐逐字
|
||||
|
||||
二者都是音乐平台私有的逐字歌词格式。网易云音乐使用 `.yrc` 作为扩展名,QQ 音乐使用 `.qrc` 作为扩展名。
|
||||
|
||||
二者都使用开头的方括号标注行时间戳,格式均为 `[lineStart,lineDur]`。`lineStart` 为行起始时间,以毫秒计的整数;`lineDur` 是行持续时间,也是以毫秒计的整数。二者也均**不支持**类似 LRC 的重复行使用多个时间戳。
|
||||
|
||||
在逐字的表现形式上二者有一些不同。
|
||||
|
||||
- 网易云的 YRC 采用 `(sylStart,sylDur,0)text` 格式,先时间戳、后文本,时间戳括号内三个数分别是起始时间、持续时间、`0`。所有 YRC 格式歌词都带有这个 `0`,目前尚未发现其具体含义,可能是为未来扩展预留的字段。
|
||||
- QQ 音乐的 QRC 采用 `text(sylStart,sylDur)` 格式,先文本、后时间戳。并且没有 YRC 的 `0`。
|
||||
|
||||
所有的开始时间均为**绝对时间**,即从音频开始到文本开始所经过的时间。
|
||||
|
||||
下面是同一段歌词在两种格式下的示例:
|
||||
|
||||
```
|
||||
# 网易云音乐 YRC
|
||||
[190871,1984](190871,361,0)For (191232,172,0)the (191404,376,0)first (191780,1075,0)time
|
||||
[193459,4198](193459,412,0)What's (193871,574,0)past (194445,506,0)is (194951,2706,0)past
|
||||
|
||||
# QQ 音乐 QRC
|
||||
[190871,1984]For (190871,361)the (191232,172)first (191404,376)time(191780,1075)
|
||||
[193459,4198]What's (193459,412)past (193871,574)is (194445,506)past(194951,2706)
|
||||
```
|
||||
|
||||
QRC 和 YRC **不具有**类似 LRC A2 的合并相邻空格特性。
|
||||
|
||||
由于在文中夹注半角括号 `()` 的时间戳,那歌词文本中的圆括号应当如何转义?由于 YRC 和 QRC 都是私有文件格式,没有官方资料可查,于是我们查找这两个平台上的官方歌词:
|
||||
|
||||
- 在我们目前处理到的所有 YRC 歌词中,所有的圆括号均为全角 `()`,无论是中文还是其他语言
|
||||
- 在我们目前处理到的所有 QRC 歌词中,圆括号未做转译或替换
|
||||
|
||||
因此根据现有样本推测:
|
||||
|
||||
- YRC 实际上可能不允许在歌词文本中使用半角圆括号,若有需要应使用全角圆括号。本库也遵守这一原则,在导出为 YRC 时若歌词文本中存在半角圆括号,会自动替换为全角。
|
||||
- QRC 在匹配时间戳时会略过非时间戳的圆括号,将其作为歌词文本处理。
|
||||
|
||||
YRC、QRC 也不支持为歌词添加翻译与音译。一般的做法是伴随提供 LRC 格式的翻译或音译。
|
||||
|
||||
另外,本库在解析 YRC 与 QRC 歌词时,若整行被圆括号括起,则会将该行视为背景行并去除括号。
|
||||
|
||||
**本库提供 [`parseYrc`](/reference/lyric/functionparseyrc) 和 [`stringifyYrc`](/reference/lyric/functionstringifyyrc) 方法用于正反序列化 YRC 歌词;提供 [`parseQrc`](/reference/lyric/functionparseqrc) 和 [`stringifyQrc`](/reference/lyric/functionstringifyqrc) 方法用于正反序列化 QRC 歌词。**
|
||||
|
||||
特别地,QQ 音乐在分发 QRC 歌词时使用了一种加密格式。此格式为包含了 QRC 文本的 XML,经一种类 DES 算法加密后使用 base64 编码而成。**本库提供 [`decryptQrcHex`](/reference/lyric/functiondecryptqrchex) 函数用于解密这样的 base64 串为 XML 文本,提供 [`encryptQrcHex`](/reference/lyric/functionencryptqrchex) 函数用于将明文 XML 加密为 base64 串。**
|
||||
|
||||
## Lyricify 系列格式
|
||||
|
||||
[Lyricify](https://lyricify.app) 是一款优秀的逐字歌词展示软件。其定义了 Lyricify Lines、Lyricify Syllable 和 Lyricify 快速导出 三种私有格式。
|
||||
|
||||
其中:
|
||||
|
||||
- Lyricify Lines 为逐行歌词,扩展名 `.lyl`
|
||||
- Lyricify Syllable 为逐字歌词,扩展名 `.lys`,支持设置背景与对唱行
|
||||
|
||||
这两种格式 [官方提供了文档说明](https://github.com/WXRIW/Lyricify-App/blob/main/docs/Lyricify%204/Lyrics.md#lyricify-lines-%E6%A0%BC%E5%BC%8F%E8%A7%84%E8%8C%83)。
|
||||
|
||||
**本库提供 [`parseLyl`](/reference/lyric/functionparselyl) 和 [`stringifyLyl`](/reference/lyric/functionstringifylyl) 方法用于正反序列化 Lyricify Lines 歌词;提供 [`parseLys`](/reference/lyric/functionparselys) 和 [`stringifyLys`](/reference/lyric/functionstringifylys) 方法用于正反序列化 Lyricify Syllable 歌词。**
|
||||
|
||||
Lyricify 快速导出格式扩展名为 `.lqe`(**L**yricify **Q**uick **E**xport 的缩写)。官方没有提供文档说明,但其内容比较易懂。以下说明基于对相关软件实际导出文件的分析,并非官方规范,也可能与未来版本存在差异。
|
||||
|
||||
```
|
||||
[Lyricify Quick Export]
|
||||
[version:1.0]
|
||||
|
||||
[lyrics: format@Lyricify Syllable]
|
||||
[4]A(365,350)ni(715,307)ro(1022,312)dham (1334,419)a(3203,337)nut(3540,350)pā(3890,306)dam(4196,382)
|
||||
[5]Qua(6206,312)e(6518,350)so (6868,370)do(7238,338)mi(7576,373)ne (7949,413)nos (8362,736)ple(9098,306)ne (9404,338)sal(9742,237)va (9979,244)tam(10223,350)
|
||||
[4]A(6164,1436)nuc(7600,744)che(8344,724)dam (9068,399)a(9467,293)śā(9760,240)śva(10000,225)tam(10225,893)
|
||||
[4]Hi (11851,812)ma(12663,344)ma (13007,369)ja(13376,263)gad (13639,237)i(13876,212)daṃ(14088,800)
|
||||
|
||||
|
||||
[translation: format@LRC]
|
||||
[00:00.365]不生亦不灭
|
||||
[00:06.206]主人啊,求你像这般,赐给我们完全的救恩
|
||||
[00:06.164]不常亦不断
|
||||
[00:11.851]此世已为我之世
|
||||
|
||||
|
||||
[pronunciation: format@LRC, language@romaji]
|
||||
[00:00.365]阿难罗昙 阿耨钵昙
|
||||
[00:06.164]阿耨遮昙 阿刹缚多
|
||||
[00:11.851]天摩诃满 荼揭谛檀
|
||||
```
|
||||
|
||||
头部定义了文件版本信息。此后内容由几部分构成。`[lyrics: format@Lyricify Syllable]` 后携带 Lyricify Syllable 格式的逐字歌词,此后 `[translation: format@LRC]` 后携带 LRC 格式的翻译歌词、`[pronunciation: format@LRC, language@romaji]` 后携带 LRC 格式的罗马字音译歌词。
|
||||
|
||||
需要注意的是,翻译或音译区块只包含存在内容的行。若某一行没有翻译或音译,则该区块中不会出现对应时间戳。
|
||||
|
||||
可见 Lyricify 快速导出格式支持逐字时间、背景行、对唱行、翻译、音译。
|
||||
|
||||
此外,也可以参考 Lyricify 开发者的项目 [Lyricify Lyrics Helper](https://github.com/WXRIW/Lyricify-Lyrics-Helper),其中有 Lyricify 系列格式的解析与生成逻辑,并以 MIT 开源。
|
||||
|
||||
**本库提供 [`parseLqe`](/reference/lyric/functionparselqe) 和 [`stringifyLqe`](/reference/lyric/functionstringifylqe) 方法用于正反序列化 Lyricify 快速导出。**
|
||||
|
||||
## 总结表格
|
||||
|
||||
| 格式 | 扩展名 | 逐行时间 | 逐字时间 | 原生翻译音译 | 原生背景对唱 |
|
||||
| ----------------- | --------------- | :------: | :------: | :----------: | :----------: |
|
||||
| TTML | `.ttml` | ✓ | ✓ | ✓ | ✓ |
|
||||
| LRC | `.lrc` | ✓ | ✕ | ✕ | ✕ |
|
||||
| LRC A2 扩展 | `.lrc`, `.alrc` | ✓ | ✓ | ✕ | ✕ |
|
||||
| 网易云逐字 | `.yrc` | ✓ | ✓ | ✕ | ✕ |
|
||||
| QQ 音乐逐字 | `.qrc` | ✓ | ✓ | ✕ | ✕ |
|
||||
| Lyricify Lines | `.lyl` | ✓ | ✕ | ✕ | ✕ |
|
||||
| Lyricify Syllable | `.lys` | ✓ | ✓ | ✕ | ✓ |
|
||||
| Lyricify 快速导出 | `.lqe` | ✓ | ✓ | ✓ | ✓ |
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user