#include "GUI.hpp" #include "BoxLayout.hpp" #include "Canvas.hpp" #include "StackLayout.hpp" #include "Event.hpp" #include "Glyph.hpp" #include "Theme.hpp" #include "ValueFormat.hpp" #include #include using namespace tp; using namespace tp::ui; namespace { 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; } } 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); for (Label* label : { &state, &title, &artist }) label->setSizing(AxisSizing::fill(), AxisSizing::fit()); // Every row reads the shown track, so refreshing the list after showing a track fills them all. const auto field = [this](std::function read) { return [this, read = std::move(read)]() { return mTrack ? read(*mTrack) : std::string(); }; }; details.addValue("Album", field([](const Track& track) { return track.mAlbum; })); details.addValue("Genre", field([](const Track& track) { return track.mGenre; })); details.addValue("Year", field([](const Track& track) { return track.mYear > 0 ? std::to_string(track.mYear) : std::string("-"); })); details.addValue("Length", field([](const Track& track) { return format::duration((halnf) track.mTotalTime / kMillisecondsPerSecond); })); details.addValue("Plays", field([](const Track& track) { return std::to_string(track.mPlayCount); })); details.addValue("Loved", field([](const Track& track) { return std::string(track.mLoved ? "yes" : "no"); })); details.addValue("File", field([](const Track& track) { return std::string(track.mExists ? "on disk" : "missing"); })); details.addValue("Added", field([](const Track& track) { return track.mDateAdded; })); 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; details.setVisible(track != nullptr); details.refresh(); if (!track) { state.setText(""); title.setText("No track selected"); artist.setText("Select a track to see its details"); return; } state.setText(playing ? "NOW PLAYING" : "SELECTED"); title.setText(track->mName); artist.setText(track->mArtist); } 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.editor().setOnEdited([this](const std::string& text) { mFilter.text = text; applyFilter(); }); lovedOnly.setOnChanged([this](bool loved) { mFilter.lovedOnly = loved; applyFilter(); }); files.selectIndex(0); files.setOnSelectionChanged([this](std::optional index) { if (!index) return; 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); mTracks.setDensity(ListRow::Density::Comfortable); mTracks.setEmptyText("No tracks match the filter"); mTracks.setOnSelectionChanged([this]() { if (mTracks.current()) select(*mTracks.current()); }); mTracks.setOnActivated([this](size_t index) { if (index < mVisible.size()) play(*mVisible[index]); }); mBrowser.setSizing(AxisSizing::fill(), AxisSizing::fill()); mBrowser.emplaceLayout(); mBrowser.addChild(mFilterBar); mBrowser.addChild(mTracks); 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() { mSelected = nullptr; mTracks.clearSelection(); applyFilter(); } void LibraryWidget::applyFilter() { mVisible.clear(); for (const Track& track : mLibrary.mTraks) { if (mFilter.accepts(track)) mVisible.push_back(&track); } ListView::Model model; model.count = mVisible.size(); model.fill = [this](size_t index, ListRow& row) { const Track& track = *mVisible[index]; row.setTitle(track.mName); row.setSubtitle(track.mArtist); // The playing track is marked with the play glyph; the others keep the room so titles line up. row.setLeading(&track == mPlaying ? ListRow::Leading::glyphOf(Glyph::Play) : ListRow::Leading::space()); row.setMuted(!track.mExists); }; mTracks.setModel(std::move(model)); // The selection follows the selected track through filtering. mTracks.clearSelection(); for (size_t index = 0; index < mVisible.size(); index++) { if (mVisible[index] == mSelected) mTracks.select(index); } count.setText(std::to_string(mVisible.size()) + " of " + std::to_string(mLibrary.mTraks.size()) + " tracks"); } void LibraryWidget::select(size_t index) { if (index >= mVisible.size()) return; mSelected = mVisible[index]; if (mTracks.current() != index) mTracks.select(index); 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); mTracks.refresh(); refreshInfo(); } void LibraryWidget::togglePlaying() { if (!mPlaying) { if (mSelected) play(*mSelected); else if (!mVisible.empty()) play(*mVisible.front()); 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] == mPlaying) index = (long) position; } } const long size = (long) mVisible.size(); const long target = index < 0 ? 0 : ((index + direction) % size + size) % size; select((size_t) target); play(*mVisible[(size_t) target]); } void LibraryWidget::onEvent(Event& event) { if (event.type() != Event::Type::KeyDown || !event.isKey(Key::Space)) return; // Space plays or pauses anywhere but in the search field, where it is a space. 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); }