refactor: restructure src into player/views/ui subfolders; fix notification skip buttons

- Move player.js + nowplaying.js → src/player/
- Move views.js + spotify/soundcloud/youtube/abs → src/views/
- Move ui.js + components.js → src/ui/
- Update all import paths (static + lazy) across every file
- MusicService: wrap ExoPlayer in ForwardingPlayer that always advertises
  next/prev commands so the notification skip buttons stay visible in radio
  mode even when the JS queue has only one native track

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-06-06 13:12:27 +02:00
1 parent d8908c69e9
commit 308a27d293
11 files changed
+78 -54

No files matched your search

@@ -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
+8 -8
View File
@@ -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() {
@@ -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)
+4 -4
View File
@@ -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 ───────────────────────────────────────────────
+5 -5
View File
@@ -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 = `<div class="no-art">${fallback}</div>`; 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='💿') {
+8 -8
View File
@@ -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 = '';
+3 -3
View File
@@ -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.
@@ -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
+5 -5
View File
@@ -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) {
+9 -9
View File
@@ -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);
};
+3 -3
View File
@@ -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();