Compare commits
63 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 | ||
![]() |
00549a504c | ||
![]() |
942ce3d3c9 | ||
![]() |
e548326977 | ||
![]() |
815544ea4e | ||
![]() |
a9b1a3f9cd | ||
![]() |
a3064385c2 | ||
![]() |
ce51da59c9 | ||
![]() |
785f63af38 | ||
![]() |
375d6ec361 | ||
![]() |
fade90e53a | ||
![]() |
65a1c26f1e | ||
![]() |
7b87a649ac | ||
![]() |
b4eef94ed4 | ||
![]() |
61bb6654d5 | ||
![]() |
1cd5892ffe | ||
![]() |
84a2e3fd96 | ||
![]() |
d814eb32f3 |
2
.gitignore
vendored
2
.gitignore
vendored
@ -8,3 +8,5 @@ node_modules
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
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>
|
@ -3,7 +3,7 @@
|
||||
"tabWidth": 4,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"printWidth": 120,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||
}
|
||||
|
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,6 +8,10 @@ AquaVox 是一个为中文虚拟歌手爱好者献上的产品。
|
||||
|
||||
## 使用
|
||||
|
||||
当前 AquaVox 的公开预览版位于 [aquavox.app](https://aquavox.app) 上。
|
||||
|
||||
[](https://wakatime.com/badge/user/018f0628-909b-47e4-bcfd-0153235426d9/project/b67c03ef-ee0b-45f2-85ec-d9c60269cc55)
|
||||
|
||||
## 项目起源
|
||||
|
||||
**开发者寒寒的话:**
|
||||
|
48
data/song/BV16r42137W5.json
Normal file
48
data/song/BV16r42137W5.json
Normal file
@ -0,0 +1,48 @@
|
||||
{
|
||||
"id": "BV16r42137W5",
|
||||
"name": "天山之外",
|
||||
"url": "https://www.bilibili.com/video/BV16r42137W5",
|
||||
"singer": [
|
||||
"洛天依"
|
||||
],
|
||||
"producer": "詹伟杰",
|
||||
"tuning": [
|
||||
"血焰玖蝶"
|
||||
],
|
||||
"lyricist": [
|
||||
"向涵"
|
||||
],
|
||||
"composer": [
|
||||
"Takanashi"
|
||||
],
|
||||
"arranger": [
|
||||
"詹伟杰"
|
||||
],
|
||||
"mixing": [
|
||||
"宋禹"
|
||||
],
|
||||
"pv": [],
|
||||
"illustrator": [
|
||||
"猫犬一号机",
|
||||
"eee",
|
||||
"Tid"
|
||||
],
|
||||
"harmony": [
|
||||
"瑞秋"
|
||||
],
|
||||
"instruments": [
|
||||
"穆热阿勒·比目拉提"
|
||||
],
|
||||
"songURL": [
|
||||
"https://assets.aquavox.app/public/BV16r42137W5.mp3"
|
||||
],
|
||||
"coverURL": [
|
||||
"https://assets.aquavox.app/public/BV16r42137W5.jpg"
|
||||
],
|
||||
"duration": 215.76,
|
||||
"views": 502678,
|
||||
"publishTime": "2024-04-18 12:00:00",
|
||||
"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]"
|
||||
}
|
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": ""
|
||||
}
|
49
data/song/BV1Rr42147Am.json
Normal file
49
data/song/BV1Rr42147Am.json
Normal file
File diff suppressed because one or more lines are too long
90
data/song/BV1Xp421o7hr.json
Normal file
90
data/song/BV1Xp421o7hr.json
Normal file
@ -0,0 +1,90 @@
|
||||
{
|
||||
"id": "BV1Xp421o7hr",
|
||||
"name": "大哉乾元",
|
||||
"url": "https://www.bilibili.com/video/BV1Xp421o7hr",
|
||||
"singer": [
|
||||
"洛天依"
|
||||
],
|
||||
"producer": "哈米伦的弄笛者",
|
||||
"tuning": [
|
||||
"Creuzer"
|
||||
],
|
||||
"lyricist": [
|
||||
"非桥段"
|
||||
],
|
||||
"composer": [
|
||||
"胡多多"
|
||||
],
|
||||
"arranger": [
|
||||
"胡多多"
|
||||
],
|
||||
"mixing": [
|
||||
"明宣May"
|
||||
],
|
||||
"pv": [
|
||||
"宇言DX",
|
||||
"顺-其自然",
|
||||
"Kensei",
|
||||
"残垣留梦",
|
||||
"Bung Kon",
|
||||
"XiroKyo",
|
||||
"学渣的本愿",
|
||||
"La pazienza",
|
||||
"妮可",
|
||||
"高米迪战士",
|
||||
"蓉蓉",
|
||||
"妖孽"
|
||||
],
|
||||
"illustrator": [
|
||||
"Clay菌",
|
||||
"吃咖喱的poi",
|
||||
"彩虹咸鱼YA",
|
||||
"鵺心NUECO",
|
||||
"知木绕林",
|
||||
"长耳朵喵舔毛狂魔",
|
||||
"龙川川川子",
|
||||
"洛橘",
|
||||
"脑子",
|
||||
"依枕云屏",
|
||||
"鷁-",
|
||||
"一只勺子spoon",
|
||||
"腥红",
|
||||
"叹云洲",
|
||||
"顾奔波",
|
||||
"辚古",
|
||||
"Small小_小小白",
|
||||
"晓溪",
|
||||
"羽虫",
|
||||
"千山白",
|
||||
"火锅加番茄",
|
||||
"大写的TY"
|
||||
],
|
||||
"harmony": [
|
||||
"言和",
|
||||
"乐正绫",
|
||||
"乐正龙牙",
|
||||
"墨清弦",
|
||||
"徵羽摩柯"
|
||||
],
|
||||
"instruments": [
|
||||
"墨韵",
|
||||
"浑元",
|
||||
"王佳男",
|
||||
"李乐",
|
||||
"李弦月",
|
||||
"小陈同学"
|
||||
],
|
||||
"songURL": [
|
||||
"https://assets.aquavox.app/public/BV1Xp421o7hr.mp3"
|
||||
],
|
||||
"coverURL": [
|
||||
"https://assets.aquavox.app/public/BV1Xp421o7hr.jpg",
|
||||
"https://ipfs.a2x.pub/ipfs/QmY76zgNJEerm75tiwoWaFg4Uq2NrJQiTjHiTWK6X2VLyb?filename=BV1Xp421o7hr.jpg"
|
||||
],
|
||||
"duration": 259.54,
|
||||
"views": 2733456,
|
||||
"publishTime": "2024-02-09 20:31:23",
|
||||
"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]"
|
||||
}
|
42
data/song/BV1pE421A7mM.json
Normal file
42
data/song/BV1pE421A7mM.json
Normal file
@ -0,0 +1,42 @@
|
||||
{
|
||||
"id": "BV1pE421A7mM",
|
||||
"name": "唱给锦依卫",
|
||||
"url": "https://www.bilibili.com/video/BV1pE421A7mM",
|
||||
"singer": [
|
||||
"洛天依"
|
||||
],
|
||||
"producer": "WOVOP",
|
||||
"tuning": [
|
||||
"鬼面P"
|
||||
],
|
||||
"lyricist": [
|
||||
"青天纤云-TsingClouds"
|
||||
],
|
||||
"composer": [
|
||||
"WOVOP"
|
||||
],
|
||||
"arranger": [
|
||||
"随手诶"
|
||||
],
|
||||
"mixing": [
|
||||
"__月华__"
|
||||
],
|
||||
"pv": [
|
||||
"璇玑坊Studio"
|
||||
],
|
||||
"illustrator": [
|
||||
"Ben_SY"
|
||||
],
|
||||
"harmony": [
|
||||
"Vsinger ACE全员"
|
||||
],
|
||||
"instruments": [],
|
||||
"songURL": ["https://assets.aquavox.app/public/BV1pE421A7mM.mp3"],
|
||||
"coverURL": ["https://assets.aquavox.app/public/BV1pE421A7mM.jpg"],
|
||||
"duration": 192.22,
|
||||
"views": 3021,
|
||||
"publishTime": "2024-07-12 00:00:00",
|
||||
"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]"
|
||||
}
|
@ -8,6 +8,7 @@ AquaVox 由于支持多种歌曲源,因此在代码层面标识时需要以不
|
||||
- 对于本地添加,开启跨设备同步后在云端音乐库匹配的歌曲,我们会用云端的歌曲唯一 ID 覆盖本地 ID。
|
||||
- 对于通过哔哩哔哩收藏夹导入,在云端音乐库中不存在的音乐,我们会以 BV 号作为唯一 ID。
|
||||
- 对于云端音乐库中的歌曲,我们以 BV 号(首选)或 `md5(歌曲名+作者[主发布人])` 作为唯一 ID。
|
||||
- 仅发布在网易云平台的,以 \`NE{id}\` 作为唯一 ID。(例如,`NE2141645940`)
|
||||
|
||||
但是,AquaVox 的云端音乐库由于其特殊性质,不显式公开其存在。
|
||||
我们未来可能允许基于社区的歌曲分享及交流,但不会像传统音乐平台一样直接公开音乐库。
|
||||
|
@ -1,214 +0,0 @@
|
||||
version: 1
|
||||
lyrics:
|
||||
1
|
||||
00:00:05,630 --> 00:00:09,680
|
||||
经起幽明 悟处通玄
|
||||
|
||||
2
|
||||
00:00:09,680 --> 00:00:13,640
|
||||
首窥龙堑 见岳见渊
|
||||
|
||||
3
|
||||
00:00:13,640 --> 00:00:17,270
|
||||
道不善宣 义不善绻
|
||||
|
||||
4
|
||||
00:00:17,270 --> 00:00:22,110
|
||||
源流万世 大哉乾元!
|
||||
|
||||
5
|
||||
00:00:36,390 --> 00:00:38,170
|
||||
不曾闻日月争辉
|
||||
|
||||
6
|
||||
00:00:38,170 --> 00:00:40,330
|
||||
坎离复往 立下恒规
|
||||
|
||||
7
|
||||
00:00:40,330 --> 00:00:43,680
|
||||
照东南 有坤徇乾 承西北
|
||||
|
||||
8
|
||||
00:00:43,680 --> 00:00:45,590
|
||||
天道自昆仑巍巍
|
||||
|
||||
9
|
||||
00:00:45,590 --> 00:00:47,810
|
||||
翻起华夏巽震艮兑
|
||||
|
||||
10
|
||||
00:00:47,810 --> 00:00:51,210
|
||||
万象予万灵得见 两相盈岁
|
||||
|
||||
11
|
||||
00:00:51,210 --> 00:00:53,000
|
||||
潜龙长生应紫微
|
||||
|
||||
12
|
||||
00:00:53,000 --> 00:00:55,200
|
||||
惟向四方五气寻遂
|
||||
|
||||
13
|
||||
00:00:55,200 --> 00:00:58,220
|
||||
燧火旁八卦百草揆经纬
|
||||
|
||||
14
|
||||
00:00:58,220 --> 00:01:00,370
|
||||
正位 纪天下一归
|
||||
|
||||
15
|
||||
00:01:00,370 --> 00:01:02,590
|
||||
不消祈天退水
|
||||
|
||||
16
|
||||
00:01:02,590 --> 00:01:05,930
|
||||
初难知一念一决生龙髓
|
||||
|
||||
17
|
||||
00:01:05,930 --> 00:01:10,260
|
||||
百家注龙慧 千军起龙威 砥淬
|
||||
|
||||
18
|
||||
00:01:10,260 --> 00:01:13,030
|
||||
妙笔生文穗 罡风抚长麾
|
||||
|
||||
19
|
||||
00:01:13,030 --> 00:01:20,490
|
||||
始见龙形汇 以天田冲腾直向九陲
|
||||
|
||||
20
|
||||
00:01:20,490 --> 00:01:24,140
|
||||
龙震于疆 万里宁壤 天地皆可往
|
||||
|
||||
21
|
||||
00:01:24,140 --> 00:01:27,940
|
||||
龙秀于象 引仙来访 诗蜀道河江
|
||||
|
||||
22
|
||||
00:01:27,940 --> 00:01:31,819
|
||||
龙明于章 执笔成鉴 映五千煌煌
|
||||
|
||||
23
|
||||
00:01:31,819 --> 00:01:35,340
|
||||
不独九州五岳 帝王将相见苍茫
|
||||
|
||||
24
|
||||
00:01:35,340 --> 00:01:39,039
|
||||
龙泽于汤 唤水筑乡 单舟见京杭
|
||||
|
||||
25
|
||||
00:01:39,039 --> 00:01:42,740
|
||||
龙健于常 百音同讲 道一种炎黄
|
||||
|
||||
26
|
||||
00:01:42,740 --> 00:01:46,580
|
||||
龙景于康 见之庙堂 亦显于曲坊
|
||||
|
||||
27
|
||||
00:01:46,580 --> 00:01:50,350
|
||||
不劳此间祥云瑞兽频频诰春长!
|
||||
|
||||
28
|
||||
00:01:57,280 --> 00:01:59,500
|
||||
干支移晷又几回
|
||||
|
||||
29
|
||||
00:01:59,500 --> 00:02:01,790
|
||||
揽尽天骄襄助一醉
|
||||
|
||||
30
|
||||
00:02:01,790 --> 00:02:04,680
|
||||
虽万言竟道不尽无字碑
|
||||
|
||||
31
|
||||
00:02:04,680 --> 00:02:06,600
|
||||
临渊乾乾 君子催
|
||||
|
||||
32
|
||||
00:02:06,600 --> 00:02:09,130
|
||||
或跃 无咎相随
|
||||
|
||||
33
|
||||
00:02:09,130 --> 00:02:12,389
|
||||
同为龙 却与往昔不连讳
|
||||
|
||||
34
|
||||
00:02:12,389 --> 00:02:16,700
|
||||
且待飞龙归 簸却沧溟水 如沸
|
||||
|
||||
35
|
||||
00:02:16,700 --> 00:02:19,540
|
||||
有龙掸风雷 见首不见尾
|
||||
|
||||
36
|
||||
00:02:19,540 --> 00:02:27,079
|
||||
苏苏万物蜕 证元亨利贞变易轮回
|
||||
|
||||
37
|
||||
00:02:27,079 --> 00:02:30,740
|
||||
龙华于旸 红旗漫卷 新水濯旧隍
|
||||
|
||||
38
|
||||
00:02:30,740 --> 00:02:34,430
|
||||
龙泰于霜 烽烟消长 更赳赳昂昂
|
||||
|
||||
39
|
||||
00:02:34,430 --> 00:02:38,260
|
||||
龙温于壮 留待潺潺 驰涌成泱泱
|
||||
|
||||
40
|
||||
00:02:38,260 --> 00:02:41,660
|
||||
好教流光紫极 鹊渡银潢伴流觞
|
||||
|
||||
41
|
||||
00:02:41,660 --> 00:02:45,490
|
||||
龙韧于刚 龙吟激荡 云止聆佳响
|
||||
|
||||
42
|
||||
00:02:45,490 --> 00:02:49,150
|
||||
龙德于昌 喜见船马 纵横间丰仓
|
||||
|
||||
43
|
||||
00:02:49,150 --> 00:02:52,930
|
||||
龙眷于邦 情习众广 仍化为一方
|
||||
|
||||
44
|
||||
00:02:52,930 --> 00:02:57,240
|
||||
其妙错综复杂 不孤兵车付一匡!
|
||||
|
||||
45
|
||||
00:03:40,980 --> 00:03:44,620
|
||||
此去向东 瀚海游龙 滔滔几万重
|
||||
|
||||
46
|
||||
00:03:44,620 --> 00:03:48,250
|
||||
一跃破空 乘风逐虹 猎猎青云中
|
||||
|
||||
47
|
||||
00:03:48,250 --> 00:03:52,080
|
||||
天音入梦 扶摇上穹 矫矫游星宫
|
||||
|
||||
48
|
||||
00:03:52,080 --> 00:03:55,670
|
||||
犹念神州谷稻耕耘收藏守时无?
|
||||
|
||||
49
|
||||
00:03:55,670 --> 00:03:59,240
|
||||
一息一动 似异似同 无之以为用
|
||||
|
||||
50
|
||||
00:03:59,240 --> 00:04:03,130
|
||||
天地辰龙 龙生九种 但两爻合共
|
||||
|
||||
51
|
||||
00:04:03,130 --> 00:04:06,830
|
||||
假逢童蒙 欲解懵懂 何处有真龙
|
||||
|
||||
52
|
||||
00:04:06,830 --> 00:04:10,350
|
||||
只道「大哉乾元」秩秩幽幽必然中
|
||||
|
||||
53
|
||||
00:04:10,350 --> 00:04:14,430
|
||||
也道「大哉乾元」切切实实一言中!
|
||||
|
@ -1,212 +0,0 @@
|
||||
1
|
||||
00:00:05,630 --> 00:00:09,680
|
||||
经起幽明 悟处通玄
|
||||
|
||||
2
|
||||
00:00:09,680 --> 00:00:13,640
|
||||
首窥龙堑 见岳见渊
|
||||
|
||||
3
|
||||
00:00:13,640 --> 00:00:17,270
|
||||
道不善宣 义不善绻
|
||||
|
||||
4
|
||||
00:00:17,270 --> 00:00:22,110
|
||||
源流万世 大哉乾元!
|
||||
|
||||
5
|
||||
00:00:36,390 --> 00:00:38,170
|
||||
不曾闻日月争辉
|
||||
|
||||
6
|
||||
00:00:38,170 --> 00:00:40,330
|
||||
坎离复往 立下恒规
|
||||
|
||||
7
|
||||
00:00:40,330 --> 00:00:43,680
|
||||
照东南 有坤徇乾 承西北
|
||||
|
||||
8
|
||||
00:00:43,680 --> 00:00:45,590
|
||||
天道自昆仑巍巍
|
||||
|
||||
9
|
||||
00:00:45,590 --> 00:00:47,810
|
||||
翻起华夏巽震艮兑
|
||||
|
||||
10
|
||||
00:00:47,810 --> 00:00:51,210
|
||||
万象予万灵得见 两相盈岁
|
||||
|
||||
11
|
||||
00:00:51,210 --> 00:00:53,000
|
||||
潜龙长生应紫微
|
||||
|
||||
12
|
||||
00:00:53,000 --> 00:00:55,200
|
||||
惟向四方五气寻遂
|
||||
|
||||
13
|
||||
00:00:55,200 --> 00:00:58,220
|
||||
燧火旁八卦百草揆经纬
|
||||
|
||||
14
|
||||
00:00:58,220 --> 00:01:00,370
|
||||
正位 纪天下一归
|
||||
|
||||
15
|
||||
00:01:00,370 --> 00:01:02,590
|
||||
不消祈天退水
|
||||
|
||||
16
|
||||
00:01:02,590 --> 00:01:05,930
|
||||
初难知一念一决生龙髓
|
||||
|
||||
17
|
||||
00:01:05,930 --> 00:01:10,260
|
||||
百家注龙慧 千军起龙威 砥淬
|
||||
|
||||
18
|
||||
00:01:10,260 --> 00:01:13,030
|
||||
妙笔生文穗 罡风抚长麾
|
||||
|
||||
19
|
||||
00:01:13,030 --> 00:01:20,490
|
||||
始见龙形汇 以天田冲腾直向九陲
|
||||
|
||||
20
|
||||
00:01:20,490 --> 00:01:24,140
|
||||
龙震于疆 万里宁壤 天地皆可往
|
||||
|
||||
21
|
||||
00:01:24,140 --> 00:01:27,940
|
||||
龙秀于象 引仙来访 诗蜀道河江
|
||||
|
||||
22
|
||||
00:01:27,940 --> 00:01:31,819
|
||||
龙明于章 执笔成鉴 映五千煌煌
|
||||
|
||||
23
|
||||
00:01:31,819 --> 00:01:35,340
|
||||
不独九州五岳 帝王将相见苍茫
|
||||
|
||||
24
|
||||
00:01:35,340 --> 00:01:39,039
|
||||
龙泽于汤 唤水筑乡 单舟见京杭
|
||||
|
||||
25
|
||||
00:01:39,039 --> 00:01:42,740
|
||||
龙健于常 百音同讲 道一种炎黄
|
||||
|
||||
26
|
||||
00:01:42,740 --> 00:01:46,580
|
||||
龙景于康 见之庙堂 亦显于曲坊
|
||||
|
||||
27
|
||||
00:01:46,580 --> 00:01:50,350
|
||||
不劳此间祥云瑞兽频频诰春长!
|
||||
|
||||
28
|
||||
00:01:57,280 --> 00:01:59,500
|
||||
干支移晷又几回
|
||||
|
||||
29
|
||||
00:01:59,500 --> 00:02:01,790
|
||||
揽尽天骄襄助一醉
|
||||
|
||||
30
|
||||
00:02:01,790 --> 00:02:04,680
|
||||
虽万言竟道不尽无字碑
|
||||
|
||||
31
|
||||
00:02:04,680 --> 00:02:06,600
|
||||
临渊乾乾 君子催
|
||||
|
||||
32
|
||||
00:02:06,600 --> 00:02:09,130
|
||||
或跃 无咎相随
|
||||
|
||||
33
|
||||
00:02:09,130 --> 00:02:12,389
|
||||
同为龙 却与往昔不连讳
|
||||
|
||||
34
|
||||
00:02:12,389 --> 00:02:16,700
|
||||
且待飞龙归 簸却沧溟水 如沸
|
||||
|
||||
35
|
||||
00:02:16,700 --> 00:02:19,540
|
||||
有龙掸风雷 见首不见尾
|
||||
|
||||
36
|
||||
00:02:19,540 --> 00:02:27,079
|
||||
苏苏万物蜕 证元亨利贞变易轮回
|
||||
|
||||
37
|
||||
00:02:27,079 --> 00:02:30,740
|
||||
龙华于旸 红旗漫卷 新水濯旧隍
|
||||
|
||||
38
|
||||
00:02:30,740 --> 00:02:34,430
|
||||
龙泰于霜 烽烟消长 更赳赳昂昂
|
||||
|
||||
39
|
||||
00:02:34,430 --> 00:02:38,260
|
||||
龙温于壮 留待潺潺 驰涌成泱泱
|
||||
|
||||
40
|
||||
00:02:38,260 --> 00:02:41,660
|
||||
好教流光紫极 鹊渡银潢伴流觞
|
||||
|
||||
41
|
||||
00:02:41,660 --> 00:02:45,490
|
||||
龙韧于刚 龙吟激荡 云止聆佳响
|
||||
|
||||
42
|
||||
00:02:45,490 --> 00:02:49,150
|
||||
龙德于昌 喜见船马 纵横间丰仓
|
||||
|
||||
43
|
||||
00:02:49,150 --> 00:02:52,930
|
||||
龙眷于邦 情习众广 仍化为一方
|
||||
|
||||
44
|
||||
00:02:52,930 --> 00:02:57,240
|
||||
其妙错综复杂 不孤兵车付一匡!
|
||||
|
||||
45
|
||||
00:03:40,980 --> 00:03:44,620
|
||||
此去向东 瀚海游龙 滔滔几万重
|
||||
|
||||
46
|
||||
00:03:44,620 --> 00:03:48,250
|
||||
一跃破空 乘风逐虹 猎猎青云中
|
||||
|
||||
47
|
||||
00:03:48,250 --> 00:03:52,080
|
||||
天音入梦 扶摇上穹 矫矫游星宫
|
||||
|
||||
48
|
||||
00:03:52,080 --> 00:03:55,670
|
||||
犹念神州谷稻耕耘收藏守时无?
|
||||
|
||||
49
|
||||
00:03:55,670 --> 00:03:59,240
|
||||
一息一动 似异似同 无之以为用
|
||||
|
||||
50
|
||||
00:03:59,240 --> 00:04:03,130
|
||||
天地辰龙 龙生九种 但两爻合共
|
||||
|
||||
51
|
||||
00:04:03,130 --> 00:04:06,830
|
||||
假逢童蒙 欲解懵懂 何处有真龙
|
||||
|
||||
52
|
||||
00:04:06,830 --> 00:04:10,350
|
||||
只道「大哉乾元」秩秩幽幽必然中
|
||||
|
||||
53
|
||||
00:04:10,350 --> 00:04:14,430
|
||||
也道「大哉乾元」切切实实一言中!
|
||||
|
3
doc/lyrics.md
Normal file
3
doc/lyrics.md
Normal file
@ -0,0 +1,3 @@
|
||||
# AquaVox中的歌词表示
|
||||
|
||||
AquaVox
|
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:
|
54
package.json
54
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aquavox",
|
||||
"version": "1.8.0",
|
||||
"version": "2.3.2",
|
||||
"private": false,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
@ -10,37 +10,57 @@
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch",
|
||||
"test": "vitest",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write ."
|
||||
"format": "prettier --write .",
|
||||
"go": "PORT=4173 bun ./build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/svelte": "^4.0.1",
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.0",
|
||||
"@types/eslint": "^8.56.0",
|
||||
"@iconify/svelte": "^4.0.2",
|
||||
"@sveltejs/adapter-auto": "^3.2.0",
|
||||
"@sveltejs/adapter-node": "^5.0.1",
|
||||
"@sveltejs/kit": "^2.5.9",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.1.0",
|
||||
"@types/eslint": "^8.56.10",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-svelte": "^2.35.1",
|
||||
"eslint-plugin-svelte": "^2.39.0",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier-plugin-svelte": "^3.1.2",
|
||||
"svelte": "^4.2.7",
|
||||
"svelte-check": "^3.6.0",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-svelte": "^3.2.3",
|
||||
"svelte": "^4.2.17",
|
||||
"svelte-check": "^3.7.1",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^5.0.3",
|
||||
"vitest": "^1.2.0"
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.11",
|
||||
"vite-plugin-wasm": "^3.3.0",
|
||||
"vitest": "^1.6.0"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@applemusic-like-lyrics/core": "^0.1.3",
|
||||
"@applemusic-like-lyrics/lyric": "^0.2.2",
|
||||
"@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",
|
||||
"bezier-easing": "^2.1.0",
|
||||
"jotai": "^2.8.0",
|
||||
"jotai-svelte": "^0.0.2",
|
||||
"jss": "^10.10.0",
|
||||
"jss-preset-default": "^10.10.0",
|
||||
"localforage": "^1.10.0",
|
||||
"lrc-parser-ts": "^1.0.3",
|
||||
"music-metadata-browser": "^2.5.10",
|
||||
"srt-parser-2": "^1.2.3",
|
||||
"node-cache": "^5.1.2",
|
||||
"rollup-plugin-node-polyfills": "^0.2.1",
|
||||
"typescript-parsec": "^0.3.4",
|
||||
"uuid": "^9.0.1"
|
||||
}
|
||||
}
|
||||
|
2949
pnpm-lock.yaml
2949
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
@ -29,3 +29,10 @@ h2 {
|
||||
.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="preconnect" href="https://fonts.googleapis.com" />
|
||||
<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" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</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>
|
||||
|
@ -1,11 +1,16 @@
|
||||
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', () => {
|
||||
it('converts 120 seconds to "2:00"', () => {
|
||||
expect(formatDuration(120)).toBe('2:00');
|
||||
});
|
||||
|
||||
it('converts 119.935429 seconds to "1:59"', () => {
|
||||
expect(formatDuration(119.935429)).toBe('1:59');
|
||||
});
|
||||
|
||||
it('converts 185 seconds to "3:05"', () => {
|
||||
expect(formatDuration(185)).toBe('3:05');
|
||||
});
|
||||
@ -18,3 +23,25 @@ describe('formatDuration test', () => {
|
||||
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 blobToImageData from '$lib/graphics/blob2imageData';
|
||||
import imageDataToBlob from '$lib/graphics/imageData2blob';
|
||||
import localforage from '$lib/storage';
|
||||
import localforage from '$lib/utils/storage';
|
||||
export let coverId: string;
|
||||
let canvas: HTMLCanvasElement;
|
||||
|
||||
@ -55,5 +55,6 @@
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
transition: .45s;
|
||||
filter: brightness(0.8);
|
||||
}
|
||||
</style>
|
||||
|
@ -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">
|
||||
import { useAtom } from 'jotai-svelte';
|
||||
import { fileListState, finalFileListState } from '$lib/state/fileList.state';
|
||||
import toHumanSize from '$lib/humanSize';
|
||||
import formatText from '$lib/formatText';
|
||||
import extractFileName from '$lib/extractFileName';
|
||||
import getAudioMeta from '$lib/getAudioCoverURL';
|
||||
import convertCoverData from '$lib/convertCoverData';
|
||||
import toHumanSize from '$lib/utils/humanSize';
|
||||
import formatText from '$lib/utils/formatText';
|
||||
import extractFileName from '$lib/utils/extractFileName';
|
||||
import getAudioMeta from '$lib/utils/getAudioCoverURL';
|
||||
import convertCoverData from '$lib/utils/convertCoverData';
|
||||
import type { IAudioMetadata } from 'music-metadata-browser';
|
||||
import formatDuration from '$lib/formatDuration';
|
||||
import formatDuration from '$lib/utils/formatDuration';
|
||||
const items = useAtom(fileListState);
|
||||
const finalItems = useAtom(finalFileListState);
|
||||
let displayItems: any[] = [];
|
||||
|
@ -1,6 +1,9 @@
|
||||
<script lang="ts">
|
||||
import formatDuration from '$lib/formatDuration';
|
||||
import formatDuration from '$lib/utils/formatDuration';
|
||||
import { onMount } from 'svelte';
|
||||
import userAdjustingProgress from '$lib/state/userAdjustingProgress';
|
||||
import progressBarSlideValue from '$lib/state/progressBarSlideValue';
|
||||
import truncate from '$lib/utils/truncate';
|
||||
|
||||
export let name: string;
|
||||
export let singer: string = '';
|
||||
@ -10,27 +13,44 @@
|
||||
export let volume: number = 1;
|
||||
export let clickPlay: Function;
|
||||
export let adjustProgress: Function;
|
||||
export let adjustRealProgress: Function;
|
||||
export let adjustDisplayProgress: Function;
|
||||
export let adjustVolume: Function;
|
||||
export let onSlide: boolean;
|
||||
export let setOnSlide: Function;
|
||||
|
||||
export let hasLyrics: boolean;
|
||||
|
||||
let progressBar: HTMLInputElement;
|
||||
let volumeBar: HTMLInputElement;
|
||||
let progressBar: HTMLDivElement;
|
||||
let volumeBar: HTMLDivElement;
|
||||
let showInfoTop: boolean = false;
|
||||
let isInfoTopOverflowing = false;
|
||||
let songInfoTopContainer: HTMLDivElement;
|
||||
let songInfoTopContent: HTMLSpanElement;
|
||||
let userAdjustingVolume = false;
|
||||
|
||||
const mql = window.matchMedia('(max-width: 1280px)');
|
||||
|
||||
function progressBarOnChange(e: any) {
|
||||
adjustProgress(e.target.value / (duration + 0.001));
|
||||
function volumeBarOnChange(e: MouseEvent) {
|
||||
const value = e.offsetX / volumeBar.getBoundingClientRect().width;
|
||||
adjustVolume(value);
|
||||
localStorage.setItem('volume', value.toString());
|
||||
}
|
||||
|
||||
function progressBarOnInput(e: any) {
|
||||
adjustRealProgress(e.target.value / (duration + 0.001));
|
||||
function volumeBarChangeTouch(e: TouchEvent) {
|
||||
const value = truncate(
|
||||
e.touches[0].clientX - volumeBar.getBoundingClientRect().x,
|
||||
0,
|
||||
volumeBar.getBoundingClientRect().width
|
||||
) / volumeBar.getBoundingClientRect().width;
|
||||
adjustVolume(value);
|
||||
localStorage.setItem('volume', value.toString());
|
||||
}
|
||||
|
||||
function volumeBarOnChange(e: any) {
|
||||
adjustVolume(e.target.value);
|
||||
function progressBarOnClick(e: MouseEvent) {
|
||||
adjustProgress(e.offsetX / progressBar.getBoundingClientRect().width);
|
||||
progressBarSlideValue.set((e.offsetX / progressBar.getBoundingClientRect().width) * duration);
|
||||
}
|
||||
|
||||
function progressBarMouseUp(offsetX: number) {
|
||||
adjustDisplayProgress(offsetX / progressBar.getBoundingClientRect().width);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
@ -39,6 +59,12 @@
|
||||
});
|
||||
});
|
||||
|
||||
$: {
|
||||
if (songInfoTopContainer && songInfoTopContent) {
|
||||
isInfoTopOverflowing = songInfoTopContent.offsetWidth > songInfoTopContainer.offsetWidth;
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
showInfoTop = mql.matches && hasLyrics;
|
||||
}
|
||||
@ -52,72 +78,139 @@
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class={"absolute select-none bottom-2 h-60 w-[86vw] left-[7vw] z-10 " + (hasLyrics
|
||||
? "lg:w-[76vw] lg:left-[12vw] xl:w-[37vw] xl:left-[7vw]"
|
||||
: "lg:w-[76vw] lg:left-[12vw] xl:w-[37vw] xl:left-[31.5vw]")}
|
||||
class={'absolute select-none bottom-2 h-60 w-[86vw] left-[7vw] z-10 ' +
|
||||
(hasLyrics
|
||||
? 'lg:w-[76vw] lg:left-[12vw] xl:w-[37vw] xl:left-[7vw]'
|
||||
: 'lg:w-[76vw] lg:left-[12vw] xl:w-[37vw] xl:left-[31.5vw]')}
|
||||
>
|
||||
{#if !showInfoTop}
|
||||
<div class="song-info">
|
||||
<span class="song-name text-shadow">{name}</span><br />
|
||||
<span class="song-author">{singer}</span>
|
||||
<div class="song-info-regular {isInfoTopOverflowing ? 'animate' : ''}" bind:this={songInfoTopContainer}>
|
||||
<span
|
||||
class="song-name text-shadow {isInfoTopOverflowing ? 'animate' : ''}"
|
||||
bind:this={songInfoTopContent}>{name}</span
|
||||
>
|
||||
</div>
|
||||
<span class="song-author text-shadow-lg">{singer}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="progress top-16">
|
||||
<div class="time-indicator text-shadow-md time-current">{formatDuration(progress)}</div>
|
||||
<input
|
||||
class="progress-bar shadow-md"
|
||||
<div class="time-indicator text-shadow-md time-current">
|
||||
{formatDuration(progress)}
|
||||
</div>
|
||||
<div
|
||||
aria-valuemax={duration}
|
||||
aria-valuemin="0"
|
||||
aria-valuenow={progress}
|
||||
bind:this={progressBar}
|
||||
on:change={progressBarOnChange}
|
||||
on:input={progressBarOnInput}
|
||||
on:mousedown={() => setOnSlide(true)}
|
||||
on:mouseup={() => {
|
||||
class="progress-bar shadow-md"
|
||||
on:keydown
|
||||
on:keyup
|
||||
on:mousedown={() => {
|
||||
userAdjustingProgress.set(true);
|
||||
}}
|
||||
on:mousemove={(e) => {
|
||||
if ($userAdjustingProgress) {
|
||||
adjustDisplayProgress(e.offsetX / progressBar.getBoundingClientRect().width);
|
||||
}
|
||||
}}
|
||||
on:mouseup={(e) => {
|
||||
const offsetX = e.offsetX;
|
||||
progressBarOnClick(e);
|
||||
// Q: why it needs delay?
|
||||
// A: I do not know.
|
||||
setTimeout(()=> {
|
||||
setOnSlide(false);
|
||||
userAdjustingProgress.set(false);
|
||||
progressBarMouseUp(offsetX);
|
||||
}, 50);
|
||||
}}
|
||||
type="range"
|
||||
min="0"
|
||||
max={duration - 0.2}
|
||||
step="1"
|
||||
value={onSlide ? progressBar.value : progress}
|
||||
/>
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="bar" style={`width: ${(progress / (duration + 0.001)) * 100}%;`}></div>
|
||||
</div>
|
||||
|
||||
<div class="time-indicator text-shadow-md time-total">{formatDuration(duration)}</div>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
<img class="control-img switch-song-img" src="/previous.svg" alt="上一曲" />
|
||||
<button class="control-btn previous" style="filter: drop-shadow( 0 4px 8px rgba(0, 0, 0, 0.12) );">
|
||||
<img alt="上一曲" class="control-img switch-song-img" src="/previous.svg" />
|
||||
</button>
|
||||
<button
|
||||
style="filter: drop-shadow( 0 4px 8px rgba(0, 0, 0, 0.12) );"
|
||||
class="control-btn play-btn"
|
||||
on:click={() => clickPlay()}
|
||||
>
|
||||
<img class="control-img" src={paused ? '/play.svg' : '/pause.svg'} alt="暂停或播放" />
|
||||
</button>
|
||||
<button
|
||||
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) );"
|
||||
class="control-btn next"
|
||||
>
|
||||
<img class="control-img switch-song-img" src="/next.svg" alt="下一曲" />
|
||||
<img alt={paused ? '播放' : '暂停'} class="control-img" src={paused ? '/play.svg' : '/pause.svg'} />
|
||||
</button>
|
||||
<button class="control-btn next" style="filter: drop-shadow( 0 4px 8px rgba(0, 0, 0, 0.12) );">
|
||||
<img alt="下一曲" class="control-img switch-song-img" src="/next.svg" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative top-52 h-6 flex">
|
||||
<img class="scale-75" src="/volumeDown.svg" alt="最小音量" />
|
||||
<input
|
||||
class="mx-2 progress-bar shadow-md !translate-y-[-50%] !top-1/2"
|
||||
<img alt="最小音量" class="scale-75" src="/volumeDown.svg" />
|
||||
<div
|
||||
aria-valuemax="1"
|
||||
aria-valuemin="0"
|
||||
aria-valuenow={volume}
|
||||
bind:this={volumeBar}
|
||||
on:input={volumeBarOnChange}
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={onSlide ? volumeBar.value : volume}
|
||||
/>
|
||||
<img class="scale-75" src="/volumeUp.svg" alt="最大音量" />
|
||||
class="progress-bar shadow-md !top-1/2 !translate-y-[-50%]"
|
||||
on:click={(e) => volumeBarOnChange(e)}
|
||||
on:keydown
|
||||
on:keyup
|
||||
on:mousedown={() => {
|
||||
userAdjustingVolume = true;
|
||||
}}
|
||||
on:mousemove={(e) => {
|
||||
if (userAdjustingVolume) {
|
||||
volumeBarOnChange(e);
|
||||
}
|
||||
}}
|
||||
on:mouseup={() => {
|
||||
userAdjustingVolume = false;
|
||||
}}
|
||||
on:touchend={(e) => {
|
||||
e.preventDefault();
|
||||
userAdjustingVolume = false;
|
||||
}}
|
||||
on:touchmove={(e) => {
|
||||
e.preventDefault();
|
||||
userAdjustingVolume = true;
|
||||
if (userAdjustingVolume) {
|
||||
volumeBarChangeTouch(e);
|
||||
}
|
||||
}}
|
||||
on:touchstart={(e) => {
|
||||
if (e.cancelable) {
|
||||
e.preventDefault();
|
||||
}
|
||||
userAdjustingVolume = true;
|
||||
}}
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="bar" style={`width: ${volume * 100}%;`}></div>
|
||||
</div>
|
||||
<img alt="最大音量" class="scale-75" src="/volumeUp.svg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -128,6 +221,7 @@
|
||||
left: 50%;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
display: inline-block;
|
||||
height: 3.7rem;
|
||||
@ -135,11 +229,10 @@
|
||||
cursor: pointer;
|
||||
margin: 0 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
transition: 0.1s;
|
||||
}
|
||||
.control-btn:hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
transition: 0.45s;
|
||||
scale: 1;
|
||||
}
|
||||
|
||||
.control-img {
|
||||
height: 2rem;
|
||||
width: 2rem;
|
||||
@ -147,6 +240,7 @@
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.switch-song-img {
|
||||
width: auto !important;
|
||||
height: 1.7rem !important;
|
||||
@ -156,22 +250,68 @@
|
||||
user-select: text;
|
||||
position: absolute;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 0);
|
||||
top: 1rem;
|
||||
font-family: sans-serif;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.song-info-regular {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
height: 2.375rem;
|
||||
}
|
||||
|
||||
.song-info-regular.animate {
|
||||
mask-image: linear-gradient(
|
||||
90deg,
|
||||
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%
|
||||
);
|
||||
}
|
||||
|
||||
.song-name {
|
||||
position: relative;
|
||||
font-size: 1.6rem;
|
||||
line-height: 2.5rem;
|
||||
overflow-y: auto;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
scrollbar-width: none;
|
||||
height: 2.5rem;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.song-name.animate {
|
||||
animation: scroll 10s linear infinite;
|
||||
}
|
||||
|
||||
.song-name::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes scroll {
|
||||
0% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(0%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
}
|
||||
|
||||
.song-author {
|
||||
font-size: 1.2rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.progress {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
@ -179,6 +319,7 @@
|
||||
transform: translate(-50%, 0);
|
||||
height: 2.4rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
@ -193,27 +334,23 @@
|
||||
cursor: pointer;
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
.progress-bar:hover {
|
||||
height: 0.7rem;
|
||||
}
|
||||
.progress-bar::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 0rem;
|
||||
height: 0.7rem;
|
||||
|
||||
.bar {
|
||||
background-color: white;
|
||||
box-shadow: -700px 0 0 700px white;
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
content: '';
|
||||
height: 0.4rem;
|
||||
display: inline-block;
|
||||
border-radius: 1rem;
|
||||
transition: height 0.3s;
|
||||
}
|
||||
|
||||
.progress-bar::-moz-range-thumb {
|
||||
appearance: none;
|
||||
width: 0px;
|
||||
height: 0px;
|
||||
background-color: white;
|
||||
box-shadow: -700px 0 0 700px white;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
.progress-bar:hover .bar {
|
||||
height: 0.7rem;
|
||||
}
|
||||
|
||||
.time-indicator {
|
||||
@ -225,10 +362,18 @@
|
||||
display: inline-block;
|
||||
top: 0.2rem;
|
||||
}
|
||||
|
||||
.time-current {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.time-total {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.control-btn {
|
||||
transition: 0.1s
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -1,148 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Line } from 'srt-parser-2';
|
||||
export let lyrics: string[];
|
||||
export let originalLyrics: Line[];
|
||||
export let progress: number;
|
||||
let currentScrollPos = '';
|
||||
let currentLyric: Line;
|
||||
let currentLyricIndex = -1;
|
||||
|
||||
let refs = [];
|
||||
let _refs: any[] = [];
|
||||
$: refs = _refs.filter(Boolean);
|
||||
|
||||
function getClass(lyricIndex: number, progress: number) {
|
||||
if (lyricIndex === currentLyricIndex) return 'current-lyric';
|
||||
else if (progress > currentLyric.endSeconds) return 'after-lyric';
|
||||
else return 'previous-lyric';
|
||||
}
|
||||
|
||||
$: {
|
||||
if (originalLyrics) {
|
||||
let found = false;
|
||||
for (let i = 0; i < originalLyrics.length; i++) {
|
||||
let l = originalLyrics[i];
|
||||
if (progress >= l.startSeconds && progress <= l.endSeconds) {
|
||||
currentLyric = l;
|
||||
currentLyricIndex = i;
|
||||
found = true;
|
||||
const currentRef = refs[i];
|
||||
if (currentRef && currentScrollPos !== currentLyric.text) {
|
||||
currentRef.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
currentScrollPos = currentLyric.text;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < lyrics.length; i++) {
|
||||
const offset = Math.abs(i - currentLyricIndex);
|
||||
const blurRadius = Math.min(offset * 1, 16);
|
||||
if (refs[i]) {
|
||||
refs[i].style.filter = `blur(${blurRadius}px)`;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
currentLyric = {
|
||||
id: '-1',
|
||||
startTime: '00:00:00,000',
|
||||
startSeconds: 0,
|
||||
endTime: '00:00:00,000',
|
||||
endSeconds: 0,
|
||||
text: ''
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
</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 scroll-smooth no-scrollbar overflow-y-auto z-[1] lyrics"
|
||||
>
|
||||
{#each lyrics as lyric, i}
|
||||
<p bind:this={_refs[i]} class={getClass(i, progress)}>
|
||||
{lyric}
|
||||
</p>
|
||||
{/each}
|
||||
</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: none;
|
||||
}
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
width: 0;
|
||||
}
|
||||
.current-lyric {
|
||||
position: relative;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 2.3rem;
|
||||
line-height: 2.7rem;
|
||||
top: 1rem;
|
||||
transition: 0.2s;
|
||||
margin: 1rem 0.3rem;
|
||||
}
|
||||
.previous-lyric {
|
||||
position: relative;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-weight: 600;
|
||||
font-size: 2.2rem;
|
||||
line-height: 2.7rem;
|
||||
filter: blur(1px);
|
||||
top: 1rem;
|
||||
transition: 0.2s;
|
||||
margin: 1rem 0rem;
|
||||
}
|
||||
.after-lyric {
|
||||
position: relative;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-weight: 600;
|
||||
font-size: 2.2rem;
|
||||
line-height: 2.7rem;
|
||||
filter: blur(1px);
|
||||
top: 1rem;
|
||||
transition: 0.2s;
|
||||
margin: 1rem 0rem;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.current-lyric {
|
||||
font-size: 3rem;
|
||||
line-height: 4rem;
|
||||
margin: 2.4rem 0rem;
|
||||
}
|
||||
.after-lyric {
|
||||
font-size: 2.8rem;
|
||||
line-height: 3.3rem;
|
||||
margin: 2.4rem 0rem;
|
||||
}
|
||||
.previous-lyric {
|
||||
font-size: 2.8rem;
|
||||
line-height: 3.3rem;
|
||||
margin: 2.4rem 0rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.current-lyric {
|
||||
font-size: 3.5rem;
|
||||
line-height: 4.5rem;
|
||||
margin: 2rem 0rem;
|
||||
}
|
||||
.after-lyric {
|
||||
font-size: 3rem;
|
||||
line-height: 3.5rem;
|
||||
margin: 2rem 0rem;
|
||||
}
|
||||
.previous-lyric {
|
||||
font-size: 3rem;
|
||||
line-height: 3.5rem;
|
||||
margin: 2rem 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;
|
||||
}
|
33
src/lib/lyrics/lyricSearcher.ts
Normal file
33
src/lib/lyrics/lyricSearcher.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import type { LrcJsonData } from "lrc-parser-ts";
|
||||
|
||||
export default function createLyricsSearcher(lrc: LrcJsonData): (progress: number) => number {
|
||||
if (!lrc || !lrc.scripts) return () => 0;
|
||||
const startTimes: number[] = lrc.scripts.map(script => script.start);
|
||||
const endTimes: number[] = lrc.scripts.map(script => script.end);
|
||||
|
||||
return function(progress: number): number {
|
||||
// 使用二分查找定位 progress 对应的歌词索引
|
||||
let left = 0;
|
||||
let right = startTimes.length - 1;
|
||||
|
||||
while (left <= right) {
|
||||
const mid = Math.floor((left + right) / 2);
|
||||
|
||||
if (startTimes[mid] === progress) {
|
||||
return mid;
|
||||
} else if (startTimes[mid] < progress) {
|
||||
left = mid + 1;
|
||||
} else {
|
||||
right = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 循环结束后,检查 left 索引
|
||||
if (left < startTimes.length && startTimes[left] > progress && (left === 0 || endTimes[left - 1] <= progress)) {
|
||||
return left;
|
||||
}
|
||||
|
||||
// 如果没有找到确切的 progress,返回小于等于 progress 的最大索引
|
||||
return Math.max(0, right);
|
||||
};
|
||||
}
|
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;
|
||||
}
|
4
src/lib/server/cache.ts
Normal file
4
src/lib/server/cache.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import NodeCache from "node-cache";
|
||||
export const songData = new NodeCache( { checkperiod: 0 } );
|
||||
export const songNameCache = new NodeCache( { checkperiod: 0} );
|
||||
export const globalMemoryStorage = new NodeCache( { checkperiod: 0} );
|
45
src/lib/server/database/loadData.ts
Normal file
45
src/lib/server/database/loadData.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import fs from 'fs';
|
||||
import { globalMemoryStorage, songData, songNameCache } from '$lib/server/cache.js';
|
||||
import { getDirectoryHash } from '../dirHash';
|
||||
import { safePath } from '../safePath';
|
||||
|
||||
const dataPath = './data/song/';
|
||||
|
||||
export async function loadData() {
|
||||
const LastLoaded: number | undefined = globalMemoryStorage.get('lastLoadData');
|
||||
const LastHash: string | undefined = globalMemoryStorage.get('lastDataDirHash');
|
||||
const currentTime = new Date().getTime();
|
||||
const currentHash = getDirectoryHash(dataPath);
|
||||
// Already loaded.
|
||||
if (LastLoaded && LastHash && currentTime - LastLoaded < 120 * 1000 && currentHash === LastHash) {
|
||||
return;
|
||||
}
|
||||
|
||||
const songList = fs
|
||||
.readdirSync(dataPath)
|
||||
.map((fileName) => {
|
||||
if (fileName.endsWith('.json')) return fileName.slice(0, fileName.length - 5);
|
||||
else return null;
|
||||
})
|
||||
.filter((fileName) => fileName !== null);
|
||||
songData.flushAll();
|
||||
songNameCache.flushAll();
|
||||
for (const songID of songList) {
|
||||
try {
|
||||
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);
|
||||
songData.set(songID, data);
|
||||
const metadata: MusicMetadata = data;
|
||||
songNameCache.set(metadata.name, metadata);
|
||||
} catch {
|
||||
console.error(`[load-song-data] Could not load song ID ${songID}`);
|
||||
}
|
||||
}
|
||||
globalMemoryStorage.set('lastLoadData', new Date().getTime());
|
||||
globalMemoryStorage.set('lastDataDirHash', getDirectoryHash(dataPath));
|
||||
}
|
24
src/lib/server/database/musicInfo.d.ts
vendored
Normal file
24
src/lib/server/database/musicInfo.d.ts
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
interface MusicMetadata {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
singer: string[];
|
||||
producer: string | null;
|
||||
tuning: string[];
|
||||
lyricist: string[];
|
||||
composer: string[];
|
||||
arranger: string[];
|
||||
mixing: string[];
|
||||
pv: string[];
|
||||
illustrator: string[];
|
||||
harmony: string[];
|
||||
instruments: string[];
|
||||
songURL: string[];
|
||||
coverURL: string[];
|
||||
duration: number | null;
|
||||
views: number | null;
|
||||
publishTime: string | null;
|
||||
updateTime: string | null;
|
||||
netEaseID: number | null;
|
||||
lyric: string | null;
|
||||
}
|
36
src/lib/server/dirHash.ts
Normal file
36
src/lib/server/dirHash.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
// Function to get hash for a given directory
|
||||
export function getDirectoryHash(dir: string): string {
|
||||
const fileDetails: string[] = [];
|
||||
|
||||
// Recursive function to traverse the directory
|
||||
function traverseDirectory(currentDir: string): void {
|
||||
const files = fs.readdirSync(currentDir);
|
||||
|
||||
files.forEach(file => {
|
||||
const filePath = path.join(currentDir, file);
|
||||
const stats = fs.lstatSync(filePath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
traverseDirectory(filePath);
|
||||
} else {
|
||||
// Collect file path and last modification time
|
||||
fileDetails.push(`${filePath}:${stats.mtimeMs}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
traverseDirectory(dir);
|
||||
|
||||
// Sort file details to ensure consistent hash
|
||||
fileDetails.sort();
|
||||
|
||||
// Create hash from file details
|
||||
const hash = crypto.createHash('sha256');
|
||||
hash.update(fileDetails.join('\x00'));
|
||||
|
||||
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/state/progressBarRaw.ts
Normal file
3
src/lib/state/progressBarRaw.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { writable } from 'svelte/store';
|
||||
const progressBarRaw = writable(0);
|
||||
export default progressBarRaw;
|
3
src/lib/state/progressBarSlideValue.ts
Normal file
3
src/lib/state/progressBarSlideValue.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { writable } from 'svelte/store';
|
||||
const progressBarSlideValue = writable(0);
|
||||
export default progressBarSlideValue;
|
3
src/lib/state/userAdjustingProgress.ts
Normal file
3
src/lib/state/userAdjustingProgress.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { writable } from 'svelte/store';
|
||||
const userAdjustingProgress = writable(false);
|
||||
export default userAdjustingProgress;
|
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);
|
||||
}
|
@ -2,7 +2,7 @@ export default function(durationInSeconds: number): string {
|
||||
// Calculate hours, minutes, and seconds
|
||||
const hours = Math.floor(durationInSeconds / 3600);
|
||||
const minutes = Math.floor((durationInSeconds % 3600) / 60);
|
||||
const seconds = Math.round(durationInSeconds) % 60;
|
||||
const seconds = Math.floor(durationInSeconds) % 60;
|
||||
|
||||
// Format hours, minutes, and seconds into string
|
||||
let formattedTime = '';
|
@ -4,7 +4,7 @@ export default function(key: string){
|
||||
"audio/ogg": "OGG 容器",
|
||||
"audio/flac": "FLAC 无损音频",
|
||||
"audio/aac": "AAC 音频",
|
||||
"srt": "SRT 字幕"
|
||||
"lrc": "LRC 歌词"
|
||||
}
|
||||
if (!key) return "未知格式";
|
||||
else return dict[key as keyof typeof dict];
|
8
src/lib/utils/formatViews.ts
Normal file
8
src/lib/utils/formatViews.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export function formatViews(num: number): string {
|
||||
if (num >= 10000) {
|
||||
const formattedNum = Math.floor(num / 1000) / 10; // 向下保留1位小数
|
||||
return `${formattedNum} 万`;
|
||||
} else {
|
||||
return num.toString() + " ";
|
||||
}
|
||||
}
|
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){
|
||||
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;
|
||||
while (size >= 1000 && unitIndex < units.length - 1) {
|
||||
size /= 1000;
|
13
src/lib/utils/songUpdateTime.ts
Normal file
13
src/lib/utils/songUpdateTime.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export function getCurrentFormattedDateTime() {
|
||||
const now = new Date();
|
||||
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0'); // getMonth() is zero-based
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
}
|
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">
|
||||
import extractFileName from '$lib/extractFileName';
|
||||
import toHumanSize from '$lib/humanSize';
|
||||
import localforage from '$lib/storage';
|
||||
import extractFileName from '$lib/utils/extractFileName';
|
||||
import getVersion from '$lib/utils/getVersion';
|
||||
import toHumanSize from '$lib/utils/humanSize';
|
||||
import localforage from '$lib/utils/storage';
|
||||
interface Song {
|
||||
name: string;
|
||||
singer?: string;
|
||||
@ -63,7 +64,7 @@
|
||||
>
|
||||
<span class="font-bold">{musicList[id].name}</span> <br />
|
||||
<span>{toHumanSize(musicList[id].size)}</span> ·
|
||||
<a href={`/import/${id}/lyric`}>导入歌词</a>
|
||||
<a class="!no-underline" href={`/import/${id}/lyric`}>导入歌词</a>
|
||||
{#if musicList[id].coverUrl}
|
||||
<img
|
||||
class="h-16 w-16 object-cover absolute rounded-lg right-2 top-2"
|
||||
@ -77,7 +78,7 @@
|
||||
</ul>
|
||||
</div>
|
||||
<p>
|
||||
AquaVox 1.8.0 · 早期公开预览 · 源代码参见
|
||||
AquaVox {getVersion()} · 早期公开预览 · 源代码参见
|
||||
<a href="https://github.com/alikia2x/aquavox">GitHub</a>
|
||||
</p>
|
||||
<a href="/import">导入音乐</a> <br />
|
||||
@ -85,6 +86,8 @@
|
||||
on:click={() => window.confirm('确定要删除本地数据库中所有内容吗?') && clear()}
|
||||
class="text-white bg-red-500 px-4 py-2 mt-4 rounded-md">一键清除</button
|
||||
>
|
||||
<h2 class="mt-4"><a href="/database/">音乐数据库</a></h2>
|
||||
<p>你可以在这里探索,提交和分享好听的歌曲。</p>
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
|
28
src/routes/api/database/search/+server.ts
Normal file
28
src/routes/api/database/search/+server.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { songNameCache } from '$lib/server/cache.js';
|
||||
import { loadData } from '$lib/server/database/loadData';
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const keyword = url.searchParams.get('keyword');
|
||||
|
||||
await loadData();
|
||||
|
||||
if (keyword === null) {
|
||||
return error(400, {
|
||||
'message': 'Miss parameter: keyword'
|
||||
});
|
||||
}
|
||||
|
||||
const resultList: MusicMetadata[] = [];
|
||||
|
||||
for (const songName of songNameCache.keys()) {
|
||||
if (songName.toLocaleLowerCase().includes(keyword.toLocaleLowerCase())) {
|
||||
resultList.push(songNameCache.get(songName)!);
|
||||
}
|
||||
}
|
||||
|
||||
return json({
|
||||
'result': resultList
|
||||
});
|
||||
};
|
43
src/routes/api/database/song/[id]/+server.ts
Normal file
43
src/routes/api/database/song/[id]/+server.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { safePath } from '$lib/server/safePath';
|
||||
import { getCurrentFormattedDateTime } from '$lib/utils/songUpdateTime';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import fs from 'fs';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
const filePath = safePath(`${params.id}.json`, { base: './data/song' });
|
||||
if (!filePath) {
|
||||
return error(404, {
|
||||
message: "No correspoding song."
|
||||
});
|
||||
}
|
||||
let data;
|
||||
try { data = fs.readFileSync(filePath); } catch (e) {
|
||||
return error(404, {
|
||||
message: "No corresponding song."
|
||||
});
|
||||
}
|
||||
return json(JSON.parse(data.toString()));
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async ({ request, params }) => {
|
||||
const timeStamp = new Date().getTime();
|
||||
try {
|
||||
if (!fs.existsSync("./data/pending/")) {
|
||||
fs.mkdirSync("./data/pending", { mode: 0o755 });
|
||||
}
|
||||
const filePath = `./data/pending/${params.id}-${timeStamp}.json`;
|
||||
const data: MusicMetadata = await request.json();
|
||||
data.updateTime = getCurrentFormattedDateTime();
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 4), { mode: 0o644 });
|
||||
return json({
|
||||
"message": "successfully created"
|
||||
}, {
|
||||
status: 201
|
||||
});
|
||||
} catch (e) {
|
||||
return error(500, {
|
||||
message: "Internal server error."
|
||||
});
|
||||
}
|
||||
}
|
16
src/routes/api/database/songs/+server.ts
Normal file
16
src/routes/api/database/songs/+server.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { songData } from '$lib/server/cache.js';
|
||||
import { loadData } from '$lib/server/database/loadData.js';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const limit = parseInt(url.searchParams.get("limit") ?? "20");
|
||||
const offset = parseInt(url.searchParams.get("offset") ?? "0");
|
||||
loadData();
|
||||
const songIDList = songData.keys().slice(offset, offset + limit);
|
||||
const songDataList = [];
|
||||
for (const songID of songIDList) {
|
||||
songDataList.push(songData.get(songID)!);
|
||||
}
|
||||
return json(songDataList);
|
||||
}
|
3
src/routes/database/+layout.svelte
Normal file
3
src/routes/database/+layout.svelte
Normal file
@ -0,0 +1,3 @@
|
||||
<div class="absolute w-screen md:w-2/3 lg:w-1/2 left-0 md:left-[16.6667%] lg:left-1/4 px-[3%] md:px-0 top-16">
|
||||
<slot />
|
||||
</div>
|
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;
|
34
src/routes/database/+page.svelte
Normal file
34
src/routes/database/+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>
|
23
src/routes/database/edit/[id]/+page.server.ts
Normal file
23
src/routes/database/edit/[id]/+page.server.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import fs from 'fs';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { safePath } from '$lib/server/safePath';
|
||||
|
||||
export const load: PageServerLoad = ({ params }) => {
|
||||
const filePath = safePath(`${params.id}.json`, { base: './data/song' });
|
||||
if (!filePath) {
|
||||
return {
|
||||
songData: null
|
||||
};
|
||||
}
|
||||
try {
|
||||
const dataBuffer = fs.readFileSync(filePath);
|
||||
const data = JSON.parse(dataBuffer.toString());
|
||||
return {
|
||||
songData: data
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
songData: null
|
||||
}
|
||||
}
|
||||
}
|
39
src/routes/database/edit/[id]/+page.svelte
Normal file
39
src/routes/database/edit/[id]/+page.svelte
Normal file
@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
/** @type {import('./$types').PageData} */
|
||||
export let data;
|
||||
import { page } from '$app/stores';
|
||||
const songID = $page.params.id;
|
||||
let editingData: string = JSON.stringify(data.songData, null, 8);
|
||||
|
||||
async function submit() {
|
||||
fetch(`/api/database/song/${songID}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: editingData
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>建议编辑: {data.songData.name} ({songID})</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1 class="text-3xl text-red-500"><a href="/database/">AquaVox 音乐数据库</a></h1>
|
||||
|
||||
<h1>建议编辑: {data.songData.name} ({songID})</h1>
|
||||
|
||||
<textarea bind:value={editingData} class="dark:bg-zinc-600 w-full min-h-[30rem] mt-6" />
|
||||
|
||||
<button
|
||||
class="mt-4 mb-32 h-12 w-24 border-black dark:border-white border-2 flex items-center justify-center rounded-lg"
|
||||
on:click={() => {
|
||||
submit();
|
||||
}}>提交</button
|
||||
>
|
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>
|
61
src/routes/database/submit/+page.svelte
Normal file
61
src/routes/database/submit/+page.svelte
Normal file
@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { getCurrentFormattedDateTime } from '$lib/utils/songUpdateTime';
|
||||
let templateSongData: MusicMetadata = {
|
||||
id: '',
|
||||
name: '',
|
||||
url: '',
|
||||
singer: [],
|
||||
producer: '',
|
||||
tuning: [],
|
||||
lyricist: [],
|
||||
composer: [],
|
||||
arranger: [],
|
||||
mixing: [],
|
||||
pv: [],
|
||||
illustrator: [],
|
||||
harmony: [],
|
||||
instruments: [],
|
||||
songURL: [],
|
||||
coverURL: [],
|
||||
duration: null,
|
||||
views: null,
|
||||
publishTime: null,
|
||||
updateTime: getCurrentFormattedDateTime(),
|
||||
netEaseID: null,
|
||||
lyric: null
|
||||
};
|
||||
let editingData: string = JSON.stringify(templateSongData, null, 8);
|
||||
|
||||
async function submit() {
|
||||
const dataToSubmit: MusicMetadata = JSON.parse(editingData);
|
||||
fetch(`/api/database/song/${dataToSubmit.id}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: editingData
|
||||
}).catch((error) => {
|
||||
console.log(error);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>提交新曲</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1 class="text-3xl text-red-500"><a href="/database/">AquaVox 音乐数据库</a></h1>
|
||||
|
||||
<h1>提交新曲</h1>
|
||||
|
||||
<textarea bind:value={editingData} class="dark:bg-zinc-600 w-full min-h-[36rem] mt-6" />
|
||||
|
||||
<button
|
||||
class="mt-4 mb-32 h-12 w-24 border-black dark:border-white border-2 flex items-center justify-center rounded-lg"
|
||||
on:click={() => {
|
||||
submit();
|
||||
}}>提交</button
|
||||
>
|
@ -2,7 +2,7 @@
|
||||
import { page } from '$app/stores';
|
||||
import FileList from '$lib/components/import/fileList.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 { useAtom } from 'jotai-svelte';
|
||||
const fileList = useAtom(fileListState);
|
||||
@ -16,7 +16,7 @@
|
||||
|
||||
<div class="w-full flex my-3">
|
||||
<h2>歌词文件</h2>
|
||||
<FileSelector accept=".srt" class="ml-auto top-2 relative" />
|
||||
<FileSelector accept=".lrc, .ttml" class="ml-auto top-2 relative" />
|
||||
</div>
|
||||
|
||||
<FileList />
|
||||
|
@ -4,7 +4,7 @@
|
||||
import { fileListState, finalFileListState } from '$lib/state/fileList.state';
|
||||
import { localImportFailed, localImportSuccess } from '$lib/state/localImportStatus.state';
|
||||
import { useAtom } from 'jotai-svelte';
|
||||
import localforage from '$lib/storage';
|
||||
import localforage from '$lib/utils/storage';
|
||||
import { v1 as uuidv1 } from 'uuid';
|
||||
const fileList = useAtom(fileListState);
|
||||
const finalFiles = useAtom(finalFileListState);
|
||||
|
@ -4,16 +4,20 @@
|
||||
import Background from '$lib/components/background.svelte';
|
||||
import Cover from '$lib/components/cover.svelte';
|
||||
import InteractiveBox from '$lib/components/interactiveBox.svelte';
|
||||
import Lyrics from '$lib/components/lyrics.svelte';
|
||||
import extractFileName from '$lib/extractFileName';
|
||||
import extractFileName from '$lib/utils/extractFileName';
|
||||
import localforage from 'localforage';
|
||||
import { writable } from 'svelte/store';
|
||||
import srtParser2 from 'srt-parser-2';
|
||||
import type { Line } from 'srt-parser-2';
|
||||
import lrcParser from '$lib/lyrics/lrc/parser';
|
||||
import type { LrcJsonData } from '$lib/lyrics/type';
|
||||
import userAdjustingProgress from '$lib/state/userAdjustingProgress';
|
||||
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;
|
||||
let audioPlayer: HTMLAudioElement;
|
||||
let audioPlayer: HTMLAudioElement | null = null;
|
||||
let volume = 1;
|
||||
let name = '';
|
||||
let singer = '';
|
||||
@ -23,9 +27,8 @@
|
||||
let paused: boolean = true;
|
||||
let launched = false;
|
||||
let prepared: string[] = [];
|
||||
let originalLyrics: Line[];
|
||||
let originalLyrics: LrcJsonData;
|
||||
let lyricsText: string[] = [];
|
||||
let onAdjustingProgress = false;
|
||||
let hasLyrics: boolean;
|
||||
const coverPath = writable('');
|
||||
let mainInterval: ReturnType<typeof setInterval>;
|
||||
@ -43,22 +46,26 @@
|
||||
]
|
||||
});
|
||||
ms.setActionHandler('play', function () {
|
||||
if (audioPlayer === null) return;
|
||||
audioPlayer.play();
|
||||
paused = false;
|
||||
});
|
||||
|
||||
ms.setActionHandler('pause', function () {
|
||||
if (audioPlayer === null) return;
|
||||
audioPlayer.pause();
|
||||
paused = true;
|
||||
});
|
||||
|
||||
ms.setActionHandler('seekbackward', function () {
|
||||
if (audioPlayer === null) return;
|
||||
if (audioPlayer.currentTime > 4) {
|
||||
audioPlayer.currentTime = 0;
|
||||
}
|
||||
});
|
||||
|
||||
ms.setActionHandler('previoustrack', function () {
|
||||
if (audioPlayer === null) return;
|
||||
if (audioPlayer.currentTime > 4) {
|
||||
audioPlayer.currentTime = 0;
|
||||
}
|
||||
@ -69,7 +76,6 @@
|
||||
getAudioIDMetadata(audioId, (metadata: IAudioMetadata | null) => {
|
||||
if (!metadata) return;
|
||||
duration = metadata.format.duration ? metadata.format.duration : 0;
|
||||
console.log(metadata);
|
||||
singer = metadata.common.artist ? metadata.common.artist : '未知歌手';
|
||||
prepared.push('duration');
|
||||
});
|
||||
@ -81,6 +87,7 @@
|
||||
prepared.push('cover');
|
||||
});
|
||||
localforage.getItem(`${audioId}-file`, function (err, file) {
|
||||
if (audioPlayer === null) return;
|
||||
if (file) {
|
||||
const f = file as File;
|
||||
audioFile = f;
|
||||
@ -94,17 +101,26 @@
|
||||
if (file) {
|
||||
const f = file as File;
|
||||
f.text().then((lr) => {
|
||||
const parser = new srtParser2();
|
||||
originalLyrics = parser.fromSrt(lr);
|
||||
for (const line of originalLyrics) {
|
||||
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);
|
||||
if (!originalLyrics.scripts) return;
|
||||
for (const line of originalLyrics.scripts) {
|
||||
lyricsText.push(line.text);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function playAudio() {
|
||||
if (audioPlayer === null) return;
|
||||
if (audioPlayer.duration) {
|
||||
duration = audioPlayer.duration;
|
||||
}
|
||||
@ -114,7 +130,7 @@
|
||||
}
|
||||
|
||||
$: {
|
||||
if (!launched) {
|
||||
if (!launched && audioPlayer) {
|
||||
const requirements = ['name', 'file', 'cover'];
|
||||
let flag = true;
|
||||
for (const r of requirements) {
|
||||
@ -137,7 +153,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function adjustRealProgress(progress: number) {
|
||||
function adjustDisplayProgress(progress: number) {
|
||||
if (audioPlayer) {
|
||||
currentProgress = duration * progress;
|
||||
}
|
||||
@ -149,23 +165,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
function setOnSlide(value: boolean) {
|
||||
onAdjustingProgress = value;
|
||||
}
|
||||
|
||||
$: {
|
||||
clearInterval(mainInterval);
|
||||
mainInterval = setInterval(() => {
|
||||
if (
|
||||
audioPlayer !== null &&
|
||||
audioPlayer.currentTime !== undefined &&
|
||||
onAdjustingProgress === false
|
||||
) {
|
||||
currentProgress = audioPlayer.currentTime;
|
||||
}
|
||||
}, 100);
|
||||
if (audioPlayer === null) return;
|
||||
if ($userAdjustingProgress === false) currentProgress = audioPlayer.currentTime;
|
||||
progressBarRaw.set(audioPlayer.currentTime);
|
||||
}, 50);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (audioPlayer === null) return;
|
||||
audioPlayer.volume = localStorage.getItem('volume') ? Number(localStorage.getItem('volume')) : 1;
|
||||
});
|
||||
|
||||
$: {
|
||||
if (audioPlayer) {
|
||||
paused = audioPlayer.paused;
|
||||
@ -173,19 +186,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
if (originalLyrics) {
|
||||
hasLyrics = true;
|
||||
} else {
|
||||
hasLyrics = false;
|
||||
}
|
||||
}
|
||||
$: hasLyrics = !!originalLyrics;
|
||||
|
||||
readDB();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{name} - Aquavox</title>
|
||||
<title>{name} - AquaVox</title>
|
||||
</svelte:head>
|
||||
|
||||
<Background coverId={audioId} />
|
||||
@ -200,20 +207,29 @@
|
||||
{paused}
|
||||
{adjustProgress}
|
||||
{adjustVolume}
|
||||
{adjustRealProgress}
|
||||
onSlide={onAdjustingProgress}
|
||||
{setOnSlide}
|
||||
{adjustDisplayProgress}
|
||||
{hasLyrics}
|
||||
/>
|
||||
|
||||
<Lyrics lyrics={lyricsText} {originalLyrics} progress={currentProgress} />
|
||||
<NewLyrics {originalLyrics} progress={currentProgress} player={audioPlayer}/>
|
||||
|
||||
<!-- <Lyrics lyrics={lyricsText} {originalLyrics} progress={currentProgress} player={audioPlayer} class="hidden" /> -->
|
||||
|
||||
<audio
|
||||
bind:this={audioPlayer}
|
||||
controls
|
||||
style="display: none"
|
||||
on:play={() => {
|
||||
if (audioPlayer === null) return;
|
||||
paused = audioPlayer.paused;
|
||||
}}
|
||||
on:pause={() => {
|
||||
if (audioPlayer === null) return;
|
||||
paused = audioPlayer.paused;
|
||||
}}
|
||||
on:ended={() => {
|
||||
paused = true;
|
||||
if (audioPlayer == null) return;
|
||||
audioPlayer.pause();
|
||||
}}
|
||||
></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]
|
11
static/edit.svg
Normal file
11
static/edit.svg
Normal file
@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--Generator: Apple Native CoreSVG 232.5-->
|
||||
<!DOCTYPE svg
|
||||
PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="20.5" height="17.0234">
|
||||
<g>
|
||||
<rect height="17.0234" opacity="0" width="20.5" x="0" y="0"/>
|
||||
<path d="M13.7656 17.0234C15.0469 17.0234 16.0938 15.9766 16.0938 14.6953C16.0938 13.4219 15.0469 12.375 13.7656 12.375C12.4922 12.375 11.4453 13.4219 11.4453 14.6953C11.4453 15.9766 12.4922 17.0234 13.7656 17.0234ZM13.7656 15.9141C13.0859 15.9141 12.5547 15.375 12.5547 14.6953C12.5547 14.0078 13.0859 13.4766 13.7656 13.4766C14.4531 13.4766 14.9844 14.0078 14.9844 14.6953C14.9844 15.375 14.4531 15.9141 13.7656 15.9141ZM12.1562 14L0.695312 14C0.304688 14 0 14.3047 0 14.6953C0 15.0781 0.304688 15.3828 0.695312 15.3828L12.1562 15.3828ZM19.4297 14L15.4922 14L15.4922 15.3828L19.4297 15.3828C19.7891 15.3828 20.0938 15.0781 20.0938 14.6953C20.0938 14.3047 19.7891 14 19.4297 14ZM6.52344 10.8594C7.80469 10.8594 8.85156 9.82031 8.85156 8.53906C8.85156 7.25781 7.80469 6.21094 6.52344 6.21094C5.25 6.21094 4.20312 7.25781 4.20312 8.53906C4.20312 9.82031 5.25 10.8594 6.52344 10.8594ZM6.52344 9.75781C5.84375 9.75781 5.3125 9.21094 5.3125 8.53125C5.3125 7.84375 5.84375 7.3125 6.52344 7.3125C7.21094 7.3125 7.74219 7.84375 7.74219 8.53125C7.74219 9.21094 7.21094 9.75781 6.52344 9.75781ZM0.664062 7.84375C0.304688 7.84375 0 8.14844 0 8.53125C0 8.92188 0.304688 9.22656 0.664062 9.22656L4.82031 9.22656L4.82031 7.84375ZM19.3984 7.84375L8.14062 7.84375L8.14062 9.22656L19.3984 9.22656C19.7891 9.22656 20.0938 8.92188 20.0938 8.53125C20.0938 8.14844 19.7891 7.84375 19.3984 7.84375ZM13.7656 4.6875C15.0469 4.6875 16.0938 3.64062 16.0938 2.35938C16.0938 1.08594 15.0469 0.0390625 13.7656 0.0390625C12.4922 0.0390625 11.4453 1.08594 11.4453 2.35938C11.4453 3.64062 12.4922 4.6875 13.7656 4.6875ZM13.7656 3.57812C13.0859 3.57812 12.5547 3.03906 12.5547 2.35938C12.5547 1.67188 13.0859 1.14062 13.7656 1.14062C14.4531 1.14062 14.9844 1.67188 14.9844 2.35938C14.9844 3.03906 14.4531 3.57812 13.7656 3.57812ZM12.1797 1.67969L0.695312 1.67969C0.304688 1.67969 0 1.98438 0 2.375C0 2.75781 0.304688 3.0625 0.695312 3.0625L12.1797 3.0625ZM19.4297 1.67969L15.4062 1.67969L15.4062 3.0625L19.4297 3.0625C19.7891 3.0625 20.0938 2.75781 20.0938 2.375C20.0938 1.98438 19.7891 1.67969 19.4297 1.67969Z" fill="#ffffff"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 2.4 KiB |
@ -1,4 +1,4 @@
|
||||
import adapter from '@sveltejs/adapter-auto';
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
|
@ -9,7 +9,8 @@
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["bun"]
|
||||
}
|
||||
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
|
||||
// except $lib which is handled by https://kit.svelte.dev/docs/configuration#files
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user