Compare commits
46 Commits
Author | SHA1 | Date | |
---|---|---|---|
93e13f9668 | |||
57fd2f626b | |||
3ee8361669 | |||
fb36719aad | |||
d0c216fdaf | |||
4b108d81fb | |||
a0c75b14c3 | |||
5d72fa6b64 | |||
![]() |
7947b46af5 | ||
![]() |
26bb6b5bdf | ||
![]() |
8b6afd408e | ||
![]() |
d19b230809 | ||
![]() |
e8b7a6e3d2 | ||
![]() |
d562c1c9a5 | ||
![]() |
0f01c05830 | ||
![]() |
d248a1a084 | ||
![]() |
8b541818c9 | ||
![]() |
9e388b66ce | ||
![]() |
3dd9829a43 | ||
![]() |
dd675ad710 | ||
![]() |
ecaa7c3e1c | ||
![]() |
9074f800d6 | ||
![]() |
c8f8a21b94 | ||
![]() |
504c8772da | ||
![]() |
bb27df5c8d | ||
![]() |
bf7f2f508b | ||
![]() |
2341d13721 | ||
![]() |
e4677f4117 | ||
![]() |
253742f681 | ||
![]() |
cc97de5353 | ||
![]() |
b4eab1b1ff | ||
![]() |
a23a52f5e7 | ||
![]() |
ab8c32ffb8 | ||
![]() |
74d783b5d5 | ||
![]() |
d0f562452a | ||
![]() |
b2f3bb9602 | ||
![]() |
e4026cf022 | ||
![]() |
6b65fda69d | ||
![]() |
63c65e8b7d | ||
![]() |
aef964aa35 | ||
![]() |
73ad9036c2 | ||
![]() |
7f66b54ab2 | ||
![]() |
900a60c656 | ||
![]() |
3633c6b356 | ||
![]() |
5cd9abdcc0 | ||
![]() |
e1aa87ece0 |
4
.github/workflows/main.yml
vendored
4
.github/workflows/main.yml
vendored
@ -1,4 +0,0 @@
|
|||||||
- name: Upload coverage reports to Codecov
|
|
||||||
uses: codecov/codecov-action@v4.0.1
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -9,3 +9,4 @@ node_modules
|
|||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
data/pending
|
data/pending
|
||||||
|
.vscode
|
5
.idea/.gitignore
vendored
Normal file
5
.idea/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
12
.idea/AquaVox.iml
Normal file
12
.idea/AquaVox.iml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="WEB_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
48
.idea/codeStyles/Project.xml
Normal file
48
.idea/codeStyles/Project.xml
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<code_scheme name="Project" version="173">
|
||||||
|
<HTMLCodeStyleSettings>
|
||||||
|
<option name="HTML_SPACE_INSIDE_EMPTY_TAG" value="true" />
|
||||||
|
</HTMLCodeStyleSettings>
|
||||||
|
<JSCodeStyleSettings version="0">
|
||||||
|
<option name="FORCE_SEMICOLON_STYLE" value="true" />
|
||||||
|
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
|
||||||
|
<option name="USE_DOUBLE_QUOTES" value="false" />
|
||||||
|
<option name="FORCE_QUOTE_STYlE" value="true" />
|
||||||
|
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
|
||||||
|
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
|
||||||
|
<option name="SPACES_WITHIN_IMPORTS" value="true" />
|
||||||
|
</JSCodeStyleSettings>
|
||||||
|
<TypeScriptCodeStyleSettings version="0">
|
||||||
|
<option name="FORCE_SEMICOLON_STYLE" value="true" />
|
||||||
|
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
|
||||||
|
<option name="USE_DOUBLE_QUOTES" value="false" />
|
||||||
|
<option name="FORCE_QUOTE_STYlE" value="true" />
|
||||||
|
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
|
||||||
|
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
|
||||||
|
<option name="SPACES_WITHIN_IMPORTS" value="true" />
|
||||||
|
</TypeScriptCodeStyleSettings>
|
||||||
|
<VueCodeStyleSettings>
|
||||||
|
<option name="INTERPOLATION_NEW_LINE_AFTER_START_DELIMITER" value="false" />
|
||||||
|
<option name="INTERPOLATION_NEW_LINE_BEFORE_END_DELIMITER" value="false" />
|
||||||
|
</VueCodeStyleSettings>
|
||||||
|
<codeStyleSettings language="HTML">
|
||||||
|
<option name="SOFT_MARGINS" value="120" />
|
||||||
|
<indentOptions>
|
||||||
|
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
||||||
|
</indentOptions>
|
||||||
|
</codeStyleSettings>
|
||||||
|
<codeStyleSettings language="JavaScript">
|
||||||
|
<option name="SOFT_MARGINS" value="120" />
|
||||||
|
</codeStyleSettings>
|
||||||
|
<codeStyleSettings language="TypeScript">
|
||||||
|
<option name="SOFT_MARGINS" value="120" />
|
||||||
|
</codeStyleSettings>
|
||||||
|
<codeStyleSettings language="Vue">
|
||||||
|
<option name="SOFT_MARGINS" value="120" />
|
||||||
|
<indentOptions>
|
||||||
|
<option name="INDENT_SIZE" value="4" />
|
||||||
|
<option name="TAB_SIZE" value="4" />
|
||||||
|
</indentOptions>
|
||||||
|
</codeStyleSettings>
|
||||||
|
</code_scheme>
|
||||||
|
</component>
|
5
.idea/codeStyles/codeStyleConfig.xml
Normal file
5
.idea/codeStyles/codeStyleConfig.xml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<state>
|
||||||
|
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||||
|
</state>
|
||||||
|
</component>
|
6
.idea/jsLibraryMappings.xml
Normal file
6
.idea/jsLibraryMappings.xml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="JavaScriptLibraryMappings">
|
||||||
|
<includedPredefinedLibrary name="Node.js Core" />
|
||||||
|
</component>
|
||||||
|
</project>
|
8
.idea/modules.xml
Normal file
8
.idea/modules.xml
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/AquaVox.iml" filepath="$PROJECT_DIR$/.idea/AquaVox.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
17
.idea/runConfigurations/Run_Dev_Server.xml
Normal file
17
.idea/runConfigurations/Run_Dev_Server.xml
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="Run Dev Server" type="ShConfigurationType" focusToolWindowBeforeRun="true">
|
||||||
|
<option name="SCRIPT_TEXT" value="bun dev" />
|
||||||
|
<option name="INDEPENDENT_SCRIPT_PATH" value="true" />
|
||||||
|
<option name="SCRIPT_PATH" value="" />
|
||||||
|
<option name="SCRIPT_OPTIONS" value="" />
|
||||||
|
<option name="INDEPENDENT_SCRIPT_WORKING_DIRECTORY" value="true" />
|
||||||
|
<option name="SCRIPT_WORKING_DIRECTORY" value="$PROJECT_DIR$" />
|
||||||
|
<option name="INDEPENDENT_INTERPRETER_PATH" value="true" />
|
||||||
|
<option name="INTERPRETER_PATH" value="/opt/homebrew/bin/nu" />
|
||||||
|
<option name="INTERPRETER_OPTIONS" value="" />
|
||||||
|
<option name="EXECUTE_IN_TERMINAL" value="true" />
|
||||||
|
<option name="EXECUTE_SCRIPT_FILE" value="false" />
|
||||||
|
<envs />
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
6
.idea/vcs.xml
Normal file
6
.idea/vcs.xml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Use the official Bun image as the base image
|
||||||
|
FROM oven/bun:latest
|
||||||
|
|
||||||
|
# Set the working directory inside the container
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy the package.json and bun.lockb files to the working directory
|
||||||
|
COPY package.json bun.lockb ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN bun install
|
||||||
|
|
||||||
|
# Copy the rest of the application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the app
|
||||||
|
RUN bun run build
|
||||||
|
|
||||||
|
# Expose the port the app runs on
|
||||||
|
EXPOSE 4173
|
||||||
|
|
||||||
|
# Command to run the application
|
||||||
|
CMD ["bun", "go"]
|
674
LICENSE
Normal file
674
LICENSE
Normal file
@ -0,0 +1,674 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you
|
||||||
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
|
or can get the source code. And you must show them these terms so they
|
||||||
|
know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
|
that there is no warranty for this free software. For both users' and
|
||||||
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
|
changed, so that their problems will not be attributed erroneously to
|
||||||
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU Affero General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the special requirements of the GNU Affero General Public License,
|
||||||
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
@ -8,7 +8,7 @@ AquaVox 是一个为中文虚拟歌手爱好者献上的产品。
|
|||||||
|
|
||||||
## 使用
|
## 使用
|
||||||
|
|
||||||
当前 AquaVox 的公开预览版位于[aquavox.a2x.pub](https://aquavox.a2x.pub)上。
|
当前 AquaVox 的公开预览版位于 [aquavox.app](https://aquavox.app) 上。
|
||||||
|
|
||||||
[](https://wakatime.com/badge/user/018f0628-909b-47e4-bcfd-0153235426d9/project/b67c03ef-ee0b-45f2-85ec-d9c60269cc55)
|
[](https://wakatime.com/badge/user/018f0628-909b-47e4-bcfd-0153235426d9/project/b67c03ef-ee0b-45f2-85ec-d9c60269cc55)
|
||||||
|
|
||||||
|
@ -43,5 +43,6 @@
|
|||||||
"views": 502678,
|
"views": 502678,
|
||||||
"publishTime": "2024-04-18 12:00:00",
|
"publishTime": "2024-04-18 12:00:00",
|
||||||
"updateTime": "2024-07-12 02:49:26",
|
"updateTime": "2024-07-12 02:49:26",
|
||||||
|
"netEaseID": 2145230796,
|
||||||
"lyric": "[ti: 天山之外]\n[ar: 洛天依]\n[al: 游四方]\n[00:00.000]\n[00:20.890] 旷野是 自由的家\n[00:29.680] 顶冰花 雪里生芽\n[00:39.950] 光的延伸 让香弥漫夕阳下\n[00:49.310] 可妈妈 天山外是哪\n[00:59.130] 我是否如她挺拔 四季更迭洁白无暇\n[01:04.070] 你有绝世风华 芦苇悠悠 粼粼之下\n[01:08.910] 不必如四季伟大\n[01:11.260] 天山不曾 规定谁 像她 随心而去吧\n[01:17.020] 高空下\n[01:18.550] 自由它该乘骏马\n[01:20.880] 绿浪滚滚中散着发\n[01:23.360] 随风起舞落下\n[01:25.430] 也算生命别样优雅\n[01:28.110] 乘风随心而去吧\n[01:30.470] 向着无边的旷野 飞吧\n[01:35.890] 去浪迹天涯\n[01:57.190] 辽阔的 不止晚霞\n[02:06.430] 又盛开 天山梦话\n[02:16.430] 光的延伸 是你和我 放不下\n[02:25.930] 记忆里 天山下的家\n[02:35.280] 我是否如她挺拔 四季更迭洁白无暇\n[02:40.140] 你有绝世风华 芦苇悠悠粼粼之下\n[02:44.870] 不必如四季伟大\n[02:47.290] 天山不曾 规定谁像她随心而去吧\n[02:53.250] 高空下\n[02:54.520] 自由它该乘骏马\n[02:56.880] 绿浪滚滚中散着发\n[02:59.380] 随风起舞落下\n[03:01.720] 也算生命别样优雅\n[03:04.080] 乘风随心而去吧\n[03:06.500] 向着无边的旷野 飞吧\n[03:11.920] 去浪迹天涯\n[03:14.580]"
|
"lyric": "[ti: 天山之外]\n[ar: 洛天依]\n[al: 游四方]\n[00:00.000]\n[00:20.890] 旷野是 自由的家\n[00:29.680] 顶冰花 雪里生芽\n[00:39.950] 光的延伸 让香弥漫夕阳下\n[00:49.310] 可妈妈 天山外是哪\n[00:59.130] 我是否如她挺拔 四季更迭洁白无暇\n[01:04.070] 你有绝世风华 芦苇悠悠 粼粼之下\n[01:08.910] 不必如四季伟大\n[01:11.260] 天山不曾 规定谁 像她 随心而去吧\n[01:17.020] 高空下\n[01:18.550] 自由它该乘骏马\n[01:20.880] 绿浪滚滚中散着发\n[01:23.360] 随风起舞落下\n[01:25.430] 也算生命别样优雅\n[01:28.110] 乘风随心而去吧\n[01:30.470] 向着无边的旷野 飞吧\n[01:35.890] 去浪迹天涯\n[01:57.190] 辽阔的 不止晚霞\n[02:06.430] 又盛开 天山梦话\n[02:16.430] 光的延伸 是你和我 放不下\n[02:25.930] 记忆里 天山下的家\n[02:35.280] 我是否如她挺拔 四季更迭洁白无暇\n[02:40.140] 你有绝世风华 芦苇悠悠粼粼之下\n[02:44.870] 不必如四季伟大\n[02:47.290] 天山不曾 规定谁像她随心而去吧\n[02:53.250] 高空下\n[02:54.520] 自由它该乘骏马\n[02:56.880] 绿浪滚滚中散着发\n[02:59.380] 随风起舞落下\n[03:01.720] 也算生命别样优雅\n[03:04.080] 乘风随心而去吧\n[03:06.500] 向着无边的旷野 飞吧\n[03:11.920] 去浪迹天涯\n[03:14.580]"
|
||||||
}
|
}
|
42
data/song/BV1Au4y1a7cN.json
Normal file
42
data/song/BV1Au4y1a7cN.json
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"id": "BV1Au4y1a7cN",
|
||||||
|
"name": "姑苏画舫录",
|
||||||
|
"url": "https://www.bilibili.com/video/BV1Au4y1a7cN",
|
||||||
|
"singer": [
|
||||||
|
"洛天依", "乐正绫"
|
||||||
|
],
|
||||||
|
"producer": "青天纤云-TsingClouds",
|
||||||
|
"tuning": [
|
||||||
|
"鬼面P"
|
||||||
|
],
|
||||||
|
"lyricist": [
|
||||||
|
"青天纤云·纳兰清婧"
|
||||||
|
],
|
||||||
|
"composer": [
|
||||||
|
"砖厂浪人"
|
||||||
|
],
|
||||||
|
"arranger": [
|
||||||
|
"砖厂浪人"
|
||||||
|
],
|
||||||
|
"mixing": [
|
||||||
|
"莫逆iMoNe"
|
||||||
|
],
|
||||||
|
"pv": [
|
||||||
|
"結月林", "墨雨清泉"
|
||||||
|
],
|
||||||
|
"illustrator": [
|
||||||
|
"小夏Elin", "绫也舒芙蕾", "景育"
|
||||||
|
],
|
||||||
|
"harmony": [
|
||||||
|
"Vsinger ACE全员"
|
||||||
|
],
|
||||||
|
"instruments": [],
|
||||||
|
"songURL": ["https://assets.aquavox.app/public/BV1Au4y1a7cN.flac"],
|
||||||
|
"coverURL": ["https://assets.aquavox.app/public/BV1Au4y1a7cN.jpg"],
|
||||||
|
"duration": 320.00,
|
||||||
|
"views": 100002,
|
||||||
|
"publishTime": "2023-11-04 18:00:00",
|
||||||
|
"updateTime": "2024-07-16 15:14:30",
|
||||||
|
"netEaseID": 2094903334,
|
||||||
|
"lyric": ""
|
||||||
|
}
|
File diff suppressed because one or more lines are too long
@ -85,5 +85,6 @@
|
|||||||
"views": 2733456,
|
"views": 2733456,
|
||||||
"publishTime": "2024-02-09 20:31:23",
|
"publishTime": "2024-02-09 20:31:23",
|
||||||
"updateTime": "2024-07-12 00:23:22",
|
"updateTime": "2024-07-12 00:23:22",
|
||||||
|
"netEaseID": 2124462309,
|
||||||
"lyric": "[ti: 大哉乾元]\n[ar: 洛天依]\n[al: 2024哔哩哔哩拜年纪]\n[tool: 歌词滚动姬 https://lrc-maker.github.io]\n[length: 04:19.536]\n[00:05.630] 经起幽明 悟处通玄\n[00:09.680] 首窥龙堑 见岳见渊\n[00:13.640] 道不善宣 义不善绻\n[00:17.270] 源流万世 大哉乾元!\n[00:21.837]\n[00:36.390] 不曾闻日月争辉\n[00:38.170] 坎离复往 立下恒规\n[00:40.330] 照东南 有坤徇乾 承西北\n[00:43.680] 天道自昆仑巍巍\n[00:45.590] 翻起华夏巽震艮兑\n[00:47.810] 万象予万灵得见 两相盈岁\n[00:51.210] 潜龙长生应紫微\n[00:53.000] 惟向四方五气寻遂\n[00:55.200] 燧火旁八卦百草揆经纬\n[00:58.220] 正位 纪天下一归\n[01:00.370] 不消祈天退水\n[01:02.590] 初难知一念一决生龙髓\n[01:05.930] 百家注龙慧 千军起龙威 砥淬\n[01:10.260] 妙笔生文穗 罡风抚长麾\n[01:13.030] 始见龙形汇 以天田冲腾直向九陲\n[01:20.490] 龙震于疆 万里宁壤 天地皆可往\n[01:24.140] 龙秀于象 引仙来访 诗蜀道河江\n[01:27.940] 龙明于章 执笔成鉴 映五千煌煌\n[01:31.820] 不独九州五岳 帝王将相见苍茫\n[01:35.340] 龙泽于汤 唤水筑乡 单舟见京杭\n[01:39.040] 龙健于常 百音同讲 道一种炎黄\n[01:42.740] 龙景于康 见之庙堂 亦显于曲坊\n[01:46.580] 不劳此间祥云瑞兽频频诰春长!\n[01:57.280] 干支移晷又几回\n[01:59.500] 揽尽天骄襄助一醉\n[02:01.790] 虽万言竟道不尽无字碑\n[02:04.680] 临渊乾乾 君子催\n[02:06.600] 或跃 无咎相随\n[02:09.130] 同为龙 却与往昔不连讳\n[02:12.390] 且待飞龙归 簸却沧溟水 如沸\n[02:16.700] 有龙掸风雷 见首不见尾\n[02:19.540] 苏苏万物蜕 证元亨利贞变易轮回\n[02:27.080] 龙华于旸 红旗漫卷 新水濯旧隍\n[02:30.740] 龙泰于霜 烽烟消长 更赳赳昂昂\n[02:34.430] 龙温于壮 留待潺潺 驰涌成泱泱\n[02:38.260] 好教流光紫极 鹊渡银潢伴流觞\n[02:41.660] 龙韧于刚 龙吟激荡 云止聆佳响\n[02:45.490] 龙德于昌 喜见船马 纵横间丰仓\n[02:49.150] 龙眷于邦 情习众广 仍化为一方\n[02:52.930] 其妙错综复杂 不孤兵车付一匡!\n[02:56.602]\n[03:40.980] 此去向东 瀚海游龙 滔滔几万重\n[03:44.620] 一跃破空 乘风逐虹 猎猎青云中\n[03:48.250] 天音入梦 扶摇上穹 矫矫游星宫\n[03:52.080] 犹念神州谷稻耕耘收藏守时无?\n[03:55.670] 一息一动 似异似同 无之以为用\n[03:59.240] 天地辰龙 龙生九种 但两爻合共\n[04:03.130] 假逢童蒙 欲解懵懂 何处有真龙\n[04:06.830] 只道「大哉乾元」秩秩幽幽必然中\n[04:10.350] 也道「大哉乾元」切切实实一言中!\n[04:14.693]"
|
"lyric": "[ti: 大哉乾元]\n[ar: 洛天依]\n[al: 2024哔哩哔哩拜年纪]\n[tool: 歌词滚动姬 https://lrc-maker.github.io]\n[length: 04:19.536]\n[00:05.630] 经起幽明 悟处通玄\n[00:09.680] 首窥龙堑 见岳见渊\n[00:13.640] 道不善宣 义不善绻\n[00:17.270] 源流万世 大哉乾元!\n[00:21.837]\n[00:36.390] 不曾闻日月争辉\n[00:38.170] 坎离复往 立下恒规\n[00:40.330] 照东南 有坤徇乾 承西北\n[00:43.680] 天道自昆仑巍巍\n[00:45.590] 翻起华夏巽震艮兑\n[00:47.810] 万象予万灵得见 两相盈岁\n[00:51.210] 潜龙长生应紫微\n[00:53.000] 惟向四方五气寻遂\n[00:55.200] 燧火旁八卦百草揆经纬\n[00:58.220] 正位 纪天下一归\n[01:00.370] 不消祈天退水\n[01:02.590] 初难知一念一决生龙髓\n[01:05.930] 百家注龙慧 千军起龙威 砥淬\n[01:10.260] 妙笔生文穗 罡风抚长麾\n[01:13.030] 始见龙形汇 以天田冲腾直向九陲\n[01:20.490] 龙震于疆 万里宁壤 天地皆可往\n[01:24.140] 龙秀于象 引仙来访 诗蜀道河江\n[01:27.940] 龙明于章 执笔成鉴 映五千煌煌\n[01:31.820] 不独九州五岳 帝王将相见苍茫\n[01:35.340] 龙泽于汤 唤水筑乡 单舟见京杭\n[01:39.040] 龙健于常 百音同讲 道一种炎黄\n[01:42.740] 龙景于康 见之庙堂 亦显于曲坊\n[01:46.580] 不劳此间祥云瑞兽频频诰春长!\n[01:57.280] 干支移晷又几回\n[01:59.500] 揽尽天骄襄助一醉\n[02:01.790] 虽万言竟道不尽无字碑\n[02:04.680] 临渊乾乾 君子催\n[02:06.600] 或跃 无咎相随\n[02:09.130] 同为龙 却与往昔不连讳\n[02:12.390] 且待飞龙归 簸却沧溟水 如沸\n[02:16.700] 有龙掸风雷 见首不见尾\n[02:19.540] 苏苏万物蜕 证元亨利贞变易轮回\n[02:27.080] 龙华于旸 红旗漫卷 新水濯旧隍\n[02:30.740] 龙泰于霜 烽烟消长 更赳赳昂昂\n[02:34.430] 龙温于壮 留待潺潺 驰涌成泱泱\n[02:38.260] 好教流光紫极 鹊渡银潢伴流觞\n[02:41.660] 龙韧于刚 龙吟激荡 云止聆佳响\n[02:45.490] 龙德于昌 喜见船马 纵横间丰仓\n[02:49.150] 龙眷于邦 情习众广 仍化为一方\n[02:52.930] 其妙错综复杂 不孤兵车付一匡!\n[02:56.602]\n[03:40.980] 此去向东 瀚海游龙 滔滔几万重\n[03:44.620] 一跃破空 乘风逐虹 猎猎青云中\n[03:48.250] 天音入梦 扶摇上穹 矫矫游星宫\n[03:52.080] 犹念神州谷稻耕耘收藏守时无?\n[03:55.670] 一息一动 似异似同 无之以为用\n[03:59.240] 天地辰龙 龙生九种 但两爻合共\n[04:03.130] 假逢童蒙 欲解懵懂 何处有真龙\n[04:06.830] 只道「大哉乾元」秩秩幽幽必然中\n[04:10.350] 也道「大哉乾元」切切实实一言中!\n[04:14.693]"
|
||||||
}
|
}
|
@ -37,5 +37,6 @@
|
|||||||
"views": 3021,
|
"views": 3021,
|
||||||
"publishTime": "2024-07-12 00:00:00",
|
"publishTime": "2024-07-12 00:00:00",
|
||||||
"updateTime": "2024-07-12 01:49:30",
|
"updateTime": "2024-07-12 01:49:30",
|
||||||
|
"netEaseID": 2606318125,
|
||||||
"lyric": "[ti: 唱给锦依卫]\n[ar: 洛天依]\n[tool: 歌词滚动姬 https://lrc-maker.github.io]\n[00:00.000]\n[00:11.400] 推开窗,又是一缕曙光轻拂你脸庞\n[00:17.100] 风轻扬,浮现初见时你青涩模样\n[00:22.830] 齐开唱,歌声驱散你眼神中的迷茫\n[00:28.770] 飘过秋叶与春花,流转几轮冬与夏\n[00:34.410] 乘着馨风去远航,有你长夜也明亮\n[00:40.230] 携手向梦的彼方,追寻心中那道光\n[00:46.800] 一起唱下去吧\n[00:50.880] 用你我梦想\n[00:53.610] 希望光芒为你点亮\n[00:58.230] 续写未来篇章\n[01:02.400] 纵岁月漫长\n[01:05.220] 会永远传唱\n[01:09.797]\n[01:32.370] 指尖淌,自由弦音随着风儿去流浪\n[01:38.040] 回头望,路上不时也会跌跌撞撞\n[01:44.010] 别彷徨,遗忘那过去的灰心和失望\n[01:49.740] 有你相伴的过往,每篇故事都珍藏\n[01:55.470] 载着梦想去远航,你我并肩去闯荡\n[02:01.260] 执笔最美的诗行,烟火为你而绽放\n[02:07.830] 一起唱下去吧\n[02:11.640] 星澜里荡漾\n[02:14.700] 留下最绚烂的光芒\n[02:19.350] 唱你我的梦想\n[02:23.370] 在舞台中央\n[02:26.190] 交织中回响\n[02:31.020] 一起唱下去吧\n[02:34.980] 用你我梦想\n[02:37.800] 将希望光芒再点亮\n[02:42.450] 续写未来篇章\n[02:46.500] 纵岁月沧桑\n[02:49.410] 永远在你身旁\n[02:53.364]"
|
"lyric": "[ti: 唱给锦依卫]\n[ar: 洛天依]\n[tool: 歌词滚动姬 https://lrc-maker.github.io]\n[00:00.000]\n[00:11.400] 推开窗,又是一缕曙光轻拂你脸庞\n[00:17.100] 风轻扬,浮现初见时你青涩模样\n[00:22.830] 齐开唱,歌声驱散你眼神中的迷茫\n[00:28.770] 飘过秋叶与春花,流转几轮冬与夏\n[00:34.410] 乘着馨风去远航,有你长夜也明亮\n[00:40.230] 携手向梦的彼方,追寻心中那道光\n[00:46.800] 一起唱下去吧\n[00:50.880] 用你我梦想\n[00:53.610] 希望光芒为你点亮\n[00:58.230] 续写未来篇章\n[01:02.400] 纵岁月漫长\n[01:05.220] 会永远传唱\n[01:09.797]\n[01:32.370] 指尖淌,自由弦音随着风儿去流浪\n[01:38.040] 回头望,路上不时也会跌跌撞撞\n[01:44.010] 别彷徨,遗忘那过去的灰心和失望\n[01:49.740] 有你相伴的过往,每篇故事都珍藏\n[01:55.470] 载着梦想去远航,你我并肩去闯荡\n[02:01.260] 执笔最美的诗行,烟火为你而绽放\n[02:07.830] 一起唱下去吧\n[02:11.640] 星澜里荡漾\n[02:14.700] 留下最绚烂的光芒\n[02:19.350] 唱你我的梦想\n[02:23.370] 在舞台中央\n[02:26.190] 交织中回响\n[02:31.020] 一起唱下去吧\n[02:34.980] 用你我梦想\n[02:37.800] 将希望光芒再点亮\n[02:42.450] 续写未来篇章\n[02:46.500] 纵岁月沧桑\n[02:49.410] 永远在你身旁\n[02:53.364]"
|
||||||
}
|
}
|
15
docker-compose.yml
Normal file
15
docker-compose.yml
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
aquavox:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "4173:4173"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
command: ["bun", "go"]
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
node_modules:
|
17
package.json
17
package.json
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aquavox",
|
"name": "aquavox",
|
||||||
"version": "1.10.1",
|
"version": "2.3.2",
|
||||||
"private": false,
|
"private": false,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
@ -11,7 +11,7 @@
|
|||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"lint": "prettier --check . && eslint .",
|
"lint": "prettier --check . && eslint .",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"go": "PORT=4173 node build"
|
"go": "PORT=4173 bun ./build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify/svelte": "^4.0.2",
|
"@iconify/svelte": "^4.0.2",
|
||||||
@ -34,20 +34,33 @@
|
|||||||
"tailwindcss": "^3.4.3",
|
"tailwindcss": "^3.4.3",
|
||||||
"typescript": "^5.4.5",
|
"typescript": "^5.4.5",
|
||||||
"vite": "^5.2.11",
|
"vite": "^5.2.11",
|
||||||
|
"vite-plugin-wasm": "^3.3.0",
|
||||||
"vitest": "^1.6.0"
|
"vitest": "^1.6.0"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@applemusic-like-lyrics/core": "^0.1.3",
|
||||||
|
"@applemusic-like-lyrics/lyric": "^0.2.2",
|
||||||
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
|
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
|
||||||
|
"@pixi/app": "^7.4.2",
|
||||||
|
"@pixi/core": "^7.4.2",
|
||||||
|
"@pixi/display": "^7.4.2",
|
||||||
|
"@pixi/filter-blur": "^7.4.2",
|
||||||
|
"@pixi/filter-bulge-pinch": "^5.1.1",
|
||||||
|
"@pixi/filter-color-matrix": "^7.4.2",
|
||||||
|
"@pixi/sprite": "^7.4.2",
|
||||||
"@types/bun": "^1.1.6",
|
"@types/bun": "^1.1.6",
|
||||||
"bezier-easing": "^2.1.0",
|
"bezier-easing": "^2.1.0",
|
||||||
"jotai": "^2.8.0",
|
"jotai": "^2.8.0",
|
||||||
"jotai-svelte": "^0.0.2",
|
"jotai-svelte": "^0.0.2",
|
||||||
|
"jss": "^10.10.0",
|
||||||
|
"jss-preset-default": "^10.10.0",
|
||||||
"localforage": "^1.10.0",
|
"localforage": "^1.10.0",
|
||||||
"lrc-parser-ts": "^1.0.3",
|
"lrc-parser-ts": "^1.0.3",
|
||||||
"music-metadata-browser": "^2.5.10",
|
"music-metadata-browser": "^2.5.10",
|
||||||
"node-cache": "^5.1.2",
|
"node-cache": "^5.1.2",
|
||||||
"rollup-plugin-node-polyfills": "^0.2.1",
|
"rollup-plugin-node-polyfills": "^0.2.1",
|
||||||
|
"typescript-parsec": "^0.3.4",
|
||||||
"uuid": "^9.0.1"
|
"uuid": "^9.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -29,3 +29,10 @@ h2 {
|
|||||||
.text-shadow-none {
|
.text-shadow-none {
|
||||||
text-shadow: none;
|
text-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body,
|
||||||
|
html {
|
||||||
|
position: fixed;
|
||||||
|
overflow: hidden;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
@ -5,14 +5,14 @@
|
|||||||
<!-- <link rel="icon" href="%sveltekit.assets%/favicon.png" /> -->
|
<!-- <link rel="icon" href="%sveltekit.assets%/favicon.png" /> -->
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
<div style="display: contents">%sveltekit.body%</div>
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
</body>
|
</body>
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
</html>
|
</html>
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import formatDuration from '$lib/formatDuration';
|
import formatDuration from '$lib/utils/formatDuration.js';
|
||||||
|
import { safePath } from '$lib/server/safePath';
|
||||||
|
|
||||||
describe('formatDuration test', () => {
|
describe('formatDuration test', () => {
|
||||||
it('converts 120 seconds to "2:00"', () => {
|
it('converts 120 seconds to "2:00"', () => {
|
||||||
@ -22,3 +23,25 @@ describe('formatDuration test', () => {
|
|||||||
expect(formatDuration(3601)).toBe('1:00:01');
|
expect(formatDuration(3601)).toBe('1:00:01');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('safePath test', () => {
|
||||||
|
const base = "data/subdir";
|
||||||
|
it('rejects empty string', () => {
|
||||||
|
expect(safePath('', { base })).toBe(null);
|
||||||
|
});
|
||||||
|
it('accepts a regular path', () => {
|
||||||
|
expect(safePath('subsubdir/file.txt', { base })).toBe('data/subdir/subsubdir/file.txt');
|
||||||
|
});
|
||||||
|
it('rejects path with ..', () => {
|
||||||
|
expect(safePath('../file.txt', { base })).toBe(null);
|
||||||
|
});
|
||||||
|
it('accepts path with .', () => {
|
||||||
|
expect(safePath('./file.txt', { base })).toBe('data/subdir/file.txt');
|
||||||
|
});
|
||||||
|
it('accepts path traversal within base', () => {
|
||||||
|
expect(safePath('subsubdir/../file.txt', { base })).toBe('data/subdir/file.txt');
|
||||||
|
});
|
||||||
|
it('rejects path with subdir if noSubDir is true', () => {
|
||||||
|
expect(safePath('subsubdir/file.txt', { base, noSubDir: true })).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
@ -2,7 +2,7 @@
|
|||||||
import { processImage } from '$lib/graphics';
|
import { processImage } from '$lib/graphics';
|
||||||
import blobToImageData from '$lib/graphics/blob2imageData';
|
import blobToImageData from '$lib/graphics/blob2imageData';
|
||||||
import imageDataToBlob from '$lib/graphics/imageData2blob';
|
import imageDataToBlob from '$lib/graphics/imageData2blob';
|
||||||
import localforage from '$lib/storage';
|
import localforage from '$lib/utils/storage';
|
||||||
export let coverId: string;
|
export let coverId: string;
|
||||||
let canvas: HTMLCanvasElement;
|
let canvas: HTMLCanvasElement;
|
||||||
|
|
||||||
|
@ -1,3 +0,0 @@
|
|||||||
<div>
|
|
||||||
|
|
||||||
</div>
|
|
62
src/lib/components/database/songCard.svelte
Normal file
62
src/lib/components/database/songCard.svelte
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import formatDuration from "$lib/utils/formatDuration";
|
||||||
|
import { formatViews } from "$lib/utils/formatViews";
|
||||||
|
|
||||||
|
export let songData: MusicMetadata;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="relative w-56 h-56 bg-zinc-300 dark:bg-zinc-600 rounded-lg overflow-hidden
|
||||||
|
shadow-lg cursor-pointer justify-self-center"
|
||||||
|
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute top-0 left-0 w-full h-full duration-100
|
||||||
|
z-10 opacity-0 hover:opacity-100 bg-[rgba(0,0,0,0.15)]"
|
||||||
|
>
|
||||||
|
<a href={songData.url} class="absolute z-10 h-full w-full">
|
||||||
|
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
class="brightness-125 absolute top-2 right-2 w-8 h-8 rounded-full
|
||||||
|
bg-[rgba(49,49,49,0.7)] backdrop-blur-lg z-30 hover:bg-red-500"
|
||||||
|
href={`/database/edit/${songData.id}`}
|
||||||
|
>
|
||||||
|
<img class="relative w-4 h-4 top-2 left-2 scale-90" src="/edit.svg" alt="编辑" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<img src={songData.coverURL[0]} class="w-56 h-56" alt="" />
|
||||||
|
<div
|
||||||
|
class="absolute bottom-0 w-full h-28 backdrop-blur-xl"
|
||||||
|
style="mask-image: linear-gradient(to top, black 50%, transparent);"
|
||||||
|
>
|
||||||
|
<div class="absolute bottom-0 w-full h-16 pl-2">
|
||||||
|
<span
|
||||||
|
class="font-semibold text-2xl text-white"
|
||||||
|
style="text-shadow: 0px 0px 4px rgba(65, 65, 65, .6);">{songData.name}</span
|
||||||
|
>
|
||||||
|
<br />
|
||||||
|
<span
|
||||||
|
class="relative inline-block whitespace-nowrap text-white w-28
|
||||||
|
overflow-hidden text-ellipsis"
|
||||||
|
style="text-shadow: 0px 0px 4px rgba(65, 65, 65, .6);"
|
||||||
|
>
|
||||||
|
{songData.producer}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
class="absolute right-2 bottom-2 text-right text-white"
|
||||||
|
style="text-shadow: 0px 0px 4px rgba(65, 65, 65, .6);"
|
||||||
|
>
|
||||||
|
{#if songData.duration}
|
||||||
|
<span>{formatDuration(songData.duration)}</span>
|
||||||
|
{/if}
|
||||||
|
<br />
|
||||||
|
{#if songData.views}
|
||||||
|
<span>{formatViews(songData.views)}播放</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -1,13 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useAtom } from 'jotai-svelte';
|
import { useAtom } from 'jotai-svelte';
|
||||||
import { fileListState, finalFileListState } from '$lib/state/fileList.state';
|
import { fileListState, finalFileListState } from '$lib/state/fileList.state';
|
||||||
import toHumanSize from '$lib/humanSize';
|
import toHumanSize from '$lib/utils/humanSize';
|
||||||
import formatText from '$lib/formatText';
|
import formatText from '$lib/utils/formatText';
|
||||||
import extractFileName from '$lib/extractFileName';
|
import extractFileName from '$lib/utils/extractFileName';
|
||||||
import getAudioMeta from '$lib/getAudioCoverURL';
|
import getAudioMeta from '$lib/utils/getAudioCoverURL';
|
||||||
import convertCoverData from '$lib/convertCoverData';
|
import convertCoverData from '$lib/utils/convertCoverData';
|
||||||
import type { IAudioMetadata } from 'music-metadata-browser';
|
import type { IAudioMetadata } from 'music-metadata-browser';
|
||||||
import formatDuration from '$lib/formatDuration';
|
import formatDuration from '$lib/utils/formatDuration';
|
||||||
const items = useAtom(fileListState);
|
const items = useAtom(fileListState);
|
||||||
const finalItems = useAtom(finalFileListState);
|
const finalItems = useAtom(finalFileListState);
|
||||||
let displayItems: any[] = [];
|
let displayItems: any[] = [];
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import formatDuration from '$lib/formatDuration';
|
import formatDuration from '$lib/utils/formatDuration';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import userAdjustingProgress from '$lib/state/userAdjustingProgress';
|
import userAdjustingProgress from '$lib/state/userAdjustingProgress';
|
||||||
import progressBarSlideValue from '$lib/state/progressBarSlideValue';
|
import progressBarSlideValue from '$lib/state/progressBarSlideValue';
|
||||||
|
import truncate from '$lib/utils/truncate';
|
||||||
|
|
||||||
export let name: string;
|
export let name: string;
|
||||||
export let singer: string = '';
|
export let singer: string = '';
|
||||||
@ -23,7 +24,6 @@
|
|||||||
let isInfoTopOverflowing = false;
|
let isInfoTopOverflowing = false;
|
||||||
let songInfoTopContainer: HTMLDivElement;
|
let songInfoTopContainer: HTMLDivElement;
|
||||||
let songInfoTopContent: HTMLSpanElement;
|
let songInfoTopContent: HTMLSpanElement;
|
||||||
let lastTouchProgress: number;
|
|
||||||
let userAdjustingVolume = false;
|
let userAdjustingVolume = false;
|
||||||
|
|
||||||
const mql = window.matchMedia('(max-width: 1280px)');
|
const mql = window.matchMedia('(max-width: 1280px)');
|
||||||
@ -35,7 +35,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function volumeBarChangeTouch(e: TouchEvent) {
|
function volumeBarChangeTouch(e: TouchEvent) {
|
||||||
const value = turncate(
|
const value = truncate(
|
||||||
e.touches[0].clientX - volumeBar.getBoundingClientRect().x,
|
e.touches[0].clientX - volumeBar.getBoundingClientRect().x,
|
||||||
0,
|
0,
|
||||||
volumeBar.getBoundingClientRect().width
|
volumeBar.getBoundingClientRect().width
|
||||||
@ -49,8 +49,8 @@
|
|||||||
progressBarSlideValue.set((e.offsetX / progressBar.getBoundingClientRect().width) * duration);
|
progressBarSlideValue.set((e.offsetX / progressBar.getBoundingClientRect().width) * duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
function turncate(value: number, min: number, max: number) {
|
function progressBarMouseUp(offsetX: number) {
|
||||||
return Math.min(Math.max(value, min), max);
|
adjustDisplayProgress(offsetX / progressBar.getBoundingClientRect().width);
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@ -85,7 +85,7 @@
|
|||||||
>
|
>
|
||||||
{#if !showInfoTop}
|
{#if !showInfoTop}
|
||||||
<div class="song-info">
|
<div class="song-info">
|
||||||
<div class="song-info-top {isInfoTopOverflowing ? 'animate' : ''}" bind:this={songInfoTopContainer}>
|
<div class="song-info-regular {isInfoTopOverflowing ? 'animate' : ''}" bind:this={songInfoTopContainer}>
|
||||||
<span
|
<span
|
||||||
class="song-name text-shadow {isInfoTopOverflowing ? 'animate' : ''}"
|
class="song-name text-shadow {isInfoTopOverflowing ? 'animate' : ''}"
|
||||||
bind:this={songInfoTopContent}>{name}</span
|
bind:this={songInfoTopContent}>{name}</span
|
||||||
@ -100,9 +100,13 @@
|
|||||||
{formatDuration(progress)}
|
{formatDuration(progress)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="progress-bar shadow-md"
|
aria-valuemax={duration}
|
||||||
on:click={(e) => progressBarOnClick(e)}
|
aria-valuemin="0"
|
||||||
|
aria-valuenow={progress}
|
||||||
bind:this={progressBar}
|
bind:this={progressBar}
|
||||||
|
class="progress-bar shadow-md"
|
||||||
|
on:keydown
|
||||||
|
on:keyup
|
||||||
on:mousedown={() => {
|
on:mousedown={() => {
|
||||||
userAdjustingProgress.set(true);
|
userAdjustingProgress.set(true);
|
||||||
}}
|
}}
|
||||||
@ -111,40 +115,18 @@
|
|||||||
adjustDisplayProgress(e.offsetX / progressBar.getBoundingClientRect().width);
|
adjustDisplayProgress(e.offsetX / progressBar.getBoundingClientRect().width);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
on:touchstart={(e) => {
|
on:mouseup={(e) => {
|
||||||
if (e.cancelable) {
|
const offsetX = e.offsetX;
|
||||||
e.preventDefault();
|
progressBarOnClick(e);
|
||||||
}
|
// Q: why it needs delay?
|
||||||
userAdjustingProgress.set(true);
|
// A: I do not know.
|
||||||
}}
|
setTimeout(()=> {
|
||||||
on:touchmove={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
userAdjustingProgress.set(true);
|
|
||||||
if ($userAdjustingProgress) {
|
|
||||||
lastTouchProgress =
|
|
||||||
turncate(
|
|
||||||
e.touches[0].clientX - progressBar.getBoundingClientRect().x,
|
|
||||||
0,
|
|
||||||
progressBar.getBoundingClientRect().width
|
|
||||||
) / progressBar.getBoundingClientRect().width;
|
|
||||||
adjustDisplayProgress(lastTouchProgress);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
on:touchend={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
userAdjustingProgress.set(false);
|
|
||||||
adjustProgress(lastTouchProgress);
|
|
||||||
}}
|
|
||||||
on:mouseup={() => {
|
|
||||||
userAdjustingProgress.set(false);
|
userAdjustingProgress.set(false);
|
||||||
|
progressBarMouseUp(offsetX);
|
||||||
|
}, 50);
|
||||||
}}
|
}}
|
||||||
role="slider"
|
role="slider"
|
||||||
aria-valuemin="0"
|
|
||||||
aria-valuemax={duration}
|
|
||||||
aria-valuenow={progress}
|
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
on:keydown
|
|
||||||
on:keyup
|
|
||||||
>
|
>
|
||||||
<div class="bar" style={`width: ${(progress / (duration + 0.001)) * 100}%;`}></div>
|
<div class="bar" style={`width: ${(progress / (duration + 0.001)) * 100}%;`}></div>
|
||||||
</div>
|
</div>
|
||||||
@ -152,26 +134,49 @@
|
|||||||
<div class="time-indicator text-shadow-md time-total">{formatDuration(duration)}</div>
|
<div class="time-indicator text-shadow-md time-total">{formatDuration(duration)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="controls top-32 flex h-16 overflow-hidden items-center justify-center">
|
<div class="controls top-32 flex h-16 overflow-hidden items-center justify-center">
|
||||||
<button style="filter: drop-shadow( 0 4px 8px rgba(0, 0, 0, 0.12) );" class="control-btn previous">
|
<button class="control-btn previous" style="filter: drop-shadow( 0 4px 8px rgba(0, 0, 0, 0.12) );">
|
||||||
<img class="control-img switch-song-img" src="/previous.svg" alt="上一曲" />
|
<img alt="上一曲" class="control-img switch-song-img" src="/previous.svg" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
style="filter: drop-shadow( 0 4px 8px rgba(0, 0, 0, 0.12) );"
|
|
||||||
class="control-btn play-btn"
|
class="control-btn play-btn"
|
||||||
on:click={() => clickPlay()}
|
on:click={(e) => clickPlay()}
|
||||||
|
on:focus={null}
|
||||||
|
on:mouseleave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = '';
|
||||||
|
}}
|
||||||
|
on:mouseover={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'rgba(0, 0, 0, 0.2)';
|
||||||
|
}}
|
||||||
|
on:touchend={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.currentTarget.style.backgroundColor = '';
|
||||||
|
e.currentTarget.style.scale = '1';
|
||||||
|
clickPlay();
|
||||||
|
}}
|
||||||
|
on:touchstart={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.currentTarget.style.backgroundColor = 'rgba(0, 0, 0, 0.2)';
|
||||||
|
e.currentTarget.style.scale = '0.8';
|
||||||
|
}}
|
||||||
|
style="filter: drop-shadow( 0 4px 8px rgba(0, 0, 0, 0.12) );"
|
||||||
>
|
>
|
||||||
<img class="control-img" src={paused ? '/play.svg' : '/pause.svg'} alt="暂停或播放" />
|
<img alt={paused ? '播放' : '暂停'} class="control-img" src={paused ? '/play.svg' : '/pause.svg'} />
|
||||||
</button>
|
</button>
|
||||||
<button style="filter: drop-shadow( 0 4px 8px rgba(0, 0, 0, 0.12) );" class="control-btn next">
|
<button class="control-btn next" style="filter: drop-shadow( 0 4px 8px rgba(0, 0, 0, 0.12) );">
|
||||||
<img class="control-img switch-song-img" src="/next.svg" alt="下一曲" />
|
<img alt="下一曲" class="control-img switch-song-img" src="/next.svg" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative top-52 h-6 flex">
|
<div class="relative top-52 h-6 flex">
|
||||||
<img class="scale-75" src="/volumeDown.svg" alt="最小音量" />
|
<img alt="最小音量" class="scale-75" src="/volumeDown.svg" />
|
||||||
<div
|
<div
|
||||||
|
aria-valuemax="1"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuenow={volume}
|
||||||
|
bind:this={volumeBar}
|
||||||
class="progress-bar shadow-md !top-1/2 !translate-y-[-50%]"
|
class="progress-bar shadow-md !top-1/2 !translate-y-[-50%]"
|
||||||
on:click={(e) => volumeBarOnChange(e)}
|
on:click={(e) => volumeBarOnChange(e)}
|
||||||
bind:this={volumeBar}
|
on:keydown
|
||||||
|
on:keyup
|
||||||
on:mousedown={() => {
|
on:mousedown={() => {
|
||||||
userAdjustingVolume = true;
|
userAdjustingVolume = true;
|
||||||
}}
|
}}
|
||||||
@ -180,11 +185,12 @@
|
|||||||
volumeBarOnChange(e);
|
volumeBarOnChange(e);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
on:touchstart={(e) => {
|
on:mouseup={() => {
|
||||||
if (e.cancelable) {
|
userAdjustingVolume = false;
|
||||||
|
}}
|
||||||
|
on:touchend={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
userAdjustingVolume = false;
|
||||||
userAdjustingVolume = true;
|
|
||||||
}}
|
}}
|
||||||
on:touchmove={(e) => {
|
on:touchmove={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -193,24 +199,18 @@
|
|||||||
volumeBarChangeTouch(e);
|
volumeBarChangeTouch(e);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
on:touchend={(e) => {
|
on:touchstart={(e) => {
|
||||||
|
if (e.cancelable) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
userAdjustingVolume = false;
|
}
|
||||||
}}
|
userAdjustingVolume = true;
|
||||||
on:mouseup={() => {
|
|
||||||
userAdjustingVolume = false;
|
|
||||||
}}
|
}}
|
||||||
role="slider"
|
role="slider"
|
||||||
aria-valuemin="0"
|
|
||||||
aria-valuemax="1"
|
|
||||||
aria-valuenow={volume}
|
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
on:keydown
|
|
||||||
on:keyup
|
|
||||||
>
|
>
|
||||||
<div class="bar" style={`width: ${volume * 100}%;`}></div>
|
<div class="bar" style={`width: ${volume * 100}%;`}></div>
|
||||||
</div>
|
</div>
|
||||||
<img class="scale-75" src="/volumeUp.svg" alt="最大音量" />
|
<img alt="最大音量" class="scale-75" src="/volumeUp.svg" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -221,6 +221,7 @@
|
|||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translate(-50%, 0);
|
transform: translate(-50%, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-btn {
|
.control-btn {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
height: 3.7rem;
|
height: 3.7rem;
|
||||||
@ -228,11 +229,10 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin: 0 0.5rem;
|
margin: 0 0.5rem;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
transition: 0.1s;
|
transition: 0.45s;
|
||||||
}
|
scale: 1;
|
||||||
.control-btn:hover {
|
|
||||||
background-color: rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-img {
|
.control-img {
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
@ -240,6 +240,7 @@
|
|||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.switch-song-img {
|
.switch-song-img {
|
||||||
width: auto !important;
|
width: auto !important;
|
||||||
height: 1.7rem !important;
|
height: 1.7rem !important;
|
||||||
@ -256,13 +257,15 @@
|
|||||||
font-family: sans-serif;
|
font-family: sans-serif;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.song-info-top {
|
|
||||||
|
.song-info-regular {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
height: 2.375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.song-info-top.animate {
|
.song-info-regular.animate {
|
||||||
mask-image: linear-gradient(
|
mask-image: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
rgba(0, 0, 0, 0) 0%,
|
rgba(0, 0, 0, 0) 0%,
|
||||||
@ -283,12 +286,15 @@
|
|||||||
height: 2.5rem;
|
height: 2.5rem;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.song-name.animate {
|
.song-name.animate {
|
||||||
animation: scroll 10s linear infinite;
|
animation: scroll 10s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.song-name::-webkit-scrollbar {
|
.song-name::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes scroll {
|
@keyframes scroll {
|
||||||
0% {
|
0% {
|
||||||
transform: translateX(100%);
|
transform: translateX(100%);
|
||||||
@ -300,10 +306,12 @@
|
|||||||
transform: translateX(-100%);
|
transform: translateX(-100%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.song-author {
|
.song-author {
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
color: rgba(255, 255, 255, 0.8);
|
color: rgba(255, 255, 255, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress {
|
.progress {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -311,6 +319,7 @@
|
|||||||
transform: translate(-50%, 0);
|
transform: translate(-50%, 0);
|
||||||
height: 2.4rem;
|
height: 2.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar {
|
.progress-bar {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
@ -325,9 +334,11 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: 0.3s;
|
transition: 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar:hover {
|
.progress-bar:hover {
|
||||||
height: 0.7rem;
|
height: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bar {
|
.bar {
|
||||||
background-color: white;
|
background-color: white;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@ -351,10 +362,18 @@
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
top: 0.2rem;
|
top: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-current {
|
.time-current {
|
||||||
left: 0;
|
left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-total {
|
.time-total {
|
||||||
right: 0;
|
right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.control-btn {
|
||||||
|
transition: 0.1s
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -1,287 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import userAdjustingProgress from '$lib/state/userAdjustingProgress';
|
|
||||||
import createLyricsSearcher from '$lib/lyrics/lyricSearcher';
|
|
||||||
import progressBarRaw from '$lib/state/progressBarRaw';
|
|
||||||
import type { LrcJsonData } from 'lrc-parser-ts';
|
|
||||||
import progressBarSlideValue from '$lib/state/progressBarSlideValue';
|
|
||||||
export let lyrics: string[];
|
|
||||||
export let originalLyrics: LrcJsonData;
|
|
||||||
export let progress: number;
|
|
||||||
|
|
||||||
let getLyricIndex: Function;
|
|
||||||
const debugMode = false;
|
|
||||||
let currentLyricIndex = -1;
|
|
||||||
let currentPositionIndex = -1;
|
|
||||||
let currentAnimationIndex = -1;
|
|
||||||
let lyricsContainer: HTMLDivElement;
|
|
||||||
let nextUpdate = -1;
|
|
||||||
let lastAdjustProgress = 0;
|
|
||||||
let localProgress = 0;
|
|
||||||
|
|
||||||
let refs: HTMLParagraphElement[] = [];
|
|
||||||
let _refs: any[] = [];
|
|
||||||
$: refs = _refs.filter(Boolean);
|
|
||||||
$: getLyricIndex = createLyricsSearcher(originalLyrics);
|
|
||||||
|
|
||||||
function getClass(lyricIndex: number, progress: number) {
|
|
||||||
if (!originalLyrics.scripts) return 'previous-lyric';
|
|
||||||
if (currentLyricIndex === lyricIndex) return 'current-lyric';
|
|
||||||
else if (progress > originalLyrics.scripts[lyricIndex].end) return 'after-lyric';
|
|
||||||
else return 'previous-lyric';
|
|
||||||
}
|
|
||||||
|
|
||||||
$: {
|
|
||||||
if (lyricsContainer && originalLyrics && originalLyrics.scripts) {
|
|
||||||
const scripts = originalLyrics.scripts;
|
|
||||||
currentPositionIndex = getLyricIndex(progress);
|
|
||||||
const cl = scripts[currentPositionIndex];
|
|
||||||
if (cl.start <= progress && progress <= cl.end) {
|
|
||||||
currentLyricIndex = currentPositionIndex;
|
|
||||||
nextUpdate = cl.end;
|
|
||||||
} else {
|
|
||||||
currentLyricIndex = -1;
|
|
||||||
nextUpdate = cl.start;
|
|
||||||
}
|
|
||||||
if ($userAdjustingProgress === false) {
|
|
||||||
for (let i = 0; i < scripts.length; i++) {
|
|
||||||
const offset = Math.abs(i - currentPositionIndex);
|
|
||||||
const blurRadius = Math.min(offset * 1.5, 16);
|
|
||||||
if (refs[i]) {
|
|
||||||
refs[i].style.filter = `blur(${blurRadius}px)`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sleep(ms: number) {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function a(h: number) {
|
|
||||||
let pos = currentPositionIndex + 2;
|
|
||||||
for (let i = currentPositionIndex + 2; i < refs.length; i++) {
|
|
||||||
const lyric = refs[i];
|
|
||||||
lyric.style.transition =
|
|
||||||
'transform .6s cubic-bezier(.28,.01,.29,.99), filter 200ms ease, opacity 200ms ease, font-size 200ms ease, scale 250ms ease';
|
|
||||||
lyric.style.transform = `translateY(${-h}px)`;
|
|
||||||
pos = i;
|
|
||||||
await sleep(75);
|
|
||||||
if (refs[i - 2].getBoundingClientRect().top > lyricsContainer.getBoundingClientRect().height) break;
|
|
||||||
}
|
|
||||||
// 特判,鬼知道为什么
|
|
||||||
if (refs.length - pos < 3) {
|
|
||||||
for (let i = pos; i < refs.length; i++) {
|
|
||||||
const lyric = refs[i];
|
|
||||||
lyric.style.transition =
|
|
||||||
'transform .6s cubic-bezier(.28,.01,.29,.99), filter 200ms ease, opacity 200ms ease, font-size 200ms ease, scale 250ms ease';
|
|
||||||
lyric.style.transform = `translateY(${-h}px)`;
|
|
||||||
pos = i;
|
|
||||||
await sleep(75);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (let i = pos; i < refs.length; i++) {
|
|
||||||
refs[i].style.transition =
|
|
||||||
'transform 0s, filter 200ms ease, opacity 200ms ease, font-size 200ms ease, scale 250ms ease';
|
|
||||||
const h = refs[i].getBoundingClientRect().height;
|
|
||||||
refs[i].style.transform = `translateY(${-h}px)`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await sleep(650);
|
|
||||||
for (let i = 0; i < refs.length; i++) {
|
|
||||||
refs[i].style.transition =
|
|
||||||
'transform 0s, filter 200ms ease, opacity 200ms ease, font-size 200ms ease, scale 250ms ease';
|
|
||||||
}
|
|
||||||
for (let i = 0; i < refs.length; i++) {
|
|
||||||
refs[i].style.transform = `translateY(0px)`;
|
|
||||||
}
|
|
||||||
lyricsContainer.scrollTop += h;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function b(currentLyric: HTMLParagraphElement) {
|
|
||||||
if (!originalLyrics || !originalLyrics.scripts || !lyricsContainer) return;
|
|
||||||
lyricsContainer.scrollTop += currentLyric.getBoundingClientRect().top - 144;
|
|
||||||
}
|
|
||||||
|
|
||||||
userAdjustingProgress.subscribe((v) => {
|
|
||||||
if (!originalLyrics) return;
|
|
||||||
const scripts = originalLyrics.scripts;
|
|
||||||
if (!scripts) return;
|
|
||||||
if (v) {
|
|
||||||
for (let i = 0; i < scripts.length; i++) {
|
|
||||||
refs[i].style.filter = `blur(0px)`;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (let i = 0; i < scripts.length; i++) {
|
|
||||||
const offset = Math.abs(i - currentPositionIndex);
|
|
||||||
const blurRadius = Math.min(offset * 1.5, 16);
|
|
||||||
if (refs[i]) {
|
|
||||||
refs[i].style.filter = `blur(${blurRadius}px)`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// progressBarRaw is used to detect progress changes at system-level (not in AquaVox)
|
|
||||||
progressBarRaw.subscribe((progress: number) => {
|
|
||||||
if ($userAdjustingProgress === false && getLyricIndex) {
|
|
||||||
if (Math.abs(localProgress - progress) > 0.6) {
|
|
||||||
const currentLyric = refs[getLyricIndex(progress)];
|
|
||||||
b(currentLyric);
|
|
||||||
}
|
|
||||||
localProgress = progress;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// progressBarSlideValue is to detect progress bar sliding event
|
|
||||||
progressBarSlideValue.subscribe((_) => {
|
|
||||||
if ($userAdjustingProgress === false && getLyricIndex) {
|
|
||||||
lastAdjustProgress = currentPositionIndex;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$: {
|
|
||||||
if ($userAdjustingProgress) {
|
|
||||||
nextUpdate = progress;
|
|
||||||
} else {
|
|
||||||
if (nextUpdate - progress < 0.05) {
|
|
||||||
if (
|
|
||||||
currentPositionIndex >= 0 &&
|
|
||||||
currentPositionIndex !== currentAnimationIndex &&
|
|
||||||
currentPositionIndex !== lastAdjustProgress
|
|
||||||
) {
|
|
||||||
const offsetHeight =
|
|
||||||
refs[currentPositionIndex].getBoundingClientRect().height +
|
|
||||||
refs[currentPositionIndex].getBoundingClientRect().top -
|
|
||||||
144;
|
|
||||||
const currentLyric = refs[currentPositionIndex];
|
|
||||||
currentLyric.style.transition =
|
|
||||||
'transform .6s cubic-bezier(.28,.01,.29,.99), filter 200ms ease, opacity 200ms ease, font-size 200ms ease, scale 250ms ease';
|
|
||||||
currentLyric.style.transform = `translateY(${-offsetHeight}px)`;
|
|
||||||
|
|
||||||
for (let i = currentPositionIndex - 1; i >= 0; i--) {
|
|
||||||
refs[i].style.transition =
|
|
||||||
'transform .6s cubic-bezier(.28,.01,.29,.99), filter 200ms ease, opacity 200ms ease, font-size 200ms ease, scale 250ms ease';
|
|
||||||
const h = refs[i].getBoundingClientRect().height;
|
|
||||||
refs[i].style.transform = `translateY(${-offsetHeight}px)`;
|
|
||||||
}
|
|
||||||
if (currentPositionIndex + 1 < refs.length) {
|
|
||||||
const nextLyric = refs[currentPositionIndex + 1];
|
|
||||||
nextLyric.style.transition =
|
|
||||||
'transform .6s cubic-bezier(.28,.01,.29,.99), filter 200ms ease, opacity 200ms ease, font-size 200ms ease, scale 250ms ease';
|
|
||||||
nextLyric.style.transform = `translateY(${-offsetHeight}px)`;
|
|
||||||
a(offsetHeight);
|
|
||||||
}
|
|
||||||
currentAnimationIndex = currentPositionIndex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if lyrics && originalLyrics}
|
|
||||||
<div
|
|
||||||
class="absolute top-[6.5rem] md:top-36 xl:top-0 w-screen xl:w-[52vw] px-6 md:px-12 lg:px-[7.5rem] xl:left-[45vw] xl:px-[3vw] h-[calc(100vh-17rem)] xl:h-screen font-sans
|
|
||||||
text-left no-scrollbar overflow-y-auto z-[1] pt-16 lyrics"
|
|
||||||
bind:this={lyricsContainer}
|
|
||||||
>
|
|
||||||
{#if debugMode}
|
|
||||||
<p class="fixed top-6 right-20 font-mono text-sm">
|
|
||||||
LyricIndex: {currentLyricIndex} PositionIndex: {currentPositionIndex} AnimationIndex:{currentAnimationIndex}
|
|
||||||
NextUpdate: {nextUpdate}
|
|
||||||
Progress: {progress.toFixed(2)}
|
|
||||||
lastAdjustProgress: {lastAdjustProgress}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
{#each lyrics as lyric, i}
|
|
||||||
<p bind:this={_refs[i]} class={`${getClass(i, progress)} text-shadow-lg`}>
|
|
||||||
{#if debugMode}
|
|
||||||
<span class="text-lg absolute">{i}</span>
|
|
||||||
{/if}
|
|
||||||
{lyric}
|
|
||||||
</p>
|
|
||||||
{/each}
|
|
||||||
<div class="relative w-full h-[50rem]"></div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.lyrics {
|
|
||||||
mask-image: linear-gradient(
|
|
||||||
rgba(0, 0, 0, 0) 0%,
|
|
||||||
rgba(0, 0, 0, 1) 2rem,
|
|
||||||
rgba(0, 0, 0, 1) calc(100% - 5rem),
|
|
||||||
rgba(0, 0, 0, 0) 100%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
.no-scrollbar {
|
|
||||||
scrollbar-width: 10px;
|
|
||||||
}
|
|
||||||
.no-scrollbar::-webkit-scrollbar {
|
|
||||||
width: 10px;
|
|
||||||
}
|
|
||||||
.current-lyric {
|
|
||||||
position: relative;
|
|
||||||
color: white;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 2.1rem;
|
|
||||||
line-height: 2.7rem;
|
|
||||||
margin: 1rem 0rem;
|
|
||||||
scale: 1.02 1;
|
|
||||||
top: 1rem;
|
|
||||||
}
|
|
||||||
.previous-lyric {
|
|
||||||
position: relative;
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 2.1rem;
|
|
||||||
line-height: 2.7rem;
|
|
||||||
margin: 1rem 0rem;
|
|
||||||
top: 1rem;
|
|
||||||
}
|
|
||||||
.after-lyric {
|
|
||||||
position: relative;
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 2.1rem;
|
|
||||||
line-height: 2.7rem;
|
|
||||||
margin: 1rem 0rem;
|
|
||||||
top: 1rem;
|
|
||||||
}
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.current-lyric {
|
|
||||||
font-size: 3rem;
|
|
||||||
line-height: 4rem;
|
|
||||||
margin: 2.4rem 0rem;
|
|
||||||
}
|
|
||||||
.after-lyric {
|
|
||||||
font-size: 3rem;
|
|
||||||
line-height: 3.3rem;
|
|
||||||
margin: 2.4rem 0rem;
|
|
||||||
}
|
|
||||||
.previous-lyric {
|
|
||||||
font-size: 3em;
|
|
||||||
line-height: 3.3rem;
|
|
||||||
margin: 2.4rem 0rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.current-lyric {
|
|
||||||
font-size: 3.5rem;
|
|
||||||
line-height: 6.5rem;
|
|
||||||
margin: 0rem 0rem;
|
|
||||||
}
|
|
||||||
.after-lyric {
|
|
||||||
font-size: 3.5rem;
|
|
||||||
line-height: 6.5rem;
|
|
||||||
margin: 0rem 0rem;
|
|
||||||
}
|
|
||||||
.previous-lyric {
|
|
||||||
font-size: 3.5rem;
|
|
||||||
line-height: 6.5rem;
|
|
||||||
margin: 0rem 0rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
174
src/lib/components/lyrics/lyricLine.svelte
Normal file
174
src/lib/components/lyrics/lyricLine.svelte
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import createSpring from '$lib/graphics/spring';
|
||||||
|
import type { ScriptItem } from '$lib/lyrics/type';
|
||||||
|
import type { LyricPos } from './type';
|
||||||
|
import type { Spring } from '$lib/graphics/spring/spring';
|
||||||
|
|
||||||
|
const viewportWidth = document.documentElement.clientWidth;
|
||||||
|
|
||||||
|
export let line: ScriptItem;
|
||||||
|
export let index: number;
|
||||||
|
export let debugMode: Boolean;
|
||||||
|
export let lyricClick: Function;
|
||||||
|
|
||||||
|
let ref: HTMLDivElement;
|
||||||
|
let clickMask: HTMLSpanElement;
|
||||||
|
|
||||||
|
let time = 0;
|
||||||
|
let positionX: number = 0;
|
||||||
|
let positionY: number = 0;
|
||||||
|
let opacity = 1;
|
||||||
|
let stopped = false;
|
||||||
|
let lastPosX: number | undefined = undefined;
|
||||||
|
let lastPosY: number | undefined = undefined;
|
||||||
|
let lastUpdateY: number | undefined = undefined;
|
||||||
|
let lastUpdateX: number | undefined = undefined;
|
||||||
|
let springY: Spring | undefined = undefined;
|
||||||
|
let springX: Spring | undefined = undefined;
|
||||||
|
let isCurrentLyric = false;
|
||||||
|
|
||||||
|
function updateY(timestamp: number) {
|
||||||
|
if (lastUpdateY === undefined) {
|
||||||
|
lastUpdateY = new Date().getTime();
|
||||||
|
}
|
||||||
|
if (springY === undefined) return;
|
||||||
|
time = (new Date().getTime() - lastUpdateY) / 1000;
|
||||||
|
springY.update(time);
|
||||||
|
positionY = springY.getCurrentPosition();
|
||||||
|
if (!springY.arrived() && !stopped) {
|
||||||
|
requestAnimationFrame(updateY);
|
||||||
|
}
|
||||||
|
lastUpdateY = new Date().getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateX(timestamp: number) {
|
||||||
|
if (lastUpdateX === undefined) {
|
||||||
|
lastUpdateX = timestamp;
|
||||||
|
}
|
||||||
|
if (springX === undefined) return;
|
||||||
|
time = (new Date().getTime() - lastUpdateX) / 1000;
|
||||||
|
springX.update(time);
|
||||||
|
positionX = springX.getCurrentPosition();
|
||||||
|
if (!springX.arrived()) {
|
||||||
|
requestAnimationFrame(updateX);
|
||||||
|
}
|
||||||
|
lastUpdateX = timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the x position of the element, **with no animation**
|
||||||
|
* @param {number} pos - X offset, in pixels
|
||||||
|
*/
|
||||||
|
export const setX = (pos: number) => {
|
||||||
|
positionX = pos;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the y position of the element, **with no animation**
|
||||||
|
* @param {number} pos - Y offset, in pixels
|
||||||
|
*/
|
||||||
|
export const setY = (pos: number) => {
|
||||||
|
positionY = pos;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setCurrent = (isCurrent: boolean) => {
|
||||||
|
isCurrentLyric = isCurrent;
|
||||||
|
opacity = isCurrent ? 1 : 0.36;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setBlur = (blur: number) => {
|
||||||
|
ref.style.filter = `blur(${blur}px)`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const update = (pos: LyricPos, delay: number = 0) => {
|
||||||
|
if (lastPosX === undefined || lastPosY === undefined) {
|
||||||
|
lastPosX = pos.x;
|
||||||
|
lastPosY = pos.y;
|
||||||
|
}
|
||||||
|
springX!.setTargetPosition(pos.x, delay);
|
||||||
|
springY!.setTargetPosition(pos.y, delay);
|
||||||
|
lastUpdateY = new Date().getTime();
|
||||||
|
lastUpdateX = new Date().getTime();
|
||||||
|
stopped = false;
|
||||||
|
requestAnimationFrame(updateY);
|
||||||
|
requestAnimationFrame(updateX);
|
||||||
|
lastPosX = pos.x;
|
||||||
|
lastPosY = pos.y;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getInfo = () => {
|
||||||
|
return {
|
||||||
|
x: positionX,
|
||||||
|
y: positionY,
|
||||||
|
isCurrent: isCurrentLyric
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const init = (pos: LyricPos) => {
|
||||||
|
lastPosX = pos.x;
|
||||||
|
lastPosY = pos.y;
|
||||||
|
positionX = pos.x;
|
||||||
|
positionY = pos.y;
|
||||||
|
springX = createSpring(pos.x, pos.x, 0.114, 0.72);
|
||||||
|
springY = createSpring(pos.y, pos.y, 0.114, 0.72);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stop = () => {
|
||||||
|
stopped = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRef = () => ref;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||||
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
|
<div
|
||||||
|
style="transform: translate3d({positionX}px, {positionY}px, 0); transition-property: opacity, text-shadow;
|
||||||
|
transition-duration: 0.36s; transition-timing-function: ease-out; opacity: {opacity};
|
||||||
|
transform-origin: center left;"
|
||||||
|
class="absolute z-50 w-full pr-12 lg:pr-16 cursor-default py-5"
|
||||||
|
bind:this={ref}
|
||||||
|
on:touchstart={() => {
|
||||||
|
clickMask.style.backgroundColor = 'rgba(255,255,255,.3)';
|
||||||
|
}}
|
||||||
|
on:touchend={() => {
|
||||||
|
clickMask.style.backgroundColor = 'transparent';
|
||||||
|
}}
|
||||||
|
on:click={() => {
|
||||||
|
lyricClick(index);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute w-[calc(100%-2.5rem)] lg:w-[calc(100%-3rem)] h-full
|
||||||
|
-translate-x-2 lg:-translate-x-5 -translate-y-5 rounded-lg duration-300 lg:hover:bg-[rgba(255,255,255,.15)]"
|
||||||
|
bind:this={clickMask}
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
{#if debugMode}
|
||||||
|
<span class="text-lg absolute -translate-y-7">
|
||||||
|
{index}: duration: {(line.end - line.start).toFixed(3)}, {line.start.toFixed(3)}~{line.end.toFixed(3)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<span
|
||||||
|
class={`text-white text-[2rem] leading-9 lg:text-5xl lg:leading-[4rem] font-semibold text-shadow-lg mr-4
|
||||||
|
${isCurrentLyric ? 'text-glow' : ''}`}
|
||||||
|
>
|
||||||
|
{line.text}
|
||||||
|
</span>
|
||||||
|
{#if line.translation}
|
||||||
|
<br />
|
||||||
|
<span class={`pl-2 relative text-xl lg:text-2xl top-2 duration-300`}>
|
||||||
|
{line.translation}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.text-glow {
|
||||||
|
text-shadow:
|
||||||
|
0 0 3px #ffffff2c,
|
||||||
|
0 0 6px #ffffff2c,
|
||||||
|
0 15px 30px rgba(0, 0, 0, 0.11),
|
||||||
|
0 5px 15px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
</style>
|
466
src/lib/components/lyrics/lyrics.svelte
Normal file
466
src/lib/components/lyrics/lyrics.svelte
Normal file
@ -0,0 +1,466 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import userAdjustingProgress from '$lib/state/userAdjustingProgress';
|
||||||
|
import createLyricsSearcher from '$lib/lyrics/lyricSearcher';
|
||||||
|
import progressBarRaw from '$lib/state/progressBarRaw';
|
||||||
|
import type { LrcJsonData } from '$lib/lyrics/type';
|
||||||
|
import nextUpdate from '$lib/state/nextUpdate';
|
||||||
|
import truncate from '$lib/utils/truncate';
|
||||||
|
|
||||||
|
// Component input properties
|
||||||
|
export let lyrics: string[];
|
||||||
|
export let originalLyrics: LrcJsonData;
|
||||||
|
export let progress: number;
|
||||||
|
export let player: HTMLAudioElement | null;
|
||||||
|
|
||||||
|
// Local state and variables
|
||||||
|
let getLyricIndex: Function;
|
||||||
|
let debugMode = false;
|
||||||
|
let showTranslation = false;
|
||||||
|
if (localStorage.getItem('debugMode') == null) {
|
||||||
|
localStorage.setItem('debugMode', 'false');
|
||||||
|
} else {
|
||||||
|
debugMode = localStorage.getItem('debugMode')!.toLowerCase() === 'true';
|
||||||
|
}
|
||||||
|
if (localStorage.getItem('showTranslation') == null) {
|
||||||
|
localStorage.setItem('showTranslation', 'false');
|
||||||
|
} else {
|
||||||
|
showTranslation = localStorage.getItem('showTranslation')!.toLowerCase() === 'true';
|
||||||
|
}
|
||||||
|
let currentLyricIndex = -1;
|
||||||
|
let currentPositionIndex = -1;
|
||||||
|
let currentAnimationIndex = -1;
|
||||||
|
let lyricsContainer: HTMLDivElement | null;
|
||||||
|
let localProgress = 0;
|
||||||
|
let lastScroll = 0;
|
||||||
|
let scrolling = false;
|
||||||
|
let scriptScrolling = false;
|
||||||
|
|
||||||
|
let currentLyricTopMargin = 208;
|
||||||
|
|
||||||
|
// References to lyric elements
|
||||||
|
let refs: HTMLParagraphElement[] = [];
|
||||||
|
let _refs: any[] = [];
|
||||||
|
$: refs = _refs.filter(Boolean);
|
||||||
|
$: getLyricIndex = createLyricsSearcher(originalLyrics);
|
||||||
|
|
||||||
|
// handle KeyDown event
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.altKey && e.shiftKey && (e.metaKey || e.key === 'OS') && e.key === 'Enter') {
|
||||||
|
debugMode = !debugMode;
|
||||||
|
localStorage.setItem('debugMode', debugMode ? 'true' : 'false');
|
||||||
|
} else if (e.key === 't') {
|
||||||
|
showTranslation = !showTranslation;
|
||||||
|
localStorage.setItem('showTranslation', showTranslation ? 'true' : 'false');
|
||||||
|
setTimeout(() => {
|
||||||
|
scrollToLyric(refs[currentPositionIndex]);
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// using for debug mode
|
||||||
|
function extractTranslateValue(s: string): string | null {
|
||||||
|
const regex = /translateY\((-?\d*px)\)/;
|
||||||
|
let arr = regex.exec(s);
|
||||||
|
return arr == null ? null : arr[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get CSS class for a lyric based on its index and progress
|
||||||
|
function getClass(lyricIndex: number, progress: number) {
|
||||||
|
if (!originalLyrics.scripts) return 'previous-lyric';
|
||||||
|
if (currentLyricIndex === lyricIndex) return 'current-lyric';
|
||||||
|
else if (progress > originalLyrics.scripts[lyricIndex].end) return 'after-lyric';
|
||||||
|
else return 'previous-lyric';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to move the lyrics up smoothly
|
||||||
|
async function moveToNextLine(h: number) {
|
||||||
|
console.debug(new Date().getTime(), 'moveToNextLine', h);
|
||||||
|
// the line that's going to process (like a pointer)
|
||||||
|
// by default, it's "the next line" after the lift
|
||||||
|
let processingLineIndex = currentPositionIndex + 2;
|
||||||
|
|
||||||
|
// modify translateY of all lines in viewport one by one to lift them up
|
||||||
|
for (let i = processingLineIndex; i < refs.length; i++) {
|
||||||
|
const lyric = refs[i];
|
||||||
|
lyric.style.transition = `transform .5s cubic-bezier(.16,.02,.38,.98), filter 200ms ease, opacity 200ms ease,
|
||||||
|
font-size 200ms ease, scale 250ms ease`;
|
||||||
|
lyric.style.transform = `translateY(${-h}px)`;
|
||||||
|
processingLineIndex = i;
|
||||||
|
await sleep(75);
|
||||||
|
const twoLinesAhead = refs[i - 2];
|
||||||
|
if (
|
||||||
|
lyricsContainer &&
|
||||||
|
twoLinesAhead.getBoundingClientRect().top > lyricsContainer.getBoundingClientRect().height
|
||||||
|
)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refs.length - processingLineIndex < 3) {
|
||||||
|
for (let i = processingLineIndex; i < refs.length; i++) {
|
||||||
|
const lyric = refs[i];
|
||||||
|
lyric.style.transition =
|
||||||
|
'transform .5s cubic-bezier(.16,.02,.38,.98), filter 200ms ease, opacity 200ms ease, font-size 200ms ease, scale 250ms ease';
|
||||||
|
lyric.style.transform = `translateY(${-h}px)`;
|
||||||
|
processingLineIndex = i;
|
||||||
|
await sleep(75);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let i = processingLineIndex; i < refs.length; i++) {
|
||||||
|
refs[i].style.transition =
|
||||||
|
'transform 0s, filter 200ms ease, opacity 200ms ease, font-size 200ms ease, scale 250ms ease';
|
||||||
|
const height = refs[i].getBoundingClientRect().height;
|
||||||
|
refs[i].style.transform = `translateY(${-height}px)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wait until the animation end
|
||||||
|
await sleep(650);
|
||||||
|
|
||||||
|
// clear the transition to let the following style changes could be done without animation
|
||||||
|
for (let i = 0; i < refs.length; i++) {
|
||||||
|
refs[i].style.transition =
|
||||||
|
'transform 0s, filter 200ms ease, opacity 200ms ease, font-size 200ms ease, scale 250ms ease';
|
||||||
|
}
|
||||||
|
// reset the translateY, and immediately scroll down to provide visual stability
|
||||||
|
for (let i = 0; i < refs.length; i++) {
|
||||||
|
refs[i].style.transform = `translateY(0px)`;
|
||||||
|
}
|
||||||
|
scriptScrolling = true;
|
||||||
|
if (lyricsContainer !== null) {
|
||||||
|
lyricsContainer.scrollTop += h;
|
||||||
|
}
|
||||||
|
await sleep(500);
|
||||||
|
scriptScrolling = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll the lyrics container to the given lyric
|
||||||
|
async function scrollToLyric(currentLyric: HTMLParagraphElement) {
|
||||||
|
if (!originalLyrics || !originalLyrics.scripts || !lyricsContainer) return;
|
||||||
|
scriptScrolling = true;
|
||||||
|
lyricsContainer.scrollTop += currentLyric.getBoundingClientRect().top - currentLyricTopMargin;
|
||||||
|
for (let i = 0; i < refs.length; i++) {
|
||||||
|
refs[i].style.transform = 'translateY(0px)';
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
scriptScrolling = false;
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle scroll events in the lyrics container
|
||||||
|
function scrollHandler() {
|
||||||
|
scrolling = !scriptScrolling;
|
||||||
|
if (scrolling && originalLyrics.scripts) {
|
||||||
|
lastScroll = new Date().getTime();
|
||||||
|
for (let i = 0; i < originalLyrics.scripts.length; i++) {
|
||||||
|
if (refs[i]) {
|
||||||
|
refs[i].style.filter = 'blur(0px)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
if (new Date().getTime() - lastScroll > 5000) {
|
||||||
|
scrolling = false;
|
||||||
|
}
|
||||||
|
}, 5500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility function to create a sleep/delay
|
||||||
|
function sleep(ms: number) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll to corresponding lyric while adjusting progress
|
||||||
|
$: {
|
||||||
|
if ($userAdjustingProgress == true) {
|
||||||
|
const currentLyric = refs[getLyricIndex(progress)];
|
||||||
|
scrollToLyric(currentLyric);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the current lyric and apply blur effect based on the progress
|
||||||
|
// worked in real-time.
|
||||||
|
$: {
|
||||||
|
(() => {
|
||||||
|
if (!lyricsContainer || !originalLyrics.scripts) return;
|
||||||
|
|
||||||
|
const scripts = originalLyrics.scripts;
|
||||||
|
currentPositionIndex = getLyricIndex(progress);
|
||||||
|
const cl = scripts[currentPositionIndex];
|
||||||
|
|
||||||
|
if (cl.start <= progress && progress <= cl.end) {
|
||||||
|
currentLyricIndex = currentPositionIndex;
|
||||||
|
nextUpdate.set(cl.end);
|
||||||
|
} else {
|
||||||
|
currentLyricIndex = -1;
|
||||||
|
nextUpdate.set(cl.start);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentLyric = refs[currentPositionIndex];
|
||||||
|
if ($userAdjustingProgress || scrolling || currentLyric.getBoundingClientRect().top < 0) return;
|
||||||
|
|
||||||
|
for (let i = 0; i < refs.length; i++) {
|
||||||
|
const offset = Math.abs(i - currentPositionIndex);
|
||||||
|
let blurRadius = Math.min(offset * 1.25, 16);
|
||||||
|
const rect = refs[i].getBoundingClientRect();
|
||||||
|
if (rect.top + rect.height < 0 || rect.top > lyricsContainer.getBoundingClientRect().height) {
|
||||||
|
blurRadius = 0;
|
||||||
|
}
|
||||||
|
if (refs[i]) {
|
||||||
|
refs[i].style.filter = `blur(${blurRadius}px)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getViewportRange() {
|
||||||
|
let min = 0;
|
||||||
|
let max = 0;
|
||||||
|
for (let i = 0; i < refs.length; i++) {
|
||||||
|
const element = refs[i];
|
||||||
|
if (element.getBoundingClientRect().top < 0) {
|
||||||
|
min = i;
|
||||||
|
} else if (element.getBoundingClientRect().bottom < 0) {
|
||||||
|
max = i;
|
||||||
|
return [min, max];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main function that control's lyrics update during playing
|
||||||
|
// triggered by nextUpdate's update
|
||||||
|
async function lyricsUpdate() {
|
||||||
|
if (
|
||||||
|
currentPositionIndex < 0 ||
|
||||||
|
currentPositionIndex === currentAnimationIndex ||
|
||||||
|
$userAdjustingProgress === true ||
|
||||||
|
scrolling
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const currentLyric = refs[currentPositionIndex];
|
||||||
|
const currentLyricRect = currentLyric.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (originalLyrics.scripts && currentLyricRect.top < 0) return;
|
||||||
|
|
||||||
|
const offsetHeight = truncate(currentLyricRect.top - currentLyricTopMargin, 0, Infinity);
|
||||||
|
|
||||||
|
// prepare current line
|
||||||
|
currentLyric.style.transition = `transform .5s cubic-bezier(.16,.02,.38,.98), filter 200ms ease,
|
||||||
|
opacity 200ms ease, font-size 200ms ease, scale 250ms ease`;
|
||||||
|
currentLyric.style.transform = `translateY(${-offsetHeight}px)`;
|
||||||
|
|
||||||
|
// prepare past lines
|
||||||
|
for (let i = currentPositionIndex - 1; i >= 0; i--) {
|
||||||
|
refs[i].style.transition = `transform .5s cubic-bezier(.16,.02,.38,.98), filter 200ms ease,
|
||||||
|
opacity 200ms ease, font-size 200ms ease, scale 250ms ease`;
|
||||||
|
refs[i].style.transform = `translateY(${-offsetHeight}px)`;
|
||||||
|
}
|
||||||
|
await sleep(75);
|
||||||
|
if (currentPositionIndex + 1 < refs.length) {
|
||||||
|
const nextLyric = refs[currentPositionIndex + 1];
|
||||||
|
nextLyric.style.transition = `transform .5s cubic-bezier(.16,.02,.38,.98), filter 200ms ease,
|
||||||
|
opacity 200ms ease, font-size 200ms ease, scale 250ms ease`;
|
||||||
|
nextLyric.style.transform = `translateY(${-offsetHeight}px)`;
|
||||||
|
await moveToNextLine(offsetHeight);
|
||||||
|
}
|
||||||
|
currentAnimationIndex = currentPositionIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextUpdate.subscribe(lyricsUpdate);
|
||||||
|
|
||||||
|
// Process while user is adjusting progress
|
||||||
|
userAdjustingProgress.subscribe((adjusting) => {
|
||||||
|
if (!originalLyrics) return;
|
||||||
|
const scripts = originalLyrics.scripts;
|
||||||
|
if (!scripts) return;
|
||||||
|
if (adjusting) {
|
||||||
|
for (let i = 0; i < scripts.length; i++) {
|
||||||
|
refs[i].style.filter = `blur(0px)`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < scripts.length; i++) {
|
||||||
|
const offset = Math.abs(i - currentPositionIndex);
|
||||||
|
const blurRadius = Math.min(offset * 1.5, 16);
|
||||||
|
if (refs[i]) {
|
||||||
|
refs[i].style.filter = `blur(${blurRadius}px)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle progress changes at system level
|
||||||
|
progressBarRaw.subscribe((progress: number) => {
|
||||||
|
if ($userAdjustingProgress === false && getLyricIndex) {
|
||||||
|
if (Math.abs(localProgress - progress) > 0.6) {
|
||||||
|
const currentLyric = refs[getLyricIndex(progress)];
|
||||||
|
scrollToLyric(currentLyric);
|
||||||
|
}
|
||||||
|
localProgress = progress;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function lyricClick(lyricIndex: number) {
|
||||||
|
if (player === null || originalLyrics.scripts === undefined) return;
|
||||||
|
player.currentTime = originalLyrics.scripts[lyricIndex].start;
|
||||||
|
player.play();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:keydown={onKeyDown} />
|
||||||
|
|
||||||
|
<div {...$$restProps}>
|
||||||
|
{#if debugMode && lyricsContainer}
|
||||||
|
<div
|
||||||
|
class="absolute top-6 right-10 font-mono text-sm backdrop-blur-md z-20 bg-[rgba(255,255,255,.15)]
|
||||||
|
px-2 rounded-xl text-white"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
LyricIndex: {currentLyricIndex} PositionIndex: {currentPositionIndex}
|
||||||
|
AnimationIndex:{currentAnimationIndex}
|
||||||
|
NextUpdate: {$nextUpdate}
|
||||||
|
Progress: {progress.toFixed(2)}
|
||||||
|
scrollPosition: {lyricsContainer.scrollTop}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if lyrics && originalLyrics && originalLyrics.scripts}
|
||||||
|
<div
|
||||||
|
class="absolute top-[6.5rem] md:top-36 xl:top-0 w-screen xl:w-[52vw] px-6 md:px-12 lg:px-[7.5rem] xl:left-[45vw] xl:px-[3vw] h-[calc(100vh-17rem)] xl:h-screen font-sans
|
||||||
|
text-left no-scrollbar overflow-y-auto z-[1] pt-16 lyrics"
|
||||||
|
bind:this={lyricsContainer}
|
||||||
|
on:scroll={scrollHandler}
|
||||||
|
>
|
||||||
|
{#each lyrics as lyric, i}
|
||||||
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||||
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
|
<div
|
||||||
|
bind:this={_refs[i]}
|
||||||
|
class="relative h-fit text-shadow-lg"
|
||||||
|
on:click={() => {
|
||||||
|
lyricClick(i);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#if debugMode && refs[i] && refs[i].style !== undefined}
|
||||||
|
<span class="previous-lyric !text-lg !absolute !-translate-y-12"
|
||||||
|
>{i}
|
||||||
|
{originalLyrics.scripts[i].start} ~ {originalLyrics.scripts[i].end}
|
||||||
|
tY: {extractTranslateValue(refs[i].style.transform)}
|
||||||
|
top: {Math.round(refs[i].getBoundingClientRect().top)}px
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<p
|
||||||
|
class={`${getClass(i, progress)} hover:bg-[rgba(200,200,200,0.2)] pl-2 rounded-lg duration-300 cursor-pointer `}
|
||||||
|
>
|
||||||
|
{#if originalLyrics.scripts[i].singer}
|
||||||
|
<span class="singer">{originalLyrics.scripts[i].singer}</span>
|
||||||
|
{/if}
|
||||||
|
{lyric}
|
||||||
|
</p>
|
||||||
|
{#if originalLyrics.scripts[i].translation && showTranslation}
|
||||||
|
<div
|
||||||
|
class={`${getClass(i, progress)} pl-2 relative !text-xl !md:text-2xl lg:!text-3xl !top-1 duration-300 `}
|
||||||
|
>
|
||||||
|
{originalLyrics.scripts[i].translation}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<div class="relative w-full h-[50rem]"></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--suppress CssUnusedSymbol -->
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--lyric-mobile-font-size: 2rem;
|
||||||
|
--lyric-mobile-line-height: 2.4rem;
|
||||||
|
--lyric-mobile-margin: 1.5rem 0;
|
||||||
|
--lyric-mobile-font-weight: 600;
|
||||||
|
--lyric-desktop-font-size: 3rem;
|
||||||
|
--lyric-desktop-line-height: 4.5rem;
|
||||||
|
--lyric-desktop-margin: 2.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyrics {
|
||||||
|
mask-image: linear-gradient(
|
||||||
|
rgba(0, 0, 0, 0) 0%,
|
||||||
|
rgba(0, 0, 0, 1) 2rem,
|
||||||
|
rgba(0, 0, 0, 1) calc(100% - 5rem),
|
||||||
|
rgba(0, 0, 0, 0) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.singer {
|
||||||
|
position: absolute;
|
||||||
|
display: inline-block;
|
||||||
|
bottom: 50%;
|
||||||
|
transform: translateY(calc(50%)) translateX(-3rem);
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-scrollbar {
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-lyric {
|
||||||
|
position: relative;
|
||||||
|
color: white;
|
||||||
|
font-weight: var(--lyric-mobile-font-weight);
|
||||||
|
font-size: var(--lyric-mobile-font-size);
|
||||||
|
line-height: var(--lyric-mobile-line-height);
|
||||||
|
margin: var(--lyric-mobile-margin);
|
||||||
|
scale: 1.02 1;
|
||||||
|
top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previous-lyric {
|
||||||
|
position: relative;
|
||||||
|
color: rgba(255, 255, 255, 0.48);
|
||||||
|
font-weight: var(--lyric-mobile-font-weight);
|
||||||
|
font-size: var(--lyric-mobile-font-size);
|
||||||
|
line-height: var(--lyric-mobile-line-height);
|
||||||
|
margin: var(--lyric-mobile-margin);
|
||||||
|
top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.after-lyric {
|
||||||
|
position: relative;
|
||||||
|
color: rgba(255, 255, 255, 0.48);
|
||||||
|
font-weight: var(--lyric-mobile-font-weight);
|
||||||
|
font-size: var(--lyric-mobile-font-size);
|
||||||
|
line-height: var(--lyric-mobile-line-height);
|
||||||
|
margin: var(--lyric-mobile-margin);
|
||||||
|
top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.current-lyric {
|
||||||
|
font-size: var(--lyric-desktop-font-size);
|
||||||
|
line-height: var(--lyric-desktop-line-height);
|
||||||
|
margin: var(--lyric-desktop-margin);
|
||||||
|
}
|
||||||
|
|
||||||
|
.after-lyric {
|
||||||
|
font-size: var(--lyric-desktop-font-size);
|
||||||
|
line-height: var(--lyric-desktop-line-height);
|
||||||
|
margin: var(--lyric-desktop-margin);
|
||||||
|
}
|
||||||
|
|
||||||
|
.previous-lyric {
|
||||||
|
font-size: var(--lyric-desktop-font-size);
|
||||||
|
line-height: var(--lyric-desktop-line-height);
|
||||||
|
margin: var(--lyric-desktop-margin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
265
src/lib/components/lyrics/newLyrics.svelte
Normal file
265
src/lib/components/lyrics/newLyrics.svelte
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { LrcJsonData } from '$lib/lyrics/type';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import type { ScriptItem } from '$lib/lyrics/type';
|
||||||
|
import LyricLine from './lyricLine.svelte';
|
||||||
|
import createLyricsSearcher from '$lib/lyrics/lyricSearcher';
|
||||||
|
|
||||||
|
// constants
|
||||||
|
const viewportHeight = document.documentElement.clientHeight;
|
||||||
|
const viewportWidth = document.documentElement.clientWidth;
|
||||||
|
const marginY = viewportWidth > 640 ? 12 : 0 ;
|
||||||
|
const blurRatio = viewportWidth > 640 ? 1 : 1.4;
|
||||||
|
const currentLyrictTop = viewportWidth > 640 ? viewportHeight * 0.12 : viewportHeight * 0.05;
|
||||||
|
const deceleration = 0.95; // Velocity decay factor for inertia
|
||||||
|
const minVelocity = 0.1; // Minimum velocity to stop inertia
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
// Props
|
||||||
|
export let originalLyrics: LrcJsonData;
|
||||||
|
export let progress: number;
|
||||||
|
export let player: HTMLAudioElement | null;
|
||||||
|
|
||||||
|
// States
|
||||||
|
let lyricLines: ScriptItem[] = [];
|
||||||
|
let lyricExists = false;
|
||||||
|
let lyricsContainer: HTMLDivElement | null;
|
||||||
|
let debugMode = false;
|
||||||
|
let nextUpdate = 0;
|
||||||
|
let lastProgress = 0;
|
||||||
|
let showTranslation = false;
|
||||||
|
let scrollEventAdded = false;
|
||||||
|
let scrolling = false;
|
||||||
|
let scrollingTimeout: Timer;
|
||||||
|
let lastY: number; // For tracking touch movements
|
||||||
|
let lastTime: number; // For tracking time between touch moves
|
||||||
|
let velocityY = 0; // Vertical scroll velocity
|
||||||
|
let inertiaFrame: number; // For storing the requestAnimationFrame reference
|
||||||
|
|
||||||
|
// References to lyric elements
|
||||||
|
let lyricElements: HTMLDivElement[] = [];
|
||||||
|
let lyricComponents: LyricLine[] = [];
|
||||||
|
let lyricTopList: number[] = [];
|
||||||
|
|
||||||
|
let currentLyricIndex: number;
|
||||||
|
|
||||||
|
$: getLyricIndex = createLyricsSearcher(originalLyrics);
|
||||||
|
|
||||||
|
$: {
|
||||||
|
currentLyricIndex = getLyricIndex(progress);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initLyricComponents() {
|
||||||
|
initLyricTopList();
|
||||||
|
for (let i = 0; i < lyricComponents.length; i++) {
|
||||||
|
lyricComponents[i].init({ x: 0, y: lyricTopList[i] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initLyricTopList() {
|
||||||
|
let cumulativeHeight = currentLyrictTop;
|
||||||
|
for (let i = 0; i < lyricLines.length; i++) {
|
||||||
|
const c = lyricComponents[i];
|
||||||
|
lyricElements.push(c.getRef());
|
||||||
|
const e = lyricElements[i];
|
||||||
|
const elementHeight = e.getBoundingClientRect().height;
|
||||||
|
const elementTargetTop = cumulativeHeight;
|
||||||
|
cumulativeHeight += elementHeight + marginY;
|
||||||
|
lyricTopList.push(elementTargetTop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeLayout() {
|
||||||
|
if (!originalLyrics.scripts) return;
|
||||||
|
const currentLyricDuration =
|
||||||
|
originalLyrics.scripts[currentLyricIndex].end - originalLyrics.scripts[currentLyricIndex].start;
|
||||||
|
const relativeOrigin = lyricTopList[currentLyricIndex] - currentLyrictTop;
|
||||||
|
for (let i = 0; i < lyricElements.length; i++) {
|
||||||
|
const currentLyricComponent = lyricComponents[i];
|
||||||
|
let delay = 0;
|
||||||
|
if (i < currentLyricIndex) {
|
||||||
|
delay = 0;
|
||||||
|
}
|
||||||
|
else if (i == currentLyricIndex) {
|
||||||
|
delay = 0.042;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
delay = Math.min(Math.min(currentLyricDuration, 0.6), 0.067 * (i - currentLyricIndex+1.2));
|
||||||
|
}
|
||||||
|
const offset = Math.abs(i - currentLyricIndex);
|
||||||
|
let blurRadius = Math.min(offset * blurRatio, 16);
|
||||||
|
currentLyricComponent.setBlur(blurRadius);
|
||||||
|
currentLyricComponent.update({ x: 0, y: lyricTopList[i] - relativeOrigin }, delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: {
|
||||||
|
if (originalLyrics && originalLyrics.scripts) {
|
||||||
|
lyricExists = true;
|
||||||
|
lyricLines = originalLyrics.scripts!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: {
|
||||||
|
if (lyricComponents.length > 0) {
|
||||||
|
initLyricComponents();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScroll(deltaY: number) {
|
||||||
|
for (let i = 0; i < lyricElements.length; i++) {
|
||||||
|
const currentLyricComponent = lyricComponents[i];
|
||||||
|
const currentY = currentLyricComponent.getInfo().y;
|
||||||
|
currentLyricComponent.setBlur(0);
|
||||||
|
currentLyricComponent.stop();
|
||||||
|
currentLyricComponent.setY(currentY - deltaY);
|
||||||
|
}
|
||||||
|
scrolling = true;
|
||||||
|
if (scrollingTimeout) clearTimeout(scrollingTimeout);
|
||||||
|
scrollingTimeout = setTimeout(() => {
|
||||||
|
scrolling = false;
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the touch start event
|
||||||
|
function handleTouchStart(event: TouchEvent) {
|
||||||
|
lastY = event.touches[0].clientY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the touch move event
|
||||||
|
function handleTouchMove(event: TouchEvent) {
|
||||||
|
const currentY = event.touches[0].clientY;
|
||||||
|
const currentTime = Date.now();
|
||||||
|
const deltaY = lastY - currentY; // Calculate vertical swipe distance
|
||||||
|
const deltaTime = currentTime - lastTime;
|
||||||
|
|
||||||
|
// Calculate the scroll velocity (change in Y over time)
|
||||||
|
if (deltaTime > 0) {
|
||||||
|
velocityY = deltaY / deltaTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleScroll(deltaY); // Simulate the scroll event
|
||||||
|
lastY = currentY; // Update lastY for the next move event
|
||||||
|
lastTime = currentTime; // Update the lastTime for the next move event
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the touch end event
|
||||||
|
function handleTouchEnd() {
|
||||||
|
// Start inertia scrolling based on the velocity
|
||||||
|
function inertiaScroll() {
|
||||||
|
if (Math.abs(velocityY) < minVelocity) {
|
||||||
|
cancelAnimationFrame(inertiaFrame);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleScroll(velocityY * 16); // Multiply by frame time (16ms) to get smooth scroll
|
||||||
|
velocityY *= deceleration; // Apply deceleration to velocity
|
||||||
|
inertiaFrame = requestAnimationFrame(inertiaScroll); // Continue scrolling in next frame
|
||||||
|
}
|
||||||
|
inertiaScroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
$: {
|
||||||
|
if (lyricsContainer && !scrollEventAdded) {
|
||||||
|
// Wheel event for desktop
|
||||||
|
lyricsContainer.addEventListener(
|
||||||
|
'wheel',
|
||||||
|
(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const deltaY = e.deltaY;
|
||||||
|
handleScroll(deltaY);
|
||||||
|
},
|
||||||
|
{ passive: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Touch events for mobile
|
||||||
|
lyricsContainer.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||||
|
lyricsContainer.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||||
|
lyricsContainer.addEventListener('touchend', handleTouchEnd, { passive: true });
|
||||||
|
|
||||||
|
scrollEventAdded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: {
|
||||||
|
if (lyricsContainer && lyricComponents.length > 0) {
|
||||||
|
if (progress >= nextUpdate - 0.5 && !scrolling) {
|
||||||
|
console.log("computeLayout")
|
||||||
|
computeLayout();
|
||||||
|
}
|
||||||
|
if (Math.abs(lastProgress - progress) > 0.5) {
|
||||||
|
scrolling = false;
|
||||||
|
}
|
||||||
|
if (lastProgress - progress > 0) {
|
||||||
|
computeLayout();
|
||||||
|
nextUpdate = progress;
|
||||||
|
} else {
|
||||||
|
const lyricLength = originalLyrics.scripts!.length;
|
||||||
|
const currentEnd = originalLyrics.scripts![currentLyricIndex].end;
|
||||||
|
const nextStart = originalLyrics.scripts![Math.min(currentLyricIndex + 1, lyricLength - 1)].start;
|
||||||
|
if (currentEnd !== nextStart) {
|
||||||
|
nextUpdate = currentEnd;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
nextUpdate = nextStart;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastProgress = progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
$: {
|
||||||
|
for (let i = 0; i < lyricElements.length; i++) {
|
||||||
|
const isCurrent = i == currentLyricIndex;
|
||||||
|
const currentLyricComponent = lyricComponents[i];
|
||||||
|
currentLyricComponent.setCurrent(isCurrent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// Initialize
|
||||||
|
if (localStorage.getItem('debugMode') == null) {
|
||||||
|
localStorage.setItem('debugMode', 'false');
|
||||||
|
} else {
|
||||||
|
debugMode = localStorage.getItem('debugMode')!.toLowerCase() === 'true';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// handle KeyDown event
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.altKey && e.shiftKey && (e.metaKey || e.key === 'OS') && e.key === 'Enter') {
|
||||||
|
debugMode = !debugMode;
|
||||||
|
localStorage.setItem('debugMode', debugMode ? 'true' : 'false');
|
||||||
|
} else if (e.key === 't') {
|
||||||
|
showTranslation = !showTranslation;
|
||||||
|
localStorage.setItem('showTranslation', showTranslation ? 'true' : 'false');
|
||||||
|
computeLayout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function lyricClick(lyricIndex: number) {
|
||||||
|
if (player === null || originalLyrics.scripts === undefined) return;
|
||||||
|
player.currentTime = originalLyrics.scripts[lyricIndex].start;
|
||||||
|
player.play();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:keydown={onKeyDown} />
|
||||||
|
|
||||||
|
{#if debugMode}
|
||||||
|
<span class="text-lg absolute">
|
||||||
|
progress: {progress.toFixed(2)}, nextUpdate: {nextUpdate}, scrolling: {scrolling}, current: {currentLyricIndex}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if originalLyrics && originalLyrics.scripts}
|
||||||
|
<div
|
||||||
|
class="absolute top-[6.5rem] md:top-36 xl:top-0 w-screen xl:w-[52vw] px-6 md:px-12
|
||||||
|
lg:px-[7.5rem] xl:left-[46vw] xl:px-[3vw] h-[calc(100vh-17rem)] xl:h-screen font-sans
|
||||||
|
text-left no-scrollbar z-[1] pt-16 overflow-hidden"
|
||||||
|
bind:this={lyricsContainer}
|
||||||
|
>
|
||||||
|
{#each lyricLines as lyric, i}
|
||||||
|
<LyricLine line={lyric} index={i} bind:this={lyricComponents[i]} {debugMode} {lyricClick} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
4
src/lib/components/lyrics/type.d.ts
vendored
Normal file
4
src/lib/components/lyrics/type.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export interface LyricPos {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
25
src/lib/graphics/smoothScroll.ts
Normal file
25
src/lib/graphics/smoothScroll.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import BezierEasing from 'bezier-easing';
|
||||||
|
|
||||||
|
export function smoothScrollTo(element: HTMLElement, to: number, duration: number, timingFunction: Function) {
|
||||||
|
const start = element.scrollTop;
|
||||||
|
const change = to - start;
|
||||||
|
const startTime = performance.now();
|
||||||
|
|
||||||
|
function animateScroll(timestamp: number) {
|
||||||
|
const elapsedTime = timestamp - startTime;
|
||||||
|
const progress = Math.min(elapsedTime / duration, 1);
|
||||||
|
const easedProgress = timingFunction(progress, 0.38, 0, 0.24, 0.99);
|
||||||
|
element.scrollTop = start + change * easedProgress;
|
||||||
|
|
||||||
|
if (elapsedTime < duration) {
|
||||||
|
requestAnimationFrame(animateScroll);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(animateScroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define your custom Bézier curve function
|
||||||
|
export function customBezier(progress: number, p1x: number, p1y: number, p2x: number, p2y: number) {
|
||||||
|
return BezierEasing(p1x, p1y, p2x, p2y)(progress);
|
||||||
|
}
|
8
src/lib/graphics/spring/derivative.ts
Normal file
8
src/lib/graphics/spring/derivative.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export function derivative(f: (x: number) => number) {
|
||||||
|
const h = 0.001;
|
||||||
|
return (x: number) => (f(x + h) - f(x - h)) / (2 * h);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVelocity(f: (t: number) => number): (t: number) => number {
|
||||||
|
return derivative(f);
|
||||||
|
}
|
17
src/lib/graphics/spring/index.ts
Normal file
17
src/lib/graphics/spring/index.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { Spring } from './spring';
|
||||||
|
|
||||||
|
export default function createSpring(
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
bounce: number,
|
||||||
|
duration: number,
|
||||||
|
delaySeconds: number = 0,
|
||||||
|
) {
|
||||||
|
const mass = 1;
|
||||||
|
const stiffness = Math.pow((Math.PI * 2) / duration, 2);
|
||||||
|
const damping = bounce >= 0 ? ((1 - bounce) * (4 * Math.PI)) / duration : ((1 + bounce) * (4 * Math.PI)) / duration;
|
||||||
|
const spring = new Spring(from);
|
||||||
|
spring.updateParams({ mass, stiffness, damping });
|
||||||
|
spring.setTargetPosition(to, delaySeconds);
|
||||||
|
return spring;
|
||||||
|
}
|
147
src/lib/graphics/spring/spring.ts
Normal file
147
src/lib/graphics/spring/spring.ts
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
import { getVelocity } from './derivative';
|
||||||
|
|
||||||
|
/** 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() {
|
||||||
|
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) {
|
||||||
|
this.targetPosition = targetPosition;
|
||||||
|
this.currentPosition = targetPosition;
|
||||||
|
this.currentSolver = () => this.targetPosition;
|
||||||
|
this.getV = () => 0;
|
||||||
|
this.getV2 = () => 0;
|
||||||
|
}
|
||||||
|
update(delta = 0) {
|
||||||
|
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) {
|
||||||
|
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) {
|
||||||
|
if (delay > 0) {
|
||||||
|
this.queuePosition = {
|
||||||
|
...(this.queuePosition ?? {}),
|
||||||
|
position: targetPosition,
|
||||||
|
time: delay
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
this.queuePosition = undefined;
|
||||||
|
this.targetPosition = targetPosition;
|
||||||
|
this.resetSolver();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getCurrentPosition() {
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
}
|
270
src/lib/lyrics/LRCparser.ts
Normal file
270
src/lib/lyrics/LRCparser.ts
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
import {
|
||||||
|
alt_sc,
|
||||||
|
apply,
|
||||||
|
buildLexer,
|
||||||
|
expectEOF,
|
||||||
|
fail,
|
||||||
|
kleft,
|
||||||
|
kmid,
|
||||||
|
kright,
|
||||||
|
opt_sc,
|
||||||
|
type Parser,
|
||||||
|
rep,
|
||||||
|
rep_sc,
|
||||||
|
seq,
|
||||||
|
str,
|
||||||
|
tok,
|
||||||
|
type Token
|
||||||
|
} from 'typescript-parsec';
|
||||||
|
import type { LrcJsonData, ParsedLrc, ScriptItem, ScriptWordsItem } from '../type';
|
||||||
|
import type { IDTag } from './type';
|
||||||
|
|
||||||
|
|
||||||
|
interface ParserScriptItem {
|
||||||
|
start: number;
|
||||||
|
text: string;
|
||||||
|
words?: ScriptWordsItem[];
|
||||||
|
translation?: string;
|
||||||
|
singer?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertTimeToMs({
|
||||||
|
mins,
|
||||||
|
secs,
|
||||||
|
decimals
|
||||||
|
}: {
|
||||||
|
mins?: number | string;
|
||||||
|
secs?: number | string;
|
||||||
|
decimals?: string;
|
||||||
|
}) {
|
||||||
|
let result = 0;
|
||||||
|
if (mins) {
|
||||||
|
result += Number(mins) * 60 * 1000;
|
||||||
|
}
|
||||||
|
if (secs) {
|
||||||
|
result += Number(secs) * 1000;
|
||||||
|
}
|
||||||
|
if (decimals) {
|
||||||
|
const denom = Math.pow(10, decimals.length);
|
||||||
|
result += Number(decimals) / (denom / 1000);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const digit = Array.from({ length: 10 }, (_, i) => apply(str(i.toString()), (_) => i)).reduce(
|
||||||
|
(acc, cur) => alt_sc(cur, acc),
|
||||||
|
fail('no alternatives')
|
||||||
|
);
|
||||||
|
const numStr = apply(rep_sc(digit), (r) => r.join(''));
|
||||||
|
const num = apply(numStr, (r) => parseInt(r));
|
||||||
|
const alpha = alt_sc(
|
||||||
|
Array.from({ length: 26 }, (_, i) =>
|
||||||
|
apply(str(String.fromCharCode('a'.charCodeAt(0) + i)), (_) => String.fromCharCode('a'.charCodeAt(0) + i))
|
||||||
|
).reduce((acc, cur) => alt_sc(cur, acc), fail('no alternatives')),
|
||||||
|
Array.from({ length: 26 }, (_, i) =>
|
||||||
|
apply(str(String.fromCharCode('A'.charCodeAt(0) + i)), (_) => String.fromCharCode('A'.charCodeAt(0) + i))
|
||||||
|
).reduce((acc, cur) => alt_sc(cur, acc), fail('no alternatives'))
|
||||||
|
);
|
||||||
|
|
||||||
|
const alphaStr = apply(rep(alpha), (r) => r.join(''));
|
||||||
|
|
||||||
|
function spaces<K>(): Parser<K, Token<K>[]> {
|
||||||
|
return rep_sc(str(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
const unicodeStr = rep(tok('char'));
|
||||||
|
|
||||||
|
function trimmed<K, T>(p: Parser<K, Token<T>[]>): Parser<K, Token<T>[]> {
|
||||||
|
return apply(p, (r) => {
|
||||||
|
while (r.length > 0 && r[0].text.trim() === '') {
|
||||||
|
r.shift();
|
||||||
|
}
|
||||||
|
while (r.length > 0 && r[r.length - 1].text.trim() === '') {
|
||||||
|
r.pop();
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function padded<K, T>(p: Parser<K, T>): Parser<K, T> {
|
||||||
|
return kmid(spaces(), p, spaces());
|
||||||
|
}
|
||||||
|
|
||||||
|
function anythingTyped(types: string[]) {
|
||||||
|
return types.map((t) => tok(t)).reduce((acc, cur) => alt_sc(cur, acc), fail('no alternatives'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function lrcTimestamp<K, T>(delim: [Parser<K, Token<T>>, Parser<K, Token<T>>]) {
|
||||||
|
const innerTS = alt_sc(
|
||||||
|
apply(seq(num, str(':'), num, str('.'), numStr), (r) =>
|
||||||
|
convertTimeToMs({ mins: r[0], secs: r[2], decimals: r[4] })
|
||||||
|
),
|
||||||
|
apply(seq(num, str('.'), numStr), (r) => convertTimeToMs({ secs: r[0], decimals: r[2] })),
|
||||||
|
apply(seq(num, str(':'), num), (r) => convertTimeToMs({ mins: r[0], secs: r[2] })),
|
||||||
|
apply(num, (r) => convertTimeToMs({ secs: r }))
|
||||||
|
);
|
||||||
|
return kmid(delim[0], innerTS, delim[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const squareTS = lrcTimestamp([tok('['), tok(']')]);
|
||||||
|
const angleTS = lrcTimestamp([tok('<'), tok('>')]);
|
||||||
|
|
||||||
|
const lrcTag = apply(
|
||||||
|
seq(
|
||||||
|
tok('['),
|
||||||
|
alphaStr,
|
||||||
|
str(':'),
|
||||||
|
tokenParserToText(trimmed(rep(anythingTyped(['char', '[', ']', '<', '>'])))),
|
||||||
|
tok(']')
|
||||||
|
),
|
||||||
|
(r) => ({
|
||||||
|
[r[1]]: r[3]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
function joinTokens<T>(tokens: Token<T>[]) {
|
||||||
|
return tokens.map((t) => t.text).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenParserToText<K, T>(p: Parser<K, Token<T>> | Parser<K, Token<T>[]>): Parser<K, string> {
|
||||||
|
return apply(p, (r: Token<T> | Token<T>[]) => {
|
||||||
|
if (Array.isArray(r)) {
|
||||||
|
return joinTokens(r);
|
||||||
|
}
|
||||||
|
return r.text;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const singerIndicator = kleft(tok('char'), str(':'));
|
||||||
|
const translateParser = kright(tok('|'), unicodeStr);
|
||||||
|
|
||||||
|
function lrcLine(
|
||||||
|
wordDiv = ' ', legacy = false
|
||||||
|
): Parser<unknown, ['script_item', ParserScriptItem] | ['lrc_tag', IDTag] | ['comment', string] | ['empty', null]> {
|
||||||
|
return alt_sc(
|
||||||
|
legacy ? apply(seq(squareTS, trimmed(rep_sc(anythingTyped(['char', '[', ']', '<', '>'])))), (r) =>
|
||||||
|
['script_item', { start: r[0], text: joinTokens(r[1]) } as ParserScriptItem] // TODO: Complete this
|
||||||
|
) : apply(
|
||||||
|
seq(
|
||||||
|
squareTS,
|
||||||
|
opt_sc(padded(singerIndicator)),
|
||||||
|
rep_sc(
|
||||||
|
seq(
|
||||||
|
opt_sc(angleTS),
|
||||||
|
trimmed(rep_sc(anythingTyped(['char', '[', ']'])))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
opt_sc(trimmed(translateParser))
|
||||||
|
), (r) => {
|
||||||
|
const start = r[0];
|
||||||
|
const singerPart = r[1];
|
||||||
|
const mainPart = r[2];
|
||||||
|
const translatePart = r[3];
|
||||||
|
|
||||||
|
const text = mainPart
|
||||||
|
.map((s) => joinTokens(s[1]))
|
||||||
|
.filter((s) => s.trim().length > 0)
|
||||||
|
.join(wordDiv);
|
||||||
|
|
||||||
|
const words = mainPart
|
||||||
|
.filter((s) => joinTokens(s[1]).trim().length > 0)
|
||||||
|
.map((s) => {
|
||||||
|
const wordBegin = s[0];
|
||||||
|
const word = s[1];
|
||||||
|
let ret: Partial<ScriptWordsItem> = { start: wordBegin };
|
||||||
|
if (word[0]) {
|
||||||
|
ret.beginIndex = word[0].pos.columnBegin - 1;
|
||||||
|
}
|
||||||
|
if (word[word.length - 1]) {
|
||||||
|
ret.endIndex = word[word.length - 1].pos.columnEnd;
|
||||||
|
}
|
||||||
|
return ret as ScriptWordsItem; // TODO: Complete this
|
||||||
|
});
|
||||||
|
|
||||||
|
const singer = singerPart?.text;
|
||||||
|
const translation = translatePart === undefined ? undefined : joinTokens(translatePart);
|
||||||
|
|
||||||
|
return ['script_item', { start, text, words, singer, translation } as ParserScriptItem];
|
||||||
|
}),
|
||||||
|
apply(lrcTag, (r) => ['lrc_tag', r as IDTag]),
|
||||||
|
apply(seq(spaces(), str('#'), unicodeStr), (cmt) => ['comment', cmt[2].join('')] as const),
|
||||||
|
apply(spaces(), (_) => ['empty', null] as const)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dumpToken<T>(t: Token<T> | undefined): string {
|
||||||
|
if (t === undefined) {
|
||||||
|
return '<EOF>';
|
||||||
|
}
|
||||||
|
return '`' + t.text + '` -> ' + dumpToken(t.next);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseLRC(
|
||||||
|
input: string,
|
||||||
|
{ wordDiv, strict, legacy }: { wordDiv?: string; strict?: boolean; legacy?: boolean } = {}
|
||||||
|
): ParsedLrc {
|
||||||
|
const tokenizer = buildLexer([
|
||||||
|
[true, /^\[/gu, '['],
|
||||||
|
[true, /^\]/gu, ']'],
|
||||||
|
[true, /^</gu, '<'],
|
||||||
|
[true, /^>/gu, '>'],
|
||||||
|
[true, /^\|/gu, '|'],
|
||||||
|
[true, /^./gu, 'char']
|
||||||
|
]);
|
||||||
|
|
||||||
|
const lines = input
|
||||||
|
.split(/\r\n|\r|\n/gu)
|
||||||
|
.filter((line) => line.trim().length > 0)
|
||||||
|
.map((line) => tokenizer.parse(line));
|
||||||
|
|
||||||
|
return lines
|
||||||
|
.map((line) => {
|
||||||
|
const res = expectEOF(lrcLine(wordDiv, legacy).parse(line));
|
||||||
|
if (!res.successful) {
|
||||||
|
if (strict) {
|
||||||
|
throw new Error('Failed to parse full line: ' + dumpToken(line));
|
||||||
|
} else {
|
||||||
|
console.error('Failed to parse full line: ' + dumpToken(line));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return res.candidates[0].result;
|
||||||
|
})
|
||||||
|
.filter((r) => r !== null)
|
||||||
|
.reduce((acc, cur) => {
|
||||||
|
switch (cur[0]) {
|
||||||
|
case 'lrc_tag':
|
||||||
|
Object.assign(acc, cur[1]);
|
||||||
|
return acc;
|
||||||
|
case 'script_item':
|
||||||
|
acc.scripts = acc.scripts || [];
|
||||||
|
acc.scripts.push(cur[1]);
|
||||||
|
return acc;
|
||||||
|
default:
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
}, {} as ParsedLrc);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function lrcParser(lrc: string): LrcJsonData {
|
||||||
|
const parsedLrc = parseLRC(lrc, { wordDiv: '', strict: false });
|
||||||
|
if (parsedLrc.scripts === undefined) {
|
||||||
|
return parsedLrc as LrcJsonData;
|
||||||
|
}
|
||||||
|
let finalLrc: LrcJsonData = parsedLrc as LrcJsonData;
|
||||||
|
let lyrics: ScriptItem[] = [];
|
||||||
|
let i = 0;
|
||||||
|
while (i < parsedLrc.scripts.length - 1) {
|
||||||
|
let lyricLine = parsedLrc.scripts[i] as ScriptItem;
|
||||||
|
lyricLine.start/=1000;
|
||||||
|
lyricLine.end = parsedLrc.scripts[i+1].start / 1000;
|
||||||
|
if (parsedLrc.scripts[i+1].text.trim() === "") {
|
||||||
|
i+=2;
|
||||||
|
} else i++;
|
||||||
|
if (lyricLine.text.trim() !== "") {
|
||||||
|
lyrics.push(lyricLine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finalLrc.scripts = lyrics;
|
||||||
|
return finalLrc;
|
||||||
|
}
|
20
src/lib/lyrics/LRCtoAMLL.ts
Normal file
20
src/lib/lyrics/LRCtoAMLL.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import type { LyricLine } from '@applemusic-like-lyrics/core';
|
||||||
|
import type { ScriptItem } from '$lib/lyrics/LRCparser';
|
||||||
|
|
||||||
|
export default function mapLRCtoAMLL(line: ScriptItem, i: number, lines: ScriptItem[]): LyricLine {
|
||||||
|
return {
|
||||||
|
words: [
|
||||||
|
{
|
||||||
|
word: line.text,
|
||||||
|
startTime: line.start * 1000,
|
||||||
|
endTime: line.end * 1000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
startTime: line.start * 1000,
|
||||||
|
endTime: line.end * 1000,
|
||||||
|
translatedLyric: line.translation ?? "",
|
||||||
|
romanLyric: '',
|
||||||
|
isBG: false,
|
||||||
|
isDuet: false
|
||||||
|
};
|
||||||
|
}
|
0
src/lib/lyrics/lrc/index.ts
Normal file
0
src/lib/lyrics/lrc/index.ts
Normal file
270
src/lib/lyrics/lrc/parser.ts
Normal file
270
src/lib/lyrics/lrc/parser.ts
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
import {
|
||||||
|
alt_sc,
|
||||||
|
apply,
|
||||||
|
buildLexer,
|
||||||
|
expectEOF,
|
||||||
|
fail,
|
||||||
|
kleft,
|
||||||
|
kmid,
|
||||||
|
kright,
|
||||||
|
opt_sc,
|
||||||
|
type Parser,
|
||||||
|
rep,
|
||||||
|
rep_sc,
|
||||||
|
seq,
|
||||||
|
str,
|
||||||
|
tok,
|
||||||
|
type Token
|
||||||
|
} from 'typescript-parsec';
|
||||||
|
import type { LrcJsonData, ParsedLrc, ScriptItem, ScriptWordsItem } from '../type';
|
||||||
|
import type { IDTag } from './type';
|
||||||
|
|
||||||
|
|
||||||
|
interface ParserScriptItem {
|
||||||
|
start: number;
|
||||||
|
text: string;
|
||||||
|
words?: ScriptWordsItem[];
|
||||||
|
translation?: string;
|
||||||
|
singer?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertTimeToMs({
|
||||||
|
mins,
|
||||||
|
secs,
|
||||||
|
decimals
|
||||||
|
}: {
|
||||||
|
mins?: number | string;
|
||||||
|
secs?: number | string;
|
||||||
|
decimals?: string;
|
||||||
|
}) {
|
||||||
|
let result = 0;
|
||||||
|
if (mins) {
|
||||||
|
result += Number(mins) * 60 * 1000;
|
||||||
|
}
|
||||||
|
if (secs) {
|
||||||
|
result += Number(secs) * 1000;
|
||||||
|
}
|
||||||
|
if (decimals) {
|
||||||
|
const denom = Math.pow(10, decimals.length);
|
||||||
|
result += Number(decimals) / (denom / 1000);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const digit = Array.from({ length: 10 }, (_, i) => apply(str(i.toString()), (_) => i)).reduce(
|
||||||
|
(acc, cur) => alt_sc(cur, acc),
|
||||||
|
fail('no alternatives')
|
||||||
|
);
|
||||||
|
const numStr = apply(rep_sc(digit), (r) => r.join(''));
|
||||||
|
const num = apply(numStr, (r) => parseInt(r));
|
||||||
|
const alpha = alt_sc(
|
||||||
|
Array.from({ length: 26 }, (_, i) =>
|
||||||
|
apply(str(String.fromCharCode('a'.charCodeAt(0) + i)), (_) => String.fromCharCode('a'.charCodeAt(0) + i))
|
||||||
|
).reduce((acc, cur) => alt_sc(cur, acc), fail('no alternatives')),
|
||||||
|
Array.from({ length: 26 }, (_, i) =>
|
||||||
|
apply(str(String.fromCharCode('A'.charCodeAt(0) + i)), (_) => String.fromCharCode('A'.charCodeAt(0) + i))
|
||||||
|
).reduce((acc, cur) => alt_sc(cur, acc), fail('no alternatives'))
|
||||||
|
);
|
||||||
|
|
||||||
|
const alphaStr = apply(rep(alpha), (r) => r.join(''));
|
||||||
|
|
||||||
|
function spaces<K>(): Parser<K, Token<K>[]> {
|
||||||
|
return rep_sc(str(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
const unicodeStr = rep(tok('char'));
|
||||||
|
|
||||||
|
function trimmed<K, T>(p: Parser<K, Token<T>[]>): Parser<K, Token<T>[]> {
|
||||||
|
return apply(p, (r) => {
|
||||||
|
while (r.length > 0 && r[0].text.trim() === '') {
|
||||||
|
r.shift();
|
||||||
|
}
|
||||||
|
while (r.length > 0 && r[r.length - 1].text.trim() === '') {
|
||||||
|
r.pop();
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function padded<K, T>(p: Parser<K, T>): Parser<K, T> {
|
||||||
|
return kmid(spaces(), p, spaces());
|
||||||
|
}
|
||||||
|
|
||||||
|
function anythingTyped(types: string[]) {
|
||||||
|
return types.map((t) => tok(t)).reduce((acc, cur) => alt_sc(cur, acc), fail('no alternatives'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function lrcTimestamp<K, T>(delim: [Parser<K, Token<T>>, Parser<K, Token<T>>]) {
|
||||||
|
const innerTS = alt_sc(
|
||||||
|
apply(seq(num, str(':'), num, str('.'), numStr), (r) =>
|
||||||
|
convertTimeToMs({ mins: r[0], secs: r[2], decimals: r[4] })
|
||||||
|
),
|
||||||
|
apply(seq(num, str('.'), numStr), (r) => convertTimeToMs({ secs: r[0], decimals: r[2] })),
|
||||||
|
apply(seq(num, str(':'), num), (r) => convertTimeToMs({ mins: r[0], secs: r[2] })),
|
||||||
|
apply(num, (r) => convertTimeToMs({ secs: r }))
|
||||||
|
);
|
||||||
|
return kmid(delim[0], innerTS, delim[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const squareTS = lrcTimestamp([tok('['), tok(']')]);
|
||||||
|
const angleTS = lrcTimestamp([tok('<'), tok('>')]);
|
||||||
|
|
||||||
|
const lrcTag = apply(
|
||||||
|
seq(
|
||||||
|
tok('['),
|
||||||
|
alphaStr,
|
||||||
|
str(':'),
|
||||||
|
tokenParserToText(trimmed(rep(anythingTyped(['char', '[', ']', '<', '>'])))),
|
||||||
|
tok(']')
|
||||||
|
),
|
||||||
|
(r) => ({
|
||||||
|
[r[1]]: r[3]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
function joinTokens<T>(tokens: Token<T>[]) {
|
||||||
|
return tokens.map((t) => t.text).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenParserToText<K, T>(p: Parser<K, Token<T>> | Parser<K, Token<T>[]>): Parser<K, string> {
|
||||||
|
return apply(p, (r: Token<T> | Token<T>[]) => {
|
||||||
|
if (Array.isArray(r)) {
|
||||||
|
return joinTokens(r);
|
||||||
|
}
|
||||||
|
return r.text;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const singerIndicator = kleft(tok('char'), str(':'));
|
||||||
|
const translateParser = kright(tok('|'), unicodeStr);
|
||||||
|
|
||||||
|
function lrcLine(
|
||||||
|
wordDiv = ' ', legacy = false
|
||||||
|
): Parser<unknown, ['script_item', ParserScriptItem] | ['lrc_tag', IDTag] | ['comment', string] | ['empty', null]> {
|
||||||
|
return alt_sc(
|
||||||
|
legacy ? apply(seq(squareTS, trimmed(rep_sc(anythingTyped(['char', '[', ']', '<', '>'])))), (r) =>
|
||||||
|
['script_item', { start: r[0], text: joinTokens(r[1]) } as ParserScriptItem] // TODO: Complete this
|
||||||
|
) : apply(
|
||||||
|
seq(
|
||||||
|
squareTS,
|
||||||
|
opt_sc(padded(singerIndicator)),
|
||||||
|
rep_sc(
|
||||||
|
seq(
|
||||||
|
opt_sc(angleTS),
|
||||||
|
trimmed(rep_sc(anythingTyped(['char', '[', ']'])))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
opt_sc(trimmed(translateParser))
|
||||||
|
), (r) => {
|
||||||
|
const start = r[0];
|
||||||
|
const singerPart = r[1];
|
||||||
|
const mainPart = r[2];
|
||||||
|
const translatePart = r[3];
|
||||||
|
|
||||||
|
const text = mainPart
|
||||||
|
.map((s) => joinTokens(s[1]))
|
||||||
|
.filter((s) => s.trim().length > 0)
|
||||||
|
.join(wordDiv);
|
||||||
|
|
||||||
|
const words = mainPart
|
||||||
|
.filter((s) => joinTokens(s[1]).trim().length > 0)
|
||||||
|
.map((s) => {
|
||||||
|
const wordBegin = s[0];
|
||||||
|
const word = s[1];
|
||||||
|
let ret: Partial<ScriptWordsItem> = { start: wordBegin };
|
||||||
|
if (word[0]) {
|
||||||
|
ret.beginIndex = word[0].pos.columnBegin - 1;
|
||||||
|
}
|
||||||
|
if (word[word.length - 1]) {
|
||||||
|
ret.endIndex = word[word.length - 1].pos.columnEnd;
|
||||||
|
}
|
||||||
|
return ret as ScriptWordsItem; // TODO: Complete this
|
||||||
|
});
|
||||||
|
|
||||||
|
const singer = singerPart?.text;
|
||||||
|
const translation = translatePart === undefined ? undefined : joinTokens(translatePart);
|
||||||
|
|
||||||
|
return ['script_item', { start, text, words, singer, translation } as ParserScriptItem];
|
||||||
|
}),
|
||||||
|
apply(lrcTag, (r) => ['lrc_tag', r as IDTag]),
|
||||||
|
apply(seq(spaces(), str('#'), unicodeStr), (cmt) => ['comment', cmt[2].join('')] as const),
|
||||||
|
apply(spaces(), (_) => ['empty', null] as const)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dumpToken<T>(t: Token<T> | undefined): string {
|
||||||
|
if (t === undefined) {
|
||||||
|
return '<EOF>';
|
||||||
|
}
|
||||||
|
return '`' + t.text + '` -> ' + dumpToken(t.next);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseLRC(
|
||||||
|
input: string,
|
||||||
|
{ wordDiv, strict, legacy }: { wordDiv?: string; strict?: boolean; legacy?: boolean } = {}
|
||||||
|
): ParsedLrc {
|
||||||
|
const tokenizer = buildLexer([
|
||||||
|
[true, /^\[/gu, '['],
|
||||||
|
[true, /^\]/gu, ']'],
|
||||||
|
[true, /^</gu, '<'],
|
||||||
|
[true, /^>/gu, '>'],
|
||||||
|
[true, /^\|/gu, '|'],
|
||||||
|
[true, /^./gu, 'char']
|
||||||
|
]);
|
||||||
|
|
||||||
|
const lines = input
|
||||||
|
.split(/\r\n|\r|\n/gu)
|
||||||
|
.filter((line) => line.trim().length > 0)
|
||||||
|
.map((line) => tokenizer.parse(line));
|
||||||
|
|
||||||
|
return lines
|
||||||
|
.map((line) => {
|
||||||
|
const res = expectEOF(lrcLine(wordDiv, legacy).parse(line));
|
||||||
|
if (!res.successful) {
|
||||||
|
if (strict) {
|
||||||
|
throw new Error('Failed to parse full line: ' + dumpToken(line));
|
||||||
|
} else {
|
||||||
|
console.error('Failed to parse full line: ' + dumpToken(line));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return res.candidates[0].result;
|
||||||
|
})
|
||||||
|
.filter((r) => r !== null)
|
||||||
|
.reduce((acc, cur) => {
|
||||||
|
switch (cur[0]) {
|
||||||
|
case 'lrc_tag':
|
||||||
|
Object.assign(acc, cur[1]);
|
||||||
|
return acc;
|
||||||
|
case 'script_item':
|
||||||
|
acc.scripts = acc.scripts || [];
|
||||||
|
acc.scripts.push(cur[1]);
|
||||||
|
return acc;
|
||||||
|
default:
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
}, {} as ParsedLrc);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function lrcParser(lrc: string): LrcJsonData {
|
||||||
|
const parsedLrc = parseLRC(lrc, { wordDiv: '', strict: false });
|
||||||
|
if (parsedLrc.scripts === undefined) {
|
||||||
|
return parsedLrc as LrcJsonData;
|
||||||
|
}
|
||||||
|
let finalLrc: LrcJsonData = parsedLrc as LrcJsonData;
|
||||||
|
let lyrics: ScriptItem[] = [];
|
||||||
|
let i = 0;
|
||||||
|
while (i < parsedLrc.scripts.length - 1) {
|
||||||
|
let lyricLine = parsedLrc.scripts[i] as ScriptItem;
|
||||||
|
lyricLine.start/=1000;
|
||||||
|
lyricLine.end = parsedLrc.scripts[i+1].start / 1000;
|
||||||
|
if (parsedLrc.scripts[i+1].text.trim() === "") {
|
||||||
|
i+=2;
|
||||||
|
} else i++;
|
||||||
|
if (lyricLine.text.trim() !== "") {
|
||||||
|
lyrics.push(lyricLine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finalLrc.scripts = lyrics;
|
||||||
|
return finalLrc;
|
||||||
|
}
|
11
src/lib/lyrics/lrc/type.d.ts
vendored
Normal file
11
src/lib/lyrics/lrc/type.d.ts
vendored
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export interface ParserScriptItem {
|
||||||
|
start: number;
|
||||||
|
text: string;
|
||||||
|
words?: ScriptWordsItem[];
|
||||||
|
translation?: string;
|
||||||
|
singer?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDTag {
|
||||||
|
[key: string]: string;
|
||||||
|
}
|
21
src/lib/lyrics/ttml/index.ts
Normal file
21
src/lib/lyrics/ttml/index.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import type { LrcJsonData } from '$lib/lyrics/type';
|
||||||
|
import { parseTTML as ttmlParser } from './parser';
|
||||||
|
import type { LyricLine } from './ttml-types';
|
||||||
|
export * from './writer';
|
||||||
|
export type * from './ttml-types';
|
||||||
|
|
||||||
|
export function parseTTML(text: string) {
|
||||||
|
let lyrics: LrcJsonData;
|
||||||
|
const lyricLines = ttmlParser(text).lyricLines;
|
||||||
|
lyrics = {
|
||||||
|
scripts: lyricLines.map((value: LyricLine, index: number, array: LyricLine[]) => {
|
||||||
|
return {
|
||||||
|
text: value.words.map((word) => word.word).join(''),
|
||||||
|
start: value.startTime / 1000,
|
||||||
|
end: value.endTime / 1000,
|
||||||
|
translation: value.translatedLyric || undefined
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
return lyrics;
|
||||||
|
}
|
169
src/lib/lyrics/ttml/parser.ts
Normal file
169
src/lib/lyrics/ttml/parser.ts
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview
|
||||||
|
* Parser for TTML lyrics.
|
||||||
|
* Used to parse lyrics files from Apple Music,
|
||||||
|
* and extended to support translation and pronounciation of text.
|
||||||
|
* @see https://www.w3.org/TR/2018/REC-ttml1-20181108/
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
LyricLine,
|
||||||
|
LyricWord,
|
||||||
|
TTMLLyric,
|
||||||
|
TTMLMetadata,
|
||||||
|
} from "./ttml-types";
|
||||||
|
|
||||||
|
const timeRegexp =
|
||||||
|
/^(((?<hour>[0-9]+):)?(?<min>[0-9]+):)?(?<sec>[0-9]+([.:]([0-9]+))?)/;
|
||||||
|
function parseTimespan(timeSpan: string): number {
|
||||||
|
const matches = timeRegexp.exec(timeSpan);
|
||||||
|
if (matches) {
|
||||||
|
const hour = Number(matches.groups?.hour || "0");
|
||||||
|
const min = Number(matches.groups?.min || "0");
|
||||||
|
const sec = Number(matches.groups?.sec.replace(/:/, ".") || "0");
|
||||||
|
return Math.floor((hour * 3600 + min * 60 + sec) * 1000);
|
||||||
|
}
|
||||||
|
throw new TypeError(`Failed to parse time stamp:${timeSpan}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTTML(ttmlText: string): TTMLLyric {
|
||||||
|
const domParser = new DOMParser();
|
||||||
|
const ttmlDoc: XMLDocument = domParser.parseFromString(
|
||||||
|
ttmlText,
|
||||||
|
"application/xml",
|
||||||
|
);
|
||||||
|
|
||||||
|
let mainAgentId = "v1";
|
||||||
|
|
||||||
|
const metadata: TTMLMetadata[] = [];
|
||||||
|
for (const meta of ttmlDoc.querySelectorAll("meta")) {
|
||||||
|
if (meta.tagName === "amll:meta") {
|
||||||
|
const key = meta.getAttribute("key");
|
||||||
|
if (key) {
|
||||||
|
const value = meta.getAttribute("value");
|
||||||
|
if (value) {
|
||||||
|
const existing = metadata.find((m) => m.key === key);
|
||||||
|
if (existing) {
|
||||||
|
existing.value.push(value);
|
||||||
|
} else {
|
||||||
|
metadata.push({
|
||||||
|
key,
|
||||||
|
value: [value],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const agent of ttmlDoc.querySelectorAll("ttm\\:agent")) {
|
||||||
|
if (agent.getAttribute("type") === "person") {
|
||||||
|
const id = agent.getAttribute("xml:id");
|
||||||
|
if (id) {
|
||||||
|
mainAgentId = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lyricLines: LyricLine[] = [];
|
||||||
|
|
||||||
|
function parseParseLine(lineEl: Element, isBG = false, isDuet = false) {
|
||||||
|
const line: LyricLine = {
|
||||||
|
words: [],
|
||||||
|
translatedLyric: "",
|
||||||
|
romanLyric: "",
|
||||||
|
isBG,
|
||||||
|
isDuet:
|
||||||
|
!!lineEl.getAttribute("ttm:agent") &&
|
||||||
|
lineEl.getAttribute("ttm:agent") !== mainAgentId,
|
||||||
|
startTime: 0,
|
||||||
|
endTime: 0,
|
||||||
|
};
|
||||||
|
if (isBG) line.isDuet = isDuet;
|
||||||
|
let haveBg = false;
|
||||||
|
|
||||||
|
for (const wordNode of lineEl.childNodes) {
|
||||||
|
if (wordNode.nodeType === Node.TEXT_NODE) {
|
||||||
|
line.words?.push({
|
||||||
|
word: wordNode.textContent ?? "",
|
||||||
|
startTime: 0,
|
||||||
|
endTime: 0,
|
||||||
|
});
|
||||||
|
} else if (wordNode.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
const wordEl = wordNode as Element;
|
||||||
|
const role = wordEl.getAttribute("ttm:role");
|
||||||
|
|
||||||
|
if (wordEl.nodeName === "span" && role) {
|
||||||
|
if (role === "x-bg") {
|
||||||
|
parseParseLine(wordEl, true, line.isDuet);
|
||||||
|
haveBg = true;
|
||||||
|
} else if (role === "x-translation") {
|
||||||
|
line.translatedLyric = wordEl.innerHTML;
|
||||||
|
} else if (role === "x-roman") {
|
||||||
|
line.romanLyric = wordEl.innerHTML;
|
||||||
|
}
|
||||||
|
} else if (wordEl.hasAttribute("begin") && wordEl.hasAttribute("end")) {
|
||||||
|
const word: LyricWord = {
|
||||||
|
word: wordNode.textContent ?? "",
|
||||||
|
startTime: parseTimespan(wordEl.getAttribute("begin") ?? ""),
|
||||||
|
endTime: parseTimespan(wordEl.getAttribute("end") ?? ""),
|
||||||
|
};
|
||||||
|
const emptyBeat = wordEl.getAttribute("amll:empty-beat");
|
||||||
|
if (emptyBeat) {
|
||||||
|
word.emptyBeat = Number(emptyBeat);
|
||||||
|
}
|
||||||
|
line.words.push(word);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.isBG) {
|
||||||
|
const firstWord = line.words?.[0];
|
||||||
|
if (firstWord?.word.startsWith("(")) {
|
||||||
|
firstWord.word = firstWord.word.substring(1);
|
||||||
|
if (firstWord.word.length === 0) {
|
||||||
|
line.words.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastWord = line.words?.[line.words.length - 1];
|
||||||
|
if (lastWord?.word.endsWith(")")) {
|
||||||
|
lastWord.word = lastWord.word.substring(0, lastWord.word.length - 1);
|
||||||
|
if (lastWord.word.length === 0) {
|
||||||
|
line.words.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = lineEl.getAttribute("begin");
|
||||||
|
const endTime = lineEl.getAttribute("end");
|
||||||
|
if (startTime && endTime) {
|
||||||
|
line.startTime = parseTimespan(startTime);
|
||||||
|
line.endTime = parseTimespan(endTime);
|
||||||
|
} else {
|
||||||
|
line.startTime = line.words
|
||||||
|
.filter((v) => v.word.trim().length > 0)
|
||||||
|
.reduce((pv, cv) => Math.min(pv, cv.startTime), Infinity);
|
||||||
|
line.endTime = line.words
|
||||||
|
.filter((v) => v.word.trim().length > 0)
|
||||||
|
.reduce((pv, cv) => Math.max(pv, cv.endTime), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (haveBg) {
|
||||||
|
const bgLine = lyricLines.pop();
|
||||||
|
lyricLines.push(line);
|
||||||
|
if (bgLine) lyricLines.push(bgLine);
|
||||||
|
} else {
|
||||||
|
lyricLines.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const lineEl of ttmlDoc.querySelectorAll("body p[begin][end]")) {
|
||||||
|
parseParseLine(lineEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
metadata,
|
||||||
|
lyricLines: lyricLines,
|
||||||
|
};
|
||||||
|
}
|
26
src/lib/lyrics/ttml/ttml-types.ts
Normal file
26
src/lib/lyrics/ttml/ttml-types.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
export interface TTMLMetadata {
|
||||||
|
key: string;
|
||||||
|
value: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TTMLLyric {
|
||||||
|
metadata: TTMLMetadata[];
|
||||||
|
lyricLines: LyricLine[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LyricWord {
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
word: string;
|
||||||
|
emptyBeat?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LyricLine {
|
||||||
|
words: LyricWord[];
|
||||||
|
translatedLyric: string;
|
||||||
|
romanLyric: string;
|
||||||
|
isBG: boolean;
|
||||||
|
isDuet: boolean;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
}
|
254
src/lib/lyrics/ttml/writer.ts
Normal file
254
src/lib/lyrics/ttml/writer.ts
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
import type { LyricLine, LyricWord, TTMLLyric } from "./ttml-types";
|
||||||
|
|
||||||
|
function msToTimestamp(timeMS: number): string {
|
||||||
|
let time = timeMS;
|
||||||
|
if (!Number.isSafeInteger(time) || time < 0) {
|
||||||
|
return "00:00.000";
|
||||||
|
}
|
||||||
|
if (time === Infinity) {
|
||||||
|
return "99:99.999";
|
||||||
|
}
|
||||||
|
time = time / 1000;
|
||||||
|
const secs = time % 60;
|
||||||
|
time = (time - secs) / 60;
|
||||||
|
const mins = time % 60;
|
||||||
|
const hrs = (time - mins) / 60;
|
||||||
|
|
||||||
|
const h = hrs.toString().padStart(2, "0");
|
||||||
|
const m = mins.toString().padStart(2, "0");
|
||||||
|
const s = secs.toFixed(3).padStart(6, "0");
|
||||||
|
|
||||||
|
if (hrs > 0) {
|
||||||
|
return `${h}:${m}:${s}`;
|
||||||
|
}
|
||||||
|
return `${m}:${s}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportTTML(ttmlLyric: TTMLLyric, pretty = false): string {
|
||||||
|
const params: LyricLine[][] = [];
|
||||||
|
const lyric = ttmlLyric.lyricLines;
|
||||||
|
|
||||||
|
let tmp: LyricLine[] = [];
|
||||||
|
for (const line of lyric) {
|
||||||
|
if (line.words.length === 0 && tmp.length > 0) {
|
||||||
|
params.push(tmp);
|
||||||
|
tmp = [];
|
||||||
|
} else {
|
||||||
|
tmp.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tmp.length > 0) {
|
||||||
|
params.push(tmp);
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = new Document();
|
||||||
|
|
||||||
|
function createWordElement(word: LyricWord): Element {
|
||||||
|
const span = doc.createElement("span");
|
||||||
|
span.setAttribute("begin", msToTimestamp(word.startTime));
|
||||||
|
span.setAttribute("end", msToTimestamp(word.endTime));
|
||||||
|
if (word.emptyBeat) {
|
||||||
|
span.setAttribute("amll:empty-beat", `${word.emptyBeat}`);
|
||||||
|
}
|
||||||
|
span.appendChild(doc.createTextNode(word.word));
|
||||||
|
return span;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ttRoot = doc.createElement("code");
|
||||||
|
|
||||||
|
ttRoot.setAttribute("xmlns", "http://www.w3.org/ns/ttml");
|
||||||
|
ttRoot.setAttribute("xmlns:ttm", "http://www.w3.org/ns/ttml#metadata");
|
||||||
|
ttRoot.setAttribute("xmlns:amll", "http://www.example.com/ns/amll");
|
||||||
|
ttRoot.setAttribute(
|
||||||
|
"xmlns:itunes",
|
||||||
|
"http://music.apple.com/lyric-ttml-internal",
|
||||||
|
);
|
||||||
|
|
||||||
|
doc.appendChild(ttRoot);
|
||||||
|
|
||||||
|
const head = doc.createElement("head");
|
||||||
|
|
||||||
|
ttRoot.appendChild(head);
|
||||||
|
|
||||||
|
const body = doc.createElement("body");
|
||||||
|
const hasOtherPerson = !!lyric.find((v) => v.isDuet);
|
||||||
|
|
||||||
|
const metadataEl = doc.createElement("metadata");
|
||||||
|
const mainPersonAgent = doc.createElement("ttm:agent");
|
||||||
|
mainPersonAgent.setAttribute("type", "person");
|
||||||
|
mainPersonAgent.setAttribute("xml:id", "v1");
|
||||||
|
|
||||||
|
metadataEl.appendChild(mainPersonAgent);
|
||||||
|
|
||||||
|
if (hasOtherPerson) {
|
||||||
|
const otherPersonAgent = doc.createElement("ttm:agent");
|
||||||
|
otherPersonAgent.setAttribute("type", "other");
|
||||||
|
otherPersonAgent.setAttribute("xml:id", "v2");
|
||||||
|
|
||||||
|
metadataEl.appendChild(otherPersonAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const metadata of ttmlLyric.metadata) {
|
||||||
|
for (const value of metadata.value) {
|
||||||
|
const metaEl = doc.createElement("amll:meta");
|
||||||
|
metaEl.setAttribute("key", metadata.key);
|
||||||
|
metaEl.setAttribute("value", value);
|
||||||
|
metadataEl.appendChild(metaEl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
head.appendChild(metadataEl);
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
const guessDuration = lyric[lyric.length - 1]?.endTime ?? 0;
|
||||||
|
body.setAttribute("dur", msToTimestamp(guessDuration));
|
||||||
|
|
||||||
|
for (const param of params) {
|
||||||
|
const paramDiv = doc.createElement("div");
|
||||||
|
const beginTime = param[0]?.startTime ?? 0;
|
||||||
|
const endTime = param[param.length - 1]?.endTime ?? 0;
|
||||||
|
|
||||||
|
paramDiv.setAttribute("begin", msToTimestamp(beginTime));
|
||||||
|
paramDiv.setAttribute("end", msToTimestamp(endTime));
|
||||||
|
|
||||||
|
for (let lineIndex = 0; lineIndex < param.length; lineIndex++) {
|
||||||
|
const line = param[lineIndex];
|
||||||
|
const lineP = doc.createElement("p");
|
||||||
|
const beginTime = line.startTime ?? 0;
|
||||||
|
const endTime = line.endTime;
|
||||||
|
|
||||||
|
lineP.setAttribute("begin", msToTimestamp(beginTime));
|
||||||
|
lineP.setAttribute("end", msToTimestamp(endTime));
|
||||||
|
|
||||||
|
lineP.setAttribute("ttm:agent", line.isDuet ? "v2" : "v1");
|
||||||
|
lineP.setAttribute("itunes:key", `L${++i}`);
|
||||||
|
|
||||||
|
if (line.words.length > 1) {
|
||||||
|
let beginTime = Infinity;
|
||||||
|
let endTime = 0;
|
||||||
|
for (const word of line.words) {
|
||||||
|
if (word.word.trim().length === 0) {
|
||||||
|
lineP.appendChild(doc.createTextNode(word.word));
|
||||||
|
} else {
|
||||||
|
const span = createWordElement(word);
|
||||||
|
lineP.appendChild(span);
|
||||||
|
beginTime = Math.min(beginTime, word.startTime);
|
||||||
|
endTime = Math.max(endTime, word.endTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lineP.setAttribute("begin", msToTimestamp(line.startTime));
|
||||||
|
lineP.setAttribute("end", msToTimestamp(line.endTime));
|
||||||
|
} else if (line.words.length === 1) {
|
||||||
|
const word = line.words[0];
|
||||||
|
lineP.appendChild(doc.createTextNode(word.word));
|
||||||
|
lineP.setAttribute("begin", msToTimestamp(word.startTime));
|
||||||
|
lineP.setAttribute("end", msToTimestamp(word.endTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextLine = param[lineIndex + 1];
|
||||||
|
if (nextLine?.isBG) {
|
||||||
|
lineIndex++;
|
||||||
|
const bgLine = nextLine;
|
||||||
|
const bgLineSpan = doc.createElement("span");
|
||||||
|
bgLineSpan.setAttribute("ttm:role", "x-bg");
|
||||||
|
|
||||||
|
if (bgLine.words.length > 1) {
|
||||||
|
let beginTime = Infinity;
|
||||||
|
let endTime = 0;
|
||||||
|
for (
|
||||||
|
let wordIndex = 0;
|
||||||
|
wordIndex < bgLine.words.length;
|
||||||
|
wordIndex++
|
||||||
|
) {
|
||||||
|
const word = bgLine.words[wordIndex];
|
||||||
|
if (word.word.trim().length === 0) {
|
||||||
|
bgLineSpan.appendChild(doc.createTextNode(word.word));
|
||||||
|
} else {
|
||||||
|
const span = createWordElement(word);
|
||||||
|
if (wordIndex === 0) {
|
||||||
|
span.prepend(doc.createTextNode("("));
|
||||||
|
} else if (wordIndex === bgLine.words.length - 1) {
|
||||||
|
span.appendChild(doc.createTextNode(")"));
|
||||||
|
}
|
||||||
|
bgLineSpan.appendChild(span);
|
||||||
|
beginTime = Math.min(beginTime, word.startTime);
|
||||||
|
endTime = Math.max(endTime, word.endTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bgLineSpan.setAttribute("begin", msToTimestamp(beginTime));
|
||||||
|
bgLineSpan.setAttribute("end", msToTimestamp(endTime));
|
||||||
|
} else if (bgLine.words.length === 1) {
|
||||||
|
const word = bgLine.words[0];
|
||||||
|
bgLineSpan.appendChild(doc.createTextNode(`(${word.word})`));
|
||||||
|
bgLineSpan.setAttribute("begin", msToTimestamp(word.startTime));
|
||||||
|
bgLineSpan.setAttribute("end", msToTimestamp(word.endTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bgLine.translatedLyric) {
|
||||||
|
const span = doc.createElement("span");
|
||||||
|
span.setAttribute("ttm:role", "x-translation");
|
||||||
|
span.setAttribute("xml:lang", "zh-CN");
|
||||||
|
span.appendChild(doc.createTextNode(bgLine.translatedLyric));
|
||||||
|
bgLineSpan.appendChild(span);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bgLine.romanLyric) {
|
||||||
|
const span = doc.createElement("span");
|
||||||
|
span.setAttribute("ttm:role", "x-roman");
|
||||||
|
span.appendChild(doc.createTextNode(bgLine.romanLyric));
|
||||||
|
bgLineSpan.appendChild(span);
|
||||||
|
}
|
||||||
|
|
||||||
|
lineP.appendChild(bgLineSpan);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.translatedLyric) {
|
||||||
|
const span = doc.createElement("span");
|
||||||
|
span.setAttribute("ttm:role", "x-translation");
|
||||||
|
span.setAttribute("xml:lang", "zh-CN");
|
||||||
|
span.appendChild(doc.createTextNode(line.translatedLyric));
|
||||||
|
lineP.appendChild(span);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.romanLyric) {
|
||||||
|
const span = doc.createElement("span");
|
||||||
|
span.setAttribute("ttm:role", "x-roman");
|
||||||
|
span.appendChild(doc.createTextNode(line.romanLyric));
|
||||||
|
lineP.appendChild(span);
|
||||||
|
}
|
||||||
|
|
||||||
|
paramDiv.appendChild(lineP);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.appendChild(paramDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
ttRoot.appendChild(body);
|
||||||
|
|
||||||
|
if (pretty) {
|
||||||
|
const xsltDoc = new DOMParser().parseFromString(
|
||||||
|
[
|
||||||
|
'<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0">',
|
||||||
|
' <xsl:strip-space elements="*"/>',
|
||||||
|
' <xsl:template match="para[content-style][not(text())]">',
|
||||||
|
' <xsl:value-of select="normalize-space(.)"/>',
|
||||||
|
" </xsl:template>",
|
||||||
|
' <xsl:template match="node()|@*">',
|
||||||
|
' <xsl:copy><xsl:apply-templates select="node()|@*"/></xsl:copy>',
|
||||||
|
" </xsl:template>",
|
||||||
|
' <xsl:output indent="yes"/>',
|
||||||
|
"</xsl:stylesheet>",
|
||||||
|
].join("\n"),
|
||||||
|
"application/xml",
|
||||||
|
);
|
||||||
|
|
||||||
|
const xsltProcessor = new XSLTProcessor();
|
||||||
|
xsltProcessor.importStylesheet(xsltDoc);
|
||||||
|
const resultDoc = xsltProcessor.transformToDocument(doc);
|
||||||
|
|
||||||
|
return new XMLSerializer().serializeToString(resultDoc);
|
||||||
|
}
|
||||||
|
return new XMLSerializer().serializeToString(doc);
|
||||||
|
}
|
36
src/lib/lyrics/type.d.ts
vendored
Normal file
36
src/lib/lyrics/type.d.ts
vendored
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import type { ParserScriptItem } from "./lrc/type";
|
||||||
|
|
||||||
|
export interface ScriptItem extends ParserScriptItem {
|
||||||
|
end: number;
|
||||||
|
chorus?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScriptWordsItem {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
beginIndex: number;
|
||||||
|
endIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LrcMetaData {
|
||||||
|
ar?: string;
|
||||||
|
ti?: string;
|
||||||
|
al?: string;
|
||||||
|
au?: string;
|
||||||
|
length?: string;
|
||||||
|
offset?: string;
|
||||||
|
tool?: string;
|
||||||
|
ve?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedLrc extends LrcMetaData {
|
||||||
|
scripts?: ParserScriptItem[];
|
||||||
|
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LrcJsonData extends LrcMetaData {
|
||||||
|
scripts?: ScriptItem[];
|
||||||
|
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
@ -1,6 +1,7 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { globalMemoryStorage, songData, songNameCache } from '$lib/server/cache.js';
|
import { globalMemoryStorage, songData, songNameCache } from '$lib/server/cache.js';
|
||||||
import { getDirectoryHash } from '../dirHash';
|
import { getDirectoryHash } from '../dirHash';
|
||||||
|
import { safePath } from '../safePath';
|
||||||
|
|
||||||
const dataPath = './data/song/';
|
const dataPath = './data/song/';
|
||||||
|
|
||||||
@ -25,7 +26,12 @@ export async function loadData() {
|
|||||||
songNameCache.flushAll();
|
songNameCache.flushAll();
|
||||||
for (const songID of songList) {
|
for (const songID of songList) {
|
||||||
try {
|
try {
|
||||||
const fileContentString = fs.readFileSync(dataPath + songID + '.json').toString();
|
const normPath = safePath(songID + '.json', { base: dataPath });
|
||||||
|
if (!normPath) {
|
||||||
|
console.error(`[load-song-data] Invalid path for song ID ${songID}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const fileContentString = fs.readFileSync(normPath).toString();
|
||||||
const data = JSON.parse(fileContentString);
|
const data = JSON.parse(fileContentString);
|
||||||
songData.set(songID, data);
|
songData.set(songID, data);
|
||||||
const metadata: MusicMetadata = data;
|
const metadata: MusicMetadata = data;
|
||||||
|
1
src/lib/server/database/musicInfo.d.ts
vendored
1
src/lib/server/database/musicInfo.d.ts
vendored
@ -19,5 +19,6 @@ interface MusicMetadata {
|
|||||||
views: number | null;
|
views: number | null;
|
||||||
publishTime: string | null;
|
publishTime: string | null;
|
||||||
updateTime: string | null;
|
updateTime: string | null;
|
||||||
|
netEaseID: number | null;
|
||||||
lyric: string | null;
|
lyric: string | null;
|
||||||
}
|
}
|
@ -12,7 +12,7 @@ export function getDirectoryHash(dir: string): string {
|
|||||||
|
|
||||||
files.forEach(file => {
|
files.forEach(file => {
|
||||||
const filePath = path.join(currentDir, file);
|
const filePath = path.join(currentDir, file);
|
||||||
const stats = fs.statSync(filePath);
|
const stats = fs.lstatSync(filePath);
|
||||||
|
|
||||||
if (stats.isDirectory()) {
|
if (stats.isDirectory()) {
|
||||||
traverseDirectory(filePath);
|
traverseDirectory(filePath);
|
||||||
@ -30,7 +30,7 @@ export function getDirectoryHash(dir: string): string {
|
|||||||
|
|
||||||
// Create hash from file details
|
// Create hash from file details
|
||||||
const hash = crypto.createHash('sha256');
|
const hash = crypto.createHash('sha256');
|
||||||
hash.update(fileDetails.join('|'));
|
hash.update(fileDetails.join('\x00'));
|
||||||
|
|
||||||
return hash.digest('hex');
|
return hash.digest('hex');
|
||||||
}
|
}
|
22
src/lib/server/safePath.ts
Normal file
22
src/lib/server/safePath.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export function safePath(pathIn: string, options: { base: string, noSubDir?: boolean }): string | null {
|
||||||
|
const base = options.base.endsWith('/') ? options.base : options.base + '/';
|
||||||
|
if (!pathIn.startsWith("./")) {
|
||||||
|
pathIn = "./" + pathIn;
|
||||||
|
}
|
||||||
|
pathIn = path.join(base, pathIn);
|
||||||
|
const normBase = path.normalize(base);
|
||||||
|
const normPath = path.normalize(pathIn);
|
||||||
|
|
||||||
|
if (normPath !== normBase && normPath.startsWith(normBase)) {
|
||||||
|
if (options.noSubDir) {
|
||||||
|
let rel = path.relative(normBase, normPath);
|
||||||
|
if (rel.indexOf(path.sep) !== -1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normPath;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
3
src/lib/state/nextUpdate.ts
Normal file
3
src/lib/state/nextUpdate.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
const nextUpdate = writable(-1);
|
||||||
|
export default nextUpdate;
|
3
src/lib/ttml/index.ts
Normal file
3
src/lib/ttml/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./parser";
|
||||||
|
export * from "./writer";
|
||||||
|
export type * from "./ttml-types";
|
168
src/lib/ttml/parser.ts
Normal file
168
src/lib/ttml/parser.ts
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview
|
||||||
|
* 解析 TTML 歌词文档到歌词数组的解析器
|
||||||
|
* 用于解析从 Apple Music 来的歌词文件,且扩展并支持翻译和音译文本。
|
||||||
|
* @see https://www.w3.org/TR/2018/REC-ttml1-20181108/
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
LyricLine,
|
||||||
|
LyricWord,
|
||||||
|
TTMLLyric,
|
||||||
|
TTMLMetadata,
|
||||||
|
} from "./ttml-types";
|
||||||
|
|
||||||
|
const timeRegexp =
|
||||||
|
/^(((?<hour>[0-9]+):)?(?<min>[0-9]+):)?(?<sec>[0-9]+([.:]([0-9]+))?)/;
|
||||||
|
function parseTimespan(timeSpan: string): number {
|
||||||
|
const matches = timeRegexp.exec(timeSpan);
|
||||||
|
if (matches) {
|
||||||
|
const hour = Number(matches.groups?.hour || "0");
|
||||||
|
const min = Number(matches.groups?.min || "0");
|
||||||
|
const sec = Number(matches.groups?.sec.replace(/:/, ".") || "0");
|
||||||
|
return Math.floor((hour * 3600 + min * 60 + sec) * 1000);
|
||||||
|
}
|
||||||
|
throw new TypeError(`时间戳字符串解析失败:${timeSpan}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTTML(ttmlText: string): TTMLLyric {
|
||||||
|
const domParser = new DOMParser();
|
||||||
|
const ttmlDoc: XMLDocument = domParser.parseFromString(
|
||||||
|
ttmlText,
|
||||||
|
"application/xml",
|
||||||
|
);
|
||||||
|
|
||||||
|
let mainAgentId = "v1";
|
||||||
|
|
||||||
|
const metadata: TTMLMetadata[] = [];
|
||||||
|
for (const meta of ttmlDoc.querySelectorAll("meta")) {
|
||||||
|
if (meta.tagName === "amll:meta") {
|
||||||
|
const key = meta.getAttribute("key");
|
||||||
|
if (key) {
|
||||||
|
const value = meta.getAttribute("value");
|
||||||
|
if (value) {
|
||||||
|
const existing = metadata.find((m) => m.key === key);
|
||||||
|
if (existing) {
|
||||||
|
existing.value.push(value);
|
||||||
|
} else {
|
||||||
|
metadata.push({
|
||||||
|
key,
|
||||||
|
value: [value],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const agent of ttmlDoc.querySelectorAll("ttm\\:agent")) {
|
||||||
|
if (agent.getAttribute("type") === "person") {
|
||||||
|
const id = agent.getAttribute("xml:id");
|
||||||
|
if (id) {
|
||||||
|
mainAgentId = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lyricLines: LyricLine[] = [];
|
||||||
|
|
||||||
|
function parseParseLine(lineEl: Element, isBG = false, isDuet = false) {
|
||||||
|
const line: LyricLine = {
|
||||||
|
words: [],
|
||||||
|
translatedLyric: "",
|
||||||
|
romanLyric: "",
|
||||||
|
isBG,
|
||||||
|
isDuet:
|
||||||
|
!!lineEl.getAttribute("ttm:agent") &&
|
||||||
|
lineEl.getAttribute("ttm:agent") !== mainAgentId,
|
||||||
|
startTime: 0,
|
||||||
|
endTime: 0,
|
||||||
|
};
|
||||||
|
if (isBG) line.isDuet = isDuet;
|
||||||
|
let haveBg = false;
|
||||||
|
|
||||||
|
for (const wordNode of lineEl.childNodes) {
|
||||||
|
if (wordNode.nodeType === Node.TEXT_NODE) {
|
||||||
|
line.words?.push({
|
||||||
|
word: wordNode.textContent ?? "",
|
||||||
|
startTime: 0,
|
||||||
|
endTime: 0,
|
||||||
|
});
|
||||||
|
} else if (wordNode.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
const wordEl = wordNode as Element;
|
||||||
|
const role = wordEl.getAttribute("ttm:role");
|
||||||
|
|
||||||
|
if (wordEl.nodeName === "span" && role) {
|
||||||
|
if (role === "x-bg") {
|
||||||
|
parseParseLine(wordEl, true, line.isDuet);
|
||||||
|
haveBg = true;
|
||||||
|
} else if (role === "x-translation") {
|
||||||
|
line.translatedLyric = wordEl.innerHTML;
|
||||||
|
} else if (role === "x-roman") {
|
||||||
|
line.romanLyric = wordEl.innerHTML;
|
||||||
|
}
|
||||||
|
} else if (wordEl.hasAttribute("begin") && wordEl.hasAttribute("end")) {
|
||||||
|
const word: LyricWord = {
|
||||||
|
word: wordNode.textContent ?? "",
|
||||||
|
startTime: parseTimespan(wordEl.getAttribute("begin") ?? ""),
|
||||||
|
endTime: parseTimespan(wordEl.getAttribute("end") ?? ""),
|
||||||
|
};
|
||||||
|
const emptyBeat = wordEl.getAttribute("amll:empty-beat");
|
||||||
|
if (emptyBeat) {
|
||||||
|
word.emptyBeat = Number(emptyBeat);
|
||||||
|
}
|
||||||
|
line.words.push(word);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.isBG) {
|
||||||
|
const firstWord = line.words?.[0];
|
||||||
|
if (firstWord?.word.startsWith("(")) {
|
||||||
|
firstWord.word = firstWord.word.substring(1);
|
||||||
|
if (firstWord.word.length === 0) {
|
||||||
|
line.words.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastWord = line.words?.[line.words.length - 1];
|
||||||
|
if (lastWord?.word.endsWith(")")) {
|
||||||
|
lastWord.word = lastWord.word.substring(0, lastWord.word.length - 1);
|
||||||
|
if (lastWord.word.length === 0) {
|
||||||
|
line.words.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = lineEl.getAttribute("begin");
|
||||||
|
const endTime = lineEl.getAttribute("end");
|
||||||
|
if (startTime && endTime) {
|
||||||
|
line.startTime = parseTimespan(startTime);
|
||||||
|
line.endTime = parseTimespan(endTime);
|
||||||
|
} else {
|
||||||
|
line.startTime = line.words
|
||||||
|
.filter((v) => v.word.trim().length > 0)
|
||||||
|
.reduce((pv, cv) => Math.min(pv, cv.startTime), Infinity);
|
||||||
|
line.endTime = line.words
|
||||||
|
.filter((v) => v.word.trim().length > 0)
|
||||||
|
.reduce((pv, cv) => Math.max(pv, cv.endTime), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (haveBg) {
|
||||||
|
const bgLine = lyricLines.pop();
|
||||||
|
lyricLines.push(line);
|
||||||
|
if (bgLine) lyricLines.push(bgLine);
|
||||||
|
} else {
|
||||||
|
lyricLines.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const lineEl of ttmlDoc.querySelectorAll("body p[begin][end]")) {
|
||||||
|
parseParseLine(lineEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
metadata,
|
||||||
|
lyricLines: lyricLines,
|
||||||
|
};
|
||||||
|
}
|
26
src/lib/ttml/ttml-types.ts
Normal file
26
src/lib/ttml/ttml-types.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
export interface TTMLMetadata {
|
||||||
|
key: string;
|
||||||
|
value: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TTMLLyric {
|
||||||
|
metadata: TTMLMetadata[];
|
||||||
|
lyricLines: LyricLine[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LyricWord {
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
word: string;
|
||||||
|
emptyBeat?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LyricLine {
|
||||||
|
words: LyricWord[];
|
||||||
|
translatedLyric: string;
|
||||||
|
romanLyric: string;
|
||||||
|
isBG: boolean;
|
||||||
|
isDuet: boolean;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
}
|
260
src/lib/ttml/writer.ts
Normal file
260
src/lib/ttml/writer.ts
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview
|
||||||
|
* 用于将内部歌词数组对象导出成 TTML 格式的模块
|
||||||
|
* 但是可能会有信息会丢失
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { LyricLine, LyricWord, TTMLLyric } from "./ttml-types";
|
||||||
|
|
||||||
|
function msToTimestamp(timeMS: number): string {
|
||||||
|
let time = timeMS;
|
||||||
|
if (!Number.isSafeInteger(time) || time < 0) {
|
||||||
|
return "00:00.000";
|
||||||
|
}
|
||||||
|
if (time === Infinity) {
|
||||||
|
return "99:99.999";
|
||||||
|
}
|
||||||
|
time = time / 1000;
|
||||||
|
const secs = time % 60;
|
||||||
|
time = (time - secs) / 60;
|
||||||
|
const mins = time % 60;
|
||||||
|
const hrs = (time - mins) / 60;
|
||||||
|
|
||||||
|
const h = hrs.toString().padStart(2, "0");
|
||||||
|
const m = mins.toString().padStart(2, "0");
|
||||||
|
const s = secs.toFixed(3).padStart(6, "0");
|
||||||
|
|
||||||
|
if (hrs > 0) {
|
||||||
|
return `${h}:${m}:${s}`;
|
||||||
|
}
|
||||||
|
return `${m}:${s}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportTTML(ttmlLyric: TTMLLyric, pretty = false): string {
|
||||||
|
const params: LyricLine[][] = [];
|
||||||
|
const lyric = ttmlLyric.lyricLines;
|
||||||
|
|
||||||
|
let tmp: LyricLine[] = [];
|
||||||
|
for (const line of lyric) {
|
||||||
|
if (line.words.length === 0 && tmp.length > 0) {
|
||||||
|
params.push(tmp);
|
||||||
|
tmp = [];
|
||||||
|
} else {
|
||||||
|
tmp.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tmp.length > 0) {
|
||||||
|
params.push(tmp);
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = new Document();
|
||||||
|
|
||||||
|
function createWordElement(word: LyricWord): Element {
|
||||||
|
const span = doc.createElement("span");
|
||||||
|
span.setAttribute("begin", msToTimestamp(word.startTime));
|
||||||
|
span.setAttribute("end", msToTimestamp(word.endTime));
|
||||||
|
if (word.emptyBeat) {
|
||||||
|
span.setAttribute("amll:empty-beat", `${word.emptyBeat}`);
|
||||||
|
}
|
||||||
|
span.appendChild(doc.createTextNode(word.word));
|
||||||
|
return span;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ttRoot = doc.createElement("tt");
|
||||||
|
|
||||||
|
ttRoot.setAttribute("xmlns", "http://www.w3.org/ns/ttml");
|
||||||
|
ttRoot.setAttribute("xmlns:ttm", "http://www.w3.org/ns/ttml#metadata");
|
||||||
|
ttRoot.setAttribute("xmlns:amll", "http://www.example.com/ns/amll");
|
||||||
|
ttRoot.setAttribute(
|
||||||
|
"xmlns:itunes",
|
||||||
|
"http://music.apple.com/lyric-ttml-internal",
|
||||||
|
);
|
||||||
|
|
||||||
|
doc.appendChild(ttRoot);
|
||||||
|
|
||||||
|
const head = doc.createElement("head");
|
||||||
|
|
||||||
|
ttRoot.appendChild(head);
|
||||||
|
|
||||||
|
const body = doc.createElement("body");
|
||||||
|
const hasOtherPerson = !!lyric.find((v) => v.isDuet);
|
||||||
|
|
||||||
|
const metadataEl = doc.createElement("metadata");
|
||||||
|
const mainPersonAgent = doc.createElement("ttm:agent");
|
||||||
|
mainPersonAgent.setAttribute("type", "person");
|
||||||
|
mainPersonAgent.setAttribute("xml:id", "v1");
|
||||||
|
|
||||||
|
metadataEl.appendChild(mainPersonAgent);
|
||||||
|
|
||||||
|
if (hasOtherPerson) {
|
||||||
|
const otherPersonAgent = doc.createElement("ttm:agent");
|
||||||
|
otherPersonAgent.setAttribute("type", "other");
|
||||||
|
otherPersonAgent.setAttribute("xml:id", "v2");
|
||||||
|
|
||||||
|
metadataEl.appendChild(otherPersonAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const metadata of ttmlLyric.metadata) {
|
||||||
|
for (const value of metadata.value) {
|
||||||
|
const metaEl = doc.createElement("amll:meta");
|
||||||
|
metaEl.setAttribute("key", metadata.key);
|
||||||
|
metaEl.setAttribute("value", value);
|
||||||
|
metadataEl.appendChild(metaEl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
head.appendChild(metadataEl);
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
const guessDuration = lyric[lyric.length - 1]?.endTime ?? 0;
|
||||||
|
body.setAttribute("dur", msToTimestamp(guessDuration));
|
||||||
|
|
||||||
|
for (const param of params) {
|
||||||
|
const paramDiv = doc.createElement("div");
|
||||||
|
const beginTime = param[0]?.startTime ?? 0;
|
||||||
|
const endTime = param[param.length - 1]?.endTime ?? 0;
|
||||||
|
|
||||||
|
paramDiv.setAttribute("begin", msToTimestamp(beginTime));
|
||||||
|
paramDiv.setAttribute("end", msToTimestamp(endTime));
|
||||||
|
|
||||||
|
for (let lineIndex = 0; lineIndex < param.length; lineIndex++) {
|
||||||
|
const line = param[lineIndex];
|
||||||
|
const lineP = doc.createElement("p");
|
||||||
|
const beginTime = line.startTime ?? 0;
|
||||||
|
const endTime = line.endTime;
|
||||||
|
|
||||||
|
lineP.setAttribute("begin", msToTimestamp(beginTime));
|
||||||
|
lineP.setAttribute("end", msToTimestamp(endTime));
|
||||||
|
|
||||||
|
lineP.setAttribute("ttm:agent", line.isDuet ? "v2" : "v1");
|
||||||
|
lineP.setAttribute("itunes:key", `L${++i}`);
|
||||||
|
|
||||||
|
if (line.words.length > 1) {
|
||||||
|
let beginTime = Infinity;
|
||||||
|
let endTime = 0;
|
||||||
|
for (const word of line.words) {
|
||||||
|
if (word.word.trim().length === 0) {
|
||||||
|
lineP.appendChild(doc.createTextNode(word.word));
|
||||||
|
} else {
|
||||||
|
const span = createWordElement(word);
|
||||||
|
lineP.appendChild(span);
|
||||||
|
beginTime = Math.min(beginTime, word.startTime);
|
||||||
|
endTime = Math.max(endTime, word.endTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lineP.setAttribute("begin", msToTimestamp(line.startTime));
|
||||||
|
lineP.setAttribute("end", msToTimestamp(line.endTime));
|
||||||
|
} else if (line.words.length === 1) {
|
||||||
|
const word = line.words[0];
|
||||||
|
lineP.appendChild(doc.createTextNode(word.word));
|
||||||
|
lineP.setAttribute("begin", msToTimestamp(word.startTime));
|
||||||
|
lineP.setAttribute("end", msToTimestamp(word.endTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextLine = param[lineIndex + 1];
|
||||||
|
if (nextLine?.isBG) {
|
||||||
|
lineIndex++;
|
||||||
|
const bgLine = nextLine;
|
||||||
|
const bgLineSpan = doc.createElement("span");
|
||||||
|
bgLineSpan.setAttribute("ttm:role", "x-bg");
|
||||||
|
|
||||||
|
if (bgLine.words.length > 1) {
|
||||||
|
let beginTime = Infinity;
|
||||||
|
let endTime = 0;
|
||||||
|
for (
|
||||||
|
let wordIndex = 0;
|
||||||
|
wordIndex < bgLine.words.length;
|
||||||
|
wordIndex++
|
||||||
|
) {
|
||||||
|
const word = bgLine.words[wordIndex];
|
||||||
|
if (word.word.trim().length === 0) {
|
||||||
|
bgLineSpan.appendChild(doc.createTextNode(word.word));
|
||||||
|
} else {
|
||||||
|
const span = createWordElement(word);
|
||||||
|
if (wordIndex === 0) {
|
||||||
|
span.prepend(doc.createTextNode("("));
|
||||||
|
} else if (wordIndex === bgLine.words.length - 1) {
|
||||||
|
span.appendChild(doc.createTextNode(")"));
|
||||||
|
}
|
||||||
|
bgLineSpan.appendChild(span);
|
||||||
|
beginTime = Math.min(beginTime, word.startTime);
|
||||||
|
endTime = Math.max(endTime, word.endTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bgLineSpan.setAttribute("begin", msToTimestamp(beginTime));
|
||||||
|
bgLineSpan.setAttribute("end", msToTimestamp(endTime));
|
||||||
|
} else if (bgLine.words.length === 1) {
|
||||||
|
const word = bgLine.words[0];
|
||||||
|
bgLineSpan.appendChild(doc.createTextNode(`(${word.word})`));
|
||||||
|
bgLineSpan.setAttribute("begin", msToTimestamp(word.startTime));
|
||||||
|
bgLineSpan.setAttribute("end", msToTimestamp(word.endTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bgLine.translatedLyric) {
|
||||||
|
const span = doc.createElement("span");
|
||||||
|
span.setAttribute("ttm:role", "x-translation");
|
||||||
|
span.setAttribute("xml:lang", "zh-CN");
|
||||||
|
span.appendChild(doc.createTextNode(bgLine.translatedLyric));
|
||||||
|
bgLineSpan.appendChild(span);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bgLine.romanLyric) {
|
||||||
|
const span = doc.createElement("span");
|
||||||
|
span.setAttribute("ttm:role", "x-roman");
|
||||||
|
span.appendChild(doc.createTextNode(bgLine.romanLyric));
|
||||||
|
bgLineSpan.appendChild(span);
|
||||||
|
}
|
||||||
|
|
||||||
|
lineP.appendChild(bgLineSpan);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.translatedLyric) {
|
||||||
|
const span = doc.createElement("span");
|
||||||
|
span.setAttribute("ttm:role", "x-translation");
|
||||||
|
span.setAttribute("xml:lang", "zh-CN");
|
||||||
|
span.appendChild(doc.createTextNode(line.translatedLyric));
|
||||||
|
lineP.appendChild(span);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.romanLyric) {
|
||||||
|
const span = doc.createElement("span");
|
||||||
|
span.setAttribute("ttm:role", "x-roman");
|
||||||
|
span.appendChild(doc.createTextNode(line.romanLyric));
|
||||||
|
lineP.appendChild(span);
|
||||||
|
}
|
||||||
|
|
||||||
|
paramDiv.appendChild(lineP);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.appendChild(paramDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
ttRoot.appendChild(body);
|
||||||
|
|
||||||
|
if (pretty) {
|
||||||
|
const xsltDoc = new DOMParser().parseFromString(
|
||||||
|
[
|
||||||
|
'<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0">',
|
||||||
|
' <xsl:strip-space elements="*"/>',
|
||||||
|
' <xsl:template match="para[content-style][not(text())]">',
|
||||||
|
' <xsl:value-of select="normalize-space(.)"/>',
|
||||||
|
" </xsl:template>",
|
||||||
|
' <xsl:template match="node()|@*">',
|
||||||
|
' <xsl:copy><xsl:apply-templates select="node()|@*"/></xsl:copy>',
|
||||||
|
" </xsl:template>",
|
||||||
|
' <xsl:output indent="yes"/>',
|
||||||
|
"</xsl:stylesheet>",
|
||||||
|
].join("\n"),
|
||||||
|
"application/xml",
|
||||||
|
);
|
||||||
|
|
||||||
|
const xsltProcessor = new XSLTProcessor();
|
||||||
|
xsltProcessor.importStylesheet(xsltDoc);
|
||||||
|
const resultDoc = xsltProcessor.transformToDocument(doc);
|
||||||
|
|
||||||
|
return new XMLSerializer().serializeToString(resultDoc);
|
||||||
|
}
|
||||||
|
return new XMLSerializer().serializeToString(doc);
|
||||||
|
}
|
5
src/lib/utils/getVersion.ts
Normal file
5
src/lib/utils/getVersion.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import * as pjson from "../../../package.json";
|
||||||
|
|
||||||
|
export default function getVersion(){
|
||||||
|
return pjson.version;
|
||||||
|
}
|
@ -1,6 +1,6 @@
|
|||||||
export default function toHumanSize(size: number | undefined){
|
export default function toHumanSize(size: number | undefined){
|
||||||
if (!size) return '0 B'
|
if (!size) return '0 B'
|
||||||
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
let unitIndex = 0;
|
let unitIndex = 0;
|
||||||
while (size >= 1000 && unitIndex < units.length - 1) {
|
while (size >= 1000 && unitIndex < units.length - 1) {
|
||||||
size /= 1000;
|
size /= 1000;
|
3
src/lib/utils/truncate.ts
Normal file
3
src/lib/utils/truncate.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export default function truncate(value: number, min: number, max: number) {
|
||||||
|
return Math.min(Math.max(value, min), max);
|
||||||
|
}
|
@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import extractFileName from '$lib/extractFileName';
|
import extractFileName from '$lib/utils/extractFileName';
|
||||||
import toHumanSize from '$lib/humanSize';
|
import getVersion from '$lib/utils/getVersion';
|
||||||
import localforage from '$lib/storage';
|
import toHumanSize from '$lib/utils/humanSize';
|
||||||
|
import localforage from '$lib/utils/storage';
|
||||||
interface Song {
|
interface Song {
|
||||||
name: string;
|
name: string;
|
||||||
singer?: string;
|
singer?: string;
|
||||||
@ -77,7 +78,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
AquaVox 1.10.1 · 早期公开预览 · 源代码参见
|
AquaVox {getVersion()} · 早期公开预览 · 源代码参见
|
||||||
<a href="https://github.com/alikia2x/aquavox">GitHub</a>
|
<a href="https://github.com/alikia2x/aquavox">GitHub</a>
|
||||||
</p>
|
</p>
|
||||||
<a href="/import">导入音乐</a> <br />
|
<a href="/import">导入音乐</a> <br />
|
||||||
|
@ -1,16 +1,17 @@
|
|||||||
import { songNameCache } from '$lib/server/cache.js';
|
import { songNameCache } from '$lib/server/cache.js';
|
||||||
import { loadData } from '$lib/server/database/loadData';
|
import { loadData } from '$lib/server/database/loadData';
|
||||||
import { json, error } from '@sveltejs/kit';
|
import { error, json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export async function GET({ url }) {
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
const keyword = url.searchParams.get("keyword");
|
const keyword = url.searchParams.get('keyword');
|
||||||
|
|
||||||
loadData();
|
await loadData();
|
||||||
|
|
||||||
if (keyword === null) {
|
if (keyword === null) {
|
||||||
return error(400, {
|
return error(400, {
|
||||||
"message": "Miss parameter: keyword"
|
'message': 'Miss parameter: keyword'
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const resultList: MusicMetadata[] = [];
|
const resultList: MusicMetadata[] = [];
|
||||||
@ -22,6 +23,6 @@ export async function GET({ url }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
"result": resultList
|
'result': resultList
|
||||||
});
|
});
|
||||||
}
|
};
|
@ -1,30 +1,43 @@
|
|||||||
import { getCurrentFormattedDateTime } from '$lib/songUpdateTime';
|
import { safePath } from '$lib/server/safePath';
|
||||||
|
import { getCurrentFormattedDateTime } from '$lib/utils/songUpdateTime';
|
||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export async function GET({ params }) {
|
export const GET: RequestHandler = async ({ params }) => {
|
||||||
const filePath = `./data/song/${params.id}.json`;
|
const filePath = safePath(`${params.id}.json`, { base: './data/song' });
|
||||||
if (!fs.existsSync(filePath)) {
|
if (!filePath) {
|
||||||
return error(404, {
|
return error(404, {
|
||||||
message: "No correspoding song."
|
message: "No correspoding song."
|
||||||
})
|
});
|
||||||
|
}
|
||||||
|
let data;
|
||||||
|
try { data = fs.readFileSync(filePath); } catch (e) {
|
||||||
|
return error(404, {
|
||||||
|
message: "No corresponding song."
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const data = fs.readFileSync(filePath);
|
|
||||||
return json(JSON.parse(data.toString()));
|
return json(JSON.parse(data.toString()));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST({ params, request }) {
|
export const POST: RequestHandler = async ({ request, params }) => {
|
||||||
const timeStamp = new Date().getTime();
|
const timeStamp = new Date().getTime();
|
||||||
|
try {
|
||||||
if (!fs.existsSync("./data/pending/")) {
|
if (!fs.existsSync("./data/pending/")) {
|
||||||
fs.mkdirSync("./data/pending");
|
fs.mkdirSync("./data/pending", { mode: 0o755 });
|
||||||
}
|
}
|
||||||
const filePath = `./data/pending/${params.id}-${timeStamp}.json`;
|
const filePath = `./data/pending/${params.id}-${timeStamp}.json`;
|
||||||
const data: MusicMetadata = await request.json();
|
const data: MusicMetadata = await request.json();
|
||||||
data.updateTime = getCurrentFormattedDateTime();
|
data.updateTime = getCurrentFormattedDateTime();
|
||||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 4));
|
fs.writeFileSync(filePath, JSON.stringify(data, null, 4), { mode: 0o644 });
|
||||||
return json({
|
return json({
|
||||||
"message": "successfully created"
|
"message": "successfully created"
|
||||||
}, {
|
}, {
|
||||||
status: 201
|
status: 201
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return error(500, {
|
||||||
|
message: "Internal server error."
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
@ -1,8 +1,9 @@
|
|||||||
import { songData } from '$lib/server/cache.js';
|
import { songData } from '$lib/server/cache.js';
|
||||||
import { loadData } from '$lib/server/database/loadData.js';
|
import { loadData } from '$lib/server/database/loadData.js';
|
||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export async function GET({ url }) {
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
const limit = parseInt(url.searchParams.get("limit") ?? "20");
|
const limit = parseInt(url.searchParams.get("limit") ?? "20");
|
||||||
const offset = parseInt(url.searchParams.get("offset") ?? "0");
|
const offset = parseInt(url.searchParams.get("offset") ?? "0");
|
||||||
loadData();
|
loadData();
|
||||||
|
17
src/routes/database/+page.server.ts
Normal file
17
src/routes/database/+page.server.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { songData } from '$lib/server/cache.js';
|
||||||
|
import { loadData } from '$lib/server/database/loadData.js';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = () => {
|
||||||
|
loadData();
|
||||||
|
const songIDList = songData.keys().slice(0, 20);
|
||||||
|
const songDataList = [];
|
||||||
|
for (const songID of songIDList) {
|
||||||
|
songDataList.push(songData.get(songID)!);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
songDataList: songDataList
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ssr = true;
|
@ -1,20 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import formatDuration from '$lib/formatDuration';
|
import SongCard from '$lib/components/database/songCard.svelte';
|
||||||
import { formatViews } from '$lib/formatViews';
|
import type { PageServerData } from './$types';
|
||||||
import { onMount } from 'svelte';
|
export let data: PageServerData;
|
||||||
let songList: MusicMetadata[] = [];
|
let songList: MusicMetadata[] = data.songDataList;
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
fetch('/api/database/songs')
|
|
||||||
.then((response) => response.json())
|
|
||||||
.then((data) => {
|
|
||||||
songList = data;
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.log(error);
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@ -33,57 +21,14 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid mb-32" style="grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
|
<div
|
||||||
|
class="relative grid mb-32"
|
||||||
|
style="grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 2rem 1rem;">
|
gap: 2rem 1rem;"
|
||||||
|
>
|
||||||
{#each songList as song}
|
{#each songList as song}
|
||||||
<a
|
<SongCard songData={song}/>
|
||||||
class="relative w-56 h-56 bg-zinc-300 dark:bg-zinc-600 rounded-lg overflow-hidden
|
|
||||||
shadow-lg cursor-pointer justify-self-center"
|
|
||||||
href={song.url}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="absolute top-0 left-0 w-full h-full duration-100
|
|
||||||
z-10 opacity-0 hover:opacity-100 bg-[rgba(0,0,0,0.15)]"
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
class="brightness-125 absolute top-2 right-2 w-8 h-8 rounded-full
|
|
||||||
bg-[rgba(49,49,49,0.7)] backdrop-blur-lg z-10 hover:bg-red-500"
|
|
||||||
href={`/database/edit/${song.id}`}
|
|
||||||
>
|
|
||||||
<img class="relative w-4 h-4 top-2 left-2 scale-90" src="/edit.svg" alt="编辑" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<img src={song.coverURL[0]} class="w-56 h-56" alt="" />
|
|
||||||
<div
|
|
||||||
class="absolute bottom-0 w-full h-28 backdrop-blur-xl"
|
|
||||||
style="mask-image: linear-gradient(to top, black 50%, transparent);"
|
|
||||||
>
|
|
||||||
<div class="absolute bottom-0 w-full h-16 pl-2">
|
|
||||||
<span
|
|
||||||
class="font-semibold text-2xl text-white"
|
|
||||||
style="text-shadow: 0px 0px 4px rgba(65, 65, 65, .6);">{song.name}</span
|
|
||||||
>
|
|
||||||
<br />
|
|
||||||
<span
|
|
||||||
class="relative inline-block whitespace-nowrap text-white w-40
|
|
||||||
overflow-hidden text-ellipsis"
|
|
||||||
style="text-shadow: 0px 0px 4px rgba(65, 65, 65, .6);"
|
|
||||||
>
|
|
||||||
{song.singer.join(', ')}
|
|
||||||
</span>
|
|
||||||
<div class="absolute right-2 bottom-2 text-right">
|
|
||||||
{#if song.duration}
|
|
||||||
<span style="text-shadow: 0px 0px 4px rgba(65, 65, 65, .6);">{formatDuration(song.duration)}</span>
|
|
||||||
{/if}
|
|
||||||
<br />
|
|
||||||
{#if song.views}
|
|
||||||
<span style="text-shadow: 0px 0px 4px rgba(65, 65, 65, .6);">{formatViews(song.views)}播放</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,22 +1,21 @@
|
|||||||
/** @type {import('./$types').PageLoad} */
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { safePath } from '$lib/server/safePath';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = ({ params }) => {
|
||||||
export function load({ params }) {
|
const filePath = safePath(`${params.id}.json`, { base: './data/song' });
|
||||||
const filePath = `./data/song/${params.id}.json`;
|
if (!filePath) {
|
||||||
if (!fs.existsSync(filePath)) {
|
|
||||||
return {
|
return {
|
||||||
songData: null
|
songData: null
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
const dataBuffer = fs.readFileSync(filePath);
|
|
||||||
try {
|
try {
|
||||||
|
const dataBuffer = fs.readFileSync(filePath);
|
||||||
const data = JSON.parse(dataBuffer.toString());
|
const data = JSON.parse(dataBuffer.toString());
|
||||||
return {
|
return {
|
||||||
songData: data
|
songData: data
|
||||||
};
|
};
|
||||||
}
|
} catch {
|
||||||
catch {
|
|
||||||
return {
|
return {
|
||||||
songData: null
|
songData: null
|
||||||
}
|
}
|
||||||
|
18
src/routes/database/page/[id]/+page.server.ts
Normal file
18
src/routes/database/page/[id]/+page.server.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { songData } from '$lib/server/cache.js';
|
||||||
|
import { loadData } from '$lib/server/database/loadData.js';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = ({ params }) => {
|
||||||
|
const offset = (parseInt(params.id) - 1) * 20;
|
||||||
|
loadData();
|
||||||
|
const songIDList = songData.keys().slice(offset, offset + 20);
|
||||||
|
const songDataList = [];
|
||||||
|
for (const songID of songIDList) {
|
||||||
|
songDataList.push(songData.get(songID)!);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
songDataList: songDataList
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ssr = true;
|
34
src/routes/database/page/[id]/+page.svelte
Normal file
34
src/routes/database/page/[id]/+page.svelte
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import SongCard from '$lib/components/database/songCard.svelte';
|
||||||
|
import type { PageServerData } from './$types';
|
||||||
|
export let data: PageServerData;
|
||||||
|
let songList: MusicMetadata[] = data.songDataList;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>AquaVox 音乐数据库</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<h1 class="text-3xl text-red-500"><a href="/">AquaVox</a></h1>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between items-center h-20 mb-8">
|
||||||
|
<h1>AquaVox 音乐数据库</h1>
|
||||||
|
<a
|
||||||
|
href="/database/submit"
|
||||||
|
class="h-12 w-24 border-black dark:border-white border-2 flex items-center justify-center rounded-lg"
|
||||||
|
>提交新曲</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="relative grid mb-32"
|
||||||
|
style="grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 2rem 1rem;"
|
||||||
|
>
|
||||||
|
{#each songList as song}
|
||||||
|
<SongCard songData={song}/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { getCurrentFormattedDateTime } from '$lib/songUpdateTime';
|
import { getCurrentFormattedDateTime } from '$lib/utils/songUpdateTime';
|
||||||
let templateSongData: MusicMetadata = {
|
let templateSongData: MusicMetadata = {
|
||||||
id: '',
|
id: '',
|
||||||
name: '',
|
name: '',
|
||||||
@ -22,6 +22,7 @@
|
|||||||
views: null,
|
views: null,
|
||||||
publishTime: null,
|
publishTime: null,
|
||||||
updateTime: getCurrentFormattedDateTime(),
|
updateTime: getCurrentFormattedDateTime(),
|
||||||
|
netEaseID: null,
|
||||||
lyric: null
|
lyric: null
|
||||||
};
|
};
|
||||||
let editingData: string = JSON.stringify(templateSongData, null, 8);
|
let editingData: string = JSON.stringify(templateSongData, null, 8);
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import FileList from '$lib/components/import/fileList.svelte';
|
import FileList from '$lib/components/import/fileList.svelte';
|
||||||
import FileSelector from '$lib/components/import/fileSelector.svelte';
|
import FileSelector from '$lib/components/import/fileSelector.svelte';
|
||||||
import localforage from '$lib/storage';
|
import localforage from '$lib/utils/storage';
|
||||||
import { fileListState } from '$lib/state/fileList.state';
|
import { fileListState } from '$lib/state/fileList.state';
|
||||||
import { useAtom } from 'jotai-svelte';
|
import { useAtom } from 'jotai-svelte';
|
||||||
const fileList = useAtom(fileListState);
|
const fileList = useAtom(fileListState);
|
||||||
@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
<div class="w-full flex my-3">
|
<div class="w-full flex my-3">
|
||||||
<h2>歌词文件</h2>
|
<h2>歌词文件</h2>
|
||||||
<FileSelector accept=".lrc" class="ml-auto top-2 relative" />
|
<FileSelector accept=".lrc, .ttml" class="ml-auto top-2 relative" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FileList />
|
<FileList />
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
import { fileListState, finalFileListState } from '$lib/state/fileList.state';
|
import { fileListState, finalFileListState } from '$lib/state/fileList.state';
|
||||||
import { localImportFailed, localImportSuccess } from '$lib/state/localImportStatus.state';
|
import { localImportFailed, localImportSuccess } from '$lib/state/localImportStatus.state';
|
||||||
import { useAtom } from 'jotai-svelte';
|
import { useAtom } from 'jotai-svelte';
|
||||||
import localforage from '$lib/storage';
|
import localforage from '$lib/utils/storage';
|
||||||
import { v1 as uuidv1 } from 'uuid';
|
import { v1 as uuidv1 } from 'uuid';
|
||||||
const fileList = useAtom(fileListState);
|
const fileList = useAtom(fileListState);
|
||||||
const finalFiles = useAtom(finalFileListState);
|
const finalFiles = useAtom(finalFileListState);
|
||||||
|
@ -4,16 +4,20 @@
|
|||||||
import Background from '$lib/components/background.svelte';
|
import Background from '$lib/components/background.svelte';
|
||||||
import Cover from '$lib/components/cover.svelte';
|
import Cover from '$lib/components/cover.svelte';
|
||||||
import InteractiveBox from '$lib/components/interactiveBox.svelte';
|
import InteractiveBox from '$lib/components/interactiveBox.svelte';
|
||||||
import Lyrics from '$lib/components/lyrics.svelte';
|
import extractFileName from '$lib/utils/extractFileName';
|
||||||
import extractFileName from '$lib/extractFileName';
|
|
||||||
import localforage from 'localforage';
|
import localforage from 'localforage';
|
||||||
import { writable } from 'svelte/store';
|
import { writable } from 'svelte/store';
|
||||||
import lrcParser, { type LrcJsonData } from 'lrc-parser-ts';
|
import lrcParser from '$lib/lyrics/lrc/parser';
|
||||||
|
import type { LrcJsonData } from '$lib/lyrics/type';
|
||||||
import userAdjustingProgress from '$lib/state/userAdjustingProgress';
|
import userAdjustingProgress from '$lib/state/userAdjustingProgress';
|
||||||
import type { IAudioMetadata } from 'music-metadata-browser';
|
import type { IAudioMetadata } from 'music-metadata-browser';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import progressBarRaw from '$lib/state/progressBarRaw';
|
||||||
|
import { parseTTML, type LyricLine } from '$lib/lyrics/ttml';
|
||||||
|
import NewLyrics from '$lib/components/lyrics/newLyrics.svelte';
|
||||||
|
|
||||||
const audioId = $page.params.id;
|
const audioId = $page.params.id;
|
||||||
let audioPlayer: HTMLAudioElement;
|
let audioPlayer: HTMLAudioElement | null = null;
|
||||||
let volume = 1;
|
let volume = 1;
|
||||||
let name = '';
|
let name = '';
|
||||||
let singer = '';
|
let singer = '';
|
||||||
@ -42,22 +46,26 @@
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
ms.setActionHandler('play', function () {
|
ms.setActionHandler('play', function () {
|
||||||
|
if (audioPlayer === null) return;
|
||||||
audioPlayer.play();
|
audioPlayer.play();
|
||||||
paused = false;
|
paused = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
ms.setActionHandler('pause', function () {
|
ms.setActionHandler('pause', function () {
|
||||||
|
if (audioPlayer === null) return;
|
||||||
audioPlayer.pause();
|
audioPlayer.pause();
|
||||||
paused = true;
|
paused = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
ms.setActionHandler('seekbackward', function () {
|
ms.setActionHandler('seekbackward', function () {
|
||||||
|
if (audioPlayer === null) return;
|
||||||
if (audioPlayer.currentTime > 4) {
|
if (audioPlayer.currentTime > 4) {
|
||||||
audioPlayer.currentTime = 0;
|
audioPlayer.currentTime = 0;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ms.setActionHandler('previoustrack', function () {
|
ms.setActionHandler('previoustrack', function () {
|
||||||
|
if (audioPlayer === null) return;
|
||||||
if (audioPlayer.currentTime > 4) {
|
if (audioPlayer.currentTime > 4) {
|
||||||
audioPlayer.currentTime = 0;
|
audioPlayer.currentTime = 0;
|
||||||
}
|
}
|
||||||
@ -79,6 +87,7 @@
|
|||||||
prepared.push('cover');
|
prepared.push('cover');
|
||||||
});
|
});
|
||||||
localforage.getItem(`${audioId}-file`, function (err, file) {
|
localforage.getItem(`${audioId}-file`, function (err, file) {
|
||||||
|
if (audioPlayer === null) return;
|
||||||
if (file) {
|
if (file) {
|
||||||
const f = file as File;
|
const f = file as File;
|
||||||
audioFile = f;
|
audioFile = f;
|
||||||
@ -92,17 +101,26 @@
|
|||||||
if (file) {
|
if (file) {
|
||||||
const f = file as File;
|
const f = file as File;
|
||||||
f.text().then((lr) => {
|
f.text().then((lr) => {
|
||||||
|
if (f.name.endsWith('.ttml')) {
|
||||||
|
originalLyrics = parseTTML(lr);
|
||||||
|
for (const line of originalLyrics.scripts!) {
|
||||||
|
lyricsText.push(line.text);
|
||||||
|
}
|
||||||
|
hasLyrics = true;
|
||||||
|
} else if (f.name.endsWith('.lrc')) {
|
||||||
originalLyrics = lrcParser(lr);
|
originalLyrics = lrcParser(lr);
|
||||||
if (!originalLyrics.scripts) return;
|
if (!originalLyrics.scripts) return;
|
||||||
for (const line of originalLyrics.scripts) {
|
for (const line of originalLyrics.scripts) {
|
||||||
lyricsText.push(line.text);
|
lyricsText.push(line.text);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function playAudio() {
|
function playAudio() {
|
||||||
|
if (audioPlayer === null) return;
|
||||||
if (audioPlayer.duration) {
|
if (audioPlayer.duration) {
|
||||||
duration = audioPlayer.duration;
|
duration = audioPlayer.duration;
|
||||||
}
|
}
|
||||||
@ -112,7 +130,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
$: {
|
$: {
|
||||||
if (!launched) {
|
if (!launched && audioPlayer) {
|
||||||
const requirements = ['name', 'file', 'cover'];
|
const requirements = ['name', 'file', 'cover'];
|
||||||
let flag = true;
|
let flag = true;
|
||||||
for (const r of requirements) {
|
for (const r of requirements) {
|
||||||
@ -150,21 +168,14 @@
|
|||||||
$: {
|
$: {
|
||||||
clearInterval(mainInterval);
|
clearInterval(mainInterval);
|
||||||
mainInterval = setInterval(() => {
|
mainInterval = setInterval(() => {
|
||||||
if (
|
if (audioPlayer === null) return;
|
||||||
audioPlayer !== null &&
|
if ($userAdjustingProgress === false) currentProgress = audioPlayer.currentTime;
|
||||||
audioPlayer.currentTime !== undefined
|
|
||||||
) {
|
|
||||||
if ($userAdjustingProgress === false)
|
|
||||||
currentProgress = audioPlayer.currentTime;
|
|
||||||
progressBarRaw.set(audioPlayer.currentTime);
|
progressBarRaw.set(audioPlayer.currentTime);
|
||||||
}
|
|
||||||
}, 50);
|
}, 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import progressBarRaw from '$lib/state/progressBarRaw';
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
if (audioPlayer === null) return;
|
||||||
audioPlayer.volume = localStorage.getItem('volume') ? Number(localStorage.getItem('volume')) : 1;
|
audioPlayer.volume = localStorage.getItem('volume') ? Number(localStorage.getItem('volume')) : 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -175,19 +186,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$: {
|
$: hasLyrics = !!originalLyrics;
|
||||||
if (originalLyrics) {
|
|
||||||
hasLyrics = true;
|
|
||||||
} else {
|
|
||||||
hasLyrics = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
readDB();
|
readDB();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{name} - Aquavox</title>
|
<title>{name} - AquaVox</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<Background coverId={audioId} />
|
<Background coverId={audioId} />
|
||||||
@ -206,14 +211,25 @@
|
|||||||
{hasLyrics}
|
{hasLyrics}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Lyrics lyrics={lyricsText} {originalLyrics} progress={currentProgress}/>
|
<NewLyrics {originalLyrics} progress={currentProgress} player={audioPlayer}/>
|
||||||
|
|
||||||
|
<!-- <Lyrics lyrics={lyricsText} {originalLyrics} progress={currentProgress} player={audioPlayer} class="hidden" /> -->
|
||||||
|
|
||||||
<audio
|
<audio
|
||||||
bind:this={audioPlayer}
|
bind:this={audioPlayer}
|
||||||
controls
|
controls
|
||||||
style="display: none"
|
style="display: none"
|
||||||
|
on:play={() => {
|
||||||
|
if (audioPlayer === null) return;
|
||||||
|
paused = audioPlayer.paused;
|
||||||
|
}}
|
||||||
|
on:pause={() => {
|
||||||
|
if (audioPlayer === null) return;
|
||||||
|
paused = audioPlayer.paused;
|
||||||
|
}}
|
||||||
on:ended={() => {
|
on:ended={() => {
|
||||||
paused = true;
|
paused = true;
|
||||||
|
if (audioPlayer == null) return;
|
||||||
audioPlayer.pause();
|
audioPlayer.pause();
|
||||||
}}
|
}}
|
||||||
></audio>
|
></audio>
|
||||||
|
87
src/test/lrcParser.test.ts
Normal file
87
src/test/lrcParser.test.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import fs from 'fs';
|
||||||
|
import { parseLRC } from '$lib/lyrics/lrc/parser';
|
||||||
|
|
||||||
|
describe('LRC parser test', () => {
|
||||||
|
const test01Buffer = fs.readFileSync('./src/test/resources/test-01.lrc');
|
||||||
|
const test01Text = test01Buffer.toString('utf-8');
|
||||||
|
const test02Buffer = fs.readFileSync('./src/test/resources/test-02.lrc');
|
||||||
|
const test02Text = test02Buffer.toString('utf-8');
|
||||||
|
const test03Buffer = fs.readFileSync('./src/test/resources/test-03.lrc');
|
||||||
|
const test03Text = test03Buffer.toString('utf-8');
|
||||||
|
|
||||||
|
const lf_alternatives = ['\n', '\r\n', '\r'];
|
||||||
|
|
||||||
|
it('Parses test-01.lrc', () => {
|
||||||
|
for (const lf of lf_alternatives) {
|
||||||
|
const text = test01Text.replaceAll('\n', lf);
|
||||||
|
|
||||||
|
const result = parseLRC(text, { wordDiv: '', strict: true });
|
||||||
|
|
||||||
|
expect(result.ar).toBe("洛天依");
|
||||||
|
expect(result.ti).toBe("中华少女·终");
|
||||||
|
expect(result.al).toBe("中华少女");
|
||||||
|
expect(result["tool"]).toBe("歌词滚动姬 https://lrc-maker.github.io");
|
||||||
|
expect(result.scripts!![1].text).toBe("因果与恩怨牵杂等谁来诊断");
|
||||||
|
expect(result.scripts!![1].start).toBe(49000 + 588);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
it('Parses test-02.lrc', () => {
|
||||||
|
const result = parseLRC(test02Text, { wordDiv: ' ', strict: true });
|
||||||
|
|
||||||
|
expect(result.ti).toBe("Somebody to Love");
|
||||||
|
expect(result.ar).toBe("Jefferson Airplane");
|
||||||
|
expect(result.scripts!!.length).toBe(3);
|
||||||
|
expect(result.scripts!![0].text).toBe("When the truth is found to be lies");
|
||||||
|
expect(result.scripts!![0].start).toBe(0);
|
||||||
|
expect(result.scripts!![0].words!![1].beginIndex).toBe("[00:00.00] <00:00.04> When <00:00.16> the".indexOf("the"));
|
||||||
|
expect(result.scripts!![0].words!![1].start).toBe(160);
|
||||||
|
});
|
||||||
|
it('Parses test-03.lrc', () => {
|
||||||
|
const result = parseLRC(test03Text, { wordDiv: ' ', strict: true });
|
||||||
|
expect(result.scripts!![5].text).toBe("བྲོ་ར་འདི་ལ་བྲོ་ཅིག་འཁྲབ།");
|
||||||
|
expect(result.scripts!![5].translation).toBe("在舞池里舞一舞");
|
||||||
|
expect(result.scripts!![6].text).toBe("祝祷转过千年 五色经幡飘飞");
|
||||||
|
expect(result.scripts!![6].singer).toBe("a");
|
||||||
|
expect(result.scripts!![11].singer).toBeUndefined();
|
||||||
|
expect(result.scripts!![11].translation).toBe("我们在此相聚");
|
||||||
|
});
|
||||||
|
it('Rejects some invalid LRCs', () => {
|
||||||
|
const cases = [
|
||||||
|
"[<00:00.00>] <00:00.04> When <00:00.16> the",
|
||||||
|
"[00:00.00] <00:00.04> <When> <00:00.16> the",
|
||||||
|
"[00:00.00> <00:00.04> When <00:00.16> the",
|
||||||
|
"<00:00.00> <00:00.04> When <00:00.16> the",
|
||||||
|
"<1:00:00.00> <00:00.04> When <00:00.16> the",
|
||||||
|
]
|
||||||
|
for (const c of cases) {
|
||||||
|
expect(() => parseLRC(c, { strict: true })).toThrow();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
it('Accepts some weird but parsable LRCs', () => {
|
||||||
|
const cases = [
|
||||||
|
"[ti: []]",
|
||||||
|
"[ar: [<]]",
|
||||||
|
"[ar: <ar>]",
|
||||||
|
"[ar: a b c]",
|
||||||
|
"[00:00.00] <00:00.04> When the <00:00.16> the",
|
||||||
|
"[00:00.00] [00:00.04] When [00:00.16] the",
|
||||||
|
"[00:00.0000000] <00:00.04> When <00:00.16> the",
|
||||||
|
"[00:00.00] <00:00.04> [When] <00:00.16> the",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const c of cases) {
|
||||||
|
expect(() => parseLRC(c, { strict: false })).not.toThrow();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
it('Parses a legacy LRC', () => {
|
||||||
|
const result = parseLRC(test02Text, { wordDiv: ' ', strict: true, legacy: true });
|
||||||
|
|
||||||
|
expect(result.ti).toBe("Somebody to Love");
|
||||||
|
expect(result.ar).toBe("Jefferson Airplane");
|
||||||
|
expect(result.scripts!!.length).toBe(3);
|
||||||
|
expect(result.scripts!![1].text).toBe("<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");
|
||||||
|
expect(result.scripts!![1].start).toBe(6000 + 470);
|
||||||
|
result.scripts!!.forEach((s) => expect(s.words).not.toBeDefined());
|
||||||
|
});
|
||||||
|
});
|
56
src/test/resources/test-01.lrc
Normal file
56
src/test/resources/test-01.lrc
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
[ti: 中华少女·终]
|
||||||
|
[ar: 洛天依]
|
||||||
|
[al: 中华少女]
|
||||||
|
[tool: 歌词滚动姬 https://lrc-maker.github.io]
|
||||||
|
[00:46.706] 我想要仗剑天涯却陷入纷乱
|
||||||
|
[00:49.588] 因果与恩怨牵杂等谁来诊断
|
||||||
|
[00:52.284] 暗箭在身后是否该回身看
|
||||||
|
[00:55.073] 人心有了鬼心房便要过鬼门关
|
||||||
|
[00:57.875] 早已茫然染了谁的血的这抹长衫
|
||||||
|
[01:00.702] 独木桥上独目瞧的人一夫当关
|
||||||
|
[01:03.581] 复仇或是诅咒缠在身上的宿命
|
||||||
|
[01:06.591] 棋子在棋盘被固定移动不停转
|
||||||
|
[01:09.241] 仇恨与仇恨周而复始往返
|
||||||
|
[01:12.586] 酒楼深胭脂一点分隔光暗
|
||||||
|
[01:15.205] 数求问天涯海角血债偿还
|
||||||
|
[01:18.015] 终是神念迷茫只做旁观
|
||||||
|
[01:21.087] 是非恩怨三生纠葛轮转
|
||||||
|
[01:23.709] 回望从前苦笑将杯酒斟满
|
||||||
|
[01:26.573] 那时明月今日仍旧皎洁
|
||||||
|
[01:29.115] 只叹换拨人看
|
||||||
|
[01:31.024] 你可是这样的少年
|
||||||
|
[01:33.971] 梦想着穿越回从前
|
||||||
|
[01:36.554] 弦月下着青衫抚长剑
|
||||||
|
[01:42.341] 风起时以血绘长卷
|
||||||
|
[01:45.276] 三寸剑只手撼江山
|
||||||
|
[01:47.838] 拂衣去逍遥天地间
|
||||||
|
[01:52.991]
|
||||||
|
[02:16.707] 黄藓绿斑苔痕将岁月扒谱
|
||||||
|
[02:20.077] 望眼欲穿你何时寄来家书
|
||||||
|
[02:22.788] 踱步间院落飞絮聚散化作愁字
|
||||||
|
[02:25.601] 当泪水成河能否凫上一位游子
|
||||||
|
[02:28.050] 当庭间嫣红轻轻闻着雨声
|
||||||
|
[02:30.841] 电闪雷鸣院中青翠摇曳倚风
|
||||||
|
[02:33.362] 青丝落指尖模糊记忆更为清晰
|
||||||
|
[02:36.334] 那一朵英姿遮挡于一抹旌旗飘
|
||||||
|
[02:39.511] 厮杀与厮杀周而复始招摇
|
||||||
|
[02:42.576] 血渍滑落于枪尖映照宵小
|
||||||
|
[02:45.726] 城池下红莲飞溅绽放照耀
|
||||||
|
[02:48.509] 碧落黄泉再无叨扰
|
||||||
|
[02:51.338] 北风呼啸三生等待轮转
|
||||||
|
[02:53.660] 山崖古道思绪被光阴晕染
|
||||||
|
[02:56.895] 那时明月今日仍旧皎洁
|
||||||
|
[02:59.293] 只叹孤身人看
|
||||||
|
[03:01.335] 你可是这样的少年
|
||||||
|
[03:04.377] 梦想着穿越回从前
|
||||||
|
[03:06.924] 北风里铁衣冷槊光寒
|
||||||
|
[03:12.607] 一朝去大小三百战
|
||||||
|
[03:15.623] 岁月欺万里定江山
|
||||||
|
[03:18.126] 再与她同游天地间
|
||||||
|
[03:24.356] 说书人或许会留恋
|
||||||
|
[03:27.057] 但故事毕竟有终点
|
||||||
|
[03:29.590] 最好的惊堂木是时间
|
||||||
|
[03:35.157] 就让我合上这书卷
|
||||||
|
[03:38.242] 愿那些梦中的玩伴
|
||||||
|
[03:40.857] 梦醒后仍然是少年
|
||||||
|
[03:46.139]
|
9
src/test/resources/test-02.lrc
Normal file
9
src/test/resources/test-02.lrc
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
[ti: Somebody to Love]
|
||||||
|
[ar: Jefferson Airplane]
|
||||||
|
[al: Surrealistic Pillow]
|
||||||
|
[lr: Lyricists of that song]
|
||||||
|
[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
|
77
src/test/resources/test-03.lrc
Normal file
77
src/test/resources/test-03.lrc
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
[ti: 雪山之眼]
|
||||||
|
[ar: 洛天依 & 旦增益西]
|
||||||
|
[al: 游四方]
|
||||||
|
[tool: 歌词滚动姬 https://lrc-maker.github.io]
|
||||||
|
[length: 04:17.400]
|
||||||
|
[00:34.280] 浸透了经卷 记忆的呼喊
|
||||||
|
[00:37.800] 雪珠滚落山巅 栽下一个春天
|
||||||
|
[00:47.390] 松石敲响玲珑清脆的银花
|
||||||
|
[00:51.600] 穿过玛瑙的红霞
|
||||||
|
[00:54.430] 在她眼中结编 亘久诗篇
|
||||||
|
[01:05.440] a: བྲོ་ར་འདི་ལ་བྲོ་ཅིག་འཁྲབ། | 在舞池里舞一舞
|
||||||
|
[01:08.780] a: 祝祷转过千年 五色经幡飘飞
|
||||||
|
[01:12.040] 奏起悠扬巴叶 任岁月拨弦
|
||||||
|
[01:19.130] གཞས་ར་འདི་ལ་གཞས་གཅིག་བཏང་། 我在歌坛献首歌
|
||||||
|
[01:22.330] 宫殿 塔尖 彩绘 日月 同辉
|
||||||
|
[01:25.810] 那层厚重壁垒化身 蝉翼一片
|
||||||
|
[01:29.110] ང་ཚོ་འདི་ལ་འཛོམས་འཛོམས། | 我们在此相聚
|
||||||
|
[01:30.790] གཏན་དུ་འཛོམས་རྒྱུ་བྱུང་ན། 希望可以常聚
|
||||||
|
[01:32.510] གཏན་དུ་འཛོམས་པའི་མི་ལ། 在此相聚的人们
|
||||||
|
[01:34.120] སྙུན་གཞི་གོད་ཆགས་མ་གཏོང༌། 祝愿平安富足
|
||||||
|
[01:35.920] ང་ཚོ་འདི་ལ་འཛོམས་འཛོམས། 我们在此相聚
|
||||||
|
[01:37.630] གཏན་དུ་འཛོམས་རྒྱུ་བྱུང་ན། 希望可以常聚
|
||||||
|
[01:39.350] གཏན་དུ་འཛོམས་པའི་མི་ལ། 在此相聚的人们
|
||||||
|
[01:41.050] སྙུན་གཞི་གོད་ཆགས་མ་གཏོང༌། 祝愿平安富足
|
||||||
|
[01:42.740] ང་ཚོ་འདི་ལ་འཛོམས་འཛོམས། 我们在此相聚
|
||||||
|
[01:44.630] གཏན་དུ་འཛོམས་རྒྱུ་བྱུང་ན། 希望可以常聚
|
||||||
|
[01:46.280] གཏན་དུ་འཛོམས་པའི་མི་ལ། 在此相聚的人们
|
||||||
|
[01:48.010] སྙུན་གཞི་གོད་ཆགས་མ་གཏོང༌། 祝愿平安富足
|
||||||
|
[01:49.600] ང་ཚོ་འདི་ལ་འཛོམས་འཛོམས། 我们在此相聚
|
||||||
|
[01:51.380] གཏན་དུ་འཛོམས་རྒྱུ་བྱུང་ན། 希望可以常聚
|
||||||
|
[01:53.070] གཏན་དུ་འཛོམས་པའི་མི་ལ། 在此相聚的人们
|
||||||
|
[01:54.820] སྙུན་གཞི་གོད་ཆགས་མ་གཏོང༌། 祝愿平安富足
|
||||||
|
[01:58.580] སྔོན་དང་པོ་གྲུབ་ཐོབ་ཐང་སྟོང་རྒྱལ་པོས་མཛད་པའི་མཛད་ཚུལ་དུ། དང་པོ་རྔོན་པའི་ས་སྦྱངས་ས་འདུལ། གཉིས་པ་རྒྱ་ལུའི་བྱིན་འབེབས། གསུམ་པ་ལྷ་མོའི་གླུ་གར་སོགས་རིན་ཆེན་གསུང་མགུར་གཞུང་བཟང་མང་པོ་འདུག་སྟེ། དེ་ཡང་མ་ཉུང་གི་ཚིག་ལ་དུམ་མཚམས་གཅིག་ཞུས་པ་བྱུང་བ་ཡིན་པ་ལགས་སོ། 如祖师唐东杰布所著,一有温巴净地,二有甲鲁祈福,三有仙女歌舞,所著繁多,在此简略献之。
|
||||||
|
[02:24.240] 浸透了经卷 记忆的呼喊
|
||||||
|
[02:27.450] 雪珠滚落山巅 栽下一个春天
|
||||||
|
[02:37.090] 松石敲响玲珑清脆的银花
|
||||||
|
[02:41.280] 穿过玛瑙的红霞
|
||||||
|
[02:44.010] 在她眼中结编 亘久诗篇
|
||||||
|
[02:55.250] བྲོ་ར་འདི་ལ་བྲོ་ཅིག་འཁྲབ། 在舞池里舞一舞
|
||||||
|
[02:58.410] 祝祷转过千年 五色经幡飘飞
|
||||||
|
[03:01.750] 奏起悠扬巴叶 任岁月拨弦
|
||||||
|
[03:08.840] གཞས་ར་འདི་ལ་གཞས་གཅིག་བཏང་། 我在歌坛献首歌
|
||||||
|
[03:12.050] 宫殿 塔尖 彩绘 日月 同辉
|
||||||
|
[03:15.400] 那层厚重壁垒化身 蝉翼一片
|
||||||
|
[03:18.850] ང་ཚོ་འདི་ལ་འཛོམས་འཛོམས། 我们在此相聚
|
||||||
|
[03:20.480] གཏན་དུ་འཛོམས་རྒྱུ་བྱུང་ན། 希望可以常聚
|
||||||
|
[03:22.210] གཏན་དུ་འཛོམས་པའི་མི་ལ། 在此相聚的人们
|
||||||
|
[03:23.910] སྙུན་གཞི་གོད་ཆགས་མ་གཏོང༌། 祝愿平安富足
|
||||||
|
[03:25.662] ང་ཚོ་འདི་ལ་འཛོམས་འཛོམས། 我们在此相聚
|
||||||
|
[03:27.391] གཏན་དུ་འཛོམས་རྒྱུ་བྱུང་ན། 希望可以常聚
|
||||||
|
[03:29.096] གཏན་དུ་འཛོམས་པའི་མི་ལ། 在此相聚的人们
|
||||||
|
[03:30.789] སྙུན་གཞི་གོད་ཆགས་མ་གཏོང༌། 祝愿平安富足
|
||||||
|
[03:32.496] ང་ཚོ་འདི་ལ་འཛོམས་འཛོམས། 我们在此相聚
|
||||||
|
[03:34.175] གཏན་དུ་འཛོམས་རྒྱུ་བྱུང་ན། 希望可以常聚
|
||||||
|
[03:35.876] གཏན་དུ་འཛོམས་པའི་མི་ལ། 在此相聚的人们
|
||||||
|
[03:37.606] སྙུན་གཞི་གོད་ཆགས་མ་གཏོང༌། 祝愿平安富足
|
||||||
|
[03:39.290] ང་ཚོ་འདི་ལ་འཛོམས་འཛོམས། 我们在此相聚
|
||||||
|
[03:41.030] གཏན་དུ་འཛོམས་རྒྱུ་བྱུང་ན། 希望可以常聚
|
||||||
|
[03:42.679] གཏན་དུ་འཛོམས་པའི་མི་ལ། 在此相聚的人们
|
||||||
|
[03:44.455] སྙུན་གཞི་གོད་ཆགས་མ་གཏོང༌། 祝愿平安富足
|
||||||
|
[03:46.176] ང་ཚོ་འདི་ལ་འཛོམས་འཛོམས། 我们在此相聚
|
||||||
|
[03:47.910] གཏན་དུ་འཛོམས་རྒྱུ་བྱུང་ན། 希望可以常聚
|
||||||
|
[03:49.625] གཏན་དུ་འཛོམས་པའི་མི་ལ། 在此相聚的人们
|
||||||
|
[03:51.293] སྙུན་གཞི་གོད་ཆགས་མ་གཏོང༌། 祝愿平安富足
|
||||||
|
[03:53.005] ང་ཚོ་འདི་ལ་འཛོམས་འཛོམས། 我们在此相聚
|
||||||
|
[03:54.742] གཏན་དུ་འཛོམས་རྒྱུ་བྱུང་ན། 希望可以常聚
|
||||||
|
[03:56.479] གཏན་དུ་འཛོམས་པའི་མི་ལ། 在此相聚的人们
|
||||||
|
[03:58.159] སྙུན་གཞི་གོད་ཆགས་མ་གཏོང༌། 祝愿平安富足
|
||||||
|
[03:59.859] ང་ཚོ་འདི་ལ་འཛོམས་འཛོམས། 我们在此相聚
|
||||||
|
[04:01.548] གཏན་དུ་འཛོམས་རྒྱུ་བྱུང་ན། 希望可以常聚
|
||||||
|
[04:03.312] གཏན་དུ་འཛོམས་པའི་མི་ལ། 在此相聚的人们
|
||||||
|
[04:05.026] སྙུན་གཞི་གོད་ཆགས་མ་གཏོང༌། 祝愿平安富足
|
||||||
|
[04:06.721] ང་ཚོ་འདི་ལ་འཛོམས་འཛོམས། 我们在此相聚
|
||||||
|
[04:08.479] གཏན་དུ་འཛོམས་རྒྱུ་བྱུང་ན། 希望可以常聚
|
||||||
|
[04:10.175] གཏན་དུ་འཛོམས་པའི་མི་ལ། 在此相聚的人们
|
||||||
|
[04:11.923] སྙུན་གཞི་གོད་ཆགས་མ་གཏོང༌། 祝愿平安富足
|
||||||
|
[04:17.400]
|
@ -2,9 +2,10 @@ import { sveltekit } from '@sveltejs/kit/vite';
|
|||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
import { NodeGlobalsPolyfillPlugin } from '@esbuild-plugins/node-globals-polyfill';
|
import { NodeGlobalsPolyfillPlugin } from '@esbuild-plugins/node-globals-polyfill';
|
||||||
import rollupNodePolyFill from 'rollup-plugin-node-polyfills';
|
import rollupNodePolyFill from 'rollup-plugin-node-polyfills';
|
||||||
|
import wasm from 'vite-plugin-wasm';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [sveltekit()],
|
plugins: [sveltekit(), wasm()],
|
||||||
test: {
|
test: {
|
||||||
include: ['src/**/*.{test,spec}.{js,ts}']
|
include: ['src/**/*.{test,spec}.{js,ts}']
|
||||||
},
|
},
|
||||||
@ -24,5 +25,10 @@ export default defineConfig({
|
|||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
plugins: [rollupNodePolyFill()]
|
plugins: [rollupNodePolyFill()]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
fs: {
|
||||||
|
allow: ['./package.json']
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user