Compare commits

..

9 Commits

Author SHA1 Message Date
jeffvli 18b62ae22b cap scrobble status at max values 2026-05-25 13:07:44 -07:00
jeffvli 56a552f893 fix playerbar slider styles
- left / right values should be consistent width
2026-05-25 12:59:07 -07:00
jeffvli d11c3fa58c handle positional scrobbles for OS 2026-05-25 12:23:08 -07:00
jeffvli 54f181c542 set ignoreScrobble, playbackRate as optional params 2026-05-25 11:59:21 -07:00
jeffvli 1bf51d1d72 add reportPlayback as default Jellyfin feature 2026-05-25 11:58:01 -07:00
jeffvli 37df94bd3b add playbackReport handler to Subsonic controller 2026-05-25 11:56:00 -07:00
jeffvli dd60499185 add reportPlayback server feature 2026-05-25 11:55:41 -07:00
jeffvli 009732e745 add OS reportPlayback endpoint and types 2026-05-25 11:43:50 -07:00
Hosted Weblate 0de1e1aa3e Translated using Weblate
Currently translated at 26.0% (320 of 1230 strings) (Japanese)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ja/

Translated using Weblate

Currently translated at 98.6% (1213 of 1230 strings) (Catalan)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ca/

Translated using Weblate

Currently translated at 73.8% (908 of 1230 strings) (Russian)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ru/

