diff --git a/android/app/src/main/kotlin/com/novasonic/app/MusicService.kt b/android/app/src/main/kotlin/com/novasonic/app/MusicService.kt index 57ad155..11b4b26 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/MusicService.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/MusicService.kt @@ -6,6 +6,7 @@ import android.os.Handler import android.os.Looper import androidx.media3.common.AudioAttributes import androidx.media3.common.C +import androidx.media3.common.ForwardingPlayer import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.Player @@ -97,6 +98,28 @@ class MusicService : MediaSessionService() { .setWakeMode(C.WAKE_MODE_NETWORK) // holds WakeLock + WifiLock through Doze .build() + // ForwardingPlayer that always reports next/prev as available commands. + // ExoPlayer drops those commands when its queue has only one item (single-track model), + // which makes Media3 hide the skip buttons in the notification. JS still owns queue + // logic — the MediaSession callback intercepts the commands and delegates to JS. + val sessionPlayer = object : ForwardingPlayer(player) { + override fun getAvailableCommands(): Player.Commands = + super.getAvailableCommands().buildUpon() + .add(Player.COMMAND_SEEK_TO_NEXT) + .add(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) + .add(Player.COMMAND_SEEK_TO_PREVIOUS) + .add(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) + .build() + + override fun isCommandAvailable(command: Int): Boolean = when (command) { + Player.COMMAND_SEEK_TO_NEXT, + Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM, + Player.COMMAND_SEEK_TO_PREVIOUS, + Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM -> true + else -> super.isCommandAvailable(command) + } + } + // Tapping the notification opens the app directly to the Now Playing screen. val notifTapIntent = Intent(this, MainActivity::class.java).apply { putExtra("open_now_playing", true) @@ -107,7 +130,7 @@ class MusicService : MediaSessionService() { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) - mediaSession = MediaSession.Builder(this, player) + mediaSession = MediaSession.Builder(this, sessionPlayer) .setBitmapLoader(bitmapLoader) .setSessionActivity(notifTapPending) .setCallback(object : MediaSession.Callback { @@ -148,6 +171,7 @@ class MusicService : MediaSessionService() { // No play() — wait for JS to decide whether to auto-play } } catch (_: Exception) {} + } override fun onGetSession(controllerInfo: MediaSession.ControllerInfo) = mediaSession diff --git a/src/main.js b/src/main.js index ea9259f..12a307a 100644 --- a/src/main.js +++ b/src/main.js @@ -10,18 +10,18 @@ import { toggleRadioMode, starCurrent, downloadCurrent, renderQueue, playPreview, toggleStar, dlSongLocal, _applyRadioBtn, _applyShuffleRepeat, _restoreLastPlayed, setPlayIcon, npToggleStar, updateMediaSession, updateNpColorMode, reconcileNativeState, -} from './player.js'; +} from './player/player.js'; import { navigate, _navGoBack, _navLoad, onHeaderSearch, closeHeaderSearch, onGlobalSearch, s_goArtist, s_goAlbum, playerBarGoArtist, playerBarGoAlbum, searchSimilarArtist, searchAndPreview, loadItunesAlbum, expandArtistBio, loadAlbums, loadStarred, loadRandom, filterStarred, filterPlaylist, plReorderEnter, plReorderSave, plReorderCancel, toggleAllGenres, -} from './views.js'; +} from './views/views.js'; import { loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll, - onImporterInput, resolveImporterLink } from './spotify.js'; -import { resolveYouTubeLink, executeYouTubeDownload } from './youtube.js'; -import { loadSoundCloudImporter, resolveSoundCloudLink, clearSoundCloudPreview, scDlAll, scDlSingle, scDlTrack } from './soundcloud.js'; + onImporterInput, resolveImporterLink } from './views/spotify.js'; +import { resolveYouTubeLink, executeYouTubeDownload } from './views/youtube.js'; +import { loadSoundCloudImporter, resolveSoundCloudLink, clearSoundCloudPreview, scDlAll, scDlSingle, scDlTrack } from './views/soundcloud.js'; import { absInitFromStorage, absOpenSettings, absConnectSettings, absSetLibrary, absDisconnect, absVerifyApiKey, absToggleConnection, absPlayItem, absTogglePlay, absSeek, absSkipSecs, absNextChapter, absPrevChapter, @@ -29,11 +29,11 @@ import { absToggleSpeedPicker, absToggleSleepPicker, absSeekToChapter, absProgDown, absProgMove, absProgUp, loadAbsBooks, absOpenBook, loadAbsBookDetail, absPlayChapter, absLoadSettingsLibraries, absToggleChapterTrack, absInterrupt, -} from './abs.js'; +} from './views/abs.js'; import { openNowPlaying, closeNowPlaying, switchNpTab, npGoArtist, npGoAlbum, openNpMenu, closeNpMenu, -} from './nowplaying.js'; +} from './player/nowplaying.js'; import { openSettings, closeSettings, saveSettings, confirmDisconnect, checkBackend, openSidebar, closeSidebar, switchNavMode, showCtxMenu, @@ -48,7 +48,7 @@ import { toggleInfoEdit, onInfoArtFile, autofillMusicBrainz, autofillItunes, saveMetadata, deleteSong, addRomajiOverrideUI, removeRomajiOverrideUI, openRomajiOverridesModal, closeRomajiOverridesModal, -} from './ui.js'; +} from './ui/ui.js'; // ─── CONNECT ─── async function doConnect() { diff --git a/src/nowplaying.js b/src/player/nowplaying.js similarity index 98% rename from src/nowplaying.js rename to src/player/nowplaying.js index 88abac8..2fe8bbb 100644 --- a/src/nowplaying.js +++ b/src/player/nowplaying.js @@ -1,10 +1,10 @@ -import { S } from './state.js'; -import { esc } from './utils.js'; -import { coverUrl } from './api.js'; +import { S } from '../state.js'; +import { esc } from '../utils.js'; +import { coverUrl } from '../api.js'; // Circular imports — safe (only used inside function bodies) import { playCurrent, _syncNpDirect, dlSongLocal, npToggleStar, _syncOverlayState, isPlaying, seekToMs, updatePlayerUI } from './player.js'; -import { navigate } from './views.js'; -import { openPlModal, showSongInfo } from './ui.js'; +import { navigate } from '../views/views.js'; +import { openPlModal, showSongInfo } from '../ui/ui.js'; // ─── NOW PLAYING OVERLAY ─── export function openNowPlaying() { @@ -148,7 +148,7 @@ async function _loadNpLyrics(songId) { const token = ++_lyricsLoadToken; const s = S.queue[S.qIdx]; - const { getLyricsBySongId } = await import('./api.js'); + const { getLyricsBySongId } = await import('../api.js'); if(token !== _lyricsLoadToken) return; // 1. Navidrome (plugin handles fetching + caching server-side) diff --git a/src/player.js b/src/player/player.js similarity index 99% rename from src/player.js rename to src/player/player.js index e7db00c..d7de7d3 100644 --- a/src/player.js +++ b/src/player/player.js @@ -1,8 +1,8 @@ -import { S } from './state.js'; -import { esc, formatDur, toast } from './utils.js'; -import { api, streamUrl, coverUrl, downloadUrl, dispTitle, dispArtist, dispArtistParts, dispAlbum } from './api.js'; +import { S } from '../state.js'; +import { esc, formatDur, toast } from '../utils.js'; +import { api, streamUrl, coverUrl, downloadUrl, dispTitle, dispArtist, dispArtistParts, dispAlbum } from '../api.js'; // views.js/ui.js circular imports — safe (only used inside function bodies) -import { loadRecommendations } from './views.js'; +import { loadRecommendations } from '../views/views.js'; import { updateLyricsHighlight, onNpSongChange, syncNowPlaying } from './nowplaying.js'; // ─── NATIVE BRIDGE DETECTION ─────────────────────────────────────────────── diff --git a/src/components.js b/src/ui/components.js similarity index 96% rename from src/components.js rename to src/ui/components.js index 8994833..5acdee3 100644 --- a/src/components.js +++ b/src/ui/components.js @@ -1,5 +1,5 @@ -import { S } from './state.js'; -import { esc, formatDur } from './utils.js'; +import { S } from '../state.js'; +import { esc, formatDur } from '../utils.js'; // Exposed on window so inline onerror= handlers can call it. // Retries the same src up to 3 times with increasing delays, then shows fallback. @@ -8,11 +8,11 @@ window._imgRetry = function(img, fallback) { if (n > 3 || !img.src) { img.parentNode.innerHTML = `
${fallback}
`; return; } setTimeout(() => { const s = img.src; img.src = ''; img.src = s; }, 1500 * n); }; -import { coverUrl, dispTitle, dispArtistParts, dispAlbum, dispArtistById, dispAlbumName } from './api.js'; +import { coverUrl, dispTitle, dispArtistParts, dispAlbum, dispArtistById, dispAlbumName } from '../api.js'; // Circular imports — safe (only used inside event handlers after module init) -import { playQueue, toggleStar, playPreview, parseArtists } from './player.js'; +import { playQueue, toggleStar, playPreview, parseArtists } from '../player/player.js'; import { showCtxMenu, openDlModal } from './ui.js'; -import { navigate, quickPlayAlbum, quickPlayArtist } from './views.js'; +import { navigate, quickPlayAlbum, quickPlayArtist } from '../views/views.js'; // ─── GENERIC CARD ─── export function makeCard(title, sub, imgUrl, fallback='💿') { diff --git a/src/ui.js b/src/ui/ui.js similarity index 99% rename from src/ui.js rename to src/ui/ui.js index ded5268..fcfabe0 100644 --- a/src/ui.js +++ b/src/ui/ui.js @@ -1,10 +1,10 @@ -import { S } from './state.js'; -import { esc, formatDur, toast } from './utils.js'; -import { api, coverUrl, ndAuth, fetchRomajiIndex, romajiConvert, romajiLookup, invalidateRomajiIndex, invalidateCoverCache, getRomajiOverrides, setRomajiOverride, removeRomajiOverride } from './api.js'; -import { THEMES, applyTheme, getCurrentThemeId } from './themes.js'; +import { S } from '../state.js'; +import { esc, formatDur, toast } from '../utils.js'; +import { api, coverUrl, ndAuth, fetchRomajiIndex, romajiConvert, romajiLookup, invalidateRomajiIndex, invalidateCoverCache, getRomajiOverrides, setRomajiOverride, removeRomajiOverride } from '../api.js'; +import { THEMES, applyTheme, getCurrentThemeId } from '../themes.js'; // Circular imports — safe (only used inside function bodies) -import { playQueue, dlSongLocal, toggleStar, togglePlay, renderQueue, syncNpStar, updateStarBtn, npToggleStar, playCurrent, setPlayIcon, nativeInsertAt, refreshDisplayMode, updateNpColorMode } from './player.js'; -import { navigate, loadPlaylists, loadPlaylistDetail } from './views.js'; +import { playQueue, dlSongLocal, toggleStar, togglePlay, renderQueue, syncNpStar, updateStarBtn, npToggleStar, playCurrent, setPlayIcon, nativeInsertAt, refreshDisplayMode, updateNpColorMode } from '../player/player.js'; +import { navigate, loadPlaylists, loadPlaylistDetail } from '../views/views.js'; // ─── SIDEBAR ─── export function openSidebar() { @@ -30,7 +30,7 @@ export function toggleMobileSearch() { document.getElementById('header-search-input')?.focus(); } export function closeMobileSearch() { - import('./views.js').then(m => m.closeHeaderSearch()); + import('../views/views.js').then(m => m.closeHeaderSearch()); } // ─── SETUP ─── @@ -41,7 +41,7 @@ export function switchSetupTab(t) { // ─── SETTINGS ─── export function openSettings() { // Lazy import to avoid circular dep — abs.js imports views.js - import('./abs.js').then(m => m.absOpenSettings()); + import('../views/abs.js').then(m => m.absOpenSettings()); document.getElementById('s-url').value = S.cfg.url || ''; document.getElementById('s-user').value = S.cfg.user || ''; document.getElementById('s-pass').value = ''; diff --git a/src/abs.js b/src/views/abs.js similarity index 99% rename from src/abs.js rename to src/views/abs.js index 62fbaa4..d5c1962 100644 --- a/src/abs.js +++ b/src/views/abs.js @@ -1,6 +1,6 @@ -import { S } from './state.js'; -import { esc, toast } from './utils.js'; -import { cancelMusicAutoplay } from './player.js'; +import { S } from '../state.js'; +import { esc, toast } from '../utils.js'; +import { cancelMusicAutoplay } from '../player/player.js'; // ─── HTTP HELPERS ─── // API calls are routed through the NovaSonic backend proxy to avoid CORS restrictions. diff --git a/src/soundcloud.js b/src/views/soundcloud.js similarity index 98% rename from src/soundcloud.js rename to src/views/soundcloud.js index f3f3a94..6de8eef 100644 --- a/src/soundcloud.js +++ b/src/views/soundcloud.js @@ -1,5 +1,5 @@ -import { S } from './state.js'; -import { esc, toast } from './utils.js'; +import { S } from '../state.js'; +import { esc, toast } from '../utils.js'; export function loadSoundCloudImporter() { // Hint is managed by the unified importer input in spotify.js diff --git a/src/spotify.js b/src/views/spotify.js similarity index 98% rename from src/spotify.js rename to src/views/spotify.js index f723781..13ce832 100644 --- a/src/spotify.js +++ b/src/views/spotify.js @@ -1,9 +1,9 @@ -import { S } from './state.js'; -import { esc, toast } from './utils.js'; -import { itunesSearch } from './api.js'; +import { S } from '../state.js'; +import { esc, toast } from '../utils.js'; +import { itunesSearch } from '../api.js'; // Circular imports — safe (only used inside function bodies) -import { makeGlobalTrackItem } from './components.js'; -import { openDlModal } from './ui.js'; +import { makeGlobalTrackItem } from '../ui/components.js'; +import { openDlModal } from '../ui/ui.js'; // ─── UNIFIED IMPORTER INPUT ─── export function onImporterInput(val) { diff --git a/src/views.js b/src/views/views.js similarity index 99% rename from src/views.js rename to src/views/views.js index 56aa93b..836e637 100644 --- a/src/views.js +++ b/src/views/views.js @@ -1,12 +1,12 @@ -import { S } from './state.js'; -import { esc, formatDur, toast } from './utils.js'; -import { api, apiCached, apiIsCached, coverUrl, itunesSearch, itunesLookupAlbum, itunesLookupArtistSongs, itunesLookupArtistAlbums, itunesTopCharts, itunesTopSongs } from './api.js'; +import { S } from '../state.js'; +import { esc, formatDur, toast } from '../utils.js'; +import { api, apiCached, apiIsCached, coverUrl, itunesSearch, itunesLookupAlbum, itunesLookupArtistSongs, itunesLookupArtistAlbums, itunesTopCharts, itunesTopSongs } from '../api.js'; // Circular imports — safe (only used inside function bodies) -import { playQueue, dlSongLocal, playPreview, toggleStar, parseArtists } from './player.js'; -import { showCtxMenu, openEditPlModal, openDlModal, closeSidebar, switchNavMode } from './ui.js'; -import { closeNowPlaying, closeNpMenu } from './nowplaying.js'; -import { makeCard, makeAlbumCard, makeSongItem, makeGlobalTrackItem, mkSection } from './components.js'; -import { fetchRomajiIndex, dispArtistById, dispAlbumName, dispAlbum, romajiLookup } from './api.js'; +import { playQueue, dlSongLocal, playPreview, toggleStar, parseArtists } from '../player/player.js'; +import { showCtxMenu, openEditPlModal, openDlModal, closeSidebar, switchNavMode } from '../ui/ui.js'; +import { closeNowPlaying, closeNpMenu } from '../player/nowplaying.js'; +import { makeCard, makeAlbumCard, makeSongItem, makeGlobalTrackItem, mkSection } from '../ui/components.js'; +import { fetchRomajiIndex, dispArtistById, dispAlbumName, dispAlbum, romajiLookup } from '../api.js'; import { loadSpotifyImporter } from './spotify.js'; import { loadSoundCloudImporter } from './soundcloud.js'; @@ -496,7 +496,7 @@ export async function loadArtistDetail(artist) { shuffleBtn.onclick = async () => { S.shuffle = true; localStorage.setItem('ns_shuffle', '1'); - import('./player.js').then(({ _applyShuffleRepeat }) => _applyShuffleRepeat()); + import('../player/player.js').then(({ _applyShuffleRepeat }) => _applyShuffleRepeat()); await quickPlayArtist(artist); }; diff --git a/src/youtube.js b/src/views/youtube.js similarity index 97% rename from src/youtube.js rename to src/views/youtube.js index f72fbb1..83eef51 100644 --- a/src/youtube.js +++ b/src/views/youtube.js @@ -1,6 +1,6 @@ -import { S } from './state.js'; -import { esc, toast } from './utils.js'; -import { makeGlobalTrackItem } from './components.js'; +import { S } from '../state.js'; +import { esc, toast } from '../utils.js'; +import { makeGlobalTrackItem } from '../ui/components.js'; export async function resolveYouTubeLink() { const url = document.getElementById('importer-link-input')?.value.trim();