feat(web+android): Gesamtdauer mit Stunden, in Android neu

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-24 22:38:40 +02:00
1 parent c7fed82736
commit 37f8f8acac
7 files changed
+50 -7

No files matched your search

+11 -1
View File
@@ -80,7 +80,7 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d
## [3.1] — 2026-09-22
**Android:** 1.15 (versionCode 18) → 1.23 (versionCode 26). 1.16 brachte den Rollbalken, 1.17 bis 1.21 machten ihn bedienbar, 1.19 das Bearbeiten der Warteschlange, 1.22 die Wischgeste, den Mischen-Fehler und die zwei neuen Designs, 1.23 den Honigtopf.
**Android:** 1.15 (versionCode 18) → 1.23 (versionCode 26). 1.16 brachte den Rollbalken, 1.17 bis 1.21 machten ihn bedienbar, 1.19 das Bearbeiten der Warteschlange, 1.22 die Wischgeste, den Mischen-Fehler und die zwei neuen Designs, 1.23 den Honigtopf und die Gesamtdauer.
### Added
- **Zwei neue Designs: Violet und Crimson (Web + Android)** — ein helles Lila auf tiefem
@@ -126,6 +126,16 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d
### Changed
- **Gesamtdauer mit Stunden (Web + Android)** — im Web stand bei Playlists und Alben nur eine
Minutenzahl; „134 Min." muss man selbst umrechnen, und bei einer Playlist mit 2000 Titeln steht
dort eine vierstellige Zahl. Ab einer Stunde heißt es jetzt „2 Std. 14 Min.", volle Stunden ohne
Anhängsel („2 Std."). **In Android fehlte die Dauer ganz** — Playlist und Album zeigen sie jetzt
ebenfalls.
- Kostet keinen zusätzlichen Aufruf: Subsonic liefert `duration` beim Playlist- und Album-Objekt
mit, in Android wird genau die genommen; im Web wird weiterhin über die geladenen Titel
summiert, weil die dort ohnehin schon vorliegen.
- Dieselbe Regel und dieselbe Schreibweise auf beiden Plattformen, damit dieselbe Playlist nicht
zwei verschiedene Angaben trägt. Neun Fälle geprüft (0, 59 Min., genau 1 Std., 100 Std., leer).
- **Benachrichtigungen tragen einen Honigtopf mit Brief statt der Glocke (Web + Android)** —
reine Strichzeichnung wie die übrigen Symbole, damit sie die Akzentfarbe annimmt, wenn etwas
Ungelesenes anliegt. Im Web als Inline-SVG, in Android als Vektor-Drawable mit denselben
@@ -0,0 +1,16 @@
package com.novasonic.app.ui
/**
* Gesamtdauer einer Playlist oder eines Albums, in Sekunden hereingereicht.
*
* Ab einer Stunde werden Stunden ausgewiesen: "134 Min." muss man sonst selbst umrechnen, und bei
* einer Playlist mit 2000 Titeln staende dort eine vierstellige Minutenzahl. Gleiche Regel und
* gleiche Schreibweise wie `formatGesamtdauer` im Web (src/utils.js).
*/
fun formatGesamtdauer(sekunden: Int): String {
val min = Math.round((sekunden.coerceAtLeast(0)) / 60.0).toInt()
if (min < 60) return "$min Min."
val std = min / 60
val rest = min % 60
return if (rest > 0) "$std Std. $rest Min." else "$std Std."
}
@@ -39,6 +39,7 @@ import com.novasonic.app.ui.component.SongRow
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.AlbumViewModel
import com.novasonic.app.ui.component.Rollbalken
import com.novasonic.app.ui.formatGesamtdauer
@Composable
fun AlbumScreen(
@@ -148,6 +149,7 @@ fun AlbumScreen(
album.year?.toString(),
album.genre?.takeIf { it.isNotBlank() },
if (album.songCount > 0) "${album.songCount} Songs" else null,
if (album.duration > 0) formatGesamtdauer(album.duration) else null,
).joinToString(" · ")
if (meta.isNotBlank()) {
Spacer(Modifier.height(3.dp))
@@ -41,6 +41,7 @@ import com.novasonic.app.ui.component.SongRow
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.PlaylistViewModel
import com.novasonic.app.ui.component.Rollbalken
import com.novasonic.app.ui.formatGesamtdauer
@Composable
fun PlaylistDetailScreen(
@@ -182,6 +183,9 @@ fun PlaylistDetailScreen(
// Beschriftung — bei einer geteilten Liste ist genau das die interessante Angabe.
val meta = listOfNotNull(
if (pl.songCount > 0) "${pl.songCount} Songs" else null,
// Gesamtdauer: Subsonic liefert sie beim Playlist-Objekt mit, es kostet
// also keinen zusaetzlichen Aufruf.
if (pl.duration > 0) formatGesamtdauer(pl.duration) else null,
pl.owner?.takeIf { it.isNotBlank() && it != state.currentUser }?.let { "von $it" },
).joinToString(" · ")
if (meta.isNotBlank()) {
+11
View File
@@ -47,6 +47,17 @@ export function formatDur(s) {
return `${m}:${sec.toString().padStart(2,'0')}`;
}
// Gesamtdauer einer Playlist oder eines Albums. Ab einer Stunde werden Stunden ausgewiesen —
// "134 Min." muss man sonst selbst umrechnen, und bei einer Playlist mit 2000 Titeln steht dort
// sonst eine vierstellige Minutenzahl.
export function formatGesamtdauer(sekunden) {
const min = Math.round((sekunden || 0) / 60);
if (min < 60) return `${min} Min.`;
const std = Math.floor(min / 60);
const rest = min % 60;
return rest ? `${std} Std. ${rest} Min.` : `${std} Std.`;
}
// Fan/count formatting in German: full grouped number up to a million (559534 → "559.542"),
// then compact millions (1200000 → "1,2 Mio.").
export function fmtCount(n) {
+3 -3
View File
@@ -1,5 +1,5 @@
import { S } from '../state.js';
import { esc, toast, fmtCount } from '../utils.js';
import { esc, toast, fmtCount, formatGesamtdauer } from '../utils.js';
import { api, invalidateApiCache, coverUrl, deezerSearch, deezerLookupAlbum, deezerLookupArtistSongs, deezerLookupArtistAlbums, deezerArtistImage, deezerRelatedArtists, fetchRomajiIndex, dispArtistById, dispAlbumName, cacheArtistSortNames, ensureArtistSortMap, getArtistOverride } from '../api.js';
import { openArtistPin } from '../ui/artistPin.js';
import { makeCard, makeAlbumCard, makeGlobalTrackItem, makeSongItem } from '../ui/components.js';
@@ -671,8 +671,8 @@ export async function loadAlbumDetail(album) {
try {
const r = await api('getAlbum', `&id=${album.id}`);
const songs = r.album?.song || [];
const totalMin = Math.round(songs.reduce((s,t) => s+(t.duration||0), 0) / 60);
document.getElementById('album-detail-stats').textContent = `${songs.length} Titel · ${r.album.year||''} · ${totalMin} Min.`;
const gesamt = songs.reduce((s,t) => s+(t.duration||0), 0);
document.getElementById('album-detail-stats').textContent = `${songs.length} Titel · ${r.album.year||''} · ${formatGesamtdauer(gesamt)}`;
_verdrahteMerken(
document.getElementById('btn-star-album'), album.id, r.album?.starred,
'Gemerkt', 'Merken', '❤ Album gemerkt', 'Aus den Favoriten entfernt',
+3 -3
View File
@@ -1,5 +1,5 @@
import { S } from '../state.js';
import { esc, toast } from '../utils.js';
import { esc, toast, formatGesamtdauer } from '../utils.js';
import { api, apiCached, apiIsCached, coverUrl, romajiLookup, dispArtistById, getArtistOverride } from '../api.js';
import { makeCard, makeAlbumCard, makeSongItem, mkSection } from '../ui/components.js';
import { playQueue } from '../player/player.js';
@@ -62,10 +62,10 @@ export async function loadPlaylistDetail(pl) {
const full = r.playlist || {};
S._currentPlaylist = {...pl, ...full, id: pl.id};
const songs = full.entry || [];
const totalMin = Math.round(songs.reduce((s,t) => s+(t.duration||0), 0) / 60);
const gesamt = songs.reduce((s,t) => s+(t.duration||0), 0);
const von = _besitzer({ ...pl, ...full });
document.getElementById('playlist-detail-stats').textContent =
`${songs.length} Titel · ${totalMin} Min.` + (von ? ` · von ${von}` : '');
`${songs.length} Titel · ${formatGesamtdauer(gesamt)}` + (von ? ` · von ${von}` : '');
const overviewCard = document.querySelector(`#playlists-grid [data-pl-id="${pl.id}"]`);
if (overviewCard) overviewCard.querySelector('.card-sub').textContent =
songs.length + ' Titel' + (von ? ' · von ' + von : '');