feat: music videos
This commit is contained in:
parent
e3f781d588
commit
75d16e6ce4
16 changed files with 1189 additions and 448 deletions
|
|
@ -84,7 +84,7 @@ class ServerAPI {
|
||||||
|
|
||||||
getCoverUrl(id, size = '1280') {
|
getCoverUrl(id, size = '1280') {
|
||||||
if (!id) return '';
|
if (!id) return '';
|
||||||
const formattedId = id.replace(/-/g, '/');
|
const formattedId = String(id).replace(/-/g, '/');
|
||||||
return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`;
|
return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ class ServerAPI {
|
||||||
|
|
||||||
getCoverUrl(id, size = '1080') {
|
getCoverUrl(id, size = '1080') {
|
||||||
if (!id) return '';
|
if (!id) return '';
|
||||||
const formattedId = id.replace(/-/g, '/');
|
const formattedId = String(id).replace(/-/g, '/');
|
||||||
return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`;
|
return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ class ServerAPI {
|
||||||
|
|
||||||
getCoverUrl(id, size = '1280') {
|
getCoverUrl(id, size = '1280') {
|
||||||
if (!id) return '';
|
if (!id) return '';
|
||||||
const formattedId = id.replace(/-/g, '/');
|
const formattedId = String(id).replace(/-/g, '/');
|
||||||
return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`;
|
return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
31
index.html
31
index.html
|
|
@ -36,13 +36,13 @@
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<audio id="audio-player" crossorigin="anonymous"></audio>
|
<video id="audio-player" crossorigin="anonymous" style="display: none"></video>
|
||||||
<div id="context-menu">
|
<div id="context-menu">
|
||||||
<ul>
|
<ul>
|
||||||
<li data-action="shuffle-play-card" data-type-filter="album,playlist,mix,user-playlist">
|
<li data-action="shuffle-play-card" data-type-filter="album,playlist,mix,user-playlist">
|
||||||
Shuffle play
|
Shuffle play
|
||||||
</li>
|
</li>
|
||||||
<li data-action="start-mix" data-type-filter="album,track">Start mix</li>
|
<li data-action="start-mix" data-type-filter="album,track,video">Start mix</li>
|
||||||
<li data-action="play-next">Play next</li>
|
<li data-action="play-next">Play next</li>
|
||||||
<li data-action="add-to-queue">Add to queue</li>
|
<li data-action="add-to-queue">Add to queue</li>
|
||||||
<li
|
<li
|
||||||
|
|
@ -53,18 +53,20 @@
|
||||||
data-label-unlike-album="Remove album from library"
|
data-label-unlike-album="Remove album from library"
|
||||||
data-label-playlist="Save playlist to library"
|
data-label-playlist="Save playlist to library"
|
||||||
data-label-unlike-playlist="Remove playlist from library"
|
data-label-unlike-playlist="Remove playlist from library"
|
||||||
|
data-label-video="Like"
|
||||||
|
data-label-unlike-video="Unlike"
|
||||||
>
|
>
|
||||||
Like
|
Like
|
||||||
</li>
|
</li>
|
||||||
<li data-action="toggle-pin" data-type-filter="album,artist,playlist,user-playlist">Pin</li>
|
<li data-action="toggle-pin" data-type-filter="album,artist,playlist,user-playlist">Pin</li>
|
||||||
<li data-action="add-to-playlist" data-type-filter="track">Add to playlist</li>
|
<li data-action="add-to-playlist" data-type-filter="track,video">Add to playlist</li>
|
||||||
<li data-action="go-to-artist" data-type-filter="track,album">Go to artist</li>
|
<li data-action="go-to-artist" data-type-filter="track,album,video">Go to artist</li>
|
||||||
<li data-action="go-to-album" data-type-filter="track">Go to album</li>
|
<li data-action="go-to-album" data-type-filter="track,video">Go to album</li>
|
||||||
<li data-action="copy-link">Copy link</li>
|
<li data-action="copy-link">Copy link</li>
|
||||||
<li data-action="open-in-new-tab">Open in new tab</li>
|
<li data-action="open-in-new-tab">Open in new tab</li>
|
||||||
<li data-action="open-in-harmony" data-type-filter="album">Open in Harmony</li>
|
<li data-action="open-in-harmony" data-type-filter="album">Open in Harmony</li>
|
||||||
<li data-action="track-info" data-type-filter="track">Track info</li>
|
<li data-action="track-info" data-type-filter="track,video">Track info</li>
|
||||||
<li data-action="open-original-url" data-type-filter="track">Open original URL</li>
|
<li data-action="open-original-url" data-type-filter="track,video">Open original URL</li>
|
||||||
<li data-action="download">Download</li>
|
<li data-action="download">Download</li>
|
||||||
<li class="separator"></li>
|
<li class="separator"></li>
|
||||||
<li
|
<li
|
||||||
|
|
@ -122,6 +124,7 @@
|
||||||
<div id="visualizer-container">
|
<div id="visualizer-container">
|
||||||
<canvas id="visualizer-canvas"></canvas>
|
<canvas id="visualizer-canvas"></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="fullscreen-video-container" style="display: none; position: absolute; inset: 0; width: 100%; height: 100%; justify-content: center; align-items: center; background: black; z-index: 0;"></div>
|
||||||
<button id="toggle-ui-btn" class="fullscreen-ui-toggle" title="Toggle UI">
|
<button id="toggle-ui-btn" class="fullscreen-ui-toggle" title="Toggle UI">
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
|
@ -163,7 +166,9 @@
|
||||||
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
|
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
|
||||||
alt="Album Cover"
|
alt="Album Cover"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="fullscreen-track-info">
|
<div class="fullscreen-track-info">
|
||||||
|
|
||||||
<h2 id="fullscreen-track-title"></h2>
|
<h2 id="fullscreen-track-title"></h2>
|
||||||
<h3 id="fullscreen-track-artist"></h3>
|
<h3 id="fullscreen-track-artist"></h3>
|
||||||
<div class="fullscreen-actions">
|
<div class="fullscreen-actions">
|
||||||
|
|
@ -2358,6 +2363,7 @@
|
||||||
<h2 class="section-title" id="search-results-title">Search Results</h2>
|
<h2 class="section-title" id="search-results-title">Search Results</h2>
|
||||||
<div class="search-tabs">
|
<div class="search-tabs">
|
||||||
<button class="search-tab active" data-tab="tracks">Tracks</button>
|
<button class="search-tab active" data-tab="tracks">Tracks</button>
|
||||||
|
<button class="search-tab" data-tab="videos">Videos</button>
|
||||||
<button class="search-tab" data-tab="albums">Albums</button>
|
<button class="search-tab" data-tab="albums">Albums</button>
|
||||||
<button class="search-tab" data-tab="artists">Artists</button>
|
<button class="search-tab" data-tab="artists">Artists</button>
|
||||||
<button class="search-tab" data-tab="playlists">Playlists</button>
|
<button class="search-tab" data-tab="playlists">Playlists</button>
|
||||||
|
|
@ -2365,6 +2371,9 @@
|
||||||
<div class="search-tab-content active" id="search-tab-tracks">
|
<div class="search-tab-content active" id="search-tab-tracks">
|
||||||
<div class="track-list" id="search-tracks-container"></div>
|
<div class="track-list" id="search-tracks-container"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="search-tab-content" id="search-tab-videos">
|
||||||
|
<div class="card-grid" id="search-videos-container"></div>
|
||||||
|
</div>
|
||||||
<div class="search-tab-content" id="search-tab-albums">
|
<div class="search-tab-content" id="search-tab-albums">
|
||||||
<div class="card-grid" id="search-albums-container"></div>
|
<div class="card-grid" id="search-albums-container"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2403,6 +2412,7 @@
|
||||||
<h2 class="section-title">Favorites</h2>
|
<h2 class="section-title">Favorites</h2>
|
||||||
<div class="search-tabs">
|
<div class="search-tabs">
|
||||||
<button class="search-tab active" data-tab="tracks">Liked Tracks</button>
|
<button class="search-tab active" data-tab="tracks">Liked Tracks</button>
|
||||||
|
<button class="search-tab" data-tab="videos">Videos</button>
|
||||||
<button class="search-tab" data-tab="albums">Albums</button>
|
<button class="search-tab" data-tab="albums">Albums</button>
|
||||||
<button class="search-tab" data-tab="artists">Artists</button>
|
<button class="search-tab" data-tab="artists">Artists</button>
|
||||||
<button class="search-tab" data-tab="playlists">Playlists and Mixes</button>
|
<button class="search-tab" data-tab="playlists">Playlists and Mixes</button>
|
||||||
|
|
@ -2475,6 +2485,9 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="track-list" id="library-tracks-container"></div>
|
<div class="track-list" id="library-tracks-container"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="search-tab-content" id="library-tab-videos">
|
||||||
|
<div class="card-grid" id="library-videos-container"></div>
|
||||||
|
</div>
|
||||||
<div class="search-tab-content" id="library-tab-albums">
|
<div class="search-tab-content" id="library-tab-albums">
|
||||||
<div class="card-grid" id="library-albums-container"></div>
|
<div class="card-grid" id="library-albums-container"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -3177,6 +3190,10 @@
|
||||||
<h2 class="section-title">Albums</h2>
|
<h2 class="section-title">Albums</h2>
|
||||||
<div class="card-grid" id="artist-detail-albums"></div>
|
<div class="card-grid" id="artist-detail-albums"></div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="content-section" id="artist-section-videos" style="display: none">
|
||||||
|
<h2 class="section-title">Videos</h2>
|
||||||
|
<div class="card-grid" id="artist-detail-videos"></div>
|
||||||
|
</section>
|
||||||
<section class="content-section" id="artist-section-eps" style="display: none">
|
<section class="content-section" id="artist-section-eps" style="display: none">
|
||||||
<h2 class="section-title">EPs and Singles</h2>
|
<h2 class="section-title">EPs and Singles</h2>
|
||||||
<div class="card-grid" id="artist-detail-eps"></div>
|
<div class="card-grid" id="artist-detail-eps"></div>
|
||||||
|
|
|
||||||
|
|
@ -200,6 +200,19 @@ const syncManager = {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (type === 'video') {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
type: 'video',
|
||||||
|
title: item.title || null,
|
||||||
|
duration: item.duration || null,
|
||||||
|
image: item.image || item.cover || null,
|
||||||
|
artist: item.artist || (item.artists && item.artists.length > 0 ? item.artists[0] : null) || null,
|
||||||
|
artists: item.artists?.map((a) => ({ id: a.id, name: a.name || null })) || [],
|
||||||
|
album: item.album || { title: 'Video', cover: item.image || item.cover },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (type === 'album') {
|
if (type === 'album') {
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
|
|
@ -280,7 +293,7 @@ const syncManager = {
|
||||||
id: playlist.id,
|
id: playlist.id,
|
||||||
name: playlist.name,
|
name: playlist.name,
|
||||||
cover: playlist.cover || null,
|
cover: playlist.cover || null,
|
||||||
tracks: playlist.tracks ? playlist.tracks.map((t) => this._minifyItem('track', t)) : [],
|
tracks: playlist.tracks ? playlist.tracks.map((t) => this._minifyItem(t.type || 'track', t)) : [],
|
||||||
createdAt: playlist.createdAt || Date.now(),
|
createdAt: playlist.createdAt || Date.now(),
|
||||||
updatedAt: playlist.updatedAt || Date.now(),
|
updatedAt: playlist.updatedAt || Date.now(),
|
||||||
numberOfTracks: playlist.tracks ? playlist.tracks.length : 0,
|
numberOfTracks: playlist.tracks ? playlist.tracks.length : 0,
|
||||||
|
|
@ -572,7 +585,7 @@ const syncManager = {
|
||||||
id: playlist.id,
|
id: playlist.id,
|
||||||
name: playlist.name,
|
name: playlist.name,
|
||||||
cover: playlist.cover || null,
|
cover: playlist.cover || null,
|
||||||
tracks: playlist.tracks ? playlist.tracks.map((t) => this._minifyItem('track', t)) : [],
|
tracks: playlist.tracks ? playlist.tracks.map((t) => this._minifyItem(t.type || 'track', t)) : [],
|
||||||
createdAt: playlist.createdAt || Date.now(),
|
createdAt: playlist.createdAt || Date.now(),
|
||||||
updatedAt: playlist.updatedAt || Date.now(),
|
updatedAt: playlist.updatedAt || Date.now(),
|
||||||
numberOfTracks: playlist.tracks ? playlist.tracks.length : 0,
|
numberOfTracks: playlist.tracks ? playlist.tracks.length : 0,
|
||||||
|
|
|
||||||
353
js/api.js
353
js/api.js
|
|
@ -10,6 +10,7 @@ import { trackDateSettings, losslessContainerSettings } from './storage.js';
|
||||||
import { APICache } from './cache.js';
|
import { APICache } from './cache.js';
|
||||||
import { addMetadataToAudio } from './metadata.js';
|
import { addMetadataToAudio } from './metadata.js';
|
||||||
import { DashDownloader } from './dash-downloader.js';
|
import { DashDownloader } from './dash-downloader.js';
|
||||||
|
import { HlsDownloader } from './hls-downloader.js';
|
||||||
import { encodeToMp3, MP3EncodingError } from './mp3-encoder.js';
|
import { encodeToMp3, MP3EncodingError } from './mp3-encoder.js';
|
||||||
import { ffmpeg } from './ffmpeg.js';
|
import { ffmpeg } from './ffmpeg.js';
|
||||||
|
|
||||||
|
|
@ -59,6 +60,18 @@ export class LosslessAPI {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.allowedDomains) {
|
||||||
|
instances = instances.filter((instance) => {
|
||||||
|
const url = typeof instance === 'string' ? instance : instance.url;
|
||||||
|
return options.allowedDomains.some((domain) => url.includes(domain));
|
||||||
|
});
|
||||||
|
if (instances.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`No API instances configured for type: ${type} matching allowedDomains: ${options.allowedDomains.join(', ')}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const maxTotalAttempts = instances.length * 2; // Allow some retries across instances
|
const maxTotalAttempts = instances.length * 2; // Allow some retries across instances
|
||||||
let lastError = null;
|
let lastError = null;
|
||||||
let instanceIndex = Math.floor(Math.random() * instances.length);
|
let instanceIndex = Math.floor(Math.random() * instances.length);
|
||||||
|
|
@ -156,8 +169,15 @@ export class LosslessAPI {
|
||||||
prepareTrack(track) {
|
prepareTrack(track) {
|
||||||
let normalized = track;
|
let normalized = track;
|
||||||
|
|
||||||
|
if (track.type && typeof track.type === 'string') {
|
||||||
|
const lowType = track.type.toLowerCase();
|
||||||
|
if (lowType === 'video' || lowType === 'track') {
|
||||||
|
normalized = { ...track, type: lowType };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!track.artist && Array.isArray(track.artists) && track.artists.length > 0) {
|
if (!track.artist && Array.isArray(track.artists) && track.artists.length > 0) {
|
||||||
normalized = { ...track, artist: track.artists[0] };
|
normalized = { ...normalized, artist: track.artists[0] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const derivedQuality = deriveTrackQuality(normalized);
|
const derivedQuality = deriveTrackQuality(normalized);
|
||||||
|
|
@ -181,6 +201,16 @@ export class LosslessAPI {
|
||||||
return playlist;
|
return playlist;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prepareVideo(video) {
|
||||||
|
let normalized = { ...video, type: 'video' };
|
||||||
|
|
||||||
|
if (!video.artist && Array.isArray(video.artists) && video.artists.length > 0) {
|
||||||
|
normalized.artist = video.artists[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
prepareArtist(artist) {
|
prepareArtist(artist) {
|
||||||
if (!artist.type && Array.isArray(artist.artistTypes) && artist.artistTypes.length > 0) {
|
if (!artist.type && Array.isArray(artist.artistTypes) && artist.artistTypes.length > 0) {
|
||||||
return { ...artist, type: artist.artistTypes[0] };
|
return { ...artist, type: artist.artistTypes[0] };
|
||||||
|
|
@ -264,8 +294,22 @@ export class LosslessAPI {
|
||||||
}
|
}
|
||||||
|
|
||||||
extractStreamUrlFromManifest(manifest) {
|
extractStreamUrlFromManifest(manifest) {
|
||||||
|
if (!manifest) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const decoded = atob(manifest);
|
let decoded;
|
||||||
|
if (typeof manifest === 'string') {
|
||||||
|
try {
|
||||||
|
decoded = atob(manifest);
|
||||||
|
} catch {
|
||||||
|
decoded = manifest;
|
||||||
|
}
|
||||||
|
} else if (typeof manifest === 'object') {
|
||||||
|
if (manifest.urls?.[0]) return manifest.urls[0];
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if it's a DASH manifest (XML)
|
// Check if it's a DASH manifest (XML)
|
||||||
if (decoded.includes('<MPD')) {
|
if (decoded.includes('<MPD')) {
|
||||||
|
|
@ -414,6 +458,53 @@ export class LosslessAPI {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async searchVideos(query, options = {}) {
|
||||||
|
const cached = await this.cache.get('search_videos', query);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.fetchWithRetry(`/search/?v=${encodeURIComponent(query)}`, {
|
||||||
|
...options,
|
||||||
|
allowedDomains: ['api.monochrome.tf', 'arran.monochrome.tf'],
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
const normalized = this.normalizeSearchResponse(data, 'videos');
|
||||||
|
const result = {
|
||||||
|
...normalized,
|
||||||
|
items: normalized.items.map((v) => this.prepareVideo(v)),
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.cache.set('search_videos', query, result);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'AbortError') throw error;
|
||||||
|
console.error('Video search failed:', error);
|
||||||
|
return { items: [], limit: 0, offset: 0, totalNumberOfItems: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVideo(id) {
|
||||||
|
const cached = await this.cache.get('video', id);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const response = await this.fetchWithRetry(`/video/?id=${id}`, {
|
||||||
|
type: 'streaming',
|
||||||
|
allowedDomains: ['api.monochrome.tf', 'arran.monochrome.tf'],
|
||||||
|
});
|
||||||
|
const jsonResponse = await response.json();
|
||||||
|
|
||||||
|
const data = jsonResponse.data || jsonResponse;
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
track: data,
|
||||||
|
info: data,
|
||||||
|
originalTrackUrl: data.OriginalTrackUrl || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.cache.set('video', id, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
async getAlbum(id) {
|
async getAlbum(id) {
|
||||||
const cached = await this.cache.get('album', id);
|
const cached = await this.cache.get('album', id);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
@ -769,9 +860,11 @@ export class LosslessAPI {
|
||||||
|
|
||||||
const albumMap = new Map();
|
const albumMap = new Map();
|
||||||
const trackMap = new Map();
|
const trackMap = new Map();
|
||||||
|
const videoMap = new Map();
|
||||||
|
|
||||||
const isTrack = (v) => v?.id && v.duration && v.album;
|
const isTrack = (v) => v?.id && v.duration && v.album;
|
||||||
const isAlbum = (v) => v?.id && 'numberOfTracks' in v;
|
const isAlbum = (v) => v?.id && 'numberOfTracks' in v;
|
||||||
|
const isVideo = (v) => v?.id && v.type === 'VIDEO';
|
||||||
|
|
||||||
const scan = (value, visited = new Set()) => {
|
const scan = (value, visited = new Set()) => {
|
||||||
if (!value || typeof value !== 'object' || visited.has(value)) return;
|
if (!value || typeof value !== 'object' || visited.has(value)) return;
|
||||||
|
|
@ -785,6 +878,7 @@ export class LosslessAPI {
|
||||||
const item = value.item || value;
|
const item = value.item || value;
|
||||||
if (isAlbum(item)) albumMap.set(item.id, this.prepareAlbum(item));
|
if (isAlbum(item)) albumMap.set(item.id, this.prepareAlbum(item));
|
||||||
if (isTrack(item)) trackMap.set(item.id, this.prepareTrack(item));
|
if (isTrack(item)) trackMap.set(item.id, this.prepareTrack(item));
|
||||||
|
if (isVideo(item)) videoMap.set(item.id, this.prepareVideo(item));
|
||||||
|
|
||||||
Object.values(value).forEach((nested) => scan(nested, visited));
|
Object.values(value).forEach((nested) => scan(nested, visited));
|
||||||
};
|
};
|
||||||
|
|
@ -792,25 +886,23 @@ export class LosslessAPI {
|
||||||
entries.forEach((entry) => scan(entry));
|
entries.forEach((entry) => scan(entry));
|
||||||
|
|
||||||
if (!options.lightweight) {
|
if (!options.lightweight) {
|
||||||
// Attempt to find more albums/EPs via search since the direct feed might be limited
|
|
||||||
try {
|
try {
|
||||||
const searchResults = await this.searchAlbums(artist.name);
|
const videoSearch = await this.searchVideos(artist.name);
|
||||||
if (searchResults && searchResults.items) {
|
if (videoSearch && videoSearch.items) {
|
||||||
const numericArtistId = Number(artistId);
|
const numericArtistId = Number(artistId);
|
||||||
|
for (const item of videoSearch.items) {
|
||||||
for (const item of searchResults.items) {
|
|
||||||
const itemArtistId = item.artist?.id;
|
const itemArtistId = item.artist?.id;
|
||||||
const matchesArtist =
|
const matchesArtist =
|
||||||
itemArtistId === numericArtistId ||
|
itemArtistId === numericArtistId ||
|
||||||
(Array.isArray(item.artists) && item.artists.some((a) => a.id === numericArtistId));
|
(Array.isArray(item.artists) && item.artists.some(a => a.id === numericArtistId));
|
||||||
|
|
||||||
if (matchesArtist && !albumMap.has(item.id)) {
|
if (matchesArtist && !videoMap.has(item.id)) {
|
||||||
albumMap.set(item.id, item);
|
videoMap.set(item.id, item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Failed to fetch additional albums via search:', e);
|
console.warn('Failed to fetch additional videos via search:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -826,10 +918,13 @@ export class LosslessAPI {
|
||||||
.sort((a, b) => (b.popularity || 0) - (a.popularity || 0))
|
.sort((a, b) => (b.popularity || 0) - (a.popularity || 0))
|
||||||
.slice(0, 15);
|
.slice(0, 15);
|
||||||
|
|
||||||
|
const videos = Array.from(videoMap.values())
|
||||||
|
.sort((a, b) => new Date(b.releaseDate || 0) - new Date(a.releaseDate || 0));
|
||||||
|
|
||||||
// Enrich tracks with album release dates
|
// Enrich tracks with album release dates
|
||||||
const tracks = options.lightweight ? topTracks : await this.enrichTracksWithAlbumDates(topTracks);
|
const tracks = options.lightweight ? topTracks : await this.enrichTracksWithAlbumDates(topTracks);
|
||||||
|
|
||||||
const result = { ...artist, albums, eps, tracks };
|
const result = { ...artist, albums, eps, tracks, videos };
|
||||||
|
|
||||||
await this.cache.set('artist', cacheKey, result);
|
await this.cache.set('artist', cacheKey, result);
|
||||||
return result;
|
return result;
|
||||||
|
|
@ -1108,21 +1203,93 @@ export class LosslessAPI {
|
||||||
return streamUrl;
|
return streamUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getVideoStreamUrl(id) {
|
||||||
|
const cacheKey = `video_stream_${id}`;
|
||||||
|
|
||||||
|
if (this.streamCache.has(cacheKey)) {
|
||||||
|
return this.streamCache.get(cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lookup = await this.getVideo(id);
|
||||||
|
|
||||||
|
let streamUrl;
|
||||||
|
|
||||||
|
const findValue = (obj, key) => {
|
||||||
|
if (!obj || typeof obj !== 'object') return null;
|
||||||
|
if (obj[key]) return obj[key];
|
||||||
|
for (const v of Object.values(obj)) {
|
||||||
|
if (v && typeof v === 'object') {
|
||||||
|
const f = findValue(v, key);
|
||||||
|
if (f) return f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const manifest = findValue(lookup, 'manifest') || findValue(lookup, 'Manifest');
|
||||||
|
if (manifest) {
|
||||||
|
streamUrl = this.extractStreamUrlFromManifest(manifest);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!streamUrl) {
|
||||||
|
streamUrl =
|
||||||
|
findValue(lookup, 'OriginalTrackUrl') ||
|
||||||
|
findValue(lookup, 'originalTrackUrl') ||
|
||||||
|
findValue(lookup, 'url') ||
|
||||||
|
findValue(lookup, 'streamUrl') ||
|
||||||
|
findValue(lookup, 'manifestUrl');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!streamUrl) {
|
||||||
|
throw new Error(`Could not resolve video stream URL for ID: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.streamCache.set(cacheKey, streamUrl);
|
||||||
|
return streamUrl;
|
||||||
|
}
|
||||||
|
|
||||||
async downloadTrack(id, quality = 'HI_RES_LOSSLESS', filename, options = {}) {
|
async downloadTrack(id, quality = 'HI_RES_LOSSLESS', filename, options = {}) {
|
||||||
const { onProgress, track } = options;
|
const { onProgress, track } = options;
|
||||||
|
const isVideo = track?.type === 'video';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// MP3_320 is not a native TIDAL quality, we download LOSSLESS and convert
|
// MP3_320 is not a native TIDAL quality, we download LOSSLESS and convert
|
||||||
const downloadQuality = quality === 'MP3_320' ? 'LOSSLESS' : quality;
|
const downloadQuality = quality === 'MP3_320' ? 'LOSSLESS' : quality;
|
||||||
|
|
||||||
const lookup = await this.getTrack(id, downloadQuality);
|
let lookup;
|
||||||
|
if (isVideo) {
|
||||||
|
lookup = await this.getVideo(id);
|
||||||
|
} else {
|
||||||
|
lookup = await this.getTrack(id, downloadQuality);
|
||||||
|
}
|
||||||
|
|
||||||
let streamUrl;
|
let streamUrl;
|
||||||
let blob;
|
let blob;
|
||||||
|
|
||||||
if (lookup.originalTrackUrl) {
|
if (lookup.originalTrackUrl) {
|
||||||
streamUrl = lookup.originalTrackUrl;
|
streamUrl = lookup.originalTrackUrl;
|
||||||
} else {
|
} else {
|
||||||
streamUrl = this.extractStreamUrlFromManifest(lookup.info.manifest);
|
const findValue = (obj, key) => {
|
||||||
|
if (!obj || typeof obj !== 'object') return null;
|
||||||
|
if (obj[key]) return obj[key];
|
||||||
|
for (const v of Object.values(obj)) {
|
||||||
|
if (v && typeof v === 'object') {
|
||||||
|
const f = findValue(v, key);
|
||||||
|
if (f) return f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const manifest = isVideo
|
||||||
|
? (findValue(lookup, 'manifest') || findValue(lookup, 'Manifest'))
|
||||||
|
: lookup.info?.manifest;
|
||||||
|
|
||||||
|
if (!manifest) {
|
||||||
|
throw new Error('Could not resolve manifest');
|
||||||
|
}
|
||||||
|
|
||||||
|
streamUrl = this.extractStreamUrlFromManifest(manifest);
|
||||||
if (!streamUrl) {
|
if (!streamUrl) {
|
||||||
throw new Error('Could not resolve stream URL');
|
throw new Error('Could not resolve stream URL');
|
||||||
}
|
}
|
||||||
|
|
@ -1138,6 +1305,8 @@ export class LosslessAPI {
|
||||||
});
|
});
|
||||||
} catch (dashError) {
|
} catch (dashError) {
|
||||||
console.error('DASH download failed:', dashError);
|
console.error('DASH download failed:', dashError);
|
||||||
|
if (isVideo) throw dashError;
|
||||||
|
|
||||||
// Fallback to LOSSLESS if DASH fails, but not if we're already downloading LOSSLESS
|
// Fallback to LOSSLESS if DASH fails, but not if we're already downloading LOSSLESS
|
||||||
if (downloadQuality !== 'LOSSLESS') {
|
if (downloadQuality !== 'LOSSLESS') {
|
||||||
console.warn('Falling back to LOSSLESS (16-bit) download.');
|
console.warn('Falling back to LOSSLESS (16-bit) download.');
|
||||||
|
|
@ -1145,8 +1314,18 @@ export class LosslessAPI {
|
||||||
}
|
}
|
||||||
throw dashError;
|
throw dashError;
|
||||||
}
|
}
|
||||||
} else {
|
} else if (streamUrl.includes('.m3u8') || streamUrl.includes('application/vnd.apple.mpegurl')) {
|
||||||
const response = await fetch(streamUrl, {
|
try {
|
||||||
|
const downloader = new HlsDownloader();
|
||||||
|
blob = await downloader.downloadHlsStream(streamUrl, {
|
||||||
|
signal: options.signal,
|
||||||
|
onProgress: options.onProgress,
|
||||||
|
});
|
||||||
|
} catch (hlsError) {
|
||||||
|
console.error('HLS download failed:', hlsError);
|
||||||
|
throw hlsError;
|
||||||
|
}
|
||||||
|
} else { const response = await fetch(streamUrl, {
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
});
|
});
|
||||||
|
|
@ -1155,7 +1334,6 @@ export class LosslessAPI {
|
||||||
throw new Error(`Fetch failed: ${response.status}`);
|
throw new Error(`Fetch failed: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ... (standard handling for Content-Length and body reader)
|
|
||||||
const contentLength = response.headers.get('Content-Length');
|
const contentLength = response.headers.get('Content-Length');
|
||||||
const totalBytes = contentLength ? parseInt(contentLength, 10) : 0;
|
const totalBytes = contentLength ? parseInt(contentLength, 10) : 0;
|
||||||
|
|
||||||
|
|
@ -1181,7 +1359,8 @@ export class LosslessAPI {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
blob = new Blob(chunks, { type: response.headers.get('Content-Type') || 'audio/flac' });
|
const defaultMime = isVideo ? 'video/mp4' : 'audio/flac';
|
||||||
|
blob = new Blob(chunks, { type: response.headers.get('Content-Type') || defaultMime });
|
||||||
} else {
|
} else {
|
||||||
blob = await response.blob();
|
blob = await response.blob();
|
||||||
if (onProgress) {
|
if (onProgress) {
|
||||||
|
|
@ -1194,78 +1373,80 @@ export class LosslessAPI {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to MP3 320kbps if requested
|
if (!isVideo) {
|
||||||
if (quality === 'MP3_320') {
|
// Convert to MP3 320kbps if requested
|
||||||
try {
|
if (quality === 'MP3_320') {
|
||||||
blob = await encodeToMp3(blob, onProgress, options.signal);
|
try {
|
||||||
} catch (encodingError) {
|
blob = await encodeToMp3(blob, onProgress, options.signal);
|
||||||
if (onProgress) {
|
} catch (encodingError) {
|
||||||
onProgress({
|
if (onProgress) {
|
||||||
stage: 'error',
|
onProgress({
|
||||||
message: `Encoding failed: ${encodingError.message}`,
|
stage: 'error',
|
||||||
});
|
message: `Encoding failed: ${encodingError.message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw encodingError;
|
||||||
}
|
}
|
||||||
throw encodingError;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (quality.endsWith('LOSSLESS')) {
|
if (quality.endsWith('LOSSLESS')) {
|
||||||
try {
|
try {
|
||||||
switch (losslessContainerSettings.getContainer()) {
|
switch (losslessContainerSettings.getContainer()) {
|
||||||
case 'flac':
|
case 'flac':
|
||||||
if ((await getExtensionFromBlob(blob)) != 'flac') {
|
if ((await getExtensionFromBlob(blob)) != 'flac') {
|
||||||
|
blob = await ffmpeg(
|
||||||
|
blob,
|
||||||
|
{ args: ['-c:a', 'copy'] },
|
||||||
|
'output.flac',
|
||||||
|
'audio/flac',
|
||||||
|
onProgress,
|
||||||
|
options.signal
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'alac':
|
||||||
blob = await ffmpeg(
|
blob = await ffmpeg(
|
||||||
blob,
|
blob,
|
||||||
{ args: ['-c:a', 'copy'] },
|
{ args: ['-c:a', 'alac'] },
|
||||||
'output.flac',
|
'output.m4a',
|
||||||
'audio/flac',
|
'audio/mp4',
|
||||||
onProgress,
|
onProgress,
|
||||||
options.signal
|
options.signal
|
||||||
);
|
);
|
||||||
}
|
break;
|
||||||
break;
|
default:
|
||||||
case 'alac':
|
break;
|
||||||
blob = await ffmpeg(
|
}
|
||||||
blob,
|
} catch (error) {
|
||||||
{ args: ['-c:a', 'alac'] },
|
if (error?.name === 'AbortError') {
|
||||||
'output.m4a',
|
throw error;
|
||||||
'audio/mp4',
|
}
|
||||||
onProgress,
|
|
||||||
options.signal
|
console.error('Lossless container conversion failed:', error);
|
||||||
);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
}
|
||||||
if (error?.name === 'AbortError') {
|
|
||||||
throw error;
|
// Add metadata if track information is provided
|
||||||
|
if (track) {
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress({
|
||||||
|
stage: 'processing',
|
||||||
|
message: 'Adding metadata...',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error('Lossless container conversion failed:', error);
|
const enrichedTrack = { ...track };
|
||||||
}
|
if (lookup.info) {
|
||||||
}
|
enrichedTrack.replayGain = {
|
||||||
|
trackReplayGain: lookup.info.trackReplayGain,
|
||||||
|
trackPeakAmplitude: lookup.info.trackPeakAmplitude,
|
||||||
|
albumReplayGain: lookup.info.albumReplayGain,
|
||||||
|
albumPeakAmplitude: lookup.info.albumPeakAmplitude,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Add metadata if track information is provided
|
blob = await addMetadataToAudio(blob, enrichedTrack, this, quality);
|
||||||
if (track) {
|
|
||||||
if (onProgress) {
|
|
||||||
onProgress({
|
|
||||||
stage: 'processing',
|
|
||||||
message: 'Adding metadata...',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const enrichedTrack = { ...track };
|
|
||||||
if (lookup.info) {
|
|
||||||
enrichedTrack.replayGain = {
|
|
||||||
trackReplayGain: lookup.info.trackReplayGain,
|
|
||||||
trackPeakAmplitude: lookup.info.trackPeakAmplitude,
|
|
||||||
albumReplayGain: lookup.info.albumReplayGain,
|
|
||||||
albumPeakAmplitude: lookup.info.albumPeakAmplitude,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
blob = await addMetadataToAudio(blob, enrichedTrack, this, quality);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect actual format and fix filename extension if needed
|
// Detect actual format and fix filename extension if needed
|
||||||
|
|
@ -1279,6 +1460,7 @@ export class LosslessAPI {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.triggerDownload(blob, finalFilename);
|
this.triggerDownload(blob, finalFilename);
|
||||||
|
return blob;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.name === 'AbortError') {
|
if (error.name === 'AbortError') {
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -1314,23 +1496,10 @@ export class LosslessAPI {
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formattedId = id.replace(/-/g, '/');
|
const formattedId = String(id).replace(/-/g, '/');
|
||||||
return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`;
|
return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`;
|
||||||
}
|
}
|
||||||
|
|
||||||
getVideoCoverUrl(id, size = '1280') {
|
|
||||||
if (!id) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts = id.split('-');
|
|
||||||
if (parts.length !== 5) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `https://resources.tidal.com/videos/${parts[0]}/${parts[1]}/${parts[2]}/${parts[3]}/${parts[4]}/${size}x${size}.mp4`;
|
|
||||||
}
|
|
||||||
|
|
||||||
getArtistPictureUrl(id, size = '320') {
|
getArtistPictureUrl(id, size = '320') {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return `https://picsum.photos/seed/${Math.random()}/${size}`;
|
return `https://picsum.photos/seed/${Math.random()}/${size}`;
|
||||||
|
|
@ -1340,7 +1509,7 @@ export class LosslessAPI {
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formattedId = id.replace(/-/g, '/');
|
const formattedId = String(id).replace(/-/g, '/');
|
||||||
return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`;
|
return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1960,20 +1960,23 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
|
||||||
db.getPlaylist(playlistId).then(async (playlist) => {
|
db.getPlaylist(playlistId).then(async (playlist) => {
|
||||||
let trackId = null;
|
let trackId = null;
|
||||||
|
let trackType = null;
|
||||||
|
|
||||||
// Prefer ID if available (from sorted view)
|
// Prefer ID if available (from sorted view)
|
||||||
if (btn.dataset.trackId) {
|
if (btn.dataset.trackId) {
|
||||||
trackId = btn.dataset.trackId;
|
trackId = btn.dataset.trackId;
|
||||||
|
trackType = btn.dataset.type || 'track';
|
||||||
} else if (btn.dataset.trackIndex) {
|
} else if (btn.dataset.trackIndex) {
|
||||||
// Fallback to index (legacy/unsorted)
|
// Fallback to index (legacy/unsorted)
|
||||||
const index = parseInt(btn.dataset.trackIndex);
|
const index = parseInt(btn.dataset.trackIndex);
|
||||||
if (playlist && playlist.tracks[index]) {
|
if (playlist && playlist.tracks[index]) {
|
||||||
trackId = playlist.tracks[index].id;
|
trackId = playlist.tracks[index].id;
|
||||||
|
trackType = playlist.tracks[index].type || 'track';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trackId) {
|
if (trackId) {
|
||||||
const updatedPlaylist = await db.removeTrackFromPlaylist(playlistId, trackId);
|
const updatedPlaylist = await db.removeTrackFromPlaylist(playlistId, trackId, trackType);
|
||||||
syncManager.syncUserPlaylist(updatedPlaylist, 'update');
|
syncManager.syncUserPlaylist(updatedPlaylist, 'update');
|
||||||
const scrollTop = document.querySelector('.main-content').scrollTop;
|
const scrollTop = document.querySelector('.main-content').scrollTop;
|
||||||
await ui.renderPlaylistPage(playlistId, 'user');
|
await ui.renderPlaylistPage(playlistId, 'user');
|
||||||
|
|
|
||||||
39
js/db.js
39
js/db.js
|
|
@ -1,7 +1,7 @@
|
||||||
export class MusicDatabase {
|
export class MusicDatabase {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.dbName = 'MonochromeDB';
|
this.dbName = 'MonochromeDB';
|
||||||
this.version = 8;
|
this.version = 9;
|
||||||
this.db = null;
|
this.db = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -29,6 +29,10 @@ export class MusicDatabase {
|
||||||
const store = db.createObjectStore('favorites_tracks', { keyPath: 'id' });
|
const store = db.createObjectStore('favorites_tracks', { keyPath: 'id' });
|
||||||
store.createIndex('addedAt', 'addedAt', { unique: false });
|
store.createIndex('addedAt', 'addedAt', { unique: false });
|
||||||
}
|
}
|
||||||
|
if (!db.objectStoreNames.contains('favorites_videos')) {
|
||||||
|
const store = db.createObjectStore('favorites_videos', { keyPath: 'id' });
|
||||||
|
store.createIndex('addedAt', 'addedAt', { unique: false });
|
||||||
|
}
|
||||||
if (!db.objectStoreNames.contains('favorites_albums')) {
|
if (!db.objectStoreNames.contains('favorites_albums')) {
|
||||||
const store = db.createObjectStore('favorites_albums', { keyPath: 'id' });
|
const store = db.createObjectStore('favorites_albums', { keyPath: 'id' });
|
||||||
store.createIndex('addedAt', 'addedAt', { unique: false });
|
store.createIndex('addedAt', 'addedAt', { unique: false });
|
||||||
|
|
@ -88,7 +92,7 @@ export class MusicDatabase {
|
||||||
// History API
|
// History API
|
||||||
async addToHistory(track) {
|
async addToHistory(track) {
|
||||||
const storeName = 'history_tracks';
|
const storeName = 'history_tracks';
|
||||||
const minified = this._minifyItem('track', track);
|
const minified = this._minifyItem(track.type || 'track', track);
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const entry = { ...minified, timestamp };
|
const entry = { ...minified, timestamp };
|
||||||
|
|
||||||
|
|
@ -209,6 +213,7 @@ export class MusicDatabase {
|
||||||
|
|
||||||
_minifyItem(type, item) {
|
_minifyItem(type, item) {
|
||||||
if (!item) return item;
|
if (!item) return item;
|
||||||
|
const normalizedType = (type || '').toLowerCase();
|
||||||
|
|
||||||
// Base properties to keep
|
// Base properties to keep
|
||||||
const base = {
|
const base = {
|
||||||
|
|
@ -216,7 +221,7 @@ export class MusicDatabase {
|
||||||
addedAt: item.addedAt || null,
|
addedAt: item.addedAt || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (type === 'track') {
|
if (normalizedType === 'track') {
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
title: item.title || null,
|
title: item.title || null,
|
||||||
|
|
@ -256,6 +261,19 @@ export class MusicDatabase {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (normalizedType === 'video') {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
type: 'video',
|
||||||
|
title: item.title || null,
|
||||||
|
duration: item.duration || null,
|
||||||
|
image: item.image || item.cover || null,
|
||||||
|
artist: item.artist || (item.artists && item.artists.length > 0 ? item.artists[0] : null) || null,
|
||||||
|
artists: item.artists?.map((a) => ({ id: a.id, name: a.name || null })) || [],
|
||||||
|
album: item.album || { title: 'Video', cover: item.image || item.cover },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (type === 'album') {
|
if (type === 'album') {
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
|
|
@ -548,7 +566,7 @@ export class MusicDatabase {
|
||||||
const playlist = {
|
const playlist = {
|
||||||
id: id,
|
id: id,
|
||||||
name: name,
|
name: name,
|
||||||
tracks: tracks.map((t) => this._minifyItem('track', { ...t, addedAt: Date.now() })),
|
tracks: tracks.map((t) => this._minifyItem(t.type || 'track', { ...t, addedAt: Date.now() })),
|
||||||
cover: cover,
|
cover: cover,
|
||||||
description: description,
|
description: description,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
|
|
@ -570,7 +588,7 @@ export class MusicDatabase {
|
||||||
if (!playlist) throw new Error('Playlist not found');
|
if (!playlist) throw new Error('Playlist not found');
|
||||||
playlist.tracks = playlist.tracks || [];
|
playlist.tracks = playlist.tracks || [];
|
||||||
const trackWithDate = { ...track, addedAt: Date.now() };
|
const trackWithDate = { ...track, addedAt: Date.now() };
|
||||||
const minifiedTrack = this._minifyItem('track', trackWithDate);
|
const minifiedTrack = this._minifyItem(track.type || 'track', trackWithDate);
|
||||||
if (playlist.tracks.some((t) => t.id === track.id)) return;
|
if (playlist.tracks.some((t) => t.id === track.id)) return;
|
||||||
playlist.tracks.push(minifiedTrack);
|
playlist.tracks.push(minifiedTrack);
|
||||||
playlist.updatedAt = Date.now();
|
playlist.updatedAt = Date.now();
|
||||||
|
|
@ -591,7 +609,7 @@ export class MusicDatabase {
|
||||||
for (const track of tracks) {
|
for (const track of tracks) {
|
||||||
if (!playlist.tracks.some((t) => t.id === track.id)) {
|
if (!playlist.tracks.some((t) => t.id === track.id)) {
|
||||||
const trackWithDate = { ...track, addedAt: Date.now() };
|
const trackWithDate = { ...track, addedAt: Date.now() };
|
||||||
playlist.tracks.push(this._minifyItem('track', trackWithDate));
|
playlist.tracks.push(this._minifyItem(track.type || 'track', trackWithDate));
|
||||||
addedCount++;
|
addedCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -606,11 +624,16 @@ export class MusicDatabase {
|
||||||
return playlist;
|
return playlist;
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeTrackFromPlaylist(playlistId, trackId) {
|
async removeTrackFromPlaylist(playlistId, trackId, trackType = null) {
|
||||||
const playlist = await this.performTransaction('user_playlists', 'readonly', (store) => store.get(playlistId));
|
const playlist = await this.performTransaction('user_playlists', 'readonly', (store) => store.get(playlistId));
|
||||||
if (!playlist) throw new Error('Playlist not found');
|
if (!playlist) throw new Error('Playlist not found');
|
||||||
playlist.tracks = playlist.tracks || [];
|
playlist.tracks = playlist.tracks || [];
|
||||||
playlist.tracks = playlist.tracks.filter((t) => t.id != trackId);
|
playlist.tracks = playlist.tracks.filter((t) => {
|
||||||
|
if (trackType) {
|
||||||
|
return !(t.id == trackId && (t.type || 'track') === trackType);
|
||||||
|
}
|
||||||
|
return t.id != trackId;
|
||||||
|
});
|
||||||
playlist.updatedAt = Date.now();
|
playlist.updatedAt = Date.now();
|
||||||
this._updatePlaylistMetadata(playlist);
|
this._updatePlaylistMetadata(playlist);
|
||||||
await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
|
await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ export function initializeDiscordRPC(player) {
|
||||||
|
|
||||||
let coverUrl = 'monochrome';
|
let coverUrl = 'monochrome';
|
||||||
if (track.album?.cover) {
|
if (track.album?.cover) {
|
||||||
const coverId = track.album.cover.replace(/-/g, '/');
|
const coverId = String(track.album.cover).replace(/-/g, '/');
|
||||||
coverUrl = `https://resources.tidal.com/images/${coverId}/320x320.jpg`;
|
coverUrl = `https://resources.tidal.com/images/${coverId}/320x320.jpg`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
118
js/events.js
118
js/events.js
|
|
@ -50,6 +50,7 @@ import {
|
||||||
trackSetSleepTimer,
|
trackSetSleepTimer,
|
||||||
trackCancelSleepTimer,
|
trackCancelSleepTimer,
|
||||||
trackStartMix,
|
trackStartMix,
|
||||||
|
trackEvent,
|
||||||
} from './analytics.js';
|
} from './analytics.js';
|
||||||
|
|
||||||
let currentTrackIdForWaveform = null;
|
let currentTrackIdForWaveform = null;
|
||||||
|
|
@ -952,11 +953,13 @@ export async function handleTrackAction(
|
||||||
// Track like/unlike
|
// Track like/unlike
|
||||||
if (added) {
|
if (added) {
|
||||||
if (type === 'track') trackLikeTrack(item);
|
if (type === 'track') trackLikeTrack(item);
|
||||||
|
else if (type === 'video') trackEvent('Like Video', { title: item.title });
|
||||||
else if (type === 'album') trackLikeAlbum(item);
|
else if (type === 'album') trackLikeAlbum(item);
|
||||||
else if (type === 'artist') trackLikeArtist(item);
|
else if (type === 'artist') trackLikeArtist(item);
|
||||||
else if (type === 'playlist' || type === 'user-playlist') trackLikePlaylist(item);
|
else if (type === 'playlist' || type === 'user-playlist') trackLikePlaylist(item);
|
||||||
} else {
|
} else {
|
||||||
if (type === 'track') trackUnlikeTrack(item);
|
if (type === 'track') trackUnlikeTrack(item);
|
||||||
|
else if (type === 'video') trackEvent('Unlike Video', { title: item.title });
|
||||||
else if (type === 'album') trackUnlikeAlbum(item);
|
else if (type === 'album') trackUnlikeAlbum(item);
|
||||||
else if (type === 'artist') trackUnlikeArtist(item);
|
else if (type === 'artist') trackUnlikeArtist(item);
|
||||||
else if (type === 'playlist' || type === 'user-playlist') trackUnlikePlaylist(item);
|
else if (type === 'playlist' || type === 'user-playlist') trackUnlikePlaylist(item);
|
||||||
|
|
@ -976,7 +979,9 @@ export async function handleTrackAction(
|
||||||
const selector =
|
const selector =
|
||||||
type === 'track'
|
type === 'track'
|
||||||
? `[data-track-id="${id}"] .like-btn`
|
? `[data-track-id="${id}"] .like-btn`
|
||||||
: `.card[data-${type}-id="${id}"] .like-btn, .card[data-playlist-id="${id}"] .like-btn`;
|
: type === 'video'
|
||||||
|
? `.card[data-video-id="${id}"] .like-btn`
|
||||||
|
: `.card[data-${type}-id="${id}"] .like-btn, .card[data-playlist-id="${id}"] .like-btn`;
|
||||||
|
|
||||||
// Also check header buttons
|
// Also check header buttons
|
||||||
const headerBtn = document.getElementById(`like-${type}-btn`);
|
const headerBtn = document.getElementById(`like-${type}-btn`);
|
||||||
|
|
@ -985,12 +990,12 @@ export async function handleTrackAction(
|
||||||
if (headerBtn) elementsToUpdate.push(headerBtn);
|
if (headerBtn) elementsToUpdate.push(headerBtn);
|
||||||
|
|
||||||
const nowPlayingLikeBtn = document.getElementById('now-playing-like-btn');
|
const nowPlayingLikeBtn = document.getElementById('now-playing-like-btn');
|
||||||
if (nowPlayingLikeBtn && type === 'track' && player?.currentTrack?.id === item.id) {
|
if (nowPlayingLikeBtn && (type === 'track' || type === 'video') && player?.currentTrack?.id === item.id) {
|
||||||
elementsToUpdate.push(nowPlayingLikeBtn);
|
elementsToUpdate.push(nowPlayingLikeBtn);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fsLikeBtn = document.getElementById('fs-like-btn');
|
const fsLikeBtn = document.getElementById('fs-like-btn');
|
||||||
if (fsLikeBtn && type === 'track' && player?.currentTrack?.id === item.id) {
|
if (fsLikeBtn && (type === 'track' || type === 'video') && player?.currentTrack?.id === item.id) {
|
||||||
elementsToUpdate.push(fsLikeBtn);
|
elementsToUpdate.push(fsLikeBtn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1011,7 +1016,9 @@ export async function handleTrackAction(
|
||||||
const itemSelector =
|
const itemSelector =
|
||||||
type === 'track'
|
type === 'track'
|
||||||
? `.track-item[data-track-id="${id}"]`
|
? `.track-item[data-track-id="${id}"]`
|
||||||
: `.card[data-${type}-id="${id}"], .card[data-playlist-id="${id}"]`;
|
: type === 'video'
|
||||||
|
? `.video-card[data-video-id="${id}"]`
|
||||||
|
: `.card[data-${type}-id="${id}"], .card[data-playlist-id="${id}"]`;
|
||||||
|
|
||||||
const itemEl = document.querySelector(itemSelector);
|
const itemEl = document.querySelector(itemSelector);
|
||||||
|
|
||||||
|
|
@ -1020,29 +1027,60 @@ export async function handleTrackAction(
|
||||||
const container = itemEl.parentElement;
|
const container = itemEl.parentElement;
|
||||||
itemEl.remove();
|
itemEl.remove();
|
||||||
if (container && container.children.length === 0) {
|
if (container && container.children.length === 0) {
|
||||||
const msg = type === 'track' ? 'No liked tracks yet.' : `No liked ${type}s yet.`;
|
const msg =
|
||||||
|
type === 'track'
|
||||||
|
? 'No liked tracks yet.'
|
||||||
|
: type === 'video'
|
||||||
|
? 'No liked videos yet.'
|
||||||
|
: `No liked ${type}s yet.`;
|
||||||
container.innerHTML = `<div class="placeholder-text">${msg}</div>`;
|
container.innerHTML = `<div class="placeholder-text">${msg}</div>`;
|
||||||
}
|
}
|
||||||
} else if (added && !itemEl && ui && type === 'track') {
|
} else if (added && !itemEl && ui && (type === 'track' || type === 'video')) {
|
||||||
// Add item (specifically for tracks currently)
|
// Add item
|
||||||
const tracksContainer = document.getElementById('library-tracks-container');
|
if (type === 'track') {
|
||||||
if (tracksContainer) {
|
const tracksContainer = document.getElementById('library-tracks-container');
|
||||||
// Remove placeholder if it exists
|
if (tracksContainer) {
|
||||||
const placeholder = tracksContainer.querySelector('.placeholder-text');
|
const placeholder = tracksContainer.querySelector('.placeholder-text');
|
||||||
if (placeholder) placeholder.remove();
|
if (placeholder) placeholder.remove();
|
||||||
|
|
||||||
// Create track element
|
const index = tracksContainer.children.length;
|
||||||
const index = tracksContainer.children.length;
|
const trackHTML = ui.createTrackItemHTML(item, index, true, false);
|
||||||
const trackHTML = ui.createTrackItemHTML(item, index, true, false);
|
|
||||||
|
|
||||||
const tempDiv = document.createElement('div');
|
const tempDiv = document.createElement('div');
|
||||||
tempDiv.innerHTML = trackHTML;
|
tempDiv.innerHTML = trackHTML;
|
||||||
const newEl = tempDiv.firstElementChild;
|
const newEl = tempDiv.firstElementChild;
|
||||||
|
|
||||||
if (newEl) {
|
if (newEl) {
|
||||||
tracksContainer.appendChild(newEl);
|
tracksContainer.appendChild(newEl);
|
||||||
trackDataStore.set(newEl, item);
|
trackDataStore.set(newEl, item);
|
||||||
ui.updateLikeState(newEl, 'track', item.id);
|
ui.updateLikeState(newEl, 'track', item.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (type === 'video') {
|
||||||
|
const videosTabContent = document.getElementById('library-tab-videos');
|
||||||
|
if (videosTabContent) {
|
||||||
|
const grid = videosTabContent.querySelector('.card-grid');
|
||||||
|
if (grid) {
|
||||||
|
const placeholder = grid.querySelector('.placeholder-text');
|
||||||
|
if (placeholder) grid.innerHTML = '';
|
||||||
|
|
||||||
|
const videoHTML = ui.createVideoCardHTML(item);
|
||||||
|
const tempDiv = document.createElement('div');
|
||||||
|
tempDiv.innerHTML = videoHTML;
|
||||||
|
const newEl = tempDiv.firstElementChild;
|
||||||
|
|
||||||
|
if (newEl) {
|
||||||
|
grid.appendChild(newEl);
|
||||||
|
trackDataStore.set(newEl, item);
|
||||||
|
ui.updateLikeState(newEl, 'video', item.id);
|
||||||
|
newEl.addEventListener('click', (e) => {
|
||||||
|
if (e.target.closest('.card-play-btn') || e.target.closest('.card-image-container')) {
|
||||||
|
e.stopPropagation();
|
||||||
|
player.playVideo(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1058,10 +1096,14 @@ export async function handleTrackAction(
|
||||||
// Removed empty check to allow creating new playlist
|
// Removed empty check to allow creating new playlist
|
||||||
|
|
||||||
const trackId = item.id;
|
const trackId = item.id;
|
||||||
|
const trackType = item.type || 'track';
|
||||||
const playlistsWithTrack = new Set();
|
const playlistsWithTrack = new Set();
|
||||||
|
|
||||||
for (const playlist of playlists) {
|
for (const playlist of playlists) {
|
||||||
if (playlist.tracks && playlist.tracks.some((track) => track.id == trackId)) {
|
if (
|
||||||
|
playlist.tracks &&
|
||||||
|
playlist.tracks.some((t) => t.id == trackId && (t.type || 'track') === trackType)
|
||||||
|
) {
|
||||||
playlistsWithTrack.add(playlist.id);
|
playlistsWithTrack.add(playlist.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1645,7 +1687,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
||||||
contextMenu._originalHTML = null;
|
contextMenu._originalHTML = null;
|
||||||
}
|
}
|
||||||
contextMenu._contextTrack = contextTrack;
|
contextMenu._contextTrack = contextTrack;
|
||||||
contextMenu._contextType = 'track';
|
contextMenu._contextType = menuBtn.dataset.type || trackItem.dataset.type || 'track';
|
||||||
await updateContextMenuLikeState(contextMenu, contextTrack);
|
await updateContextMenuLikeState(contextMenu, contextTrack);
|
||||||
const rect = menuBtn.getBoundingClientRect();
|
const rect = menuBtn.getBoundingClientRect();
|
||||||
positionMenu(contextMenu, rect.left, rect.bottom + 5, rect);
|
positionMenu(contextMenu, rect.left, rect.bottom + 5, rect);
|
||||||
|
|
@ -1670,15 +1712,19 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
||||||
if (isSearch) {
|
if (isSearch) {
|
||||||
const clickedTrack = trackDataStore.get(trackItem);
|
const clickedTrack = trackDataStore.get(trackItem);
|
||||||
if (clickedTrack) {
|
if (clickedTrack) {
|
||||||
player.setQueue([clickedTrack], 0);
|
if (trackItem.dataset.type === 'video') {
|
||||||
document.getElementById('shuffle-btn').classList.remove('active');
|
player.playVideo(clickedTrack);
|
||||||
player.playTrackFromQueue();
|
} else {
|
||||||
|
player.setQueue([clickedTrack], 0);
|
||||||
|
document.getElementById('shuffle-btn').classList.remove('active');
|
||||||
|
player.playTrackFromQueue();
|
||||||
|
|
||||||
api.getTrackRecommendations(clickedTrack.id).then((recs) => {
|
api.getTrackRecommendations(clickedTrack.id).then((recs) => {
|
||||||
if (recs && recs.length > 0) {
|
if (recs && recs.length > 0) {
|
||||||
player.addToQueue(recs);
|
player.addToQueue(recs);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const parentList = trackItem.closest('.track-list');
|
const parentList = trackItem.closest('.track-list');
|
||||||
|
|
@ -1763,7 +1809,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
||||||
});
|
});
|
||||||
|
|
||||||
contextMenu._contextTrack = contextTrack;
|
contextMenu._contextTrack = contextTrack;
|
||||||
contextMenu._contextType = 'track';
|
contextMenu._contextType = contextTrack.type || 'track';
|
||||||
await updateContextMenuLikeState(contextMenu, contextTrack);
|
await updateContextMenuLikeState(contextMenu, contextTrack);
|
||||||
positionMenu(contextMenu, e.clientX, e.clientY);
|
positionMenu(contextMenu, e.clientX, e.clientY);
|
||||||
}
|
}
|
||||||
|
|
@ -1919,7 +1965,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
||||||
player,
|
player,
|
||||||
api,
|
api,
|
||||||
lyricsManager,
|
lyricsManager,
|
||||||
'track',
|
player.currentTrack.type || 'track',
|
||||||
ui,
|
ui,
|
||||||
scrobbler
|
scrobbler
|
||||||
);
|
);
|
||||||
|
|
@ -1957,7 +2003,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
||||||
player,
|
player,
|
||||||
api,
|
api,
|
||||||
lyricsManager,
|
lyricsManager,
|
||||||
'track',
|
player.currentTrack.type || 'track',
|
||||||
ui,
|
ui,
|
||||||
scrobbler
|
scrobbler
|
||||||
);
|
);
|
||||||
|
|
@ -1978,7 +2024,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
||||||
player,
|
player,
|
||||||
api,
|
api,
|
||||||
lyricsManager,
|
lyricsManager,
|
||||||
'track',
|
player.currentTrack.type || 'track',
|
||||||
ui,
|
ui,
|
||||||
scrobbler
|
scrobbler
|
||||||
);
|
);
|
||||||
|
|
|
||||||
107
js/hls-downloader.js
Normal file
107
js/hls-downloader.js
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
export class HlsDownloader {
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
async downloadHlsStream(masterUrl, options = {}) {
|
||||||
|
const { onProgress, signal } = options;
|
||||||
|
|
||||||
|
const response = await fetch(masterUrl, { signal });
|
||||||
|
const masterText = await response.text();
|
||||||
|
|
||||||
|
const variantUrl = this.getBestVariantUrl(masterUrl, masterText);
|
||||||
|
|
||||||
|
const mediaResponse = await fetch(variantUrl, { signal });
|
||||||
|
const mediaText = await mediaResponse.text();
|
||||||
|
|
||||||
|
const segments = this.parseMediaPlaylist(variantUrl, mediaText);
|
||||||
|
if (segments.length === 0) {
|
||||||
|
throw new Error('No segments found in HLS playlist');
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks = [];
|
||||||
|
let downloadedBytes = 0;
|
||||||
|
const totalSegments = segments.length;
|
||||||
|
|
||||||
|
for (let i = 0; i < totalSegments; i++) {
|
||||||
|
if (signal?.aborted) throw new Error('AbortError');
|
||||||
|
|
||||||
|
const segmentUrl = segments[i];
|
||||||
|
const segmentResponse = await fetch(segmentUrl, { signal });
|
||||||
|
|
||||||
|
if (!segmentResponse.ok) {
|
||||||
|
throw new Error(`Failed to fetch segment ${i}: ${segmentResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunk = await segmentResponse.arrayBuffer();
|
||||||
|
chunks.push(chunk);
|
||||||
|
downloadedBytes += chunk.byteLength;
|
||||||
|
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress({
|
||||||
|
stage: 'downloading',
|
||||||
|
receivedBytes: downloadedBytes,
|
||||||
|
totalBytes: undefined,
|
||||||
|
currentSegment: i + 1,
|
||||||
|
totalSegments: totalSegments,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mimeType = segments[0].endsWith('.m4s') || segments[0].includes('mp4') ? 'video/mp4' : 'video/mp2t';
|
||||||
|
return new Blob(chunks, { type: mimeType });
|
||||||
|
}
|
||||||
|
|
||||||
|
getBestVariantUrl(masterUrl, masterText) {
|
||||||
|
if (!masterText.includes('#EXT-X-STREAM-INF')) {
|
||||||
|
return masterUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = masterText.split('\n');
|
||||||
|
const variants = [];
|
||||||
|
let currentVariant = null;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed.startsWith('#EXT-X-STREAM-INF:')) {
|
||||||
|
const bandwidthMatch = trimmed.match(/BANDWIDTH=(\d+)/);
|
||||||
|
const resolutionMatch = trimmed.match(/RESOLUTION=(\d+x\d+)/);
|
||||||
|
currentVariant = {
|
||||||
|
bandwidth: bandwidthMatch ? parseInt(bandwidthMatch[1], 10) : 0,
|
||||||
|
resolution: resolutionMatch ? resolutionMatch[1] : 'unknown',
|
||||||
|
};
|
||||||
|
} else if (trimmed && !trimmed.startsWith('#')) {
|
||||||
|
if (currentVariant) {
|
||||||
|
currentVariant.url = this.resolveUrl(masterUrl, trimmed);
|
||||||
|
variants.push(currentVariant);
|
||||||
|
currentVariant = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variants.length === 0) return masterUrl;
|
||||||
|
|
||||||
|
variants.sort((a, b) => b.bandwidth - a.bandwidth);
|
||||||
|
return variants[0].url;
|
||||||
|
}
|
||||||
|
|
||||||
|
parseMediaPlaylist(mediaUrl, mediaText) {
|
||||||
|
const lines = mediaText.split('\n');
|
||||||
|
const segments = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed && !trimmed.startsWith('#')) {
|
||||||
|
segments.push(this.resolveUrl(mediaUrl, trimmed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveUrl(baseUrl, relativeUrl) {
|
||||||
|
try {
|
||||||
|
return new URL(relativeUrl, baseUrl).href;
|
||||||
|
} catch {
|
||||||
|
return relativeUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -48,6 +48,11 @@ export class MusicAPI {
|
||||||
return this.tidalAPI.searchPlaylists(query, options);
|
return this.tidalAPI.searchPlaylists(query, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async searchVideos(query, options = {}) {
|
||||||
|
const provider = options.provider || this.getCurrentProvider();
|
||||||
|
return this.tidalAPI.searchVideos(query, options);
|
||||||
|
}
|
||||||
|
|
||||||
// Get methods
|
// Get methods
|
||||||
async getTrack(id, quality, provider = null) {
|
async getTrack(id, quality, provider = null) {
|
||||||
const p = provider || this.getProviderFromId(id) || this.getCurrentProvider();
|
const p = provider || this.getProviderFromId(id) || this.getCurrentProvider();
|
||||||
|
|
@ -89,6 +94,22 @@ export class MusicAPI {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getVideo(id, provider = null) {
|
||||||
|
const p = provider || this.getProviderFromId(id) || this.getCurrentProvider();
|
||||||
|
const api = this.getAPI(p);
|
||||||
|
const cleanId = this.stripProviderPrefix(id);
|
||||||
|
return api.getVideo(cleanId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVideoStreamUrl(id, provider = null) {
|
||||||
|
const p = provider || this.getProviderFromId(id) || this.getCurrentProvider();
|
||||||
|
const api = this.getAPI(p);
|
||||||
|
const cleanId = this.stripProviderPrefix(id);
|
||||||
|
if (typeof api.getVideoStreamUrl === 'function') {
|
||||||
|
return api.getVideoStreamUrl(cleanId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getArtistSocials(artistName) {
|
async getArtistSocials(artistName) {
|
||||||
return this.tidalAPI.getArtistSocials(artistName);
|
return this.tidalAPI.getArtistSocials(artistName);
|
||||||
}
|
}
|
||||||
|
|
@ -132,14 +153,6 @@ export class MusicAPI {
|
||||||
return this.tidalAPI.getCoverUrl(id, size);
|
return this.tidalAPI.getCoverUrl(id, size);
|
||||||
}
|
}
|
||||||
|
|
||||||
getVideoCoverUrl(videoCoverId, fallbackCoverId, size = '1280') {
|
|
||||||
if (videoCoverId) {
|
|
||||||
const videoUrl = this.tidalAPI.getVideoCoverUrl(videoCoverId, size);
|
|
||||||
if (videoUrl) return videoUrl;
|
|
||||||
}
|
|
||||||
return this.getCoverUrl(fallbackCoverId, size);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getVideoArtwork(title, artist) {
|
async getVideoArtwork(title, artist) {
|
||||||
const cacheKey = `${title}-${artist}`.toLowerCase();
|
const cacheKey = `${title}-${artist}`.toLowerCase();
|
||||||
if (this.videoArtworkCache.has(cacheKey)) {
|
if (this.videoArtworkCache.has(cacheKey)) {
|
||||||
|
|
|
||||||
191
js/player.js
191
js/player.js
|
|
@ -43,6 +43,7 @@ export class Player {
|
||||||
typeof window !== 'undefined' &&
|
typeof window !== 'undefined' &&
|
||||||
(window.matchMedia?.('(display-mode: standalone)')?.matches || window.navigator?.standalone === true);
|
(window.matchMedia?.('(display-mode: standalone)')?.matches || window.navigator?.standalone === true);
|
||||||
|
|
||||||
|
this.hls = null;
|
||||||
// Sleep timer properties
|
// Sleep timer properties
|
||||||
this.sleepTimer = null;
|
this.sleepTimer = null;
|
||||||
this.sleepTimerEndTime = null;
|
this.sleepTimerEndTime = null;
|
||||||
|
|
@ -201,10 +202,8 @@ export class Player {
|
||||||
const artistEl = document.querySelector('.now-playing-bar .artist');
|
const artistEl = document.querySelector('.now-playing-bar .artist');
|
||||||
|
|
||||||
if (coverEl) {
|
if (coverEl) {
|
||||||
const videoCoverUrl = track.album?.videoCover
|
const videoCoverUrl = track.videoUrl || track.videoCoverUrl || track.album?.videoCoverUrl || null;
|
||||||
? this.api.tidalAPI.getVideoCoverUrl(track.album.videoCover)
|
const coverUrl = videoCoverUrl || this.api.getCoverUrl(track.image || track.cover || track.album?.cover);
|
||||||
: null;
|
|
||||||
const coverUrl = videoCoverUrl || this.api.getCoverUrl(track.album?.cover);
|
|
||||||
|
|
||||||
if (videoCoverUrl) {
|
if (videoCoverUrl) {
|
||||||
if (coverEl.tagName === 'IMG') {
|
if (coverEl.tagName === 'IMG') {
|
||||||
|
|
@ -216,7 +215,10 @@ export class Player {
|
||||||
video.playsInline = true;
|
video.playsInline = true;
|
||||||
video.className = coverEl.className;
|
video.className = coverEl.className;
|
||||||
video.id = coverEl.id;
|
video.id = coverEl.id;
|
||||||
|
video.style.objectFit = 'cover';
|
||||||
coverEl.replaceWith(video);
|
coverEl.replaceWith(video);
|
||||||
|
} else if (coverEl.tagName === 'VIDEO' && coverEl.src !== videoCoverUrl) {
|
||||||
|
coverEl.src = videoCoverUrl;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (coverEl.tagName === 'VIDEO') {
|
if (coverEl.tagName === 'VIDEO') {
|
||||||
|
|
@ -404,41 +406,59 @@ export class Player {
|
||||||
}
|
}
|
||||||
|
|
||||||
setupHlsVideo(video, result, fallbackImg) {
|
setupHlsVideo(video, result, fallbackImg) {
|
||||||
const url = result.videoUrl || result.hlsUrl;
|
const url = result.videoUrl || result.hlsUrl || result; // Allow passing just the URL
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
|
|
||||||
if (url.endsWith('.m3u8')) {
|
if (this.hls) {
|
||||||
|
this.hls.destroy();
|
||||||
|
this.hls = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof url === 'string' && (url.includes('.m3u8') || url.includes('application/vnd.apple.mpegurl'))) {
|
||||||
if (Hls.isSupported()) {
|
if (Hls.isSupported()) {
|
||||||
const hls = new Hls();
|
this.hls = new Hls();
|
||||||
hls.loadSource(url);
|
this.hls.loadSource(url);
|
||||||
hls.attachMedia(video);
|
this.hls.attachMedia(video);
|
||||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||||
video.play().catch(() => {});
|
video.play().catch(() => {});
|
||||||
});
|
});
|
||||||
hls.on(Hls.Events.ERROR, (event, data) => {
|
this.hls.on(Hls.Events.ERROR, (event, data) => {
|
||||||
if (data.fatal) {
|
if (data.fatal) {
|
||||||
console.warn('HLS fatal error:', data.type);
|
console.warn('HLS fatal error:', data.type);
|
||||||
video.replaceWith(fallbackImg);
|
if (fallbackImg) video.replaceWith(fallbackImg);
|
||||||
hls.destroy();
|
this.hls.destroy();
|
||||||
|
this.hls = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
video.src = url;
|
video.src = url;
|
||||||
} else {
|
} else {
|
||||||
video.replaceWith(fallbackImg);
|
if (fallbackImg) video.replaceWith(fallbackImg);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
video.src = url;
|
video.src = url;
|
||||||
video.onerror = () => {
|
video.onerror = () => {
|
||||||
if (result.hlsUrl) {
|
if (result && result.hlsUrl) {
|
||||||
this.setupHlsVideo(video, { videoUrl: null, hlsUrl: result.hlsUrl }, fallbackImg);
|
this.setupHlsVideo(video, { videoUrl: null, hlsUrl: result.hlsUrl }, fallbackImg);
|
||||||
} else {
|
} else if (fallbackImg) {
|
||||||
video.replaceWith(fallbackImg);
|
video.replaceWith(fallbackImg);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async playVideo(video) {
|
||||||
|
if (!video) return;
|
||||||
|
const videoTrack = {
|
||||||
|
...video,
|
||||||
|
type: 'video',
|
||||||
|
artist: video.artist || (video.artists && video.artists[0]) || 'Unknown Artist',
|
||||||
|
album: video.album || { title: 'Video', cover: video.image || video.cover }
|
||||||
|
};
|
||||||
|
this.setQueue([videoTrack], 0);
|
||||||
|
await this.playTrackFromQueue();
|
||||||
|
}
|
||||||
|
|
||||||
async playTrackFromQueue(startTime = 0, recursiveCount = 0) {
|
async playTrackFromQueue(startTime = 0, recursiveCount = 0) {
|
||||||
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
|
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
|
||||||
if (this.currentQueueIndex < 0 || this.currentQueueIndex >= currentQueue.length) {
|
if (this.currentQueueIndex < 0 || this.currentQueueIndex >= currentQueue.length) {
|
||||||
|
|
@ -468,79 +488,42 @@ export class Player {
|
||||||
const trackArtistsHTML = getTrackArtistsHTML(track);
|
const trackArtistsHTML = getTrackArtistsHTML(track);
|
||||||
const yearDisplay = getTrackYearDisplay(track);
|
const yearDisplay = getTrackYearDisplay(track);
|
||||||
|
|
||||||
const coverEl = document.querySelector('.now-playing-bar .cover');
|
const trackInfo = document.querySelector('.now-playing-bar .track-info');
|
||||||
if (coverEl) {
|
const coverEl = trackInfo?.querySelector('.cover:not(#audio-player)');
|
||||||
let videoCoverUrl = track.album?.videoCover
|
|
||||||
? this.api.tidalAPI.getVideoCoverUrl(track.album.videoCover)
|
|
||||||
: track.album?.videoCoverUrl || null;
|
|
||||||
|
|
||||||
if (!videoCoverUrl && track.album) {
|
if (track.type === 'video') {
|
||||||
this.api.getVideoArtwork(track.title, getTrackArtists(track)).then((result) => {
|
if (coverEl) coverEl.style.display = 'none';
|
||||||
if (result && this.currentTrack?.id === track.id) {
|
if (this.audio) {
|
||||||
const url = result.videoUrl || result.hlsUrl;
|
const isInFullscreen = document.getElementById('fullscreen-cover-overlay')?.style.display === 'flex';
|
||||||
if (!url) return;
|
|
||||||
|
|
||||||
track.album.videoCoverUrl = url;
|
if (!isInFullscreen) {
|
||||||
const currentCoverEl = document.querySelector('.now-playing-bar .cover');
|
this.audio.style.display = 'block';
|
||||||
if (currentCoverEl && currentCoverEl.tagName !== 'VIDEO') {
|
this.audio.className = 'cover video-cover-mirror';
|
||||||
const video = document.createElement('video');
|
this.audio.style.width = '56px';
|
||||||
video.autoplay = true;
|
this.audio.style.height = '56px';
|
||||||
video.loop = true;
|
this.audio.style.borderRadius = 'var(--radius-sm)';
|
||||||
video.muted = true;
|
this.audio.style.objectFit = 'cover';
|
||||||
video.playsInline = true;
|
this.audio.style.gridArea = 'none';
|
||||||
video.preload = 'auto';
|
this.audio.muted = false;
|
||||||
video.className = currentCoverEl.className;
|
|
||||||
video.id = currentCoverEl.id;
|
|
||||||
video.style.opacity = '1';
|
|
||||||
video.style.zIndex = '1';
|
|
||||||
video.style.objectFit = 'cover';
|
|
||||||
video.poster = currentCoverEl.src;
|
|
||||||
|
|
||||||
this.setupHlsVideo(video, result, currentCoverEl);
|
if (trackInfo && this.audio.parentElement !== trackInfo) {
|
||||||
currentCoverEl.replaceWith(video);
|
trackInfo.insertBefore(this.audio, trackInfo.firstChild);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const coverUrl = videoCoverUrl || this.api.getCoverUrl(track.album?.cover);
|
|
||||||
|
|
||||||
if (videoCoverUrl) {
|
|
||||||
if (coverEl.tagName === 'IMG') {
|
|
||||||
const video = document.createElement('video');
|
|
||||||
video.src = videoCoverUrl;
|
|
||||||
video.poster = this.api.getCoverUrl(track.album?.cover);
|
|
||||||
video.autoplay = true;
|
|
||||||
video.loop = true;
|
|
||||||
video.muted = true;
|
|
||||||
video.playsInline = true;
|
|
||||||
video.preload = 'auto';
|
|
||||||
video.className = coverEl.className;
|
|
||||||
video.id = coverEl.id;
|
|
||||||
video.style.objectFit = 'cover';
|
|
||||||
video.style.gridArea = '1 / 1';
|
|
||||||
video.onerror = () => {
|
|
||||||
const img = document.createElement('img');
|
|
||||||
img.src = this.api.getCoverUrl(track.album?.cover);
|
|
||||||
img.className = video.className;
|
|
||||||
img.id = video.id;
|
|
||||||
video.replaceWith(img);
|
|
||||||
};
|
|
||||||
coverEl.replaceWith(video);
|
|
||||||
} else if (coverEl.tagName === 'VIDEO' && coverEl.src !== videoCoverUrl) {
|
|
||||||
coverEl.src = videoCoverUrl;
|
|
||||||
coverEl.poster = this.api.getCoverUrl(track.album?.cover);
|
|
||||||
coverEl.style.objectFit = 'cover';
|
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
if (coverEl.tagName === 'VIDEO') {
|
} else {
|
||||||
const img = document.createElement('img');
|
if (coverEl) {
|
||||||
img.src = coverUrl;
|
coverEl.style.display = 'block';
|
||||||
img.className = coverEl.className;
|
const coverUrl = this.api.getCoverUrl(track.image || track.cover || track.album?.cover);
|
||||||
img.id = coverEl.id;
|
if (coverEl.src !== coverUrl) coverEl.src = coverUrl;
|
||||||
coverEl.replaceWith(img);
|
}
|
||||||
} else {
|
if (this.audio) {
|
||||||
coverEl.src = coverUrl;
|
const isInFullscreen = document.getElementById('fullscreen-cover-overlay')?.style.display === 'flex';
|
||||||
|
if (!isInFullscreen) {
|
||||||
|
this.audio.style.display = 'none';
|
||||||
|
if (this.audio.parentElement !== document.body) {
|
||||||
|
document.body.appendChild(this.audio);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -650,7 +633,47 @@ export class Player {
|
||||||
}
|
}
|
||||||
const played = await this.safePlay();
|
const played = await this.safePlay();
|
||||||
if (!played) return;
|
if (!played) return;
|
||||||
|
} else if (track.type === 'video') {
|
||||||
|
if (this.dashInitialized) {
|
||||||
|
this.dashPlayer.reset();
|
||||||
|
this.dashInitialized = false;
|
||||||
|
}
|
||||||
|
if (this.hls) {
|
||||||
|
this.hls.destroy();
|
||||||
|
this.hls = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
streamUrl = await this.api.getVideoStreamUrl(track.id);
|
||||||
|
|
||||||
|
if (streamUrl.includes('.m3u8') || streamUrl.includes('application/vnd.apple.mpegurl')) {
|
||||||
|
this.setupHlsVideo(this.audio, streamUrl, null);
|
||||||
|
} else if (streamUrl.startsWith('blob:') || streamUrl.includes('.mpd')) {
|
||||||
|
this.dashPlayer.initialize(this.audio, streamUrl, true);
|
||||||
|
this.dashInitialized = true;
|
||||||
|
} else {
|
||||||
|
this.audio.src = streamUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.applyAudioEffects();
|
||||||
|
|
||||||
|
if (window.monochromeUi) {
|
||||||
|
const lyricsManager = window.monochromeUi.lyricsManager;
|
||||||
|
window.monochromeUi.showFullscreenCover(track, this.getNextTrack(), lyricsManager, this.audio);
|
||||||
|
}
|
||||||
|
|
||||||
|
const canPlay = await this.waitForCanPlayOrTimeout();
|
||||||
|
if (!canPlay) return;
|
||||||
|
|
||||||
|
if (startTime > 0) {
|
||||||
|
this.audio.currentTime = startTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.safePlay();
|
||||||
} else {
|
} else {
|
||||||
|
if (this.hls) {
|
||||||
|
this.hls.destroy();
|
||||||
|
this.hls = null;
|
||||||
|
}
|
||||||
const isQobuz = String(track.id).startsWith('q:');
|
const isQobuz = String(track.id).startsWith('q:');
|
||||||
|
|
||||||
if (isQobuz) {
|
if (isQobuz) {
|
||||||
|
|
|
||||||
547
js/ui.js
547
js/ui.js
|
|
@ -38,6 +38,7 @@ import { getVibrantColorFromImage } from './vibrant-color.js';
|
||||||
import { syncManager } from './accounts/pocketbase.js';
|
import { syncManager } from './accounts/pocketbase.js';
|
||||||
import { Visualizer } from './visualizer.js';
|
import { Visualizer } from './visualizer.js';
|
||||||
import { navigate } from './router.js';
|
import { navigate } from './router.js';
|
||||||
|
import { sidePanelManager } from './side-panel.js';
|
||||||
import {
|
import {
|
||||||
renderUnreleasedPage as renderUnreleasedTrackerPage,
|
renderUnreleasedPage as renderUnreleasedTrackerPage,
|
||||||
renderTrackerArtistPage as renderTrackerArtistContent,
|
renderTrackerArtistPage as renderTrackerArtistContent,
|
||||||
|
|
@ -260,7 +261,7 @@ export class UIRenderer {
|
||||||
likeBtn.style.display = 'none';
|
likeBtn.style.display = 'none';
|
||||||
} else {
|
} else {
|
||||||
likeBtn.style.display = 'flex';
|
likeBtn.style.display = 'flex';
|
||||||
this.updateLikeState(likeBtn.parentElement, 'track', track.id);
|
this.updateLikeState(likeBtn.parentElement, track.type || 'track', track.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -290,7 +291,7 @@ export class UIRenderer {
|
||||||
fsLikeBtn.style.display = 'none';
|
fsLikeBtn.style.display = 'none';
|
||||||
} else {
|
} else {
|
||||||
fsLikeBtn.style.display = 'flex';
|
fsLikeBtn.style.display = 'flex';
|
||||||
this.updateLikeState(fsLikeBtn.parentElement, 'track', track.id);
|
this.updateLikeState(fsLikeBtn.parentElement, track.type || 'track', track.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (fsAddPlaylistBtn) {
|
if (fsAddPlaylistBtn) {
|
||||||
|
|
@ -341,9 +342,18 @@ export class UIRenderer {
|
||||||
createTrackItemHTML(track, index, showCover = false, hasMultipleDiscs = false, useTrackNumber = false) {
|
createTrackItemHTML(track, index, showCover = false, hasMultipleDiscs = false, useTrackNumber = false) {
|
||||||
const isUnavailable = track.isUnavailable;
|
const isUnavailable = track.isUnavailable;
|
||||||
const isBlocked = contentBlockingSettings?.shouldHideTrack(track);
|
const isBlocked = contentBlockingSettings?.shouldHideTrack(track);
|
||||||
const trackImageHTML = showCover
|
const isVideo = track.type === 'video';
|
||||||
? this.getCoverHTML(track.album?.videoCover, track.album?.cover, 'Track Cover', 'track-item-cover')
|
|
||||||
: '';
|
let trackImageHTML = '';
|
||||||
|
if (showCover) {
|
||||||
|
if (isVideo && this.currentPage === 'playlist') {
|
||||||
|
trackImageHTML = `<div class="track-item-cover video-icon-placeholder" style="display: flex; align-items: center; justify-content: center; background: var(--secondary);"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity: 0.7;"><path d="m22 8-6 4 6 4V8Z"/><rect width="14" height="12" x="2" y="6" rx="2" ry="2"/></svg></div>`;
|
||||||
|
} else if (isVideo && (this.currentPage === 'search' || this.currentPage === 'library')) {
|
||||||
|
trackImageHTML = `<div class="track-item-cover video-icon-placeholder" style="display: flex; align-items: center; justify-content: center; background: var(--secondary);"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style="opacity: 0.7;"><path d="M8 5v14l11-7z"/></svg></div>`;
|
||||||
|
} else {
|
||||||
|
trackImageHTML = this.getCoverHTML(track.image || track.cover || track.album?.cover, 'Track Cover', 'track-item-cover', 'lazy');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let displayIndex;
|
let displayIndex;
|
||||||
if (hasMultipleDiscs && !showCover) {
|
if (hasMultipleDiscs && !showCover) {
|
||||||
|
|
@ -355,6 +365,7 @@ export class UIRenderer {
|
||||||
displayIndex = index + 1;
|
displayIndex = index + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const videoIcon = isVideo ? '<span class="video-item-icon" title="Music Video" style="display: inline-flex; align-items: center; margin-right: 4px; color: var(--muted-foreground);"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 8-6 4 6 4V8Z"/><rect width="14" height="12" x="2" y="6" rx="2" ry="2"/></svg></span>' : '';
|
||||||
const trackNumberHTML = `<div class="track-number">${showCover ? trackImageHTML : displayIndex}</div>`;
|
const trackNumberHTML = `<div class="track-number">${showCover ? trackImageHTML : displayIndex}</div>`;
|
||||||
const explicitBadge = hasExplicitContent(track) ? this.createExplicitBadge() : '';
|
const explicitBadge = hasExplicitContent(track) ? this.createExplicitBadge() : '';
|
||||||
const qualityBadge = createQualityBadgeHTML(track);
|
const qualityBadge = createQualityBadgeHTML(track);
|
||||||
|
|
@ -381,6 +392,7 @@ export class UIRenderer {
|
||||||
|
|
||||||
const classList = [
|
const classList = [
|
||||||
'track-item',
|
'track-item',
|
||||||
|
isVideo ? 'video-track-item' : '',
|
||||||
isCurrentTrack ? 'playing' : '',
|
isCurrentTrack ? 'playing' : '',
|
||||||
isUnavailable ? 'unavailable' : '',
|
isUnavailable ? 'unavailable' : '',
|
||||||
isBlocked ? 'blocked' : '',
|
isBlocked ? 'blocked' : '',
|
||||||
|
|
@ -391,6 +403,7 @@ export class UIRenderer {
|
||||||
return `
|
return `
|
||||||
<div class="${classList}"
|
<div class="${classList}"
|
||||||
data-track-id="${track.id}"
|
data-track-id="${track.id}"
|
||||||
|
${isVideo ? 'data-type="video"' : 'data-type="track"'}
|
||||||
${track.isLocal ? 'data-is-local="true"' : ''}
|
${track.isLocal ? 'data-is-local="true"' : ''}
|
||||||
${isUnavailable ? 'title="This track is currently unavailable"' : ''}
|
${isUnavailable ? 'title="This track is currently unavailable"' : ''}
|
||||||
${blockedTitle}>
|
${blockedTitle}>
|
||||||
|
|
@ -398,6 +411,7 @@ export class UIRenderer {
|
||||||
<div class="track-item-info">
|
<div class="track-item-info">
|
||||||
<div class="track-item-details">
|
<div class="track-item-details">
|
||||||
<div class="title">
|
<div class="title">
|
||||||
|
${videoIcon}
|
||||||
${escapeHtml(trackTitle)}
|
${escapeHtml(trackTitle)}
|
||||||
${explicitBadge}
|
${explicitBadge}
|
||||||
${qualityBadge}
|
${qualityBadge}
|
||||||
|
|
@ -413,11 +427,10 @@ export class UIRenderer {
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
getCoverHTML(videoCover, cover, alt, className = 'card-image', loading = 'lazy', videoCoverUrl = null) {
|
getCoverHTML(cover, alt, className = 'card-image', loading = 'lazy', videoCoverUrl = null) {
|
||||||
const videoUrl = (videoCover ? this.api.tidalAPI.getVideoCoverUrl(videoCover) : null) || videoCoverUrl;
|
|
||||||
const imageUrl = this.api.getCoverUrl(cover);
|
const imageUrl = this.api.getCoverUrl(cover);
|
||||||
if (videoUrl) {
|
if (videoCoverUrl) {
|
||||||
return `<video src="${videoUrl}" poster="${imageUrl}" class="${className}" alt="${alt}" autoplay loop muted playsinline preload="auto" onerror="this.onerror=null; this.outerHTML='<img src="${imageUrl}" class="${className}" alt="${alt}" loading="${loading}">';"></video>`;
|
return `<video src="${videoCoverUrl}" poster="${imageUrl}" class="${className}" alt="${alt}" preload="metadata" playsinline muted></video>`;
|
||||||
}
|
}
|
||||||
return `<img src="${imageUrl}" class="${className}" alt="${alt}" loading="${loading}">`;
|
return `<img src="${imageUrl}" class="${className}" alt="${alt}" loading="${loading}">`;
|
||||||
}
|
}
|
||||||
|
|
@ -625,7 +638,7 @@ export class UIRenderer {
|
||||||
href: `/album/${album.id}`,
|
href: `/album/${album.id}`,
|
||||||
title: `${escapeHtml(album.title)} ${explicitBadge} ${qualityBadge}`,
|
title: `${escapeHtml(album.title)} ${explicitBadge} ${qualityBadge}`,
|
||||||
subtitle: `${escapeHtml(artistName)} • ${yearDisplay}${typeLabel}`,
|
subtitle: `${escapeHtml(artistName)} • ${yearDisplay}${typeLabel}`,
|
||||||
imageHTML: this.getCoverHTML(album.videoCover, album.cover, escapeHtml(album.title), 'card-image', 'lazy', album.videoCoverUrl),
|
imageHTML: this.getCoverHTML(album.cover, escapeHtml(album.title), 'card-image', 'lazy', album.videoCoverUrl),
|
||||||
actionButtonsHTML: `
|
actionButtonsHTML: `
|
||||||
<button class="like-btn card-like-btn" data-action="toggle-like" data-type="album" title="Add to Liked">
|
<button class="like-btn card-like-btn" data-action="toggle-like" data-type="album" title="Add to Liked">
|
||||||
${this.createHeartIcon(false)}
|
${this.createHeartIcon(false)}
|
||||||
|
|
@ -639,6 +652,41 @@ export class UIRenderer {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createVideoCardHTML(video) {
|
||||||
|
const duration = formatTime(video.duration);
|
||||||
|
const artistName = getTrackArtists(video);
|
||||||
|
|
||||||
|
const cover = video.image || video.cover;
|
||||||
|
let imageHTML;
|
||||||
|
|
||||||
|
if (cover) {
|
||||||
|
imageHTML = this.getCoverHTML(cover, escapeHtml(video.title));
|
||||||
|
} else {
|
||||||
|
imageHTML = `<div class="card-image video-icon-placeholder" style="display: flex; align-items: center; justify-content: center; background: var(--secondary); aspect-ratio: 16/9; width: 100%;"><svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor" style="opacity: 0.7;"><path d="M8 5v14l11-7z"/></svg></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="card video-card" data-video-id="${video.id}" data-type="video" draggable="true">
|
||||||
|
<div class="card-image-container">
|
||||||
|
${imageHTML}
|
||||||
|
<div class="card-overlay">
|
||||||
|
<button class="card-play-btn" title="Play video">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button class="like-btn card-like-btn" data-action="toggle-like" data-type="video" title="Add to Liked">
|
||||||
|
${this.createHeartIcon(false)}
|
||||||
|
</button>
|
||||||
|
<div class="video-duration-badge" style="position: absolute; bottom: 8px; right: 8px; background: rgba(0,0,0,0.7); color: white; padding: 2px 6px; border-radius: 4px; font-size: 12px; font-weight: 500;">${duration}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-info">
|
||||||
|
<div class="card-title" title="${escapeHtml(video.title)}">${escapeHtml(video.title)}</div>
|
||||||
|
<div class="card-subtitle">${escapeHtml(artistName)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
createArtistCardHTML(artist) {
|
createArtistCardHTML(artist) {
|
||||||
const isCompact = cardSettings.isCompactArtist();
|
const isCompact = cardSettings.isCompactArtist();
|
||||||
const isBlocked = contentBlockingSettings?.shouldHideArtist(artist);
|
const isBlocked = contentBlockingSettings?.shouldHideArtist(artist);
|
||||||
|
|
@ -788,7 +836,7 @@ export class UIRenderer {
|
||||||
if (element && track) {
|
if (element && track) {
|
||||||
trackDataStore.set(element, track);
|
trackDataStore.set(element, track);
|
||||||
// Async update for like button
|
// Async update for like button
|
||||||
this.updateLikeState(element, 'track', track.id);
|
this.updateLikeState(element, track.type || 'track', track.id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -906,48 +954,87 @@ export class UIRenderer {
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
const overlay = document.getElementById('fullscreen-cover-overlay');
|
const overlay = document.getElementById('fullscreen-cover-overlay');
|
||||||
const image = document.getElementById('fullscreen-cover-image');
|
const image = document.getElementById('fullscreen-cover-image');
|
||||||
|
const videoContainer = document.getElementById('fullscreen-video-container');
|
||||||
const title = document.getElementById('fullscreen-track-title');
|
const title = document.getElementById('fullscreen-track-title');
|
||||||
const artist = document.getElementById('fullscreen-track-artist');
|
const artist = document.getElementById('fullscreen-track-artist');
|
||||||
const nextTrackEl = document.getElementById('fullscreen-next-track');
|
const nextTrackEl = document.getElementById('fullscreen-next-track');
|
||||||
|
|
||||||
const videoCoverUrl = track.album?.videoCover
|
const isRealVideo = track.type === 'video';
|
||||||
? this.api.tidalAPI.getVideoCoverUrl(track.album.videoCover, '1280')
|
const visualizerContainer = document.getElementById('visualizer-container');
|
||||||
: (track.album?.videoCoverUrl || null);
|
overlay.classList.toggle('is-video-mode', isRealVideo);
|
||||||
const coverUrl = videoCoverUrl || this.api.getCoverUrl(track.album?.cover, '1280');
|
|
||||||
|
|
||||||
const fsLikeBtn = document.getElementById('fs-like-btn');
|
const toggleUiBtn = document.getElementById('toggle-ui-btn');
|
||||||
if (fsLikeBtn) {
|
if (toggleUiBtn) {
|
||||||
this.updateLikeState(fsLikeBtn.parentElement, 'track', track.id);
|
toggleUiBtn.style.display = isRealVideo ? 'none' : 'flex';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (videoCoverUrl) {
|
if (isRealVideo) {
|
||||||
if (image.tagName === 'IMG') {
|
if (sidePanelManager.isActive('lyrics')) {
|
||||||
const video = document.createElement('video');
|
sidePanelManager.close();
|
||||||
video.src = videoCoverUrl;
|
|
||||||
video.autoplay = true;
|
|
||||||
video.loop = true;
|
|
||||||
video.muted = true;
|
|
||||||
video.playsInline = true;
|
|
||||||
video.id = image.id;
|
|
||||||
video.className = image.className;
|
|
||||||
image.replaceWith(video);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (videoContainer) {
|
||||||
|
videoContainer.style.display = 'flex';
|
||||||
|
const audioPlayer = document.getElementById('audio-player');
|
||||||
|
if (audioPlayer && audioPlayer.parentElement !== videoContainer) {
|
||||||
|
videoContainer.appendChild(audioPlayer);
|
||||||
|
audioPlayer.style.display = 'block';
|
||||||
|
audioPlayer.style.width = '100%';
|
||||||
|
audioPlayer.style.height = '100%';
|
||||||
|
audioPlayer.style.objectFit = 'contain';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (image) image.style.display = 'none';
|
||||||
|
if (visualizerContainer) visualizerContainer.style.display = 'none';
|
||||||
} else {
|
} else {
|
||||||
if (image.tagName === 'VIDEO') {
|
if (videoContainer) {
|
||||||
const img = document.createElement('img');
|
videoContainer.style.display = 'none';
|
||||||
img.src = coverUrl;
|
const audioPlayer = document.getElementById('audio-player');
|
||||||
img.id = image.id;
|
if (audioPlayer && audioPlayer.parentElement === videoContainer) {
|
||||||
img.className = image.className;
|
document.body.appendChild(audioPlayer);
|
||||||
image.replaceWith(img);
|
audioPlayer.style.display = 'none';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
if (image) image.style.display = 'block';
|
||||||
|
if (visualizerContainer) visualizerContainer.style.display = 'block';
|
||||||
|
|
||||||
const currentImage = document.getElementById('fullscreen-cover-image');
|
const videoCoverUrl = track.videoUrl || track.videoCoverUrl || track.album?.videoCoverUrl || null; const coverUrl = videoCoverUrl || this.api.getCoverUrl(track.album?.cover, '1280');
|
||||||
if (currentImage.src !== coverUrl || !videoCoverUrl) {
|
|
||||||
currentImage.src = coverUrl;
|
const fsLikeBtn = document.getElementById('fs-like-btn');
|
||||||
|
if (fsLikeBtn) {
|
||||||
|
this.updateLikeState(fsLikeBtn.parentElement, track.type || 'track', track.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentImage = document.getElementById('fullscreen-cover-image');
|
||||||
|
|
||||||
|
if (videoCoverUrl) {
|
||||||
|
if (currentImage.tagName === 'IMG') {
|
||||||
|
const video = document.createElement('video');
|
||||||
|
video.src = videoCoverUrl;
|
||||||
|
video.autoplay = false;
|
||||||
|
video.loop = false;
|
||||||
|
video.muted = true;
|
||||||
|
video.playsInline = true;
|
||||||
|
video.preload = 'metadata';
|
||||||
|
video.className = currentImage.className;
|
||||||
|
currentImage.replaceWith(video);
|
||||||
|
} else if (currentImage.src !== videoCoverUrl) {
|
||||||
|
currentImage.src = videoCoverUrl;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (currentImage.tagName === 'VIDEO') {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = coverUrl;
|
||||||
|
img.id = currentImage.id;
|
||||||
|
img.className = currentImage.className;
|
||||||
|
currentImage.replaceWith(img);
|
||||||
|
} else if (currentImage.src !== coverUrl) {
|
||||||
|
currentImage.src = coverUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
overlay.style.setProperty('--bg-image', `url('${this.api.getCoverUrl(track.album?.cover, '1280')}')`);
|
||||||
|
this.extractAndApplyColor(this.api.getCoverUrl(track.album?.cover, '80'));
|
||||||
}
|
}
|
||||||
overlay.style.setProperty('--bg-image', `url('${coverUrl}')`);
|
|
||||||
this.extractAndApplyColor(this.api.getCoverUrl(track.album?.cover, '80'));
|
|
||||||
|
|
||||||
const qualityBadge = createQualityBadgeHTML(track);
|
const qualityBadge = createQualityBadgeHTML(track);
|
||||||
title.innerHTML = `${escapeHtml(track.title)} ${qualityBadge}`;
|
title.innerHTML = `${escapeHtml(track.title)} ${qualityBadge}`;
|
||||||
|
|
@ -1059,6 +1146,28 @@ export class UIRenderer {
|
||||||
const playerBar = document.querySelector('.now-playing-bar');
|
const playerBar = document.querySelector('.now-playing-bar');
|
||||||
if (playerBar) playerBar.style.removeProperty('display');
|
if (playerBar) playerBar.style.removeProperty('display');
|
||||||
|
|
||||||
|
if (this.player?.currentTrack?.type === 'video') {
|
||||||
|
const coverContainer = document.querySelector('.now-playing-bar .track-info');
|
||||||
|
const audioPlayer = document.getElementById('audio-player');
|
||||||
|
const imgCover = coverContainer?.querySelector('.cover:not(#audio-player)');
|
||||||
|
|
||||||
|
if (audioPlayer && coverContainer) {
|
||||||
|
if (imgCover) imgCover.style.display = 'none';
|
||||||
|
|
||||||
|
audioPlayer.style.display = 'block';
|
||||||
|
audioPlayer.classList.add('cover', 'video-cover-mirror');
|
||||||
|
audioPlayer.style.width = '56px';
|
||||||
|
audioPlayer.style.height = '56px';
|
||||||
|
audioPlayer.style.borderRadius = 'var(--radius-sm)';
|
||||||
|
audioPlayer.style.objectFit = 'cover';
|
||||||
|
audioPlayer.style.gridArea = 'none';
|
||||||
|
|
||||||
|
if (audioPlayer.parentElement !== coverContainer) {
|
||||||
|
coverContainer.insertBefore(audioPlayer, coverContainer.firstChild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (this.fullscreenUpdateInterval) {
|
if (this.fullscreenUpdateInterval) {
|
||||||
cancelAnimationFrame(this.fullscreenUpdateInterval);
|
cancelAnimationFrame(this.fullscreenUpdateInterval);
|
||||||
this.fullscreenUpdateInterval = null;
|
this.fullscreenUpdateInterval = null;
|
||||||
|
|
@ -1107,16 +1216,32 @@ export class UIRenderer {
|
||||||
const isNearTopRight = e.clientY < 100 && e.clientX > rect.width - 150;
|
const isNearTopRight = e.clientY < 100 && e.clientX > rect.width - 150;
|
||||||
|
|
||||||
if (isUIHidden) {
|
if (isUIHidden) {
|
||||||
// When UI is hidden, only show button when mouse is near top-right
|
if (overlay.classList.contains('is-video-mode')) {
|
||||||
if (isNearTopRight) {
|
toggleUI();
|
||||||
|
} else if (isNearTopRight) {
|
||||||
showButton();
|
showButton();
|
||||||
} else {
|
} else {
|
||||||
hideButton();
|
hideButton();
|
||||||
}
|
}
|
||||||
|
} else if (overlay.classList.contains('is-video-mode')) {
|
||||||
|
resetVideoHideTimer();
|
||||||
}
|
}
|
||||||
// When UI is visible, button stays visible (no auto-hide)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let videoHideTimer = null;
|
||||||
|
const resetVideoHideTimer = () => {
|
||||||
|
if (videoHideTimer) clearTimeout(videoHideTimer);
|
||||||
|
if (!overlay.classList.contains('is-video-mode') || isUIHidden) return;
|
||||||
|
|
||||||
|
videoHideTimer = setTimeout(() => {
|
||||||
|
if (!isUIHidden && overlay.classList.contains('is-video-mode')) {
|
||||||
|
toggleUI();
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
resetVideoHideTimer();
|
||||||
|
|
||||||
// Toggle UI visibility
|
// Toggle UI visibility
|
||||||
const toggleUI = () => {
|
const toggleUI = () => {
|
||||||
isUIHidden = !isUIHidden;
|
isUIHidden = !isUIHidden;
|
||||||
|
|
@ -1476,11 +1601,13 @@ export class UIRenderer {
|
||||||
this.showPage('library');
|
this.showPage('library');
|
||||||
|
|
||||||
const tracksContainer = document.getElementById('library-tracks-container');
|
const tracksContainer = document.getElementById('library-tracks-container');
|
||||||
|
const videosTabContent = document.getElementById('library-tab-videos');
|
||||||
const albumsContainer = document.getElementById('library-albums-container');
|
const albumsContainer = document.getElementById('library-albums-container');
|
||||||
const artistsContainer = document.getElementById('library-artists-container');
|
const artistsContainer = document.getElementById('library-artists-container');
|
||||||
const playlistsContainer = document.getElementById('library-playlists-container');
|
const playlistsContainer = document.getElementById('library-playlists-container');
|
||||||
const localContainer = document.getElementById('library-local-container');
|
const localContainer = document.getElementById('library-local-container');
|
||||||
const foldersContainer = document.getElementById('my-folders-container');
|
const foldersContainer = document.getElementById('my-folders-container');
|
||||||
|
const myPlaylistsContainer = document.getElementById('my-playlists-container');
|
||||||
|
|
||||||
const likedTracks = await db.getFavorites('track');
|
const likedTracks = await db.getFavorites('track');
|
||||||
const shuffleBtn = document.getElementById('shuffle-liked-tracks-btn');
|
const shuffleBtn = document.getElementById('shuffle-liked-tracks-btn');
|
||||||
|
|
@ -1496,6 +1623,29 @@ export class UIRenderer {
|
||||||
tracksContainer.innerHTML = createPlaceholder('No liked tracks yet.');
|
tracksContainer.innerHTML = createPlaceholder('No liked tracks yet.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const likedVideos = await db.getFavorites('video');
|
||||||
|
if (videosTabContent) {
|
||||||
|
const grid = videosTabContent.querySelector('.card-grid');
|
||||||
|
if (likedVideos.length) {
|
||||||
|
grid.innerHTML = likedVideos.map((v) => this.createVideoCardHTML(v)).join('');
|
||||||
|
likedVideos.forEach((video) => {
|
||||||
|
const el = grid.querySelector(`[data-video-id="${video.id}"]`);
|
||||||
|
if (el) {
|
||||||
|
trackDataStore.set(el, video);
|
||||||
|
this.updateLikeState(el, 'video', video.id);
|
||||||
|
el.addEventListener('click', (e) => {
|
||||||
|
if (e.target.closest('.card-play-btn') || e.target.closest('.card-image-container')) {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.player.playVideo(video);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
grid.innerHTML = createPlaceholder('No liked videos yet.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const likedAlbums = await db.getFavorites('album');
|
const likedAlbums = await db.getFavorites('album');
|
||||||
if (likedAlbums.length) {
|
if (likedAlbums.length) {
|
||||||
albumsContainer.innerHTML = likedAlbums.map((a) => this.createAlbumCardHTML(a)).join('');
|
albumsContainer.innerHTML = likedAlbums.map((a) => this.createAlbumCardHTML(a)).join('');
|
||||||
|
|
@ -1527,38 +1677,6 @@ export class UIRenderer {
|
||||||
const likedPlaylists = await db.getFavorites('playlist');
|
const likedPlaylists = await db.getFavorites('playlist');
|
||||||
const likedMixes = await db.getFavorites('mix');
|
const likedMixes = await db.getFavorites('mix');
|
||||||
|
|
||||||
|
|
||||||
if (likedTracks.length > 0) {
|
|
||||||
likedTracks.slice(0, 2).forEach((track) => {
|
|
||||||
if (!track.album?.videoCover && !track.album?.videoCoverUrl) {
|
|
||||||
this.api.getVideoArtwork(track.title, getTrackArtists(track)).then((result) => {
|
|
||||||
if (result && this.currentPage === 'library') {
|
|
||||||
const url = result.videoUrl || result.hlsUrl;
|
|
||||||
if (!url) return;
|
|
||||||
track.album = track.album || {};
|
|
||||||
track.album.videoCoverUrl = url;
|
|
||||||
this.replaceVideoArtwork(tracksContainer, 'track', track.id, result);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (likedAlbums.length > 0) {
|
|
||||||
likedAlbums.slice(0, 2).forEach((album) => {
|
|
||||||
if (!album.videoCover && !album.videoCoverUrl) {
|
|
||||||
this.api.getVideoArtwork(album.title, typeof album.artist === 'string' ? album.artist : (album.artist?.name || '')).then((result) => {
|
|
||||||
if (result && this.currentPage === 'library') {
|
|
||||||
const url = result.videoUrl || result.hlsUrl;
|
|
||||||
if (!url) return;
|
|
||||||
album.videoCoverUrl = url;
|
|
||||||
this.replaceVideoArtwork(albumsContainer, 'album', album.id, result);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let mixedContent = [];
|
let mixedContent = [];
|
||||||
if (likedPlaylists.length) mixedContent.push(...likedPlaylists.map((p) => ({ ...p, _type: 'playlist' })));
|
if (likedPlaylists.length) mixedContent.push(...likedPlaylists.map((p) => ({ ...p, _type: 'playlist' })));
|
||||||
if (likedMixes.length) mixedContent.push(...likedMixes.map((m) => ({ ...m, _type: 'mix' })));
|
if (likedMixes.length) mixedContent.push(...likedMixes.map((m) => ({ ...m, _type: 'mix' })));
|
||||||
|
|
@ -1598,9 +1716,7 @@ export class UIRenderer {
|
||||||
foldersContainer.style.display = folders.length ? 'grid' : 'none';
|
foldersContainer.style.display = folders.length ? 'grid' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
const myPlaylistsContainer = document.getElementById('my-playlists-container');
|
|
||||||
const myPlaylists = await db.getPlaylists();
|
const myPlaylists = await db.getPlaylists();
|
||||||
|
|
||||||
const playlistsInFolders = new Set();
|
const playlistsInFolders = new Set();
|
||||||
folders.forEach((folder) => {
|
folders.forEach((folder) => {
|
||||||
if (folder.playlists) {
|
if (folder.playlists) {
|
||||||
|
|
@ -1610,24 +1726,28 @@ export class UIRenderer {
|
||||||
|
|
||||||
const visiblePlaylists = myPlaylists.filter((p) => !playlistsInFolders.has(p.id));
|
const visiblePlaylists = myPlaylists.filter((p) => !playlistsInFolders.has(p.id));
|
||||||
|
|
||||||
if (visiblePlaylists.length) {
|
if (myPlaylistsContainer) {
|
||||||
myPlaylistsContainer.innerHTML = visiblePlaylists.map((p) => this.createUserPlaylistCardHTML(p)).join('');
|
if (visiblePlaylists.length) {
|
||||||
visiblePlaylists.forEach((playlist) => {
|
myPlaylistsContainer.innerHTML = visiblePlaylists.map((p) => this.createUserPlaylistCardHTML(p)).join('');
|
||||||
const el = myPlaylistsContainer.querySelector(`[data-user-playlist-id="${playlist.id}"]`);
|
visiblePlaylists.forEach((playlist) => {
|
||||||
if (el) {
|
const el = myPlaylistsContainer.querySelector(`[data-user-playlist-id="${playlist.id}"]`);
|
||||||
trackDataStore.set(el, playlist);
|
if (el) {
|
||||||
}
|
trackDataStore.set(el, playlist);
|
||||||
});
|
}
|
||||||
} else {
|
});
|
||||||
if (folders.length === 0) {
|
|
||||||
myPlaylistsContainer.innerHTML = createPlaceholder('No playlists yet. Create your first playlist!');
|
|
||||||
} else {
|
} else {
|
||||||
myPlaylistsContainer.innerHTML = '';
|
if (folders.length === 0) {
|
||||||
|
myPlaylistsContainer.innerHTML = createPlaceholder('No playlists yet. Create your first playlist!');
|
||||||
|
} else {
|
||||||
|
myPlaylistsContainer.innerHTML = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render Local Files
|
// Render Local Files
|
||||||
this.renderLocalFiles(localContainer);
|
if (localContainer) {
|
||||||
|
this.renderLocalFiles(localContainer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async renderLocalFiles(container) {
|
async renderLocalFiles(container) {
|
||||||
|
|
@ -2087,7 +2207,7 @@ export class UIRenderer {
|
||||||
href: `/track/${track.id}`,
|
href: `/track/${track.id}`,
|
||||||
title: `${escapeHtml(getTrackTitle(track))} ${explicitBadge} ${qualityBadge}`,
|
title: `${escapeHtml(getTrackTitle(track))} ${explicitBadge} ${qualityBadge}`,
|
||||||
subtitle: escapeHtml(getTrackArtists(track)),
|
subtitle: escapeHtml(getTrackArtists(track)),
|
||||||
imageHTML: this.getCoverHTML(track.album?.videoCover, track.album?.cover, escapeHtml(track.title)),
|
imageHTML: this.getCoverHTML(track.album?.cover, escapeHtml(track.title), 'card-image', 'lazy', track.videoUrl || track.album?.videoCoverUrl),
|
||||||
actionButtonsHTML: `
|
actionButtonsHTML: `
|
||||||
<button class="like-btn card-like-btn" data-action="toggle-like" data-type="track" title="Add to Liked">
|
<button class="like-btn card-like-btn" data-action="toggle-like" data-type="track" title="Add to Liked">
|
||||||
${this.createHeartIcon(false)}
|
${this.createHeartIcon(false)}
|
||||||
|
|
@ -2471,11 +2591,11 @@ export class UIRenderer {
|
||||||
const img = card.querySelector('.card-image');
|
const img = card.querySelector('.card-image');
|
||||||
if (img && img.tagName !== 'VIDEO') {
|
if (img && img.tagName !== 'VIDEO') {
|
||||||
const video = document.createElement('video');
|
const video = document.createElement('video');
|
||||||
video.autoplay = true;
|
video.autoplay = false;
|
||||||
video.loop = true;
|
video.loop = false;
|
||||||
video.muted = true;
|
video.muted = true;
|
||||||
video.playsInline = true;
|
video.playsInline = true;
|
||||||
video.preload = 'auto';
|
video.preload = 'metadata';
|
||||||
video.className = img.className;
|
video.className = img.className;
|
||||||
video.id = img.id;
|
video.id = img.id;
|
||||||
video.style.objectFit = 'cover';
|
video.style.objectFit = 'cover';
|
||||||
|
|
@ -2502,6 +2622,16 @@ export class UIRenderer {
|
||||||
img.replaceWith(video);
|
img.replaceWith(video);
|
||||||
|
|
||||||
this.setupHlsVideo(video, result, img);
|
this.setupHlsVideo(video, result, img);
|
||||||
|
|
||||||
|
// If HLS, dont play
|
||||||
|
const hls = video._hls;
|
||||||
|
if (hls) {
|
||||||
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||||
|
// Dont play
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
video.src = url;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2527,14 +2657,16 @@ export class UIRenderer {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const provider = this.api.getCurrentProvider();
|
const provider = this.api.getCurrentProvider();
|
||||||
const [tracksResult, artistsResult, albumsResult, playlistsResult] = await Promise.all([
|
const [tracksResult, videosResult, artistsResult, albumsResult, playlistsResult] = await Promise.all([
|
||||||
this.api.searchTracks(query, { signal, provider }),
|
this.api.searchTracks(query, { signal, provider }),
|
||||||
|
this.api.searchVideos(query, { signal, provider }),
|
||||||
this.api.searchArtists(query, { signal, provider }),
|
this.api.searchArtists(query, { signal, provider }),
|
||||||
this.api.searchAlbums(query, { signal, provider }),
|
this.api.searchAlbums(query, { signal, provider }),
|
||||||
this.api.searchPlaylists(query, { signal, provider }),
|
this.api.searchPlaylists(query, { signal, provider }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let finalTracks = tracksResult.items;
|
let finalTracks = tracksResult.items;
|
||||||
|
let finalVideos = videosResult.items || [];
|
||||||
let finalArtists = artistsResult.items;
|
let finalArtists = artistsResult.items;
|
||||||
let finalAlbums = albumsResult.items;
|
let finalAlbums = albumsResult.items;
|
||||||
let finalPlaylists = playlistsResult.items;
|
let finalPlaylists = playlistsResult.items;
|
||||||
|
|
@ -2576,6 +2708,26 @@ export class UIRenderer {
|
||||||
tracksContainer.innerHTML = createPlaceholder('No tracks found.');
|
tracksContainer.innerHTML = createPlaceholder('No tracks found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const videosContainer = document.getElementById('search-videos-container');
|
||||||
|
if (videosContainer) {
|
||||||
|
videosContainer.innerHTML = finalVideos.length
|
||||||
|
? finalVideos.map((video) => this.createVideoCardHTML(video)).join('')
|
||||||
|
: createPlaceholder('No videos found.');
|
||||||
|
|
||||||
|
finalVideos.forEach((video) => {
|
||||||
|
const el = videosContainer.querySelector(`[data-video-id="${video.id}"]`);
|
||||||
|
if (el) {
|
||||||
|
trackDataStore.set(el, video);
|
||||||
|
el.addEventListener('click', (e) => {
|
||||||
|
if (e.target.closest('.card-play-btn') || e.target.closest('.card-image-container')) {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.player.playVideo(video);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
artistsContainer.innerHTML = finalArtists.length
|
artistsContainer.innerHTML = finalArtists.length
|
||||||
? finalArtists.map((artist) => this.createArtistCardHTML(artist)).join('')
|
? finalArtists.map((artist) => this.createArtistCardHTML(artist)).join('')
|
||||||
: createPlaceholder('No artists found.');
|
: createPlaceholder('No artists found.');
|
||||||
|
|
@ -2611,35 +2763,6 @@ export class UIRenderer {
|
||||||
this.updateLikeState(el, 'playlist', playlist.uuid);
|
this.updateLikeState(el, 'playlist', playlist.uuid);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (finalTracks.length > 0) {
|
|
||||||
const track = finalTracks[0];
|
|
||||||
if (!track.album?.videoCover && !track.album?.videoCoverUrl) {
|
|
||||||
this.api.getVideoArtwork(track.title, getTrackArtists(track)).then((result) => {
|
|
||||||
if (result && this.currentPage === 'search') {
|
|
||||||
const url = result.videoUrl || result.hlsUrl;
|
|
||||||
if (!url) return;
|
|
||||||
track.album = track.album || {};
|
|
||||||
track.album.videoCoverUrl = url;
|
|
||||||
this.replaceVideoArtwork(tracksContainer, 'track', track.id, result);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (finalAlbums.length > 0) {
|
|
||||||
const album = finalAlbums[0];
|
|
||||||
if (!album.videoCover && !album.videoCoverUrl) {
|
|
||||||
this.api.getVideoArtwork(album.title, typeof album.artist === 'string' ? album.artist : (album.artist?.name || '')).then((result) => {
|
|
||||||
if (result && this.currentPage === 'search') {
|
|
||||||
const url = result.videoUrl || result.hlsUrl;
|
|
||||||
if (!url) return;
|
|
||||||
album.videoCoverUrl = url;
|
|
||||||
this.replaceVideoArtwork(albumsContainer, 'album', album.id, result);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.name === 'AbortError') return;
|
if (error.name === 'AbortError') return;
|
||||||
console.error('Search failed:', error);
|
console.error('Search failed:', error);
|
||||||
|
|
@ -2761,9 +2884,7 @@ export class UIRenderer {
|
||||||
const { album, tracks } = await this.api.getAlbum(albumId, provider);
|
const { album, tracks } = await this.api.getAlbum(albumId, provider);
|
||||||
this.currentAlbumId = albumId;
|
this.currentAlbumId = albumId;
|
||||||
|
|
||||||
let videoCoverUrl = album.videoCover
|
const videoCoverUrl = album.videoCoverUrl || null;
|
||||||
? this.api.tidalAPI.getVideoCoverUrl(album.videoCover)
|
|
||||||
: (album.videoCoverUrl || null);
|
|
||||||
|
|
||||||
if (!videoCoverUrl && tracks.length > 0) {
|
if (!videoCoverUrl && tracks.length > 0) {
|
||||||
const firstTrack = tracks[0];
|
const firstTrack = tracks[0];
|
||||||
|
|
@ -2775,20 +2896,19 @@ export class UIRenderer {
|
||||||
const currentImageEl = document.getElementById('album-detail-image');
|
const currentImageEl = document.getElementById('album-detail-image');
|
||||||
if (currentImageEl && currentImageEl.tagName !== 'VIDEO') {
|
if (currentImageEl && currentImageEl.tagName !== 'VIDEO') {
|
||||||
const video = document.createElement('video');
|
const video = document.createElement('video');
|
||||||
video.autoplay = true;
|
video.autoplay = false;
|
||||||
video.loop = true;
|
video.loop = false;
|
||||||
video.muted = true;
|
video.muted = true;
|
||||||
video.playsInline = true;
|
video.playsInline = true;
|
||||||
video.preload = 'auto';
|
video.preload = 'metadata';
|
||||||
video.className = currentImageEl.className;
|
video.className = currentImageEl.className;
|
||||||
video.id = currentImageEl.id;
|
video.id = currentImageEl.id;
|
||||||
video.style.objectFit = 'cover';
|
video.style.opacity = '1';
|
||||||
video.poster = currentImageEl.src;
|
video.poster = currentImageEl.src;
|
||||||
|
|
||||||
this.setupHlsVideo(video, result, currentImageEl);
|
this.setupHlsVideo(video, result, currentImageEl);
|
||||||
currentImageEl.replaceWith(video);
|
currentImageEl.replaceWith(video);
|
||||||
}
|
} }
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3268,6 +3388,7 @@ export class UIRenderer {
|
||||||
removeBtn.innerHTML =
|
removeBtn.innerHTML =
|
||||||
'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>';
|
'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>';
|
||||||
removeBtn.dataset.trackId = currentTracks[index].id;
|
removeBtn.dataset.trackId = currentTracks[index].id;
|
||||||
|
removeBtn.dataset.type = currentTracks[index].type || 'track';
|
||||||
|
|
||||||
const menuBtn = actionsDiv.querySelector('.track-menu-btn');
|
const menuBtn = actionsDiv.querySelector('.track-menu-btn');
|
||||||
actionsDiv.insertBefore(removeBtn, menuBtn);
|
actionsDiv.insertBefore(removeBtn, menuBtn);
|
||||||
|
|
@ -3551,43 +3672,48 @@ export class UIRenderer {
|
||||||
// Try to get cover from first track album
|
// Try to get cover from first track album
|
||||||
if (tracks.length > 0 && tracks[0].album?.cover) {
|
if (tracks.length > 0 && tracks[0].album?.cover) {
|
||||||
const firstTrack = tracks[0];
|
const firstTrack = tracks[0];
|
||||||
let videoCoverUrl = firstTrack.album?.videoCover
|
let videoCoverUrl = firstTrack.videoUrl || firstTrack.videoCoverUrl || firstTrack.album?.videoCoverUrl || null;
|
||||||
? this.api.tidalAPI.getVideoCoverUrl(firstTrack.album.videoCover)
|
|
||||||
: (firstTrack.album?.videoCoverUrl || null);
|
|
||||||
|
|
||||||
if (!videoCoverUrl && firstTrack.album) {
|
if (!videoCoverUrl && (firstTrack.album || firstTrack.type === 'video')) {
|
||||||
this.api.getVideoArtwork(firstTrack.title, getTrackArtists(firstTrack)).then((result) => {
|
const fetchArtwork = () => {
|
||||||
if (result && this.currentPage === 'mix' && this.currentMixId === mixId) {
|
this.api.getVideoArtwork(firstTrack.title, getTrackArtists(firstTrack)).then((result) => {
|
||||||
const url = result.videoUrl || result.hlsUrl;
|
if (result && this.currentPage === 'mix' && this.currentMixId === mixId) {
|
||||||
if (!url) return;
|
const url = result.videoUrl || result.hlsUrl;
|
||||||
firstTrack.album.videoCoverUrl = url;
|
if (!url) return;
|
||||||
const currentImageEl = document.getElementById('mix-detail-image');
|
firstTrack.album = firstTrack.album || {};
|
||||||
if (currentImageEl && currentImageEl.tagName !== 'VIDEO') {
|
firstTrack.album.videoCoverUrl = url;
|
||||||
const video = document.createElement('video');
|
const currentImageEl = document.getElementById('mix-detail-image');
|
||||||
video.autoplay = true;
|
if (currentImageEl && currentImageEl.tagName !== 'VIDEO') {
|
||||||
video.loop = true;
|
const video = document.createElement('video');
|
||||||
video.muted = true;
|
video.autoplay = false;
|
||||||
video.playsInline = true;
|
video.loop = false;
|
||||||
video.preload = 'auto';
|
video.muted = true;
|
||||||
video.className = currentImageEl.className;
|
video.playsInline = true;
|
||||||
video.id = currentImageEl.id;
|
video.preload = 'metadata';
|
||||||
video.style.opacity = '0';
|
video.className = currentImageEl.className;
|
||||||
video.style.transition = 'opacity 0.3s ease-in-out';
|
video.id = currentImageEl.id;
|
||||||
|
|
||||||
video.oncanplay = () => {
|
|
||||||
video.style.opacity = '1';
|
video.style.opacity = '1';
|
||||||
setTimeout(() => {
|
video.poster = currentImageEl.src;
|
||||||
if (currentImageEl.parentNode) {
|
|
||||||
currentImageEl.style.display = 'none';
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
};
|
|
||||||
|
|
||||||
this.setupHlsVideo(video, result, currentImageEl);
|
this.setupHlsVideo(video, result, currentImageEl);
|
||||||
currentImageEl.parentNode.insertBefore(video, currentImageEl);
|
currentImageEl.replaceWith(video);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (firstTrack.type === 'video') {
|
||||||
|
this.api.getVideoStreamUrl(firstTrack.id).then((url) => {
|
||||||
|
if (url) {
|
||||||
|
firstTrack.videoUrl = url;
|
||||||
|
this.renderMixPage(mixId);
|
||||||
|
} else {
|
||||||
|
fetchArtwork();
|
||||||
|
}
|
||||||
|
}).catch(fetchArtwork);
|
||||||
|
} else {
|
||||||
|
fetchArtwork();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const coverUrl = videoCoverUrl || this.api.getCoverUrl(firstTrack.album.cover);
|
const coverUrl = videoCoverUrl || this.api.getCoverUrl(firstTrack.album.cover);
|
||||||
|
|
@ -3980,6 +4106,25 @@ export class UIRenderer {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const videosSection = document.getElementById('artist-section-videos');
|
||||||
|
const videosContainer = document.getElementById('artist-detail-videos');
|
||||||
|
if (videosSection && videosContainer) {
|
||||||
|
if (artist.videos && artist.videos.length > 0) {
|
||||||
|
videosContainer.innerHTML = artist.videos.map((video) => this.createVideoCardHTML(video)).join('');
|
||||||
|
videosSection.style.display = 'block';
|
||||||
|
|
||||||
|
artist.videos.forEach((video) => {
|
||||||
|
const el = videosContainer.querySelector(`[data-video-id="${video.id}"]`);
|
||||||
|
if (el) {
|
||||||
|
trackDataStore.set(el, video);
|
||||||
|
this.updateLikeState(el, 'track', video.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
videosSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check for unreleased projects
|
// Check for unreleased projects
|
||||||
const unreleasedSection = document.getElementById('artist-section-unreleased');
|
const unreleasedSection = document.getElementById('artist-section-unreleased');
|
||||||
const unreleasedContainer = document.getElementById('artist-detail-unreleased');
|
const unreleasedContainer = document.getElementById('artist-detail-unreleased');
|
||||||
|
|
@ -4633,39 +4778,51 @@ export class UIRenderer {
|
||||||
}
|
}
|
||||||
this.currentTrackPageId = track.id;
|
this.currentTrackPageId = track.id;
|
||||||
|
|
||||||
let videoCoverUrl = track.album?.videoCover
|
let videoCoverUrl = track.videoUrl || track.videoCoverUrl || track.album?.videoCoverUrl || null;
|
||||||
? this.api.tidalAPI.getVideoCoverUrl(track.album.videoCover)
|
|
||||||
: (track.album?.videoCoverUrl || null);
|
|
||||||
|
|
||||||
if (!videoCoverUrl && track.album) {
|
if (!videoCoverUrl && (track.album || track.type === 'video')) {
|
||||||
this.api.getVideoArtwork(track.title, getTrackArtists(track)).then((result) => {
|
const fetchArtwork = () => {
|
||||||
if (result && this.currentPage === 'track' && this.currentTrackPageId === track.id) {
|
this.api.getVideoArtwork(track.title, getTrackArtists(track)).then((result) => {
|
||||||
const url = result.videoUrl || result.hlsUrl;
|
if (result && this.currentPage === 'track' && this.currentTrackPageId === track.id) {
|
||||||
if (!url) return;
|
const url = result.videoUrl || result.hlsUrl;
|
||||||
track.album.videoCoverUrl = url;
|
if (!url) return;
|
||||||
const currentImageEl = document.getElementById('track-detail-image');
|
track.album = track.album || {};
|
||||||
if (currentImageEl && currentImageEl.tagName !== 'VIDEO') {
|
track.album.videoCoverUrl = url;
|
||||||
const video = document.createElement('video');
|
const currentImageEl = document.getElementById('track-detail-image');
|
||||||
video.autoplay = true;
|
if (currentImageEl && currentImageEl.tagName !== 'VIDEO') {
|
||||||
video.loop = true;
|
const video = document.createElement('video');
|
||||||
video.muted = true;
|
video.autoplay = false;
|
||||||
video.playsInline = true;
|
video.loop = false;
|
||||||
video.preload = 'auto';
|
video.muted = true;
|
||||||
video.className = currentImageEl.className;
|
video.playsInline = true;
|
||||||
video.id = currentImageEl.id;
|
video.preload = 'metadata';
|
||||||
video.style.opacity = '0';
|
video.className = currentImageEl.className;
|
||||||
video.style.transition = 'opacity 0.3s ease-in-out';
|
video.id = currentImageEl.id;
|
||||||
|
video.style.opacity = '1';
|
||||||
|
video.poster = currentImageEl.src;
|
||||||
|
|
||||||
video.poster = currentImageEl.src;
|
this.setupHlsVideo(video, result, currentImageEl);
|
||||||
|
currentImageEl.replaceWith(video);
|
||||||
this.setupHlsVideo(video, result, currentImageEl);
|
}
|
||||||
currentImageEl.replaceWith(video);
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (track.type === 'video') {
|
||||||
|
this.api.getVideoStreamUrl(track.id).then((url) => {
|
||||||
|
if (url) {
|
||||||
|
track.videoUrl = url;
|
||||||
|
this.renderTrackPage(trackId, provider);
|
||||||
|
} else {
|
||||||
|
fetchArtwork();
|
||||||
|
}
|
||||||
|
}).catch(fetchArtwork);
|
||||||
|
} else {
|
||||||
|
fetchArtwork();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const coverUrl = videoCoverUrl || this.api.getCoverUrl(track.album?.cover);
|
const coverUrl = videoCoverUrl || this.api.getCoverUrl(track.image || track.cover || track.album?.cover);
|
||||||
|
|
||||||
if (videoCoverUrl) {
|
if (videoCoverUrl) {
|
||||||
if (imageEl.tagName === 'IMG') {
|
if (imageEl.tagName === 'IMG') {
|
||||||
|
|
|
||||||
27
js/utils.js
27
js/utils.js
|
|
@ -134,6 +134,23 @@ export const detectAudioFormat = (view, mimeType = '') => {
|
||||||
return 'mp3';
|
return 'mp3';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
view.byteLength >= 7 &&
|
||||||
|
view.getUint8(0) === 0x23 &&
|
||||||
|
view.getUint8(1) === 0x45 &&
|
||||||
|
view.getUint8(2) === 0x58 &&
|
||||||
|
view.getUint8(3) === 0x54 &&
|
||||||
|
view.getUint8(4) === 0x4d &&
|
||||||
|
view.getUint8(5) === 0x33 &&
|
||||||
|
view.getUint8(6) === 0x55
|
||||||
|
) {
|
||||||
|
return 'm3u8';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view.byteLength >= 188 && view.getUint8(0) === 0x47 && view.getUint8(188) === 0x47) {
|
||||||
|
return 'ts';
|
||||||
|
}
|
||||||
|
|
||||||
// Fallback to MIME type
|
// Fallback to MIME type
|
||||||
if (mimeType === 'audio/flac') return 'flac';
|
if (mimeType === 'audio/flac') return 'flac';
|
||||||
if (mimeType === 'audio/mp4' || mimeType === 'audio/x-m4a') return 'mp4';
|
if (mimeType === 'audio/mp4' || mimeType === 'audio/x-m4a') return 'mp4';
|
||||||
|
|
@ -153,10 +170,16 @@ export const getExtensionFromBlob = async (blob) => {
|
||||||
|
|
||||||
const format = detectAudioFormat(view, blob.type);
|
const format = detectAudioFormat(view, blob.type);
|
||||||
|
|
||||||
if (format === 'mp4') return 'm4a';
|
if (format === 'mp4') {
|
||||||
|
if (blob.type.includes('video')) return 'mp4';
|
||||||
|
return 'm4a';
|
||||||
|
}
|
||||||
if (format) return format;
|
if (format) return format;
|
||||||
|
|
||||||
// Default fallback
|
if (blob.type.includes('video')) return 'mp4';
|
||||||
|
if (blob.type === 'audio/mp4' || blob.type === 'audio/x-m4a') return 'm4a';
|
||||||
|
if (blob.type === 'audio/mpeg' || blob.type === 'audio/mp3') return 'mp3';
|
||||||
|
|
||||||
return 'flac';
|
return 'flac';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
155
styles.css
155
styles.css
|
|
@ -5227,10 +5227,110 @@ img[src=''] {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 6rem 2rem 2rem;
|
padding: 6rem 2rem 2rem;
|
||||||
transition:
|
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
flex 0.3s ease,
|
}
|
||||||
padding-right 0.3s ease,
|
|
||||||
margin-right 0.3s ease;
|
#fullscreen-cover-overlay.is-video-mode .fullscreen-main-view {
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 2rem 2rem 6rem;
|
||||||
|
pointer-events: none;
|
||||||
|
background: linear-gradient(to top, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0.2) 15%, transparent 40%);
|
||||||
|
}
|
||||||
|
|
||||||
|
#fullscreen-cover-overlay.is-video-mode .fullscreen-track-info {
|
||||||
|
text-align: left;
|
||||||
|
background: none !important;
|
||||||
|
backdrop-filter: none !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
max-width: 40%;
|
||||||
|
margin: 0;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fullscreen-cover-overlay.is-video-mode #fullscreen-track-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-shadow: 0 1px 3px rgba(0,0,0,0.8);
|
||||||
|
margin-bottom: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fullscreen-cover-overlay.is-video-mode #fullscreen-track-artist {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-shadow: 0 1px 2px rgba(0,0,0,0.8);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fullscreen-cover-overlay.is-video-mode .fullscreen-controls {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
left: 2rem;
|
||||||
|
right: 2rem;
|
||||||
|
max-width: 500px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: rgba(15, 15, 15, 0.5);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
|
||||||
|
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.4s ease;
|
||||||
|
pointer-events: auto;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fullscreen-cover-overlay.is-video-mode .fullscreen-buttons {
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fullscreen-cover-overlay.is-video-mode .fullscreen-buttons button {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fullscreen-cover-overlay.is-video-mode .fullscreen-buttons button svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fullscreen-cover-overlay.is-video-mode .fullscreen-buttons #fs-play-pause-btn {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fullscreen-cover-overlay.is-video-mode .fullscreen-progress-container {
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fullscreen-cover-overlay.is-video-mode .fullscreen-volume-container {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fullscreen-cover-overlay.is-video-mode .fullscreen-volume-container {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#fullscreen-cover-overlay.ui-hidden .fullscreen-main-view,
|
||||||
|
#fullscreen-cover-overlay.ui-hidden .fullscreen-controls,
|
||||||
|
#fullscreen-cover-overlay.ui-hidden #fullscreen-next-track,
|
||||||
|
#fullscreen-cover-overlay.ui-hidden .fullscreen-lyrics-toggle,
|
||||||
|
#fullscreen-cover-overlay.ui-hidden #close-fullscreen-cover-btn {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#fullscreen-cover-overlay.is-video-mode.ui-hidden .fullscreen-controls {
|
||||||
|
transform: translateY(100px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#fullscreen-cover-overlay.is-video-mode.ui-hidden .fullscreen-main-view {
|
||||||
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Modals */
|
/* Modals */
|
||||||
|
|
@ -7941,3 +8041,50 @@ textarea:focus {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.video-card .card-image-container {
|
||||||
|
aspect-ratio: 16 / 9 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-card .card-image {
|
||||||
|
aspect-ratio: 16 / 9 !important;
|
||||||
|
object-fit: cover !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-duration-badge {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
color: white;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fullscreen-video-container {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 0;
|
||||||
|
background: black;
|
||||||
|
display: none;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fullscreen-video-container video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-tab[data-tab="videos"] {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue