[vlc-commits] [Git][videolan/vlc][master] 6 commits: qt: player: introduce LyricsFlickable.qml
Jean-Baptiste Kempf (@jbk)
gitlab at videolan.org
Sun Jun 14 15:59:03 UTC 2026
Jean-Baptiste Kempf pushed to branch master at VideoLAN / VLC
Commits:
7101382a by Duncan Lawler at 2026-06-14T17:41:36+02:00
qt: player: introduce LyricsFlickable.qml
Add a new QML component for displaying SYLT (synchronized lyrics)
scrolled in sync with the currently playing audio track. Registered
in the qml/player module via both autotools and meson build systems.
No callers yet; following commits wire up the data source and the UI.
- - - - -
f0aa4773 by Duncan Lawler at 2026-06-14T17:41:36+02:00
qt: player_controller: introduce TimedText gadget
A small Q_GADGET that pairs a lyric line with its cue position:
- text: the lyric line, as QString
- time: the cue position, as VLCTime
Both properties are CONSTANT and FINAL so QML can bind to them
without needing notify signals. The type is registered with the QML
engine and as a Qt metatype so it can be used as a QList<TimedText>
property.
No behavior change yet; the next commit wires it up in
PlayerController.
- - - - -
c3830a32 by Duncan Lawler at 2026-06-14T17:41:36+02:00
qt: player_controller: capture and expose SYLT lyrics
Read the optional ID3v2 SYLT (synchronized lyrics) buffer that the
demuxers attach as 'sylt-data' meta extra, cache it on the
controller and decode it lazily on first request into TimedText
entries separated by 0x1E (record separator) and 0x1F (unit
separator).
Two read-only Q_PROPERTYs are added so QML can react to the data:
- hasLyrics: true when sylt-data is present on the current track
- syltLyrics: the decoded list, exposed as a QList<TimedText>
suitable for use as a QML model.
The cached buffer and decoded list are cleared on input change and
on player state transitions away from playing.
- - - - -
4a8c4c10 by Duncan Lawler at 2026-06-14T17:41:36+02:00
qt: player_controller: track current lyric index
Walk the cached SYLT entry list on each updateTime() tick to find
the entry whose timestamp last passed the current playback time,
and emit a change notification when the index moves.
Two read-only Q_PROPERTYs are added so QML can highlight the active
line and display the active text:
- currentLyricIndex: index in syltLyrics, or -1 when nothing has
played yet for the current track
- currentLyric: TimedText at currentLyricIndex,
default-constructed when index < 0 or out of
range.
Both are reset alongside the SYLT buffer on input changes.
- - - - -
33e78c88 by Duncan Lawler at 2026-06-14T17:41:36+02:00
qt: maininterface: add lyricsMode setting and persist albumSections
Introduce a new persistent boolean QML property 'lyricsMode' on
MainCtx (defaulting to true) so the audio player can remember
whether the lyrics pane was shown. Persistence uses the existing
QSettings group used by other UI mode flags.
Also start persisting the existing 'albumSections' property the
same way; it was previously loaded from settings on startup but
never written back.
- - - - -
853c0f19 by Duncan Lawler at 2026-06-14T17:41:36+02:00
qt: player: use LyricsFlickable in Player.qml
Add a lyrics column to the audio player that hosts the new
LyricsFlickable component, bound to PlayerController's syltLyrics,
currentLyricIndex and currentLyric properties.
Visibility follows MainCtx.lyricsMode and PlayerController.hasLyrics
so the column appears only when the user wants it and the current
track actually carries SYLT data.
The Loader's sourceComponent wraps LyricsFlickable in a T.Pane with
wheelEnabled, so wheel events spent scrolling the lyrics do not
bubble up to the player and change the volume.
- - - - -
11 changed files:
- modules/gui/qt/Makefile.am
- modules/gui/qt/maininterface/mainctx.cpp
- modules/gui/qt/maininterface/mainctx.hpp
- modules/gui/qt/maininterface/mainui.cpp
- modules/gui/qt/meson.build
- modules/gui/qt/player/player_controller.cpp
- modules/gui/qt/player/player_controller.hpp
- modules/gui/qt/player/player_controller_p.hpp
- + modules/gui/qt/player/qml/LyricsFlickable.qml
- modules/gui/qt/player/qml/Player.qml
- modules/gui/qt/qt.cpp
Changes:
=====================================
modules/gui/qt/Makefile.am
=====================================
@@ -1122,6 +1122,7 @@ libqml_module_player_a_CPPFLAGS = $(libqt_plugin_la_CPPFLAGS)
libqml_module_player_a_QML = \
player/qml/ControlBar.qml \
player/qml/PlaybackSpeed.qml \
+ player/qml/LyricsFlickable.qml \
player/qml/MiniPlayer.qml \
player/qml/PIPPlayer.qml \
player/qml/Player.qml \
=====================================
modules/gui/qt/maininterface/mainctx.cpp
=====================================
@@ -328,6 +328,11 @@ MainCtx::~MainCtx()
settings->setValue( "grouping", m_grouping );
settings->setValue( "color-scheme-index", m_colorScheme->currentIndex() );
+
+ settings->setValue( "album-sections", m_albumSections );
+
+ settings->setValue( "lyrics-mode", m_lyricsMode );
+
/* Save the stackCentralW sizes */
settings->endGroup();
}
@@ -482,6 +487,8 @@ void MainCtx::loadFromSettingsImpl(const bool callSignals)
loadFromSettings(m_albumSections, "MainWindow/album-sections", true, &MainCtx::albumSectionsChanged);
+ loadFromSettings(m_lyricsMode, "MainWindow/lyrics-mode", true, &MainCtx::lyricsModeChanged);
+
const auto colorSchemeIndex = getSettings()->value( "MainWindow/color-scheme-index", 0 ).toInt();
m_colorScheme->setCurrentIndex(colorSchemeIndex);
@@ -747,6 +754,15 @@ void MainCtx::setAlbumSections(bool enabled)
emit albumSectionsChanged(enabled);
}
+void MainCtx::setLyricsMode(bool enabled)
+{
+ if (m_lyricsMode == enabled)
+ return;
+
+ m_lyricsMode = enabled;
+ emit lyricsModeChanged(enabled);
+}
+
void MainCtx::setInterfaceAlwaysOnTop( bool on_top )
{
if (b_interfaceOnTop == on_top)
=====================================
modules/gui/qt/maininterface/mainctx.hpp
=====================================
@@ -131,6 +131,7 @@ class MainCtx : public QObject
Q_PROPERTY(VideoSurfaceProvider* videoSurfaceProvider READ getVideoSurfaceProvider WRITE setVideoSurfaceProvider NOTIFY hasEmbededVideoChanged FINAL)
Q_PROPERTY(int mouseHideTimeout READ mouseHideTimeout NOTIFY mouseHideTimeoutChanged FINAL)
Q_PROPERTY(bool albumSections READ albumSections WRITE setAlbumSections NOTIFY albumSectionsChanged FINAL)
+ Q_PROPERTY(bool lyricsMode READ lyricsMode WRITE setLyricsMode NOTIFY lyricsModeChanged FINAL)
Q_PROPERTY(CSDButtonModel *csdButtonModel READ csdButtonModel CONSTANT FINAL)
Q_PROPERTY(MainInterfaceModes mainInterfaceModes READ getMainInterfaceModes NOTIFY mainInterfaceModesChanged FINAL)
Q_PROPERTY(MainInterfaceMode effectiveMainInterfaceMode READ getEffectiveMainInterfaceMode NOTIFY mainInterfaceModesChanged FINAL)
@@ -237,6 +238,7 @@ public:
inline bool hasGridView() const { return m_gridView; }
inline Grouping grouping() const { return m_grouping; }
inline bool albumSections() const { return m_albumSections; }
+ inline bool lyricsMode() const { return m_lyricsMode; }
inline ColorSchemeModel* getColorScheme() const { return m_colorScheme; }
bool hasVLM() const;
bool useClientSideDecoration() const;
@@ -478,6 +480,8 @@ protected:
bool m_albumSections = true;
+ bool m_lyricsMode = true;
+
OsType m_osName;
int m_osVersion;
@@ -509,6 +513,7 @@ public slots:
void setGridView( bool );
void setGrouping( Grouping );
void setAlbumSections( bool );
+ void setLyricsMode( bool );
void incrementIntfUserScaleFactor( bool increment);
void setIntfUserScaleFactor( double );
void setHasToolbarMenu( bool );
@@ -561,6 +566,7 @@ signals:
void hasGridListModeChanged( bool );
void groupingChanged( Grouping );
void albumSectionsChanged( bool );
+ void lyricsModeChanged( bool );
void colorSchemeChanged( QString );
void useClientSideDecorationChanged();
void hasToolbarMenuChanged();
=====================================
modules/gui/qt/maininterface/mainui.cpp
=====================================
@@ -297,6 +297,7 @@ void MainUI::registerQMLTypes()
qmlRegisterUncreatableType<ProgramListModel>(uri, versionMajor, versionMinor, "ProgramListModel", "available programs of a media" );
assert(m_intf->p_mainPlayerController);
qmlRegisterSingletonInstance<PlayerController>(uri, versionMajor, versionMinor, "Player", m_intf->p_mainPlayerController);
+ qmlRegisterTypesAndRevisions<TimedText>(uri, versionMajor);
qmlRegisterType<PlayerHighResolutionTimeUpdater>(uri, versionMajor, versionMinor, "HighResolutionTimeUpdater");
qmlRegisterType<QmlBookmarkMenu>( uri, versionMajor, versionMinor, "QmlBookmarkMenu" );
=====================================
modules/gui/qt/meson.build
=====================================
@@ -720,6 +720,7 @@ qml_modules += {
'sources': files(
'player/qml/ControlBar.qml',
'player/qml/PlaybackSpeed.qml',
+ 'player/qml/LyricsFlickable.qml',
'player/qml/MiniPlayer.qml',
'player/qml/PIPPlayer.qml',
'player/qml/Player.qml',
=====================================
modules/gui/qt/player/player_controller.cpp
=====================================
@@ -197,12 +197,64 @@ void PlayerControllerPrivate::UpdateMeta( input_item_t *p_item )
m_artist = vlc_meta_Get(p_item->p_meta, vlc_meta_Artist);
m_album = vlc_meta_Get(p_item->p_meta, vlc_meta_Album);
m_artwork = vlc_meta_Get(p_item->p_meta, vlc_meta_ArtworkURL);
+
+ /* Capture the raw sylt-data buffer; the entry list is built on
+ * first read so we don't pay for it when lyrics are never shown.
+ * Later meta refreshes may not carry sylt-data again, so keep the
+ * cached buffer until the current media changes. */
+ const char *psz_sylt_data = vlc_meta_GetExtra( p_item->p_meta, "sylt-data" );
+ if (psz_sylt_data != NULL)
+ {
+ QByteArray newRaw( psz_sylt_data );
+ const auto hash = qHash(newRaw);
+ if (!m_syltRaw || hash != m_syltRaw->second)
+ {
+ m_syltRaw = qMakePair(std::move( newRaw ), hash);
+ m_syltLyrics.reset();
+ }
+ }
}
}
+ if (m_syltRaw) {
+ emit q->hasLyricsChanged(true);
+ emit q->syltLyricsChanged();
+ }
+
emit q->currentMetaChanged( p_item );
}
+QList<TimedText>
+PlayerControllerPrivate::syltLyrics() const
+{
+ if (!m_syltLyrics.has_value())
+ {
+ QList<TimedText> lyrics;
+
+ if (m_syltRaw)
+ {
+ assert(!m_syltRaw->first.isEmpty());
+ const QString sylt = QString::fromUtf8(m_syltRaw->first);
+ m_syltRaw->first.clear();
+
+ const auto entries = QStringView( sylt ).split( QChar( 0x1E ), Qt::SkipEmptyParts );
+
+ for (const QStringView &entry : entries)
+ {
+ const qsizetype sep = entry.indexOf( QChar( 0x1F ) );
+ if (sep <= 0)
+ continue;
+
+ const vlc_tick_t time =
+ VLC_TICK_FROM_MS( entry.first( sep ).toLongLong() );
+ lyrics.push_back( TimedText( entry.sliced( sep + 1 ).toString(), VLCTime( time ) ) );
+ }
+ m_syltLyrics = std::move( lyrics );
+ }
+ }
+ return *m_syltLyrics;
+}
+
void PlayerControllerPrivate::UpdateInfo( input_item_t *p_item )
{
Q_Q(PlayerController);
@@ -290,6 +342,14 @@ static void on_player_current_media_changed(vlc_player_t *, input_item_t *new_m
vlc_player_locker lock{ that->m_player };
that->m_currentItem.reset(nullptr);
}
+
+ that->m_syltRaw.reset();
+ that->m_syltLyrics.reset();
+ that->m_currentLyricIndex = -1;
+ emit that->q_func()->hasLyricsChanged(false);
+ emit that->q_func()->syltLyricsChanged();
+ emit that->q_func()->currentLyricIndexChanged(-1);
+ emit that->q_func()->currentLyricChanged();
emit that->q_func()->inputChanged(false);
});
return;
@@ -298,6 +358,15 @@ static void on_player_current_media_changed(vlc_player_t *, input_item_t *new_m
SharedInputItem newMediaPtr = SharedInputItem( new_media );
that->callAsync([that,newMediaPtr] () {
PlayerController* q = that->q_func();
+
+ that->m_syltRaw.reset();
+ that->m_syltLyrics.reset();
+ that->m_currentLyricIndex = -1;
+ emit q->hasLyricsChanged(false);
+ emit q->syltLyricsChanged();
+ emit q->currentLyricIndexChanged(-1);
+ emit q->currentLyricChanged();
+
that->UpdateArt( newMediaPtr.get() );
that->UpdateMeta( newMediaPtr.get() );
that->UpdateInfo( newMediaPtr.get() );
@@ -393,6 +462,17 @@ static void on_player_state_changed(vlc_player_t *, enum vlc_player_state state,
emit q->infoChanged( NULL );
emit q->currentMetaChanged( (input_item_t *)NULL );
+ if (that->m_syltRaw || that->m_currentLyricIndex != -1)
+ {
+ that->m_syltRaw.reset();
+ that->m_syltLyrics.reset();
+ that->m_currentLyricIndex = -1;
+ emit q->hasLyricsChanged( false );
+ emit q->syltLyricsChanged();
+ emit q->currentLyricIndexChanged( -1 );
+ emit q->currentLyricChanged();
+ }
+
that->m_hasPrograms =false;
emit q->hasProgramsChanged( false );
@@ -1786,6 +1866,27 @@ void PlayerController::updateTime(vlc_tick_t system_now, bool forceUpdate)
d->m_remainingTime = VLCDuration();
emit remainingTimeChanged(d->m_remainingTime);
+ if (d->m_syltRaw.has_value() && d->m_time.valid())
+ {
+ const auto& lyrics = d->syltLyrics();
+ int currentIndex = -1;
+ const vlc_tick_t currentTime = d->m_time.toVLCTick();
+
+ for (int i = 0; i < lyrics.size(); ++i)
+ {
+ if (lyrics[i].time().toVLCTick() > currentTime)
+ break;
+ currentIndex = i;
+ }
+
+ if (currentIndex != d->m_currentLyricIndex)
+ {
+ d->m_currentLyricIndex = currentIndex;
+ emit currentLyricIndexChanged( currentIndex );
+ emit currentLyricChanged();
+ }
+ }
+
if (system_now != VLC_TICK_INVALID
&& d->m_player_time.system_date != VLC_TICK_MAX
&& (forceUpdate || !d->m_time_timer.isActive()))
@@ -2171,4 +2272,33 @@ PRIMITIVETYPE_GETTER(QString, getAlbum, m_album)
PRIMITIVETYPE_GETTER(QUrl, getArtwork, m_artwork)
PRIMITIVETYPE_GETTER(QUrl, getUrl, m_url)
+TimedText PlayerController::getCurrentLyric() const
+{
+ Q_D(const PlayerController);
+ if (d->m_currentLyricIndex < 0)
+ return {};
+ const auto& lyrics = d->syltLyrics();
+ if (d->m_currentLyricIndex >= lyrics.size())
+ return {};
+ return lyrics[d->m_currentLyricIndex];
+}
+
+bool PlayerController::hasLyrics() const
+{
+ Q_D(const PlayerController);
+ return d->m_syltRaw.has_value();
+}
+
+QList<TimedText> PlayerController::getSyltLyrics() const
+{
+ Q_D(const PlayerController);
+ return d->syltLyrics();
+}
+
+int PlayerController::getCurrentLyricIndex() const
+{
+ Q_D(const PlayerController);
+ return d->m_currentLyricIndex;
+}
+
#undef PRIMITIVETYPE_GETTER
=====================================
modules/gui/qt/player/player_controller.hpp
=====================================
@@ -77,6 +77,29 @@ private:
input_item_t *p_item;
};
+class TimedText
+{
+ Q_GADGET
+ QML_VALUE_TYPE(timedText)
+
+ Q_PROPERTY(QString text READ text CONSTANT FINAL)
+ Q_PROPERTY(VLCTime time READ time CONSTANT FINAL)
+
+public:
+ TimedText() = default;
+ TimedText(QString text, VLCTime time)
+ : m_text(std::move(text))
+ , m_time(time)
+ { }
+
+ QString text() const { return m_text; }
+ VLCTime time() const { return m_time; }
+
+private:
+ QString m_text;
+ VLCTime m_time;
+};
+
class PlayerControllerPrivate;
class PlayerController : public QObject
{
@@ -147,6 +170,10 @@ public:
Q_PROPERTY(int subtitleDelayMS READ getSubtitleDelayMS WRITE setSubtitleDelayMS NOTIFY subtitleDelayChanged FINAL)
Q_PROPERTY(int secondarySubtitleDelayMS READ getSecondarySubtitleDelayMS WRITE setSecondarySubtitleDelayMS NOTIFY secondarySubtitleDelayChanged FINAL)
Q_PROPERTY(float subtitleFPS READ getSubtitleFPS WRITE setSubtitleFPS NOTIFY subtitleFPSChanged FINAL)
+ Q_PROPERTY(TimedText currentLyric READ getCurrentLyric NOTIFY currentLyricChanged FINAL)
+ Q_PROPERTY(bool hasLyrics READ hasLyrics NOTIFY hasLyricsChanged FINAL)
+ Q_PROPERTY(QList<TimedText> syltLyrics READ getSyltLyrics NOTIFY syltLyricsChanged FINAL)
+ Q_PROPERTY(int currentLyricIndex READ getCurrentLyricIndex NOTIFY currentLyricIndexChanged FINAL)
//title/chapters/menu
Q_PROPERTY(TitleListModel* titles READ getTitles CONSTANT FINAL)
@@ -401,6 +428,10 @@ public slots:
QString getArtist() const;
QString getAlbum() const;
QUrl getArtwork() const;
+ TimedText getCurrentLyric() const;
+ bool hasLyrics() const;
+ QList<TimedText> getSyltLyrics() const;
+ int getCurrentLyricIndex() const;
//Renderer
RendererManager* getRendererManager();
@@ -480,6 +511,10 @@ signals:
void statisticsUpdated( const input_stats_t& stats );
void infoChanged( input_item_t* );
void currentMetaChanged( input_item_t* );
+ void currentLyricChanged();
+ void hasLyricsChanged( bool );
+ void syltLyricsChanged();
+ void currentLyricIndexChanged( int );
void metaChanged( input_item_t *);
void artChanged( QString ); /* current item art ( same as item == NULL ) */
void artChanged( input_item_t * );
=====================================
modules/gui/qt/player/player_controller_p.hpp
=====================================
@@ -28,6 +28,10 @@
#include <QTimer>
#include <QUrl>
+#include <QByteArray>
+#include <QPair>
+
+#include <optional>
#ifndef QT_HAS_LIBATOMIC
#warning "libatomic is not available. Read write lock is going to be used instead."
@@ -58,6 +62,11 @@ public:
void UpdateSpuOrder(vlc_es_id_t *es_id, enum vlc_vout_order spu_order);
int interpolateTime(vlc_tick_t system_now);
bool isCurrentItemSynced();
+
+ // Lazily reads m_syltRaw (the raw sylt-data buffer captured during
+ // UpdateMeta) into a list of TimedText entries. The cache is
+ // invalidated when m_syltRaw changes.
+ QList<TimedText> syltLyrics() const;
void onArtFetchEnded(input_item_t *, bool fetched);
// SMPTE Timer
@@ -195,6 +204,10 @@ public:
QString m_album;
QUrl m_artwork;
QUrl m_url;
+
+ mutable std::optional<QPair<QByteArray, size_t>> m_syltRaw;
+ mutable std::optional<QList<TimedText>> m_syltLyrics;
+ int m_currentLyricIndex = -1;
};
#endif /* QVLC_INPUT_MANAGER_P_H_ */
=====================================
modules/gui/qt/player/qml/LyricsFlickable.qml
=====================================
@@ -0,0 +1,183 @@
+/*****************************************************************************
+ * Copyright (C) 2026 VLC authors and VideoLAN
+ *
+ * This program 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 2 of the License, or
+ * ( at your option ) any later version.
+ *
+ * This program 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 this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301, USA.
+ *****************************************************************************/
+import QtQuick
+import QtQuick.Controls
+import VLC.Player
+import VLC.Widgets as Widgets
+import VLC.Style
+
+Flickable {
+ id: lyricsFlickable
+
+ property bool lyricsSyncToPlayback: true
+ property bool animationsEnabled: true
+ property bool fadingEdgeEnabled: true
+
+ readonly property ColorContext colorContext: ColorContext {
+ colorSet: ColorContext.View
+ }
+
+ implicitWidth: contentWidth
+ implicitHeight: contentHeight
+
+ contentWidth: lyricsColumn.width
+ contentHeight: lyricsColumn.height
+
+ interactive: height < implicitHeight
+
+ ScrollBar.vertical: Widgets.ScrollBarExt {}
+
+ boundsBehavior: Flickable.StopAtBounds
+ clip: !fadingEdge.implicitClipping && (height < implicitHeight)
+
+ function snapToCurrentLyric() {
+ const idx = Player.currentLyricIndex
+ if (idx < 0)
+ return
+
+ const item = lyricsRepeater.itemAt(idx)
+ if (!item)
+ return
+
+ const targetY = item.y + item.height / 2 - lyricsFlickable.height / 2
+ lyricsFlickable.contentY = Math.max(0, Math.min(
+ targetY, lyricsFlickable.contentHeight - lyricsFlickable.height))
+ }
+
+ function scheduleSnapToCurrentLyric() {
+ if (lyricsSyncToPlayback)
+ Qt.callLater(lyricsFlickable.snapToCurrentLyric)
+ }
+
+ // Disable auto-scroll when user manually flicks
+ onFlickStarted: lyricsSyncToPlayback = false
+ onDragStarted: lyricsSyncToPlayback = false
+ onLyricsSyncToPlaybackChanged: scheduleSnapToCurrentLyric()
+ onHeightChanged: scheduleSnapToCurrentLyric()
+ onContentHeightChanged: scheduleSnapToCurrentLyric()
+
+ Component.onCompleted: scheduleSnapToCurrentLyric()
+
+ Behavior on contentY {
+ enabled: animationsEnabled && lyricsSyncToPlayback
+ SmoothedAnimation {
+ velocity: VLCStyle.dp(300, VLCStyle.scale)
+ duration: VLCStyle.duration_veryLong
+ }
+ }
+
+ Connections {
+ target: Player
+ enabled: lyricsSyncToPlayback
+ function onCurrentLyricIndexChanged(idx) {
+ lyricsFlickable.snapToCurrentLyric()
+ }
+ }
+
+ Column {
+ id: lyricsColumn
+
+ width: lyricsFlickable.width
+
+ Repeater {
+ id: lyricsRepeater
+
+ model: Player.syltLyrics
+
+ delegate: Text {
+ id: lyricDelegate
+
+ required property int index
+ required property var modelData
+
+ readonly property bool isCurrent: index === Player.currentLyricIndex
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ height: (index === (lyricsRepeater.count - 1))
+ ? Math.max(implicitHeight, lyricsFlickable.height / 2)
+ : implicitHeight
+ padding: VLCStyle.margin_small
+
+ text: modelData.text
+
+ horizontalAlignment: Text.AlignHCenter
+ wrapMode: Text.WordWrap
+
+ // Use a single pixel size and rely on `scale` to differentiate
+ // the current line; this is cheaper than re-rasterizing the
+ // font on every animation frame.
+ font.pixelSize: VLCStyle.fontSize_xlarge
+ font.weight: isCurrent ? Font.Bold : Font.Normal
+
+ color: lyricsFlickable.colorContext.fg.primary
+ opacity: isCurrent ? 1.0 : 0.45
+ scale: isCurrent ? 1.0 : (VLCStyle.fontSize_large / VLCStyle.fontSize_xlarge)
+
+ Behavior on scale {
+ // Native text rendering does not handle scaled glyphs
+ // gracefully, so skip the animation in that case.
+ enabled: lyricsFlickable.animationsEnabled
+ && lyricDelegate.renderType !== Text.NativeRendering
+ NumberAnimation { duration: VLCStyle.duration_long }
+ }
+ Behavior on opacity {
+ enabled: lyricsFlickable.animationsEnabled
+ OpacityAnimator { duration: VLCStyle.duration_long }
+ }
+ }
+ }
+ }
+
+ Widgets.FadingEdge {
+ id: fadingEdge
+
+ parent: lyricsFlickable
+
+ anchors.fill: parent
+
+ backgroundColor: "transparent"
+
+ sourceItem: lyricsFlickable.contentItem
+
+ sourceX: lyricsFlickable.contentX
+ sourceY: lyricsFlickable.contentY
+
+ orientation: Qt.Vertical
+
+ // Scale the fade with the lyrics' default (non-current) line size,
+ // similar to how list views derive it from delegate height.
+ fadeSize: VLCStyle.fontSize_large / 2
+
+ // `fadingEdgeEnabled` is the master toggle; the per-edge disabler
+ // Bindings below override these when the view is scrolled to the
+ // corresponding edge.
+ enableBeginningFade: lyricsFlickable.fadingEdgeEnabled
+ enableEndFade: lyricsFlickable.fadingEdgeEnabled
+
+ Binding on enableBeginningFade {
+ when: lyricsFlickable.atYBeginning
+ value: false
+ }
+
+ Binding on enableEndFade {
+ when: lyricsFlickable.atYEnd
+ value: false
+ }
+ }
+}
\ No newline at end of file
=====================================
modules/gui/qt/player/qml/Player.qml
=====================================
@@ -20,6 +20,7 @@ import QtQuick.Controls
import QtQuick.Layouts
import QtQml.Models
import QtQuick.Window
+import QtQuick.Templates as T
import VLC.MainInterface
import VLC.Style
@@ -292,6 +293,18 @@ FocusScope {
property real topPadding: playerSpecializationLoader.topPadding
property real bottomPadding: playerSpecializationLoader.bottomPadding
+ // Whether the full lyrics view is active
+ readonly property bool lyricsMode: MainCtx.lyricsMode
+ readonly property bool lyricsLayoutActive: lyricsMode
+ && Player.hasLyrics
+ && centerContent.width > VLCStyle.colWidth(6)
+
+ // Reset sync when entering lyrics mode
+ onLyricsModeChanged: {
+ if (lyricsMode && lyricsLoader.item)
+ lyricsLoader.item.lyricsSyncToPlayback = true
+ }
+
// background image
Widgets.DualKawaseBlur {
id: blurredBackground
@@ -391,7 +404,100 @@ FocusScope {
onVlcWheelKey: (key) => MainCtx.sendVLCHotkey(key)
}
+ // ── Two-panel lyrics layout ────────────────────────
+ Item {
+ id: leftSideParent
+
+ visible: (opacity > 0.0) || (width > 0.0)
+
+ opacity: (lyricsLoader.item ? 1.0 : 0.0)
+
+ Behavior on opacity {
+ id: opacityBehavior
+
+ enabled: false
+
+ NumberAnimation {
+ duration: VLCStyle.duration_long
+
+ easing.type: Easing.InOutSine
+ }
+
+ Component.onCompleted: {
+ // The animation should not be used at initialization:
+ Qt.callLater(() => { opacityBehavior.enabled = true })
+ }
+ }
+
+ anchors.top: parent.top
+ anchors.topMargin: VLCStyle.margin_large
+ anchors.bottom: parent.bottom
+ anchors.left: parent.left
+
+ width: lyricsLoader.item ? (parent.width * 0.6) : 0.0
+
+ Behavior on width {
+ id: widthBehavior
+
+ enabled: false
+
+ NumberAnimation {
+ duration: VLCStyle.duration_long
+
+ easing.type: Easing.InOutSine
+ }
+
+ Component.onCompleted: {
+ // The animation should not be used at initialization:
+ Qt.callLater(() => { widthBehavior.enabled = true })
+ }
+ }
+
+ Loader {
+ id: lyricsLoader
+
+ active: audioFocusScope.lyricsLayoutActive
+ sourceComponent: T.Pane {
+ // Swallow wheel events so scrolling the lyrics
+ // does not bubble up to the player and change
+ // the volume.
+ wheelEnabled: true
+
+ // Expose the inner Flickable's sync flag so the
+ // Loader's `item.lyricsSyncToPlayback` reaches
+ // through to LyricsFlickable in both directions.
+ property alias lyricsSyncToPlayback: lyricsFlickable.lyricsSyncToPlayback
+
+ implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset,
+ implicitContentWidth + leftPadding + rightPadding)
+ implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset,
+ implicitContentHeight + topPadding + bottomPadding)
+
+ contentItem: LyricsFlickable { id: lyricsFlickable }
+ }
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+
+ height: Math.min(implicitHeight, parent.height)
+ }
+ }
+
+ Item {
+ id: rightSideParent
+
+ visible: leftSideParent.visible
+
+ anchors.top: parent.top
+ anchors.topMargin: VLCStyle.margin_large
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ anchors.left: leftSideParent.right
+ }
+
ColumnLayout {
+ parent: leftSideParent.visible ? rightSideParent : centerContent
+
anchors.centerIn: parent
spacing: 0
@@ -404,7 +510,6 @@ FocusScope {
readonly property real sizeConstant: 2.7182
-
Image {
id: cover
@@ -527,6 +632,58 @@ FocusScope {
}
}
+ Widgets.SubtitleLabel {
+ id: lyricsLabel
+
+ Layout.alignment: Qt.AlignHCenter
+ Layout.topMargin: VLCStyle.margin_large
+ // Pin width and height so the surrounding centered
+ // column does not shift when the current lyric
+ // changes between empty / 1 line / 2 lines. Text
+ // exceeding two lines wraps then elides on line 2.
+ Layout.preferredWidth: parent.parent.width - VLCStyle.margin_xlarge * 2
+ Layout.preferredHeight: (lyricsLabelTextMetrics.height * 2) + VLCStyle.margin_xxxsmall
+
+ visible: false
+
+ Binding on visible {
+ delayed: true
+ when: lyricsLabel.componentCompleted
+
+ // Stay visible (reserving the 2-line slot) for the
+ // entire track when it has any lyrics, so the
+ // layout does not collapse between lyric lines.
+ value: Player.hasLyrics && !audioFocusScope.lyricsLayoutActive &&
+ (centerContent.height > (lyricsLabel.y + lyricsLabel.height))
+ }
+
+ property bool componentCompleted: false
+
+ Component.onCompleted: {
+ componentCompleted = true
+ }
+
+ elide: Text.ElideRight
+
+ text: Player.currentLyric.text
+ font.pixelSize: VLCStyle.fontSize_xlarge
+ font.weight: Font.DemiBold
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ wrapMode: Text.WordWrap
+ color: centerTheme.fg.primary
+
+ TextMetrics {
+ id: lyricsLabelTextMetrics
+ font: lyricsLabel.font
+ text: "TEXT"
+ }
+
+ Accessible.role: Accessible.StaticText
+ Accessible.name: qsTr("Lyrics")
+ Accessible.description: text
+ }
+
Widgets.NavigableRow {
id: audioControls
@@ -543,7 +700,7 @@ FocusScope {
spacing: VLCStyle.margin_xxsmall
Navigation.parentItem: rootPlayer
Navigation.upItem: topBar
- Navigation.downItem: Player.isInteractive ? toggleControlBarButton : controlBar
+ Navigation.downItem: syncToPlaybackCheckBox
property bool componentCompleted: false
@@ -565,6 +722,17 @@ FocusScope {
description: qsTr("Visualization")
}
+ Widgets.IconToolButton {
+ text: VLCIcons.topbar_music
+ font.pixelSize: VLCStyle.icon_audioPlayerButton
+ checked: MainCtx.lyricsMode
+ onClicked: MainCtx.lyricsMode = !MainCtx.lyricsMode
+ description: qsTr("Lyrics")
+
+ Accessible.role: Accessible.Button
+ Accessible.name: qsTr("Toggle lyrics view")
+ }
+
Widgets.IconToolButton{
text: VLCIcons.skip_for
font.pixelSize: VLCStyle.icon_audioPlayerButton
@@ -572,6 +740,50 @@ FocusScope {
description: qsTr("Step forward")
}
}
+
+ // "Sync to Playback" checkbox pinned to bottom of right panel
+ Widgets.CheckBoxExt {
+ id: syncToPlaybackCheckBox
+
+ Layout.alignment: Qt.AlignHCenter
+ Layout.topMargin: VLCStyle.margin_small
+
+ visible: false
+
+ Binding on visible {
+ delayed: true
+ when: syncToPlaybackCheckBox.componentCompleted
+ value: audioFocusScope.lyricsLayoutActive &&
+ centerContent.height > (syncToPlaybackCheckBox.y + syncToPlaybackCheckBox.height)
+ }
+
+ property bool componentCompleted: false
+
+ Component.onCompleted: {
+ componentCompleted = true
+ }
+
+ text: qsTr("Sync lyrics to playback")
+ // Use a Binding element rather than an inline binding
+ // so manual clicks (which write `checked` directly)
+ // don't permanently break the source-of-truth link.
+ Binding on checked {
+ when: lyricsLoader.item
+ value: lyricsLoader.item ? lyricsLoader.item.lyricsSyncToPlayback : true
+ restoreMode: Binding.RestoreBindingOrValue
+ }
+ onClicked: {
+ if (lyricsLoader.item)
+ lyricsLoader.item.lyricsSyncToPlayback = checked
+ }
+
+ Accessible.role: Accessible.CheckBox
+ Accessible.name: qsTr("Sync lyrics to playback position")
+
+ Navigation.parentItem: rootPlayer
+ Navigation.upItem: audioControls
+ Navigation.downItem: Player.isInteractive ? toggleControlBarButton : controlBar
+ }
}
Widgets.SubtitleLabel {
@@ -609,6 +821,7 @@ FocusScope {
PropertyAction { target: labelVolume; property: "visible"; value: false }
}
}
+
}
}
}
=====================================
modules/gui/qt/qt.cpp
=====================================
@@ -769,6 +769,7 @@ static inline void registerMetaTypes()
qRegisterMetaType<VLCTime>();
qRegisterMetaType<VLCDuration>();
+ qRegisterMetaType<QList<TimedText>>("QList<TimedText>");
qRegisterMetaType<SharedInputItem>();
qRegisterMetaType<NetworkTreeItem>();
qRegisterMetaType<Playlist>();
View it on GitLab: https://code.videolan.org/videolan/vlc/-/compare/c84ef1eda4f14f8b57a5b266b51c843cf51ed28b...853c0f1964cf0acb14a6b1157655e1eb9cbd2fd3
--
View it on GitLab: https://code.videolan.org/videolan/vlc/-/compare/c84ef1eda4f14f8b57a5b266b51c843cf51ed28b...853c0f1964cf0acb14a6b1157655e1eb9cbd2fd3
You're receiving this email because of your account on code.videolan.org. Manage all notifications: https://code.videolan.org/-/profile/notifications | Help: https://code.videolan.org/help
More information about the vlc-commits
mailing list