() translation by (you can also view the original English article)
Ini adalah bagian kedua dari seri tentang Audero Audio Player. Dalam artikel ini, kita akan membuat logika bisnis pemain kita. Saya juga akan menjelaskan beberapa API Cordova yang diperkenalkan di artikel sebelumnya.
Ikhtisar Seri
Menciptakan Player
Di bagian ini, saya akan menunjukkan kepada Anda kelas yang disebut Player
, yang memungkinkan kami bermain, berhenti, mundur, dan maju cepat. Kelas sangat bergantung pada API Media; tanpa metode, pemain kami akan benar-benar tidak berguna. Selain API Media, kelas ini mengambil keuntungan dari metode alert()
pada Pemberitahuan API. Tampilan lansiran bervariasi di antara platform. Sebagian besar sistem operasi yang didukung menggunakan kotak dialog asli tetapi yang lain, seperti Bada 2.X, menggunakan fungsi alert ()
klasik browser, yang kurang dapat disesuaikan. Metode sebelumnya menerima hingga empat parameter:
- message: String yang berisi pesan untuk ditampilkan
- alertCallback: Callback untuk memanggil ketika dialog peringatan ditutup
- title: Judul dialog (nilai defaultnya adalah "Alert")
- buttonName: Teks tombol yang termasuk dalam dialog (nilai default adalah "OK")
Ingatlah bahwa Windows Phone 7 mengabaikan nama tombol dan selalu menggunakan default. Windows Phone 7 dan 8 tidak memiliki peringatan browser built-in, jadi jika Anda ingin menggunakan alert ('message') ;
, Anda harus menetapkan window.alert = navigator.notification.alert
.
Sekarang setelah saya menjelaskan API yang digunakan oleh Player
, kita dapat melihat cara pembuatannya. Kami memiliki tiga properti:
-
media
: referensi ke objek suara saat ini -
mediaTimer
: yang akan berisi ID interval unik yang dibuat menggunakan fungsisetInterval ()
yang akan kita lewati keclearInterval ()
untuk menghentikan pengatur suara -
isPlaying
: variabel yang menentukan apakah suara saat ini diputar atau tidak. Selain properti, kelas memiliki beberapa metode.
Metode initMedia ()
menginisialisasi properti media
dengan objek Media
yang mewakili suara yang dipilih oleh pengguna. Yang terakhir ini diberitahukan menggunakan API Pemberitahuan jika terjadi kesalahan. Tujuan dari metode playPause
, stop ()
, dan seekPosition ()
harus jelas, jadi saya akan melanjutkan. Metode resetLayout ()
dan changePlayButton ()
sangat sederhana. Mereka digunakan untuk mengatur ulang atau memperbarui tata letak pemain sesuai dengan tindakan yang dilakukan oleh pengguna. Metode terakhir yang tersisa adalah updateSliderPosition ()
, yang mirip dengan penggeser waktu. Yang terakhir memiliki nol (awal slider) sebagai nilai default untuk posisi saat ini, atur menggunakan atribut value = "0"
. Ini harus diperbarui secara bersamaan saat suara dimainkan untuk diberikan kepada pengguna umpan balik visual mengenai waktu pemutaran yang telah berlalu.
Kami telah menemukan semua detail kelas ini, jadi di sini adalah kode sumber file:
1 |
|
2 |
var Player = { |
3 |
media: null, |
4 |
mediaTimer: null, |
5 |
isPlaying: false, |
6 |
initMedia: function(path) { |
7 |
Player.media = new Media( |
8 |
path, |
9 |
function() { |
10 |
console.log('Media file read succesfully'); |
11 |
if (Player.media !== null) |
12 |
Player.media.release(); |
13 |
Player.resetLayout(); |
14 |
},
|
15 |
function(error) { |
16 |
navigator.notification.alert( |
17 |
'Unable to read the media file.', |
18 |
function(){}, |
19 |
'Error' |
20 |
);
|
21 |
Player.changePlayButton('play'); |
22 |
console.log('Unable to read the media file (Code): ' + error.code); |
23 |
}
|
24 |
);
|
25 |
},
|
26 |
playPause: function(path) { |
27 |
if (Player.media === null) |
28 |
Player.initMedia(path); |
29 |
|
30 |
if (Player.isPlaying === false) |
31 |
{
|
32 |
Player.media.play(); |
33 |
Player.mediaTimer = setInterval( |
34 |
function() { |
35 |
Player.media.getCurrentPosition( |
36 |
function(position) { |
37 |
if (position > -1) |
38 |
{
|
39 |
$('#media-played').text(Utility.formatTime(position)); |
40 |
Player.updateSliderPosition(position); |
41 |
}
|
42 |
},
|
43 |
function(error) { |
44 |
console.log('Unable to retrieve media position: ' + error.code); |
45 |
$('#media-played').text(Utility.formatTime(0)); |
46 |
}
|
47 |
);
|
48 |
},
|
49 |
1000
|
50 |
);
|
51 |
var counter = 0; |
52 |
var timerDuration = setInterval( |
53 |
function() { |
54 |
counter++; |
55 |
if (counter > 20) |
56 |
clearInterval(timerDuration); |
57 |
|
58 |
var duration = Player.media.getDuration(); |
59 |
if (duration > -1) |
60 |
{
|
61 |
clearInterval(timerDuration); |
62 |
$('#media-duration').text(Utility.formatTime(duration)); |
63 |
$('#time-slider').attr('max', Math.round(duration)); |
64 |
$('#time-slider').slider('refresh'); |
65 |
}
|
66 |
else
|
67 |
$('#media-duration').text('Unknown'); |
68 |
},
|
69 |
100
|
70 |
);
|
71 |
|
72 |
Player.changePlayButton('pause'); |
73 |
}
|
74 |
else
|
75 |
{
|
76 |
Player.media.pause(); |
77 |
clearInterval(Player.mediaTimer); |
78 |
Player.changePlayButton('play'); |
79 |
}
|
80 |
Player.isPlaying = !Player.isPlaying; |
81 |
},
|
82 |
stop: function() { |
83 |
if (Player.media !== null) |
84 |
{
|
85 |
Player.media.stop(); |
86 |
Player.media.release(); |
87 |
}
|
88 |
clearInterval(Player.mediaTimer); |
89 |
Player.media = null; |
90 |
Player.isPlaying = false; |
91 |
Player.resetLayout(); |
92 |
},
|
93 |
resetLayout: function() { |
94 |
$('#media-played').text(Utility.formatTime(0)); |
95 |
Player.changePlayButton('play'); |
96 |
Player.updateSliderPosition(0); |
97 |
},
|
98 |
updateSliderPosition: function(seconds) { |
99 |
var $slider = $('#time-slider'); |
100 |
|
101 |
if (seconds < $slider.attr('min')) |
102 |
$slider.val($slider.attr('min')); |
103 |
else if (seconds > $slider.attr('max')) |
104 |
$slider.val($slider.attr('max')); |
105 |
else
|
106 |
$slider.val(Math.round(seconds)); |
107 |
|
108 |
$slider.slider('refresh'); |
109 |
},
|
110 |
seekPosition: function(seconds) { |
111 |
if (Player.media === null) |
112 |
return; |
113 |
|
114 |
Player.media.seekTo(seconds * 1000); |
115 |
Player.updateSliderPosition(seconds); |
116 |
},
|
117 |
changePlayButton: function(imageName) { |
118 |
var background = $('#player-play') |
119 |
.css('background-image') |
120 |
.replace('url(', '') |
121 |
.replace(')', ''); |
122 |
|
123 |
$('#player-play').css( |
124 |
'background-image', |
125 |
'url(' + background.replace(/images\/.*\.png$/, 'images/' + imageName + '.png') + ')' |
126 |
);
|
127 |
}
|
128 |
};
|
Mengelola File Audio
Bagian ini mengilustrasikan kelas AppFile
yang akan digunakan untuk membuat, menghapus, dan memuat suara menggunakan Web Storage API. API ini memiliki dua area, Sesi dan Lokal, tetapi Cordova menggunakan yang terakhir. Semua suara disimpan dalam item yang berjudul "file" seperti yang Anda lihat dengan melihat properti _tableName
.
Harap perhatikan bahwa API ini hanya dapat menyimpan data dasar. Oleh karena itu, agar sesuai dengan kebutuhan kita untuk menyimpan objek, kita akan menggunakan format JSON. JavaScript memiliki kelas untuk menangani format yang disebut JSON ini. Ia menggunakan metode parse()
untuk mem-parse string dan membuat ulang data yang sesuai, dan stringify()
untuk mengonversi objek dalam string. Sebagai catatan akhir, saya tidak akan menggunakan notasi titik dari API karena Windows Phone 7 tidak mendukungnya, jadi kami akan menggunakan metode setItem()
dan getItem ()
untuk memastikan kompatibilitas untuk semua perangkat.
Sekarang setelah Anda memiliki gambaran tentang bagaimana kami akan menyimpan data, mari kita bicara tentang data yang perlu kita simpan. Satu-satunya informasi yang kita butuhkan untuk setiap suara yang ditemukan adalah nama (name
properti) dan jalur absolut (properti fullPath
). Kelas AppFile
juga memiliki "konstan", disebut EXTENSIONS
, di mana kami akan mengatur ekstensi yang akan diuji terhadap setiap file. Jika mereka cocok, file akan dikumpulkan oleh aplikasi. Kami memiliki metode untuk menambahkan file (addFile()
), salah satu metode untuk menghapus file (deleteFile()
), salah satu metode yang menghapus seluruh database (deleteFiles()
), dan, terakhir, dua metode yang mengambil file dari database: getAppFiles()
untuk mengambil semua file, dan getAppFile()
untuk mengambil satu saja. Kelas ini juga memiliki empat metode perbandingan, dua statis (compare()
dan compareIgnoreCase()
) dan dua non-statis (compareTo()
dan compareToIgnoreCase()
). Metode terakhir adalah yang digunakan untuk mengambil indeks file tertentu, getIndex()
. Kelas AppFile
memungkinkan Anda untuk melakukan semua operasi dasar yang mungkin Anda butuhkan.
Kode yang mengimplementasikan apa yang telah kita bahas dapat dibaca di sini:
1 |
|
2 |
function AppFile(name, fullPath) |
3 |
{
|
4 |
var _db = window.localStorage; |
5 |
var _tableName = 'files'; |
6 |
|
7 |
this.name = name; |
8 |
this.fullPath = fullPath; |
9 |
|
10 |
this.save = function(files) |
11 |
{
|
12 |
_db.setItem(_tableName, JSON.stringify(files)); |
13 |
}
|
14 |
|
15 |
this.load = function() |
16 |
{
|
17 |
return JSON.parse(_db.getItem(_tableName)); |
18 |
}
|
19 |
}
|
20 |
|
21 |
AppFile.prototype.addFile = function() |
22 |
{
|
23 |
var index = AppFile.getIndex(this.fullPath); |
24 |
var files = AppFile.getAppFiles(); |
25 |
|
26 |
if (index === false) |
27 |
files.push(this); |
28 |
else
|
29 |
files[index] = this; |
30 |
|
31 |
this.save(files); |
32 |
};
|
33 |
|
34 |
AppFile.prototype.deleteFile = function() |
35 |
{
|
36 |
var index = AppFile.getIndex(this.fullPath); |
37 |
var files = AppFile.getAppFiles(); |
38 |
if (index !== false) |
39 |
{
|
40 |
files.splice(index, 1); |
41 |
this.save(files); |
42 |
}
|
43 |
|
44 |
return files; |
45 |
};
|
46 |
|
47 |
AppFile.prototype.compareTo = function(other) |
48 |
{
|
49 |
return AppFile.compare(this, other); |
50 |
};
|
51 |
|
52 |
AppFile.prototype.compareToIgnoreCase = function(other) |
53 |
{
|
54 |
return AppFile.compareIgnoreCase(this, other); |
55 |
};
|
56 |
|
57 |
AppFile.EXTENSIONS = ['.mp3', '.wav', '.m4a']; |
58 |
|
59 |
AppFile.compare = function(appFile, other) |
60 |
{
|
61 |
if (other == null) |
62 |
return 1; |
63 |
else if (appFile == null) |
64 |
return -1; |
65 |
|
66 |
return appFile.name.localeCompare(other.name); |
67 |
};
|
68 |
|
69 |
AppFile.compareIgnoreCase = function(appFile, other) |
70 |
{
|
71 |
if (other == null) |
72 |
return 1; |
73 |
else if (appFile == null) |
74 |
return -1; |
75 |
|
76 |
return appFile.name.toUpperCase().localeCompare(other.name.toUpperCase()); |
77 |
};
|
78 |
|
79 |
AppFile.getAppFiles = function() |
80 |
{
|
81 |
var files = new AppFile().load(); |
82 |
return (files === null) ? [] : files; |
83 |
};
|
84 |
|
85 |
AppFile.getAppFile = function(path) |
86 |
{
|
87 |
var index = AppFile.getIndex(path); |
88 |
if (index === false) |
89 |
return null; |
90 |
else
|
91 |
{
|
92 |
var file = AppFile.getAppFiles()[index]; |
93 |
return new AppFile(file.name, file.fullPath); |
94 |
}
|
95 |
};
|
96 |
|
97 |
AppFile.getIndex = function(path) |
98 |
{
|
99 |
var files = AppFile.getAppFiles(); |
100 |
for(var i = 0; i < files.length; i++) |
101 |
{
|
102 |
if (files[i].fullPath.toUpperCase() === path.toUpperCase()) |
103 |
return i; |
104 |
}
|
105 |
|
106 |
return false; |
107 |
};
|
108 |
|
109 |
AppFile.deleteFiles = function() |
110 |
{
|
111 |
new AppFile().save([]); |
112 |
};
|
Kelas Utilitas
File utility.js
sangat singkat dan mudah dimengerti. Hanya memiliki dua metode. Satu digunakan untuk mengonversi milidetik menjadi string berformat yang akan ditampilkan di pemutar, sementara yang lain adalah implementasi JavaScript dari metode Java yang terkenal endsWith
.
Inilah sumbernya:
1 |
|
2 |
var Utility = { |
3 |
formatTime: function(milliseconds) { |
4 |
if (milliseconds <= 0) |
5 |
return '00:00'; |
6 |
|
7 |
var seconds = Math.round(milliseconds); |
8 |
var minutes = Math.floor(seconds / 60); |
9 |
if (minutes < 10) |
10 |
minutes = '0' + minutes; |
11 |
|
12 |
seconds = seconds % 60; |
13 |
if (seconds < 10) |
14 |
seconds = '0' + seconds; |
15 |
|
16 |
return minutes + ':' + seconds; |
17 |
},
|
18 |
endsWith: function(string, suffix) { |
19 |
return string.indexOf(suffix, string.length - suffix.length) !== -1; |
20 |
}
|
21 |
};
|
Menempatkannya Semua Bersama
Bagian ini membahas file JavaScript terakhir dari proyek, application.js
, yang berisi kelas Aplikasi
. Tujuannya adalah melampirkan acara ke elemen halaman aplikasi. Kejadian-kejadian itu akan memanfaatkan kelas yang telah kita lihat sejauh ini dan memungkinkan pemain untuk bekerja dengan benar.
Kode fungsi yang diilustrasikan tercantum di bawah ini:
1 |
|
2 |
var Application = { |
3 |
initApplication: function() { |
4 |
$(document).on( |
5 |
'pageinit', |
6 |
'#files-list-page', |
7 |
function() |
8 |
{
|
9 |
Application.initFilesListPage(); |
10 |
}
|
11 |
);
|
12 |
$(document).on( |
13 |
'pageinit', |
14 |
'#aurelio-page', |
15 |
function() |
16 |
{
|
17 |
Application.openLinksInApp(); |
18 |
}
|
19 |
);
|
20 |
$(document).on( |
21 |
'pagechange', |
22 |
function(event, properties) |
23 |
{
|
24 |
if (properties.absUrl === $.mobile.path.makeUrlAbsolute('player.html')) |
25 |
{
|
26 |
Application.initPlayerPage( |
27 |
JSON.parse(properties.options.data.file) |
28 |
);
|
29 |
}
|
30 |
}
|
31 |
);
|
32 |
},
|
33 |
initFilesListPage: function() { |
34 |
$('#update-button').click( |
35 |
function() |
36 |
{
|
37 |
$('#waiting-popup').popup('open'); |
38 |
setTimeout(function(){ |
39 |
Application.updateMediaList(); |
40 |
}, 150); |
41 |
}
|
42 |
);
|
43 |
$(document).on('endupdate', function(){ |
44 |
Application.createFilesList('files-list', AppFile.getAppFiles()); |
45 |
$('#waiting-popup').popup('close'); |
46 |
});
|
47 |
Application.createFilesList('files-list', AppFile.getAppFiles()); |
48 |
},
|
49 |
initPlayerPage: function(file) { |
50 |
Player.stop(); |
51 |
$('#media-name').text(file.name); |
52 |
$('#media-path').text(file.fullPath); |
53 |
$('#player-play').click(function() { |
54 |
Player.playPause(file.fullPath); |
55 |
});
|
56 |
$('#player-stop').click(Player.stop); |
57 |
$('#time-slider').on('slidestop', function(event) { |
58 |
Player.seekPosition(event.target.value); |
59 |
});
|
60 |
},
|
61 |
updateIcons: function() |
62 |
{
|
63 |
if ($(window).width() > 480) |
64 |
{
|
65 |
$('a[data-icon], button[data-icon]').each(function() { |
66 |
$(this).removeAttr('data-iconpos'); |
67 |
});
|
68 |
}
|
69 |
else
|
70 |
{
|
71 |
$('a[data-icon], button[data-icon]').each(function() { |
72 |
$(this).attr('data-iconpos', 'notext'); |
73 |
});
|
74 |
}
|
75 |
},
|
76 |
openLinksInApp: function() |
77 |
{
|
78 |
$("a[target=\"_blank\"]").on('click', function(event) { |
79 |
event.preventDefault(); |
80 |
window.open($(this).attr('href'), '_target'); |
81 |
});
|
82 |
},
|
83 |
updateMediaList: function() { |
84 |
window.requestFileSystem( |
85 |
LocalFileSystem.PERSISTENT, |
86 |
0, |
87 |
function(fileSystem){ |
88 |
var root = fileSystem.root; |
89 |
AppFile.deleteFiles(); |
90 |
Application.collectMedia(root.fullPath, true); |
91 |
},
|
92 |
function(error){ |
93 |
console.log('File System Error: ' + error.code); |
94 |
}
|
95 |
);
|
96 |
},
|
97 |
collectMedia: function(path, recursive, level) { |
98 |
if (level === undefined) |
99 |
level = 0; |
100 |
var directoryEntry = new DirectoryEntry('', path); |
101 |
if(!directoryEntry.isDirectory) { |
102 |
console.log('The provided path is not a directory'); |
103 |
return; |
104 |
}
|
105 |
var directoryReader = directoryEntry.createReader(); |
106 |
directoryReader.readEntries( |
107 |
function (entries) { |
108 |
var appFile; |
109 |
var extension; |
110 |
for (var i = 0; i < entries.length; i++) { |
111 |
if (entries[i].name === '.') |
112 |
continue; |
113 |
|
114 |
extension = entries[i].name.substr(entries[i].name.lastIndexOf('.')); |
115 |
if (entries[i].isDirectory === true && recursive === true) |
116 |
Application.collectMedia(entries[i].fullPath, recursive, level + 1); |
117 |
else if (entries[i].isFile === true && $.inArray(extension, AppFile.EXTENSIONS) >= 0) |
118 |
{
|
119 |
appFile = new AppFile(entries[i].name, entries[i].fullPath); |
120 |
appFile.addFile(); |
121 |
console.log('File saved: ' + entries[i].fullPath); |
122 |
}
|
123 |
}
|
124 |
},
|
125 |
function(error) { |
126 |
console.log('Unable to read the directory. Errore: ' + error.code); |
127 |
}
|
128 |
);
|
129 |
|
130 |
if (level === 0) |
131 |
$(document).trigger('endupdate'); |
132 |
console.log('Current path analized is: ' + path); |
133 |
},
|
134 |
createFilesList: function(idElement, files) |
135 |
{
|
136 |
$('#' + idElement).empty(); |
137 |
|
138 |
if (files == null || files.length == 0) |
139 |
{
|
140 |
$('#' + idElement).append('<p>No files to show. Would you consider a files update (top right button)?</p>'); |
141 |
return; |
142 |
}
|
143 |
|
144 |
function getPlayHandler(file) { |
145 |
return function playHandler() { |
146 |
$.mobile.changePage( |
147 |
'player.html', |
148 |
{
|
149 |
data: { |
150 |
file: JSON.stringify(file) |
151 |
}
|
152 |
}
|
153 |
);
|
154 |
};
|
155 |
}
|
156 |
|
157 |
function getDeleteHandler(file) { |
158 |
return function deleteHandler() { |
159 |
var oldLenght = AppFile.getAppFiles().length; |
160 |
var $parentUl = $(this).closest('ul'); |
161 |
|
162 |
file = new AppFile('', file.fullPath); |
163 |
file.deleteFile(); |
164 |
if (oldLenght === AppFile.getAppFiles().length + 1) |
165 |
{
|
166 |
$(this).closest('li').remove(); |
167 |
$parentUl.listview('refresh'); |
168 |
}
|
169 |
else
|
170 |
{
|
171 |
console.log('Media not deleted. Something gone wrong.'); |
172 |
navigator.notification.alert( |
173 |
'Media not deleted. Something gone wrong so please try again.', |
174 |
function(){}, |
175 |
'Error' |
176 |
);
|
177 |
}
|
178 |
};
|
179 |
}
|
180 |
|
181 |
var $listElement, $linkElement; |
182 |
files.sort(AppFile.compareIgnoreCase); |
183 |
for(var i = 0; i < files.length; i++) |
184 |
{
|
185 |
$listElement = $('<li>'); |
186 |
$linkElement = $('<a>'); |
187 |
$linkElement
|
188 |
.attr('href', '#') |
189 |
.text(files[i].name) |
190 |
.click(getPlayHandler(files[i])); |
191 |
|
192 |
// Append the link to the <li> element
|
193 |
$listElement.append($linkElement); |
194 |
|
195 |
$linkElement = $('<a>'); |
196 |
$linkElement
|
197 |
.attr('href', '#') |
198 |
.text('Delete') |
199 |
.click(getDeleteHandler(files[i])); |
200 |
|
201 |
// Append the link to the <li> element
|
202 |
$listElement.append($linkElement); |
203 |
|
204 |
// Append the <li> element to the <ul> element
|
205 |
$('#' + idElement).append($listElement); |
206 |
}
|
207 |
$('#' + idElement).listview('refresh'); |
208 |
}
|
209 |
};
|
Mengelola Tautan Eksternal
Di bagian sebelumnya dari seri ini, saya menyebutkan bahwa poin menarik dari halaman kredit adalah atribut target="_blank"
yang diterapkan pada tautan. Bagian ini akan menjelaskan mengapa metode openLinksInApp ()
kelas Aplikasi
masuk akal.
Sekali waktu, Cordova digunakan untuk membuka tautan eksternal di Cordova WebView yang sama yang menjalankan aplikasi. Saat ini, tautan eksternal dibuka, secara default, menggunakan Cordova WebView jika URL ada di daftar putih aplikasi Anda. URL yang tidak ada dalam daftar putih Anda dibuka menggunakan API InAppBrowser. Saat ini, tautan eksternal dibuka, secara default, menggunakan Cordova WebView jika URL ada di daftar putih aplikasi Anda. URL yang tidak ada dalam daftar putih Anda dibuka menggunakan API InAppBrowser. Jika Anda tidak mengelola tautan dengan cara yang benar, atau jika pengguna mengetuk tautan yang ditampilkan di InAppBrowser atau sistem dan kemudian memilih untuk kembali, semua perangkat seluler jQuery Mobile hilang. Perilaku ini terjadi karena file CSS dan JavaScript dimuat oleh halaman utama, dan yang berikut dimuat menggunakan AJAX. Sebelum mengungkap solusi, mari kita lihat apa yang InAppBrowser.
The InAppBrowser adalah browser web yang ditampilkan di aplikasi Anda ketika Anda menggunakan jendela. Buka panggilan.
API ini memiliki tiga metode:
-
addEventListener ()
: Memungkinkan Anda untuk mendengarkan tiga peristiwa (loadstart
,loadstop
, danexit
) dan melampirkan fungsi yang berjalan segera setelah peristiwa-peristiwa itu dipecat -
removeEventListener ()
: Menghapus listener yang terlampir sebelumnya. -
close ()
: Digunakan untuk menutup jendela InAppBrowser.
Jadi, apa solusinya? Tujuan dari fungsi openLinksInApp ()
, digabungkan dengan whitelist yang ditentukan dalam file konfigurasi, adalah untuk menangkap klik pada semua tautan eksternal yang dikenali dengan menggunakan atribut target = "_ blank"
, dan membukanya menggunakan window.open ()
metode. Dengan teknik ini, kami akan menghindari masalah yang dijelaskan, dan pemain kami akan terus melihat dan bekerja seperti yang diharapkan.
Bagian Selanjutnya
Pada seri ketiga dan terakhir dari seri ini, kita akan melihat file terakhir yang tersisa sehingga Anda dapat menyelesaikan proyek dan bermain-main dengannya.