Modules/LibraryViewer/private/GUI.cpp
Шурупов Илья Викторович 8842536836 Show song details in a docked Info panel in the library viewer
The library viewer is laid out in a dock area: the filter bar and
track list in the centre and an Info panel docked on the right, which
can be hidden, unpinned or floated like any dock panel and toggled from
View. It shows a cover art placeholder, whether the track is selected
or now playing, the title and artist, and album, genre, year, length,
plays, loved, file state and date added on separate lines.
2026-10-15 14:53:41 +03:00

348 lines
10 KiB
C++

#include "GUI.hpp"
#include "BoxLayout.hpp"
#include "StackLayout.hpp"
#include "Event.hpp"
#include "Theme.hpp"
#include <algorithm>
#include <cctype>
using namespace tp;
using namespace tp::ui;
namespace {
constexpr halnf kTrackHeight = 70;
constexpr halnf kArtSize = 60;
constexpr halnf kPlayingMarkWidth = 3;
constexpr halnf kSearchWidth = 280;
constexpr halnf kMillisecondsPerSecond = 1000;
constexpr halnf kInfoCoverMaxSize = 240;
constexpr halnf kInfoTitleSize = 18;
constexpr halnf kInfoPanelWidth = 300;
std::string lowercase(std::string text) {
std::transform(text.begin(), text.end(), text.begin(), [](unsigned char c) { return (char) std::tolower(c); });
return text;
}
bool containsIgnoringCase(const std::string& haystack, const std::string& needle) {
return lowercase(haystack).find(needle) != std::string::npos;
}
}
TrackWidget::TrackWidget(const Track& track) :
mTrack(track) {
setDebugName("track: " + track.mName);
setSizing(AxisSizing::fill(), AxisSizing::fixed(kTrackHeight));
}
void TrackWidget::setSelected(bool selected) {
mSelected = selected;
refreshVisualState();
}
void TrackWidget::setPlaying(bool playing) {
if (playing == mPlaying) return;
mPlaying = playing;
requestPaint();
}
Extent TrackWidget::measureContent(Axis axis, halnf) const {
if (axis == Axis::Vertical) return Extent::rigid(kTrackHeight);
return Extent::shrinkable(textWidth(mTrack.mName, theme().metrics.fontSize) + kArtSize + 20, kArtSize + 20);
}
void TrackWidget::paint(Painter& painter) {
const Theme& style = theme();
const halnf margin = (kTrackHeight - kArtSize) / 2;
painter.fillRect(localRect(), background(), style.metrics.cornerRadius);
painter.fillRect(
makeRect(margin, margin, kArtSize, kArtSize), style.palette.controlPressed, style.metrics.cornerRadius
);
if (mPlaying) {
painter.fillRect(makeRect(0, margin, kPlayingMarkWidth, kArtSize), style.palette.text, kPlayingMarkWidth / 2);
}
const halnf textLeft = kArtSize + margin * 3;
const halnf textWidthValue = size().x - textLeft - margin;
painter.text(
mTrack.mName, makeRect(textLeft, margin, textWidthValue, kArtSize / 2), 15, TextAlign::Start, style.palette.text
);
painter.text(
mTrack.mArtist,
makeRect(textLeft, margin + kArtSize / 2, textWidthValue, kArtSize / 2),
12,
TextAlign::Start,
style.palette.mutedText
);
}
RGBA TrackWidget::backgroundTarget() const {
if (mSelected) return theme().palette.controlPressed;
if (isHovered()) return theme().palette.controlHovered;
return { 0, 0, 0, 0 };
}
TrackInfoView::TrackInfoView() {
setDebugName("track info");
setSizing(AxisSizing::fill(), AxisSizing::fill());
emplaceLayout<StackLayout>();
cover.setSizing(AxisSizing::fill().atMost(kInfoCoverMaxSize), AxisSizing::fixed(kInfoCoverMaxSize));
state.setTone(Label::Tone::Muted);
state.setFontSize(theme().metrics.smallFontSize);
title.setFontSize(kInfoTitleSize);
artist.setTone(Label::Tone::Muted);
details.setTone(Label::Tone::Muted);
details.setFontSize(theme().metrics.smallFontSize);
for (Label* label : { &state, &title, &artist, &details })
label->setSizing(AxisSizing::fill(), AxisSizing::fit());
mColumn.setSizing(AxisSizing::fill(), AxisSizing::fit());
mColumn.emplaceLayout<ColumnLayout>(6).setPadding(Insets::uniform(12));
mColumn.addChild(cover);
mColumn.addChild(state);
mColumn.addChild(title);
mColumn.addChild(artist);
mColumn.addChild(details);
mScroll.setContent(&mColumn);
mScroll.setSizing(AxisSizing::fill(), AxisSizing::fill());
addChild(mScroll);
showTrack(nullptr, false);
}
void TrackInfoView::showTrack(const Track* track, bool playing) {
mTrack = track;
if (!track) {
state.setText("");
title.setText("No track selected");
artist.setText("Select a track to see its details");
details.setText("");
return;
}
state.setText(playing ? "NOW PLAYING" : "SELECTED");
title.setText(track->mName);
artist.setText(track->mArtist);
std::string text = "Album: " + track->mAlbum;
text += "\nGenre: " + track->mGenre;
if (track->mYear > 0) text += "\nYear: " + std::to_string(track->mYear);
text += "\nLength: " + formatDuration((halnf) track->mTotalTime / kMillisecondsPerSecond);
text += "\nPlays: " + std::to_string(track->mPlayCount);
text += "\nLoved: " + std::string(track->mLoved ? "yes" : "no");
text += "\nFile: " + std::string(track->mExists ? "on disk" : "missing");
text += "\nAdded: " + track->mDateAdded;
details.setText(text);
}
bool TrackFilter::accepts(const Track& track) const {
if (!text.empty()) {
const std::string needle = lowercase(text);
const bool matches = containsIgnoringCase(track.mName, needle) || containsIgnoringCase(track.mArtist, needle) ||
containsIgnoringCase(track.mAlbum, needle);
if (!matches) return false;
}
if (lovedOnly && !track.mLoved) return false;
if (existence == ExistenceFilter::Existing && !track.mExists) return false;
if (existence == ExistenceFilter::Missing && track.mExists) return false;
return true;
}
LibraryWidget::LibraryWidget(Library& library, PlaybackDevice& device) :
Panel(Surface::Window),
mLibrary(library),
mDevice(device) {
setDebugName("library");
setSizing(AxisSizing::fill(), AxisSizing::fill());
emplaceLayout<ColumnLayout>();
search.setPlaceholder("Search title, artist or album");
search.setSizing(AxisSizing::fixed(kSearchWidth), AxisSizing::fit());
search.setOnEdited([this](const std::string& text) {
mFilter.text = text;
applyFilter();
});
lovedOnly.setOnChanged([this](bool loved) {
mFilter.lovedOnly = loved;
applyFilter();
});
files.setSelectedIndex(0);
files.setOnSelected([this](long index) {
mFilter.existence = (ExistenceFilter) index;
applyFilter();
});
count.setTone(Label::Tone::Muted);
mFilterBar.setSizing(AxisSizing::fill(), AxisSizing::fit());
RowLayout& filterRow = mFilterBar.emplaceLayout<RowLayout>(10);
filterRow.setPadding(Insets::symmetric(12, 8));
filterRow.setCrossAlign(CrossAlign::Center);
mFilterBar.addChild(search);
mFilterBar.addChild(lovedOnly);
mFilterBar.addChild(files);
mFilterBar.addChild(count);
mTrackList.emplaceLayout<ColumnLayout>(4).setPadding(Insets::uniform(8));
mScroll.setContent(&mTrackList);
mScroll.setSizing(AxisSizing::fill(), AxisSizing::fill());
mBrowser.setSizing(AxisSizing::fill(), AxisSizing::fill());
mBrowser.emplaceLayout<ColumnLayout>();
mBrowser.addChild(mFilterBar);
mBrowser.addChild(mScroll);
infoPanel.setContent(&info);
infoPanel.setDockedLength(kInfoPanelWidth);
dock.setSizing(AxisSizing::fill(), AxisSizing::fill());
dock.setCenter(&mBrowser);
dock.addPanel(infoPanel);
dock.openPanel(infoPanel);
player.setCornerRadius(0);
player.onPlayPause = [this]() { togglePlaying(); };
player.onNext = [this]() { playAdjacent(1); };
player.onPrevious = [this]() { playAdjacent(-1); };
player.seek.setOnSeek([this](halnf position) { mDevice.setProgress(position); });
player.volume.setOnChanged([this](halnf volume) {
mVolume = volume;
mDevice.setVolume(volume);
});
addChild(dock);
addChild(player);
startTicking();
}
void LibraryWidget::buildMenus(MenuBar& menuBar) {
fileMenu.addItem("Rescan files", [this]() {
mLibrary.checkExisting();
applyFilter();
});
viewMenu.addToggle("Info panel", [this]() { return infoPanel.isOpen(); }, [this]() { dock.togglePanel(infoPanel); });
viewMenu.addToggle(
"Loved only", [this]() { return mFilter.lovedOnly; }, [this]() { lovedOnly.setChecked(!lovedOnly.isChecked()); }
);
playbackMenu.addItem("Play / Pause", [this]() { togglePlaying(); });
playbackMenu.addItem("Next", [this]() { playAdjacent(1); });
playbackMenu.addItem("Previous", [this]() { playAdjacent(-1); });
menuBar.addMenu("File", fileMenu);
menuBar.addMenu("View", viewMenu);
menuBar.addMenu("Playback", playbackMenu);
}
void LibraryWidget::rebuildTracks() {
mTrackList.removeAllChildren();
mTracks.clear();
mVisible.clear();
mSelected = nullptr;
for (auto track : mLibrary.mTraks) {
auto widget = std::make_unique<TrackWidget>(track.data());
TrackWidget* raw = widget.get();
widget->setOnClicked([this, raw](const Event& click) {
select(*raw);
if (click.clickCount() == 2) play(raw->track());
});
mTracks.push_back(std::move(widget));
}
applyFilter();
}
void LibraryWidget::applyFilter() {
mTrackList.removeAllChildren();
mVisible.clear();
for (auto& track : mTracks) {
if (!mFilter.accepts(track->track())) continue;
mTrackList.addChild(*track);
mVisible.push_back(track.get());
}
count.setText(std::to_string(mVisible.size()) + " of " + std::to_string(mTracks.size()) + " tracks");
}
void LibraryWidget::select(TrackWidget& widget) {
for (auto& track : mTracks)
track->setSelected(track.get() == &widget);
mSelected = &widget.track();
refreshInfo();
}
void LibraryWidget::refreshInfo() {
const Track* shown = mSelected ? mSelected : mPlaying;
info.showTrack(shown, shown && shown == mPlaying && !mPaused);
}
void LibraryWidget::play(const Track& track) {
if (!mDevice.start(track.mId)) {
player.title.setText("Can't play \"" + track.mName + "\"");
return;
}
mPlaying = &track;
mPaused = false;
mDevice.setVolume(mVolume);
mDevice.resume();
mNowPlaying = {
track.mName, track.mArtist, track.mAlbum, (halnf) track.mTotalTime / kMillisecondsPerSecond, track.mLoved
};
player.setTrack(&mNowPlaying);
player.setPlaying(true);
refreshPlayingMarks();
refreshInfo();
}
void LibraryWidget::togglePlaying() {
if (!mPlaying) {
if (mSelected) play(*mSelected);
else if (!mVisible.empty()) play(mVisible.front()->track());
return;
}
mPaused = !mPaused;
if (mPaused) mDevice.pause();
else mDevice.resume();
player.setPlaying(!mPaused);
refreshInfo();
}
void LibraryWidget::playAdjacent(int direction) {
if (mVisible.empty()) return;
long index = -1;
if (mPlaying) {
for (size_t position = 0; position < mVisible.size(); position++) {
if (&mVisible[position]->track() == mPlaying) index = (long) position;
}
}
const long size = (long) mVisible.size();
const long target = index < 0 ? 0 : ((index + direction) % size + size) % size;
select(*mVisible[(size_t) target]);
play(mVisible[(size_t) target]->track());
}
void LibraryWidget::refreshPlayingMarks() {
for (auto& track : mTracks)
track->setPlaying(&track->track() == mPlaying);
}
void LibraryWidget::onEvent(Event& event) {
if (event.type() != Event::Type::KeyDown || event.keyCode() != InputID::SPACE) return;
if (search.hasKeyboardFocus()) return;
togglePlaying();
event.accept();
}
void LibraryWidget::onTick(halnf) {
if (!mPlaying) return;
if (!player.seek.isScrubbing()) player.setPosition(mDevice.progress());
player.setBuffered(mDevice.loaded());
if (!mPaused && mDevice.progress() >= 1.0f) playAdjacent(1);
}