move web components to web/
This commit is contained in:
4
web/.gitignore
vendored
Normal file
4
web/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
build/
|
||||
result
|
||||
node_modules/
|
||||
tsconfig.tsbuildinfo
|
||||
42
web/Dockerfile
Normal file
42
web/Dockerfile
Normal file
@@ -0,0 +1,42 @@
|
||||
# Build stage
|
||||
FROM --platform=$TARGETPLATFORM node:20-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY backend/package*.json ./backend/
|
||||
COPY frontend/package*.json ./frontend/
|
||||
|
||||
# Copy source files
|
||||
COPY . .
|
||||
|
||||
# Build frontend and backend
|
||||
RUN npm install
|
||||
RUN npm run build --workspaces
|
||||
|
||||
# Production stage
|
||||
FROM --platform=$TARGETPLATFORM debian:testing-20250203
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
mpv npm yt-dlp pulseaudio pulseaudio-utils ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install only production dependencies
|
||||
COPY backend/package*.json ./
|
||||
COPY package-lock.json ./
|
||||
|
||||
RUN rm -rf node_modules/ # need to do a clean build
|
||||
RUN npm ci --production
|
||||
|
||||
# Copy built files
|
||||
COPY --from=builder /app/backend/build ./build
|
||||
COPY --from=builder /app/frontend/dist ./dist/frontend
|
||||
|
||||
# Copy entrypoint script
|
||||
COPY entrypoint.sh ./
|
||||
RUN chmod +x entrypoint.sh
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["./entrypoint.sh"]
|
||||
110
web/Dockerfile.from_source
Normal file
110
web/Dockerfile.from_source
Normal file
@@ -0,0 +1,110 @@
|
||||
ARG YTDLP_VERSION='2024.03.10'
|
||||
ARG MPV_VERSION='0.39'
|
||||
|
||||
# Build stage for Node.js application
|
||||
FROM --platform=$TARGETPLATFORM node:23-alpine AS node-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
COPY frontend/package*.json ./frontend/
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
RUN cd frontend && npm ci
|
||||
|
||||
# Copy source files
|
||||
COPY . .
|
||||
|
||||
# Build frontend and backend
|
||||
RUN npm run build
|
||||
|
||||
# Build stage for mpv
|
||||
FROM --platform=$TARGETPLATFORM alpine AS mpv-builder
|
||||
|
||||
# Install build dependencies for mpv
|
||||
RUN apk add --no-cache \
|
||||
git \
|
||||
python3 \
|
||||
python3-dev \
|
||||
py3-pip \
|
||||
meson \
|
||||
ninja \
|
||||
pkgconfig \
|
||||
gcc \
|
||||
g++ \
|
||||
musl-dev \
|
||||
make \
|
||||
ffmpeg-dev \
|
||||
mesa-dev \
|
||||
alsa-lib-dev \
|
||||
libplacebo-dev \
|
||||
libass-dev \
|
||||
lua \
|
||||
lua-dev \
|
||||
pulseaudio-dev
|
||||
|
||||
# Clone and build mpv
|
||||
ARG MPV_VERSION
|
||||
WORKDIR /src
|
||||
RUN git clone --depth 1 -b release/${MPV_VERSION} https://github.com/mpv-player/mpv.git && \
|
||||
cd mpv && \
|
||||
meson setup build --prefix=/tmp/mpv-prefix && \
|
||||
meson compile -C build && \
|
||||
meson install -C build && \
|
||||
tar -czf /tmp/mpv-install.tar.gz -C /tmp/mpv-prefix .
|
||||
|
||||
# Build stage for yt-dlp
|
||||
FROM --platform=$TARGETPLATFORM python:3.11-alpine AS ytdlp-builder
|
||||
|
||||
# Install build dependencies for yt-dlp
|
||||
RUN apk add --no-cache \
|
||||
git \
|
||||
make \
|
||||
py3-pip \
|
||||
python3-dev \
|
||||
gcc \
|
||||
musl-dev
|
||||
|
||||
# Clone and build yt-dlp
|
||||
ARG YTDLP_VERSION
|
||||
WORKDIR /build
|
||||
RUN git clone https://github.com/yt-dlp/yt-dlp.git --single-branch --branch ${YTDLP_VERSION} .
|
||||
RUN python3 devscripts/install_deps.py --include pyinstaller
|
||||
RUN python3 devscripts/make_lazy_extractors.py
|
||||
RUN python3 -m bundle.pyinstaller --name=yt-dlp
|
||||
|
||||
# Production stage
|
||||
FROM --platform=$TARGETPLATFORM alpine:latest
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache \
|
||||
python3 \
|
||||
ffmpeg \
|
||||
mesa \
|
||||
alsa-lib \
|
||||
pulseaudio \
|
||||
pulseaudio-utils \
|
||||
libstdc++ \
|
||||
ca-certificates \
|
||||
npm
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install only production dependencies for Node.js
|
||||
COPY package*.json ./
|
||||
RUN npm ci --production
|
||||
|
||||
# Copy built files from previous stages
|
||||
COPY --from=node-builder /app/build ./build
|
||||
COPY --from=node-builder /app/frontend/dist ./dist/frontend
|
||||
COPY --from=ytdlp-builder /build/dist/yt-dlp /usr/bin/
|
||||
|
||||
COPY --from=mpv-builder /tmp/mpv-install.tar.gz /tmp/
|
||||
RUN tar -xzf /tmp/mpv-install.tar.gz -C / && \
|
||||
rm /tmp/mpv-install.tar.gz
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", "build/server.js"]
|
||||
|
||||
674
web/LICENSE
Normal file
674
web/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>.
|
||||
23
web/Makefile
Normal file
23
web/Makefile
Normal file
@@ -0,0 +1,23 @@
|
||||
VERSION := $(shell git describe --always --dirty)
|
||||
|
||||
.PHONY: build
|
||||
build:
|
||||
npm install
|
||||
npm run build --workspaces
|
||||
|
||||
.PHONY: dev
|
||||
dev:
|
||||
npm run dev
|
||||
|
||||
.PHONY: run
|
||||
run:
|
||||
npm run start
|
||||
|
||||
.PHONY: image
|
||||
image:
|
||||
docker build -t queuecube:$(VERSION)-$(shell uname -m) .
|
||||
|
||||
.PHONY: images
|
||||
images:
|
||||
docker buildx build --platform linux/arm/v7 -t queuecube:$(VERSION)-armv7l .
|
||||
docker buildx build --platform linux/amd64 -t queuecube:$(VERSION)-amd64 .
|
||||
33
web/backend/package.json
Normal file
33
web/backend/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "mpvqueue",
|
||||
"version": "1.0.0",
|
||||
"main": "build/server.js",
|
||||
"scripts": {
|
||||
"build": "tsc -b",
|
||||
"dev": "concurrently \"tsc -w -p src\" \"nodemon build/server.js\"",
|
||||
"start": "node build/index.js"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/express-ws": "^3.0.5",
|
||||
"@types/node": "^22.13.4",
|
||||
"@types/ws": "^8.5.14",
|
||||
"concurrently": "^9.1.2",
|
||||
"nodemon": "^3.1.9",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/node-fetch": "^2.6.12",
|
||||
"bonjour-service": "^1.3.0",
|
||||
"classnames": "^2.5.1",
|
||||
"express": "^4.21.2",
|
||||
"express-ws": "^5.0.2",
|
||||
"link-preview-js": "^3.0.14",
|
||||
"node-fetch": "^2.7.0",
|
||||
"react-icons": "^5.4.0",
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
}
|
||||
149
web/backend/src/FavoritesStore.ts
Normal file
149
web/backend/src/FavoritesStore.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* FavoritesStore.ts
|
||||
* Copyleft 2025 James Magahern <buzzert@buzzert.net>
|
||||
*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { PlaylistItem } from './types';
|
||||
import { getLinkPreview } from 'link-preview-js';
|
||||
|
||||
export class FavoritesStore {
|
||||
onFavoritesChanged: (favorites: PlaylistItem[]) => void = () => {};
|
||||
|
||||
private storePath: string;
|
||||
private favorites: PlaylistItem[] = [];
|
||||
|
||||
constructor() {
|
||||
this.storePath = this.determineStorePath();
|
||||
this.loadFavorites();
|
||||
}
|
||||
|
||||
private determineStorePath(): string {
|
||||
const storeFilename = 'favorites.json';
|
||||
var storePath = path.join(os.tmpdir(), 'queuecube');
|
||||
|
||||
// Check for explicitly set path
|
||||
if (process.env.STORE_PATH) {
|
||||
storePath = path.resolve(process.env.STORE_PATH);
|
||||
}
|
||||
|
||||
// In production (in a container), use /app/data
|
||||
else if (process.env.NODE_ENV === 'production') {
|
||||
storePath = path.resolve('/app/data');
|
||||
}
|
||||
|
||||
fs.mkdir(storePath, { recursive: true }).catch(err => {
|
||||
console.error('Failed to create intermediate directory:', err);
|
||||
});
|
||||
|
||||
const fullPath = path.join(storePath, storeFilename);
|
||||
console.log("Favorites store path: " + fullPath);
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
private async loadFavorites() {
|
||||
try {
|
||||
// Ensure parent directory exists
|
||||
await fs.mkdir(path.dirname(this.storePath), { recursive: true });
|
||||
|
||||
const data = await fs.readFile(this.storePath, 'utf-8');
|
||||
this.favorites = JSON.parse(data);
|
||||
} catch (error) {
|
||||
// If file doesn't exist or is invalid, start with empty array
|
||||
this.favorites = [];
|
||||
await this.saveFavorites();
|
||||
}
|
||||
}
|
||||
|
||||
private async saveFavorites() {
|
||||
await fs.writeFile(this.storePath, JSON.stringify(this.favorites, null, 2));
|
||||
this.onFavoritesChanged(this.favorites);
|
||||
}
|
||||
|
||||
async getFavorites(): Promise<PlaylistItem[]> {
|
||||
return this.favorites;
|
||||
}
|
||||
|
||||
async addFavorite(filename: string): Promise<void> {
|
||||
// Check if the item already exists by filename
|
||||
const exists = this.favorites.some(f => f.filename === filename);
|
||||
if (!exists) {
|
||||
this.favorites.push({
|
||||
filename: filename,
|
||||
id: this.favorites.length // Generate new ID
|
||||
});
|
||||
await this.saveFavorites();
|
||||
|
||||
// Fetch metadata for the new favorite
|
||||
await this.fetchMetadata(filename);
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchMetadata(filename: string): Promise<void> {
|
||||
console.log("Fetching metadata for " + filename);
|
||||
const metadata = await getLinkPreview(filename);
|
||||
|
||||
const item: PlaylistItem = {
|
||||
filename: filename,
|
||||
id: this.favorites.length,
|
||||
metadata: {
|
||||
title: (metadata as any)?.title,
|
||||
description: (metadata as any)?.description,
|
||||
siteName: (metadata as any)?.siteName,
|
||||
},
|
||||
};
|
||||
|
||||
console.log("Metadata fetched for " + item.filename);
|
||||
console.log(item);
|
||||
|
||||
const index = this.favorites.findIndex(f => f.filename === filename);
|
||||
if (index !== -1) {
|
||||
this.favorites[index] = item;
|
||||
await this.saveFavorites();
|
||||
}
|
||||
}
|
||||
|
||||
async removeFavorite(filename: string): Promise<void> {
|
||||
console.log("Removing favorite " + filename);
|
||||
this.favorites = this.favorites.filter(f => f.filename !== filename);
|
||||
await this.saveFavorites();
|
||||
}
|
||||
|
||||
async updateFavoriteTitle(filename: string, title: string): Promise<void> {
|
||||
console.log(`Updating title for favorite ${filename} to "${title}"`);
|
||||
const index = this.favorites.findIndex(f => f.filename === filename);
|
||||
if (index !== -1) {
|
||||
// Create metadata object if it doesn't exist
|
||||
if (!this.favorites[index].metadata) {
|
||||
this.favorites[index].metadata = {};
|
||||
}
|
||||
|
||||
// Update the title in metadata
|
||||
this.favorites[index].metadata!.title = title;
|
||||
|
||||
await this.saveFavorites();
|
||||
} else {
|
||||
throw new Error(`Favorite with filename ${filename} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
async clearFavorites(): Promise<void> {
|
||||
this.favorites = [];
|
||||
await this.saveFavorites();
|
||||
}
|
||||
}
|
||||
120
web/backend/src/InvidiousAPI.ts
Normal file
120
web/backend/src/InvidiousAPI.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* InvidiousAPI.ts
|
||||
* Copyleft 2025 James Magahern <buzzert@buzzert.net>
|
||||
*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
interface InvidiousVideoThumbnail {
|
||||
quality: string;
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface InvidiousResult {
|
||||
type: string;
|
||||
title: string;
|
||||
videoId: string;
|
||||
playlistId: string;
|
||||
author: string;
|
||||
videoThumbnails?: InvidiousVideoThumbnail[];
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
type: string;
|
||||
title: string;
|
||||
author: string;
|
||||
mediaUrl: string;
|
||||
thumbnailUrl: string;
|
||||
}
|
||||
|
||||
export interface ThumbnailResponse {
|
||||
data: NodeJS.ReadableStream;
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
const USE_INVIDIOUS = process.env.USE_INVIDIOUS || true;
|
||||
const INVIDIOUS_BASE_URL = process.env.INVIDIOUS_BASE_URL || process.env.INVIDIOUS_URL || 'http://invidious.nor';
|
||||
const INVIDIOUS_API_ENDPOINT = `${INVIDIOUS_BASE_URL}/api/v1`;
|
||||
|
||||
export const getInvidiousSearchURL = (query: string): string =>
|
||||
`${INVIDIOUS_API_ENDPOINT}/search?q=${encodeURIComponent(query)}`;
|
||||
|
||||
export const getInvidiousThumbnailURL = (url: string): string =>
|
||||
`${INVIDIOUS_BASE_URL}/${url}`;
|
||||
|
||||
const preferredThumbnailAPIURL = (thumbnails: InvidiousVideoThumbnail[] | undefined): string => {
|
||||
if (!thumbnails || thumbnails.length === 0) {
|
||||
return '/assets/placeholder.jpg';
|
||||
}
|
||||
|
||||
const mediumThumbnail = thumbnails.find(t => t.quality === 'medium');
|
||||
const thumbnail = mediumThumbnail || thumbnails[0];
|
||||
return `/api/thumbnail?url=${encodeURIComponent(thumbnail.url)}`;
|
||||
};
|
||||
|
||||
const getMediaURL = (result: InvidiousResult): string => {
|
||||
if (result.type === 'video') {
|
||||
return `https://www.youtube.com/watch?v=${result.videoId}`;
|
||||
} else if (result.type === 'playlist') {
|
||||
return `https://www.youtube.com/playlist?list=${result.playlistId}`;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown result type: ${result.type}`);
|
||||
};
|
||||
|
||||
export const searchInvidious = async (query: string): Promise<SearchResult[]> => {
|
||||
try {
|
||||
const response = await fetch(getInvidiousSearchURL(query));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Invidious HTTP error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as Array<InvidiousResult>;
|
||||
return data.filter(item => {
|
||||
return item.type === 'video' || item.type === 'playlist';
|
||||
}).map(item => ({
|
||||
type: item.type,
|
||||
title: item.title,
|
||||
author: item.author,
|
||||
mediaUrl: getMediaURL(item),
|
||||
thumbnailUrl: preferredThumbnailAPIURL(item.videoThumbnails)
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to search Invidious:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const fetchThumbnail = async (thumbnailUrl: string): Promise<ThumbnailResponse> => {
|
||||
let path = thumbnailUrl;
|
||||
if (thumbnailUrl.startsWith('http://') || thumbnailUrl.startsWith('https://')) {
|
||||
const url = new URL(thumbnailUrl);
|
||||
path = url.pathname + url.search;
|
||||
}
|
||||
path = path.replace(/^\/+/, ''); // Strip leading slash
|
||||
|
||||
const response = await fetch(getInvidiousThumbnailURL(path));
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return {
|
||||
data: response.body,
|
||||
contentType: response.headers.get('content-type') || 'image/jpeg'
|
||||
};
|
||||
};
|
||||
483
web/backend/src/MediaPlayer.ts
Normal file
483
web/backend/src/MediaPlayer.ts
Normal file
@@ -0,0 +1,483 @@
|
||||
/*
|
||||
* MediaPlayer.ts
|
||||
* Copyleft 2025 James Magahern <buzzert@buzzert.net>
|
||||
*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
import { ChildProcess, spawn } from "child_process";
|
||||
import { Socket } from "net";
|
||||
import { WebSocket } from "ws";
|
||||
import { getLinkPreview } from "link-preview-js";
|
||||
import { PlaylistItem, LinkMetadata } from './types';
|
||||
import { FavoritesStore } from "./FavoritesStore";
|
||||
import { Bonjour } from "bonjour-service";
|
||||
import os from 'os';
|
||||
|
||||
interface PendingCommand {
|
||||
resolve: (value: any) => void;
|
||||
reject: (reason: any) => void;
|
||||
}
|
||||
|
||||
enum UserEvent {
|
||||
PlaylistUpdate = "playlist_update",
|
||||
NowPlayingUpdate = "now_playing_update",
|
||||
VolumeUpdate = "volume_update",
|
||||
FavoritesUpdate = "favorites_update",
|
||||
MetadataUpdate = "metadata_update",
|
||||
MPDUpdate = "mpd_update",
|
||||
}
|
||||
|
||||
export interface Features {
|
||||
video: boolean;
|
||||
screenshare: boolean;
|
||||
browserPlayback: boolean;
|
||||
}
|
||||
|
||||
export class MediaPlayer {
|
||||
private playerProcess: ChildProcess | null = null;
|
||||
private socket: Promise<Socket>;
|
||||
|
||||
private eventSubscribers: WebSocket[] = [];
|
||||
private favoritesStore: FavoritesStore;
|
||||
|
||||
private pendingCommands: Map<number, PendingCommand> = new Map();
|
||||
private requestId: number = 1;
|
||||
private dataBuffer: string = '';
|
||||
private metadata: Map<string, LinkMetadata> = new Map();
|
||||
private bonjourInstance: Bonjour | null = null;
|
||||
|
||||
constructor() {
|
||||
this.socket = this.tryRespawnPlayerProcess();
|
||||
|
||||
this.favoritesStore = new FavoritesStore();
|
||||
this.favoritesStore.onFavoritesChanged = (favorites) => {
|
||||
this.handleEvent(UserEvent.FavoritesUpdate, { favorites });
|
||||
};
|
||||
|
||||
this.getFeatures().then(features => {
|
||||
console.log("Features: ", features);
|
||||
});
|
||||
}
|
||||
|
||||
public startZeroconfService(port: number) {
|
||||
if (this.bonjourInstance) {
|
||||
console.log("Zeroconf service already running");
|
||||
return;
|
||||
}
|
||||
|
||||
this.bonjourInstance = new Bonjour();
|
||||
|
||||
const service = this.bonjourInstance.publish({
|
||||
name: `QueueCube Media Server (${os.hostname()})`,
|
||||
type: 'queuecube',
|
||||
port: port,
|
||||
txt: {
|
||||
version: '1.0.0',
|
||||
features: 'playlist,favorites,screenshare'
|
||||
}
|
||||
});
|
||||
|
||||
service.on('up', () => {
|
||||
console.log(`Zeroconf service advertised: ${service.name} on port ${port}`);
|
||||
});
|
||||
|
||||
service.on('error', (err: Error) => {
|
||||
console.error('Zeroconf service error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
public stopZeroconfService() {
|
||||
if (this.bonjourInstance) {
|
||||
this.bonjourInstance.destroy();
|
||||
this.bonjourInstance = null;
|
||||
console.log("Zeroconf service stopped");
|
||||
}
|
||||
}
|
||||
|
||||
private tryRespawnPlayerProcess(): Promise<Socket> {
|
||||
const socketFilename = Math.random().toString(36).substring(2, 10);
|
||||
const socketPath = `/tmp/mpv-${socketFilename}`;
|
||||
const enableVideo = process.env.ENABLE_VIDEO || false;
|
||||
const logfilePath = `/tmp/mpv-logfile.txt`;
|
||||
|
||||
console.log("Starting player process (video: " + (enableVideo ? "enabled" : "disabled") + ")");
|
||||
this.playerProcess = spawn("mpv", [
|
||||
"--video=" + (enableVideo ? "auto" : "no"),
|
||||
"--fullscreen",
|
||||
"--no-terminal",
|
||||
"--idle=yes",
|
||||
"--input-ipc-server=" + socketPath,
|
||||
"--log-file=" + logfilePath,
|
||||
"--msg-level=all=v"
|
||||
]);
|
||||
|
||||
|
||||
let socketReady!: (s: Socket) => void;
|
||||
let socketPromise = new Promise<Socket>(resolve => {
|
||||
socketReady = resolve;
|
||||
});
|
||||
|
||||
this.playerProcess.on("spawn", () => {
|
||||
console.log(`Player process spawned, opening socket @ ${socketPath}`);
|
||||
setTimeout(() => {
|
||||
let socket = this.connectToSocket(socketPath);
|
||||
socketReady(socket);
|
||||
}, 500);
|
||||
});
|
||||
|
||||
this.playerProcess.on("error", (error) => {
|
||||
console.error("Player process error:", error);
|
||||
console.log("Continuing without mpv player...");
|
||||
});
|
||||
|
||||
return socketPromise;
|
||||
}
|
||||
|
||||
public async getPlaylist(): Promise<PlaylistItem[]> {
|
||||
return this.writeCommand("get_property", ["playlist"])
|
||||
.then((response) => {
|
||||
// Enhance playlist items with metadata
|
||||
const playlist = response.data as PlaylistItem[];
|
||||
return playlist.map((item: PlaylistItem) => ({
|
||||
...item,
|
||||
metadata: this.metadata.get(item.filename) || {}
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
public async getNowPlaying(): Promise<PlaylistItem> {
|
||||
const playlist = await this.getPlaylist();
|
||||
const currentlyPlayingSong = playlist.find((item: PlaylistItem) => item.current);
|
||||
const fetchMediaTitle = async (): Promise<string | null> => {
|
||||
try {
|
||||
return (await this.writeCommand("get_property", ["media-title"])).data;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (currentlyPlayingSong !== undefined) {
|
||||
// Use media title if we don't have a title
|
||||
if (currentlyPlayingSong.title === undefined && currentlyPlayingSong.metadata?.title === undefined) {
|
||||
return {
|
||||
...currentlyPlayingSong,
|
||||
title: await fetchMediaTitle() || currentlyPlayingSong.filename
|
||||
};
|
||||
}
|
||||
|
||||
return currentlyPlayingSong;
|
||||
}
|
||||
|
||||
const mediaTitle = await fetchMediaTitle() || "";
|
||||
return {
|
||||
id: 0,
|
||||
filename: mediaTitle,
|
||||
title: mediaTitle
|
||||
};
|
||||
}
|
||||
|
||||
public async getCurrentFile(): Promise<string | null> {
|
||||
return this.writeCommand("get_property", ["stream-open-filename"])
|
||||
.then((response) => {
|
||||
return response.data;
|
||||
}, (reject) => { return null; });
|
||||
}
|
||||
|
||||
public async getPauseState(): Promise<boolean> {
|
||||
return this.writeCommand("get_property", ["pause"])
|
||||
.then((response) => {
|
||||
return response.data;
|
||||
});
|
||||
}
|
||||
|
||||
public async getVolume(): Promise<number> {
|
||||
return this.writeCommand("get_property", ["volume"])
|
||||
.then((response) => {
|
||||
return response.data;
|
||||
});
|
||||
}
|
||||
|
||||
public async getTimePosition(): Promise<number | null> {
|
||||
return this.writeCommand("get_property", ["time-pos"])
|
||||
.then((response) => {
|
||||
return response.data;
|
||||
}, (rejected) => { return null; });
|
||||
}
|
||||
|
||||
public async getDuration(): Promise<number | null> {
|
||||
return this.writeCommand("get_property", ["duration"])
|
||||
.then((response) => {
|
||||
return response.data;
|
||||
}, (rejected) => { return null; });
|
||||
}
|
||||
|
||||
public async getSeekable(): Promise<boolean | null> {
|
||||
return this.writeCommand("get_property", ["seekable"])
|
||||
.then((response) => {
|
||||
return response.data;
|
||||
}, (rejected) => { return null; });
|
||||
}
|
||||
|
||||
public async getIdle(): Promise<boolean> {
|
||||
return this.writeCommand("get_property", ["idle"])
|
||||
.then((response) => {
|
||||
return response.data;
|
||||
});
|
||||
}
|
||||
|
||||
public async append(url: string) {
|
||||
await this.loadFile(url, "append-play");
|
||||
}
|
||||
|
||||
public async replace(url: string) {
|
||||
await this.loadFile(url, "replace");
|
||||
}
|
||||
|
||||
public async initiateScreenSharing(url: string) {
|
||||
console.log(`Initiating screen sharing with file: ${url}`);
|
||||
|
||||
this.metadata.set(url, {
|
||||
title: "Screen Sharing",
|
||||
description: "Screen Sharing",
|
||||
siteName: "Screen Sharing",
|
||||
});
|
||||
|
||||
// Special options for mpv to better handle screen sharing (AI recommended...)
|
||||
await this.loadFile(url, "replace", false, [
|
||||
"demuxer-lavf-o=fflags=+nobuffer+discardcorrupt", // Reduce buffering and discard corrupt frames
|
||||
"demuxer-lavf-o=analyzeduration=100000", // Reduce analyze duration
|
||||
"demuxer-lavf-o=probesize=1000000", // Reduce probe size
|
||||
"untimed=yes", // Ignore timing info
|
||||
"cache=no", // Disable cache
|
||||
"force-seekable=yes", // Force seekable
|
||||
"no-cache=yes", // Disable cache
|
||||
"demuxer-max-bytes=500K", // Limit demuxer buffer
|
||||
"demuxer-readahead-secs=0.1", // Reduce readahead
|
||||
"hr-seek=no", // Disable high-res seeking
|
||||
"video-sync=display-resample", // Better sync mode
|
||||
"video-latency-hacks=yes", // Enable latency hacks
|
||||
"audio-sync=yes", // Enable audio sync
|
||||
"audio-buffer=0.1", // Reduce audio buffer
|
||||
"audio-channels=stereo", // Force stereo audio
|
||||
"audio-samplerate=44100", // Match sample rate
|
||||
"audio-format=s16", // Use 16-bit audio
|
||||
]);
|
||||
|
||||
// Make sure it's playing
|
||||
setTimeout(() => this.play(), 100);
|
||||
}
|
||||
|
||||
public async play() {
|
||||
return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("set_property", ["pause", false]));
|
||||
}
|
||||
|
||||
public async pause() {
|
||||
return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("set_property", ["pause", true]));
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("stop", []));
|
||||
}
|
||||
|
||||
public async skip() {
|
||||
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-next", []));
|
||||
}
|
||||
|
||||
public async skipTo(index: number) {
|
||||
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-play-index", [index]));
|
||||
}
|
||||
|
||||
public async previous() {
|
||||
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-prev", []));
|
||||
}
|
||||
|
||||
public async deletePlaylistItem(index: number) {
|
||||
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-remove", [index]));
|
||||
}
|
||||
|
||||
public async setVolume(volume: number) {
|
||||
return this.modify(UserEvent.VolumeUpdate, () => this.writeCommand("set_property", ["volume", volume]));
|
||||
}
|
||||
|
||||
public async seek(time: number) {
|
||||
return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("seek", [time, "absolute"]));
|
||||
}
|
||||
|
||||
public subscribe(ws: WebSocket) {
|
||||
this.eventSubscribers.push(ws);
|
||||
}
|
||||
|
||||
public unsubscribe(ws: WebSocket) {
|
||||
this.eventSubscribers = this.eventSubscribers.filter(subscriber => subscriber !== ws);
|
||||
}
|
||||
|
||||
public async getFavorites(): Promise<PlaylistItem[]> {
|
||||
return this.favoritesStore.getFavorites();
|
||||
}
|
||||
|
||||
public async addFavorite(filename: string) {
|
||||
return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.addFavorite(filename));
|
||||
}
|
||||
|
||||
public async removeFavorite(filename: string) {
|
||||
return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.removeFavorite(filename));
|
||||
}
|
||||
|
||||
public async clearFavorites() {
|
||||
return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.clearFavorites());
|
||||
}
|
||||
|
||||
public async updateFavoriteTitle(filename: string, title: string) {
|
||||
return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.updateFavoriteTitle(filename, title));
|
||||
}
|
||||
|
||||
public async getFeatures(): Promise<Features> {
|
||||
return {
|
||||
video: !!process.env.ENABLE_VIDEO,
|
||||
screenshare: !!process.env.ENABLE_SCREENSHARE,
|
||||
browserPlayback: !!process.env.ENABLE_BROWSER_PLAYBACK
|
||||
};
|
||||
}
|
||||
|
||||
private async loadFile(url: string, mode: string, fetchMetadata: boolean = true, options: string[] = []) {
|
||||
this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("loadfile", [url, mode, "-1", options.join(',')]));
|
||||
|
||||
if (fetchMetadata) {
|
||||
this.fetchMetadataAndNotify(url).catch(error => {
|
||||
console.warn(`Failed to fetch metadata for ${url}:`, error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async modify<T>(event: UserEvent, func: () => Promise<T>): Promise<T> {
|
||||
return func()
|
||||
.then((result) => {
|
||||
// Notify all subscribers
|
||||
this.handleEvent(event, {});
|
||||
return result;
|
||||
}, (reject) => {
|
||||
console.log("Error modifying playlist: " + reject);
|
||||
return reject;
|
||||
});
|
||||
}
|
||||
|
||||
private async writeCommand(command: string, args: any[]): Promise<any> {
|
||||
// Wait for socket to become available.
|
||||
let socket = await this.socket;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = this.requestId++;
|
||||
|
||||
const commandObject = JSON.stringify({
|
||||
command: [command, ...args],
|
||||
request_id: id
|
||||
});
|
||||
|
||||
try {
|
||||
this.pendingCommands.set(id, { resolve, reject });
|
||||
socket.write(commandObject + '\n');
|
||||
} catch (e: any) {
|
||||
console.error(`Error writing to socket: ${e}. Trying to respawn.`)
|
||||
this.tryRespawnPlayerProcess();
|
||||
}
|
||||
|
||||
// Add timeout to prevent hanging promises
|
||||
setTimeout(() => {
|
||||
if (this.pendingCommands.has(id)) {
|
||||
const pending = this.pendingCommands.get(id);
|
||||
if (pending) {
|
||||
pending.reject(new Error('Command timed out'));
|
||||
this.pendingCommands.delete(id);
|
||||
}
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
private async fetchMetadataAndNotify(url: string) {
|
||||
try {
|
||||
console.log("Fetching metadata for " + url);
|
||||
const metadata = await getLinkPreview(url);
|
||||
this.metadata.set(url, {
|
||||
title: (metadata as any)?.title,
|
||||
description: (metadata as any)?.description,
|
||||
siteName: (metadata as any)?.siteName,
|
||||
});
|
||||
|
||||
console.log("Metadata fetched for " + url);
|
||||
console.log(this.metadata.get(url));
|
||||
|
||||
// Notify clients that metadata has been updated
|
||||
this.handleEvent(UserEvent.MetadataUpdate, {
|
||||
url,
|
||||
metadata: this.metadata.get(url)
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private connectToSocket(path: string): Socket {
|
||||
let socket = new Socket();
|
||||
socket.connect(path);
|
||||
socket.on("data", data => this.receiveData(data.toString()));
|
||||
|
||||
return socket;
|
||||
}
|
||||
|
||||
private handleEvent(event: string, data: any) {
|
||||
console.log("Event [" + event + "]: ", data);
|
||||
|
||||
// Notify all subscribers
|
||||
this.eventSubscribers.forEach(subscriber => {
|
||||
subscriber.send(JSON.stringify({ event, data }));
|
||||
});
|
||||
}
|
||||
|
||||
private receiveData(data: string) {
|
||||
this.dataBuffer += data;
|
||||
|
||||
const lines = this.dataBuffer.split('\n');
|
||||
|
||||
// Keep last incomplete line in the buffer
|
||||
this.dataBuffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim().length > 0) {
|
||||
try {
|
||||
const response = JSON.parse(line);
|
||||
if (response.request_id) {
|
||||
const pending = this.pendingCommands.get(response.request_id);
|
||||
if (pending) {
|
||||
if (response.error == "success") {
|
||||
pending.resolve(response);
|
||||
} else {
|
||||
pending.reject(response.error);
|
||||
}
|
||||
|
||||
this.pendingCommands.delete(response.request_id);
|
||||
}
|
||||
} else if (response.event) {
|
||||
this.handleEvent(UserEvent.MPDUpdate, response);
|
||||
} else {
|
||||
console.log(response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing JSON:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
342
web/backend/src/server.ts
Normal file
342
web/backend/src/server.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
/*
|
||||
* server.ts
|
||||
* Copyleft 2025 James Magahern <buzzert@buzzert.net>
|
||||
*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
import express from "express";
|
||||
import expressWs from "express-ws";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import { MediaPlayer } from "./MediaPlayer";
|
||||
import { searchInvidious, fetchThumbnail } from "./InvidiousAPI";
|
||||
import { PlaylistItem } from './types';
|
||||
import { PassThrough } from "stream";
|
||||
import { AddressInfo } from "net";
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
expressWs(app);
|
||||
|
||||
const apiRouter = express.Router();
|
||||
const mediaPlayer = new MediaPlayer();
|
||||
|
||||
// Create a shared stream that both endpoints can access
|
||||
let activeScreenshareStream: PassThrough | null = null;
|
||||
let activeScreenshareMimeType: string | null = null;
|
||||
|
||||
const withErrorHandling = (func: (req: any, res: any) => Promise<any>) => {
|
||||
return async (req: any, res: any) => {
|
||||
try {
|
||||
await func(req, res);
|
||||
} catch (error: any) {
|
||||
console.log(`Error (${func.name}): ${error}`);
|
||||
res.status(500).send(JSON.stringify({ success: false, error: error.message }));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
apiRouter.get("/playlist", withErrorHandling(async (req, res) => {
|
||||
const playlist = await mediaPlayer.getPlaylist();
|
||||
res.send(playlist);
|
||||
}));
|
||||
|
||||
apiRouter.post("/playlist", withErrorHandling(async (req, res) => {
|
||||
const { url } = req.body as { url: string };
|
||||
await mediaPlayer.append(url);
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.delete("/playlist/:index", withErrorHandling(async (req, res) => {
|
||||
const { index } = req.params as { index: string };
|
||||
await mediaPlayer.deletePlaylistItem(parseInt(index));
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.post("/playlist/replace", withErrorHandling(async (req, res) => {
|
||||
const { url } = req.body as { url: string };
|
||||
await mediaPlayer.replace(url);
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.post("/play", withErrorHandling(async (req, res) => {
|
||||
await mediaPlayer.play();
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.post("/pause", withErrorHandling(async (req, res) => {
|
||||
await mediaPlayer.pause();
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.post("/stop", withErrorHandling(async (req, res) => {
|
||||
await mediaPlayer.stop();
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.post("/skip", withErrorHandling(async (req, res) => {
|
||||
await mediaPlayer.skip();
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.post("/skip/:index", withErrorHandling(async (req, res) => {
|
||||
const { index } = req.params as { index: string };
|
||||
await mediaPlayer.skipTo(parseInt(index));
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.post("/previous", withErrorHandling(async (req, res) => {
|
||||
await mediaPlayer.previous();
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.get("/nowplaying", withErrorHandling(async (req, res) => {
|
||||
const playingItem = await mediaPlayer.getNowPlaying();
|
||||
const currentFile = await mediaPlayer.getCurrentFile();
|
||||
const pauseState = await mediaPlayer.getPauseState();
|
||||
const volume = await mediaPlayer.getVolume();
|
||||
const idle = await mediaPlayer.getIdle();
|
||||
const timePosition = await mediaPlayer.getTimePosition();
|
||||
const duration = await mediaPlayer.getDuration();
|
||||
const seekable = await mediaPlayer.getSeekable();
|
||||
|
||||
res.send(JSON.stringify({
|
||||
success: true,
|
||||
playingItem: playingItem,
|
||||
isPaused: pauseState,
|
||||
volume: volume,
|
||||
isIdle: idle,
|
||||
currentFile: currentFile,
|
||||
timePosition: timePosition,
|
||||
duration: duration,
|
||||
seekable: seekable
|
||||
}));
|
||||
}));
|
||||
|
||||
apiRouter.post("/volume", withErrorHandling(async (req, res) => {
|
||||
const { volume } = req.body as { volume: number };
|
||||
await mediaPlayer.setVolume(volume);
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.post("/player/seek", withErrorHandling(async (req, res) => {
|
||||
const { time } = req.body as { time: number };
|
||||
await mediaPlayer.seek(time);
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.ws("/events", (ws, req) => {
|
||||
console.log("Events client connected");
|
||||
mediaPlayer.subscribe(ws);
|
||||
|
||||
ws.on("close", () => {
|
||||
console.log("Events client disconnected");
|
||||
mediaPlayer.unsubscribe(ws);
|
||||
});
|
||||
});
|
||||
|
||||
// This is effectively a "private" endpoint that only the MPV instance accesses. We're
|
||||
// using the fact that QueueCube/MPV is based all around streaming URLs, so the active
|
||||
// screenshare stream manifests as just another URL to play.
|
||||
apiRouter.get("/screenshareStream", withErrorHandling(async (req, res) => {
|
||||
res.setHeader("Content-Type", activeScreenshareMimeType || "video/mp4");
|
||||
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
res.setHeader("Pragma", "no-cache");
|
||||
res.setHeader("Expires", "0");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
res.setHeader("Transfer-Encoding", "chunked");
|
||||
|
||||
if (!activeScreenshareStream) {
|
||||
res.status(503).send("No active screen sharing session");
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle client disconnection
|
||||
req.on('close', () => {
|
||||
console.log("Screenshare viewer disconnected");
|
||||
});
|
||||
|
||||
// Configure stream for low latency
|
||||
activeScreenshareStream.setMaxListeners(0);
|
||||
|
||||
// Pipe with immediate flush
|
||||
activeScreenshareStream.pipe(res, { end: false });
|
||||
}));
|
||||
|
||||
apiRouter.ws("/screenshare", (ws, req) => {
|
||||
const mimeType = req.query.mimeType as string;
|
||||
console.log("Screen sharing client connected with mimeType: " + mimeType);
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
let firstChunk = false;
|
||||
|
||||
// Configure WebSocket for low latency
|
||||
ws.setMaxListeners(0);
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
ws.on('message', (data: any) => {
|
||||
const buffer = data instanceof Buffer ? data : Buffer.from(data);
|
||||
|
||||
if (!firstChunk) {
|
||||
firstChunk = true;
|
||||
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
const url = `http://localhost:${port}/api/screenshareStream`;
|
||||
console.log(`Starting screen share stream at ${url}`);
|
||||
|
||||
// Create new shared stream with immediate flush
|
||||
activeScreenshareStream = new PassThrough({
|
||||
highWaterMark: 1024 * 1024, // 1MB buffer
|
||||
allowHalfOpen: false
|
||||
});
|
||||
|
||||
activeScreenshareStream.write(buffer);
|
||||
mediaPlayer.initiateScreenSharing(url);
|
||||
} else if (activeScreenshareStream) {
|
||||
// Write with immediate flush
|
||||
activeScreenshareStream.write(buffer, () => {
|
||||
activeScreenshareStream?.cork();
|
||||
activeScreenshareStream?.uncork();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log("Screen sharing client disconnected");
|
||||
if (activeScreenshareStream) {
|
||||
activeScreenshareStream.end();
|
||||
activeScreenshareStream = null;
|
||||
}
|
||||
mediaPlayer.stop();
|
||||
});
|
||||
});
|
||||
|
||||
apiRouter.get("/search", withErrorHandling(async (req, res) => {
|
||||
const query = req.query.q as string;
|
||||
if (!query) {
|
||||
res.status(400)
|
||||
.send(JSON.stringify({ success: false, error: "Query parameter 'q' is required" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await searchInvidious(query);
|
||||
res.send(JSON.stringify({ success: true, results }));
|
||||
}));
|
||||
|
||||
apiRouter.get("/thumbnail", withErrorHandling(async (req, res) => {
|
||||
const thumbnailUrl = req.query.url as string;
|
||||
if (!thumbnailUrl) {
|
||||
res.status(400)
|
||||
.send(JSON.stringify({ success: false, error: "URL parameter is required" }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data, contentType } = await fetchThumbnail(thumbnailUrl);
|
||||
res.set('Content-Type', contentType);
|
||||
data.pipe(res);
|
||||
} catch (error) {
|
||||
console.error('Failed to proxy thumbnail:', error);
|
||||
res.status(500)
|
||||
.send(JSON.stringify({ success: false, error: 'Failed to fetch thumbnail' }));
|
||||
}
|
||||
}));
|
||||
|
||||
apiRouter.get("/favorites", withErrorHandling(async (req, res) => {
|
||||
const favorites = await mediaPlayer.getFavorites();
|
||||
res.send(JSON.stringify(favorites));
|
||||
}));
|
||||
|
||||
apiRouter.post("/favorites", withErrorHandling(async (req, res) => {
|
||||
const { filename } = req.body as { filename: string };
|
||||
console.log("Adding favorite: " + filename);
|
||||
await mediaPlayer.addFavorite(filename);
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.delete("/favorites/:filename", withErrorHandling(async (req, res) => {
|
||||
const { filename } = req.params as { filename: string };
|
||||
await mediaPlayer.removeFavorite(filename);
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.delete("/favorites", withErrorHandling(async (req, res) => {
|
||||
await mediaPlayer.clearFavorites();
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.put("/favorites/:filename/title", withErrorHandling(async (req, res) => {
|
||||
const { filename } = req.params as { filename: string };
|
||||
const { title } = req.body as { title: string };
|
||||
|
||||
if (!title) {
|
||||
res.status(400).send(JSON.stringify({
|
||||
success: false,
|
||||
error: "Title is required"
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
await mediaPlayer.updateFavoriteTitle(filename, title);
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.get("/features", withErrorHandling(async (req, res) => {
|
||||
const features = await mediaPlayer.getFeatures();
|
||||
res.send(JSON.stringify(features));
|
||||
}));
|
||||
|
||||
// Serve static files for React app (after building)
|
||||
app.use(express.static(path.join(__dirname, "../dist/frontend")));
|
||||
|
||||
// Mount API routes under /api
|
||||
app.use("/api", apiRouter);
|
||||
|
||||
// Serve React app for all other routes (client-side routing)
|
||||
app.get("*", (req, res) => {
|
||||
res.sendFile(path.join(__dirname, "../dist/frontend/index.html"));
|
||||
});
|
||||
|
||||
const port = process.env.PORT || 3000;
|
||||
const server = app.listen(port, () => {
|
||||
console.log(`Server is running on port ${port}`);
|
||||
|
||||
// Start zeroconf service advertisement
|
||||
mediaPlayer.startZeroconfService(Number(port));
|
||||
});
|
||||
|
||||
// Add graceful shutdown handling
|
||||
const shutdown = async () => {
|
||||
console.log('Received shutdown signal. Closing server...');
|
||||
|
||||
// Stop zeroconf service
|
||||
mediaPlayer.stopZeroconfService();
|
||||
|
||||
server.close(() => {
|
||||
console.log('Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Force termination after some timeout (10sec)
|
||||
setTimeout(() => {
|
||||
console.log('Forcing server shutdown');
|
||||
process.exit(1);
|
||||
}, 10000);
|
||||
};
|
||||
|
||||
// Handle various shutdown signals
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
9
web/backend/src/tsconfig.json
Normal file
9
web/backend/src/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "../build",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["./**/*"]
|
||||
}
|
||||
32
web/backend/src/types.ts
Normal file
32
web/backend/src/types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* types.ts
|
||||
* Copyleft 2025 James Magahern <buzzert@buzzert.net>
|
||||
*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
export interface LinkMetadata {
|
||||
title?: string;
|
||||
description?: string;
|
||||
siteName?: string;
|
||||
}
|
||||
|
||||
export interface PlaylistItem {
|
||||
id: number;
|
||||
filename: string;
|
||||
title?: string;
|
||||
playing?: boolean;
|
||||
current?: boolean;
|
||||
metadata?: LinkMetadata;
|
||||
}
|
||||
10
web/backend/tsconfig.base.json
Normal file
10
web/backend/tsconfig.base.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2016",
|
||||
"module": "commonjs",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
}
|
||||
}
|
||||
116
web/backend/tsconfig.json
Normal file
116
web/backend/tsconfig.json
Normal file
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||
|
||||
/* Projects */
|
||||
"composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
|
||||
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
|
||||
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
|
||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||
|
||||
/* Language and Environment */
|
||||
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
||||
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
||||
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
||||
|
||||
/* Modules */
|
||||
"module": "commonjs", /* Specify what module code is generated. */
|
||||
"rootDir": "./src",
|
||||
"outDir": "./build",
|
||||
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
||||
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
||||
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
|
||||
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
|
||||
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
|
||||
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
|
||||
// "resolveJsonModule": true, /* Enable importing .json files. */
|
||||
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
|
||||
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
||||
|
||||
/* JavaScript Support */
|
||||
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
|
||||
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
|
||||
|
||||
/* Emit */
|
||||
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
|
||||
// "removeComments": true, /* Disable emitting comments. */
|
||||
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
|
||||
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
|
||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
|
||||
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
|
||||
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
|
||||
|
||||
/* Interop Constraints */
|
||||
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
|
||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
||||
|
||||
/* Type Checking */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
||||
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
|
||||
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
||||
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
||||
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
|
||||
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
|
||||
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
|
||||
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
|
||||
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
|
||||
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||
|
||||
/* Completeness */
|
||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
},
|
||||
"include": ["src/**/*"], // Only include backend files
|
||||
"exclude": ["../frontend/**/*"], // Explicitly exclude frontend files
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./src" }, // Backend reference
|
||||
{ "path": "../frontend" } // Frontend reference
|
||||
]
|
||||
}
|
||||
46
web/entrypoint.sh
Executable file
46
web/entrypoint.sh
Executable file
@@ -0,0 +1,46 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Check if browser playback is enabled
|
||||
if [ "$ENABLE_BROWSER_PLAYBACK" = "1" ]; then
|
||||
echo "Browser playback enabled - setting up audio streaming..."
|
||||
|
||||
echo "Starting PulseAudio..."
|
||||
pulseaudio --start --log-target=syslog --system=false
|
||||
|
||||
# Wait a moment for PulseAudio to initialize
|
||||
sleep 2
|
||||
|
||||
# Create virtual sink
|
||||
echo "Creating virtual audio sink..."
|
||||
pactl load-module module-null-sink sink_name=virtual_output sink_properties=device.description="Virtual_Audio_Output"
|
||||
|
||||
# Make it the default sink
|
||||
pactl set-default-sink virtual_output
|
||||
|
||||
# Create stream directory if it doesn't exist
|
||||
mkdir -p ./dist/frontend/stream
|
||||
|
||||
# Start FFmpeg streaming in background
|
||||
echo "Starting audio stream..."
|
||||
|
||||
FFMPEG_OPTS="-loglevel error -f pulse \
|
||||
-i virtual_output.monitor \
|
||||
-c:a aac -b:a 128k \
|
||||
-f hls \
|
||||
-hls_time 1 \
|
||||
-hls_list_size 3 \
|
||||
-hls_flags delete_segments+append_list \
|
||||
-hls_segment_type mpegts \
|
||||
-hls_segment_filename ./dist/frontend/stream/segment_%03d.ts \
|
||||
./dist/frontend/stream/audio.m3u8"
|
||||
|
||||
echo "FFmpeg options: $FFMPEG_OPTS"
|
||||
|
||||
ffmpeg $FFMPEG_OPTS &
|
||||
else
|
||||
echo "Browser playback disabled - skipping audio streaming setup"
|
||||
fi
|
||||
|
||||
# Start the Node.js server
|
||||
echo "Starting Node.js server..."
|
||||
exec node build/server.js
|
||||
61
web/flake.lock
generated
Normal file
61
web/flake.lock
generated
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1740828860,
|
||||
"narHash": "sha256-cjbHI+zUzK5CPsQZqMhE3npTyYFt9tJ3+ohcfaOF/WM=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "303bd8071377433a2d8f76e684ec773d70c5b642",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
196
web/flake.nix
Normal file
196
web/flake.nix
Normal file
@@ -0,0 +1,196 @@
|
||||
{
|
||||
description = "NodeJS application with mpv, yt-dlp, and pulseaudio dependencies";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
let
|
||||
# Define the NixOS module for the systemd service
|
||||
nixosModule = { config, lib, pkgs, ... }:
|
||||
let
|
||||
cfg = config.services.queuecube;
|
||||
in {
|
||||
options.services.queuecube = {
|
||||
enable = lib.mkEnableOption "QueueCube media player service";
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 3000;
|
||||
description = "Port on which QueueCube will listen";
|
||||
};
|
||||
|
||||
enable_video = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Enable video playback";
|
||||
};
|
||||
|
||||
store_path = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "/var/tmp/queuecube";
|
||||
description = "Path to the store for QueueCube";
|
||||
};
|
||||
|
||||
invidious = lib.mkOption {
|
||||
type = lib.types.submodule {
|
||||
options = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Enable Invidious";
|
||||
};
|
||||
|
||||
url = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "http://invidious.nor";
|
||||
description = "URL of the Invidious instance to use";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
default = {
|
||||
enable = false;
|
||||
url = "http://invidious.nor";
|
||||
};
|
||||
};
|
||||
|
||||
user = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "User account under which QueueCube runs (required)";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
users.users.${cfg.user} = {
|
||||
packages = [ self.packages.${pkgs.system}.queuecube ];
|
||||
};
|
||||
|
||||
systemd.user.services.queuecube = {
|
||||
description = "QueueCube media player service";
|
||||
wantedBy = [ "default.target" ];
|
||||
after = [ "pipewire.service" "pipewire-pulse.service" ];
|
||||
|
||||
serviceConfig = {
|
||||
ExecStart = "${self.packages.${pkgs.system}.queuecube}/bin/queuecube";
|
||||
Restart = "on-failure";
|
||||
RestartSec = 5;
|
||||
|
||||
# Allow access to X11 for mpv
|
||||
Environment = [ "DISPLAY=:0" ];
|
||||
};
|
||||
|
||||
environment = {
|
||||
PORT = toString cfg.port;
|
||||
ENABLE_VIDEO = if cfg.enable_video then "1" else "0";
|
||||
USE_INVIDIOUS = if cfg.invidious.enable then "1" else "0";
|
||||
INVIDIOUS_BASE_URL = cfg.invidious.url;
|
||||
STORE_PATH = cfg.store_path;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
in
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
|
||||
# Define the package using buildNpmPackage
|
||||
queuecube = pkgs.buildNpmPackage {
|
||||
pname = "queuecube";
|
||||
version = "0.1.0";
|
||||
|
||||
src = ./.;
|
||||
|
||||
# Skip the standard buildPhase and provide our own
|
||||
dontNpmBuild = true;
|
||||
buildPhase = ''
|
||||
# First install all dependencies
|
||||
npm install
|
||||
|
||||
# Then run the build with workspaces flag
|
||||
npm run build --workspaces
|
||||
'';
|
||||
|
||||
# Runtime dependencies
|
||||
buildInputs = with pkgs; [
|
||||
mpv
|
||||
yt-dlp
|
||||
pulseaudio
|
||||
];
|
||||
|
||||
# Create a wrapper script to ensure runtime deps are available
|
||||
postInstall = ''
|
||||
# Create the necessary directories
|
||||
mkdir -p $out/lib/node_modules/queuecube
|
||||
|
||||
# Copy the entire project with built files
|
||||
cp -r . $out/lib/node_modules/queuecube
|
||||
|
||||
# Install the frontend build to the backend dist directory
|
||||
mkdir -p $out/lib/node_modules/queuecube/backend/dist/
|
||||
cp -r frontend/dist $out/lib/node_modules/queuecube/backend/dist/frontend
|
||||
|
||||
# Create bin directory if it doesn't exist
|
||||
mkdir -p $out/bin
|
||||
|
||||
# Create executable script
|
||||
cat > $out/bin/queuecube <<EOF
|
||||
#!/bin/sh
|
||||
exec ${pkgs.nodejs}/bin/node $out/lib/node_modules/queuecube/backend/build/server.js
|
||||
EOF
|
||||
|
||||
# Make it executable
|
||||
chmod +x $out/bin/queuecube
|
||||
|
||||
# Wrap the program to include runtime deps in PATH
|
||||
wrapProgram $out/bin/queuecube \
|
||||
--prefix PATH : ${pkgs.lib.makeBinPath [
|
||||
pkgs.mpv
|
||||
pkgs.yt-dlp
|
||||
pkgs.pulseaudio
|
||||
]}
|
||||
'';
|
||||
|
||||
# Let buildNpmPackage handle npm package hash
|
||||
npmDepsHash = "sha256-kwbWqNqji0EcBeRuc/sqQUuGQkE+P8puLTfpAyRRzgY=";
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "NodeJS application with media playback capabilities";
|
||||
platforms = platforms.linux;
|
||||
};
|
||||
};
|
||||
|
||||
in {
|
||||
packages = {
|
||||
default = queuecube;
|
||||
queuecube = queuecube;
|
||||
};
|
||||
|
||||
apps.default = {
|
||||
type = "app";
|
||||
program = "${queuecube}/bin/queuecube";
|
||||
};
|
||||
|
||||
# Development environment
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
nodejs_20
|
||||
nodePackages.npm
|
||||
mpv
|
||||
yt-dlp
|
||||
pulseaudio
|
||||
];
|
||||
};
|
||||
|
||||
# Add a basic check to verify the package builds
|
||||
checks.queuecube = queuecube;
|
||||
}
|
||||
) // {
|
||||
# Export the NixOS module
|
||||
nixosModules.default = nixosModule;
|
||||
nixosModules.queuecube = nixosModule;
|
||||
};
|
||||
}
|
||||
24
web/frontend/.gitignore
vendored
Normal file
24
web/frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
50
web/frontend/README.md
Normal file
50
web/frontend/README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||
|
||||
- Configure the top-level `parserOptions` property like this:
|
||||
|
||||
```js
|
||||
export default tseslint.config({
|
||||
languageOptions: {
|
||||
// other options...
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
|
||||
- Optionally add `...tseslint.configs.stylisticTypeChecked`
|
||||
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import react from 'eslint-plugin-react'
|
||||
|
||||
export default tseslint.config({
|
||||
// Set the react version
|
||||
settings: { react: { version: '18.3' } },
|
||||
plugins: {
|
||||
// Add the react plugin
|
||||
react,
|
||||
},
|
||||
rules: {
|
||||
// other rules...
|
||||
// Enable its recommended rules
|
||||
...react.configs.recommended.rules,
|
||||
...react.configs['jsx-runtime'].rules,
|
||||
},
|
||||
})
|
||||
```
|
||||
28
web/frontend/eslint.config.js
Normal file
28
web/frontend/eslint.config.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
12
web/frontend/index.html
Normal file
12
web/frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Music Control</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
31
web/frontend/package.json
Normal file
31
web/frontend/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.0.6",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwindcss": "^4.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"eslint": "^9.19.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.18",
|
||||
"globals": "^15.14.0",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.22.0",
|
||||
"vite": "^6.1.0"
|
||||
}
|
||||
}
|
||||
BIN
web/frontend/public/assets/placeholder.jpg
Normal file
BIN
web/frontend/public/assets/placeholder.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
207
web/frontend/src/api/player.tsx
Normal file
207
web/frontend/src/api/player.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
export interface NowPlayingResponse {
|
||||
success: boolean;
|
||||
playingItem: PlaylistItem;
|
||||
isPaused: boolean;
|
||||
volume: number;
|
||||
isIdle: boolean;
|
||||
currentFile: string;
|
||||
timePosition?: number;
|
||||
duration?: number;
|
||||
seekable?: boolean;
|
||||
}
|
||||
|
||||
export interface Features {
|
||||
video: boolean;
|
||||
screenshare: boolean;
|
||||
browserPlayback: boolean;
|
||||
}
|
||||
|
||||
export interface Metadata {
|
||||
title?: string;
|
||||
description?: string;
|
||||
siteName?: string;
|
||||
}
|
||||
|
||||
export interface PlaylistItem {
|
||||
filename: string;
|
||||
title: string | null;
|
||||
id: number;
|
||||
playing: boolean | null;
|
||||
metadata?: Metadata;
|
||||
}
|
||||
|
||||
export const getDisplayTitle = (item: PlaylistItem): string => {
|
||||
return item.title || item.metadata?.title || item.filename;
|
||||
}
|
||||
|
||||
export interface MetadataUpdateEvent {
|
||||
event: 'metadata_update';
|
||||
data: {
|
||||
url: string;
|
||||
metadata: Metadata;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
type: string;
|
||||
title: string;
|
||||
author: string;
|
||||
mediaUrl: string;
|
||||
thumbnailUrl: string;
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
success: boolean;
|
||||
results: SearchResult[];
|
||||
}
|
||||
|
||||
export enum ServerEvent {
|
||||
PlaylistUpdate = "playlist_update",
|
||||
NowPlayingUpdate = "now_playing_update",
|
||||
VolumeUpdate = "volume_update",
|
||||
FavoritesUpdate = "favorites_update",
|
||||
MetadataUpdate = "metadata_update",
|
||||
MPDUpdate = "mpd_update",
|
||||
ScreenShare = "screen_share",
|
||||
}
|
||||
|
||||
export const API = {
|
||||
async getFeatures(): Promise<Features> {
|
||||
const response = await fetch('/api/features');
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async getPlaylist(): Promise<PlaylistItem[]> {
|
||||
const response = await fetch('/api/playlist');
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async addToPlaylist(url: string): Promise<void> {
|
||||
await fetch('/api/playlist', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
},
|
||||
|
||||
async replaceCurrentFile(url: string): Promise<void> {
|
||||
await fetch('/api/playlist/replace', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
},
|
||||
|
||||
async removeFromPlaylist(index: number): Promise<void> {
|
||||
await fetch(`/api/playlist/${index}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
|
||||
async play(): Promise<void> {
|
||||
await fetch('/api/play', { method: 'POST' });
|
||||
},
|
||||
|
||||
async stop(): Promise<void> {
|
||||
await fetch('/api/stop', { method: 'POST' });
|
||||
},
|
||||
|
||||
async pause(): Promise<void> {
|
||||
await fetch('/api/pause', { method: 'POST' });
|
||||
},
|
||||
|
||||
async skip(): Promise<void> {
|
||||
await fetch('/api/skip', { method: 'POST' });
|
||||
},
|
||||
|
||||
async skipTo(index: number): Promise<void> {
|
||||
await fetch(`/api/skip/${index}`, { method: 'POST' });
|
||||
},
|
||||
|
||||
async previous(): Promise<void> {
|
||||
await fetch('/api/previous', { method: 'POST' });
|
||||
},
|
||||
|
||||
async getNowPlaying(): Promise<NowPlayingResponse> {
|
||||
const response = await fetch('/api/nowplaying');
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async setVolume(volume: number): Promise<void> {
|
||||
await fetch('/api/volume', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ volume }),
|
||||
});
|
||||
},
|
||||
|
||||
async seek(time: number): Promise<void> {
|
||||
await fetch('/api/player/seek', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ time }),
|
||||
});
|
||||
},
|
||||
|
||||
async search(query: string): Promise<SearchResponse> {
|
||||
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
|
||||
return response.json();
|
||||
},
|
||||
|
||||
subscribeToEvents(onMessage: (event: any) => void): WebSocket {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const ws = new WebSocket(`${protocol}://${window.location.host}/api/events`);
|
||||
ws.onmessage = (event) => {
|
||||
onMessage(JSON.parse(event.data));
|
||||
};
|
||||
|
||||
return ws;
|
||||
},
|
||||
|
||||
async getFavorites(): Promise<PlaylistItem[]> {
|
||||
const response = await fetch('/api/favorites');
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async addToFavorites(filename: string): Promise<void> {
|
||||
await fetch('/api/favorites', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ filename }),
|
||||
});
|
||||
},
|
||||
|
||||
async removeFromFavorites(filename: string): Promise<void> {
|
||||
await fetch(`/api/favorites/${encodeURIComponent(filename)}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async clearFavorites(): Promise<void> {
|
||||
await fetch('/api/favorites', { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async updateFavoriteTitle(filename: string, title: string): Promise<void> {
|
||||
await fetch(`/api/favorites/${encodeURIComponent(filename)}/title`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
},
|
||||
|
||||
startScreenShare(mimeType: string): WebSocket {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const ws = new WebSocket(`${protocol}://${window.location.host}/api/screenshare?mimeType=${mimeType}`);
|
||||
return ws;
|
||||
}
|
||||
};
|
||||
64
web/frontend/src/components/AddSongPanel.tsx
Normal file
64
web/frontend/src/components/AddSongPanel.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React, { useState, KeyboardEvent, ChangeEvent } from 'react';
|
||||
import { FaSearch } from 'react-icons/fa';
|
||||
import InvidiousSearchModal from './InvidiousSearchModal';
|
||||
import { USE_INVIDIOUS } from '../config';
|
||||
|
||||
interface AddSongPanelProps {
|
||||
onAddURL: (url: string) => void;
|
||||
}
|
||||
|
||||
const AddSongPanel: React.FC<AddSongPanelProps> = ({ onAddURL }) => {
|
||||
const [url, setUrl] = useState('');
|
||||
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
|
||||
|
||||
const handleAddURL = () => {
|
||||
onAddURL(url);
|
||||
setUrl('');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-fit bg-black/50 md:rounded-b-2xl text-white">
|
||||
<div className="flex flex-row items-center gap-4 w-full px-8 py-4">
|
||||
{USE_INVIDIOUS && (
|
||||
<button
|
||||
className="bg-violet-500/20 text-white p-2 rounded-lg border-2 border-violet-500"
|
||||
onClick={() => setIsSearchModalOpen(true)}
|
||||
>
|
||||
<FaSearch />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={url}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setUrl(e.target.value)}
|
||||
placeholder="Add any URL..."
|
||||
className="p-2 rounded-lg border-2 border-violet-500 flex-grow"
|
||||
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleAddURL();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
className="bg-violet-500 text-white p-2 rounded-lg px-4 border-2 border-violet-500"
|
||||
onClick={handleAddURL}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<InvidiousSearchModal
|
||||
isOpen={isSearchModalOpen}
|
||||
onClose={() => setIsSearchModalOpen(false)}
|
||||
onSelectVideo={(videoUrl) => {
|
||||
onAddURL(videoUrl);
|
||||
setIsSearchModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddSongPanel;
|
||||
402
web/frontend/src/components/App.tsx
Normal file
402
web/frontend/src/components/App.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
import React, { useState, useEffect, useCallback, ReactNode } from 'react';
|
||||
import SongTable from './SongTable';
|
||||
import NowPlaying from './NowPlaying';
|
||||
import AddSongPanel from './AddSongPanel';
|
||||
import RenameFavoriteModal from './RenameFavoriteModal';
|
||||
import { TabView, Tab } from './TabView';
|
||||
import { API, Features, getDisplayTitle, PlaylistItem, ServerEvent } from '../api/player';
|
||||
import { FaMusic, FaHeart, FaPlus, FaEdit } from 'react-icons/fa';
|
||||
import useWebSocket from 'react-use-websocket';
|
||||
import classNames from 'classnames';
|
||||
import { useScreenShare } from '../hooks/useScreenShare';
|
||||
import AudioPlayer from './AudioPlayer';
|
||||
|
||||
enum Tabs {
|
||||
Playlist = "playlist",
|
||||
Favorites = "favorites",
|
||||
}
|
||||
|
||||
const EmptyContent: React.FC<{ label: string}> = ({label}) => (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-white text-2xl font-bold">{label}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface SonglistContentProps {
|
||||
songs: PlaylistItem[];
|
||||
isPlaying: boolean;
|
||||
auxControlProvider?: (song: PlaylistItem) => ReactNode;
|
||||
onNeedsRefresh: () => void;
|
||||
}
|
||||
|
||||
const PlaylistContent: React.FC<SonglistContentProps> = ({ songs, isPlaying, auxControlProvider, onNeedsRefresh }) => {
|
||||
const handleDelete = (index: number) => {
|
||||
API.removeFromPlaylist(index);
|
||||
onNeedsRefresh();
|
||||
};
|
||||
|
||||
const handleSkipTo = (index: number) => {
|
||||
API.skipTo(index);
|
||||
onNeedsRefresh();
|
||||
};
|
||||
|
||||
return (
|
||||
songs.length > 0 ? (
|
||||
<SongTable
|
||||
songs={songs}
|
||||
isPlaying={isPlaying}
|
||||
auxControlProvider={auxControlProvider}
|
||||
onDelete={handleDelete}
|
||||
onSkipTo={handleSkipTo}
|
||||
/>
|
||||
) : (
|
||||
<EmptyContent label="Playlist is empty" />
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const FavoritesContent: React.FC<SonglistContentProps> = ({ songs, isPlaying, auxControlProvider, onNeedsRefresh }) => {
|
||||
const handleDelete = (index: number) => {
|
||||
API.removeFromFavorites(songs[index].filename);
|
||||
onNeedsRefresh();
|
||||
};
|
||||
|
||||
const handleSkipTo = (index: number) => {
|
||||
API.replaceCurrentFile(songs[index].filename);
|
||||
API.play();
|
||||
onNeedsRefresh();
|
||||
};
|
||||
|
||||
return (
|
||||
songs.length > 0 ? (
|
||||
<SongTable
|
||||
songs={songs}
|
||||
isPlaying={isPlaying}
|
||||
auxControlProvider={auxControlProvider}
|
||||
onDelete={handleDelete}
|
||||
onSkipTo={handleSkipTo}
|
||||
/>
|
||||
) : (
|
||||
<EmptyContent label="Favorites are empty" />
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isIdle, setIsIdle] = useState(false);
|
||||
const [nowPlayingSong, setNowPlayingSong] = useState<string | null>(null);
|
||||
const [nowPlayingFileName, setNowPlayingFileName] = useState<string | null>(null);
|
||||
const [timePosition, setTimePosition] = useState<number | undefined>(undefined);
|
||||
const [duration, setDuration] = useState<number | undefined>(undefined);
|
||||
const [seekable, setSeekable] = useState<boolean | undefined>(undefined);
|
||||
const [volume, setVolume] = useState(100);
|
||||
const [volumeSettingIsLocked, setVolumeSettingIsLocked] = useState(false);
|
||||
const [playlist, setPlaylist] = useState<PlaylistItem[]>([]);
|
||||
const [favorites, setFavorites] = useState<PlaylistItem[]>([]);
|
||||
const [selectedTab, setSelectedTab] = useState<Tabs>(Tabs.Playlist);
|
||||
const [isRenameModalOpen, setIsRenameModalOpen] = useState(false);
|
||||
const [favoriteToRename, setFavoriteToRename] = useState<PlaylistItem | null>(null);
|
||||
const [audioEnabled, setAudioEnabled] = useState(false);
|
||||
const [features, setFeatures] = useState<Features | null>(null);
|
||||
|
||||
const {
|
||||
isScreenSharing,
|
||||
isScreenSharingSupported,
|
||||
toggleScreenShare,
|
||||
stopScreenShare
|
||||
} = useScreenShare();
|
||||
|
||||
const fetchPlaylist = useCallback(async () => {
|
||||
const playlist = await API.getPlaylist();
|
||||
setPlaylist(playlist);
|
||||
}, []);
|
||||
|
||||
const fetchFavorites = useCallback(async () => {
|
||||
const favorites = await API.getFavorites();
|
||||
setFavorites(favorites);
|
||||
}, []);
|
||||
|
||||
const fetchNowPlaying = useCallback(async () => {
|
||||
if (volumeSettingIsLocked) {
|
||||
// We are actively changing the volume, which we do actually want to send events
|
||||
// continuously to the server, but we don't want to refresh our state while doing that.
|
||||
return;
|
||||
}
|
||||
|
||||
const nowPlaying = await API.getNowPlaying();
|
||||
setNowPlayingSong(getDisplayTitle(nowPlaying.playingItem));
|
||||
setNowPlayingFileName(nowPlaying.playingItem.filename);
|
||||
setIsPlaying(!nowPlaying.isPaused);
|
||||
setVolume(nowPlaying.volume);
|
||||
setIsIdle(nowPlaying.playingItem ? !nowPlaying.playingItem.playing : true);
|
||||
setTimePosition(nowPlaying.timePosition);
|
||||
setDuration(nowPlaying.duration);
|
||||
setSeekable(nowPlaying.seekable);
|
||||
|
||||
const features = await API.getFeatures();
|
||||
setFeatures(features);
|
||||
}, [volumeSettingIsLocked]);
|
||||
|
||||
const handleAddURL = async (url: string) => {
|
||||
const urlToAdd = url.trim();
|
||||
if (urlToAdd) {
|
||||
if (selectedTab === Tabs.Favorites) {
|
||||
await API.addToFavorites(urlToAdd);
|
||||
fetchFavorites();
|
||||
} else {
|
||||
await API.addToPlaylist(urlToAdd);
|
||||
fetchPlaylist();
|
||||
}
|
||||
|
||||
if (!isPlaying) {
|
||||
await API.play();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const togglePlayPause = async () => {
|
||||
if (isPlaying) {
|
||||
await API.pause();
|
||||
} else {
|
||||
await API.play();
|
||||
}
|
||||
|
||||
fetchNowPlaying();
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
stopScreenShare();
|
||||
|
||||
await API.stop();
|
||||
fetchNowPlaying();
|
||||
};
|
||||
|
||||
const handleSkip = async () => {
|
||||
await API.skip();
|
||||
fetchNowPlaying();
|
||||
};
|
||||
|
||||
const handlePrevious = async () => {
|
||||
await API.previous();
|
||||
fetchNowPlaying();
|
||||
};
|
||||
|
||||
const handleSeek = async (time: number) => {
|
||||
await API.seek(time);
|
||||
fetchNowPlaying();
|
||||
};
|
||||
|
||||
const handleVolumeSettingChange = async (volume: number) => {
|
||||
setVolume(volume);
|
||||
await API.setVolume(volume);
|
||||
};
|
||||
|
||||
const handleWebSocketEvent = useCallback((message: MessageEvent) => {
|
||||
const event = JSON.parse(message.data);
|
||||
switch (event.event) {
|
||||
case ServerEvent.PlaylistUpdate:
|
||||
case ServerEvent.NowPlayingUpdate:
|
||||
case ServerEvent.MetadataUpdate:
|
||||
case ServerEvent.MPDUpdate:
|
||||
fetchPlaylist();
|
||||
fetchNowPlaying();
|
||||
break;
|
||||
case ServerEvent.VolumeUpdate:
|
||||
if (!volumeSettingIsLocked) {
|
||||
fetchNowPlaying();
|
||||
}
|
||||
|
||||
break;
|
||||
case ServerEvent.FavoritesUpdate:
|
||||
fetchFavorites();
|
||||
break;
|
||||
}
|
||||
}, [fetchPlaylist, fetchNowPlaying, fetchFavorites]);
|
||||
|
||||
const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/api/events`;
|
||||
useWebSocket(wsUrl, {
|
||||
onOpen: () => {
|
||||
console.log('WebSocket connected');
|
||||
},
|
||||
onClose: () => {
|
||||
console.log('WebSocket disconnected');
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
},
|
||||
onMessage: handleWebSocketEvent,
|
||||
shouldReconnect: () => true,
|
||||
});
|
||||
|
||||
// Handle visibility changes
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
fetchPlaylist();
|
||||
fetchNowPlaying();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [fetchPlaylist, fetchNowPlaying]);
|
||||
|
||||
const refreshContent = () => {
|
||||
fetchPlaylist();
|
||||
fetchNowPlaying();
|
||||
fetchFavorites();
|
||||
}
|
||||
|
||||
// Initial data fetch
|
||||
useEffect(() => {
|
||||
fetchPlaylist();
|
||||
fetchNowPlaying();
|
||||
fetchFavorites();
|
||||
}, [fetchPlaylist, fetchNowPlaying, fetchFavorites]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (isPlaying) {
|
||||
fetchNowPlaying();
|
||||
}
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isPlaying, fetchNowPlaying]);
|
||||
|
||||
const AuxButton: React.FC<{ children: ReactNode, className: string, title: string, onClick: () => void }> = (props) => (
|
||||
<button
|
||||
className={
|
||||
classNames("hover:text-white transition-colors px-3 py-1 rounded", props.className)
|
||||
}
|
||||
title={props.title}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
|
||||
const playlistAuxControlProvider = (song: PlaylistItem) => {
|
||||
const isFavorite = favorites.some(f => f.filename === song.filename);
|
||||
return (
|
||||
<AuxButton
|
||||
className={classNames({
|
||||
"text-red-500": isFavorite,
|
||||
"text-white/40": !isFavorite,
|
||||
})}
|
||||
title={isFavorite ? "Remove from favorites" : "Add to favorites"}
|
||||
onClick={() => {
|
||||
if (isFavorite) {
|
||||
API.removeFromFavorites(song.filename);
|
||||
} else {
|
||||
API.addToFavorites(song.filename);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FaHeart />
|
||||
</AuxButton>
|
||||
);
|
||||
};
|
||||
|
||||
const favoritesAuxControlProvider = (song: PlaylistItem) => {
|
||||
const isInPlaylist = playlist.some(p => p.filename === song.filename);
|
||||
return (
|
||||
<div className="flex">
|
||||
<AuxButton
|
||||
className="text-white hover:text-white"
|
||||
title="Rename favorite"
|
||||
onClick={() => {
|
||||
setFavoriteToRename(song);
|
||||
setIsRenameModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<FaEdit />
|
||||
</AuxButton>
|
||||
|
||||
<AuxButton
|
||||
className={classNames({
|
||||
"text-white/40": isInPlaylist,
|
||||
"text-white": !isInPlaylist,
|
||||
})}
|
||||
title={isInPlaylist ? "Remove from playlist" : "Add to playlist"}
|
||||
onClick={() => {
|
||||
if (isInPlaylist) {
|
||||
API.removeFromPlaylist(playlist.findIndex(p => p.filename === song.filename));
|
||||
} else {
|
||||
API.addToPlaylist(song.filename);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FaPlus />
|
||||
</AuxButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen w-screen bg-black md:py-10">
|
||||
<div className="bg-violet-900 w-full md:max-w-2xl h-full md:max-h-xl md:border md:rounded-2xl flex flex-col">
|
||||
{features?.browserPlayback && (
|
||||
<AudioPlayer isPlaying={isPlaying} enabled={audioEnabled} />
|
||||
)}
|
||||
|
||||
<NowPlaying
|
||||
className="flex flex-row md:rounded-t-2xl"
|
||||
songName={nowPlayingSong || "(Not Playing)"}
|
||||
fileName={nowPlayingFileName || ""}
|
||||
isPlaying={isPlaying}
|
||||
isIdle={isIdle}
|
||||
timePosition={timePosition}
|
||||
duration={duration}
|
||||
seekable={seekable}
|
||||
onPlayPause={togglePlayPause}
|
||||
onStop={handleStop}
|
||||
onSkip={handleSkip}
|
||||
onPrevious={handlePrevious}
|
||||
onSeek={handleSeek}
|
||||
onScreenShare={toggleScreenShare}
|
||||
isScreenSharing={isScreenSharing}
|
||||
volume={volume}
|
||||
onVolumeSettingChange={handleVolumeSettingChange}
|
||||
onVolumeWillChange={() => setVolumeSettingIsLocked(true)}
|
||||
onVolumeDidChange={() => setVolumeSettingIsLocked(false)}
|
||||
isScreenSharingSupported={isScreenSharingSupported}
|
||||
features={features}
|
||||
audioEnabled={audioEnabled}
|
||||
onAudioEnabledChange={setAudioEnabled}
|
||||
/>
|
||||
|
||||
<TabView selectedTab={selectedTab} onTabChange={setSelectedTab}>
|
||||
<Tab label="Playlist" identifier={Tabs.Playlist} icon={<FaMusic />}>
|
||||
<PlaylistContent
|
||||
songs={playlist}
|
||||
isPlaying={isPlaying}
|
||||
onNeedsRefresh={refreshContent}
|
||||
auxControlProvider={playlistAuxControlProvider}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab label="Favorites" identifier={Tabs.Favorites} icon={<FaHeart />}>
|
||||
<FavoritesContent
|
||||
songs={favorites.map(f => ({ ...f, playing: f.filename === nowPlayingFileName }))}
|
||||
isPlaying={isPlaying}
|
||||
onNeedsRefresh={refreshContent}
|
||||
auxControlProvider={favoritesAuxControlProvider}
|
||||
/>
|
||||
</Tab>
|
||||
</TabView>
|
||||
|
||||
<AddSongPanel onAddURL={handleAddURL} />
|
||||
|
||||
<RenameFavoriteModal
|
||||
isOpen={isRenameModalOpen}
|
||||
onClose={() => setIsRenameModalOpen(false)}
|
||||
favorite={favoriteToRename}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
32
web/frontend/src/components/AudioPlayer.tsx
Normal file
32
web/frontend/src/components/AudioPlayer.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
interface AudioPlayerProps {
|
||||
isPlaying: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const AudioPlayer: React.FC<AudioPlayerProps> = ({ isPlaying, enabled }) => {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled && isPlaying) {
|
||||
console.log("Playing audio");
|
||||
audioRef.current?.play().catch((error) => {
|
||||
console.error("Audio playback error:", error);
|
||||
});
|
||||
} else {
|
||||
console.log("Pausing audio");
|
||||
audioRef.current?.pause();
|
||||
}
|
||||
}, [isPlaying, enabled]);
|
||||
|
||||
return (
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src="/stream/audio.m3u8"
|
||||
preload="metadata"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioPlayer;
|
||||
111
web/frontend/src/components/InvidiousSearchModal.tsx
Normal file
111
web/frontend/src/components/InvidiousSearchModal.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React, { useState, KeyboardEvent } from 'react';
|
||||
import { FaSearch, FaSpinner, FaTimes } from 'react-icons/fa';
|
||||
import { API, SearchResult } from '../api/player';
|
||||
|
||||
interface InvidiousSearchModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSelectVideo: (url: string) => void;
|
||||
}
|
||||
|
||||
const ResultCell: React.FC<{ result: SearchResult, onClick: () => void }> = ({ result, onClick, ...props }) => {
|
||||
return (
|
||||
<div className="flex gap-4 bg-black/20 p-2 rounded-lg cursor-pointer hover:bg-black/30 transition-colors" onClick={onClick} {...props}>
|
||||
<img src={result.thumbnailUrl} alt={result.title} className="w-32 h-18 object-cover rounded" />
|
||||
<div className="flex flex-col justify-center">
|
||||
<h3 className="text-white font-semibold">{result.title}</h3>
|
||||
<p className="text-white/60">{result.author}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const InvidiousSearchModal: React.FC<InvidiousSearchModalProps> = ({ isOpen, onClose, onSelectVideo }) => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchQuery.trim()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await API.search(searchQuery);
|
||||
if (response.success) {
|
||||
setResults(response.results);
|
||||
} else {
|
||||
console.error('Search failed:', response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to search:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch();
|
||||
}
|
||||
};
|
||||
|
||||
const _onSelectVideo = (url: string) => {
|
||||
setSearchQuery('');
|
||||
setResults([]);
|
||||
onSelectVideo(url);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-violet-900 w-full max-w-xl rounded-lg p-4 shadow-lg">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-white text-xl font-bold">Search YouTube (Invidious)</h2>
|
||||
|
||||
<button onClick={onClose} className="text-white/60 hover:text-white">
|
||||
<FaTimes size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search videos..."
|
||||
className="p-2 rounded-lg border-2 border-violet-500 flex-grow bg-black/20 text-white"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={isLoading}
|
||||
className="bg-violet-500 text-white p-2 rounded-lg px-4 border-2 border-violet-500 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? <span className="animate-spin"><FaSpinner /></span> : <span><FaSearch /></span>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[60vh] overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="text-white text-center py-12">Searching...</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{results.map((result) => (
|
||||
<ResultCell
|
||||
key={result.mediaUrl}
|
||||
result={result}
|
||||
onClick={() => _onSelectVideo(result.mediaUrl)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InvidiousSearchModal;
|
||||
176
web/frontend/src/components/NowPlaying.tsx
Normal file
176
web/frontend/src/components/NowPlaying.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import React, { HTMLAttributes, useState, useRef } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { FaPlay, FaPause, FaStepForward, FaStepBackward, FaVolumeUp, FaDesktop, FaStop } from 'react-icons/fa';
|
||||
import { Features } from '../api/player';
|
||||
|
||||
interface NowPlayingProps extends HTMLAttributes<HTMLDivElement> {
|
||||
songName: string;
|
||||
fileName: string;
|
||||
isPlaying: boolean;
|
||||
isIdle: boolean;
|
||||
volume: number;
|
||||
timePosition?: number;
|
||||
duration?: number;
|
||||
seekable?: boolean;
|
||||
onPlayPause: () => void;
|
||||
onStop: () => void;
|
||||
onSkip: () => void;
|
||||
onPrevious: () => void;
|
||||
onSeek: (time: number) => void;
|
||||
|
||||
onScreenShare: () => void;
|
||||
isScreenSharingSupported: boolean;
|
||||
isScreenSharing: boolean;
|
||||
|
||||
// Sent when the volume setting actually changes value
|
||||
onVolumeSettingChange: (volume: number) => void;
|
||||
|
||||
// Sent when the volume is about to start changing
|
||||
onVolumeWillChange: (volume: number) => void;
|
||||
|
||||
// Sent when the volume has changed
|
||||
onVolumeDidChange: (volume: number) => void;
|
||||
|
||||
features: Features | null;
|
||||
|
||||
audioEnabled: boolean;
|
||||
onAudioEnabledChange: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
const NowPlaying: React.FC<NowPlayingProps> = (props) => {
|
||||
const [isSeeking, setIsSeeking] = useState(false);
|
||||
const progressBarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const formatTime = (time: number) => {
|
||||
const minutes = Math.floor(time / 60);
|
||||
const seconds = Math.floor(time % 60);
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (progressBarRef.current) {
|
||||
const rect = progressBarRef.current.getBoundingClientRect();
|
||||
const newSeekPosition = (e.clientX - rect.left) / rect.width;
|
||||
if (props.duration) {
|
||||
props.onSeek(newSeekPosition * props.duration);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const titleArea = props.isScreenSharing ? (
|
||||
<div className="flex flex-row items-center gap-2 text-white text-center justify-center">
|
||||
<FaDesktop size={24} />
|
||||
<div className="text-lg font-bold truncate">Screen Sharing</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={classNames(props.isIdle ? 'opacity-50' : 'opacity-100', "flex flex-row items-center justify-between gap-2 w-full")}>
|
||||
<div className="truncate">
|
||||
<div className="text-lg font-bold truncate">{props.songName}</div>
|
||||
<div className="text-sm truncate">{props.fileName}</div>
|
||||
</div>
|
||||
<div className="text-sm opacity-50 shrink-0">
|
||||
{props.timePosition && props.duration ?
|
||||
(props.seekable ? `${formatTime(props.timePosition)} / ${formatTime(props.duration)}`
|
||||
: `${formatTime(props.timePosition)}` )
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={classNames(props.className, 'bg-black/50 h-fit p-5')}>
|
||||
<div className="flex flex-col w-full gap-2">
|
||||
<div className="flex flex-col w-full h-full bg-black/50 rounded-lg gap-4 overflow-hidden">
|
||||
<div className="p-5">
|
||||
<div className="flex-grow min-w-0 w-full text-white text-left">
|
||||
{titleArea}
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-4 w-full pt-4">
|
||||
<div className="flex items-center gap-2 text-white w-full max-w-[250px]">
|
||||
<FaVolumeUp size={20} />
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={props.volume}
|
||||
onMouseDown={() => props.onVolumeWillChange(props.volume)}
|
||||
onMouseUp={() => props.onVolumeDidChange(props.volume)}
|
||||
onChange={(e) => props.onVolumeSettingChange(Number(e.target.value))}
|
||||
className="fancy-slider h-2 w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-grow"></div>
|
||||
|
||||
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onPrevious}>
|
||||
<FaStepBackward size={24} />
|
||||
</button>
|
||||
|
||||
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onPlayPause}>
|
||||
{(props.isPlaying && !props.isIdle) ? <FaPause size={24} /> : <FaPlay size={24} />}
|
||||
</button>
|
||||
|
||||
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onStop}>
|
||||
<FaStop size={24} className={props.isIdle ? 'opacity-25' : 'opacity-100'} />
|
||||
</button>
|
||||
|
||||
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onSkip}>
|
||||
<FaStepForward size={24} />
|
||||
</button>
|
||||
|
||||
{(props.isScreenSharingSupported && props.features?.screenshare) && (
|
||||
<button
|
||||
className={classNames("text-white hover:text-violet-300 transition-colors rounded-full p-2", props.isScreenSharing ? ' bg-violet-800' : '')}
|
||||
onClick={props.onScreenShare}
|
||||
title="Share your screen"
|
||||
>
|
||||
<FaDesktop size={24} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{props.seekable !== false && (
|
||||
<div
|
||||
ref={progressBarRef}
|
||||
className="w-full h-2 bg-gray-600 cursor-pointer -mt-3"
|
||||
onMouseDown={(e) => {
|
||||
setIsSeeking(true);
|
||||
handleSeek(e);
|
||||
}}
|
||||
onMouseMove={(e) => {
|
||||
if (isSeeking) {
|
||||
handleSeek(e);
|
||||
}
|
||||
}}
|
||||
onMouseUp={() => setIsSeeking(false)}
|
||||
onMouseLeave={() => setIsSeeking(false)}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-violet-500"
|
||||
style={{ width: `${(props.timePosition && props.duration ? (props.timePosition / props.duration) * 100 : 0)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.features?.browserPlayback && (
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-white text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.audioEnabled}
|
||||
onChange={(e) => props.onAudioEnabledChange(e.target.checked)}
|
||||
className="w-4 h-4 text-violet-600 bg-gray-100 border-gray-300 rounded focus:ring-violet-500 focus:ring-2"
|
||||
/>
|
||||
Enable audio playback in browser
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NowPlaying;
|
||||
97
web/frontend/src/components/RenameFavoriteModal.tsx
Normal file
97
web/frontend/src/components/RenameFavoriteModal.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React, { useState, KeyboardEvent, useEffect } from 'react';
|
||||
import { FaTimes, FaCheck } from 'react-icons/fa';
|
||||
import { API, PlaylistItem, getDisplayTitle } from '../api/player';
|
||||
|
||||
interface RenameFavoriteModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
favorite: PlaylistItem | null;
|
||||
}
|
||||
|
||||
const RenameFavoriteModal: React.FC<RenameFavoriteModalProps> = ({ isOpen, onClose, favorite }) => {
|
||||
const [title, setTitle] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (favorite) {
|
||||
setTitle(getDisplayTitle(favorite));
|
||||
}
|
||||
}, [favorite]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!favorite || !title.trim()) return;
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
await API.updateFavoriteTitle(favorite.filename, title);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to rename favorite:', error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSave();
|
||||
} else if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen || !favorite) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-violet-900 w-full max-w-md rounded-lg p-4 shadow-lg">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-white text-xl font-bold">Rename Favorite</h2>
|
||||
|
||||
<button onClick={onClose} className="text-white/60 hover:text-white">
|
||||
<FaTimes size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="text-white/80 block mb-2">Original filename:</label>
|
||||
<div className="text-white/60 text-sm truncate bg-black/20 p-2 rounded-lg mb-4">
|
||||
{favorite.filename}
|
||||
</div>
|
||||
|
||||
<label className="text-white/80 block mb-2">Title:</label>
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter new title..."
|
||||
className="p-2 rounded-lg border-2 border-violet-500 w-full bg-black/20 text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="bg-black/30 text-white p-2 rounded-lg px-4 hover:bg-black/40"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !title.trim()}
|
||||
className="bg-violet-500 text-white p-2 rounded-lg px-4 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
<FaCheck size={16} />
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RenameFavoriteModal;
|
||||
103
web/frontend/src/components/SongRow.tsx
Normal file
103
web/frontend/src/components/SongRow.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import classNames from 'classnames';
|
||||
import React, { useState, useRef, useEffect, ReactNode } from 'react';
|
||||
import { FaPlay, FaVolumeUp, FaVolumeOff } from 'react-icons/fa';
|
||||
import { getDisplayTitle, PlaylistItem } from '../api/player';
|
||||
|
||||
export enum PlayState {
|
||||
NotPlaying,
|
||||
Playing,
|
||||
Paused,
|
||||
}
|
||||
|
||||
export interface SongRowProps {
|
||||
song: PlaylistItem;
|
||||
auxControl?: ReactNode;
|
||||
playState: PlayState;
|
||||
onDelete: () => void;
|
||||
onPlay: () => void;
|
||||
}
|
||||
|
||||
const SongRow: React.FC<SongRowProps> = ({ song, auxControl, playState, onDelete, onPlay }) => {
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (buttonRef.current && !buttonRef.current.contains(event.target as Node)) {
|
||||
setShowDeleteConfirm(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (showDeleteConfirm) {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
};
|
||||
}, [showDeleteConfirm]);
|
||||
|
||||
const displayTitle = getDisplayTitle(song);
|
||||
|
||||
return (
|
||||
<div className={classNames(
|
||||
"flex flex-row w-full h-20 px-2 py-2 items-center border-b gap-2 transition-colors shrink-0", {
|
||||
"qc-highlighted": (playState === PlayState.Playing || playState === PlayState.Paused),
|
||||
"bg-black/30": playState === PlayState.NotPlaying,
|
||||
})}>
|
||||
<div className="flex flex-row gap-2">
|
||||
<button
|
||||
className="text-white/40 hover:text-white transition-colors px-3 py-1 rounded"
|
||||
onClick={onPlay}
|
||||
>
|
||||
{
|
||||
playState === PlayState.Playing ? <FaVolumeUp size={12} />
|
||||
: playState === PlayState.Paused ? <FaVolumeOff size={12} />
|
||||
: <FaPlay size={12} />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-grow min-w-0">
|
||||
{
|
||||
displayTitle ? (
|
||||
<div>
|
||||
<div className="text-white text-md truncate text-bold">
|
||||
{displayTitle}
|
||||
</div>
|
||||
<div className="text-white/80 text-xs truncate">
|
||||
{song.filename}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-white text-md truncate text-bold">
|
||||
{song.filename}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-2">
|
||||
{auxControl}
|
||||
|
||||
<button
|
||||
ref={buttonRef}
|
||||
className="text-red-100 px-3 py-1 bg-red-500/40 rounded"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (showDeleteConfirm) {
|
||||
setShowDeleteConfirm(false);
|
||||
onDelete();
|
||||
} else {
|
||||
setShowDeleteConfirm(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showDeleteConfirm ? 'Delete' : '×'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SongRow;
|
||||
43
web/frontend/src/components/SongTable.tsx
Normal file
43
web/frontend/src/components/SongTable.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React, { useEffect, useRef, ReactNode } from "react";
|
||||
import SongRow, { PlayState } from "./SongRow";
|
||||
import { PlaylistItem } from "../api/player";
|
||||
|
||||
interface SongTableProps {
|
||||
songs: PlaylistItem[];
|
||||
isPlaying: boolean
|
||||
auxControlProvider?: (song: PlaylistItem) => ReactNode;
|
||||
onDelete: (index: number) => void;
|
||||
onSkipTo: (index: number) => void;
|
||||
}
|
||||
|
||||
const SongTable: React.FC<SongTableProps> = ({ songs, isPlaying, auxControlProvider, onDelete, onSkipTo }) => {
|
||||
const nowPlayingIndex = songs.findIndex(song => song.playing ?? false);
|
||||
const songTableRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const songTable = songTableRef.current;
|
||||
if (songTable) {
|
||||
songTable.scrollTop = nowPlayingIndex * 100;
|
||||
}
|
||||
}, [nowPlayingIndex]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-y-auto" ref={songTableRef}>
|
||||
{songs.map((song, index) => (
|
||||
<SongRow
|
||||
key={index}
|
||||
song={song}
|
||||
auxControl={auxControlProvider ? auxControlProvider(song) : undefined}
|
||||
playState={
|
||||
(song.playing ?? false) ? (isPlaying ? PlayState.Playing : PlayState.Paused)
|
||||
: PlayState.NotPlaying
|
||||
}
|
||||
onDelete={() => onDelete(index)}
|
||||
onPlay={() => onSkipTo(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SongTable;
|
||||
64
web/frontend/src/components/TabView.tsx
Normal file
64
web/frontend/src/components/TabView.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface TabProps<T> {
|
||||
label: string;
|
||||
identifier: T;
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const Tab = <T,>({ children }: TabProps<T>) => {
|
||||
// Wrapper component
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
interface TabViewProps<T> {
|
||||
children: ReactNode;
|
||||
selectedTab: T;
|
||||
onTabChange: (tab: T) => void;
|
||||
}
|
||||
|
||||
export const TabView = <T,>({ children, selectedTab, onTabChange }: TabViewProps<T>) => {
|
||||
// Filter and validate children to only get Tab components
|
||||
const tabs = React.Children.toArray(children).filter(
|
||||
(child) => React.isValidElement(child) && child.type === Tab
|
||||
) as React.ReactElement<TabProps<T>>[];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 overflow-hidden">
|
||||
<div className="flex flex-row h-11 border-b border-white/20">
|
||||
{tabs.map((tab, index) => {
|
||||
const isSelected = selectedTab === tab.props.identifier;
|
||||
const rowClassName = classNames(
|
||||
"flex flex-row items-center justify-center w-full gap-2 text-white",
|
||||
{ "qc-highlighted": isSelected }
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={rowClassName}
|
||||
onClick={() => onTabChange(tab.props.identifier)}
|
||||
>
|
||||
{tab.props.icon}
|
||||
<div className="text-sm font-bold">{tab.props.label}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{tabs.map((tab, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={classNames("w-full h-full", {
|
||||
hidden: selectedTab !== tab.props.identifier,
|
||||
})}
|
||||
>
|
||||
{tab.props.children}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1
web/frontend/src/components/index.tsx
Normal file
1
web/frontend/src/components/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default as App } from './App';
|
||||
24
web/frontend/src/config.ts
Normal file
24
web/frontend/src/config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* config.ts
|
||||
* Copyleft 2025 James Magahern <buzzert@buzzert.net>
|
||||
*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
export const USE_INVIDIOUS = import.meta.env.VITE_USE_INVIDIOUS || true;
|
||||
export const INVIDIOUS_BASE_URL = import.meta.env.VITE_INVIDIOUS_BASE_URL || 'http://invidious.nor';
|
||||
export const INVIDIOUS_API_ENDPOINT = `${INVIDIOUS_BASE_URL}/api/v1`;
|
||||
|
||||
export const getInvidiousSearchURL = (query: string): string =>
|
||||
`${INVIDIOUS_API_ENDPOINT}/search?q=${encodeURIComponent(query)}`;
|
||||
136
web/frontend/src/hooks/useScreenShare.ts
Normal file
136
web/frontend/src/hooks/useScreenShare.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { API } from '../api/player';
|
||||
|
||||
interface UseScreenShareResult {
|
||||
isScreenSharing: boolean;
|
||||
isScreenSharingSupported: boolean;
|
||||
toggleScreenShare: () => Promise<void>;
|
||||
stopScreenShare: () => void;
|
||||
}
|
||||
|
||||
function getBestSupportedMimeType() {
|
||||
// Ordered by preference (best first) - all of these include audio+video
|
||||
const mimeTypes = [
|
||||
'video/webm;codecs=vp9,opus', // Best quality, good compression
|
||||
'video/webm;codecs=vp8,opus', // Good fallback, well supported
|
||||
'video/webm;codecs=h264,opus', // Better compatibility with some systems
|
||||
'video/mp4;codecs=h264,aac', // Good for Safari but may not be supported for MediaRecorder
|
||||
'video/webm', // Generic fallback (browser will choose codecs)
|
||||
'video/mp4' // Last resort
|
||||
];
|
||||
|
||||
// Find the first supported mimetype
|
||||
for (const type of mimeTypes) {
|
||||
if (MediaRecorder.isTypeSupported(type)) {
|
||||
console.log(`Using mime type: ${type}`);
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
// If none are supported, return null or a basic fallback
|
||||
console.warn('No preferred mime types supported by this browser');
|
||||
return 'video/webm'; // Most basic fallback
|
||||
}
|
||||
|
||||
export const useScreenShare = (): UseScreenShareResult => {
|
||||
const [isScreenSharing, setIsScreenSharing] = useState(false);
|
||||
const [isScreenSharingSupported, setIsScreenSharingSupported] = useState(false);
|
||||
const screenShareSocketRef = useRef<WebSocket | null>(null);
|
||||
|
||||
// Check if screen sharing is supported
|
||||
useEffect(() => {
|
||||
setIsScreenSharingSupported(
|
||||
typeof navigator !== 'undefined' &&
|
||||
navigator.mediaDevices !== undefined &&
|
||||
typeof navigator.mediaDevices.getDisplayMedia === 'function'
|
||||
);
|
||||
}, []);
|
||||
|
||||
const stopScreenShare = useCallback(() => {
|
||||
if (screenShareSocketRef.current) {
|
||||
screenShareSocketRef.current.close();
|
||||
screenShareSocketRef.current = null;
|
||||
}
|
||||
setIsScreenSharing(false);
|
||||
}, []);
|
||||
|
||||
const startScreenShare = useCallback(async () => {
|
||||
try {
|
||||
const mediaStream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: true,
|
||||
audio: true,
|
||||
});
|
||||
|
||||
let mimeType = getBestSupportedMimeType();
|
||||
console.log('Using MIME type:', mimeType);
|
||||
|
||||
const mediaRecorder = new MediaRecorder(mediaStream, {
|
||||
mimeType: mimeType,
|
||||
videoBitsPerSecond: 2500000, // 2.5 Mbps
|
||||
audioBitsPerSecond: 128000, // 128 kbps
|
||||
});
|
||||
|
||||
// Connect to WebSocket
|
||||
screenShareSocketRef.current = API.startScreenShare(mimeType);
|
||||
|
||||
// Set up WebSocket event handlers
|
||||
screenShareSocketRef.current.onopen = () => {
|
||||
console.log('Screen sharing WebSocket connected');
|
||||
setIsScreenSharing(true);
|
||||
|
||||
mediaRecorder.start(100);
|
||||
};
|
||||
|
||||
screenShareSocketRef.current.onclose = () => {
|
||||
console.log('Screen sharing WebSocket closed');
|
||||
setIsScreenSharing(false);
|
||||
|
||||
// Stop all tracks when WebSocket is closed
|
||||
mediaStream.getTracks().forEach(track => track.stop());
|
||||
};
|
||||
|
||||
screenShareSocketRef.current.onerror = (error) => {
|
||||
console.error('Screen sharing WebSocket error:', error);
|
||||
setIsScreenSharing(false);
|
||||
|
||||
// Stop all tracks on error
|
||||
mediaStream.getTracks().forEach(track => track.stop());
|
||||
};
|
||||
|
||||
// Send data over WebSocket when available
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data && event.data.size > 0 && screenShareSocketRef.current && screenShareSocketRef.current.readyState === WebSocket.OPEN) {
|
||||
screenShareSocketRef.current.send(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle stream ending (user clicks "Stop sharing")
|
||||
mediaStream.getVideoTracks()[0].onended = () => {
|
||||
if (screenShareSocketRef.current) {
|
||||
screenShareSocketRef.current.close();
|
||||
screenShareSocketRef.current = null;
|
||||
}
|
||||
setIsScreenSharing(false);
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error starting screen share:', error);
|
||||
setIsScreenSharing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleScreenShare = useCallback(async () => {
|
||||
if (screenShareSocketRef.current) {
|
||||
stopScreenShare();
|
||||
} else {
|
||||
await startScreenShare();
|
||||
}
|
||||
}, [startScreenShare, stopScreenShare]);
|
||||
|
||||
return {
|
||||
isScreenSharing,
|
||||
isScreenSharingSupported,
|
||||
toggleScreenShare,
|
||||
stopScreenShare
|
||||
};
|
||||
};
|
||||
15
web/frontend/src/index.css
Normal file
15
web/frontend/src/index.css
Normal file
@@ -0,0 +1,15 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@layer components {
|
||||
.fancy-slider {
|
||||
@apply bg-gray-700 rounded-lg appearance-none cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3
|
||||
[&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:bg-white
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
hover:[&::-webkit-slider-thumb]:bg-violet-300;
|
||||
}
|
||||
|
||||
.qc-highlighted {
|
||||
@apply shadow-[inset_0_0_35px_rgba(147,51,234,0.8)];
|
||||
}
|
||||
}
|
||||
10
web/frontend/src/main.tsx
Normal file
10
web/frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import { App } from './components'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
19
web/frontend/src/vite-env.d.ts
vendored
Normal file
19
web/frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* vite-env.d.ts
|
||||
* Copyleft 2025 James Magahern <buzzert@buzzert.net>
|
||||
*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/// <reference types="vite/client" />
|
||||
26
web/frontend/tsconfig.app.json
Normal file
26
web/frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
10
web/frontend/tsconfig.json
Normal file
10
web/frontend/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true
|
||||
}
|
||||
}
|
||||
24
web/frontend/tsconfig.node.json
Normal file
24
web/frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
36
web/frontend/vite.config.ts
Normal file
36
web/frontend/vite.config.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* vite.config.ts
|
||||
* Copyleft 2025 James Magahern <buzzert@buzzert.net>
|
||||
*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
// For development only: proxy /api to backend running on separate port.
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
5937
web/package-lock.json
generated
Normal file
5937
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
web/package.json
Normal file
15
web/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "queuecube",
|
||||
"version": "1.82",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "concurrently \"cd frontend && npm run dev\" \"cd backend && npm run dev\""
|
||||
},
|
||||
"workspaces": [
|
||||
"backend",
|
||||
"frontend"
|
||||
],
|
||||
"dependencies": {
|
||||
"react-use-websocket": "^4.13.0"
|
||||
}
|
||||
}
|
||||
BIN
web/screenshots/queuecube.png
Normal file
BIN
web/screenshots/queuecube.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 196 KiB |
Reference in New Issue
Block a user