#include "GUI.hpp" #include "BoxLayout.hpp" #include "StackLayout.hpp" #include "Event.hpp" #include "Theme.hpp" #include #include 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(); 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(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(); 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(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(4).setPadding(Insets::uniform(8)); mScroll.setContent(&mTrackList); mScroll.setSizing(AxisSizing::fill(), AxisSizing::fill()); mBrowser.setSizing(AxisSizing::fill(), AxisSizing::fill()); mBrowser.emplaceLayout(); 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(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); }