mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 14:16:16 -08:00
Merge remote-tracking branch 'origin/master' into claude/vod-multi-source-49a8ba
This commit is contained in:
commit
26b820cc29
60 files changed
+2772
-160
No files matched your search
@@ -21,13 +21,13 @@ import {
|
||||
import {
|
||||
AUTO_UPDATE_PLAYLISTS,
|
||||
Language,
|
||||
OPEN_FILE,
|
||||
Settings,
|
||||
STORE_KEY,
|
||||
Theme,
|
||||
createDevLogger,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { SettingsService } from './services/settings.service';
|
||||
import { PlaylistOpenRequestService } from './services/playlist-open-request.service';
|
||||
import { AppUpdateNotificationPanelComponent } from './app-update-notification-panel.component';
|
||||
|
||||
const debugAppComponent = createDevLogger('AppComponent');
|
||||
@@ -59,6 +59,7 @@ export class AppComponent implements OnInit {
|
||||
private translate = inject(TranslateService);
|
||||
private settingsService = inject(SettingsService);
|
||||
private settingsStore = inject(SettingsStore);
|
||||
private playlistOpenRequests = inject(PlaylistOpenRequestService);
|
||||
private runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly workspaceShellActions = inject(WORKSPACE_SHELL_ACTIONS);
|
||||
|
||||
@@ -72,27 +73,12 @@ export class AppComponent implements OnInit {
|
||||
document.body.classList.add('frameless-platform');
|
||||
}
|
||||
|
||||
const electronProcess = this.dataService.remote?.process;
|
||||
if (
|
||||
this.dataService.isElectron &&
|
||||
electronProcess &&
|
||||
(electronProcess.platform === 'linux' ||
|
||||
electronProcess.platform === 'win32') &&
|
||||
electronProcess.argv.length > 2
|
||||
) {
|
||||
const filePath = electronProcess.argv.find(
|
||||
(filepath: string) =>
|
||||
filepath.endsWith('.m3u') || filepath.endsWith('.m3u8')
|
||||
);
|
||||
if (filePath) {
|
||||
const filePathsArray = filePath.split('/');
|
||||
const fileName = filePathsArray[filePathsArray.length - 1];
|
||||
this.dataService.sendIpcEvent(OPEN_FILE, {
|
||||
filePath,
|
||||
fileName,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Playlist files the OS asked us to open (command line argument, file
|
||||
// association, macOS `open-file`) are resolved in the main process and
|
||||
// queued there until the renderer subscribes. Start listening as early
|
||||
// as possible so a first-launch file is not delayed behind app init.
|
||||
this.playlistOpenRequests.start();
|
||||
|
||||
effect(() => {
|
||||
const size = this.settingsStore.coverSize?.() ?? 'medium';
|
||||
document.documentElement.dataset.coverSize = size;
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistFileImportService } from '@iptvnator/playlist/shared/util';
|
||||
import type { ElectronBridgePlaylistOpenRequest } from '@iptvnator/shared/interfaces';
|
||||
import { PlaylistOpenRequestService } from './playlist-open-request.service';
|
||||
|
||||
type BridgeMock = {
|
||||
acknowledgePlaylistOpenRequest: jest.Mock;
|
||||
announcePlaylistOpenListener: jest.Mock;
|
||||
onPlaylistOpenRequest: jest.Mock;
|
||||
};
|
||||
|
||||
describe('PlaylistOpenRequestService', () => {
|
||||
let service: PlaylistOpenRequestService;
|
||||
let importService: { importFromPath: jest.Mock };
|
||||
let snackBar: { open: jest.Mock };
|
||||
let bridge: BridgeMock;
|
||||
let pushRequest: (request: ElectronBridgePlaylistOpenRequest) => void;
|
||||
let unsubscribe: jest.Mock;
|
||||
let originalElectron: typeof window.electron | undefined;
|
||||
|
||||
const request = (name: string): ElectronBridgePlaylistOpenRequest => ({
|
||||
fileName: `${name}.m3u`,
|
||||
filePath: `/tmp/${name}.m3u`,
|
||||
requestId: name,
|
||||
});
|
||||
|
||||
/** Lets every queued promise callback settle. */
|
||||
const settle = () =>
|
||||
new Promise((resolvePromise) => setTimeout(resolvePromise, 0));
|
||||
|
||||
beforeEach(() => {
|
||||
originalElectron = window.electron;
|
||||
pushRequest = () => undefined;
|
||||
unsubscribe = jest.fn();
|
||||
importService = {
|
||||
importFromPath: jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
title: 'Playlist',
|
||||
}),
|
||||
};
|
||||
snackBar = { open: jest.fn() };
|
||||
bridge = {
|
||||
acknowledgePlaylistOpenRequest: jest
|
||||
.fn()
|
||||
.mockResolvedValue(undefined),
|
||||
announcePlaylistOpenListener: jest
|
||||
.fn()
|
||||
.mockResolvedValue(undefined),
|
||||
onPlaylistOpenRequest: jest.fn((callback) => {
|
||||
pushRequest = callback;
|
||||
return unsubscribe;
|
||||
}),
|
||||
};
|
||||
window.electron = bridge as unknown as typeof window.electron;
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
PlaylistOpenRequestService,
|
||||
{ provide: PlaylistFileImportService, useValue: importService },
|
||||
{ provide: MatSnackBar, useValue: snackBar },
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: { instant: (key: string) => key },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(PlaylistOpenRequestService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.electron = originalElectron as typeof window.electron;
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
// The announcement makes the main process flush its queue at once, so a
|
||||
// listener attached afterwards would miss every startup file.
|
||||
it('subscribes before announcing itself', () => {
|
||||
service.start();
|
||||
|
||||
expect(
|
||||
bridge.onPlaylistOpenRequest.mock.invocationCallOrder[0]
|
||||
).toBeLessThan(
|
||||
bridge.announcePlaylistOpenListener.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('imports a playlist the main process pushes', async () => {
|
||||
service.start();
|
||||
pushRequest(request('startup'));
|
||||
await settle();
|
||||
|
||||
expect(importService.importFromPath).toHaveBeenCalledWith(
|
||||
'/tmp/startup.m3u',
|
||||
'startup.m3u'
|
||||
);
|
||||
});
|
||||
|
||||
// `send()` returns before the listener runs, so the main process holds the
|
||||
// request until this confirms a live renderer got it.
|
||||
it('acknowledges a request as soon as it arrives', async () => {
|
||||
importService.importFromPath.mockReturnValue(new Promise(() => undefined));
|
||||
|
||||
service.start();
|
||||
pushRequest(request('startup'));
|
||||
|
||||
expect(bridge.acknowledgePlaylistOpenRequest).toHaveBeenCalledWith(
|
||||
'startup'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps importing when acknowledging fails', async () => {
|
||||
bridge.acknowledgePlaylistOpenRequest.mockRejectedValue(
|
||||
new Error('gone')
|
||||
);
|
||||
|
||||
service.start();
|
||||
pushRequest(request('startup'));
|
||||
await settle();
|
||||
|
||||
expect(importService.importFromPath).toHaveBeenCalledWith(
|
||||
'/tmp/startup.m3u',
|
||||
'startup.m3u'
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a failed import to the user', async () => {
|
||||
importService.importFromPath.mockResolvedValue({
|
||||
ok: false,
|
||||
reason: 'read-error',
|
||||
});
|
||||
|
||||
service.start();
|
||||
pushRequest(request('broken'));
|
||||
await settle();
|
||||
|
||||
expect(snackBar.open).toHaveBeenCalledWith(
|
||||
'HOME.FILE_UPLOAD.OPEN_FAILED',
|
||||
'CLOSE',
|
||||
expect.objectContaining({ panelClass: ['error-snackbar'] })
|
||||
);
|
||||
});
|
||||
|
||||
// The main-process queue flushes back to back, so a burst of pushes must
|
||||
// not fan out into parallel file reads landing in arbitrary order — the
|
||||
// last playlist imported is the one the app navigates to.
|
||||
it('never runs two imports at the same time', async () => {
|
||||
let inFlight = 0;
|
||||
let maxInFlight = 0;
|
||||
const resolvers: (() => void)[] = [];
|
||||
|
||||
importService.importFromPath.mockImplementation(() => {
|
||||
inFlight += 1;
|
||||
maxInFlight = Math.max(maxInFlight, inFlight);
|
||||
|
||||
return new Promise((resolvePromise) => {
|
||||
resolvers.push(() => {
|
||||
inFlight -= 1;
|
||||
resolvePromise({ ok: true, title: 'Playlist' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
service.start();
|
||||
pushRequest(request('one'));
|
||||
pushRequest(request('two'));
|
||||
pushRequest(request('three'));
|
||||
|
||||
for (let step = 0; step < 12; step += 1) {
|
||||
resolvers.shift()?.();
|
||||
await settle();
|
||||
}
|
||||
|
||||
expect(maxInFlight).toBe(1);
|
||||
expect(
|
||||
importService.importFromPath.mock.calls.map(([path]) => path)
|
||||
).toEqual(['/tmp/one.m3u', '/tmp/two.m3u', '/tmp/three.m3u']);
|
||||
});
|
||||
|
||||
it('keeps importing after one file fails', async () => {
|
||||
importService.importFromPath
|
||||
.mockRejectedValueOnce(new Error('boom'))
|
||||
.mockResolvedValueOnce({ ok: true, title: 'Playlist' });
|
||||
|
||||
service.start();
|
||||
pushRequest(request('broken'));
|
||||
pushRequest(request('healthy'));
|
||||
await settle();
|
||||
|
||||
expect(importService.importFromPath).toHaveBeenCalledWith(
|
||||
'/tmp/healthy.m3u',
|
||||
'healthy.m3u'
|
||||
);
|
||||
});
|
||||
|
||||
it('starts only once', () => {
|
||||
service.start();
|
||||
service.start();
|
||||
|
||||
expect(bridge.announcePlaylistOpenListener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does nothing without the Electron bridge', () => {
|
||||
window.electron = undefined as unknown as typeof window.electron;
|
||||
|
||||
expect(() => service.start()).not.toThrow();
|
||||
expect(importService.importFromPath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('releases the listener on stop', () => {
|
||||
service.start();
|
||||
service.stop();
|
||||
|
||||
expect(unsubscribe).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistFileImportService } from '@iptvnator/playlist/shared/util';
|
||||
import type { ElectronBridgePlaylistOpenRequest } from '@iptvnator/shared/interfaces';
|
||||
|
||||
type PlaylistOpenRequestBridge = {
|
||||
acknowledgePlaylistOpenRequest?: (requestId: string) => Promise<void>;
|
||||
announcePlaylistOpenListener?: () => Promise<void>;
|
||||
onPlaylistOpenRequest?: (
|
||||
callback: (request: ElectronBridgePlaylistOpenRequest) => void
|
||||
) => () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Imports playlist files the operating system handed to the app: a command
|
||||
* line argument, a file association double-click, or macOS' `open-file`.
|
||||
*
|
||||
* The main process queues those requests until this service announces itself,
|
||||
* so the listener has to be attached *before* the announcement — otherwise the
|
||||
* push that follows it has nowhere to land. Electron only; in the PWA the
|
||||
* bridge is absent and `start()` is a no-op.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlaylistOpenRequestService {
|
||||
private readonly importService = inject(PlaylistFileImportService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
private started = false;
|
||||
private unsubscribe: (() => void) | undefined;
|
||||
|
||||
/**
|
||||
* Imports run strictly one after another, in arrival order. The main
|
||||
* process flushes its queue back to back, so without this a burst — two
|
||||
* playlists picked in Finder at once — would fan out into parallel
|
||||
* file reads and land in a nondeterministic order, with an arbitrary one
|
||||
* of them ending up the active playlist. `addPlaylist$` serialises the
|
||||
* persistence side separately.
|
||||
*/
|
||||
private importChain: Promise<void> = Promise.resolve();
|
||||
|
||||
start(): void {
|
||||
if (this.started) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bridge = this.getBridge();
|
||||
if (!bridge?.announcePlaylistOpenListener) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.started = true;
|
||||
|
||||
// Subscribe first: announcing makes the main process flush its queue
|
||||
// immediately, and every request — queued or live — arrives this way.
|
||||
this.unsubscribe = bridge.onPlaylistOpenRequest?.((request) => {
|
||||
// Acknowledge on receipt rather than after the import: the main
|
||||
// process only needs to know the request reached a live renderer,
|
||||
// and holding the ack back until a slow import finished would
|
||||
// replay it if the window were closed meanwhile.
|
||||
void bridge
|
||||
.acknowledgePlaylistOpenRequest?.(request?.requestId)
|
||||
.catch(() => undefined);
|
||||
this.chain(() => this.open(request));
|
||||
});
|
||||
|
||||
void bridge.announcePlaylistOpenListener().catch((error) => {
|
||||
console.error(
|
||||
'Failed to announce the playlist open listener:',
|
||||
error
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Appends a step to the import chain, never breaking it on failure. */
|
||||
private chain(step: () => Promise<void>): void {
|
||||
this.importChain = this.importChain.then(step).catch((error) => {
|
||||
console.error('Failed to open a playlist file:', error);
|
||||
});
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = undefined;
|
||||
this.started = false;
|
||||
}
|
||||
|
||||
private async open(
|
||||
request: ElectronBridgePlaylistOpenRequest
|
||||
): Promise<void> {
|
||||
if (!request?.filePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.importService.importFromPath(
|
||||
request.filePath,
|
||||
request.fileName
|
||||
);
|
||||
|
||||
if (result.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.snackBar.open(
|
||||
this.translate.instant('HOME.FILE_UPLOAD.OPEN_FAILED', {
|
||||
filename: request.fileName || request.filePath,
|
||||
}),
|
||||
this.translate.instant('CLOSE'),
|
||||
{ duration: 7000, panelClass: ['error-snackbar'] }
|
||||
);
|
||||
}
|
||||
|
||||
private getBridge(): PlaylistOpenRequestBridge | undefined {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (window as Window & { electron?: PlaylistOpenRequestBridge })
|
||||
.electron;
|
||||
}
|
||||
}
|
||||
@@ -151,7 +151,8 @@
|
||||
"OR": "أو",
|
||||
"CHOOSE_PLAYLIST": "اختر قائمة تشغيل",
|
||||
"BROWSE": "تصفح الملفات",
|
||||
"REJECTED": "تم رفض الملف، تنسيق غير مدعوم {{filename}}."
|
||||
"REJECTED": "تم رفض الملف، تنسيق غير مدعوم {{filename}}.",
|
||||
"OPEN_FAILED": "تعذر فتح \"{{filename}}\"."
|
||||
},
|
||||
"URL_UPLOAD": {
|
||||
"PLAYLIST_URL": "رابط قائمة التشغيل (M3U/M3U8)",
|
||||
|
||||
@@ -151,7 +151,8 @@
|
||||
"OR": "أو",
|
||||
"CHOOSE_PLAYLIST": "اختار قائمة تشغيل",
|
||||
"BROWSE": "تصفح الملفات",
|
||||
"REJECTED": "الملف ترفض، صيغة الملف غير مدعومة {{filename}}."
|
||||
"REJECTED": "الملف ترفض، صيغة الملف غير مدعومة {{filename}}.",
|
||||
"OPEN_FAILED": "ما تقدرش تحل \"{{filename}}\"."
|
||||
},
|
||||
"URL_UPLOAD": {
|
||||
"PLAYLIST_URL": "رابط قائمة التشغيل (m3u, m3u8)",
|
||||
|
||||
@@ -151,7 +151,8 @@
|
||||
"OR": "або",
|
||||
"CHOOSE_PLAYLIST": "выберыце плэйліст",
|
||||
"BROWSE": "Агляд файлаў",
|
||||
"REJECTED": "На жаль, гэты фармат файлаў не падтрымліваецца {{filename}}."
|
||||
"REJECTED": "На жаль, гэты фармат файлаў не падтрымліваецца {{filename}}.",
|
||||
"OPEN_FAILED": "Не атрымалася адкрыць \"{{filename}}\"."
|
||||
},
|
||||
"URL_UPLOAD": {
|
||||
"PLAYLIST_URL": "Спасылка на плэйліст (*.m3u або *.m3u8)",
|
||||
|
||||
@@ -151,7 +151,8 @@
|
||||
"OR": "oder",
|
||||
"CHOOSE_PLAYLIST": "Playlist auswählen",
|
||||
"BROWSE": "Dateien durchsuchen",
|
||||
"REJECTED": "Datei wurde abgelehnt, nicht unterstütztes Dateiformat {{filename}}."
|
||||
"REJECTED": "Datei wurde abgelehnt, nicht unterstütztes Dateiformat {{filename}}.",
|
||||
"OPEN_FAILED": "\"{{filename}}\" konnte nicht geöffnet werden."
|
||||
},
|
||||
"URL_UPLOAD": {
|
||||
"PLAYLIST_URL": "URL der Playlist",
|
||||
|
||||
@@ -151,7 +151,8 @@
|
||||
"OR": "or",
|
||||
"CHOOSE_PLAYLIST": "επιλέξτε μια λίστα αναπαραγωγής",
|
||||
"BROWSE": "Περιήγηση αρχείων",
|
||||
"REJECTED": "Το αρχείο απορρίφθηκε, η μορφή αρχείου δεν υποστηρίζεται {{filename}}."
|
||||
"REJECTED": "Το αρχείο απορρίφθηκε, η μορφή αρχείου δεν υποστηρίζεται {{filename}}.",
|
||||
"OPEN_FAILED": "Δεν ήταν δυνατό το άνοιγμα του \"{{filename}}\"."
|
||||
},
|
||||
"URL_UPLOAD": {
|
||||
"PLAYLIST_URL": "Διεύθηνση URL λίστας αναπαραγωγής (m3u, m3u8)",
|
||||
|
||||
@@ -151,7 +151,8 @@
|
||||
"OR": "or",
|
||||
"CHOOSE_PLAYLIST": "click to browse",
|
||||
"BROWSE": "Browse files",
|
||||
"REJECTED": "File was rejected, unsupported file format {{filename}}."
|
||||
"REJECTED": "File was rejected, unsupported file format {{filename}}.",
|
||||
"OPEN_FAILED": "Could not open \"{{filename}}\"."
|
||||
},
|
||||
"URL_UPLOAD": {
|
||||
"PLAYLIST_URL": "Playlist URL (m3u, m3u8)",
|
||||
|
||||
@@ -151,7 +151,8 @@
|
||||
"OR": "o",
|
||||
"CHOOSE_PLAYLIST": "Escoga una lista de reproducción",
|
||||
"BROWSE": "Explorar archivos",
|
||||
"REJECTED": "El archivo fue rechazado, formato de archivo no compatible {{filename}}."
|
||||
"REJECTED": "El archivo fue rechazado, formato de archivo no compatible {{filename}}.",
|
||||
"OPEN_FAILED": "No se pudo abrir \"{{filename}}\"."
|
||||
},
|
||||
"URL_UPLOAD": {
|
||||
"PLAYLIST_URL": "Lista de reproducción (m3u, m3u8)",
|
||||
|
||||
@@ -151,7 +151,8 @@
|
||||
"OR": "ou",
|
||||
"CHOOSE_PLAYLIST": "Choisir une liste de lecture",
|
||||
"BROWSE": "Parcourir les fichiers",
|
||||
"REJECTED": "Le fichier a été rejeté, format de fichier non pris en charge {{filename}}."
|
||||
"REJECTED": "Le fichier a été rejeté, format de fichier non pris en charge {{filename}}.",
|
||||
"OPEN_FAILED": "Impossible d’ouvrir \"{{filename}}\"."
|
||||
},
|
||||
"URL_UPLOAD": {
|
||||
"PLAYLIST_URL": "URL de la liste de lecture (m3u, m3u8)",
|
||||
|
||||
@@ -151,7 +151,8 @@
|
||||
"OR": "vagy",
|
||||
"CHOOSE_PLAYLIST": "kattintson a tallózáshoz",
|
||||
"BROWSE": "Fájlok tallózása",
|
||||
"REJECTED": "Elutasított fájl: {{filename}} – nem támogatott fájlformátum."
|
||||
"REJECTED": "Elutasított fájl: {{filename}} – nem támogatott fájlformátum.",
|
||||
"OPEN_FAILED": "A(z) \"{{filename}}\" nem nyitható meg."
|
||||
},
|
||||
"URL_UPLOAD": {
|
||||
"PLAYLIST_URL": "Lejátszási lista URL-címe (m3u, m3u8)",
|
||||
|
||||
@@ -151,7 +151,8 @@
|
||||
"OR": "o",
|
||||
"CHOOSE_PLAYLIST": "scegli una playlist",
|
||||
"BROWSE": "Sfoglia file",
|
||||
"REJECTED": "Il file è stato rifiutato, formato di file non supportato {{filename}}."
|
||||
"REJECTED": "Il file è stato rifiutato, formato di file non supportato {{filename}}.",
|
||||
"OPEN_FAILED": "Impossibile aprire \"{{filename}}\"."
|
||||
},
|
||||
"URL_UPLOAD": {
|
||||
"PLAYLIST_URL": "URL playlist (m3u, m3u8)",
|
||||
|
||||
@@ -151,7 +151,8 @@
|
||||
"OR": "または",
|
||||
"CHOOSE_PLAYLIST": "プレイリストを選択",
|
||||
"BROWSE": "ファイルを参照",
|
||||
"REJECTED": "ファイルが拒否されました。サポートされていないファイル形式です:{{filename}}"
|
||||
"REJECTED": "ファイルが拒否されました。サポートされていないファイル形式です:{{filename}}",
|
||||
"OPEN_FAILED": "\"{{filename}}\" を開けませんでした。"
|
||||
},
|
||||
"URL_UPLOAD": {
|
||||
"PLAYLIST_URL": "プレイリストURL (m3u, m3u8)",
|
||||
|
||||
@@ -151,7 +151,8 @@
|
||||
"OR": "또는",
|
||||
"CHOOSE_PLAYLIST": "재생 목록을 선택",
|
||||
"BROWSE": "파일 찾아보기",
|
||||
"REJECTED": "파일이 거부되었습니다. 지원되지 않는 파일 형식 {{filename}}."
|
||||
"REJECTED": "파일이 거부되었습니다. 지원되지 않는 파일 형식 {{filename}}.",
|
||||
"OPEN_FAILED": "\"{{filename}}\" 파일을 열 수 없습니다."
|
||||
},
|
||||
"URL_UPLOAD": {
|
||||
"PLAYLIST_URL": "재생 목록 URL (m3u, m3u8)",
|
||||
|
||||
@@ -151,7 +151,8 @@
|
||||
"OR": "of",
|
||||
"CHOOSE_PLAYLIST": "kies een afspeellijst",
|
||||
"BROWSE": "Bestanden bladeren",
|
||||
"REJECTED": "Bestand geweigerd, niet-ondersteund bestandsformaat: {{filename}}."
|
||||
"REJECTED": "Bestand geweigerd, niet-ondersteund bestandsformaat: {{filename}}.",
|
||||
"OPEN_FAILED": "Kan \"{{filename}}\" niet openen."
|
||||
},
|
||||
"URL_UPLOAD": {
|
||||
"PLAYLIST_URL": "Afspeellijst-URL (m3u, m3u8)",
|
||||
|
||||
@@ -151,7 +151,8 @@
|
||||
"OR": "lub",
|
||||
"CHOOSE_PLAYLIST": "wybierz listę odtwarzania",
|
||||
"BROWSE": "Przeglądaj pliki",
|
||||
"REJECTED": "Plik został odrzucony, nieobsługiwany format pliku {{filename}}."
|
||||
"REJECTED": "Plik został odrzucony, nieobsługiwany format pliku {{filename}}.",
|
||||
"OPEN_FAILED": "Nie udało się otworzyć \"{{filename}}\"."
|
||||
},
|
||||
"URL_UPLOAD": {
|
||||
"PLAYLIST_URL": "URL listy odtwarzania (m3u, m3u8)",
|
||||
|
||||
@@ -151,7 +151,8 @@
|
||||
"OR": "ou",
|
||||
"CHOOSE_PLAYLIST": "escolha uma playlist",
|
||||
"BROWSE": "Procurar arquivos",
|
||||
"REJECTED": "O arquivo foi rejeitado, formato de arquivo não suportado {{filename}}."
|
||||
"REJECTED": "O arquivo foi rejeitado, formato de arquivo não suportado {{filename}}.",
|
||||
"OPEN_FAILED": "Não foi possível abrir \"{{filename}}\"."
|
||||
},
|
||||
"URL_UPLOAD": {
|
||||
"PLAYLIST_URL": "URL da playlist (m3u, m3u8)",
|
||||
|
||||
@@ -151,7 +151,8 @@
|
||||
"OR": "или",
|
||||
"CHOOSE_PLAYLIST": "выберите плейлист",
|
||||
"BROWSE": "Выбрать файлы",
|
||||
"REJECTED": "К сожалению, данный формат файлов не поддерживается {{filename}}."
|
||||
"REJECTED": "К сожалению, данный формат файлов не поддерживается {{filename}}.",
|
||||
"OPEN_FAILED": "Не удалось открыть \"{{filename}}\"."
|
||||
},
|
||||
"URL_UPLOAD": {
|
||||
"PLAYLIST_URL": "Ссылка на плейлист (*.m3u или *.m3u8)",
|
||||
|
||||
@@ -151,7 +151,8 @@
|
||||
"OR": "veya",
|
||||
"CHOOSE_PLAYLIST": "oynatma listesi seçin",
|
||||
"BROWSE": "Dosyalara göz at",
|
||||
"REJECTED": "Dosya reddedildi, desteklenmeyen dosya formatı {{filename}}."
|
||||
"REJECTED": "Dosya reddedildi, desteklenmeyen dosya formatı {{filename}}.",
|
||||
"OPEN_FAILED": "\"{{filename}}\" açılamadı."
|
||||
},
|
||||
"URL_UPLOAD": {
|
||||
"PLAYLIST_URL": "Oynatma listesi URL'si (m3u, m3u8)",
|
||||
|
||||
@@ -151,7 +151,8 @@
|
||||
"OR": "或",
|
||||
"CHOOSE_PLAYLIST": "选择一个播放列表",
|
||||
"BROWSE": "浏览文件",
|
||||
"REJECTED": "文件已被拒绝,不支持的文件格式 {{filename}}。"
|
||||
"REJECTED": "文件已被拒绝,不支持的文件格式 {{filename}}。",
|
||||
"OPEN_FAILED": "无法打开 \"{{filename}}\"。"
|
||||
},
|
||||
"URL_UPLOAD": {
|
||||
"PLAYLIST_URL": "播放一个播放列表(m3u, m3u8)",
|
||||
|
||||
@@ -151,7 +151,8 @@
|
||||
"OR": "或者",
|
||||
"CHOOSE_PLAYLIST": "選擇播放清單",
|
||||
"BROWSE": "瀏覽檔案",
|
||||
"REJECTED": "檔案被拒絕,不支援的檔案格式 {{filename}}。"
|
||||
"REJECTED": "檔案被拒絕,不支援的檔案格式 {{filename}}。",
|
||||
"OPEN_FAILED": "無法開啟 \"{{filename}}\"。"
|
||||
},
|
||||
"URL_UPLOAD": {
|
||||
"PLAYLIST_URL": "播放清單網址(m3u,m3u8)",
|
||||
|
||||
Reference in new issue
Block a user