feat: implement infinite scroll with pagination for media browsing
Add infinite scroll functionality to MediaBrowser component using IntersectionObserver, introduce BrowseState and pagination support (offset/limit) in API and backend, update version to 0.3.24
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "PMOMusic"
|
||||
version = "0.3.23"
|
||||
version = "0.3.24"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { useMediaServers } from "@/composables/useMediaServers";
|
||||
import { useRenderers } from "@/composables/useRenderers";
|
||||
import { useUIStore } from "@/stores/ui";
|
||||
@@ -16,8 +16,11 @@ const props = defineProps<{
|
||||
const {
|
||||
getBrowseCached,
|
||||
browseContainer,
|
||||
loadMoreBrowse,
|
||||
hasMore,
|
||||
currentPath: breadcrumbPath,
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
} = useMediaServers();
|
||||
|
||||
@@ -25,8 +28,9 @@ const { playContent, addToQueue, attachAndPlayPlaylist, attachPlaylist } =
|
||||
useRenderers();
|
||||
const uiStore = useUIStore();
|
||||
|
||||
// Flag pour gérer le rechargement automatique
|
||||
const isRefreshing = ref(false);
|
||||
const sentinelRef = ref<HTMLElement | null>(null);
|
||||
let observer: IntersectionObserver | null = null;
|
||||
|
||||
const browseData = computed(() =>
|
||||
getBrowseCached(props.serverId, props.containerId),
|
||||
@@ -40,6 +44,28 @@ const items = computed(
|
||||
() => browseData.value?.entries.filter((e) => !e.is_container) || [],
|
||||
);
|
||||
|
||||
const canLoadMore = computed(() => hasMore(props.serverId, props.containerId));
|
||||
|
||||
function setupObserver() {
|
||||
if (observer) observer.disconnect();
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting && canLoadMore.value && !loadingMore.value) {
|
||||
loadMoreBrowse(props.serverId, props.containerId);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
if (sentinelRef.value) observer.observe(sentinelRef.value);
|
||||
}
|
||||
|
||||
onMounted(() => setupObserver());
|
||||
onBeforeUnmount(() => observer?.disconnect());
|
||||
|
||||
watch(sentinelRef, (el) => {
|
||||
if (el) setupObserver();
|
||||
});
|
||||
|
||||
// Charger le container au montage et quand containerId change
|
||||
watch(
|
||||
() => props.containerId,
|
||||
@@ -51,15 +77,10 @@ watch(
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// Recharger automatiquement si le cache est invalidé (ex: après un ContainersUpdated SSE)
|
||||
// Cela se produit notamment quand on clique sur "Lire maintenant" sur une playlist,
|
||||
// ce qui déclenche un événement ContainersUpdated qui invalide le cache
|
||||
// Le serveur contrôle déjà le flux SSE, pas besoin de debouncing côté client
|
||||
// Recharger si le cache est invalidé (ContainersUpdated SSE)
|
||||
watch(
|
||||
() => browseData.value,
|
||||
async (data) => {
|
||||
// Si browseData devient undefined alors que containerId est présent,
|
||||
// et qu'on n'est pas déjà en train de charger, recharger immédiatement
|
||||
if (
|
||||
!data &&
|
||||
props.containerId &&
|
||||
@@ -201,6 +222,14 @@ async function handleQueueItem(itemId: string, rendererId: string) {
|
||||
>
|
||||
<p>Ce dossier est vide</p>
|
||||
</div>
|
||||
|
||||
<!-- Sentinel infinite scroll -->
|
||||
<div ref="sentinelRef" class="scroll-sentinel" />
|
||||
|
||||
<!-- Spinner load more -->
|
||||
<div v-if="loadingMore" class="load-more-spinner">
|
||||
<Loader2 :size="20" class="spinner" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -295,6 +324,17 @@ async function handleQueueItem(itemId: string, rendererId: string) {
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.scroll-sentinel {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.load-more-spinner {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-md);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.browser-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
|
||||
@@ -18,9 +18,9 @@ import { useMediaServers } from "@/composables/useMediaServers";
|
||||
import { useRenderers } from "@/composables/useRenderers";
|
||||
import type {
|
||||
MediaServerSummary,
|
||||
BrowseResponse,
|
||||
ContainerEntry,
|
||||
} from "@/services/pmocontrol/types";
|
||||
import type { BrowseState } from "@/composables/useMediaServers";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean; // v-model pour contrôler l'ouverture
|
||||
@@ -47,7 +47,7 @@ const router = useRouter();
|
||||
|
||||
// État de navigation
|
||||
const currentServer = ref<MediaServerSummary | null>(null);
|
||||
const browseData = ref<BrowseResponse | null>(null);
|
||||
const browseData = ref<BrowseState | null>(null);
|
||||
const isLoading = ref(false);
|
||||
|
||||
// État du menu dropdown (pour chaque item, on stocke si son menu est ouvert)
|
||||
|
||||
@@ -7,7 +7,7 @@ import { api } from '../services/pmocontrol/api'
|
||||
import { sse } from '../services/pmocontrol/sse'
|
||||
import type {
|
||||
MediaServerSummary,
|
||||
BrowseResponse
|
||||
ContainerEntry,
|
||||
} from '../services/pmocontrol/types'
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
@@ -15,9 +15,15 @@ export interface BreadcrumbItem {
|
||||
title: string
|
||||
}
|
||||
|
||||
export interface BrowseState {
|
||||
container_id: string
|
||||
entries: ContainerEntry[]
|
||||
total_count: number
|
||||
}
|
||||
|
||||
// Cache global partagé
|
||||
const serversCache = ref<Map<string, MediaServerSummary>>(new Map())
|
||||
const browseCache = ref<Map<string, BrowseResponse>>(new Map())
|
||||
const browseCache = ref<Map<string, BrowseState>>(new Map())
|
||||
const currentPath = ref<BreadcrumbItem[]>([])
|
||||
|
||||
// Timestamps
|
||||
@@ -100,6 +106,7 @@ export function useMediaServers() {
|
||||
ensureSSEConnected()
|
||||
|
||||
const loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Getters computed
|
||||
@@ -110,7 +117,7 @@ export function useMediaServers() {
|
||||
async function fetchServers(force = false) {
|
||||
const now = Date.now()
|
||||
if (!force && now - lastFetch.servers < CACHE_DURATION_MS) {
|
||||
return // Cache encore valide
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -129,11 +136,10 @@ export function useMediaServers() {
|
||||
}
|
||||
}
|
||||
|
||||
// Browse container (avec cache automatique)
|
||||
// Charge la première page (remplace le cache)
|
||||
async function browseContainer(serverId: string, containerId: string, useCache = true) {
|
||||
const key = `${serverId}/${containerId}`
|
||||
|
||||
// Vérifier le cache
|
||||
if (useCache && browseCache.value.has(key)) {
|
||||
return browseCache.value.get(key)!
|
||||
}
|
||||
@@ -142,12 +148,15 @@ export function useMediaServers() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
const data = await api.browseContainer(serverId, containerId)
|
||||
const data = await api.browseContainer(serverId, containerId, 0)
|
||||
|
||||
// Mettre en cache
|
||||
browseCache.value.set(key, data)
|
||||
browseCache.value.set(key, {
|
||||
container_id: data.container_id,
|
||||
entries: data.entries,
|
||||
total_count: data.total_count,
|
||||
})
|
||||
|
||||
return data
|
||||
return browseCache.value.get(key)!
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Erreur browse container'
|
||||
console.error(`[useMediaServers] Erreur browse ${serverId}/${containerId}:`, e)
|
||||
@@ -157,6 +166,33 @@ export function useMediaServers() {
|
||||
}
|
||||
}
|
||||
|
||||
// Charge la page suivante et accumule (infinite scroll)
|
||||
async function loadMoreBrowse(serverId: string, containerId: string) {
|
||||
const key = `${serverId}/${containerId}`
|
||||
const state = browseCache.value.get(key)
|
||||
|
||||
if (!state) return
|
||||
if (state.entries.length >= state.total_count) return
|
||||
if (loadingMore.value) return
|
||||
|
||||
try {
|
||||
loadingMore.value = true
|
||||
|
||||
const offset = state.entries.length
|
||||
const data = await api.browseContainer(serverId, containerId, offset)
|
||||
|
||||
// Accumuler les nouvelles entrées
|
||||
state.entries.push(...data.entries)
|
||||
state.total_count = data.total_count
|
||||
// Forcer la réactivité
|
||||
browseCache.value.set(key, { ...state })
|
||||
} catch (e) {
|
||||
console.error(`[useMediaServers] Erreur load more ${serverId}/${containerId}:`, e)
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Getters
|
||||
function getServerById(id: string) {
|
||||
return serversCache.value.get(id)
|
||||
@@ -167,6 +203,13 @@ export function useMediaServers() {
|
||||
return browseCache.value.get(key)
|
||||
}
|
||||
|
||||
function hasMore(serverId: string, containerId: string): boolean {
|
||||
const key = `${serverId}/${containerId}`
|
||||
const state = browseCache.value.get(key)
|
||||
if (!state) return false
|
||||
return state.entries.length < state.total_count
|
||||
}
|
||||
|
||||
// Breadcrumb path management
|
||||
function setPath(path: BreadcrumbItem[]) {
|
||||
currentPath.value = path
|
||||
@@ -179,11 +222,9 @@ export function useMediaServers() {
|
||||
// Invalidation du cache
|
||||
function invalidateCache(serverId: string, containerId?: string) {
|
||||
if (containerId) {
|
||||
// Invalider un container spécifique
|
||||
const key = `${serverId}/${containerId}`
|
||||
browseCache.value.delete(key)
|
||||
} else {
|
||||
// Invalider tous les containers d'un serveur
|
||||
const keysToDelete: string[] = []
|
||||
browseCache.value.forEach((_, key) => {
|
||||
if (key.startsWith(serverId + '/')) {
|
||||
@@ -197,6 +238,7 @@ export function useMediaServers() {
|
||||
return {
|
||||
// État
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
currentPath,
|
||||
// Getters
|
||||
@@ -204,9 +246,11 @@ export function useMediaServers() {
|
||||
onlineServers,
|
||||
getServerById,
|
||||
getBrowseCached,
|
||||
hasMore,
|
||||
// Actions
|
||||
fetchServers,
|
||||
browseContainer,
|
||||
loadMoreBrowse,
|
||||
setPath,
|
||||
clearPath,
|
||||
invalidateCache
|
||||
|
||||
@@ -418,9 +418,11 @@ class PMOControlAPI {
|
||||
async browseContainer(
|
||||
serverId: string,
|
||||
containerId: string,
|
||||
offset = 0,
|
||||
limit = 50,
|
||||
): Promise<BrowseResponse> {
|
||||
return this.request<BrowseResponse>(
|
||||
`/servers/${encodeURIComponent(serverId)}/containers/${encodeURIComponent(containerId)}`,
|
||||
`/servers/${encodeURIComponent(serverId)}/containers/${encodeURIComponent(containerId)}?offset=${offset}&limit=${limit}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -120,6 +120,8 @@ export interface ContainerEntry {
|
||||
export interface BrowseResponse {
|
||||
container_id: string;
|
||||
entries: ContainerEntry[];
|
||||
total_count: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -91,6 +91,16 @@ impl UpnpMediaServer {
|
||||
start: u32,
|
||||
count: u32,
|
||||
) -> Result<Vec<MediaEntry>, ControlPointError> {
|
||||
Ok(self.browse_with_flag_paged(object_id, browse_flag, start, count)?.entries)
|
||||
}
|
||||
|
||||
fn browse_with_flag_paged(
|
||||
&self,
|
||||
object_id: &str,
|
||||
browse_flag: &str,
|
||||
start: u32,
|
||||
count: u32,
|
||||
) -> Result<BrowsePage, ControlPointError> {
|
||||
let start_str = start.to_string();
|
||||
let count_str = count.to_string();
|
||||
let args = vec![
|
||||
@@ -104,10 +114,12 @@ impl UpnpMediaServer {
|
||||
|
||||
let response = self.invoke_content_directory("Browse", None, args)?;
|
||||
let envelope = response.envelope.ok_or_else(|| {
|
||||
ControlPointError::MediaServerError(format!("Missing SOAP envelope in Browse response"))
|
||||
ControlPointError::MediaServerError("Missing SOAP envelope in Browse response".to_string())
|
||||
})?;
|
||||
let total_count = extract_total_matches(&envelope, "BrowseResponse");
|
||||
let didl_xml = extract_result_payload(&envelope, "BrowseResponse")?;
|
||||
map_didl_entries(&didl_xml)
|
||||
let entries = map_didl_entries(&didl_xml)?;
|
||||
Ok(BrowsePage { entries, total_count })
|
||||
}
|
||||
|
||||
fn has_content_directory(&self) -> bool {
|
||||
@@ -362,6 +374,13 @@ pub struct MediaEntry {
|
||||
pub creator: Option<String>,
|
||||
}
|
||||
|
||||
/// Résultat paginé d'un browse avec total connu.
|
||||
#[derive(Debug)]
|
||||
pub struct BrowsePage {
|
||||
pub entries: Vec<MediaEntry>,
|
||||
pub total_count: u32,
|
||||
}
|
||||
|
||||
/// Backend-agnostic media browsing contract.
|
||||
pub trait MediaBrowser {
|
||||
fn browse_root(&self) -> Result<Vec<MediaEntry>, ControlPointError>;
|
||||
@@ -371,6 +390,16 @@ pub trait MediaBrowser {
|
||||
start: u32,
|
||||
count: u32,
|
||||
) -> Result<Vec<MediaEntry>, ControlPointError>;
|
||||
fn browse_children_paged(
|
||||
&self,
|
||||
object_id: &str,
|
||||
start: u32,
|
||||
count: u32,
|
||||
) -> Result<BrowsePage, ControlPointError> {
|
||||
let entries = self.browse_children(object_id, start, count)?;
|
||||
let total_count = entries.len() as u32 + start;
|
||||
Ok(BrowsePage { entries, total_count })
|
||||
}
|
||||
fn browse_object(&self, object_id: &str) -> Result<MediaEntry, ControlPointError>;
|
||||
fn search(
|
||||
&self,
|
||||
@@ -488,6 +517,17 @@ impl MediaBrowser for MusicServer {
|
||||
}
|
||||
}
|
||||
|
||||
fn browse_children_paged(
|
||||
&self,
|
||||
object_id: &str,
|
||||
start: u32,
|
||||
count: u32,
|
||||
) -> Result<BrowsePage, ControlPointError> {
|
||||
match self {
|
||||
MusicServer::Upnp(upnp) => upnp.browse_children_paged(object_id, start, count),
|
||||
}
|
||||
}
|
||||
|
||||
fn browse_object(&self, object_id: &str) -> Result<MediaEntry, ControlPointError> {
|
||||
match self {
|
||||
MusicServer::Upnp(upnp) => upnp.browse_object(object_id),
|
||||
@@ -603,6 +643,15 @@ impl MediaBrowser for UpnpMediaServer {
|
||||
self.browse_with_flag(object_id, "BrowseDirectChildren", start, count)
|
||||
}
|
||||
|
||||
fn browse_children_paged(
|
||||
&self,
|
||||
object_id: &str,
|
||||
start: u32,
|
||||
count: u32,
|
||||
) -> Result<BrowsePage, ControlPointError> {
|
||||
self.browse_with_flag_paged(object_id, "BrowseDirectChildren", start, count)
|
||||
}
|
||||
|
||||
fn browse_object(&self, object_id: &str) -> Result<MediaEntry, ControlPointError> {
|
||||
let entries = self.browse_with_flag(object_id, "BrowseMetadata", 0, 1)?;
|
||||
entries.into_iter().next().ok_or_else(|| {
|
||||
@@ -721,6 +770,17 @@ fn extract_result_payload(
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
fn extract_total_matches(envelope: &SoapEnvelope, response_suffix: &str) -> u32 {
|
||||
let response = match find_child_with_suffix(&envelope.body.content, response_suffix) {
|
||||
Some(r) => r,
|
||||
None => return 0,
|
||||
};
|
||||
find_child_with_suffix(response, "TotalMatches")
|
||||
.and_then(|e| e.get_text())
|
||||
.and_then(|t| t.trim().parse::<u32>().ok())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn find_child_with_suffix<'a>(parent: &'a Element, suffix: &str) -> Option<&'a Element> {
|
||||
parent.children.iter().find_map(|node| match node {
|
||||
XMLNode::Element(elem) if elem.name.ends_with(suffix) => Some(elem),
|
||||
|
||||
@@ -197,8 +197,12 @@ pub struct ContainerEntry {
|
||||
pub struct BrowseResponse {
|
||||
/// ID du container browsé
|
||||
pub container_id: String,
|
||||
/// Entrées du container
|
||||
/// Entrées du container (page courante)
|
||||
pub entries: Vec<ContainerEntry>,
|
||||
/// Nombre total d'entrées dans le container
|
||||
pub total_count: u32,
|
||||
/// Offset de la page courante
|
||||
pub offset: u32,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -27,7 +27,7 @@ use async_trait::async_trait;
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, State},
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
routing::{get, post},
|
||||
};
|
||||
@@ -43,7 +43,7 @@ use tracing::{debug, warn};
|
||||
use utoipa::OpenApi;
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
const BROWSE_PAGE_SIZE: u32 = 100;
|
||||
const BROWSE_DEFAULT_LIMIT: u32 = 50;
|
||||
#[cfg(feature = "pmoserver")]
|
||||
const BROWSE_REQUEST_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
@@ -2050,6 +2050,15 @@ async fn list_servers(State(state): State<ControlPointState>) -> Json<Vec<MediaS
|
||||
Json(summaries)
|
||||
}
|
||||
|
||||
/// Paramètres de pagination pour le browse
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct BrowseParams {
|
||||
#[serde(default)]
|
||||
offset: u32,
|
||||
limit: Option<u32>,
|
||||
}
|
||||
|
||||
/// GET /control/servers/{server_id}/containers/{container_id} - Browse un container
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[utoipa::path(
|
||||
@@ -2057,7 +2066,9 @@ async fn list_servers(State(state): State<ControlPointState>) -> Json<Vec<MediaS
|
||||
path = "/servers/{server_id}/containers/{container_id}",
|
||||
params(
|
||||
("server_id" = String, Path, description = "ID unique du serveur"),
|
||||
("container_id" = String, Path, description = "ID du container (use '0' for root)")
|
||||
("container_id" = String, Path, description = "ID du container (use '0' for root)"),
|
||||
("offset" = Option<u32>, Query, description = "Index de départ (défaut: 0)"),
|
||||
("limit" = Option<u32>, Query, description = "Nombre max d'items (défaut: 50)"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Contenu du container", body = BrowseResponse),
|
||||
@@ -2069,6 +2080,7 @@ async fn list_servers(State(state): State<ControlPointState>) -> Json<Vec<MediaS
|
||||
async fn browse_container(
|
||||
State(state): State<ControlPointState>,
|
||||
Path((server_id, container_id)): Path<(String, String)>,
|
||||
Query(params): Query<BrowseParams>,
|
||||
) -> Result<Json<BrowseResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let sid = DeviceId(server_id.clone());
|
||||
|
||||
@@ -2099,14 +2111,17 @@ async fn browse_container(
|
||||
));
|
||||
}
|
||||
|
||||
let offset = params.offset;
|
||||
let limit = params.limit.unwrap_or(BROWSE_DEFAULT_LIMIT);
|
||||
|
||||
// Use spawn_blocking to avoid blocking the async runtime with synchronous SOAP calls
|
||||
let container_id_clone = container_id.clone();
|
||||
let server_clone = server.clone();
|
||||
let browse_task = tokio::task::spawn_blocking(move || {
|
||||
server_clone.browse_children(&container_id_clone, 0, BROWSE_PAGE_SIZE)
|
||||
server_clone.browse_children_paged(&container_id_clone, offset, limit)
|
||||
});
|
||||
|
||||
let entries = time::timeout(BROWSE_REQUEST_TIMEOUT, browse_task)
|
||||
let page = time::timeout(BROWSE_REQUEST_TIMEOUT, browse_task)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
warn!(
|
||||
@@ -2145,14 +2160,14 @@ async fn browse_container(
|
||||
)
|
||||
})?;
|
||||
|
||||
let container_entries: Vec<ContainerEntry> = entries
|
||||
let container_entries: Vec<ContainerEntry> = page.entries
|
||||
.into_iter()
|
||||
.map(|e| ContainerEntry {
|
||||
id: e.id,
|
||||
title: e.title,
|
||||
class: e.class,
|
||||
is_container: e.is_container,
|
||||
child_count: None, // Could be extracted from DIDL-Lite if needed
|
||||
child_count: None,
|
||||
artist: e.artist,
|
||||
album: e.album,
|
||||
album_art_uri: e.album_art_uri,
|
||||
@@ -2162,6 +2177,8 @@ async fn browse_container(
|
||||
Ok(Json(BrowseResponse {
|
||||
container_id,
|
||||
entries: container_entries,
|
||||
total_count: page.total_count,
|
||||
offset,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -2218,7 +2235,7 @@ fn fetch_playback_items(
|
||||
|
||||
let entries = if object_metadata.is_container {
|
||||
// For containers, browse children to get all items
|
||||
server.browse_children(object_id, 0, BROWSE_PAGE_SIZE)?
|
||||
server.browse_children(object_id, 0, BROWSE_DEFAULT_LIMIT)?
|
||||
} else {
|
||||
// For items, use the object itself
|
||||
vec![object_metadata]
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.3.23
|
||||
0.3.24
|
||||
|
||||
Reference in New Issue
Block a user