Merge branch 'develop' for release v3.29.0

This commit is contained in:
emeric
2022-05-26 14:53:51 +02:00
137 changed files with 2327 additions and 3030 deletions
+2 -2
View File
@@ -17,7 +17,7 @@ extraction:
- libtag1-dev
- libpam0g-dev
after_prepare:
- export WT_VERSION=4.5.0
- export WT_VERSION=4.7.2
- export WT_INSTALL_PREFIX=${LGTM_WORKSPACE}/wt-${WT_VERSION}
- pushd ${LGTM_WORKSPACE}
- git clone https://github.com/emweb/wt.git ${LGTM_WORKSPACE}/wt
@@ -28,7 +28,7 @@ extraction:
- popd
configure:
command:
- export WT_VERSION=4.5.0
- export WT_VERSION=4.7.2
- export WT_INSTALL_PREFIX=${LGTM_WORKSPACE}/wt-${WT_VERSION}
- cmake -DCMAKE_PREFIX_PATH=${WT_INSTALL_PREFIX} -DCMAKE_BUILD_TYPE=Release
-55
View File
@@ -1,55 +0,0 @@
arch:
- amd64
- arm64
language: cpp
os: linux
dist: focal
addons:
apt:
packages:
- build-essential
- cmake
- libboost-all-dev
- libconfig++-dev
- libavcodec-dev
- libavutil-dev
- libavformat-dev
- ffmpeg
- libstb-dev
- libtag1-dev
- libpam0g-dev
- libgraphicsmagick++1-dev
- libgtest-dev
compiler:
- clang
- gcc
before_install:
- |
if [ ! -d ${HOME}/wt-${WT_VERSION}/include ]; then
git clone https://github.com/emweb/wt.git wt
cd wt
git checkout ${WT_VERSION}
cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=${WT_INSTALL_PREFIX} -DBUILD_EXAMPLES=OFF -DENABLE_LIBWTTEST=OFF -DCONNECTOR_FCGI=OFF
make install
cd ..
fi
script: mkdir build && cd build && cmake -DCMAKE_PREFIX_PATH=${WT_INSTALL_PREFIX} -DCMAKE_BUILD_TYPE=${BUILD_TYPE} -DUSE_PAM=${USE_PAM} -DIMAGE_LIBRARY=${IMAGE_LIBRARY} .. && make && make test
cache:
directories:
- ${HOME}/wt-${WT_VERSION}
env:
global:
- MAKEFLAGS="-j 2"
- WT_VERSION=4.5.0
- WT_INSTALL_PREFIX=${HOME}/wt-${WT_VERSION}
jobs:
- BUILD_TYPE=Release USE_PAM=ON IMAGE_LIBRARY=STB
- BUILD_TYPE=Release USE_PAM=OFF IMAGE_LIBRARY=STB
- BUILD_TYPE=Release USE_PAM=OFF IMAGE_LIBRARY=GraphicsMagick++
- BUILD_TYPE=Debug USE_PAM=ON IMAGE_LIBRARY=STB
# clang7 is not enough for full c++17 support
jobs:
exclude:
- compiler: clang
arch: amd64
+1 -1
View File
@@ -5,7 +5,7 @@ project(lms)
set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} ${CMAKE_SOURCE_DIR}/cmake/modules/)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED True)
set(CMAKE_CXX_STANDARD_REQUIRED TRUE)
if (UNIX)
set(CMAKE_SHARED_LINKER_FLAGS "-Wl,--no-undefined")
endif ()
+3 -3
View File
@@ -1,5 +1,5 @@
FROM --platform=$BUILDPLATFORM tonistiigi/xx AS xx
FROM --platform=$BUILDPLATFORM alpine:3.14
FROM --platform=$BUILDPLATFORM alpine:3.16
ARG BUILD_PACKAGES="\
clang \
@@ -30,7 +30,7 @@ RUN xx-apk add --no-scripts --no-cache ${LMS_BUILD_PACKAGES}
ARG PREFIX="/tmp/install"
# STB
ARG STB_VERSION=b42009b3b9d4ca35bc703f5310eedc74f584be58
ARG STB_VERSION=af1a5bc352164740c1cc1354942b1c6b72eacb8a
RUN \
DIR=/tmp/stb && mkdir -p ${DIR} && cd ${DIR} && \
curl -sLO https://github.com/nothings/stb/archive/${STB_VERSION}.tar.gz && \
@@ -47,4 +47,4 @@ RUN \
PKG_CONFIG_PATH=/$(xx-info)/usr/lib/pkgconfig cmake /tmp/lms/ -DCMAKE_INCLUDE_PATH=${PREFIX}/include -DCMAKE_BUILD_TYPE=${LMS_BUILD_TYPE} $(xx-clang --print-cmake-defines) -DCMAKE_PREFIX_PATH=/$(xx-info)/usr/lib/cmake -DBUILD_TESTING=${BUILD_TESTS} && \
VERBOSE=1 make -j$(nproc) && \
xx-verify src/lms/lms && \
xx-info is-cross || make test
(xx-info is-cross || make test)
+8 -3
View File
@@ -1,4 +1,4 @@
FROM alpine:3.14 AS build
FROM alpine:3.16 AS build
WORKDIR /tmp/workdir
@@ -77,7 +77,7 @@ RUN \
make distclean
# WT
ARG WT_VERSION=4.5.0
ARG WT_VERSION=4.7.2
RUN \
DIR=/tmp/wt && mkdir -p ${DIR} && cd ${DIR} && \
curl -sLO https://github.com/emweb/wt/archive/${WT_VERSION}.tar.gz && \
@@ -126,8 +126,13 @@ RUN \
rm -rf /tmp/fakeroot/share/doc && \
rm -rf /tmp/fakeroot/share/man
# Remove useless stuff
RUN \
rm -rf /tmp/fakeroot/share/Wt/resources/jPlayer \
rm -rf /tmp/fakeroot/share/Wt/resources/themes
## Release Stage
FROM alpine:3.14 AS release
FROM alpine:3.16 AS release
LABEL maintainer="Emeric Poupon <itmfr@yahoo.fr>"
ARG RUNTIME_PACKAGES=" \
+16 -45
View File
@@ -1,6 +1,6 @@
- [Installation](#installation)
* [Docker](#docker)
* [Debian Buster packages](#debian-buster-packages)
* [Debian Bullseye packages](#debian-bullseye-packages)
* [From source](#from-source)
+ [Build dependencies](#build-dependencies)
+ [Build](#build)
@@ -12,33 +12,25 @@
* [Deploy on non root path](#deploy-on-non-root-path)
* [Reverse proxy settings](#reverse-proxy-settings)
- [Run](#run)
# Installation
## Docker
_Docker_ images are available, please see detailed instructions on https://hub.docker.com/r/epoupon/lms.
## Debian Buster packages
_Buster_ packages are provided for _amd64_ and _armhf_ architectures.
## Debian Bullseye packages
_Bullseye_ packages are provided for _amd64_ and _armhf_ architectures.
As root, trust the following debian package provider and add it in your list of repositories:
```sh
wget -O - https://debian.poupon.dev/apt/debian/epoupon.gpg.key | apt-key add -
echo "deb https://debian.poupon.dev/apt/debian buster main" > /etc/apt/sources.list.d/epoupon.list
echo "deb https://debian.poupon.dev/apt/debian bullseye main" > /etc/apt/sources.list.d/epoupon.list
```
To install or upgrade _LMS_:
```sh
apt update
apt install lms
```
The _lms_ service is started just after the package installation, run by a dedicated _lms_ system user.</br>
Please refer to [Deployment](#deployment) for further configuration options.
## From source
__Note__: this installation process and the default values of the configuration files have been written for _Debian Buster_. Therefore, you may have to adapt commands and/or paths in order to fit to your distribution.
__Note__: this installation process and the default values of the configuration files have been written for _Debian Bullseye_. Therefore, you may have to adapt commands and/or paths in order to fit to your distribution.
### Build dependencies
__Notes__:
* a C++17 compiler is needed
@@ -49,12 +41,9 @@ apt-get install g++ cmake libboost-program-options-dev libboost-system-dev libav
__Notes__:
* libpam0g-dev is optional (only for using PAM authentication)
* libstb-dev can be replaced by libgraphicsmagick++1-dev (the latter will likely use more RAM)
You also need _Wt4_, which is not packaged yet on _Debian_. See [installation instructions](https://www.webtoolkit.eu/wt/doc/reference/html/InstallationUnix.html).</br>
No optional requirement is needed, except openSSL if you plan not to deploy behind a reverse proxy (which is not recommended).
### Build
Get the latest stable release and build it:
```sh
git clone https://github.com/epoupon/lms.git lms
@@ -66,80 +55,69 @@ cmake .. -DCMAKE_BUILD_TYPE=Release
__Notes__:
* you can customize the installation directory using `-DCMAKE_INSTALL_PREFIX=path` (defaults to `/usr/local`).
* you can customize the image library using `-DIMAGE_LIBRARY=<STB|GraphicsMagick++>`
```sh
make
```
__Note__: you can use `make -jN` to speed up compilation time (N is the number of compilation workers to spawn).
### Installation
__Note__: the commands of this section require root privileges.
```sh
make install
```
Create a dedicated system user:
```sh
useradd --system --group lms
```
Copy the configuration files:
```sh
cp /usr/share/lms/lms.conf /etc/lms.conf
cp /usr/share/lms/lms.service /lib/systemd/system/lms.service
```
Create the working directory and give it access to the _lms_ user:
```sh
mkdir /var/lms
chown lms:lms /var/lms
```
To make _LMS_ run automatically during startup:
```sh
systemctl enable lms
```
### Upgrade
To upgrade _LMS_ from sources, you need to update the master branch and rebuild/install it:
```sh
cd build
git pull
make
```
Then using root privileges:
```sh
make install
systemctl restart lms
```
# Deployment
__Note__: don't forget to give the _lms_ user read access to the music directory you want to scan.
## Configuration
_LMS_ uses a configuration file, installed by default in `/etc/lms.conf`. It is recommended to edit this file and change relevant settings (listen address, listen port, working directory, Subsonic API activation, deployment path, ...).
All other settings are set using the web interface (user management, scan settings, transcode settings, ...).
If a setting is not present in the configuration file, a hardcoded default value is used (the same as in the [default configuration file](conf/lms.conf))
## Authentication backend
You can define which authentication backend to be used thanks to the `authentication-backend` option:
* `internal` (default): _LMS_ uses an internal database to store users and their associated passwords (salted and hashed using [Bcrypt](https://en.wikipedia.org/wiki/Bcrypt)). Only the admin user can create, edit or remove other users.
* `PAM`: the user/password authentication request is forwarded to PAM (see the default [PAM configuration file](conf/pam/lms) provided).
* `http-headers`: _LMS_ uses a configurable HTTP header field, typically set by a reverse proxy to handle [SSO](https://en.wikipedia.org/wiki/Single_sign-on), to extract the login name. You can customize the field to be used using the `http-headers-login-field` option.
__Note__: the first created user is the admin user
### `internal` backend: reset admin password
Open the the database file located in `/var/lms/lms.db` using `sqlite3`:
```sh
sqlite3 /var/lms/lms.db
```
Now force reset the password of the admin user to `adminadmin`:
```sqlite3
UPDATE user SET password_salt="sliIgHUpEoAlBMquivH4VzKXbSzexlvS", password_hash="$2y$07$a0vnQUbGTV/DZyDqOizvbOdCaytNjDhkOPcGoBZup4V0MRKDzOpEG" WHERE id=1;
.quit
```
## Deploy on non root path
If you want to deploy on non root path (e.g. https://mydomain.com/newroot/), you have to set the `deploy-path` option accordingly in `lms.conf`.
As static resources are __not__ related to the `deploy-path` option, you have to perform the following steps if you want them to be on a non root path too:
* Create a new intermediary `newroot` directory in `/usr/share/lms/docroot` and move everything in it.
* Symlink `/usr/share/lms/docroot/newroot/resources` to `/usr/share/Wt/resources`.
@@ -149,12 +127,9 @@ wt-resources = "" # do not comment the whole line
docroot = "/usr/share/lms/docroot/;/newroot/resources,/newroot/css,/newroot/images,/newroot/js,/newroot/favicon.ico";`
deploy-path = "/newroot/"; # ending slash is important
```
If you use nginx as a reverse proxy, you can simply replace `location /` with `location /newroot/` to achieve the same result.
## Reverse proxy settings
_LMS_ is shipped with an embedded web server, but it is recommended to deploy behind a reverse proxy. You have to set the _behind-reverse-proxy_ option to _true_ in the `lms.conf` configuration file.
Here is an example to make _LMS_ properly work on _myserver.org_ using _nginx_:
```
server {
@@ -183,16 +158,12 @@ server {
}
}
```
# Run
```sh
systemctl start lms
```
Log traces can be accessed using journactl:
```sh
journalctl -u lms.service
```
To connect to _LMS_, just open your favorite browser and go to http://localhost:5082
To connect to _LMS_, just open your favorite browser and go to `http://localhost:5082`
+52 -69
View File
@@ -4,77 +4,60 @@
<!--FORMS message blocks-->
<message id="Lms.Admin.Database.template">
<div class ="row">
<div class="col-lg-8">
<form>
<legend>${tr:Lms.Admin.Database.database}</legend>
<div class="form-horizontal">
<div class="form-group">
<label class="col-lg-3 control-label" for="${id:media-directory}">
${tr:Lms.Admin.Database.path}
</label>
<div class="col-lg-9">
${media-directory}
${media-directory-info class="help-block"}
<span class="help-block">${tr:Lms.Admin.Database.path-help}</span>
</div>
</div>
<div class="form-group">
<label class="col-lg-3 control-label" for="${id:update-period}">
${tr:Lms.Admin.Database.update-period}
</label>
<div class="col-lg-9">
${update-period}
${update-period-info class="help-block"}
</div>
</div>
<div class="form-group">
<label class="col-lg-3 control-label" for="${id:update-start-time}">
${tr:Lms.Admin.Database.update-start-time}
</label>
<div class="col-lg-9">
${update-start-time}
${update-start-time-info class="help-block"}
</div>
</div>
</div>
<legend>${tr:Lms.Admin.Database.scan-options}</legend>
<div class="form-horizontal">
<div class="form-group">
<label class="col-lg-3 control-label" for="${id:recommendation-engine-type}">
${tr:Lms.Admin.Database.recommendation-engine-type}
</label>
<div class="col-lg-9">
${recommendation-engine-type}
${recommendation-engine-type-info class="help-block"}
</div>
</div>
<div class="form-group">
<label class="col-lg-3 control-label" for="${id:tags}">
${tr:Lms.Admin.Database.tags}
</label>
<div class="col-lg-9">
${tags}
${tags-info class="help-block"}
</div>
</div>
</div>
<div class="form-horizontal">
<div class="form-group">
<div class="col-lg-offset-3 col-lg-9">
${apply-btn class="btn-primary"} ${discard-btn}
</div>
</div>
</div>
</form>
<form class="row g-3">
<legend>${tr:Lms.Admin.Database.database}</legend>
<div class="col-12">
<label class="form-label" for="${id:media-directory}">
${tr:Lms.Admin.Database.path}
</label>
${media-directory class="form-control"}
<div class="invalid-feedback">
${media-directory-info}
</div>
</div>
</div>
<div class="col-lg-6">
<label class="form-label" for="${id:update-period}">
${tr:Lms.Admin.Database.update-period}
</label>
${update-period class="form-control"}
<div class="invalid-feedback">
${update-period-info"}
</div>
</div>
<div class="col-lg-6">
<label class="form-label" for="${id:update-start-time}">
${tr:Lms.Admin.Database.update-start-time}
</label>
${update-start-time class="form-control"}
<div class="invalid-feedback">
${update-start-time-info}
</div>
</div>
<legend>${tr:Lms.Admin.Database.scan-options}</legend>
<div class="col-lg-6">
<label class="form-label" for="${id:recommendation-engine-type}">
${tr:Lms.Admin.Database.recommendation-engine-type}
</label>
${recommendation-engine-type class="form-control"}
<div class="invalid-feedback">
${recommendation-engine-type-info}
</div>
</div>
<div class="col-12">
<label class="form-label" for="${id:tags}">
${tr:Lms.Admin.Database.tags}
</label>
${tags class="form-control"}
<div class="invalid-feedback">
${tags-info}
</div>
</div>
<div class="col-12">
${apply-btn class="btn btn-primary me-1"}${discard-btn class="btn btn-secondary"}
</div>
</form>
<br/>
<div class="row">
<div class="col-lg-8">
${scanner-controller}
</div>
</div>
${scanner-controller}
</message>
</messages>
+34 -39
View File
@@ -4,49 +4,44 @@
<message id="Lms.Admin.InitWizard.template">
<div class="container">
<div class="row">
<div class="col-lg-8">
<div class="page-header">
<form class="row mt-3 justify-content-md-center g-3">
<div class="col-lg-6">
<div class="row g-3">
<h2>${tr:Lms.Admin.InitWizard.header}</h2>
</div>
<form>
<div class="form-horizontal">
<div class="form-group">
<label class="col-lg-3 control-label" for="${id:admin-login}">
${tr:Lms.login}
</label>
<div class="col-lg-9">
${admin-login}
${admin-login-info class="help-block"}
</div>
</div>
<div class="form-group">
<label class="col-lg-3 control-label" for="${id:password}">
${tr:Lms.password}
</label>
<div class="col-lg-9">
${password}
${password-info class="help-block"}
</div>
</div>
<div class="form-group">
<label class="col-lg-3 control-label" for="${id:password-confirm}">
${tr:Lms.password-confirm}
</label>
<div class="col-lg-9">
${password-confirm}
${password-confirm-info class="help-block"}
</div>
</div>
<div class="form-group">
<div class="col-lg-offset-3 col-lg-9">
${create-btn class="btn-primary"}
</div>
<div class="col-12">
<label class="form-label" for="${id:admin-login}">
${tr:Lms.login}
</label>
${admin-login class="form-control"}
<div class="invalid-feedback">
${admin-login-info}
</div>
</div>
</form>
<div class="col-lg-6">
<label class="form-label" for="${id:password}">
${tr:Lms.password}
</label>
${password class="form-control"}
<div class="invalid-feedback">
${password-info}
</div>
</div>
<div class="col-lg-6">
<label class="form-label" for="${id:password-confirm}">
${tr:Lms.password-confirm}
</label>
${password-confirm class="form-control"}
<div class="invalid-feedback">
${password-confirm-info class="help-block"}
</div>
</div>
<div class="col-12">
${create-btn class="btn btn-primary"}
</div>
${info class="alert alert-success mt-3"}
</div>
</div>
</div>
</form>
</div>
</message>
+35 -33
View File
@@ -2,42 +2,44 @@
<messages xmlns:if="Wt.WTemplate.conditions">
<message id="Lms.Admin.ScannerController.template">
<div class="panel panel-info">
<div class="panel-heading">${tr:Lms.Admin.ScannerController.scanner}</div>
<div class="panel-body">
<div class="form-horizontal">
<div class="form-group">
<label class="col-lg-3 control-label">
${tr:Lms.Admin.ScannerController.status}
</label>
<div class="col-lg-9">
<div class="well well-sm">
${status}
<div>${step-status}</div>
<div class="card">
<h5 class="card-header">${tr:Lms.Admin.ScannerController.scanner}</h5>
<div class="card-body">
<form>
<div class="row g-3">
<div class="col-lg-6">
<label class="form-label" for="${id:status}">
${tr:Lms.Admin.ScannerController.status}
</label>
${status class="form-control"}
</div>
<div class="col-lg-6">
<label class="form-label" for="${id:step-status}">
${tr:Lms.Admin.ScannerController.step-status}
</label>
${step-status class="form-control"}
</div>
<div class="col-12">
<label class="form-label" for="{id:last-scan}">
${tr:Lms.Admin.ScannerController.last-scan}
</label>
<div class="input-group">
${last-scan class="form-control"}
${report-btn class="btn btn-outline-info"}
</div>
</div>
<div class="col-12">
<div class="btn-group">
${scan-btn class="btn btn-primary"}
<button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
</button>
<ul class="dropdown-menu">
<li>${full-scan-btn class="dropdown-item"}</li>
</ul>
</div>
</div>
</div>
<div class="form-group">
<label class="col-lg-3 control-label">
${tr:Lms.Admin.ScannerController.last-scan}
</label>
<div class="col-lg-9">
<div class="well well-sm">
${last-scan}
</div>
</div>
</div>
<div class="form-group">
<div class="col-lg-offset-3 col-lg-9">
${btn-report class="btn btn-xs"}
</div>
</div>
<div class="form-group">
<div class="col-lg-offset-3 col-lg-9">
${btn-action}
</div>
</div>
</div>
</form>
</div>
</div>
</message>
+46 -55
View File
@@ -2,64 +2,55 @@
<messages xmlns:if="Wt.WTemplate.conditions">
<message id="Lms.Admin.User.template">
<div class="row">
<div class="col-lg-8">
<legend>${title}</legend>
<form>
<div class="form-horizontal">
${<if-has-last-login>}
<div class="form-group">
<label class="control-label col-lg-3" for="{id:last-login}">
${tr:Lms.Admin.User.last-login}
</label>
<div class="col-lg-9">
<div class="well well-sm">
${last-login}
</div>
</div>
</div>
${</if-has-last-login>}
${<if-has-login>}
<div class="form-group">
<label class="control-label col-lg-3" for="${id:login}">
${tr:Lms.login}
</label>
<div class="col-lg-9">
${login}
${login-info class="help-block"}
</div>
</div>
${</if-has-login>}
${<if-has-password>}
<div class="form-group">
<label class="control-label col-lg-3" for="${id:password}">
${tr:Lms.password-new}
</label>
<div class="col-lg-9">
${password}
${password-info class="help-block"}
</div>
</div>
${</if-has-password>}
${<if-demo>}
<div class="form-group">
<div class="col-lg-offset-3 col-lg-3" for="${id:demo}">
<div class="checkbox">
<label>${demo}${tr:Lms.Admin.User.demo-account}</label>
${demo-info class="help-block"}
</div>
</div>
</div>
${</if-demo>}
<div class="form-group">
<div class="col-lg-offset-3 col-lg-9">
${save-btn class="btn-primary"}
</div>
<form class="row g-3">
<legend>${title}</legend>
${<if-has-last-login>}
<div class="col-lg-6">
<label class="form-label" for="{id:last-login}">
${tr:Lms.Admin.User.last-login}
</label>
<input type="text" readonly="readonly" class="form-control" id="status" value="${last-login}"/>
</div>
${</if-has-last-login>}
${<if-has-login>}
<div class="col-lg-6">
<label class="form-label" for="${id:login}">
${tr:Lms.login}
</label>
${login class="form-control"}
<div class="invalid-feedback">
${login-info}
</div>
</div>
${</if-has-login>}
${<if-has-password>}
<div class="col-lg-6">
<label class="form-label" for="${id:password}">
${tr:Lms.password-new}
</label>
${password class="form-control"}
<div class="invalid-feedback">
${password-info}
</div>
</div>
${</if-has-password>}
${<if-demo>}
<div class="col-12">
<div class="form-check">
${demo class="form-check-input"}
<label class="form-check-label" for="${id:demo}">
${tr:Lms.Admin.User.demo-account}
</label>
<div class="invalid-feedback">
${demo-info}
</div>
</div>
</form>
</div>
${</if-demo>}
<div class="col-12">
${save-btn class="btn btn-primary"}
</div>
</div>
</form>
</message>
</messages>
+27 -10
View File
@@ -2,22 +2,39 @@
<messages xmlns:if="Wt.WTemplate.conditions">
<message id="Lms.Admin.Users.template">
<div class="Lms-header">
<h2>${tr:Lms.Admin.Users.users}</h2>
</div>
${users}
<h3>${tr:Lms.Admin.Users.users}</h3>
${users class="d-grid gap-1 mb-3"}
${<if-can-create-user>}
${add-btn class="btn-primary Lms-admin-users-add-btn"}
${add-btn class="btn btn-primary"}
${</if-can-create-user>}
</message>
<message id="Lms.Admin.Users.template.entry">
<div class="row Lms-admin-users-entry Lms-vertical-align">
<div class="col-md-5 col-xs-8 Lms-vertical-align">
${name}${<if-tag>}<span class="label label-info Lms-admin-users-entry-role">${tag}</span>${</if-tag>}
<div class="d-flex align-items-center Lms-bg-dark-hover rounded">
<div class="p-2 flex-fill">
${name}${<if-tag>}<span class="badge bg-info ms-2">${tag}</span>${</if-tag>}
</div>
<div class="col-md-3 col-xs-4 Lms-horizontal-center">
${<if-edit>}${edit-btn class="btn-warning btn-sm Lms-admin-users-entry-btn"}${del-btn class="btn-danger btn-sm Lms-admin-users-entry-btn"}${</if-edit>}
<div class="p-2">
${<if-edit>}${edit-btn class="btn btn-sm btn-secondary me-1"}${del-btn class="btn btn-sm btn-danger"}${</if-edit>}
</div>
</div>
</message>
<message id="Lms.Admin.Users.template.delete-user">
<div class="modal fade" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">${tr:Lms.Admin.Users.del-user}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
${tr:Lms.Admin.Users.del-user-name}
</div>
<div class="modal-footer">
${del-btn class="btn btn-danger me-1"}${cancel-btn class="btn btn-secondary"}
</div>
</div>
</div>
</div>
</message>
+23 -44
View File
@@ -3,56 +3,35 @@
<!--FORMS message blocks-->
<message id="Lms.Explore.Artist.template">
<div class="Lms-header">
<h2><div class="Lms-artistname text-primary">${name}</div></h2>
${clusters}
${play-btn class="Lms-explore-btn Lms-btn"}${more-btn class="Lms-explore-btn Lms-btn"}
<div class="mb-3">
<h3>${name}</h3>
${clusters class="mb-2"}
<div class="btn-group">
${play-btn class="btn btn-primary"}
<button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false"/>
<ul class="dropdown-menu">
<li>${play-shuffled class="btn dropdown-item"}</li>
<li>${play-last class="btn dropdown-item"}</li>
<li>${star class="btn dropdown-item"}</li>
${<if-has-mbid>}
<li><a href="${mbid-link}" target="_blank" class="dropdown-item">${tr:Lms.Explore.musicbrainz-artist}</a></li>
${</if-has-mbid>}
<li>${download class="btn dropdown-item"}</li>
</ul>
</div>
</div>
${<if-has-release>}
<div class="row">
${releases class="Lms-explore-artist-entries-container"}
</div>
${releases class="mb-3"}
${</if-has-release>}
${<if-has-non-release-track>}
<div class="Lms-explore-artist-tracks-container">
${tracks}
</div>
${tracks class="mb-3"}
${</if-has-non-release-track>}
<div class="row">
${<if-has-similar-artists>}
<div class="col-md-4">
<div class="well well-sm">
<h3>${tr:Lms.Explore.Artist.similar-artists}</h3>
${similar-artists}
</div>
</div>
${</if-has-similar-artists>}
${<if-has-mbid>}
<div class="col-md-4">
<div class="well well-sm">
<h3>${tr:Lms.Explore.links}</h3>
${mbid-link class="Lms-btn text-muted"}
</div>
</div>
${</if-has-mbid>}
</div>
</message>
<message id="Lms.Explore.Artist.template.entry">
<div class="Lms-explore-entry-list">
<div class="row Lms-vertical-align">
<div class="col-xs-4 col-sm-3 col-md-2">
<div class="Lms-responsive-square Lms-cover-container">
${cover}
</div>
</div>
<div class="col-xs-8 col-sm-9 col-md-10 Lms-explore-list-entry-text-container">
<h5>${name class="Lms-releasename text-primary"}</h5>
${<if-has-artist>}<h6>${artist class="Lms-btn Lms-artistname text-muted"}</h6>${</if-has-artist>}
<h6><small><div class="text-muted">${<if-has-year>}${year}${</if-has-year>}${<if-has-orig-year>} (${orig-year})${</if-has-orig-year>}</div></small></h6>
</div>
</div>
${<if-has-similar-artists>}
<div>
<h3>${tr:Lms.Explore.Artist.similar-artists}</h3>
${similar-artists class="d-grid gap-3"}
</div>
${</if-has-similar-artists>}
</message>
</messages>
+27 -11
View File
@@ -3,11 +3,30 @@
<!--FORMS message blocks-->
<message id="Lms.Explore.Artists.template">
<div class="Lms-header">
${mode class="nav nav-pills Lms-explore-mode-container"}
<div class="mb-3">
<ul class="nav nav-pills mb-2">
<li class="nav-item">
${random class="nav-link"}
</li>
<li class="nav-item">
${starred class="nav-link"}
</li>
<li class="nav-item">
${recently-played class="nav-link"}
</li>
<li class="nav-item">
${most-played class="nav-link"}
</li>
<li class="nav-item">
${recently-added class="nav-link"}
</li>
<li class="nav-item">
${all class="nav-link"}
</li>
</ul>
<div class="row">
<div class="col-sm-2">
${link-type}
<div class="col-lg-3">
${link-type class="form-select"}
</div>
</div>
</div>
@@ -15,15 +34,12 @@
</message>
<message id="Lms.Explore.Artists.template.entry">
<div class="Lms-explore-artists-entry Lms-entry-hoverable Lms-vertical-align">
<h4>${name class="Lms-btn Lms-artistname text-primary"}</h4>
</div>
${name class="text-decoration-none link-secondary"}
</message>
<message id="Lms.Explore.Artists.template.entry-small">
<div class="Lms-explore-artists-entry-small Lms-entry-hoverable Lms-vertical-align">
${name class="Lms-btn Lms-artistname text-primary"}
</div>
<message id="Lms.Explore.Artists.template.container">
${elements class="d-grid gap-3"}
${loading-indicator}
</message>
</messages>
+30 -24
View File
@@ -2,37 +2,43 @@
<messages xmlns:if="Wt.WTemplate.conditions">
<message id="Lms.Explore.template">
${contents class="Lms-explore-contents"}
${contents}
</message>
<message id="Lms.Explore.template.more-btn"><i class="fa fa-fw fa-plus-circle"></i></message>
<message id="Lms.Explore.template.play-btn"><i class="fa fa-fw fa-play-circle"></i></message>
<message id="Lms.Explore.template.more-btn"><i class="fa fa-fw fa-ellipsis-v"></i></message>
<message id="Lms.Explore.template.play-btn"><i class="fa fa-fw fa-play"></i></message>
<message id="Lms.Explore.template.filters">
${add-filter class="btn-sm btn-info"} ${clusters class="Lms-explore-clusters"}
${add-filter class="btn btn-sm btn-outline-primary me-2"}${clusters class="d-inline-block align-items-center"}
</message>
<message id="Lms.Explore.template.add-filter">
<div class="form-horizontal">
<div class="form-group">
<label class="control-label col-sm-2" for="${id:type}">
${tr:Lms.Explore.type}
</label>
<div class="col-sm-10">
${type}
</div>
</div>
<div class="form-group">
<label class="control-label col-sm-2" for="${id:value}">
${tr:Lms.Explore.value}
</label>
<div class="col-sm-10">
${value}
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
${add-btn class="btn-primary"} ${cancel-btn}
<div class="modal fade" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">${tr:Lms.Explore.add-filter}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form class="row g-3">
<div class="col-12">
<label class="form-label" for="${id:type}">
${tr:Lms.Explore.type}
</label>
${type class="form-control"}
</div>
<div class="col-12">
<label class="form-label" for="${id:value}">
${tr:Lms.Explore.value}
</label>
${value class="form-control"}
</div>
</form>
<div class="modal-footer">
${add-btn class="btn btn-primary me-1"}${cancel-btn class="btn btn-secondary"}
</div>
</div>
</div>
</div>
</div>
+29 -37
View File
@@ -4,46 +4,38 @@
<message id="Lms.Auth.template">
<div class="container">
<div class="page-header">
<h2>${tr:Lms.Auth.welcome}</h2>
</div>
<form>
<div class="form-horizontal">
<div class="form-group">
<label class="control-label col-sm-2" for="${id:login-name}">
${tr:Lms.login}
</label>
<div class="col-sm-5">
${login-name}
<form class="row mt-3 justify-content-md-center">
<div class="col-lg-6">
<div class="row g-3">
<h2>${tr:Lms.Auth.welcome}</h2>
<div class="col-12">
<label class="form-label" for="${id:login-name}">
${tr:Lms.login}
</label>
${login-name class="form-control"}
<div class="invalid-feedback">
${login-name-info}
</div>
</div>
<div class="help-block col-sm-5">
${login-name-info}
<div class="col-12">
<label class="form-label" for="${id:password}">
${tr:Lms.password}
</label>
${password class="form-control"}
<div class="invalid-feedback">
${password-info}
</div>
</div>
</div>
<div class="form-group">
<label class="control-label col-sm-2" for="${id:password}">
${tr:Lms.password}
</label>
<div class="col-sm-5">
${password}
<div class="col-12">
<div class="form-check">
${remember-me class="form-check-input"}
<label class="form-check-label" for="${id:remember-me}">
${tr:Lms.Auth.remember-me}
</label>
</div>
</div>
<div class="help-block col-sm-5">
${password-info}
</div>
</div>
<div class="form-group">
<label class="control-label col-sm-2" for="${id:remember-me}">
${tr:Lms.Auth.remember-me}
</label>
<div class="col-sm-5">
<div class="checkbox"><label>${remember-me}</label></div>
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
${login-btn class="btn-primary"}
<div class="col-12">
${login-btn class="btn btn-primary"}
</div>
</div>
</div>
+70
View File
@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="UTF-8" ?>
<messages xmlns:if="Wt.WTemplate.conditions">
<message id="Lms.main.template">
${navbar}
<div class="container">
${contents}
${notifications class="toast-container Lms-notification-container p-3"}
${modal}
</div>
${player class="fixed-bottom bg-dark Lms-player"}
</message>
<message id="Lms.main.template.navbar">
<nav class="navbar navbar-expand-md navbar-dark bg-dark mb-3">
<div class="container">
<form class="d-flex">
${search class="form-control" type="search"}
</form>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto align-items-md-center mb-lg-0">
<li class="nav-item">
${releases class="nav-link"}
</li>
<li class="nav-item">
${artists class="nav-link"}
</li>
<li class="nav-item">
${tracks class="nav-link"}
</li>
<li class="nav-item">
${playqueue class="nav-link"}
</li>
<li class="nav-item">
${filters class="nav-link d-flex align-items-center"}
</li>
</ul>
<div class="navbar-nav align-items-md-center mb-lg-0">
${<if-is-admin>}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="${tr:Lms.administration}">
<i class="fa fa-cogs" aria-hidden="true"></i>
</a>
<ul class="dropdown-menu" aria-labelledby="navbarDropdown">
<li>${database class="dropdown-item"}</li>
<li>${users class="dropdown-item"}</li>
</ul>
</li>
${</if-is-admin>}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="${tr:Lms.user}">
<i class="fa fa-user" aria-hidden="true"></i>
</a>
<ul class="dropdown-menu" aria-labelledby="navbarDropdown">
<li class="dropdown-header">${username}</li>
<li class="dropdown-divider"></li>
<li>${settings class="dropdown-item"}</li>
<li>${logout class="dropdown-item"}</li>
</ul>
</li>
</div>
</div>
</div>
</nav>
</message>
</messages>
+22 -23
View File
@@ -6,36 +6,35 @@
<audio id="lms-mp-audio">
</audio>
<div class="container">
<div class="Lms-player-seek-container">
<div class="p-1 my-1 position-relative">
<input id="lms-mp-seek" class="Lms-player-seek-common Lms-player-seek" type="range" min="0" max="100" step="1" value="0"/>
<div class="progress Lms-player-seek-common Lms-player-progress">
<div id="lms-mp-progress" class="progress-bar" role="progressbar" aria-valuenow="20" aria-valuemin="0" aria-valuemax="100" style="width: 0%">
</div>
<div class="progress Lms-player-seek-common bg-secondary rounded">
<div id="lms-mp-progress" class="progress-bar" role="progressbar" aria-valuenow="20" aria-valuemin="0" aria-valuemax="100" style="width: 0%"></div>
</div>
</div>
<div class="row">
<div class="col-xs-5 col-sm-3 col-md-2 Lms-player-controls">
<i id="lms-mp-previous" class="fa fa-fw fa-step-backward Lms-player-btn Lms-btn Lms-player-btn-controls"></i><i id="lms-mp-playpause" class="fa fa-fw fa-play Lms-player-btn Lms-btn Lms-player-btn-controls"></i><i id="lms-mp-next" class="fa fa-fw fa-step-forward Lms-player-btn Lms-btn Lms-player-btn-controls"></i>
<div class="d-flex align-items-center">
<div class="d-inline-flex me-2">
<button id="lms-mp-previous" class="btn btn-sm btn-outline-primary border-0">
<i class="fa fa-fw fa-step-backward"/>
</button>
<button id="lms-mp-playpause" class="btn btn-outline-primary border-0">
<i class="fa fa-fw fa-play"/>
</button>
<button id="lms-mp-next" class="btn btn-sm btn-outline-primary border-0">
<i class="fa fa-fw fa-step-forward"/>
</button>
</div>
<div class="hidden-xs hidden-sm col-md-2">
<div class="Lms-player-volume">
<i id="lms-mp-volume" class="fa fa-fw fa-volume-up Lms-player-btn Lms-btn Lms-player-btn-volume"></i>
<span class="Lms-player-volume-slider-container">
<input id ="lms-mp-volume-slider" type="range" min="0" max="1" step="0.01" value="0.8" class="Lms-player-volume-slider"/>
</span>
</div>
<div class="flex-fill text-center text-truncate">
${title class="d-block text-truncate"}
${release class="text-decoration-none text-truncate link-success"}
</div>
<div class="col-xs-7 col-sm-7 col-md-6">
<div class="Lms-player-text-center">
${title class="Lms-player-text Lms-trackname text-primary"}
</div>
<div class="Lms-player-text-center">
${release class="Lms-btn Lms-player-text Lms-releasename text-primary"}<span class="hidden-xs"><small>${artist class="Lms-btn Lms-player-text Lms-artistname text-muted"}</small></span>
</div>
<div class="d-none d-md-flex align-items-center Lms-player-volume-container">
<i id="lms-mp-volume" class="fa fa-fw fa-volume-up link-primary me-1"></i>
<input id ="lms-mp-volume-slider" class="form-range" type="range" min="0" max="1" step="0.01" value="0.8"/>
</div>
<div class="hidden-xs col-sm-2 col-md-2 Lms-player-duration text-muted">
<span id="lms-transcoding-active" class="Lms-player-btn Lms-btn" data-toggle="tooltip" title="${tr:Lms.Player.transcoding-active}" style="visibility:hidden"><i class="fa fa-fw fa-info-circle" aria-hidden="true"></i></span>
<div class="d-none d-sm-block text-muted text-nowrap">
<span id="lms-transcoding-active" title="${tr:Lms.Player.transcoding-active}" class="p-2" style="visibility:hidden"><i class="fa fa-fw fa-info-circle" aria-hidden="true"></i></span>
<span id="lms-mp-curtime">--</span> / <span id="lms-mp-duration">--</span>
</div>
</div>
+3 -4
View File
@@ -80,6 +80,7 @@
<message id="Lms.Admin.ScannerController.step-fetching-track-features">Fetching track features from AcousticBrainz: {1}/{2} tracks ({3}%)...</message>
<message id="Lms.Admin.ScannerController.step-reloading-similarity-engine">Reloading similarity engine: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-scanning-files">Scanning files: {1}/{2} files ({3}%)...</message>
<message id="Lms.Admin.ScannerController.step-status">Step status</message>
<!--Users-->
<message id="Lms.Admin.Users.add">New user</message>
@@ -99,7 +100,7 @@
<message id="Lms.Admin.User.user-already-exists">User already exists!</message>
<message id="Lms.Admin.User.user-create">New user</message>
<message id="Lms.Admin.User.user-created">New user created!</message>
<message id="Lms.Admin.User.user-edit">Edit user {1}</message>
<message id="Lms.Admin.User.user-edit">Edit user '{1}'</message>
<message id="Lms.Admin.User.user-updated">User updated!</message>
<!--Wizard-->
@@ -117,10 +118,10 @@
<message id="Lms.Explore.download">Download</message>
<message id="Lms.Explore.filter-added">Filter added</message>
<message id="Lms.Explore.filters">Filters</message>
<message id="Lms.Explore.links">Links</message>
<message id="Lms.Explore.most-played">Most played</message>
<message id="Lms.Explore.musicbrainz-artist">MusicBrainz Artist</message>
<message id="Lms.Explore.musicbrainz-release">MusicBrainz Release</message>
<message id="Lms.Explore.play">Play</message>
<message id="Lms.Explore.play-last">Play last</message>
<message id="Lms.Explore.play-shuffled">Play shuffled</message>
<message id="Lms.Explore.random">Random</message>
@@ -177,11 +178,9 @@
<!--Settings-->
<message id="Lms.Settings.appearance">Appearance</message>
<message id="Lms.Settings.audio">Audio</message>
<message id="Lms.Settings.audio-settings-are-local">These audio settings are local to your browser!</message>
<message id="Lms.Settings.change-password">Change password</message>
<message id="Lms.Settings.dark-mode">Dark mode</message>
<message id="Lms.Settings.demo-cannot-save">Cannot save using a demo account!</message>
<message id="Lms.Settings.menu-settings"><i class="fa fa-fw fa-cog" aria-hidden="true"></i> Settings</message>
<message id="Lms.Settings.password-bad">Bad password</message>
+3 -4
View File
@@ -80,6 +80,7 @@
<message id="Lms.Admin.ScannerController.step-fetching-track-features">Récupération des métadonnées AcousticBrainz : {1}/{2} fichiers ({3}%)...</message>
<message id="Lms.Admin.ScannerController.step-reloading-similarity-engine">Rechargement du moteur de recommandation : {1}%...</message>
<message id="Lms.Admin.ScannerController.step-scanning-files">Scan des fichiers : {1}/{2} fichiers ({3}%)...</message>
<message id="Lms.Admin.ScannerController.step-status">Statut de l'étape</message>
<!--Users-->
<message id="Lms.Admin.Users.add">Ajouter</message>
@@ -99,7 +100,7 @@
<message id="Lms.Admin.User.user-already-exists">L'utilisateur existe déjà !</message>
<message id="Lms.Admin.User.user-create">Nouvel utilisateur</message>
<message id="Lms.Admin.User.user-created">Nouvel utilisateur créé !</message>
<message id="Lms.Admin.User.user-edit">Utilisateur {1}</message>
<message id="Lms.Admin.User.user-edit">Utilisateur '{1}'</message>
<message id="Lms.Admin.User.user-updated">L'utilisateur a été mis à jour !</message>
<!--Wizard-->
@@ -117,10 +118,10 @@
<message id="Lms.Explore.download">Télécharger</message>
<message id="Lms.Explore.filter-added">Filtre ajouté</message>
<message id="Lms.Explore.filters">Filtres</message>
<message id="Lms.Explore.links">Liens</message>
<message id="Lms.Explore.most-played">Plus joués</message>
<message id="Lms.Explore.musicbrainz-artist">MusicBrainz Artist</message>
<message id="Lms.Explore.musicbrainz-release">MusicBrainz Release</message>
<message id="Lms.Explore.play">Jouer</message>
<message id="Lms.Explore.play-last">Jouer en dernier</message>
<message id="Lms.Explore.play-shuffled">Jouer aléatoirement</message>
<message id="Lms.Explore.random">Aléatoire</message>
@@ -177,11 +178,9 @@
<!--Settings-->
<message id="Lms.Settings.appearance">Apparence</message>
<message id="Lms.Settings.audio">Audio</message>
<message id="Lms.Settings.audio-settings-are-local">Ces paramètres audio sont locaux à votre navigateur !</message>
<message id="Lms.Settings.change-password">Changement de mot de passe</message>
<message id="Lms.Settings.dark-mode">Mode sombre</message>
<message id="Lms.Settings.demo-cannot-save">Impossible de sauvegarder en utilisant un compte de démo !</message>
<message id="Lms.Settings.menu-settings"><i class="fa fa-fw fa-cog" aria-hidden="true"></i> Paramètres</message>
<message id="Lms.Settings.password-bad">Mauvais mot de passe</message>
+2 -3
View File
@@ -80,6 +80,7 @@
<message id="Lms.Admin.ScannerController.step-reloading-similarity-engine">Ricarica motore di tracce simili: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-scanning-files">Scansione files: {1}/{2} files ({3}%)...</message>
<!--Users-->
<message id="Lms.Admin.Users.add">Nuovo utente</message>
<message id="Lms.Admin.Users.admin">Amministratore</message>
@@ -116,10 +117,10 @@
<message id="Lms.Explore.download">Download</message>
<message id="Lms.Explore.filter-added">Filtro aggiunto</message>
<message id="Lms.Explore.filters">Filtri</message>
<message id="Lms.Explore.links">Collegamenti</message>
<message id="Lms.Explore.most-played">Più ascoltati</message>
<message id="Lms.Explore.musicbrainz-artist">Artista MusicBrainz</message>
<message id="Lms.Explore.musicbrainz-release">Album MusicBrainz</message>
<message id="Lms.Explore.play-last">Aggiungi alla coda di riproduzione</message>
<message id="Lms.Explore.play-shuffled">Aggiungi alla coda di riproduzione mischiando</message>
<message id="Lms.Explore.random">Casuali</message>
@@ -176,11 +177,9 @@
<!--Settings-->
<message id="Lms.Settings.appearance">Aspetto</message>
<message id="Lms.Settings.audio">Audio</message>
<message id="Lms.Settings.audio-settings-are-local">Queste impostazioni sono salvate localmente su questo dispositivo!</message>
<message id="Lms.Settings.change-password">Cambia password</message>
<message id="Lms.Settings.dark-mode">Tema scuro</message>
<message id="Lms.Settings.demo-cannot-save">Non posso salvare le impostazioni utilizzando l'account demo!</message>
<message id="Lms.Settings.menu-settings"><i class="fa fa-fw fa-cog" aria-hidden="true"></i> Impostazioni</message>
<message id="Lms.Settings.password-bad">Password errata</message>
+2 -3
View File
@@ -81,6 +81,7 @@
<message id="Lms.Admin.ScannerController.step-reloading-similarity-engine">重载相似引擎中 {1}%...</message>
<message id="Lms.Admin.ScannerController.step-scanning-files">扫描文件中: {1}/{2} 文件 ({3}%)...</message>
<!--Users-->
<message id="Lms.Admin.Users.add">新建用户</message>
<message id="Lms.Admin.Users.admin">管理员</message>
@@ -117,10 +118,10 @@
<message id="Lms.Explore.download">下载</message>
<message id="Lms.Explore.filter-added">筛选已添加</message>
<message id="Lms.Explore.filters">筛选</message>
<message id="Lms.Explore.links">链接</message>
<message id="Lms.Explore.most-played">播放最多</message>
<message id="Lms.Explore.musicbrainz-artist">MusicBrainz 歌手</message>
<message id="Lms.Explore.musicbrainz-release">MusicBrainz 专辑</message>
<message id="Lms.Explore.play-last">播放列表</message>
<message id="Lms.Explore.play-shuffled">随机播放</message>
<message id="Lms.Explore.random">随机</message>
@@ -176,11 +177,9 @@
<message id="Lms.PlayHistory.playhistory">播放历史</message>
<!--Settings-->
<message id="Lms.Settings.appearance">外观</message>
<message id="Lms.Settings.audio">音频</message>
<message id="Lms.Settings.audio-settings-are-local">这些音频设置仅保存在您的本地浏览器!</message>
<message id="Lms.Settings.change-password">更改密码</message>
<message id="Lms.Settings.dark-mode">夜间模式</message>
<message id="Lms.Settings.demo-cannot-save">使用演示账号时不能保存!</message>
<message id="Lms.Settings.menu-settings"><i class="fa fa-fw fa-cog" aria-hidden="true"></i> 设置</message>
<message id="Lms.Settings.password-bad">无效密码</message>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" ?>
<messages xmlns:if="Wt.WTemplate.conditions">
<message id="Lms.LoadingIndicator.template">
<div class="my-5 text-center">
<div class="spinner-border" role="status">
<span class="visually-hidden">${tr:Lms.loading}</span>
</div>
</div>
</message>
<message id="Lms.infinite-scrolling-container">
${elements}
${loading-indicator}
</message>
</messages>
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" ?>
<messages xmlns:if="Wt.WTemplate.conditions">
<message id="Lms.notifications.template.entry">
<div class="toast align-items-center ${bg-color} text-${text-color} border-0" role="alert" aria-live="assertive" aria-atomic="true" data-bs-delay="${duration}">
<div class="d-flex">
<div class="toast-body">
${message}
</div>
<button type="button" class="btn-close btn-close-${text-color} me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
</message>
</messages>
+37 -29
View File
@@ -2,54 +2,62 @@
<messages xmlns:if="Wt.WTemplate.conditions">
<!--FORMS message blocks-->
<message id="Lms.PlayQueue.template.clear-btn"><i class="fa fa-fw fa-times-circle"></i></message>
<message id="Lms.PlayQueue.template.clear-btn"><i class="fa fa-fw fa-times"></i></message>
<message id="Lms.PlayQueue.template.shuffle-btn"><i class="fa fa-fw fa-random"></i></message>
<message id="Lms.PlayQueue.template.repeat-btn"><i class="fa fa-fw fa-repeat"></i></message>
<message id="Lms.PlayQueue.template.radio-btn"><i class="fa fa-fw fa-rss"></i></message>
<message id="Lms.PlayQueue.template">
<div class="Lms-header">
${clear-btn class="Lms-playqueue-btn Lms-btn"}${shuffle-btn class="Lms-playqueue-btn Lms-btn"}${repeat-btn class="Lms-playqueue-btn Lms-btn"}${radio-btn class="Lms-playqueue-btn Lms-btn"}
<h4><small>${nb-tracks}</small></h4>
</div>
<div class="row">
<div class="col-lg-8">
${entries}
<div class="mb-3">
<div class="d-inline-block me-2" title="${tr:Lms.PlayQueue.clear}">
${clear-btn class="btn btn-secondary"}
</div>
<div class="d-inline-block me-2" title="${tr:Lms.PlayQueue.shuffle}">
${shuffle-btn class="btn btn-secondary"}
</div>
<div class="btn-group me-2">
${repeat-btn class="btn-check"}
<label class="btn btn-outline-info" title="${tr:Lms.PlayQueue.repeat}" for="${id:repeat-btn}">${tr:Lms.PlayQueue.template.repeat-btn}</label>
${radio-btn class="btn-check"}
<label class="btn btn-outline-info" title="${tr:Lms.PlayQueue.radio-mode}" for="${id:radio-btn}">${tr:Lms.PlayQueue.template.radio-btn}</label>
</div>
${nb-tracks}
</div>
${entries}
</message>
<message id="Lms.PlayQueue.template.play-btn"><i class="fa fa-fw fa-play-circle"></i></message>
<message id="Lms.PlayQueue.template.delete-btn"><i class="fa fa-fw fa-times-circle"></i></message>
<message id="Lms.PlayQueue.template.more-btn"><i class="fa fa-fw fa-plus-circle"></i></message>
<message id="Lms.PlayQueue.template.play-btn"><i class="fa fa-fw fa-play"></i></message>
<message id="Lms.PlayQueue.template.delete-btn"><i class="fa fa-fw fa-times"></i></message>
<message id="Lms.PlayQueue.template.more-btn"><i class="fa fa-fw fa-ellipsis-v"></i></message>
<message id="Lms.PlayQueue.template.entry">
<div class="row Lms-vertical-align Lms-explore-entry-list Lms-playqueue-entry ${is-selected}">
<div class="col-xs-3 col-sm-2 col-md-2">
<div class="Lms-responsive-square Lms-cover-container">
${cover}
</div>
<div class="d-flex align-items-center rounded Lms-bg-dark-hover mb-2">
<div class="p-1 Lms-cover-small">
${cover}
</div>
<div class="col-xs-7 col-sm-8 col-md-8 Lms-explore-entry-list-text-container">
<div class="Lms-trackname text-primary"><h5>${name}</h5></div>
${<if-has-artists-or-release>}
${<if-has-release>}<small>${release class="Lms-btn Lms-releasename text-primary"}</small>${</if-has-release>}
${<if-has-artists>}${artists}${</if-has-artists>}
${</if-has-artists-or-release>}
<div class="p-2 flex-fill text-truncate">
<div class="d-block text-truncate">${name}</div>
${<if-has-release>}${release class="text-decoration-none link-success text-truncate"}${</if-has-release>}
</div>
<div class="col-xs-2 col-sm-2 col-md-2">
<div class="Lms-playqueue-entry-controls Lms-vertical-align">
${play-btn class="Lms-playqueue-btn Lms-btn"}${del-btn class="Lms-playqueue-btn Lms-btn"}<span class="hidden-xs Lms-playqueue-btn Lms-btn">${more-btn}</span>
</div>
<div class="Lms-playqueue-entry-duration text-muted">
${duration}
<div class="p-2 d-none d-sm-block text-muted">
${duration}
</div>
<div class="p-2 d-flex">
${play-btn class="btn btn-sm btn-outline-secondary border-0"}
${del-btn class="btn btn-sm btn-outline-secondary border-0"}
<div class="dropdown d-none d-sm-block">
${more-btn data-bs-toggle="dropdown" aria-expanded="false" class="btn btn-sm btn-outline-secondary border-0"}
<ul class="dropdown-menu" aria-labelledby="${id:more-btn}">
<li>${star class="btn dropdown-item"}</li>
<li>${download class="btn dropdown-item"}</li>
</ul>
</div>
</div>
</div>
</message>
<message id="Lms.PlayQueue.template.entry-artist">
<small>${artist class="Lms-btn Lms-artistname text-muted"}</small>
${artist}
</message>
</messages>
+53 -56
View File
@@ -7,83 +7,80 @@
</message>
<message id="Lms.Explore.Release.template">
<div class="row Lms-explore-release">
<div class="col-md-4">
${cover class="Lms-explore-release-cover"}
<div class="row mb-3 gy-3">
<div class="col-lg-3 col-md-4">
${cover}
</div>
<div class="col-md-8">
<div class="Lms-header">
<h3><div class="Lms-releasename text-primary">${name}</div></h3>
${<if-has-release-artists>}${artists}${</if-has-release-artists>}
${<if-has-various-release-artists>}<div class="Lms-artistname text-muted">${tr:Lms.Explore.various-artists}</div>${</if-has-various-release-artists>}
<div class="text-muted"><small>${<if-has-year>}${year}${<if-has-orig-year>} (${orig-year})${</if-has-orig-year>} · ${</if-has-year>}${duration}</small></div>
${clusters}
${play-btn class="Lms-explore-btn Lms-btn"}${more-btn class="Lms-explore-btn Lms-btn"}
<div class="col">
<h3>${name}</h3>
${<if-has-release-artists>}${artists}${</if-has-release-artists>}
${<if-has-various-release-artists>}<div class="text-secondary">${tr:Lms.Explore.various-artists}</div>${</if-has-various-release-artists>}
<div class="small text-muted">${<if-has-year>}${year}${<if-has-orig-year>} (${orig-year})${</if-has-orig-year>} · ${</if-has-year>}${duration}</div>
${clusters class="mb-2"}
<div class="btn-group">
${play-btn class="btn btn-primary"}
<button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false"/>
<ul class="dropdown-menu">
<li>${play-shuffled class="btn dropdown-item"}</li>
<li>${play-last class="btn dropdown-item"}</li>
<li>${star class="btn dropdown-item"}</li>
${<if-has-mbid>}
<li><a href="${mbid-link}" target="_blank" class="dropdown-item">${tr:Lms.Explore.musicbrainz-release}</a></li>
${</if-has-mbid>}
<li>${download class="btn dropdown-item"}</li>
</ul>
</div>
${container}
</div>
<div class="col-12">
${container class="d-grid gap-3"}
${<if-has-copyright>}
<div class="pt-1 mt-1 border-top border-dark small text-muted fw-lighter fst-italic">${copyright class="text-reset"}</div>
${</if-has-copyright>}
</div>
</div>
<div class="row">
${<if-has-similar-releases>}
<div class="col-xs-12 col-md-8">
<div class="well well-sm">
<h3>${tr:Lms.Explore.Release.similar-releases}</h3>
<div class="row">
${similar-releases}
</div>
</div>
</div>
${</if-has-similar-releases>}
${<if-has-copyright>}
<div class="col-xs-12 col-md-4">
<div class="well well-sm">
<h3>${tr:Lms.Explore.Release.copyright}</h3>
${copyright class="text-muted"}
</div>
</div>
${</if-has-copyright>}
${<if-has-mbid>}
<div class="col-xs-12 col-md-4">
<div class="well well-sm">
<h3>${tr:Lms.Explore.links}</h3>
${mbid-link class="Lms-btn text-muted"}
</div>
</div>
${</if-has-mbid>}
</div>
${<if-has-similar-releases>}
<h3>${tr:Lms.Explore.Release.similar-releases}</h3>
${similar-releases class="row row-cols-2 row-cols-md-3 row-cols-lg-4 row-cols-xl-6 gx-2 gy-4"}
${</if-has-similar-releases>}
</message>
<message id="Lms.Explore.Release.template.entry-release-artist">
<h4>${artist class="Lms-btn Lms-artistname text-muted"}</h4>
${artist class="text-decoration-none link-success"}
</message>
<message id="Lms.Explore.Release.template.entry-disc">
<h4>${disc-title}</h4>
${tracks}
${tracks class="d-grid gap-1"}
</message>
<message id="Lms.Explore.Release.template.entry-nodisc">
${tracks class="d-grid gap-1"}
</message>
<message id="Lms.Explore.Release.template.entry">
<div class="row Lms-explore-release-entry Lms-entry-hoverable Lms-vertical-align ${is-playing}">
<div class="col-sm-1 col-xs-1 Lms-explore-release-entry-tracknumber text-muted">
<div class="d-flex align-items-center rounded Lms-bg-dark-hover">
<div class="p-2 text-muted text-end" style="min-width: 2.1rem;">
${<if-has-track-number>}${track-number}${</if-has-track-number>}
</div>
<div class="col-sm-9 col-xs-7 Lms-explore-release-entry-name">
<div class="Lms-trackname text-primary">${name}</div>
<div class="p-2 flex-fill">
${name}
${<if-has-artists>}${artists}${</if-has-artists>}
</div>
<div class="col-sm-2 col-xs-4">
<div class="Lms-explore-release-entry-controls">
${play-btn class="Lms-explore-btn Lms-btn"}${more-btn class="Lms-explore-btn Lms-btn"}
</div>
<div class="Lms-explore-release-entry-track-duration text-muted">
${duration}
<div class="p-2 d-none d-sm-block text-muted">
${duration}
</div>
<div class="p-2 d-flex">
${play-btn class="btn btn-sm btn-outline-secondary border-0"}
<div class="dropdown d-inline-block">
${more-btn data-bs-toggle="dropdown" aria-expanded="false" class="btn btn-sm btn-outline-secondary border-0"}
<ul class="dropdown-menu" aria-labelledby="${id:more-btn}">
<li>${play-last class="btn dropdown-item"}</li>
<li>${star class="btn dropdown-item"}</li>
<li>${download class="btn dropdown-item"}</li>
</ul>
</div>
</div>
</div>
</message>
<message id="Lms.Explore.Release.template.entry-artist">
<small>${artist class="Lms-btn Lms-artistname text-muted"}</small>
</message>
</messages>
+38 -15
View File
@@ -3,33 +3,56 @@
<messages xmlns:if="Wt.WTemplate.conditions">
<message id="Lms.Explore.Releases.template">
<div class="Lms-header">
${mode class="nav nav-pills Lms-explore-mode-container"}
${play-btn class="Lms-explore-btn Lms-btn"}${more-btn class="Lms-explore-btn Lms-btn"}
<div class="mb-3">
<ul class="nav nav-pills mb-2">
<li class="nav-item">
${random class="nav-link"}
</li>
<li class="nav-item">
${starred class="nav-link"}
</li>
<li class="nav-item">
${recently-played class="nav-link"}
</li>
<li class="nav-item">
${most-played class="nav-link"}
</li>
<li class="nav-item">
${recently-added class="nav-link"}
</li>
<li class="nav-item">
${all class="nav-link"}
</li>
</ul>
<div class="btn-group">
${play-btn class="btn btn-primary"}
<button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false"/>
<ul class="dropdown-menu">
<li>${play-last class="btn dropdown-item"}</li>
<li>${play-shuffled class="btn dropdown-item"}</li>
</ul>
</div>
</div>
${releases}
</message>
<message id="Lms.Explore.Releases.template.entry-grid">
<div class="col-xs-6 col-sm-3 col-md-2 col-lg-2">
<div class="Lms-explore-entry-grid">
<div class="col h-100 Lms-bg-dark-hover rounded">
<div class="p-2">
<div class="Lms-responsive-square Lms-cover-container">
${cover}
</div>
<div class="Lms-explore-entry-grid-text-container">
<h5>${release-name class="Lms-btn Lms-releasename text-primary"}</h5>
<div class="Lms-explore-entry-separator"><h6>${<if-has-year>}<small><span class="text-muted">${year}</span></small>${</if-has-year>}${<if-has-artist>}${artist-name class="Lms-btn Lms-artistname text-muted"}${</if-has-artist>}${<if-has-various-artists>}<div class="Lms-artistname text-muted">${tr:Lms.Explore.various-artists}</div>${</if-has-various-artists>}</h6></div>
${cover class="shadow-sm"}
</div>
${release-name class="d-block text-truncate text-nowrap text-decoration-none link-success"}
${<if-has-artist>}${artist-name class="d-block text-truncate text-nowrap text-decoration-none link-secondary"}${</if-has-artist>}
${<if-has-various-artists>}<div class="text-truncate text-nowrap text-secondary">${tr:Lms.Explore.various-artists}</div>${</if-has-various-artists>}
${<if-has-year>}<div class="small text-muted">${year}</div>${</if-has-year>}
</div>
</div>
</message>
<message id="Lms.Explore.Releases.template.container">
<div class="row">
${elements}
</div>
${loading-indicator class="Lms-horizontal-center Lms-loading-indicator"}
${elements class="row row-cols-2 row-cols-md-3 row-cols-lg-4 row-cols-xl-6 gx-2 gy-4"}
${loading-indicator}
</message>
</messages>
+15 -3
View File
@@ -3,10 +3,22 @@
<!--FORMS message blocks-->
<message id="Lms.Explore.Search.template">
<div class="Lms-header">
${mode class="nav nav-pills Lms-explore-mode-container"}
<ul class="nav nav-pills mb-3" id="myTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="releases-tab" data-bs-toggle="tab" data-bs-target="#releases" type="button" role="tab" aria-controls="releases" aria-selected="true">${tr:Lms.Explore.releases}</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="artists-tab" data-bs-toggle="tab" data-bs-target="#artists" type="button" role="tab" aria-controls="artists" aria-selected="false">${tr:Lms.Explore.artists}</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tracks-tab" data-bs-toggle="tab" data-bs-target="#tracks" type="button" role="tab" aria-controls="tracks" aria-selected="false">${tr:Lms.Explore.tracks}</button>
</li>
</ul>
<div class="tab-content" id="myTabContent">
<div class="tab-pane show active" id="releases" role="tabpanel" aria-labelledby="releases-tab">${releases}</div>
<div class="tab-pane" id="artists" role="tabpanel" aria-labelledby="artists-tab">${artists}</div>
<div class="tab-pane" id="tracks" role="tabpanel" aria-labelledby="tracks-tab">${tracks}</div>
</div>
${stack}
</message>
</messages>
+166 -191
View File
@@ -4,198 +4,173 @@
<!--FORMS message blocks-->
<message id="Lms.Settings.template">
<div class="row">
<div class="col-lg-8">
<form>
<legend>${tr:Lms.Settings.appearance}</legend>
<div class="form-horizontal">
<div class="form-group">
<div class="col-lg-offset-3 col-lg-9">
<div class="checkbox">
<label>${dark-mode}${tr:Lms.Settings.dark-mode}</label>
${dark-mode-info clas="help-block"}
</div>
</div>
</div>
</div>
<legend>${tr:Lms.Settings.audio}</legend>
<div class="alert alert-info">
${tr:Lms.Settings.audio-settings-are-local}
</div>
<div class="form-horizontal">
<div class="form-group">
<label class="col-lg-3 control-label" for="${id:transcode-mode}">
${tr:Lms.Settings.transcode-mode}
</label>
<div class="col-lg-9">
${transcode-mode}
${transcode-mode-info class="help-block"}
</div>
</div>
<div class="form-group">
<label class="col-lg-3 control-label" for="${id:transcode-format}">
${tr:Lms.Settings.transcode-format}
</label>
<div class="col-lg-9">
${transcode-format}
${transcode-format-info class="help-block"}
</div>
</div>
<div class="form-group">
<label class="col-lg-3 control-label" for="${id:transcode-bitrate}">
${tr:Lms.Settings.transcode-bitrate}
</label>
<div class="col-lg-9">
<div class="input-group">
${transcode-bitrate}
<span class="input-group-addon">kbps</span>
</div>
${transcode-bitrate-info class="help-block"}
</div>
</div>
<div class="form-group">
<label class="col-lg-3 control-label" for="${id:replaygain-mode}">
${tr:Lms.Settings.replaygain-mode}
</label>
<div class="col-lg-9">
${replaygain-mode}
${replaygain-mode-info class="help-block"}
</div>
</div>
<div class="form-group">
<label class="col-lg-3 control-label" for="${id:replaygain-preamp}">
${tr:Lms.Settings.replaygain-preamp}
</label>
<div class="col-lg-9">
<div class="input-group">
${replaygain-preamp}
<span class="input-group-addon">dB</span>
</div>
${replaygain-preamp-info class="help-block"}
</div>
</div>
<div class="form-group">
<label class="col-lg-3 control-label" for="${id:replaygain-preamp-no-rg-info}">
${tr:Lms.Settings.replaygain-preamp-no-rg-info}
</label>
<div class="col-lg-9">
<div class="input-group">
${replaygain-preamp-no-rg-info}
<span class="input-group-addon">dB</span>
</div>
${replaygain-preamp-no-rg-info-info class="help-block"}
</div>
</div>
</div>
${<if-has-subsonic-api>}
<legend>${tr:Lms.Settings.subsonic-api}</legend>
<div class="form-horizontal">
<div class="form-group">
<div class="col-lg-offset-3 col-lg-9">
<div class="checkbox">
<label>${subsonic-transcode-enable}${tr:Lms.Settings.transcode-enable}</label>
${subsonic-transcode-enable-info class="help-block"}
</div>
</div>
</div>
<div class="form-group">
<label class="col-lg-3 control-label" for="${id:subsonic-transcode-format}">
${tr:Lms.Settings.transcode-format}
</label>
<div class="col-lg-9">
${subsonic-transcode-format}
${subsonic-transcode-format-info class="help-block"}
</div>
</div>
<div class="form-group">
<label class="col-lg-3 control-label" for="${id:subsonic-transcode-bitrate}">
${tr:Lms.Settings.transcode-bitrate}
</label>
<div class="col-lg-9">
<div class="input-group">
${subsonic-transcode-bitrate}
<span class="input-group-addon">kbps</span>
</div>
${subsonic-transcode-bitrate-info class="help-block"}
</div>
</div>
<div class="form-group">
<label class="col-lg-3 control-label" for="${id:subsonic-artist-list-mode}">
${tr:Lms.Settings.subsonic-artist-list-mode}
</label>
<div class="col-lg-9">
${subsonic-artist-list-mode}
${subsonic-artist-list-mode-info class="help-block"}
</div>
</div>
</div>
${</if-has-subsonic-api>}
<legend>${tr:Lms.Settings.scrobbling}</legend>
<div class="form-horizontal">
<div class="form-group">
<label class="col-lg-3 control-label" for="${id:scrobbler}">
${tr:Lms.Settings.scrobbling.scrobbler}
</label>
<div class="col-lg-9">
${scrobbler}
${scrobbler-info class="help-block"}
</div>
</div>
<div class="form-group">
<label class="col-lg-3 control-label" for="${id:listenbrainz-token}">
${tr:Lms.Settings.scrobbling.listenbrainz-token}
</label>
<div class="col-lg-9">
${listenbrainz-token}
${listenbrainz-token-info class="help-block"}
</div>
</div>
</div>
${<if-has-change-password>}
<legend>${tr:Lms.Settings.change-password}</legend>
<div class="form-horizontal">
${<if-has-old-password>}
<div class="form-group">
<label class="col-lg-3 control-label" for="${id:password-old}">
${tr:Lms.password-old}
</label>
<div class="col-lg-9">
${password-old}
${password-old-info class="help-block"}
</div>
</div>
${</if-has-old-password>}
<div class="form-group">
<label class="col-lg-3 control-label" for="${id:password}">
${tr:Lms.password-new}
</label>
<div class="col-lg-9">
${password}
${password-info class="help-block"}
</div>
</div>
<div class="form-group">
<label class="col-lg-3 control-label" for="${id:password-confirm}">
${tr:Lms.password-confirm}
</label>
<div class="col-lg-9">
${password-confirm}
${password-confirm-info class="help-block"}
</div>
</div>
</div>
${</if-has-change-password>}
<div class="form-horizontal">
<div class="form-group">
<div class="col-lg-offset-3 col-lg-9">
${apply-btn class="btn-primary"} ${discard-btn}
</div>
</div>
</div>
</form>
<form class="row g-3">
<legend>${tr:Lms.Settings.audio}</legend>
<div class="alert alert-info">
${tr:Lms.Settings.audio-settings-are-local}
</div>
</div>
<div class="col-lg-4">
<label class="form-label" for="${id:transcode-mode}">
${tr:Lms.Settings.transcode-mode}
</label>
${transcode-mode class="form-control"}
<div class="invalid-feedback">
${transcode-mode-info}
</div>
</div>
<div class="col-lg-4">
<label class="form-label" for="${id:transcode-format}">
${tr:Lms.Settings.transcode-format}
</label>
${transcode-format class="form-control"}
<div class="invalid-feedback">
${transcode-format-info}
</div>
</div>
<div class="col-lg-4">
<label class="form-label" for="${id:transcode-bitrate}">
${tr:Lms.Settings.transcode-bitrate}
</label>
<div class="input-group mb-3">
${transcode-bitrate class="form-control"}
<span class="input-group-text">kbps</span>
</div>
<div class="invalid-feedback">
${transcode-bitrate-info}
</div>
</div>
<div class="col-lg-4">
<label class="form-label" for="${id:replaygain-mode}">
${tr:Lms.Settings.replaygain-mode}
</label>
${replaygain-mode class="form-control"}
<div class="invalid-feedback">
${replaygain-mode-info}
</div>
</div>
<div class="col-lg-4">
<label class="form-label" for="${id:replaygain-preamp}">
${tr:Lms.Settings.replaygain-preamp}
</label>
<div class="input-group mb-3">
${replaygain-preamp class="form-control"}
<span class="input-group-text">dB</span>
<div class="invalid-feedback">
${replaygain-preamp-info}
</div>
</div>
</div>
<div class="col-lg-4">
<label class="form-label" for="${id:replaygain-preamp-no-rg-info}">
${tr:Lms.Settings.replaygain-preamp-no-rg-info}
</label>
<div class="input-group mb-3">
${replaygain-preamp-no-rg-info class="form-control"}
<span class="input-group-text">dB</span>
<div class="invalid-feedback">
${replaygain-preamp-no-rg-info-info}
</div>
</div>
</div>
${<if-has-subsonic-api>}
<legend>${tr:Lms.Settings.subsonic-api}</legend>
<div class="col-12">
<div class="form-check">
${subsonic-transcode-enable class="form-check-input"}
<label class="form-check-label" for="${id:subsonic-transcode-enable}">
${tr:Lms.Settings.transcode-enable class="form-control"}
</label>
<div class="invalid-feedback">
${subsonic-transcode-enable-info}
</div>
</div>
</div>
<div class="col-lg-6">
<label class="form-label" for="${id:subsonic-transcode-format}">
${tr:Lms.Settings.transcode-format}
</label>
${subsonic-transcode-format class="form-control"}
<div class="invalid-feedback">
${subsonic-transcode-format-info class="help-block"}
</div>
</div>
<div class="col-lg-6">
<label class="col-lg-3 form-label" for="${id:subsonic-transcode-bitrate}">
${tr:Lms.Settings.transcode-bitrate}
</label>
<div class="input-group mb-3">
${subsonic-transcode-bitrate class="form-control"}
<span class="input-group-text">kbps</span>
</div>
<div class="invalid-feedback">
${subsonic-transcode-bitrate-info class="help-block"}
</div>
</div>
<div class="col-12">
<label class="form-label" for="${id:subsonic-artist-list-mode}">
${tr:Lms.Settings.subsonic-artist-list-mode}
</label>
${subsonic-artist-list-mode class="form-control"}
<div class="invalid-feedback">
${subsonic-artist-list-mode-info class="help-block"}
</div>
</div>
${</if-has-subsonic-api>}
<legend>${tr:Lms.Settings.scrobbling}</legend>
<div class="col-lg-6">
<label class="form-label" for="${id:scrobbler}">
${tr:Lms.Settings.scrobbling.scrobbler}
</label>
${scrobbler class="form-control"}
<div class="invalid-feedback">
${scrobbler-info class="help-block"}
</div>
</div>
<div class="col-lg-6">
<label class="form-label" for="${id:listenbrainz-token}">
${tr:Lms.Settings.scrobbling.listenbrainz-token}
</label>
${listenbrainz-token class="form-control"}
<div class="invalid-feedback">
${listenbrainz-token-info class="help-block"}
</div>
</div>
${<if-has-change-password>}
<legend>${tr:Lms.Settings.change-password}</legend>
${<if-has-old-password>}
<div class="col-12">
<label class="form-label" for="${id:password-old}">
${tr:Lms.password-old}
</label>
${password-old class="form-control"}
<div class="invalid-feedback">
${password-old-info class="help-block"}
</div>
</div>
${</if-has-old-password>}
<div class="col-lg-6">
<label class="form-label" for="${id:password}">
${tr:Lms.password-new}
</label>
${password class="form-control"}
<div class="invalid-feedback">
${password-info class="help-block"}
</div>
</div>
<div class="col-lg-6">
<label class="form-label" for="${id:password-confirm}">
${tr:Lms.password-confirm}
</label>
${password-confirm class="form-control"}
<div class="invalid-feedback">
${password-confirm-info class="help-block"}
</div>
</div>
${</if-has-change-password>}
<div class="col-12">
${apply-btn class="btn btn-primary me-1"}${discard-btn class="btn btn-secondary"}
</div>
</form>
</message>
</messages>
-69
View File
@@ -1,69 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<messages xmlns:if="Wt.WTemplate.conditions">
<message id="Lms.template">
<nav class="navbar navbar-default">
<div class="container">
<div class="navbar-header">
<button class="navbar-toggle" data-target=".navbar-collapse" data-toggle="collapse">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
${title class="navbar-brand Lms-btn"}
<div class="Lms-navbar-search pull-left">
${search class="form-control"}
</div>
</div>
<div class="collapse navbar-collapse">
<ul class="nav navbar-nav">
<li>${releases class="Lms-btn"}</li>
<li>${artists class="Lms-btn"}</li>
<li>${tracks class="Lms-btn"}</li>
<li>${playqueue class="Lms-btn"}</li>
</ul>
<div class="navbar-form navbar-left">
${filters class="form-group Lms-clusters-control"}
</div>
<ul class="nav navbar-nav navbar-right">
${<if-is-admin>}
<li class="dropdown">
<a href="#" class="Lms-btn dropdown-toggle" data-toggle="dropdown" role="button" aria-expanded="false" title="${tr:Lms.administration}"><i class="fa fa-cogs" aria-hidden="true"></i> <span class="caret"></span></a>
<ul class="dropdown-menu" role="menu">
<li>${database}</li>
<li>${users}</li>
</ul>
</li>
${</if-is-admin>}
<li class="dropdown">
<a href="#" class="Lms-btn dropdown-toggle" data-toggle="dropdown" role="button" aria-expanded="false" title="${tr:Lms.user}"><i class="fa fa-user" aria-hidden="true"></i>
<span class="caret"></span></a>
<ul class="dropdown-menu" role="menu">
<li class="dropdown-header">${username}</li>
<li class="divider"></li>
<li>${settings}</li>
<li>${logout}</li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
<div class="container">
${contents class="Lms-contents"}
</div>
${player class="Lms-player"}
</message>
<message id="Lms.LoadingIndicator.template">
<i class="fa fa-spinner fa-pulse fa-3x fa-fw"></i>
<span class="sr-only">${tr:Lms.loading}</span>
</message>
<message id="Lms.infinite-scrolling-container">
${elements}
${loading-indicator class="Lms-horizontal-center Lms-loading-indicator"}
</message>
</messages>
+53 -35
View File
@@ -4,49 +4,67 @@
<!--FORMS message blocks-->
<message id="Lms.Explore.Tracks.template">
<div class="Lms-header">
${mode class="nav nav-pills Lms-explore-mode-container"}
${play-btn class="Lms-explore-btn Lms-btn"}${more-btn class="Lms-explore-btn Lms-btn"}
<div class="mb-3">
<ul class="nav nav-pills mb-2">
<li class="nav-item">
${random class="nav-link"}
</li>
<li class="nav-item">
${starred class="nav-link"}
</li>
<li class="nav-item">
${recently-played class="nav-link"}
</li>
<li class="nav-item">
${most-played class="nav-link"}
</li>
<li class="nav-item">
${recently-added class="nav-link"}
</li>
<li class="nav-item">
${all class="nav-link"}
</li>
</ul>
<div class="btn-group">
${play-btn class="btn btn-primary"}
<button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false"/>
<ul class="dropdown-menu">
<li>${play-last class="btn dropdown-item"}</li>
<li>${play-shuffled class="btn dropdown-item"}</li>
</ul>
</div>
</div>
${tracks}
</message>
<message id="Lms.Explore.Tracks.template.entry">
<div class="row Lms-vertical-align Lms-explore-entry-list Lms-explore-tracks-entry ${is-playing}">
<div class="col-xs-3 col-sm-2 col-md-2">
<div class="Lms-responsive-square Lms-cover-container">
${cover}
</div>
</div>
<div class="col-xs-7 col-sm-8 col-md-8 Lms-explore-entry-list-text-container">
<div class="Lms-trackname text-primary"><h5>${name}</h5></div>
${<if-has-artists-or-release>}
${<if-has-release>}<small>${release class="Lms-btn Lms-releasename text-primary"}</small>${</if-has-release>}
${<if-has-artists>}${artists}${</if-has-artists>}
${</if-has-artists-or-release>}
</div>
<div class="col-xs-2 col-sm-2 col-md-2">
<div class="Lms-explore-tracks-entry-controls">
${play-btn class="Lms-explore-btn Lms-btn"}${more-btn class="Lms-explore-btn Lms-btn"}
</div>
<div class="Lms-explore-tracks-entry-duration text-muted">
${duration}
</div>
</div>
<div class="d-flex align-items-center rounded Lms-bg-dark-hover mb-2">
<div class="p-1 Lms-cover-small">
${cover}
</div>
</message>
<message id="Lms.Explore.Tracks.template.entry-artist">
<small>${artist class="Lms-btn Lms-artistname text-muted"}</small>
</message>
<message id="Lms.Explore.Tracks.template.container">
<div class="row">
<div class="col-lg-8">
${elements}
${loading-indicator class="Lms-horizontal-center Lms-loading-indicator"}
<div class="p-2 flex-fill text-truncate">
<div class="d-block text-truncate">${name}</div>
${<if-has-release>}${release class="text-decoration-none link-success text-truncate"}${</if-has-release>}
</div>
<div class="p-2 d-none d-sm-block text-muted">
${duration}
</div>
<div class="p-2 d-flex">
${play-btn class="btn btn-sm btn-outline-secondary border-0"}
<div class="dropdown d-inline-block">
${more-btn data-bs-toggle="dropdown" aria-expanded="false" class="btn btn-sm btn-outline-secondary border-0"}
<ul class="dropdown-menu" aria-labelledby="${id:more-btn}">
<li>${play-last class="btn dropdown-item"}</li>
<li>${star class="btn dropdown-item"}</li>
<li>${download class="btn dropdown-item"}</li>
</ul>
</div>
</div>
</div>
</message>
<message id="Lms.Explore.Tracks.template.entry-artist">
${artist class="text-decoration-none link-secondary"}
</message>
</messages>
+1 -1
View File
@@ -1,4 +1,4 @@
find_path(STB_INCLUDE_DIR stb/stb.h)
find_path(STB_INCLUDE_DIR stb/stb_image.h)
include(FindPackageHandleStandardArgs)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+12
View File
File diff suppressed because one or more lines are too long
-54
View File
@@ -1,54 +0,0 @@
/* latin-ext */
@font-face {
font-family: 'Lato';
font-style: italic;
font-weight: 400;
font-display: swap;
src: local('Lato Italic'), local('Lato-Italic'), url(S6u8w4BMUTPHjxsAUi-qJCY.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Lato';
font-style: italic;
font-weight: 400;
font-display: swap;
src: local('Lato Italic'), local('Lato-Italic'), url(S6u8w4BMUTPHjxsAXC-q.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Lato';
font-style: normal;
font-weight: 400;
font-display: swap;
src: local('Lato Regular'), local('Lato-Regular'), url(S6uyw4BMUTPHjxAwXjeu.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Lato';
font-style: normal;
font-weight: 400;
font-display: swap;
src: local('Lato Regular'), local('Lato-Regular'), url(S6uyw4BMUTPHjx4wXg.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Lato';
font-style: normal;
font-weight: 700;
font-display: swap;
src: local('Lato Bold'), local('Lato-Bold'), url(S6u9w4BMUTPHh6UVSwaPGR_p.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Lato';
font-style: normal;
font-weight: 700;
font-display: swap;
src: local('Lato Bold'), local('Lato-Bold'), url(S6u9w4BMUTPHh6UVSwiPGQ.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
-37
View File
@@ -1,37 +0,0 @@
:root {
--body-color: #ffffff;
--hover-color: #00bc8c;
--playing-background-color: #375a7f4f;
--slider-background-color: #5a5a5a;
--player-background-color: #303030;
}
.Lms-artistname.text-muted {
font-style: italic;
color: #7e7e7e;
}
.Lms-artistname.text-primary, .Lms-artistname.text-primary:hover {
color: var(--body-color)
}
a.Lms-artistname.text-primary:hover {
color: var(--hover-color)
}
.Lms-releasename.text-primary, .Lms-releasename.text-primary:hover {
color: var(--body-color)
}
a.Lms-releasename.text-primary:hover {
color: var(--hover-color)
}
.Lms-trackname.text-primary, .Lms-trackname.text-primary:hover {
color: var(--body-color)
}
.media, .media-body {
overflow: visible;
}
-17
View File
@@ -1,17 +0,0 @@
:root {
--body-color: #2c3e50;
--hover-color: #18bc9c;
--playing-background-color: #2c3e5029;
--slider-background-color: #2c3e5029;
--player-background-color: #ecf0f1;
}
.Lms-artistname.text-muted {
font-style: italic;
color: #7e8f9e;
}
.media, .media-body {
overflow: visible;
}
+65 -416
View File
@@ -1,73 +1,58 @@
/* Stylized for flaty/darkly themes */
a, a:link, a:active, a:visited {
text-decoration: none;
cursor: pointer;
/* Generic rules */
.link-success {
transition: color 0.15s ease;
}
.link-secondary {
transition: color 0.15s ease;
}
.Lms-bg-dark-hover {
transition: background-color 0.15s ease;
}
.Lms-bg-dark-hover:hover {
background-color: var(--bs-dark);
}
/* Specific rules */
body {
padding-bottom: 78px;
}
.Lms-vertical-align {
display: flex;
align-items: center;
}
.Lms-admin-users-entry {
min-height: 48px;
padding-top: 8px;
padding-bottom: 8px;
margin: 0;
}
.Lms-admin-users-add-btn {
margin-top: 8px;
margin-bottom: 8px;
}
.Lms-admin-users-entry-role {
margin-left: 8px;
}
.Lms-admin-users-entry-btn {
margin-right: 8px;
}
.Lms-artistname {
color: var(--body-color);
}
a.Lms-artistname:hover, a.Lms-artistname:focus {
color: var(--hover-color);
}
.Lms-btn {
.Lms-badge-cluster {
transition: filter 0.15s ease;
cursor: pointer;
}
.Lms-btn:hover {
color: var(--hover-color);
transition: color .20s cubic-bezier(.43,-.1,.16,1.1);
}
.Lms-cluster {
display: inline-block;
.Lms-badge-cluster:hover {
filter: brightness(85%);
cursor: pointer;
line-height: 1.3;
margin-right: 4px;
margin-bottom: 4px;
}
.Lms-cluster:hover {
filter: brightness(130%);
transition: filter .20s cubic-bezier(.43,-.1,.16,1.1);
}
.Lms-clusters-control {
margin-top: 4px;
.Lms-entry-playing {
background-color: var(--bs-dark);
}
.Lms-cover {
opacity: 0;
transition: opacity 0.5s ease-in, filter 0.15s ease;
}
.Lms-cover-anchor:hover {
filter: brightness(85%);
}
.Lms-cover-container {
position: relative;
}
.Lms-cover-loaded {
opacity: 1;
}
.Lms-cover-release {
max-height: 100%;
max-width: 100%;
width: auto;
@@ -78,299 +63,53 @@ a.Lms-artistname:hover, a.Lms-artistname:focus {
left: 0;
right: 0;
margin: auto;
border-radius: 3px;
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.4);
opacity: 0;
-moz-transition: opacity 1s;
-webkit-transition: opacity 1s;
-o-transition: opacity 1s;
transition: opacity 1s;
}
.Lms-cover:hover {
filter: brightness(130%);
transition: filter .20s cubic-bezier(.43,-.1,.16,1.1);
.Lms-cover-track {
width: 64px;
min-width: 64px;
}
.Lms-cover-container {
position: relative;
#lms-mp-progress {
transition: none;
}
.Lms-cover-large {
border-radius: 3px;
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.4);
#lms-mp-volume {
cursor: pointer;
}
.Lms-cover-loaded {
opacity: 1;
#lms-mp-volume-slider::-webkit-slider-runnable-track {
background-color: var(--bs-gray-900);
}
.Lms-entry-playing {
font-weight: bold;
border-radius: 3px;
background-color: var(--playing-background-color);
#lms-mp-volume-slider::-moz-range-track {
background-color: var(--bs-gray-900);
}
.Lms-explore-btn {
font-size: 24px;
}
.Lms-explore-clusters {
display: inline-block;
}
.Lms-explore-contents {
margin-bottom: 24px;
}
.Lms-explore-entry-list {
padding-top: 4px;
padding-bottom: 4px;
min-height: 76px;
}
.Lms-explore-entry-list-text-container {
margin-left: -15px;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.Lms-explore-entry-list-text-container h5 {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.Lms-explore-entry-grid {
margin-bottom: 25px;
}
.Lms-explore-entry-grid-text-container {
height: 36px;
}
.Lms-explore-entry-grid-text-container h5, h6 {
margin-top: 6px;
margin-bottom: 6px;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.Lms-explore-entry-separator :nth-child(2) {
margin-left: 8px;
}
.Lms-explore-release {
margin-bottom: 25px;
}
.Lms-explore-release-cover {
max-width: 100%;
}
.Lms-explore-release-entry-tracknumber {
text-align: right;
white-space: nowrap;
}
.Lms-explore-release-entry-name {
margin-top: 4px;
margin-bottom: 4px;
font-weight: inherit;
}
.Lms-explore-release-entry {
min-height: 36px;
}
.Lms-explore-release-entry:hover .Lms-explore-release-entry-controls {
display: block;
}
.Lms-explore-release-entry:hover .Lms-explore-release-entry-track-duration {
display: none;
}
.Lms-explore-release-entry-controls {
text-align: right;
display: none;
}
.Lms-explore-release-entry-track-duration {
text-align: right;
white-space: nowrap;
display: block;
}
.Lms-explore-releaselink {
margin: 0;
padding-top: 4px;
padding-bottom: 4px;
}
.Lms-explore-artists-entry-small {
min-height: 28px;
margin: 0;
}
.Lms-explore-artists-input {
margin-bottom: 10px;
}
.Lms-explore-artist-entry {
padding-top: 4px;
padding-bottom: 4px;
}
.Lms-explore-artist-entries-container {
margin-bottom: 15px;
}
.Lms-explore-artist-tracks-container {
margin-bottom: 15px;
}
.Lms-explore-mode-container {
margin-bottom: 8px;
}
.Lms-explore-tracks-entry:hover .Lms-explore-tracks-entry-controls {
display: block;
}
.Lms-explore-tracks-entry:hover .Lms-explore-tracks-entry-duration {
display: none;
}
.Lms-explore-tracks-entry-controls {
text-align: right;
display: none;
}
.Lms-explore-tracks-entry-duration {
text-align: right;
white-space: nowrap;
display: block;
}
.Lms-header {
padding-bottom: 9px;
margin-top: 20px;
margin-bottom: 20px;
}
.Lms-horizontal-center {
display: flex;
justify-content: center;
}
.Lms-loading-indicator {
margin-top: 8px;
margin-bottom: 8px;
}
.Lms-navbar-search {
max-width: 175px;
padding-top: 7.5px;
padding-bottom: 7.5px;
margin-right: 8px;
}
.Lms-playqueue-btn {
font-size: 24px;
}
.Lms-playqueue-entry:hover .Lms-playqueue-entry-controls {
display: block;
}
.Lms-playqueue-entry:hover .Lms-playqueue-entry-duration {
display: none;
}
.text-success .Lms-playqueue-btn {
filter: brightness(130%);
filter: drop-shadow(0 0 0.65rem var(--hover-color));
color: var(--hover-color);
}
.Lms-playqueue-entry-controls {
text-align: right;
display: none;
}
.Lms-playqueue-entry-duration {
text-align: right;
white-space: nowrap;
display: block;
}
.Lms-playqueue-entry-name {
margin-top: 4px;
margin-bottom: 4px;
}
.Lms-playqueue-selected {
font-weight: bold;
border-radius: 3px;
background-color: var(--playing-background-color);
}
.Lms-playqueue-selected h4 {
font-weight: inherit;
}
.Lms-playhistory-entry {
min-height: 32px;
margin-bottom: 0;
}
.Lms-playhistory-entry-name {
margin-top: 4px;
margin-bottom: 4px;
.Lms-notification-container {
position: fixed;
bottom: 78px;
right: 0;
}
.Lms-player {
z-index: 3;
position: fixed;
bottom: 0;
width: 100%;
height: 70px;
padding: 0;
margin: 0;
background-color: var(--player-background-color);
}
.Lms-player-btn-volume {
font-size: 18px !important;
margin: auto;
.fixed-bottom.Lms-player {
z-index: 999;
}
.Lms-player-btn-controls {
font-size: 40px !important;
.Lms-player-volume-container {
width: 100px;
}
.Lms-player-controls {
text-align: center;
white-space: nowrap;
}
.Lms-player-duration {
line-height: 40px;
text-align: right;
white-space: nowrap;
}
.Lms-player-progress {
background-color: var(--slider-background-color);
}
.Lms-player-progress .progress-bar {
transition: none;
.Lms-player-seek-common {
position: absolute;
width:100%;
top: 0;
left: 0;
height: 6px;
}
.Lms-player-seek {
@@ -380,8 +119,6 @@ a.Lms-artistname:hover, a.Lms-artistname:focus {
-webkit-appearance: none;
-moz-appearance: none;
background: transparent;
border-style: solid;
border-width: thin;
}
.Lms-player-seek::-webkit-slider-thumb {
@@ -391,23 +128,6 @@ a.Lms-artistname:hover, a.Lms-artistname:focus {
width: 0;
}
.Lms-player-seek-container {
margin-top: 8px;
margin-bottom: 8px;
position: relative;
width: 100%;
height: 8px;
}
.Lms-player-seek-common {
position: absolute;
width:100%;
top: 0;
left: 0;
height: 8px;
border-radius: 3px;
}
.Lms-player-seek::-moz-range-thumb {
-webkit-appearance: none;
-moz-appearance: none;
@@ -415,75 +135,6 @@ a.Lms-artistname:hover, a.Lms-artistname:focus {
width: 0;
}
.Lms-player-text-center {
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.Lms-player-text {
margin-left: 8px;
margin-right: 8px;
}
.Lms-player-volume {
width: 125px;
height: 40px;
display: flex;
}
.Lms-player-volume-slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 8px;
outline: none;
cursor: pointer;
border-radius: 3px;
border-style: solid;
border-width: thin;
background-color: var(--slider-background-color);
}
.Lms-player-volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
background-color: var(--body-color);
appearance: none;
width: 14px;
height: 14px;
border-radius: 7px;
}
.Lms-player-volume-slider::-webkit-slider-thumb:hover {
background-color: var(--hover-color);
}
.Lms-player-volume-slider::-moz-range-thumb {
background-color: var(--body-color);
width: 14px;
height: 14px;
border-radius: 7px;
}
.Lms-player-volume-slider::-moz-range-thumb:hover {
background-color: var(--hover-color);
}
.Lms-player-volume-slider-container {
display: inline-block;
width: 85px;
margin: auto;
}
.Lms-releasename {
color: var(--body-color);
}
a.Lms-releasename:hover, a.Lms-releasename:focus {
color: var(--hover-color);
}
.Lms-responsive-square {
width: 100%;
}
@@ -493,5 +144,3 @@ a.Lms-releasename:hover, a.Lms-releasename:focus {
display: block;
padding-bottom: 100%;
}
-417
View File
@@ -1,417 +0,0 @@
/*
* Project: Bootstrap Notify = v3.1.5
* Description: Turns standard Bootstrap alerts into "Growl-like" notifications.
* Author: Mouse0270 aka Robert McIntosh
* License: MIT License
* Website: https://github.com/mouse0270/bootstrap-growl
*/
/* global define:false, require: false, jQuery:false */
(function (factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['jquery'], factory);
} else if (typeof exports === 'object') {
// Node/CommonJS
factory(require('jquery'));
} else {
// Browser globals
factory(jQuery);
}
}(function ($) {
// Create the defaults once
var defaults = {
element: 'body',
position: null,
type: "info",
allow_dismiss: true,
allow_duplicates: true,
newest_on_top: false,
showProgressbar: false,
placement: {
from: "top",
align: "right"
},
offset: 20,
spacing: 10,
z_index: 1031,
delay: 5000,
timer: 1000,
url_target: '_blank',
mouse_over: null,
animate: {
enter: 'animated fadeInDown',
exit: 'animated fadeOutUp'
},
onShow: null,
onShown: null,
onClose: null,
onClosed: null,
onClick: null,
icon_type: 'class',
template: '<div data-notify="container" class="col-xs-11 col-sm-4 alert alert-{0}" role="alert"><button type="button" aria-hidden="true" class="close" data-notify="dismiss">&times;</button><span data-notify="icon"></span> <span data-notify="title">{1}</span> <span data-notify="message">{2}</span><div class="progress" data-notify="progressbar"><div class="progress-bar progress-bar-{0}" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="width: 0%;"></div></div><a href="{3}" target="{4}" data-notify="url"></a></div>'
};
String.format = function () {
var args = arguments;
var str = arguments[0];
return str.replace(/(\{\{\d\}\}|\{\d\})/g, function (str) {
if (str.substring(0, 2) === "{{") return str;
var num = parseInt(str.match(/\d/)[0]);
return args[num + 1];
});
};
function isDuplicateNotification(notification) {
var isDupe = false;
$('[data-notify="container"]').each(function (i, el) {
var $el = $(el);
var title = $el.find('[data-notify="title"]').html().trim();
var message = $el.find('[data-notify="message"]').html().trim();
// The input string might be different than the actual parsed HTML string!
// (<br> vs <br /> for example)
// So we have to force-parse this as HTML here!
var isSameTitle = title === $("<div>" + notification.settings.content.title + "</div>").html().trim();
var isSameMsg = message === $("<div>" + notification.settings.content.message + "</div>").html().trim();
var isSameType = $el.hasClass('alert-' + notification.settings.type);
if (isSameTitle && isSameMsg && isSameType) {
//we found the dupe. Set the var and stop checking.
isDupe = true;
}
return !isDupe;
});
return isDupe;
}
function Notify(element, content, options) {
// Setup Content of Notify
var contentObj = {
content: {
message: typeof content === 'object' ? content.message : content,
title: content.title ? content.title : '',
icon: content.icon ? content.icon : '',
url: content.url ? content.url : '#',
target: content.target ? content.target : '-'
}
};
options = $.extend(true, {}, contentObj, options);
this.settings = $.extend(true, {}, defaults, options);
this._defaults = defaults;
if (this.settings.content.target === "-") {
this.settings.content.target = this.settings.url_target;
}
this.animations = {
start: 'webkitAnimationStart oanimationstart MSAnimationStart animationstart',
end: 'webkitAnimationEnd oanimationend MSAnimationEnd animationend'
};
if (typeof this.settings.offset === 'number') {
this.settings.offset = {
x: this.settings.offset,
y: this.settings.offset
};
}
//if duplicate messages are not allowed, then only continue if this new message is not a duplicate of one that it already showing
if (this.settings.allow_duplicates || (!this.settings.allow_duplicates && !isDuplicateNotification(this))) {
this.init();
}
}
$.extend(Notify.prototype, {
init: function () {
var self = this;
this.buildNotify();
if (this.settings.content.icon) {
this.setIcon();
}
if (this.settings.content.url != "#") {
this.styleURL();
}
this.styleDismiss();
this.placement();
this.bind();
this.notify = {
$ele: this.$ele,
update: function (command, update) {
var commands = {};
if (typeof command === "string") {
commands[command] = update;
} else {
commands = command;
}
for (var cmd in commands) {
switch (cmd) {
case "type":
this.$ele.removeClass('alert-' + self.settings.type);
this.$ele.find('[data-notify="progressbar"] > .progress-bar').removeClass('progress-bar-' + self.settings.type);
self.settings.type = commands[cmd];
this.$ele.addClass('alert-' + commands[cmd]).find('[data-notify="progressbar"] > .progress-bar').addClass('progress-bar-' + commands[cmd]);
break;
case "icon":
var $icon = this.$ele.find('[data-notify="icon"]');
if (self.settings.icon_type.toLowerCase() === 'class') {
$icon.removeClass(self.settings.content.icon).addClass(commands[cmd]);
} else {
if (!$icon.is('img')) {
$icon.find('img');
}
$icon.attr('src', commands[cmd]);
}
self.settings.content.icon = commands[command];
break;
case "progress":
var newDelay = self.settings.delay - (self.settings.delay * (commands[cmd] / 100));
this.$ele.data('notify-delay', newDelay);
this.$ele.find('[data-notify="progressbar"] > div').attr('aria-valuenow', commands[cmd]).css('width', commands[cmd] + '%');
break;
case "url":
this.$ele.find('[data-notify="url"]').attr('href', commands[cmd]);
break;
case "target":
this.$ele.find('[data-notify="url"]').attr('target', commands[cmd]);
break;
default:
this.$ele.find('[data-notify="' + cmd + '"]').html(commands[cmd]);
}
}
var posX = this.$ele.outerHeight() + parseInt(self.settings.spacing) + parseInt(self.settings.offset.y);
self.reposition(posX);
},
close: function () {
self.close();
}
};
},
buildNotify: function () {
var content = this.settings.content;
this.$ele = $(String.format(this.settings.template, this.settings.type, content.title, content.message, content.url, content.target));
this.$ele.attr('data-notify-position', this.settings.placement.from + '-' + this.settings.placement.align);
if (!this.settings.allow_dismiss) {
this.$ele.find('[data-notify="dismiss"]').css('display', 'none');
}
if ((this.settings.delay <= 0 && !this.settings.showProgressbar) || !this.settings.showProgressbar) {
this.$ele.find('[data-notify="progressbar"]').remove();
}
},
setIcon: function () {
if (this.settings.icon_type.toLowerCase() === 'class') {
this.$ele.find('[data-notify="icon"]').addClass(this.settings.content.icon);
} else {
if (this.$ele.find('[data-notify="icon"]').is('img')) {
this.$ele.find('[data-notify="icon"]').attr('src', this.settings.content.icon);
} else {
this.$ele.find('[data-notify="icon"]').append('<img src="' + this.settings.content.icon + '" alt="Notify Icon" />');
}
}
},
styleDismiss: function () {
this.$ele.find('[data-notify="dismiss"]').css({
position: 'absolute',
right: '10px',
top: '5px',
zIndex: this.settings.z_index + 2
});
},
styleURL: function () {
this.$ele.find('[data-notify="url"]').css({
backgroundImage: 'url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)',
height: '100%',
left: 0,
position: 'absolute',
top: 0,
width: '100%',
zIndex: this.settings.z_index + 1
});
},
placement: function () {
var self = this,
offsetAmt = this.settings.offset.y,
css = {
display: 'inline-block',
margin: '0px auto',
position: this.settings.position ? this.settings.position : (this.settings.element === 'body' ? 'fixed' : 'absolute'),
transition: 'all .5s ease-in-out',
zIndex: this.settings.z_index
},
hasAnimation = false,
settings = this.settings;
$('[data-notify-position="' + this.settings.placement.from + '-' + this.settings.placement.align + '"]:not([data-closing="true"])').each(function () {
offsetAmt = Math.max(offsetAmt, parseInt($(this).css(settings.placement.from)) + parseInt($(this).outerHeight()) + parseInt(settings.spacing));
});
if (this.settings.newest_on_top === true) {
offsetAmt = this.settings.offset.y;
}
css[this.settings.placement.from] = offsetAmt + 'px';
switch (this.settings.placement.align) {
case "left":
case "right":
css[this.settings.placement.align] = this.settings.offset.x + 'px';
break;
case "center":
css.left = 0;
css.right = 0;
break;
}
this.$ele.css(css).addClass(this.settings.animate.enter);
$.each(Array('webkit-', 'moz-', 'o-', 'ms-', ''), function (index, prefix) {
self.$ele[0].style[prefix + 'AnimationIterationCount'] = 1;
});
$(this.settings.element).append(this.$ele);
if (this.settings.newest_on_top === true) {
offsetAmt = (parseInt(offsetAmt) + parseInt(this.settings.spacing)) + this.$ele.outerHeight();
this.reposition(offsetAmt);
}
if ($.isFunction(self.settings.onShow)) {
self.settings.onShow.call(this.$ele);
}
this.$ele.one(this.animations.start, function () {
hasAnimation = true;
}).one(this.animations.end, function () {
self.$ele.removeClass(self.settings.animate.enter);
if ($.isFunction(self.settings.onShown)) {
self.settings.onShown.call(this);
}
});
setTimeout(function () {
if (!hasAnimation) {
if ($.isFunction(self.settings.onShown)) {
self.settings.onShown.call(this);
}
}
}, 600);
},
bind: function () {
var self = this;
this.$ele.find('[data-notify="dismiss"]').on('click', function () {
self.close();
});
if ($.isFunction(self.settings.onClick)) {
this.$ele.on('click', function (event) {
if (event.target != self.$ele.find('[data-notify="dismiss"]')[0]) {
self.settings.onClick.call(this, event);
}
});
}
this.$ele.mouseover(function () {
$(this).data('data-hover', "true");
}).mouseout(function () {
$(this).data('data-hover', "false");
});
this.$ele.data('data-hover', "false");
if (this.settings.delay > 0) {
self.$ele.data('notify-delay', self.settings.delay);
var timer = setInterval(function () {
var delay = parseInt(self.$ele.data('notify-delay')) - self.settings.timer;
if ((self.$ele.data('data-hover') === 'false' && self.settings.mouse_over === "pause") || self.settings.mouse_over != "pause") {
var percent = ((self.settings.delay - delay) / self.settings.delay) * 100;
self.$ele.data('notify-delay', delay);
self.$ele.find('[data-notify="progressbar"] > div').attr('aria-valuenow', percent).css('width', percent + '%');
}
if (delay <= -(self.settings.timer)) {
clearInterval(timer);
self.close();
}
}, self.settings.timer);
}
},
close: function () {
var self = this,
posX = parseInt(this.$ele.css(this.settings.placement.from)),
hasAnimation = false;
this.$ele.attr('data-closing', 'true').addClass(this.settings.animate.exit);
self.reposition(posX);
if ($.isFunction(self.settings.onClose)) {
self.settings.onClose.call(this.$ele);
}
this.$ele.one(this.animations.start, function () {
hasAnimation = true;
}).one(this.animations.end, function () {
$(this).remove();
if ($.isFunction(self.settings.onClosed)) {
self.settings.onClosed.call(this);
}
});
setTimeout(function () {
if (!hasAnimation) {
self.$ele.remove();
if ($.isFunction(self.settings.onClosed)) {
self.settings.onClosed.call(this);
}
}
}, 600);
},
reposition: function (posX) {
var self = this,
notifies = '[data-notify-position="' + this.settings.placement.from + '-' + this.settings.placement.align + '"]:not([data-closing="true"])',
$elements = this.$ele.nextAll(notifies);
if (this.settings.newest_on_top === true) {
$elements = this.$ele.prevAll(notifies);
}
$elements.each(function () {
$(this).css(self.settings.placement.from, posX);
posX = (parseInt(posX) + parseInt(self.settings.spacing)) + $(this).outerHeight();
});
}
});
$.notify = function (content, options) {
var plugin = new Notify(this, content, options);
return plugin.notify;
};
$.notifyDefaults = function (options) {
defaults = $.extend(true, {}, defaults, options);
return defaults;
};
$.notifyClose = function (selector) {
if (typeof selector === "undefined" || selector === "all") {
$('[data-notify]').find('[data-notify="dismiss"]').trigger('click');
}else if(selector === 'success' || selector === 'info' || selector === 'warning' || selector === 'danger'){
$('.alert-' + selector + '[data-notify]').find('[data-notify="dismiss"]').trigger('click');
} else if(selector){
$(selector + '[data-notify]').find('[data-notify="dismiss"]').trigger('click');
}
else {
$('[data-notify-position="' + selector + '"]').find('[data-notify="dismiss"]').trigger('click');
}
};
$.notifyCloseExcept = function (selector) {
if(selector === 'success' || selector === 'info' || selector === 'warning' || selector === 'danger'){
$('[data-notify]').not('.alert-' + selector).find('[data-notify="dismiss"]').trigger('click');
} else{
$('[data-notify]').not(selector).find('[data-notify="dismiss"]').trigger('click');
}
};
}));
File diff suppressed because one or more lines are too long
-7
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+7 -6
View File
@@ -31,13 +31,16 @@ LMS.mediaplayer = function () {
var _lastStartPlaying = null;
var _updateControls = function() {
const pauseClass = "fa-pause";
const playClass = "fa-play";
if (_elems.audio.paused) {
_elems.playpause.classList.remove("fa-pause");
_elems.playpause.classList.add("fa-play");
_elems.playpause.firstElementChild.classList.remove(pauseClass);
_elems.playpause.firstElementChild.classList.add(playClass);
}
else {
_elems.playpause.classList.remove("fa-play");
_elems.playpause.classList.add("fa-pause");
_elems.playpause.firstElementChild.classList.remove(playClass);
_elems.playpause.firstElementChild.classList.add(pauseClass);
}
}
@@ -178,8 +181,6 @@ LMS.mediaplayer = function () {
_elems.volumeslider = document.getElementById("lms-mp-volume-slider");
_elems.transcodingActive = document.getElementById("lms-transcoding-active");
$(_elems.transcodingActive).tooltip();
var source = _audioCtx.createMediaElementSource(_elems.audio);
source.connect(_gainNode);
_gainNode.connect(_audioCtx.destination);
+1 -1
View File
@@ -241,7 +241,7 @@ AudioFile::visitAttachedPictures(std::function<void(const Picture&)> func) const
std::optional<AudioFileFormat>
guessMediaFileFormat(const std::filesystem::path& file)
{
AVOutputFormat* format {av_guess_format(NULL,file.string().c_str(),NULL)};
const AVOutputFormat* format {av_guess_format(NULL,file.string().c_str(),NULL)};
if (!format || !format->name)
return {};
@@ -226,7 +226,6 @@ AvFormatParser::parse(const std::filesystem::path& p, bool debug)
track.clusters[tag] = std::move(values);
}
}
}
track.artists = getArtists(metadataMap);
-1
View File
@@ -148,7 +148,6 @@ getAlbum(const TagLib::PropertyMap& properties)
void
TagLibParser::processTag(Track& track, const std::string& tag, const TagLib::StringList& values, bool debug)
{
if (debug)
{
std::vector<std::string> strs;
@@ -20,6 +20,7 @@
#pragma once
#include <string_view>
#include <optional>
#include <boost/asio/ip/address.hpp>
#include <Wt/WDateTime.h>
+5 -6
View File
@@ -204,8 +204,8 @@ Artist::find(Session& session, const FindParameters& params)
return execQuery(query, params.range);
}
std::vector<Release::pointer>
Artist::getReleases(const std::vector<ClusterId>& clusterIds) const
RangeResults<ReleaseId>
Artist::getReleases(Range range, const std::vector<ClusterId>& clusterIds) const
{
assert(session());
@@ -213,7 +213,7 @@ Artist::getReleases(const std::vector<ClusterId>& clusterIds) const
std::ostringstream oss;
oss << "SELECT DISTINCT r FROM release r INNER JOIN artist a ON a.id = t_a_l.artist_id INNER JOIN track_artist_link t_a_l ON t_a_l.track_id = t.id INNER JOIN track t ON t.release_id = r.id";
oss << "SELECT DISTINCT r.id FROM release r INNER JOIN artist a ON a.id = t_a_l.artist_id INNER JOIN track_artist_link t_a_l ON t_a_l.track_id = t.id INNER JOIN track t ON t.release_id = r.id";
if (!clusterIds.empty())
{
@@ -236,13 +236,12 @@ Artist::getReleases(const std::vector<ClusterId>& clusterIds) const
oss << " ORDER BY t.date DESC, r.name COLLATE NOCASE";
auto query {session()->query<Wt::Dbo::ptr<Release>>(oss.str())};
auto query {session()->query<ReleaseId>(oss.str())};
for (const std::string& bindArg : where.getBindArgs())
query.bind(bindArg);
auto res {query.resultList()};
return std::vector<Release::pointer>(res.begin(), res.end());
return execQuery(query, range);
}
std::size_t
@@ -30,6 +30,7 @@
#include "services/database/ArtistId.hpp"
#include "services/database/ClusterId.hpp"
#include "services/database/Object.hpp"
#include "services/database/ReleaseId.hpp"
#include "services/database/Types.hpp"
#include "services/database/UserId.hpp"
#include "utils/EnumSet.hpp"
@@ -88,7 +89,7 @@ class Artist : public Object<Artist, ArtistId>
const std::string& getSortName() const { return _sortName; }
std::optional<UUID> getMBID() const { return UUID::fromString(_MBID); }
std::vector<ObjectPtr<Release>> getReleases(const std::vector<ClusterId>& clusterIds = {}) const; // if non empty, get the releases that match all these clusters
RangeResults<ReleaseId> getReleases(Range range, const std::vector<ClusterId>& clusterIds = {}) const; // if non empty, get the releases that match all these clusters
std::size_t getReleaseCount() const;
std::vector<ObjectPtr<Track>> getTracks(std::optional<TrackArtistLinkType> linkType = {}) const;
bool hasNonReleaseTracks(std::optional<TrackArtistLinkType> linkType = std::nullopt) const;
+12 -12
View File
@@ -574,13 +574,13 @@ TEST_F(DatabaseFixture, SingleTrackSingleReleaseSingleArtistSingleCluster)
ASSERT_EQ(artists.results.size(), 1);
EXPECT_EQ(artists.results.front(), artist.getId());
auto releases {artist->getReleases()};
ASSERT_EQ(releases.size(), 1);
EXPECT_EQ(releases.front()->getId(), release.getId());
auto releases {artist->getReleases(Range {})};
ASSERT_EQ(releases.results.size(), 1);
EXPECT_EQ(releases.results.front(), release.getId());
releases = artist->getReleases({cluster.getId()});
ASSERT_EQ(releases.size(), 1);
EXPECT_EQ(releases.front()->getId(), release.getId());
releases = artist->getReleases(Range {}, {cluster.getId()});
ASSERT_EQ(releases.results.size(), 1);
EXPECT_EQ(releases.results.front(), release.getId());
}
}
@@ -605,13 +605,13 @@ TEST_F(DatabaseFixture, SingleTrackSingleReleaseSingleArtistMultiClusters)
{
auto transaction {session.createSharedTransaction()};
auto releases {artist->getReleases()};
ASSERT_EQ(releases.size(), 1);
EXPECT_EQ(releases.front()->getId(), release.getId());
auto releases {artist->getReleases(Range {})};
ASSERT_EQ(releases.results.size(), 1);
EXPECT_EQ(releases.results.front(), release.getId());
releases = artist->getReleases({cluster1.getId(), cluster2.getId()});
ASSERT_EQ(releases.size(), 1);
EXPECT_EQ(releases.front()->getId(), release.getId());
releases = artist->getReleases(Range {}, {cluster1.getId(), cluster2.getId()});
ASSERT_EQ(releases.results.size(), 1);
EXPECT_EQ(releases.results.front(), release.getId());
}
}
@@ -50,8 +50,9 @@ TEST_F(DatabaseFixture, MultiTracksSingleArtistSingleRelease)
auto transaction {session.createSharedTransaction()};
EXPECT_EQ(artist->getReleaseCount(), 1);
ASSERT_EQ(artist->getReleases().size(), 1);
EXPECT_EQ(artist->getReleases().front()->getId(), release.getId());
auto releases {artist->getReleases(Range {})};
ASSERT_EQ(releases.results.size(), 1);
EXPECT_EQ(releases.results.front(), release.getId());
EXPECT_EQ(release->getTracks().size(), nbTracks);
}
@@ -73,9 +74,9 @@ TEST_F(DatabaseFixture, SingleTrackSingleReleaseSingleArtist)
{
auto transaction {session.createUniqueTransaction()};
auto releases {artist->getReleases()};
ASSERT_EQ(releases.size(), 1);
EXPECT_EQ(releases.front()->getId(), release.getId());
auto releases {artist->getReleases(Range {})};
ASSERT_EQ(releases.results.size(), 1);
EXPECT_EQ(releases.results.front(), release.getId());
EXPECT_EQ(artist->getReleaseCount(), 1);
@@ -30,7 +30,6 @@ namespace SOM
class DataNormalizer
{
public:
struct MinMax
{
InputVector::value_type min;
-1
View File
@@ -163,7 +163,6 @@ class InputVector
}
private:
friend class InputVector operator-(const InputVector& a, const InputVector& b)
{
if (!a.hasSameDimension(b.getNbDimensions()))
-2
View File
@@ -53,7 +53,6 @@ template <typename T>
class Matrix
{
public:
Matrix() = default;
Matrix(Coordinate width, Coordinate height)
@@ -109,7 +108,6 @@ class Matrix
}
private:
Coordinate _width {};
Coordinate _height {};
std::vector<T> _values;
-1
View File
@@ -40,7 +40,6 @@ std::ostream& operator<<(std::ostream& os, const InputVector& a);
class Network
{
public:
// Init a network with random values
Network(Coordinate width, Coordinate height, std::size_t inputDimCount);
+26 -35
View File
@@ -22,6 +22,7 @@
#include <atomic>
#include <ctime>
#include <iomanip>
#include <map>
#include <unordered_map>
#include <Wt/WLocalDateTime.h>
@@ -885,9 +886,12 @@ handleGetArtistRequest(RequestContext& context)
Response response {Response::createOkResponse(context.serverProtocolVersion)};
Response::Node artistNode {artistToResponseNode(user, artist, true /* id3 */)};
auto releases {artist->getReleases()};
for (const Release::pointer& release : releases)
auto releases {artist->getReleases(Range {})};
for (const ReleaseId releaseId : releases.results)
{
const Release::pointer release {Release::find(context.dbSession, releaseId)};
artistNode.addArrayChild("album", releaseToResponseNode(release, context.dbSession, user, true /* id3 */));
}
response.addNode("artist", std::move(artistNode));
@@ -997,9 +1001,12 @@ handleGetMusicDirectoryRequest(RequestContext& context)
directoryNode.setAttribute("name", makeNameFilesystemCompatible(artist->getName()));
auto releases {artist->getReleases()};
for (const Release::pointer& release : releases)
auto releases {artist->getReleases(Range {})};
for (const ReleaseId releaseId : releases.results)
{
const Release::pointer release {Release::find(context.dbSession, releaseId)};
directoryNode.addArrayChild("child", releaseToResponseNode(release, context.dbSession, user, false /* no id3 */));
}
}
else if (releaseId)
{
@@ -1065,46 +1072,30 @@ handleGetArtistsRequestCommon(RequestContext& context, bool id3)
break;
}
Response::Node* currentIndexNode {};
char currentIndex{};
Response::Node* unknownIndexNode {};
auto getOrCreateUnknownIndexNode {[&]
{
if (!unknownIndexNode)
{
unknownIndexNode = &artistsNode.createArrayChild("index");
unknownIndexNode->setAttribute("name", "?");
}
return unknownIndexNode;
}};
auto getOrCreateIndexNode {[&](char first)
{
if (!currentIndexNode || currentIndex != first)
{
currentIndexNode = &artistsNode.createArrayChild("index");
currentIndexNode->setAttribute("name", std::string {first});
currentIndex = first;
}
return currentIndexNode;
}};
std::map<char, std::vector<Artist::pointer>> artistsSortedByFirstChar;
const RangeResults<ArtistId> artists {Artist::find(context.dbSession, parameters)};
for (const ArtistId artistId : artists.results)
{
const Artist::pointer artist {Artist::find(context.dbSession, artistId)};
const std::string& sortName {artist->getSortName()};
Response::Node* indexNode{};
char sortChar;
if (sortName.empty() || !std::isalpha(sortName[0]))
indexNode = getOrCreateUnknownIndexNode();
sortChar = '?';
else
indexNode = getOrCreateIndexNode(std::toupper(sortName[0]));
sortChar = std::toupper(sortName[0]);
indexNode->addArrayChild("artist", artistToResponseNode(user, artist, id3));
artistsSortedByFirstChar[sortChar].push_back(artist);
}
for (const auto& [sortChar, artists] : artistsSortedByFirstChar)
{
Response::Node& indexNode {artistsNode.createArrayChild("index")};
indexNode.setAttribute("name", std::string {sortChar});
for (const Artist::pointer& artist :artists)
indexNode.addArrayChild("artist", artistToResponseNode(user, artist, id3));
}
return response;
@@ -203,7 +203,6 @@ class Response
void addArrayChild(const std::string& key, Node node);
private:
void setVersionAttribute(ProtocolVersion version);
friend class Response;
@@ -28,7 +28,6 @@ class FileResourceHandler final : public IResourceHandler
FileResourceHandler(const std::filesystem::path& filePath);
private:
Wt::Http::ResponseContinuation* processRequest(const Wt::Http::Request& request, Wt::Http::Response& response) override;
static constexpr std::size_t _chunkSize {65536};
+1 -3
View File
@@ -32,15 +32,13 @@ namespace Http
{
public:
Client(boost::asio::io_context& ioContext, std::string_view baseUrl)
: _ioContext {ioContext}
, _sendQueue {ioContext, baseUrl}
: _sendQueue {ioContext, baseUrl}
{}
private:
void sendGETRequest(ClientGETRequestParameters&& request) override;
void sendPOSTRequest(ClientPOSTRequestParameters&& request) override;
boost::asio::io_context& _ioContext;
SendQueue _sendQueue;
};
} // namespace Http
-1
View File
@@ -26,7 +26,6 @@
class IConfig
{
public:
virtual ~IConfig() = default;
// Default values are returned in case of setting not found
+15
View File
@@ -31,34 +31,43 @@
namespace StringUtils {
[[nodiscard]]
std::vector<std::string>
splitStringCopy(std::string_view string, std::string_view separators);
[[nodiscard]]
std::vector<std::string_view>
splitString(std::string_view string, std::string_view separators);
[[nodiscard]]
std::string
joinStrings(const std::vector<std::string>& strings, const std::string& delimiter);
[[nodiscard]]
std::string
stringTrim(std::string_view str, std::string_view whitespaces = " \t");
[[nodiscard]]
std::string
stringTrimEnd(std::string_view str, std::string_view whitespaces = " \t");
[[nodiscard]]
std::string
stringToLower(std::string_view str);
void
stringToLower(std::string& str);
[[nodiscard]]
std::string
stringToUpper(const std::string& str);
[[nodiscard]]
std::string
bufferToString(const std::vector<unsigned char>& data);
template<typename T>
[[nodiscard]]
std::optional<T> readAs(std::string_view str)
{
T res;
@@ -72,21 +81,27 @@ std::optional<T> readAs(std::string_view str)
}
template<>
[[nodiscard]]
std::optional<std::string>
readAs(std::string_view str);
[[nodiscard]]
std::string
replaceInString(const std::string& str, const std::string& from, const std::string& to);
[[nodiscard]]
std::string
jsEscape(const std::string& str);
[[nodiscard]]
std::string
escapeString(std::string_view str, std::string_view charsToEscape, char escapeChar);
[[nodiscard]]
bool
stringEndsWith(const std::string& str, const std::string& ending);
[[nodiscard]]
std::optional<std::string>
stringFromHex(const std::string& str);
-1
View File
@@ -28,7 +28,6 @@
class UUID
{
public:
static std::optional<UUID> fromString(std::string_view str);
std::string_view getAsString() const { return _value; }
-1
View File
@@ -40,7 +40,6 @@ namespace Zip
class Zipper
{
public:
Zipper(const std::map<std::string, std::filesystem::path>& files, const Wt::WDateTime& lastModifiedTime = {});
static constexpr SizeType minOutputBufferSize {64};
+28 -6
View File
@@ -24,7 +24,7 @@
TEST(StringUtils, splitString)
{
{
const std::string test{"a"};
const std::string test {"a"};
const std::vector<std::string_view> strings {StringUtils::splitString(test, "")};
ASSERT_EQ(strings.size(), 1);
@@ -32,7 +32,7 @@ TEST(StringUtils, splitString)
}
{
const std::string test{"a b"};
const std::string test {"a b"};
const std::vector<std::string_view> strings {StringUtils::splitString(test, "|")};
ASSERT_EQ(strings.size(), 1);
@@ -40,7 +40,7 @@ TEST(StringUtils, splitString)
}
{
const std::string test{" a"};
const std::string test {" a"};
const std::vector<std::string_view> strings {StringUtils::splitString(test, " ")};
ASSERT_EQ(strings.size(), 1);
@@ -48,7 +48,7 @@ TEST(StringUtils, splitString)
}
{
const std::string test{"a "};
const std::string test {"a "};
const std::vector<std::string_view> strings {StringUtils::splitString(test, " ")};
ASSERT_EQ(strings.size(), 1);
@@ -56,7 +56,7 @@ TEST(StringUtils, splitString)
}
{
const std::string test{"a b"};
const std::string test {"a b"};
const std::vector<std::string_view> strings {StringUtils::splitString(test, " ")};
ASSERT_EQ(strings.size(), 2);
@@ -65,7 +65,7 @@ TEST(StringUtils, splitString)
}
{
const std::string test{"a b,c|defgh "};
const std::string test {"a b,c|defgh "};
const std::vector<std::string_view> strings {StringUtils::splitString(test, " ,|")};
ASSERT_EQ(strings.size(), 4);
@@ -76,6 +76,28 @@ TEST(StringUtils, splitString)
}
}
TEST(StringUtils, splitStringCopy)
{
{
const std::string test {"test=foo"};
const std::vector<std::string> strings {StringUtils::splitStringCopy(test, "=")};
ASSERT_EQ(strings.size(), 2);
EXPECT_EQ(strings[0], "test");
EXPECT_EQ(strings[1], "foo");
}
{
const std::string test {"test=foo bar"};
const std::vector<std::string> strings {StringUtils::splitStringCopy(test, "=")};
ASSERT_EQ(strings.size(), 2);
EXPECT_EQ(strings[0], "test");
EXPECT_EQ(strings[1], "foo bar");
}
}
TEST(StringUtils, escapeString)
{
EXPECT_EQ(StringUtils::escapeString("", "*", ' '), "");
+5 -3
View File
@@ -6,20 +6,24 @@ add_executable(lms
ui/LmsApplicationManager.cpp
ui/LmsTheme.cpp
ui/MediaPlayer.cpp
ui/ModalManager.cpp
ui/NotificationContainer.cpp
ui/PlayQueue.cpp
ui/SettingsView.cpp
ui/TrackStringUtils.cpp
ui/Utils.cpp
ui/admin/DatabaseSettingsView.cpp
ui/admin/ScannerController.cpp
ui/admin/InitWizardView.cpp
ui/admin/UserView.cpp
ui/admin/UsersView.cpp
ui/common/DirectoryValidator.cpp
ui/common/DoubleValidator.cpp
ui/common/InfiniteScrollingContainer.cpp
ui/common/LoadingIndicator.cpp
ui/common/LoginNameValidator.cpp
ui/common/MandatoryValidator.cpp
ui/common/PasswordValidator.cpp
ui/common/Template.cpp
ui/common/UUIDValidator.cpp
ui/explore/ArtistCollector.cpp
ui/explore/ArtistListHelpers.cpp
@@ -30,13 +34,11 @@ add_executable(lms
ui/explore/Filters.cpp
ui/explore/ReleaseCollector.cpp
ui/explore/ReleaseListHelpers.cpp
ui/explore/ReleasePopup.cpp
ui/explore/ReleasesView.cpp
ui/explore/ReleaseView.cpp
ui/explore/SearchView.cpp
ui/explore/TrackCollector.cpp
ui/explore/TrackListHelpers.cpp
ui/explore/TrackPopup.cpp
ui/explore/TracksView.cpp
ui/resource/AudioFileResource.cpp
ui/resource/AudioTranscodeResource.cpp
-4
View File
@@ -86,7 +86,6 @@ processAuthToken(const Wt::WEnvironment& env)
class AuthModel : public Wt::WFormModel
{
public:
// Associate each field with a unique string literal.
static const Field LoginNameField;
static const Field PasswordField;
@@ -160,7 +159,6 @@ class AuthModel : public Wt::WFormModel
std::optional<Database::UserId> getUserId() const { return _userId; }
private:
std::optional<Database::UserId> _userId;
};
@@ -168,7 +166,6 @@ const AuthModel::Field AuthModel::LoginNameField {"login-name"};
const AuthModel::Field AuthModel::PasswordField {"password"};
const AuthModel::Field AuthModel::RememberMeField {"remember-me"};
Auth::Auth()
: Wt::WTemplateFormView {Wt::WString::tr("Lms.Auth.template")}
{
@@ -217,7 +214,6 @@ Auth::Auth()
loginBtn->clicked().connect(this, processAuth);
updateView(model.get());
}
} // namespace UserInterface
+38 -111
View File
@@ -22,11 +22,9 @@
#include <Wt/WAnchor.h>
#include <Wt/WEnvironment.h>
#include <Wt/WLineEdit.h>
#include <Wt/WPopupMenu.h>
#include <Wt/WPushButton.h>
#include <Wt/WServer.h>
#include <Wt/WStackedWidget.h>
#include <Wt/WText.h>
#include "services/auth/IEnvService.hpp"
#include "services/auth/IPasswordService.hpp"
@@ -57,6 +55,8 @@
#include "LmsApplicationManager.hpp"
#include "LmsTheme.hpp"
#include "MediaPlayer.hpp"
#include "ModalManager.hpp"
#include "NotificationContainer.hpp"
#include "PlayQueue.hpp"
#include "SettingsView.hpp"
@@ -162,7 +162,12 @@ LmsApplication::~LmsApplication() = default;
void
LmsApplication::init()
{
setTheme(std::make_shared<LmsTheme>());
useStyleSheet("resources/font-awesome/css/font-awesome.min.css");
require("js/mediaplayer.js");
setTitle("LMS");
// Add a resource bundle
messageResourceBundle().use(appRoot() + "admin-database");
@@ -175,24 +180,18 @@ LmsApplication::init()
messageResourceBundle().use(appRoot() + "error");
messageResourceBundle().use(appRoot() + "explore");
messageResourceBundle().use(appRoot() + "login");
messageResourceBundle().use(appRoot() + "main");
messageResourceBundle().use(appRoot() + "mediaplayer");
messageResourceBundle().use(appRoot() + "messages");
messageResourceBundle().use(appRoot() + "misc");
messageResourceBundle().use(appRoot() + "notifications");
messageResourceBundle().use(appRoot() + "playqueue");
messageResourceBundle().use(appRoot() + "release");
messageResourceBundle().use(appRoot() + "releases");
messageResourceBundle().use(appRoot() + "search");
messageResourceBundle().use(appRoot() + "settings");
messageResourceBundle().use(appRoot() + "templates");
messageResourceBundle().use(appRoot() + "tracks");
// Require js here to avoid async problems
requireJQuery("js/jquery-1.10.2.min.js");
require("js/bootstrap-notify.js");
require("js/bootstrap.min.js");
require("js/mediaplayer.js");
setTitle("LMS");
// Handle Media Scanner events and other session events
enableUpdates(true);
@@ -218,8 +217,6 @@ LmsApplication::processPasswordAuth()
}
}
setTheme();
// If here is no account in the database, launch the first connection wizard
bool firstConnection {};
{
@@ -244,19 +241,6 @@ LmsApplication::processPasswordAuth()
}
}
void
LmsApplication::setTheme()
{
Database::UITheme theme {Database::User::defaultUITheme};
{
auto transaction {getDbSession().createSharedTransaction()};
if (const auto user {getUser()})
theme = user->getUITheme();
}
WApplication::setTheme(std::make_unique<LmsTheme>(theme));
}
void
LmsApplication::finalize()
{
@@ -306,7 +290,6 @@ LmsApplication::createReleaseAnchor(Database::Release::pointer release, bool add
if (addText)
{
res->setWordWrap(false);
res->setTextFormat(Wt::TextFormat::Plain);
res->setText(Wt::WString::fromUTF8(release->getName()));
res->setToolTip(Wt::WString::fromUTF8(release->getName()), Wt::TextFormat::Plain);
@@ -315,40 +298,6 @@ LmsApplication::createReleaseAnchor(Database::Release::pointer release, bool add
return res;
}
std::unique_ptr<Wt::WText>
LmsApplication::createCluster(Database::Cluster::pointer cluster, bool canDelete)
{
auto getStyleClass = [](const Database::Cluster::pointer cluster)
{
switch (cluster->getType()->getId().getValue() % 6)
{
case 0: return "label-info";
case 1: return "label-warning";
case 2: return "label-primary";
case 3: return "label-default";
case 4: return "label-success";
case 5: return "label-danger";
}
return "label-default";
};
const std::string styleClass {getStyleClass(cluster)};
auto res {std::make_unique<Wt::WText>(std::string {} + (canDelete ? "<i class=\"fa fa-times-circle\"></i> " : "") + Wt::WString::fromUTF8(cluster->getName()), Wt::TextFormat::UnsafeXHTML)};
res->setStyleClass("Lms-cluster label " + styleClass);
res->setToolTip(cluster->getType()->getName(), Wt::TextFormat::Plain);
res->setInline(true);
return res;
}
Wt::WPopupMenu*
LmsApplication::createPopupMenu()
{
_popupMenu = std::make_unique<Wt::WPopupMenu>();
return _popupMenu.get();
}
void
LmsApplication::handleException(LmsApplicationException& e)
{
@@ -407,8 +356,7 @@ handlePathChange(Wt::WStackedWidget& stack, bool isAdmin)
LMS_LOG(UI, DEBUG) << "Internal path changed to '" << wApp->internalPath() << "'";
LmsApp->doJavaScript(R"($('.navbar-nav li.active').removeClass('active'); $('.navbar-nav a[href="' + location.pathname + '"]').closest('li').addClass('active');)");
LmsApp->doJavaScript(R"($('.navbar-nav a.active').removeClass('active'); $('.navbar-nav a[href="' + location.pathname + '"]').closest('a').addClass('active');)");
for (const auto& view : views)
{
if (wApp->internalPathMatches(view.path))
@@ -439,7 +387,6 @@ LmsApplication::logoutUser()
void
LmsApplication::onUserLoggedIn()
{
setTheme();
root()->clear();
LMS_LOG(UI, INFO) << "User '" << getUserLoginName() << "' logged in from '" << environment().clientAddress() << "', user agent = " << environment().userAgent();
@@ -466,45 +413,50 @@ LmsApplication::createHome()
_coverResource = std::make_shared<CoverResource>();
declareJavaScriptFunction("onLoadCover", "function(id) { id.className += \" Lms-cover-loaded\"}");
doJavaScript("$('body').tooltip({ selector: '[data-toggle=\"tooltip\"]'})");
Wt::WTemplate* main {root()->addWidget(std::make_unique<Wt::WTemplate>(Wt::WString::tr("Lms.template")))};
Wt::WTemplate* main {root()->addWidget(std::make_unique<Wt::WTemplate>(Wt::WString::tr("Lms.main.template")))};
main->addFunction("tr", &Wt::WTemplate::Functions::tr);
Wt::WTemplate* navbar {main->bindNew<Wt::WTemplate>("navbar", Wt::WString::tr("Lms.main.template.navbar"))};
navbar->addFunction("tr", &Wt::WTemplate::Functions::tr);
_notificationContainer = main->bindNew<NotificationContainer>("notifications");
_modalManager = main->bindNew<ModalManager>("modal");
// MediaPlayer
_mediaPlayer = main->bindNew<MediaPlayer>("player");
main->bindNew<Wt::WAnchor>("title", Wt::WLink {Wt::LinkType::InternalPath, defaultPath}, "LMS");
main->bindNew<Wt::WAnchor>("artists", Wt::WLink {Wt::LinkType::InternalPath, "/artists"}, Wt::WString::tr("Lms.Explore.artists"));
main->bindNew<Wt::WAnchor>("releases", Wt::WLink {Wt::LinkType::InternalPath, "/releases"}, Wt::WString::tr("Lms.Explore.releases"));
main->bindNew<Wt::WAnchor>("tracks", Wt::WLink {Wt::LinkType::InternalPath, "/tracks"}, Wt::WString::tr("Lms.Explore.tracks"));
navbar->bindNew<Wt::WAnchor>("title", Wt::WLink {Wt::LinkType::InternalPath, defaultPath}, "LMS");
navbar->bindNew<Wt::WAnchor>("artists", Wt::WLink {Wt::LinkType::InternalPath, "/artists"}, Wt::WString::tr("Lms.Explore.artists"));
navbar->bindNew<Wt::WAnchor>("releases", Wt::WLink {Wt::LinkType::InternalPath, "/releases"}, Wt::WString::tr("Lms.Explore.releases"));
navbar->bindNew<Wt::WAnchor>("tracks", Wt::WLink {Wt::LinkType::InternalPath, "/tracks"}, Wt::WString::tr("Lms.Explore.tracks"));
Filters* filters {main->bindNew<Filters>("filters")};
main->bindNew<Wt::WAnchor>("playqueue", Wt::WLink {Wt::LinkType::InternalPath, "/playqueue"}, Wt::WString::tr("Lms.PlayQueue.playqueue"));
main->bindString("username", getUserLoginName(), Wt::TextFormat::Plain);
main->bindNew<Wt::WAnchor>("settings", Wt::WLink {Wt::LinkType::InternalPath, "/settings"}, Wt::WString::tr("Lms.Settings.menu-settings"));
Filters* filters {navbar->bindNew<Filters>("filters")};
navbar->bindNew<Wt::WAnchor>("playqueue", Wt::WLink {Wt::LinkType::InternalPath, "/playqueue"}, Wt::WString::tr("Lms.PlayQueue.playqueue"));
navbar->bindString("username", getUserLoginName(), Wt::TextFormat::Plain);
navbar->bindNew<Wt::WAnchor>("settings", Wt::WLink {Wt::LinkType::InternalPath, "/settings"}, Wt::WString::tr("Lms.Settings.menu-settings"));
{
Wt::WAnchor* logout {main->bindNew<Wt::WAnchor>("logout")};
Wt::WAnchor* logout {navbar->bindNew<Wt::WAnchor>("logout")};
logout->setText(Wt::WString::tr("Lms.logout"));
logout->clicked().connect(this, &LmsApplication::logoutUser);
}
Wt::WLineEdit* searchEdit {main->bindNew<Wt::WLineEdit>("search")};
Wt::WLineEdit* searchEdit {navbar->bindNew<Wt::WLineEdit>("search")};
searchEdit->setPlaceholderText(Wt::WString::tr("Lms.Explore.Search.search-placeholder"));
if (LmsApp->getUserType() == Database::UserType::ADMIN)
{
main->setCondition("if-is-admin", true);
main->bindNew<Wt::WAnchor>("database", Wt::WLink {Wt::LinkType::InternalPath, "/admin/database"}, Wt::WString::tr("Lms.Admin.Database.menu-database"));
main->bindNew<Wt::WAnchor>("users", Wt::WLink {Wt::LinkType::InternalPath, "/admin/users"}, Wt::WString::tr("Lms.Admin.Users.menu-users"));
navbar->setCondition("if-is-admin", true);
navbar->bindNew<Wt::WAnchor>("database", Wt::WLink {Wt::LinkType::InternalPath, "/admin/database"}, Wt::WString::tr("Lms.Admin.Database.menu-database"));
navbar->bindNew<Wt::WAnchor>("users", Wt::WLink {Wt::LinkType::InternalPath, "/admin/users"}, Wt::WString::tr("Lms.Admin.Users.menu-users"));
}
// Contents
// Order is important in mainStack, see IdxRoot!
Wt::WStackedWidget* mainStack {main->bindNew<Wt::WStackedWidget>("contents")};
mainStack->setAttributeValue("style", "overflow-x:visible;overflow-y:visible;");
mainStack->setOverflow(Wt::Overflow::Visible); // wt makes it hidden by default
Explore* explore {mainStack->addNew<Explore>(filters)};
_playQueue = mainStack->addNew<PlayQueue>();
@@ -578,7 +530,9 @@ LmsApplication::createHome()
{
_scannerEvents.scanComplete.connect([=] (const Scanner::ScanStats& stats)
{
notifyMsg(MsgType::Info, Wt::WString::tr("Lms.Admin.Database.scan-complete")
notifyMsg(Notification::Type::Info,
Wt::WString::tr("Lms.Admin.Database.database"),
Wt::WString::tr("Lms.Admin.Database.scan-complete")
.arg(static_cast<unsigned>(stats.nbFiles()))
.arg(static_cast<unsigned>(stats.additions))
.arg(static_cast<unsigned>(stats.updates))
@@ -615,46 +569,19 @@ LmsApplication::notify(const Wt::WEvent& event)
}
}
static std::string msgTypeToString(LmsApplication::MsgType type)
{
switch(type)
{
case LmsApplication::MsgType::Success: return "success";
case LmsApplication::MsgType::Info: return "info";
case LmsApplication::MsgType::Warning: return "warning";
case LmsApplication::MsgType::Danger: return "danger";
}
return "";
}
void
LmsApplication::post(std::function<void()> func)
{
Wt::WServer::instance()->post(LmsApp->sessionId(), std::move(func));
}
void
LmsApplication::notifyMsg(MsgType type, const Wt::WString& message, std::chrono::milliseconds duration)
LmsApplication::notifyMsg(Notification::Type type, const Wt::WString& category, const Wt::WString& message, std::chrono::milliseconds duration)
{
LMS_LOG(UI, INFO) << "Notifying message '" << message.toUTF8() << "' of type '" << msgTypeToString(type) << "'";
std::ostringstream oss;
oss << "$.notify({"
"message: '" << StringUtils::jsEscape(message.toUTF8()) << "'"
"},{"
"type: '" << msgTypeToString(type) << "',"
"placement: {from: 'bottom', align: 'right'},"
"timer: 250,"
"offset: {x: 20, y: 80},"
"delay: " << duration.count() << ""
"});";
LmsApp->doJavaScript(oss.str());
LMS_LOG(UI, INFO) << "Notifying message '" << message.toUTF8() << "' for category '" << category.toUTF8() << "'";
_notificationContainer->add(type, category, message, duration);
}
} // namespace UserInterface
+7 -17
View File
@@ -27,6 +27,7 @@
#include "services/database/UserId.hpp"
#include "services/database/Types.hpp"
#include "services/scanner/ScannerEvents.hpp"
#include "Notification.hpp"
namespace Database
{
@@ -37,10 +38,6 @@ namespace Database
class Session;
class User;
}
namespace Wt
{
class WPopupMenu;
}
namespace UserInterface {
@@ -49,11 +46,12 @@ class LmsApplicationException;
class MediaPlayer;
class PlayQueue;
class LmsApplicationManager;
class NotificationContainer;
class ModalManager;
class LmsApplication : public Wt::WApplication
{
public:
LmsApplication(const Wt::WEnvironment& env, Database::Db& db, LmsApplicationManager& appManager, std::optional<Database::UserId> userId = std::nullopt);
~LmsApplication();
@@ -78,31 +76,22 @@ class LmsApplication : public Wt::WApplication
void post(std::function<void()> func);
// Used to classify the message sent to the user
enum class MsgType
{
Success,
Info,
Warning,
Danger,
};
void notifyMsg(MsgType type, const Wt::WString& message, std::chrono::milliseconds duration = std::chrono::milliseconds {4000});
void notifyMsg(Notification::Type type, const Wt::WString& category, const Wt::WString& message, std::chrono::milliseconds duration = std::chrono::milliseconds {5000});
static Wt::WLink createArtistLink(Database::ObjectPtr<Database::Artist> artist);
static std::unique_ptr<Wt::WAnchor> createArtistAnchor(Database::ObjectPtr<Database::Artist> artist, bool addText = true);
static Wt::WLink createReleaseLink(Database::ObjectPtr<Database::Release> release);
static std::unique_ptr<Wt::WAnchor> createReleaseAnchor(Database::ObjectPtr<Database::Release> release, bool addText = true);
static std::unique_ptr<Wt::WText> createCluster(Database::ObjectPtr<Database::Cluster> cluster, bool canDelete = false);
Wt::WPopupMenu* createPopupMenu();
MediaPlayer& getMediaPlayer() const { return *_mediaPlayer; }
PlayQueue& getPlayQueue() const { return *_playQueue; }
ModalManager& getModalManager() const { return *_modalManager; }
// Signal emitted just before the session ends (user may already be logged out)
Wt::Signal<>& preQuit() { return _preQuit; }
private:
void init();
void setTheme();
void processPasswordAuth();
void handleException(LmsApplicationException& e);
void goHomeAndQuit();
@@ -129,7 +118,8 @@ class LmsApplication : public Wt::WApplication
std::shared_ptr<CoverResource> _coverResource;
MediaPlayer* _mediaPlayer {};
PlayQueue* _playQueue {};
std::unique_ptr<Wt::WPopupMenu> _popupMenu;
NotificationContainer* _notificationContainer {};
ModalManager* _modalManager {};
};
+50 -77
View File
@@ -1,89 +1,62 @@
/*
* Copyright (C) 2020 Emeric Poupon
* Copyright (C) 2020 Emweb bv, Herent, Belgium.
*
* This file is part of LMS.
*
* LMS 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.
*
* LMS 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 LMS. If not, see <http://www.gnu.org/licenses/>.
* See the LICENSE file for terms of use.
*/
#include "LmsTheme.hpp"
#include "utils/Logger.hpp"
#include "LmsApplication.hpp"
#include <Wt/WApplication.h>
#include <Wt/WLinkedCssStyleSheet.h>
namespace UserInterface {
LmsTheme::LmsTheme(Database::UITheme theme)
: _theme {theme}
namespace UserInterface
{
setVersion(Wt::BootstrapVersion::v3);
setResponsive(true); // Should be not necessary since applied via head-matter
}
void
LmsTheme::setTheme(Database::UITheme theme)
{
if (theme == _theme)
return;
// Hack, use the application interface directly since changing theme is not allowed
const auto currentStyleSheets {getStyleSheets(_theme)};
for (auto it {std::crbegin(currentStyleSheets)}; it != std::crend(currentStyleSheets); ++it)
LmsApp->removeStyleSheet(*it);
_theme = theme;
for (const auto& styleSheet : getStyleSheets(theme))
LmsApp->useStyleSheet(styleSheet);
}
std::vector<Wt::WLinkedCssStyleSheet>
LmsTheme::styleSheets() const
{
const std::vector<Wt::WLink> styleSheets {getStyleSheets(_theme)};
std::vector<Wt::WLinkedCssStyleSheet> res;
res.reserve(styleSheets.size());
std::transform(std::cbegin(styleSheets), std::cend(styleSheets), std::back_inserter(res), [](const Wt::WLink& styleSheet) { return Wt::WLinkedCssStyleSheet {styleSheet}; });
return res;
}
std::vector<Wt::WLink>
LmsTheme::getStyleSheets(Database::UITheme theme)
{
switch (theme)
void
LmsTheme::init(Wt::WApplication* app) const
{
case Database::UITheme::Dark:
return
{
{"css/fonts.css"},
{"css/bootstrap-darkly.min.css"},
{"resources/themes/bootstrap/3/wt.css"},
{"css/lms.css"},
{"css/lms-darkly.css"},
};
case Database::UITheme::Light:
return
{
{"css/fonts.css"},
{"css/bootstrap-flatly.min.css"},
{"resources/themes/bootstrap/3/wt.css"},
{"css/lms.css"},
{"css/lms-flatly.css"},
};
app->require("js/bootstrap.bundle.min.js");
Wt::WString v = app->metaHeader(Wt::MetaHeaderType::Meta, "viewport");
if (v.empty())
app->addMetaHeader("viewport", "width=device-width, initial-scale=1, user-scalable=no");
}
return {};
}
}
std::string
LmsTheme::name() const
{
return "bootstrap5";
}
std::string
LmsTheme::resourcesUrl() const
{
return "";
}
std::vector<Wt::WLinkedCssStyleSheet>
LmsTheme::styleSheets() const
{
static const std::vector<Wt::WLinkedCssStyleSheet> files
{
{"css/bootstrap.solar.min.css"}, // TODO parametrize this
{"css/lms.css"},
};
return files;
}
void LmsTheme::applyValidationStyle(Wt::WWidget* widget, const Wt::WValidator::Result& validation, Wt::WFlags<Wt::ValidationStyleFlag> styles) const
{
{
const bool validStyle {(validation.state() == Wt::ValidationState::Valid) && styles.test(Wt::ValidationStyleFlag::ValidStyle)};
widget->toggleStyleClass("is-valid", validStyle);
}
{
const bool invalidStyle {(validation.state() != Wt::ValidationState::Valid) && styles.test(Wt::ValidationStyleFlag::InvalidStyle)};
widget->toggleStyleClass("is-invalid", invalidStyle);
}
}
} // namespace UserInterface
+17 -16
View File
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2020 Emeric Poupon
* Copyright (C) 2022 Emeric Poupon
*
* This file is part of LMS.
*
@@ -19,26 +19,27 @@
#pragma once
#include <vector>
#include <Wt/WBootstrapTheme.h>
#include <Wt/WLinkedCssStyleSheet.h>
#include "services/database/Types.hpp"
#include <Wt/WTheme.h>
namespace UserInterface
{
class LmsTheme : public Wt::WBootstrapTheme
class LmsTheme : public Wt::WTheme
{
public:
LmsTheme(Database::UITheme theme);
void setTheme(Database::UITheme theme);
private:
std::vector<Wt::WLinkedCssStyleSheet> styleSheets() const override;
static std::vector<Wt::WLink> getStyleSheets(Database::UITheme theme);
void init(Wt::WApplication *app) const override;
Database::UITheme _theme;
std::string name() const override;
std::string resourcesUrl() const override;
std::vector<Wt::WLinkedCssStyleSheet> styleSheets() const override;
void apply(Wt::WWidget*, Wt::WWidget*, int) const override {};
void apply(Wt::WWidget*, Wt::DomElement&, int) const override {};
std::string disabledClass() const override { return "disabled"; }
std::string activeClass() const override { return "active"; };
std::string utilityCssClass(int) const override { return ""; };
bool canStyleAnchorAsButton() const override { return true; };
void applyValidationStyle(Wt::WWidget* widget,
const Wt::WValidator::Result& validation,
Wt::WFlags<Wt::ValidationStyleFlag> flags) const override;
bool canBorderBoxElement(const Wt::DomElement&) const override { return true; }
};
}
+83
View File
@@ -0,0 +1,83 @@
/*
* Copyright (C) 2022 Emeric Poupon
*
* This file is part of LMS.
*
* LMS 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.
*
* LMS 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 LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "ModalManager.hpp"
#include "utils/Logger.hpp"
namespace UserInterface
{
ModalManager::ModalManager()
: _closed {this, "closed"}
{
_closed.connect([=](const std::string& id)
{
LMS_LOG(UI, DEBUG) << "Received closed for id '" << id << "'";
for (int i {}; i < count(); ++i)
{
Wt::WWidget* widget {this->widget(i)};
LMS_LOG(UI, DEBUG) << "Widget " << i << ", id = '" << widget->id();
if (widget->id() == id)
{
removeWidget(widget);
break;
}
}
});
}
void
ModalManager::show(std::unique_ptr<Wt::WWidget> modalWidget)
{
assert(count() == 0);
LMS_LOG(UI, DEBUG) << "Want to show, id = " << modalWidget->id();
std::ostringstream oss;
oss
<< R"({const modalElement = )" << jsRef() << R"(.getElementsByClassName('modal')[0];)"
<< R"(const modal = bootstrap.Modal.getOrCreateInstance(modalElement);)"
<< R"(modal.show();)"
<< R"(modalElement.addEventListener('hidden.bs.modal', function () {)"
<< _closed.createCall({"'" + modalWidget->id() + "'"})
<< R"(modal.dispose();)"
<< R"(});})";
LMS_LOG(UI, DEBUG) << "Running JS '" << oss.str() << "'";
doJavaScript(oss.str());
addWidget(move(modalWidget));
}
void
ModalManager::dispose(Wt::WWidget* modalWidget)
{
std::ostringstream oss;
oss
<< R"({const modalElementParent = document.getElementById(')" << modalWidget->id() << R"(');)"
<< R"(const modalElement = modalElementParent.getElementsByClassName('modal')[0];)"
<< R"(const modal = bootstrap.Modal.getInstance(modalElement);)"
<< R"(modal.hide();)"
<< R"(})";
LMS_LOG(UI, DEBUG) << "Running JS '" << oss.str() << "'";
doJavaScript(oss.str());
}
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2020 Emeric Poupon
* Copyright (C) 2022 Emeric Poupon
*
* This file is part of LMS.
*
@@ -17,21 +17,25 @@
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "TrackStringUtils.hpp"
#pragma once
#include <iomanip>
#include <sstream>
#include <Wt/WContainerWidget.h>
#include <Wt/WSignal.h>
#include <Wt/WWidget.h>
std::string
durationToString(std::chrono::milliseconds msDuration)
namespace UserInterface
{
const std::chrono::seconds duration {std::chrono::duration_cast<std::chrono::seconds>(msDuration)};
class ModalManager : public Wt::WContainerWidget
{
public:
ModalManager();
std::ostringstream oss;
oss << (duration.count() / 60)
<< ":"
<< std::setfill('0') << std::setw(2) << duration.count() % 60;
// should handle only one modal at a time
// Widget must contain a "modal" element
void show(std::unique_ptr<Wt::WWidget> modalWidget);
void dispose(Wt::WWidget* modalWidget);
return oss.str();
private:
Wt::JSignal<std::string> _closed;
};
}
+11
View File
@@ -0,0 +1,11 @@
#pragma once
namespace UserInterface::Notification
{
enum class Type
{
Info,
Warning,
Danger,
};
}
+86
View File
@@ -0,0 +1,86 @@
/*
* Copyright (C) 2022 Emeric Poupon
*
* This file is part of LMS.
*
* LMS 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.
*
* LMS 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 LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "NotificationContainer.hpp"
#include <sstream>
#include <Wt/WTemplate.h>
#include "utils/Logger.hpp"
#include "LmsApplication.hpp"
namespace UserInterface
{
class NotificationWidget : public Wt::WTemplate
{
public:
NotificationWidget(Notification::Type type, const Wt::WString& category, const Wt::WString& message, std::chrono::milliseconds duration);
Wt::JSignal<> closed {this, "closed"};
};
NotificationWidget::NotificationWidget(Notification::Type type, const Wt::WString& category, const Wt::WString& message, std::chrono::milliseconds duration)
: Wt::WTemplate {Wt::WString::tr("Lms.notifications.template.entry")}
{
switch (type)
{
case Notification::Type::Info:
bindString("bg-color", "bg-primary");
bindString("text-color", "white");
break;
case Notification::Type::Warning:
bindString("bg-color", "bg-warning");
bindString("text-color", "dark");
break;
case Notification::Type::Danger:
bindString("bg-color", "bg-danger");
bindString("text-color", "white");
break;
}
bindString("category", category);
bindString("message", message);
bindInt("duration", duration.count());
std::ostringstream oss;
oss
<< R"({const toastElement = )" << jsRef() << R"(.getElementsByClassName('toast')[0];)"
<< R"(const toast = bootstrap.Toast.getOrCreateInstance(toastElement);)"
<< R"(toast.show();)"
<< R"(toastElement.addEventListener('hidden.bs.toast', function () {)"
<< closed.createCall({})
<< R"(toast.dispose();)"
<< R"(});})";
LMS_LOG(UI, DEBUG) << "Running JS '" << oss.str() << "'";
doJavaScript(oss.str());
}
void
NotificationContainer::add(Notification::Type type, const Wt::WString& category, const Wt::WString& message, std::chrono::milliseconds duration)
{
NotificationWidget* notification {addNew<NotificationWidget>(type, category, message, duration)};
notification->closed.connect([=]
{
removeWidget(notification);
});
}
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2020 Emeric Poupon
* Copyright (C) 2022 Emeric Poupon
*
* This file is part of LMS.
*
@@ -19,16 +19,19 @@
#pragma once
#include <Wt/WWidget.h>
#include <Wt/WSignal.h>
#include <chrono>
#include <Wt/WContainerWidget.h>
#include <Wt/WString.h>
#include "services/database/Types.hpp"
#include "PlayQueueAction.hpp"
#include "Notification.hpp"
namespace UserInterface
{
void displayTrackPopupMenu(Wt::WInteractWidget& target,
Database::TrackId trackId,
PlayQueueActionTrackSignal& tracksAction);
} // namespace UserInterface
class NotificationContainer : public Wt::WContainerWidget
{
public:
void add(Notification::Type type, const Wt::WString& category, const Wt::WString& message, std::chrono::milliseconds duration);
private:
};
} // namespace UserInterface
+62 -95
View File
@@ -19,8 +19,8 @@
#include "PlayQueue.hpp"
#include <Wt/WPopupMenu.h>
#include <Wt/WText.h>
#include <Wt/WCheckBox.h>
#include <Wt/WPushButton.h>
#include "services/database/Cluster.hpp"
#include "services/database/Release.hpp"
@@ -36,34 +36,20 @@
#include "utils/String.hpp"
#include "common/InfiniteScrollingContainer.hpp"
#include "resource/CoverResource.hpp"
#include "resource/DownloadResource.hpp"
#include "LmsApplication.hpp"
#include "MediaPlayer.hpp"
#include "TrackStringUtils.hpp"
#include "Utils.hpp"
namespace UserInterface {
PlayQueue::PlayQueue()
: Wt::WTemplate(Wt::WString::tr("Lms.PlayQueue.template"))
: Template {Wt::WString::tr("Lms.PlayQueue.template")}
{
addFunction("id", &Wt::WTemplate::Functions::id);
addFunction("tr", &Wt::WTemplate::Functions::tr);
{
auto transaction {LmsApp->getDbSession().createSharedTransaction()};
_repeatAll = LmsApp->getUser()->isRepeatAllSet();
_radioMode = LmsApp->getUser()->isRadioSet();
}
auto setToolTip = [](Wt::WWidget& widget, Wt::WString title)
{
widget.setAttributeValue("data-toggle", "tooltip");
widget.setAttributeValue("data-placement", "bottom");
widget.setAttributeValue("title", std::move(title));
};
Wt::WText* clearBtn = bindNew<Wt::WText>("clear-btn", Wt::WString::tr("Lms.PlayQueue.template.clear-btn"), Wt::TextFormat::XHTML);
setToolTip(*clearBtn, Wt::WString::tr("Lms.PlayQueue.clear"));
clearBtn->clicked().connect([=]
{
clearTracks();
@@ -77,7 +63,6 @@ PlayQueue::PlayQueue()
});
Wt::WText* shuffleBtn = bindNew<Wt::WText>("shuffle-btn", Wt::WString::tr("Lms.PlayQueue.template.shuffle-btn"), Wt::TextFormat::XHTML);
setToolTip(*shuffleBtn, Wt::WString::tr("Lms.PlayQueue.shuffle"));
shuffleBtn->clicked().connect([=]
{
{
@@ -95,33 +80,33 @@ PlayQueue::PlayQueue()
addSome();
});
_repeatBtn = bindNew<Wt::WText>("repeat-btn", Wt::WString::tr("Lms.PlayQueue.template.repeat-btn"), Wt::TextFormat::XHTML);
setToolTip(*_repeatBtn, Wt::WString::tr("Lms.PlayQueue.repeat"));
_repeatBtn = bindNew<Wt::WCheckBox>("repeat-btn");
_repeatBtn->clicked().connect([=]
{
_repeatAll = !_repeatAll;
updateRepeatBtn();
auto transaction {LmsApp->getDbSession().createUniqueTransaction()};
if (!LmsApp->getUser()->isDemo())
LmsApp->getUser().modify()->setRepeatAll(_repeatAll);
LmsApp->getUser().modify()->setRepeatAll(isRepeatAllSet());
});
updateRepeatBtn();
{
auto transaction {LmsApp->getDbSession().createSharedTransaction()};
if (LmsApp->getUser()->isRepeatAllSet())
_repeatBtn->setCheckState(Wt::CheckState::Checked);
}
_radioBtn = bindNew<Wt::WText>("radio-btn", Wt::WString::tr("Lms.PlayQueue.template.radio-btn"));
setToolTip(*_radioBtn, Wt::WString::tr("Lms.PlayQueue.radio-mode"));
_radioBtn = bindNew<Wt::WCheckBox>("radio-btn");
_radioBtn->clicked().connect([=]
{
_radioMode = !_radioMode;
updateRadioBtn();
auto transaction {LmsApp->getDbSession().createUniqueTransaction()};
if (!LmsApp->getUser()->isDemo())
LmsApp->getUser().modify()->setRadio(_radioMode);
LmsApp->getUser().modify()->setRadio(isRadioModeSet());
});
updateRadioBtn();
{
auto transaction {LmsApp->getDbSession().createSharedTransaction()};
if (LmsApp->getUser()->isRadioSet())
_radioBtn->setCheckState(Wt::CheckState::Checked);
}
_nbTracks = bindNew<Wt::WText>("nb-tracks");
@@ -180,18 +165,16 @@ PlayQueue::PlayQueue()
addSome();
}
void
PlayQueue::updateRepeatBtn()
bool
PlayQueue::isRepeatAllSet() const
{
_repeatBtn->toggleStyleClass("text-success", _repeatAll);
_repeatBtn->toggleStyleClass("text-muted", !_repeatAll);
return _repeatBtn->checkState() == Wt::CheckState::Checked;
}
void
PlayQueue::updateRadioBtn()
bool
PlayQueue::isRadioModeSet() const
{
_radioBtn->toggleStyleClass("text-success",_radioMode);
_radioBtn->toggleStyleClass("text-muted", !_radioMode);
return _radioBtn->checkState() == Wt::CheckState::Checked;
}
Database::TrackList::pointer
@@ -243,7 +226,7 @@ PlayQueue::loadTrack(std::size_t pos, bool play)
// If out of range, stop playing
if (pos >= tracklist->getCount())
{
if (!_repeatAll || tracklist->getCount() == 0)
if (!isRepeatAllSet() || tracklist->getCount() == 0)
{
stop();
return;
@@ -253,7 +236,7 @@ PlayQueue::loadTrack(std::size_t pos, bool play)
}
// If last and radio mode, fill the next song
if (_radioMode && pos == tracklist->getCount() - 1)
if (isRadioModeSet() && pos == tracklist->getCount() - 1)
addRadioTrack = true;
_trackPos = pos;
@@ -313,9 +296,11 @@ PlayQueue::updateCurrentTrack(bool selected)
if (!_trackPos || *_trackPos >= static_cast<std::size_t>(_entriesContainer->getCount()))
return;
Wt::WTemplate* entry {static_cast<Wt::WTemplate*>(_entriesContainer->getWidget(*_trackPos))};
if (entry)
entry->bindString("is-selected", selected ? "Lms-playqueue-selected" : "");
Template* entry {static_cast<Template*>(_entriesContainer->getWidget(*_trackPos))};
if (!entry)
return;
entry->toggleStyleClass("Lms-entry-playing", selected);
}
std::size_t
@@ -383,12 +368,10 @@ PlayQueue::processTracks(PlayQueueAction action, const std::vector<Database::Tra
}
if (nbAddedTracks > 0)
LmsApp->notifyMsg(LmsApplication::MsgType::Info, Wt::WString::trn("Lms.PlayQueue.nb-tracks-added", nbAddedTracks).arg(nbAddedTracks), std::chrono::milliseconds(2000));
LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr("Lms.PlayQueue.playqueue"), Wt::WString::trn("Lms.PlayQueue.nb-tracks-added", nbAddedTracks).arg(nbAddedTracks), std::chrono::milliseconds(2000));
if (isFull())
LmsApp->notifyMsg(LmsApplication::MsgType::Warning, Wt::WString::tr("Lms.PlayQueue.playqueue-full"), std::chrono::milliseconds(2000));
LmsApp->notifyMsg(Notification::Type::Warning, Wt::WString::tr("Lms.PlayQueue.playqueue"), Wt::WString::tr("Lms.PlayQueue.playqueue-full"), std::chrono::milliseconds(2000));
}
void
@@ -412,50 +395,30 @@ PlayQueue::addEntry(const Database::TrackListEntry::pointer& tracklistEntry)
const auto track {tracklistEntry->getTrack()};
const Database::TrackId trackId {track->getId()};
Wt::WTemplate* entry = _entriesContainer->addNew<Wt::WTemplate>(Wt::WString::tr("Lms.PlayQueue.template.entry"));
Template* entry {_entriesContainer->addNew<Template>(Wt::WString::tr("Lms.PlayQueue.template.entry"))};
entry->addFunction("id", &Wt::WTemplate::Functions::id);
entry->bindString("is-selected", "");
entry->bindString("name", Wt::WString::fromUTF8(track->getName()), Wt::TextFormat::Plain);
const auto artists {track->getArtists({Database::TrackArtistLinkType::Artist})};
const auto release {track->getRelease()};
if (!artists.empty() || release)
entry->setCondition("if-has-artists-or-release", true);
if (!artists.empty())
{
entry->setCondition("if-has-artists", true);
Wt::WContainerWidget* artistContainer {entry->bindNew<Wt::WContainerWidget>("artists")};
for (const auto& artist : artists)
{
Wt::WTemplate* a {artistContainer->addNew<Wt::WTemplate>(Wt::WString::tr("Lms.PlayQueue.template.entry-artist"))};
a->bindWidget("artist", LmsApplication::createArtistAnchor(artist));
}
}
if (release)
{
entry->setCondition("if-has-release", true);
entry->bindWidget("release", LmsApplication::createReleaseAnchor(release));
{
Wt::WAnchor* anchor = entry->bindWidget("cover", LmsApplication::createReleaseAnchor(release, false));
auto cover = std::make_unique<Wt::WImage>();
cover->setImageLink(LmsApp->getCoverResource()->getReleaseUrl(release->getId(), CoverResource::Size::Large));
cover->setStyleClass("Lms-cover");
cover->setAttributeValue("onload", LmsApp->javaScriptClass() + ".onLoadCover(this)");
Wt::WAnchor* anchor {entry->bindWidget("cover", LmsApplication::createReleaseAnchor(release, false))};
auto cover {Utils::createCover(release->getId(), CoverResource::Size::Small)};
cover->addStyleClass("Lms-cover-track Lms-cover-anchor"); // HACK
anchor->setImage(std::move(cover));
}
}
else
{
auto cover = entry->bindNew<Wt::WImage>("cover");
cover->setImageLink(LmsApp->getCoverResource()->getTrackUrl(track->getId(), CoverResource::Size::Large));
cover->setStyleClass("Lms-cover");
cover->setAttributeValue("onload", LmsApp->javaScriptClass() + ".onLoadCover(this)");
auto cover {entry->bindWidget<Wt::WImage>("cover", Utils::createCover(track->getId(), CoverResource::Size::Small))};
cover->addStyleClass("Lms-cover-track");
}
entry->bindString("duration", durationToString(track->getDuration()), Wt::TextFormat::Plain);
entry->bindString("duration", Utils::durationToString(track->getDuration()), Wt::TextFormat::Plain);
Wt::WText* playBtn {entry->bindNew<Wt::WText>("play-btn", Wt::WString::tr("Lms.PlayQueue.template.play-btn"), Wt::TextFormat::XHTML)};
playBtn->clicked().connect([=]
@@ -488,25 +451,29 @@ PlayQueue::addEntry(const Database::TrackListEntry::pointer& tracklistEntry)
updateInfo();
});
Wt::WText* moreBtn {entry->bindNew<Wt::WText>("more-btn", Wt::WString::tr("Lms.PlayQueue.template.more-btn"), Wt::TextFormat::XHTML)};
moreBtn->clicked().connect([=]
entry->bindNew<Wt::WText>("more-btn", Wt::WString::tr("Lms.PlayQueue.template.more-btn"), Wt::TextFormat::XHTML);
auto isStarred {[=] { return Service<Scrobbling::IScrobblingService>::get()->isStarred(LmsApp->getUserId(), trackId); }};
Wt::WPushButton* starBtn {entry->bindNew<Wt::WPushButton>("star", Wt::WString::tr(isStarred() ? "Lms.Explore.unstar" : "Lms.Explore.star"))};
starBtn->clicked().connect([=]
{
Wt::WPopupMenu* popup {LmsApp->createPopupMenu()};
auto transaction {LmsApp->getDbSession().createUniqueTransaction()};
const bool isStarred {Service<Scrobbling::IScrobblingService>::get()->isStarred(LmsApp->getUserId(), trackId)};
popup->addItem(Wt::WString::tr(isStarred ? "Lms.Explore.unstar" : "Lms.Explore.star"))
->triggered().connect(moreBtn, [=]
{
if (isStarred)
Service<Scrobbling::IScrobblingService>::get()->unstar(LmsApp->getUserId(), trackId);
else
Service<Scrobbling::IScrobblingService>::get()->star(LmsApp->getUserId(), trackId);
});
popup->addItem(Wt::WString::tr("Lms.Explore.download"))
->setLink(Wt::WLink {std::make_unique<DownloadTrackResource>(trackId)});
popup->popup(moreBtn);
if (isStarred())
{
Service<Scrobbling::IScrobblingService>::get()->unstar(LmsApp->getUserId(), trackId);
starBtn->setText(Wt::WString::tr("Lms.Explore.star"));
}
else
{
Service<Scrobbling::IScrobblingService>::get()->star(LmsApp->getUserId(), trackId);
starBtn->setText(Wt::WString::tr("Lms.Explore.unstar"));
}
});
entry->bindNew<Wt::WPushButton>("download", Wt::WString::tr("Lms.Explore.download"))
->setLink(Wt::WLink {std::make_unique<DownloadTrackResource>(trackId)});
}
void
+8 -7
View File
@@ -21,6 +21,7 @@
#include <optional>
#include <Wt/WCheckBox.h>
#include <Wt/WContainerWidget.h>
#include <Wt/WTemplate.h>
#include <Wt/WText.h>
@@ -29,6 +30,8 @@
#include "services/database/TrackListId.hpp"
#include "PlayQueueAction.hpp"
#include "common/Template.hpp"
namespace Similarity
{
class Finder;
@@ -45,7 +48,7 @@ namespace UserInterface {
class InfiniteScrollingContainer;
class PlayQueue : public Wt::WTemplate
class PlayQueue : public Template
{
public:
PlayQueue();
@@ -75,8 +78,8 @@ class PlayQueue : public Wt::WTemplate
void enqueueRadioTracks();
void updateInfo();
void updateCurrentTrack(bool selected);
void updateRepeatBtn();
void updateRadioBtn();
bool isRepeatAllSet() const;
bool isRadioModeSet() const;
void loadTrack(std::size_t pos, bool play);
void stop();
@@ -88,14 +91,12 @@ class PlayQueue : public Wt::WTemplate
static inline constexpr std::size_t _nbMaxEntries {1000};
static inline constexpr std::size_t _batchSize {12};
bool _repeatAll {};
bool _radioMode {};
bool _mediaPlayerSettingsLoaded {};
Database::TrackListId _tracklistId {};
InfiniteScrollingContainer* _entriesContainer {};
Wt::WText* _nbTracks {};
Wt::WText* _repeatBtn {};
Wt::WText* _radioBtn {};
Wt::WCheckBox* _repeatBtn {};
Wt::WCheckBox* _radioBtn {};
std::optional<std::size_t> _trackPos; // current track position, if set
};
+10 -32
View File
@@ -21,7 +21,6 @@
#include <Wt/WCheckBox.h>
#include <Wt/WComboBox.h>
#include <Wt/WDoubleValidator.h>
#include <Wt/WDoubleSpinBox.h>
#include <Wt/WFormModel.h>
#include <Wt/WLineEdit.h>
@@ -29,8 +28,9 @@
#include <Wt/WString.h>
#include <Wt/WTemplateFormView.h>
#include "common/PasswordValidator.hpp"
#include "common/DoubleValidator.hpp"
#include "common/MandatoryValidator.hpp"
#include "common/PasswordValidator.hpp"
#include "common/UUIDValidator.hpp"
#include "common/ValueStringModel.hpp"
@@ -42,7 +42,6 @@
#include "utils/Service.hpp"
#include "LmsApplication.hpp"
#include "LmsTheme.hpp"
#include "MediaPlayer.hpp"
namespace UserInterface {
@@ -53,7 +52,6 @@ class SettingsModel : public Wt::WFormModel
{
public:
// Associate each field with a unique string literal.
static inline const Field DarkModeField {"dark-mode"};
static inline const Field TranscodeModeField {"transcode-mode"};
static inline const Field TranscodeFormatField {"transcode-format"};
static inline const Field TranscodeBitrateField {"transcode-bitrate"};
@@ -80,7 +78,6 @@ class SettingsModel : public Wt::WFormModel
{
initializeModels();
addField(DarkModeField);
addField(TranscodeModeField);
addField(TranscodeBitrateField);
addField(TranscodeFormatField);
@@ -111,12 +108,10 @@ class SettingsModel : public Wt::WFormModel
setValidator(TranscodeBitrateField, createMandatoryValidator());
setValidator(TranscodeFormatField, createMandatoryValidator());
setValidator(ReplayGainModeField, createMandatoryValidator());
auto createPreAmpValidator = []
auto createPreAmpValidator {[]
{
auto preampGainValidator {std::make_unique<Wt::WDoubleValidator>()};
preampGainValidator->setRange(MediaPlayer::Settings::ReplayGain::minPreAmpGain, MediaPlayer::Settings::ReplayGain::maxPreAmpGain);
return preampGainValidator;
};
return createDoubleValidator(MediaPlayer::Settings::ReplayGain::minPreAmpGain, MediaPlayer::Settings::ReplayGain::maxPreAmpGain);
}};
setValidator(ReplayGainPreAmpGainField, createPreAmpValidator());
setValidator(ReplayGainPreAmpGainIfNoInfoField, createPreAmpValidator());
@@ -139,14 +134,6 @@ class SettingsModel : public Wt::WFormModel
User::pointer user {LmsApp->getUser()};
{
const UITheme newTheme {Wt::asNumber(value(DarkModeField)) ? UITheme::Dark : UITheme::Light};
LmsTheme* lmsTheme {static_cast<LmsTheme*>(LmsApp->theme().get())};
lmsTheme->setTheme(newTheme);
user.modify()->setUITheme(newTheme);
}
{
MediaPlayer::Settings settings;
@@ -200,7 +187,6 @@ class SettingsModel : public Wt::WFormModel
{
_authPasswordService->setPassword(user->getId(), valueText(PasswordField).toUTF8());
}
}
void loadData()
@@ -209,8 +195,6 @@ class SettingsModel : public Wt::WFormModel
User::pointer user {LmsApp->getUser()};
setValue(DarkModeField, user->getUITheme() == UITheme::Dark);
{
const auto settings {*LmsApp->getMediaPlayer().getSettings()};
@@ -408,12 +392,6 @@ SettingsView::refreshView()
auto model {std::make_shared<SettingsModel>(authPasswordService, !LmsApp->isUserAuthStrong())};
// Appearance
{
auto darkMode {std::make_unique<Wt::WCheckBox>()};
t->setFormWidget(SettingsModel::DarkModeField, std::move(darkMode));
}
if (authPasswordService)
{
t->setCondition("if-has-change-password", true);
@@ -557,14 +535,14 @@ SettingsView::refreshView()
Wt::WPushButton *saveBtn {t->bindWidget("apply-btn", std::make_unique<Wt::WPushButton>(Wt::WString::tr("Lms.apply")))};
Wt::WPushButton *discardBtn {t->bindWidget("discard-btn", std::make_unique<Wt::WPushButton>(Wt::WString::tr("Lms.discard")))};
saveBtn->clicked().connect([=]()
saveBtn->clicked().connect([=]
{
{
auto transaction {LmsApp->getDbSession().createSharedTransaction()};
if (LmsApp->getUser()->isDemo())
{
LmsApp->notifyMsg(LmsApplication::MsgType::Warning, Wt::WString::tr("Lms.Settings.demo-cannot-save"));
LmsApp->notifyMsg(Notification::Type::Warning, Wt::WString::tr("Lms.Settings.settings"), Wt::WString::tr("Lms.Settings.demo-cannot-save"));
return;
}
}
@@ -574,19 +552,19 @@ SettingsView::refreshView()
if (model->validate())
{
model->saveData();
LmsApp->notifyMsg(LmsApplication::MsgType::Success, Wt::WString::tr("Lms.Settings.settings-saved"));
LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr("Lms.Settings.settings"), Wt::WString::tr("Lms.Settings.settings-saved"));
}
// Udate the view: Delete any validation message in the view, etc.
t->updateView(model.get());
});
discardBtn->clicked().connect(std::bind([=] ()
discardBtn->clicked().connect([=]
{
model->loadData();
model->validate();
t->updateView(model.get());
}));
});
t->updateView(model.get());
}
+103
View File
@@ -0,0 +1,103 @@
/*
* Copyright (C) 2020 Emeric Poupon
*
* This file is part of LMS.
*
* LMS 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.
*
* LMS 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 LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "Utils.hpp"
#include <iomanip>
#include <sstream>
#include <Wt/WText.h>
#include "services/database/Cluster.hpp"
#include "services/database/Session.hpp"
#include "LmsApplication.hpp"
namespace UserInterface::Utils
{
std::string
durationToString(std::chrono::milliseconds msDuration)
{
const std::chrono::seconds duration {std::chrono::duration_cast<std::chrono::seconds>(msDuration)};
std::ostringstream oss;
oss << (duration.count() / 60)
<< ":"
<< std::setfill('0') << std::setw(2) << duration.count() % 60;
return oss.str();
}
std::unique_ptr<Wt::WImage>
createCover(Database::ReleaseId releaseId, CoverResource::Size size)
{
auto cover {std::make_unique<Wt::WImage>()};
cover->setImageLink(LmsApp->getCoverResource()->getReleaseUrl(releaseId, size));
cover->setStyleClass("Lms-cover img-fluid"); // HACK
cover->setAttributeValue("onload", LmsApp->javaScriptClass() + ".onLoadCover(this)"); // HACK
return cover;
}
std::unique_ptr<Wt::WImage>
createCover(Database::TrackId trackId, CoverResource::Size size)
{
auto cover {std::make_unique<Wt::WImage>()};
cover->setImageLink(LmsApp->getCoverResource()->getTrackUrl(trackId, size));
cover->setStyleClass("Lms-cover img-fluid"); // HACK
cover->setAttributeValue("onload", LmsApp->javaScriptClass() + ".onLoadCover(this)"); // HACK
return cover;
}
std::unique_ptr<Wt::WInteractWidget>
createCluster(Database::ClusterId clusterId, bool canDelete)
{
auto transaction {LmsApp->getDbSession().createSharedTransaction()};
const Database::Cluster::pointer cluster {Database::Cluster::find(LmsApp->getDbSession(), clusterId)};
if (!cluster)
return {};
auto getStyleClass {[](const Database::Cluster::pointer& cluster) -> const char*
{
switch (cluster->getType()->getId().getValue() % 8)
{
case 0: return "bg-primary";
case 1: return "bg-secondary";
case 2: return "bg-success";
case 3: return "bg-danger";
case 4: return "bg-warning text-dark";
case 5: return "bg-info text-dark";
case 6: return "bg-light text-dark";
case 7: return "bg-dark";
}
return "bg-primary";
}};
const std::string styleClass {getStyleClass(cluster)};
auto res {std::make_unique<Wt::WText>(std::string {} + (canDelete ? "<i class=\"fa fa-times-circle\"></i> " : "") + Wt::WString::fromUTF8(cluster->getName()), Wt::TextFormat::UnsafeXHTML)};
res->setStyleClass("Lms-badge-cluster badge me-1 " + styleClass); // HACK
res->setToolTip(cluster->getType()->getName(), Wt::TextFormat::Plain);
res->setInline(true);
return res;
}
}
+43
View File
@@ -0,0 +1,43 @@
/*
* Copyright (C) 2020 Emeric Poupon
*
* This file is part of LMS.
*
* LMS 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.
*
* LMS 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 LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <chrono>
#include <string>
#include <Wt/WImage.h>
#include <Wt/WInteractWidget.h>
#include "services/database/ClusterId.hpp"
#include "services/database/ReleaseId.hpp"
#include "services/database/TrackId.hpp"
#include "resource/CoverResource.hpp"
namespace UserInterface::Utils
{
std::string
durationToString(std::chrono::milliseconds msDuration);
std::unique_ptr<Wt::WImage> createCover(Database::ReleaseId releaseId, CoverResource::Size size);
std::unique_ptr<Wt::WImage> createCover(Database::TrackId trackId, CoverResource::Size size);
std::unique_ptr<Wt::WInteractWidget> createCluster(Database::ClusterId clusterId, bool canDelete = false);
}
+2 -9
View File
@@ -69,12 +69,11 @@ class DatabaseSettingsModel : public Wt::WFormModel
auto dirValidator {createDirectoryValidator()};
dirValidator->setMandatory(true);
setValidator(MediaDirectoryField, dirValidator);
setValidator(MediaDirectoryField, move(dirValidator));
setValidator(UpdatePeriodField, createMandatoryValidator());
setValidator(UpdateStartTimeField, createMandatoryValidator());
setValidator(RecommendationEngineTypeField, createMandatoryValidator());
setValidator(TagsField, createTagsValidator());
// populate the model with initial data
loadData();
@@ -144,12 +143,6 @@ class DatabaseSettingsModel : public Wt::WFormModel
}
private:
static std::shared_ptr<Wt::WValidator> createTagsValidator()
{
auto v = std::make_shared<Wt::WValidator>();
return v;
}
void initializeModels()
{
_updatePeriodModel = std::make_shared<ValueStringModel<ScanSettings::UpdatePeriod>>();
@@ -241,7 +234,7 @@ DatabaseSettingsView::refreshView()
model->saveData();
Service<Scanner::IScannerService>::get()->requestImmediateScan(false);
LmsApp->notifyMsg(LmsApplication::MsgType::Success, Wt::WString::tr("Lms.Admin.Database.settings-saved"));
LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr("Lms.Admin.Database.database"), Wt::WString::tr("Lms.Admin.Database.settings-saved"));
}
// Udate the view: Delete any validation message in the view, etc.
+7 -2
View File
@@ -41,7 +41,6 @@ namespace UserInterface {
class InitWizardModel : public Wt::WFormModel
{
public:
// Associate each field with a unique string literal.
static inline const Field AdminLoginField {"admin-login"};
static inline const Field PasswordField {"password"};
@@ -126,6 +125,11 @@ InitWizardView::InitWizardView()
setFormWidget(InitWizardModel::PasswordConfirmField, std::move(passwordConfirmEdit));
}
// Result notification
Wt::WText* resultNotification {bindNew<Wt::WText>("info")};
resultNotification->setHidden(true);
Wt::WPushButton* saveButton = bindNew<Wt::WPushButton>("create-btn", Wt::WString::tr("Lms.create"));
saveButton->clicked().connect([=]
{
@@ -134,7 +138,8 @@ InitWizardView::InitWizardView()
if (model->validate())
{
model->saveData();
LmsApp->notifyMsg(LmsApplication::MsgType::Success, Wt::WString::tr("Lms.Admin.InitWizard.done"));
resultNotification->setText(Wt::WString::tr("Lms.Admin.InitWizard.done"));
resultNotification->setHidden(false);
saveButton->setEnabled(false);
}
-1
View File
@@ -27,7 +27,6 @@ class InitWizardView : public Wt::WTemplateFormView
{
public:
InitWizardView();
};
} // namespace UserInterface
+69 -49
View File
@@ -23,10 +23,8 @@
#include <Wt/Http/Response.h>
#include <Wt/WDateTime.h>
#include <Wt/WPopupMenu.h>
#include <Wt/WPushButton.h>
#include <Wt/WResource.h>
#include <Wt/WSplitButton.h>
#include "services/database/Session.hpp"
#include "services/database/Track.hpp"
@@ -57,9 +55,7 @@ durationToString(const Wt::WDateTime& begin, const Wt::WDateTime& end)
class ReportResource : public Wt::WResource
{
public:
ReportResource(const Scanner::ScanStats& stats)
: _stats {stats}
ReportResource()
{
suggestFileName("report.txt");
}
@@ -69,11 +65,22 @@ class ReportResource : public Wt::WResource
beingDeleted();
}
void setScanStats(const Scanner::ScanStats& stats)
{
if (!_stats)
_stats = std::make_unique<Scanner::ScanStats>();
*_stats = stats;
}
void handleRequest(const Wt::Http::Request&, Wt::Http::Response& response)
{
response.out() << Wt::WString::tr("Lms.Admin.ScannerController.errors-header").arg(_stats.errors.size()).toUTF8() << std::endl;
if (!_stats)
return;
for (const auto& error : _stats.errors)
response.out() << Wt::WString::tr("Lms.Admin.ScannerController.errors-header").arg(_stats->errors.size()).toUTF8() << std::endl;
for (const auto& error : _stats->errors)
{
response.out() << error.file.string() << " - " << errorTypeToWString(error.error).toUTF8();
if (!error.systemError.empty())
@@ -83,12 +90,12 @@ class ReportResource : public Wt::WResource
response.out() << std::endl;
response.out() << Wt::WString::tr("Lms.Admin.ScannerController.duplicates-header").arg(_stats.duplicates.size()).toUTF8() << std::endl;
response.out() << Wt::WString::tr("Lms.Admin.ScannerController.duplicates-header").arg(_stats->duplicates.size()).toUTF8() << std::endl;
{
auto transaction {LmsApp->getDbSession().createSharedTransaction()};
for (const auto& duplicate : _stats.duplicates)
for (const auto& duplicate : _stats->duplicates)
{
const auto& track {Database::Track::find(LmsApp->getDbSession(), duplicate.trackId)};
if (!track)
@@ -127,7 +134,7 @@ class ReportResource : public Wt::WResource
return "?";
}
Scanner::ScanStats _stats;
std::unique_ptr<Scanner::ScanStats> _stats;
};
@@ -135,14 +142,48 @@ ScannerController::ScannerController()
: WTemplate {Wt::WString::tr("Lms.Admin.ScannerController.template")}
{
addFunction("tr", &Wt::WTemplate::Functions::tr);
addFunction("id", &Wt::WTemplate::Functions::id);
using namespace Scanner;
auto onDbEvent = [&]() { refreshContents(); };
{
_reportBtn = bindNew<Wt::WPushButton>("report-btn", Wt::WString::tr("Lms.Admin.ScannerController.get-report"));
auto reportResource {std::make_shared<ReportResource>()};
reportResource->setTakesUpdateLock(true);
_reportResource = reportResource.get();
Wt::WLink link {reportResource};
link.setTarget(Wt::LinkTarget::NewWindow);
_reportBtn->setLink(link);
}
Wt::WPushButton* scanBtn {bindNew<Wt::WPushButton>("scan-btn", Wt::WString::tr("Lms.Admin.ScannerController.scan-now"))};
scanBtn->clicked().connect([]
{
Service<Scanner::IScannerService>::get()->requestImmediateScan(false);
});
Wt::WPushButton* fullScanBtn {bindNew<Wt::WPushButton>("full-scan-btn", Wt::WString::tr("Lms.Admin.ScannerController.force-scan-now"))};
fullScanBtn->clicked().connect([]
{
Service<Scanner::IScannerService>::get()->requestImmediateScan(true);
});
_lastScanStatus = bindNew<Wt::WLineEdit>("last-scan");
_lastScanStatus->setReadOnly(true);
_status = bindNew<Wt::WLineEdit>("status");
_status->setReadOnly(true);
_stepStatus = bindNew<Wt::WLineEdit>("step-status");
_stepStatus->setReadOnly(true);
auto onDbEvent {[&]() { refreshContents(); }};
LmsApp->getScannerEvents().scanStarted.connect(this, []
{
LmsApp->notifyMsg(LmsApplication::MsgType::Info, Wt::WString::tr("Lms.Admin.Database.scan-launched"));
LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr("Lms.Admin.Database.database"), Wt::WString::tr("Lms.Admin.Database.scan-launched"));
});
LmsApp->getScannerEvents().scanComplete.connect(this, onDbEvent);
LmsApp->getScannerEvents().scanInProgress.connect(this, onDbEvent);
@@ -156,30 +197,10 @@ ScannerController::refreshContents()
{
using namespace Scanner;
Wt::WPushButton* reportBtn {bindNew<Wt::WPushButton>("btn-report", Wt::WString::tr("Lms.Admin.ScannerController.get-report"))};
Wt::WSplitButton* actionBtn {bindNew<Wt::WSplitButton>("btn-action")};
actionBtn->actionButton()->addStyleClass("btn-primary");
actionBtn->actionButton()->setText(Wt::WString::tr("Lms.Admin.ScannerController.scan-now"));
actionBtn->actionButton()->clicked().connect([]
{
Service<Scanner::IScannerService>::get()->requestImmediateScan(false);
});
auto popup = std::make_unique<Wt::WPopupMenu>();
popup->addItem(Wt::WString::tr("Lms.Admin.ScannerController.force-scan-now"));
popup->itemSelected().connect([]
{
Service<Scanner::IScannerService>::get()->requestImmediateScan(true);
});
actionBtn->dropDownButton()->setMenu(std::move(popup));
actionBtn->dropDownButton()->addStyleClass("btn-primary");
const IScannerService::Status status {Service<IScannerService>::get()->getStatus()};
if (status.lastCompleteScanStats)
{
bindString("last-scan", Wt::WString::tr("Lms.Admin.ScannerController.last-scan-status")
_lastScanStatus->setText(Wt::WString::tr("Lms.Admin.ScannerController.last-scan-status")
.arg(status.lastCompleteScanStats->nbFiles())
.arg(durationToString(status.lastCompleteScanStats->startTime, status.lastCompleteScanStats->stopTime))
.arg(status.lastCompleteScanStats->stopTime.toString())
@@ -187,66 +208,65 @@ ScannerController::refreshContents()
.arg(status.lastCompleteScanStats->duplicates.size())
);
Wt::WLink link {std::make_shared<ReportResource>(*status.lastCompleteScanStats)};
link.setTarget(Wt::LinkTarget::NewWindow);
reportBtn->setLink(link);
_reportResource->setScanStats(*status.lastCompleteScanStats);
_reportBtn->setEnabled(true);
}
else
{
bindString("last-scan", Wt::WString::tr("Lms.Admin.ScannerController.last-scan-not-available"));
reportBtn->setEnabled(false);
_lastScanStatus->setText(Wt::WString::tr("Lms.Admin.ScannerController.last-scan-not-available"));
_reportBtn->setEnabled(false);
}
switch (status.currentState)
{
case IScannerService::State::NotScheduled:
bindString("status", Wt::WString::tr("Lms.Admin.ScannerController.status-not-scheduled"));
bindEmpty("step-status");
_status->setText(Wt::WString::tr("Lms.Admin.ScannerController.status-not-scheduled"));
_stepStatus->setText("");
break;
case IScannerService::State::Scheduled:
bindString("status", Wt::WString::tr("Lms.Admin.ScannerController.status-scheduled")
_status->setText(Wt::WString::tr("Lms.Admin.ScannerController.status-scheduled")
.arg(status.nextScheduledScan.toString()));
bindEmpty("step-status");
_stepStatus->setText("");
break;
case IScannerService::State::InProgress:
bindString("status", Wt::WString::tr("Lms.Admin.ScannerController.status-in-progress")
_status->setText(Wt::WString::tr("Lms.Admin.ScannerController.status-in-progress")
.arg(static_cast<int>(status.currentScanStepStats->currentStep) + 1)
.arg(Scanner::ScanProgressStepCount));
switch (status.currentScanStepStats->currentStep)
{
case Scanner::ScanProgressStep::ChekingForMissingFiles:
bindString("step-status", Wt::WString::tr("Lms.Admin.ScannerController.step-checking-for-missing-files")
_stepStatus->setText(Wt::WString::tr("Lms.Admin.ScannerController.step-checking-for-missing-files")
.arg(status.currentScanStepStats->progress()));
break;
case Scanner::ScanProgressStep::DiscoveringFiles:
bindString("step-status", Wt::WString::tr("Lms.Admin.ScannerController.step-discovering-files")
_stepStatus->setText(Wt::WString::tr("Lms.Admin.ScannerController.step-discovering-files")
.arg(status.currentScanStepStats->processedElems));
break;
case Scanner::ScanProgressStep::ScanningFiles:
bindString("step-status", Wt::WString::tr("Lms.Admin.ScannerController.step-scanning-files")
_stepStatus->setText(Wt::WString::tr("Lms.Admin.ScannerController.step-scanning-files")
.arg(status.currentScanStepStats->processedElems)
.arg(status.currentScanStepStats->totalElems)
.arg(status.currentScanStepStats->progress()));
break;
case Scanner::ScanProgressStep::FetchingTrackFeatures:
bindString("step-status", Wt::WString::tr("Lms.Admin.ScannerController.step-fetching-track-features")
_stepStatus->setText(Wt::WString::tr("Lms.Admin.ScannerController.step-fetching-track-features")
.arg(status.currentScanStepStats->processedElems)
.arg(status.currentScanStepStats->totalElems)
.arg(status.currentScanStepStats->progress()));
break;
case Scanner::ScanProgressStep::ReloadingSimilarityEngine:
bindString("step-status", Wt::WString::tr("Lms.Admin.ScannerController.step-reloading-similarity-engine")
_stepStatus->setText(Wt::WString::tr("Lms.Admin.ScannerController.step-reloading-similarity-engine")
.arg(status.currentScanStepStats->progress()));
break;
}
break;
}
}
} // namespace UserInterface
+8
View File
@@ -19,7 +19,9 @@
#pragma once
#include <Wt/WPushButton.h>
#include <Wt/WTemplate.h>
#include <Wt/WLineEdit.h>
namespace UserInterface
{
@@ -31,6 +33,12 @@ namespace UserInterface
private:
void refreshContents();
Wt::WPushButton* _reportBtn;
Wt::WLineEdit* _lastScanStatus;
Wt::WLineEdit* _status;
Wt::WLineEdit* _stepStatus;
class ReportResource* _reportResource;
};
} // namespace DatabaseStatus
+3 -2
View File
@@ -47,7 +47,6 @@ using namespace Database;
class UserModel : public Wt::WFormModel
{
public:
static inline const Field LoginField {"login"};
static inline const Field PasswordField {"password"};
@@ -252,7 +251,9 @@ UserView::refreshView()
if (model->validate())
{
model->saveData();
LmsApp->notifyMsg(LmsApplication::MsgType::Success, Wt::WString::tr(userId ? "Lms.Admin.User.user-updated" : "Lms.Admin.User.user-created"));
LmsApp->notifyMsg(Notification::Type::Info,
Wt::WString::tr("Lms.Admin.Users.users"),
Wt::WString::tr(userId ? "Lms.Admin.User.user-updated" : "Lms.Admin.User.user-created"));
LmsApp->setInternalPath("/admin/users", true);
}
else
+20 -18
View File
@@ -30,6 +30,7 @@
#include "utils/Service.hpp"
#include "LmsApplication.hpp"
#include "ModalManager.hpp"
namespace UserInterface {
@@ -101,30 +102,31 @@ UsersView::refreshView()
Wt::WPushButton* delBtn = entry->bindNew<Wt::WPushButton>("del-btn", Wt::WString::tr("Lms.Admin.Users.del"));
delBtn->clicked().connect([=]
{
auto msgBox = delBtn->addChild(std::make_unique<Wt::WMessageBox>(Wt::WString::tr("Lms.Admin.Users.del-user"),
Wt::WString::tr("Lms.Admin.Users.del-user-name"),
Wt::Icon::Warning, Wt::StandardButton::Yes | Wt::StandardButton::No));
auto modal {std::make_unique<Wt::WTemplate>(Wt::WString::tr("Lms.Admin.Users.template.delete-user"))};
modal->addFunction("tr", &Wt::WTemplate::Functions::tr);
Wt::WWidget* modalPtr {modal.get()};
msgBox->setModal(true);
msgBox->buttonClicked().connect([=] (Wt::StandardButton btn)
auto* delBtn {modal->bindNew<Wt::WPushButton>("del-btn", Wt::WString::tr("Lms.Admin.Users.del"))};
delBtn->clicked().connect([=]
{
if (btn == Wt::StandardButton::Yes)
{
auto transaction {LmsApp->getDbSession().createUniqueTransaction()};
auto transaction {LmsApp->getDbSession().createUniqueTransaction()};
User::pointer user {User::find(LmsApp->getDbSession(), userId)};
if (user)
user.remove();
User::pointer user {User::find(LmsApp->getDbSession(), userId)};
if (user)
user.remove();
_container->removeWidget(entry);
}
else
{
delBtn->removeChild(msgBox);
}
_container->removeWidget(entry);
LmsApp->getModalManager().dispose(modalPtr);
});
msgBox->show();
auto* cancelBtn {modal->bindNew<Wt::WPushButton>("cancel-btn", Wt::WString::tr("Lms.cancel"))};
cancelBtn->clicked().connect([=]
{
LmsApp->getModalManager().dispose(modalPtr);
});
LmsApp->getModalManager().show(std::move(modal));
});
}
}
+3 -2
View File
@@ -25,8 +25,9 @@ namespace UserInterface
{
class DirectoryValidator : public Wt::WValidator
{
public:
private:
Wt::WValidator::Result validate(const Wt::WString& input) const override;
std::string javaScriptValidate() const override { return {}; }
};
Wt::WValidator::Result
@@ -48,7 +49,7 @@ namespace UserInterface
return Wt::WValidator::Result(Wt::ValidationState::Invalid, Wt::WString::tr("Lms.not-a-directory"));
}
std::shared_ptr<Wt::WValidator>
std::unique_ptr<Wt::WValidator>
createDirectoryValidator()
{
return std::make_unique<DirectoryValidator>();
+1 -1
View File
@@ -23,6 +23,6 @@
namespace UserInterface
{
std::shared_ptr<Wt::WValidator> createDirectoryValidator();
std::unique_ptr<Wt::WValidator> createDirectoryValidator();
} // namespace UserInterface
+41
View File
@@ -0,0 +1,41 @@
/*
* Copyright (C) 2022 Emeric Poupon
*
* This file is part of LMS.
*
* LMS 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.
*
* LMS 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 LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "DoubleValidator.hpp"
#include <Wt/WDoubleValidator.h>
namespace UserInterface
{
class DoubleValidator : public Wt::WDoubleValidator
{
public:
using Wt::WDoubleValidator::WDoubleValidator;
private:
std::string javaScriptValidate() const override { return {}; }
};
std::unique_ptr<Wt::WValidator>
createDoubleValidator(double min, double max)
{
auto validator {std::make_unique<DoubleValidator>(min, max)};
return validator;
}
} // namespace UserInterface
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2020 Emeric Poupon
* Copyright (C) 2022 Emeric Poupon
*
* This file is part of LMS.
*
@@ -19,9 +19,10 @@
#pragma once
#include <chrono>
#include <string>
#include <Wt/WValidator.h>
std::string
durationToString(std::chrono::milliseconds msDuration);
namespace UserInterface
{
std::unique_ptr<Wt::WValidator> createDoubleValidator(double min, double max);
} // namespace UserInterface
+8 -2
View File
@@ -24,10 +24,16 @@
namespace UserInterface
{
std::shared_ptr<Wt::WValidator>
class LengthValidator : public Wt::WLengthValidator
{
private:
std::string javaScriptValidate() const override { return {}; }
};
std::unique_ptr<Wt::WValidator>
createLoginNameValidator()
{
auto v = std::make_unique<Wt::WLengthValidator>();
auto v = std::make_unique<LengthValidator>();
v->setMandatory(true);
v->setMinimumLength(::Database::User::MinNameLength);
v->setMaximumLength(::Database::User::MaxNameLength);
+1 -1
View File
@@ -23,6 +23,6 @@
namespace UserInterface
{
std::shared_ptr<Wt::WValidator> createLoginNameValidator();
std::unique_ptr<Wt::WValidator> createLoginNameValidator();
} // namespace UserInterface
+8 -2
View File
@@ -21,10 +21,16 @@
namespace UserInterface
{
std::shared_ptr<Wt::WValidator>
class MandatoryValidator : public Wt::WValidator
{
private:
std::string javaScriptValidate() const override { return {}; }
};
std::unique_ptr<Wt::WValidator>
createMandatoryValidator()
{
auto v {std::make_shared<Wt::WValidator>()};
auto v {std::make_unique<MandatoryValidator>()};
v->setMandatory(true);
return v;
}

Some files were not shown because too many files have changed in this diff Show More