24
.github/workflows/ci.yml
vendored
24
.github/workflows/ci.yml
vendored
@ -127,7 +127,7 @@ jobs:
|
||||
name: 'Publish: DockerHub'
|
||||
runs-on: ubuntu-latest
|
||||
needs: [jest, tox]
|
||||
if: github.event_name == 'release'
|
||||
if: github.event_name == 'release' || github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/master'
|
||||
steps:
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
@ -135,8 +135,14 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
- name: Generating tags and labels
|
||||
id: meta
|
||||
- name: Extract branch name
|
||||
shell: bash
|
||||
run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})"
|
||||
id: extract_branch
|
||||
|
||||
- name: Generating release tags and labels
|
||||
if: github.event_name == 'release'
|
||||
id: release_meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: jaedb/iris
|
||||
@ -145,6 +151,14 @@ jobs:
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: Generating edge branch labels
|
||||
if: github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/master'
|
||||
id: edge_meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: jaedb/iris
|
||||
tags: ${{ steps.extract_branch.outputs.branch }}
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
@ -155,5 +169,5 @@ jobs:
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.release_meta.outputs.tags || steps.edge_meta.outputs.tags }}
|
||||
labels: ${{ steps.release_meta.outputs.labels || steps.edge_meta.outputs.tags }}
|
||||
|
||||
70
.github/workflows/codeql-analysis.yml
vendored
70
.github/workflows/codeql-analysis.yml
vendored
@ -1,70 +0,0 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ master ]
|
||||
schedule:
|
||||
- cron: '29 5 * * 4'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'javascript', 'python' ]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||
# Learn more about CodeQL language support at https://git.io/codeql-language-support
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
||||
165
Dockerfile
Executable file → Normal file
165
Dockerfile
Executable file → Normal file
@ -1,80 +1,171 @@
|
||||
FROM debian:buster
|
||||
# --- Build Node ---
|
||||
FROM rust:slim-bullseye AS Builder
|
||||
LABEL org.opencontainers.image.authors="https://github.com/seppi91"
|
||||
ARG TARGETPLATFORM
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
|
||||
# Print Info about current build Target
|
||||
RUN printf "I'm building for TARGETPLATFORM=${TARGETPLATFORM}" \
|
||||
&& printf ", TARGETARCH=${TARGETARCH}" \
|
||||
&& printf ", TARGETVARIANT=${TARGETVARIANT} \n" \
|
||||
&& printf "With uname -s : " && uname -s \
|
||||
&& printf "and uname -m : " && uname -mm
|
||||
|
||||
# Switch to the root user while we do our changes
|
||||
USER root
|
||||
|
||||
# Install all libraries and needs
|
||||
RUN apt update \
|
||||
&& apt install -yq --no-install-recommends \
|
||||
git \
|
||||
patch \
|
||||
libgstreamer-plugins-base1.0-dev \
|
||||
libgstreamer1.0-dev \
|
||||
libcsound64-dev \
|
||||
libclang-11-dev \
|
||||
libpango1.0-dev \
|
||||
libdav1d-dev \
|
||||
# libgtk-4-dev \ Only in bookworm
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /usr/src/gst-plugins-rs
|
||||
|
||||
# Clone source of gst-plugins-rs to workdir
|
||||
ARG GST_PLUGINS_RS_TAG=main
|
||||
RUN git clone -c advice.detachedHead=false \
|
||||
--single-branch --depth 1 \
|
||||
--branch ${GST_PLUGINS_RS_TAG} \
|
||||
https://gitlab.freedesktop.org/gstreamer/gst-plugins-rs.git ./
|
||||
# EXPERIMENTAL: For gstreamer-spotify set upgraded version number of dependency librespot to 0.4.2
|
||||
RUN sed -i 's/librespot = { version = "0.4", default-features = false }/librespot = { version = "0.4.2", default-features = false }/g' audio/spotify/Cargo.toml
|
||||
|
||||
# Build GStreamer plugins written in Rust (optional with --no-default-features)
|
||||
ENV DEST_DIR /target/gst-plugins-rs
|
||||
ENV CARGO_PROFILE_RELEASE_DEBUG false
|
||||
RUN export CSOUND_LIB_DIR="/usr/lib/$(uname -m)-linux-gnu" \
|
||||
&& export PLUGINS_DIR=$(pkg-config --variable=pluginsdir gstreamer-1.0) \
|
||||
&& export SO_SUFFIX=so \
|
||||
&& cargo build --release --no-default-features \
|
||||
# List of packages to build
|
||||
--package gst-plugin-spotify \
|
||||
# Use install command to create directory (-d), copy and print filenames (-v), and set attributes/permissions (-m)
|
||||
&& install -v -d ${DEST_DIR}/${PLUGINS_DIR} \
|
||||
&& install -v -m 755 target/release/*.${SO_SUFFIX} ${DEST_DIR}/${PLUGINS_DIR}
|
||||
|
||||
|
||||
# --- Release Node ---
|
||||
FROM debian:bullseye-slim as Release
|
||||
|
||||
# Switch to the root user while we do our changes
|
||||
USER root
|
||||
WORKDIR /
|
||||
|
||||
# Install GStreamer and other required Debian packages
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
sudo \
|
||||
build-essential \
|
||||
curl \
|
||||
git \
|
||||
wget \
|
||||
gnupg2 \
|
||||
git \
|
||||
python3-setuptools \
|
||||
python3-pip \
|
||||
dumb-init \
|
||||
graphviz-dev \
|
||||
gstreamer1.0-plugins-bad \
|
||||
gstreamer1.0-plugins-good \
|
||||
gstreamer1.0-plugins-ugly \
|
||||
gstreamer1.0-pulseaudio \
|
||||
pulseaudio \
|
||||
libasound2-dev \
|
||||
python3-dev \
|
||||
python3-gst-1.0 \
|
||||
build-essential \
|
||||
libdbus-glib-1-dev \
|
||||
libgirepository1.0-dev \
|
||||
dleyna-server \
|
||||
sudo \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Install Python
|
||||
python3-dev \
|
||||
python3-gst-1.0 \
|
||||
python3-setuptools \
|
||||
python3-pip \
|
||||
# GStreamer (Plugins)
|
||||
gstreamer1.0-plugins-good \
|
||||
gstreamer1.0-plugins-bad \
|
||||
gstreamer1.0-plugins-ugly \
|
||||
gstreamer1.0-libav \
|
||||
gstreamer1.0-pulseaudio \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install libspotify-dev from apt.mopidy.com
|
||||
# Copy builded target data from Builder DEST_DIR to root
|
||||
# Note: target directory tree links directly to $GST_PLUGIN_PATH
|
||||
COPY --from=Builder /target/gst-plugins-rs/ /
|
||||
|
||||
# Install Node, to build Iris JS application
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_14.x | bash - && \
|
||||
apt-get install -y nodejs
|
||||
|
||||
# Install mopidy and (optional) DLNA-server dleyna from apt.mopidy.com
|
||||
# see https://docs.mopidy.com/en/latest/installation/debian/
|
||||
RUN mkdir -p /usr/local/share/keyrings \
|
||||
&& wget -q -O /usr/local/share/keyrings/mopidy-archive-keyring.gpg https://apt.mopidy.com/mopidy.gpg \
|
||||
&& wget -q -O /etc/apt/sources.list.d/mopidy.list https://apt.mopidy.com/buster.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y libspotify-dev mopidy-spotify \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
&& wget -q -O /usr/local/share/keyrings/mopidy-archive-keyring.gpg https://apt.mopidy.com/mopidy.gpg \
|
||||
&& wget -q -O /etc/apt/sources.list.d/mopidy.list https://apt.mopidy.com/buster.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y \
|
||||
mopidy \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Upgrade Python package manager pip
|
||||
# https://pypi.org/project/pip/
|
||||
RUN python3 -m pip install --upgrade pip
|
||||
|
||||
# Clone Iris from the repository and install in development mode.
|
||||
# This allows a binding at "/iris" to map to your local folder for development, rather than
|
||||
# installing using pip.
|
||||
# Note using ADD helps prevent caching issues. When HEAD changes, our cache is invalidated, whee!
|
||||
# Note: ADD helps prevent RUN caching issues. When HEAD changes in repo, our cache will be invalidated!
|
||||
ADD https://api.github.com/repos/jaedb/Iris/git/refs/heads/master version.json
|
||||
RUN git clone --depth 1 -b master https://github.com/jaedb/Iris.git /iris \
|
||||
ENV IRIS_VERSION=develop
|
||||
RUN git clone --depth 1 --single-branch -b ${IRIS_VERSION} https://github.com/jaedb/Iris.git /iris \
|
||||
&& cd /iris \
|
||||
&& npm install \
|
||||
&& npm run prod \
|
||||
&& python3 setup.py develop \
|
||||
&& mkdir -p /var/lib/mopidy/.config \
|
||||
&& ln -s /config /var/lib/mopidy/.config/mopidy \
|
||||
# Allow mopidy user to run system commands (restart, local scan, etc)
|
||||
&& echo "mopidy ALL=NOPASSWD: /iris/mopidy_iris/system.sh" >> /etc/sudoers
|
||||
&& echo "mopidy ALL=NOPASSWD: /iris/mopidy_iris/system.sh" >> /etc/sudoers \
|
||||
# Enable container mode (disable restart option, etc.)
|
||||
&& echo "1" >> /IS_CONTAINER \
|
||||
# Copy Version file
|
||||
&& cp /iris/VERSION /
|
||||
|
||||
# Install additional Python dependencies
|
||||
RUN python3 -m pip install --no-cache \
|
||||
tox \
|
||||
mopidy-mpd \
|
||||
mopidy-local
|
||||
# Install mopidy-spotify-gstspotify (Hack, not released yet!)
|
||||
# (https://github.com/kingosticks/mopidy-spotify/tree/gstspotifysrc-hack)
|
||||
RUN git clone --depth 1 -b gstspotifysrc-hack https://github.com/kingosticks/mopidy-spotify.git mopidy-spotify \
|
||||
&& cd mopidy-spotify \
|
||||
&& python3 setup.py install \
|
||||
&& cd .. \
|
||||
&& rm -rf mopidy-spotify
|
||||
|
||||
# Install additional mopidy extensions and Python dependencies via pip
|
||||
COPY docker/requirements.txt .
|
||||
RUN python3 -m pip install -r requirements.txt
|
||||
|
||||
# Cleanup
|
||||
RUN apt-get clean all \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& rm -rf /root/.cache \
|
||||
&& rm -rf /iris/node_modules
|
||||
|
||||
# Start helper script.
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
|
||||
# Default configuration.
|
||||
# Copy Default configuration for mopidy
|
||||
COPY docker/mopidy/mopidy.example.conf /config/mopidy.conf
|
||||
|
||||
# Copy the pulse-client configuratrion.
|
||||
# Copy the pulse-client configuratrion
|
||||
COPY docker/mopidy/pulse-client.conf /etc/pulse/client.conf
|
||||
|
||||
# Add version info to image
|
||||
COPY VERSION /
|
||||
|
||||
# Allows any user to run mopidy, but runs by default as a randomly generated UID/GID.
|
||||
# RUN useradd -ms /bin/bash mopidy
|
||||
ENV HOME=/var/lib/mopidy
|
||||
RUN set -ex \
|
||||
&& usermod -G audio,sudo mopidy \
|
||||
&& usermod -G audio,sudo,pulse-access mopidy \
|
||||
&& mkdir /var/lib/mopidy/local \
|
||||
&& chown mopidy:audio -R $HOME /entrypoint.sh /iris \
|
||||
&& chmod go+rwx -R $HOME /entrypoint.sh /iris \
|
||||
&& echo "1" >> /IS_CONTAINER
|
||||
&& chmod go+rwx -R $HOME /entrypoint.sh /iris
|
||||
|
||||
# Runs as mopidy user by default.
|
||||
USER mopidy:audio
|
||||
|
||||
@ -15,4 +15,5 @@ recursive-exclude src *
|
||||
recursive-exclude docker *
|
||||
exclude .jshintrc
|
||||
exclude .babelrc
|
||||
exclude jest.config.js
|
||||
exclude jest.config.js
|
||||
exclude .nvmrc
|
||||
@ -23,6 +23,7 @@ services:
|
||||
- 6600:6600
|
||||
- 6680:6680
|
||||
volumes:
|
||||
# - ./mopidy/iris:/iris/mopidy/iris # To use a locally-built UI
|
||||
- ./docker/mopidy/iris:/var/lib/mopidy/iris # Iris-specific storage
|
||||
- ./docker/mopidy/m3u:/var/lib/mopidy/m3u # To persist local playlists
|
||||
- ./docker/mopidy/mopidy.conf:/config/mopidy.conf
|
||||
|
||||
9
docker/requirements.txt
Normal file
9
docker/requirements.txt
Normal file
@ -0,0 +1,9 @@
|
||||
Mopidy-Local
|
||||
Mopidy-Mpd
|
||||
Mopidy-MusicBox-Webclient
|
||||
Mopidy-Soundcloud
|
||||
Mopidy-Youtube
|
||||
Mopidy-YTMusic
|
||||
ytmusicapi
|
||||
youtube_dl
|
||||
tox
|
||||
@ -101,7 +101,7 @@ const GridItem = ({
|
||||
dispatch(spotifyActions.getArtistImages(item));
|
||||
}
|
||||
break;
|
||||
// case 'playlist':
|
||||
case 'playlist':
|
||||
case 'album':
|
||||
dispatch(mopidyActions.getImages([item.uri]));
|
||||
break;
|
||||
@ -122,7 +122,7 @@ const GridItem = ({
|
||||
to = `/${item.type}/${encodeUri(item.uri)}`;
|
||||
if (item.name && item.type !== 'artist') {
|
||||
// Strip out "%"; this causes conflicts with our uri decoder
|
||||
to += `/${encodeURIComponent(item.name.replace('%', ''))}`;
|
||||
to += `/${encodeURIComponent(item.name.replace('%', '').replace('/', ''))}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
18
src/js/components/Sidebar.js
Executable file → Normal file
18
src/js/components/Sidebar.js
Executable file → Normal file
@ -72,7 +72,9 @@ const StatusIcon = () => {
|
||||
|
||||
const Sidebar = () => {
|
||||
const dispatch = useDispatch();
|
||||
const spotify_available = useSelector((state) => state.spotify.access_token);
|
||||
const spotify_enabled = useSelector((state) => state.spotify.enabled);
|
||||
const spotify_has_token = useSelector((state) => state.spotify.access_token);
|
||||
const spotify_available = spotify_enabled && spotify_has_token;
|
||||
|
||||
const close = () => dispatch(toggleSidebar(false));
|
||||
|
||||
@ -109,16 +111,16 @@ const Sidebar = () => {
|
||||
<Icon name="mood" type="material" />
|
||||
<I18n path="sidebar.moods" />
|
||||
</Link>
|
||||
<Link to="/discover/featured-playlists" className="sidebar__menu__item" activeClassName="sidebar__menu__item--active">
|
||||
<Icon name="star" type="material" />
|
||||
<I18n path="sidebar.featured_playlists" />
|
||||
</Link>
|
||||
{spotify_available && (
|
||||
<Link to="/discover/featured-playlists" className="sidebar__menu__item" activeClassName="sidebar__menu__item--active">
|
||||
<Icon name="star" type="material" />
|
||||
<I18n path="sidebar.featured_playlists" />
|
||||
<Link to="/discover/new-releases" className="sidebar__menu__item" activeClassName="sidebar__menu__item--active">
|
||||
<Icon name="new_releases" type="material" />
|
||||
<I18n path="sidebar.new_releases" />
|
||||
</Link>
|
||||
)}
|
||||
<Link to="/discover/new-releases" className="sidebar__menu__item" activeClassName="sidebar__menu__item--active">
|
||||
<Icon name="new_releases" type="material" />
|
||||
<I18n path="sidebar.new_releases" />
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
<section className="sidebar__menu__section">
|
||||
|
||||
@ -371,9 +371,7 @@ export function reorderPlaylistTracks(uri, indexes, insert_before, snapshot_id =
|
||||
insert_before,
|
||||
snapshot_id,
|
||||
};
|
||||
|
||||
case 'm3u':
|
||||
case 'gmusic':
|
||||
default:
|
||||
return {
|
||||
type: 'MOPIDY_REORDER_PLAYLIST_TRACKS',
|
||||
key: uri,
|
||||
@ -381,40 +379,22 @@ export function reorderPlaylistTracks(uri, indexes, insert_before, snapshot_id =
|
||||
range_length: range.length,
|
||||
insert_before,
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
type: 'UNSUPPORTED_ACTION',
|
||||
name: 'reorderPlaylistTracks',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function savePlaylist(uri, name, description = '', is_public = false, is_collaborative = false, image = null) {
|
||||
export function savePlaylist(uri, data) {
|
||||
switch (uriSource(uri)) {
|
||||
case 'spotify':
|
||||
return {
|
||||
type: 'SPOTIFY_SAVE_PLAYLIST',
|
||||
key: uri,
|
||||
name,
|
||||
description: (description == '' ? null : description),
|
||||
image,
|
||||
is_public,
|
||||
is_collaborative,
|
||||
data,
|
||||
};
|
||||
|
||||
case 'm3u':
|
||||
case 'gmusic':
|
||||
default:
|
||||
return {
|
||||
type: 'MOPIDY_SAVE_PLAYLIST',
|
||||
key: uri,
|
||||
name,
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
type: 'UNSUPPORTED_ACTION',
|
||||
name: 'savePlaylist',
|
||||
data,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -432,7 +412,6 @@ export function deletePlaylist(uri) {
|
||||
switch (uriSource(uri)) {
|
||||
case 'spotify':
|
||||
return spotifyActions.following(uri, 'DELETE');
|
||||
|
||||
default:
|
||||
return mopidyActions.deletePlaylist(uri);
|
||||
}
|
||||
@ -446,20 +425,12 @@ export function removeTracksFromPlaylist(uri, tracks_indexes) {
|
||||
key: uri,
|
||||
tracks_indexes,
|
||||
};
|
||||
|
||||
case 'm3u':
|
||||
case 'gmusic':
|
||||
default:
|
||||
return {
|
||||
type: 'MOPIDY_REMOVE_PLAYLIST_TRACKS',
|
||||
key: uri,
|
||||
tracks_indexes,
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
type: 'UNSUPPORTED_ACTION',
|
||||
name: 'removeTracksFromPlaylist',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -471,20 +442,12 @@ export function addTracksToPlaylist(uri, tracks_uris) {
|
||||
key: uri,
|
||||
tracks_uris,
|
||||
};
|
||||
|
||||
case 'm3u':
|
||||
case 'gmusic':
|
||||
default:
|
||||
return {
|
||||
type: 'MOPIDY_ADD_PLAYLIST_TRACKS',
|
||||
key: uri,
|
||||
tracks_uris,
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
type: 'UNSUPPORTED_ACTION',
|
||||
name: 'addTracksToPlaylist',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -275,10 +275,8 @@ const CoreMiddleware = (function () {
|
||||
case 'spotify':
|
||||
store.dispatch(spotifyActions.getPlaylist(key, {}));
|
||||
break;
|
||||
case 'm3u':
|
||||
store.dispatch(mopidyActions.getPlaylist(key, {}));
|
||||
break;
|
||||
default:
|
||||
store.dispatch(mopidyActions.getPlaylist(key, {}));
|
||||
break;
|
||||
}
|
||||
next(action);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import ReactGA from 'react-ga';
|
||||
import Mopidy from 'mopidy';
|
||||
import { sha256 } from 'js-sha256';
|
||||
import { sampleSize, compact, chunk, find } from 'lodash';
|
||||
import { sampleSize, compact, chunk, find, uniq } from 'lodash';
|
||||
import { i18n } from '../../locale';
|
||||
import {
|
||||
generateGuid,
|
||||
@ -860,7 +860,6 @@ const MopidyMiddleware = (function () {
|
||||
break;
|
||||
|
||||
case 'MOPIDY_PLAY_PLAYLIST': {
|
||||
console.debug(action)
|
||||
const playlist = store.getState().core.items[action.uri];
|
||||
const { sortField, sortReverse } = getSortSelector(store.getState(), 'playlist_tracks');
|
||||
if (playlist && playlist.tracks) {
|
||||
@ -1423,7 +1422,7 @@ const MopidyMiddleware = (function () {
|
||||
// requires a Mopidy playlist object (with updates)
|
||||
request(store, 'playlists.lookup', { uri: action.key })
|
||||
.then((response) => {
|
||||
const mopidy_playlist = { ...response, name: action.name };
|
||||
const mopidy_playlist = { ...response, ...action.data };
|
||||
|
||||
request(store, 'playlists.save', { playlist: mopidy_playlist })
|
||||
.then((response) => {
|
||||
@ -1432,9 +1431,9 @@ const MopidyMiddleware = (function () {
|
||||
// Overwrite our playlist with the response to our save
|
||||
// This is essential to get the updated URI from Mopidy
|
||||
const playlist = {
|
||||
...action.data,
|
||||
...store.getState().core.items[action.key],
|
||||
uri: response.uri,
|
||||
name: response.name,
|
||||
...response,
|
||||
};
|
||||
|
||||
// When we rename a playlist, the URI also changes to reflect the name change.
|
||||
@ -2097,7 +2096,7 @@ const MopidyMiddleware = (function () {
|
||||
...item,
|
||||
images: [], // Images is a playlist dependency, so this prevents triggering full load
|
||||
}));
|
||||
const playlists_uris = arrayOf('uri', playlists);
|
||||
const playlists_uris = uniq(arrayOf('uri', playlists));
|
||||
const allUris = [...playlists_uris];
|
||||
store.dispatch(coreActions.itemLoaded({
|
||||
...playlistGroup,
|
||||
@ -2153,135 +2152,61 @@ const MopidyMiddleware = (function () {
|
||||
}
|
||||
case 'MOPIDY_GET_LIBRARY_PLAYLISTS': {
|
||||
store.dispatch(uiActions.startProcess(action.type, { notification: false }));
|
||||
const scheme = action.uri.split(':')[0];
|
||||
|
||||
// Built-in playlist support works differently to other providers
|
||||
if (action.uri === 'm3u:playlists') {
|
||||
request(store, 'playlists.asList')
|
||||
.then((listResponse) => {
|
||||
const libraryPlaylists = [];
|
||||
const playlist_uris = arrayOf('uri', listResponse).filter(
|
||||
(pUri) => (pUri.indexOf('m3u') > -1),
|
||||
);
|
||||
store.dispatch(
|
||||
uiActions.updateProcess(
|
||||
action.type,
|
||||
{
|
||||
total: playlist_uris.length,
|
||||
remaining: playlist_uris.length,
|
||||
},
|
||||
),
|
||||
);
|
||||
request(store, 'playlists.asList').then((browseResponse) => {
|
||||
const allUris = arrayOf('uri', browseResponse).filter((uri) => uri.startsWith(scheme));
|
||||
const unloadedUris = [...allUris];
|
||||
|
||||
if (playlist_uris.length) {
|
||||
playlist_uris.forEach((uri, index) => {
|
||||
request(store, 'playlists.lookup', { uri })
|
||||
.then((response) => {
|
||||
if (response) {
|
||||
libraryPlaylists.push(
|
||||
formatPlaylist({
|
||||
name: response.name,
|
||||
uri: response.uri,
|
||||
can_edit: uriSource(response.uri) === 'm3u',
|
||||
last_modified: response.last_modified,
|
||||
// By not including actual tracks they will be fetched when needed. We don't
|
||||
// want these simple tracks because they don't contain duration, artist, etc.
|
||||
tracks_total: response.tracks ? response.tracks.length : null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
store.dispatch(
|
||||
uiActions.updateProcess(
|
||||
action.type,
|
||||
{
|
||||
total: allUris.length,
|
||||
remaining: unloadedUris.length,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
store.dispatch(
|
||||
uiActions.updateProcess(
|
||||
action.type,
|
||||
{
|
||||
remaining: playlist_uris.length - index - 1,
|
||||
},
|
||||
),
|
||||
);
|
||||
const run = () => {
|
||||
if (unloadedUris.length) {
|
||||
const uri = unloadedUris.splice(0, 1)[0];
|
||||
const processor = store.getState().ui.processes[action.type];
|
||||
|
||||
if (index === playlist_uris.length - 1) {
|
||||
store.dispatch(coreActions.itemsLoaded(libraryPlaylists));
|
||||
store.dispatch(coreActions.libraryLoaded({
|
||||
uri: action.uri,
|
||||
type: 'playlists',
|
||||
items_uris: arrayOf('uri', libraryPlaylists),
|
||||
}));
|
||||
store.dispatch(uiActions.processFinished(action.type));
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
store.dispatch(coreActions.libraryLoaded({
|
||||
uri: action.uri,
|
||||
type: 'playlists',
|
||||
items_uris: [],
|
||||
}));
|
||||
store.dispatch(uiActions.stopLoading('mopidy:library:playlists'));
|
||||
store.dispatch(uiActions.processFinished(action.type));
|
||||
if (processor && processor.status === 'cancelling') {
|
||||
store.dispatch(uiActions.processCancelled(action.type));
|
||||
store.dispatch(uiActions.stopLoading(action.uri));
|
||||
return;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
request(store, 'library.browse', { uri: action.uri })
|
||||
.then((browseResponse) => {
|
||||
const libraryPlaylists = [];
|
||||
store.dispatch(uiActions.updateProcess(action.type, { remaining: unloadedUris.length }));
|
||||
|
||||
store.dispatch(
|
||||
uiActions.updateProcess(
|
||||
action.type,
|
||||
{
|
||||
total: browseResponse.length,
|
||||
remaining: browseResponse.length,
|
||||
},
|
||||
),
|
||||
);
|
||||
request(store, 'playlists.lookup', { uri }).then((lookupResponse) => {
|
||||
if (lookupResponse) {
|
||||
const playlist = formatPlaylist({
|
||||
name: lookupResponse.name,
|
||||
uri: lookupResponse.uri,
|
||||
can_edit: true, // TODO: Confirm whether some are uneditable??
|
||||
last_modified: lookupResponse.last_modified,
|
||||
// Don't include simple tracks; they don't contain duration, artist, etc.
|
||||
tracks_total: lookupResponse?.tracks?.length || null,
|
||||
});
|
||||
|
||||
if (browseResponse.length) {
|
||||
browseResponse.forEach((playlist, index) => {
|
||||
request(store, 'library.lookup', { uris: [playlist.uri] })
|
||||
.then((response) => {
|
||||
if (response) {
|
||||
libraryPlaylists.push(
|
||||
formatPlaylist({
|
||||
name: playlist.name,
|
||||
uri: playlist.uri,
|
||||
can_edit: uriSource(playlist.uri) === 'm3u',
|
||||
last_modified: playlist.last_modified,
|
||||
tracks: formatTracks(response[playlist.uri]),
|
||||
}),
|
||||
);
|
||||
}
|
||||
store.dispatch(coreActions.itemLoaded(playlist));
|
||||
}
|
||||
run();
|
||||
});
|
||||
} else {
|
||||
store.dispatch(uiActions.processFinished(action.type));
|
||||
store.dispatch(coreActions.libraryLoaded({
|
||||
uri: action.uri,
|
||||
type: 'playlists',
|
||||
items_uris: allUris,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
store.dispatch(
|
||||
uiActions.updateProcess(
|
||||
action.type,
|
||||
{
|
||||
remaining: browseResponse.length - index - 1,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (index === browseResponse.length - 1) {
|
||||
store.dispatch(coreActions.itemsLoaded(libraryPlaylists));
|
||||
store.dispatch(coreActions.libraryLoaded({
|
||||
uri: action.uri,
|
||||
type: 'playlists',
|
||||
items_uris: arrayOf('uri', libraryPlaylists),
|
||||
}));
|
||||
store.dispatch(uiActions.processFinished(action.type));
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
store.dispatch(coreActions.libraryLoaded({
|
||||
uri: action.uri,
|
||||
type: 'playlists',
|
||||
items_uris: [],
|
||||
}));
|
||||
store.dispatch(uiActions.stopLoading('mopidy:library:playlists'));
|
||||
store.dispatch(uiActions.processFinished(action.type));
|
||||
}
|
||||
});
|
||||
}
|
||||
run();
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { uniq } from 'lodash';
|
||||
import { arrayOf } from '../../util/arrays';
|
||||
import {
|
||||
generateGuid,
|
||||
@ -506,13 +507,16 @@ export function getMood(uri, { forceRefetch } = {}) {
|
||||
getState,
|
||||
endpoint: plEndpoint,
|
||||
}).then((response) => {
|
||||
playlists = [...playlists, ...formatPlaylists(response.playlists.items)];
|
||||
playlists = [
|
||||
...playlists,
|
||||
...formatPlaylists(response.playlists.items.filter((item) => item)),
|
||||
];
|
||||
if (response.playlists.next) {
|
||||
fetchPlaylists(response.playlists.next);
|
||||
} else {
|
||||
dispatch(coreActions.itemLoaded({
|
||||
...playlistGroup,
|
||||
playlists_uris: arrayOf('uri', playlists),
|
||||
playlists_uris: uniq(arrayOf('uri', playlists)),
|
||||
}));
|
||||
dispatch(coreActions.itemsLoaded(playlists));
|
||||
dispatch(uiActions.stopLoading(loaderId));
|
||||
@ -1237,14 +1241,8 @@ export function createPlaylist(playlist) {
|
||||
};
|
||||
}
|
||||
|
||||
export function savePlaylist(uri, name, description, is_public, is_collaborative, image) {
|
||||
export function savePlaylist(uri, { image, ...data }) {
|
||||
return (dispatch, getState) => {
|
||||
const data = {
|
||||
name,
|
||||
description,
|
||||
public: is_public,
|
||||
collaborative: is_collaborative,
|
||||
};
|
||||
const {
|
||||
spotify: {
|
||||
me: {
|
||||
|
||||
@ -121,6 +121,11 @@ const providers = {
|
||||
uri: 'jellyfin:playlists',
|
||||
title: i18n('services.jellyfin.title'),
|
||||
},
|
||||
{
|
||||
scheme: 'ytmusic:',
|
||||
uri: 'ytmusic:playlists',
|
||||
title: i18n('services.youtube.title'),
|
||||
},
|
||||
],
|
||||
albums: [
|
||||
{
|
||||
|
||||
@ -140,7 +140,7 @@ class Mood extends React.Component {
|
||||
items={moods}
|
||||
details={['playlists']}
|
||||
right_column={['source']}
|
||||
getLink={(item) => `/discover/moods/${encodeUri(item.uri)}/${item.name}`}
|
||||
getLink={(item) => `/discover/moods/${encodeUri(item.uri)}/${encodeURIComponent(item.name.replace('%', '').replace('/', ''))}`}
|
||||
thumbnail
|
||||
/>
|
||||
</section>
|
||||
@ -150,7 +150,7 @@ class Mood extends React.Component {
|
||||
<section className="content-wrapper">
|
||||
<Grid
|
||||
items={moods}
|
||||
getLink={(item) => `/discover/moods/${encodeUri(item.uri)}/${item.name}`}
|
||||
getLink={(item) => `/discover/moods/${encodeUri(item.uri)}/${encodeURIComponent(item.name.replace('%', '').replace('/', ''))}`}
|
||||
tile
|
||||
/>
|
||||
</section>
|
||||
|
||||
@ -154,6 +154,18 @@ const CreatePlaylist = () => {
|
||||
<I18n path="services.mopidy.title" />
|
||||
</span>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="scheme"
|
||||
value="ytmusic"
|
||||
checked={playlist.scheme === 'ytmusic'}
|
||||
onChange={(e) => onChange({ scheme: e.target.value })}
|
||||
/>
|
||||
<span className="label">
|
||||
<I18n path="services.youtube.title" />
|
||||
</span>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
|
||||
@ -1,294 +1,192 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import Modal from './Modal';
|
||||
import * as coreActions from '../../services/core/actions';
|
||||
import * as uiActions from '../../services/ui/actions';
|
||||
import * as mopidyActions from '../../services/mopidy/actions';
|
||||
import * as spotifyActions from '../../services/spotify/actions';
|
||||
import { loadPlaylist, savePlaylist } from '../../services/core/actions';
|
||||
import { closeModal, setWindowTitle } from '../../services/ui/actions';
|
||||
import { uriSource } from '../../util/helpers';
|
||||
import { i18n, I18n } from '../../locale';
|
||||
import Button from '../../components/Button';
|
||||
import { decodeUri } from '../../util/format';
|
||||
import { makeItemSelector, makeLoadingSelector } from '../../util/selectors';
|
||||
import { makeItemSelector } from '../../util/selectors';
|
||||
|
||||
class EditPlaylist extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
loaded: false,
|
||||
error: null,
|
||||
name: '',
|
||||
description: '',
|
||||
image: null,
|
||||
public: false,
|
||||
collaborative: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const {
|
||||
uri,
|
||||
playlist,
|
||||
coreActions: {
|
||||
loadPlaylist,
|
||||
},
|
||||
uiActions: {
|
||||
setWindowTitle,
|
||||
},
|
||||
} = this.props;
|
||||
|
||||
setWindowTitle(i18n('modal.edit_playlist.title'));
|
||||
|
||||
if (playlist) {
|
||||
this.setState({
|
||||
loaded: true,
|
||||
name: playlist.name,
|
||||
description: playlist.description,
|
||||
public: (playlist.public === true),
|
||||
collaborative: (playlist.collaborative === true),
|
||||
});
|
||||
} else {
|
||||
loadPlaylist(uri);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate = ({
|
||||
playlist: prevPlaylist,
|
||||
}) => {
|
||||
const {
|
||||
uri,
|
||||
playlist,
|
||||
mopidyActions: {
|
||||
getPlaylist,
|
||||
},
|
||||
} = this.props;
|
||||
|
||||
if (playlist !== prevPlaylist) {
|
||||
getPlaylist(uri);
|
||||
}
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps({ playlist }, state) {
|
||||
if (playlist && !state.loaded) {
|
||||
return {
|
||||
loaded: true,
|
||||
name: playlist.name,
|
||||
description: playlist.description,
|
||||
public: (playlist.public === true),
|
||||
collaborative: (playlist.collaborative === true),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
setImage = (e) => {
|
||||
const self = this;
|
||||
|
||||
// Create a file-reader to import the selected image as a base64 string
|
||||
const file_reader = new FileReader();
|
||||
|
||||
// Once the image is loaded, convert the result
|
||||
file_reader.addEventListener('load', (e) => {
|
||||
const image_base64 = e.target.result.replace('data:image/jpeg;base64,', '');
|
||||
self.setState({ image: image_base64 });
|
||||
});
|
||||
|
||||
// This calls the filereader to load the file
|
||||
file_reader.readAsDataURL(e.target.files[0]);
|
||||
}
|
||||
|
||||
savePlaylist = (e) => {
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
public: isPublic,
|
||||
collaborative,
|
||||
image,
|
||||
} = this.state;
|
||||
const {
|
||||
uri,
|
||||
coreActions: {
|
||||
savePlaylist,
|
||||
},
|
||||
uiActions: {
|
||||
closeModal,
|
||||
},
|
||||
} = this.props;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
if (!name || name == '') {
|
||||
this.setState({ error: i18n('modal.edit_playlist.name_required') });
|
||||
return false;
|
||||
}
|
||||
savePlaylist(
|
||||
uri,
|
||||
name,
|
||||
description,
|
||||
isPublic,
|
||||
collaborative,
|
||||
image,
|
||||
);
|
||||
closeModal();
|
||||
return false;
|
||||
}
|
||||
|
||||
renderFields = () => {
|
||||
const {
|
||||
uri,
|
||||
} = this.props;
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
public: isPublic,
|
||||
collaborative,
|
||||
image,
|
||||
} = this.state;
|
||||
|
||||
switch (uriSource(uri)) {
|
||||
case 'spotify':
|
||||
return (
|
||||
<div>
|
||||
<div className="field text">
|
||||
<div className="name">
|
||||
<I18n path="modal.edit_playlist.name" />
|
||||
</div>
|
||||
<div className="input">
|
||||
<input
|
||||
type="text"
|
||||
onChange={(e) => this.setState({ name: e.target.value })}
|
||||
value={name}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field text">
|
||||
<div className="name">
|
||||
<I18n path="modal.edit_playlist.description" />
|
||||
</div>
|
||||
<div className="input">
|
||||
<input
|
||||
type="text"
|
||||
onChange={(e) => this.setState({ description: e.target.value })}
|
||||
value={description}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field file">
|
||||
<div className="name">
|
||||
<I18n path="modal.edit_playlist.image.label" />
|
||||
</div>
|
||||
<div className="input">
|
||||
<input
|
||||
type="file"
|
||||
placeholder="Leave empty to keep existing image"
|
||||
onChange={(e) => this.setImage(e)}
|
||||
/>
|
||||
<div className="description">
|
||||
<I18n path="modal.edit_playlist.image.description" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field checkbox white">
|
||||
<div className="name">
|
||||
<I18n path="modal.edit_playlist.options.label" />
|
||||
</div>
|
||||
<div className="input">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="playlist_private"
|
||||
checked={isPublic}
|
||||
onChange={() => this.setState({ public: !isPublic })}
|
||||
/>
|
||||
<span className="label">
|
||||
<I18n path="modal.edit_playlist.options.public" />
|
||||
</span>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="collaborative"
|
||||
checked={collaborative}
|
||||
onChange={() => this.setState({ collaborative: !collaborative })}
|
||||
/>
|
||||
<span className="label">
|
||||
<I18n path="modal.edit_playlist.options.collaborative" />
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div>
|
||||
<div className="field text">
|
||||
<div className="name">
|
||||
<I18n path="modal.edit_playlist.name" />
|
||||
</div>
|
||||
<div className="input">
|
||||
<input
|
||||
type="text"
|
||||
onChange={(e) => this.setState({ name: e.target.value })}
|
||||
value={name}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
render = () => {
|
||||
const {
|
||||
error,
|
||||
} = this.state;
|
||||
|
||||
return (
|
||||
<Modal className="modal--edit-playlist">
|
||||
<h1>
|
||||
<I18n path="modal.edit_playlist.title" />
|
||||
</h1>
|
||||
{error ? <h3 className="red-text">{error}</h3> : null}
|
||||
<form onSubmit={(e) => this.savePlaylist(e)}>
|
||||
|
||||
{this.renderFields()}
|
||||
|
||||
<div className="actions centered-text">
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
tracking={{ category: 'EditPlaylist', action: 'Submit' }}
|
||||
submit
|
||||
>
|
||||
<I18n path="actions.save" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state, ownProps) => {
|
||||
const uri = decodeUri(ownProps.match.params.uri);
|
||||
const itemSelector = makeItemSelector(uri);
|
||||
const loadingSelector = makeLoadingSelector([`(.*)${uri}(.*)`, '^((?!contains).)*$', '^((?!tracks).)*$']);
|
||||
|
||||
return {
|
||||
uri,
|
||||
playlist: itemSelector(state),
|
||||
loading: loadingSelector(state),
|
||||
};
|
||||
const EMPTY_FORM = {
|
||||
loaded: false,
|
||||
error: null,
|
||||
name: '',
|
||||
description: '',
|
||||
image: null,
|
||||
public: false,
|
||||
collaborative: false,
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
coreActions: bindActionCreators(coreActions, dispatch),
|
||||
uiActions: bindActionCreators(uiActions, dispatch),
|
||||
mopidyActions: bindActionCreators(mopidyActions, dispatch),
|
||||
spotifyActions: bindActionCreators(spotifyActions, dispatch),
|
||||
});
|
||||
const Fields = ({
|
||||
uri,
|
||||
form,
|
||||
onChange,
|
||||
onChangeImage,
|
||||
}) => (
|
||||
<div>
|
||||
<div>
|
||||
<div className="field text">
|
||||
<div className="name">
|
||||
<I18n path="modal.edit_playlist.name" />
|
||||
</div>
|
||||
<div className="input">
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
onChange={onChange}
|
||||
value={form.name}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{uriSource(uri) === 'spotify' && (
|
||||
<div>
|
||||
<div className="field text">
|
||||
<div className="name">
|
||||
<I18n path="modal.edit_playlist.description" />
|
||||
</div>
|
||||
<div className="input">
|
||||
<input
|
||||
type="text"
|
||||
name="description"
|
||||
onChange={onChange}
|
||||
value={form.description}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field file">
|
||||
<div className="name">
|
||||
<I18n path="modal.edit_playlist.image.label" />
|
||||
</div>
|
||||
<div className="input">
|
||||
<input
|
||||
type="file"
|
||||
placeholder="Leave empty to keep existing image"
|
||||
onChange={onChangeImage}
|
||||
/>
|
||||
<div className="description">
|
||||
<I18n path="modal.edit_playlist.image.description" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field checkbox white">
|
||||
<div className="name">
|
||||
<I18n path="modal.edit_playlist.options.label" />
|
||||
</div>
|
||||
<div className="input">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="public"
|
||||
checked={form.isPublic}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<span className="label">
|
||||
<I18n path="modal.edit_playlist.options.public" />
|
||||
</span>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="collaborative"
|
||||
checked={form.collaborative}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<span className="label">
|
||||
<I18n path="modal.edit_playlist.options.collaborative" />
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(EditPlaylist);
|
||||
const EditPlaylist = () => {
|
||||
const { uri: encodedUri } = useParams();
|
||||
const uri = decodeUri(encodedUri);
|
||||
const itemSelector = makeItemSelector(uri);
|
||||
const playlist = useSelector(itemSelector);
|
||||
const dispatch = useDispatch();
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [error, setError] = useState();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(setWindowTitle(i18n('modal.edit_playlist.title')));
|
||||
|
||||
if (uri && !playlist) dispatch(loadPlaylist(uri));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setForm({
|
||||
loaded: true,
|
||||
name: playlist?.name || '',
|
||||
description: playlist?.description || '',
|
||||
public: (playlist?.public === true),
|
||||
collaborative: (playlist?.collaborative === true),
|
||||
});
|
||||
}, [playlist]);
|
||||
|
||||
const onChange = ({ target: { name, type, value, checked }}) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value,
|
||||
}));
|
||||
|
||||
const onChangeImage = (e) => {
|
||||
const file_reader = new FileReader();
|
||||
file_reader.addEventListener('load', (e) => {
|
||||
const image_base64 = e.target.result.replace('form:image/jpeg;base64,', '');
|
||||
setForm({ image: image_base64 });
|
||||
});
|
||||
file_reader.readAsDataURL(e.target.files[0]);
|
||||
}
|
||||
|
||||
const onSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (form.name == '') {
|
||||
setError(i18n('modal.edit_playlist.name_required'));
|
||||
return;
|
||||
}
|
||||
const data = uriSource(uri) === 'spotify'
|
||||
? form
|
||||
: { name: form.name };
|
||||
dispatch(savePlaylist(uri, data));
|
||||
dispatch(closeModal());
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal className="modal--edit-playlist">
|
||||
<h1>
|
||||
<I18n path="modal.edit_playlist.title" />
|
||||
</h1>
|
||||
{error && <h3 className="red-text">{error}</h3>}
|
||||
<form onSubmit={onSubmit}>
|
||||
<Fields
|
||||
uri={uri}
|
||||
form={form}
|
||||
onChange={onChange}
|
||||
onChangeImage={onChangeImage}
|
||||
/>
|
||||
|
||||
<div className="actions centered-text">
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
tracking={{ category: 'EditPlaylist', action: 'Submit' }}
|
||||
submit
|
||||
>
|
||||
<I18n path="actions.save" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditPlaylist;
|
||||
|
||||
@ -40,27 +40,6 @@ const Actions = ({
|
||||
handleContextMenu,
|
||||
}) => {
|
||||
switch (uriSource(uri)) {
|
||||
case 'm3u':
|
||||
return (
|
||||
<div className="actions">
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={onPlay}
|
||||
tracking={{ category: 'Playlist', action: 'Play' }}
|
||||
>
|
||||
<I18n path="actions.play" />
|
||||
</Button>
|
||||
<Button
|
||||
to={`/modal/edit-playlist/${encodedUri}`}
|
||||
tracking={{ category: 'Playlist', action: 'Edit' }}
|
||||
>
|
||||
<I18n path="actions.edit" />
|
||||
</Button>
|
||||
<PinButton item={{ uri, name }} />
|
||||
<ContextMenuTrigger onTrigger={handleContextMenu} />
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'spotify':
|
||||
if (can_edit) {
|
||||
return (
|
||||
@ -111,6 +90,12 @@ const Actions = ({
|
||||
>
|
||||
<I18n path="actions.play" />
|
||||
</Button>
|
||||
<Button
|
||||
to={`/modal/edit-playlist/${encodedUri}`}
|
||||
tracking={{ category: 'Playlist', action: 'Edit' }}
|
||||
>
|
||||
<I18n path="actions.edit" />
|
||||
</Button>
|
||||
<PinButton item={{ uri, name }} />
|
||||
<ContextMenuTrigger onTrigger={handleContextMenu} />
|
||||
</div>
|
||||
|
||||
@ -59,7 +59,7 @@
|
||||
.source {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
left: 10px;
|
||||
padding: 0;
|
||||
font-size: 1.5rem;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user