Co-authored-by: Napster2k <Napster2k@gmail.com>
Co-authored-by: Ondo <SparkyOndo@proton.me>
Co-authored-by: karigane <169052233+karigane-cha@users.noreply.github.com>
2026-05-22 11:11:40 +00:00
13 changed files with 274 additions and 111 deletions
+42 -20
View File
@@ -1,7 +1,7 @@
{ {
"page": { "page": {
"sidebar": { "sidebar": {
"myLibrary": "La meva llibreria", "myLibrary": "La meva mediateca",
"albumArtists": "$t(entity.albumArtist, {\"count\": 2})", "albumArtists": "$t(entity.albumArtist, {\"count\": 2})",
"albums": "$t(entity.album, {\"count\": 2})", "albums": "$t(entity.album, {\"count\": 2})",
"artists": "$t(entity.artist, {\"count\": 2})", "artists": "$t(entity.artist, {\"count\": 2})",
@@ -340,7 +340,8 @@
"filter_multiple": "Multi", "filter_multiple": "Multi",
"rename": "Reanomena", "rename": "Reanomena",
"newVersionAvailable": "Hi ha una nova versió disponible", "newVersionAvailable": "Hi ha una nova versió disponible",
"numberOfResults": "{{numberOfResults}} resultats" "numberOfResults": "{{numberOfResults}} resultats",
"back": "Enrere"
}, },
"entity": { "entity": {
"album_one": "Àlbum", "album_one": "Àlbum",
@@ -557,7 +558,9 @@
"selectRangeOfItems": "Selecciona un interval d'elements", "selectRangeOfItems": "Selecciona un interval d'elements",
"selectAll": "Selecciona-ho tot", "selectAll": "Selecciona-ho tot",
"openApplicationDirectory": "Obre el directori de l'aplicació", "openApplicationDirectory": "Obre el directori de l'aplicació",
"goToCurrent": "Anar a l'element actual" "goToCurrent": "Anar a l'element actual",
"collapseAllFolders": "Replega totes les carpetes",
"expandAllFolders": "Expandeix totes les carpetes"
}, },
"setting": { "setting": {
"language_description": "Estableix l'idioma de l'aplicació ($t(common.restartRequired))", "language_description": "Estableix l'idioma de l'aplicació ($t(common.restartRequired))",
@@ -921,8 +924,8 @@
"primaryShade_description": "Substitueix el to primari (09) utilitzat per a botons, enllaços i altres elements de color primari", "primaryShade_description": "Substitueix el to primari (09) utilitzat per a botons, enllaços i altres elements de color primari",
"playerItemConfiguration_description": "Configurar quins elements es mostren i en quin ordre al reproductor de pantalla completa", "playerItemConfiguration_description": "Configurar quins elements es mostren i en quin ordre al reproductor de pantalla completa",
"playerItemConfiguration": "Configuració d'elements del jugador", "playerItemConfiguration": "Configuració d'elements del jugador",
"listenbrainz_description": "mostra enllaços a ListenBrainz a les pàgines d'artista/àlbum", "listenbrainz_description": "Mostra enllaços a ListenBrainz a les pàgines d'artista/àlbum",
"listenbrainz": "mostra enllaços a ListenBrainz", "listenbrainz": "Mostra enllaços a ListenBrainz",
"qobuz_description": "Mostra enllaços a Qobuz a les pàgines d'artista/àlbum", "qobuz_description": "Mostra enllaços a Qobuz a les pàgines d'artista/àlbum",
"qobuz": "Mostra enllaços a Qobuz", "qobuz": "Mostra enllaços a Qobuz",
"spotify_description": "Mostra enllaços a Spotify a les pàgines d'artista/àlbum", "spotify_description": "Mostra enllaços a Spotify a les pàgines d'artista/àlbum",
@@ -930,25 +933,43 @@
"nativeSpotify_description": "Obre amb Spotify en lloc del vostre navegador", "nativeSpotify_description": "Obre amb Spotify en lloc del vostre navegador",
"nativeSpotify": "Fes servir Spotify", "nativeSpotify": "Fes servir Spotify",
"playerbarWaveformStretch": "Extensió de la forma d'ona", "playerbarWaveformStretch": "Extensió de la forma d'ona",
"playerbarWaveformStretch_description": "estén la forma d'ona per omplir l'espai disponible", "playerbarWaveformStretch_description": "Estén la forma d'ona per omplir l'espai disponible",
"sidePlayQueueLayout": "Disposició de la cua de reproducció lateral", "sidePlayQueueLayout": "Disposició de la cua de reproducció lateral",
"sidePlayQueueLayout_description": "estableix la disposició de la cua de reproducció lateral adjunta", "sidePlayQueueLayout_description": "Estableix la disposició de la cua de reproducció lateral adjunta",
"sidePlayQueueLayout_optionHorizontal": "horitzontal", "sidePlayQueueLayout_optionHorizontal": "Horitzontal",
"sidePlayQueueLayout_optionVertical": "vertical", "sidePlayQueueLayout_optionVertical": "Vertical",
"waveformLoadingDelay": "Demora de càrrega de la forma d'ona", "waveformLoadingDelay": "Demora de càrrega de la forma d'ona",
"waveformLoadingDelay_description": "Demora en segons abans de carregar la forma d'ona. Incrementeu aquest valor si patiu interrupcions en fer servir el reproductor web.", "waveformLoadingDelay_description": "Demora en segons abans de carregar la forma d'ona. Incrementeu aquest valor si patiu interrupcions en fer servir el reproductor web.",
"preventSuspendOnPlayback_description": "Evita que l'aplicació quedi suspesa mentre es reprodueix música", "preventSuspendOnPlayback_description": "Evita que l'aplicació quedi suspesa mentre es reprodueix música",
"preventSuspendOnPlayback": "Evita la suspensió durant la reproducció" "preventSuspendOnPlayback": "Evita la suspensió durant la reproducció",
"hotkey_listShowPlayingSong": "Mostra la cançó en reproducció a la llista",
"sidebarPlaylistFolders_description": "Create una vista de carpeta per lliste de reproducció que inclogui el separador configurat al nom",
"sidebarPlaylistFolders": "Habilita les carpetes",
"sidebarPlaylistFolderSeparator_description": "Caràcter (o cadena) que separa els nivells de carpetes al nom d'una llista de reproducció",
"sidebarPlaylistFolderSeparator": "Separador de carpetes",
"sidebarPlaylistFolderView_description": "Com es mostren les carpetes a la barra lateral",
"sidebarPlaylistFolderView": "Vista de carpeta",
"sidebarPlaylistFolderView_optionSingle": "Carpeta única",
"sidebarPlaylistFolderView_optionTree": "Vista d'arbre",
"sidebarPlaylistFolderView_optionNavigation": "Vista de navegació",
"sidebarPlaylistFolderTreeIndent_description": "Píxels de sagnat per cada nivell de l'arbre",
"sidebarPlaylistFolderTreeIndent": "Sagnat de l'arbre",
"sidebarPlaylistFolderTreeLineColor_description": "Color de les línies connectores de l'arbre (deixeu-ho en blanc per fer servir el valor predeterminat)",
"sidebarPlaylistFolderTreeLineColor": "Color de la línia de l'arbre",
"sidebarPlaylistMode_description": "Com es mostra cada llista de reproducció a la llista de la barra lateral",
"sidebarPlaylistMode": "Mode de llista de reproducció a la barra lateral",
"sidebarPlaylistMode_optionCompact": "Compacte",
"sidebarPlaylistMode_optionExpanded": "Expandit"
}, },
"table": { "table": {
"column": { "column": {
"albumCount": "$t(entity.album, {\"count\": 2})", "albumCount": "Àlbums",
"artist": "$t(entity.artist, {\"count\": 1})", "artist": "Artista",
"channels": "$t(common.channel, {\"count\": 2})", "channels": "Canals",
"codec": "$t(common.codec)", "codec": "dec",
"genre": "$t(entity.genre, {\"count\": 1})", "genre": "Gènere",
"size": "$t(common.size)", "size": "Mida",
"songCount": "$t(entity.track, {\"count\": 2})", "songCount": "Pistes",
"releaseYear": "Any", "releaseYear": "Any",
"playCount": "Reproduccions", "playCount": "Reproduccions",
"releaseDate": "Data de llançament", "releaseDate": "Data de llançament",
@@ -966,8 +987,8 @@
"path": "Ruta", "path": "Ruta",
"rating": "Qualificació", "rating": "Qualificació",
"title": "Títol", "title": "Títol",
"bitDepth": "$t(common.bitDepth)", "bitDepth": "Profunditat de bits",
"sampleRate": "$t(common.sampleRate)", "sampleRate": "Freqüència de mostreig",
"owner": "Propietari" "owner": "Propietari"
}, },
"config": { "config": {
@@ -1146,7 +1167,8 @@
"sleepTimer_timeRemaining": "Queden {{time}}", "sleepTimer_timeRemaining": "Queden {{time}}",
"sleepTimer_setCustom": "Configura el temporitzador", "sleepTimer_setCustom": "Configura el temporitzador",
"sleepTimer_cancel": "Cancel·la el temporitzador", "sleepTimer_cancel": "Cancel·la el temporitzador",
"albumRadio": "Ràdio d'àlbums" "albumRadio": "Ràdio d'àlbums",
"scrobbleForceSubmit": "Força l'scrobble"
}, },
"error": { "error": {
"credentialsRequired": "Credencials requerides", "credentialsRequired": "Credencials requerides",
+19 -14
View File
@@ -48,7 +48,8 @@
"holdToShuffle": "長押しでシャッフル", "holdToShuffle": "長押しでシャッフル",
"albumRadio": "アルバム・ラジオ", "albumRadio": "アルバム・ラジオ",
"artistRadio": "アーティストラジオ", "artistRadio": "アーティストラジオ",
"trackRadio": "ラジオを追跡する" "trackRadio": "ラジオを追跡する",
"scrobbleForceSubmit": "強制 Scrobble"
}, },
"setting": { "setting": {
"crossfadeStyle_description": "オーディオプレーヤーが使用するクロスフェードのスタイルを選択します", "crossfadeStyle_description": "オーディオプレーヤーが使用するクロスフェードのスタイルを選択します",
@@ -429,7 +430,8 @@
"playerbarWaveformStretch": "波形伸縮", "playerbarWaveformStretch": "波形伸縮",
"playerbarWaveformStretch_description": "波形を伸縮させて、利用可能なスペースを埋めます", "playerbarWaveformStretch_description": "波形を伸縮させて、利用可能なスペースを埋めます",
"preventSuspendOnPlayback_description": "音楽再生中にアプリケーションが停止しないようにします", "preventSuspendOnPlayback_description": "音楽再生中にアプリケーションが停止しないようにします",
"preventSuspendOnPlayback": "再生の中断を防止する" "preventSuspendOnPlayback": "再生の中断を防止する",
"hotkey_listShowPlayingSong": "再生中の曲をリストに表示"
}, },
"action": { "action": {
"editPlaylist": "$t(entity.playlist, {\"count\": 1}) を編集", "editPlaylist": "$t(entity.playlist, {\"count\": 1}) を編集",
@@ -473,7 +475,9 @@
"openApplicationDirectory": "アプリケーションディレクトリを開く", "openApplicationDirectory": "アプリケーションディレクトリを開く",
"selectRangeOfItems": "項目の範囲を選択", "selectRangeOfItems": "項目の範囲を選択",
"addOrRemoveFromSelection": "選択に追加または削除", "addOrRemoveFromSelection": "選択に追加または削除",
"goToCurrent": "現在の項目へ移動" "goToCurrent": "現在の項目へ移動",
"collapseAllFolders": "すべてのフォルダーを折りたたむ",
"expandAllFolders": "すべてのフォルダーを展開する"
}, },
"common": { "common": {
"backward": "戻る", "backward": "戻る",
@@ -594,7 +598,8 @@
"rename": "名前を変更", "rename": "名前を変更",
"newVersionAvailable": "新しいバージョンが利用可能です", "newVersionAvailable": "新しいバージョンが利用可能です",
"numberOfResults": "{{numberOfResults}} 件の結果", "numberOfResults": "{{numberOfResults}} 件の結果",
"grouping": "グループ化" "grouping": "グループ化",
"back": "戻る"
}, },
"table": { "table": {
"config": { "config": {
@@ -643,7 +648,7 @@
"titleCombined": "$t(common.title) (結合)", "titleCombined": "$t(common.title) (結合)",
"dateAdded": "追加日", "dateAdded": "追加日",
"size": "$t(common.size)", "size": "$t(common.size)",
"bpm": "$t(common.BPM)", "bpm": "$t(common.bpm)",
"lastPlayed": "最後に再生", "lastPlayed": "最後に再生",
"trackNumber": "トラック番号", "trackNumber": "トラック番号",
"rowIndex": "行インデックス", "rowIndex": "行インデックス",
@@ -681,7 +686,7 @@
"rating": "評価", "rating": "評価",
"favorite": "お気に入り", "favorite": "お気に入り",
"playCount": "再生回数", "playCount": "再生回数",
"albumCount": "$t(entity.album, {\"count\": 2})", "albumCount": "アルバム",
"releaseYear": "年", "releaseYear": "年",
"lastPlayed": "最後に再生", "lastPlayed": "最後に再生",
"biography": "バイオグラフィー", "biography": "バイオグラフィー",
@@ -690,18 +695,18 @@
"title": "タイトル", "title": "タイトル",
"bpm": "BPM", "bpm": "BPM",
"dateAdded": "追加日", "dateAdded": "追加日",
"artist": "$t(entity.artist, {\"count\": 1})", "artist": "アーティスト",
"songCount": "$t(entity.track, {\"count\": 2})", "songCount": "トラック",
"trackNumber": "トラック", "trackNumber": "トラック",
"genre": "$t(entity.genre, {\"count\": 1})", "genre": "ジャンル",
"albumArtist": "アルバムアーティスト", "albumArtist": "アルバムアーティスト",
"path": "パス", "path": "パス",
"discNumber": "ディスク", "discNumber": "ディスク",
"channels": "$t(common.channel, {\"count\": 2})", "channels": "チャンネル",
"size": "$t(common.size)", "size": "サイズ",
"codec": "$t(common.codec)", "codec": "コーデック",
"bitDepth": "$t(common.bitDepth)", "bitDepth": "ビット深度",
"sampleRate": "$t(common.sampleRate)", "sampleRate": "サンプリング周波数",
"owner": "所有者" "owner": "所有者"
} }
}, },
+13 -6
View File
@@ -41,7 +41,9 @@
"viewMore": "Посмотреть больше", "viewMore": "Посмотреть больше",
"openApplicationDirectory": "Открыть папку приложения", "openApplicationDirectory": "Открыть папку приложения",
"selectRangeOfItems": "Выбрать диапазон элементов", "selectRangeOfItems": "Выбрать диапазон элементов",
"goToCurrent": "Перейти к текущему элементу" "goToCurrent": "Перейти к текущему элементу",
"collapseAllFolders": "Свернуть все папки",
"expandAllFolders": "Развернуть все папки"
}, },
"common": { "common": {
"backward": "Назад", "backward": "Назад",
@@ -171,7 +173,8 @@
"externalLinks": "Внешние ссылки", "externalLinks": "Внешние ссылки",
"explicitStatus": "Признак нецензурного контента", "explicitStatus": "Признак нецензурного контента",
"newVersionAvailable": "Доступна новая версия", "newVersionAvailable": "Доступна новая версия",
"numberOfResults": "{{numberOfResults}} результатов" "numberOfResults": "{{numberOfResults}} результатов",
"back": "Назад"
}, },
"entity": { "entity": {
"album_one": "Альбом", "album_one": "Альбом",
@@ -430,7 +433,8 @@
"sleepTimer_timeRemaining": "{{time}} осталось", "sleepTimer_timeRemaining": "{{time}} осталось",
"sleepTimer_setCustom": "Установить таймер", "sleepTimer_setCustom": "Установить таймер",
"sleepTimer_custom": "Пользовательский", "sleepTimer_custom": "Пользовательский",
"sleepTimer_cancel": "Отменить таймер" "sleepTimer_cancel": "Отменить таймер",
"scrobbleForceSubmit": "Принудительная скробблинг"
}, },
"page": { "page": {
"sidebar": { "sidebar": {
@@ -491,7 +495,8 @@
"selectMusicFolder": "Выбрать папку с музыкой", "selectMusicFolder": "Выбрать папку с музыкой",
"noMusicFolder": "Папка с музыкой не выбрана", "noMusicFolder": "Папка с музыкой не выбрана",
"multipleMusicFolders": "{{count}} выбрано музыкальных папок", "multipleMusicFolders": "{{count}} выбрано музыкальных папок",
"commandPalette": "открыть командную строку" "commandPalette": "открыть командную строку",
"settings": "$t(common.setting, {\"count\": 2})"
}, },
"manageServers": { "manageServers": {
"title": "Сервера", "title": "Сервера",
@@ -569,7 +574,8 @@
}, },
"genreList": { "genreList": {
"showAlbums": "показать $t(entity.genre, {\"count\": 1}) $t(entity.album, {\"count\": 2})", "showAlbums": "показать $t(entity.genre, {\"count\": 1}) $t(entity.album, {\"count\": 2})",
"showTracks": "показать $t(entity.genre, {\"count\": 1}) $t(entity.track, {\"count\": 2})" "showTracks": "показать $t(entity.genre, {\"count\": 1}) $t(entity.track, {\"count\": 2})",
"title": "$t(entity.genre, {\"count\": 2})"
}, },
"trackList": { "trackList": {
"artistTracks": "Треки {{artist}}", "artistTracks": "Треки {{artist}}",
@@ -681,7 +687,8 @@
"input_skipDuplicates": "не добавлять дубликаты", "input_skipDuplicates": "не добавлять дубликаты",
"create": "создать $t(entity.playlist, {\"count\": 1}) {{playlist}}", "create": "создать $t(entity.playlist, {\"count\": 1}) {{playlist}}",
"searchOrCreate": "для создания нового списка выполните поиск по $t(entity.playlist, {\"count\": 2}) или введите соответствующий текст", "searchOrCreate": "для создания нового списка выполните поиск по $t(entity.playlist, {\"count\": 2}) или введите соответствующий текст",
"input_playlists": "$t(entity.playlist, {\"count\": 2})" "input_playlists": "$t(entity.playlist, {\"count\": 2})",
"noneAdded": "Ни один трек не добавлен в $t(entity.playlist, {\"count\": 1}) '{{playlist}}'"
}, },
"updateServer": { "updateServer": {
"title": "Обновление сервера", "title": "Обновление сервера",
@@ -1062,7 +1062,9 @@ export const JellyfinController: InternalControllerEndpoint = {
throw new Error('Failed to get server info'); throw new Error('Failed to get server info');
} }
const defaultFeatures = {}; const defaultFeatures = {
[ServerFeature.REPORT_PLAYBACK]: [1],
};
const features = { const features = {
...defaultFeatures, ...defaultFeatures,
@@ -289,6 +289,14 @@ export const contract = c.router({
200: ssType._response.removeFavorite, 200: ssType._response.removeFavorite,
}, },
}, },
reportPlayback: {
method: 'GET',
path: 'reportPlayback.view',
query: ssType._parameters.reportPlayback,
responses: {
200: ssType._response.reportPlayback,
},
},
savePlayQueue: { savePlayQueue: {
method: 'GET', method: 'GET',
path: 'savePlayQueue.view', path: 'savePlayQueue.view',
@@ -1460,6 +1460,10 @@ export const SubsonicController: InternalControllerEndpoint = {
features.serverPlayQueue = [1]; features.serverPlayQueue = [1];
} }
if (subsonicFeatures[SubsonicExtensions.PLAYBACK_REPORT]) {
features.reportPlayback = [1];
}
return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion }; return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion };
}, },
getSimilarSongs: async (args) => { getSimilarSongs: async (args) => {
@@ -2298,6 +2302,57 @@ export const SubsonicController: InternalControllerEndpoint = {
scrobble: async (args) => { scrobble: async (args) => {
const { apiClientProps, query } = args; const { apiClientProps, query } = args;
if (hasFeature(apiClientProps.server, ServerFeature.REPORT_PLAYBACK)) {
if (query.submission) {
const res = await ssApiClient(apiClientProps).scrobble({
query: {
id: query.id,
submission: query.submission,
},
});
if (res.status !== 200) {
throw new Error('Failed to scrobble');
}
return null;
}
let state: 'paused' | 'playing' | 'starting' | 'stopped' = 'playing';
switch (query.event) {
case 'pause':
state = 'paused';
break;
case 'start':
state = 'starting';
break;
case 'timeupdate':
case 'unpause':
state = 'playing';
break;
default:
state = 'playing';
}
const res = await ssApiClient(apiClientProps).reportPlayback({
query: {
ignoreScrobble: true,
mediaId: query.id,
mediaType: query.mediaType,
playbackRate: query.playbackRate,
positionMs: query.position ?? 0,
state,
},
});
if (res.status !== 200) {
throw new Error('Failed to report playback');
}
return null;
}
const res = await ssApiClient(apiClientProps).scrobble({ const res = await ssApiClient(apiClientProps).scrobble({
query: { query: {
id: query.id, id: query.id,
@@ -14,6 +14,8 @@
} }
.root { .root {
width: 100%;
&:hover { &:hover {
.bar { .bar {
background-color: var(--theme-colors-primary-filled); background-color: var(--theme-colors-primary-filled);
@@ -44,48 +46,45 @@
transition: opacity 0.2s ease-in-out; transition: opacity 0.2s ease-in-out;
} }
.track {
&::before {
right: calc(0.1rem * -1);
}
}
.slider-container { .slider-container {
display: flex; --slider-value-width: 4.75rem;
--slider-value-gap: var(--theme-spacing-sm);
display: grid;
grid-template-columns: var(--slider-value-width) minmax(0, 1fr) var(--slider-value-width);
gap: var(--slider-value-gap);
align-items: center;
width: 95%; width: 95%;
height: 20px; height: 20px;
} }
.slider-value-wrapper { .slider-value-wrapper {
display: flex; min-width: 0;
flex: 1;
align-items: center;
justify-content: center;
max-width: 50px;
min-height: 0; min-height: 0;
&:first-child {
justify-self: end;
}
&:last-child {
justify-self: start;
}
@media (width < 768px) { @media (width < 768px) {
display: none; display: none;
} }
} }
.slider-value-wrapper-elapsed { @media (width < 768px) {
display: flex; .slider-container {
flex: 1; grid-template-columns: minmax(0, 1fr);
align-items: center;
justify-content: center;
min-width: 0;
max-width: 4.75rem;
min-height: 0;
@media (width < 768px) {
display: none;
} }
} }
.slider-wrapper { .slider-wrapper {
display: flex; display: flex;
flex: 6;
align-items: center; align-items: center;
width: 100%;
min-width: 0;
height: 100%; height: 100%;
} }
@@ -42,7 +42,7 @@ export const PlayerbarSlider = () => {
return ( return (
<> <>
<div className={styles.sliderContainer}> <div className={styles.sliderContainer}>
<div className={styles.sliderValueWrapperElapsed}> <div className={styles.sliderValueWrapper}>
<ScrobbleStatus formattedTime={formattedTime} /> <ScrobbleStatus formattedTime={formattedTime} />
</div> </div>
<div className={styles.sliderWrapper}> <div className={styles.sliderWrapper}>
@@ -81,7 +81,6 @@ export const CustomPlayerbarSlider = ({ ...props }: SliderProps) => {
label: styles.label, label: styles.label,
root: styles.root, root: styles.root,
thumb: styles.thumb, thumb: styles.thumb,
track: styles.track,
}} }}
{...props} {...props}
size={6} size={6}
@@ -22,6 +22,12 @@ const scrobbleProgressProps = {
const clampPct = (n: number) => Math.min(100, Math.max(0, n)); const clampPct = (n: number) => Math.min(100, Math.max(0, n));
const capForDisplay = (value: number, limit: number) =>
limit > 0 ? Math.min(value, limit) : value;
const progressTowardLimit = (current: number, limit: number) =>
limit > 0 ? clampPct((current / limit) * 100) : 0;
const ScrobbleConditionProgress = ({ value }: { value: number }) => ( const ScrobbleConditionProgress = ({ value }: { value: number }) => (
<Progress {...scrobbleProgressProps} value={value} w="100%" /> <Progress {...scrobbleProgressProps} value={value} w="100%" />
); );
@@ -34,18 +40,25 @@ export const ScrobbleStatus = ({ formattedTime }: { formattedTime: string }) =>
const hookInactive = !scrobbleEnabled || privateMode; const hookInactive = !scrobbleEnabled || privateMode;
const listenedSec = (snapshot.listenedMs / 1000).toFixed(1); const listenedSecRaw = snapshot.listenedMs / 1000;
const listenPercentOfTrack = const listenedSecDisplay = snapshot.submitted
snapshot.trackDurationMs > 0 ? (snapshot.listenedMs / snapshot.trackDurationMs) * 100 : 0; ? snapshot.targetDurationSec
: capForDisplay(listenedSecRaw, snapshot.targetDurationSec);
const durationConditionPct = const listenPercentOfTrackRaw =
snapshot.targetDurationSec > 0 snapshot.trackDurationMs > 0 ? (snapshot.listenedMs / snapshot.trackDurationMs) * 100 : 0;
? clampPct((snapshot.listenedMs / 1000 / snapshot.targetDurationSec) * 100) const listenPercentDisplay = snapshot.submitted
: 0; ? snapshot.targetPercentage
const percentConditionPct = : capForDisplay(listenPercentOfTrackRaw, snapshot.targetPercentage);
snapshot.targetPercentage > 0 && snapshot.trackDurationMs > 0
? clampPct((listenPercentOfTrack / snapshot.targetPercentage) * 100) const durationConditionPct = progressTowardLimit(
: 0; listenedSecDisplay,
snapshot.targetDurationSec,
);
const percentConditionPct = progressTowardLimit(
listenPercentDisplay,
snapshot.targetPercentage,
);
return ( return (
<HoverCard position="top" width={280}> <HoverCard position="top" width={280}>
@@ -87,13 +100,13 @@ export const ScrobbleStatus = ({ formattedTime }: { formattedTime: string }) =>
<> <>
<Stack gap="xs"> <Stack gap="xs">
<Text size="xs"> <Text size="xs">
{`${listenedSec}s / ${snapshot.targetDurationSec}s`} {`${listenedSecDisplay.toFixed(1)}s / ${snapshot.targetDurationSec}s`}
</Text> </Text>
<ScrobbleConditionProgress value={durationConditionPct} /> <ScrobbleConditionProgress value={durationConditionPct} />
</Stack> </Stack>
<Stack gap="xs"> <Stack gap="xs">
<Text size="xs"> <Text size="xs">
{`${listenPercentOfTrack.toFixed(1)}% / ${snapshot.targetPercentage}%`} {`${listenPercentDisplay.toFixed(1)}% / ${snapshot.targetPercentage}%`}
</Text> </Text>
<ScrobbleConditionProgress value={percentConditionPct} /> <ScrobbleConditionProgress value={percentConditionPct} />
</Stack> </Stack>
@@ -4,17 +4,21 @@ import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events'; import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
import { useSendScrobble } from '/@/renderer/features/player/mutations/scrobble-mutation'; import { useSendScrobble } from '/@/renderer/features/player/mutations/scrobble-mutation';
import { import {
getServerById,
publishScrobbleDebug, publishScrobbleDebug,
useAppStore, useAppStore,
usePlaybackSettings, usePlaybackSettings,
usePlayerSong, usePlayerSong,
usePlayerSpeed,
usePlayerStore, usePlayerStore,
useSettingsStore, useSettingsStore,
useTimestampStoreBase, useTimestampStoreBase,
} from '/@/renderer/store'; } from '/@/renderer/store';
import { LogCategory, logFn } from '/@/renderer/utils/logger'; import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { logMsg } from '/@/renderer/utils/logger-message'; import { logMsg } from '/@/renderer/utils/logger-message';
import { hasFeature } from '/@/shared/api/utils';
import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types'; import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
import { PlayerStatus } from '/@/shared/types/types'; import { PlayerStatus } from '/@/shared/types/types';
type ScrobbleManualHandlers = { type ScrobbleManualHandlers = {
@@ -36,6 +40,14 @@ export const invokeScrobbleResetListenedState = () => {
scrobbleManualHandlers?.resetListenedState(); scrobbleManualHandlers?.resetListenedState();
}; };
const getPositionValue = (seconds: number, useTicks: boolean) => {
if (useTicks) {
return Math.round(seconds * 1e7);
}
return seconds;
};
/* /*
Submission (Last.fm / etc.) eligibility uses accumulated listen time: Submission (Last.fm / etc.) eligibility uses accumulated listen time:
- If listened time meets the required percentage of track duration - If listened time meets the required percentage of track duration
@@ -99,6 +111,7 @@ export const useScrobble = () => {
const isPrivateModeEnabled = useAppStore((state) => state.privateMode); const isPrivateModeEnabled = useAppStore((state) => state.privateMode);
const sendScrobble = useSendScrobble(); const sendScrobble = useSendScrobble();
const currentSong = usePlayerSong(); const currentSong = usePlayerSong();
const playbackRate = usePlayerSpeed();
const imageUrl = useItemImageUrl({ const imageUrl = useItemImageUrl({
id: currentSong?.imageId || undefined, id: currentSong?.imageId || undefined,
@@ -166,6 +179,11 @@ export const useScrobble = () => {
if (!isScrobbleEnabled || isPrivateModeEnabled) return; if (!isScrobbleEnabled || isPrivateModeEnabled) return;
const currentSong = usePlayerStore.getState().getCurrentSong(); const currentSong = usePlayerStore.getState().getCurrentSong();
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
const serverId = currentSong?._serverId;
const server = getServerById(serverId);
const hasPlaybackReport = hasFeature(server, ServerFeature.REPORT_PLAYBACK);
const useTicks = currentSong?._serverType === ServerType.JELLYFIN;
const currentStatus = usePlayerStore.getState().player.status; const currentStatus = usePlayerStore.getState().player.status;
const currentTime = properties.timestamp; const currentTime = properties.timestamp;
const previousTime = prev.timestamp; const previousTime = prev.timestamp;
@@ -220,19 +238,20 @@ export const useScrobble = () => {
} }
} }
// Send Jellyfin progress events every 10 seconds // Send progress events every 10 seconds
if (currentSong._serverType === ServerType.JELLYFIN) { if (hasPlaybackReport) {
const timeSinceLastProgress = currentTime - lastProgressEventRef.current; const timeSinceLastProgress = currentTime - lastProgressEventRef.current;
if (timeSinceLastProgress >= 10) { if (timeSinceLastProgress >= 10) {
const position = currentTime * 1e7;
sendScrobble.mutate( sendScrobble.mutate(
{ {
apiClientProps: { serverId: currentSong._serverId || '' }, apiClientProps: { serverId: serverId || '' },
query: { query: {
albumId: currentSong.albumId, albumId: currentSong.albumId,
event: 'timeupdate', event: 'timeupdate',
id: currentSong.id, id: currentSong.id,
position, mediaType: mediaType,
playbackRate,
position: getPositionValue(currentTime, useTicks),
submission: false, submission: false,
}, },
}, },
@@ -261,20 +280,15 @@ export const useScrobble = () => {
}); });
if (shouldSubmitScrobble) { if (shouldSubmitScrobble) {
// Since jellyfin-plugin-lastfm uses the submission Position to determine if the song should actually scrobble
// we just send the full duration of the song when it matches the local scrobble conditions
const position =
currentSong._serverType === ServerType.JELLYFIN
? currentSong.duration * 1e7
: undefined;
sendScrobble.mutate( sendScrobble.mutate(
{ {
apiClientProps: { serverId: currentSong._serverId || '' }, apiClientProps: { serverId: currentSong._serverId || '' },
query: { query: {
albumId: currentSong.albumId, albumId: currentSong.albumId,
id: currentSong.id, id: currentSong.id,
position, mediaType: mediaType,
playbackRate: playbackRate,
position: getPositionValue(currentSong.duration ?? 0, useTicks),
submission: true, submission: true,
}, },
}, },
@@ -295,7 +309,7 @@ export const useScrobble = () => {
} }
} }
}, },
[isScrobbleEnabled, isPrivateModeEnabled, sendScrobble], [isScrobbleEnabled, isPrivateModeEnabled, sendScrobble, playbackRate],
); );
const handleScrobbleFromSongChange = useCallback( const handleScrobbleFromSongChange = useCallback(
@@ -305,6 +319,7 @@ export const useScrobble = () => {
) => { ) => {
const currentSong = properties.song; const currentSong = properties.song;
const previousSong = previousSongRef.current; const previousSong = previousSongRef.current;
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
// Handle notifications // Handle notifications
if (scrobbleSettings?.notify && currentSong?.id) { if (scrobbleSettings?.notify && currentSong?.id) {
@@ -365,6 +380,8 @@ export const useScrobble = () => {
albumId: currentSong.albumId, albumId: currentSong.albumId,
event: 'start', event: 'start',
id: currentSong.id, id: currentSong.id,
mediaType: mediaType,
playbackRate: playbackRate,
position: 0, position: 0,
submission: false, submission: false,
}, },
@@ -388,11 +405,12 @@ export const useScrobble = () => {
flushScrobbleDebug(); flushScrobbleDebug();
}, },
[ [
flushScrobbleDebug,
scrobbleSettings?.notify, scrobbleSettings?.notify,
isScrobbleEnabled, isScrobbleEnabled,
isPrivateModeEnabled, isPrivateModeEnabled,
flushScrobbleDebug,
sendScrobble, sendScrobble,
playbackRate,
], ],
); );
@@ -404,6 +422,11 @@ export const useScrobble = () => {
} }
const currentSong = usePlayerStore.getState().getCurrentSong(); const currentSong = usePlayerStore.getState().getCurrentSong();
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
const serverId = currentSong?._serverId;
const server = getServerById(serverId);
const hasPlaybackReport = hasFeature(server, ServerFeature.REPORT_PLAYBACK);
const useTicks = currentSong?._serverType === ServerType.JELLYFIN;
if (!currentSong?.id) { if (!currentSong?.id) {
return; return;
@@ -422,7 +445,7 @@ export const useScrobble = () => {
} }
// Position scrobbles are only relevant for Jellyfin // Position scrobbles are only relevant for Jellyfin
if (currentSong._serverType !== ServerType.JELLYFIN) { if (!hasPlaybackReport) {
flushScrobbleDebug(); flushScrobbleDebug();
return; return;
} }
@@ -436,8 +459,6 @@ export const useScrobble = () => {
return; return;
} }
const position = properties.timestamp * 1e7;
lastProgressEventRef.current = properties.timestamp; lastProgressEventRef.current = properties.timestamp;
lastSeekEventRef.current = now; lastSeekEventRef.current = now;
@@ -448,7 +469,9 @@ export const useScrobble = () => {
albumId: currentSong.albumId, albumId: currentSong.albumId,
event: 'timeupdate', event: 'timeupdate',
id: currentSong.id, id: currentSong.id,
position, mediaType: mediaType,
playbackRate: playbackRate,
position: getPositionValue(properties.timestamp, useTicks),
submission: false, submission: false,
}, },
}, },
@@ -465,7 +488,7 @@ export const useScrobble = () => {
); );
flushScrobbleDebug(); flushScrobbleDebug();
}, },
[flushScrobbleDebug, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble], [isScrobbleEnabled, isPrivateModeEnabled, sendScrobble, playbackRate, flushScrobbleDebug],
); );
const handleScrobbleFromStatus = useCallback( const handleScrobbleFromStatus = useCallback(
@@ -475,18 +498,22 @@ export const useScrobble = () => {
} }
const currentSong = usePlayerStore.getState().getCurrentSong(); const currentSong = usePlayerStore.getState().getCurrentSong();
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
const serverId = currentSong?._serverId;
const server = getServerById(serverId);
const hasPlaybackReport = hasFeature(server, ServerFeature.REPORT_PLAYBACK);
const useTicks = currentSong?._serverType === ServerType.JELLYFIN;
if (!currentSong?.id) { if (!currentSong?.id) {
return; return;
} }
// Only apply to Jellyfin controller scrobble // Only apply to Jellyfin controller scrobble
if (currentSong._serverType !== ServerType.JELLYFIN) { if (!hasPlaybackReport) {
return; return;
} }
const currentTimestamp = useTimestampStoreBase.getState().timestamp; const currentTimestamp = useTimestampStoreBase.getState().timestamp;
const position = currentTimestamp * 1e7;
// Send pause event when status changes to paused // Send pause event when status changes to paused
if (properties.status === PlayerStatus.PAUSED && prev.status === PlayerStatus.PLAYING) { if (properties.status === PlayerStatus.PAUSED && prev.status === PlayerStatus.PLAYING) {
@@ -497,7 +524,9 @@ export const useScrobble = () => {
albumId: currentSong.albumId, albumId: currentSong.albumId,
event: 'pause', event: 'pause',
id: currentSong.id, id: currentSong.id,
position, mediaType: mediaType,
playbackRate: playbackRate,
position: getPositionValue(currentTimestamp, useTicks),
submission: false, submission: false,
}, },
}, },
@@ -523,7 +552,9 @@ export const useScrobble = () => {
albumId: currentSong.albumId, albumId: currentSong.albumId,
event: 'unpause', event: 'unpause',
id: currentSong.id, id: currentSong.id,
position, mediaType: mediaType,
playbackRate: playbackRate,
position: getPositionValue(currentTimestamp, useTicks),
submission: false, submission: false,
}, },
}, },
@@ -542,7 +573,7 @@ export const useScrobble = () => {
flushScrobbleDebug(); flushScrobbleDebug();
}, },
[flushScrobbleDebug, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble], [isScrobbleEnabled, isPrivateModeEnabled, flushScrobbleDebug, sendScrobble, playbackRate],
); );
const handleScrobbleFromRepeat = useCallback(() => { const handleScrobbleFromRepeat = useCallback(() => {
@@ -552,6 +583,7 @@ export const useScrobble = () => {
const currentSong = usePlayerStore.getState().getCurrentSong(); const currentSong = usePlayerStore.getState().getCurrentSong();
const currentStatus = usePlayerStore.getState().player.status; const currentStatus = usePlayerStore.getState().player.status;
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
if (currentStatus !== PlayerStatus.PLAYING || !currentSong?.id) { if (currentStatus !== PlayerStatus.PLAYING || !currentSong?.id) {
return; return;
@@ -570,6 +602,8 @@ export const useScrobble = () => {
albumId: currentSong.albumId, albumId: currentSong.albumId,
event: 'start', event: 'start',
id: currentSong.id, id: currentSong.id,
mediaType: mediaType,
playbackRate: playbackRate,
position: 0, position: 0,
submission: false, submission: false,
}, },
@@ -587,7 +621,7 @@ export const useScrobble = () => {
}, },
); );
flushScrobbleDebug(); flushScrobbleDebug();
}, [flushScrobbleDebug, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble]); }, [isScrobbleEnabled, isPrivateModeEnabled, sendScrobble, playbackRate, flushScrobbleDebug]);
// Update previous timestamp on progress for use in status change handler // Update previous timestamp on progress for use in status change handler
const handleProgressUpdate = useCallback( const handleProgressUpdate = useCallback(
@@ -607,20 +641,22 @@ export const useScrobble = () => {
} }
const song = usePlayerStore.getState().getCurrentSong(); const song = usePlayerStore.getState().getCurrentSong();
const mediaType = song?._itemType.includes('song') ? 'song' : 'podcast';
const useTicks = song?._serverType === ServerType.JELLYFIN;
if (!song?.id) { if (!song?.id) {
return; return;
} }
const position =
song._serverType === ServerType.JELLYFIN ? song.duration * 1e7 : undefined;
sendScrobble.mutate( sendScrobble.mutate(
{ {
apiClientProps: { serverId: song._serverId || '' }, apiClientProps: { serverId: song._serverId || '' },
query: { query: {
albumId: song.albumId, albumId: song.albumId,
id: song.id, id: song.id,
position, mediaType: mediaType,
playbackRate: playbackRate,
position: getPositionValue(song.duration ?? 0, useTicks),
submission: true, submission: true,
}, },
}, },
@@ -659,7 +695,7 @@ export const useScrobble = () => {
}); });
return () => registerScrobbleManualHandlers(null); return () => registerScrobbleManualHandlers(null);
}, [flushScrobbleDebug, isPrivateModeEnabled, isScrobbleEnabled, sendScrobble]); }, [flushScrobbleDebug, isPrivateModeEnabled, isScrobbleEnabled, playbackRate, sendScrobble]);
usePlayerEvents( usePlayerEvents(
{ {
+14
View File
@@ -454,6 +454,7 @@ const similarSongs2 = z.object({
export enum SubsonicExtensions { export enum SubsonicExtensions {
FORM_POST = 'formPost', FORM_POST = 'formPost',
INDEX_BASED_QUEUE = 'indexBasedQueue', INDEX_BASED_QUEUE = 'indexBasedQueue',
PLAYBACK_REPORT = 'playbackReport',
SONG_LYRICS = 'songLyrics', SONG_LYRICS = 'songLyrics',
TRANSCODE_OFFSET = 'transcodeOffset', TRANSCODE_OFFSET = 'transcodeOffset',
TRANSCODING = 'transcoding', TRANSCODING = 'transcoding',
@@ -793,6 +794,17 @@ const getInternetRadioStations = z.object({
.optional(), .optional(),
}); });
const reportPlaybackParameters = z.object({
ignoreScrobble: z.boolean().optional(),
mediaId: z.string(),
mediaType: z.enum(['song', 'podcast']),
playbackRate: z.number().optional(),
positionMs: z.number(),
state: z.enum(['starting', 'playing', 'paused', 'stopped']),
});
const reportPlayback = z.null();
export const ssType = { export const ssType = {
_body: { _body: {
getTranscodeDecision: transcodeDecisionRequestBody, getTranscodeDecision: transcodeDecisionRequestBody,
@@ -824,6 +836,7 @@ export const ssType = {
getTranscodeStream: getTranscodeStreamParameters, getTranscodeStream: getTranscodeStreamParameters,
randomSongList: randomSongListParameters, randomSongList: randomSongListParameters,
removeFavorite: removeFavoriteParameters, removeFavorite: removeFavoriteParameters,
reportPlayback: reportPlaybackParameters,
savePlayQueueByIndex: savePlayQueueByIndexParameters, savePlayQueueByIndex: savePlayQueueByIndexParameters,
saveQueue: saveQueueParameters, saveQueue: saveQueueParameters,
scrobble: scrobbleParameters, scrobble: scrobbleParameters,
@@ -877,6 +890,7 @@ export const ssType = {
playQueueByIndex, playQueueByIndex,
randomSongList, randomSongList,
removeFavorite, removeFavorite,
reportPlayback,
saveQueue, saveQueue,
scrobble, scrobble,
search3, search3,
+2
View File
@@ -1365,6 +1365,8 @@ export type ScrobbleQuery = {
albumId?: string; albumId?: string;
event?: 'pause' | 'start' | 'timeupdate' | 'unpause'; event?: 'pause' | 'start' | 'timeupdate' | 'unpause';
id: string; id: string;
mediaType: 'podcast' | 'song';
playbackRate: number;
position?: number; position?: number;
submission: boolean; submission: boolean;
}; };
+1
View File
@@ -13,6 +13,7 @@ export enum ServerFeature {
PLAYLIST_IMAGE_UPLOAD = 'playlistImageUpload', PLAYLIST_IMAGE_UPLOAD = 'playlistImageUpload',
PLAYLISTS_SMART = 'playlistsSmart', PLAYLISTS_SMART = 'playlistsSmart',
PUBLIC_PLAYLIST = 'publicPlaylist', PUBLIC_PLAYLIST = 'publicPlaylist',
REPORT_PLAYBACK = 'reportPlayback',
SERVER_PLAY_QUEUE = 'serverPlayQueue', SERVER_PLAY_QUEUE = 'serverPlayQueue',
SHARING_ALBUM_SONG = 'sharingAlbumSong', SHARING_ALBUM_SONG = 'sharingAlbumSong',
SIMILAR_SONGS_MUSIC_FOLDER = 'similarSongsMusicFolder', SIMILAR_SONGS_MUSIC_FOLDER = 'similarSongsMusicFolder',